Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions EXAMPLES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
11 changes: 11 additions & 0 deletions src/main/java/com/auth0/AuthenticationController.java
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@
return new Builder(clientId, clientSecret).withDomainResolver(domainResolver);
}

public static class Builder {

Check warning on line 69 in src/main/java/com/auth0/AuthenticationController.java

View workflow job for this annotation

GitHub Actions / gradle

no comment
private static final String RESPONSE_TYPE_CODE = "code";

private String domain;
Expand Down Expand Up @@ -360,6 +360,17 @@
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.
*
Expand Down
9 changes: 9 additions & 0 deletions src/main/java/com/auth0/IdentityVerificationException.java
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
package com.auth0;

@SuppressWarnings("WeakerAccess")
public class IdentityVerificationException extends Exception {

Check warning on line 4 in src/main/java/com/auth0/IdentityVerificationException.java

View workflow job for this annotation

GitHub Actions / gradle

no comment

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;

Check warning on line 10 in src/main/java/com/auth0/IdentityVerificationException.java

View workflow job for this annotation

GitHub Actions / gradle

no comment

IdentityVerificationException(String code, String message) {
this(code, message, null);
}

IdentityVerificationException(String code, String message, Throwable cause) {
super(message, cause);
this.code = code;
Expand All @@ -22,11 +27,15 @@
return code;
}

public boolean isAPIError() {

Check warning on line 30 in src/main/java/com/auth0/IdentityVerificationException.java

View workflow job for this annotation

GitHub Actions / gradle

no comment
return API_ERROR.equals(code);
}

public boolean isJWTError() {

Check warning on line 34 in src/main/java/com/auth0/IdentityVerificationException.java

View workflow job for this annotation

GitHub Actions / gradle

no comment
return JWT_MISSING_PUBLIC_KEY_ERROR.equals(code) || JWT_VERIFICATION_ERROR.equals(code);
}

public boolean isSessionExpiryError() {

Check warning on line 38 in src/main/java/com/auth0/IdentityVerificationException.java

View workflow job for this annotation

GitHub Actions / gradle

no comment
return SESSION_EXPIRY_IN_PAST_ERROR.equals(code);
}
}
66 changes: 65 additions & 1 deletion src/main/java/com/auth0/RequestProcessor.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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.
* <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)) {
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);
}

/**
Expand Down
95 changes: 94 additions & 1 deletion src/main/java/com/auth0/Tokens.java
Original file line number Diff line number Diff line change
Expand Up @@ -10,20 +10,29 @@
* <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;
private final String type;
private final Long expiresIn;
private final String domain;
private final String issuer;
private final Long sessionExpiresAt;

/**
* @param accessToken access token for Auth0 API
Expand All @@ -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.
* <p>
* 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
Expand All @@ -49,13 +61,34 @@ 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;
this.type = type;
this.expiresIn = expiresIn;
this.domain = domain;
this.issuer = issuer;
this.sessionExpiresAt = sessionExpiresAt;
}

/**
Expand Down Expand Up @@ -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 <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) {
if (sessionExpiresAt == null) {
return false;
}
long nowSeconds = Math.floorDiv(System.currentTimeMillis(), 1000L);
return nowSeconds >= sessionExpiresAt - leewaySeconds;
}
}
Loading
Loading