From 7faea42ed321ae915da5488340b8339101d75854 Mon Sep 17 00:00:00 2001 From: Subhankar Maiti Date: Thu, 16 Jul 2026 15:31:32 +0530 Subject: [PATCH 1/4] feat: enforce IPSIE session_expiry with a SESSION_EXPIRED error --- A0Auth0.podspec | 2 +- EXAMPLES.md | 43 +++++++++++++++++++ README.md | 3 ++ .../java/com/auth0/react/A0Auth0Module.kt | 3 +- ios/NativeBridge.swift | 1 + package.json | 2 +- src/core/models/CredentialsManagerError.ts | 11 +++++ src/core/models/__tests__/ErrorCodes.spec.ts | 7 ++- .../NativeCredentialsManager.errors.spec.ts | 32 ++++++++++++++ .../web/adapters/WebCredentialsManager.ts | 18 ++++++++ .../WebCredentialsManager.errors.spec.ts | 21 +++++++++ yarn.lock | 32 +++++++------- 12 files changed, 154 insertions(+), 21 deletions(-) diff --git a/A0Auth0.podspec b/A0Auth0.podspec index 5c1100439..f38a668c8 100644 --- a/A0Auth0.podspec +++ b/A0Auth0.podspec @@ -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.0' s.dependency 'SimpleKeychain', '1.3.0' install_modules_dependencies(s) diff --git a/EXAMPLES.md b/EXAMPLES.md index feb0aefca..517ecbeac 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -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) @@ -637,6 +638,48 @@ 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, it is available as the `session_expiry` claim (absolute Unix timestamp, in seconds) on the decoded ID token — see [Parse user profile from an ID token locally](#parse-user-profile-from-an-id-token-locally). + +> [!NOTE] +> The `session_expiry` ceiling is pinned at the initial login and is not raised by subsequent refresh-token grants. Sessions from connections **without** the claim behave exactly as before. + ## Biometric Authentication > **Platform Support:** Native only (iOS/Android) diff --git a/README.md b/README.md index b62b06701..50de91bca 100644 --- a/README.md +++ b/README.md @@ -754,6 +754,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. ### MFA errors diff --git a/android/src/main/java/com/auth0/react/A0Auth0Module.kt b/android/src/main/java/com/auth0/react/A0Auth0Module.kt index e19a0d4c1..816657acf 100644 --- a/android/src/main/java/com/auth0/react/A0Auth0Module.kt +++ b/android/src/main/java/com/auth0/react/A0Auth0Module.kt @@ -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 diff --git a/ios/NativeBridge.swift b/ios/NativeBridge.swift index d86d63934..525685c80 100644 --- a/ios/NativeBridge.swift +++ b/ios/NativeBridge.swift @@ -747,6 +747,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 diff --git a/package.json b/package.json index 0cb35a34c..a9a380c4a 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/src/core/models/CredentialsManagerError.ts b/src/core/models/CredentialsManagerError.ts index d2a1f2723..4b8b2316a 100644 --- a/src/core/models/CredentialsManagerError.ts +++ b/src/core/models/CredentialsManagerError.ts @@ -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 */ @@ -80,6 +85,8 @@ const ERROR_CODE_MAP: Record = { 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, @@ -104,6 +111,7 @@ const ERROR_CODE_MAP: Record = { 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, @@ -112,6 +120,7 @@ const ERROR_CODE_MAP: Record = { 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, @@ -178,6 +187,7 @@ const ERROR_CODE_MAP: Record = { * - `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 @@ -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 diff --git a/src/core/models/__tests__/ErrorCodes.spec.ts b/src/core/models/__tests__/ErrorCodes.spec.ts index 2aed7639b..b769f3f17 100644 --- a/src/core/models/__tests__/ErrorCodes.spec.ts +++ b/src/core/models/__tests__/ErrorCodes.spec.ts @@ -91,6 +91,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' ); @@ -120,9 +123,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)', () => { diff --git a/src/platforms/native/adapters/__tests__/NativeCredentialsManager.errors.spec.ts b/src/platforms/native/adapters/__tests__/NativeCredentialsManager.errors.spec.ts index 699c23ff7..25618e55a 100644 --- a/src/platforms/native/adapters/__tests__/NativeCredentialsManager.errors.spec.ts +++ b/src/platforms/native/adapters/__tests__/NativeCredentialsManager.errors.spec.ts @@ -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', diff --git a/src/platforms/web/adapters/WebCredentialsManager.ts b/src/platforms/web/adapters/WebCredentialsManager.ts index 770edcecb..5411ffc8a 100644 --- a/src/platforms/web/adapters/WebCredentialsManager.ts +++ b/src/platforms/web/adapters/WebCredentialsManager.ts @@ -31,6 +31,20 @@ export class WebCredentialsManager implements ICredentialsManager { detailedResponse: true, }); + // @auth0/auth0-spa-js enforces the IPSIE `session_expiry` ceiling silently: + // once the upstream IdP session has expired it resolves without a token + // instead of throwing. Surface this as SESSION_EXPIRED so callers get the + // same actionable signal as native (re-authentication required). + if (!tokenResponse) { + throw new CredentialsManagerError( + new AuthError( + 'session_expired', + 'The session has expired and the user must re-authenticate.', + { code: 'session_expired' } + ) + ); + } + const claims = await this.client.getIdTokenClaims(); if (!claims || !claims.exp) { throw new AuthError( @@ -47,6 +61,10 @@ export class WebCredentialsManager implements ICredentialsManager { scope: tokenResponse.scope, }); } catch (e: any) { + // Rethrow errors we've already classified (e.g. the session_expiry ceiling). + if (e instanceof CredentialsManagerError) { + throw e; + } const code = e.error ?? 'GetCredentialsFailed'; const authError = new AuthError(code, e.error_description ?? e.message, { json: e, diff --git a/src/platforms/web/adapters/__tests__/WebCredentialsManager.errors.spec.ts b/src/platforms/web/adapters/__tests__/WebCredentialsManager.errors.spec.ts index 62b4b31d6..7171d88c6 100644 --- a/src/platforms/web/adapters/__tests__/WebCredentialsManager.errors.spec.ts +++ b/src/platforms/web/adapters/__tests__/WebCredentialsManager.errors.spec.ts @@ -70,4 +70,25 @@ describe('WebCredentialsManager Error Handling', () => { }); }); }); + + describe('IPSIE session_expiry ceiling', () => { + it('should map a silent undefined token response to SESSION_EXPIRED', async () => { + // spa-js enforces the session_expiry ceiling silently: past the ceiling + // getTokenSilently resolves without a token rather than throwing. + (mockSpaClient.getTokenSilently as jest.Mock).mockResolvedValue( + undefined + ); + + await expect(manager.getCredentials()).rejects.toThrow( + CredentialsManagerError + ); + + try { + await manager.getCredentials(); + } catch (e) { + const err = e as CredentialsManagerError; + expect(err.type).toBe('SESSION_EXPIRED'); + } + }); + }); }); diff --git a/yarn.lock b/yarn.lock index a3b1f8fd9..da4808e93 100644 --- a/yarn.lock +++ b/yarn.lock @@ -28,25 +28,25 @@ __metadata: languageName: node linkType: hard -"@auth0/auth0-auth-js@npm:1.6.0": - version: 1.6.0 - resolution: "@auth0/auth0-auth-js@npm:1.6.0" +"@auth0/auth0-auth-js@npm:^1.10.0": + version: 1.12.0 + resolution: "@auth0/auth0-auth-js@npm:1.12.0" dependencies: jose: "npm:^6.0.8" openid-client: "npm:^6.8.0" - checksum: 10c0/0f5672ea77045d1172dac8a2571f1a7111045677191fa6105c0458a720fab3db385384b302535a6694c16dfe82ef0e9a3510d2113d5c35ca0f5ecd1bb1d96c05 + checksum: 10c0/52cafd1a1b0bef8d44078ac36cbcf41c5e858ba32614e93ae2dd2020efd0c63dbe9a3d92af59579c1eacd60d918da622c33fe7dde92a723bb1831963a2ba6c11 languageName: node linkType: hard -"@auth0/auth0-spa-js@npm:2.19.3": - version: 2.19.3 - resolution: "@auth0/auth0-spa-js@npm:2.19.3" +"@auth0/auth0-spa-js@npm:^2.23.0": + version: 2.23.0 + resolution: "@auth0/auth0-spa-js@npm:2.23.0" dependencies: - "@auth0/auth0-auth-js": "npm:1.6.0" - browser-tabs-lock: "npm:1.3.0" - dpop: "npm:2.1.1" - es-cookie: "npm:1.3.2" - checksum: 10c0/6449b0d47b311989baf0650f67dc3629397952af04eb5dc8303feabe10b51a695acfb7a4814052c94b89c228acccd299be7753bce41ed20986131da54a516114 + "@auth0/auth0-auth-js": "npm:^1.10.0" + browser-tabs-lock: "npm:^1.3.0" + dpop: "npm:^2.1.1" + es-cookie: "npm:~1.3.2" + checksum: 10c0/439b9a3083b01962eeb824e475e411b6579144d2935d4d6ff77e87d172f811e183584712617507128ae56545e9d7e8884ad8fa852f9dcbb5eeeb1651bb66150f languageName: node linkType: hard @@ -7264,7 +7264,7 @@ __metadata: languageName: node linkType: hard -"browser-tabs-lock@npm:1.3.0": +"browser-tabs-lock@npm:^1.3.0": version: 1.3.0 resolution: "browser-tabs-lock@npm:1.3.0" dependencies: @@ -8768,7 +8768,7 @@ __metadata: languageName: node linkType: hard -"dpop@npm:2.1.1": +"dpop@npm:^2.1.1": version: 2.1.1 resolution: "dpop@npm:2.1.1" checksum: 10c0/e46dfd62325dd63372a17492c1867f79cdaf235645d32b87c3be8a09d4c7b03b8b44efec26688ba19e8279c77497a08deb302a9a4704b432795efd1163519611 @@ -9009,7 +9009,7 @@ __metadata: languageName: node linkType: hard -"es-cookie@npm:1.3.2": +"es-cookie@npm:~1.3.2": version: 1.3.2 resolution: "es-cookie@npm:1.3.2" checksum: 10c0/26eb6e06b25b5569d8763fcb23b5335a5098e354b0a9a7bc5122e8c8705003307187a165ddaeda5cff08fa4cc8e1675dbddd5709279fb27cfa8875514dc3eccb @@ -14968,7 +14968,7 @@ __metadata: version: 0.0.0-use.local resolution: "react-native-auth0@workspace:." dependencies: - "@auth0/auth0-spa-js": "npm:2.19.3" + "@auth0/auth0-spa-js": "npm:^2.23.0" "@commitlint/config-conventional": "npm:^21.1.0" "@eslint/compat": "npm:^2.1.0" "@eslint/eslintrc": "npm:^3.3.5" From 7ce05a3e91a7afd08dd6355fb2b60002373455b3 Mon Sep 17 00:00:00 2001 From: Subhankar Maiti Date: Thu, 23 Jul 2026 18:21:12 +0530 Subject: [PATCH 2/4] surface IPSIE session_expiry ceiling as sessionExpiresAt --- A0Auth0.podspec | 2 +- EXAMPLES.md | 12 ++++++- .../java/com/auth0/react/CredentialsParser.kt | 4 +++ example/ios/Podfile.lock | 10 +++--- ios/NativeBridge.swift | 7 +++- src/core/models/Credentials.ts | 2 ++ src/core/models/__tests__/Credentials.spec.ts | 3 ++ .../NativeCredentialsManager.spec.ts | 15 ++++++++ .../web/adapters/WebCredentialsManager.ts | 12 +++++++ .../__tests__/WebCredentialsManager.spec.ts | 34 +++++++++++++++++++ src/types/common.ts | 15 ++++++++ 11 files changed, 108 insertions(+), 8 deletions(-) diff --git a/A0Auth0.podspec b/A0Auth0.podspec index f38a668c8..88d86e125 100644 --- a/A0Auth0.podspec +++ b/A0Auth0.podspec @@ -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.24.0' + s.dependency 'Auth0', '2.24.1' s.dependency 'SimpleKeychain', '1.3.0' install_modules_dependencies(s) diff --git a/EXAMPLES.md b/EXAMPLES.md index 517ecbeac..fcb995ad6 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -675,7 +675,17 @@ function MyComponent() { } ``` -If you need to read the ceiling directly, it is available as the `session_expiry` claim (absolute Unix timestamp, in seconds) on the decoded ID token — see [Parse user profile from an ID token locally](#parse-user-profile-from-an-id-token-locally). +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()}`); +} +``` + +On native this reflects the value pinned at the initial login (the value actually enforced); on web it is decoded from the current ID token. It is also readable 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] > The `session_expiry` ceiling is pinned at the initial login and is not raised by subsequent refresh-token grants. Sessions from connections **without** the claim behave exactly as before. diff --git a/android/src/main/java/com/auth0/react/CredentialsParser.kt b/android/src/main/java/com/auth0/react/CredentialsParser.kt index ab2af622b..1fa043a8c 100644 --- a/android/src/main/java/com/auth0/react/CredentialsParser.kt +++ b/android/src/main/java/com/auth0/react/CredentialsParser.kt @@ -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" @@ -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 } diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index 1c282e272..053402739 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -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 @@ -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) @@ -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 diff --git a/ios/NativeBridge.swift b/ios/NativeBridge.swift index 525685c80..532ea5872 100644 --- a/ios/NativeBridge.swift +++ b/ios/NativeBridge.swift @@ -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"; @@ -664,7 +665,7 @@ 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, @@ -672,6 +673,10 @@ extension Credentials { 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 } } diff --git a/src/core/models/Credentials.ts b/src/core/models/Credentials.ts index fb188155f..d8c6984d7 100644 --- a/src/core/models/Credentials.ts +++ b/src/core/models/Credentials.ts @@ -14,6 +14,7 @@ export class Credentials implements ICredentials { public expiresAt: number; public refreshToken?: string; public scope?: string; + public sessionExpiresAt?: number; /** * Creates an instance of Credentials. @@ -27,6 +28,7 @@ export class Credentials implements ICredentials { this.expiresAt = params.expiresAt; this.refreshToken = params.refreshToken; this.scope = params.scope; + this.sessionExpiresAt = params.sessionExpiresAt; } /** diff --git a/src/core/models/__tests__/Credentials.spec.ts b/src/core/models/__tests__/Credentials.spec.ts index 40e6130ba..0ee5e005b 100644 --- a/src/core/models/__tests__/Credentials.spec.ts +++ b/src/core/models/__tests__/Credentials.spec.ts @@ -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); @@ -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', () => { @@ -48,6 +50,7 @@ describe('Credentials', () => { expect(credentials.refreshToken).toBeUndefined(); expect(credentials.scope).toBeUndefined(); + expect(credentials.sessionExpiresAt).toBeUndefined(); }); }); diff --git a/src/platforms/native/adapters/__tests__/NativeCredentialsManager.spec.ts b/src/platforms/native/adapters/__tests__/NativeCredentialsManager.spec.ts index 81340845e..f8983f1a8 100644 --- a/src/platforms/native/adapters/__tests__/NativeCredentialsManager.spec.ts +++ b/src/platforms/native/adapters/__tests__/NativeCredentialsManager.spec.ts @@ -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', () => { diff --git a/src/platforms/web/adapters/WebCredentialsManager.ts b/src/platforms/web/adapters/WebCredentialsManager.ts index 5411ffc8a..5507ea544 100644 --- a/src/platforms/web/adapters/WebCredentialsManager.ts +++ b/src/platforms/web/adapters/WebCredentialsManager.ts @@ -53,12 +53,24 @@ export class WebCredentialsManager implements ICredentialsManager { ); } + // Decode the IPSIE `session_expiry` claim (absolute Unix seconds). Reject values + // outside (0, 10_000_000_000) to match native: the upper bound discards + // millisecond-valued timestamps that would otherwise disable the ceiling. + const rawSessionExpiry = claims.session_expiry; + const sessionExpiresAt = + typeof rawSessionExpiry === 'number' && + rawSessionExpiry > 0 && + rawSessionExpiry < 10_000_000_000 + ? Math.floor(rawSessionExpiry) + : undefined; + return new CredentialsModel({ idToken: tokenResponse.id_token, accessToken: tokenResponse.access_token, tokenType: tokenResponse.token_type ?? 'Bearer', expiresAt: claims.exp, scope: tokenResponse.scope, + sessionExpiresAt, }); } catch (e: any) { // Rethrow errors we've already classified (e.g. the session_expiry ceiling). diff --git a/src/platforms/web/adapters/__tests__/WebCredentialsManager.spec.ts b/src/platforms/web/adapters/__tests__/WebCredentialsManager.spec.ts index a540c9d0c..22358fd24 100644 --- a/src/platforms/web/adapters/__tests__/WebCredentialsManager.spec.ts +++ b/src/platforms/web/adapters/__tests__/WebCredentialsManager.spec.ts @@ -103,6 +103,40 @@ describe('WebCredentialsManager', () => { }); }); + it('should decode the session_expiry claim into sessionExpiresAt', async () => { + const sessionExpiry = 1893456000; // 2030-01-01, within (0, 10_000_000_000) + mockSpaClient.getTokenSilently.mockResolvedValue(mockTokenResponse); + mockSpaClient.getIdTokenClaims.mockResolvedValue({ + ...mockIdTokenClaims, + session_expiry: sessionExpiry, + }); + + const result = await credentialsManager.getCredentials(); + + expect(result.sessionExpiresAt).toBe(sessionExpiry); + }); + + it('should leave sessionExpiresAt undefined when the claim is absent', async () => { + mockSpaClient.getTokenSilently.mockResolvedValue(mockTokenResponse); + mockSpaClient.getIdTokenClaims.mockResolvedValue(mockIdTokenClaims); + + const result = await credentialsManager.getCredentials(); + + expect(result.sessionExpiresAt).toBeUndefined(); + }); + + it('should reject a millisecond-valued session_expiry (out of range)', async () => { + mockSpaClient.getTokenSilently.mockResolvedValue(mockTokenResponse); + mockSpaClient.getIdTokenClaims.mockResolvedValue({ + ...mockIdTokenClaims, + session_expiry: 1893456000000, // 13-digit ms value + }); + + const result = await credentialsManager.getCredentials(); + + expect(result.sessionExpiresAt).toBeUndefined(); + }); + it('should get credentials with custom scope and parameters', async () => { mockSpaClient.getTokenSilently.mockResolvedValue(mockTokenResponse); mockSpaClient.getIdTokenClaims.mockResolvedValue(mockIdTokenClaims); diff --git a/src/types/common.ts b/src/types/common.ts index b94784930..8a9ce782e 100644 --- a/src/types/common.ts +++ b/src/types/common.ts @@ -30,6 +30,21 @@ export type Credentials = { refreshToken?: string; /** A space-separated list of scopes granted for the access token. */ scope?: string; + /** + * The absolute upstream-IdP session ceiling, as a UNIX timestamp (in seconds), asserted via the + * IPSIE `session_expiry` claim. This is `undefined` when the connection does not emit the claim. + * + * @remarks + * This ceiling is independent of {@link Credentials.expiresAt} (the access-token expiry): it + * usually sits much further out and caps how long the local session may live regardless of + * access-token renewals. The underlying platform SDKs enforce it automatically — once it passes, + * credential retrieval fails with a `SESSION_EXPIRED` `CredentialsManagerError` and the user must + * re-authenticate. It is surfaced here for display purposes only. + * + * @remarks Native (iOS/Android) and web. On native it reflects the value pinned at the initial + * login; on web it is decoded from the current ID token. + */ + sessionExpiresAt?: number; /** Allows for additional, non-standard properties returned from the server. */ [key: string]: any; }; From 6d2bb03c3260833c5a3fc85eae20ee7cf171972b Mon Sep 17 00:00:00 2001 From: Subhankar Maiti Date: Thu, 23 Jul 2026 18:31:50 +0530 Subject: [PATCH 3/4] clarify sessionExpiresAt derivation per platform --- EXAMPLES.md | 2 +- src/types/common.ts | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/EXAMPLES.md b/EXAMPLES.md index fcb995ad6..6f7472766 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -685,7 +685,7 @@ if (credentials.sessionExpiresAt) { } ``` -On native this reflects the value pinned at the initial login (the value actually enforced); on web it is decoded from the current ID token. It is also readable 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). +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] > The `session_expiry` ceiling is pinned at the initial login and is not raised by subsequent refresh-token grants. Sessions from connections **without** the claim behave exactly as before. diff --git a/src/types/common.ts b/src/types/common.ts index 8a9ce782e..95523cd8c 100644 --- a/src/types/common.ts +++ b/src/types/common.ts @@ -41,8 +41,9 @@ export type Credentials = { * credential retrieval fails with a `SESSION_EXPIRED` `CredentialsManagerError` and the user must * re-authenticate. It is surfaced here for display purposes only. * - * @remarks Native (iOS/Android) and web. On native it reflects the value pinned at the initial - * login; on web it is decoded from the current ID token. + * @remarks Available on iOS, Android, and web. The value is derived from the `session_expiry` + * claim of the current ID token, except on Android where the credentials manager reports the + * ceiling pinned at the initial login (the value it actually enforces) when one is stored. */ sessionExpiresAt?: number; /** Allows for additional, non-standard properties returned from the server. */ From 6bd99e5e6b4108cabc09b8b19a83ad495822bf79 Mon Sep 17 00:00:00 2001 From: Subhankar Maiti Date: Fri, 24 Jul 2026 12:33:58 +0530 Subject: [PATCH 4/4] surface silent session_expiry ceiling in web getApiCredentials --- EXAMPLES.md | 2 +- README.md | 5 +++++ src/core/models/Credentials.ts | 4 ++++ .../web/adapters/WebCredentialsManager.ts | 18 ++++++++++++++++++ .../WebCredentialsManager.errors.spec.ts | 17 +++++++++++++++++ src/types/common.ts | 6 ++++-- 6 files changed, 49 insertions(+), 3 deletions(-) diff --git a/EXAMPLES.md b/EXAMPLES.md index 6f7472766..91fc9a1b1 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -688,7 +688,7 @@ if (credentials.sessionExpiresAt) { 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] -> The `session_expiry` ceiling is pinned at the initial login and is not raised by subsequent refresh-token grants. Sessions from connections **without** the claim behave exactly as before. +> 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 diff --git a/README.md b/README.md index 50de91bca..0f5c396db 100644 --- a/README.md +++ b/README.md @@ -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); } diff --git a/src/core/models/Credentials.ts b/src/core/models/Credentials.ts index d8c6984d7..4115f8ae2 100644 --- a/src/core/models/Credentials.ts +++ b/src/core/models/Credentials.ts @@ -14,6 +14,10 @@ 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; /** diff --git a/src/platforms/web/adapters/WebCredentialsManager.ts b/src/platforms/web/adapters/WebCredentialsManager.ts index 5507ea544..09439e915 100644 --- a/src/platforms/web/adapters/WebCredentialsManager.ts +++ b/src/platforms/web/adapters/WebCredentialsManager.ts @@ -102,6 +102,20 @@ export class WebCredentialsManager implements ICredentialsManager { detailedResponse: true, }); + // @auth0/auth0-spa-js enforces the IPSIE `session_expiry` ceiling silently: + // once the upstream IdP session has expired it resolves without a token + // instead of throwing. Surface this as SESSION_EXPIRED so callers get the + // same actionable signal as native (re-authentication required). + if (!tokenResponse) { + throw new CredentialsManagerError( + new AuthError( + 'session_expired', + 'The session has expired and the user must re-authenticate.', + { code: 'session_expired' } + ) + ); + } + // Calculate access token expiration from expires_in (seconds until expiration) // This is more accurate than using ID token claims for API credentials const nowInSeconds = Math.floor(Date.now() / 1000); @@ -114,6 +128,10 @@ export class WebCredentialsManager implements ICredentialsManager { scope: tokenResponse.scope, }); } catch (e: any) { + // Rethrow errors we've already classified (e.g. the session_expiry ceiling). + if (e instanceof CredentialsManagerError) { + throw e; + } const code = e.error ?? 'GetApiCredentialsFailed'; const authError = new AuthError(code, e.error_description ?? e.message, { json: e, diff --git a/src/platforms/web/adapters/__tests__/WebCredentialsManager.errors.spec.ts b/src/platforms/web/adapters/__tests__/WebCredentialsManager.errors.spec.ts index 7171d88c6..50e097d11 100644 --- a/src/platforms/web/adapters/__tests__/WebCredentialsManager.errors.spec.ts +++ b/src/platforms/web/adapters/__tests__/WebCredentialsManager.errors.spec.ts @@ -90,5 +90,22 @@ describe('WebCredentialsManager Error Handling', () => { expect(err.type).toBe('SESSION_EXPIRED'); } }); + + it('should map a silent undefined response in getApiCredentials to SESSION_EXPIRED', async () => { + (mockSpaClient.getTokenSilently as jest.Mock).mockResolvedValue( + undefined + ); + + await expect( + manager.getApiCredentials('https://api.example.com') + ).rejects.toThrow(CredentialsManagerError); + + try { + await manager.getApiCredentials('https://api.example.com'); + } catch (e) { + const err = e as CredentialsManagerError; + expect(err.type).toBe('SESSION_EXPIRED'); + } + }); }); }); diff --git a/src/types/common.ts b/src/types/common.ts index 95523cd8c..9cc8f93d2 100644 --- a/src/types/common.ts +++ b/src/types/common.ts @@ -42,8 +42,10 @@ export type Credentials = { * re-authenticate. It is surfaced here for display purposes only. * * @remarks Available on iOS, Android, and web. The value is derived from the `session_expiry` - * claim of the current ID token, except on Android where the credentials manager reports the - * ceiling pinned at the initial login (the value it actually enforces) when one is stored. + * claim of the current ID token. + * + * **Android only**: the credentials manager reports the ceiling pinned at the initial login + * (the value it actually enforces) when one is stored. */ sessionExpiresAt?: number; /** Allows for additional, non-standard properties returned from the server. */