Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion A0Auth0.podspec
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ Pod::Spec.new do |s|
s.source_files = 'ios/**/*.{h,m,mm,swift}'
s.requires_arc = true

s.dependency 'Auth0', '2.23.0'
s.dependency 'Auth0', '2.24.1'
s.dependency 'SimpleKeychain', '1.3.0'

install_modules_dependencies(s)
Expand Down
53 changes: 53 additions & 0 deletions EXAMPLES.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
- [Using Retry with Auth0 Class](#using-retry-with-auth0-class)
- [Platform Support](#platform-support)
- [Error Handling](#error-handling)
- [IPSIE Session Expiry](#ipsie-session-expiry)
- [Biometric Authentication](#biometric-authentication)
- [Biometric Policy Types](#biometric-policy-types)
- [Using with Auth0Provider (Hooks)](#using-with-auth0provider-hooks)
Expand Down Expand Up @@ -637,6 +638,58 @@ function MyComponent() {
2. **Configure adequate overlap period**: Ensure your Auth0 tenant has at least 180 seconds token overlap configured
3. **Test on real devices**: Simulate network instability during testing to validate retry behavior

## IPSIE Session Expiry

> **Platform Support:** iOS, Android, and Web.

Auth0 supports the [IPSIE SL1](https://openid.github.io/ipsie-openid-sl1/draft-openid-ipsie-sl1-profile.html) `session_expiry` claim, which lets an upstream identity provider (e.g. Okta) set a hard ceiling on how long an Auth0-issued session may live. When your enterprise connection has this option enabled, Auth0 includes a `session_expiry` Unix timestamp in the ID token returned to your app after login.

The underlying platform SDKs enforce this ceiling on every credential retrieval. Once the ceiling has passed, `getCredentials()` clears the stored credentials and rejects instead of attempting a token renewal — the user must re-authenticate. **No opt-in code is required**; enforcement is transparent once the connection option is active on your tenant.

`react-native-auth0` surfaces this as a single, cross-platform error type: `CredentialsManagerError` with `type === 'SESSION_EXPIRED'`. Your existing "no credentials" re-login path already handles it, or you can match it explicitly:

```jsx
import { useAuth0, CredentialsManagerError } from 'react-native-auth0';

function MyComponent() {
const { getCredentials, authorize } = useAuth0();

const fetchCredentials = async () => {
try {
const credentials = await getCredentials();
return credentials;
} catch (error) {
if (
error instanceof CredentialsManagerError &&
error.type === 'SESSION_EXPIRED'
) {
// Upstream IdP session has ended — send the user back to login.
await authorize({ scope: 'openid profile offline_access' });
} else {
throw error;
}
}
};

// ...
}
```

If you need to read the ceiling directly — for example to warn the user before their session ends — it is exposed as `sessionExpiresAt` (an absolute Unix timestamp, in seconds) on the returned `Credentials`. It is `undefined` when the connection does not emit the claim:

```jsx
const credentials = await getCredentials();
if (credentials.sessionExpiresAt) {
const endsAt = new Date(credentials.sessionExpiresAt * 1000);
console.log(`Upstream IdP session ends at: ${endsAt.toISOString()}`);
}
```

This value is decoded from the current ID token's `session_expiry` claim, except on Android where the credentials manager reports the ceiling pinned at the initial login (the value it actually enforces) when one is stored. It is also readable directly from the raw `session_expiry` claim on the decoded ID token — see [Parse user profile from an ID token locally](#parse-user-profile-from-an-id-token-locally).

> [!NOTE]
> On Android, the `session_expiry` ceiling is pinned at the initial login and is not raised by subsequent refresh-token grants. On iOS and Web, `sessionExpiresAt` is derived from the current ID token. Sessions from connections **without** the claim behave exactly as before.

## Biometric Authentication

> **Platform Support:** Native only (iOS/Android)
Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -730,6 +730,11 @@ try {
'DPoP credential state error. Clear credentials and re-authenticate.'
);
break;
case CredentialsManagerErrorCodes.SESSION_EXPIRED:
console.log(
'Upstream IdP session ceiling reached. Clear credentials and restart the login flow.'
);
break;
default:
console.error('Credentials error:', error.message);
}
Expand All @@ -754,6 +759,9 @@ try {
| `DPOP_KEY_MISSING` | `DPOP_KEY_MISSING` | `dpopKeyMissing` | |
| `DPOP_NOT_CONFIGURED` | `DPOP_NOT_CONFIGURED` | `dpopNotConfigured` | |
| `DPOP_KEY_MISMATCH` | `DPOP_KEY_MISMATCH` | `dpopKeyMismatch` | |
| `SESSION_EXPIRED` | `SESSION_EXPIRED` | `sessionExpired` | `session_expired` |

> **`SESSION_EXPIRED`** is raised when the upstream IdP session ceiling (IPSIE `session_expiry` claim) has been reached. The local session is no longer valid and the user must re-authenticate. See [IPSIE Session Expiry](https://github.com/auth0/react-native-auth0/blob/master/EXAMPLES.md#ipsie-session-expiry) for details.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

### MFA errors

Expand Down
3 changes: 2 additions & 1 deletion android/src/main/java/com/auth0/react/A0Auth0Module.kt
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,8 @@ class A0Auth0Module(private val reactContext: ReactApplicationContext) : A0Auth0
CredentialsManagerException.NO_NETWORK to "NO_NETWORK",
CredentialsManagerException.DPOP_KEY_MISSING to "DPOP_KEY_MISSING",
CredentialsManagerException.DPOP_NOT_CONFIGURED to "DPOP_NOT_CONFIGURED",
CredentialsManagerException.DPOP_KEY_MISMATCH to "DPOP_KEY_MISMATCH"
CredentialsManagerException.DPOP_KEY_MISMATCH to "DPOP_KEY_MISMATCH",
CredentialsManagerException.SESSION_EXPIRED to "SESSION_EXPIRED"
)
// DPoP enabled by default
private var useDPoP: Boolean = true
Expand Down
4 changes: 4 additions & 0 deletions android/src/main/java/com/auth0/react/CredentialsParser.kt
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ object CredentialsParser {
private const val ACCESS_TOKEN_KEY = "accessToken"
private const val ID_TOKEN_KEY = "idToken"
private const val EXPIRES_AT_KEY = "expiresAt"
private const val SESSION_EXPIRES_AT_KEY = "sessionExpiresAt"
private const val SCOPE = "scope"
private const val REFRESH_TOKEN_KEY = "refreshToken"
private const val TOKEN_TYPE_KEY = "tokenType"
Expand All @@ -23,6 +24,9 @@ object CredentialsParser {
map.putString(SCOPE, credentials.scope)
map.putString(REFRESH_TOKEN_KEY, credentials.refreshToken)
map.putString(TOKEN_TYPE_KEY, credentials.type)
credentials.sessionExpiresAt?.let {
map.putDouble(SESSION_EXPIRES_AT_KEY, it.toDouble())
}
return map
}

Expand Down
10 changes: 5 additions & 5 deletions example/ios/Podfile.lock
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
PODS:
- A0Auth0 (5.9.0):
- Auth0 (= 2.23.0)
- A0Auth0 (5.10.0):
- Auth0 (= 2.24.1)
- hermes-engine
- RCTRequired
- RCTTypeSafety
Expand All @@ -23,7 +23,7 @@ PODS:
- ReactNativeDependencies
- SimpleKeychain (= 1.3.0)
- Yoga
- Auth0 (2.23.0):
- Auth0 (2.24.1):
- JWTDecode (= 3.3.0)
- SimpleKeychain (= 1.3.0)
- FBLazyVector (0.86.0)
Expand Down Expand Up @@ -2242,8 +2242,8 @@ EXTERNAL SOURCES:
:path: "../node_modules/react-native/ReactCommon/yoga"

SPEC CHECKSUMS:
A0Auth0: 260f4197bc27923b13525663cc625cbb01ac4e4a
Auth0: dd46c309079f995e91dfd7af38e8fefd1a43bf4e
A0Auth0: 88b0133241904699bfb3234731983d30dbf9cba0
Auth0: 700271885e7d3bab08f372f7e119adb7b2ba662b
FBLazyVector: b3e7ad108f0d882e30445c5527d774e3fd432f3d
hermes-engine: 4ba8c4848d46c6a441e09d1c62f883ba69bc5b76
JWTDecode: 1ca6f765844457d0dd8690436860fecee788f631
Expand Down
8 changes: 7 additions & 1 deletion ios/NativeBridge.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ public class NativeBridge: NSObject {
static let accessTokenKey = "accessToken";
static let idTokenKey = "idToken";
static let expiresAtKey = "expiresAt";
static let sessionExpiresAtKey = "sessionExpiresAt";
static let scopeKey = "scope";
static let refreshTokenKey = "refreshToken";
static let typeKey = "type";
Expand Down Expand Up @@ -664,14 +665,18 @@ public class NativeBridge: NSObject {

extension Credentials {
func asDictionary() -> [String: Any] {
return [
var dict: [String: Any] = [
NativeBridge.accessTokenKey: self.accessToken,
NativeBridge.tokenTypeKey: self.tokenType,
NativeBridge.idTokenKey: self.idToken,
NativeBridge.refreshTokenKey: self.refreshToken as Any,
NativeBridge.expiresAtKey: floor(self.expiresIn.timeIntervalSince1970),
NativeBridge.scopeKey: self.scope as Any
]
if let sessionExpiresAt = self.sessionExpiresAt {
dict[NativeBridge.sessionExpiresAtKey] = floor(sessionExpiresAt.timeIntervalSince1970)
}
return dict
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}

Expand Down Expand Up @@ -754,6 +759,7 @@ extension CredentialsManagerError {
case CredentialsManagerError.dpopKeyMissing: code = "DPOP_KEY_MISSING"
case CredentialsManagerError.dpopKeyMismatch: code = "DPOP_KEY_MISMATCH"
case CredentialsManagerError.dpopNotConfigured: code = "DPOP_NOT_CONFIGURED"
case CredentialsManagerError.sessionExpired: code = "SESSION_EXPIRED"
default: code = "UNKNOWN"
}
return code
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@
"typescript-eslint": "^8.62.0"
},
"dependencies": {
"@auth0/auth0-spa-js": "2.19.3",
"@auth0/auth0-spa-js": "^2.23.0",
"base-64": "^1.0.0",
"jwt-decode": "^4.0.0",
"url": "^0.11.4"
Expand Down
6 changes: 6 additions & 0 deletions src/core/models/Credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ export class Credentials implements ICredentials {
public expiresAt: number;
public refreshToken?: string;
public scope?: string;
/**
* The upstream IdP session ceiling as a UNIX timestamp (in seconds), asserted via the
* IPSIE `session_expiry` claim. `undefined` when the ID token lacks the claim.
*/
public sessionExpiresAt?: number;
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/**
* Creates an instance of Credentials.
Expand All @@ -27,6 +32,7 @@ export class Credentials implements ICredentials {
this.expiresAt = params.expiresAt;
this.refreshToken = params.refreshToken;
this.scope = params.scope;
this.sessionExpiresAt = params.sessionExpiresAt;
}

/**
Expand Down
11 changes: 11 additions & 0 deletions src/core/models/CredentialsManagerError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,11 @@ export const CredentialsManagerErrorCodes = {
REVOKE_FAILED: 'REVOKE_FAILED',
/** Requested minimum TTL exceeds token lifetime */
LARGE_MIN_TTL: 'LARGE_MIN_TTL',
/**
* The upstream IdP session ceiling (IPSIE `session_expiry`) has been reached.
* The local session is no longer valid and the user must re-authenticate.
*/
SESSION_EXPIRED: 'SESSION_EXPIRED',
/** Generic credentials manager error */
CREDENTIAL_MANAGER_ERROR: 'CREDENTIAL_MANAGER_ERROR',
/** Biometric authentication failed */
Expand Down Expand Up @@ -80,6 +85,8 @@ const ERROR_CODE_MAP: Record<string, string> = {
STORE_FAILED: CredentialsManagerErrorCodes.STORE_FAILED,
REVOKE_FAILED: CredentialsManagerErrorCodes.REVOKE_FAILED,
LARGE_MIN_TTL: CredentialsManagerErrorCodes.LARGE_MIN_TTL,
// IPSIE session_expiry ceiling reached (Android SESSION_EXPIRED, iOS sessionExpired)
SESSION_EXPIRED: CredentialsManagerErrorCodes.SESSION_EXPIRED,
CREDENTIAL_MANAGER_ERROR:
CredentialsManagerErrorCodes.CREDENTIAL_MANAGER_ERROR,
BIOMETRICS_FAILED: CredentialsManagerErrorCodes.BIOMETRICS_FAILED,
Expand All @@ -104,6 +111,7 @@ const ERROR_CODE_MAP: Record<string, string> = {
invalid_scope: CredentialsManagerErrorCodes.API_ERROR,
server_error: CredentialsManagerErrorCodes.API_ERROR,
temporarily_unavailable: CredentialsManagerErrorCodes.NO_NETWORK,
session_expired: CredentialsManagerErrorCodes.SESSION_EXPIRED,

// --- iOS-specific mappings ---
renewFailed: CredentialsManagerErrorCodes.RENEW_FAILED,
Expand All @@ -112,6 +120,7 @@ const ERROR_CODE_MAP: Record<string, string> = {
noRefreshToken: CredentialsManagerErrorCodes.NO_REFRESH_TOKEN,
storeFailed: CredentialsManagerErrorCodes.STORE_FAILED,
largeMinTTL: CredentialsManagerErrorCodes.LARGE_MIN_TTL,
sessionExpired: CredentialsManagerErrorCodes.SESSION_EXPIRED,

// --- Many-to-one mapping for granular Android Biometric errors ---
INCOMPATIBLE_DEVICE: CredentialsManagerErrorCodes.INCOMPATIBLE_DEVICE,
Expand Down Expand Up @@ -178,6 +187,7 @@ const ERROR_CODE_MAP: Record<string, string> = {
* - `STORE_FAILED`: Failed to store credentials
* - `REVOKE_FAILED`: Failed to revoke refresh token
* - `LARGE_MIN_TTL`: Requested minimum TTL exceeds token lifetime
* - `SESSION_EXPIRED`: Upstream IdP session ceiling (IPSIE `session_expiry`) reached - re-authentication required
*
* ### API Credentials (MRRT):
* - `API_EXCHANGE_FAILED`: Failed to exchange refresh token for API-specific credentials
Expand Down Expand Up @@ -307,6 +317,7 @@ export class CredentialsManagerError extends AuthError {
* - `STORE_FAILED`: Failed to store credentials
* - `REVOKE_FAILED`: Failed to revoke refresh token
* - `LARGE_MIN_TTL`: Requested minimum TTL exceeds token lifetime
* - `SESSION_EXPIRED`: Upstream IdP session ceiling (IPSIE `session_expiry`) reached
* - `BIOMETRICS_FAILED`: Biometric authentication failed
* - `INCOMPATIBLE_DEVICE`: Device incompatible with secure storage
* - `CRYPTO_EXCEPTION`: Cryptographic operation failed
Expand Down
3 changes: 3 additions & 0 deletions src/core/models/__tests__/Credentials.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ describe('Credentials', () => {
expiresAt: 1672578000, // Some time in the future
refreshToken: 'refresh_token_789',
scope: 'openid profile email',
sessionExpiresAt: 1893456000,
};

const credentials = new Credentials(credsData);
Expand All @@ -34,6 +35,7 @@ describe('Credentials', () => {
expect(credentials.expiresAt).toBe(credsData.expiresAt);
expect(credentials.refreshToken).toBe(credsData.refreshToken);
expect(credentials.scope).toBe(credsData.scope);
expect(credentials.sessionExpiresAt).toBe(credsData.sessionExpiresAt);
});

it('should handle missing optional properties', () => {
Expand All @@ -48,6 +50,7 @@ describe('Credentials', () => {

expect(credentials.refreshToken).toBeUndefined();
expect(credentials.scope).toBeUndefined();
expect(credentials.sessionExpiresAt).toBeUndefined();
});
});

Expand Down
7 changes: 5 additions & 2 deletions src/core/models/__tests__/ErrorCodes.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,9 @@ describe('Error Code Constants', () => {
expect(CredentialsManagerErrorCodes.STORE_FAILED).toBe('STORE_FAILED');
expect(CredentialsManagerErrorCodes.REVOKE_FAILED).toBe('REVOKE_FAILED');
expect(CredentialsManagerErrorCodes.LARGE_MIN_TTL).toBe('LARGE_MIN_TTL');
expect(CredentialsManagerErrorCodes.SESSION_EXPIRED).toBe(
'SESSION_EXPIRED'
);
expect(CredentialsManagerErrorCodes.CREDENTIAL_MANAGER_ERROR).toBe(
'CREDENTIAL_MANAGER_ERROR'
);
Expand Down Expand Up @@ -123,9 +126,9 @@ describe('Error Code Constants', () => {
);
});

it('should have exactly 18 error codes', () => {
it('should have exactly 19 error codes', () => {
const keys = Object.keys(CredentialsManagerErrorCodes);
expect(keys).toHaveLength(18);
expect(keys).toHaveLength(19);
});

it('should be immutable (as const)', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,38 @@ describe('Common Error Handling - NativeCredentialsManager', () => {
});
});

describe('IPSIE session_expiry ceiling', () => {
const sessionExpiredTestCases = [
{
code: 'SESSION_EXPIRED',
message: 'The session has expired.',
},
{
code: 'sessionExpired',
message: 'The session has expired.',
},
];

sessionExpiredTestCases.forEach(({ code, message }) => {
it(`should map ${code} to SESSION_EXPIRED`, async () => {
const nativeError = { code, message };
mockBridge.getCredentials.mockRejectedValue(nativeError);

await expect(manager.getCredentials()).rejects.toThrow(
CredentialsManagerError
);

try {
await manager.getCredentials();
} catch (e) {
const err = e as CredentialsManagerError;
expect(err.type).toBe('SESSION_EXPIRED');
expect(err.message).toBe(message);
}
});
});
});

describe('Android Biometric Error Mappings', () => {
const biometricErrorTestCases = [
'BIOMETRIC_NO_ACTIVITY',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,21 @@ describe('NativeCredentialsManager', () => {

expect(result).toEqual(expectedCredentials);
});

it('should pass through sessionExpiresAt from the bridge', async () => {
const expectedCredentials = {
idToken: 'a',
accessToken: 'b',
tokenType: 'c',
expiresAt: 123,
sessionExpiresAt: 1893456000,
};
mockBridge.getCredentials.mockResolvedValueOnce(expectedCredentials);

const result = await manager.getCredentials();

expect(result.sessionExpiresAt).toBe(1893456000);
});
});

describe('hasValidCredentials', () => {
Expand Down
Loading
Loading