From d335fe71cd4daffb0a201cc98c3b971b16117deb Mon Sep 17 00:00:00 2001 From: sanchitmehta94 Date: Wed, 14 Jan 2026 09:10:55 +0530 Subject: [PATCH 1/6] feat: add custom token exchange support across all platforms --- auth0_flutter/EXAMPLES.md | 46 +++ auth0_flutter/android/build.gradle | 2 +- .../auth0/auth0_flutter/Auth0FlutterPlugin.kt | 1 + .../CustomTokenExchangeApiRequestHandler.kt | 72 ++++ ...ustomTokenExchangeApiRequestHandlerTest.kt | 344 ++++++++++++++++++ ...hAPICustomTokenExchangeMethodHandler.swift | 53 +++ .../Classes/AuthAPI/AuthAPIHandler.swift | 2 + auth0_flutter/darwin/auth0_flutter.podspec | 2 +- ...ustomTokenExchangeMethodHandlerTests.swift | 192 ++++++++++ .../Tests/AuthAPI/AuthAPIHandlerTests.swift | 1 + auth0_flutter/ios/auth0_flutter.podspec | 2 +- auth0_flutter/lib/auth0_flutter_web.dart | 92 +++++ .../lib/src/mobile/authentication_api.dart | 58 +++ .../src/web/auth0_flutter_plugin_real.dart | 14 + .../web/auth0_flutter_web_platform_proxy.dart | 3 + .../exchange_token_options_extension.dart | 23 ++ auth0_flutter/lib/src/web/js_interop.dart | 21 ++ auth0_flutter/macos/auth0_flutter.podspec | 2 +- .../test/web/auth0_flutter_web_test.dart | 172 +++++++++ ...exchange_token_options_extension_test.dart | 71 ++++ .../lib/auth0_flutter_platform_interface.dart | 2 + .../auth_custom_token_exchange_options.dart | 29 ++ .../lib/src/auth0_flutter_auth_platform.dart | 6 + .../lib/src/auth0_flutter_web_platform.dart | 4 + .../method_channel_auth0_flutter_auth.dart | 13 + .../lib/src/web/exchange_token_options.dart | 17 + ...th_custom_token_exchange_options_test.dart | 107 ++++++ ...ethod_channel_auth0_flutter_auth_test.dart | 156 ++++++++ 28 files changed, 1503 insertions(+), 4 deletions(-) create mode 100644 auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/api/CustomTokenExchangeApiRequestHandler.kt create mode 100644 auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/api/CustomTokenExchangeApiRequestHandlerTest.kt create mode 100644 auth0_flutter/darwin/Classes/AuthAPI/AuthAPICustomTokenExchangeMethodHandler.swift create mode 100644 auth0_flutter/example/ios/Tests/AuthAPI/AuthAPICustomTokenExchangeMethodHandlerTests.swift create mode 100644 auth0_flutter/lib/src/web/extensions/exchange_token_options_extension.dart create mode 100644 auth0_flutter/test/web/extensions/exchange_token_options_extension_test.dart create mode 100644 auth0_flutter_platform_interface/lib/src/auth/auth_custom_token_exchange_options.dart create mode 100644 auth0_flutter_platform_interface/lib/src/web/exchange_token_options.dart create mode 100644 auth0_flutter_platform_interface/test/auth_custom_token_exchange_options_test.dart diff --git a/auth0_flutter/EXAMPLES.md b/auth0_flutter/EXAMPLES.md index c15c1e187..cfee4a71f 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,51 @@ 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 exchange tokens from external identity providers for Auth0 tokens. This is useful for migrating users from legacy systems or integrating with third-party identity providers. + +> **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:ietf:params:oauth:token-type:jwt', + audience: 'https://api.example.com', + scopes: {'openid', 'profile', 'email'}, + organization: 'org_abc123', // Optional + parameters: {'custom_param': 'value'} // Optional +); +``` + +
+ +
+ Web + +```dart +final credentials = await auth0Web.customTokenExchange( + subjectToken: 'external-idp-token', + subjectTokenType: 'urn:ietf:params:oauth:token-type:jwt', + audience: 'https://api.example.com', + scopes: {'openid', 'profile', 'email'}, + organizationId: 'org_abc123', // Optional + parameters: {'custom_param': 'value'} // Optional +); +``` + +
+ +**Required setup:** +1. Configure a Custom 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 + +> 💡 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..6b9652442 --- /dev/null +++ b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/api/CustomTokenExchangeApiRequestHandler.kt @@ -0,0 +1,72 @@ +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) + } + if (args["parameters"] is HashMap<*, *>) { + addParameters(args["parameters"] as Map) + } + 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..fbd1f1b9d --- /dev/null +++ b/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/api/CustomTokenExchangeApiRequestHandlerTest.kt @@ -0,0 +1,344 @@ +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:ietf:params:oauth:token-type:jwt") + 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:ietf:params:oauth:token-type:jwt" + ) + 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:ietf:params:oauth:token-type:jwt", + "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:ietf:params:oauth:token-type:jwt" + ) + 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:ietf:params:oauth:token-type:jwt", + "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 custom parameters when provided`() { + val customParams = hashMapOf("custom_param" to "value", "another_param" to "test") + val options = hashMapOf( + "subjectToken" to "external-token-abc", + "subjectTokenType" to "urn:ietf:params:oauth:token-type:jwt", + "parameters" to customParams + ) + 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", + null, + Date(), + "openid" + ) + + whenever(mockApi.customTokenExchange(any(), any(), isNull())).thenReturn(mockRequest) + whenever(mockRequest.addParameters(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).addParameters(customParams) + verify(mockResult).success(any()) + } + + @Test + fun `should include all optional parameters when provided`() { + val customParams = hashMapOf("org_id" to "org_123") + 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"), + "parameters" to customParams + ) + 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.addParameters(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).addParameters(customParams) + 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:ietf:params:oauth:token-type:jwt", + "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:ietf:params:oauth:token-type:jwt", + "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..a496812af --- /dev/null +++ b/auth0_flutter/darwin/Classes/AuthAPI/AuthAPICustomTokenExchangeMethodHandler.swift @@ -0,0 +1,53 @@ +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 parameters + 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] else { + return callback(FlutterError(from: .requiredArgumentMissing(Argument.scopes.rawValue))) + } + guard let parameters = arguments[Argument.parameters] as? [String: Any] else { + return callback(FlutterError(from: .requiredArgumentMissing(Argument.parameters.rawValue))) + } + + let audience = arguments[Argument.audience] as? String + let organization = arguments[Argument.organization] as? String + let scope = scopes.isEmpty ? "openid profile email" : scopes.asSpaceSeparatedString + + client + .customTokenExchange(subjectToken: subjectToken, + subjectTokenType: subjectTokenType, + audience: audience, + scope: scope, + organization: organization) + .parameters(parameters) + .start { + switch $0 { + case .success(let credentials): callback(self.result(from: credentials)) + case .failure(let error): callback(FlutterError(from: error)) + } + } + } +} 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..924dce056 --- /dev/null +++ b/auth0_flutter/example/ios/Tests/AuthAPI/AuthAPICustomTokenExchangeMethodHandlerTests.swift @@ -0,0 +1,192 @@ +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, .scopes, .parameters] + 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 testWorksWithEmptyScopes() { + let expectation = self.expectation(description: "Called with empty scopes") + spy.onCustomTokenExchange = { _, _, _, scope, _ in + XCTAssertNil(scope) + expectation.fulfill() + } + var args = arguments() + args[Argument.scopes.rawValue] = [] + sut.handle(with: args) { _ 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: - Additional Parameters + +extension AuthAPICustomTokenExchangeMethodHandlerTests { + func testCallsParametersWithCustomParameters() { + let expectation = self.expectation(description: "Called parameters") + spy.onParameters = { parameters in + XCTAssertTrue(parameters["test"] as? String == "test-123") + expectation.fulfill() + } + sut.handle(with: arguments()) { _ 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"], + Argument.parameters.rawValue: ["test": "test-123"] + ] + 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/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..ef50ba0bb 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. Common examples: + /// - `urn:ietf:params:oauth:token-type:jwt` for JWT tokens + /// - `urn:ietf:params:oauth:token-type:id_token` for OIDC ID tokens + /// - `urn:ietf:params:oauth:token-type:access_token` for OAuth access tokens + /// - Custom URNs like `urn:example:external-token` for custom token types + /// + /// * [audience] - Optional API identifier for which you want to receive an + /// access token. If not specified, uses the audience from [onLoad] configuration + /// or the default audience configured in your Auth0 application. + /// + /// * [scopes] - Optional set of scopes to request. Defaults to + /// `{'openid', 'profile', 'email'}`. These scopes determine what information + /// and permissions the resulting tokens will have. + /// + /// * [organizationId] - Optional organization ID or name to associate the + /// token exchange with a specific organization context. + /// + /// * [parameters] - Additional custom parameters to include in the token + /// exchange request. These can be processed by Auth0 Actions or Rules. + /// + /// **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:ietf:params:oauth:token-type:jwt', + /// 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/login/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, + final Map parameters = const {}, + }) => + Auth0FlutterWebPlatform.instance.customTokenExchange( + ExchangeTokenOptions( + subjectToken: subjectToken, + subjectTokenType: subjectTokenType, + audience: audience, + scopes: scopes ?? {'openid', 'profile', 'email'}, + organizationId: organizationId, + parameters: parameters, + ), + ); + /// 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..420067b00 100644 --- a/auth0_flutter/lib/src/mobile/authentication_api.dart +++ b/auth0_flutter/lib/src/mobile/authentication_api.dart @@ -349,6 +349,64 @@ 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. + /// * Arbitrary [parameters] can be specified and then picked up in a custom + /// Auth0 [Action](https://auth0.com/docs/customize/actions) or + /// [Rule](https://auth0.com/docs/customize/rules). + /// + /// ## 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, + final Map parameters = const {}, + }) => + Auth0FlutterAuthPlatform.instance.customTokenExchange(_createApiRequest( + AuthCustomTokenExchangeOptions( + subjectToken: subjectToken, + subjectTokenType: subjectTokenType, + audience: audience, + scopes: scopes, + organization: organization, + parameters: parameters))); + /// 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..647a58c62 --- /dev/null +++ b/auth0_flutter/lib/src/web/extensions/exchange_token_options_extension.dart @@ -0,0 +1,23 @@ +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 ? scopes.join(' ') : null; + + final options = JsInteropUtils.stripNulls(interop.ExchangeTokenOptions( + subject_token: subjectToken, + subject_token_type: subjectTokenType, + audience: audience, + scope: scopeString, + organization: organizationId, + )); + + // Add custom parameters if provided + if (parameters.isNotEmpty) { + JsInteropUtils.addCustomParams(options, parameters); + } + return options; + } +} 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/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..f8492e647 100644 --- a/auth0_flutter/test/web/auth0_flutter_web_test.dart +++ b/auth0_flutter/test/web/auth0_flutter_web_test.dart @@ -413,6 +413,178 @@ 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(any)) + .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 is called with custom parameters', () async { + when(mockClientProxy.exchangeToken(any)) + .thenAnswer((final _) => Future.value(webCredentials)); + + await auth0.customTokenExchange( + subjectToken: 'external-token-789', + subjectTokenType: 'urn:example:custom-token', + parameters: {'custom_param': 'value', 'device_id': 'mobile-123'}); + + final options = + verify(mockClientProxy.exchangeToken(captureAny)).captured.first; + expect(options.subject_token, 'external-token-789'); + expect(options.subject_token_type, 'urn:example:custom-token'); + // Verify custom parameters are added to the JS object + final jsObject = JSObject.fromInteropObject(options); + expect(jsObject.getProperty('custom_param'.toJS).toString(), 'value'); + expect(jsObject.getProperty('device_id'.toJS).toString(), 'mobile-123'); + }); + + 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']!)); + + expect( + () async => auth0.customTokenExchange( + subjectToken: 'token', subjectTokenType: 'urn:example:token'), + throwsA(predicate((final e) => + e is WebException && + e.code == errorCase['code'] && + 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/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..abca4d55a --- /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:ietf:params:oauth:token-type:jwt', + ); + + final result = options.toInteropExchangeTokenOptions(); + + expect(result.subject_token, 'external-token-123'); + expect(result.subject_token_type, + 'urn:ietf:params:oauth:token-type:jwt'); + 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..8e3ebca1f --- /dev/null +++ b/auth0_flutter_platform_interface/lib/src/auth/auth_custom_token_exchange_options.dart @@ -0,0 +1,29 @@ +import '../request/request_options.dart'; + +class AuthCustomTokenExchangeOptions implements RequestOptions { + final String subjectToken; + final String subjectTokenType; + final String? audience; + final Set scopes; + final String? organization; + final Map parameters; + + const AuthCustomTokenExchangeOptions({ + required this.subjectToken, + required this.subjectTokenType, + this.audience, + this.scopes = const {}, + this.organization, + this.parameters = const {}, + }); + + @override + Map toMap() => { + 'subjectToken': subjectToken, + 'subjectTokenType': subjectTokenType, + if (audience != null) 'audience': audience, + 'scopes': scopes.toList(), + if (organization != null) 'organization': organization, + 'parameters': parameters + }; +} 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..98e339412 --- /dev/null +++ b/auth0_flutter_platform_interface/lib/src/web/exchange_token_options.dart @@ -0,0 +1,17 @@ +class ExchangeTokenOptions { + final String subjectToken; + final String subjectTokenType; + final String? audience; + final Set scopes; + final String? organizationId; + final Map parameters; + + ExchangeTokenOptions({ + required this.subjectToken, + required this.subjectTokenType, + this.audience, + this.scopes = const {}, + this.organizationId, + this.parameters = const {}, + }); +} 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..63b9497a4 --- /dev/null +++ b/auth0_flutter_platform_interface/test/auth_custom_token_exchange_options_test.dart @@ -0,0 +1,107 @@ +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); + expect(options.parameters, isEmpty); + }); + + 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', + parameters: {'test': 'test-123'}, + ); + + 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'); + expect(options.parameters, {'test': 'test-123'}); + 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', + parameters: {'test': 'test-123'}, + ); + + 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'); + expect(map['parameters'], {'test': 'test-123'}); + 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 and parameters', () { + expect(map.containsKey('audience'), isFalse); + }); + + test('toMap includes empty scopes and parameters', () { + final options = AuthCustomTokenExchangeOptions( + subjectToken: 'existing-token', + subjectTokenType: 'http://acme.com/legacy-token', + scopes: {}, + parameters: {}, + ); + + final map = options.toMap(); + + expect(map['scopes'], isEmpty); + expect(map['parameters'], 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..cebf9c65c 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,162 @@ 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'}, + parameters: {'test': 'test-123'}))); + + 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']); + expect(verificationResult.arguments['parameters']['test'], 'test-123'); + }); + + 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['parameters'], 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)) From 7e8227da68bffc193e791d2396a9642b6dfa2aa6 Mon Sep 17 00:00:00 2001 From: sanchitmehta94 Date: Wed, 14 Jan 2026 09:46:25 +0530 Subject: [PATCH 2/6] fixed test cases --- .../test/web/auth0_flutter_web_test.dart | 18 +++++++++--------- ...uth_custom_token_exchange_options_test.dart | 8 ++++---- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/auth0_flutter/test/web/auth0_flutter_web_test.dart b/auth0_flutter/test/web/auth0_flutter_web_test.dart index f8492e647..40885682f 100644 --- a/auth0_flutter/test/web/auth0_flutter_web_test.dart +++ b/auth0_flutter/test/web/auth0_flutter_web_test.dart @@ -416,7 +416,7 @@ void main() { group('customTokenExchange', () { test('customTokenExchange is called with required parameters and succeeds', () async { - when(mockClientProxy.exchangeToken(any)) + when(mockClientProxy.exchangeToken(any())) .thenAnswer((final _) => Future.value(webCredentials)); final result = await auth0.customTokenExchange( @@ -440,7 +440,7 @@ void main() { test('customTokenExchange is called with all optional parameters', () async { - when(mockClientProxy.exchangeToken(any)) + when(mockClientProxy.exchangeToken(any())) .thenAnswer((final _) => Future.value(webCredentials)); await auth0.customTokenExchange( @@ -460,7 +460,7 @@ void main() { }); test('customTokenExchange is called with custom parameters', () async { - when(mockClientProxy.exchangeToken(any)) + when(mockClientProxy.exchangeToken(any())) .thenAnswer((final _) => Future.value(webCredentials)); await auth0.customTokenExchange( @@ -479,7 +479,7 @@ void main() { }); test('customTokenExchange handles empty scopes correctly', () async { - when(mockClientProxy.exchangeToken(any)) + when(mockClientProxy.exchangeToken(any())) .thenAnswer((final _) => Future.value(webCredentials)); await auth0.customTokenExchange( @@ -493,7 +493,7 @@ void main() { }); test('customTokenExchange throws WebException on error', () async { - when(mockClientProxy.exchangeToken(any)) + when(mockClientProxy.exchangeToken(any())) .thenThrow(createJsException('invalid_token', 'Token is invalid')); expect( @@ -515,7 +515,7 @@ void main() { ]; for (final errorCase in errorCases) { - when(mockClientProxy.exchangeToken(any)) + when(mockClientProxy.exchangeToken(any())) .thenThrow(createJsException(errorCase['code']!, errorCase['message']!)); expect( @@ -539,7 +539,7 @@ void main() { scope: 'openid profile email read:data write:data', expires_in: 0.toJS); - when(mockClientProxy.exchangeToken(any)) + when(mockClientProxy.exchangeToken(any())) .thenAnswer((final _) => Future.value(customScopeCredentials)); final result = await auth0.customTokenExchange( @@ -558,7 +558,7 @@ void main() { scope: 'openid', expires_in: 0.toJS); - when(mockClientProxy.exchangeToken(any)) + when(mockClientProxy.exchangeToken(any())) .thenAnswer((final _) => Future.value(credentialsNoRefresh)); final result = await auth0.customTokenExchange( @@ -571,7 +571,7 @@ void main() { test('customTokenExchange converts JS credentials to Dart Credentials', () async { - when(mockClientProxy.exchangeToken(any)) + when(mockClientProxy.exchangeToken(any())) .thenAnswer((final _) => Future.value(webCredentials)); final result = await auth0.customTokenExchange( 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 index 63b9497a4..9dd056623 100644 --- 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 @@ -33,6 +33,8 @@ void main() { expect(options.scopes, {'openid', 'profile', 'email'}); expect(options.organization, 'org_abc123'); expect(options.parameters, {'test': 'test-123'}); + }); + test('toMap includes all properties', () { final options = AuthCustomTokenExchangeOptions( subjectToken: 'existing-token', @@ -51,6 +53,8 @@ void main() { expect(map['scopes'], ['openid', 'profile', 'email']); expect(map['organization'], 'org_abc123'); expect(map['parameters'], {'test': 'test-123'}); + }); + test('toMap excludes null audience', () { final options = AuthCustomTokenExchangeOptions( subjectToken: 'existing-token', @@ -86,10 +90,6 @@ void main() { expect(map.containsKey('organization'), isTrue); }); - test('toMap includes empty scopes and parameters', () { - expect(map.containsKey('audience'), isFalse); - }); - test('toMap includes empty scopes and parameters', () { final options = AuthCustomTokenExchangeOptions( subjectToken: 'existing-token', From 2822c82d1307fe640c1a3bf3a9c055c198cdeb50 Mon Sep 17 00:00:00 2001 From: sanchitmehta94 Date: Wed, 14 Jan 2026 10:34:59 +0530 Subject: [PATCH 3/6] fixed test cases --- .../test/web/auth0_flutter_web_test.dart | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/auth0_flutter/test/web/auth0_flutter_web_test.dart b/auth0_flutter/test/web/auth0_flutter_web_test.dart index 40885682f..b1d1b9dd3 100644 --- a/auth0_flutter/test/web/auth0_flutter_web_test.dart +++ b/auth0_flutter/test/web/auth0_flutter_web_test.dart @@ -416,7 +416,7 @@ void main() { group('customTokenExchange', () { test('customTokenExchange is called with required parameters and succeeds', () async { - when(mockClientProxy.exchangeToken(any())) + when(mockClientProxy.exchangeToken(argThat(anything))) .thenAnswer((final _) => Future.value(webCredentials)); final result = await auth0.customTokenExchange( @@ -440,7 +440,7 @@ void main() { test('customTokenExchange is called with all optional parameters', () async { - when(mockClientProxy.exchangeToken(any())) + when(mockClientProxy.exchangeToken(argThat(anything))) .thenAnswer((final _) => Future.value(webCredentials)); await auth0.customTokenExchange( @@ -460,7 +460,7 @@ void main() { }); test('customTokenExchange is called with custom parameters', () async { - when(mockClientProxy.exchangeToken(any())) + when(mockClientProxy.exchangeToken(argThat(anything))) .thenAnswer((final _) => Future.value(webCredentials)); await auth0.customTokenExchange( @@ -479,7 +479,7 @@ void main() { }); test('customTokenExchange handles empty scopes correctly', () async { - when(mockClientProxy.exchangeToken(any())) + when(mockClientProxy.exchangeToken(argThat(anything))) .thenAnswer((final _) => Future.value(webCredentials)); await auth0.customTokenExchange( @@ -493,7 +493,7 @@ void main() { }); test('customTokenExchange throws WebException on error', () async { - when(mockClientProxy.exchangeToken(any())) + when(mockClientProxy.exchangeToken(argThat(anything))) .thenThrow(createJsException('invalid_token', 'Token is invalid')); expect( @@ -515,7 +515,7 @@ void main() { ]; for (final errorCase in errorCases) { - when(mockClientProxy.exchangeToken(any())) + when(mockClientProxy.exchangeToken(argThat(anything))) .thenThrow(createJsException(errorCase['code']!, errorCase['message']!)); expect( @@ -539,7 +539,7 @@ void main() { scope: 'openid profile email read:data write:data', expires_in: 0.toJS); - when(mockClientProxy.exchangeToken(any())) + when(mockClientProxy.exchangeToken(argThat(anything))) .thenAnswer((final _) => Future.value(customScopeCredentials)); final result = await auth0.customTokenExchange( @@ -558,7 +558,7 @@ void main() { scope: 'openid', expires_in: 0.toJS); - when(mockClientProxy.exchangeToken(any())) + when(mockClientProxy.exchangeToken(argThat(anything))) .thenAnswer((final _) => Future.value(credentialsNoRefresh)); final result = await auth0.customTokenExchange( @@ -571,7 +571,7 @@ void main() { test('customTokenExchange converts JS credentials to Dart Credentials', () async { - when(mockClientProxy.exchangeToken(any())) + when(mockClientProxy.exchangeToken(argThat(anything))) .thenAnswer((final _) => Future.value(webCredentials)); final result = await auth0.customTokenExchange( From dc8015a212fb84b2d91042fd6699461a88ced513 Mon Sep 17 00:00:00 2001 From: sanchitmehta94 Date: Wed, 14 Jan 2026 15:39:41 +0530 Subject: [PATCH 4/6] fix test cases and code refactoring --- auth0_flutter/EXAMPLES.md | 10 ++-- .../CustomTokenExchangeApiRequestHandler.kt | 3 -- ...ustomTokenExchangeApiRequestHandlerTest.kt | 46 +------------------ ...hAPICustomTokenExchangeMethodHandler.swift | 9 +--- ...hAPICustomTokenExchangeMethodHandler.swift | 1 + auth0_flutter/lib/auth0_flutter_web.dart | 9 ++-- .../lib/src/mobile/authentication_api.dart | 7 +-- .../exchange_token_options_extension.dart | 10 +--- ...hAPICustomTokenExchangeMethodHandler.swift | 1 + .../test/web/auth0_flutter_web_test.dart | 39 ++++------------ .../web/auth0_flutter_web_test.mocks.dart | 17 +++++++ .../auth_custom_token_exchange_options.dart | 3 -- .../lib/src/web/exchange_token_options.dart | 6 +-- ...th_custom_token_exchange_options_test.dart | 9 +--- ...ethod_channel_auth0_flutter_auth_test.dart | 5 +- 15 files changed, 46 insertions(+), 129 deletions(-) create mode 120000 auth0_flutter/ios/Classes/AuthAPI/AuthAPICustomTokenExchangeMethodHandler.swift create mode 120000 auth0_flutter/macos/Classes/AuthAPI/AuthAPICustomTokenExchangeMethodHandler.swift diff --git a/auth0_flutter/EXAMPLES.md b/auth0_flutter/EXAMPLES.md index cfee4a71f..81bfab1f4 100644 --- a/auth0_flutter/EXAMPLES.md +++ b/auth0_flutter/EXAMPLES.md @@ -703,7 +703,10 @@ final didStore = ### Custom Token Exchange -[Custom Token Exchange](https://auth0.com/docs/authenticate/custom-token-exchange) allows you to exchange tokens from external identity providers for Auth0 tokens. This is useful for migrating users from legacy systems or integrating with third-party identity providers. +[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: +- Get Auth0 tokens for another audience +- 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. @@ -739,11 +742,6 @@ final credentials = await auth0Web.customTokenExchange( -**Required setup:** -1. Configure a Custom 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 - > 💡 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 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 index 6b9652442..41888725f 100644 --- 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 @@ -37,9 +37,6 @@ class CustomTokenExchangeApiRequestHandler : ApiRequestHandler { if (args["audience"] is String) { setAudience(args["audience"] as String) } - if (args["parameters"] is HashMap<*, *>) { - addParameters(args["parameters"] as Map) - } validateClaims() } 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 index fbd1f1b9d..8eadab55e 100644 --- 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 @@ -207,55 +207,13 @@ class CustomTokenExchangeApiRequestHandlerTest { verify(mockResult).success(any()) } - @Test - fun `should include custom parameters when provided`() { - val customParams = hashMapOf("custom_param" to "value", "another_param" to "test") - val options = hashMapOf( - "subjectToken" to "external-token-abc", - "subjectTokenType" to "urn:ietf:params:oauth:token-type:jwt", - "parameters" to customParams - ) - 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", - null, - Date(), - "openid" - ) - - whenever(mockApi.customTokenExchange(any(), any(), isNull())).thenReturn(mockRequest) - whenever(mockRequest.addParameters(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).addParameters(customParams) - verify(mockResult).success(any()) - } - @Test fun `should include all optional parameters when provided`() { - val customParams = hashMapOf("org_id" to "org_123") 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"), - "parameters" to customParams + "scopes" to arrayListOf("openid", "profile", "email") ) val handler = CustomTokenExchangeApiRequestHandler() val mockApi = mock() @@ -276,7 +234,6 @@ class CustomTokenExchangeApiRequestHandlerTest { whenever(mockApi.customTokenExchange(any(), any(), isNull())).thenReturn(mockRequest) whenever(mockRequest.setAudience(any())).thenReturn(mockRequest) whenever(mockRequest.setScope(any())).thenReturn(mockRequest) - whenever(mockRequest.addParameters(any())).thenReturn(mockRequest) whenever(mockRequest.validateClaims()).thenReturn(mockRequest) doAnswer { @@ -289,7 +246,6 @@ class CustomTokenExchangeApiRequestHandlerTest { verify(mockRequest).setAudience("https://api.example.com") verify(mockRequest).setScope("openid profile email") - verify(mockRequest).addParameters(customParams) verify(mockRequest).validateClaims() verify(mockResult).success(any()) } diff --git a/auth0_flutter/darwin/Classes/AuthAPI/AuthAPICustomTokenExchangeMethodHandler.swift b/auth0_flutter/darwin/Classes/AuthAPI/AuthAPICustomTokenExchangeMethodHandler.swift index a496812af..e1c05227d 100644 --- a/auth0_flutter/darwin/Classes/AuthAPI/AuthAPICustomTokenExchangeMethodHandler.swift +++ b/auth0_flutter/darwin/Classes/AuthAPI/AuthAPICustomTokenExchangeMethodHandler.swift @@ -12,7 +12,6 @@ struct AuthAPICustomTokenExchangeMethodHandler: MethodHandler { case subjectTokenType case audience case scopes - case parameters case organization } @@ -28,13 +27,10 @@ struct AuthAPICustomTokenExchangeMethodHandler: MethodHandler { guard let scopes = arguments[Argument.scopes] as? [String] else { return callback(FlutterError(from: .requiredArgumentMissing(Argument.scopes.rawValue))) } - guard let parameters = arguments[Argument.parameters] as? [String: Any] else { - return callback(FlutterError(from: .requiredArgumentMissing(Argument.parameters.rawValue))) - } let audience = arguments[Argument.audience] as? String let organization = arguments[Argument.organization] as? String - let scope = scopes.isEmpty ? "openid profile email" : scopes.asSpaceSeparatedString + let scope: String = scopes.isEmpty ? "openid profile email" : scopes.asSpaceSeparatedString client .customTokenExchange(subjectToken: subjectToken, @@ -42,7 +38,6 @@ struct AuthAPICustomTokenExchangeMethodHandler: MethodHandler { audience: audience, scope: scope, organization: organization) - .parameters(parameters) .start { switch $0 { case .success(let credentials): callback(self.result(from: credentials)) @@ -50,4 +45,4 @@ struct AuthAPICustomTokenExchangeMethodHandler: MethodHandler { } } } -} +} \ No newline at end of file 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/lib/auth0_flutter_web.dart b/auth0_flutter/lib/auth0_flutter_web.dart index ef50ba0bb..22fb6b801 100644 --- a/auth0_flutter/lib/auth0_flutter_web.dart +++ b/auth0_flutter/lib/auth0_flutter_web.dart @@ -299,9 +299,8 @@ class Auth0Web { /// access token. If not specified, uses the audience from [onLoad] configuration /// or the default audience configured in your Auth0 application. /// - /// * [scopes] - Optional set of scopes to request. Defaults to - /// `{'openid', 'profile', 'email'}`. These scopes determine what information - /// and permissions the resulting tokens will have. + /// * [scopes] - Optional set of scopes to request. + /// These scopes determine what information and permissions the resulting tokens will have. /// /// * [organizationId] - Optional organization ID or name to associate the /// token exchange with a specific organization context. @@ -353,16 +352,14 @@ class Auth0Web { final String? audience, final Set? scopes, final String? organizationId, - final Map parameters = const {}, }) => Auth0FlutterWebPlatform.instance.customTokenExchange( ExchangeTokenOptions( subjectToken: subjectToken, subjectTokenType: subjectTokenType, audience: audience, - scopes: scopes ?? {'openid', 'profile', 'email'}, + scopes: scopes, organizationId: organizationId, - parameters: parameters, ), ); diff --git a/auth0_flutter/lib/src/mobile/authentication_api.dart b/auth0_flutter/lib/src/mobile/authentication_api.dart index 420067b00..15599d083 100644 --- a/auth0_flutter/lib/src/mobile/authentication_api.dart +++ b/auth0_flutter/lib/src/mobile/authentication_api.dart @@ -369,9 +369,6 @@ class AuthenticationApi { /// to learn more. /// * [scopes] defaults to `openid profile email`. You can override this to /// specify a different set of scopes. - /// * Arbitrary [parameters] can be specified and then picked up in a custom - /// Auth0 [Action](https://auth0.com/docs/customize/actions) or - /// [Rule](https://auth0.com/docs/customize/rules). /// /// ## Usage example /// @@ -396,7 +393,6 @@ class AuthenticationApi { final String? audience, final Set scopes = const {'openid', 'profile', 'email'}, final String? organization, - final Map parameters = const {}, }) => Auth0FlutterAuthPlatform.instance.customTokenExchange(_createApiRequest( AuthCustomTokenExchangeOptions( @@ -404,8 +400,7 @@ class AuthenticationApi { subjectTokenType: subjectTokenType, audience: audience, scopes: scopes, - organization: organization, - parameters: parameters))); + 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/extensions/exchange_token_options_extension.dart b/auth0_flutter/lib/src/web/extensions/exchange_token_options_extension.dart index 647a58c62..025e54b04 100644 --- a/auth0_flutter/lib/src/web/extensions/exchange_token_options_extension.dart +++ b/auth0_flutter/lib/src/web/extensions/exchange_token_options_extension.dart @@ -4,20 +4,14 @@ import '../js_interop_utils.dart'; extension ExchangeTokenOptionsExtension on ExchangeTokenOptions { interop.ExchangeTokenOptions toInteropExchangeTokenOptions() { - final scopeString = scopes.isNotEmpty ? scopes.join(' ') : null; + final scopeString = scopes?.isNotEmpty == true ? scopes!.join(' ') : null; - final options = JsInteropUtils.stripNulls(interop.ExchangeTokenOptions( + return JsInteropUtils.stripNulls(interop.ExchangeTokenOptions( subject_token: subjectToken, subject_token_type: subjectTokenType, audience: audience, scope: scopeString, organization: organizationId, )); - - // Add custom parameters if provided - if (parameters.isNotEmpty) { - JsInteropUtils.addCustomParams(options, parameters); - } - return options; } } 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/test/web/auth0_flutter_web_test.dart b/auth0_flutter/test/web/auth0_flutter_web_test.dart index b1d1b9dd3..477da1084 100644 --- a/auth0_flutter/test/web/auth0_flutter_web_test.dart +++ b/auth0_flutter/test/web/auth0_flutter_web_test.dart @@ -416,7 +416,7 @@ void main() { group('customTokenExchange', () { test('customTokenExchange is called with required parameters and succeeds', () async { - when(mockClientProxy.exchangeToken(argThat(anything))) + when(mockClientProxy.exchangeToken(any)) .thenAnswer((final _) => Future.value(webCredentials)); final result = await auth0.customTokenExchange( @@ -459,27 +459,8 @@ void main() { expect(options.organization, 'org_abc123'); }); - test('customTokenExchange is called with custom parameters', () async { - when(mockClientProxy.exchangeToken(argThat(anything))) - .thenAnswer((final _) => Future.value(webCredentials)); - - await auth0.customTokenExchange( - subjectToken: 'external-token-789', - subjectTokenType: 'urn:example:custom-token', - parameters: {'custom_param': 'value', 'device_id': 'mobile-123'}); - - final options = - verify(mockClientProxy.exchangeToken(captureAny)).captured.first; - expect(options.subject_token, 'external-token-789'); - expect(options.subject_token_type, 'urn:example:custom-token'); - // Verify custom parameters are added to the JS object - final jsObject = JSObject.fromInteropObject(options); - expect(jsObject.getProperty('custom_param'.toJS).toString(), 'value'); - expect(jsObject.getProperty('device_id'.toJS).toString(), 'mobile-123'); - }); - test('customTokenExchange handles empty scopes correctly', () async { - when(mockClientProxy.exchangeToken(argThat(anything))) + when(mockClientProxy.exchangeToken(any)) .thenAnswer((final _) => Future.value(webCredentials)); await auth0.customTokenExchange( @@ -493,7 +474,7 @@ void main() { }); test('customTokenExchange throws WebException on error', () async { - when(mockClientProxy.exchangeToken(argThat(anything))) + when(mockClientProxy.exchangeToken(any)) .thenThrow(createJsException('invalid_token', 'Token is invalid')); expect( @@ -515,15 +496,15 @@ void main() { ]; for (final errorCase in errorCases) { - when(mockClientProxy.exchangeToken(argThat(anything))) + when(mockClientProxy.exchangeToken(any)) .thenThrow(createJsException(errorCase['code']!, errorCase['message']!)); - expect( - () async => auth0.customTokenExchange( + await expectLater( + auth0.customTokenExchange( subjectToken: 'token', subjectTokenType: 'urn:example:token'), throwsA(predicate((final e) => e is WebException && - e.code == errorCase['code'] && + e.code == 'AUTHENTICATION_ERROR' && e.message == errorCase['message']))); reset(mockClientProxy); @@ -539,7 +520,7 @@ void main() { scope: 'openid profile email read:data write:data', expires_in: 0.toJS); - when(mockClientProxy.exchangeToken(argThat(anything))) + when(mockClientProxy.exchangeToken(any)) .thenAnswer((final _) => Future.value(customScopeCredentials)); final result = await auth0.customTokenExchange( @@ -558,7 +539,7 @@ void main() { scope: 'openid', expires_in: 0.toJS); - when(mockClientProxy.exchangeToken(argThat(anything))) + when(mockClientProxy.exchangeToken(any)) .thenAnswer((final _) => Future.value(credentialsNoRefresh)); final result = await auth0.customTokenExchange( @@ -571,7 +552,7 @@ void main() { test('customTokenExchange converts JS credentials to Dart Credentials', () async { - when(mockClientProxy.exchangeToken(argThat(anything))) + when(mockClientProxy.exchangeToken(any)) .thenAnswer((final _) => Future.value(webCredentials)); final result = await auth0.customTokenExchange( 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_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 index 8e3ebca1f..174eee47e 100644 --- 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 @@ -6,7 +6,6 @@ class AuthCustomTokenExchangeOptions implements RequestOptions { final String? audience; final Set scopes; final String? organization; - final Map parameters; const AuthCustomTokenExchangeOptions({ required this.subjectToken, @@ -14,7 +13,6 @@ class AuthCustomTokenExchangeOptions implements RequestOptions { this.audience, this.scopes = const {}, this.organization, - this.parameters = const {}, }); @override @@ -24,6 +22,5 @@ class AuthCustomTokenExchangeOptions implements RequestOptions { if (audience != null) 'audience': audience, 'scopes': scopes.toList(), if (organization != null) 'organization': organization, - 'parameters': parameters }; } 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 index 98e339412..f3c2b95d2 100644 --- a/auth0_flutter_platform_interface/lib/src/web/exchange_token_options.dart +++ b/auth0_flutter_platform_interface/lib/src/web/exchange_token_options.dart @@ -2,16 +2,14 @@ class ExchangeTokenOptions { final String subjectToken; final String subjectTokenType; final String? audience; - final Set scopes; + final Set? scopes; final String? organizationId; - final Map parameters; ExchangeTokenOptions({ required this.subjectToken, required this.subjectTokenType, this.audience, - this.scopes = const {}, + this.scopes, this.organizationId, - this.parameters = const {}, }); } 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 index 9dd056623..a871f1fb3 100644 --- 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 @@ -14,7 +14,6 @@ void main() { expect(options.audience, isNull); expect(options.scopes, isEmpty); expect(options.organization, isNull); - expect(options.parameters, isEmpty); }); test('creates options with all parameters', () { @@ -24,7 +23,6 @@ void main() { audience: 'https://example.com/api', scopes: {'openid', 'profile', 'email'}, organization: 'org_abc123', - parameters: {'test': 'test-123'}, ); expect(options.subjectToken, 'existing-token'); @@ -32,7 +30,6 @@ void main() { expect(options.audience, 'https://example.com/api'); expect(options.scopes, {'openid', 'profile', 'email'}); expect(options.organization, 'org_abc123'); - expect(options.parameters, {'test': 'test-123'}); }); test('toMap includes all properties', () { @@ -42,7 +39,6 @@ void main() { audience: 'https://example.com/api', scopes: {'openid', 'profile', 'email'}, organization: 'org_abc123', - parameters: {'test': 'test-123'}, ); final map = options.toMap(); @@ -52,7 +48,6 @@ void main() { expect(map['audience'], 'https://example.com/api'); expect(map['scopes'], ['openid', 'profile', 'email']); expect(map['organization'], 'org_abc123'); - expect(map['parameters'], {'test': 'test-123'}); }); test('toMap excludes null audience', () { @@ -90,18 +85,16 @@ void main() { expect(map.containsKey('organization'), isTrue); }); - test('toMap includes empty scopes and parameters', () { + test('toMap includes empty scopes', () { final options = AuthCustomTokenExchangeOptions( subjectToken: 'existing-token', subjectTokenType: 'http://acme.com/legacy-token', scopes: {}, - parameters: {}, ); final map = options.toMap(); expect(map['scopes'], isEmpty); - expect(map['parameters'], 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 cebf9c65c..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 @@ -900,8 +900,7 @@ void main() { subjectToken: 'existing-token', subjectTokenType: 'http://acme.com/legacy-token', audience: 'https://example.com/api', - scopes: {'openid', 'profile', 'email'}, - parameters: {'test': 'test-123'}))); + scopes: {'openid', 'profile', 'email'}))); final verificationResult = verify(mocked.methodCallHandler(captureAny)).captured.single; @@ -917,7 +916,6 @@ void main() { 'http://acme.com/legacy-token'); expect(verificationResult.arguments['audience'], 'https://example.com/api'); expect(verificationResult.arguments['scopes'], ['openid', 'profile', 'email']); - expect(verificationResult.arguments['parameters']['test'], 'test-123'); }); test( @@ -936,7 +934,6 @@ void main() { final verificationResult = verify(mocked.methodCallHandler(captureAny)).captured.single; expect(verificationResult.arguments['scopes'], isEmpty); - expect(verificationResult.arguments['parameters'], isEmpty); expect(verificationResult.arguments.containsKey('audience'), isFalse); expect(verificationResult.arguments.containsKey('organization'), isFalse); }); From 29a50912c072eb16ad8b097115c7037d7c8bcb74 Mon Sep 17 00:00:00 2001 From: sanchitmehta94 Date: Wed, 14 Jan 2026 16:16:48 +0530 Subject: [PATCH 5/6] refactor --- ...hAPICustomTokenExchangeMethodHandler.swift | 8 +++---- ...ustomTokenExchangeMethodHandlerTests.swift | 21 +++---------------- 2 files changed, 6 insertions(+), 23 deletions(-) diff --git a/auth0_flutter/darwin/Classes/AuthAPI/AuthAPICustomTokenExchangeMethodHandler.swift b/auth0_flutter/darwin/Classes/AuthAPI/AuthAPICustomTokenExchangeMethodHandler.swift index e1c05227d..cb2c17f6c 100644 --- a/auth0_flutter/darwin/Classes/AuthAPI/AuthAPICustomTokenExchangeMethodHandler.swift +++ b/auth0_flutter/darwin/Classes/AuthAPI/AuthAPICustomTokenExchangeMethodHandler.swift @@ -24,13 +24,11 @@ struct AuthAPICustomTokenExchangeMethodHandler: MethodHandler { 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] else { - return callback(FlutterError(from: .requiredArgumentMissing(Argument.scopes.rawValue))) - } - + + let scopes = arguments[Argument.scopes] as? [String] ?? [] + let scope = scopes.isEmpty ? "openid profile email" : scopes.asSpaceSeparatedString let audience = arguments[Argument.audience] as? String let organization = arguments[Argument.organization] as? String - let scope: String = scopes.isEmpty ? "openid profile email" : scopes.asSpaceSeparatedString client .customTokenExchange(subjectToken: subjectToken, diff --git a/auth0_flutter/example/ios/Tests/AuthAPI/AuthAPICustomTokenExchangeMethodHandlerTests.swift b/auth0_flutter/example/ios/Tests/AuthAPI/AuthAPICustomTokenExchangeMethodHandlerTests.swift index 924dce056..ad3f8a5ab 100644 --- a/auth0_flutter/example/ios/Tests/AuthAPI/AuthAPICustomTokenExchangeMethodHandlerTests.swift +++ b/auth0_flutter/example/ios/Tests/AuthAPI/AuthAPICustomTokenExchangeMethodHandlerTests.swift @@ -19,7 +19,7 @@ class AuthAPICustomTokenExchangeMethodHandlerTests: XCTestCase { extension AuthAPICustomTokenExchangeMethodHandlerTests { func testProducesErrorWhenRequiredArgumentsAreMissing() { - let keys: [Argument] = [.subjectToken, .subjectTokenType, .scopes, .parameters] + 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 @@ -90,7 +90,7 @@ extension AuthAPICustomTokenExchangeMethodHandlerTests { func testWorksWithEmptyScopes() { let expectation = self.expectation(description: "Called with empty scopes") spy.onCustomTokenExchange = { _, _, _, scope, _ in - XCTAssertNil(scope) + XCTAssertEqual(scope, "openid profile email") expectation.fulfill() } var args = arguments() @@ -126,20 +126,6 @@ extension AuthAPICustomTokenExchangeMethodHandlerTests { } } -// MARK: - Additional Parameters - -extension AuthAPICustomTokenExchangeMethodHandlerTests { - func testCallsParametersWithCustomParameters() { - let expectation = self.expectation(description: "Called parameters") - spy.onParameters = { parameters in - XCTAssertTrue(parameters["test"] as? String == "test-123") - expectation.fulfill() - } - sut.handle(with: arguments()) { _ in } - wait(for: [expectation]) - } -} - // MARK: - Error extension AuthAPICustomTokenExchangeMethodHandlerTests { @@ -167,8 +153,7 @@ fileprivate extension AuthAPICustomTokenExchangeMethodHandlerTests { 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"], - Argument.parameters.rawValue: ["test": "test-123"] + Argument.scopes.rawValue: ["openid", "profile", "email"] ] if let key = key { args.removeValue(forKey: key.rawValue) From 559fdc7c7f7c29e3d0e96e2c412314916ef30070 Mon Sep 17 00:00:00 2001 From: sanchitmehta94 Date: Fri, 16 Jan 2026 12:28:35 +0530 Subject: [PATCH 6/6] address feedback --- auth0_flutter/EXAMPLES.md | 15 ++++----- ...ustomTokenExchangeApiRequestHandlerTest.kt | 14 ++++----- ...hAPICustomTokenExchangeMethodHandler.swift | 6 ++-- ...ustomTokenExchangeMethodHandlerTests.swift | 14 ++------- auth0_flutter/lib/auth0_flutter_web.dart | 31 ++++++++++--------- ...exchange_token_options_extension_test.dart | 4 +-- .../lib/src/web/exchange_token_options.dart | 12 +++++++ 7 files changed, 50 insertions(+), 46 deletions(-) diff --git a/auth0_flutter/EXAMPLES.md b/auth0_flutter/EXAMPLES.md index 81bfab1f4..992e9548c 100644 --- a/auth0_flutter/EXAMPLES.md +++ b/auth0_flutter/EXAMPLES.md @@ -704,7 +704,6 @@ final didStore = ### 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: -- Get Auth0 tokens for another audience - Integrate an external identity provider - Migrate to Auth0 @@ -716,11 +715,10 @@ final didStore = ```dart final credentials = await auth0.api.customTokenExchange( subjectToken: 'external-idp-token', - subjectTokenType: 'urn:ietf:params:oauth:token-type:jwt', - audience: 'https://api.example.com', - scopes: {'openid', 'profile', 'email'}, + 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 - parameters: {'custom_param': 'value'} // Optional ); ``` @@ -732,11 +730,10 @@ final credentials = await auth0.api.customTokenExchange( ```dart final credentials = await auth0Web.customTokenExchange( subjectToken: 'external-idp-token', - subjectTokenType: 'urn:ietf:params:oauth:token-type:jwt', - audience: 'https://api.example.com', - scopes: {'openid', 'profile', 'email'}, + subjectTokenType: 'urn:acme:legacy-token', + audience: 'https://api.example.com', // Optional + scopes: {'openid', 'profile', 'email'}, // Optional organizationId: 'org_abc123', // Optional - parameters: {'custom_param': 'value'} // Optional ); ``` 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 index 8eadab55e..31f82ec4f 100644 --- 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 @@ -23,7 +23,7 @@ import java.util.* class CustomTokenExchangeApiRequestHandlerTest { @Test fun `should throw when missing subjectToken`() { - val options = hashMapOf("subjectTokenType" to "urn:ietf:params:oauth:token-type:jwt") + val options = hashMapOf("subjectTokenType" to "urn:acme:legacy-token") val handler = CustomTokenExchangeApiRequestHandler() val mockApi = mock() val mockAccount = mock() @@ -53,7 +53,7 @@ class CustomTokenExchangeApiRequestHandlerTest { fun `should call success with required parameters only`() { val options = hashMapOf( "subjectToken" to "external-token-123", - "subjectTokenType" to "urn:ietf:params:oauth:token-type:jwt" + "subjectTokenType" to "urn:acme:legacy-token" ) val handler = CustomTokenExchangeApiRequestHandler() val mockApi = mock() @@ -88,7 +88,7 @@ class CustomTokenExchangeApiRequestHandlerTest { handler.handle(mockApi, request, mockResult) verify(mockApi).customTokenExchange( - "urn:ietf:params:oauth:token-type:jwt", + "urn:acme:legacy-token", "external-token-123", null ) @@ -101,7 +101,7 @@ class CustomTokenExchangeApiRequestHandlerTest { fun `should handle error callback`() { val options = hashMapOf( "subjectToken" to "invalid-token", - "subjectTokenType" to "urn:ietf:params:oauth:token-type:jwt" + "subjectTokenType" to "urn:acme:legacy-token" ) val handler = CustomTokenExchangeApiRequestHandler() val mockApi = mock() @@ -172,7 +172,7 @@ class CustomTokenExchangeApiRequestHandlerTest { fun `should include scopes when provided`() { val options = hashMapOf( "subjectToken" to "external-token-789", - "subjectTokenType" to "urn:ietf:params:oauth:token-type:jwt", + "subjectTokenType" to "urn:acme:legacy-token", "scopes" to arrayListOf("openid", "profile", "email", "read:data") ) val handler = CustomTokenExchangeApiRequestHandler() @@ -254,7 +254,7 @@ class CustomTokenExchangeApiRequestHandlerTest { fun `should include organization when provided`() { val options = hashMapOf( "subjectToken" to "external-token-org", - "subjectTokenType" to "urn:ietf:params:oauth:token-type:jwt", + "subjectTokenType" to "urn:acme:legacy-token", "organization" to "org_abc123" ) val handler = CustomTokenExchangeApiRequestHandler() @@ -285,7 +285,7 @@ class CustomTokenExchangeApiRequestHandlerTest { handler.handle(mockApi, request, mockResult) verify(mockApi).customTokenExchange( - "urn:ietf:params:oauth:token-type:jwt", + "urn:acme:legacy-token", "external-token-org", "org_abc123" ) diff --git a/auth0_flutter/darwin/Classes/AuthAPI/AuthAPICustomTokenExchangeMethodHandler.swift b/auth0_flutter/darwin/Classes/AuthAPI/AuthAPICustomTokenExchangeMethodHandler.swift index cb2c17f6c..f415b3b75 100644 --- a/auth0_flutter/darwin/Classes/AuthAPI/AuthAPICustomTokenExchangeMethodHandler.swift +++ b/auth0_flutter/darwin/Classes/AuthAPI/AuthAPICustomTokenExchangeMethodHandler.swift @@ -24,9 +24,11 @@ struct AuthAPICustomTokenExchangeMethodHandler: MethodHandler { 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 scopes = arguments[Argument.scopes] as? [String] ?? [] - let scope = scopes.isEmpty ? "openid profile email" : scopes.asSpaceSeparatedString + let scope = scopes.asSpaceSeparatedString let audience = arguments[Argument.audience] as? String let organization = arguments[Argument.organization] as? String diff --git a/auth0_flutter/example/ios/Tests/AuthAPI/AuthAPICustomTokenExchangeMethodHandlerTests.swift b/auth0_flutter/example/ios/Tests/AuthAPI/AuthAPICustomTokenExchangeMethodHandlerTests.swift index ad3f8a5ab..6f34ec019 100644 --- a/auth0_flutter/example/ios/Tests/AuthAPI/AuthAPICustomTokenExchangeMethodHandlerTests.swift +++ b/auth0_flutter/example/ios/Tests/AuthAPI/AuthAPICustomTokenExchangeMethodHandlerTests.swift @@ -58,7 +58,7 @@ extension AuthAPICustomTokenExchangeMethodHandlerTests { expiresIn: Date(timeIntervalSinceNow: 3600), scope: "openid profile email" ) - spy.onCustomTokenExchange = { _, _, _, _ in + spy.onCustomTokenExchange = { _, _, _, _, _ in return self.spy.request(returning: credentials) } sut.handle(with: arguments()) { result in @@ -87,17 +87,7 @@ extension AuthAPICustomTokenExchangeMethodHandlerTests { wait(for: [expectation]) } - func testWorksWithEmptyScopes() { - let expectation = self.expectation(description: "Called with empty scopes") - spy.onCustomTokenExchange = { _, _, _, scope, _ in - XCTAssertEqual(scope, "openid profile email") - expectation.fulfill() - } - var args = arguments() - args[Argument.scopes.rawValue] = [] - sut.handle(with: args) { _ in } - wait(for: [expectation]) - } + func testWorksWithoutOrganization() { let expectation = self.expectation(description: "Called without organization") diff --git a/auth0_flutter/lib/auth0_flutter_web.dart b/auth0_flutter/lib/auth0_flutter_web.dart index 22fb6b801..d12e14c6d 100644 --- a/auth0_flutter/lib/auth0_flutter_web.dart +++ b/auth0_flutter/lib/auth0_flutter_web.dart @@ -289,24 +289,27 @@ class Auth0Web { /// system or another identity provider. /// /// * [subjectTokenType] (required) - A URI identifying the type of the - /// subject token according to RFC 8693. Common examples: - /// - `urn:ietf:params:oauth:token-type:jwt` for JWT tokens - /// - `urn:ietf:params:oauth:token-type:id_token` for OIDC ID tokens - /// - `urn:ietf:params:oauth:token-type:access_token` for OAuth access tokens - /// - Custom URNs like `urn:example:external-token` for custom token types + /// 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. If not specified, uses the audience from [onLoad] configuration - /// or the default audience configured in your Auth0 application. + /// 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 information and permissions the resulting tokens will have. + /// 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. - /// - /// * [parameters] - Additional custom parameters to include in the token - /// exchange request. These can be processed by Auth0 Actions or Rules. + /// 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 @@ -327,7 +330,7 @@ class Auth0Web { /// try { /// final credentials = await auth0Web.customTokenExchange( /// subjectToken: externalToken, - /// subjectTokenType: 'urn:ietf:params:oauth:token-type:jwt', + /// subjectTokenType: 'urn:acme:legacy-system-token', /// audience: 'https://myapi.example.com', /// scopes: {'openid', 'profile', 'email', 'read:data'}, /// ); @@ -344,7 +347,7 @@ class Auth0Web { /// * Network issues prevent the exchange request /// /// See also: - /// * [Token Exchange Documentation](https://auth0.com/docs/authenticate/login/token-exchange) + /// * [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, 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 index abca4d55a..02ebf9149 100644 --- a/auth0_flutter/test/web/extensions/exchange_token_options_extension_test.dart +++ b/auth0_flutter/test/web/extensions/exchange_token_options_extension_test.dart @@ -9,14 +9,14 @@ void main() { test('converts ExchangeTokenOptions with required fields only', () { final options = ExchangeTokenOptions( subjectToken: 'external-token-123', - subjectTokenType: 'urn:ietf:params:oauth:token-type:jwt', + subjectTokenType: 'urn:acme:legacy-token', ); final result = options.toInteropExchangeTokenOptions(); expect(result.subject_token, 'external-token-123'); expect(result.subject_token_type, - 'urn:ietf:params:oauth:token-type:jwt'); + 'urn:acme:legacy-token'); expect(result.audience, isNull); expect(result.scope, isNull); expect(result.organization, isNull); 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 index f3c2b95d2..c41906621 100644 --- a/auth0_flutter_platform_interface/lib/src/web/exchange_token_options.dart +++ b/auth0_flutter_platform_interface/lib/src/web/exchange_token_options.dart @@ -1,3 +1,15 @@ +/// 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;