feat: add recent-auth checking (checkRecentAuth + useRecentAuth) and maxAge step-up#446
Conversation
Surface the access token's auth_time claim so apps can enforce step-up / reauthentication, and forward OIDC max_age on sign-in. - evaluateRecentAuth (utils): pure recency helper. Fails closed when auth_time is missing/non-finite; future auth_time (clock skew) is not stale; the maxAge boundary is inclusive. - checkRecentAuth (session): data-only server check that never redirects. withAuth-gated and fails closed when there is no user or no auth_time. - useRecentAuth (client): presentation-only recency state from claims already in client memory.
Greptile SummaryThis PR surfaces the OIDC
Confidence Score: 5/5Safe to merge — all new paths fail closed, the existing iron-session integrity model is preserved, and the public API additions are additive and non-breaking. The evaluateRecentAuth logic is correct (inclusive boundary, clock-skew treated as recent, fail-closed on missing/non-finite values), checkRecentAuth correctly short-circuits when no user is present, and the maxAge conditional-type gate cleanly prevents runtime surprises on older SDK versions. Test coverage is thorough across boundary cases, missing claims, and the unauthenticated scenario. No files require special attention. src/components/useRecentAuth.ts has a snapshot-at-render-time characteristic worth noting in the JSDoc, but it is an intentional design trade-off. Important Files Changed
|
gjtorikian
left a comment
There was a problem hiding this comment.
The README here has a pretty thorough Usage section -- we may want to also add some kind of "Enforcing recent authentication (step-up)" guide here.
| * the `maxAge` boundary is inclusive. | ||
| */ | ||
| export function evaluateRecentAuth({ authTime, maxAgeSeconds, nowSeconds }: EvaluateRecentAuthParameters) { | ||
| if (typeof authTime !== 'number' || !Number.isFinite(authTime)) { |
There was a problem hiding this comment.
Since NaN is a number, I think maxAgeSeconds needs a similar check here:
if (typeof authTime !== 'number' || !Number.isFinite(authTime) || !Number.isFinite(maxAgeSeconds)) {
...
}
Because otherwise, isStale will always be false (nowSeconds - authTime > NaN == false).
Bonus points: add it('fails closed when maxAge is not finite') to utils.spec.ts
There was a problem hiding this comment.
Does it change your perspective if maxAgeSeconds is always the user provided value? I also don't check nowSeconds on the way into this function.
Surface the access token's
auth_timeclaim so apps can enforce step-up / reauthentication.What's in here
checkRecentAuthserver function — data-only recency check that fails closed (isStale: truewhenauth_timeis missing or there's no user). It never redirects, so it's safe to call as the enforcement step inside a sensitive server action or in a server component where you decide what to do.useRecentAuthclient hook — presentation-only recency state from claims already in client memory.evaluateRecentAuth— shared internal helper (inutils.ts) backing both. Fails closed on a missing/non-finiteauth_time, treats a futureauth_time(clock skew) as recent, and uses an inclusivemaxAgeboundary.auth_timetoJWTPayload.maxAgestep-up (OIDCmax_age)getSignInUrl/getSignUpUrl/getAuthorizationUrlnow acceptmaxAge, forwarding OIDCmax_ageso the IdP forces a reauth when the most recent auth is older. This is the redirect half of the step-up loop thatcheckRecentAuthgates.GetAuthURLOptions.maxAgefield is version-gated via a conditional type derived from the installed SDK (Parameters<WorkOS['userManagement']['getAuthorizationUrl']>[0]). On@workos-inc/node< 10.6 it resolves tonever— unsettable at compile time rather than advertised and silently dropped at runtime. No false promise, no silent no-op.Dependencies
@workos-inc/node^9.0.0 || ^10.0.0); the conditional type self-gates per consumer.@workos-inc/node^10.7.0so the build type-checks themaxAgepassthrough.Notes for reviewers
@workos/authkit-session), socheckRecentAuthreadsauth_timevia the existingwithAuth+getTokenClaimspath. That read is decode-only, consistent withwithAuth/getTokenClaims— the iron-session seal (verified/refreshed in middleware) is the integrity boundary throughout the library.