Skip to content

Commit eca5a32

Browse files
feat: Add Custom Token Exchange support (#1433)
1 parent b711a4e commit eca5a32

19 files changed

Lines changed: 1247 additions & 21 deletions

File tree

EXAMPLES.md

Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,11 @@
4444
- [Using MRRT with Hooks](#using-mrrt-with-hooks)
4545
- [Using MRRT with Auth0 Class](#using-mrrt-with-auth0-class)
4646
- [Web Platform Configuration](#web-platform-configuration)
47+
- [Custom Token Exchange (RFC 8693)](#custom-token-exchange-rfc-8693)
48+
- [Using Custom Token Exchange with Hooks](#using-custom-token-exchange-with-hooks)
49+
- [Using Custom Token Exchange with Auth0 Class](#using-custom-token-exchange-with-auth0-class)
50+
- [With Organization Context](#with-organization-context)
51+
- [Subject Token Type Requirements](#subject-token-type-requirements)
4752
- [Native to Web SSO (Early Access)](#native-to-web-sso-early-access)
4853
- [Overview](#native-to-web-sso-overview)
4954
- [Prerequisites](#native-to-web-sso-prerequisites)
@@ -758,6 +763,250 @@ function App() {
758763
}
759764
```
760765
766+
## Custom Token Exchange (RFC 8693)
767+
768+
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.
769+
770+
> ⚠️ **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.
771+
772+
### Using Custom Token Exchange with Hooks
773+
774+
```typescript
775+
import React from 'react';
776+
import { Button, Alert } from 'react-native';
777+
import {
778+
useAuth0,
779+
AuthenticationException,
780+
AuthenticationErrorCodes,
781+
} from 'react-native-auth0';
782+
783+
function TokenExchangeScreen() {
784+
const { customTokenExchange, user, error } = useAuth0();
785+
786+
const handleExchange = async () => {
787+
try {
788+
// Exchange an external token for Auth0 tokens
789+
const credentials = await customTokenExchange({
790+
subjectToken: 'token-from-external-provider',
791+
subjectTokenType: 'urn:acme:legacy-system-token',
792+
scope: 'openid profile email',
793+
audience: 'https://api.example.com',
794+
});
795+
796+
Alert.alert('Success', `Logged in as ${user?.name}`);
797+
} catch (e) {
798+
if (e instanceof AuthenticationException) {
799+
switch (e.type) {
800+
case AuthenticationErrorCodes.INVALID_SUBJECT_TOKEN:
801+
Alert.alert('Error', 'The external token is invalid or expired');
802+
break;
803+
case AuthenticationErrorCodes.UNSUPPORTED_TOKEN_TYPE:
804+
Alert.alert('Error', 'The token type is not supported');
805+
break;
806+
case AuthenticationErrorCodes.TOKEN_EXCHANGE_NOT_CONFIGURED:
807+
Alert.alert(
808+
'Error',
809+
'Custom Token Exchange is not configured for this tenant'
810+
);
811+
break;
812+
case AuthenticationErrorCodes.TOKEN_VALIDATION_FAILED:
813+
Alert.alert('Error', 'Token validation failed in Auth0 Action');
814+
break;
815+
case AuthenticationErrorCodes.NETWORK_ERROR:
816+
Alert.alert('Error', 'Network error. Please check your connection.');
817+
break;
818+
default:
819+
Alert.alert('Error', e.message);
820+
}
821+
} else {
822+
console.error('Token exchange failed:', e);
823+
}
824+
}
825+
};
826+
827+
return <Button onPress={handleExchange} title="Exchange Token" />;
828+
}
829+
```
830+
831+
### Using Custom Token Exchange with Auth0 Class
832+
833+
```typescript
834+
import Auth0, {
835+
AuthenticationException,
836+
AuthenticationErrorCodes,
837+
} from 'react-native-auth0';
838+
839+
const auth0 = new Auth0({
840+
domain: 'YOUR_AUTH0_DOMAIN',
841+
clientId: 'YOUR_CLIENT_ID',
842+
});
843+
844+
async function exchangeExternalToken(externalToken: string) {
845+
try {
846+
const credentials = await auth0.customTokenExchange({
847+
subjectToken: externalToken,
848+
subjectTokenType: 'urn:acme:legacy-system-token',
849+
audience: 'https://api.example.com',
850+
scope: 'openid profile email',
851+
});
852+
853+
console.log('Exchange successful:', credentials);
854+
return credentials;
855+
} catch (error) {
856+
if (error instanceof AuthenticationException) {
857+
// Access the underlying error details
858+
console.error('Error type:', error.type);
859+
console.error('Error message:', error.message);
860+
console.error('Underlying error code:', error.underlyingError.code);
861+
862+
// Handle specific error types
863+
if (error.type === AuthenticationErrorCodes.INVALID_SUBJECT_TOKEN) {
864+
// Token is invalid or expired - prompt user to re-authenticate
865+
throw new Error('Please authenticate again with the external provider');
866+
}
867+
}
868+
throw error;
869+
}
870+
}
871+
```
872+
873+
### With Organization Context
874+
875+
Exchange tokens within a specific organization context:
876+
877+
```typescript
878+
const credentials = await customTokenExchange({
879+
subjectToken: 'external-provider-token',
880+
subjectTokenType: 'urn:acme:legacy-system-token',
881+
organization: 'org_123', // or organization name
882+
scope: 'openid profile email',
883+
});
884+
```
885+
886+
### Subject Token Type Requirements
887+
888+
The `subjectTokenType` parameter must be a **unique profile token type URI** starting with `https://` or `urn:`.
889+
890+
#### Valid Token Type Patterns
891+
892+
You control the token type namespace. Use one of these patterns:
893+
894+
**URN Format (Recommended):**
895+
896+
- `urn:yourcompany:token-type` - Company-specific token type
897+
- `urn:acme:legacy-system-token` - Legacy system tokens
898+
- `urn:example:external-idp` - External IdP tokens
899+
900+
**HTTPS URL Format:**
901+
902+
- `https://yourcompany.com/tokens/legacy` - Using your organization's domain
903+
- `https://example.com/custom-token` - Custom token identifier
904+
905+
#### Reserved Namespaces (Forbidden)
906+
907+
The following namespaces are **reserved** and you **CANNOT use them**:
908+
909+
- ❌ `http://auth0.com/*`
910+
- ❌ `https://auth0.com/*`
911+
- ❌ `http://okta.com/*`
912+
- ❌ `https://okta.com/*`
913+
- ❌ `urn:ietf:*`
914+
- ❌ `urn:auth0:*`
915+
- ❌ `urn:okta:*`
916+
917+
#### Common Use Cases
918+
919+
1. **Seamless Migration from Legacy IdP**: Exchange legacy refresh tokens
920+
921+
```typescript
922+
await customTokenExchange({
923+
subjectToken: legacyRefreshToken,
924+
subjectTokenType: 'urn:acme:legacy-system-token',
925+
scope: 'openid profile email offline_access',
926+
});
927+
```
928+
929+
2. **External Authentication Provider**: Exchange tokens from partner IdP
930+
931+
```typescript
932+
await customTokenExchange({
933+
subjectToken: externalProviderToken,
934+
subjectTokenType: 'urn:partner:auth-token',
935+
scope: 'openid profile email',
936+
});
937+
```
938+
939+
3. **Custom JWT Tokens**: Exchange JWTs from your own system
940+
```typescript
941+
await customTokenExchange({
942+
subjectToken: customJwt,
943+
subjectTokenType: 'urn:yourcompany:jwt-token',
944+
audience: 'https://api.example.com',
945+
});
946+
```
947+
948+
### Error Codes Reference
949+
950+
Custom Token Exchange throws `AuthError` with specific error codes for different failure scenarios. Use the `code` property for programmatic error handling:
951+
952+
```typescript
953+
try {
954+
await auth0.customTokenExchange({...});
955+
} catch (error) {
956+
console.error('Error code:', error.code);
957+
console.error('Error message:', error.message);
958+
959+
// Handle specific errors
960+
if (error.code === 'invalid_grant') {
961+
// Handle invalid token
962+
}
963+
}
964+
```
965+
966+
| Error Code | Description |
967+
| ----------------------------------- | ---------------------------------------------------------- |
968+
| `custom_token_exchange_failed` | General token exchange failure |
969+
| `invalid_grant` | The external token is invalid, malformed, or expired |
970+
| `invalid_request` | The request is missing required parameters or is malformed |
971+
| `unsupported_token_type` | The token type is not supported or recognized |
972+
| `unauthorized_client` | Custom Token Exchange is not enabled for this client |
973+
| `invalid_target` | The requested audience is invalid or not allowed |
974+
| `invalid_scope` | The requested scope is invalid or not allowed |
975+
| `access_denied` | Token exchange was denied by the authorization server |
976+
| `server_error` | The authorization server encountered an internal error |
977+
| `temporarily_unavailable` | The server is temporarily unable to handle the request |
978+
| `network_error` | Network connectivity issue occurred |
979+
| `a0.token_exchange_failed` | Auth0-specific token exchange failure |
980+
| `a0.action_failed` | The token validation in Auth0 Action failed |
981+
| `a0.invalid_subject_token` | Subject token validation failed |
982+
| `a0.unsupported_subject_token_type` | Subject token type is not supported |
983+
984+
These error codes follow:
985+
986+
- **RFC 8693 standard**: `invalid_grant`, `invalid_request`, `unsupported_token_type`, `access_denied`, etc.
987+
- **Auth0-specific codes**: `a0.token_exchange_failed`, `a0.action_failed`, etc.
988+
989+
### Auth0 Actions Validation
990+
991+
Custom Token Exchange requires validation of the subject token in Auth0 Actions. The Action must:
992+
993+
1. **Validate the subject token** cryptographically (verify signature, expiration, issuer, etc.)
994+
2. **Apply authorization policy** to determine if the exchange is allowed
995+
3. **Set the user** using one of the `api.authentication.setUser*()` methods
996+
997+
For detailed examples of validating different token types in Actions, see:
998+
999+
- [Auth0 Custom Token Exchange Documentation](https://auth0.com/docs/authenticate/custom-token-exchange)
1000+
- [Example Use Cases](https://auth0.com/docs/authenticate/custom-token-exchange/cte-example-use-cases)
1001+
1002+
**Security Best Practices:**
1003+
1004+
- Use asymmetric algorithms (RS256, ES256) whenever possible
1005+
- Store secrets in Actions Secrets, never hardcode them
1006+
- Cache JWKS keys using `api.cache.set()` to improve performance
1007+
- Validate token expiration, issuer, and audience claims
1008+
- Implement rate limiting for failed validations using `api.access.rejectInvalidSubjectToken()`
1009+
7611010
## Native to Web SSO (Early Access)
7621011

7631012
> ⚠️ **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).

android/src/main/java/com/auth0/react/A0Auth0Module.kt

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -469,6 +469,44 @@ class A0Auth0Module(private val reactContext: ReactApplicationContext) : A0Auth0
469469
)
470470
}
471471

472+
@ReactMethod
473+
override fun customTokenExchange(
474+
subjectToken: String,
475+
subjectTokenType: String,
476+
audience: String?,
477+
scope: String?,
478+
organization: String?,
479+
promise: Promise
480+
) {
481+
val authClient = AuthenticationAPIClient(auth0!!)
482+
if (useDPoP) {
483+
authClient.useDPoP(reactContext)
484+
}
485+
486+
val finalScope = scope ?: "openid profile email"
487+
488+
val request = authClient.customTokenExchange(
489+
subjectTokenType = subjectTokenType,
490+
subjectToken = subjectToken,
491+
organization = organization
492+
)
493+
494+
// Set audience and scope using the request builder methods
495+
audience?.let { request.setAudience(it) }
496+
request.setScope(finalScope)
497+
498+
request.start(object : com.auth0.android.callback.Callback<Credentials, AuthenticationException> {
499+
override fun onSuccess(result: Credentials) {
500+
val map = CredentialsParser.toMap(result)
501+
promise.resolve(map)
502+
}
503+
504+
override fun onFailure(error: AuthenticationException) {
505+
handleError(error, promise)
506+
}
507+
})
508+
}
509+
472510
override fun onActivityResult(activity: Activity, requestCode: Int, resultCode: Int, data: Intent?) {
473511
// No-op
474512
}

android/src/main/oldarch/com/auth0/react/A0Auth0Spec.kt

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,4 +109,15 @@ abstract class A0Auth0Spec(context: ReactApplicationContext) : ReactContextBaseJ
109109
@ReactMethod
110110
@DoNotStrip
111111
abstract fun getSSOCredentials(parameters: ReadableMap?, headers: ReadableMap?, promise: Promise)
112+
113+
@ReactMethod
114+
@DoNotStrip
115+
abstract fun customTokenExchange(
116+
subjectToken: String,
117+
subjectTokenType: String,
118+
audience: String?,
119+
scope: String?,
120+
organization: String?,
121+
promise: Promise
122+
)
112123
}

ios/A0Auth0.mm

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,16 @@ - (dispatch_queue_t)methodQueue
168168
[self.nativeBridge getSSOCredentialsWithParameters:parameters headers:headers resolve:resolve reject:reject];
169169
}
170170

171+
RCT_EXPORT_METHOD(customTokenExchange:(NSString *)subjectToken
172+
subjectTokenType:(NSString *)subjectTokenType
173+
audience:(NSString * _Nullable)audience
174+
scope:(NSString * _Nullable)scope
175+
organization:(NSString * _Nullable)organization
176+
resolve:(RCTPromiseResolveBlock)resolve
177+
reject:(RCTPromiseRejectBlock)reject) {
178+
[self.nativeBridge customTokenExchangeWithSubjectToken:subjectToken subjectTokenType:subjectTokenType audience:audience scope:scope organization:organization resolve:resolve reject:reject];
179+
}
180+
171181

172182

173183

ios/NativeBridge.swift

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -380,6 +380,30 @@ public class NativeBridge: NSObject {
380380
resolve(credentialsManager.clear(forAudience: audience, scope: scope))
381381
}
382382

383+
@objc public func customTokenExchange(subjectToken: String, subjectTokenType: String, audience: String?, scope: String?, organization: String?, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
384+
var auth = Auth0.authentication(clientId: self.clientId, domain: self.domain)
385+
if self.useDPoP {
386+
auth = auth.useDPoP()
387+
}
388+
389+
let finalScope = scope ?? "openid profile email"
390+
391+
auth.customTokenExchange(
392+
subjectToken: subjectToken,
393+
subjectTokenType: subjectTokenType,
394+
audience: audience,
395+
scope: finalScope,
396+
organization: organization
397+
).start { result in
398+
switch result {
399+
case .success(let credentials):
400+
resolve(credentials.asDictionary())
401+
case .failure(let error):
402+
reject(error.code, error.localizedDescription, error)
403+
}
404+
}
405+
}
406+
383407
@objc public func getClientId() -> String {
384408
return clientId
385409
}

0 commit comments

Comments
 (0)