From d1683a79f2750eb4db9d918324203635d58be72c Mon Sep 17 00:00:00 2001 From: tanya732 Date: Fri, 19 Jun 2026 17:20:01 +0530 Subject: [PATCH 1/2] feat: Support IPSIE session_expiry claim --- .../auth0/IdentityVerificationException.java | 9 ++ src/main/java/com/auth0/RequestProcessor.java | 50 +++++++- src/main/java/com/auth0/Tokens.java | 95 ++++++++++++++- .../java/com/auth0/RequestProcessorTest.java | 109 ++++++++++++++++++ src/test/java/com/auth0/TokensTest.java | 45 ++++++++ 5 files changed, 306 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/auth0/IdentityVerificationException.java b/src/main/java/com/auth0/IdentityVerificationException.java index 4a44dfb..e755cf3 100644 --- a/src/main/java/com/auth0/IdentityVerificationException.java +++ b/src/main/java/com/auth0/IdentityVerificationException.java @@ -6,8 +6,13 @@ public class IdentityVerificationException extends Exception { static final String API_ERROR = "a0.api_error"; static final String JWT_MISSING_PUBLIC_KEY_ERROR = "a0.missing_jwt_public_key_error"; static final String JWT_VERIFICATION_ERROR = "a0.invalid_jwt_error"; + static final String SESSION_EXPIRY_IN_PAST_ERROR = "a0.session_expiry_in_past"; private final String code; + IdentityVerificationException(String code, String message) { + this(code, message, null); + } + IdentityVerificationException(String code, String message, Throwable cause) { super(message, cause); this.code = code; @@ -29,4 +34,8 @@ public boolean isAPIError() { public boolean isJWTError() { return JWT_MISSING_PUBLIC_KEY_ERROR.equals(code) || JWT_VERIFICATION_ERROR.equals(code); } + + public boolean isSessionExpiryError() { + return SESSION_EXPIRY_IN_PAST_ERROR.equals(code); + } } diff --git a/src/main/java/com/auth0/RequestProcessor.java b/src/main/java/com/auth0/RequestProcessor.java index c1e9512..86dab36 100644 --- a/src/main/java/com/auth0/RequestProcessor.java +++ b/src/main/java/com/auth0/RequestProcessor.java @@ -6,6 +6,8 @@ import com.auth0.exception.IdTokenValidationException; import com.auth0.exception.PublicKeyProviderException; import com.auth0.jwt.JWT; +import com.auth0.jwt.interfaces.Claim; +import com.auth0.jwt.interfaces.DecodedJWT; import com.auth0.json.auth.TokenHolder; import com.auth0.jwk.Jwk; import com.auth0.jwk.JwkException; @@ -21,6 +23,7 @@ import jakarta.servlet.http.HttpServletResponse; import java.security.interfaces.RSAPublicKey; import java.util.Arrays; +import java.util.Date; import java.util.List; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; @@ -301,7 +304,52 @@ private Tokens getVerifiedTokens(HttpServletRequest request, HttpServletResponse throw new IdentityVerificationException(API_ERROR, "An error occurred while exchanging the authorization code.", e); } // Keep the front-channel ID Token and the code-exchange Access Token. - return mergeTokens(frontChannelTokens, codeExchangeTokens); + Tokens tokens = mergeTokens(frontChannelTokens, codeExchangeTokens); + return withSessionExpiry(tokens); + } + + /** + * Reads the IPSIE {@code session_expiry} claim from the verified ID token and stamps it onto + * the returned {@link Tokens} so the application can persist it and enforce the upstream IdP + * session ceiling on subsequent reads. + *

+ * The claim is an integer Unix timestamp (seconds since epoch). When it is absent the tokens + * are returned unchanged (no ceiling). As a lockout guard, if the ceiling is already in the + * past relative to the token's {@code iat}, the login is rejected rather than producing an + * already-expired session. + * + * @param tokens the merged tokens whose ID token is inspected. + * @return the same tokens augmented with {@code sessionExpiresAt}, or {@code tokens} unchanged + * when no {@code session_expiry} claim is present. + * @throws IdentityVerificationException if {@code session_expiry <= iat}. + */ + private Tokens withSessionExpiry(Tokens tokens) throws IdentityVerificationException { + String idToken = tokens.getIdToken(); + if (idToken == null) { + return tokens; + } + + DecodedJWT decoded = JWT.decode(idToken); + Claim sessionExpiryClaim = decoded.getClaim("session_expiry"); + if (sessionExpiryClaim.isMissing() || sessionExpiryClaim.isNull()) { + return tokens; + } + + Long sessionExpiresAt = sessionExpiryClaim.asLong(); + if (sessionExpiresAt == null) { + // Present but not a numeric value — ignore rather than fail, matching "no ceiling". + return tokens; + } + + // Lockout guard: a session that is already past its ceiling at login must not be persisted. + Date issuedAt = decoded.getIssuedAt(); + if (issuedAt != null && sessionExpiresAt <= Math.floorDiv(issuedAt.getTime(), 1000L)) { + throw new IdentityVerificationException(SESSION_EXPIRY_IN_PAST_ERROR, + "The session_expiry claim is at or before the token's issued-at time; the session is already expired."); + } + + return new Tokens(tokens.getAccessToken(), tokens.getIdToken(), tokens.getRefreshToken(), + tokens.getType(), tokens.getExpiresIn(), tokens.getDomain(), tokens.getIssuer(), sessionExpiresAt); } /** diff --git a/src/main/java/com/auth0/Tokens.java b/src/main/java/com/auth0/Tokens.java index cd3951d..7791c95 100644 --- a/src/main/java/com/auth0/Tokens.java +++ b/src/main/java/com/auth0/Tokens.java @@ -10,6 +10,7 @@ *

  • refreshToken: Refresh Token that can be used to request new tokens without signing in again
  • *
  • type: Token Type
  • *
  • expiresIn: Token expiration
  • + *
  • sessionExpiresAt: Upstream IdP session ceiling, from the {@code session_expiry} ID token claim
  • * */ @SuppressWarnings({"unused", "WeakerAccess"}) @@ -17,6 +18,13 @@ public class Tokens implements Serializable { private static final long serialVersionUID = 2371882820082543721L; + /** + * Default leeway, in seconds, applied when evaluating the {@code session_expiry} ceiling. + * The session is treated as expired slightly before the wall-clock ceiling to + * absorb clock skew between the application and the Auth0 platform. + */ + public static final long DEFAULT_SESSION_EXPIRY_LEEWAY = 30; + private final String accessToken; private final String idToken; private final String refreshToken; @@ -24,6 +32,7 @@ public class Tokens implements Serializable { private final Long expiresIn; private final String domain; private final String issuer; + private final Long sessionExpiresAt; /** * @param accessToken access token for Auth0 API @@ -37,7 +46,10 @@ public Tokens(String accessToken, String idToken, String refreshToken, String ty } /** - * Full constructor with domain information for MCD support + * Full constructor with domain information for MCD support. + *

    + * Equivalent to calling {@link #Tokens(String, String, String, String, Long, String, String, Long)} + * with a {@code null} {@code sessionExpiresAt} (no upstream IdP session ceiling). * * @param accessToken access token for Auth0 API * @param idToken identity token with user information @@ -49,6 +61,26 @@ public Tokens(String accessToken, String idToken, String refreshToken, String ty * @param issuer the issuer URL from the ID token */ public Tokens(String accessToken, String idToken, String refreshToken, String type, Long expiresIn, String domain, String issuer) { + this(accessToken, idToken, refreshToken, type, expiresIn, domain, issuer, null); + } + + /** + * Full constructor including the upstream IdP session ceiling. + * + * @param accessToken access token for Auth0 API + * @param idToken identity token with user information + * @param refreshToken refresh token that can be used to request new tokens + * without signing in again + * @param type token type + * @param expiresIn token expiration + * @param domain the Auth0 domain that issued these tokens + * @param issuer the issuer URL from the ID token + * @param sessionExpiresAt the value of the {@code session_expiry} ID token claim + * (Unix timestamp, seconds since epoch), or {@code null} when the + * claim is absent. A {@code null} value means "no session ceiling" + * and must never be treated as an already-expired session. + */ + public Tokens(String accessToken, String idToken, String refreshToken, String type, Long expiresIn, String domain, String issuer, Long sessionExpiresAt) { this.accessToken = accessToken; this.idToken = idToken; this.refreshToken = refreshToken; @@ -56,6 +88,7 @@ public Tokens(String accessToken, String idToken, String refreshToken, String ty this.expiresIn = expiresIn; this.domain = domain; this.issuer = issuer; + this.sessionExpiresAt = sessionExpiresAt; } /** @@ -125,4 +158,64 @@ public String getDomain() { public String getIssuer() { return issuer; } + + /** + * Getter for the upstream IdP session ceiling, taken from the {@code session_expiry} claim + * of the ID token at login (see the IPSIE SL1 profile). The value is an absolute point in + * time expressed as a Unix timestamp in seconds since the epoch — not a + * duration, and distinct from {@link #getExpiresIn()} (which bounds the access token). + *

    + * This value is fixed at login and is not updated by a token refresh. It is {@code null} + * when the connection did not emit the claim, in which case there is no session ceiling and + * existing behavior is unchanged. + *

    + * The library does not own a session, so it does not enforce this ceiling on your behalf. + * The application must persist this value alongside the session and, on every session read, + * treat the session as expired once {@link #isSessionExpired()} returns {@code true} — + * redirecting the user to log in again. The same check must run before any refresh-token + * exchange (see {@link #isSessionExpired()}). + * + * @return the {@code session_expiry} value in seconds since epoch, or {@code null} if the + * claim was not present. + */ + public Long getSessionExpiresAt() { + return sessionExpiresAt; + } + + /** + * Convenience equivalent to {@link #isSessionExpired(long)} using + * {@link #DEFAULT_SESSION_EXPIRY_LEEWAY}. + * + * @return {@code true} if the upstream IdP session ceiling has been reached, {@code false} + * otherwise (including when no ceiling is present). + */ + public boolean isSessionExpired() { + return isSessionExpired(DEFAULT_SESSION_EXPIRY_LEEWAY); + } + + /** + * Whether the upstream IdP session ceiling ({@code session_expiry}) has been reached. + *

    + * Call this on every session read and, critically, before + * exchanging a refresh token: once the ceiling has passed the application must not call the + * token endpoint with {@code grant_type=refresh_token}, and should surface a "session + * expired" outcome and re-authenticate instead. + *

    + * When no {@code session_expiry} was emitted ({@link #getSessionExpiresAt()} is {@code null}), + * this always returns {@code false} — absence of the claim means "no ceiling" and must never + * be treated as an expired session. The comparison is performed entirely in integer seconds. + * + * @param leewaySeconds a non-negative leeway, in seconds, applied so the session is treated + * as expired slightly before the wall-clock ceiling to absorb clock + * skew. Pass {@code 0} for an exact comparison. + * @return {@code true} if a ceiling is present and {@code now >= sessionExpiresAt - leeway}, + * {@code false} otherwise. + */ + public boolean isSessionExpired(long leewaySeconds) { + if (sessionExpiresAt == null) { + return false; + } + long nowSeconds = Math.floorDiv(System.currentTimeMillis(), 1000L); + return nowSeconds >= sessionExpiresAt - leewaySeconds; + } } diff --git a/src/test/java/com/auth0/RequestProcessorTest.java b/src/test/java/com/auth0/RequestProcessorTest.java index 0851418..6f32908 100644 --- a/src/test/java/com/auth0/RequestProcessorTest.java +++ b/src/test/java/com/auth0/RequestProcessorTest.java @@ -2,6 +2,9 @@ import com.auth0.client.auth.AuthAPI; import com.auth0.exception.Auth0Exception; +import com.auth0.jwt.JWT; +import com.auth0.jwt.JWTCreator; +import com.auth0.jwt.algorithms.Algorithm; import com.auth0.json.auth.TokenHolder; import com.auth0.jwk.JwkProvider; import com.auth0.net.Response; @@ -18,6 +21,7 @@ import jakarta.servlet.http.HttpServletRequest; import java.util.Arrays; import java.util.Collections; +import java.util.Date; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -486,6 +490,90 @@ public void shouldThrowOnProcessIfIdTokenRequestDoesNotPassIdTokenVerification() assertThat(e.getMessage(), is("An error occurred while trying to verify the ID Token.")); } + // --- IPSIE session_expiry Tests --- + + @Test + public void shouldStampSessionExpiryFromVerifiedIdToken() throws Exception { + when(mockDomainProvider.getDomain(any())).thenReturn(DOMAIN); + + long iat = nowSeconds() - 60; + long sessionExpiry = nowSeconds() + 3600; + String idToken = signedIdToken(iat, sessionExpiry); + + Map params = new HashMap<>(); + params.put("code", "abc123"); + params.put("state", "1234"); + MockHttpServletRequest request = getRequest(params); + request.setCookies(new Cookie("com.auth0.state", "1234")); + + when(mockTokenHolder.getIdToken()).thenReturn(idToken); + when(mockTokenHolder.getAccessToken()).thenReturn("backAccessToken"); + when(mockTokenResponse.getBody()).thenReturn(mockTokenHolder); + when(mockTokenRequest.execute()).thenReturn(mockTokenResponse); + when(mockAuthAPI.exchangeCode(eq("abc123"), anyString())).thenReturn(mockTokenRequest); + + RequestProcessor handler = createDefaultRequestProcessor(); + RequestProcessor spy = spy(handler); + doReturn(mockAuthAPI).when(spy).createClientForDomain(anyString()); + + Tokens tokens = spy.process(request, response); + + assertThat(tokens.getSessionExpiresAt(), is(sessionExpiry)); + } + + @Test + public void shouldLeaveSessionExpiryNullWhenClaimAbsent() throws Exception { + when(mockDomainProvider.getDomain(any())).thenReturn(DOMAIN); + + String idToken = signedIdToken(nowSeconds() - 60, null); + + Map params = new HashMap<>(); + params.put("code", "abc123"); + params.put("state", "1234"); + MockHttpServletRequest request = getRequest(params); + request.setCookies(new Cookie("com.auth0.state", "1234")); + + when(mockTokenHolder.getIdToken()).thenReturn(idToken); + when(mockTokenResponse.getBody()).thenReturn(mockTokenHolder); + when(mockTokenRequest.execute()).thenReturn(mockTokenResponse); + when(mockAuthAPI.exchangeCode(eq("abc123"), anyString())).thenReturn(mockTokenRequest); + + RequestProcessor handler = createDefaultRequestProcessor(); + RequestProcessor spy = spy(handler); + doReturn(mockAuthAPI).when(spy).createClientForDomain(anyString()); + + Tokens tokens = spy.process(request, response); + + assertThat(tokens.getSessionExpiresAt(), is(nullValue())); + } + + @Test + public void shouldThrowWhenSessionExpiryIsAtOrBeforeIssuedAt() throws Exception { + when(mockDomainProvider.getDomain(any())).thenReturn(DOMAIN); + + long iat = nowSeconds() - 60; + String idToken = signedIdToken(iat, iat - 100); + + Map params = new HashMap<>(); + params.put("code", "abc123"); + params.put("state", "1234"); + MockHttpServletRequest request = getRequest(params); + request.setCookies(new Cookie("com.auth0.state", "1234")); + + when(mockTokenHolder.getIdToken()).thenReturn(idToken); + when(mockTokenResponse.getBody()).thenReturn(mockTokenHolder); + when(mockTokenRequest.execute()).thenReturn(mockTokenResponse); + when(mockAuthAPI.exchangeCode(eq("abc123"), anyString())).thenReturn(mockTokenRequest); + + RequestProcessor handler = createDefaultRequestProcessor(); + RequestProcessor spy = spy(handler); + doReturn(mockAuthAPI).when(spy).createClientForDomain(anyString()); + + IdentityVerificationException e = assertThrows(IdentityVerificationException.class, () -> spy.process(request, response)); + assertThat(e.getCode(), is("a0.session_expiry_in_past")); + assertThat(e.isSessionExpiryError(), is(true)); + } + // --- AuthorizeUrl Building Tests --- @Test @@ -929,6 +1017,27 @@ private RequestProcessor createRequestProcessorWithResponseType(String responseT .build(); } + private static long nowSeconds() { + return Math.floorDiv(System.currentTimeMillis(), 1000L); + } + + /** + * Builds an HS256-signed ID token (verifiable with the client secret) carrying the standard + * issuer/audience the processor expects, plus an optional {@code session_expiry} claim. + */ + private static String signedIdToken(long iat, Long sessionExpiry) { + JWTCreator.Builder builder = JWT.create() + .withIssuer("https://" + DOMAIN + "/") + .withAudience(CLIENT_ID) + .withSubject("user123") + .withIssuedAt(new Date(iat * 1000L)) + .withExpiresAt(new Date((nowSeconds() + 3600) * 1000L)); + if (sessionExpiry != null) { + builder.withClaim("session_expiry", sessionExpiry); + } + return builder.sign(Algorithm.HMAC256(CLIENT_SECRET)); + } + private MockHttpServletRequest getRequest(Map parameters) { MockHttpServletRequest request = new MockHttpServletRequest(); request.setScheme("https"); diff --git a/src/test/java/com/auth0/TokensTest.java b/src/test/java/com/auth0/TokensTest.java index c82a0c1..d9a5c4b 100644 --- a/src/test/java/com/auth0/TokensTest.java +++ b/src/test/java/com/auth0/TokensTest.java @@ -31,4 +31,49 @@ public void shouldReturnMissingTokens() { assertThat(tokens.getDomain(), is(nullValue())); assertThat(tokens.getIssuer(), is(nullValue())); } + + @Test + public void shouldDefaultSessionExpiresAtToNull() { + Tokens tokens = new Tokens("at", "it", "rt", "bearer", 3600L, "domain", "issuer"); + assertThat(tokens.getSessionExpiresAt(), is(nullValue())); + } + + @Test + public void shouldExposeSessionExpiresAt() { + long ceiling = nowSeconds() + 3600; + Tokens tokens = new Tokens("at", "it", "rt", "bearer", 3600L, "domain", "issuer", ceiling); + assertThat(tokens.getSessionExpiresAt(), is(ceiling)); + } + + @Test + public void shouldNotBeExpiredWhenNoCeilingPresent() { + Tokens tokens = new Tokens("at", "it", "rt", "bearer", 3600L, "domain", "issuer", null); + assertThat(tokens.isSessionExpired(), is(false)); + assertThat(tokens.isSessionExpired(0), is(false)); + } + + @Test + public void shouldNotBeExpiredWhenCeilingIsInFuture() { + Tokens tokens = new Tokens("at", "it", "rt", "bearer", 3600L, "domain", "issuer", nowSeconds() + 3600); + assertThat(tokens.isSessionExpired(), is(false)); + } + + @Test + public void shouldBeExpiredWhenCeilingHasPassed() { + Tokens tokens = new Tokens("at", "it", "rt", "bearer", 3600L, "domain", "issuer", nowSeconds() - 3600); + assertThat(tokens.isSessionExpired(), is(true)); + } + + @Test + public void shouldTreatCeilingWithinLeewayAsExpired() { + // Ceiling 10s in the future, but default 30s leeway pulls it back into the past. + Tokens tokens = new Tokens("at", "it", "rt", "bearer", 3600L, "domain", "issuer", nowSeconds() + 10); + assertThat(tokens.isSessionExpired(), is(true)); + // With no leeway the same ceiling is still in the future. + assertThat(tokens.isSessionExpired(0), is(false)); + } + + private static long nowSeconds() { + return Math.floorDiv(System.currentTimeMillis(), 1000L); + } } From 9255731581eb836cb03791347d2cbbe55dd01f65 Mon Sep 17 00:00:00 2001 From: tanya732 Date: Tue, 30 Jun 2026 15:18:33 +0530 Subject: [PATCH 2/2] add examples --- EXAMPLES.md | 37 +++++++++++++++++++ .../com/auth0/AuthenticationController.java | 11 ++++++ src/main/java/com/auth0/RequestProcessor.java | 24 ++++++++++-- .../java/com/auth0/RequestProcessorTest.java | 30 +++++++++++++++ 4 files changed, 98 insertions(+), 4 deletions(-) diff --git a/EXAMPLES.md b/EXAMPLES.md index 6b56503..52542ba 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -6,6 +6,7 @@ - [Allowing clock skew for token validation](#allow-a-clock-skew-for-token-validation) - [Changing the OAuth response_type](#changing-the-oauth-response_type) - [HTTP logging](#http-logging) +- [IPSIE session_expiry (upstream IdP session ceiling)](#ipsie-session_expiry-upstream-idp-session-ceiling) ## Including additional authorization parameters @@ -231,3 +232,39 @@ Once you have created the instance of the `AuthenticationController`, you can en ```java authController.setLoggingEnabled(true); ``` + +## IPSIE session_expiry (upstream IdP session ceiling) + +When an enterprise connection has **"Use ID Token for Session Expiry"** +(`id_token_session_expiry_supported: true`) enabled, Auth0 adds a `session_expiry` claim to +the ID token: an absolute Unix timestamp (seconds) that caps how long the session may live, +independent of the `exp` token lifetime. + +This library does not own a session. It reads and validates the claim at login, exposing it +via `Tokens.getSessionExpiresAt()` (seconds, or `null` when absent) and +`Tokens.isSessionExpired()`. Persisting the value and enforcing the ceiling is the +application's job. + +Persist it at login alongside the tokens (`null` means "no ceiling", store as-is): + +```java +Tokens tokens = authenticationController.handle(request, response); +request.getSession().setAttribute("sessionExpiresAt", tokens.getSessionExpiresAt()); +``` + +On every session read, rebuild a `Tokens` and check the ceiling. When it returns `true`, +drop the session and fall through to your existing redirect-to-login path: + +```java +HttpSession session = request.getSession(); +Tokens tokens = new Tokens(null, null, null, "Bearer", null, null, null, + (Long) session.getAttribute("sessionExpiresAt")); + +if (tokens.isSessionExpired()) { + session.invalidate(); + response.sendRedirect("/login"); +} +``` + +`isSessionExpired()` applies a 30s negative leeway for clock skew; pass +`isSessionExpired(0)` for an exact comparison. diff --git a/src/main/java/com/auth0/AuthenticationController.java b/src/main/java/com/auth0/AuthenticationController.java index 62cee5a..d1bc7a3 100644 --- a/src/main/java/com/auth0/AuthenticationController.java +++ b/src/main/java/com/auth0/AuthenticationController.java @@ -360,6 +360,17 @@ public Tokens handle(HttpServletRequest request, HttpServletResponse response) t return requestProcessor.process(request, response); } + // TODO(IPSIE Req 3 — refresh-token ceiling): this branch has no renew/refresh-token API, so the + // session_expiry ceiling is only stamped at login (see RequestProcessor#withSessionExpiry). When + // the refresh-token grant (renewAuth) is merged from the MRRT work, it must: + // 1. Gate the refresh: refuse to exchange grant_type=refresh_token once the persisted ceiling + // has passed (Tokens#isSessionExpired()) and surface a0.session_expired instead of calling + // /oauth/token — the renewed access token must never outlive the IdP session ceiling. + // 2. Preserve the ceiling: a refresh response without a fresh session_expiry claim must carry + // forward the original sessionExpiresAt rather than dropping it to null (no ceiling). Only a + // newly-emitted, valid session_expiry should replace it. + // Until then, enforcement is login-time only; the example-app demonstrates the gate manually. + /** * Pre builds an Auth0 Authorize Url with the given redirect URI using a random state and a random nonce if applicable. * diff --git a/src/main/java/com/auth0/RequestProcessor.java b/src/main/java/com/auth0/RequestProcessor.java index 86dab36..4a17392 100644 --- a/src/main/java/com/auth0/RequestProcessor.java +++ b/src/main/java/com/auth0/RequestProcessor.java @@ -49,6 +49,12 @@ class RequestProcessor { private static final String KEY_FORM_POST = "form_post"; private static final String KEY_MAX_AGE = "max_age"; + // Upper bound for a valid session_expiry (Unix seconds). Anything at/above this is treated as + // "no ceiling": it is almost certainly a milliseconds-since-epoch value emitted by mistake, + // which would otherwise read as a date thousands of years out and silently disable enforcement. + // Per the IPSIE Decision Log, reject anything >= 10,000,000,000. + private static final long MAX_SESSION_EXPIRY_SECONDS = 10_000_000_000L; + private final DomainProvider domainProvider; private final String responseType; private final String clientId; @@ -314,13 +320,16 @@ private Tokens getVerifiedTokens(HttpServletRequest request, HttpServletResponse * session ceiling on subsequent reads. *

    * The claim is an integer Unix timestamp (seconds since epoch). When it is absent the tokens - * are returned unchanged (no ceiling). As a lockout guard, if the ceiling is already in the - * past relative to the token's {@code iat}, the login is rejected rather than producing an - * already-expired session. + * are returned unchanged (no ceiling). The value is developer-controlled (it may be stamped by a + * Post-Login Action), so it is validated rather than trusted: a non-numeric value, or one large + * enough to be milliseconds-since-epoch ({@code >= 10_000_000_000}), is treated as "no ceiling" + * rather than silently disabling enforcement with a date thousands of years out. As a lockout + * guard, if the ceiling is already in the past relative to the token's {@code iat}, the login is + * rejected rather than producing an already-expired session. * * @param tokens the merged tokens whose ID token is inspected. * @return the same tokens augmented with {@code sessionExpiresAt}, or {@code tokens} unchanged - * when no {@code session_expiry} claim is present. + * when no usable {@code session_expiry} claim is present. * @throws IdentityVerificationException if {@code session_expiry <= iat}. */ private Tokens withSessionExpiry(Tokens tokens) throws IdentityVerificationException { @@ -341,6 +350,13 @@ private Tokens withSessionExpiry(Tokens tokens) throws IdentityVerificationExcep return tokens; } + // Range guard: reject milliseconds-since-epoch (or any absurdly large value). A value + // accidentally emitted in milliseconds would read as a date ~thousands of years out and + // silently switch off enforcement, so treat anything at/above this bound as "no ceiling". + if (sessionExpiresAt >= MAX_SESSION_EXPIRY_SECONDS) { + return tokens; + } + // Lockout guard: a session that is already past its ceiling at login must not be persisted. Date issuedAt = decoded.getIssuedAt(); if (issuedAt != null && sessionExpiresAt <= Math.floorDiv(issuedAt.getTime(), 1000L)) { diff --git a/src/test/java/com/auth0/RequestProcessorTest.java b/src/test/java/com/auth0/RequestProcessorTest.java index 6f32908..19b4075 100644 --- a/src/test/java/com/auth0/RequestProcessorTest.java +++ b/src/test/java/com/auth0/RequestProcessorTest.java @@ -574,6 +574,36 @@ public void shouldThrowWhenSessionExpiryIsAtOrBeforeIssuedAt() throws Exception assertThat(e.isSessionExpiryError(), is(true)); } + @Test + public void shouldIgnoreSessionExpiryWhenValueIsInMilliseconds() throws Exception { + when(mockDomainProvider.getDomain(any())).thenReturn(DOMAIN); + + long iat = nowSeconds() - 60; + // An Action that forgot to convert to seconds: a millisecond-scale value reads as a date + // thousands of years out and would silently disable enforcement. Treat as "no ceiling". + long millisecondValue = (nowSeconds() + 3600) * 1000L; + String idToken = signedIdToken(iat, millisecondValue); + + Map params = new HashMap<>(); + params.put("code", "abc123"); + params.put("state", "1234"); + MockHttpServletRequest request = getRequest(params); + request.setCookies(new Cookie("com.auth0.state", "1234")); + + when(mockTokenHolder.getIdToken()).thenReturn(idToken); + when(mockTokenResponse.getBody()).thenReturn(mockTokenHolder); + when(mockTokenRequest.execute()).thenReturn(mockTokenResponse); + when(mockAuthAPI.exchangeCode(eq("abc123"), anyString())).thenReturn(mockTokenRequest); + + RequestProcessor handler = createDefaultRequestProcessor(); + RequestProcessor spy = spy(handler); + doReturn(mockAuthAPI).when(spy).createClientForDomain(anyString()); + + Tokens tokens = spy.process(request, response); + + assertThat(tokens.getSessionExpiresAt(), is(nullValue())); + } + // --- AuthorizeUrl Building Tests --- @Test