From d1683a79f2750eb4db9d918324203635d58be72c Mon Sep 17 00:00:00 2001 From: tanya732 Date: Fri, 19 Jun 2026 17:20:01 +0530 Subject: [PATCH 1/5] 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/5] 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 From 68b66ed7ae15a0852e06afc61d2eef421dca7223 Mon Sep 17 00:00:00 2001 From: tanya732 Date: Fri, 17 Jul 2026 06:22:48 +0530 Subject: [PATCH 3/5] fix javadoc --- src/main/java/com/auth0/Tokens.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/auth0/Tokens.java b/src/main/java/com/auth0/Tokens.java index 856a66a..c911fe9 100644 --- a/src/main/java/com/auth0/Tokens.java +++ b/src/main/java/com/auth0/Tokens.java @@ -49,7 +49,7 @@ public Tokens(String accessToken, String idToken, String refreshToken, String ty /** * Full constructor with domain information for MCD support. *

    - * Equivalent to calling {@link #Tokens(String, String, String, String, Long, String, String, Long)} + * Equivalent to calling {@link #Tokens(String, String, String, String, Long, String, String)} * with a {@code null} {@code sessionExpiresAt} (no upstream IdP session ceiling). * * @param accessToken access token for Auth0 API From 293507eda680ab943336eeb20772bd7ed5853fea Mon Sep 17 00:00:00 2001 From: tanya732 Date: Mon, 27 Jul 2026 21:19:29 +0530 Subject: [PATCH 4/5] Modify docs --- EXAMPLES.md | 84 +++++++++++++++++-- .../com/auth0/AuthenticationController.java | 11 --- src/main/java/com/auth0/RenewAuthRequest.java | 56 ++++++++++++- src/main/java/com/auth0/RequestProcessor.java | 69 +++++++++++---- .../com/auth0/SessionExpiredException.java | 25 ++++++ src/main/java/com/auth0/Tokens.java | 31 ++++++- .../java/com/auth0/RenewAuthRequestTest.java | 76 +++++++++++++++++ .../java/com/auth0/RequestProcessorTest.java | 76 +++++++++++++++++ src/test/java/com/auth0/TokensTest.java | 21 +++++ 9 files changed, 405 insertions(+), 44 deletions(-) create mode 100644 src/main/java/com/auth0/SessionExpiredException.java diff --git a/EXAMPLES.md b/EXAMPLES.md index dfe20e8..3f366d9 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -226,6 +226,10 @@ String rotatedRefreshToken = tokens.getRefreshToken(); The refresh-token grant does not return an ID token, so `tokens.getIdToken()` is typically `null`. +> **Session ceiling:** if the connection emits the IPSIE `session_expiry` claim, pass the persisted +> ceiling via `withSessionExpiresAt(...)` so the refresh is gated against it. See +> [IPSIE session_expiry](#ipsie-session_expiry-upstream-idp-session-ceiling). + ### Using MRRT with Multiple Custom Domains When using a `DomainResolver`, pass the domain explicitly so the grant targets the correct tenant. This is required because a refresh can occur outside of an HTTP request: @@ -395,9 +399,16 @@ 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 +`Tokens.isSessionExpired(...)`. Persisting the value and enforcing the ceiling is the application's job. +> :warning: **The claim must be an integer number of seconds since the epoch.** The value is +> emitted by your tenant, so the most common mistake is emitting +> **milliseconds** (a `getTime()` without the `/ 1000`). A millisecond-scale value is **not** +> enforced as a date thousands of years out — it is out of range, so the library treats it as +> "no ceiling" and enforcement is silently **off**. Any non-numeric, zero, or negative value is +> likewise ignored (fails open to "no ceiling") rather than locking users out. + Persist it at login alongside the tokens (`null` means "no ceiling", store as-is): ```java @@ -405,19 +416,76 @@ 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: +**Login can now fail on this claim.** When the connection option is on and the emitted +`session_expiry` is at or before the token's issued-at time (already expired), `handle()` throws +an `IdentityVerificationException` for which `isSessionExpiryError()` returns `true` — a new error +out of `handle()` that apps enabling the option weren't previously catching. Send the user back to +log in: + +```java +try { + Tokens tokens = authenticationController.handle(request, response); + request.getSession().setAttribute("sessionExpiresAt", tokens.getSessionExpiresAt()); +} catch (IdentityVerificationException e) { + if (e.isSessionExpiryError()) { + response.sendRedirect("/login"); + return; + } + throw e; +} +``` + +On every session read, check the persisted ceiling directly with the static helper (no need to +reconstruct a `Tokens`). 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")); +Long sessionExpiresAt = (Long) session.getAttribute("sessionExpiresAt"); -if (tokens.isSessionExpired()) { +if (Tokens.isSessionExpired(sessionExpiresAt, Tokens.DEFAULT_SESSION_EXPIRY_LEEWAY)) { session.invalidate(); response.sendRedirect("/login"); } ``` -`isSessionExpired()` applies a 30s negative leeway for clock skew; pass -`isSessionExpired(0)` for an exact comparison. +The leeway is a 30s clock-skew allowance that treats the session as expired slightly *early*; pass +`0` for an exact comparison. (When you hold a live `Tokens` instance, the instance methods +`tokens.isSessionExpired()` / `tokens.isSessionExpired(0)` do the same thing against its own +ceiling.) + +### Enforcing the ceiling on refresh + +The ceiling must be honored on **refresh**, not just on session reads: renewing an access token past +the ceiling defeats the point of the upstream session limit. If you use this library's refresh-token +grant (see [Refresh Token Grant (MRRT)](#refresh-token-grant-mrrt)), hand the persisted ceiling to +`withSessionExpiresAt(...)` and the SDK enforces it for you — `execute()` throws +`SessionExpiredException` **before** calling the token endpoint when the ceiling has passed, and +carries the same ceiling forward onto the returned `Tokens` (the refresh grant returns no ID token, +so there is no fresh `session_expiry` to re-read): + +```java +Long sessionExpiresAt = (Long) session.getAttribute("sessionExpiresAt"); +try { + Tokens tokens = authenticationController.renewAuth(refreshToken, domain) + .withSessionExpiresAt(sessionExpiresAt) + .execute(); + // ceiling is preserved on the result — persist it as-is for the next refresh + session.setAttribute("sessionExpiresAt", tokens.getSessionExpiresAt()); +} catch (SessionExpiredException e) { + session.invalidate(); + response.sendRedirect("/login"); +} +``` + +If instead you refresh tokens **outside** this SDK, run the same check yourself **before** calling +the token endpoint with `grant_type=refresh_token`: + +```java +if (Tokens.isSessionExpired(sessionExpiresAt, Tokens.DEFAULT_SESSION_EXPIRY_LEEWAY)) { + // do not refresh — re-authenticate instead +} +``` + +Either way, the ceiling is fixed at login and does **not** advance on refresh, so carry the original +value forward. diff --git a/src/main/java/com/auth0/AuthenticationController.java b/src/main/java/com/auth0/AuthenticationController.java index ce87ebb..6ca85aa 100644 --- a/src/main/java/com/auth0/AuthenticationController.java +++ b/src/main/java/com/auth0/AuthenticationController.java @@ -361,17 +361,6 @@ 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/RenewAuthRequest.java b/src/main/java/com/auth0/RenewAuthRequest.java index 60895bc..8c40699 100644 --- a/src/main/java/com/auth0/RenewAuthRequest.java +++ b/src/main/java/com/auth0/RenewAuthRequest.java @@ -5,6 +5,8 @@ import com.auth0.json.auth.TokenHolder; import com.auth0.net.TokenRequest; +import static com.auth0.Tokens.DEFAULT_SESSION_EXPIRY_LEEWAY; + /** * Class to exchange a refresh token for a new set of {@link Tokens}, optionally targeting a * specific {@code audience} and/or {@code scope}. This exposes Auth0's refresh-token grant, @@ -27,6 +29,7 @@ public class RenewAuthRequest { private final String issuer; private String audience; private String scope; + private Long sessionExpiresAt; RenewAuthRequest(AuthAPI client, String refreshToken, String domain, String issuer) { this.client = client; @@ -62,6 +65,35 @@ public RenewAuthRequest withScope(String scope) { return this; } + /** + * Supplies the upstream IdP session ceiling ({@code session_expiry}, see the IPSIE SL1 profile) + * that the application persisted at login from {@link Tokens#getSessionExpiresAt()}. The library + * is stateless and does not remember it across requests, so it must be handed back here for the + * ceiling to be enforced on refresh. + *

    + * When set, {@link #execute()} enforces the ceiling in two ways: + *

    + * Passing {@code null} (the default) means "no known ceiling": no gate is applied and the + * returned tokens carry no ceiling unless the response itself provides one. + * + * @param sessionExpiresAt the persisted {@code session_expiry} ceiling (Unix seconds), or + * {@code null} for no ceiling. + * @return this request instance for fluent chaining. + */ + public RenewAuthRequest withSessionExpiresAt(Long sessionExpiresAt) { + this.sessionExpiresAt = sessionExpiresAt; + return this; + } + /** * Executes the refresh-token grant against Auth0 and returns the resulting tokens. *

    @@ -69,11 +101,23 @@ public RenewAuthRequest withScope(String scope) { * typically null. When refresh-token rotation is enabled, the returned * {@link Tokens#getRefreshToken()} is a new refresh token that supersedes the one used here; * the application is responsible for persisting it. + *

    + * When a session ceiling was supplied via {@link #withSessionExpiresAt(Long)}, it is enforced: + * an already-passed ceiling short-circuits with a {@link SessionExpiredException} before any + * network call, and the ceiling is carried forward onto the returned tokens (see that method). * * @return the {@link Tokens} obtained from the grant, including the granted scope. - * @throws Auth0Exception if the request to the Auth0 server failed. + * @throws SessionExpiredException if the supplied session ceiling has already passed. + * @throws Auth0Exception if the request to the Auth0 server failed. */ - public Tokens execute() throws Auth0Exception { + public Tokens execute() throws Auth0Exception, SessionExpiredException { + // Gate: never refresh past the IdP session ceiling — the renewed access token must not + // outlive the session. Checked before the network call so no token is minted. + if (Tokens.isSessionExpired(sessionExpiresAt, DEFAULT_SESSION_EXPIRY_LEEWAY)) { + throw new SessionExpiredException( + "The session_expiry ceiling has passed; the refresh token must not be exchanged. Re-authenticate instead."); + } + TokenRequest request = client.renewAuth(refreshToken); if (audience != null) { request.setAudience(audience); @@ -82,7 +126,13 @@ public Tokens execute() throws Auth0Exception { request.setScope(scope); } TokenHolder holder = request.execute().getBody(); + + // Carry-forward: prefer a fresh, valid ceiling from the response (rare — the grant has no + // ID token), otherwise re-stamp the supplied ceiling so a refresh never silently drops it. + Long freshCeiling = RequestProcessor.parseSessionExpiry(holder.getIdToken()); + Long ceiling = freshCeiling != null ? freshCeiling : sessionExpiresAt; + return new Tokens(holder.getAccessToken(), holder.getIdToken(), holder.getRefreshToken(), - holder.getTokenType(), holder.getExpiresIn(), holder.getScope(), domain, issuer); + holder.getTokenType(), holder.getExpiresIn(), holder.getScope(), domain, issuer, ceiling); } } diff --git a/src/main/java/com/auth0/RequestProcessor.java b/src/main/java/com/auth0/RequestProcessor.java index dae4811..0cba22d 100644 --- a/src/main/java/com/auth0/RequestProcessor.java +++ b/src/main/java/com/auth0/RequestProcessor.java @@ -565,38 +565,71 @@ private Tokens getVerifiedTokens(HttpServletRequest request, HttpServletResponse */ private Tokens withSessionExpiry(Tokens tokens) throws IdentityVerificationException { String idToken = tokens.getIdToken(); - if (idToken == null) { + Long sessionExpiresAt = parseSessionExpiry(idToken); + if (sessionExpiresAt == null) { return tokens; } - DecodedJWT decoded = JWT.decode(idToken); - Claim sessionExpiryClaim = decoded.getClaim("session_expiry"); - if (sessionExpiryClaim.isMissing() || sessionExpiryClaim.isNull()) { - return tokens; + // Lockout guard: a session that would already be expired on its first read must not be + // persisted. Applies the same leeway as Tokens#isSessionExpired so a ceiling that clears + // login isn't immediately bounced by the very next read. When the token carries an iat we + // anchor the check to it (the canonical "session started at" instant); if it somehow has no + // iat we fall back to wall-clock now, using the same leewayed comparison, so an already-past + // ceiling can't slip through unguarded. + Date issuedAt = JWT.decode(idToken).getIssuedAt(); + long referenceSeconds = issuedAt != null + ? Math.floorDiv(issuedAt.getTime(), 1000L) + : Math.floorDiv(System.currentTimeMillis(), 1000L); + if (sessionExpiresAt <= referenceSeconds + Tokens.DEFAULT_SESSION_EXPIRY_LEEWAY) { + throw new IdentityVerificationException(SESSION_EXPIRY_IN_PAST_ERROR, + "The session_expiry claim is within the expiry leeway of 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.getScope(), tokens.getDomain(), tokens.getIssuer(), sessionExpiresAt); + } + + /** + * Reads and validates the IPSIE {@code session_expiry} claim from an ID token, returning the + * ceiling as a Unix timestamp in seconds, or {@code null} when there is no usable ceiling. + *

    + * A {@code null} ID token, an absent/null/non-numeric claim, a non-positive value + * ({@code <= 0}), or a value large enough to be milliseconds-since-epoch + * ({@code >= 10_000_000_000}) all yield {@code null} — meaning "no ceiling" — rather than an + * exception, so a malformed or nonsensical value fails open instead of locking the user out, and + * absence is never mistaken for an expired session. The lockout guard (rejecting a valid ceiling + * already in the past at login) is applied separately by the caller, since it only applies to the + * login path. + * + * @param idToken the ID token to inspect, or {@code null}. + * @return the validated {@code session_expiry} in seconds since epoch, or {@code null}. + */ + static Long parseSessionExpiry(String idToken) { + if (idToken == null) { + return null; + } + Claim sessionExpiryClaim = JWT.decode(idToken).getClaim("session_expiry"); + if (sessionExpiryClaim.isMissing() || sessionExpiryClaim.isNull()) { + return null; + } Long sessionExpiresAt = sessionExpiryClaim.asLong(); if (sessionExpiresAt == null) { // Present but not a numeric value — ignore rather than fail, matching "no ceiling". - return tokens; + return null; + } + // Fail open on non-positive values: 0 or a negative is not a real ceiling, so treat it as + // "no ceiling" rather than an already-expired session that would lock the user out. This + // keeps every nonsensical value (malformed, out-of-range, non-positive) behaving uniformly. + if (sessionExpiresAt <= 0) { + return null; } - // 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; + return null; } - - // 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.getScope(), tokens.getDomain(), tokens.getIssuer(), sessionExpiresAt); + return sessionExpiresAt; } /** diff --git a/src/main/java/com/auth0/SessionExpiredException.java b/src/main/java/com/auth0/SessionExpiredException.java new file mode 100644 index 0000000..7d0d36f --- /dev/null +++ b/src/main/java/com/auth0/SessionExpiredException.java @@ -0,0 +1,25 @@ +package com.auth0; + +/** + * Raised when a refresh-token exchange is attempted after the upstream IdP session ceiling + * ({@code session_expiry}, see the IPSIE SL1 profile) has already passed. + *

    + * The library is stateless and does not own the session, so it cannot know the ceiling on its own: + * the application persists {@link Tokens#getSessionExpiresAt()} at login and hands it back via + * {@link RenewAuthRequest#withSessionExpiresAt(Long)}. When that ceiling has passed, + * {@link RenewAuthRequest#execute()} throws this exception before calling the token + * endpoint, so a renewed access token can never outlive the session ceiling. The application should + * treat this as a "must re-authenticate" outcome rather than retrying the refresh. + * + * @see RenewAuthRequest#withSessionExpiresAt(Long) + * @see Tokens#isSessionExpired() + */ +@SuppressWarnings("WeakerAccess") +public class SessionExpiredException extends IdentityVerificationException { + + static final String SESSION_EXPIRED = "a0.session_expired"; + + SessionExpiredException(String message) { + super(SESSION_EXPIRED, message, null); + } +} diff --git a/src/main/java/com/auth0/Tokens.java b/src/main/java/com/auth0/Tokens.java index c911fe9..a87a693 100644 --- a/src/main/java/com/auth0/Tokens.java +++ b/src/main/java/com/auth0/Tokens.java @@ -237,17 +237,40 @@ public boolean isSessionExpired() { * 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. + * @param leewaySeconds a 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. A negative value is clamped to + * {@code 0}: leeway may only move the effective ceiling earlier, + * never later, so it can never extend the session past its ceiling. * @return {@code true} if a ceiling is present and {@code now >= sessionExpiresAt - leeway}, * {@code false} otherwise. */ public boolean isSessionExpired(long leewaySeconds) { + return isSessionExpired(sessionExpiresAt, leewaySeconds); + } + + /** + * Whether the given session ceiling has been reached, using the same rules as + * {@link #isSessionExpired(long)} but against a caller-supplied value rather than the ceiling + * held on a {@code Tokens} instance. This lets an application check a persisted + * {@code session_expiry} without reconstructing a {@code Tokens} — for example on a session read, + * or before running its own refresh-token exchange (which must not renew past the ceiling). + * + * @param sessionExpiresAt the {@code session_expiry} ceiling (Unix seconds), or {@code null} for + * "no ceiling". + * @param leewaySeconds a leeway, in seconds, applied so the session is treated as expired + * slightly before the wall-clock ceiling to absorb clock skew. A negative + * value is clamped to {@code 0} so leeway can never extend the session + * past its ceiling. + * @return {@code true} if a ceiling is present and {@code now >= sessionExpiresAt - leeway}, + * {@code false} otherwise (including when {@code sessionExpiresAt} is {@code null}). + */ + public static boolean isSessionExpired(Long sessionExpiresAt, long leewaySeconds) { if (sessionExpiresAt == null) { return false; } + long effectiveLeeway = Math.max(0L, leewaySeconds); long nowSeconds = Math.floorDiv(System.currentTimeMillis(), 1000L); - return nowSeconds >= sessionExpiresAt - leewaySeconds; + return nowSeconds >= sessionExpiresAt - effectiveLeeway; } } diff --git a/src/test/java/com/auth0/RenewAuthRequestTest.java b/src/test/java/com/auth0/RenewAuthRequestTest.java index 5f6dbf5..2fbc37c 100644 --- a/src/test/java/com/auth0/RenewAuthRequestTest.java +++ b/src/test/java/com/auth0/RenewAuthRequestTest.java @@ -3,6 +3,8 @@ import com.auth0.client.auth.AuthAPI; import com.auth0.exception.Auth0Exception; import com.auth0.json.auth.TokenHolder; +import com.auth0.jwt.JWT; +import com.auth0.jwt.algorithms.Algorithm; import com.auth0.net.Response; import com.auth0.net.TokenRequest; import org.junit.jupiter.api.BeforeEach; @@ -12,6 +14,7 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.core.Is.is; +import static org.hamcrest.core.IsNull.nullValue; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -100,4 +103,77 @@ public void shouldPropagateAuth0Exception() throws Exception { assertThrows(Auth0Exception.class, request::execute); } + + @Test + public void shouldGateRefreshWhenSessionCeilingHasPassed() throws Exception { + RenewAuthRequest request = new RenewAuthRequest(mockClient, REFRESH_TOKEN, DOMAIN, ISSUER); + + assertThrows(SessionExpiredException.class, + () -> request.withSessionExpiresAt(nowSeconds() - 3600).execute()); + + verify(mockTokenRequest, never()).execute(); + } + + @Test + public void shouldNotGateRefreshWhenSessionCeilingIsInFuture() throws Exception { + RenewAuthRequest request = new RenewAuthRequest(mockClient, REFRESH_TOKEN, DOMAIN, ISSUER); + + request.withSessionExpiresAt(nowSeconds() + 3600).execute(); + + verify(mockTokenRequest).execute(); + } + + @Test + public void shouldNotGateRefreshWhenNoSessionCeilingSupplied() throws Exception { + RenewAuthRequest request = new RenewAuthRequest(mockClient, REFRESH_TOKEN, DOMAIN, ISSUER); + + request.execute(); + + verify(mockTokenRequest).execute(); + } + + @Test + public void shouldCarryForwardSuppliedCeilingWhenResponseHasNone() throws Exception { + when(mockTokenHolder.getIdToken()).thenReturn(null); + long ceiling = nowSeconds() + 3600; + + RenewAuthRequest request = new RenewAuthRequest(mockClient, REFRESH_TOKEN, DOMAIN, ISSUER); + + Tokens tokens = request.withSessionExpiresAt(ceiling).execute(); + + assertThat(tokens.getSessionExpiresAt(), is(ceiling)); + } + + @Test + public void shouldLeaveCeilingNullWhenNoneSuppliedAndResponseHasNone() throws Exception { + when(mockTokenHolder.getIdToken()).thenReturn(null); + + RenewAuthRequest request = new RenewAuthRequest(mockClient, REFRESH_TOKEN, DOMAIN, ISSUER); + + Tokens tokens = request.execute(); + + assertThat(tokens.getSessionExpiresAt(), is(nullValue())); + } + + @Test + public void shouldPreferFreshCeilingFromResponseOverSuppliedOne() throws Exception { + long freshCeiling = nowSeconds() + 7200; + when(mockTokenHolder.getIdToken()).thenReturn(idTokenWithSessionExpiry(freshCeiling)); + + RenewAuthRequest request = new RenewAuthRequest(mockClient, REFRESH_TOKEN, DOMAIN, ISSUER); + + Tokens tokens = request.withSessionExpiresAt(nowSeconds() + 3600).execute(); + + assertThat(tokens.getSessionExpiresAt(), is(freshCeiling)); + } + + private static long nowSeconds() { + return Math.floorDiv(System.currentTimeMillis(), 1000L); + } + + private static String idTokenWithSessionExpiry(long sessionExpiry) { + return JWT.create() + .withClaim("session_expiry", sessionExpiry) + .sign(Algorithm.none()); + } } diff --git a/src/test/java/com/auth0/RequestProcessorTest.java b/src/test/java/com/auth0/RequestProcessorTest.java index 3f94be0..2e37c83 100644 --- a/src/test/java/com/auth0/RequestProcessorTest.java +++ b/src/test/java/com/auth0/RequestProcessorTest.java @@ -1001,6 +1001,67 @@ public void shouldIgnoreSessionExpiryWhenValueIsInMilliseconds() throws Exceptio assertThat(tokens.getSessionExpiresAt(), is(nullValue())); } + @Test + public void shouldIgnoreSessionExpiryWhenValueIsNotNumeric() throws Exception { + when(mockDomainProvider.getDomain(any())).thenReturn(DOMAIN); + + // Developer-set claim: a non-numeric value (e.g. a string) must fail open to "no ceiling" + // rather than locking the user out. + String idToken = signedIdTokenWithStringSessionExpiry(nowSeconds() - 60, "not-a-number"); + + 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 shouldIgnoreSessionExpiryWhenValueIsZeroOrNegative() throws Exception { + when(mockDomainProvider.getDomain(any())).thenReturn(DOMAIN); + + // Zero/negative are numbers, so they pass the range check, but they are not real ceilings. + // They must fail open to "no ceiling" rather than throwing an already-expired lockout. + long iat = nowSeconds() - 60; + + for (long value : new long[]{0L, -100L}) { + String idToken = signedIdToken(iat, value); + + 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("value " + value + " should fail open to no ceiling", + tokens.getSessionExpiresAt(), is(nullValue())); + } + } + // --- AuthorizeUrl Building Tests --- @Test @@ -1507,6 +1568,21 @@ private static String signedIdToken(long iat, Long sessionExpiry) { return builder.sign(Algorithm.HMAC256(CLIENT_SECRET)); } + /** + * Variant of {@link #signedIdToken(long, Long)} that emits a non-numeric {@code session_expiry} + * claim, to exercise the malformed-value (fail-open) path. + */ + private static String signedIdTokenWithStringSessionExpiry(long iat, String sessionExpiry) { + return JWT.create() + .withIssuer("https://" + DOMAIN + "/") + .withAudience(CLIENT_ID) + .withSubject("user123") + .withIssuedAt(new Date(iat * 1000L)) + .withExpiresAt(new Date((nowSeconds() + 3600) * 1000L)) + .withClaim("session_expiry", sessionExpiry) + .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 43ed0d8..cca7b07 100644 --- a/src/test/java/com/auth0/TokensTest.java +++ b/src/test/java/com/auth0/TokensTest.java @@ -100,6 +100,27 @@ public void shouldTreatCeilingWithinLeewayAsExpired() { assertThat(tokens.isSessionExpired(0), is(false)); } + @Test + public void shouldClampNegativeLeewayToZeroRatherThanExtendingCeiling() { + // A ceiling 10s in the future. A negative leeway must not push the effective ceiling later + // (which would report "not expired" past the real ceiling); it is clamped to 0. + Tokens tokens = new Tokens("at", "it", "rt", "bearer", 3600L, "scope", "domain", "issuer", nowSeconds() + 10); + assertThat(tokens.isSessionExpired(-3600), is(false)); + + // A ceiling 10s in the past: clamped-to-0 leeway still reports expired, not extended. + Tokens past = new Tokens("at", "it", "rt", "bearer", 3600L, "scope", "domain", "issuer", nowSeconds() - 10); + assertThat(past.isSessionExpired(-3600), is(true)); + } + + @Test + public void staticIsSessionExpiredMatchesInstanceBehavior() { + assertThat(Tokens.isSessionExpired(null, 0), is(false)); + assertThat(Tokens.isSessionExpired(nowSeconds() + 3600, Tokens.DEFAULT_SESSION_EXPIRY_LEEWAY), is(false)); + assertThat(Tokens.isSessionExpired(nowSeconds() - 3600, Tokens.DEFAULT_SESSION_EXPIRY_LEEWAY), is(true)); + // Negative leeway clamped to 0: a future ceiling is not reported expired. + assertThat(Tokens.isSessionExpired(nowSeconds() + 10, -3600), is(false)); + } + private static long nowSeconds() { return Math.floorDiv(System.currentTimeMillis(), 1000L); } From 8d9f129ce3e11f6e19a3c665781c14469553566a Mon Sep 17 00:00:00 2001 From: tanya732 Date: Tue, 28 Jul 2026 20:10:34 +0530 Subject: [PATCH 5/5] Minor refactoring --- src/main/java/com/auth0/RenewAuthRequest.java | 9 +++---- src/main/java/com/auth0/RequestProcessor.java | 26 +++++-------------- .../java/com/auth0/RenewAuthRequestTest.java | 12 +++++---- 3 files changed, 17 insertions(+), 30 deletions(-) diff --git a/src/main/java/com/auth0/RenewAuthRequest.java b/src/main/java/com/auth0/RenewAuthRequest.java index 8c40699..b2302ba 100644 --- a/src/main/java/com/auth0/RenewAuthRequest.java +++ b/src/main/java/com/auth0/RenewAuthRequest.java @@ -111,7 +111,7 @@ public RenewAuthRequest withSessionExpiresAt(Long sessionExpiresAt) { * @throws Auth0Exception if the request to the Auth0 server failed. */ public Tokens execute() throws Auth0Exception, SessionExpiredException { - // Gate: never refresh past the IdP session ceiling — the renewed access token must not + // Gate: never refresh past the IdP session ceiling, the renewed access token must not // outlive the session. Checked before the network call so no token is minted. if (Tokens.isSessionExpired(sessionExpiresAt, DEFAULT_SESSION_EXPIRY_LEEWAY)) { throw new SessionExpiredException( @@ -127,12 +127,9 @@ public Tokens execute() throws Auth0Exception, SessionExpiredException { } TokenHolder holder = request.execute().getBody(); - // Carry-forward: prefer a fresh, valid ceiling from the response (rare — the grant has no - // ID token), otherwise re-stamp the supplied ceiling so a refresh never silently drops it. - Long freshCeiling = RequestProcessor.parseSessionExpiry(holder.getIdToken()); - Long ceiling = freshCeiling != null ? freshCeiling : sessionExpiresAt; + // the ceiling is fixed at login and does not advance on refresh, so the supplied value is always re-stamped unchanged. return new Tokens(holder.getAccessToken(), holder.getIdToken(), holder.getRefreshToken(), - holder.getTokenType(), holder.getExpiresIn(), holder.getScope(), domain, issuer, ceiling); + holder.getTokenType(), holder.getExpiresIn(), holder.getScope(), domain, issuer, sessionExpiresAt); } } diff --git a/src/main/java/com/auth0/RequestProcessor.java b/src/main/java/com/auth0/RequestProcessor.java index 0cba22d..f776f4a 100644 --- a/src/main/java/com/auth0/RequestProcessor.java +++ b/src/main/java/com/auth0/RequestProcessor.java @@ -53,10 +53,7 @@ class RequestProcessor { private static final String KEY_MAX_AGE = "max_age"; private static final String CIBA_GRANT_TYPE = "urn:openid:params:grant-type:ciba"; - // 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. + // Upper bound for a valid session_expiry (Unix seconds) private static final long MAX_SESSION_EXPIRY_SECONDS = 10_000_000_000L; private final DomainProvider domainProvider; @@ -561,7 +558,7 @@ private Tokens getVerifiedTokens(HttpServletRequest request, HttpServletResponse * @param tokens the merged tokens whose ID token is inspected. * @return the same tokens augmented with {@code sessionExpiresAt}, or {@code tokens} unchanged * when no usable {@code session_expiry} claim is present. - * @throws IdentityVerificationException if {@code session_expiry <= iat}. + * @throws IdentityVerificationException if {@code session_expiry <= iat + leeway}. */ private Tokens withSessionExpiry(Tokens tokens) throws IdentityVerificationException { String idToken = tokens.getIdToken(); @@ -570,12 +567,7 @@ private Tokens withSessionExpiry(Tokens tokens) throws IdentityVerificationExcep return tokens; } - // Lockout guard: a session that would already be expired on its first read must not be - // persisted. Applies the same leeway as Tokens#isSessionExpired so a ceiling that clears - // login isn't immediately bounced by the very next read. When the token carries an iat we - // anchor the check to it (the canonical "session started at" instant); if it somehow has no - // iat we fall back to wall-clock now, using the same leewayed comparison, so an already-past - // ceiling can't slip through unguarded. + // Applies the same leeway as Tokens#isSessionExpired so a ceiling that just clears login isn't immediately bounced by the very next read. Date issuedAt = JWT.decode(idToken).getIssuedAt(); long referenceSeconds = issuedAt != null ? Math.floorDiv(issuedAt.getTime(), 1000L) @@ -595,7 +587,7 @@ private Tokens withSessionExpiry(Tokens tokens) throws IdentityVerificationExcep *

    * A {@code null} ID token, an absent/null/non-numeric claim, a non-positive value * ({@code <= 0}), or a value large enough to be milliseconds-since-epoch - * ({@code >= 10_000_000_000}) all yield {@code null} — meaning "no ceiling" — rather than an + * ({@code >= 10_000_000_000}) all yield {@code null} (meaning "no ceiling") rather than an * exception, so a malformed or nonsensical value fails open instead of locking the user out, and * absence is never mistaken for an expired session. The lockout guard (rejecting a valid ceiling * already in the past at login) is applied separately by the caller, since it only applies to the @@ -614,18 +606,14 @@ static Long parseSessionExpiry(String idToken) { } Long sessionExpiresAt = sessionExpiryClaim.asLong(); if (sessionExpiresAt == null) { - // Present but not a numeric value — ignore rather than fail, matching "no ceiling". + // Present but not a numeric value, ignore rather than fail, matching "no ceiling". return null; } - // Fail open on non-positive values: 0 or a negative is not a real ceiling, so treat it as - // "no ceiling" rather than an already-expired session that would lock the user out. This - // keeps every nonsensical value (malformed, out-of-range, non-positive) behaving uniformly. + // A zero or negative isn't a real ceiling, so we fail open rather than lock the user out. if (sessionExpiresAt <= 0) { return null; } - // 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". + // A milliseconds value reads as a far future date and silently turns enforcement off. if (sessionExpiresAt >= MAX_SESSION_EXPIRY_SECONDS) { return null; } diff --git a/src/test/java/com/auth0/RenewAuthRequestTest.java b/src/test/java/com/auth0/RenewAuthRequestTest.java index 2fbc37c..1e8b677 100644 --- a/src/test/java/com/auth0/RenewAuthRequestTest.java +++ b/src/test/java/com/auth0/RenewAuthRequestTest.java @@ -156,15 +156,17 @@ public void shouldLeaveCeilingNullWhenNoneSuppliedAndResponseHasNone() throws Ex } @Test - public void shouldPreferFreshCeilingFromResponseOverSuppliedOne() throws Exception { - long freshCeiling = nowSeconds() + 7200; - when(mockTokenHolder.getIdToken()).thenReturn(idTokenWithSessionExpiry(freshCeiling)); + public void shouldKeepSuppliedCeilingEvenWhenResponseCarriesADifferentOne() throws Exception { + // The ceiling is fixed at login (write-once): a session_expiry in the refresh response must + // not move it. The response token is also unverified here, so its claims must not be trusted. + long suppliedCeiling = nowSeconds() + 3600; + when(mockTokenHolder.getIdToken()).thenReturn(idTokenWithSessionExpiry(nowSeconds() + 7200)); RenewAuthRequest request = new RenewAuthRequest(mockClient, REFRESH_TOKEN, DOMAIN, ISSUER); - Tokens tokens = request.withSessionExpiresAt(nowSeconds() + 3600).execute(); + Tokens tokens = request.withSessionExpiresAt(suppliedCeiling).execute(); - assertThat(tokens.getSessionExpiresAt(), is(freshCeiling)); + assertThat(tokens.getSessionExpiresAt(), is(suppliedCeiling)); } private static long nowSeconds() {