Skip to content

Commit 293507e

Browse files
committed
Modify docs
1 parent 00d876e commit 293507e

9 files changed

Lines changed: 405 additions & 44 deletions

EXAMPLES.md

Lines changed: 76 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,10 @@ String rotatedRefreshToken = tokens.getRefreshToken();
226226
227227
The refresh-token grant does not return an ID token, so `tokens.getIdToken()` is typically `null`.
228228

229+
> **Session ceiling:** if the connection emits the IPSIE `session_expiry` claim, pass the persisted
230+
> ceiling via `withSessionExpiresAt(...)` so the refresh is gated against it. See
231+
> [IPSIE session_expiry](#ipsie-session_expiry-upstream-idp-session-ceiling).
232+
229233
### Using MRRT with Multiple Custom Domains
230234

231235
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:
@@ -395,29 +399,93 @@ independent of the `exp` token lifetime.
395399

396400
This library does not own a session. It reads and validates the claim at login, exposing it
397401
via `Tokens.getSessionExpiresAt()` (seconds, or `null` when absent) and
398-
`Tokens.isSessionExpired()`. Persisting the value and enforcing the ceiling is the
402+
`Tokens.isSessionExpired(...)`. Persisting the value and enforcing the ceiling is the
399403
application's job.
400404

405+
> :warning: **The claim must be an integer number of seconds since the epoch.** The value is
406+
> emitted by your tenant, so the most common mistake is emitting
407+
> **milliseconds** (a `getTime()` without the `/ 1000`). A millisecond-scale value is **not**
408+
> enforced as a date thousands of years out — it is out of range, so the library treats it as
409+
> "no ceiling" and enforcement is silently **off**. Any non-numeric, zero, or negative value is
410+
> likewise ignored (fails open to "no ceiling") rather than locking users out.
411+
401412
Persist it at login alongside the tokens (`null` means "no ceiling", store as-is):
402413

403414
```java
404415
Tokens tokens = authenticationController.handle(request, response);
405416
request.getSession().setAttribute("sessionExpiresAt", tokens.getSessionExpiresAt());
406417
```
407418

408-
On every session read, rebuild a `Tokens` and check the ceiling. When it returns `true`,
409-
drop the session and fall through to your existing redirect-to-login path:
419+
**Login can now fail on this claim.** When the connection option is on and the emitted
420+
`session_expiry` is at or before the token's issued-at time (already expired), `handle()` throws
421+
an `IdentityVerificationException` for which `isSessionExpiryError()` returns `true` — a new error
422+
out of `handle()` that apps enabling the option weren't previously catching. Send the user back to
423+
log in:
424+
425+
```java
426+
try {
427+
Tokens tokens = authenticationController.handle(request, response);
428+
request.getSession().setAttribute("sessionExpiresAt", tokens.getSessionExpiresAt());
429+
} catch (IdentityVerificationException e) {
430+
if (e.isSessionExpiryError()) {
431+
response.sendRedirect("/login");
432+
return;
433+
}
434+
throw e;
435+
}
436+
```
437+
438+
On every session read, check the persisted ceiling directly with the static helper (no need to
439+
reconstruct a `Tokens`). When it returns `true`, drop the session and fall through to your existing
440+
redirect-to-login path:
410441

411442
```java
412443
HttpSession session = request.getSession();
413-
Tokens tokens = new Tokens(null, null, null, "Bearer", null, null, null,
414-
(Long) session.getAttribute("sessionExpiresAt"));
444+
Long sessionExpiresAt = (Long) session.getAttribute("sessionExpiresAt");
415445

416-
if (tokens.isSessionExpired()) {
446+
if (Tokens.isSessionExpired(sessionExpiresAt, Tokens.DEFAULT_SESSION_EXPIRY_LEEWAY)) {
417447
session.invalidate();
418448
response.sendRedirect("/login");
419449
}
420450
```
421451

422-
`isSessionExpired()` applies a 30s negative leeway for clock skew; pass
423-
`isSessionExpired(0)` for an exact comparison.
452+
The leeway is a 30s clock-skew allowance that treats the session as expired slightly *early*; pass
453+
`0` for an exact comparison. (When you hold a live `Tokens` instance, the instance methods
454+
`tokens.isSessionExpired()` / `tokens.isSessionExpired(0)` do the same thing against its own
455+
ceiling.)
456+
457+
### Enforcing the ceiling on refresh
458+
459+
The ceiling must be honored on **refresh**, not just on session reads: renewing an access token past
460+
the ceiling defeats the point of the upstream session limit. If you use this library's refresh-token
461+
grant (see [Refresh Token Grant (MRRT)](#refresh-token-grant-mrrt)), hand the persisted ceiling to
462+
`withSessionExpiresAt(...)` and the SDK enforces it for you — `execute()` throws
463+
`SessionExpiredException` **before** calling the token endpoint when the ceiling has passed, and
464+
carries the same ceiling forward onto the returned `Tokens` (the refresh grant returns no ID token,
465+
so there is no fresh `session_expiry` to re-read):
466+
467+
```java
468+
Long sessionExpiresAt = (Long) session.getAttribute("sessionExpiresAt");
469+
try {
470+
Tokens tokens = authenticationController.renewAuth(refreshToken, domain)
471+
.withSessionExpiresAt(sessionExpiresAt)
472+
.execute();
473+
// ceiling is preserved on the result — persist it as-is for the next refresh
474+
session.setAttribute("sessionExpiresAt", tokens.getSessionExpiresAt());
475+
} catch (SessionExpiredException e) {
476+
session.invalidate();
477+
response.sendRedirect("/login");
478+
}
479+
```
480+
481+
If instead you refresh tokens **outside** this SDK, run the same check yourself **before** calling
482+
the token endpoint with `grant_type=refresh_token`:
483+
484+
```java
485+
if (Tokens.isSessionExpired(sessionExpiresAt, Tokens.DEFAULT_SESSION_EXPIRY_LEEWAY)) {
486+
// do not refresh — re-authenticate instead
487+
}
488+
```
489+
490+
Either way, the ceiling is fixed at login and does **not** advance on refresh, so carry the original
491+
value forward.

src/main/java/com/auth0/AuthenticationController.java

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -361,17 +361,6 @@ public Tokens handle(HttpServletRequest request, HttpServletResponse response) t
361361
return requestProcessor.process(request, response);
362362
}
363363

364-
// TODO(IPSIE Req 3 — refresh-token ceiling): this branch has no renew/refresh-token API, so the
365-
// session_expiry ceiling is only stamped at login (see RequestProcessor#withSessionExpiry). When
366-
// the refresh-token grant (renewAuth) is merged from the MRRT work, it must:
367-
// 1. Gate the refresh: refuse to exchange grant_type=refresh_token once the persisted ceiling
368-
// has passed (Tokens#isSessionExpired()) and surface a0.session_expired instead of calling
369-
// /oauth/token — the renewed access token must never outlive the IdP session ceiling.
370-
// 2. Preserve the ceiling: a refresh response without a fresh session_expiry claim must carry
371-
// forward the original sessionExpiresAt rather than dropping it to null (no ceiling). Only a
372-
// newly-emitted, valid session_expiry should replace it.
373-
// Until then, enforcement is login-time only; the example-app demonstrates the gate manually.
374-
375364
/**
376365
* Pre builds an Auth0 Authorize Url with the given redirect URI using a random state and a random nonce if applicable.
377366
*

src/main/java/com/auth0/RenewAuthRequest.java

Lines changed: 53 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
import com.auth0.json.auth.TokenHolder;
66
import com.auth0.net.TokenRequest;
77

8+
import static com.auth0.Tokens.DEFAULT_SESSION_EXPIRY_LEEWAY;
9+
810
/**
911
* Class to exchange a refresh token for a new set of {@link Tokens}, optionally targeting a
1012
* specific {@code audience} and/or {@code scope}. This exposes Auth0's refresh-token grant,
@@ -27,6 +29,7 @@ public class RenewAuthRequest {
2729
private final String issuer;
2830
private String audience;
2931
private String scope;
32+
private Long sessionExpiresAt;
3033

3134
RenewAuthRequest(AuthAPI client, String refreshToken, String domain, String issuer) {
3235
this.client = client;
@@ -62,18 +65,59 @@ public RenewAuthRequest withScope(String scope) {
6265
return this;
6366
}
6467

68+
/**
69+
* Supplies the upstream IdP session ceiling ({@code session_expiry}, see the IPSIE SL1 profile)
70+
* that the application persisted at login from {@link Tokens#getSessionExpiresAt()}. The library
71+
* is stateless and does not remember it across requests, so it must be handed back here for the
72+
* ceiling to be enforced on refresh.
73+
* <p>
74+
* When set, {@link #execute()} enforces the ceiling in two ways:
75+
* <ul>
76+
* <li><strong>Gate:</strong> if the ceiling has already passed, {@link #execute()} throws a
77+
* {@link SessionExpiredException} <em>before</em> contacting the token endpoint, so a renewed
78+
* access token can never outlive the session ceiling.</li>
79+
* <li><strong>Carry-forward:</strong> the refresh-token grant returns no ID token (hence no
80+
* fresh {@code session_expiry}), so the returned {@link Tokens} carries this same ceiling
81+
* forward via {@link Tokens#getSessionExpiresAt()} rather than dropping it to {@code null}
82+
* ("no ceiling"). Only a newly-emitted, valid {@code session_expiry} in the response replaces
83+
* it.</li>
84+
* </ul>
85+
* Passing {@code null} (the default) means "no known ceiling": no gate is applied and the
86+
* returned tokens carry no ceiling unless the response itself provides one.
87+
*
88+
* @param sessionExpiresAt the persisted {@code session_expiry} ceiling (Unix seconds), or
89+
* {@code null} for no ceiling.
90+
* @return this request instance for fluent chaining.
91+
*/
92+
public RenewAuthRequest withSessionExpiresAt(Long sessionExpiresAt) {
93+
this.sessionExpiresAt = sessionExpiresAt;
94+
return this;
95+
}
96+
6597
/**
6698
* Executes the refresh-token grant against Auth0 and returns the resulting tokens.
6799
* <p>
68100
* The refresh-token grant does not return an ID token, so {@link Tokens#getIdToken()} is
69101
* typically null. When refresh-token rotation is enabled, the returned
70102
* {@link Tokens#getRefreshToken()} is a new refresh token that supersedes the one used here;
71103
* the application is responsible for persisting it.
104+
* <p>
105+
* When a session ceiling was supplied via {@link #withSessionExpiresAt(Long)}, it is enforced:
106+
* an already-passed ceiling short-circuits with a {@link SessionExpiredException} before any
107+
* network call, and the ceiling is carried forward onto the returned tokens (see that method).
72108
*
73109
* @return the {@link Tokens} obtained from the grant, including the granted scope.
74-
* @throws Auth0Exception if the request to the Auth0 server failed.
110+
* @throws SessionExpiredException if the supplied session ceiling has already passed.
111+
* @throws Auth0Exception if the request to the Auth0 server failed.
75112
*/
76-
public Tokens execute() throws Auth0Exception {
113+
public Tokens execute() throws Auth0Exception, SessionExpiredException {
114+
// Gate: never refresh past the IdP session ceiling — the renewed access token must not
115+
// outlive the session. Checked before the network call so no token is minted.
116+
if (Tokens.isSessionExpired(sessionExpiresAt, DEFAULT_SESSION_EXPIRY_LEEWAY)) {
117+
throw new SessionExpiredException(
118+
"The session_expiry ceiling has passed; the refresh token must not be exchanged. Re-authenticate instead.");
119+
}
120+
77121
TokenRequest request = client.renewAuth(refreshToken);
78122
if (audience != null) {
79123
request.setAudience(audience);
@@ -82,7 +126,13 @@ public Tokens execute() throws Auth0Exception {
82126
request.setScope(scope);
83127
}
84128
TokenHolder holder = request.execute().getBody();
129+
130+
// Carry-forward: prefer a fresh, valid ceiling from the response (rare — the grant has no
131+
// ID token), otherwise re-stamp the supplied ceiling so a refresh never silently drops it.
132+
Long freshCeiling = RequestProcessor.parseSessionExpiry(holder.getIdToken());
133+
Long ceiling = freshCeiling != null ? freshCeiling : sessionExpiresAt;
134+
85135
return new Tokens(holder.getAccessToken(), holder.getIdToken(), holder.getRefreshToken(),
86-
holder.getTokenType(), holder.getExpiresIn(), holder.getScope(), domain, issuer);
136+
holder.getTokenType(), holder.getExpiresIn(), holder.getScope(), domain, issuer, ceiling);
87137
}
88138
}

src/main/java/com/auth0/RequestProcessor.java

Lines changed: 51 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -565,38 +565,71 @@ private Tokens getVerifiedTokens(HttpServletRequest request, HttpServletResponse
565565
*/
566566
private Tokens withSessionExpiry(Tokens tokens) throws IdentityVerificationException {
567567
String idToken = tokens.getIdToken();
568-
if (idToken == null) {
568+
Long sessionExpiresAt = parseSessionExpiry(idToken);
569+
if (sessionExpiresAt == null) {
569570
return tokens;
570571
}
571572

572-
DecodedJWT decoded = JWT.decode(idToken);
573-
Claim sessionExpiryClaim = decoded.getClaim("session_expiry");
574-
if (sessionExpiryClaim.isMissing() || sessionExpiryClaim.isNull()) {
575-
return tokens;
573+
// Lockout guard: a session that would already be expired on its first read must not be
574+
// persisted. Applies the same leeway as Tokens#isSessionExpired so a ceiling that clears
575+
// login isn't immediately bounced by the very next read. When the token carries an iat we
576+
// anchor the check to it (the canonical "session started at" instant); if it somehow has no
577+
// iat we fall back to wall-clock now, using the same leewayed comparison, so an already-past
578+
// ceiling can't slip through unguarded.
579+
Date issuedAt = JWT.decode(idToken).getIssuedAt();
580+
long referenceSeconds = issuedAt != null
581+
? Math.floorDiv(issuedAt.getTime(), 1000L)
582+
: Math.floorDiv(System.currentTimeMillis(), 1000L);
583+
if (sessionExpiresAt <= referenceSeconds + Tokens.DEFAULT_SESSION_EXPIRY_LEEWAY) {
584+
throw new IdentityVerificationException(SESSION_EXPIRY_IN_PAST_ERROR,
585+
"The session_expiry claim is within the expiry leeway of the token's issued-at time; the session is already expired.");
576586
}
577587

588+
return new Tokens(tokens.getAccessToken(), tokens.getIdToken(), tokens.getRefreshToken(),
589+
tokens.getType(), tokens.getExpiresIn(), tokens.getScope(), tokens.getDomain(), tokens.getIssuer(), sessionExpiresAt);
590+
}
591+
592+
/**
593+
* Reads and validates the IPSIE {@code session_expiry} claim from an ID token, returning the
594+
* ceiling as a Unix timestamp in seconds, or {@code null} when there is no usable ceiling.
595+
* <p>
596+
* A {@code null} ID token, an absent/null/non-numeric claim, a non-positive value
597+
* ({@code <= 0}), or a value large enough to be milliseconds-since-epoch
598+
* ({@code >= 10_000_000_000}) all yield {@code null} — meaning "no ceiling" — rather than an
599+
* exception, so a malformed or nonsensical value fails open instead of locking the user out, and
600+
* absence is never mistaken for an expired session. The lockout guard (rejecting a valid ceiling
601+
* already in the past at login) is applied separately by the caller, since it only applies to the
602+
* login path.
603+
*
604+
* @param idToken the ID token to inspect, or {@code null}.
605+
* @return the validated {@code session_expiry} in seconds since epoch, or {@code null}.
606+
*/
607+
static Long parseSessionExpiry(String idToken) {
608+
if (idToken == null) {
609+
return null;
610+
}
611+
Claim sessionExpiryClaim = JWT.decode(idToken).getClaim("session_expiry");
612+
if (sessionExpiryClaim.isMissing() || sessionExpiryClaim.isNull()) {
613+
return null;
614+
}
578615
Long sessionExpiresAt = sessionExpiryClaim.asLong();
579616
if (sessionExpiresAt == null) {
580617
// Present but not a numeric value — ignore rather than fail, matching "no ceiling".
581-
return tokens;
618+
return null;
619+
}
620+
// Fail open on non-positive values: 0 or a negative is not a real ceiling, so treat it as
621+
// "no ceiling" rather than an already-expired session that would lock the user out. This
622+
// keeps every nonsensical value (malformed, out-of-range, non-positive) behaving uniformly.
623+
if (sessionExpiresAt <= 0) {
624+
return null;
582625
}
583-
584626
// Range guard: reject milliseconds-since-epoch (or any absurdly large value). A value
585627
// accidentally emitted in milliseconds would read as a date ~thousands of years out and
586628
// silently switch off enforcement, so treat anything at/above this bound as "no ceiling".
587629
if (sessionExpiresAt >= MAX_SESSION_EXPIRY_SECONDS) {
588-
return tokens;
630+
return null;
589631
}
590-
591-
// Lockout guard: a session that is already past its ceiling at login must not be persisted.
592-
Date issuedAt = decoded.getIssuedAt();
593-
if (issuedAt != null && sessionExpiresAt <= Math.floorDiv(issuedAt.getTime(), 1000L)) {
594-
throw new IdentityVerificationException(SESSION_EXPIRY_IN_PAST_ERROR,
595-
"The session_expiry claim is at or before the token's issued-at time; the session is already expired.");
596-
}
597-
598-
return new Tokens(tokens.getAccessToken(), tokens.getIdToken(), tokens.getRefreshToken(),
599-
tokens.getType(), tokens.getExpiresIn(), tokens.getScope(), tokens.getDomain(), tokens.getIssuer(), sessionExpiresAt);
632+
return sessionExpiresAt;
600633
}
601634

602635
/**
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package com.auth0;
2+
3+
/**
4+
* Raised when a refresh-token exchange is attempted after the upstream IdP session ceiling
5+
* ({@code session_expiry}, see the IPSIE SL1 profile) has already passed.
6+
* <p>
7+
* The library is stateless and does not own the session, so it cannot know the ceiling on its own:
8+
* the application persists {@link Tokens#getSessionExpiresAt()} at login and hands it back via
9+
* {@link RenewAuthRequest#withSessionExpiresAt(Long)}. When that ceiling has passed,
10+
* {@link RenewAuthRequest#execute()} throws this exception <em>before</em> calling the token
11+
* endpoint, so a renewed access token can never outlive the session ceiling. The application should
12+
* treat this as a "must re-authenticate" outcome rather than retrying the refresh.
13+
*
14+
* @see RenewAuthRequest#withSessionExpiresAt(Long)
15+
* @see Tokens#isSessionExpired()
16+
*/
17+
@SuppressWarnings("WeakerAccess")
18+
public class SessionExpiredException extends IdentityVerificationException {
19+
20+
static final String SESSION_EXPIRED = "a0.session_expired";
21+
22+
SessionExpiredException(String message) {
23+
super(SESSION_EXPIRED, message, null);
24+
}
25+
}

0 commit comments

Comments
 (0)