feat: Support IPSIE session_expiry claim#245
Conversation
| * @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.
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?
| return false; | ||
| } | ||
| long nowSeconds = Math.floorDiv(System.currentTimeMillis(), 1000L); | ||
| return nowSeconds >= sessionExpiresAt - leewaySeconds; |
There was a problem hiding this comment.
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.
|
|
||
| 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.
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?
| } | ||
|
|
||
| @Test | ||
| public void shouldIgnoreSessionExpiryWhenValueIsInMilliseconds() throws Exception { |
There was a problem hiding this comment.
These cover missing, past-iat, and the millisecond case, but the plain malformed case isn't here: a session_expiry that isn't a number (a string, say) comes back as null from asLong() and should fall through to "no ceiling". Since the claim is developer-set, that's the input most likely to actually show up, and nothing yet checks that it fails open instead of locking someone out.
The zero/negative case is worth settling too, those are numbers, so they get past the range check and hit the at-or-before-iat throw, i.e. a lockout rather than "no ceiling". Shall we add a malformed-value test and decide which behavior we want there?
|
|
||
| // 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.
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.
|
|
||
| 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, |
There was a problem hiding this comment.
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.
|
|
||
| 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.
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?
Summary
This PR adds support for the IPSIE SL1
session_expiryID token claim, which represents an absolute upper bound (Unix timestamp in seconds) on a session's lifetime.The claim is asserted by the upstream IdP and emitted by Auth0 when the enterprise connection has
id_token_session_expiry_supported: true.Since this library is a stateless core (it does not own or manage application sessions), it provides the primitives required to enforce the session ceiling while leaving session persistence and redirect behavior to the application.
Specifically, this PR:
session_expiryclaim during the Authorization Code flow.Tokens.Tokens.getSessionExpiresAt()Tokens.isSessionExpired()Tokens.isSessionExpired(long leeway)isSessionExpired()whenever a session is read, integrating with their existing redirect-to-login flow.Changes
TokenssessionExpiresAtfield.getSessionExpiresAt()isSessionExpired()isSessionExpired(long leeway)DEFAULT_SESSION_EXPIRY_LEEWAY.serialVersionUIDremains unchanged, so previously serialized sessions deserialize withsessionExpiresAt == null, preserving backward compatibility.RequestProcessorReads
session_expiryfrom the verified ID token after token merge and validates it before stamping it ontoTokens.Validation rules:
>= 10_000_000_000) are ignored to prevent a Post-Login Action that accidentally emits milliseconds from silently disabling enforcement.session_expiry <= iat, login fails instead of creating an already-expired session.IdentityVerificationExceptionAdded:
a0.session_expiry_in_pasterror codeisSessionExpiryError()helperDocumentation
Updated
EXAMPLES.mdwith an IPSIEsession_expirysection covering:Tests
Added coverage for:
RequestProcessorTestiatvalidation failureTokensTestSemantics
nullmeans no session ceiling and is never considered expired, making rollout backward compatible.session_expiryis independent of both:expclaimOut of Scope / Follow-up
Refresh token enforcement
This library does not currently expose a refresh-token API, so the session ceiling is enforced only:
A
TODOhas been added toAuthenticationControllerdocumenting the intended behavior once the MRRT renewal flow is implemented:grant_type=refresh_tokenrequests once the session ceiling has passed, returningsession_expired.session_expiryacross refreshes (write once; never re-derive it from refresh responses).Documentation
Cross-linking from the session management documentation is not included in this PR.