Skip to content
Merged
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
5 changes: 3 additions & 2 deletions EXAMPLES-WEB.md
Original file line number Diff line number Diff line change
Expand Up @@ -299,7 +299,8 @@ function PasskeyLoginButton() {

// App calls navigator.credentials directly (not wrapped by SDK)
const credential = (await navigator.credentials.get({
publicKey: challenge.authParamsPublicKey as PublicKeyCredentialRequestOptions,
publicKey:
challenge.authParamsPublicKey as PublicKeyCredentialRequestOptions,
})) as PublicKeyCredential | null;

if (!credential) {
Expand All @@ -317,7 +318,7 @@ function PasskeyLoginButton() {
} catch (error) {
if (error instanceof PasskeyError) {
console.error('Passkey login failed:', error.type, error.message);

// Handle MFA required scenario
const mfaPayload = error.getMfaRequiredPayload();
if (mfaPayload) {
Expand Down
91 changes: 77 additions & 14 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 @@ -640,6 +641,66 @@ 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 an `okta` or `oidc` enterprise connection has the **"Use ID Token for Session Expiry"** toggle enabled (in the Dashboard, or `id_token_session_expiry_supported: true` via the Management API), and the app uses the Authorization Code flow, Auth0 includes a `session_expiry` Unix timestamp in the ID token returned to your app after login.

This ceiling is layered **on top of** your tenant's existing idle and absolute session timeouts — it does not replace them. The session ends at whichever limit is reached first.

> [!WARNING]
> `session_expiry` is interpreted as **seconds** since the Unix epoch (per RFC 7519 `NumericDate`). If the Post-Login Action that sets it emits **milliseconds** (e.g. `Date.now()` without `/ 1000`), the value reads as tens of thousands of years out; the platform SDKs reject implausibly large values (≥ `10_000_000_000`) as malformed and treat them as **no ceiling**, silently disabling enforcement. Always emit seconds.

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.
Comment thread
subhankarmaiti marked this conversation as resolved.

`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:
Comment thread
subhankarmaiti marked this conversation as resolved.

```jsx
const credentials = await getCredentials();
if (credentials.sessionExpiresAt) {
const endsAt = new Date(credentials.sessionExpiresAt * 1000);
Comment thread
kishore7snehil marked this conversation as resolved.
console.log(`Upstream IdP session ends at: ${endsAt.toISOString()}`);
}
```

> [!NOTE]
> Enforcement applies a small negative leeway (about 30 seconds) to account for clock skew, so the session is treated as expired slightly before this exact timestamp. Build any countdown UI with that margin in mind.

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 Expand Up @@ -1377,7 +1438,8 @@ function PasskeySignupScreenWeb() {
let credential: PublicKeyCredential;
try {
credential = (await navigator.credentials.create({
publicKey: challenge.authParamsPublicKey as PublicKeyCredentialCreationOptions,
publicKey:
challenge.authParamsPublicKey as PublicKeyCredentialCreationOptions,
})) as PublicKeyCredential;
} catch (e) {
throw new PasskeyError(e as Error);
Expand Down Expand Up @@ -1423,7 +1485,8 @@ function PasskeySigninScreenWeb() {
let credential: PublicKeyCredential;
try {
credential = (await navigator.credentials.get({
publicKey: challenge.authParamsPublicKey as PublicKeyCredentialRequestOptions,
publicKey:
challenge.authParamsPublicKey as PublicKeyCredentialRequestOptions,
})) as PublicKeyCredential;
} catch (e) {
throw new PasskeyError(e as Error);
Expand Down Expand Up @@ -1556,16 +1619,16 @@ The `passkeySignupChallenge` method accepts the following parameters to create a

Passkey operations throw `PasskeyError` (extends `AuthError`) with a normalized `type` property. Use `PasskeyErrorCodes` for type-safe error handling:

| Error Code | Description |
| ------------------------------ | ----------------------------------------------------------------------------------------------------------------- |
| `PASSKEY_CHALLENGE_FAILED` | Auth0 challenge request failed |
| `PASSKEY_EXCHANGE_FAILED` | Token exchange with credential response failed |
| `PASSKEY_NOT_AVAILABLE` | Passkeys not available on this device/OS version, or WebAuthn is not supported in this browser |
| `PASSKEY_UNSUPPORTED_PLATFORM` | Passkeys not supported on this platform |
| `PASSKEY_INVALID_PARAMETER` | **Native only.** `authResponse` passed to `getTokenByPasskey` was not a JSON string |
| `PASSKEY_INVALID_CREDENTIAL` | **Web only.** The credential passed to `getTokenByPasskey` is neither a valid attestation (signup) nor assertion (login) response |
| Error Code | Description |
| ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `PASSKEY_CHALLENGE_FAILED` | Auth0 challenge request failed |
| `PASSKEY_EXCHANGE_FAILED` | Token exchange with credential response failed |
| `PASSKEY_NOT_AVAILABLE` | Passkeys not available on this device/OS version, or WebAuthn is not supported in this browser |
| `PASSKEY_UNSUPPORTED_PLATFORM` | Passkeys not supported on this platform |
| `PASSKEY_INVALID_PARAMETER` | **Native only.** `authResponse` passed to `getTokenByPasskey` was not a JSON string |
| `PASSKEY_INVALID_CREDENTIAL` | **Web only.** The credential passed to `getTokenByPasskey` is neither a valid attestation (signup) nor assertion (login) response |
| `PASSKEY_MFA_REQUIRED` | **Web only.** MFA is required to complete the exchange — use `error.getMfaRequiredPayload()` to extract `mfaToken` and `mfaRequirements`, then continue with `mfa.challenge()`/`mfa.verify()` |
| `PASSKEY_UNKNOWN_ERROR` | Unknown or uncategorized passkey error — check `error.message` for the underlying description |
| `PASSKEY_UNKNOWN_ERROR` | Unknown or uncategorized passkey error — check `error.message` for the underlying description |

```typescript
import { PasskeyError, PasskeyErrorCodes } from 'react-native-auth0';
Expand All @@ -1580,7 +1643,7 @@ try {
console.log('Error type:', error.type); // e.g. "PASSKEY_CHALLENGE_FAILED"
console.log('Error message:', error.message);
console.log('Error code:', error.code); // Raw error code

// Handle MFA required
if (error.type === PasskeyErrorCodes.MFA_REQUIRED) {
const mfaPayload = error.getMfaRequiredPayload();
Expand All @@ -1598,8 +1661,8 @@ try {

### Platform Support

| Platform | Support | Requirements |
| ----------- | ------------ | ------------------------------------------------------------- |
| Platform | Support | Requirements |
| ----------- | ------------ | -------------------------------------------------------------- |
| **iOS** | ✅ Supported | iOS 16.6+, Associated Domains with `webcredentials` |
| **Android** | ✅ Supported | Android API 28+, Digital Asset Links configured |
| **Web** | ✅ Supported | Modern browser with WebAuthn support; call from a user gesture |
Expand Down
14 changes: 11 additions & 3 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 Expand Up @@ -916,9 +924,9 @@ This library provides a unified API across Native (iOS/Android) and Web platform
| `auth.passwordless...()` | ✅ | ❌ | **Not supported on Web.** Passwordless flows on the web should be configured via Universal Login and initiated with `webAuth.authorize()`. |
| `auth.loginWith...()` (OTP/SMS etc) | ✅ | ❌ | **Not supported on Web.** These direct grant flows are not secure for public clients like browsers. |
| **Passkeys** | | | --- |
| `passkeySignupChallenge()` | ✅ | ✅ | Gets a WebAuthn registration challenge from Auth0. Requires iOS 16.6+, Android API 28+, or a browser with WebAuthn support. |
| `passkeyLoginChallenge()` | ✅ | ✅ | Gets a WebAuthn assertion challenge from Auth0. Requires iOS 16.6+, Android API 28+, or a browser with WebAuthn support. |
| `getTokenByPasskey()` | ✅ | ✅ | Exchanges a passkey credential response for Auth0 tokens. On Web, uses `@auth0/auth0-spa-js`. |
| `passkeySignupChallenge()` | ✅ | ✅ | Gets a WebAuthn registration challenge from Auth0. Requires iOS 16.6+, Android API 28+, or a browser with WebAuthn support. |
| `passkeyLoginChallenge()` | ✅ | ✅ | Gets a WebAuthn assertion challenge from Auth0. Requires iOS 16.6+, Android API 28+, or a browser with WebAuthn support. |
| `getTokenByPasskey()` | ✅ | ✅ | Exchanges a passkey credential response for Auth0 tokens. On Web, uses `@auth0/auth0-spa-js`. |
| **Token & User Management** | | | --- |
| `auth.refreshToken()` | ✅ | ❌ | **Not supported on Web.** Token refresh is handled automatically by `getCredentials()` via `getTokenSilently()` on the web. |
| `auth.userInfo()` | ✅ | ✅ | Fetches the user's profile from the `/userinfo` endpoint using an access token. |
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 @@ -105,7 +105,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
Loading
Loading