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/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..4a17392 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; @@ -46,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; @@ -301,7 +310,62 @@ 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). 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 usable {@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; + } + + // 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)) { + 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 @@ *
+ * 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..19b4075 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,120 @@ 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