diff --git a/EXAMPLES.md b/EXAMPLES.md index c2645a8..3f366d9 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -9,6 +9,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 @@ -225,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: @@ -384,3 +389,103 @@ 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. + +> :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 +Tokens tokens = authenticationController.handle(request, response); +request.getSession().setAttribute("sessionExpiresAt", tokens.getSessionExpiresAt()); +``` + +**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(); +Long sessionExpiresAt = (Long) session.getAttribute("sessionExpiresAt"); + +if (Tokens.isSessionExpired(sessionExpiresAt, Tokens.DEFAULT_SESSION_EXPIRY_LEEWAY)) { + session.invalidate(); + response.sendRedirect("/login"); +} +``` + +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/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/RenewAuthRequest.java b/src/main/java/com/auth0/RenewAuthRequest.java index 60895bc..b2302ba 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,10 @@ public Tokens execute() throws Auth0Exception { request.setScope(scope); } TokenHolder holder = request.execute().getBody(); + + + // 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); + 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 8dcb591..f776f4a 100644 --- a/src/main/java/com/auth0/RequestProcessor.java +++ b/src/main/java/com/auth0/RequestProcessor.java @@ -8,6 +8,8 @@ import com.auth0.exception.PublicKeyProviderException; import com.auth0.jwt.JWT; import com.auth0.json.auth.BackChannelTokenResponse; +import com.auth0.jwt.interfaces.Claim; +import com.auth0.jwt.interfaces.DecodedJWT; import com.auth0.json.auth.TokenHolder; import com.auth0.net.TokenRequest; import com.auth0.jwk.Jwk; @@ -24,6 +26,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; @@ -50,6 +53,9 @@ 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) + private static final long MAX_SESSION_EXPIRY_SECONDS = 10_000_000_000L; + private final DomainProvider domainProvider; private final String responseType; private final String clientId; @@ -532,7 +538,86 @@ 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 + leeway}. + */ + private Tokens withSessionExpiry(Tokens tokens) throws IdentityVerificationException { + String idToken = tokens.getIdToken(); + Long sessionExpiresAt = parseSessionExpiry(idToken); + if (sessionExpiresAt == null) { + return tokens; + } + + // 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) + : 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 null; + } + // A zero or negative isn't a real ceiling, so we fail open rather than lock the user out. + if (sessionExpiresAt <= 0) { + return null; + } + // A milliseconds value reads as a far future date and silently turns enforcement off. + if (sessionExpiresAt >= MAX_SESSION_EXPIRY_SECONDS) { + return null; + } + 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 fa02682..a87a693 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; @@ -25,6 +33,7 @@ public class Tokens implements Serializable { private final String scope; private final String domain; private final String issuer; + private final Long sessionExpiresAt; /** * @param accessToken access token for Auth0 API @@ -38,7 +47,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)} + * 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 @@ -50,11 +62,14 @@ 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, null, domain, issuer); + this(accessToken, idToken, refreshToken, type, expiresIn, null, domain, issuer, null); } /** - * Full constructor including the granted scope. + * Full constructor including the granted scope and domain information. + *

    + * Equivalent to calling {@link #Tokens(String, String, String, String, Long, String, 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 @@ -67,6 +82,27 @@ 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 scope, String domain, String issuer) { + this(accessToken, idToken, refreshToken, type, expiresIn, scope, 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 scope the scope granted for the access token, or null if not provided + * @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 scope, String domain, String issuer, Long sessionExpiresAt) { this.accessToken = accessToken; this.idToken = idToken; this.refreshToken = refreshToken; @@ -75,6 +111,7 @@ public Tokens(String accessToken, String idToken, String refreshToken, String ty this.scope = scope; this.domain = domain; this.issuer = issuer; + this.sessionExpiresAt = sessionExpiresAt; } /** @@ -153,4 +190,87 @@ 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 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 - effectiveLeeway; + } } diff --git a/src/test/java/com/auth0/RenewAuthRequestTest.java b/src/test/java/com/auth0/RenewAuthRequestTest.java index 5f6dbf5..1e8b677 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,79 @@ 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 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(suppliedCeiling).execute(); + + assertThat(tokens.getSessionExpiresAt(), is(suppliedCeiling)); + } + + 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 d76610e..2e37c83 100644 --- a/src/test/java/com/auth0/RequestProcessorTest.java +++ b/src/test/java/com/auth0/RequestProcessorTest.java @@ -3,6 +3,9 @@ import com.auth0.client.auth.AuthAPI; import com.auth0.exception.APIException; 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.BackChannelTokenResponse; import com.auth0.json.auth.TokenHolder; import com.auth0.jwk.JwkProvider; @@ -884,6 +887,181 @@ 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)); + } + + @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())); + } + + @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 @@ -1369,6 +1547,42 @@ 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)); + } + + /** + * 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 6bcc3d2..cca7b07 100644 --- a/src/test/java/com/auth0/TokensTest.java +++ b/src/test/java/com/auth0/TokensTest.java @@ -58,4 +58,70 @@ public void shouldReturnScopeFromEightArgConstructor() { assertThat(tokens.getDomain(), is("domain.auth0.com")); assertThat(tokens.getIssuer(), is("https://domain.auth0.com/")); } + + @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, "scope", "domain", "issuer", ceiling); + assertThat(tokens.getSessionExpiresAt(), is(ceiling)); + } + + @Test + public void shouldNotBeExpiredWhenNoCeilingPresent() { + Tokens tokens = new Tokens("at", "it", "rt", "bearer", 3600L, "scope", "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, "scope", "domain", "issuer", nowSeconds() + 3600); + assertThat(tokens.isSessionExpired(), is(false)); + } + + @Test + public void shouldBeExpiredWhenCeilingHasPassed() { + Tokens tokens = new Tokens("at", "it", "rt", "bearer", 3600L, "scope", "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, "scope", "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)); + } + + @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); + } }