diff --git a/A0Auth0.podspec b/A0Auth0.podspec index 5c110043..88d86e12 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.1' s.dependency 'SimpleKeychain', '1.3.0' install_modules_dependencies(s) diff --git a/EXAMPLES.md b/EXAMPLES.md index feb0aefc..91fc9a1b 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,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) diff --git a/README.md b/README.md index 98a6c279..d179bfc8 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); } @@ -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. ### 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 e19a0d4c..816657ac 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/android/src/main/java/com/auth0/react/CredentialsParser.kt b/android/src/main/java/com/auth0/react/CredentialsParser.kt index ab2af622..1fa043a8 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 1c282e27..05340273 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 888ba875..076cac3d 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 } } @@ -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 diff --git a/package.json b/package.json index 23831617..4f0efa9b 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/Credentials.ts b/src/core/models/Credentials.ts index fb188155..4115f8ae 100644 --- a/src/core/models/Credentials.ts +++ b/src/core/models/Credentials.ts @@ -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; /** * Creates an instance of Credentials. @@ -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; } /** diff --git a/src/core/models/CredentialsManagerError.ts b/src/core/models/CredentialsManagerError.ts index d2a1f272..4b8b2316 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__/Credentials.spec.ts b/src/core/models/__tests__/Credentials.spec.ts index 40e6130b..0ee5e005 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/core/models/__tests__/ErrorCodes.spec.ts b/src/core/models/__tests__/ErrorCodes.spec.ts index 9853da0b..565c6a90 100644 --- a/src/core/models/__tests__/ErrorCodes.spec.ts +++ b/src/core/models/__tests__/ErrorCodes.spec.ts @@ -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' ); @@ -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)', () => { diff --git a/src/platforms/native/adapters/__tests__/NativeCredentialsManager.errors.spec.ts b/src/platforms/native/adapters/__tests__/NativeCredentialsManager.errors.spec.ts index 699c23ff..25618e55 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/native/adapters/__tests__/NativeCredentialsManager.spec.ts b/src/platforms/native/adapters/__tests__/NativeCredentialsManager.spec.ts index 81340845..f8983f1a 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 770edcec..09439e91 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( @@ -39,14 +53,30 @@ 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). + if (e instanceof CredentialsManagerError) { + throw e; + } const code = e.error ?? 'GetCredentialsFailed'; const authError = new AuthError(code, e.error_description ?? e.message, { json: e, @@ -72,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); @@ -84,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 62b4b31d..50e097d1 100644 --- a/src/platforms/web/adapters/__tests__/WebCredentialsManager.errors.spec.ts +++ b/src/platforms/web/adapters/__tests__/WebCredentialsManager.errors.spec.ts @@ -70,4 +70,42 @@ 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'); + } + }); + + 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/platforms/web/adapters/__tests__/WebCredentialsManager.spec.ts b/src/platforms/web/adapters/__tests__/WebCredentialsManager.spec.ts index a540c9d0..22358fd2 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 b9478493..9cc8f93d 100644 --- a/src/types/common.ts +++ b/src/types/common.ts @@ -30,6 +30,24 @@ 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 Available on iOS, Android, and web. The value is derived from the `session_expiry` + * 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. */ [key: string]: any; }; diff --git a/yarn.lock b/yarn.lock index a3b1f8fd..da4808e9 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"