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
44 changes: 44 additions & 0 deletions EXAMPLES.md
Original file line number Diff line number Diff line change
Expand Up @@ -3007,6 +3007,7 @@ In the event that something happened while trying to save or retrieve the creden
- **DPoP key pair lost** — The DPoP key pair is no longer available in the Android KeyStore. The stored credentials are cleared and re-authentication is required.
- **DPoP key pair mismatch** — The DPoP key pair exists but is different from the one used when the credentials were saved. The stored credentials are cleared and re-authentication is required.
- **DPoP not configured** — The stored credentials are DPoP-bound but the `AuthenticationAPIClient` used by the credentials manager was not configured with `useDPoP(context)`. The developer needs to call `AuthenticationAPIClient(auth0).useDPoP(context)` and pass the configured client to the credentials manager.
- **Session expired** — The session has reached the `session_expiry` ceiling asserted by the upstream identity provider. The stored credentials are cleared and re-authentication is required. See [Upstream session expiry](#upstream-session-expiry) below.

You can access the `code` property of the `CredentialsManagerException` to understand why the operation with `CredentialsManager` has failed and the `message` property of the `CredentialsManagerException` would give you a description of the exception.

Expand Down Expand Up @@ -3040,10 +3041,53 @@ when(credentialsManagerException) {
// Developer forgot to call useDPoP() on the AuthenticationAPIClient
// passed to the credentials manager. Fix the client configuration.
}

CredentialsManagerException.SESSION_EXPIRED -> {
// The upstream identity provider's session_expiry ceiling was reached.
// The stored credentials have already been cleared; prompt the user to
// re-authenticate.
}
// ... similarly for other error codes
}
```

### Upstream session expiry

When an enterprise connection (for example an OIDC or Okta connection) is configured to assert a session lifetime, Auth0 includes a `session_expiry` claim in the ID token. This claim is an absolute ceiling — expressed in **Unix seconds** — on how long the local session may live, independently of the access-token expiry. It usually sits much further out than `expiresAt`, and it cannot be extended by a refresh-token renewal.
Comment thread
kishore7snehil marked this conversation as resolved.

The credentials managers enforce this ceiling automatically:

- The ceiling is read from the ID token at login and persisted, so it survives refreshes whose ID token does not re-emit the claim.
Comment thread
kishore7snehil marked this conversation as resolved.
- `saveCredentials` rejects an already-expired session up front: if the ID token is already past its ceiling at login, the save throws `CredentialsManagerException.SESSION_EXPIRED` and nothing is persisted.
- On every `getCredentials` call, if the ceiling has been reached the stored credentials are cleared and the call fails with `CredentialsManagerException.SESSION_EXPIRED`. The refresh token is **never** used to renew a session past the ceiling.
Comment thread
kishore7snehil marked this conversation as resolved.
- A small negative clock-skew leeway (~30 seconds) is applied, so the session is treated as expired slightly *before* the wall-clock ceiling, never after.
- Connections that do not emit the claim are unaffected — there is no ceiling and behavior is unchanged.

> ⚠️ **The `session_expiry` value must be Unix seconds.** Per [RFC 7519](https://datatracker.ietf.org/doc/html/rfc7519), the claim is interpreted as seconds since the Unix epoch. A millisecond-magnitude value (e.g. `1700000000000`) resolves to a date ~50,000 years out and would **silently disable** the ceiling, so the SDK treats any implausibly large value (`>= 10_000_000_000`) as "no ceiling". The SDK also **fails open** on any malformed value — a non-numeric, zero, negative, or millisecond value is treated as "no ceiling" and the session proceeds without enforcement. When emitting the claim from an Action, always use seconds (divide a milliseconds timestamp by 1000).

> ⚠️ **Upgrade note:** For a user whose connection asserts `session_expiry`, a `getCredentials` call that previously succeeded can now fail with `SESSION_EXPIRED` once the ceiling is reached. Make sure your error handling treats `SESSION_EXPIRED` as a prompt to re-authenticate.

#### Emitting the claim

The `session_expiry` claim is not emitted by default — it is set on your tenant by a [Post-Login Action](https://auth0.com/docs/customize/actions/flows-and-triggers/login-flow) that adds it to the ID token, for example:

```javascript
exports.onExecutePostLogin = async (event, api) => {
// session_expiry must be expressed in Unix seconds
const sessionExpiry = Math.floor(Date.now() / 1000) + 8 * 60 * 60; // 8 hours from now
api.idToken.setCustomClaim('session_expiry', sessionExpiry);
};
```

> 📝 A link to the canonical Auth0 `session_expiry` Action guide will be added here once it is published.

You can read the ceiling for a given credential set from `Credentials.sessionExpiresAt` (a nullable `Long` of Unix seconds, `null` when the connection does not emit the claim):

```kotlin
val credentials = credentialsManager.awaitCredentials()
val ceiling: Long? = credentials.sessionExpiresAt
```

## Passkeys
User should have a custom domain configured and passkey grant-type enabled in the Auth0 dashboard to use passkeys.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,19 @@ public abstract class BaseCredentialsManager internal constructor(

@VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE)
internal const val KEY_TOKEN_TYPE = "com.auth0.token_type"

/**
* Storage key for the IPSIE `session_expiry` ceiling (Unix seconds), persisted at login so it
* survives a refresh whose ID token does not re-emit the claim. See [isSessionExpired].
*/
@VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE)
internal const val KEY_SESSION_EXPIRY = "com.auth0.session_expiry"

/**
* Negative clock-skew leeway (seconds) applied when checking the `session_expiry` ceiling, so
* the session is treated as expired slightly *before* the wall-clock ceiling, never after.
*/
private const val SESSION_EXPIRY_LEEWAY_SECONDS = 30L

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is 30s is the agreed leeway across ?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes — 30s aligns implementation ,the ~30s negative leeway. The leeway is intentionally negative so we treat the session as expired slightly before the wall-clock ceiling, never after.

}

private var _clock: Clock = ClockImpl()
Expand Down Expand Up @@ -302,6 +315,93 @@ public abstract class BaseCredentialsManager internal constructor(
return expiresAt <= currentTimeInMillis
}

/**
* Reads the IPSIE `session_expiry` ceiling (Unix seconds) from the given ID token, or null when
* the token is absent/unparseable or does not carry the claim.
*/
private fun sessionExpiryFromIdToken(idToken: String?): Long? {
if (idToken.isNullOrBlank()) return null
return runCatching { jwtDecoder.decode(idToken).sessionExpiry }.getOrNull()
}

/**
* Checks whether the upstream-IdP session ceiling (`session_expiry`) has been reached.
*
* The ceiling is resolved in order: (1) the value pinned at login under [KEY_SESSION_EXPIRY];
* (2) the live claim in [idToken], as a fallback only when nothing is pinned yet (migration of a
* session saved before this control existed); (3) if neither is present there is no ceiling and
* the session is NOT expired — a missing value must fall through to existing behavior, never be
* treated as already-expired. The pinned value is read first because the ceiling is fixed at the
* initial login: a refresh whose ID token re-emits a *later* `session_expiry` must never raise it.
*
* A small negative clock-skew leeway is applied so the session is treated as expired slightly
* before the wall-clock ceiling, never after.
*/
protected fun isSessionExpired(idToken: String?): Boolean {
// A non-positive value is not a valid Unix timestamp; treat it as "not pinned"/"no ceiling"
// (mirrors the unset/migration guard in [willExpire]) so a 0/negative stored sentinel falls
// through to the live claim rather than fail-open as "no ceiling".
val sessionExpiry = storage.retrieveLong(KEY_SESSION_EXPIRY)?.takeIf { it > 0 }
?: sessionExpiryFromIdToken(idToken)?.takeIf { it > 0 }
?: return false
Comment thread
coderabbitai[bot] marked this conversation as resolved.
val nowSeconds = currentTimeInMillis / 1000
return nowSeconds + SESSION_EXPIRY_LEEWAY_SECONDS >= sessionExpiry
}

/**
* Stamps the pinned `session_expiry` ceiling (the value persisted at login under
* [KEY_SESSION_EXPIRY]) onto [credentials] so its public `sessionExpiresAt` reflects the value
* the SDK actually enforces, rather than re-decoding the live ID token — which would diverge
* after a refresh whose token omits or re-emits the claim. No-op when nothing is pinned, so the
* getter falls back to the token claim. Returns the same instance for call-site convenience.
*/
protected fun stampPinnedSessionExpiry(credentials: Credentials): Credentials {
storage.retrieveLong(KEY_SESSION_EXPIRY)?.takeIf { it > 0 }?.let {
credentials.pinnedSessionExpiresAt = it
}
return credentials
}

/**
* Pins the `session_expiry` ceiling from the initial login and preserves it across refreshes.
*
* The ceiling is read once and stamped onto the session at login: it is stored only when no value
* is already persisted. A `session_expiry` re-emitted on a later (refresh) grant is deliberately
* ignored, so the bound stays pinned to the initial-login value and a refresh can never extend the
* session past it. [clearCredentials] removes the stored value on logout, so the next login re-pins
* a fresh ceiling. Call from `saveCredentials`.
*/
protected fun persistSessionExpiry(idToken: String?) {
val incoming = sessionExpiryFromIdToken(idToken) ?: return
// A positive value is already pinned from the initial login -> keep it; ignore the claim
// re-emitted on this (refresh) grant. A null/non-positive stored value means nothing is pinned
// yet (mirrors the unset/migration guard in [isSessionExpired]), so stamp the ceiling now.
val pinned = storage.retrieveLong(KEY_SESSION_EXPIRY)
if (pinned != null && pinned > 0) return

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't this check be before the
val incoming = sessionExpiryFromIdToken(idToken) ?: return
It makes sense to decode the new session_expiry from the Id token, if there is no pinned value already present

storage.store(KEY_SESSION_EXPIRY, incoming)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/**
* Validates, at session-creation time, that the given ID token is not already past its
* `session_expiry` ceiling. Throws [CredentialsManagerException] with code `SESSION_EXPIRED`
* when it is, so an already-expired session is never persisted. No-op when the token is absent
* or does not carry both `session_expiry` and `iat`.
*
* The same [SESSION_EXPIRY_LEEWAY_SECONDS] leeway used by [isSessionExpired] is applied here so
* the two checks agree: a ceiling within the leeway of `iat` is rejected up front rather than
* being persisted only to be treated as expired on the very next read.
*/
@Throws(CredentialsManagerException::class)
protected fun validateSessionExpiryAtCreation(idToken: String?) {
if (idToken.isNullOrBlank()) return
val jwt = runCatching { jwtDecoder.decode(idToken) }.getOrNull() ?: return
val sessionExpiry = jwt.sessionExpiry ?: return
val issuedAtSeconds = jwt.issuedAt?.time?.div(1000) ?: return
if (sessionExpiry <= issuedAtSeconds + SESSION_EXPIRY_LEEWAY_SECONDS) {
throw CredentialsManagerException.SESSION_EXPIRED
}
}

/**
* Returns the key for storing the APICredentials in storage. Uses a combination of audience and scope.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,18 +63,27 @@ public class CredentialsManager @VisibleForTesting(otherwise = VisibleForTesting
* Stores the given credentials in the storage. Must have an access_token or id_token and a expires_in value.
*
* @param credentials the credentials to save in the storage.
* @throws CredentialsManagerException with code `SESSION_EXPIRED` if the credentials carry an
* IPSIE `session_expiry` claim that is already past its ceiling at creation time, or with code
* `INVALID_CREDENTIALS` if neither an access_token nor an id_token is present.
*/
@Throws(CredentialsManagerException::class)
override fun saveCredentials(credentials: Credentials) {
if (TextUtils.isEmpty(credentials.accessToken) && TextUtils.isEmpty(credentials.idToken)) {
throw CredentialsManagerException.INVALID_CREDENTIALS
}
// IPSIE session_expiry: reject a session already past its ceiling at creation time.
validateSessionExpiryAtCreation(credentials.idToken)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

validateSessionExpiryAtCreation now throws an exception . Update the comment and signature of saveCredentials for the same

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. Added @throws(CredentialsManagerException::class) and documented that saveCredentials can now throw SESSION_EXPIRED (when the ID token is already past its session_expiry ceiling at creation) and INVALID_CREDENTIALS.

storage.store(KEY_ACCESS_TOKEN, credentials.accessToken)
storage.store(KEY_REFRESH_TOKEN, credentials.refreshToken)
storage.store(KEY_ID_TOKEN, credentials.idToken)
storage.store(KEY_TOKEN_TYPE, credentials.type)
storage.store(KEY_EXPIRES_AT, credentials.expiresAt.time)
storage.store(KEY_SCOPE, credentials.scope)
storage.store(LEGACY_KEY_CACHE_EXPIRES_AT, credentials.expiresAt.time)
// Preserve the session_expiry ceiling across refreshes: only ever written, never cleared,
// so a refresh whose ID token omits the claim does not silently remove the limit.
persistSessionExpiry(credentials.idToken)
saveDPoPThumbprint(credentials)
}

Expand Down Expand Up @@ -129,6 +138,13 @@ public class CredentialsManager @VisibleForTesting(otherwise = VisibleForTesting
) {
serialExecutor.execute {
runCatchingOnExecutor(callback) {
// IPSIE session_expiry: enforce the upstream-IdP session ceiling before exchanging the
// refresh token, so the SSO exchange is never used to outlive the session.
if (isSessionExpired(storage.retrieveString(KEY_ID_TOKEN))) {
clearCredentials()
callback.onFailure(CredentialsManagerException.SESSION_EXPIRED)
return@execute
}
val refreshToken = storage.retrieveString(KEY_REFRESH_TOKEN)
if (refreshToken.isNullOrEmpty()) {
callback.onFailure(CredentialsManagerException.NO_REFRESH_TOKEN)
Expand Down Expand Up @@ -462,17 +478,27 @@ public class CredentialsManager @VisibleForTesting(otherwise = VisibleForTesting
callback.onFailure(CredentialsManagerException.NO_CREDENTIALS)
return@execute
}
// IPSIE session_expiry: enforce the upstream-IdP session ceiling before serving any
// cached token or attempting a refresh. Past the ceiling, clear and surface the error
// so the refresh-token grant is never used to outlive the session.
if (isSessionExpired(idToken)) {
clearCredentials()
callback.onFailure(CredentialsManagerException.SESSION_EXPIRED)
return@execute
}
val willAccessTokenExpire = willExpire(expiresAt!!, minTtl.toLong())
val scopeChanged = hasScopeChanged(storedScope, scope)
if (!forceRefresh && !willAccessTokenExpire && !scopeChanged) {
callback.onSuccess(
recreateCredentials(
idToken.orEmpty(),
accessToken.orEmpty(),
tokenType.orEmpty(),
refreshToken,
Date(expiresAt),
storedScope
stampPinnedSessionExpiry(
recreateCredentials(
idToken.orEmpty(),
accessToken.orEmpty(),
tokenType.orEmpty(),
refreshToken,
Date(expiresAt),
storedScope
)
)
)
return@execute
Expand Down Expand Up @@ -525,7 +551,7 @@ public class CredentialsManager @VisibleForTesting(otherwise = VisibleForTesting
fresh.scope
)
saveCredentials(credentials)
callback.onSuccess(credentials)
callback.onSuccess(stampPinnedSessionExpiry(credentials))
Comment thread
kishore7snehil marked this conversation as resolved.
} catch (error: AuthenticationException) {
if (error.isMultifactorRequired) {
callback.onFailure(
Expand Down Expand Up @@ -580,6 +606,13 @@ public class CredentialsManager @VisibleForTesting(otherwise = VisibleForTesting
) {
serialExecutor.execute {
runCatchingOnExecutor(callback) {
// IPSIE session_expiry: enforce the upstream-IdP session ceiling before serving cached
// API credentials or exchanging the refresh token, so the session is never extended past it.
if (isSessionExpired(storage.retrieveString(KEY_ID_TOKEN))) {
clearCredentials()
callback.onFailure(CredentialsManagerException.SESSION_EXPIRED)
return@execute
}
val key = getAPICredentialsKey(audience, scope)
val apiCredentialsJson = storage.retrieveString(key)
var apiCredentialType: String? = null
Expand Down Expand Up @@ -697,9 +730,15 @@ public class CredentialsManager @VisibleForTesting(otherwise = VisibleForTesting
val expiresAt = storage.retrieveLong(KEY_EXPIRES_AT)
val emptyCredentials =
TextUtils.isEmpty(accessToken) && TextUtils.isEmpty(idToken) || expiresAt == null
return !(emptyCredentials || willExpire(
expiresAt!!, minTtl
) && refreshToken == null)
if (emptyCredentials) {
return false
}
// IPSIE session_expiry: once the upstream-IdP ceiling passes, no valid credentials remain and
// a refresh cannot extend the session past it, so report no valid credentials.
if (isSessionExpired(idToken)) {
return false
}
return !(willExpire(expiresAt!!, minTtl) && refreshToken == null)
}

/**
Expand All @@ -714,6 +753,7 @@ public class CredentialsManager @VisibleForTesting(otherwise = VisibleForTesting
storage.remove(KEY_SCOPE)
storage.remove(LEGACY_KEY_CACHE_EXPIRES_AT)
storage.remove(KEY_DPOP_THUMBPRINT)
storage.remove(KEY_SESSION_EXPIRY)
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ public class CredentialsManagerException :
DPOP_KEY_MISSING,
DPOP_KEY_MISMATCH,
DPOP_NOT_CONFIGURED,
SESSION_EXPIRED,
UNKNOWN_ERROR
}

Expand Down Expand Up @@ -169,6 +170,9 @@ public class CredentialsManagerException :
public val DPOP_NOT_CONFIGURED: CredentialsManagerException =
CredentialsManagerException(Code.DPOP_NOT_CONFIGURED)

public val SESSION_EXPIRED: CredentialsManagerException =
CredentialsManagerException(Code.SESSION_EXPIRED)

public val UNKNOWN_ERROR: CredentialsManagerException = CredentialsManagerException(Code.UNKNOWN_ERROR)


Expand Down Expand Up @@ -220,6 +224,7 @@ public class CredentialsManagerException :
Code.DPOP_KEY_MISSING -> "The stored credentials are DPoP-bound but the DPoP key pair is no longer available in the Android KeyStore. Re-authentication is required."
Code.DPOP_KEY_MISMATCH -> "The stored credentials are DPoP-bound but the current DPoP key pair does not match the one used when credentials were saved. Re-authentication is required."
Code.DPOP_NOT_CONFIGURED -> "The stored credentials are DPoP-bound but the AuthenticationAPIClient used by this credentials manager was not configured with useDPoP(context). Call AuthenticationAPIClient(auth0).useDPoP(context) and pass the configured client to the credentials manager."
Code.SESSION_EXPIRED -> "The session has reached the session_expiry ceiling set by the identity provider and is no longer valid. The user must re-authenticate."
Code.UNKNOWN_ERROR -> "An unknown error has occurred while fetching the token. Please check the error cause for more details."
}
}
Expand Down
Loading
Loading