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

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added a warning callout in the IPSIE section: the claim must be integer seconds, and an out-of-range value (e.g. milliseconds from a getTime() without /1000) is ignored

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.
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);
}
}
56 changes: 53 additions & 3 deletions src/main/java/com/auth0/RenewAuthRequest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
Expand Down Expand Up @@ -62,18 +65,59 @@ 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.
* <p>
* When set, {@link #execute()} enforces the ceiling in two ways:
* <ul>
* <li><strong>Gate:</strong> if the ceiling has already passed, {@link #execute()} throws a
* {@link SessionExpiredException} <em>before</em> contacting the token endpoint, so a renewed
* access token can never outlive the session ceiling.</li>
* <li><strong>Carry-forward:</strong> the refresh-token grant returns no ID token (hence no
* fresh {@code session_expiry}), so the returned {@link Tokens} carries this same ceiling
* forward via {@link Tokens#getSessionExpiresAt()} rather than dropping it to {@code null}
* ("no ceiling"). Only a newly-emitted, valid {@code session_expiry} in the response replaces
* it.</li>
* </ul>
* 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.
* <p>
* The refresh-token grant does not return an ID token, so {@link Tokens#getIdToken()} is
* 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.
* <p>
* 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);
Expand All @@ -82,7 +126,13 @@ public Tokens execute() throws Auth0Exception {
request.setScope(scope);
}
TokenHolder holder = request.execute().getBody();

// Carry-forward: prefer a fresh, valid ceiling from the response (rare — the grant has no
// ID token), otherwise re-stamp the supplied ceiling so a refresh never silently drops it.
Long freshCeiling = RequestProcessor.parseSessionExpiry(holder.getIdToken());
Long ceiling = freshCeiling != null ? freshCeiling : sessionExpiresAt;

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, ceiling);
}
}
99 changes: 98 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,95 @@ 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();
Long sessionExpiresAt = parseSessionExpiry(idToken);
if (sessionExpiresAt == null) {
return tokens;
}

// Lockout guard: a session that would already be expired on its first read must not be
// persisted. Applies the same leeway as Tokens#isSessionExpired so a ceiling that clears
// login isn't immediately bounced by the very next read. When the token carries an iat we
// anchor the check to it (the canonical "session started at" instant); if it somehow has no
// iat we fall back to wall-clock now, using the same leewayed comparison, so an already-past
// ceiling can't slip through unguarded.
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.
* <p>
* 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;
}
// Fail open on non-positive values: 0 or a negative is not a real ceiling, so treat it as
// "no ceiling" rather than an already-expired session that would lock the user out. This
// keeps every nonsensical value (malformed, out-of-range, non-positive) behaving uniformly.
if (sessionExpiresAt <= 0) {
return null;
}
// 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 null;
}
return sessionExpiresAt;
}

/**
Expand Down
25 changes: 25 additions & 0 deletions src/main/java/com/auth0/SessionExpiredException.java
Original file line number Diff line number Diff line change
@@ -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.
* <p>
* 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 <em>before</em> 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);
}
}
Loading
Loading