Skip to content

fix(auth): bound session lifetime and make OIDC token refresh resilient - #8

Merged
hendrikebbers merged 2 commits into
mainfrom
Daniel-dev
Aug 12, 2026
Merged

fix(auth): bound session lifetime and make OIDC token refresh resilient#8
hendrikebbers merged 2 commits into
mainfrom
Daniel-dev

Conversation

@danielmarv

Copy link
Copy Markdown
Contributor

Summary

createAppLayerAuth() had three defects that cost consuming apps (e.g. open-tasks) their
session after roughly one access-token lifetime, and hammered the IdP token endpoint when that
lifetime was short. All three are fixed in the library — consumers need no workaround.

Public API is unchanged: createAppLayerAuth() still returns
{ handlers, auth, signIn, signOut, oidcIssuer }, and calling it with only
{ issuer, clientId, clientSecret } keeps working. Released as 0.7.1.

Defects fixed

  1. No session lifetime. session: { strategy: "jwt" } inherited the Auth.js default of
    30 days, so the session cookie outlived the access token by weeks: the middleware said
    "authenticated" while every proxied API call returned 401.
  2. Hard-coded 60 s refresh window. With an IdP issuing access tokens with a lifetime ≤ 60 s
    (a common Authentik default), the refresh window was open from the moment the token was
    minted — a refresh was attempted on every RSC render, session poll, and proxied call.
  3. Refresh failure was terminal, silent, and racy. Any failure (including a transient 5xx or
    a timeout) permanently set RefreshTokenError; authorized() ignored it and admitted the
    user into a dead app shell; concurrent requests each POSTed the same refresh token, so
    rotation-enabled IdPs invalidated all but one (random logouts); discovery was re-fetched on
    every refresh; neither fetch had a timeout.

Changes

New src/server/oidc-tokens.ts — all token logic, split out of auth.ts so it is
unit-testable without a live IdP:

  • Refresh skew clamped to the observed token lifetime: max(5, min(skew, floor(lifetime / 2))),
    with tokenLifetime persisted on the JWT (from account.expires_at on sign-in, expires_in
    on refresh). Short-lived tokens now refresh at most once per half-lifetime.
  • Single-flight refresh: a module-level Map<refreshToken, Promise> so concurrent callers share
    one POST; the entry is cleared in finally.
  • OIDC discovery cached for 10 minutes, invalidated when the token endpoint returns 4xx.
  • AbortSignal.timeout() on both the discovery and token-endpoint fetches.
  • Failure classification: 4xx (e.g. invalid_grant) → refresh token is dead →
    RefreshTokenError. 5xx / network / timeout → transient: log, keep the existing token,
    and only fail the session if the access token has already expired. Same rule when the IdP
    granted no refresh token at all.
  • No token values, refresh tokens, or client secrets are logged.

src/server/auth.ts

  • session: { strategy, maxAge, updateAge } + jwt: { maxAge }.

  • authorized() now returns false when session.error === "RefreshTokenError", even with a
    user present. session() is unchanged — still blanks accessToken on that error and still
    exposes idToken, expiresAt, roles, and error.

  • Four new optional AppLayerAuthConfig fields, each also readable from an env var
    (precedence: explicit value > env var > default; non-numeric/non-positive falls back to default):

    Option Env var Default
    sessionMaxAgeSeconds AUTH_SESSION_MAX_AGE_SECONDS 28800 (8 h)
    sessionUpdateAgeSeconds AUTH_SESSION_UPDATE_AGE_SECONDS 900
    refreshSkewSeconds AUTH_TOKEN_REFRESH_SKEW_SECONDS 300
    oidcTimeoutMs OIDC_HTTP_TIMEOUT_MS 10000

ClientSessionProvider takes a refetchInterval prop (default 120, refetchOnWindowFocus
kept), forwarded from OERootLayout as sessionRefetchInterval, so an app whose tokens live
under two minutes can poll more often.

Behavioural changes for consumers

  • Sessions now expire after 8 hours instead of 30 days (configurable). Apps that relied on
    the 30-day cookie will see one IdP round-trip per working day.
  • A session whose refresh token is dead is now redirected to /login instead of rendering a
    shell whose API calls 401.

Both are documented in docs/releases/upgrade-to-0.7.1.md.

Tests

src/server/__tests__/oidc-tokens.test.ts — 24 cases, covering:
needsRefresh() false right after a 60 s token is issued and true past half its lifetime; true
when expiresAt is missing; two concurrent refreshes issue exactly one POST; 400 invalid_grant
sets the error; 503 with a still-valid token does not set it and keeps accessToken; 503 with an
expired token does; authorized() false on RefreshTokenError despite a user; discovery fetched
once across refreshes within the TTL; plus option-precedence and skew-clamping cases.

Full suite 60 passed (9 files); typecheck, lint, build, and format:check all green.

Notes

  • The package has no @types/node, so env lookup goes through a typed globalThis.process?.env
    accessor rather than adding a dependency.
  • Version bumped to 0.7.1open-tasks can bump its dependency from ^0.7.0.

… token refresh logic

Signed-off-by: Ntege Daniel <danientege785@gmail.com>
… token refresh logic

Signed-off-by: Ntege Daniel <danientege785@gmail.com>
@danielmarv danielmarv self-assigned this Aug 12, 2026
@hendrikebbers
hendrikebbers merged commit 7ea9a93 into main Aug 12, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants