diff --git a/auth0_flutter/EXAMPLES.md b/auth0_flutter/EXAMPLES.md index c15c1e187..992e9548c 100644 --- a/auth0_flutter/EXAMPLES.md +++ b/auth0_flutter/EXAMPLES.md @@ -29,6 +29,7 @@ - [Passwordless Login](#passwordless-login) - [Retrieve user information](#retrieve-user-information) - [Renew credentials](#renew-credentials) + - [Custom Token Exchange](#custom-token-exchange) - [Errors](#errors-2) - [🌐📱 Organizations](#-organizations) - [Log in to an organization](#log-in-to-an-organization) @@ -700,6 +701,46 @@ final didStore = > 💡 To obtain a refresh token, make sure your Auth0 application has the **refresh token** [grant enabled](https://auth0.com/docs/get-started/applications/update-grant-types). If you are also specifying an audience value, make sure that the corresponding Auth0 API has the **Allow Offline Access** [setting enabled](https://auth0.com/docs/get-started/apis/api-settings#access-settings). +### Custom Token Exchange + +[Custom Token Exchange](https://auth0.com/docs/authenticate/custom-token-exchange) allows you to enable applications to exchange their existing tokens for Auth0 tokens when calling the /oauth/token endpoint. This is useful for advanced integration use cases, such as: +- Integrate an external identity provider +- Migrate to Auth0 + +> **Note:** This feature is currently available in [Early Access](https://auth0.com/docs/troubleshoot/product-lifecycle/product-release-stages#early-access). Please reach out to Auth0 support to enable it for your tenant. + +
+ Mobile (Android/iOS) + +```dart +final credentials = await auth0.api.customTokenExchange( + subjectToken: 'external-idp-token', + subjectTokenType: 'urn:acme:legacy-token', + audience: 'https://api.example.com', // Optional + scopes: {'openid', 'profile', 'email'}, // Optional, defaults to {'openid', 'profile', 'email'} + organization: 'org_abc123', // Optional +); +``` + +
+ +
+ Web + +```dart +final credentials = await auth0Web.customTokenExchange( + subjectToken: 'external-idp-token', + subjectTokenType: 'urn:acme:legacy-token', + audience: 'https://api.example.com', // Optional + scopes: {'openid', 'profile', 'email'}, // Optional + organizationId: 'org_abc123', // Optional +); +``` + +
+ +> 💡 For more information, see the [Custom Token Exchange documentation](https://auth0.com/docs/authenticate/custom-token-exchange) and [RFC 8693](https://tools.ietf.org/html/rfc8693). + ### Errors The Authentication API client will only throw `ApiException` exceptions. You can find more information in the `details` property of the exception. Check the [API documentation](https://pub.dev/documentation/auth0_flutter_platform_interface/latest/auth0_flutter_platform_interface/ApiException-class.html) to learn more about the available `ApiException` properties. diff --git a/auth0_flutter/android/build.gradle b/auth0_flutter/android/build.gradle index 4584749d0..18b983028 100644 --- a/auth0_flutter/android/build.gradle +++ b/auth0_flutter/android/build.gradle @@ -73,7 +73,7 @@ android { dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" - implementation 'com.auth0.android:auth0:3.11.0' + implementation 'com.auth0.android:auth0:3.12.0' testImplementation 'junit:junit:4.13.2' testImplementation 'org.hamcrest:java-hamcrest:2.0.0.0' testImplementation "org.mockito.kotlin:mockito-kotlin:4.1.0" diff --git a/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/Auth0FlutterPlugin.kt b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/Auth0FlutterPlugin.kt index 24d75c58f..27e3b872f 100644 --- a/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/Auth0FlutterPlugin.kt +++ b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/Auth0FlutterPlugin.kt @@ -66,6 +66,7 @@ class Auth0FlutterPlugin: FlutterPlugin, MethodCallHandler, ActivityAware { SignupApiRequestHandler(), UserInfoApiRequestHandler(), RenewApiRequestHandler(), + CustomTokenExchangeApiRequestHandler(), ResetPasswordApiRequestHandler() ) ) diff --git a/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/api/CustomTokenExchangeApiRequestHandler.kt b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/api/CustomTokenExchangeApiRequestHandler.kt new file mode 100644 index 000000000..41888725f --- /dev/null +++ b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/api/CustomTokenExchangeApiRequestHandler.kt @@ -0,0 +1,69 @@ +package com.auth0.auth0_flutter.request_handlers.api + +import com.auth0.android.authentication.AuthenticationAPIClient +import com.auth0.android.authentication.AuthenticationException +import com.auth0.android.callback.Callback +import com.auth0.android.result.Credentials +import com.auth0.auth0_flutter.request_handlers.MethodCallRequest +import com.auth0.auth0_flutter.toMap +import com.auth0.auth0_flutter.utils.assertHasProperties +import io.flutter.plugin.common.MethodChannel +import java.util.ArrayList + +private const val AUTH_CUSTOM_TOKEN_EXCHANGE_METHOD = "auth#customTokenExchange" + +class CustomTokenExchangeApiRequestHandler : ApiRequestHandler { + override val method: String = AUTH_CUSTOM_TOKEN_EXCHANGE_METHOD + + override fun handle( + api: AuthenticationAPIClient, + request: MethodCallRequest, + result: MethodChannel.Result + ) { + val args = request.data + assertHasProperties(listOf("subjectToken", "subjectTokenType"), args) + + val organization = if (args["organization"] is String) args["organization"] as String else null + + val builder = api.customTokenExchange( + args["subjectTokenType"] as String, + args["subjectToken"] as String, + organization + ).apply { + val scopes = (args["scopes"] ?: arrayListOf()) as ArrayList<*> + if (scopes.isNotEmpty()) { + setScope(scopes.joinToString(separator = " ")) + } + if (args["audience"] is String) { + setAudience(args["audience"] as String) + } + validateClaims() + } + + builder.start(object : Callback { + override fun onFailure(exception: AuthenticationException) { + result.error( + exception.getCode(), + exception.getDescription(), + exception.toMap() + ) + } + + override fun onSuccess(credentials: Credentials) { + val scope = credentials.scope?.split(" ") ?: listOf() + val formattedDate = credentials.expiresAt.toInstant().toString() + result.success( + mapOf( + "accessToken" to credentials.accessToken, + "idToken" to credentials.idToken, + "refreshToken" to credentials.refreshToken, + "userProfile" to credentials.user.toMap(), + "expiresAt" to formattedDate, + "scopes" to scope, + "tokenType" to credentials.type + ) + ) + } + }) + } +} diff --git a/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/api/CustomTokenExchangeApiRequestHandlerTest.kt b/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/api/CustomTokenExchangeApiRequestHandlerTest.kt new file mode 100644 index 000000000..31f82ec4f --- /dev/null +++ b/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/api/CustomTokenExchangeApiRequestHandlerTest.kt @@ -0,0 +1,300 @@ +package com.auth0.auth0_flutter.request_handlers.api + +import com.auth0.android.Auth0 +import com.auth0.android.authentication.AuthenticationAPIClient +import com.auth0.android.authentication.AuthenticationException +import com.auth0.android.callback.Callback +import com.auth0.android.request.AuthenticationRequest +import com.auth0.android.result.Credentials +import com.auth0.auth0_flutter.JwtTestUtils +import com.auth0.auth0_flutter.request_handlers.MethodCallRequest +import io.flutter.plugin.common.MethodChannel.Result +import org.hamcrest.CoreMatchers.equalTo +import org.hamcrest.MatcherAssert.assertThat +import org.junit.Assert +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.* +import org.robolectric.RobolectricTestRunner +import java.text.SimpleDateFormat +import java.util.* + +@RunWith(RobolectricTestRunner::class) +class CustomTokenExchangeApiRequestHandlerTest { + @Test + fun `should throw when missing subjectToken`() { + val options = hashMapOf("subjectTokenType" to "urn:acme:legacy-token") + val handler = CustomTokenExchangeApiRequestHandler() + val mockApi = mock() + val mockAccount = mock() + val mockResult = mock() + val request = MethodCallRequest(account = mockAccount, options) + + Assert.assertThrows(IllegalArgumentException::class.java) { + handler.handle(mockApi, request, mockResult) + } + } + + @Test + fun `should throw when missing subjectTokenType`() { + val options = hashMapOf("subjectToken" to "external-token-123") + val handler = CustomTokenExchangeApiRequestHandler() + val mockApi = mock() + val mockAccount = mock() + val mockResult = mock() + val request = MethodCallRequest(account = mockAccount, options) + + Assert.assertThrows(IllegalArgumentException::class.java) { + handler.handle(mockApi, request, mockResult) + } + } + + @Test + fun `should call success with required parameters only`() { + val options = hashMapOf( + "subjectToken" to "external-token-123", + "subjectTokenType" to "urn:acme:legacy-token" + ) + val handler = CustomTokenExchangeApiRequestHandler() + val mockApi = mock() + val mockAccount = mock() + val mockResult = mock() + val mockRequest = mock() + val request = MethodCallRequest(account = mockAccount, options) + + val accessToken = JwtTestUtils.createJwt("openid") + val idToken = JwtTestUtils.createJwt("openid") + val refreshToken = "refresh-token" + val expiresAt = Date() + + val credentials = Credentials( + idToken, + accessToken, + "Bearer", + refreshToken, + expiresAt, + "openid profile email" + ) + + whenever(mockApi.customTokenExchange(any(), any(), isNull())).thenReturn(mockRequest) + whenever(mockRequest.validateClaims()).thenReturn(mockRequest) + + doAnswer { + val callback = it.arguments[0] as Callback + callback.onSuccess(credentials) + null + }.whenever(mockRequest).start(any()) + + handler.handle(mockApi, request, mockResult) + + verify(mockApi).customTokenExchange( + "urn:acme:legacy-token", + "external-token-123", + null + ) + verify(mockRequest).validateClaims() + verify(mockRequest).start(any()) + verify(mockResult).success(any()) + } + + @Test + fun `should handle error callback`() { + val options = hashMapOf( + "subjectToken" to "invalid-token", + "subjectTokenType" to "urn:acme:legacy-token" + ) + val handler = CustomTokenExchangeApiRequestHandler() + val mockApi = mock() + val mockAccount = mock() + val mockResult = mock() + val mockRequest = mock() + val request = MethodCallRequest(account = mockAccount, options) + + val exception = mock { + on { getCode() } doReturn "invalid_token" + on { getDescription() } doReturn "Token validation failed" + } + + whenever(mockApi.customTokenExchange(any(), any(), isNull())).thenReturn(mockRequest) + whenever(mockRequest.validateClaims()).thenReturn(mockRequest) + + doAnswer { + val callback = it.arguments[0] as Callback + callback.onFailure(exception) + null + }.whenever(mockRequest).start(any()) + + handler.handle(mockApi, request, mockResult) + + verify(mockResult).error(eq("invalid_token"), eq("Token validation failed"), any()) + } + + @Test + fun `should include audience when provided`() { + val options = hashMapOf( + "subjectToken" to "external-token-456", + "subjectTokenType" to "urn:example:custom-token", + "audience" to "https://myapi.example.com" + ) + val handler = CustomTokenExchangeApiRequestHandler() + val mockApi = mock() + val mockAccount = mock() + val mockResult = mock() + val mockRequest = mock() + val request = MethodCallRequest(account = mockAccount, options) + + val credentials = Credentials( + JwtTestUtils.createJwt("openid"), + JwtTestUtils.createJwt("openid"), + "Bearer", + "refresh-token", + Date(), + "openid" + ) + + whenever(mockApi.customTokenExchange(any(), any(), isNull())).thenReturn(mockRequest) + whenever(mockRequest.setAudience(any())).thenReturn(mockRequest) + whenever(mockRequest.validateClaims()).thenReturn(mockRequest) + + doAnswer { + val callback = it.arguments[0] as Callback + callback.onSuccess(credentials) + null + }.whenever(mockRequest).start(any()) + + handler.handle(mockApi, request, mockResult) + + verify(mockRequest).setAudience("https://myapi.example.com") + verify(mockResult).success(any()) + } + + @Test + fun `should include scopes when provided`() { + val options = hashMapOf( + "subjectToken" to "external-token-789", + "subjectTokenType" to "urn:acme:legacy-token", + "scopes" to arrayListOf("openid", "profile", "email", "read:data") + ) + val handler = CustomTokenExchangeApiRequestHandler() + val mockApi = mock() + val mockAccount = mock() + val mockResult = mock() + val mockRequest = mock() + val request = MethodCallRequest(account = mockAccount, options) + + val credentials = Credentials( + JwtTestUtils.createJwt("openid"), + JwtTestUtils.createJwt("openid"), + "Bearer", + "refresh-token", + Date(), + "openid profile email read:data" + ) + + whenever(mockApi.customTokenExchange(any(), any(), isNull())).thenReturn(mockRequest) + whenever(mockRequest.setScope(any())).thenReturn(mockRequest) + whenever(mockRequest.validateClaims()).thenReturn(mockRequest) + + doAnswer { + val callback = it.arguments[0] as Callback + callback.onSuccess(credentials) + null + }.whenever(mockRequest).start(any()) + + handler.handle(mockApi, request, mockResult) + + verify(mockRequest).setScope("openid profile email read:data") + verify(mockResult).success(any()) + } + + @Test + fun `should include all optional parameters when provided`() { + val options = hashMapOf( + "subjectToken" to "external-token-full", + "subjectTokenType" to "urn:example:full-token", + "audience" to "https://api.example.com", + "scopes" to arrayListOf("openid", "profile", "email") + ) + val handler = CustomTokenExchangeApiRequestHandler() + val mockApi = mock() + val mockAccount = mock() + val mockResult = mock() + val mockRequest = mock() + val request = MethodCallRequest(account = mockAccount, options) + + val credentials = Credentials( + JwtTestUtils.createJwt("openid"), + JwtTestUtils.createJwt("openid"), + "Bearer", + "refresh-token", + Date(), + "openid profile email" + ) + + whenever(mockApi.customTokenExchange(any(), any(), isNull())).thenReturn(mockRequest) + whenever(mockRequest.setAudience(any())).thenReturn(mockRequest) + whenever(mockRequest.setScope(any())).thenReturn(mockRequest) + whenever(mockRequest.validateClaims()).thenReturn(mockRequest) + + doAnswer { + val callback = it.arguments[0] as Callback + callback.onSuccess(credentials) + null + }.whenever(mockRequest).start(any()) + + handler.handle(mockApi, request, mockResult) + + verify(mockRequest).setAudience("https://api.example.com") + verify(mockRequest).setScope("openid profile email") + verify(mockRequest).validateClaims() + verify(mockResult).success(any()) + } + + @Test + fun `should include organization when provided`() { + val options = hashMapOf( + "subjectToken" to "external-token-org", + "subjectTokenType" to "urn:acme:legacy-token", + "organization" to "org_abc123" + ) + val handler = CustomTokenExchangeApiRequestHandler() + val mockApi = mock() + val mockAccount = mock() + val mockResult = mock() + val mockRequest = mock() + val request = MethodCallRequest(account = mockAccount, options) + + val credentials = Credentials( + JwtTestUtils.createJwt("openid"), + JwtTestUtils.createJwt("openid"), + "Bearer", + "refresh-token", + Date(), + "openid" + ) + + whenever(mockApi.customTokenExchange(any(), any(), eq("org_abc123"))).thenReturn(mockRequest) + whenever(mockRequest.validateClaims()).thenReturn(mockRequest) + + doAnswer { + val callback = it.arguments[0] as Callback + callback.onSuccess(credentials) + null + }.whenever(mockRequest).start(any()) + + handler.handle(mockApi, request, mockResult) + + verify(mockApi).customTokenExchange( + "urn:acme:legacy-token", + "external-token-org", + "org_abc123" + ) + verify(mockResult).success(any()) + } + + @Test + fun `should return correct method name`() { + val handler = CustomTokenExchangeApiRequestHandler() + assertThat(handler.method, equalTo("auth#customTokenExchange")) + } +} diff --git a/auth0_flutter/darwin/Classes/AuthAPI/AuthAPICustomTokenExchangeMethodHandler.swift b/auth0_flutter/darwin/Classes/AuthAPI/AuthAPICustomTokenExchangeMethodHandler.swift new file mode 100644 index 000000000..f415b3b75 --- /dev/null +++ b/auth0_flutter/darwin/Classes/AuthAPI/AuthAPICustomTokenExchangeMethodHandler.swift @@ -0,0 +1,48 @@ +import Auth0 + +#if os(iOS) +import Flutter +#else +import FlutterMacOS +#endif + +struct AuthAPICustomTokenExchangeMethodHandler: MethodHandler { + enum Argument: String { + case subjectToken + case subjectTokenType + case audience + case scopes + case organization + } + + let client: Authentication + + func handle(with arguments: [String: Any], callback: @escaping FlutterResult) { + guard let subjectToken = arguments[Argument.subjectToken] as? String else { + return callback(FlutterError(from: .requiredArgumentMissing(Argument.subjectToken.rawValue))) + } + guard let subjectTokenType = arguments[Argument.subjectTokenType] as? String else { + return callback(FlutterError(from: .requiredArgumentMissing(Argument.subjectTokenType.rawValue))) + } + guard let scopes = arguments[Argument.scopes] as? [String], !scopes.isEmpty else { + return callback(FlutterError(from: .requiredArgumentMissing(Argument.scopes.rawValue))) + } + + let scope = scopes.asSpaceSeparatedString + let audience = arguments[Argument.audience] as? String + let organization = arguments[Argument.organization] as? String + + client + .customTokenExchange(subjectToken: subjectToken, + subjectTokenType: subjectTokenType, + audience: audience, + scope: scope, + organization: organization) + .start { + switch $0 { + case .success(let credentials): callback(self.result(from: credentials)) + case .failure(let error): callback(FlutterError(from: error)) + } + } + } +} \ No newline at end of file diff --git a/auth0_flutter/darwin/Classes/AuthAPI/AuthAPIHandler.swift b/auth0_flutter/darwin/Classes/AuthAPI/AuthAPIHandler.swift index e2380a1f4..3e1f4c832 100644 --- a/auth0_flutter/darwin/Classes/AuthAPI/AuthAPIHandler.swift +++ b/auth0_flutter/darwin/Classes/AuthAPI/AuthAPIHandler.swift @@ -21,6 +21,7 @@ public class AuthAPIHandler: NSObject, FlutterPlugin { case signup = "auth#signUp" case userInfo = "auth#userInfo" case renew = "auth#renew" + case customTokenExchange = "auth#customTokenExchange" case resetPassword = "auth#resetPassword" case passwordlessWithEmail = "auth#passwordlessWithEmail" case passwordlessWithPhoneNumber = "auth#passwordlessWithPhoneNumber" @@ -64,6 +65,7 @@ public class AuthAPIHandler: NSObject, FlutterPlugin { case .signup: return AuthAPISignupMethodHandler(client: client) case .userInfo: return AuthAPIUserInfoMethodHandler(client: client) case .renew: return AuthAPIRenewMethodHandler(client: client) + case .customTokenExchange: return AuthAPICustomTokenExchangeMethodHandler(client: client) case .resetPassword: return AuthAPIResetPasswordMethodHandler(client: client) case .passwordlessWithEmail: return AuthAPIPasswordlessEmailMethodHandler(client: client) case .passwordlessWithPhoneNumber: return AuthAPIPasswordlessPhoneNumberMethodHandler(client: client) diff --git a/auth0_flutter/darwin/auth0_flutter.podspec b/auth0_flutter/darwin/auth0_flutter.podspec index 16aadb281..169f8f050 100644 --- a/auth0_flutter/darwin/auth0_flutter.podspec +++ b/auth0_flutter/darwin/auth0_flutter.podspec @@ -19,7 +19,7 @@ Pod::Spec.new do |s| s.osx.deployment_target = '11.0' s.osx.dependency 'FlutterMacOS' - s.dependency 'Auth0', '2.14.0' + s.dependency 'Auth0', '2.16.2' s.dependency 'JWTDecode', '3.3.0' s.dependency 'SimpleKeychain', '1.3.0' diff --git a/auth0_flutter/example/ios/Tests/AuthAPI/AuthAPICustomTokenExchangeMethodHandlerTests.swift b/auth0_flutter/example/ios/Tests/AuthAPI/AuthAPICustomTokenExchangeMethodHandlerTests.swift new file mode 100644 index 000000000..6f34ec019 --- /dev/null +++ b/auth0_flutter/example/ios/Tests/AuthAPI/AuthAPICustomTokenExchangeMethodHandlerTests.swift @@ -0,0 +1,167 @@ +import XCTest +import Auth0 + +@testable import auth0_flutter + +fileprivate typealias Argument = AuthAPICustomTokenExchangeMethodHandler.Argument + +class AuthAPICustomTokenExchangeMethodHandlerTests: XCTestCase { + var spy: SpyAuthentication! + var sut: AuthAPICustomTokenExchangeMethodHandler! + + override func setUpWithError() throws { + spy = SpyAuthentication() + sut = AuthAPICustomTokenExchangeMethodHandler(client: spy) + } +} + +// MARK: - Required Arguments Error + +extension AuthAPICustomTokenExchangeMethodHandlerTests { + func testProducesErrorWhenRequiredArgumentsAreMissing() { + let keys: [Argument] = [.subjectToken, .subjectTokenType] + let expectations = keys.map { expectation(description: "\($0.rawValue) is missing") } + for (argument, currentExpectation) in zip(keys, expectations) { + sut.handle(with: arguments(without: argument)) { result in + assert(result: result, isError: .requiredArgumentMissing(argument.rawValue)) + currentExpectation.fulfill() + } + } + wait(for: expectations) + } +} + +// MARK: - Successful Result + +extension AuthAPICustomTokenExchangeMethodHandlerTests { + func testCallsCustomTokenExchange() { + let expectation = self.expectation(description: "Called customTokenExchange") + spy.onCustomTokenExchange = { subjectToken, subjectTokenType, audience, scope, organization in + XCTAssertEqual(subjectToken, "existing-token") + XCTAssertEqual(subjectTokenType, "http://acme.com/legacy-token") + XCTAssertEqual(audience, "https://example.com/api") + XCTAssertEqual(scope, "openid profile email") + XCTAssertNil(organization) + expectation.fulfill() + } + sut.handle(with: arguments()) { _ in } + wait(for: [expectation]) + } + + func testReturnsCredentialsOnSuccess() { + let expectation = self.expectation(description: "Returned credentials") + let credentials = Credentials( + accessToken: "access-token", + tokenType: "bearer", + idToken: "id-token", + refreshToken: "refresh-token", + expiresIn: Date(timeIntervalSinceNow: 3600), + scope: "openid profile email" + ) + spy.onCustomTokenExchange = { _, _, _, _, _ in + return self.spy.request(returning: credentials) + } + sut.handle(with: arguments()) { result in + let values = result as? [String: Any] + XCTAssertNotNil(values) + XCTAssertEqual(values?[CredentialsProperty.accessToken.rawValue] as? String, "access-token") + XCTAssertEqual(values?[CredentialsProperty.idToken.rawValue] as? String, "id-token") + XCTAssertEqual(values?[CredentialsProperty.refreshToken.rawValue] as? String, "refresh-token") + XCTAssertEqual(values?[CredentialsProperty.tokenType.rawValue] as? String, "bearer") + expectation.fulfill() + } + wait(for: [expectation]) + } +} + +// MARK: - Optional Parameters + +extension AuthAPICustomTokenExchangeMethodHandlerTests { + func testWorksWithoutAudience() { + let expectation = self.expectation(description: "Called without audience") + spy.onCustomTokenExchange = { _, _, audience, _, _ in + XCTAssertNil(audience) + expectation.fulfill() + } + sut.handle(with: arguments(without: .audience)) { _ in } + wait(for: [expectation]) + } + + + + func testWorksWithoutOrganization() { + let expectation = self.expectation(description: "Called without organization") + spy.onCustomTokenExchange = { _, _, _, _, organization in + XCTAssertNil(organization) + expectation.fulfill() + } + sut.handle(with: arguments(without: .organization)) { _ in } + wait(for: [expectation]) + } + + func testIncludesOrganizationWhenProvided() { + let expectation = self.expectation(description: "Called with organization") + spy.onCustomTokenExchange = { subjectToken, subjectTokenType, audience, scope, organization in + XCTAssertEqual(subjectToken, "existing-token") + XCTAssertEqual(subjectTokenType, "http://acme.com/legacy-token") + XCTAssertEqual(audience, "https://example.com/api") + XCTAssertEqual(scope, "openid profile email") + XCTAssertEqual(organization, "org_abc123") + expectation.fulfill() + } + var args = arguments() + args[Argument.organization.rawValue] = "org_abc123" + sut.handle(with: args) { _ in } + wait(for: [expectation]) + } +} + +// MARK: - Error + +extension AuthAPICustomTokenExchangeMethodHandlerTests { + func testReturnsAuthenticationErrorOnFailure() { + let expectation = self.expectation(description: "Returned error") + let authError = AuthenticationError( + info: ["error": "invalid_grant", "error_description": "Invalid token"] + ) + spy.onCustomTokenExchange = { _, _, _, _ in + return self.spy.request(failing: authError) + } + sut.handle(with: arguments()) { result in + assert(result: result, isAuthenticationError: authError) + expectation.fulfill() + } + wait(for: [expectation]) + } +} + +// MARK: - Helpers + +fileprivate extension AuthAPICustomTokenExchangeMethodHandlerTests { + func arguments(without key: Argument? = nil) -> [String: Any] { + var args: [String: Any] = [ + Argument.subjectToken.rawValue: "existing-token", + Argument.subjectTokenType.rawValue: "http://acme.com/legacy-token", + Argument.audience.rawValue: "https://example.com/api", + Argument.scopes.rawValue: ["openid", "profile", "email"] + ] + if let key = key { + args.removeValue(forKey: key.rawValue) + } + return args + } +} +// MARK: - Spy Extension + +fileprivate extension SpyAuthentication { + var onCustomTokenExchange: ((String, String, String?, String?, String?) -> Request)? + + func customTokenExchange(subjectToken: String, + subjectTokenType: String, + audience: String?, + scope: String?, + organization: String?) -> Request { + return onCustomTokenExchange?(subjectToken, subjectTokenType, audience, scope, organization) ?? request() + } +} } +} diff --git a/auth0_flutter/example/ios/Tests/AuthAPI/AuthAPIHandlerTests.swift b/auth0_flutter/example/ios/Tests/AuthAPI/AuthAPIHandlerTests.swift index d6119e0e9..fece4af5a 100644 --- a/auth0_flutter/example/ios/Tests/AuthAPI/AuthAPIHandlerTests.swift +++ b/auth0_flutter/example/ios/Tests/AuthAPI/AuthAPIHandlerTests.swift @@ -115,6 +115,7 @@ extension AuthAPIHandlerTests { .signup: AuthAPISignupMethodHandler.self, .userInfo: AuthAPIUserInfoMethodHandler.self, .renew: AuthAPIRenewMethodHandler.self, + .customTokenExchange: AuthAPICustomTokenExchangeMethodHandler.self, .resetPassword: AuthAPIResetPasswordMethodHandler.self ] methodHandlers.forEach { method, methodHandler in diff --git a/auth0_flutter/ios/Classes/AuthAPI/AuthAPICustomTokenExchangeMethodHandler.swift b/auth0_flutter/ios/Classes/AuthAPI/AuthAPICustomTokenExchangeMethodHandler.swift new file mode 120000 index 000000000..3bb2c2d3d --- /dev/null +++ b/auth0_flutter/ios/Classes/AuthAPI/AuthAPICustomTokenExchangeMethodHandler.swift @@ -0,0 +1 @@ +../../../darwin/Classes/AuthAPI/AuthAPICustomTokenExchangeMethodHandler.swift \ No newline at end of file diff --git a/auth0_flutter/ios/auth0_flutter.podspec b/auth0_flutter/ios/auth0_flutter.podspec index 16aadb281..169f8f050 100644 --- a/auth0_flutter/ios/auth0_flutter.podspec +++ b/auth0_flutter/ios/auth0_flutter.podspec @@ -19,7 +19,7 @@ Pod::Spec.new do |s| s.osx.deployment_target = '11.0' s.osx.dependency 'FlutterMacOS' - s.dependency 'Auth0', '2.14.0' + s.dependency 'Auth0', '2.16.2' s.dependency 'JWTDecode', '3.3.0' s.dependency 'SimpleKeychain', '1.3.0' diff --git a/auth0_flutter/lib/auth0_flutter_web.dart b/auth0_flutter/lib/auth0_flutter_web.dart index 792ea4926..d12e14c6d 100644 --- a/auth0_flutter/lib/auth0_flutter_web.dart +++ b/auth0_flutter/lib/auth0_flutter_web.dart @@ -274,6 +274,98 @@ class Auth0Web { cacheMode: cacheMode, parameters: parameters)); + /// Exchanges an external subject token for Auth0 access and ID tokens using + /// RFC 8693 Token Exchange. + /// + /// This method implements the OAuth 2.0 Token Exchange flow, allowing you to + /// exchange a token from an external provider for Auth0 tokens. This is useful + /// when integrating with external identity providers or custom authentication + /// systems. + /// + /// **Parameters:** + /// + /// * [subjectToken] (required) - The token being exchanged from the external + /// provider. For example, this might be a JWT from your custom authentication + /// system or another identity provider. + /// + /// * [subjectTokenType] (required) - A URI identifying the type of the + /// subject token according to RFC 8693. Must be a namespaced URI under your + /// organization's control. + /// + /// **Forbidden patterns:** + /// - `^urn:ietf:params:oauth:*` (IETF reserved) + /// - `^https://auth0.com/*` (Auth0 reserved) + /// - `^urn:auth0:*` (Auth0 reserved) + /// + /// **Example:** `urn:acme:legacy-system-token` + /// + /// * [audience] - Optional API identifier for which you want to receive an + /// access token. Must match exactly with an API identifier configured in + /// your Auth0 tenant. If not provided, falls back to the client's default audience. + /// + /// * [scopes] - Optional set of scopes to request. + /// These scopes determine what permissions the resulting tokens will have. + /// Subject to API authorization policies configured in Auth0. + /// + /// * [organizationId] - Optional organization ID or name to associate the + /// token exchange with a specific organization context. The organization ID + /// will be present in the access token payload. + /// + /// **Returns** a [Credentials] object containing: + /// * `accessToken` - The new Auth0 access token + /// * `idToken` - The Auth0 ID token with user information + /// * `expiresAt` - When the access token expires + /// * `scopes` - The granted scopes + /// * `refreshToken` - Optional refresh token (if offline_access scope was requested) + /// + /// **Requirements:** + /// + /// 1. Configure a Token Exchange profile in your Auth0 Dashboard + /// 2. Implement validation logic in an Auth0 Action to verify the external token + /// 3. Grant your Auth0 application the `urn:auth0:oauth2:grant-type:token-exchange` permission + /// + /// **Example:** + /// + /// ```dart + /// try { + /// final credentials = await auth0Web.customTokenExchange( + /// subjectToken: externalToken, + /// subjectTokenType: 'urn:acme:legacy-system-token', + /// audience: 'https://myapi.example.com', + /// scopes: {'openid', 'profile', 'email', 'read:data'}, + /// ); + /// print('Access token: ${credentials.accessToken}'); + /// } catch (e) { + /// print('Token exchange failed: $e'); + /// } + /// ``` + /// + /// **Throws** a [WebException] if: + /// * The subject token is invalid or expired + /// * Token exchange is not properly configured in Auth0 + /// * The external token fails validation in your Auth0 Action + /// * Network issues prevent the exchange request + /// + /// See also: + /// * [Token Exchange Documentation](https://auth0.com/docs/authenticate/custom-token-exchange) + /// * [RFC 8693 Specification](https://tools.ietf.org/html/rfc8693) + Future customTokenExchange({ + required final String subjectToken, + required final String subjectTokenType, + final String? audience, + final Set? scopes, + final String? organizationId, + }) => + Auth0FlutterWebPlatform.instance.customTokenExchange( + ExchangeTokenOptions( + subjectToken: subjectToken, + subjectTokenType: subjectTokenType, + audience: audience, + scopes: scopes, + organizationId: organizationId, + ), + ); + /// Indicates whether a user is currently authenticated. Future hasValidCredentials() => Auth0FlutterWebPlatform.instance.hasValidCredentials(); diff --git a/auth0_flutter/lib/src/mobile/authentication_api.dart b/auth0_flutter/lib/src/mobile/authentication_api.dart index 9e2a639fe..15599d083 100644 --- a/auth0_flutter/lib/src/mobile/authentication_api.dart +++ b/auth0_flutter/lib/src/mobile/authentication_api.dart @@ -349,6 +349,59 @@ class AuthenticationApi { scopes: scopes, parameters: parameters))); + /// Performs a custom token exchange to obtain Auth0 credentials using an + /// existing identity provider token. + /// + /// This method allows you to exchange tokens from external identity providers + /// for Auth0 tokens, enabling seamless integration with existing authentication + /// systems. + /// + /// ## Endpoint + /// https://auth0.com/docs/api/authentication#token-exchange + /// + /// ## Notes + /// + /// * [subjectToken] is the token obtained from the external identity provider. + /// * [subjectTokenType] specifies the format of the subject token (e.g., + /// 'http://acme.com/legacy-token'). + /// * [audience] relates to the API Identifier you want to reference in your + /// access tokens. See [API settings](https://auth0.com/docs/get-started/apis/api-settings) + /// to learn more. + /// * [scopes] defaults to `openid profile email`. You can override this to + /// specify a different set of scopes. + /// + /// ## Usage example + /// + /// ```dart + /// final result = await auth0.api.customTokenExchange( + /// subjectToken: 'existing-identity-provider-token', + /// subjectTokenType: 'http://acme.com/legacy-token', + /// audience: 'https://my-api.example.com', + /// scopes: {'openid', 'profile', 'email'}, + /// organization: 'org_abc123' + /// ); + /// + /// final accessToken = result.accessToken; + /// ``` + /// + /// ## Further reading + /// + /// * [Custom Token Exchange Documentation](https://auth0.com/docs/authenticate/custom-token-exchange) + Future customTokenExchange({ + required final String subjectToken, + required final String subjectTokenType, + final String? audience, + final Set scopes = const {'openid', 'profile', 'email'}, + final String? organization, + }) => + Auth0FlutterAuthPlatform.instance.customTokenExchange(_createApiRequest( + AuthCustomTokenExchangeOptions( + subjectToken: subjectToken, + subjectTokenType: subjectTokenType, + audience: audience, + scopes: scopes, + organization: organization))); + /// Initiates a reset of password of the user with the specific [email] /// address in the specific [connection]. /// diff --git a/auth0_flutter/lib/src/web/auth0_flutter_plugin_real.dart b/auth0_flutter/lib/src/web/auth0_flutter_plugin_real.dart index f556b1c7c..7b3d9492f 100644 --- a/auth0_flutter/lib/src/web/auth0_flutter_plugin_real.dart +++ b/auth0_flutter/lib/src/web/auth0_flutter_plugin_real.dart @@ -9,6 +9,7 @@ import 'auth0_flutter_web_platform_proxy.dart'; import 'extensions/client_options_extensions.dart'; import 'extensions/credentials_extension.dart'; import 'extensions/credentials_options_extension.dart'; +import 'extensions/exchange_token_options_extension.dart'; import 'extensions/logout_options.extension.dart'; import 'extensions/web_exception_extensions.dart'; import 'js_interop.dart' as interop; @@ -159,6 +160,19 @@ class Auth0FlutterPlugin extends Auth0FlutterWebPlatform { } } + @override + Future customTokenExchange( + final ExchangeTokenOptions options) async { + final client = _ensureClient(); + try { + final result = + await client.exchangeToken(options.toInteropExchangeTokenOptions()); + return CredentialsExtension.fromWeb(result); + } catch (e) { + throw WebExceptionExtension.fromJsObject(JSObject.fromInteropObject(e)); + } + } + @override Future hasValidCredentials() => clientProxy!.isAuthenticated(); diff --git a/auth0_flutter/lib/src/web/auth0_flutter_web_platform_proxy.dart b/auth0_flutter/lib/src/web/auth0_flutter_web_platform_proxy.dart index 55d993094..06f109cef 100644 --- a/auth0_flutter/lib/src/web/auth0_flutter_web_platform_proxy.dart +++ b/auth0_flutter/lib/src/web/auth0_flutter_web_platform_proxy.dart @@ -21,6 +21,9 @@ class Auth0FlutterWebClientProxy { [final GetTokenSilentlyOptions? options]) => JSPromiseToFuture(client.getTokenSilently(options)).toDart; + Future exchangeToken(final ExchangeTokenOptions options) => + JSPromiseToFuture(client.exchangeToken(options)).toDart; + Future handleRedirectCallback([final String? url]) { // Omit the url if it is not provided, so that the default argument is used. if (url == null) { diff --git a/auth0_flutter/lib/src/web/extensions/exchange_token_options_extension.dart b/auth0_flutter/lib/src/web/extensions/exchange_token_options_extension.dart new file mode 100644 index 000000000..025e54b04 --- /dev/null +++ b/auth0_flutter/lib/src/web/extensions/exchange_token_options_extension.dart @@ -0,0 +1,17 @@ +import 'package:auth0_flutter_platform_interface/auth0_flutter_platform_interface.dart'; +import '../js_interop.dart' as interop; +import '../js_interop_utils.dart'; + +extension ExchangeTokenOptionsExtension on ExchangeTokenOptions { + interop.ExchangeTokenOptions toInteropExchangeTokenOptions() { + final scopeString = scopes?.isNotEmpty == true ? scopes!.join(' ') : null; + + return JsInteropUtils.stripNulls(interop.ExchangeTokenOptions( + subject_token: subjectToken, + subject_token_type: subjectTokenType, + audience: audience, + scope: scopeString, + organization: organizationId, + )); + } +} diff --git a/auth0_flutter/lib/src/web/js_interop.dart b/auth0_flutter/lib/src/web/js_interop.dart index d1adc671c..915c98eed 100644 --- a/auth0_flutter/lib/src/web/js_interop.dart +++ b/auth0_flutter/lib/src/web/js_interop.dart @@ -186,6 +186,24 @@ extension type PopupLoginOptions._(JSObject _) implements JSObject { }); } +@JS() +@anonymous +extension type ExchangeTokenOptions._(JSObject _) implements JSObject { + external String get subject_token; + external String get subject_token_type; + external String? get audience; + external String? get scope; + external String? get organization; + + external factory ExchangeTokenOptions({ + required final String subject_token, + required final String subject_token_type, + final String? audience, + final String? scope, + final String? organization, + }); +} + @JS() @anonymous extension type PopupConfigOptions._(JSObject _) implements JSObject { @@ -215,6 +233,9 @@ extension type Auth0Client._(JSObject _) implements JSObject { external JSPromise getTokenSilently([ final GetTokenSilentlyOptions? options, ]); + external JSPromise exchangeToken( + final ExchangeTokenOptions options, + ); external JSPromise isAuthenticated(); external JSPromise logout([final LogoutOptions? logoutParams]); } diff --git a/auth0_flutter/macos/Classes/AuthAPI/AuthAPICustomTokenExchangeMethodHandler.swift b/auth0_flutter/macos/Classes/AuthAPI/AuthAPICustomTokenExchangeMethodHandler.swift new file mode 120000 index 000000000..3bb2c2d3d --- /dev/null +++ b/auth0_flutter/macos/Classes/AuthAPI/AuthAPICustomTokenExchangeMethodHandler.swift @@ -0,0 +1 @@ +../../../darwin/Classes/AuthAPI/AuthAPICustomTokenExchangeMethodHandler.swift \ No newline at end of file diff --git a/auth0_flutter/macos/auth0_flutter.podspec b/auth0_flutter/macos/auth0_flutter.podspec index 16aadb281..169f8f050 100644 --- a/auth0_flutter/macos/auth0_flutter.podspec +++ b/auth0_flutter/macos/auth0_flutter.podspec @@ -19,7 +19,7 @@ Pod::Spec.new do |s| s.osx.deployment_target = '11.0' s.osx.dependency 'FlutterMacOS' - s.dependency 'Auth0', '2.14.0' + s.dependency 'Auth0', '2.16.2' s.dependency 'JWTDecode', '3.3.0' s.dependency 'SimpleKeychain', '1.3.0' diff --git a/auth0_flutter/test/web/auth0_flutter_web_test.dart b/auth0_flutter/test/web/auth0_flutter_web_test.dart index 69d2ab9b8..477da1084 100644 --- a/auth0_flutter/test/web/auth0_flutter_web_test.dart +++ b/auth0_flutter/test/web/auth0_flutter_web_test.dart @@ -413,6 +413,159 @@ void main() { e.message == 'test exception'))); }); + group('customTokenExchange', () { + test('customTokenExchange is called with required parameters and succeeds', + () async { + when(mockClientProxy.exchangeToken(any)) + .thenAnswer((final _) => Future.value(webCredentials)); + + final result = await auth0.customTokenExchange( + subjectToken: 'external-token-123', + subjectTokenType: 'urn:example:external-token'); + + expect(result.accessToken, jwt); + expect(result.idToken, jwt); + expect(result.refreshToken, jwt); + expect(result.user.sub, jwtPayload['sub']); + expect(result.scopes, {'openid', 'read_messages'}); + + final options = + verify(mockClientProxy.exchangeToken(captureAny)).captured.first; + expect(options.subject_token, 'external-token-123'); + expect(options.subject_token_type, 'urn:example:external-token'); + expect(options.audience, null); + expect(options.scope, null); + expect(options.organization, null); + }); + + test('customTokenExchange is called with all optional parameters', + () async { + when(mockClientProxy.exchangeToken(argThat(anything))) + .thenAnswer((final _) => Future.value(webCredentials)); + + await auth0.customTokenExchange( + subjectToken: 'external-token-456', + subjectTokenType: 'urn:example:custom-token', + audience: 'https://api.example.com', + scopes: {'openid', 'profile', 'email'}, + organizationId: 'org_abc123'); + + final options = + verify(mockClientProxy.exchangeToken(captureAny)).captured.first; + expect(options.subject_token, 'external-token-456'); + expect(options.subject_token_type, 'urn:example:custom-token'); + expect(options.audience, 'https://api.example.com'); + expect(options.scope, 'openid profile email'); + expect(options.organization, 'org_abc123'); + }); + + test('customTokenExchange handles empty scopes correctly', () async { + when(mockClientProxy.exchangeToken(any)) + .thenAnswer((final _) => Future.value(webCredentials)); + + await auth0.customTokenExchange( + subjectToken: 'token', + subjectTokenType: 'urn:example:token', + scopes: {}); + + final options = + verify(mockClientProxy.exchangeToken(captureAny)).captured.first; + expect(options.scope, null); + }); + + test('customTokenExchange throws WebException on error', () async { + when(mockClientProxy.exchangeToken(any)) + .thenThrow(createJsException('invalid_token', 'Token is invalid')); + + expect( + () async => auth0.customTokenExchange( + subjectToken: 'invalid-token', + subjectTokenType: 'urn:example:token'), + throwsA(predicate((final e) => + e is WebException && + e.code == 'invalid_token' && + e.message == 'Token is invalid'))); + }); + + test('customTokenExchange throws WebException with specific error codes', + () async { + final errorCases = [ + {'code': 'invalid_grant', 'message': 'Invalid grant type'}, + {'code': 'unauthorized_client', 'message': 'Client not authorized'}, + {'code': 'access_denied', 'message': 'Access denied'}, + ]; + + for (final errorCase in errorCases) { + when(mockClientProxy.exchangeToken(any)) + .thenThrow(createJsException(errorCase['code']!, errorCase['message']!)); + + await expectLater( + auth0.customTokenExchange( + subjectToken: 'token', subjectTokenType: 'urn:example:token'), + throwsA(predicate((final e) => + e is WebException && + e.code == 'AUTHENTICATION_ERROR' && + e.message == errorCase['message']))); + + reset(mockClientProxy); + } + }); + + test('customTokenExchange returns credentials with correct scopes', + () async { + final customScopeCredentials = interop.WebCredentials( + access_token: jwt, + id_token: jwt, + refresh_token: jwt, + scope: 'openid profile email read:data write:data', + expires_in: 0.toJS); + + when(mockClientProxy.exchangeToken(any)) + .thenAnswer((final _) => Future.value(customScopeCredentials)); + + final result = await auth0.customTokenExchange( + subjectToken: 'token', + subjectTokenType: 'urn:example:token', + scopes: {'openid', 'profile', 'email', 'read:data', 'write:data'}); + + expect(result.scopes, + {'openid', 'profile', 'email', 'read:data', 'write:data'}); + }); + + test('customTokenExchange works without refresh token', () async { + final credentialsNoRefresh = interop.WebCredentials( + access_token: jwt, + id_token: jwt, + scope: 'openid', + expires_in: 0.toJS); + + when(mockClientProxy.exchangeToken(any)) + .thenAnswer((final _) => Future.value(credentialsNoRefresh)); + + final result = await auth0.customTokenExchange( + subjectToken: 'token', subjectTokenType: 'urn:example:token'); + + expect(result.accessToken, jwt); + expect(result.idToken, jwt); + expect(result.refreshToken, null); + }); + + test('customTokenExchange converts JS credentials to Dart Credentials', + () async { + when(mockClientProxy.exchangeToken(any)) + .thenAnswer((final _) => Future.value(webCredentials)); + + final result = await auth0.customTokenExchange( + subjectToken: 'token', subjectTokenType: 'urn:example:token'); + + expect(result, isA()); + expect(result.accessToken, isNotEmpty); + expect(result.idToken, isNotEmpty); + expect(result.user, isA()); + expect(result.expiresAt, isA()); + }); + }); + group('invitationUrl handling', () { const fullInvitationUrl = 'https://my-tenant.auth0.com/login/invitation?invitation=abc-123&organization=org_xyz'; diff --git a/auth0_flutter/test/web/auth0_flutter_web_test.mocks.dart b/auth0_flutter/test/web/auth0_flutter_web_test.mocks.dart index 64c2514ca..8b7ba1742 100644 --- a/auth0_flutter/test/web/auth0_flutter_web_test.mocks.dart +++ b/auth0_flutter/test/web/auth0_flutter_web_test.mocks.dart @@ -135,6 +135,23 @@ class MockAuth0FlutterWebClientProxy extends _i1.Mock )) as _i2.WebCredentials), ) as _i4.Future<_i2.WebCredentials>); + @override + _i4.Future<_i2.WebCredentials> exchangeToken(_i2.ExchangeTokenOptions? options) => + (super.noSuchMethod( + Invocation.method( + #exchangeToken, + [options], + ), + returnValue: _i4.Future<_i2.WebCredentials>.value( + createJSInteropWrapper<_FakeWebCredentials_1>(_FakeWebCredentials_1( + this, + Invocation.method( + #exchangeToken, + [options], + ), + )) as _i2.WebCredentials), + ) as _i4.Future<_i2.WebCredentials>); + @override _i4.Future<_i2.RedirectLoginResult> handleRedirectCallback([String? url]) => (super.noSuchMethod( diff --git a/auth0_flutter/test/web/extensions/exchange_token_options_extension_test.dart b/auth0_flutter/test/web/extensions/exchange_token_options_extension_test.dart new file mode 100644 index 000000000..02ebf9149 --- /dev/null +++ b/auth0_flutter/test/web/extensions/exchange_token_options_extension_test.dart @@ -0,0 +1,71 @@ +@Tags(['browser']) + +import 'package:auth0_flutter/src/web/extensions/exchange_token_options_extension.dart'; +import 'package:auth0_flutter_platform_interface/auth0_flutter_platform_interface.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('ExchangeTokenOptionsExtension', () { + test('converts ExchangeTokenOptions with required fields only', () { + final options = ExchangeTokenOptions( + subjectToken: 'external-token-123', + subjectTokenType: 'urn:acme:legacy-token', + ); + + final result = options.toInteropExchangeTokenOptions(); + + expect(result.subject_token, 'external-token-123'); + expect(result.subject_token_type, + 'urn:acme:legacy-token'); + expect(result.audience, isNull); + expect(result.scope, isNull); + expect(result.organization, isNull); + }); + + test('converts ExchangeTokenOptions with all fields', () { + final options = ExchangeTokenOptions( + subjectToken: 'external-token-456', + subjectTokenType: 'urn:example:custom-token', + audience: 'https://myapi.example.com', + scopes: {'openid', 'profile', 'email'}, + organizationId: 'org_abc123', + ); + + final result = options.toInteropExchangeTokenOptions(); + + expect(result.subject_token, 'external-token-456'); + expect(result.subject_token_type, 'urn:example:custom-token'); + expect(result.audience, 'https://myapi.example.com'); + expect(result.scope, 'openid profile email'); + expect(result.organization, 'org_abc123'); + }); + + test('converts empty scopes to null', () { + final options = ExchangeTokenOptions( + subjectToken: 'token', + subjectTokenType: 'type', + scopes: {}, + ); + + final result = options.toInteropExchangeTokenOptions(); + + expect(result.scope, isNull); + }); + + test('joins multiple scopes with spaces', () { + final options = ExchangeTokenOptions( + subjectToken: 'token', + subjectTokenType: 'type', + scopes: {'read:data', 'write:data', 'delete:data'}, + ); + + final result = options.toInteropExchangeTokenOptions(); + + // Set order is not guaranteed, but all should be present + expect(result.scope, contains('read:data')); + expect(result.scope, contains('write:data')); + expect(result.scope, contains('delete:data')); + expect(result.scope?.split(' ').length, 3); + }); + }); +} diff --git a/auth0_flutter_platform_interface/lib/auth0_flutter_platform_interface.dart b/auth0_flutter_platform_interface/lib/auth0_flutter_platform_interface.dart index a65820a78..64fa64777 100644 --- a/auth0_flutter_platform_interface/lib/auth0_flutter_platform_interface.dart +++ b/auth0_flutter_platform_interface/lib/auth0_flutter_platform_interface.dart @@ -1,5 +1,6 @@ export 'src/account.dart'; export 'src/auth/api_exception.dart'; +export 'src/auth/auth_custom_token_exchange_options.dart'; export 'src/auth/auth_dpop_headers_options.dart'; export 'src/auth/auth_login_code_options.dart'; export 'src/auth/auth_login_options.dart'; @@ -49,6 +50,7 @@ export 'src/web/cache_location.dart'; export 'src/web/cache_mode.dart'; export 'src/web/client_options.dart'; export 'src/web/credentials_options.dart'; +export 'src/web/exchange_token_options.dart'; export 'src/web/logout_options.dart'; export 'src/web/popup_login_options.dart'; export 'src/web/web_exception.dart'; diff --git a/auth0_flutter_platform_interface/lib/src/auth/auth_custom_token_exchange_options.dart b/auth0_flutter_platform_interface/lib/src/auth/auth_custom_token_exchange_options.dart new file mode 100644 index 000000000..174eee47e --- /dev/null +++ b/auth0_flutter_platform_interface/lib/src/auth/auth_custom_token_exchange_options.dart @@ -0,0 +1,26 @@ +import '../request/request_options.dart'; + +class AuthCustomTokenExchangeOptions implements RequestOptions { + final String subjectToken; + final String subjectTokenType; + final String? audience; + final Set scopes; + final String? organization; + + const AuthCustomTokenExchangeOptions({ + required this.subjectToken, + required this.subjectTokenType, + this.audience, + this.scopes = const {}, + this.organization, + }); + + @override + Map toMap() => { + 'subjectToken': subjectToken, + 'subjectTokenType': subjectTokenType, + if (audience != null) 'audience': audience, + 'scopes': scopes.toList(), + if (organization != null) 'organization': organization, + }; +} diff --git a/auth0_flutter_platform_interface/lib/src/auth0_flutter_auth_platform.dart b/auth0_flutter_platform_interface/lib/src/auth0_flutter_auth_platform.dart index 0d0c913e2..155e2d7d7 100644 --- a/auth0_flutter_platform_interface/lib/src/auth0_flutter_auth_platform.dart +++ b/auth0_flutter_platform_interface/lib/src/auth0_flutter_auth_platform.dart @@ -1,6 +1,7 @@ // coverage:ignore-file import 'package:plugin_platform_interface/plugin_platform_interface.dart'; +import 'auth/auth_custom_token_exchange_options.dart'; import 'auth/auth_login_code_options.dart'; import 'auth/auth_login_options.dart'; import 'auth/auth_login_with_otp_options.dart'; @@ -76,6 +77,11 @@ abstract class Auth0FlutterAuthPlatform extends PlatformInterface { throw UnimplementedError('authRenewCredentials() has not been implemented'); } + Future customTokenExchange( + final ApiRequest request) { + throw UnimplementedError('customTokenExchange() has not been implemented'); + } + Future resetPassword( final ApiRequest request) { throw UnimplementedError('authResetPassword() has not been implemented'); diff --git a/auth0_flutter_platform_interface/lib/src/auth0_flutter_web_platform.dart b/auth0_flutter_platform_interface/lib/src/auth0_flutter_web_platform.dart index 43d7b0170..f2a4d2e46 100644 --- a/auth0_flutter_platform_interface/lib/src/auth0_flutter_web_platform.dart +++ b/auth0_flutter_platform_interface/lib/src/auth0_flutter_web_platform.dart @@ -40,6 +40,10 @@ abstract class Auth0FlutterWebPlatform extends PlatformInterface { throw UnimplementedError('web.credentials has not been implemented'); } + Future customTokenExchange(final ExchangeTokenOptions options) { + throw UnimplementedError('web.customTokenExchange has not been implemented'); + } + Future hasValidCredentials() { throw UnimplementedError( 'web.hasValidCredentials has not been implemented', diff --git a/auth0_flutter_platform_interface/lib/src/method_channel_auth0_flutter_auth.dart b/auth0_flutter_platform_interface/lib/src/method_channel_auth0_flutter_auth.dart index 38e335591..d81c1d9f7 100644 --- a/auth0_flutter_platform_interface/lib/src/method_channel_auth0_flutter_auth.dart +++ b/auth0_flutter_platform_interface/lib/src/method_channel_auth0_flutter_auth.dart @@ -1,6 +1,7 @@ import 'package:flutter/services.dart'; import 'auth/api_exception.dart'; +import 'auth/auth_custom_token_exchange_options.dart'; import 'auth/auth_login_code_options.dart'; import 'auth/auth_login_options.dart'; import 'auth/auth_login_with_otp_options.dart'; @@ -32,6 +33,7 @@ const String authLoginWithSmsCodeMethod = 'auth#loginWithPhoneNumber'; const String authUserInfoMethod = 'auth#userInfo'; const String authSignUpMethod = 'auth#signUp'; const String authRenewMethod = 'auth#renew'; +const String authCustomTokenExchangeMethod = 'auth#customTokenExchange'; const String authResetPasswordMethod = 'auth#resetPassword'; class MethodChannelAuth0FlutterAuth extends Auth0FlutterAuthPlatform { @@ -123,6 +125,17 @@ class MethodChannelAuth0FlutterAuth extends Auth0FlutterAuthPlatform { return Credentials.fromMap(result); } + @override + Future customTokenExchange( + final ApiRequest request) async { + final Map result = await invokeRequest( + method: authCustomTokenExchangeMethod, + request: request, + ); + + return Credentials.fromMap(result); + } + @override Future resetPassword( final ApiRequest request) async { diff --git a/auth0_flutter_platform_interface/lib/src/web/exchange_token_options.dart b/auth0_flutter_platform_interface/lib/src/web/exchange_token_options.dart new file mode 100644 index 000000000..c41906621 --- /dev/null +++ b/auth0_flutter_platform_interface/lib/src/web/exchange_token_options.dart @@ -0,0 +1,27 @@ +/// Options for custom token exchange on web platforms. +/// +/// This class encapsulates the parameters needed to exchange an external +/// token for Auth0 tokens using the OAuth 2.0 Token Exchange flow (RFC 8693). +/// +/// **Parameters:** +/// +/// * [subjectToken] - The external token to be exchanged (required) +/// * [subjectTokenType] - A URI that indicates the type of the subject token, +/// * [audience] - The API identifier for which the access token is requested (optional) +/// * [scopes] - Set of OAuth scopes to request (optional) +/// * [organizationId] - organization ID or name of the organization to authenticate with (optional) +class ExchangeTokenOptions { + final String subjectToken; + final String subjectTokenType; + final String? audience; + final Set? scopes; + final String? organizationId; + + ExchangeTokenOptions({ + required this.subjectToken, + required this.subjectTokenType, + this.audience, + this.scopes, + this.organizationId, + }); +} diff --git a/auth0_flutter_platform_interface/test/auth_custom_token_exchange_options_test.dart b/auth0_flutter_platform_interface/test/auth_custom_token_exchange_options_test.dart new file mode 100644 index 000000000..a871f1fb3 --- /dev/null +++ b/auth0_flutter_platform_interface/test/auth_custom_token_exchange_options_test.dart @@ -0,0 +1,100 @@ +import 'package:auth0_flutter_platform_interface/auth0_flutter_platform_interface.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('AuthCustomTokenExchangeOptions', () { + test('creates options with required parameters', () { + final options = AuthCustomTokenExchangeOptions( + subjectToken: 'existing-token', + subjectTokenType: 'http://acme.com/legacy-token', + ); + + expect(options.subjectToken, 'existing-token'); + expect(options.subjectTokenType, 'http://acme.com/legacy-token'); + expect(options.audience, isNull); + expect(options.scopes, isEmpty); + expect(options.organization, isNull); + }); + + test('creates options with all parameters', () { + final options = AuthCustomTokenExchangeOptions( + subjectToken: 'existing-token', + subjectTokenType: 'http://acme.com/legacy-token', + audience: 'https://example.com/api', + scopes: {'openid', 'profile', 'email'}, + organization: 'org_abc123', + ); + + expect(options.subjectToken, 'existing-token'); + expect(options.subjectTokenType, 'http://acme.com/legacy-token'); + expect(options.audience, 'https://example.com/api'); + expect(options.scopes, {'openid', 'profile', 'email'}); + expect(options.organization, 'org_abc123'); + }); + + test('toMap includes all properties', () { + final options = AuthCustomTokenExchangeOptions( + subjectToken: 'existing-token', + subjectTokenType: 'http://acme.com/legacy-token', + audience: 'https://example.com/api', + scopes: {'openid', 'profile', 'email'}, + organization: 'org_abc123', + ); + + final map = options.toMap(); + + expect(map['subjectToken'], 'existing-token'); + expect(map['subjectTokenType'], 'http://acme.com/legacy-token'); + expect(map['audience'], 'https://example.com/api'); + expect(map['scopes'], ['openid', 'profile', 'email']); + expect(map['organization'], 'org_abc123'); + }); + + test('toMap excludes null audience', () { + final options = AuthCustomTokenExchangeOptions( + subjectToken: 'existing-token', + subjectTokenType: 'http://acme.com/legacy-token', + ); + + final map = options.toMap(); + + expect(map.containsKey('audience'), isFalse); + }); + + test('toMap excludes null organization', () { + final options = AuthCustomTokenExchangeOptions( + subjectToken: 'existing-token', + subjectTokenType: 'http://acme.com/legacy-token', + ); + + final map = options.toMap(); + + expect(map.containsKey('organization'), isFalse); + }); + + test('toMap includes organization when provided', () { + final options = AuthCustomTokenExchangeOptions( + subjectToken: 'existing-token', + subjectTokenType: 'http://acme.com/legacy-token', + organization: 'org_abc123', + ); + + final map = options.toMap(); + + expect(map['organization'], 'org_abc123'); + expect(map.containsKey('organization'), isTrue); + }); + + test('toMap includes empty scopes', () { + final options = AuthCustomTokenExchangeOptions( + subjectToken: 'existing-token', + subjectTokenType: 'http://acme.com/legacy-token', + scopes: {}, + ); + + final map = options.toMap(); + + expect(map['scopes'], isEmpty); + }); + }); +} diff --git a/auth0_flutter_platform_interface/test/method_channel_auth0_flutter_auth_test.dart b/auth0_flutter_platform_interface/test/method_channel_auth0_flutter_auth_test.dart index 3b46f34c8..70340f594 100644 --- a/auth0_flutter_platform_interface/test/method_channel_auth0_flutter_auth_test.dart +++ b/auth0_flutter_platform_interface/test/method_channel_auth0_flutter_auth_test.dart @@ -870,6 +870,159 @@ void main() { }); }); + group('customTokenExchange', () { + test('calls the correct MethodChannel method', () async { + when(mocked.methodCallHandler(any)) + .thenAnswer((final _) async => MethodCallHandler.renewResult); + + await MethodChannelAuth0FlutterAuth().customTokenExchange( + ApiRequest( + account: const Account('test-domain', 'test-clientId'), + userAgent: UserAgent(name: 'test-name', version: 'test-version'), + options: const AuthCustomTokenExchangeOptions( + subjectToken: 'existing-token', + subjectTokenType: 'http://acme.com/legacy-token'))); + + expect( + verify(mocked.methodCallHandler(captureAny)).captured.single.method, + 'auth#customTokenExchange'); + }); + + test('correctly maps all properties to the method channel', () async { + when(mocked.methodCallHandler(any)) + .thenAnswer((final _) async => MethodCallHandler.renewResult); + + await MethodChannelAuth0FlutterAuth().customTokenExchange( + ApiRequest( + account: const Account('test-domain', 'test-clientId'), + userAgent: UserAgent(name: 'test-name', version: 'test-version'), + options: const AuthCustomTokenExchangeOptions( + subjectToken: 'existing-token', + subjectTokenType: 'http://acme.com/legacy-token', + audience: 'https://example.com/api', + scopes: {'openid', 'profile', 'email'}))); + + final verificationResult = + verify(mocked.methodCallHandler(captureAny)).captured.single; + expect(verificationResult.arguments['_account']['domain'], 'test-domain'); + expect(verificationResult.arguments['_account']['clientId'], + 'test-clientId'); + expect(verificationResult.arguments['_userAgent']['name'], 'test-name'); + expect(verificationResult.arguments['_userAgent']['version'], + 'test-version'); + expect( + verificationResult.arguments['subjectToken'], 'existing-token'); + expect(verificationResult.arguments['subjectTokenType'], + 'http://acme.com/legacy-token'); + expect(verificationResult.arguments['audience'], 'https://example.com/api'); + expect(verificationResult.arguments['scopes'], ['openid', 'profile', 'email']); + }); + + test( + 'correctly assigns default values to all non-required properties when missing', + () async { + when(mocked.methodCallHandler(any)) + .thenAnswer((final _) async => MethodCallHandler.renewResult); + + await MethodChannelAuth0FlutterAuth().customTokenExchange( + ApiRequest( + account: const Account('', ''), + userAgent: UserAgent(name: '', version: ''), + options: const AuthCustomTokenExchangeOptions( + subjectToken: 'existing-token', + subjectTokenType: 'http://acme.com/legacy-token'))); + final verificationResult = + verify(mocked.methodCallHandler(captureAny)).captured.single; + expect(verificationResult.arguments['scopes'], isEmpty); + expect(verificationResult.arguments.containsKey('audience'), isFalse); + expect(verificationResult.arguments.containsKey('organization'), isFalse); + }); + + test('correctly maps organization parameter when provided', () async { + when(mocked.methodCallHandler(any)) + .thenAnswer((final _) async => MethodCallHandler.renewResult); + + await MethodChannelAuth0FlutterAuth().customTokenExchange( + ApiRequest( + account: const Account('test-domain', 'test-clientId'), + userAgent: UserAgent(name: 'test-name', version: 'test-version'), + options: const AuthCustomTokenExchangeOptions( + subjectToken: 'existing-token', + subjectTokenType: 'http://acme.com/legacy-token', + organization: 'org_abc123'))); + + final verificationResult = + verify(mocked.methodCallHandler(captureAny)).captured.single; + expect(verificationResult.arguments['organization'], 'org_abc123'); + }); + + test('correctly returns the response from the Method Channel', () async { + when(mocked.methodCallHandler(any)) + .thenAnswer((final _) async => MethodCallHandler.renewResult); + + final result = await MethodChannelAuth0FlutterAuth().customTokenExchange( + ApiRequest( + account: const Account('test-domain', 'test-clientId'), + userAgent: UserAgent(name: 'test-name', version: 'test-version'), + options: const AuthCustomTokenExchangeOptions( + subjectToken: 'existing-token', + subjectTokenType: 'http://acme.com/legacy-token'))); + + expect(result.accessToken, MethodCallHandler.renewResult['accessToken']); + expect(result.idToken, MethodCallHandler.renewResult['idToken']); + expect( + result.refreshToken, MethodCallHandler.renewResult['refreshToken']); + expect(result.scopes, MethodCallHandler.renewResult['scopes']); + expect(result.expiresAt, + DateTime.parse(MethodCallHandler.renewResult['expiresAt'] as String)); + expect(result.user.name, + MethodCallHandler.renewResult['userProfile']['name']); + }); + + test('throws an ApiException when method channel returns null', () async { + when(mocked.methodCallHandler(any)).thenAnswer((final _) async => null); + + Future actual() async { + final result = + await MethodChannelAuth0FlutterAuth().customTokenExchange( + ApiRequest( + account: const Account('test-domain', 'test-clientId'), + userAgent: + UserAgent(name: 'test-name', version: 'test-version'), + options: const AuthCustomTokenExchangeOptions( + subjectToken: 'existing-token', + subjectTokenType: 'http://acme.com/legacy-token'))); + + return result; + } + + await expectLater(actual, throwsA(isA())); + }); + + test( + 'throws an ApiException when method channel throws a PlatformException', + () async { + when(mocked.methodCallHandler(any)) + .thenThrow(PlatformException(code: '123')); + + Future actual() async { + final result = + await MethodChannelAuth0FlutterAuth().customTokenExchange( + ApiRequest( + account: const Account('test-domain', 'test-clientId'), + userAgent: + UserAgent(name: 'test-name', version: 'test-version'), + options: const AuthCustomTokenExchangeOptions( + subjectToken: 'existing-token', + subjectTokenType: 'http://acme.com/legacy-token'))); + + return result; + } + + await expectLater(actual, throwsA(isA())); + }); + }); + group('userInfo', () { test('calls the correct MethodChannel method', () async { when(mocked.methodCallHandler(any))