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