diff --git a/EXAMPLES.md b/EXAMPLES.md
index a789e7c3..638e94c8 100644
--- a/EXAMPLES.md
+++ b/EXAMPLES.md
@@ -44,6 +44,11 @@
- [Using MRRT with Hooks](#using-mrrt-with-hooks)
- [Using MRRT with Auth0 Class](#using-mrrt-with-auth0-class)
- [Web Platform Configuration](#web-platform-configuration)
+- [Custom Token Exchange (RFC 8693)](#custom-token-exchange-rfc-8693)
+ - [Using Custom Token Exchange with Hooks](#using-custom-token-exchange-with-hooks)
+ - [Using Custom Token Exchange with Auth0 Class](#using-custom-token-exchange-with-auth0-class)
+ - [With Organization Context](#with-organization-context)
+ - [Subject Token Type Requirements](#subject-token-type-requirements)
- [Native to Web SSO (Early Access)](#native-to-web-sso-early-access)
- [Overview](#native-to-web-sso-overview)
- [Prerequisites](#native-to-web-sso-prerequisites)
@@ -758,6 +763,250 @@ function App() {
}
```
+## Custom Token Exchange (RFC 8693)
+
+Custom Token Exchange allows you to exchange external identity provider tokens for Auth0 tokens using the [RFC 8693 OAuth 2.0 Token Exchange](https://datatracker.ietf.org/doc/html/rfc8693) specification. This enables scenarios where users authenticate with an external system and that token needs to be exchanged for Auth0 tokens.
+
+> ⚠️ **Important**: The external token must be validated in Auth0 Actions using cryptographic verification. See the [Auth0 Custom Token Exchange documentation](https://auth0.com/docs/authenticate/custom-token-exchange) for setup instructions.
+
+### Using Custom Token Exchange with Hooks
+
+```typescript
+import React from 'react';
+import { Button, Alert } from 'react-native';
+import {
+ useAuth0,
+ AuthenticationException,
+ AuthenticationErrorCodes,
+} from 'react-native-auth0';
+
+function TokenExchangeScreen() {
+ const { customTokenExchange, user, error } = useAuth0();
+
+ const handleExchange = async () => {
+ try {
+ // Exchange an external token for Auth0 tokens
+ const credentials = await customTokenExchange({
+ subjectToken: 'token-from-external-provider',
+ subjectTokenType: 'urn:acme:legacy-system-token',
+ scope: 'openid profile email',
+ audience: 'https://api.example.com',
+ });
+
+ Alert.alert('Success', `Logged in as ${user?.name}`);
+ } catch (e) {
+ if (e instanceof AuthenticationException) {
+ switch (e.type) {
+ case AuthenticationErrorCodes.INVALID_SUBJECT_TOKEN:
+ Alert.alert('Error', 'The external token is invalid or expired');
+ break;
+ case AuthenticationErrorCodes.UNSUPPORTED_TOKEN_TYPE:
+ Alert.alert('Error', 'The token type is not supported');
+ break;
+ case AuthenticationErrorCodes.TOKEN_EXCHANGE_NOT_CONFIGURED:
+ Alert.alert(
+ 'Error',
+ 'Custom Token Exchange is not configured for this tenant'
+ );
+ break;
+ case AuthenticationErrorCodes.TOKEN_VALIDATION_FAILED:
+ Alert.alert('Error', 'Token validation failed in Auth0 Action');
+ break;
+ case AuthenticationErrorCodes.NETWORK_ERROR:
+ Alert.alert('Error', 'Network error. Please check your connection.');
+ break;
+ default:
+ Alert.alert('Error', e.message);
+ }
+ } else {
+ console.error('Token exchange failed:', e);
+ }
+ }
+ };
+
+ return ;
+}
+```
+
+### Using Custom Token Exchange with Auth0 Class
+
+```typescript
+import Auth0, {
+ AuthenticationException,
+ AuthenticationErrorCodes,
+} from 'react-native-auth0';
+
+const auth0 = new Auth0({
+ domain: 'YOUR_AUTH0_DOMAIN',
+ clientId: 'YOUR_CLIENT_ID',
+});
+
+async function exchangeExternalToken(externalToken: string) {
+ try {
+ const credentials = await auth0.customTokenExchange({
+ subjectToken: externalToken,
+ subjectTokenType: 'urn:acme:legacy-system-token',
+ audience: 'https://api.example.com',
+ scope: 'openid profile email',
+ });
+
+ console.log('Exchange successful:', credentials);
+ return credentials;
+ } catch (error) {
+ if (error instanceof AuthenticationException) {
+ // Access the underlying error details
+ console.error('Error type:', error.type);
+ console.error('Error message:', error.message);
+ console.error('Underlying error code:', error.underlyingError.code);
+
+ // Handle specific error types
+ if (error.type === AuthenticationErrorCodes.INVALID_SUBJECT_TOKEN) {
+ // Token is invalid or expired - prompt user to re-authenticate
+ throw new Error('Please authenticate again with the external provider');
+ }
+ }
+ throw error;
+ }
+}
+```
+
+### With Organization Context
+
+Exchange tokens within a specific organization context:
+
+```typescript
+const credentials = await customTokenExchange({
+ subjectToken: 'external-provider-token',
+ subjectTokenType: 'urn:acme:legacy-system-token',
+ organization: 'org_123', // or organization name
+ scope: 'openid profile email',
+});
+```
+
+### Subject Token Type Requirements
+
+The `subjectTokenType` parameter must be a **unique profile token type URI** starting with `https://` or `urn:`.
+
+#### Valid Token Type Patterns
+
+You control the token type namespace. Use one of these patterns:
+
+**URN Format (Recommended):**
+
+- `urn:yourcompany:token-type` - Company-specific token type
+- `urn:acme:legacy-system-token` - Legacy system tokens
+- `urn:example:external-idp` - External IdP tokens
+
+**HTTPS URL Format:**
+
+- `https://yourcompany.com/tokens/legacy` - Using your organization's domain
+- `https://example.com/custom-token` - Custom token identifier
+
+#### Reserved Namespaces (Forbidden)
+
+The following namespaces are **reserved** and you **CANNOT use them**:
+
+- ❌ `http://auth0.com/*`
+- ❌ `https://auth0.com/*`
+- ❌ `http://okta.com/*`
+- ❌ `https://okta.com/*`
+- ❌ `urn:ietf:*`
+- ❌ `urn:auth0:*`
+- ❌ `urn:okta:*`
+
+#### Common Use Cases
+
+1. **Seamless Migration from Legacy IdP**: Exchange legacy refresh tokens
+
+ ```typescript
+ await customTokenExchange({
+ subjectToken: legacyRefreshToken,
+ subjectTokenType: 'urn:acme:legacy-system-token',
+ scope: 'openid profile email offline_access',
+ });
+ ```
+
+2. **External Authentication Provider**: Exchange tokens from partner IdP
+
+ ```typescript
+ await customTokenExchange({
+ subjectToken: externalProviderToken,
+ subjectTokenType: 'urn:partner:auth-token',
+ scope: 'openid profile email',
+ });
+ ```
+
+3. **Custom JWT Tokens**: Exchange JWTs from your own system
+ ```typescript
+ await customTokenExchange({
+ subjectToken: customJwt,
+ subjectTokenType: 'urn:yourcompany:jwt-token',
+ audience: 'https://api.example.com',
+ });
+ ```
+
+### Error Codes Reference
+
+Custom Token Exchange throws `AuthError` with specific error codes for different failure scenarios. Use the `code` property for programmatic error handling:
+
+```typescript
+try {
+ await auth0.customTokenExchange({...});
+} catch (error) {
+ console.error('Error code:', error.code);
+ console.error('Error message:', error.message);
+
+ // Handle specific errors
+ if (error.code === 'invalid_grant') {
+ // Handle invalid token
+ }
+}
+```
+
+| Error Code | Description |
+| ----------------------------------- | ---------------------------------------------------------- |
+| `custom_token_exchange_failed` | General token exchange failure |
+| `invalid_grant` | The external token is invalid, malformed, or expired |
+| `invalid_request` | The request is missing required parameters or is malformed |
+| `unsupported_token_type` | The token type is not supported or recognized |
+| `unauthorized_client` | Custom Token Exchange is not enabled for this client |
+| `invalid_target` | The requested audience is invalid or not allowed |
+| `invalid_scope` | The requested scope is invalid or not allowed |
+| `access_denied` | Token exchange was denied by the authorization server |
+| `server_error` | The authorization server encountered an internal error |
+| `temporarily_unavailable` | The server is temporarily unable to handle the request |
+| `network_error` | Network connectivity issue occurred |
+| `a0.token_exchange_failed` | Auth0-specific token exchange failure |
+| `a0.action_failed` | The token validation in Auth0 Action failed |
+| `a0.invalid_subject_token` | Subject token validation failed |
+| `a0.unsupported_subject_token_type` | Subject token type is not supported |
+
+These error codes follow:
+
+- **RFC 8693 standard**: `invalid_grant`, `invalid_request`, `unsupported_token_type`, `access_denied`, etc.
+- **Auth0-specific codes**: `a0.token_exchange_failed`, `a0.action_failed`, etc.
+
+### Auth0 Actions Validation
+
+Custom Token Exchange requires validation of the subject token in Auth0 Actions. The Action must:
+
+1. **Validate the subject token** cryptographically (verify signature, expiration, issuer, etc.)
+2. **Apply authorization policy** to determine if the exchange is allowed
+3. **Set the user** using one of the `api.authentication.setUser*()` methods
+
+For detailed examples of validating different token types in Actions, see:
+
+- [Auth0 Custom Token Exchange Documentation](https://auth0.com/docs/authenticate/custom-token-exchange)
+- [Example Use Cases](https://auth0.com/docs/authenticate/custom-token-exchange/cte-example-use-cases)
+
+**Security Best Practices:**
+
+- Use asymmetric algorithms (RS256, ES256) whenever possible
+- Store secrets in Actions Secrets, never hardcode them
+- Cache JWKS keys using `api.cache.set()` to improve performance
+- Validate token expiration, issuer, and audience claims
+- Implement rate limiting for failed validations using `api.access.rejectInvalidSubjectToken()`
+
## Native to Web SSO (Early Access)
> ⚠️ **Early Access Feature**: Native to Web SSO is currently available in Early Access. To use this feature, you must have an Enterprise plan. For more information, see [Product Release Stages](https://auth0.com/docs/troubleshoot/product-lifecycle/product-release-stages).
diff --git a/android/src/main/java/com/auth0/react/A0Auth0Module.kt b/android/src/main/java/com/auth0/react/A0Auth0Module.kt
index dbffe189..ade908c0 100644
--- a/android/src/main/java/com/auth0/react/A0Auth0Module.kt
+++ b/android/src/main/java/com/auth0/react/A0Auth0Module.kt
@@ -469,6 +469,44 @@ class A0Auth0Module(private val reactContext: ReactApplicationContext) : A0Auth0
)
}
+ @ReactMethod
+ override fun customTokenExchange(
+ subjectToken: String,
+ subjectTokenType: String,
+ audience: String?,
+ scope: String?,
+ organization: String?,
+ promise: Promise
+ ) {
+ val authClient = AuthenticationAPIClient(auth0!!)
+ if (useDPoP) {
+ authClient.useDPoP(reactContext)
+ }
+
+ val finalScope = scope ?: "openid profile email"
+
+ val request = authClient.customTokenExchange(
+ subjectTokenType = subjectTokenType,
+ subjectToken = subjectToken,
+ organization = organization
+ )
+
+ // Set audience and scope using the request builder methods
+ audience?.let { request.setAudience(it) }
+ request.setScope(finalScope)
+
+ request.start(object : com.auth0.android.callback.Callback {
+ override fun onSuccess(result: Credentials) {
+ val map = CredentialsParser.toMap(result)
+ promise.resolve(map)
+ }
+
+ override fun onFailure(error: AuthenticationException) {
+ handleError(error, promise)
+ }
+ })
+ }
+
override fun onActivityResult(activity: Activity, requestCode: Int, resultCode: Int, data: Intent?) {
// No-op
}
diff --git a/android/src/main/oldarch/com/auth0/react/A0Auth0Spec.kt b/android/src/main/oldarch/com/auth0/react/A0Auth0Spec.kt
index 8c028c7e..f0c26d10 100644
--- a/android/src/main/oldarch/com/auth0/react/A0Auth0Spec.kt
+++ b/android/src/main/oldarch/com/auth0/react/A0Auth0Spec.kt
@@ -109,4 +109,15 @@ abstract class A0Auth0Spec(context: ReactApplicationContext) : ReactContextBaseJ
@ReactMethod
@DoNotStrip
abstract fun getSSOCredentials(parameters: ReadableMap?, headers: ReadableMap?, promise: Promise)
+
+ @ReactMethod
+ @DoNotStrip
+ abstract fun customTokenExchange(
+ subjectToken: String,
+ subjectTokenType: String,
+ audience: String?,
+ scope: String?,
+ organization: String?,
+ promise: Promise
+ )
}
\ No newline at end of file
diff --git a/ios/A0Auth0.mm b/ios/A0Auth0.mm
index d3729630..94223abd 100644
--- a/ios/A0Auth0.mm
+++ b/ios/A0Auth0.mm
@@ -168,6 +168,16 @@ - (dispatch_queue_t)methodQueue
[self.nativeBridge getSSOCredentialsWithParameters:parameters headers:headers resolve:resolve reject:reject];
}
+RCT_EXPORT_METHOD(customTokenExchange:(NSString *)subjectToken
+ subjectTokenType:(NSString *)subjectTokenType
+ audience:(NSString * _Nullable)audience
+ scope:(NSString * _Nullable)scope
+ organization:(NSString * _Nullable)organization
+ resolve:(RCTPromiseResolveBlock)resolve
+ reject:(RCTPromiseRejectBlock)reject) {
+ [self.nativeBridge customTokenExchangeWithSubjectToken:subjectToken subjectTokenType:subjectTokenType audience:audience scope:scope organization:organization resolve:resolve reject:reject];
+}
+
diff --git a/ios/NativeBridge.swift b/ios/NativeBridge.swift
index 27f8fb90..01968069 100644
--- a/ios/NativeBridge.swift
+++ b/ios/NativeBridge.swift
@@ -380,6 +380,30 @@ public class NativeBridge: NSObject {
resolve(credentialsManager.clear(forAudience: audience, scope: scope))
}
+ @objc public func customTokenExchange(subjectToken: String, subjectTokenType: String, audience: String?, scope: String?, organization: String?, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
+ var auth = Auth0.authentication(clientId: self.clientId, domain: self.domain)
+ if self.useDPoP {
+ auth = auth.useDPoP()
+ }
+
+ let finalScope = scope ?? "openid profile email"
+
+ auth.customTokenExchange(
+ subjectToken: subjectToken,
+ subjectTokenType: subjectTokenType,
+ audience: audience,
+ scope: finalScope,
+ organization: organization
+ ).start { result in
+ switch result {
+ case .success(let credentials):
+ resolve(credentials.asDictionary())
+ case .failure(let error):
+ reject(error.code, error.localizedDescription, error)
+ }
+ }
+ }
+
@objc public func getClientId() -> String {
return clientId
}
diff --git a/src/Auth0.ts b/src/Auth0.ts
index d9276368..d01ec99b 100644
--- a/src/Auth0.ts
+++ b/src/Auth0.ts
@@ -1,7 +1,12 @@
import type { IAuth0Client } from './core/interfaces/IAuth0Client';
import type { TokenType } from './types/common';
import { Auth0ClientFactory } from './factory/Auth0ClientFactory';
-import type { Auth0Options, DPoPHeadersParams } from './types';
+import type {
+ Auth0Options,
+ DPoPHeadersParams,
+ CustomTokenExchangeParameters,
+ Credentials,
+} from './types';
/**
* The main Auth0 client class.
@@ -92,6 +97,30 @@ class Auth0 {
getDPoPHeaders(params: DPoPHeadersParams) {
return this.client.getDPoPHeaders(params);
}
+
+ /**
+ * Performs a Custom Token Exchange using RFC 8693.
+ * Exchanges an external identity provider token for Auth0 tokens.
+ *
+ * @param parameters The token exchange parameters.
+ * @returns A promise resolving with Auth0 credentials.
+ *
+ * @example
+ * ```typescript
+ * const credentials = await auth0.customTokenExchange({
+ * subjectToken: 'external-idp-token',
+ * subjectTokenType: 'urn:acme:external-idp-token',
+ * audience: 'https://api.example.com',
+ * scope: 'openid profile email',
+ * organization: 'org_abc123'
+ * });
+ * ```
+ */
+ customTokenExchange(
+ parameters: CustomTokenExchangeParameters
+ ): Promise {
+ return this.client.customTokenExchange(parameters);
+ }
}
export default Auth0;
diff --git a/src/core/interfaces/IAuth0Client.ts b/src/core/interfaces/IAuth0Client.ts
index db42feab..f54cb58b 100644
--- a/src/core/interfaces/IAuth0Client.ts
+++ b/src/core/interfaces/IAuth0Client.ts
@@ -2,7 +2,12 @@ import type { IWebAuthProvider } from './IWebAuthProvider';
import type { ICredentialsManager } from './ICredentialsManager';
import type { IAuthenticationProvider } from './IAuthenticationProvider';
import type { IUsersClient } from './IUsersClient';
-import type { DPoPHeadersParams, TokenType } from '../../types';
+import type {
+ DPoPHeadersParams,
+ TokenType,
+ CustomTokenExchangeParameters,
+ Credentials,
+} from '../../types';
/**
* The primary interface for the Auth0 client.
@@ -58,4 +63,15 @@ export interface IAuth0Client {
* ```
*/
getDPoPHeaders(params: DPoPHeadersParams): Promise>;
+
+ /**
+ * Performs a Custom Token Exchange using RFC 8693.
+ * Exchanges an external identity provider token for Auth0 tokens.
+ *
+ * @param parameters The token exchange parameters.
+ * @returns A promise resolving with Auth0 credentials.
+ */
+ customTokenExchange(
+ parameters: CustomTokenExchangeParameters
+ ): Promise;
}
diff --git a/src/hooks/Auth0Context.ts b/src/hooks/Auth0Context.ts
index 824d894a..80606697 100644
--- a/src/hooks/Auth0Context.ts
+++ b/src/hooks/Auth0Context.ts
@@ -16,6 +16,7 @@ import type {
LoginOtpParameters,
LoginRecoveryCodeParameters,
ExchangeNativeSocialParameters,
+ CustomTokenExchangeParameters,
RevokeOptions,
ResetPasswordParameters,
MfaChallengeResponse,
@@ -178,6 +179,29 @@ export interface Auth0ContextInterface extends AuthState {
parameters: ExchangeNativeSocialParameters
) => Promise;
+ /**
+ * Exchanges an external identity provider token for Auth0 tokens.
+ * Uses RFC 8693 OAuth 2.0 Token Exchange specification.
+ *
+ * @param parameters The token exchange parameters.
+ * @returns A promise that resolves with the user's Auth0 credentials.
+ * @throws {AuthError} If the token exchange fails.
+ *
+ * @example
+ * ```typescript
+ * const credentials = await customTokenExchange({
+ * subjectToken: 'external-provider-token',
+ * subjectTokenType: 'urn:acme:legacy-system-token',
+ * scope: 'openid profile email',
+ * audience: 'https://api.example.com',
+ * organization: 'org_123'
+ * });
+ * ```
+ */
+ customTokenExchange: (
+ parameters: CustomTokenExchangeParameters
+ ) => Promise;
+
/**
* Sends a verification code to the user's email.
* @param parameters The parameters for sending the email code.
@@ -346,6 +370,7 @@ const initialContext: Auth0ContextInterface = {
createUser: stub,
authorizeWithRecoveryCode: stub,
authorizeWithExchangeNativeSocial: stub,
+ customTokenExchange: stub,
sendEmailCode: stub,
sendSMSCode: stub,
authorizeWithEmail: stub,
diff --git a/src/hooks/Auth0Provider.tsx b/src/hooks/Auth0Provider.tsx
index 918f4848..fab641d0 100644
--- a/src/hooks/Auth0Provider.tsx
+++ b/src/hooks/Auth0Provider.tsx
@@ -21,6 +21,7 @@ import type {
LoginOtpParameters,
LoginRecoveryCodeParameters,
ExchangeNativeSocialParameters,
+ CustomTokenExchangeParameters,
RevokeOptions,
ResetPasswordParameters,
MfaChallengeResponse,
@@ -307,6 +308,12 @@ export const Auth0Provider = ({
[client, loginFlow]
);
+ const customTokenExchange = useCallback(
+ (parameters: CustomTokenExchangeParameters) =>
+ loginFlow(client.customTokenExchange(parameters)),
+ [client, loginFlow]
+ );
+
const sendEmailCode = useCallback(
(parameters: PasswordlessEmailParameters) =>
voidFlow(client.auth.passwordlessWithEmail(parameters)),
@@ -400,6 +407,7 @@ export const Auth0Provider = ({
resetPassword,
authorizeWithExchange,
authorizeWithExchangeNativeSocial,
+ customTokenExchange,
sendEmailCode,
authorizeWithEmail,
sendSMSCode,
@@ -428,6 +436,7 @@ export const Auth0Provider = ({
resetPassword,
authorizeWithExchange,
authorizeWithExchangeNativeSocial,
+ customTokenExchange,
sendEmailCode,
authorizeWithEmail,
sendSMSCode,
diff --git a/src/hooks/__tests__/Auth0Provider.spec.tsx b/src/hooks/__tests__/Auth0Provider.spec.tsx
index 31c60f39..4b6a6cca 100644
--- a/src/hooks/__tests__/Auth0Provider.spec.tsx
+++ b/src/hooks/__tests__/Auth0Provider.spec.tsx
@@ -1053,6 +1053,232 @@ describe('Auth0Provider', () => {
});
});
+ // Custom Token Exchange Tests
+ describe('customTokenExchange', () => {
+ const mockExchangeCredentials = {
+ idToken: 'exchanged.id.token',
+ accessToken: 'exchanged-access-token',
+ tokenType: 'Bearer',
+ expiresAt: Date.now() / 1000 + 3600,
+ scope: 'openid profile email',
+ refreshToken: 'exchanged-refresh-token',
+ };
+
+ const TestCustomTokenExchangeConsumer = () => {
+ const { customTokenExchange, user, error, isLoading } = useAuth0();
+
+ const handleExchange = () => {
+ customTokenExchange({
+ subjectToken: 'external-token',
+ subjectTokenType: 'urn:acme:legacy-token',
+ audience: 'https://api.example.com',
+ scope: 'openid profile email',
+ organization: 'org_123',
+ }).catch(() => {});
+ };
+
+ if (isLoading) return Loading...;
+ if (error) return Error: {error.message};
+
+ return (
+
+ {user ? (
+ Logged in as: {user.name}
+ ) : (
+ Not logged in
+ )}
+
+
+ );
+ };
+
+ beforeEach(() => {
+ mockClientInstance.customTokenExchange = jest
+ .fn()
+ .mockResolvedValue(mockExchangeCredentials);
+ });
+
+ it('should call customTokenExchange with all parameters', async () => {
+ await act(async () => {
+ render(
+
+
+
+ );
+ });
+
+ await waitFor(() =>
+ expect(screen.getByTestId('user-status')).toHaveTextContent(
+ 'Not logged in'
+ )
+ );
+
+ const exchangeButton = screen.getByTestId('exchange-button');
+ await act(async () => {
+ fireEvent.click(exchangeButton);
+ });
+
+ expect(mockClientInstance.customTokenExchange).toHaveBeenCalledWith({
+ subjectToken: 'external-token',
+ subjectTokenType: 'urn:acme:legacy-token',
+ audience: 'https://api.example.com',
+ scope: 'openid profile email',
+ organization: 'org_123',
+ });
+ });
+
+ it('should update user state after successful token exchange', async () => {
+ await act(async () => {
+ render(
+
+
+
+ );
+ });
+
+ await waitFor(() =>
+ expect(screen.getByTestId('user-status')).toHaveTextContent(
+ 'Not logged in'
+ )
+ );
+
+ const exchangeButton = screen.getByTestId('exchange-button');
+ await act(async () => {
+ fireEvent.click(exchangeButton);
+ });
+
+ await waitFor(() =>
+ expect(screen.getByTestId('user-status')).toHaveTextContent(
+ 'Logged in as: Test User'
+ )
+ );
+
+ // Should save credentials after exchange
+ expect(
+ mockClientInstance.credentialsManager.saveCredentials
+ ).toHaveBeenCalledWith(mockExchangeCredentials);
+ });
+
+ it('should handle customTokenExchange error and dispatch to state', async () => {
+ const exchangeError = new Error('Token exchange failed');
+ mockClientInstance.customTokenExchange.mockRejectedValueOnce(
+ exchangeError
+ );
+
+ await act(async () => {
+ render(
+
+
+
+ );
+ });
+
+ await waitFor(() =>
+ expect(screen.getByTestId('user-status')).toHaveTextContent(
+ 'Not logged in'
+ )
+ );
+
+ const exchangeButton = screen.getByTestId('exchange-button');
+ await act(async () => {
+ fireEvent.click(exchangeButton);
+ });
+
+ await waitFor(() => {
+ expect(screen.getByTestId('error')).toHaveTextContent(
+ 'Error: Token exchange failed'
+ );
+ });
+ });
+
+ it('should call customTokenExchange with minimal parameters', async () => {
+ const TestMinimalExchangeConsumer = () => {
+ const { customTokenExchange } = useAuth0();
+
+ const handleMinimalExchange = () => {
+ customTokenExchange({
+ subjectToken: 'external-token',
+ subjectTokenType: 'urn:acme:legacy-token',
+ }).catch(() => {});
+ };
+
+ return (
+
+ );
+ };
+
+ await act(async () => {
+ render(
+
+
+
+ );
+ });
+
+ const exchangeButton = screen.getByTestId('minimal-exchange-button');
+ await act(async () => {
+ fireEvent.click(exchangeButton);
+ });
+
+ expect(mockClientInstance.customTokenExchange).toHaveBeenCalledWith({
+ subjectToken: 'external-token',
+ subjectTokenType: 'urn:acme:legacy-token',
+ });
+ });
+
+ it('should return credentials from customTokenExchange', async () => {
+ let returnedCredentials: any = null;
+
+ const TestReturnValueConsumer = () => {
+ const { customTokenExchange } = useAuth0();
+
+ const handleExchange = async () => {
+ try {
+ returnedCredentials = await customTokenExchange({
+ subjectToken: 'external-token',
+ subjectTokenType: 'urn:acme:legacy-token',
+ });
+ } catch {
+ // ignore
+ }
+ };
+
+ return (
+
+ );
+ };
+
+ await act(async () => {
+ render(
+
+
+
+ );
+ });
+
+ const exchangeButton = screen.getByTestId('return-value-exchange-button');
+ await act(async () => {
+ fireEvent.click(exchangeButton);
+ });
+
+ await waitFor(() => {
+ expect(returnedCredentials).toEqual(mockExchangeCredentials);
+ });
+ });
+ });
+
// Web Platform Method Tests
describe('Web Platform Methods', () => {
it('should verify webAuth methods exist and are callable', () => {
diff --git a/src/platforms/native/adapters/NativeAuth0Client.ts b/src/platforms/native/adapters/NativeAuth0Client.ts
index 4b4e9abe..cb0946cd 100644
--- a/src/platforms/native/adapters/NativeAuth0Client.ts
+++ b/src/platforms/native/adapters/NativeAuth0Client.ts
@@ -4,7 +4,11 @@ import type {
IUsersClient,
} from '../../../core/interfaces';
import type { NativeAuth0Options } from '../../../types/platform-specific';
-import type { DPoPHeadersParams } from '../../../types';
+import type {
+ DPoPHeadersParams,
+ CustomTokenExchangeParameters,
+ Credentials,
+} from '../../../types';
import { NativeWebAuthProvider } from './NativeWebAuthProvider';
import { NativeCredentialsManager } from './NativeCredentialsManager';
import { type INativeBridge, NativeBridgeManager } from '../bridge';
@@ -25,6 +29,7 @@ export class NativeAuth0Client implements IAuth0Client {
private readonly tokenType: TokenType;
private readonly bridge: INativeBridge;
private readonly baseUrl: string;
+ private guardedBridge!: INativeBridge;
private readonly getDPoPHeadersForOrchestrator?: (
params: DPoPHeadersParams
) => Promise>;
@@ -53,6 +58,14 @@ export class NativeAuth0Client implements IAuth0Client {
? getDPoPHeadersForOrchestrator
: undefined;
+ this.ready = this.initialize(bridge, options);
+
+ // The adapters are now constructed with a "proxied" bridge that
+ // automatically awaits the `ready` promise before any call.
+ const guardedBridge = this.createGuardedBridge(bridge);
+ this.guardedBridge = guardedBridge;
+
+ // Use AuthenticationOrchestrator directly for standard auth methods
this.auth = new AuthenticationOrchestrator({
clientId: options.clientId,
httpClient: this.httpClient,
@@ -61,11 +74,6 @@ export class NativeAuth0Client implements IAuth0Client {
getDPoPHeaders: useDPoP ? getDPoPHeadersForOrchestrator : undefined,
});
- this.ready = this.initialize(bridge, options);
-
- // The adapters are now constructed with a "proxied" bridge that
- // automatically awaits the `ready` promise before any call.
- const guardedBridge = this.createGuardedBridge(bridge);
this.webAuth = new NativeWebAuthProvider(guardedBridge, options.domain);
this.credentialsManager = new NativeCredentialsManager(guardedBridge);
}
@@ -155,4 +163,27 @@ export class NativeAuth0Client implements IAuth0Client {
return guarded as INativeBridge;
}
+
+ /**
+ * Performs a Custom Token Exchange using RFC 8693.
+ * Exchanges an external identity provider token for Auth0 tokens.
+ *
+ * This method delegates directly to the native SDK bridge.
+ *
+ * @param parameters The token exchange parameters.
+ * @returns A promise resolving with Auth0 credentials.
+ */
+ async customTokenExchange(
+ parameters: CustomTokenExchangeParameters
+ ): Promise {
+ const { subjectToken, subjectTokenType, audience, scope, organization } =
+ parameters;
+ return this.guardedBridge.customTokenExchange(
+ subjectToken,
+ subjectTokenType,
+ audience,
+ scope,
+ organization
+ );
+ }
}
diff --git a/src/platforms/native/adapters/__tests__/NativeAuth0Client.spec.ts b/src/platforms/native/adapters/__tests__/NativeAuth0Client.spec.ts
index b4f4e279..0368800d 100644
--- a/src/platforms/native/adapters/__tests__/NativeAuth0Client.spec.ts
+++ b/src/platforms/native/adapters/__tests__/NativeAuth0Client.spec.ts
@@ -168,4 +168,113 @@ describe('NativeAuth0Client', () => {
await authorizePromise;
expect(mockBridgeInstance.authorize).toHaveBeenCalledTimes(1);
});
+
+ describe('customTokenExchange', () => {
+ const mockCredentials = {
+ idToken: 'mock-id-token',
+ accessToken: 'mock-access-token',
+ tokenType: 'Bearer',
+ expiresAt: Math.floor(Date.now() / 1000) + 3600,
+ refreshToken: 'mock-refresh-token',
+ scope: 'openid profile email',
+ };
+
+ beforeEach(() => {
+ // Add customTokenExchange to the mock methods
+ (mockBridgeInstance as any).customTokenExchange = jest
+ .fn()
+ .mockResolvedValue(mockCredentials);
+ });
+
+ it('should call customTokenExchange on the guarded bridge with all parameters', async () => {
+ const client = new NativeAuth0Client(options);
+ await new Promise(process.nextTick);
+
+ const parameters = {
+ subjectToken: 'external-token',
+ subjectTokenType: 'urn:acme:legacy-token',
+ audience: 'https://api.example.com',
+ scope: 'openid profile email',
+ organization: 'org_123',
+ };
+
+ await client.customTokenExchange(parameters);
+
+ expect(
+ (mockBridgeInstance as any).customTokenExchange
+ ).toHaveBeenCalledWith(
+ 'external-token',
+ 'urn:acme:legacy-token',
+ 'https://api.example.com',
+ 'openid profile email',
+ 'org_123'
+ );
+ });
+
+ it('should call customTokenExchange with only required parameters', async () => {
+ const client = new NativeAuth0Client(options);
+ await new Promise(process.nextTick);
+
+ const parameters = {
+ subjectToken: 'external-token',
+ subjectTokenType: 'urn:acme:legacy-token',
+ };
+
+ await client.customTokenExchange(parameters);
+
+ expect(
+ (mockBridgeInstance as any).customTokenExchange
+ ).toHaveBeenCalledWith(
+ 'external-token',
+ 'urn:acme:legacy-token',
+ undefined,
+ undefined,
+ undefined
+ );
+ });
+
+ it('should return credentials from customTokenExchange', async () => {
+ const client = new NativeAuth0Client(options);
+ await new Promise(process.nextTick);
+
+ const result = await client.customTokenExchange({
+ subjectToken: 'external-token',
+ subjectTokenType: 'urn:acme:legacy-token',
+ });
+
+ expect(result).toEqual(mockCredentials);
+ });
+
+ it('should wait for initialization before calling customTokenExchange', async () => {
+ let resolveInitialization: () => void;
+ const initializationPromise = new Promise((resolve) => {
+ resolveInitialization = resolve;
+ });
+
+ mockBridgeInstance.initialize.mockReturnValue(initializationPromise);
+ mockBridgeInstance.hasValidInstance.mockResolvedValue(false);
+
+ const client = new NativeAuth0Client(options);
+
+ const exchangePromise = client.customTokenExchange({
+ subjectToken: 'external-token',
+ subjectTokenType: 'urn:acme:legacy-token',
+ });
+
+ // Should not be called yet since initialization is pending
+ expect(
+ (mockBridgeInstance as any).customTokenExchange
+ ).not.toHaveBeenCalled();
+
+ // Resolve initialization
+ await act(async () => {
+ resolveInitialization!();
+ });
+
+ await exchangePromise;
+ expect(
+ (mockBridgeInstance as any).customTokenExchange
+ ).toHaveBeenCalledTimes(1);
+ });
+ });
});
diff --git a/src/platforms/native/bridge/INativeBridge.ts b/src/platforms/native/bridge/INativeBridge.ts
index 9462f031..184ebc3d 100644
--- a/src/platforms/native/bridge/INativeBridge.ts
+++ b/src/platforms/native/bridge/INativeBridge.ts
@@ -186,4 +186,23 @@ export interface INativeBridge {
parameters?: Record,
headers?: Record
): Promise;
+
+ /**
+ * Performs a Custom Token Exchange, exchanging an external provider's token
+ * for Auth0 credentials using RFC 8693 Token Exchange.
+ *
+ * @param subjectToken The external token to exchange.
+ * @param subjectTokenType The type identifier of the external token (URI).
+ * @param audience Optional target API identifier.
+ * @param scope Optional space-separated scopes.
+ * @param organization Optional organization ID or name.
+ * @returns A promise that resolves with Auth0 credentials.
+ */
+ customTokenExchange(
+ subjectToken: string,
+ subjectTokenType: string,
+ audience?: string,
+ scope?: string,
+ organization?: string
+ ): Promise;
}
diff --git a/src/platforms/native/bridge/NativeBridgeManager.ts b/src/platforms/native/bridge/NativeBridgeManager.ts
index c888a1b1..a5e3a6b7 100644
--- a/src/platforms/native/bridge/NativeBridgeManager.ts
+++ b/src/platforms/native/bridge/NativeBridgeManager.ts
@@ -224,4 +224,31 @@ export class NativeBridgeManager implements INativeBridge {
hdrs
);
}
+
+ async customTokenExchange(
+ subjectToken: string,
+ subjectTokenType: string,
+ audience?: string,
+ scope?: string,
+ organization?: string
+ ): Promise {
+ try {
+ const credential = await this.a0_call(
+ Auth0NativeModule.customTokenExchange.bind(Auth0NativeModule),
+ subjectToken,
+ subjectTokenType,
+ audience,
+ scope,
+ organization
+ );
+ return new CredentialsModel(credential);
+ } catch (e) {
+ // Convert to AuthError if needed, then throw directly
+ throw new AuthError(
+ e instanceof AuthError ? e.code : 'custom_token_exchange_failed',
+ e instanceof Error ? e.message : String(e),
+ { code: 'custom_token_exchange_failed', json: e }
+ );
+ }
+ }
}
diff --git a/src/platforms/native/bridge/__tests__/NativeBridgeManager.spec.ts b/src/platforms/native/bridge/__tests__/NativeBridgeManager.spec.ts
index 909290ec..aadec133 100644
--- a/src/platforms/native/bridge/__tests__/NativeBridgeManager.spec.ts
+++ b/src/platforms/native/bridge/__tests__/NativeBridgeManager.spec.ts
@@ -3,7 +3,25 @@ import { AuthError, Credentials } from '../../../../core/models';
import Auth0NativeModule from '../../../../specs/NativeA0Auth0';
// Mock the entire spec file, which is our native module.
-jest.mock('../../../../specs/NativeA0Auth0');
+jest.mock('../../../../specs/NativeA0Auth0', () => ({
+ webAuth: jest.fn(),
+ webAuthLogout: jest.fn(),
+ getCredentials: jest.fn(),
+ hasValidAuth0InstanceWithConfiguration: jest.fn(),
+ initializeAuth0WithConfiguration: jest.fn(),
+ getBundleIdentifier: jest.fn(),
+ saveCredentials: jest.fn(),
+ hasValidCredentials: jest.fn(),
+ clearCredentials: jest.fn(),
+ cancelWebAuth: jest.fn(),
+ resumeWebAuth: jest.fn(),
+ getDPoPHeaders: jest.fn(),
+ clearDPoPKey: jest.fn(),
+ getSSOCredentials: jest.fn(),
+ customTokenExchange: jest.fn(),
+ getApiCredentials: jest.fn(),
+ clearApiCredentials: jest.fn(),
+}));
const MockedAuth0NativeModule = Auth0NativeModule as jest.Mocked<
typeof Auth0NativeModule
>;
@@ -159,7 +177,7 @@ describe('NativeBridgeManager', () => {
code: 'a0.session.user_cancelled',
message: 'User cancelled the Auth',
};
- MockedAuth0NativeModule.webAuth.mockRejectedValueOnce(nativeError);
+ MockedAuth0NativeModule.webAuth.mockRejectedValue(nativeError);
await expect(bridge.authorize({} as any, {} as any)).rejects.toThrow(
AuthError
@@ -175,4 +193,97 @@ describe('NativeBridgeManager', () => {
}
});
});
+
+ describe('customTokenExchange', () => {
+ const mockExchangeResponse = {
+ idToken: validIdToken,
+ accessToken: 'exchanged-access-token',
+ tokenType: 'Bearer',
+ expiresAt: Math.floor(Date.now() / 1000) + 3600,
+ refreshToken: 'exchanged-refresh-token',
+ scope: 'openid profile email',
+ };
+
+ it('should call the native customTokenExchange method with all parameters', async () => {
+ MockedAuth0NativeModule.customTokenExchange.mockResolvedValueOnce(
+ mockExchangeResponse as any
+ );
+
+ await bridge.customTokenExchange(
+ 'external-token',
+ 'urn:acme:legacy-token',
+ 'https://api.example.com',
+ 'openid profile email',
+ 'org_123'
+ );
+
+ expect(MockedAuth0NativeModule.customTokenExchange).toHaveBeenCalledTimes(
+ 1
+ );
+ expect(MockedAuth0NativeModule.customTokenExchange).toHaveBeenCalledWith(
+ 'external-token',
+ 'urn:acme:legacy-token',
+ 'https://api.example.com',
+ 'openid profile email',
+ 'org_123'
+ );
+ });
+
+ it('should call native method with undefined for optional parameters', async () => {
+ MockedAuth0NativeModule.customTokenExchange.mockResolvedValueOnce(
+ mockExchangeResponse as any
+ );
+
+ await bridge.customTokenExchange(
+ 'external-token',
+ 'urn:acme:legacy-token'
+ );
+
+ expect(MockedAuth0NativeModule.customTokenExchange).toHaveBeenCalledWith(
+ 'external-token',
+ 'urn:acme:legacy-token',
+ undefined,
+ undefined,
+ undefined
+ );
+ });
+
+ it('should correctly transform the native response to a Credentials model', async () => {
+ MockedAuth0NativeModule.customTokenExchange.mockResolvedValueOnce(
+ mockExchangeResponse as any
+ );
+
+ const credentials = await bridge.customTokenExchange(
+ 'external-token',
+ 'urn:acme:legacy-token'
+ );
+
+ expect(credentials).toBeInstanceOf(Credentials);
+ expect(credentials.accessToken).toBe('exchanged-access-token');
+ expect(credentials.idToken).toBe(validIdToken);
+ expect(credentials.tokenType).toBe('Bearer');
+ });
+
+ it('should throw AuthError when native method fails', async () => {
+ const nativeError = {
+ code: 'a0.token_exchange_failed',
+ message: 'Token exchange failed',
+ };
+ MockedAuth0NativeModule.customTokenExchange.mockRejectedValue(
+ nativeError
+ );
+
+ await expect(
+ bridge.customTokenExchange('bad-token', 'urn:acme:legacy-token')
+ ).rejects.toThrow(AuthError);
+
+ try {
+ await bridge.customTokenExchange('bad-token', 'urn:acme:legacy-token');
+ } catch (e) {
+ const tokenExchangeError = e as AuthError;
+ expect(tokenExchangeError.message).toBe('Token exchange failed');
+ expect(tokenExchangeError.code).toBe('custom_token_exchange_failed');
+ }
+ });
+ });
});
diff --git a/src/platforms/web/adapters/WebAuth0Client.ts b/src/platforms/web/adapters/WebAuth0Client.ts
index 394b3fbc..b64240dd 100644
--- a/src/platforms/web/adapters/WebAuth0Client.ts
+++ b/src/platforms/web/adapters/WebAuth0Client.ts
@@ -3,9 +3,17 @@ import {
type Auth0ClientOptions,
type LogoutOptions,
} from '@auth0/auth0-spa-js';
-import type { IAuth0Client, IUsersClient } from '../../../core/interfaces';
+import type {
+ IAuth0Client,
+ IAuthenticationProvider,
+ IUsersClient,
+} from '../../../core/interfaces';
import type { WebAuth0Options } from '../../../types/platform-specific';
-import type { DPoPHeadersParams } from '../../../types';
+import type {
+ DPoPHeadersParams,
+ CustomTokenExchangeParameters,
+ Credentials,
+} from '../../../types';
import { WebWebAuthProvider } from './WebWebAuthProvider';
import { WebCredentialsManager } from './WebCredentialsManager';
import {
@@ -19,7 +27,7 @@ import { AuthError, DPoPError } from '../../../core/models';
export class WebAuth0Client implements IAuth0Client {
readonly webAuth: WebWebAuthProvider;
readonly credentialsManager: WebCredentialsManager;
- readonly auth: AuthenticationOrchestrator;
+ readonly auth: IAuthenticationProvider;
private readonly httpClient: HttpClient;
private readonly tokenType: TokenType;
@@ -198,4 +206,49 @@ export class WebAuth0Client implements IAuth0Client {
throw new DPoPError(authError);
}
}
+
+ /**
+ * Performs a Custom Token Exchange using RFC 8693.
+ * Exchanges an external identity provider token for Auth0 tokens.
+ *
+ * @param parameters The token exchange parameters.
+ * @returns A promise that resolves with Auth0 credentials.
+ */
+ async customTokenExchange(
+ parameters: CustomTokenExchangeParameters
+ ): Promise {
+ try {
+ const { subjectToken, subjectTokenType, audience, scope, organization } =
+ parameters;
+
+ // Apply default scope if not provided for consistency with native platforms
+ const finalScope = scope ?? 'openid profile email';
+
+ const response = await this.client.exchangeToken({
+ subject_token: subjectToken,
+ subject_token_type: subjectTokenType,
+ audience,
+ scope: finalScope,
+ organization,
+ });
+
+ // Convert expiresIn (seconds from now) to expiresAt (UNIX timestamp)
+ const expiresAt = Math.floor(Date.now() / 1000) + response.expires_in;
+
+ return {
+ accessToken: response.access_token,
+ idToken: response.id_token,
+ tokenType: (response.token_type as TokenType) ?? this.tokenType,
+ expiresAt,
+ scope: response.scope,
+ refreshToken: response.refresh_token,
+ };
+ } catch (e: any) {
+ throw new AuthError(
+ e.error ?? 'custom_token_exchange_failed',
+ e.error_description ?? e.message ?? 'Custom token exchange failed',
+ { json: e }
+ );
+ }
+ }
}
diff --git a/src/platforms/web/adapters/__tests__/WebAuth0Client.spec.ts b/src/platforms/web/adapters/__tests__/WebAuth0Client.spec.ts
index 8c9088e6..aafbf952 100644
--- a/src/platforms/web/adapters/__tests__/WebAuth0Client.spec.ts
+++ b/src/platforms/web/adapters/__tests__/WebAuth0Client.spec.ts
@@ -16,21 +16,47 @@ jest.mock('../../../../core/services/AuthenticationOrchestrator');
jest.mock('../../../../core/services/ManagementApiOrchestrator');
jest.mock('../../../../core/services/HttpClient');
-// Mock AuthError properly to support inheritance
-jest.mock('../../../../core/models', () => ({
- AuthError: class MockAuthError extends Error {
+// Mock AuthError and AuthenticationException properly to support inheritance
+jest.mock('../../../../core/models', () => {
+ class MockAuthError extends Error {
+ code: string;
+ json: any;
constructor(name: string, message: string, details?: any) {
super(message);
this.name = name;
+ this.code = details?.code ?? name;
+ this.json = details?.json;
if (details) {
Object.assign(this, details);
}
}
- },
- // Add other exports from models if needed
- Credentials: jest.fn(),
- Auth0User: jest.fn(),
-}));
+ }
+
+ class MockAuthenticationException extends Error {
+ type: string;
+ underlyingError: MockAuthError;
+ constructor(underlyingError: MockAuthError) {
+ super(underlyingError.message);
+ this.name = 'AuthenticationException';
+ this.underlyingError = underlyingError;
+ // Map error codes to types
+ const codeMap: Record = {
+ 'invalid_grant': 'INVALID_GRANT',
+ 'a0.token_exchange_failed': 'TOKEN_EXCHANGE_DENIED',
+ 'custom_token_exchange_failed': 'UNKNOWN_ERROR',
+ };
+ this.type = codeMap[underlyingError.code] ?? 'UNKNOWN_ERROR';
+ }
+ }
+
+ return {
+ AuthError: MockAuthError,
+ AuthenticationException: MockAuthenticationException,
+ // Add other exports from models if needed
+ Credentials: jest.fn(),
+ Auth0User: jest.fn(),
+ };
+});
const MockAuth0Client = Auth0Client as jest.MockedClass;
const MockWebWebAuthProvider = WebWebAuthProvider as jest.MockedClass<
@@ -309,4 +335,150 @@ describe('WebAuth0Client', () => {
expect(MockWebCredentialsManager).toHaveBeenCalledWith(mockSpaClient);
});
});
+
+ describe('customTokenExchange method', () => {
+ const mockExchangeResponse = {
+ access_token: 'exchanged-access-token',
+ id_token: 'exchanged-id-token',
+ token_type: 'Bearer',
+ expires_in: 3600,
+ scope: 'openid profile email',
+ refresh_token: 'exchanged-refresh-token',
+ };
+
+ beforeEach(() => {
+ mockSpaClient.exchangeToken = jest
+ .fn()
+ .mockResolvedValue(mockExchangeResponse);
+ });
+
+ it('should call exchangeToken on SPA client with all parameters', async () => {
+ const parameters = {
+ subjectToken: 'external-token',
+ subjectTokenType: 'urn:acme:legacy-token',
+ audience: 'https://api.example.com',
+ scope: 'openid profile email',
+ organization: 'org_123',
+ };
+
+ await client.customTokenExchange(parameters);
+
+ expect(mockSpaClient.exchangeToken).toHaveBeenCalledWith({
+ subject_token: 'external-token',
+ subject_token_type: 'urn:acme:legacy-token',
+ audience: 'https://api.example.com',
+ scope: 'openid profile email',
+ organization: 'org_123',
+ });
+ });
+
+ it('should call exchangeToken with only required parameters', async () => {
+ const parameters = {
+ subjectToken: 'external-token',
+ subjectTokenType: 'urn:acme:legacy-token',
+ };
+
+ await client.customTokenExchange(parameters);
+
+ expect(mockSpaClient.exchangeToken).toHaveBeenCalledWith({
+ subject_token: 'external-token',
+ subject_token_type: 'urn:acme:legacy-token',
+ audience: undefined,
+ scope: 'openid profile email', // Default scope applied
+ organization: undefined,
+ });
+ });
+
+ it('should return credentials with correct structure', async () => {
+ const result = await client.customTokenExchange({
+ subjectToken: 'external-token',
+ subjectTokenType: 'urn:acme:legacy-token',
+ });
+
+ expect(result.accessToken).toBe('exchanged-access-token');
+ expect(result.idToken).toBe('exchanged-id-token');
+ expect(result.tokenType).toBe('Bearer');
+ expect(result.scope).toBe('openid profile email');
+ expect(result.refreshToken).toBe('exchanged-refresh-token');
+ // expiresAt should be a timestamp in the future
+ expect(result.expiresAt).toBeGreaterThan(Math.floor(Date.now() / 1000));
+ });
+
+ it('should convert expires_in to expiresAt timestamp', async () => {
+ const beforeCall = Math.floor(Date.now() / 1000);
+
+ const result = await client.customTokenExchange({
+ subjectToken: 'external-token',
+ subjectTokenType: 'urn:acme:legacy-token',
+ });
+
+ const afterCall = Math.floor(Date.now() / 1000);
+
+ // expiresAt should be approximately now + expires_in (3600 seconds)
+ expect(result.expiresAt).toBeGreaterThanOrEqual(beforeCall + 3600);
+ expect(result.expiresAt).toBeLessThanOrEqual(afterCall + 3600);
+ });
+
+ it('should use client tokenType as fallback when response token_type is missing', async () => {
+ mockSpaClient.exchangeToken.mockResolvedValueOnce({
+ ...mockExchangeResponse,
+ token_type: undefined,
+ });
+
+ const result = await client.customTokenExchange({
+ subjectToken: 'external-token',
+ subjectTokenType: 'urn:acme:legacy-token',
+ });
+
+ // Should use client's default tokenType (DPoP)
+ expect(result.tokenType).toBe('DPoP');
+ });
+
+ it('should propagate errors from exchangeToken as AuthenticationException', async () => {
+ const exchangeError = {
+ error: 'invalid_grant',
+ error_description: 'Token exchange failed',
+ message: 'Token exchange failed',
+ };
+ mockSpaClient.exchangeToken.mockRejectedValueOnce(exchangeError);
+
+ await expect(
+ client.customTokenExchange({
+ subjectToken: 'bad-token',
+ subjectTokenType: 'urn:acme:legacy-token',
+ })
+ ).rejects.toThrow('Token exchange failed');
+
+ try {
+ await client.customTokenExchange({
+ subjectToken: 'bad-token',
+ subjectTokenType: 'urn:acme:legacy-token',
+ });
+ } catch (e: any) {
+ expect(e.name).toBe('AuthenticationException');
+ expect(e.type).toBe('INVALID_GRANT');
+ }
+ });
+
+ it('should wrap generic errors in AuthenticationException', async () => {
+ const genericError = new Error('Network error');
+ mockSpaClient.exchangeToken.mockRejectedValueOnce(genericError);
+
+ await expect(
+ client.customTokenExchange({
+ subjectToken: 'bad-token',
+ subjectTokenType: 'urn:acme:legacy-token',
+ })
+ ).rejects.toThrow('Network error');
+
+ try {
+ await client.customTokenExchange({
+ subjectToken: 'bad-token',
+ subjectTokenType: 'urn:acme:legacy-token',
+ });
+ } catch (e: any) {
+ expect(e.name).toBe('AuthenticationException');
+ }
+ });
+ });
});
diff --git a/src/specs/NativeA0Auth0.ts b/src/specs/NativeA0Auth0.ts
index 1a9c8d6b..58779f48 100644
--- a/src/specs/NativeA0Auth0.ts
+++ b/src/specs/NativeA0Auth0.ts
@@ -145,6 +145,18 @@ export interface Spec extends TurboModule {
idToken?: string;
refreshToken?: string;
}>;
+
+ /**
+ * Perform Custom Token Exchange (RFC 8693)
+ * Exchanges an external token for Auth0 tokens.
+ */
+ customTokenExchange(
+ subjectToken: string,
+ subjectTokenType: string,
+ audience: string | undefined,
+ scope: string | undefined,
+ organization: string | undefined
+ ): Promise;
}
export default TurboModuleRegistry.getEnforcing('A0Auth0');
diff --git a/src/types/parameters.ts b/src/types/parameters.ts
index 05a35956..6e33c2ff 100644
--- a/src/types/parameters.ts
+++ b/src/types/parameters.ts
@@ -114,6 +114,61 @@ export interface ExchangeNativeSocialParameters extends RequestOptions {
scope?: string;
}
+/**
+ * Parameters for Custom Token Exchange (RFC 8693).
+ * Exchanges an external identity provider token for Auth0 tokens.
+ *
+ * Custom Token Exchange allows you to exchange tokens from external identity
+ * providers for Auth0 tokens. The external token must be validated in Auth0
+ * Actions using cryptographic verification.
+ *
+ * @see https://auth0.com/docs/authenticate/custom-token-exchange
+ */
+export interface CustomTokenExchangeParameters {
+ /**
+ * The external token to be exchanged for Auth0 tokens.
+ * Must be validated in Auth0 Actions using cryptographic verification.
+ */
+ subjectToken: string;
+
+ /**
+ * The type identifier for the subject token being exchanged.
+ *
+ * Must be a unique profile token type URI starting with `https://` or `urn:`.
+ *
+ * Valid patterns:
+ * - `urn:yourcompany:token-type` - Company-specific URN (recommended)
+ * - `https://yourcompany.com/tokens/custom` - HTTPS URL under your control
+ *
+ * Reserved namespaces (forbidden):
+ * - `http://auth0.com/*`, `https://auth0.com/*`
+ * - `http://okta.com/*`, `https://okta.com/*`
+ * - `urn:ietf:*`, `urn:auth0:*`, `urn:okta:*`
+ *
+ * @example "urn:acme:legacy-system-token" // Custom legacy token
+ * @example "https://yourcompany.com/tokens/partner-jwt" // Custom HTTPS identifier
+ */
+ subjectTokenType: string;
+
+ /**
+ * The target audience for the requested Auth0 token.
+ * Must match an API identifier configured in your Auth0 tenant.
+ */
+ audience?: string;
+
+ /**
+ * Space-separated list of OAuth 2.0 scopes.
+ * @default "openid profile email"
+ */
+ scope?: string;
+
+ /**
+ * Organization ID or name for authenticating in an organization context.
+ * When provided, the organization ID will be present in the access token.
+ */
+ organization?: string;
+}
+
/**
* Parameters for authenticating with a username and password.
* @see https://auth0.com/docs/api-auth/grant/password