Skip to content

Commit 9027c92

Browse files
feat: add ssoExchange to Authentication API for Native-to-Web SSO (#1494)
1 parent df07be5 commit 9027c92

16 files changed

+568
-1
lines changed

EXAMPLES.md

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,9 @@
5454
- [Prerequisites](#native-to-web-sso-prerequisites)
5555
- [Using Native to Web SSO with Hooks](#using-native-to-web-sso-with-hooks)
5656
- [Using Native to Web SSO with Auth0 Class](#using-native-to-web-sso-with-auth0-class)
57+
- [SSO Exchange via Authentication API](#sso-exchange-via-authentication-api)
58+
- [Using SSO Exchange with Hooks](#using-sso-exchange-with-hooks)
59+
- [Using SSO Exchange with Auth0 Class](#using-sso-exchange-with-auth0-class)
5760
- [Sending the Session Transfer Token](#sending-the-session-transfer-token)
5861
- [Bot Protection](#bot-protection)
5962
- [Domain Switching](#domain-switching)
@@ -1104,6 +1107,75 @@ const webAppUrl = `https://your-web-app.com/login?session_transfer_token=${ssoCr
11041107
await Linking.openURL(webAppUrl);
11051108
```
11061109
1110+
### SSO Exchange via Authentication API
1111+
1112+
If your app manages tokens independently (without using the Credentials Manager), you can use `auth.ssoExchange()` to exchange a refresh token for a session transfer token directly via the Authentication API.
1113+
1114+
This is useful when:
1115+
1116+
- Your app stores tokens outside of the built-in Credentials Manager
1117+
- You need more control over the token exchange process
1118+
- You want to perform the exchange as a standalone API call
1119+
1120+
> **Note:** This method is only available on native platforms (iOS/Android). It is not supported on the web platform.
1121+
1122+
#### Using SSO Exchange with Hooks
1123+
1124+
```js
1125+
import { useAuth0 } from 'react-native-auth0';
1126+
import { Linking } from 'react-native';
1127+
1128+
function SSOExchangeScreen() {
1129+
const { ssoExchange } = useAuth0();
1130+
1131+
const handleSSOExchange = async (refreshToken) => {
1132+
try {
1133+
const ssoCredentials = await ssoExchange({ refreshToken });
1134+
1135+
console.log('Session Transfer Token:', ssoCredentials.sessionTransferToken);
1136+
console.log('Token Type:', ssoCredentials.tokenType);
1137+
console.log('Expires In:', ssoCredentials.expiresIn);
1138+
1139+
// Open your web application with the session transfer token
1140+
const webAppUrl = `https://your-web-app.com/login?session_transfer_token=${ssoCredentials.sessionTransferToken}`;
1141+
await Linking.openURL(webAppUrl);
1142+
} catch (error) {
1143+
console.error('SSO Exchange failed:', error);
1144+
}
1145+
};
1146+
1147+
return (
1148+
// Your UI components
1149+
);
1150+
}
1151+
```
1152+
1153+
#### Using SSO Exchange with Auth0 Class
1154+
1155+
```js
1156+
import Auth0 from 'react-native-auth0';
1157+
import { Linking } from 'react-native';
1158+
1159+
const auth0 = new Auth0({
1160+
domain: 'YOUR_AUTH0_DOMAIN',
1161+
clientId: 'YOUR_AUTH0_CLIENT_ID',
1162+
});
1163+
1164+
// You must already have a refresh token (e.g., from a previous login with offline_access scope)
1165+
const refreshToken = 'YOUR_REFRESH_TOKEN';
1166+
1167+
// Exchange the refresh token for a session transfer token
1168+
const ssoCredentials = await auth0.auth.ssoExchange({ refreshToken });
1169+
1170+
console.log('Session Transfer Token:', ssoCredentials.sessionTransferToken);
1171+
console.log('Token Type:', ssoCredentials.tokenType);
1172+
console.log('Expires In:', ssoCredentials.expiresIn);
1173+
1174+
// Open your web application with the session transfer token
1175+
const webAppUrl = `https://your-web-app.com/login?session_transfer_token=${ssoCredentials.sessionTransferToken}`;
1176+
await Linking.openURL(webAppUrl);
1177+
```
1178+
11071179
### Sending the Session Transfer Token
11081180
11091181
There are two ways to send the Session Transfer Token to your web application:

src/core/interfaces/IAuthenticationProvider.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type {
22
Credentials,
3+
SessionTransferCredentials,
34
User,
45
MfaChallengeResponse,
56
PasswordRealmParameters,
@@ -8,6 +9,7 @@ import type {
89
RevokeOptions,
910
ExchangeParameters,
1011
ExchangeNativeSocialParameters,
12+
SSOExchangeParameters,
1113
PasswordlessEmailParameters,
1214
PasswordlessSmsParameters,
1315
LoginEmailParameters,
@@ -48,4 +50,8 @@ export interface IAuthenticationProvider {
4850
exchangeNativeSocial(
4951
parameters: ExchangeNativeSocialParameters
5052
): Promise<Credentials>;
53+
54+
ssoExchange(
55+
parameters: SSOExchangeParameters
56+
): Promise<SessionTransferCredentials>;
5157
}

src/core/models/SSOCredentials.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import type {
2+
SessionTransferCredentials,
3+
SSOCredentialsResponse,
4+
} from '../../types';
5+
6+
/**
7+
* A class representation of SSO credentials returned by the session transfer token exchange.
8+
* Maps the raw API response (snake_case) to the SDK's SessionTransferCredentials type (camelCase).
9+
*/
10+
export class SSOCredentials implements SessionTransferCredentials {
11+
public sessionTransferToken: string;
12+
public tokenType: string;
13+
public expiresIn: number;
14+
public idToken?: string;
15+
public refreshToken?: string;
16+
17+
constructor(params: SessionTransferCredentials) {
18+
this.sessionTransferToken = params.sessionTransferToken;
19+
this.tokenType = params.tokenType;
20+
this.expiresIn = params.expiresIn;
21+
this.idToken = params.idToken;
22+
this.refreshToken = params.refreshToken;
23+
}
24+
25+
/**
26+
* Creates an SSOCredentials instance from a raw /oauth/token response.
27+
* The API returns `access_token` as the session transfer token and
28+
* `issued_token_type` as the token type.
29+
*/
30+
static fromResponse(response: SSOCredentialsResponse): SSOCredentials {
31+
return new SSOCredentials({
32+
sessionTransferToken: response.access_token,
33+
tokenType: response.issued_token_type,
34+
expiresIn: response.expires_in,
35+
idToken: response.id_token,
36+
refreshToken: response.refresh_token,
37+
});
38+
}
39+
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { SSOCredentials } from '../SSOCredentials';
2+
import type { SSOCredentialsResponse } from '../../../types';
3+
4+
describe('SSOCredentials', () => {
5+
describe('constructor', () => {
6+
it('should correctly assign all properties from the input object', () => {
7+
const data = {
8+
sessionTransferToken: 'stt_token_123',
9+
tokenType: 'urn:auth0:params:oauth:token-type:session_transfer_token',
10+
expiresIn: 120,
11+
idToken: 'id_token_456',
12+
refreshToken: 'refresh_token_789',
13+
};
14+
15+
const creds = new SSOCredentials(data);
16+
17+
expect(creds.sessionTransferToken).toBe(data.sessionTransferToken);
18+
expect(creds.tokenType).toBe(data.tokenType);
19+
expect(creds.expiresIn).toBe(data.expiresIn);
20+
expect(creds.idToken).toBe(data.idToken);
21+
expect(creds.refreshToken).toBe(data.refreshToken);
22+
});
23+
24+
it('should handle missing optional properties', () => {
25+
const data = {
26+
sessionTransferToken: 'stt_token_123',
27+
tokenType: 'urn:auth0:params:oauth:token-type:session_transfer_token',
28+
expiresIn: 120,
29+
};
30+
31+
const creds = new SSOCredentials(data);
32+
33+
expect(creds.idToken).toBeUndefined();
34+
expect(creds.refreshToken).toBeUndefined();
35+
});
36+
});
37+
38+
describe('fromResponse', () => {
39+
it('should correctly map snake_case API response to camelCase properties', () => {
40+
const response: SSOCredentialsResponse = {
41+
access_token: 'stt_token_123',
42+
issued_token_type:
43+
'urn:auth0:params:oauth:token-type:session_transfer_token',
44+
token_type: 'N_A',
45+
expires_in: 120,
46+
id_token: 'id_token_456',
47+
refresh_token: 'refresh_token_789',
48+
};
49+
50+
const creds = SSOCredentials.fromResponse(response);
51+
52+
expect(creds).toBeInstanceOf(SSOCredentials);
53+
expect(creds.sessionTransferToken).toBe(response.access_token);
54+
expect(creds.tokenType).toBe(response.issued_token_type);
55+
expect(creds.expiresIn).toBe(response.expires_in);
56+
expect(creds.idToken).toBe(response.id_token);
57+
expect(creds.refreshToken).toBe(response.refresh_token);
58+
});
59+
60+
it('should handle a response with no optional fields', () => {
61+
const response: SSOCredentialsResponse = {
62+
access_token: 'stt_token_123',
63+
issued_token_type:
64+
'urn:auth0:params:oauth:token-type:session_transfer_token',
65+
token_type: 'N_A',
66+
expires_in: 60,
67+
};
68+
69+
const creds = SSOCredentials.fromResponse(response);
70+
71+
expect(creds.idToken).toBeUndefined();
72+
expect(creds.refreshToken).toBeUndefined();
73+
});
74+
});
75+
});

src/core/models/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export { AuthError } from './AuthError';
22
export { Credentials } from './Credentials';
3+
export { SSOCredentials } from './SSOCredentials';
34
export { Auth0User } from './Auth0User';
45
export { ApiCredentials } from './ApiCredentials';
56
export {

src/core/services/AuthenticationOrchestrator.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { IAuthenticationProvider } from '../interfaces';
22
import type {
33
Credentials,
4+
SessionTransferCredentials,
45
User,
56
MfaChallengeResponse,
67
PasswordRealmParameters,
@@ -9,6 +10,7 @@ import type {
910
RevokeOptions,
1011
ExchangeParameters,
1112
ExchangeNativeSocialParameters,
13+
SSOExchangeParameters,
1214
PasswordlessEmailParameters,
1315
PasswordlessSmsParameters,
1416
LoginEmailParameters,
@@ -20,11 +22,13 @@ import type {
2022
ResetPasswordParameters,
2123
CreateUserParameters,
2224
NativeCredentialsResponse,
25+
SSOCredentialsResponse,
2326
AuthorizeUrlParameters,
2427
LogoutUrlParameters,
2528
} from '../../types';
2629
import {
2730
Credentials as CredentialsModel,
31+
SSOCredentials,
2832
Auth0User,
2933
AuthError,
3034
} from '../models';
@@ -457,4 +461,25 @@ export class AuthenticationOrchestrator implements IAuthenticationProvider {
457461
// The signup endpoint returns a snake_cased user profile.
458462
return deepCamelCase<Partial<User>>(json);
459463
}
464+
465+
async ssoExchange(
466+
parameters: SSOExchangeParameters
467+
): Promise<SessionTransferCredentials> {
468+
validateParameters(parameters, ['refreshToken']);
469+
const { headers, ...payload } = parameters;
470+
const domain = new URL(this.baseUrl).host;
471+
const body = {
472+
client_id: this.clientId,
473+
grant_type: 'refresh_token',
474+
audience: `urn:${domain}:session_transfer`,
475+
refresh_token: payload.refreshToken,
476+
};
477+
const { json, response } = await this.client.post<SSOCredentialsResponse>(
478+
'/oauth/token',
479+
body,
480+
headers
481+
);
482+
if (!response.ok) throw AuthError.fromResponse(response, json);
483+
return SSOCredentials.fromResponse(json);
484+
}
460485
}

src/core/services/__tests__/AuthenticationOrchestrator.spec.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
AuthError,
55
Auth0User,
66
Credentials as CredentialsModel,
7+
SSOCredentials,
78
} from '../../models';
89

910
// Mock HttpClient but preserve getAuthHeader
@@ -116,6 +117,7 @@ describe('AuthenticationOrchestrator', () => {
116117
orchestrator = new AuthenticationOrchestrator({
117118
clientId,
118119
httpClient: mockHttpClientInstance as unknown as HttpClient,
120+
baseUrl: baseUrl,
119121
});
120122
});
121123

@@ -860,4 +862,90 @@ describe('AuthenticationOrchestrator', () => {
860862
);
861863
});
862864
});
865+
866+
describe('sso exchange', () => {
867+
const ssoResponse = {
868+
access_token: 'session_transfer_token_value',
869+
issued_token_type:
870+
'urn:auth0:params:oauth:token-type:session_transfer_token',
871+
token_type: 'N_A',
872+
expires_in: 120,
873+
id_token: validIdToken,
874+
refresh_token: 'new_refresh_token',
875+
};
876+
877+
const parameters = {
878+
refreshToken: 'a refresh token of a user',
879+
};
880+
881+
it('should send correct payload with session_transfer audience', async () => {
882+
mockHttpClientInstance.post.mockResolvedValueOnce({
883+
json: ssoResponse,
884+
response: new Response(null, { status: 200 }),
885+
});
886+
await orchestrator.ssoExchange(parameters);
887+
888+
expect(mockHttpClientInstance.post).toHaveBeenCalledWith(
889+
'/oauth/token',
890+
expect.objectContaining({
891+
grant_type: 'refresh_token',
892+
client_id: clientId,
893+
refresh_token: parameters.refreshToken,
894+
audience: 'urn:samples.auth0.com:session_transfer',
895+
}),
896+
undefined
897+
);
898+
});
899+
900+
it('should return SSOCredentials instance', async () => {
901+
mockHttpClientInstance.post.mockResolvedValueOnce({
902+
json: ssoResponse,
903+
response: new Response(null, { status: 200 }),
904+
});
905+
906+
const result = await orchestrator.ssoExchange(parameters);
907+
908+
expect(result).toBeInstanceOf(SSOCredentials);
909+
expect(result.sessionTransferToken).toBe(ssoResponse.access_token);
910+
expect(result.tokenType).toBe(ssoResponse.issued_token_type);
911+
expect(result.expiresIn).toBe(ssoResponse.expires_in);
912+
expect(result.idToken).toBe(ssoResponse.id_token);
913+
expect(result.refreshToken).toBe(ssoResponse.refresh_token);
914+
});
915+
916+
it('should handle oauth error', async () => {
917+
mockHttpClientInstance.post.mockResolvedValueOnce({
918+
json: oauthErrorResponse,
919+
response: new Response(null, { status: 400 }),
920+
});
921+
922+
await expect(orchestrator.ssoExchange(parameters)).rejects.toThrow(
923+
AuthError
924+
);
925+
});
926+
927+
it('should pass custom headers', async () => {
928+
mockHttpClientInstance.post.mockResolvedValueOnce({
929+
json: ssoResponse,
930+
response: new Response(null, { status: 200 }),
931+
});
932+
await orchestrator.ssoExchange({
933+
refreshToken: 'a refresh token',
934+
headers: { 'X-Custom-Header': 'custom-value' },
935+
});
936+
937+
expect(mockHttpClientInstance.post).toHaveBeenCalledWith(
938+
'/oauth/token',
939+
expect.objectContaining({
940+
grant_type: 'refresh_token',
941+
refresh_token: 'a refresh token',
942+
}),
943+
{ 'X-Custom-Header': 'custom-value' }
944+
);
945+
});
946+
947+
it('should throw when refreshToken is missing', async () => {
948+
await expect(orchestrator.ssoExchange({} as any)).rejects.toThrow();
949+
});
950+
});
863951
});

0 commit comments

Comments
 (0)