Skip to content
Open
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 @@ -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

Expand Down Expand Up @@ -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,

Copy link
Copy Markdown

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.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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, handle() throws and isSessionExpiryError() returns true. For an app just turning on the connection option, that's a new error out of handle() they weren't catching before.

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?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 isSessionExpired() check before a refresh exchange, otherwise it can renew a token past the ceiling and defeat the point. The isSessionExpired() Javadoc mentions this, but the guide is where a developer wiring up their own refresh would actually look.

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.
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 @@ -67,7 +67,7 @@
return new Builder(clientId, clientSecret).withDomainResolver(domainResolver);
}

public static class Builder {

Check warning on line 70 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 @@ -361,6 +361,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;

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

IdentityVerificationException(String code, String message, Throwable cause) {
super(message, cause);
this.code = code;
Expand All @@ -29,4 +34,8 @@
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);
}
}
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 @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The login guard here compares the ceiling against iat with no leeway, while the read-time check applies the 30s skew leeway against now. A ceiling landing a few seconds after iat clears this guard and gets stored, but the very first isSessionExpired() read then treats it as expired because the default leeway pulls it back, so the session is saved at login and bounced on the next read.

Should the login guard use the same leeway. Also, if a verified token somehow has no iat, this guard is skipped and any below-cutoff ceiling gets stored.

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);
}

/**
Expand Down
103 changes: 100 additions & 3 deletions src/main/java/com/auth0/Tokens.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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;
Expand All @@ -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;
}

/**
Expand Down Expand Up @@ -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) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 Tokens with seven nulls just to call this one method , the example does exactly that (new Tokens(null, null, null, "Bearer", null, null, null, sessionExpiresAt)). It's a lot of setup for a single comparison, and it puts a "has this expired" check on a class that otherwise just holds token values.

Would a small static helper that takes the stored value directly, something like Tokens.isSessionExpired(Long sessionExpiresAt, long leeway)be easier to call? WDYT?

if (sessionExpiresAt == null) {
return false;
}
long nowSeconds = Math.floorDiv(System.currentTimeMillis(), 1000L);
return nowSeconds >= sessionExpiresAt - leewaySeconds;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isSessionExpired(long) documents leeway as non-negative but doesn't enforce it, and a negative value flips the comparison to sessionExpiresAt + x , pushing the ceiling later than wall-clock rather than earlier, which is the opposite of what an absolute upper bound should do.

}
}
Loading
Loading