Skip to content

feat: add recent-auth checking (checkRecentAuth + useRecentAuth) and maxAge step-up#446

Merged
imkesin merged 2 commits into
mainfrom
feat/recent-auth-and-max-age
Jun 30, 2026
Merged

feat: add recent-auth checking (checkRecentAuth + useRecentAuth) and maxAge step-up#446
imkesin merged 2 commits into
mainfrom
feat/recent-auth-and-max-age

Conversation

@imkesin

@imkesin imkesin commented Jun 28, 2026

Copy link
Copy Markdown
Contributor

Surface the access token's auth_time claim so apps can enforce step-up / reauthentication.

What's in here

  • checkRecentAuth server function — data-only recency check that fails closed (isStale: true when auth_time is 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.
  • useRecentAuth client hook — presentation-only recency state from claims already in client memory.
  • evaluateRecentAuth — shared internal helper (in utils.ts) backing both. Fails closed on a missing/non-finite auth_time, treats a future auth_time (clock skew) as recent, and uses an inclusive maxAge boundary.
  • Added auth_time to JWTPayload.

maxAge step-up (OIDC max_age)

  • getSignInUrl / getSignUpUrl / getAuthorizationUrl now accept maxAge, forwarding OIDC max_age so the IdP forces a reauth when the most recent auth is older. This is the redirect half of the step-up loop that checkRecentAuth gates.
  • The GetAuthURLOptions.maxAge field 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 to never — unsettable at compile time rather than advertised and silently dropped at runtime. No false promise, no silent no-op.

Dependencies

  • Non-breaking. The peer range is unchanged (@workos-inc/node ^9.0.0 || ^10.0.0); the conditional type self-gates per consumer.
  • Dev dependency bumped to @workos-inc/node ^10.7.0 so the build type-checks the maxAge passthrough.

Notes for reviewers

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.
@imkesin imkesin self-assigned this Jun 28, 2026
@imkesin
imkesin marked this pull request as ready for review June 28, 2026 02:44
@imkesin
imkesin requested a review from a team as a code owner June 28, 2026 02:44
@greptile-apps

greptile-apps Bot commented Jun 28, 2026

Copy link
Copy Markdown

Greptile Summary

This PR surfaces the OIDC auth_time claim to enable step-up / re-authentication flows. It adds a server-side checkRecentAuth function and a client-side useRecentAuth hook, backed by a shared evaluateRecentAuth helper in utils.ts. The OIDC max_age parameter is forwarded through getSignInUrl / getAuthorizationUrl using a compile-time version gate so consumers on older SDK versions get a type error rather than a silent no-op.

  • checkRecentAuth (server): reads auth_time from the iron-session-sealed access token via the existing withAuth + getTokenClaims path; fails closed (returns isStale: true) when the claim is absent or the user is unauthenticated.
  • useRecentAuth (client hook): presentation-only staleness state computed from claims already in client memory; evaluates staleness once per render at Date.now() — not continuously — which is intentional given server-side enforcement is the safety net.
  • maxAge type gate in GetAuthURLOptions: resolves to number on @workos-inc/node ≥ 10.7.0 and never on older versions, preventing the option from being silently ignored at runtime.

Confidence Score: 5/5

Safe 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

Filename Overview
src/utils.ts Adds evaluateRecentAuth — shared recency helper with correct fail-closed logic, inclusive boundary, and clock-skew handling.
src/session.ts Adds checkRecentAuth server function; correctly delegates to withAuth() + getTokenClaims, fails closed when no user/token, and never redirects.
src/components/useRecentAuth.ts New useRecentAuth client hook; hooks called unconditionally (Rules of Hooks compliant), staleness evaluated at render time which is intentional for a presentation-only hook.
src/interfaces.ts Adds version-gated maxAge to GetAuthURLOptions using a conditional type derived from the installed SDK — elegantly prevents silent no-ops on older SDK versions.
src/get-authorization-url.ts Forwards maxAge to the SDK's getAuthorizationUrl via conditional spread; correctly omits when undefined.
src/jwt.ts Adds optional auth_time field to JWTPayload; straightforward additive change.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[Sensitive server action / server component] --> B[checkRecentAuth maxAge]
    B --> C{withAuth}
    C -->|no user / no token| D[evaluateRecentAuth\nauthTime=undefined]
    C -->|user + accessToken| E[getTokenClaims\ndecodeJwt]
    E --> F[auth_time claim]
    F --> D
    D -->|missing / non-finite| G[isStale: true\nauthenticatedAt: null]
    D -->|now - authTime <= maxAge| H[isStale: false\nauthenticatedAt: Date]
    D -->|now - authTime > maxAge| I[isStale: true\nauthenticatedAt: Date]

    J[Client component] --> K[useRecentAuth maxAge]
    K --> L[useAccessToken]
    K --> M[useTokenClaims\nauth_time]
    L -->|loading| N[loading: true\nisStale: undefined]
    M --> D

    P[getSignInUrl / getSignUpUrl / getAuthorizationUrl] -->|maxAge provided| Q[SDK getAuthorizationUrl\nmax_age param to IdP]
    Q --> R[IdP forces fresh login\nif auth older than maxAge]
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
    A[Sensitive server action / server component] --> B[checkRecentAuth maxAge]
    B --> C{withAuth}
    C -->|no user / no token| D[evaluateRecentAuth\nauthTime=undefined]
    C -->|user + accessToken| E[getTokenClaims\ndecodeJwt]
    E --> F[auth_time claim]
    F --> D
    D -->|missing / non-finite| G[isStale: true\nauthenticatedAt: null]
    D -->|now - authTime <= maxAge| H[isStale: false\nauthenticatedAt: Date]
    D -->|now - authTime > maxAge| I[isStale: true\nauthenticatedAt: Date]

    J[Client component] --> K[useRecentAuth maxAge]
    K --> L[useAccessToken]
    K --> M[useTokenClaims\nauth_time]
    L -->|loading| N[loading: true\nisStale: undefined]
    M --> D

    P[getSignInUrl / getSignUpUrl / getAuthorizationUrl] -->|maxAge provided| Q[SDK getAuthorizationUrl\nmax_age param to IdP]
    Q --> R[IdP forces fresh login\nif auth older than maxAge]
Loading

Reviews (2): Last reviewed commit: "docs: add re-authentication example to R..." | Re-trigger Greptile

@gjtorikian gjtorikian left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

Comment thread src/utils.ts
* the `maxAge` boundary is inclusive.
*/
export function evaluateRecentAuth({ authTime, maxAgeSeconds, nowSeconds }: EvaluateRecentAuthParameters) {
if (typeof authTime !== 'number' || !Number.isFinite(authTime)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

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.

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.

@imkesin
imkesin merged commit 34f92ac into main Jun 30, 2026
5 checks passed
@imkesin
imkesin deleted the feat/recent-auth-and-max-age branch June 30, 2026 13:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants