-
Notifications
You must be signed in to change notification settings - Fork 46
feat: Support IPSIE session_expiry claim #245
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: v2
Are you sure you want to change the base?
Changes from all commits
d1683a7
9255731
6752500
d66677c
68b66ed
00d876e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
|
||
|
|
@@ -384,3 +385,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 | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This shows how to persist and check the ceiling, but it doesn't mention that login itself can now fail: when the claim is at or before the token's issued-at time, Could we add a short note here on catching that case and sending the user back to login, so the login-time behavior sits next to the read-time check? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. One thing worth spelling out that a session owning SDK gets for free: since this library has no refresh API, an app that refreshes tokens itself has to run the same Shall we add a line here noting the ceiling should be checked before any refresh, not just on session reads? |
||
| 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. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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,12 @@ 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. | ||
| 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 +541,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. | ||
| * <p> | ||
| * 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)) { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The login guard here compares the ceiling against Should the login guard use the same leeway. Also, if a verified token somehow has no |
||
| 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); | ||
| } | ||
|
|
||
| /** | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -10,13 +10,21 @@ | |
| * <li><i>refreshToken</i>: Refresh Token that can be used to request new tokens without signing in again</li> | ||
| * <li><i>type</i>: Token Type</li> | ||
| * <li><i>expiresIn</i>: Token expiration</li> | ||
| * <li><i>sessionExpiresAt</i>: Upstream IdP session ceiling, from the {@code session_expiry} ID token claim</li> | ||
| * </ul> | ||
| */ | ||
| @SuppressWarnings({"unused", "WeakerAccess"}) | ||
| 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 <em>before</em> 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. | ||
| * <p> | ||
| * 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. | ||
| * <p> | ||
| * 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,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 <strong>seconds</strong> since the epoch — not a | ||
| * duration, and distinct from {@link #getExpiresIn()} (which bounds the access token). | ||
| * <p> | ||
| * 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. | ||
| * <p> | ||
| * 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. | ||
| * <p> | ||
| * Call this on <strong>every</strong> session read and, critically, <strong>before</strong> | ||
| * 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. | ||
| * <p> | ||
| * 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) { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. To check the ceiling on a session read, the app has to build a whole Would a small static helper that takes the stored value directly, something like |
||
| if (sessionExpiresAt == null) { | ||
| return false; | ||
| } | ||
| long nowSeconds = Math.floorDiv(System.currentTimeMillis(), 1000L); | ||
| return nowSeconds >= sessionExpiresAt - leewaySeconds; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The section explains the claim is in seconds but doesn't warn about what happens when it isn't. Since the value comes from a customer Post-Login Action, the most common mistake is emitting milliseconds (a
getTime()without the/1000), and the code treats anything at or above 10,000,000,000 as "no ceiling". So a milliseconds value doesn't push expiry thousands of years out, it quietly switches enforcement off, which is easy to ship without noticing.Could we add a short warning that the claim must be an integer number of seconds, and that an out of range value is ignored rather than enforced? Refer here: Emitting the claim.