From 35aef603732ab64acbfc2f4954487134b3dc63a3 Mon Sep 17 00:00:00 2001 From: Gordan Ovcaric Date: Thu, 5 Feb 2026 12:09:56 +0100 Subject: [PATCH 1/4] TW-4612: add authentication multi-credential support --- .claude/settings.local.json | 4 +- src/main/kotlin/com/nylas/models/Connector.kt | 19 +- .../nylas/models/CreateCredentialRequest.kt | 24 ++- .../com/nylas/models/CreateGrantRequest.kt | 15 +- .../kotlin/com/nylas/models/CredentialData.kt | 10 +- src/main/kotlin/com/nylas/models/Grant.kt | 5 + .../nylas/models/UpdateConnectorRequest.kt | 30 ++- .../models/UrlForAuthenticationConfig.kt | 15 ++ .../com/nylas/util/CredentialDataAdapter.kt | 14 +- .../kotlin/com/nylas/resources/AuthTests.kt | 98 ++++++++++ .../com/nylas/resources/ConnectorsTests.kt | 117 +++++++++++ .../com/nylas/resources/CredentialsTests.kt | 181 ++++++++++++++++++ .../kotlin/com/nylas/resources/GrantsTests.kt | 46 +++++ 13 files changed, 564 insertions(+), 14 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 01d9d051..fa7c91e2 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -10,7 +10,9 @@ "Bash(find:*)", "Bash(./gradlew clean build:*)", "Bash(java -version:*)", - "Bash(/usr/libexec/java_home:*)" + "Bash(/usr/libexec/java_home:*)", + "Bash(JAVA_HOME=/Users/gordan.o@nylas.com/Library/Java/JavaVirtualMachines/corretto-19.0.2/Contents/Home ./gradlew test:*)", + "Bash(JAVA_HOME=/Users/gordan.o@nylas.com/Library/Java/JavaVirtualMachines/corretto-19.0.2/Contents/Home ./gradlew:*)" ] } } diff --git a/src/main/kotlin/com/nylas/models/Connector.kt b/src/main/kotlin/com/nylas/models/Connector.kt index 62eb9060..21ea2334 100644 --- a/src/main/kotlin/com/nylas/models/Connector.kt +++ b/src/main/kotlin/com/nylas/models/Connector.kt @@ -12,6 +12,11 @@ sealed class Connector( */ @Json(name = "provider") val provider: AuthProvider, + /** + * The ID of the active credential for this connector (for multi-credential setups). + */ + @Json(name = "active_credential_id") + open val activeCredentialId: String? = null, ) { /** * Class representing a Google connector creation request. @@ -27,7 +32,12 @@ sealed class Connector( */ @Json(name = "scope") val scope: List? = null, - ) : Connector(AuthProvider.GOOGLE) + /** + * The ID of the active credential for this connector + */ + @Json(name = "active_credential_id") + override val activeCredentialId: String? = null, + ) : Connector(AuthProvider.GOOGLE, activeCredentialId) /** * Class representing a Microsoft connector creation request. @@ -43,7 +53,12 @@ sealed class Connector( */ @Json(name = "scope") val scope: List? = null, - ) : Connector(AuthProvider.MICROSOFT) + /** + * The ID of the active credential for this connector + */ + @Json(name = "active_credential_id") + override val activeCredentialId: String? = null, + ) : Connector(AuthProvider.MICROSOFT, activeCredentialId) /** * Class representing an IMAP connector creation request. diff --git a/src/main/kotlin/com/nylas/models/CreateCredentialRequest.kt b/src/main/kotlin/com/nylas/models/CreateCredentialRequest.kt index 946bf777..2aeb1398 100644 --- a/src/main/kotlin/com/nylas/models/CreateCredentialRequest.kt +++ b/src/main/kotlin/com/nylas/models/CreateCredentialRequest.kt @@ -56,8 +56,28 @@ sealed class CreateCredentialRequest( ) : CreateCredentialRequest(name, credentialData, CredentialType.SERVICEACCOUNT) /** - * Class representing a request to create an override credential + * Class representing a request to create a connector credential. + * For multi-credential OAuth flows, provide clientId and clientSecret in credentialData. + * For other overrides, use extraProperties in credentialData. */ + data class Connector( + /** + * Unique name of this credential + */ + @Json(name = "name") + override val name: String, + /** + * Data that specifies the credential details (client_id/client_secret for OAuth, or extraProperties for overrides) + */ + @Json(name = "credential_data") + override val credentialData: CredentialData.ConnectorOverride, + ) : CreateCredentialRequest(name, credentialData, CredentialType.CONNECTOR) + + /** + * Alias for [Connector] to maintain backward compatibility. + * @deprecated Use [Connector] instead. + */ + @Deprecated("Use Connector instead", ReplaceWith("Connector")) data class Override( /** * Unique name of this credential @@ -77,6 +97,6 @@ sealed class CreateCredentialRequest( PolymorphicJsonAdapterFactory.of(CreateCredentialRequest::class.java, "credential_type") .withSubtype(Microsoft::class.java, CredentialType.ADMINCONSENT.value) .withSubtype(Google::class.java, CredentialType.SERVICEACCOUNT.value) - .withSubtype(Override::class.java, CredentialType.CONNECTOR.value) + .withSubtype(Connector::class.java, CredentialType.CONNECTOR.value) } } diff --git a/src/main/kotlin/com/nylas/models/CreateGrantRequest.kt b/src/main/kotlin/com/nylas/models/CreateGrantRequest.kt index 20d9c1a4..d0b944e3 100644 --- a/src/main/kotlin/com/nylas/models/CreateGrantRequest.kt +++ b/src/main/kotlin/com/nylas/models/CreateGrantRequest.kt @@ -26,6 +26,11 @@ data class CreateGrantRequest( */ @Json(name = "scope") val scope: List? = null, + /** + * The credential ID to use for authentication (for multi-credential setups). + */ + @Json(name = "credential_id") + val credentialId: String? = null, ) { /** * Builder for [CreateGrantRequest]. @@ -38,6 +43,7 @@ data class CreateGrantRequest( ) { private var state: String? = null private var scopes: List? = null + private var credentialId: String? = null /** * Set the state value to return to developer's website after authentication flow is completed. @@ -53,10 +59,17 @@ data class CreateGrantRequest( */ fun scopes(scopes: List) = apply { this.scopes = scopes } + /** + * Set the credential ID to use for authentication (for multi-credential setups). + * @param credentialId The credential ID + * @return This builder + */ + fun credentialId(credentialId: String) = apply { this.credentialId = credentialId } + /** * Build the [CreateGrantRequest]. * @return The built [CreateGrantRequest] */ - fun build() = CreateGrantRequest(provider, settings, state, scopes) + fun build() = CreateGrantRequest(provider, settings, state, scopes, credentialId) } } diff --git a/src/main/kotlin/com/nylas/models/CredentialData.kt b/src/main/kotlin/com/nylas/models/CredentialData.kt index b7ecbb7c..d91e02a3 100644 --- a/src/main/kotlin/com/nylas/models/CredentialData.kt +++ b/src/main/kotlin/com/nylas/models/CredentialData.kt @@ -33,9 +33,15 @@ sealed class CredentialData( ) : CredentialData(extraProperties) /** - * Class representing additional data needed to create a credential for a Connector Override + * Class representing additional data needed to create a credential for a Connector Override. + * For multi-credential OAuth flows, provide clientId and clientSecret. + * For other overrides, use extraProperties. */ data class ConnectorOverride( - override val extraProperties: Map, + @Json(name = "client_id") + val clientId: String? = null, + @Json(name = "client_secret") + val clientSecret: String? = null, + override val extraProperties: Map? = emptyMap(), ) : CredentialData(extraProperties) } diff --git a/src/main/kotlin/com/nylas/models/Grant.kt b/src/main/kotlin/com/nylas/models/Grant.kt index 6a4843d1..4ab54688 100644 --- a/src/main/kotlin/com/nylas/models/Grant.kt +++ b/src/main/kotlin/com/nylas/models/Grant.kt @@ -67,4 +67,9 @@ data class Grant( */ @Json(name = "settings") val settings: Map? = null, + /** + * The credential ID associated with this grant (for multi-credential setups). + */ + @Json(name = "credential_id") + val credentialId: String? = null, ) diff --git a/src/main/kotlin/com/nylas/models/UpdateConnectorRequest.kt b/src/main/kotlin/com/nylas/models/UpdateConnectorRequest.kt index 12c026b9..e3d426b1 100644 --- a/src/main/kotlin/com/nylas/models/UpdateConnectorRequest.kt +++ b/src/main/kotlin/com/nylas/models/UpdateConnectorRequest.kt @@ -20,10 +20,16 @@ sealed class UpdateConnectorRequest { */ @Json(name = "scope") val scope: List? = null, + /** + * The ID of the active credential for this connector (for multi-credential setups) + */ + @Json(name = "active_credential_id") + val activeCredentialId: String? = null, ) : UpdateConnectorRequest() { class Builder { private var settings: GoogleConnectorSettings? = null private var scope: List? = null + private var activeCredentialId: String? = null /** * Set the Google OAuth provider credentials and settings @@ -39,11 +45,18 @@ sealed class UpdateConnectorRequest { */ fun scope(scope: List) = apply { this.scope = scope } + /** + * Set the active credential ID for this connector + * @param activeCredentialId The active credential ID + * @return The builder + */ + fun activeCredentialId(activeCredentialId: String) = apply { this.activeCredentialId = activeCredentialId } + /** * Build the Google connector creation request * @return The Google connector creation request */ - fun build() = Google(settings, scope) + fun build() = Google(settings, scope, activeCredentialId) } } @@ -61,10 +74,16 @@ sealed class UpdateConnectorRequest { */ @Json(name = "scope") val scope: List? = null, + /** + * The ID of the active credential for this connector (for multi-credential setups) + */ + @Json(name = "active_credential_id") + val activeCredentialId: String? = null, ) : UpdateConnectorRequest() { class Builder { private var settings: MicrosoftConnectorSettings? = null private var scope: List? = null + private var activeCredentialId: String? = null /** * Set the Microsoft OAuth provider credentials and settings @@ -80,11 +99,18 @@ sealed class UpdateConnectorRequest { */ fun scope(scope: List) = apply { this.scope = scope } + /** + * Set the active credential ID for this connector + * @param activeCredentialId The active credential ID + * @return The builder + */ + fun activeCredentialId(activeCredentialId: String) = apply { this.activeCredentialId = activeCredentialId } + /** * Build the Microsoft connector creation request * @return The Microsoft connector creation request */ - fun build() = Microsoft(settings, scope) + fun build() = Microsoft(settings, scope, activeCredentialId) } } } diff --git a/src/main/kotlin/com/nylas/models/UrlForAuthenticationConfig.kt b/src/main/kotlin/com/nylas/models/UrlForAuthenticationConfig.kt index 8b1be0ae..c4be9804 100644 --- a/src/main/kotlin/com/nylas/models/UrlForAuthenticationConfig.kt +++ b/src/main/kotlin/com/nylas/models/UrlForAuthenticationConfig.kt @@ -54,6 +54,12 @@ data class UrlForAuthenticationConfig( */ @Json(name = "login_hint") val loginHint: String? = null, + /** + * The credential ID to use for authentication (for multi-credential setups). + * Allowed when response_type is "code". + */ + @Json(name = "credential_id") + val credentialId: String? = null, ) { /** * Builder for [UrlForAuthenticationConfig]. @@ -71,6 +77,7 @@ data class UrlForAuthenticationConfig( private var includeGrantScopes: Boolean? = null private var state: String? = null private var loginHint: String? = null + private var credentialId: String? = null /** * Set the integration provider type that you already had set up with Nylas for this application. @@ -124,6 +131,13 @@ data class UrlForAuthenticationConfig( */ fun loginHint(loginHint: String) = apply { this.loginHint = loginHint } + /** + * Set the credential ID to use for authentication (for multi-credential setups). + * @param credentialId The credential ID. + * @return This builder. + */ + fun credentialId(credentialId: String) = apply { this.credentialId = credentialId } + /** * Build the [UrlForAuthenticationConfig]. * @return The [UrlForAuthenticationConfig]. @@ -138,6 +152,7 @@ data class UrlForAuthenticationConfig( includeGrantScopes, state, loginHint, + credentialId, ) } } diff --git a/src/main/kotlin/com/nylas/util/CredentialDataAdapter.kt b/src/main/kotlin/com/nylas/util/CredentialDataAdapter.kt index e1b8372a..c399fea6 100644 --- a/src/main/kotlin/com/nylas/util/CredentialDataAdapter.kt +++ b/src/main/kotlin/com/nylas/util/CredentialDataAdapter.kt @@ -122,22 +122,28 @@ class GoogleServiceAccountCredentialDataAdapter { class ConnectorOverrideCredentialDataAdapter { @FromJson fun fromJson(reader: JsonReader): CredentialData.ConnectorOverride { + var clientId: String? = null + var clientSecret: String? = null val extraProperties = mutableMapOf() reader.beginObject() while (reader.hasNext()) { - val key = reader.nextName() - val value = reader.nextString() - extraProperties[key] = value + when (val key = reader.nextName()) { + "client_id" -> clientId = reader.nextString() + "client_secret" -> clientSecret = reader.nextString() + else -> extraProperties[key] = reader.nextString() + } } reader.endObject() - return CredentialData.ConnectorOverride(extraProperties) + return CredentialData.ConnectorOverride(clientId, clientSecret, extraProperties) } @ToJson fun toJson(writer: JsonWriter, value: CredentialData.ConnectorOverride?) { writer.beginObject() + value?.clientId?.let { writer.name("client_id").value(it) } + value?.clientSecret?.let { writer.name("client_secret").value(it) } value?.extraProperties?.forEach { (k, v) -> writer.name(k).value(v) } diff --git a/src/test/kotlin/com/nylas/resources/AuthTests.kt b/src/test/kotlin/com/nylas/resources/AuthTests.kt index e8984cc0..6868e47f 100644 --- a/src/test/kotlin/com/nylas/resources/AuthTests.kt +++ b/src/test/kotlin/com/nylas/resources/AuthTests.kt @@ -98,6 +98,54 @@ class AuthTests { assert(url == "https://api.test.nylas.com/v3/connect/auth?client_id=abc-123&redirect_uri=https%3A%2F%2Fexample.com%2Foauth%2Fcallback&access_type=online&provider=google&prompt=select_provider%2Cdetect&scope=email.read_only%20calendar%20contacts&state=abc-123-state&login_hint=test%40gmail.com&response_type=adminconsent&credential_id=abc-123-credential-id") } + + @Test + fun `urlForOAuth2 with credentialId should return the correct URL`() { + val configWithCredentialId = UrlForAuthenticationConfig( + clientId = "abc-123", + redirectUri = "https://example.com/oauth/callback", + scope = listOf("email.read_only", "calendar"), + provider = AuthProvider.GOOGLE, + state = "abc-123-state", + credentialId = "5d6ac8b4-ba68-438a-b578-9b5104602bdc", + ) + + val url = auth.urlForOAuth2(configWithCredentialId) + + assert(url.contains("credential_id=5d6ac8b4-ba68-438a-b578-9b5104602bdc")) + assert(url.contains("response_type=code")) + } + + @Test + fun `urlForOAuth2PKCE with credentialId should return the correct URL`() { + val configWithCredentialId = UrlForAuthenticationConfig( + clientId = "abc-123", + redirectUri = "https://example.com/oauth/callback", + scope = listOf("email.read_only", "calendar"), + provider = AuthProvider.GOOGLE, + state = "abc-123-state", + credentialId = "5d6ac8b4-ba68-438a-b578-9b5104602bdc", + ) + + val pkceAuthURL = auth.urlForOAuth2PKCE(configWithCredentialId) + + assert(pkceAuthURL.url.contains("credential_id=5d6ac8b4-ba68-438a-b578-9b5104602bdc")) + assert(pkceAuthURL.url.contains("response_type=code")) + assert(pkceAuthURL.url.contains("code_challenge_method=s256")) + } + + @Test + fun `UrlForAuthenticationConfig Builder with credentialId works correctly`() { + val config = UrlForAuthenticationConfig.Builder("client-123", "https://example.com/callback") + .provider(AuthProvider.GOOGLE) + .credentialId("cred-456") + .build() + + assertEquals("client-123", config.clientId) + assertEquals("https://example.com/callback", config.redirectUri) + assertEquals(AuthProvider.GOOGLE, config.provider) + assertEquals("cred-456", config.credentialId) + } } @Nested @@ -282,6 +330,56 @@ class AuthTests { assertEquals(adapter.toJson(request), requestBodyCaptor.firstValue) } + @Test + fun `customAuthentication with credentialId calls requests with the correct params`() { + val adapter = JsonHelper.moshi().adapter(CreateGrantRequest::class.java) + val request = CreateGrantRequest( + provider = AuthProvider.GOOGLE, + settings = mapOf("refresh_token" to "test-refresh-token"), + state = "abc-123-state", + scope = listOf("email.read_only", "calendar"), + credentialId = "5d6ac8b4-ba68-438a-b578-9b5104602bdc", + ) + + auth.customAuthentication(request) + + val pathCaptor = argumentCaptor() + val typeCaptor = argumentCaptor() + val requestBodyCaptor = argumentCaptor() + val queryParamCaptor = argumentCaptor() + val overrideParamCaptor = argumentCaptor() + verify(mockNylasClient).executePost( + pathCaptor.capture(), + typeCaptor.capture(), + requestBodyCaptor.capture(), + queryParamCaptor.capture(), + overrideParamCaptor.capture(), + ) + + assertEquals("v3/connect/custom", pathCaptor.firstValue) + assertEquals(Types.newParameterizedType(Response::class.java, Grant::class.java), typeCaptor.firstValue) + val json = adapter.toJson(request) + assertEquals(json, requestBodyCaptor.firstValue) + assert(json.contains("\"credential_id\":\"5d6ac8b4-ba68-438a-b578-9b5104602bdc\"")) + } + + @Test + fun `CreateGrantRequest Builder with credentialId works correctly`() { + val request = CreateGrantRequest.Builder( + AuthProvider.GOOGLE, + mapOf("refresh_token" to "test-token"), + ) + .state("test-state") + .scopes(listOf("email.read_only")) + .credentialId("cred-123") + .build() + + assertEquals(AuthProvider.GOOGLE, request.provider) + assertEquals("test-state", request.state) + assertEquals(listOf("email.read_only"), request.scope) + assertEquals("cred-123", request.credentialId) + } + @Test fun `detectProvider calls requests with the correct params`() { val request = ProviderDetectParams( diff --git a/src/test/kotlin/com/nylas/resources/ConnectorsTests.kt b/src/test/kotlin/com/nylas/resources/ConnectorsTests.kt index c691b165..9080f72d 100644 --- a/src/test/kotlin/com/nylas/resources/ConnectorsTests.kt +++ b/src/test/kotlin/com/nylas/resources/ConnectorsTests.kt @@ -21,6 +21,7 @@ import java.lang.reflect.Type import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertIs +import kotlin.test.assertNull class ConnectorsTests { private val mockHttpClient: OkHttpClient = Mockito.mock(OkHttpClient::class.java) @@ -72,6 +73,76 @@ class ConnectorsTests { connector.scope, ) } + + @Test + fun `Connector with activeCredentialId serializes properly`() { + val adapter = JsonHelper.moshi().adapter(Connector::class.java) + val jsonBuffer = Buffer().writeUtf8( + """ + { + "provider": "google", + "settings": { + "topic_name": "abc123" + }, + "scope": [ + "https://www.googleapis.com/auth/userinfo.email" + ], + "active_credential_id": "5d6ac8b4-ba68-438a-b578-9b5104602bdc" + } + """.trimIndent(), + ) + + val connector = adapter.fromJson(jsonBuffer)!! + assertIs(connector) + assertEquals(AuthProvider.GOOGLE, connector.provider) + assertEquals("5d6ac8b4-ba68-438a-b578-9b5104602bdc", connector.activeCredentialId) + } + + @Test + fun `Microsoft Connector with activeCredentialId serializes properly`() { + val adapter = JsonHelper.moshi().adapter(Connector::class.java) + val jsonBuffer = Buffer().writeUtf8( + """ + { + "provider": "microsoft", + "settings": { + "tenant": "common" + }, + "scope": [ + "Mail.Read" + ], + "active_credential_id": "a6db72c9-5362-4401-8547-b9b6713ce48f" + } + """.trimIndent(), + ) + + val connector = adapter.fromJson(jsonBuffer)!! + assertIs(connector) + assertEquals(AuthProvider.MICROSOFT, connector.provider) + assertEquals("a6db72c9-5362-4401-8547-b9b6713ce48f", connector.activeCredentialId) + } + + @Test + fun `Connector without activeCredentialId has null activeCredentialId`() { + val adapter = JsonHelper.moshi().adapter(Connector::class.java) + val jsonBuffer = Buffer().writeUtf8( + """ + { + "provider": "google", + "settings": { + "topic_name": "abc123" + }, + "scope": [ + "https://www.googleapis.com/auth/userinfo.email" + ] + } + """.trimIndent(), + ) + + val connector = adapter.fromJson(jsonBuffer)!! + assertIs(connector) + assertNull(connector.activeCredentialId) + } } @Nested @@ -187,6 +258,52 @@ class ConnectorsTests { assertEquals(adapter.toJson(updateConnectorRequest), requestBodyCaptor.firstValue) } + @Test + fun `updating a connector with activeCredentialId calls requests with the correct params`() { + val adapter = JsonHelper.moshi().adapter(UpdateConnectorRequest::class.java) + val updateConnectorRequest = UpdateConnectorRequest.Google( + activeCredentialId = "5d6ac8b4-ba68-438a-b578-9b5104602bdc", + ) + + connectors.update(AuthProvider.GOOGLE, updateConnectorRequest) + val pathCaptor = argumentCaptor() + val typeCaptor = argumentCaptor() + val requestBodyCaptor = argumentCaptor() + val queryParamCaptor = argumentCaptor() + val overrideParamCaptor = argumentCaptor() + verify(mockNylasClient).executePatch>( + pathCaptor.capture(), + typeCaptor.capture(), + requestBodyCaptor.capture(), + queryParamCaptor.capture(), + overrideParamCaptor.capture(), + ) + + assertEquals("v3/connectors/google", pathCaptor.firstValue) + assertEquals(Types.newParameterizedType(Response::class.java, Connector::class.java), typeCaptor.firstValue) + val json = adapter.toJson(updateConnectorRequest) + assertEquals(json, requestBodyCaptor.firstValue) + assert(json.contains("\"active_credential_id\":\"5d6ac8b4-ba68-438a-b578-9b5104602bdc\"")) + } + + @Test + fun `UpdateConnectorRequest Google Builder with activeCredentialId works correctly`() { + val request = UpdateConnectorRequest.Google.Builder() + .activeCredentialId("cred-123") + .build() + + assertEquals("cred-123", request.activeCredentialId) + } + + @Test + fun `UpdateConnectorRequest Microsoft Builder with activeCredentialId works correctly`() { + val request = UpdateConnectorRequest.Microsoft.Builder() + .activeCredentialId("cred-456") + .build() + + assertEquals("cred-456", request.activeCredentialId) + } + @Test fun `destroying a connector calls requests with the correct params`() { connectors.destroy(AuthProvider.IMAP) diff --git a/src/test/kotlin/com/nylas/resources/CredentialsTests.kt b/src/test/kotlin/com/nylas/resources/CredentialsTests.kt index 3eeb6e97..91ec5452 100644 --- a/src/test/kotlin/com/nylas/resources/CredentialsTests.kt +++ b/src/test/kotlin/com/nylas/resources/CredentialsTests.kt @@ -62,6 +62,117 @@ class CredentialsTests { assertEquals(1617817109L, credential.createdAt) assertEquals(1617817109L, credential.updatedAt) } + + @Test + fun `Credential with connector type serializes properly`() { + val adapter = JsonHelper.moshi().adapter(Credential::class.java) + val jsonBuffer = Buffer().writeUtf8( + """ + { + "id": "e19f8e1a-eb1c-41c0-b6a6-d2e59daf7f47", + "name": "My GCP credential", + "credential_type": "connector", + "created_at": 1617817109, + "updated_at": 1617817109 + } + """.trimIndent(), + ) + + val credential = adapter.fromJson(jsonBuffer)!! + assertEquals("e19f8e1a-eb1c-41c0-b6a6-d2e59daf7f47", credential.id) + assertEquals("My GCP credential", credential.name) + assertEquals(CredentialType.CONNECTOR, credential.credentialType) + assertEquals(1617817109L, credential.createdAt) + assertEquals(1617817109L, credential.updatedAt) + } + + @Test + fun `CreateCredentialRequest Connector with OAuth credentials serializes properly`() { + val adapter = JsonHelper.moshi().adapter(CreateCredentialRequest::class.java) + val request = CreateCredentialRequest.Connector( + name = "my GCP credential Google connector", + credentialData = CredentialData.ConnectorOverride( + clientId = "906653528181-hiu1u4q78kk1ag529robsq2s4un3kndo.apps.googleusercontent.com", + clientSecret = "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", + ), + ) + + val json = adapter.toJson(request) + val jsonMap = JsonHelper.jsonToMap(json) + + assertEquals("my GCP credential Google connector", jsonMap["name"]) + assertEquals("connector", jsonMap["credential_type"]) + @Suppress("UNCHECKED_CAST") + val credentialData = jsonMap["credential_data"] as Map + assertEquals("906653528181-hiu1u4q78kk1ag529robsq2s4un3kndo.apps.googleusercontent.com", credentialData["client_id"]) + assertEquals("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", credentialData["client_secret"]) + } + + @Test + fun `CredentialData ConnectorOverride with OAuth credentials serializes properly`() { + val adapter = JsonHelper.moshi().adapter(CredentialData.ConnectorOverride::class.java) + val credentialData = CredentialData.ConnectorOverride( + clientId = "test-client-id", + clientSecret = "test-client-secret", + ) + + val json = adapter.toJson(credentialData) + val jsonMap = JsonHelper.jsonToMap(json) + + assertEquals("test-client-id", jsonMap["client_id"]) + assertEquals("test-client-secret", jsonMap["client_secret"]) + } + + @Test + fun `CredentialData ConnectorOverride with extraProperties serializes properly`() { + val adapter = JsonHelper.moshi().adapter(CredentialData.ConnectorOverride::class.java) + val credentialData = CredentialData.ConnectorOverride( + clientId = "test-client-id", + clientSecret = "test-client-secret", + extraProperties = mapOf("tenant" to "my-tenant-id"), + ) + + val json = adapter.toJson(credentialData) + val jsonMap = JsonHelper.jsonToMap(json) + + assertEquals("test-client-id", jsonMap["client_id"]) + assertEquals("test-client-secret", jsonMap["client_secret"]) + assertEquals("my-tenant-id", jsonMap["tenant"]) + } + + @Test + fun `CredentialData ConnectorOverride deserializes properly`() { + val adapter = JsonHelper.moshi().adapter(CredentialData.ConnectorOverride::class.java) + val jsonBuffer = Buffer().writeUtf8( + """ + { + "client_id": "test-client-id", + "client_secret": "test-client-secret", + "tenant": "my-tenant-id" + } + """.trimIndent(), + ) + + val credentialData = adapter.fromJson(jsonBuffer)!! + assertEquals("test-client-id", credentialData.clientId) + assertEquals("test-client-secret", credentialData.clientSecret) + assertEquals("my-tenant-id", credentialData.extraProperties?.get("tenant")) + } + + @Test + fun `CredentialData ConnectorOverride with only extraProperties serializes properly`() { + val adapter = JsonHelper.moshi().adapter(CredentialData.ConnectorOverride::class.java) + val credentialData = CredentialData.ConnectorOverride( + extraProperties = mapOf("custom_key" to "custom_value"), + ) + + val json = adapter.toJson(credentialData) + val jsonMap = JsonHelper.jsonToMap(json) + + assertNull(jsonMap["client_id"]) + assertNull(jsonMap["client_secret"]) + assertEquals("custom_value", jsonMap["custom_key"]) + } } @Nested @@ -173,6 +284,37 @@ class CredentialsTests { assertEquals(adapter.toJson(createCredentialRequest), requestBodyCaptor.firstValue) } + @Test + fun `creating a connector credential with OAuth credentials calls requests with the correct params`() { + val adapter = JsonHelper.moshi().adapter(CreateCredentialRequest::class.java) + val createCredentialRequest = CreateCredentialRequest.Connector( + name = "my GCP credential Google connector", + credentialData = CredentialData.ConnectorOverride( + clientId = "906653528181-hiu1u4q78kk1ag529robsq2s4un3kndo.apps.googleusercontent.com", + clientSecret = "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", + ), + ) + + credentials.create(AuthProvider.GOOGLE, createCredentialRequest) + + val pathCaptor = argumentCaptor() + val typeCaptor = argumentCaptor() + val requestBodyCaptor = argumentCaptor() + val queryParamCaptor = argumentCaptor() + val overrideParamCaptor = argumentCaptor() + verify(mockNylasClient).executePost>( + pathCaptor.capture(), + typeCaptor.capture(), + requestBodyCaptor.capture(), + queryParamCaptor.capture(), + overrideParamCaptor.capture(), + ) + + assertEquals("v3/connectors/google/creds", pathCaptor.firstValue) + assertEquals(Types.newParameterizedType(Response::class.java, Credential::class.java), typeCaptor.firstValue) + assertEquals(adapter.toJson(createCredentialRequest), requestBodyCaptor.firstValue) + } + @Test fun `updating a credential calls requests with the correct params`() { val credentialId = "abc-123" @@ -225,5 +367,44 @@ class CredentialsTests { assertEquals("v3/connectors/imap/creds/abc-123", pathCaptor.firstValue) assertEquals(DeleteResponse::class.java, typeCaptor.firstValue) } + + @Test + fun `updating a credential with ConnectorOverride data calls requests with the correct params`() { + val credentialId = "abc-123" + val updateCredentialRequest = UpdateCredentialRequest( + name = "Updated connector credential", + credentialData = CredentialData.ConnectorOverride( + clientId = "new-client-id", + clientSecret = "new-client-secret", + extraProperties = mapOf("tenant" to "my-tenant"), + ), + ) + + credentials.update(AuthProvider.GOOGLE, credentialId, updateCredentialRequest) + + val pathCaptor = argumentCaptor() + val typeCaptor = argumentCaptor() + val requestBodyCaptor = argumentCaptor() + val queryParamCaptor = argumentCaptor() + val overrideParamCaptor = argumentCaptor() + verify(mockNylasClient).executePatch>( + pathCaptor.capture(), + typeCaptor.capture(), + requestBodyCaptor.capture(), + queryParamCaptor.capture(), + overrideParamCaptor.capture(), + ) + + assertEquals("v3/connectors/google/creds/abc-123", pathCaptor.firstValue) + assertEquals(Types.newParameterizedType(Response::class.java, Credential::class.java), typeCaptor.firstValue) + val json = requestBodyCaptor.firstValue + val jsonMap = JsonHelper.jsonToMap(json) + assertEquals("Updated connector credential", jsonMap["name"]) + @Suppress("UNCHECKED_CAST") + val credentialData = jsonMap["credential_data"] as Map + assertEquals("new-client-id", credentialData["client_id"]) + assertEquals("new-client-secret", credentialData["client_secret"]) + assertEquals("my-tenant", credentialData["tenant"]) + } } } diff --git a/src/test/kotlin/com/nylas/resources/GrantsTests.kt b/src/test/kotlin/com/nylas/resources/GrantsTests.kt index 6ad79f56..ec891c4e 100644 --- a/src/test/kotlin/com/nylas/resources/GrantsTests.kt +++ b/src/test/kotlin/com/nylas/resources/GrantsTests.kt @@ -80,6 +80,52 @@ class GrantsTests { assertEquals(1617817109, grant.createdAt) assertEquals(1617817109, grant.updatedAt) } + + @Test + fun `Grant with credentialId serializes properly`() { + val adapter = JsonHelper.moshi().adapter(Grant::class.java) + val jsonBuffer = Buffer().writeUtf8( + """ + { + "id": "e19f8e1a-eb1c-41c0-b6a6-d2e59daf7f47", + "provider": "google", + "grant_status": "valid", + "email": "email@example.com", + "scope": [ + "Mail.Read", + "User.Read" + ], + "created_at": 1617817109, + "updated_at": 1617817109, + "credential_id": "5d6ac8b4-ba68-438a-b578-9b5104602bdc" + } + """.trimIndent(), + ) + + val grant = adapter.fromJson(jsonBuffer)!! + assertIs(grant) + assertEquals("e19f8e1a-eb1c-41c0-b6a6-d2e59daf7f47", grant.id) + assertEquals(AuthProvider.GOOGLE, grant.provider) + assertEquals("5d6ac8b4-ba68-438a-b578-9b5104602bdc", grant.credentialId) + } + + @Test + fun `Grant without credentialId has null credentialId`() { + val adapter = JsonHelper.moshi().adapter(Grant::class.java) + val jsonBuffer = Buffer().writeUtf8( + """ + { + "id": "e19f8e1a-eb1c-41c0-b6a6-d2e59daf7f47", + "provider": "google", + "created_at": 1617817109 + } + """.trimIndent(), + ) + + val grant = adapter.fromJson(jsonBuffer)!! + assertIs(grant) + assertNull(grant.credentialId) + } } @Nested From d5d169224a53fe04857d6a251f53a65d4df8a9eb Mon Sep 17 00:00:00 2001 From: Gordan Ovcaric Date: Fri, 6 Feb 2026 12:06:38 +0100 Subject: [PATCH 2/4] fix tests credentials --- .claude/settings.local.json | 4 +++- .../kotlin/com/nylas/resources/CredentialsTests.kt | 12 ++++++------ 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index fa7c91e2..f0ad2a3a 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -12,7 +12,9 @@ "Bash(java -version:*)", "Bash(/usr/libexec/java_home:*)", "Bash(JAVA_HOME=/Users/gordan.o@nylas.com/Library/Java/JavaVirtualMachines/corretto-19.0.2/Contents/Home ./gradlew test:*)", - "Bash(JAVA_HOME=/Users/gordan.o@nylas.com/Library/Java/JavaVirtualMachines/corretto-19.0.2/Contents/Home ./gradlew:*)" + "Bash(JAVA_HOME=/Users/gordan.o@nylas.com/Library/Java/JavaVirtualMachines/corretto-19.0.2/Contents/Home ./gradlew:*)", + "Bash(export JAVA_HOME=/Users/gordan.o@nylas.com/Library/Java/JavaVirtualMachines/temurin-11.0.29/Contents/Home)", + "Bash(./gradlew clean test:*)" ] } } diff --git a/src/test/kotlin/com/nylas/resources/CredentialsTests.kt b/src/test/kotlin/com/nylas/resources/CredentialsTests.kt index 91ec5452..683b20f9 100644 --- a/src/test/kotlin/com/nylas/resources/CredentialsTests.kt +++ b/src/test/kotlin/com/nylas/resources/CredentialsTests.kt @@ -92,8 +92,8 @@ class CredentialsTests { val request = CreateCredentialRequest.Connector( name = "my GCP credential Google connector", credentialData = CredentialData.ConnectorOverride( - clientId = "906653528181-hiu1u4q78kk1ag529robsq2s4un3kndo.apps.googleusercontent.com", - clientSecret = "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", + clientId = "fake-client-id.apps.googleusercontent.com", + clientSecret = "fake-client-secret", ), ) @@ -104,8 +104,8 @@ class CredentialsTests { assertEquals("connector", jsonMap["credential_type"]) @Suppress("UNCHECKED_CAST") val credentialData = jsonMap["credential_data"] as Map - assertEquals("906653528181-hiu1u4q78kk1ag529robsq2s4un3kndo.apps.googleusercontent.com", credentialData["client_id"]) - assertEquals("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", credentialData["client_secret"]) + assertEquals("fake-client-id.apps.googleusercontent.com", credentialData["client_id"]) + assertEquals("fake-client-secret", credentialData["client_secret"]) } @Test @@ -290,8 +290,8 @@ class CredentialsTests { val createCredentialRequest = CreateCredentialRequest.Connector( name = "my GCP credential Google connector", credentialData = CredentialData.ConnectorOverride( - clientId = "906653528181-hiu1u4q78kk1ag529robsq2s4un3kndo.apps.googleusercontent.com", - clientSecret = "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", + clientId = "fake-client-id.apps.googleusercontent.com", + clientSecret = "fake-client-secret", ), ) From 95e501857c90a9a146c49994e8a7ca27a55af3ce Mon Sep 17 00:00:00 2001 From: Gordan Ovcaric Date: Fri, 6 Feb 2026 14:49:06 +0100 Subject: [PATCH 3/4] improve unit test coverage --- .../com/nylas/resources/CredentialsTests.kt | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/src/test/kotlin/com/nylas/resources/CredentialsTests.kt b/src/test/kotlin/com/nylas/resources/CredentialsTests.kt index 683b20f9..a3af419f 100644 --- a/src/test/kotlin/com/nylas/resources/CredentialsTests.kt +++ b/src/test/kotlin/com/nylas/resources/CredentialsTests.kt @@ -2,7 +2,10 @@ package com.nylas.resources import com.nylas.NylasClient import com.nylas.models.* +import com.nylas.util.ConnectorOverrideCredentialDataAdapter import com.nylas.util.JsonHelper +import com.squareup.moshi.JsonReader +import com.squareup.moshi.JsonWriter import com.squareup.moshi.Types import okhttp3.Call import okhttp3.Interceptor @@ -173,6 +176,39 @@ class CredentialsTests { assertNull(jsonMap["client_secret"]) assertEquals("custom_value", jsonMap["custom_key"]) } + + @Test + fun `ConnectorOverrideCredentialDataAdapter fromJson parses client credentials`() { + val adapter = ConnectorOverrideCredentialDataAdapter() + val json = """{"client_id":"test-client-id","client_secret":"test-client-secret","tenant":"my-tenant"}""" + val reader = JsonReader.of(Buffer().writeUtf8(json)) + + val result = adapter.fromJson(reader) + + assertEquals("test-client-id", result.clientId) + assertEquals("test-client-secret", result.clientSecret) + assertEquals("my-tenant", result.extraProperties?.get("tenant")) + } + + @Test + fun `ConnectorOverrideCredentialDataAdapter toJson writes client credentials`() { + val adapter = ConnectorOverrideCredentialDataAdapter() + val buffer = Buffer() + val writer = JsonWriter.of(buffer) + val credentialData = CredentialData.ConnectorOverride( + clientId = "test-client-id", + clientSecret = "test-client-secret", + extraProperties = mapOf("tenant" to "my-tenant"), + ) + + adapter.toJson(writer, credentialData) + + val json = buffer.readUtf8() + val jsonMap = JsonHelper.jsonToMap(json) + assertEquals("test-client-id", jsonMap["client_id"]) + assertEquals("test-client-secret", jsonMap["client_secret"]) + assertEquals("my-tenant", jsonMap["tenant"]) + } } @Nested From e154a3980073877cd959223217beb6939c679aa2 Mon Sep 17 00:00:00 2001 From: Gordan Ovcaric Date: Fri, 6 Feb 2026 16:03:36 +0100 Subject: [PATCH 4/4] address comment, add changelog --- .claude/settings.local.json | 4 ---- CHANGELOG.md | 15 +++++++++++++++ 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index f0ad2a3a..ba271215 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -1,7 +1,6 @@ { "permissions": { "allow": [ - "Bash(export JAVA_HOME=/Users/gordan.o@nylas.com/Library/Java/JavaVirtualMachines/corretto-11.0.29/Contents/Home:*)", "Bash(./gradlew test:*)", "Bash(export JAVA_HOME:*)", "Bash(./gradlew jacocoTestReport:*)", @@ -11,9 +10,6 @@ "Bash(./gradlew clean build:*)", "Bash(java -version:*)", "Bash(/usr/libexec/java_home:*)", - "Bash(JAVA_HOME=/Users/gordan.o@nylas.com/Library/Java/JavaVirtualMachines/corretto-19.0.2/Contents/Home ./gradlew test:*)", - "Bash(JAVA_HOME=/Users/gordan.o@nylas.com/Library/Java/JavaVirtualMachines/corretto-19.0.2/Contents/Home ./gradlew:*)", - "Bash(export JAVA_HOME=/Users/gordan.o@nylas.com/Library/Java/JavaVirtualMachines/temurin-11.0.29/Contents/Home)", "Bash(./gradlew clean test:*)" ] } diff --git a/CHANGELOG.md b/CHANGELOG.md index 1276f49e..364a5ad7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Nylas Java SDK Changelog +## [Unreleased] + +### Added +* Multi-credential authentication support allowing multiple provider credentials per Connector + - `CreateCredentialRequest.Connector` class for creating connector credentials with `client_id`, `client_secret`, and optional extra properties like `tenant` + - `credentialId` field in `UrlForAuthenticationConfig` for hosted auth URL generation via `urlForOAuth2` and `urlForOAuth2PKCE` + - `credentialId` field in `CreateGrantRequest` for custom authentication + - `credentialId` field in `Grant` response model + - `activeCredentialId` field in `Connector` response model + - `activeCredentialId` field in `UpdateConnectorRequest` for setting the active credential on a Connector +* Enhanced `CredentialData.ConnectorOverride` to support optional `clientId` and `clientSecret` fields + +### Deprecated +* `CreateCredentialRequest.Override` - Use `CreateCredentialRequest.Connector` instead + ## [2.14.1] ### Added