From 06163473bf010047c679869bf68f281080a34507 Mon Sep 17 00:00:00 2001 From: utkrishtS Date: Thu, 18 Jun 2026 06:57:51 +0530 Subject: [PATCH 1/6] feat: enforce IPSIE session_expiry ceiling in credentials managers --- EXAMPLES.md | 27 +++ .../storage/BaseCredentialsManager.kt | 74 ++++++++ .../storage/CredentialsManager.kt | 26 ++- .../storage/CredentialsManagerException.kt | 5 + .../storage/SecureCredentialsManager.kt | 36 +++- .../com/auth0/android/request/internal/Jwt.kt | 8 + .../com/auth0/android/result/Credentials.kt | 14 ++ .../storage/CredentialsManagerTest.kt | 176 ++++++++++++++++++ .../storage/SecureCredentialsManagerTest.kt | 126 +++++++++++++ .../auth0/android/request/internal/JwtTest.kt | 50 +++++ .../auth0/android/result/CredentialsTest.kt | 38 ++++ 11 files changed, 574 insertions(+), 6 deletions(-) diff --git a/EXAMPLES.md b/EXAMPLES.md index 1520f3cff..1c617d7af 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -2886,6 +2886,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. @@ -2919,10 +2920,36 @@ 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. + +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. +- 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. +- 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. + +> ⚠️ **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. + +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. diff --git a/auth0/src/main/java/com/auth0/android/authentication/storage/BaseCredentialsManager.kt b/auth0/src/main/java/com/auth0/android/authentication/storage/BaseCredentialsManager.kt index c3ce2133a..9be239d25 100644 --- a/auth0/src/main/java/com/auth0/android/authentication/storage/BaseCredentialsManager.kt +++ b/auth0/src/main/java/com/auth0/android/authentication/storage/BaseCredentialsManager.kt @@ -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 } private var _clock: Clock = ClockImpl() @@ -302,6 +315,67 @@ 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 live claim in [idToken]; (2) the value persisted at + * login under [KEY_SESSION_EXPIRY] (so a refresh whose ID token omits the claim does not silently + * drop the ceiling); (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. + * + * 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 { + val sessionExpiry = sessionExpiryFromIdToken(idToken) + ?: storage.retrieveLong(KEY_SESSION_EXPIRY) + ?: return false + // A non-positive ceiling is not a valid Unix timestamp; treat it as "no ceiling" rather than + // already-expired (mirrors the guard in [willExpire] for unset/migration values). + if (sessionExpiry <= 0) { + return false + } + val nowSeconds = currentTimeInMillis / 1000 + return nowSeconds + SESSION_EXPIRY_LEEWAY_SECONDS >= sessionExpiry + } + + /** + * Persists the `session_expiry` ceiling read from the given ID token, if present. + * + * To preserve the ceiling across refreshes (the refresh grant does not re-emit `session_expiry`), + * the stored value is only ever written, never cleared, when a fresh ID token omits the claim. + * Call from `saveCredentials`. + */ + protected fun persistSessionExpiry(idToken: String?) { + sessionExpiryFromIdToken(idToken)?.let { storage.store(KEY_SESSION_EXPIRY, it) } + } + + /** + * Validates, at session-creation time, that the given ID token is not already past its + * `session_expiry` ceiling (i.e. `session_expiry <= iat`). 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`. + */ + @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) { + throw CredentialsManagerException.SESSION_EXPIRED + } + } + /** * Returns the key for storing the APICredentials in storage. Uses a combination of audience and scope. * diff --git a/auth0/src/main/java/com/auth0/android/authentication/storage/CredentialsManager.kt b/auth0/src/main/java/com/auth0/android/authentication/storage/CredentialsManager.kt index 31c865f02..29499c27c 100644 --- a/auth0/src/main/java/com/auth0/android/authentication/storage/CredentialsManager.kt +++ b/auth0/src/main/java/com/auth0/android/authentication/storage/CredentialsManager.kt @@ -68,6 +68,8 @@ public class CredentialsManager @VisibleForTesting(otherwise = VisibleForTesting 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) storage.store(KEY_ACCESS_TOKEN, credentials.accessToken) storage.store(KEY_REFRESH_TOKEN, credentials.refreshToken) storage.store(KEY_ID_TOKEN, credentials.idToken) @@ -75,6 +77,9 @@ public class CredentialsManager @VisibleForTesting(otherwise = VisibleForTesting 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) } @@ -462,6 +467,14 @@ 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) { @@ -697,9 +710,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) } /** @@ -714,6 +733,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) } /** diff --git a/auth0/src/main/java/com/auth0/android/authentication/storage/CredentialsManagerException.kt b/auth0/src/main/java/com/auth0/android/authentication/storage/CredentialsManagerException.kt index 44c31f7d8..681140489 100644 --- a/auth0/src/main/java/com/auth0/android/authentication/storage/CredentialsManagerException.kt +++ b/auth0/src/main/java/com/auth0/android/authentication/storage/CredentialsManagerException.kt @@ -51,6 +51,7 @@ public class CredentialsManagerException : DPOP_KEY_MISSING, DPOP_KEY_MISMATCH, DPOP_NOT_CONFIGURED, + SESSION_EXPIRED, UNKNOWN_ERROR } @@ -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) @@ -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." } } diff --git a/auth0/src/main/java/com/auth0/android/authentication/storage/SecureCredentialsManager.kt b/auth0/src/main/java/com/auth0/android/authentication/storage/SecureCredentialsManager.kt index a982e2634..ac8119648 100644 --- a/auth0/src/main/java/com/auth0/android/authentication/storage/SecureCredentialsManager.kt +++ b/auth0/src/main/java/com/auth0/android/authentication/storage/SecureCredentialsManager.kt @@ -177,6 +177,8 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT 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) val json = gson.toJson(credentials) val canRefresh = !TextUtils.isEmpty(credentials.refreshToken) Log.d(TAG, "Trying to encrypt the given data using the private key.") @@ -190,6 +192,9 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT storage.store(LEGACY_KEY_CACHE_EXPIRES_AT, credentials.expiresAt.time) storage.store(KEY_CAN_REFRESH, canRefresh) storage.store(KEY_TOKEN_TYPE, credentials.type) + // 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) } catch (e: IncompatibleDeviceException) { throw CredentialsManagerException( @@ -650,6 +655,15 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT forceRefresh: Boolean, callback: Callback ) { + // IPSIE session_expiry: short-circuit before any biometric prompt or refresh. The ceiling is + // read from the value persisted at login (KEY_SESSION_EXPIRY); past it we clear and surface the + // dedicated error rather than prompting biometrics for a session that can no longer be served. + if (isSessionExpired(null)) { + clearCredentials() + callback.onFailure(CredentialsManagerException.SESSION_EXPIRED) + return + } + if (!hasValidCredentials(minTtl.toLong())) { callback.onFailure(CredentialsManagerException.NO_CREDENTIALS) return @@ -736,6 +750,7 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT storage.remove(KEY_CAN_REFRESH) storage.remove(KEY_TOKEN_TYPE) storage.remove(KEY_DPOP_THUMBPRINT) + storage.remove(KEY_SESSION_EXPIRY) clearBiometricSession() Log.d(TAG, "Credentials were just removed from the storage") } @@ -775,9 +790,16 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT } val canRefresh = storage.retrieveBoolean(KEY_CAN_REFRESH) val emptyCredentials = TextUtils.isEmpty(encryptedEncoded) - return !(emptyCredentials || willExpire( - expiresAt, minTtl - ) && (canRefresh == null || !canRefresh)) + if (emptyCredentials) { + return false + } + // IPSIE session_expiry: once the upstream-IdP ceiling passes, no valid credentials remain. + // The credentials blob is encrypted and cannot be decoded here, so the ceiling is read from + // the value persisted at login (KEY_SESSION_EXPIRY) via isSessionExpired(null). + if (isSessionExpired(null)) { + return false + } + return !(willExpire(expiresAt, minTtl) && (canRefresh == null || !canRefresh)) } @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) @@ -833,6 +855,14 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT 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(credentials.idToken)) { + clearCredentials() + callback.onFailure(CredentialsManagerException.SESSION_EXPIRED) + return@execute + } val willAccessTokenExpire = willExpire(expiresAt, minTtl.toLong()) val scopeChanged = hasScopeChanged(credentials.scope, scope) if (!forceRefresh && !willAccessTokenExpire && !scopeChanged) { diff --git a/auth0/src/main/java/com/auth0/android/request/internal/Jwt.kt b/auth0/src/main/java/com/auth0/android/request/internal/Jwt.kt index 09558f650..9c5f80d27 100644 --- a/auth0/src/main/java/com/auth0/android/request/internal/Jwt.kt +++ b/auth0/src/main/java/com/auth0/android/request/internal/Jwt.kt @@ -30,6 +30,13 @@ internal class Jwt(rawToken: String) { val authenticationTime: Date? val audience: List + /** + * The IPSIE `session_expiry` claim: an absolute session-expiry ceiling in **Unix seconds** + * asserted by the upstream identity provider. Null when the connection does not emit the claim, + * which MUST be treated as "no ceiling". + */ + val sessionExpiry: Long? + init { parts = splitToken(rawToken) val jsonHeader = decodeBase64(parts[0]) @@ -53,6 +60,7 @@ internal class Jwt(rawToken: String) { authorizedParty = decodedPayload["azp"] as String? authenticationTime = (decodedPayload["auth_time"] as? Double)?.let { Date(it.toLong() * 1000) } + sessionExpiry = (decodedPayload["session_expiry"] as? Double)?.toLong() audience = when (val aud = decodedPayload["aud"]) { is String -> listOf(aud) is List<*> -> aud as List diff --git a/auth0/src/main/java/com/auth0/android/result/Credentials.kt b/auth0/src/main/java/com/auth0/android/result/Credentials.kt index ec32ed1f3..86dbf8965 100755 --- a/auth0/src/main/java/com/auth0/android/result/Credentials.kt +++ b/auth0/src/main/java/com/auth0/android/result/Credentials.kt @@ -79,4 +79,18 @@ public data class Credentials( return gson.fromJson(Jwt.decodeBase64(payload), UserProfile::class.java) } + /** + * The absolute session-expiry ceiling, in **Unix seconds**, asserted by the upstream identity + * provider via the IPSIE `session_expiry` claim in the ID token, or `null` when the connection + * does not emit the claim. + * + * This is a session-level ceiling that is independent of [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. A `null` value means there is no such ceiling. + * + * The value is decoded on demand from [idToken] and is not stored as a separate field. + */ + public val sessionExpiresAt: Long? + get() = runCatching { Jwt(idToken).sessionExpiry }.getOrNull() + } \ No newline at end of file diff --git a/auth0/src/test/java/com/auth0/android/authentication/storage/CredentialsManagerTest.kt b/auth0/src/test/java/com/auth0/android/authentication/storage/CredentialsManagerTest.kt index c0e40a29d..2a81b7373 100644 --- a/auth0/src/test/java/com/auth0/android/authentication/storage/CredentialsManagerTest.kt +++ b/auth0/src/test/java/com/auth0/android/authentication/storage/CredentialsManagerTest.kt @@ -1521,6 +1521,7 @@ public class CredentialsManagerTest { verify(storage).remove("com.auth0.scope") verify(storage).remove("com.auth0.cache_expires_at") verify(storage).remove("com.auth0.dpop_key_thumbprint") + verify(storage).remove("com.auth0.session_expiry") verifyNoMoreInteractions(storage) } @@ -1812,6 +1813,20 @@ public class CredentialsManagerTest { Assert.assertFalse(manager.hasValidCredentials()) } + @Test + public fun shouldNotHaveCredentialsWhenSessionCeilingReachedEvenWithRefreshToken() { + val nowSeconds = CredentialsMock.CURRENT_TIME_MS / 1000 + // Access token still valid and a refresh token is present, so absent the ceiling this would + // report valid credentials; the breached session_expiry ceiling must override that. + Mockito.`when`(storage.retrieveLong("com.auth0.expires_at")) + .thenReturn(CredentialsMock.ONE_HOUR_AHEAD_MS) + Mockito.`when`(storage.retrieveString("com.auth0.refresh_token")).thenReturn("refreshToken") + Mockito.`when`(storage.retrieveString("com.auth0.id_token")).thenReturn("idToken") + Mockito.`when`(storage.retrieveString("com.auth0.access_token")).thenReturn("accessToken") + Mockito.`when`(storage.retrieveLong("com.auth0.session_expiry")).thenReturn(nowSeconds - 100) + Assert.assertFalse(manager.hasValidCredentials()) + } + @Test public fun shouldRecreateTheCredentials() { val credentialsManager = CredentialsManager(client, storage) @@ -2528,9 +2543,170 @@ public class CredentialsManagerTest { DPoPUtil.keyStore = DPoPKeyStore() } + // IPSIE session_expiry enforcement + + @Test + public fun shouldFailWithSessionExpiredAndNotRenewWhenSessionCeilingReached() { + val nowSeconds = CredentialsMock.CURRENT_TIME_MS / 1000 + Mockito.`when`(storage.retrieveString("com.auth0.id_token")).thenReturn("idToken") + Mockito.`when`(storage.retrieveString("com.auth0.access_token")).thenReturn("accessToken") + Mockito.`when`(storage.retrieveString("com.auth0.refresh_token")).thenReturn("refreshToken") + Mockito.`when`(storage.retrieveString("com.auth0.token_type")).thenReturn("type") + // Access token already expired so, absent the ceiling, a refresh would otherwise be attempted. + Mockito.`when`(storage.retrieveLong("com.auth0.expires_at")) + .thenReturn(CredentialsMock.CURRENT_TIME_MS) + Mockito.`when`(storage.retrieveString("com.auth0.scope")).thenReturn("scope") + prepareJwtDecoderMockWithSessionExpiry(nowSeconds - 100) + + manager.getCredentials(callback) + + verify(callback).onFailure(exceptionCaptor.capture()) + MatcherAssert.assertThat( + exceptionCaptor.firstValue, + Is.`is`(CredentialsManagerException.SESSION_EXPIRED) + ) + // The refresh-token grant must never be used past the session ceiling. + verifyZeroInteractions(client) + // The breached session must be cleared. + verify(storage).remove("com.auth0.session_expiry") + verify(storage).remove("com.auth0.id_token") + } + + @Test + public fun shouldFailWithSessionExpiredWhenCeilingFallsWithinLeeway() { + val nowSeconds = CredentialsMock.CURRENT_TIME_MS / 1000 + Mockito.`when`(storage.retrieveString("com.auth0.id_token")).thenReturn("idToken") + Mockito.`when`(storage.retrieveString("com.auth0.access_token")).thenReturn("accessToken") + Mockito.`when`(storage.retrieveString("com.auth0.refresh_token")).thenReturn("refreshToken") + Mockito.`when`(storage.retrieveString("com.auth0.token_type")).thenReturn("type") + Mockito.`when`(storage.retrieveLong("com.auth0.expires_at")) + .thenReturn(CredentialsMock.ONE_HOUR_AHEAD_MS) + Mockito.`when`(storage.retrieveString("com.auth0.scope")).thenReturn("scope") + // 10s in the future, but inside the 30s negative leeway -> treated as expired. + prepareJwtDecoderMockWithSessionExpiry(nowSeconds + 10) + + manager.getCredentials(callback) + + verify(callback).onFailure(exceptionCaptor.capture()) + MatcherAssert.assertThat( + exceptionCaptor.firstValue, + Is.`is`(CredentialsManagerException.SESSION_EXPIRED) + ) + verifyZeroInteractions(client) + } + + @Test + public fun shouldReturnCachedCredentialsWhenSessionCeilingNotReached() { + val nowSeconds = CredentialsMock.CURRENT_TIME_MS / 1000 + Mockito.`when`(storage.retrieveString("com.auth0.id_token")).thenReturn("idToken") + Mockito.`when`(storage.retrieveString("com.auth0.access_token")).thenReturn("accessToken") + Mockito.`when`(storage.retrieveString("com.auth0.refresh_token")).thenReturn("refreshToken") + Mockito.`when`(storage.retrieveString("com.auth0.token_type")).thenReturn("type") + // Access token not yet expired -> cached credentials are served without a refresh. + Mockito.`when`(storage.retrieveLong("com.auth0.expires_at")) + .thenReturn(CredentialsMock.ONE_HOUR_AHEAD_MS) + Mockito.`when`(storage.retrieveString("com.auth0.scope")).thenReturn("scope") + prepareJwtDecoderMockWithSessionExpiry(nowSeconds + 100_000) + + manager.getCredentials(callback) + + verify(callback).onSuccess(credentialsCaptor.capture()) + MatcherAssert.assertThat(credentialsCaptor.firstValue, Is.`is`(Matchers.notNullValue())) + verifyZeroInteractions(client) + } + + @Test + public fun shouldNotEnforceSessionExpiryWhenClaimAndStoredValueAreAbsent() { + Mockito.`when`(storage.retrieveString("com.auth0.id_token")).thenReturn("idToken") + Mockito.`when`(storage.retrieveString("com.auth0.access_token")).thenReturn("accessToken") + Mockito.`when`(storage.retrieveString("com.auth0.refresh_token")).thenReturn("refreshToken") + Mockito.`when`(storage.retrieveString("com.auth0.token_type")).thenReturn("type") + Mockito.`when`(storage.retrieveLong("com.auth0.expires_at")) + .thenReturn(CredentialsMock.ONE_HOUR_AHEAD_MS) + Mockito.`when`(storage.retrieveString("com.auth0.scope")).thenReturn("scope") + // No session_expiry claim and no stored ceiling -> existing behavior, no regression. + prepareJwtDecoderMockWithSessionExpiry(null) + Mockito.`when`(storage.retrieveLong("com.auth0.session_expiry")).thenReturn(null) + + manager.getCredentials(callback) + + verify(callback).onSuccess(credentialsCaptor.capture()) + MatcherAssert.assertThat(credentialsCaptor.firstValue, Is.`is`(Matchers.notNullValue())) + } + + @Test + public fun shouldUseStoredSessionExpiryWhenFreshIdTokenOmitsClaim() { + val nowSeconds = CredentialsMock.CURRENT_TIME_MS / 1000 + Mockito.`when`(storage.retrieveString("com.auth0.id_token")).thenReturn("idToken") + Mockito.`when`(storage.retrieveString("com.auth0.access_token")).thenReturn("accessToken") + Mockito.`when`(storage.retrieveString("com.auth0.refresh_token")).thenReturn("refreshToken") + Mockito.`when`(storage.retrieveString("com.auth0.token_type")).thenReturn("type") + Mockito.`when`(storage.retrieveLong("com.auth0.expires_at")) + .thenReturn(CredentialsMock.CURRENT_TIME_MS) + Mockito.`when`(storage.retrieveString("com.auth0.scope")).thenReturn("scope") + // The (refreshed) ID token does not re-emit the claim, but the ceiling persisted at login still applies. + prepareJwtDecoderMockWithSessionExpiry(null) + Mockito.`when`(storage.retrieveLong("com.auth0.session_expiry")).thenReturn(nowSeconds - 100) + + manager.getCredentials(callback) + + verify(callback).onFailure(exceptionCaptor.capture()) + MatcherAssert.assertThat( + exceptionCaptor.firstValue, + Is.`is`(CredentialsManagerException.SESSION_EXPIRED) + ) + verifyZeroInteractions(client) + } + + @Test + public fun shouldThrowWhenSavingCredentialsAlreadyPastSessionCeiling() { + val credentials: Credentials = CredentialsMock.create( + "idToken", "accessToken", "type", "refreshToken", + Date(CredentialsMock.ONE_HOUR_AHEAD_MS), "scope" + ) + val jwtMock = mock() + // session_expiry (1000) is at/below iat (2000) -> already expired at creation. + Mockito.`when`(jwtMock.sessionExpiry).thenReturn(1000L) + Mockito.`when`(jwtMock.issuedAt).thenReturn(Date(2000L * 1000)) + Mockito.`when`(jwtDecoder.decode("idToken")).thenReturn(jwtMock) + + val exception = assertThrows(CredentialsManagerException::class.java) { + manager.saveCredentials(credentials) + } + MatcherAssert.assertThat(exception, Is.`is`(CredentialsManagerException.SESSION_EXPIRED)) + verify(storage, never()).store(eq("com.auth0.id_token"), ArgumentMatchers.anyString()) + } + + @Test + public fun shouldPersistSessionExpiryWhenSavingCredentials() { + val credentials: Credentials = CredentialsMock.create( + "idToken", "accessToken", "type", "refreshToken", + Date(CredentialsMock.ONE_HOUR_AHEAD_MS), "scope" + ) + val sessionExpiry = (CredentialsMock.ONE_HOUR_AHEAD_MS / 1000) + 100_000 + val jwtMock = mock() + Mockito.`when`(jwtMock.sessionExpiry).thenReturn(sessionExpiry) + Mockito.`when`(jwtMock.issuedAt).thenReturn(Date(CredentialsMock.CURRENT_TIME_MS)) + Mockito.`when`(jwtMock.expiresAt).thenReturn(Date(CredentialsMock.ONE_HOUR_AHEAD_MS)) + Mockito.`when`(jwtDecoder.decode("idToken")).thenReturn(jwtMock) + + manager.saveCredentials(credentials) + + verify(storage).store("com.auth0.session_expiry", sessionExpiry) + } + + private fun prepareJwtDecoderMockWithSessionExpiry(sessionExpiry: Long?) { + val jwtMock = mock() + Mockito.`when`(jwtMock.sessionExpiry).thenReturn(sessionExpiry) + Mockito.`when`(jwtDecoder.decode("idToken")).thenReturn(jwtMock) + } + private fun prepareJwtDecoderMock(expiresAt: Date?) { val jwtMock = mock() Mockito.`when`(jwtMock.expiresAt).thenReturn(expiresAt) + // Default to a token without the IPSIE session_expiry claim. Mockito returns 0 (not null) for + // an unstubbed Long?-returning property, which would otherwise trigger a spurious persist. + Mockito.`when`(jwtMock.sessionExpiry).thenReturn(null) Mockito.`when`(jwtDecoder.decode("idToken")).thenReturn(jwtMock) } diff --git a/auth0/src/test/java/com/auth0/android/authentication/storage/SecureCredentialsManagerTest.kt b/auth0/src/test/java/com/auth0/android/authentication/storage/SecureCredentialsManagerTest.kt index fde98151c..a3edb5d9d 100644 --- a/auth0/src/test/java/com/auth0/android/authentication/storage/SecureCredentialsManagerTest.kt +++ b/auth0/src/test/java/com/auth0/android/authentication/storage/SecureCredentialsManagerTest.kt @@ -2176,6 +2176,7 @@ public class SecureCredentialsManagerTest { verify(storage).remove("com.auth0.credentials_can_refresh") verify(storage).remove("com.auth0.token_type") verify(storage).remove("com.auth0.dpop_key_thumbprint") + verify(storage).remove("com.auth0.session_expiry") verifyNoMoreInteractions(storage) } @@ -3571,6 +3572,128 @@ public class SecureCredentialsManagerTest { /** * Used to simplify the tests length */ + // IPSIE session_expiry enforcement + + @Test + public fun shouldFailWithSessionExpiredWithoutBiometricsOrRenewWhenStoredCeilingReached() { + Mockito.`when`(localAuthenticationManager.authenticate()).then { + localAuthenticationManager.resultCallback.onSuccess(true) + } + verifyNoMoreInteractions(client) + val nowSeconds = CredentialsMock.CURRENT_TIME_MS / 1000 + // Stored ceiling already in the past. + Mockito.`when`(storage.retrieveLong("com.auth0.session_expiry")).thenReturn(nowSeconds - 100) + + manager.getCredentials(callback) + + verify(callback).onFailure(exceptionCaptor.capture()) + MatcherAssert.assertThat( + exceptionCaptor.firstValue, + Is.`is`(CredentialsManagerException.SESSION_EXPIRED) + ) + // No biometric prompt should be raised for a dead session. + verify(localAuthenticationManager, never()).authenticate() + // The breached session must be cleared. The refresh-token grant must never be used past the + // ceiling, asserted by verifyNoMoreInteractions(client) at the top of this test. + verify(storage).remove("com.auth0.session_expiry") + verify(storage).remove("com.auth0.credentials") + } + + @Test + public fun shouldFailWithSessionExpiredWhenStoredCeilingFallsWithinLeeway() { + Mockito.`when`(localAuthenticationManager.authenticate()).then { + localAuthenticationManager.resultCallback.onSuccess(true) + } + verifyNoMoreInteractions(client) + val nowSeconds = CredentialsMock.CURRENT_TIME_MS / 1000 + // 10s ahead, but inside the 30s negative leeway -> treated as expired. + Mockito.`when`(storage.retrieveLong("com.auth0.session_expiry")).thenReturn(nowSeconds + 10) + + manager.getCredentials(callback) + + verify(callback).onFailure(exceptionCaptor.capture()) + MatcherAssert.assertThat( + exceptionCaptor.firstValue, + Is.`is`(CredentialsManagerException.SESSION_EXPIRED) + ) + // No refresh past the ceiling, asserted by verifyNoMoreInteractions(client) at the top. + } + + @Test + public fun shouldGetCredentialsWhenStoredSessionCeilingNotReached() { + Mockito.`when`(localAuthenticationManager.authenticate()).then { + localAuthenticationManager.resultCallback.onSuccess(true) + } + verifyNoMoreInteractions(client) + val nowSeconds = CredentialsMock.CURRENT_TIME_MS / 1000 + Mockito.`when`(storage.retrieveLong("com.auth0.session_expiry")) + .thenReturn(nowSeconds + 100_000) + val expiresAt = Date(CredentialsMock.CURRENT_TIME_MS + ONE_HOUR_SECONDS * 1000) + insertTestCredentials(true, true, true, expiresAt, "scope") + + manager.getCredentials(callback) + + verify(callback).onSuccess(credentialsCaptor.capture()) + MatcherAssert.assertThat(credentialsCaptor.firstValue, Is.`is`(Matchers.notNullValue())) + // No refresh needed (token not expired), asserted by verifyNoMoreInteractions(client) at the top. + } + + @Test + public fun shouldNotEnforceSessionExpiryWhenNoStoredCeiling() { + Mockito.`when`(localAuthenticationManager.authenticate()).then { + localAuthenticationManager.resultCallback.onSuccess(true) + } + verifyNoMoreInteractions(client) + // No stored ceiling -> existing behavior, no regression. + Mockito.`when`(storage.retrieveLong("com.auth0.session_expiry")).thenReturn(null) + val expiresAt = Date(CredentialsMock.CURRENT_TIME_MS + ONE_HOUR_SECONDS * 1000) + insertTestCredentials(true, true, true, expiresAt, "scope") + + manager.getCredentials(callback) + + verify(callback).onSuccess(credentialsCaptor.capture()) + MatcherAssert.assertThat(credentialsCaptor.firstValue, Is.`is`(Matchers.notNullValue())) + } + + @Test + public fun shouldThrowWhenSavingCredentialsAlreadyPastSessionCeiling() { + val credentials = CredentialsMock.create( + "idToken", "accessToken", "type", "refreshToken", + Date(CredentialsMock.ONE_HOUR_AHEAD_MS), "scope" + ) + val jwtMock = mock() + // session_expiry (1000) is at/below iat (2000) -> already expired at creation. + Mockito.`when`(jwtMock.sessionExpiry).thenReturn(1000L) + Mockito.`when`(jwtMock.issuedAt).thenReturn(Date(2000L * 1000)) + Mockito.`when`(jwtDecoder.decode("idToken")).thenReturn(jwtMock) + + val exception = assertThrows(CredentialsManagerException::class.java) { + manager.saveCredentials(credentials) + } + MatcherAssert.assertThat(exception, Is.`is`(CredentialsManagerException.SESSION_EXPIRED)) + verify(storage, never()).store(eq("com.auth0.credentials"), anyString()) + } + + @Test + public fun shouldPersistSessionExpiryWhenSavingCredentials() { + val expirationTime = CredentialsMock.ONE_HOUR_AHEAD_MS + val credentials = CredentialsMock.create( + "idToken", "accessToken", "type", "refreshToken", Date(expirationTime), "scope" + ) + val sessionExpiry = (expirationTime / 1000) + 100_000 + val json = gson.toJson(credentials) + val jwtMock = mock() + Mockito.`when`(jwtMock.expiresAt).thenReturn(Date(expirationTime)) + Mockito.`when`(jwtMock.sessionExpiry).thenReturn(sessionExpiry) + Mockito.`when`(jwtMock.issuedAt).thenReturn(Date(CredentialsMock.CURRENT_TIME_MS)) + Mockito.`when`(jwtDecoder.decode("idToken")).thenReturn(jwtMock) + Mockito.`when`(crypto.encrypt(json.toByteArray())).thenReturn(json.toByteArray()) + + manager.saveCredentials(credentials) + + verify(storage).store("com.auth0.session_expiry", sessionExpiry) + } + private fun insertTestCredentials( hasIdToken: Boolean, hasAccessToken: Boolean, @@ -3934,6 +4057,9 @@ public class SecureCredentialsManagerTest { private fun prepareJwtDecoderMock(expiresAt: Date?) { val jwtMock = mock() Mockito.`when`(jwtMock.expiresAt).thenReturn(expiresAt) + // Default to a token without the IPSIE session_expiry claim. Mockito returns 0 (not null) for + // an unstubbed Long?-returning property, which would otherwise trigger a spurious persist. + Mockito.`when`(jwtMock.sessionExpiry).thenReturn(null) Mockito.`when`(jwtDecoder.decode("idToken")).thenReturn(jwtMock) } diff --git a/auth0/src/test/java/com/auth0/android/request/internal/JwtTest.kt b/auth0/src/test/java/com/auth0/android/request/internal/JwtTest.kt index 17e768a5e..cdff660fb 100644 --- a/auth0/src/test/java/com/auth0/android/request/internal/JwtTest.kt +++ b/auth0/src/test/java/com/auth0/android/request/internal/JwtTest.kt @@ -1,5 +1,6 @@ package com.auth0.android.request.internal +import android.util.Base64 import androidx.test.espresso.matcher.ViewMatchers.assertThat import com.google.gson.stream.MalformedJsonException import org.hamcrest.Matchers.* @@ -203,4 +204,53 @@ public class JwtTest { assertThat(jwt.issuedAt, `is`(nullValue())) } + + // IPSIE session_expiry claim + + @Test + public fun shouldGetSessionExpiryAsLongSeconds() { + val jwt = Jwt(jwtWithPayload("""{"session_expiry":1700000000}""")) + assertThat(jwt, `is`(notNullValue())) + assertThat(jwt.sessionExpiry, `is`(1700000000L)) + } + + @Test + public fun shouldGetNullSessionExpiryIfMissing() { + val jwt = Jwt("eyJhbGciOiJIUzI1NiJ9.e30.something") + assertThat(jwt, `is`(notNullValue())) + + assertThat(jwt.sessionExpiry, `is`(nullValue())) + } + + @Test + public fun shouldGetNullSessionExpiryIfNonNumeric() { + val jwt = Jwt(jwtWithPayload("""{"session_expiry":"not-a-number"}""")) + assertThat(jwt, `is`(notNullValue())) + + assertThat(jwt.sessionExpiry, `is`(nullValue())) + } + + @Test + public fun shouldTruncateFractionalSessionExpiryToLong() { + val jwt = Jwt(jwtWithPayload("""{"session_expiry":1700000000.75}""")) + assertThat(jwt, `is`(notNullValue())) + + assertThat(jwt.sessionExpiry, `is`(1700000000L)) + } + + /** + * Builds a JWT with a fixed `alg=HS256` header and a dummy signature, encoding the given JSON + * payload so that [Jwt] can decode it. The signature is never verified by [Jwt]. + */ + private fun jwtWithPayload(jsonPayload: String): String { + val header = encode("""{"alg":"HS256","typ":"JWT"}""") + val payload = encode(jsonPayload) + return "$header.$payload.signature" + } + + private fun encode(json: String): String = + Base64.encodeToString( + json.toByteArray(Charsets.UTF_8), + Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING + ) } \ No newline at end of file diff --git a/auth0/src/test/java/com/auth0/android/result/CredentialsTest.kt b/auth0/src/test/java/com/auth0/android/result/CredentialsTest.kt index ac38dc36b..b06f1f194 100644 --- a/auth0/src/test/java/com/auth0/android/result/CredentialsTest.kt +++ b/auth0/src/test/java/com/auth0/android/result/CredentialsTest.kt @@ -1,5 +1,6 @@ package com.auth0.android.result +import android.util.Base64 import com.auth0.android.request.internal.GsonProvider.gson import org.hamcrest.MatcherAssert import org.hamcrest.Matchers @@ -81,4 +82,41 @@ public class CredentialsTest { Matchers.`is`("Credentials(idToken='xxxxx', accessToken='xxxxx', type='type', refreshToken='xxxxx', expiresAt='$date', scope='scope')") ) } + + @Test + public fun shouldGetSessionExpiresAtFromIdToken() { + val credentials = Credentials( + jwtWithPayload("""{"session_expiry":1700000000}"""), + "accessToken", "type", "refreshToken", Date(), "scope" + ) + MatcherAssert.assertThat(credentials.sessionExpiresAt, Matchers.`is`(1700000000L)) + } + + @Test + public fun shouldGetNullSessionExpiresAtWhenClaimMissing() { + val credentials = Credentials( + jwtWithPayload("""{"sub":"auth0|123456"}"""), + "accessToken", "type", "refreshToken", Date(), "scope" + ) + MatcherAssert.assertThat(credentials.sessionExpiresAt, Matchers.`is`(Matchers.nullValue())) + } + + @Test + public fun shouldGetNullSessionExpiresAtWhenIdTokenIsNotAValidJwt() { + val credentials = + CredentialsMock.create("not-a-jwt", "accessToken", "type", "refreshToken", Date(), "scope") + MatcherAssert.assertThat(credentials.sessionExpiresAt, Matchers.`is`(Matchers.nullValue())) + } + + private fun jwtWithPayload(jsonPayload: String): String { + val header = encode("""{"alg":"HS256","typ":"JWT"}""") + val payload = encode(jsonPayload) + return "$header.$payload.signature" + } + + private fun encode(json: String): String = + Base64.encodeToString( + json.toByteArray(Charsets.UTF_8), + Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING + ) } \ No newline at end of file From 36a60c8130fa805ac9f7ab653ba973049b71dc0d Mon Sep 17 00:00:00 2001 From: utkrishtS Date: Fri, 19 Jun 2026 15:37:55 +0530 Subject: [PATCH 2/6] fix: enforce session_expiry on SSO/API refresh paths and address review feedback --- .../storage/CredentialsManager.kt | 18 ++++++ .../storage/SecureCredentialsManager.kt | 29 ++++++++- .../storage/CredentialsManagerTest.kt | 40 ++++++++++++ .../storage/SecureCredentialsManagerTest.kt | 64 ++++++++++++++++--- 4 files changed, 142 insertions(+), 9 deletions(-) diff --git a/auth0/src/main/java/com/auth0/android/authentication/storage/CredentialsManager.kt b/auth0/src/main/java/com/auth0/android/authentication/storage/CredentialsManager.kt index 29499c27c..23217fd13 100644 --- a/auth0/src/main/java/com/auth0/android/authentication/storage/CredentialsManager.kt +++ b/auth0/src/main/java/com/auth0/android/authentication/storage/CredentialsManager.kt @@ -63,7 +63,11 @@ 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 @@ -134,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) @@ -593,6 +604,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 diff --git a/auth0/src/main/java/com/auth0/android/authentication/storage/SecureCredentialsManager.kt b/auth0/src/main/java/com/auth0/android/authentication/storage/SecureCredentialsManager.kt index ac8119648..0851f9e74 100644 --- a/auth0/src/main/java/com/auth0/android/authentication/storage/SecureCredentialsManager.kt +++ b/auth0/src/main/java/com/auth0/android/authentication/storage/SecureCredentialsManager.kt @@ -169,7 +169,10 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT * Saves the given credentials in the Storage. * * @param credentials the credentials to save. - * @throws CredentialsManagerException if the credentials couldn't be encrypted. Some devices are not compatible at all with the cryptographic + * @throws CredentialsManagerException with code `SESSION_EXPIRED` if the credentials carry an + * IPSIE `session_expiry` claim that is already past its ceiling at creation time, with code + * `INVALID_CREDENTIALS` if neither an access_token nor an id_token is present, or if the + * credentials couldn't be encrypted. Some devices are not compatible at all with the cryptographic * implementation and will have [CredentialsManagerException.isDeviceIncompatible] return true. */ @Throws(CredentialsManagerException::class) @@ -283,6 +286,13 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT callback.onFailure(exception) return@execute } + // 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(existingCredentials.idToken)) { + clearCredentials() + callback.onFailure(CredentialsManagerException.SESSION_EXPIRED) + return@execute + } if (existingCredentials.refreshToken.isNullOrEmpty()) { callback.onFailure(CredentialsManagerException.NO_REFRESH_TOKEN) return@execute @@ -715,6 +725,14 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT headers: Map, callback: Callback ) { + // IPSIE session_expiry: short-circuit before any biometric prompt or refresh. The ceiling is + // read from the value persisted at login (KEY_SESSION_EXPIRY); past it we clear and surface the + // dedicated error rather than prompting biometrics for a session that can no longer be served. + if (isSessionExpired(null)) { + clearCredentials() + callback.onFailure(CredentialsManagerException.SESSION_EXPIRED) + return + } if (fragmentActivity != null && localAuthenticationOptions != null && localAuthenticationManagerFactory != null) { @@ -977,6 +995,15 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT ) { serialExecutor.execute { runCatchingOnExecutor(callback) { + // IPSIE session_expiry: enforce the upstream-IdP session ceiling before serving cached + // API credentials or exchanging the refresh token. The ceiling is read from the value + // persisted at login (KEY_SESSION_EXPIRY) so it holds even though the credentials blob + // is encrypted; past it we clear and surface the dedicated error. + if (isSessionExpired(null)) { + clearCredentials() + callback.onFailure(CredentialsManagerException.SESSION_EXPIRED) + return@execute + } val encryptedEncodedJson = storage.retrieveString(getAPICredentialsKey(audience, scope)) diff --git a/auth0/src/test/java/com/auth0/android/authentication/storage/CredentialsManagerTest.kt b/auth0/src/test/java/com/auth0/android/authentication/storage/CredentialsManagerTest.kt index 2a81b7373..23ebff264 100644 --- a/auth0/src/test/java/com/auth0/android/authentication/storage/CredentialsManagerTest.kt +++ b/auth0/src/test/java/com/auth0/android/authentication/storage/CredentialsManagerTest.kt @@ -2695,6 +2695,46 @@ public class CredentialsManagerTest { verify(storage).store("com.auth0.session_expiry", sessionExpiry) } + @Test + public fun shouldFailGetSsoCredentialsWithSessionExpiredWhenSessionCeilingReached() { + val nowSeconds = CredentialsMock.CURRENT_TIME_MS / 1000 + Mockito.`when`(storage.retrieveString("com.auth0.id_token")).thenReturn("idToken") + Mockito.`when`(storage.retrieveString("com.auth0.refresh_token")).thenReturn("refreshToken") + prepareJwtDecoderMockWithSessionExpiry(nowSeconds - 100) + + manager.getSsoCredentials(ssoCallback) + + verify(ssoCallback).onFailure(exceptionCaptor.capture()) + MatcherAssert.assertThat( + exceptionCaptor.firstValue, + Is.`is`(CredentialsManagerException.SESSION_EXPIRED) + ) + // The refresh-token grant must never be exchanged for SSO credentials past the ceiling. + verifyZeroInteractions(client) + verify(storage).remove("com.auth0.session_expiry") + verify(storage).remove("com.auth0.id_token") + } + + @Test + public fun shouldFailGetApiCredentialsWithSessionExpiredWhenSessionCeilingReached() { + val nowSeconds = CredentialsMock.CURRENT_TIME_MS / 1000 + Mockito.`when`(storage.retrieveString("com.auth0.id_token")).thenReturn("idToken") + Mockito.`when`(storage.retrieveString("com.auth0.refresh_token")).thenReturn("refreshToken") + prepareJwtDecoderMockWithSessionExpiry(nowSeconds - 100) + + manager.getApiCredentials("audience", "scope", callback = apiCredentialsCallback) + + verify(apiCredentialsCallback).onFailure(exceptionCaptor.capture()) + MatcherAssert.assertThat( + exceptionCaptor.firstValue, + Is.`is`(CredentialsManagerException.SESSION_EXPIRED) + ) + // The refresh-token grant must never be exchanged for API credentials past the ceiling. + verifyZeroInteractions(client) + verify(storage).remove("com.auth0.session_expiry") + verify(storage).remove("com.auth0.id_token") + } + private fun prepareJwtDecoderMockWithSessionExpiry(sessionExpiry: Long?) { val jwtMock = mock() Mockito.`when`(jwtMock.sessionExpiry).thenReturn(sessionExpiry) diff --git a/auth0/src/test/java/com/auth0/android/authentication/storage/SecureCredentialsManagerTest.kt b/auth0/src/test/java/com/auth0/android/authentication/storage/SecureCredentialsManagerTest.kt index a3edb5d9d..fde66fbc5 100644 --- a/auth0/src/test/java/com/auth0/android/authentication/storage/SecureCredentialsManagerTest.kt +++ b/auth0/src/test/java/com/auth0/android/authentication/storage/SecureCredentialsManagerTest.kt @@ -3579,7 +3579,6 @@ public class SecureCredentialsManagerTest { Mockito.`when`(localAuthenticationManager.authenticate()).then { localAuthenticationManager.resultCallback.onSuccess(true) } - verifyNoMoreInteractions(client) val nowSeconds = CredentialsMock.CURRENT_TIME_MS / 1000 // Stored ceiling already in the past. Mockito.`when`(storage.retrieveLong("com.auth0.session_expiry")).thenReturn(nowSeconds - 100) @@ -3593,10 +3592,11 @@ public class SecureCredentialsManagerTest { ) // No biometric prompt should be raised for a dead session. verify(localAuthenticationManager, never()).authenticate() - // The breached session must be cleared. The refresh-token grant must never be used past the - // ceiling, asserted by verifyNoMoreInteractions(client) at the top of this test. + // The breached session must be cleared. verify(storage).remove("com.auth0.session_expiry") verify(storage).remove("com.auth0.credentials") + // The refresh-token grant must never be used past the ceiling. + verifyNoMoreInteractions(client) } @Test @@ -3604,7 +3604,6 @@ public class SecureCredentialsManagerTest { Mockito.`when`(localAuthenticationManager.authenticate()).then { localAuthenticationManager.resultCallback.onSuccess(true) } - verifyNoMoreInteractions(client) val nowSeconds = CredentialsMock.CURRENT_TIME_MS / 1000 // 10s ahead, but inside the 30s negative leeway -> treated as expired. Mockito.`when`(storage.retrieveLong("com.auth0.session_expiry")).thenReturn(nowSeconds + 10) @@ -3616,7 +3615,8 @@ public class SecureCredentialsManagerTest { exceptionCaptor.firstValue, Is.`is`(CredentialsManagerException.SESSION_EXPIRED) ) - // No refresh past the ceiling, asserted by verifyNoMoreInteractions(client) at the top. + // No refresh past the ceiling. + verifyNoMoreInteractions(client) } @Test @@ -3624,7 +3624,6 @@ public class SecureCredentialsManagerTest { Mockito.`when`(localAuthenticationManager.authenticate()).then { localAuthenticationManager.resultCallback.onSuccess(true) } - verifyNoMoreInteractions(client) val nowSeconds = CredentialsMock.CURRENT_TIME_MS / 1000 Mockito.`when`(storage.retrieveLong("com.auth0.session_expiry")) .thenReturn(nowSeconds + 100_000) @@ -3635,7 +3634,8 @@ public class SecureCredentialsManagerTest { verify(callback).onSuccess(credentialsCaptor.capture()) MatcherAssert.assertThat(credentialsCaptor.firstValue, Is.`is`(Matchers.notNullValue())) - // No refresh needed (token not expired), asserted by verifyNoMoreInteractions(client) at the top. + // No refresh needed (token not expired). + verifyNoMoreInteractions(client) } @Test @@ -3643,7 +3643,6 @@ public class SecureCredentialsManagerTest { Mockito.`when`(localAuthenticationManager.authenticate()).then { localAuthenticationManager.resultCallback.onSuccess(true) } - verifyNoMoreInteractions(client) // No stored ceiling -> existing behavior, no regression. Mockito.`when`(storage.retrieveLong("com.auth0.session_expiry")).thenReturn(null) val expiresAt = Date(CredentialsMock.CURRENT_TIME_MS + ONE_HOUR_SECONDS * 1000) @@ -3653,6 +3652,7 @@ public class SecureCredentialsManagerTest { verify(callback).onSuccess(credentialsCaptor.capture()) MatcherAssert.assertThat(credentialsCaptor.firstValue, Is.`is`(Matchers.notNullValue())) + verifyNoMoreInteractions(client) } @Test @@ -3694,6 +3694,54 @@ public class SecureCredentialsManagerTest { verify(storage).store("com.auth0.session_expiry", sessionExpiry) } + @Test + public fun shouldFailGetSsoCredentialsWithSessionExpiredWhenSessionCeilingReached() { + val nowSeconds = CredentialsMock.CURRENT_TIME_MS / 1000 + insertTestCredentials( + hasIdToken = true, + hasAccessToken = true, + hasRefreshToken = true, + willExpireAt = Date(CredentialsMock.ONE_HOUR_AHEAD_MS), + scope = "scope" + ) + // The decrypted ID token carries a session_expiry ceiling already in the past. + val jwtMock = mock() + Mockito.`when`(jwtMock.sessionExpiry).thenReturn(nowSeconds - 100) + Mockito.`when`(jwtDecoder.decode("idToken")).thenReturn(jwtMock) + + manager.getSsoCredentials(ssoCallback) + + verify(ssoCallback).onFailure(exceptionCaptor.capture()) + MatcherAssert.assertThat( + exceptionCaptor.firstValue, + Is.`is`(CredentialsManagerException.SESSION_EXPIRED) + ) + // The refresh-token grant must never be exchanged for SSO credentials past the ceiling. + verifyNoMoreInteractions(client) + verify(storage).remove("com.auth0.session_expiry") + verify(storage).remove("com.auth0.credentials") + } + + @Test + public fun shouldFailGetApiCredentialsWithSessionExpiredWhenStoredCeilingReached() { + val nowSeconds = CredentialsMock.CURRENT_TIME_MS / 1000 + // Stored ceiling already in the past; the encrypted blob need not be read. + Mockito.`when`(storage.retrieveLong("com.auth0.session_expiry")).thenReturn(nowSeconds - 100) + + manager.getApiCredentials("audience", "scope", callback = apiCredentialsCallback) + + verify(apiCredentialsCallback).onFailure(exceptionCaptor.capture()) + MatcherAssert.assertThat( + exceptionCaptor.firstValue, + Is.`is`(CredentialsManagerException.SESSION_EXPIRED) + ) + // No biometric prompt and no refresh past the ceiling. + verify(localAuthenticationManager, never()).authenticate() + verifyNoMoreInteractions(client) + verify(storage).remove("com.auth0.session_expiry") + verify(storage).remove("com.auth0.credentials") + } + private fun insertTestCredentials( hasIdToken: Boolean, hasAccessToken: Boolean, From bb44ffc9090c07ad07f47a6280606fd5573340d9 Mon Sep 17 00:00:00 2001 From: utkrishtS Date: Fri, 19 Jun 2026 20:01:21 +0530 Subject: [PATCH 3/6] fix: pin session_expiry at login and ignore it on refresh grants --- .../storage/BaseCredentialsManager.kt | 18 ++++++++++---- .../storage/CredentialsManagerTest.kt | 22 +++++++++++++++++ .../storage/SecureCredentialsManagerTest.kt | 24 +++++++++++++++++++ 3 files changed, 59 insertions(+), 5 deletions(-) diff --git a/auth0/src/main/java/com/auth0/android/authentication/storage/BaseCredentialsManager.kt b/auth0/src/main/java/com/auth0/android/authentication/storage/BaseCredentialsManager.kt index 9be239d25..669a82084 100644 --- a/auth0/src/main/java/com/auth0/android/authentication/storage/BaseCredentialsManager.kt +++ b/auth0/src/main/java/com/auth0/android/authentication/storage/BaseCredentialsManager.kt @@ -349,14 +349,22 @@ public abstract class BaseCredentialsManager internal constructor( } /** - * Persists the `session_expiry` ceiling read from the given ID token, if present. + * Pins the `session_expiry` ceiling from the initial login and preserves it across refreshes. * - * To preserve the ceiling across refreshes (the refresh grant does not re-emit `session_expiry`), - * the stored value is only ever written, never cleared, when a fresh ID token omits the claim. - * Call from `saveCredentials`. + * 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?) { - sessionExpiryFromIdToken(idToken)?.let { storage.store(KEY_SESSION_EXPIRY, it) } + 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 + storage.store(KEY_SESSION_EXPIRY, incoming) } /** diff --git a/auth0/src/test/java/com/auth0/android/authentication/storage/CredentialsManagerTest.kt b/auth0/src/test/java/com/auth0/android/authentication/storage/CredentialsManagerTest.kt index 23ebff264..3023855b3 100644 --- a/auth0/src/test/java/com/auth0/android/authentication/storage/CredentialsManagerTest.kt +++ b/auth0/src/test/java/com/auth0/android/authentication/storage/CredentialsManagerTest.kt @@ -2735,6 +2735,28 @@ public class CredentialsManagerTest { verify(storage).remove("com.auth0.id_token") } + @Test + public fun shouldNotOverwriteStoredSessionExpiryWhenSavingRefreshedCredentials() { + val credentials: Credentials = CredentialsMock.create( + "idToken", "accessToken", "type", "refreshToken", + Date(CredentialsMock.ONE_HOUR_AHEAD_MS), "scope" + ) + // A ceiling is already pinned from the initial login. + val pinnedCeiling = (CredentialsMock.ONE_HOUR_AHEAD_MS / 1000) + 100_000 + Mockito.`when`(storage.retrieveLong("com.auth0.session_expiry")).thenReturn(pinnedCeiling) + // The refreshed ID token re-emits a later session_expiry that must be ignored. + val jwtMock = mock() + Mockito.`when`(jwtMock.sessionExpiry).thenReturn(pinnedCeiling + 100_000) + Mockito.`when`(jwtMock.issuedAt).thenReturn(Date(CredentialsMock.CURRENT_TIME_MS)) + Mockito.`when`(jwtMock.expiresAt).thenReturn(Date(CredentialsMock.ONE_HOUR_AHEAD_MS)) + Mockito.`when`(jwtDecoder.decode("idToken")).thenReturn(jwtMock) + + manager.saveCredentials(credentials) + + // The pinned ceiling must not be moved by a refresh-grant claim. + verify(storage, never()).store(eq("com.auth0.session_expiry"), ArgumentMatchers.anyLong()) + } + private fun prepareJwtDecoderMockWithSessionExpiry(sessionExpiry: Long?) { val jwtMock = mock() Mockito.`when`(jwtMock.sessionExpiry).thenReturn(sessionExpiry) diff --git a/auth0/src/test/java/com/auth0/android/authentication/storage/SecureCredentialsManagerTest.kt b/auth0/src/test/java/com/auth0/android/authentication/storage/SecureCredentialsManagerTest.kt index fde66fbc5..61a5035bf 100644 --- a/auth0/src/test/java/com/auth0/android/authentication/storage/SecureCredentialsManagerTest.kt +++ b/auth0/src/test/java/com/auth0/android/authentication/storage/SecureCredentialsManagerTest.kt @@ -3694,6 +3694,30 @@ public class SecureCredentialsManagerTest { verify(storage).store("com.auth0.session_expiry", sessionExpiry) } + @Test + public fun shouldNotOverwriteStoredSessionExpiryWhenSavingRefreshedCredentials() { + val expirationTime = CredentialsMock.ONE_HOUR_AHEAD_MS + val credentials = CredentialsMock.create( + "idToken", "accessToken", "type", "refreshToken", Date(expirationTime), "scope" + ) + // A ceiling is already pinned from the initial login. + val pinnedCeiling = (expirationTime / 1000) + 100_000 + Mockito.`when`(storage.retrieveLong("com.auth0.session_expiry")).thenReturn(pinnedCeiling) + val json = gson.toJson(credentials) + // The refreshed ID token re-emits a later session_expiry that must be ignored. + val jwtMock = mock() + Mockito.`when`(jwtMock.expiresAt).thenReturn(Date(expirationTime)) + Mockito.`when`(jwtMock.sessionExpiry).thenReturn(pinnedCeiling + 100_000) + Mockito.`when`(jwtMock.issuedAt).thenReturn(Date(CredentialsMock.CURRENT_TIME_MS)) + Mockito.`when`(jwtDecoder.decode("idToken")).thenReturn(jwtMock) + Mockito.`when`(crypto.encrypt(json.toByteArray())).thenReturn(json.toByteArray()) + + manager.saveCredentials(credentials) + + // The pinned ceiling must not be moved by a refresh-grant claim. + verify(storage, never()).store(eq("com.auth0.session_expiry"), anyLong()) + } + @Test public fun shouldFailGetSsoCredentialsWithSessionExpiredWhenSessionCeilingReached() { val nowSeconds = CredentialsMock.CURRENT_TIME_MS / 1000 From dd74bac3d6dfdcebd0df014c4d52bd860a522bb4 Mon Sep 17 00:00:00 2001 From: utkrishtS Date: Mon, 22 Jun 2026 17:07:14 +0530 Subject: [PATCH 4/6] fix: address review feedback on session_expiry enforcement --- .../storage/BaseCredentialsManager.kt | 40 ++++++++++++++----- .../storage/CredentialsManager.kt | 18 +++++---- .../storage/SecureCredentialsManager.kt | 4 +- .../com/auth0/android/request/internal/Jwt.kt | 19 +++++++-- .../com/auth0/android/result/Credentials.kt | 17 ++++++-- .../storage/CredentialsManagerTest.kt | 33 +++++++++++++++ .../storage/SecureCredentialsManagerTest.kt | 4 ++ .../auth0/android/request/internal/JwtTest.kt | 10 +++++ .../auth0/android/result/CredentialsTest.kt | 22 ++++++++++ 9 files changed, 140 insertions(+), 27 deletions(-) diff --git a/auth0/src/main/java/com/auth0/android/authentication/storage/BaseCredentialsManager.kt b/auth0/src/main/java/com/auth0/android/authentication/storage/BaseCredentialsManager.kt index 669a82084..a4a8184d9 100644 --- a/auth0/src/main/java/com/auth0/android/authentication/storage/BaseCredentialsManager.kt +++ b/auth0/src/main/java/com/auth0/android/authentication/storage/BaseCredentialsManager.kt @@ -327,17 +327,19 @@ public abstract class BaseCredentialsManager internal constructor( /** * Checks whether the upstream-IdP session ceiling (`session_expiry`) has been reached. * - * The ceiling is resolved in order: (1) the live claim in [idToken]; (2) the value persisted at - * login under [KEY_SESSION_EXPIRY] (so a refresh whose ID token omits the claim does not silently - * drop the ceiling); (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 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 { - val sessionExpiry = sessionExpiryFromIdToken(idToken) - ?: storage.retrieveLong(KEY_SESSION_EXPIRY) + val sessionExpiry = storage.retrieveLong(KEY_SESSION_EXPIRY) + ?: sessionExpiryFromIdToken(idToken) ?: return false // A non-positive ceiling is not a valid Unix timestamp; treat it as "no ceiling" rather than // already-expired (mirrors the guard in [willExpire] for unset/migration values). @@ -348,6 +350,20 @@ public abstract class BaseCredentialsManager internal constructor( 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. * @@ -369,9 +385,13 @@ public abstract class BaseCredentialsManager internal constructor( /** * Validates, at session-creation time, that the given ID token is not already past its - * `session_expiry` ceiling (i.e. `session_expiry <= iat`). 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`. + * `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?) { @@ -379,7 +399,7 @@ public abstract class BaseCredentialsManager internal constructor( val jwt = runCatching { jwtDecoder.decode(idToken) }.getOrNull() ?: return val sessionExpiry = jwt.sessionExpiry ?: return val issuedAtSeconds = jwt.issuedAt?.time?.div(1000) ?: return - if (sessionExpiry <= issuedAtSeconds) { + if (sessionExpiry <= issuedAtSeconds + SESSION_EXPIRY_LEEWAY_SECONDS) { throw CredentialsManagerException.SESSION_EXPIRED } } diff --git a/auth0/src/main/java/com/auth0/android/authentication/storage/CredentialsManager.kt b/auth0/src/main/java/com/auth0/android/authentication/storage/CredentialsManager.kt index 23217fd13..fe11bdd26 100644 --- a/auth0/src/main/java/com/auth0/android/authentication/storage/CredentialsManager.kt +++ b/auth0/src/main/java/com/auth0/android/authentication/storage/CredentialsManager.kt @@ -490,13 +490,15 @@ public class CredentialsManager @VisibleForTesting(otherwise = VisibleForTesting 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 @@ -549,7 +551,7 @@ public class CredentialsManager @VisibleForTesting(otherwise = VisibleForTesting fresh.scope ) saveCredentials(credentials) - callback.onSuccess(credentials) + callback.onSuccess(stampPinnedSessionExpiry(credentials)) } catch (error: AuthenticationException) { if (error.isMultifactorRequired) { callback.onFailure( diff --git a/auth0/src/main/java/com/auth0/android/authentication/storage/SecureCredentialsManager.kt b/auth0/src/main/java/com/auth0/android/authentication/storage/SecureCredentialsManager.kt index 0851f9e74..5d8d9107a 100644 --- a/auth0/src/main/java/com/auth0/android/authentication/storage/SecureCredentialsManager.kt +++ b/auth0/src/main/java/com/auth0/android/authentication/storage/SecureCredentialsManager.kt @@ -884,7 +884,7 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT val willAccessTokenExpire = willExpire(expiresAt, minTtl.toLong()) val scopeChanged = hasScopeChanged(credentials.scope, scope) if (!forceRefresh && !willAccessTokenExpire && !scopeChanged) { - callback.onSuccess(credentials) + callback.onSuccess(stampPinnedSessionExpiry(credentials)) return@execute } if (credentials.refreshToken == null) { @@ -969,7 +969,7 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT try { saveCredentials(freshCredentials) - callback.onSuccess(freshCredentials) + callback.onSuccess(stampPinnedSessionExpiry(freshCredentials)) } catch (error: CredentialsManagerException) { val exception = CredentialsManagerException( CredentialsManagerException.Code.STORE_FAILED, error diff --git a/auth0/src/main/java/com/auth0/android/request/internal/Jwt.kt b/auth0/src/main/java/com/auth0/android/request/internal/Jwt.kt index 9c5f80d27..98eb39fa9 100644 --- a/auth0/src/main/java/com/auth0/android/request/internal/Jwt.kt +++ b/auth0/src/main/java/com/auth0/android/request/internal/Jwt.kt @@ -32,8 +32,9 @@ internal class Jwt(rawToken: String) { /** * The IPSIE `session_expiry` claim: an absolute session-expiry ceiling in **Unix seconds** - * asserted by the upstream identity provider. Null when the connection does not emit the claim, - * which MUST be treated as "no ceiling". + * asserted by the upstream identity provider. Null when the connection does not emit the claim + * or the value is not a plausible Unix-seconds timestamp (see [MAX_PLAUSIBLE_SESSION_EXPIRY]), + * both of which MUST be treated as "no ceiling". */ val sessionExpiry: Long? @@ -60,7 +61,12 @@ internal class Jwt(rawToken: String) { authorizedParty = decodedPayload["azp"] as String? authenticationTime = (decodedPayload["auth_time"] as? Double)?.let { Date(it.toLong() * 1000) } - sessionExpiry = (decodedPayload["session_expiry"] as? Double)?.toLong() + // `session_expiry` is customer-authored and expected in Unix *seconds*. A value mistakenly + // emitted in milliseconds would parse as a timestamp ~50,000 years out and silently disable + // the ceiling (fail-open), so reject implausibly large values and treat them as "no ceiling". + // `as? Number` (not `as? Double`) so a JSON value deserialized as a Long is not dropped. + sessionExpiry = (decodedPayload["session_expiry"] as? Number)?.toLong() + ?.takeIf { it < MAX_PLAUSIBLE_SESSION_EXPIRY } audience = when (val aud = decodedPayload["aud"]) { is String -> listOf(aud) is List<*> -> aud as List @@ -69,6 +75,13 @@ internal class Jwt(rawToken: String) { } companion object { + /** + * Upper bound (exclusive) for a plausible `session_expiry` in Unix seconds: 10,000,000,000 + * (year ~2286). A value at or above this is almost certainly milliseconds and is treated as + * "no ceiling" rather than a date tens of thousands of years out. + */ + private const val MAX_PLAUSIBLE_SESSION_EXPIRY = 10_000_000_000L + fun splitToken(token: String): Array { var parts = token.split(".").toTypedArray() if (parts.size == 2 && token.endsWith(".")) { diff --git a/auth0/src/main/java/com/auth0/android/result/Credentials.kt b/auth0/src/main/java/com/auth0/android/result/Credentials.kt index 86dbf8965..9565b66cd 100755 --- a/auth0/src/main/java/com/auth0/android/result/Credentials.kt +++ b/auth0/src/main/java/com/auth0/android/result/Credentials.kt @@ -79,18 +79,27 @@ public data class Credentials( return gson.fromJson(Jwt.decodeBase64(payload), UserProfile::class.java) } + /** + * The session-expiry ceiling pinned at the initial login, in Unix seconds, stamped by the + * credentials manager when it serves these credentials. When set, it is the value actually + * enforced by the SDK and takes precedence over the live [idToken] claim — so [sessionExpiresAt] + * does not diverge from enforcement after a refresh whose token omits or re-emits the claim. + */ + @Transient + internal var pinnedSessionExpiresAt: Long? = null + /** * The absolute session-expiry ceiling, in **Unix seconds**, asserted by the upstream identity - * provider via the IPSIE `session_expiry` claim in the ID token, or `null` when the connection - * does not emit the claim. + * provider via the IPSIE `session_expiry` claim, or `null` when the connection does not emit it. * * This is a session-level ceiling that is independent of [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. A `null` value means there is no such ceiling. * - * The value is decoded on demand from [idToken] and is not stored as a separate field. + * When these credentials were served by a credentials manager, this reflects the value pinned at + * the initial login (the one the SDK enforces). Otherwise it is decoded on demand from [idToken]. */ public val sessionExpiresAt: Long? - get() = runCatching { Jwt(idToken).sessionExpiry }.getOrNull() + get() = pinnedSessionExpiresAt ?: runCatching { Jwt(idToken).sessionExpiry }.getOrNull() } \ No newline at end of file diff --git a/auth0/src/test/java/com/auth0/android/authentication/storage/CredentialsManagerTest.kt b/auth0/src/test/java/com/auth0/android/authentication/storage/CredentialsManagerTest.kt index 3023855b3..02a7434b1 100644 --- a/auth0/src/test/java/com/auth0/android/authentication/storage/CredentialsManagerTest.kt +++ b/auth0/src/test/java/com/auth0/android/authentication/storage/CredentialsManagerTest.kt @@ -2735,6 +2735,34 @@ public class CredentialsManagerTest { verify(storage).remove("com.auth0.id_token") } + @Test + public fun shouldEnforcePinnedSessionExpiryWhenRefreshedIdTokenReEmitsLaterValue() { + val nowSeconds = CredentialsMock.CURRENT_TIME_MS / 1000 + Mockito.`when`(storage.retrieveString("com.auth0.id_token")).thenReturn("idToken") + Mockito.`when`(storage.retrieveString("com.auth0.access_token")).thenReturn("accessToken") + Mockito.`when`(storage.retrieveString("com.auth0.refresh_token")).thenReturn("refreshToken") + Mockito.`when`(storage.retrieveString("com.auth0.token_type")).thenReturn("type") + Mockito.`when`(storage.retrieveLong("com.auth0.expires_at")) + .thenReturn(CredentialsMock.ONE_HOUR_AHEAD_MS) + Mockito.`when`(storage.retrieveString("com.auth0.scope")).thenReturn("scope") + // The (refreshed) ID token re-emits a *later* session_expiry; it must NOT raise the ceiling. + prepareJwtDecoderMockWithSessionExpiry(nowSeconds + 100_000) + // The pinned ceiling from the initial login is already reached. Stubbed after the helper so + // the storage-first lookup sees this value rather than the helper's default null. + Mockito.`when`(storage.retrieveLong("com.auth0.session_expiry")).thenReturn(nowSeconds - 100) + + manager.getCredentials(callback) + + verify(callback).onFailure(exceptionCaptor.capture()) + MatcherAssert.assertThat( + exceptionCaptor.firstValue, + Is.`is`(CredentialsManagerException.SESSION_EXPIRED) + ) + // Enforcement honors the pinned value, so the refresh-token grant is never used. + verifyZeroInteractions(client) + verify(storage).remove("com.auth0.session_expiry") + } + @Test public fun shouldNotOverwriteStoredSessionExpiryWhenSavingRefreshedCredentials() { val credentials: Credentials = CredentialsMock.create( @@ -2761,6 +2789,11 @@ public class CredentialsManagerTest { val jwtMock = mock() Mockito.`when`(jwtMock.sessionExpiry).thenReturn(sessionExpiry) Mockito.`when`(jwtDecoder.decode("idToken")).thenReturn(jwtMock) + // No value is pinned in storage by default, so the ceiling resolves from the idToken claim + // above. (Mockito returns 0L for an unstubbed Long?-returning property, which the + // storage-first lookup in isSessionExpired would otherwise consume as a bogus ceiling.) + // Tests that exercise a pinned value stub this key explicitly *after* calling this helper. + Mockito.`when`(storage.retrieveLong("com.auth0.session_expiry")).thenReturn(null) } private fun prepareJwtDecoderMock(expiresAt: Date?) { diff --git a/auth0/src/test/java/com/auth0/android/authentication/storage/SecureCredentialsManagerTest.kt b/auth0/src/test/java/com/auth0/android/authentication/storage/SecureCredentialsManagerTest.kt index 61a5035bf..109c993f2 100644 --- a/auth0/src/test/java/com/auth0/android/authentication/storage/SecureCredentialsManagerTest.kt +++ b/auth0/src/test/java/com/auth0/android/authentication/storage/SecureCredentialsManagerTest.kt @@ -3728,6 +3728,10 @@ public class SecureCredentialsManagerTest { willExpireAt = Date(CredentialsMock.ONE_HOUR_AHEAD_MS), scope = "scope" ) + // Nothing pinned in storage, so the ceiling resolves from the decrypted ID token claim + // below. (Mockito returns 0L for the unstubbed key, which the storage-first lookup in + // isSessionExpired would otherwise consume as a bogus "no ceiling" value.) + Mockito.`when`(storage.retrieveLong("com.auth0.session_expiry")).thenReturn(null) // The decrypted ID token carries a session_expiry ceiling already in the past. val jwtMock = mock() Mockito.`when`(jwtMock.sessionExpiry).thenReturn(nowSeconds - 100) diff --git a/auth0/src/test/java/com/auth0/android/request/internal/JwtTest.kt b/auth0/src/test/java/com/auth0/android/request/internal/JwtTest.kt index cdff660fb..d9a6ffcb0 100644 --- a/auth0/src/test/java/com/auth0/android/request/internal/JwtTest.kt +++ b/auth0/src/test/java/com/auth0/android/request/internal/JwtTest.kt @@ -238,6 +238,16 @@ public class JwtTest { assertThat(jwt.sessionExpiry, `is`(1700000000L)) } + @Test + public fun shouldGetNullSessionExpiryIfImplausiblyLarge() { + // A value mistakenly emitted in milliseconds (1700000000000) is ~50,000 years out in seconds + // and would silently disable the ceiling; it must be treated as "no ceiling" (null) instead. + val jwt = Jwt(jwtWithPayload("""{"session_expiry":1700000000000}""")) + assertThat(jwt, `is`(notNullValue())) + + assertThat(jwt.sessionExpiry, `is`(nullValue())) + } + /** * Builds a JWT with a fixed `alg=HS256` header and a dummy signature, encoding the given JSON * payload so that [Jwt] can decode it. The signature is never verified by [Jwt]. diff --git a/auth0/src/test/java/com/auth0/android/result/CredentialsTest.kt b/auth0/src/test/java/com/auth0/android/result/CredentialsTest.kt index b06f1f194..e8d6a272d 100644 --- a/auth0/src/test/java/com/auth0/android/result/CredentialsTest.kt +++ b/auth0/src/test/java/com/auth0/android/result/CredentialsTest.kt @@ -108,6 +108,28 @@ public class CredentialsTest { MatcherAssert.assertThat(credentials.sessionExpiresAt, Matchers.`is`(Matchers.nullValue())) } + @Test + public fun shouldPreferPinnedSessionExpiresAtOverIdTokenClaim() { + // The manager stamps the value pinned at login; it must take precedence over a (later) + // claim re-emitted on the current ID token so the public value matches what is enforced. + val credentials = Credentials( + jwtWithPayload("""{"session_expiry":1700000000}"""), + "accessToken", "type", "refreshToken", Date(), "scope" + ) + credentials.pinnedSessionExpiresAt = 1690000000L + MatcherAssert.assertThat(credentials.sessionExpiresAt, Matchers.`is`(1690000000L)) + } + + @Test + public fun shouldFallBackToIdTokenClaimWhenNoPinnedSessionExpiresAt() { + val credentials = Credentials( + jwtWithPayload("""{"session_expiry":1700000000}"""), + "accessToken", "type", "refreshToken", Date(), "scope" + ) + // No pinned value (credentials not served by a manager) -> decode from the ID token. + MatcherAssert.assertThat(credentials.sessionExpiresAt, Matchers.`is`(1700000000L)) + } + private fun jwtWithPayload(jsonPayload: String): String { val header = encode("""{"alg":"HS256","typ":"JWT"}""") val payload = encode(jsonPayload) From 4f77b20bee76406c4380cede36489abe468f0ea6 Mon Sep 17 00:00:00 2001 From: utkrishtS Date: Thu, 25 Jun 2026 14:21:10 +0530 Subject: [PATCH 5/6] fix: harden session_expiry fallback and add pinned-ceiling coverage --- EXAMPLES.md | 1 + .../storage/BaseCredentialsManager.kt | 12 ++++----- .../storage/CredentialsManagerTest.kt | 26 +++++++++++++++++++ 3 files changed, 32 insertions(+), 7 deletions(-) diff --git a/EXAMPLES.md b/EXAMPLES.md index 1c617d7af..6da60b053 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -2937,6 +2937,7 @@ When an enterprise connection (for example an OIDC or Okta connection) is config 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. +- `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. - 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. diff --git a/auth0/src/main/java/com/auth0/android/authentication/storage/BaseCredentialsManager.kt b/auth0/src/main/java/com/auth0/android/authentication/storage/BaseCredentialsManager.kt index a4a8184d9..1a4e8b09b 100644 --- a/auth0/src/main/java/com/auth0/android/authentication/storage/BaseCredentialsManager.kt +++ b/auth0/src/main/java/com/auth0/android/authentication/storage/BaseCredentialsManager.kt @@ -338,14 +338,12 @@ public abstract class BaseCredentialsManager internal constructor( * before the wall-clock ceiling, never after. */ protected fun isSessionExpired(idToken: String?): Boolean { - val sessionExpiry = storage.retrieveLong(KEY_SESSION_EXPIRY) - ?: sessionExpiryFromIdToken(idToken) + // 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 - // A non-positive ceiling is not a valid Unix timestamp; treat it as "no ceiling" rather than - // already-expired (mirrors the guard in [willExpire] for unset/migration values). - if (sessionExpiry <= 0) { - return false - } val nowSeconds = currentTimeInMillis / 1000 return nowSeconds + SESSION_EXPIRY_LEEWAY_SECONDS >= sessionExpiry } diff --git a/auth0/src/test/java/com/auth0/android/authentication/storage/CredentialsManagerTest.kt b/auth0/src/test/java/com/auth0/android/authentication/storage/CredentialsManagerTest.kt index 02a7434b1..f6cb22b89 100644 --- a/auth0/src/test/java/com/auth0/android/authentication/storage/CredentialsManagerTest.kt +++ b/auth0/src/test/java/com/auth0/android/authentication/storage/CredentialsManagerTest.kt @@ -2615,6 +2615,32 @@ public class CredentialsManagerTest { verifyZeroInteractions(client) } + @Test + public fun shouldReturnCredentialsCarryingPinnedSessionExpiresAt() { + val nowSeconds = CredentialsMock.CURRENT_TIME_MS / 1000 + val pinnedCeiling = nowSeconds + 100_000 + Mockito.`when`(storage.retrieveString("com.auth0.id_token")).thenReturn("idToken") + Mockito.`when`(storage.retrieveString("com.auth0.access_token")).thenReturn("accessToken") + Mockito.`when`(storage.retrieveString("com.auth0.refresh_token")).thenReturn("refreshToken") + Mockito.`when`(storage.retrieveString("com.auth0.token_type")).thenReturn("type") + // Access token not yet expired -> cached credentials are served without a refresh. + Mockito.`when`(storage.retrieveLong("com.auth0.expires_at")) + .thenReturn(CredentialsMock.ONE_HOUR_AHEAD_MS) + Mockito.`when`(storage.retrieveString("com.auth0.scope")).thenReturn("scope") + prepareJwtDecoderMockWithSessionExpiry(pinnedCeiling) + // Pinned at login. The returned credentials must carry this value via stampPinnedSessionExpiry, + // not a value re-decoded from the live ID token. Stubbed after the helper's default null. + Mockito.`when`(storage.retrieveLong("com.auth0.session_expiry")).thenReturn(pinnedCeiling) + + manager.getCredentials(callback) + + verify(callback).onSuccess(credentialsCaptor.capture()) + MatcherAssert.assertThat( + credentialsCaptor.firstValue.sessionExpiresAt, + Is.`is`(pinnedCeiling) + ) + } + @Test public fun shouldNotEnforceSessionExpiryWhenClaimAndStoredValueAreAbsent() { Mockito.`when`(storage.retrieveString("com.auth0.id_token")).thenReturn("idToken") From 6a90fafc25f456f4473b9ad31819dff3821e534b Mon Sep 17 00:00:00 2001 From: utkrishtS Date: Fri, 26 Jun 2026 11:25:15 +0530 Subject: [PATCH 6/6] docs: clarify session_expiry units and how to emit the claim --- EXAMPLES.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/EXAMPLES.md b/EXAMPLES.md index b6a66aea6..d667ac4ee 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -3063,8 +3063,24 @@ The credentials managers enforce this ceiling automatically: - 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