LibreChat/api/server/services/OpenIDSessionRefresh.spec.js
Danny Avila fa913148fb
🔒 fix: Refresh MCP OBO Tokens From the Live OpenID Session (#15334)
* 🧊 fix: Inline-refresh OpenID session tokens at MCP OBO call time

Resolves the walk-away failure mode where MCP tool calls using OBO auth
fail with "No valid OpenID access token is available for OBO exchange"
after a user idles past their access-token lifetime. The strategy-time
snapshot on `user.federatedTokens` could expire mid-stream before
`resolveOboToken` ran, while `req.session.openidTokens` carried a still-
valid (or refreshable) token that nothing read.

- New OpenIDSessionRefresh service: per-user single-flighted closure that
  reads `req.session.openidTokens` at OBO time and inline-refreshes via
  `openid-client.refreshTokenGrant` when expired (30s skew), persisting
  via `req.session.save()`. No cookie writes (headers already flushed).
- `resolveOboToken` gains a required UpstreamTokenProvider parameter
  (typed as `() => Promise<OIDCTokens | null>`, reusing the shared shape
  from @librechat/data-schemas). Compile-time guarantee that every call
  site is updated.
- New `session_refresh_failed` OboTokenResolutionReason distinguishes
  "session expired and IdP rejected refresh" from "no upstream token
  ever existed."
- `req` threaded through createMCPTool/createMCPTools/createToolInstance
  to construct the closure with captured request, plus fail-closed
  guards in MCPConnectionFactory.getOboTokens and MCPManager.callTool
  when the closure isn't plumbed.
- Startup warning in MCPServersInitializer when OBO is configured but
  OPENID_REUSE_TOKENS is unset (the strategy populating
  user.federatedTokens is only registered under reuse, so OBO would
  fail every call without it).

Tests: 16 new in OpenIDSessionRefresh.spec.js; obo.spec.ts extended
for the new param + error reason; wiring smoke tests in MCPManager,
MCPConnectionFactory, MCPServersInitializer, and MCP.spec.js.

* 🛡️ fix: Harden OBO inline-refresh against token type and session edge cases

- Token-preference asymmetry: live-token reuse and expires_at derivation
  now strictly gate on the access_token, not the id_token. Added a
  required `tokenPreference` parameter on isLiveSessionTokenStillValid,
  buildOIDCTokensFromSession, and createOpenIDSessionTokenProvider
  so every call site is explicit. Dropped the bogus id_token-exp
  fallback in performIdpRefresh — id_token TTL is governed by IdP
  session policy and would mark a short-lived access_token reusable
  past its real lifetime.
- Missing req in /reinitialize route: the manual reconnect
  endpoint now forwards req into reinitMCPServer, so OBO servers can
  build a session-aware upstream-token closure instead of failing with
  missing_upstream_token.
- Single-flight key collisions: composed key as
  tenantId:openidIssuer:openidId:sessionId via getSingleFlightKey.
  Concurrent calls in the same session still coalesce; separate sessions
  never share an in-flight refresh, preventing refresh-token rotation
  from breaking sibling sessions and preventing cross-tenant token
  crossover when distinct users share an IdP sub.
- Opaque access token reuse): persist accessTokenExpiresAt
  (unix seconds, from tokenset.expires_in) on each refresh AND on initial
  login / SPA refresh in setOpenIDAuthTokens. New getAccessTokenExp
  helper falls back to it when the access token isn't a JWT, avoiding
  redundant inline refreshes for Microsoft Graph and Auth0 default
  audiences.
- Log hygiene: the single-flight key (containing sessionId,
  openidId, openidIssuer, tenantId) is now SHA-256-hashed in the
  "Joining in-flight refresh" debug log. Preserves cross-line correlation
  via a 12-char prefix without leaking credential or PII material.

Documented req.session.openidTokens shape contract via JSDoc typedef so
the new accessTokenExpiresAt field has a discoverable home alongside the
existing accessToken/idToken/refreshToken/expiresAt/lastRefreshedAt.

Tests: OpenIDSessionRefresh.spec.js up to 30 passing (added coverage for
opaque-token reuse, JWT-access-token-exp fallback, no-id_token-fallback
regression, cross-session no-coalesce, persistence on refresh, and a
guard against stale accessTokenExpiresAt carryover). AuthService.spec.js
adds two cases covering accessTokenExpiresAt persistence on login.
mcp.spec.js (route) gains a regression test asserting req flows into
reinitMCPServer.

* 🔍 fix: Detect OBO-only MCP admin config overrides

Admin Config overlays for YAML-defined MCP servers compare only
ADMIN_CONFIGURABLE_FIELDS to decide whether to lazy-init a config-tier override.
The OBO config field was added after that fingerprint list, so an override that
only added or changed `obo` was treated as unchanged YAML and skipped.

Include `obo` in the admin-configurable field list and add a regression test for
an OBO-only override.

* 🔊 fix: Mock MCP OAuth timeout in SDK integration test

MCPConnectionFactory.attemptToConnect reads mcpConfig.OAUTH_HANDLING_TIMEOUT
when building the OAuth connection timeout. The SDK OAuth integration test
mocked mcpConfig without that field, which made the timeout calculation produce
NaN and caused the test to fail before the OAuth refresh/start path completed.

Add OAUTH_HANDLING_TIMEOUT to the test mock.

* ♻️ refactor: Pass OBO upstream-token closure into MCP instead of req

Build the OpenID upstream-token provider at the request boundary and thread
only the closure through MCP handling, so the MCP service layer no longer
receives the raw Express request. The closure still reads/refreshes the live
session at tool-call time, preserving the walk-away recovery.

- Drop `req`/`capturedReq` from createMCPTools, createMCPTool, reconnectServer,
  createToolInstance, and reinitMCPServer; forward `upstreamTokenProvider`
  instead. Closure is constructed in loadTools, loadToolDefinitionsWrapper, and
  the reinitialize route, where req/res are in scope.
- OBO: fall back to user.federatedTokens when the provider yields no live
  session, so OIDC remote-agent calls (verified bearer, no session) still work.
- Inline refresh: mirror a rotated refresh token to the refreshToken cookie via
  a shared setRefreshTokenCookie helper, guarded by !res.headersSent (no-op on
  the streaming path; session copy stays authoritative).
- Single-flight: hydrate a joining request's own session from the resolved
  tokens so a later OBO call doesn't replay a rotated-away refresh token.

Addresses owner feedback and three review findings.

* 🔒 fix: Recover OIDC refresh-token rotation after SSE OBO refresh

When an inline OBO refresh rotates the OpenID refresh token after SSE headers
have already been sent, the browser refreshToken cookie cannot be updated. Store
a short-lived encrypted bridge from the stale cookie token to the rotated token
so /api/auth/refresh can recover after express-session loss.

Use the signed openid_user_id cookie to load user context for bridge validation,
retry only on invalid_grant, and delete the bridge only after the bridged refresh
succeeds.

* 🔨 fix: hydrate joined OIDC refresh sessions with stable refresh tokens

Update single-flight OIDC refresh joiners whenever refreshed access token
state changes, even if the IdP keeps the refresh token unchanged.

This prevents joined requests from retaining stale accessToken or
accessTokenExpiresAt values and redundantly refreshing later in the same run.

* 🌉 Persist OIDC refresh-token recovery bridges in MongoDB

Store SSE OBO refresh-token recovery bridges in MongoDB instead of
process-local memory so /api/auth/refresh can recover after worker
restarts or cross-worker routing.

Derive bridge expiry from REFRESH_TOKEN_EXPIRY so the recovery window
matches the stale refreshToken cookie it repairs, and delete bridges
after successful recovery.

* 🤝 Coordinate OIDC inline refreshes across workers

Add a short-lived Mongo-backed refresh-flight record so concurrent
OBO refreshes for the same OpenID session do not redeem the same
rotating refresh token on different workers.

The winning worker performs the IdP refresh and stores an encrypted
result; joiners wait for that result, hydrate their request session,
and return without calling the IdP.

*  Keep OpenID marker cookies aligned on inline refresh

Refresh token_provider and openid_user_id with the same expiry as the
rotated refreshToken cookie when an inline OBO refresh can still write
headers.

Share the marker-cookie writer with the normal OpenID auth refresh path
so the fallback /api/auth/refresh branch continues to recognize valid
OpenID refresh tokens after session expiry.

* 🔑 fix: include refresh token in OIDC local refresh flight key

Key the process-local OIDC refresh coalescing by the current session
refresh token, matching the Mongo-backed flight key. This prevents a
request with a newly rotated token from joining an older pending refresh
and inheriting its failure/result.

* 🌉 fix: store OIDC refresh bridge without cookie response

Treat missing or non-cookie responses like headers-sent streaming
responses during inline OIDC refresh. When the IdP rotates the refresh
token and cookies cannot be written, persist a recovery bridge so a later
/auth/refresh can recover after session expiry.

* 🫙 fix: preserve stale OIDC cookie bridge key

Track the refresh token last written to the browser cookie separately
from the current session refresh token. When inline OIDC refreshes rotate
tokens without a writable response, keep bridging from the browser-stale
token directly to the latest session token.

* 🙌 fix: keep OIDC bridge recovery success on cleanup failure

Make refresh-token bridge cleanup best-effort after a bridged OIDC
refresh succeeds. A transient delete failure now logs a warning but does
not convert the already-refreshed session and cookies into a 403 response.

* 📦 test: Exclude RefreshTokenBridge from tenant-isolation coverage

Add RefreshTokenBridge to the tenant-isolation coverage allowlist because
refresh bridge lookups run during unauthenticated OpenID refresh recovery.
The controller first recovers user context from the signed OpenID marker
cookie, then the bridge methods apply explicit user and tenant filters.

Ambient tenant isolation would bind this recovery path to request-local
tenant context that is not available at the point the stale cookie is being
resolved

*  Fix OpenID refresh flight retry and marker hydration

Allow failed OpenID refresh flights to be reclaimed immediately instead of pinning transient errors.

Preserve the browser refresh-token marker when joined refreshes hydrate session tokens from a shared flight result.

Stabilize AuthService tests by isolating mocked module imports from prior suites.

* 🛠️ fix: centralize OBO identity scoping

Add shared auth identity helpers for app user ids, OpenID subjects,
tenant ids, and normalized OpenID issuers.

Thread a non-placeholder-visible OBO identity context from the real
request user through MCP connection, tool-call, reinit, and refresh
paths. Keep tenantId and openidIssuer out of createSafeUser so MCP
user placeholders do not expose those fields.

Scope OBO token cache and in-flight exchange keys by tenant, issuer,
OpenID subject, scopes, and a SHA-256 hash of the upstream assertion.
This prevents cross-tenant/cross-issuer collisions and avoids reusing
tokens minted from stale rotated assertions.

Use the shared identity helpers for OpenID refresh-flight keys and
refresh-token bridge recovery records so related OBO refresh paths share
the same identity normalization rules.

The helper is intended for auth-boundary and credential-cache code, not
as a blanket replacement for ordinary app user id ownership checks.

* 🛠️ fix: preserve OIDC refresh-token sync on save failures

Sync OpenID refresh-token cookie/bridge state before persisting the
session so a transient session-store failure cannot lose an IdP-rotated
refresh token.

Also trigger sync when the session refresh token differs from the
browser refresh-token marker, not only when the current grant rotates
the token. This lets later writable refreshes repair stale browser
cookies left behind by SSE refreshes.

Route refresh bridge identity through the shared identity helper with
the threaded OBO identity context, falling back to request/user context
when needed.

Add regression coverage for session-save failures, stale browser cookie
repair, non-writable bridge storage, and shared-helper identity fallback.

* 🛠️ fix: keep OIDC refresh bridge during recovery grace

After successful bridged refresh recovery, re-store the stale-cookie bridge
with a short grace TTL instead of deleting it immediately. This lets parallel
/api/auth/refresh requests that already sent the stale browser cookie recover
before they can observe the first response's Set-Cookie.

Retarget the bridge to the refresh token returned by the bridged retry so
B-to-C refresh-token rotation remains recoverable. The grace TTL is parsed with
math() and defaults to 60s, which shrinks the replay window from the original
REFRESH_TOKEN_EXPIRY bridge lifetime to the short recovery grace period.

Remove the now-unused explicit bridge delete path from the service and
data-schemas method surface. Add regression coverage for grace re-store,
identity symmetry, retry failure behavior, and same-key upsert replacement.

* 🛠️ fix: fail closed on OBO MCP user identity mismatch

Add an OBO-specific guard before MCP tool execution that requires the
effective invocation user and captured request user to both have ids and
to match. This prevents OBO tool calls from falling back to a separate
configurable.user_id identity after request-bound OBO context has already
been captured.

Keep the existing user id fallback behavior for non-OBO MCP calls.

Tests cover mismatched OBO users, missing user ids, and the matching-user
path ignoring a conflicting configurable.user_id.

* 🛠️ fix: Guard OpenID bridge retry user identity

Extract the shared OpenID refresh/user-resolution flow in AuthController
so the normal refresh path and bridge-recovery retry use the same grant,
claims, issuer, user lookup, and diagnostic logging code.

Preserve the existing path-specific behavior: the normal path still owns
migration updates and 401 login redirects, while the bridge retry still
falls through to the existing 403 invalid-token response.

Add a bridge-recovery guard that rejects retry results whose resolved
user id differs from the signed openid_user_id cookie before issuing
tokens or re-storing the grace bridge. Cover both the successful
matching-user recovery and the mismatched-user rejection.

* 🛠️ fix: type-safety polish on OBO data layer

Replace refresh token bridge query/update Record<string, unknown> usage
with typed Mongoose FilterQuery and UpdateQuery definitions.

Harden OpenID marker cookie JWT expiry handling by converting refresh
expiry milliseconds to integer seconds and rejecting invalid or
non-positive durations.

Add focused CSRF tests for fractional refresh expiry values and invalid
expiry configuration.

* 🛠️ fix: Bind OpenID session tokens to authenticated identity

Stamp OpenID session token state with the LibreChat user id, OpenID subject,
tenant id, and normalized issuer when tokens are stored.

Fail closed before OBO inline token reuse/refresh when the session token
identity does not match the current authenticated identity, preventing a stale
or mixed Express session from supplying another user's upstream assertion.

Also validate the normal /api/auth/refresh session-token reuse shortcut against
the signed marker-cookie user before returning cached session tokens.

Note: sessions created before this change carry no identity stamp and are
treated as a mismatch. This is self-healing — the reuse path forces a full IdP
refresh (which re-stamps the session) and the OBO path throws, surfacing as a
one-time re-authentication for active OBO users at deploy time. The session
re-stamps within one session lifetime (SESSION_EXPIRY, default 15 min).

* 🛠️ fix: Recover OpenID refresh token drift

Prefer the browser refresh-token cookie when it differs from the
server-side OpenID session state, and force a real IdP refresh in that
case instead of reusing stale session tokens.

Store a short-lived refresh-token bridge when inline OBO refresh writes
a rotated browser cookie but session persistence fails, so follow-up
refreshes can still recover from the old token.

Keep the bridge grace TTL centralized in RefreshTokenBridge so both
recovery paths use the same env-backed value.

Note: drift is measured against the last-synced browserRefreshToken
marker, so the SSE path (intentionally stale cookie, authoritative
session) does not false-positive. Sessions predating the marker have no
browserRefreshToken; for those, drift falls back to comparing the cookie
against the session refresh token and prefers the cookie on difference.
This is the same self-healing pre-change-session window as the identity
binding fix and re-syncs within one session lifetime.

Tests cover cookie/session drift selection, reusable-session bypass on
drift, bridge storage after session-save failure, and the shared bridge
constant wiring.

* 🛠️ fix: Harden OBO token caching and expiry handling

Reject malformed OBO grant responses before writing them to the exchanged-token cache so a missing access_token cannot poison the cache.

Store absolute expires_at values with cached OBO tokens and ignore legacy cache entries without usable expiry metadata. This keeps cached-token freshness based on the token’s real
remaining lifetime instead of reusing the original relative expires_in on cache hits.

Move OBO expiry normalization and skew helpers into packages/api and use them from both the JS exchange service and the TS MCP resolver. Apply a 30-second safety margin with a one-
second floor for short-lived tokens, covered by direct helper tests and caller-level regression tests.

Tests:
- packages/api: npm run build
- packages/api: npx jest src/mcp/oauth/expiry.spec.ts src/mcp/oauth/obo.spec.ts
- api: npx jest server/services/OboTokenService.spec.js

* 🛠️ fix: Harden OBO refresh-token bridge lookup and indexing

Reuse getValidOpenIDReuseUserId for the bridge-recovery user lookup in
refreshController instead of re-verifying openid_user_id inline. The shared
helper enforces the JWT_REFRESH_SECRET presence check and a strict
typeof payload.id === 'string' guard, rejecting tokens whose id claim is
present but not a string (e.g. a numeric id) that the inline check accepted.

Fail closed on issuer mismatch in getRefreshTokenBridge. Both the stored and
the expected issuer are now normalized and compared for equality, so a bridge
is recovered only when both sides agree (both absent, or both present and
equal after normalization). Previously the check was skipped whenever the
stored issuer was absent, allowing recovery across mismatched issuer context.

Drop the unused {oldRefreshTokenHash, userId, tenantId, openidIssuer} index
and the openidIssuer field on RefreshTokenBridgeQuery. The data-layer filter
only queries the 3-field {oldRefreshTokenHash, userId, tenantId} index; the
issuer is verified in application code, not the query. Hoist the repeated
model accessor into getRefreshTokenBridgeModel.

Note: issuer is now load-bearing for recovery. A bridge stored with an issuer
recovers only when the lookup supplies a matching issuer; the recovery lookup
reads user.openidIssuer via AUTH_REFRESH_USER_PROJECTION (an exclusion
projection that retains the field). If a user's persisted openidIssuer is
empty while the stored bridge has one, recovery fails closed (falls through to
normal re-authentication) until the bridge TTLs out — no security regression.

Tests cover invalid signed-cookie payloads bypassing the bridge, both
asymmetric issuer-presence cases, issuer normalization before comparison, and
an index-alignment assertion guarding against re-adding the dropped index.

* 🛠️ fix: Degrade OBO discovery on token resolution failures

Catch expected OboTokenResolutionError failures during MCP tool discovery and
fall back to unauthenticated tool listing instead of aborting discovery. This
keeps discovery aligned with the existing unauthenticated listing behavior while
preserving unexpected errors as real failures.

Also correct OBO tool-call freshness comment and tighten the OBO trust-check
permissions type to the existing role permission shape.

Tests:
- npx jest src/mcp/__tests__/MCPConnectionFactory.test.ts --runInBand --coverage=false
- npx jest src/mcp/oauth/obo.spec.ts --runInBand --coverage=false

* 🛠️ fix: tighten OBO tool-call errors, bridge logging, and flight typing

Move resolveToolCallUserId inside the tool-call try/catch so an OBO
identity mismatch surfaces with serverName/toolName context and the
standard tool-call-failed message instead of an opaque bare Error.

Raise the refresh-token bridge lookup failure log from debug to warn so
transient infrastructure failures on the unauthenticated /api/auth/refresh
path are observable, and guard the message access against non-Error values.

Replace the unknown+cast in isDuplicateKeyError with a hasErrorCode type
predicate so the duplicate-key check reads error.code without an assertion.

Preserve real math/isEnabled in the MCPConnectionFactory test mock (mock
only processMCPEnv) so mcpConfig timeouts no longer resolve to NaN, fixing
the TimeoutNaNWarning that masked slow OAuth retry behavior.

* 🧪 fix: Restore the Flight Uniqueness Index and Buffer the Graph Cache TTL

Two CI failures on the merge, both in suites this environment cannot run
(their MongoDB binary download is blocked).

`GraphApiService.spec.js` still asserted the unbuffered TTL. Graph tokens
route through the same `getTokenCacheTtlMs` as the OBO and openidStrategy
caches, so the entry now expires 30s before the credential does.

`openidRefreshFlight.spec.ts` dropped the database between tests, which takes
the indexes with it, and Mongoose builds them only once when the model is
compiled. Whether the unique `key` index survived into a test was a race with
that one-time build. Without it a second `create` inserts instead of raising a
duplicate-key error, so every worker believes it won the flight — the
mutual exclusion the file exists to prove. Indexes are now rebuilt after each
drop, which also makes the reclaim and complete cases reach those paths for
the right reason.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SxKWxwqxAGckYpRsYTqx3F

* fix: address OBO review findings

* 🔐 fix: Install Bridge Indexes and Carry OBO Through Assistant Recovery

Two findings from the Codex pass on d08c82f0d.

The refresh-token bridge relied on Mongoose auto-indexing for both of its
indexes, and `MONGO_AUTO_INDEX=false` is a supported deployment setting. A
bridge holds an encrypted refresh token and the TTL index is the only thing
that ever deletes one, so under that setting they would accumulate for the
life of the collection while concurrent upserts lost the compound uniqueness
the filter assumes. Installed before the first write, matching the flight
methods and the session and schedule methods before them.

`recoverServerTools`, the assistant create/update path that reruns
`reinitMCPServer` when a referenced server's catalog and connection snapshot
are both missing, was the last reinit site not carrying the upstream-token
closure. For an OBO server the factory rejects the connection outright, so the
assistant write failed with unavailable MCP definitions. It now builds the
provider at that request boundary like the other entry points; assistant
writes have no `res`, so a rotation there falls back to the recovery bridge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SxKWxwqxAGckYpRsYTqx3F

* fix: harden OBO refresh coordination

*  fix: Keep Elapsed Expiries Elapsed and Revoke the Superseded Session

Two of the five findings from the Codex pass on fcdc15885 — the two that are
defects in code this branch introduced rather than design questions about the
bridge.

`getSkewedTokenExpiresAtMs` floored every result at a second in the future,
including an expiry the provider had already declared elapsed. An exchange
answering `expires_in: 0` or a past `expires_at` was handed to the MCP
connection stamped valid for another second, which only moves the failure
downstream. The floor now applies to a lifetime that is still live, which is
what it was for; an elapsed one stays elapsed so the caller rejects it. Same
for the cache TTL, which falls back to the elapsed-credential floor.

Bridge recovery left the stale token's durable Session behind. Only the token
it recovered through was passed as `existingRefreshToken`, so that one's
session was replaced while the token the browser actually presented kept its
record until its original expiry. That record, with the marker cookie still
bound to it, is what authorizes local image access for OpenID users — so a
copy of the stale cookie outlived the rotation it had lost. Revoked
explicitly on successful recovery.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SxKWxwqxAGckYpRsYTqx3F

* fix: close OBO refresh review findings

* 🎟️ fix: Carry the Bridged Token Through a Non-Rotating Recovery

Bridge recovery passes the browser's stale token as `existingRefreshToken` so
the durable Session naming it is the record replaced. That also makes the
stale token the fallback the installed session and the refresh cookie use when
a tokenset carries no `refresh_token` of its own, which holds only while the
recovery grant rotates.

An IdP that answers that grant without rotating sends the browser back to the
very token the bridge exists to retire: `storeOpenIDSession` installs and
deletes the same stale record in one call, and the cookie is rewritten to a
token the IdP already rejected — a sign-out on the next refresh. The grace
bridge one line above already guards this with `|| bridgedRefreshToken`; the
resolved tokenset now does the same, so leader and followers alike publish the
recovered token.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SxKWxwqxAGckYpRsYTqx3F

* 🚫 fix: Reject an OBO Exchange That Returns an Expired Credential

Preserving an elapsed expiry through the skew helper only helps if something
acts on it, and nothing did: `MCPManager.callTool` checks the access token and
nothing else before setting the Authorization header, so a credential the IdP
declared spent still went downstream to fail there. It is rejected at the
exchange now, where the reason is known, and retryably — the exchange itself
worked, so a fresh grant can succeed.

Completes the elapsed-expiry change in cb26a6f7d, which made the stamp honest
without giving anyone a reason to look at it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SxKWxwqxAGckYpRsYTqx3F

* fix: close OpenID refresh review findings

* fix: coordinate OpenID refresh entry points

* chore: sort OpenID flight imports

* fix: fence OpenID refreshes during logout

* fix: close OpenID logout publication races

* fix: narrow completed refresh flight

* test: cover bridge cleanup failure after ownership loss

The compensating delete in storeRefreshTokenBridgeWithLease swallows its own
failure so the lease error stays the one the caller sees. Nothing asserted
that, so removing the inner catch left every suite green while callers began
receiving the cleanup error instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SxKWxwqxAGckYpRsYTqx3F

* fix: compensate OpenID bridges only on proven ownership loss

The post-write lease assertion deletes the bridge it just published when it
throws, but it threw for two different reasons: a coordination record that is
no longer ours, and a coordination read that simply failed. Treating the second
as the first destroys the only mapping from the token the browser still holds
to the one the IdP already rotated to, so a transient Mongo error on the
headers-already-sent path signed the user out.

Tag the ownership error where the lease raises it and compensate only for that,
preserving the bridge whenever ownership is merely undetermined. A preserved
bridge stays behind the logout revocation fence, so the safe default costs
nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SxKWxwqxAGckYpRsYTqx3F

* fix: reject spent OpenID refresh results

Two ways a refresh could report success while handing back a credential
nothing can use.

An inline refresh carries the previous id_token forward when the IdP omits
one on rotation, so tokenset.id_token is not necessarily freshly issued.
setOpenIDAuthTokens applied its freshness guard only to the session copy and
took tokenset.id_token unconditionally, so /refresh returned an expired
bearer even though the grant produced a usable access token. Skip it only
when it is provably expired: an id_token whose expiry cannot be read stays
preferred, since access_token may be opaque or scoped to another audience.

normalizeExpiresIn preserves a zero or negative lifetime rather than
discarding it, so a grant declaring an already-spent access token still
published, rotating the refresh token and returning a token every freshness
check rejects. Each OBO call then repeated the grant. Reject an elapsed
lifetime before publishing; an unknown lifetime still publishes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SxKWxwqxAGckYpRsYTqx3F

* fix: harden OpenID refresh publication

* fix: keep identity and results intact through OpenID refresh cleanup

Two follow-ons from the last round's fixes.

Stripping an expired carried-forward id_token from the refresh result removed
the only identity material a rotation without id_token leaves behind. The
result is rebuilt by buildOIDCTokensFromSession, so it carries no provider
claims() either, and getTokenClaims accepts only those two — bridge recovery
failed with "no usable identity claims" before setOpenIDAuthTokens could hand
back the fresh access token. The stripped token now travels in a
non-enumerable marker, alongside the existing browser and predecessor markers,
which identity resolution reads and the authentication response never sees.

The lease drained a pending renewal by awaiting it in finally, so a transient
coordination failure there threw from finally and replaced the operation's
result. The refresh had already settled and published, so the caller saw a
failure on credentials that had rotated. Proven ownership loss is recorded on
ownershipLost and checked before the return, so the drain has nothing to add
but noise; it now absorbs and logs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SxKWxwqxAGckYpRsYTqx3F

* refactor: fence OpenID recovery publication

* fix: close OpenID publication transaction

* refactor: make OpenID publication transactional

* fix: satisfy OpenID publication type checks

* fix: fence OpenID session publication

* fix: authorize OpenID refresh publication

* fix: bind OpenID replay generations

* fix: fence OpenID response generations

* fix: authorize OpenID token delivery

* fix: linearize OpenID publication delivery

* chore: sort OpenID refresh flight imports

---------

Co-authored-by: J.C. Bartle <jcbartle@users.noreply.github.com>
Co-authored-by: jbartle <jbartle@rand.org>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: jcbartle <7274202+jcbartle@users.noreply.github.com>
2026-08-29 23:55:58 -04:00

2495 lines
98 KiB
JavaScript

jest.mock('openid-client', () => ({
refreshTokenGrant: jest.fn(),
}));
jest.mock('~/strategies/openidStrategy', () => ({
getOpenIdConfig: jest.fn(),
}));
jest.mock('@librechat/data-schemas', () => ({
logger: {
error: jest.fn(),
debug: jest.fn(),
warn: jest.fn(),
info: jest.fn(),
},
DEFAULT_REFRESH_TOKEN_EXPIRY: 1000 * 60 * 60 * 24 * 7,
}));
jest.mock('@librechat/api', () => ({
...jest.requireActual('@librechat/api'),
isEnabled: jest.fn(),
math: jest.fn((_value, fallback) => fallback),
createAuthIdentityContext: jest.fn(({ user, requestUser }) => ({
appUserId:
user?._id?.toString?.() ?? user?.id ?? requestUser?._id?.toString?.() ?? requestUser?.id,
openidSubject: user?.openidId ?? requestUser?.openidId,
tenantId: user?.tenantId ?? requestUser?.tenantId,
openidIssuer: user?.openidIssuer ?? requestUser?.openidIssuer,
})),
isOpenIDSessionIdentityMatch: jest.fn((sessionIdentity, expectedIdentity) => {
const normalize = (value) => {
if (value == null) {
return undefined;
}
const normalized = typeof value === 'string' ? value.trim() : value.toString().trim();
return normalized || undefined;
};
const normalizeIssuer = (value) => normalize(value)?.replace(/\/+$/, '');
const session = {
appUserId: normalize(sessionIdentity?.appUserId),
openidSubject: normalize(sessionIdentity?.openidSubject),
tenantId: normalize(sessionIdentity?.tenantId),
openidIssuer: normalizeIssuer(sessionIdentity?.openidIssuer),
};
const expected = {
appUserId: normalize(expectedIdentity?.appUserId),
openidSubject: normalize(expectedIdentity?.openidSubject),
tenantId: normalize(expectedIdentity?.tenantId),
openidIssuer: normalizeIssuer(expectedIdentity?.openidIssuer),
};
return (
Boolean(session.appUserId) &&
Boolean(session.openidSubject) &&
session.appUserId === expected.appUserId &&
session.openidSubject === expected.openidSubject &&
session.tenantId === expected.tenantId &&
session.openidIssuer === expected.openidIssuer
);
}),
createOpenIDRefreshIdentityTuple: jest.fn(({ user, requestUser }) => {
const subject =
user?.openidId ??
user?.id ??
user?._id?.toString?.() ??
requestUser?.openidId ??
requestUser?.id ??
requestUser?._id?.toString?.();
if (!subject) {
return null;
}
return {
subject,
tenantId: user?.tenantId ?? requestUser?.tenantId ?? 'no-tenant',
openidIssuer: user?.openidIssuer ?? requestUser?.openidIssuer ?? 'no-issuer',
};
}),
serializeAuthIdentityTuple: jest.fn(
(tuple) => `${tuple.tenantId}\x1f${tuple.openidIssuer}\x1f${tuple.subject}`,
),
createRefreshTokenBridgeIdentity: jest.fn(
({ user, requestUser, userId, tenantId, openidIssuer }) => {
const normalize = (value) => {
if (value == null) {
return undefined;
}
const normalized = typeof value === 'string' ? value.trim() : value.toString().trim();
return normalized || undefined;
};
const resolvedUserId =
normalize(userId) ??
normalize(user?._id) ??
normalize(user?.id) ??
normalize(requestUser?._id) ??
normalize(requestUser?.id);
if (!resolvedUserId) {
return null;
}
return {
userId: resolvedUserId,
tenantId: tenantId ?? user?.tenantId ?? requestUser?.tenantId,
openidIssuer: openidIssuer ?? user?.openidIssuer ?? requestUser?.openidIssuer,
};
},
),
buildOpenIDRefreshParams: jest.fn(() => ({ scope: 'openid profile' })),
setRefreshTokenCookie: jest.fn((res, refreshToken, expires) => {
res.cookie('refreshToken', refreshToken, { expires });
}),
setOpenIDMarkerCookies: jest.fn((res, { userId, expires }) => {
res.cookie('token_provider', 'openid', { expires });
if (userId) {
res.cookie('openid_user_id', `signed:${userId}`, { expires });
}
}),
normalizeExpiresIn: (value) => {
const normalized = typeof value === 'string' && value.trim() ? Number(value) : value;
return typeof normalized === 'number' && Number.isFinite(normalized) ? normalized : undefined;
},
storeOpenIdSession: jest.fn(),
}));
jest.mock('~/models', () => ({
upsertSession: jest.fn(),
deleteSession: jest.fn(),
}));
jest.mock('./RefreshTokenBridge', () => ({
OPENID_REFRESH_BRIDGE_GRACE_MS: 60 * 1000,
storeRefreshTokenBridge: jest.fn(),
deleteRefreshTokenBridges: jest.fn(),
}));
jest.mock('./OpenIDRefreshFlight', () => ({
acquireOpenIDRefreshFlight: jest.fn(),
assertOpenIDRefreshFlightAvailable: jest.fn(),
assertOpenIDRefreshSessionGenerationAvailable: jest.fn(),
completeOpenIDRefreshFlight: jest.fn(),
createOpenIDRefreshFlightKey: jest.fn(),
failOpenIDRefreshFlight: jest.fn(),
waitForOpenIDRefreshFlight: jest.fn(),
withOpenIDRefreshFlightLease: jest.fn(({ operation }) =>
operation({
assertLeaseOwned: jest.fn().mockResolvedValue(true),
markLeaseSettled: jest.fn(),
}),
),
}));
const jwt = require('jsonwebtoken');
const crypto = require('node:crypto');
const openIdClient = require('openid-client');
const {
isEnabled,
buildOpenIDRefreshParams,
setRefreshTokenCookie,
setOpenIDMarkerCookies,
storeOpenIdSession,
} = require('@librechat/api');
const { upsertSession, deleteSession } = require('~/models');
const { getOpenIdConfig } = require('~/strategies/openidStrategy');
const { deleteRefreshTokenBridges, storeRefreshTokenBridge } = require('./RefreshTokenBridge');
const {
acquireOpenIDRefreshFlight,
assertOpenIDRefreshFlightAvailable,
assertOpenIDRefreshSessionGenerationAvailable,
completeOpenIDRefreshFlight,
createOpenIDRefreshFlightKey,
failOpenIDRefreshFlight,
waitForOpenIDRefreshFlight,
withOpenIDRefreshFlightLease,
} = require('./OpenIDRefreshFlight');
const {
createOpenIDSessionTokenProvider,
refreshOpenIDSession,
__internals,
} = require('./OpenIDSessionRefresh');
const SECRET = 'test-secret';
const makeJwt = (exp) => jwt.sign({ sub: 'user-123', exp }, SECRET);
const DEFAULT_SESSION_IDENTITY = {
appUserId: 'local-id-1',
openidSubject: 'oidc-sub-123',
tenantId: 'tenant-1',
openidIssuer: 'https://issuer.example.com',
};
const withSessionIdentity = (sessionTokens) =>
sessionTokens == null ? sessionTokens : { ...DEFAULT_SESSION_IDENTITY, ...sessionTokens };
const buildReq = (sessionTokens, sessionId = 'session-A', { bindIdentity = true } = {}) => ({
sessionID: sessionId,
session: Object.assign(
{
save: jest.fn((cb) => cb(null)),
},
sessionTokens === undefined
? {}
: { openidTokens: bindIdentity ? withSessionIdentity(sessionTokens) : sessionTokens },
),
});
/** Minimal writable Express response stub for cookie-sync assertions. */
const buildRes = ({ headersSent = false } = {}) => ({
headersSent,
cookie: jest.fn(),
clearCookie: jest.fn(),
});
const makeOpenIdUser = (overrides = {}) => ({
id: 'local-id-1',
openidId: 'oidc-sub-123',
tenantId: 'tenant-1',
openidIssuer: 'https://issuer.example.com',
provider: 'openid',
...overrides,
});
const { createOpenIDRefreshOwnershipError } = jest.requireActual('@librechat/api');
const ownershipLost = (message) => createOpenIDRefreshOwnershipError(message);
describe('OpenIDSessionRefresh', () => {
beforeEach(() => {
jest.clearAllMocks();
__internals.inFlightRefreshes.clear();
isEnabled.mockReturnValue(true);
getOpenIdConfig.mockReturnValue({ issuer: 'https://issuer.example.com' });
openIdClient.refreshTokenGrant.mockReset();
createOpenIDRefreshFlightKey.mockImplementation(
({ req, refreshToken }) => refreshToken && `flight:${req?.sessionID}:${refreshToken}`,
);
acquireOpenIDRefreshFlight.mockResolvedValue({ acquired: true, ownerId: 'owner-1' });
assertOpenIDRefreshFlightAvailable.mockResolvedValue({ status: 'completed' });
assertOpenIDRefreshSessionGenerationAvailable.mockResolvedValue(true);
completeOpenIDRefreshFlight.mockResolvedValue({});
failOpenIDRefreshFlight.mockResolvedValue({});
waitForOpenIDRefreshFlight.mockResolvedValue(null);
storeRefreshTokenBridge.mockResolvedValue('bridge-version-1');
withOpenIDRefreshFlightLease.mockImplementation(({ operation }) =>
operation({
assertLeaseOwned: jest.fn().mockResolvedValue(true),
markLeaseSettled: jest.fn(),
}),
);
});
describe('createOpenIDSessionTokenProvider closure no-op cases', () => {
it('throws when tokenPreference is missing', () => {
expect(() =>
createOpenIDSessionTokenProvider({
req: buildReq({ accessToken: makeJwt(Date.now() / 1000 + 600) }),
user: makeOpenIdUser(),
}),
).toThrow(/tokenPreference/);
});
it('throws when tokenPreference is invalid', () => {
expect(() =>
createOpenIDSessionTokenProvider({
req: buildReq({ accessToken: makeJwt(Date.now() / 1000 + 600) }),
user: makeOpenIdUser(),
tokenPreference: 'bogus',
}),
).toThrow(/tokenPreference/);
});
it('returns null when OPENID_REUSE_TOKENS is disabled', async () => {
isEnabled.mockReturnValue(false);
const provider = createOpenIDSessionTokenProvider({
req: buildReq({ accessToken: makeJwt(Date.now() / 1000 + 600) }),
user: makeOpenIdUser(),
tokenPreference: 'access_token',
});
await expect(provider()).resolves.toBeNull();
expect(openIdClient.refreshTokenGrant).not.toHaveBeenCalled();
});
it('returns null when user is non-OpenID', async () => {
const provider = createOpenIDSessionTokenProvider({
req: buildReq({ accessToken: makeJwt(Date.now() / 1000 + 600) }),
user: { id: 'local-1', provider: 'local' },
tokenPreference: 'access_token',
});
await expect(provider()).resolves.toBeNull();
expect(openIdClient.refreshTokenGrant).not.toHaveBeenCalled();
});
it('returns null when user is missing entirely', async () => {
const provider = createOpenIDSessionTokenProvider({
req: buildReq({ accessToken: makeJwt(Date.now() / 1000 + 600) }),
user: undefined,
tokenPreference: 'access_token',
});
await expect(provider()).resolves.toBeNull();
});
it('rejects a stale user token snapshot when an Express session lost its OpenID tokens', async () => {
const provider = createOpenIDSessionTokenProvider({
req: buildReq(undefined),
user: makeOpenIdUser(),
tokenPreference: 'access_token',
});
await expect(provider()).rejects.toMatchObject({
code: 'OPENID_REFRESH_OWNERSHIP_LOST',
});
expect(openIdClient.refreshTokenGrant).not.toHaveBeenCalled();
});
it('allows remote OIDC bearer fallback when the request itself carries the upstream token', async () => {
const req = buildReq(undefined);
req.headers = { authorization: 'Bearer remote-access-token' };
const provider = createOpenIDSessionTokenProvider({
req,
user: makeOpenIdUser({
federatedTokens: { access_token: 'remote-access-token' },
}),
tokenPreference: 'access_token',
});
await expect(provider()).resolves.toBeNull();
expect(openIdClient.refreshTokenGrant).not.toHaveBeenCalled();
});
it('returns null when req is missing entirely', async () => {
const provider = createOpenIDSessionTokenProvider({
req: undefined,
user: makeOpenIdUser(),
tokenPreference: 'access_token',
});
await expect(provider()).resolves.toBeNull();
});
});
describe('refreshOpenIDSession live-token reuse', () => {
it('returns live tokens without calling IdP when access_token still valid past skew', async () => {
const farFutureExp = Math.floor(Date.now() / 1000) + 600;
const sessionTokens = {
accessToken: makeJwt(farFutureExp),
idToken: makeJwt(farFutureExp),
refreshToken: 'rt-1',
};
const req = buildReq(sessionTokens);
const result = await refreshOpenIDSession(req, undefined, makeOpenIdUser(), 'access_token');
expect(openIdClient.refreshTokenGrant).not.toHaveBeenCalled();
expect(result).toEqual({
access_token: sessionTokens.accessToken,
id_token: sessionTokens.idToken,
refresh_token: 'rt-1',
expires_at: farFutureExp,
});
});
it('rejects a live OBO token whose recorded publication generation was revoked', async () => {
const farFutureExp = Math.floor(Date.now() / 1000) + 600;
const sessionTokens = {
accessToken: makeJwt(farFutureExp),
idToken: makeJwt(farFutureExp),
refreshToken: 'rt-revoked',
publicationFlightKey: 'publication-key',
publicationFlightOwnerId: 'publication-owner',
};
const req = buildReq(sessionTokens);
assertOpenIDRefreshSessionGenerationAvailable.mockRejectedValueOnce(
ownershipLost('revoked by logout'),
);
await expect(
refreshOpenIDSession(req, undefined, makeOpenIdUser(), 'access_token'),
).rejects.toThrow('revoked by logout');
expect(assertOpenIDRefreshSessionGenerationAvailable).toHaveBeenCalledWith({
key: 'publication-key',
ownerId: 'publication-owner',
});
expect(openIdClient.refreshTokenGrant).not.toHaveBeenCalled();
});
it('rejects legacy session tokens without a verifiable signed marker', async () => {
const farFutureExp = Math.floor(Date.now() / 1000) + 600;
const sessionTokens = {
accessToken: makeJwt(farFutureExp),
idToken: makeJwt(farFutureExp),
refreshToken: 'rt-unbound',
};
const req = buildReq(sessionTokens, 'session-unbound', { bindIdentity: false });
await expect(
refreshOpenIDSession(req, undefined, makeOpenIdUser(), 'access_token'),
).rejects.toThrow('OpenID session token identity mismatch');
expect(openIdClient.refreshTokenGrant).not.toHaveBeenCalled();
});
it('binds a verified legacy session during rolling upgrades before token reuse', async () => {
const farFutureExp = Math.floor(Date.now() / 1000) + 600;
const refreshToken = 'rt-legacy';
const sessionTokens = {
accessToken: makeJwt(farFutureExp),
idToken: makeJwt(farFutureExp),
refreshToken,
};
const req = buildReq(sessionTokens, 'session-legacy', { bindIdentity: false });
const previousSecret = process.env.JWT_REFRESH_SECRET;
process.env.JWT_REFRESH_SECRET = SECRET;
const refreshTokenHash = crypto.createHash('sha256').update(refreshToken).digest('base64url');
const marker = jwt.sign({ id: 'local-id-1', refreshTokenHash }, SECRET);
req.headers = {
cookie: `refreshToken=${refreshToken}; openid_user_id=${marker}`,
};
try {
await expect(
refreshOpenIDSession(req, undefined, makeOpenIdUser(), 'access_token'),
).resolves.toEqual(
expect.objectContaining({
access_token: sessionTokens.accessToken,
refresh_token: refreshToken,
}),
);
} finally {
if (previousSecret == null) {
delete process.env.JWT_REFRESH_SECRET;
} else {
process.env.JWT_REFRESH_SECRET = previousSecret;
}
}
expect(req.session.openidTokens).toEqual(expect.objectContaining(DEFAULT_SESSION_IDENTITY));
expect(req.session.save).toHaveBeenCalledTimes(1);
expect(openIdClient.refreshTokenGrant).not.toHaveBeenCalled();
});
it('rejects session tokens bound to a different OpenID identity', async () => {
const farFutureExp = Math.floor(Date.now() / 1000) + 600;
const sessionTokens = {
accessToken: makeJwt(farFutureExp),
idToken: makeJwt(farFutureExp),
refreshToken: 'rt-other-user',
appUserId: 'other-user',
};
const req = buildReq(sessionTokens);
await expect(
refreshOpenIDSession(req, undefined, makeOpenIdUser(), 'access_token'),
).rejects.toThrow('OpenID session token identity mismatch');
expect(openIdClient.refreshTokenGrant).not.toHaveBeenCalled();
});
/**
* The bug fixed by Codex Finding 1a: id_token can outlive access_token.
* Old behavior would declare "live" because id_token is fresh, sending an
* expired access_token to the OBO IdP. New behavior must trigger a refresh.
*/
it('refreshes when access_token is expired even if id_token is still fresh', async () => {
const accessExp = Math.floor(Date.now() / 1000) - 30;
const idExp = Math.floor(Date.now() / 1000) + 3600;
const sessionTokens = {
accessToken: makeJwt(accessExp),
idToken: makeJwt(idExp),
refreshToken: 'rt-asym',
};
const refreshedExp = Math.floor(Date.now() / 1000) + 3600;
openIdClient.refreshTokenGrant.mockResolvedValueOnce({
access_token: makeJwt(refreshedExp),
id_token: makeJwt(refreshedExp),
refresh_token: 'rt-asym-2',
expires_in: 3600,
});
const req = buildReq(sessionTokens);
const result = await refreshOpenIDSession(req, undefined, makeOpenIdUser(), 'access_token');
expect(openIdClient.refreshTokenGrant).toHaveBeenCalledTimes(1);
expect(result.access_token).not.toBe(sessionTokens.accessToken);
});
it('falls through to refresh when access_token expires within the skew buffer', async () => {
const veryNearExp = Math.floor(Date.now() / 1000) + 10; // < 30s buffer
const sessionTokens = {
accessToken: makeJwt(veryNearExp),
idToken: makeJwt(veryNearExp),
refreshToken: 'rt-2',
};
const refreshedExp = Math.floor(Date.now() / 1000) + 3600;
openIdClient.refreshTokenGrant.mockResolvedValueOnce({
access_token: makeJwt(refreshedExp),
id_token: makeJwt(refreshedExp),
refresh_token: 'rt-3',
expires_in: 3600,
});
const req = buildReq(sessionTokens);
const result = await refreshOpenIDSession(req, undefined, makeOpenIdUser(), 'access_token');
expect(openIdClient.refreshTokenGrant).toHaveBeenCalledTimes(1);
expect(buildOpenIDRefreshParams).toHaveBeenCalled();
expect(result.refresh_token).toBe('rt-3');
expect(req.session.openidTokens.refreshToken).toBe('rt-3');
expect(req.session.save).toHaveBeenCalled();
});
});
describe('refreshOpenIDSession refresh path', () => {
it('refreshes when access_token is expired and persists session', async () => {
const expiredExp = Math.floor(Date.now() / 1000) - 60;
const sessionTokens = {
accessToken: makeJwt(expiredExp),
idToken: makeJwt(expiredExp),
refreshToken: 'rt-old',
};
const refreshedExp = Math.floor(Date.now() / 1000) + 3600;
openIdClient.refreshTokenGrant.mockResolvedValueOnce({
access_token: makeJwt(refreshedExp),
id_token: makeJwt(refreshedExp),
refresh_token: 'rt-new',
expires_in: 3600,
});
const req = buildReq(sessionTokens);
const result = await refreshOpenIDSession(req, undefined, makeOpenIdUser(), 'access_token');
expect(openIdClient.refreshTokenGrant).toHaveBeenCalledTimes(1);
expect(req.session.save).toHaveBeenCalledTimes(1);
expect(typeof result.access_token).toBe('string');
expect(req.session.openidTokens).toEqual(
expect.objectContaining({
refreshToken: 'rt-new',
lastRefreshedAt: expect.any(Number),
}),
);
});
it('preserves an expired prior id_token only as session metadata when rotation omits it', async () => {
const expiredExp = Math.floor(Date.now() / 1000) - 60;
const priorIdToken = makeJwt(expiredExp);
const sessionTokens = {
accessToken: makeJwt(expiredExp),
idToken: priorIdToken,
refreshToken: 'rt-keep',
};
const refreshedExp = Math.floor(Date.now() / 1000) + 3600;
openIdClient.refreshTokenGrant.mockResolvedValueOnce({
access_token: makeJwt(refreshedExp),
// id_token and refresh_token both omitted
expires_in: 3600,
});
const req = buildReq(sessionTokens);
const result = await refreshOpenIDSession(req, undefined, makeOpenIdUser(), 'access_token');
expect(result.id_token).toBeUndefined();
expect(result.__identityClaims).toEqual(expect.objectContaining({ sub: 'user-123' }));
expect(result.refresh_token).toBe('rt-keep');
expect(req.session.openidTokens.idToken).toBe(priorIdToken);
expect(req.session.openidTokens.refreshToken).toBe('rt-keep');
});
it.each([0, -30])('rejects an elapsed IdP access-token lifetime (%s)', async (expiresIn) => {
const expiredExp = Math.floor(Date.now() / 1000) - 60;
const req = buildReq({
accessToken: makeJwt(expiredExp),
idToken: makeJwt(expiredExp),
refreshToken: 'rt-elapsed',
});
const res = buildRes({ headersSent: false });
openIdClient.refreshTokenGrant.mockResolvedValueOnce({
access_token: 'elapsed-access-token',
refresh_token: 'rt-rotated',
expires_in: expiresIn,
});
await expect(
refreshOpenIDSession(req, res, makeOpenIdUser(), 'access_token'),
).rejects.toThrow('expired access_token');
expect(req.session.openidTokens.refreshToken).toBe('rt-elapsed');
expect(req.session.save).not.toHaveBeenCalled();
expect(setRefreshTokenCookie).not.toHaveBeenCalled();
expect(storeRefreshTokenBridge).not.toHaveBeenCalled();
});
/**
* The bug fixed by Codex Finding 1b: when IdP rotates only access_token,
* derive expires_at from the IdP's tokenset.expires_in (authoritative for
* the new access_token) rather than the prior id_token's exp claim. The
* latter would cause `isOpenIDTokenValid` to reject a fresh credential.
*/
it('uses tokenset.expires_in (not prior id_token exp) for expires_at after rotation-omits-id-token refresh', async () => {
const expiredExp = Math.floor(Date.now() / 1000) - 60;
const sessionTokens = {
accessToken: makeJwt(expiredExp),
idToken: makeJwt(expiredExp),
refreshToken: 'rt-rot',
};
// IdP omits id_token; expires_in is the only authoritative expiry source
openIdClient.refreshTokenGrant.mockResolvedValueOnce({
access_token: makeJwt(Math.floor(Date.now() / 1000) + 3600),
// id_token omitted
expires_in: 3600,
});
const req = buildReq(sessionTokens);
const beforeSec = Math.floor(Date.now() / 1000);
const result = await refreshOpenIDSession(req, undefined, makeOpenIdUser(), 'access_token');
// expires_at should be ~now + 3600, NOT the stale prior id_token exp
expect(result.expires_at).toBeGreaterThanOrEqual(beforeSec + 3590);
expect(result.expires_at).toBeLessThanOrEqual(beforeSec + 3610);
});
it('returns null when session lacks a refresh_token (cannot refresh)', async () => {
const expiredExp = Math.floor(Date.now() / 1000) - 60;
const sessionTokens = {
accessToken: makeJwt(expiredExp),
idToken: makeJwt(expiredExp),
// no refreshToken
};
const req = buildReq(sessionTokens);
const result = await refreshOpenIDSession(req, undefined, makeOpenIdUser(), 'access_token');
expect(openIdClient.refreshTokenGrant).not.toHaveBeenCalled();
expect(result).toBeNull();
});
it('rethrows when refreshTokenGrant rejects', async () => {
const expiredExp = Math.floor(Date.now() / 1000) - 60;
const sessionTokens = {
accessToken: makeJwt(expiredExp),
idToken: makeJwt(expiredExp),
refreshToken: 'rt-bad',
};
openIdClient.refreshTokenGrant.mockRejectedValueOnce(new Error('invalid_grant'));
const req = buildReq(sessionTokens);
await expect(
refreshOpenIDSession(req, undefined, makeOpenIdUser(), 'access_token'),
).rejects.toThrow('invalid_grant');
expect(req.session.save).not.toHaveBeenCalled();
});
it('rethrows when refreshTokenGrant returns no access_token', async () => {
const expiredExp = Math.floor(Date.now() / 1000) - 60;
const sessionTokens = {
accessToken: makeJwt(expiredExp),
idToken: makeJwt(expiredExp),
refreshToken: 'rt-incomplete',
};
openIdClient.refreshTokenGrant.mockResolvedValueOnce({
// access_token absent
expires_in: 3600,
});
const req = buildReq(sessionTokens);
await expect(
refreshOpenIDSession(req, undefined, makeOpenIdUser(), 'access_token'),
).rejects.toThrow(/no access_token/i);
});
});
describe('rotated refresh-token cookie sync', () => {
const buildExpiredSession = (refreshToken, browserRefreshToken) => {
const expiredExp = Math.floor(Date.now() / 1000) - 60;
const sessionTokens = {
accessToken: makeJwt(expiredExp),
idToken: makeJwt(expiredExp),
refreshToken,
};
if (browserRefreshToken) {
sessionTokens.browserRefreshToken = browserRefreshToken;
}
return sessionTokens;
};
it('writes the rotated refresh token to the cookie when res is writable', async () => {
const refreshedExp = Math.floor(Date.now() / 1000) + 3600;
openIdClient.refreshTokenGrant.mockResolvedValueOnce({
access_token: makeJwt(refreshedExp),
id_token: makeJwt(refreshedExp),
refresh_token: 'rt-rotated',
expires_in: 3600,
});
const req = buildReq(buildExpiredSession('rt-old'));
const res = buildRes({ headersSent: false });
await refreshOpenIDSession(req, res, makeOpenIdUser(), 'access_token');
expect(setRefreshTokenCookie).toHaveBeenCalledTimes(1);
expect(setRefreshTokenCookie).toHaveBeenCalledWith(res, 'rt-rotated', expect.any(Date));
expect(setOpenIDMarkerCookies).toHaveBeenCalledTimes(1);
expect(setOpenIDMarkerCookies).toHaveBeenCalledWith(res, {
userId: 'local-id-1',
expires: expect.any(Date),
refreshExpiryMs: 1000 * 60 * 60 * 24 * 7,
refreshToken: 'rt-rotated',
});
expect(storeRefreshTokenBridge).toHaveBeenCalledWith(
expect.objectContaining({
oldRefreshToken: 'rt-old',
newRefreshToken: 'rt-rotated',
ttl: 60 * 1000,
}),
);
expect(req.session.openidTokens.browserRefreshToken).toBe('rt-rotated');
});
/** The durable Session record is what authorizes local image access for OpenID users, and it is
* looked up by the refresh token in the browser's cookie — so it has to move with the cookie. */
it('moves the durable session record onto the rotated token alongside the cookie', async () => {
const refreshedExp = Math.floor(Date.now() / 1000) + 3600;
openIdClient.refreshTokenGrant.mockResolvedValueOnce({
access_token: makeJwt(refreshedExp),
id_token: makeJwt(refreshedExp),
refresh_token: 'rt-rotated',
expires_in: 3600,
});
const req = buildReq(buildExpiredSession('rt-old'));
const res = buildRes({ headersSent: false });
await refreshOpenIDSession(req, res, makeOpenIdUser(), 'access_token');
expect(storeOpenIdSession).toHaveBeenCalledWith(
{
userId: 'local-id-1',
refreshToken: 'rt-rotated',
tenantId: 'tenant-1',
previousRefreshToken: 'rt-old',
},
{ upsertSession, deleteSession },
);
expect(storeOpenIdSession.mock.invocationCallOrder[0]).toBeLessThan(
setRefreshTokenCookie.mock.invocationCallOrder[0],
);
});
/** Headers are already sent, so the browser keeps the old cookie: revoking the record it still
* presents would lock the user out of images until the next `/refresh` recovers the bridge. */
it('leaves the durable session alone when the rotation can only be bridged', async () => {
const refreshedExp = Math.floor(Date.now() / 1000) + 3600;
openIdClient.refreshTokenGrant.mockResolvedValueOnce({
access_token: makeJwt(refreshedExp),
id_token: makeJwt(refreshedExp),
refresh_token: 'rt-rotated',
expires_in: 3600,
});
const req = buildReq(buildExpiredSession('rt-old'));
const res = buildRes({ headersSent: true });
await refreshOpenIDSession(req, res, makeOpenIdUser(), 'access_token');
expect(storeRefreshTokenBridge).toHaveBeenCalled();
expect(storeOpenIdSession).not.toHaveBeenCalled();
});
it('removes a bridge published concurrently with logout revocation', async () => {
const refreshedExp = Math.floor(Date.now() / 1000) + 3600;
openIdClient.refreshTokenGrant.mockResolvedValueOnce({
access_token: makeJwt(refreshedExp),
id_token: makeJwt(refreshedExp),
refresh_token: 'rt-rotated',
expires_in: 3600,
});
const assertLeaseOwned = jest
.fn()
.mockResolvedValueOnce(true)
.mockResolvedValueOnce(true)
.mockRejectedValueOnce(ownershipLost('revoked by logout'));
withOpenIDRefreshFlightLease.mockImplementationOnce(({ operation }) =>
operation({ assertLeaseOwned, markLeaseSettled: jest.fn() }),
);
const req = buildReq(buildExpiredSession('rt-old'));
const res = buildRes({ headersSent: true });
await expect(
refreshOpenIDSession(req, res, makeOpenIdUser(), 'access_token'),
).rejects.toThrow('revoked by logout');
expect(storeRefreshTokenBridge).toHaveBeenCalledWith({
oldRefreshToken: 'rt-old',
newRefreshToken: 'rt-rotated',
userId: 'local-id-1',
tenantId: 'tenant-1',
openidIssuer: 'https://issuer.example.com',
ttl: 60 * 1000,
});
expect(deleteRefreshTokenBridges).toHaveBeenCalledWith({
refreshTokens: ['rt-old'],
userId: 'local-id-1',
tenantId: 'tenant-1',
version: 'bridge-version-1',
});
expect(deleteSession).not.toHaveBeenCalled();
expect(res.clearCookie).not.toHaveBeenCalled();
expect(req.session.openidTokens.refreshToken).toBe('rt-old');
expect(req.session.save).not.toHaveBeenCalled();
});
it('keeps the bridge when the ownership check fails for an undetermined reason', async () => {
const refreshedExp = Math.floor(Date.now() / 1000) + 3600;
openIdClient.refreshTokenGrant.mockResolvedValueOnce({
access_token: makeJwt(refreshedExp),
id_token: makeJwt(refreshedExp),
refresh_token: 'rt-rotated',
expires_in: 3600,
});
const assertLeaseOwned = jest
.fn()
.mockResolvedValueOnce(true)
.mockResolvedValueOnce(true)
.mockRejectedValueOnce(new Error('connection timed out'));
withOpenIDRefreshFlightLease.mockImplementationOnce(({ operation }) =>
operation({ assertLeaseOwned, markLeaseSettled: jest.fn() }),
);
const req = buildReq(buildExpiredSession('rt-old'));
const res = buildRes({ headersSent: true });
await expect(
refreshOpenIDSession(req, res, makeOpenIdUser(), 'access_token'),
).rejects.toThrow('connection timed out');
expect(storeRefreshTokenBridge).toHaveBeenCalled();
expect(deleteRefreshTokenBridges).not.toHaveBeenCalled();
});
it('surfaces the lease error when removing the orphaned bridge also fails', async () => {
const refreshedExp = Math.floor(Date.now() / 1000) + 3600;
openIdClient.refreshTokenGrant.mockResolvedValueOnce({
access_token: makeJwt(refreshedExp),
id_token: makeJwt(refreshedExp),
refresh_token: 'rt-rotated',
expires_in: 3600,
});
deleteRefreshTokenBridges.mockRejectedValueOnce(new Error('mongo unavailable'));
const assertLeaseOwned = jest
.fn()
.mockResolvedValueOnce(true)
.mockResolvedValueOnce(true)
.mockRejectedValueOnce(ownershipLost('revoked by logout'));
withOpenIDRefreshFlightLease.mockImplementationOnce(({ operation }) =>
operation({ assertLeaseOwned, markLeaseSettled: jest.fn() }),
);
const req = buildReq(buildExpiredSession('rt-old'));
const res = buildRes({ headersSent: true });
await expect(
refreshOpenIDSession(req, res, makeOpenIdUser(), 'access_token'),
).rejects.toThrow('revoked by logout');
expect(deleteRefreshTokenBridges).toHaveBeenCalled();
});
it('fails closed before publishing cookies when the durable session transition fails', async () => {
const refreshedExp = Math.floor(Date.now() / 1000) + 3600;
openIdClient.refreshTokenGrant.mockResolvedValueOnce({
access_token: makeJwt(refreshedExp),
id_token: makeJwt(refreshedExp),
refresh_token: 'rt-rotated',
expires_in: 3600,
});
storeOpenIdSession.mockRejectedValueOnce(new Error('mongo down'));
const req = buildReq(buildExpiredSession('rt-old'));
const res = buildRes({ headersSent: false });
await expect(
refreshOpenIDSession(req, res, makeOpenIdUser(), 'access_token'),
).rejects.toThrow('mongo down');
expect(setRefreshTokenCookie).not.toHaveBeenCalled();
expect(setOpenIDMarkerCookies).not.toHaveBeenCalled();
expect(storeRefreshTokenBridge).toHaveBeenCalledWith({
oldRefreshToken: 'rt-old',
newRefreshToken: 'rt-rotated',
userId: 'local-id-1',
tenantId: 'tenant-1',
openidIssuer: 'https://issuer.example.com',
ttl: 60 * 1000,
});
expect(req.session.openidTokens.refreshToken).toBe('rt-old');
});
it('does not begin durable publication when ownership is lost at candidate settlement', async () => {
const refreshedExp = Math.floor(Date.now() / 1000) + 3600;
openIdClient.refreshTokenGrant.mockResolvedValueOnce({
access_token: makeJwt(refreshedExp),
id_token: makeJwt(refreshedExp),
refresh_token: 'rt-rotated',
expires_in: 3600,
});
const assertLeaseOwned = jest
.fn()
.mockResolvedValueOnce(true)
.mockResolvedValueOnce(true)
.mockRejectedValueOnce(new Error('lease lost during durable transition'));
withOpenIDRefreshFlightLease.mockImplementationOnce(({ operation }) =>
operation({ assertLeaseOwned, markLeaseSettled: jest.fn() }),
);
const req = buildReq(buildExpiredSession('rt-old'));
const res = buildRes({ headersSent: false });
await expect(
refreshOpenIDSession(req, res, makeOpenIdUser(), 'access_token'),
).rejects.toThrow('lease lost');
expect(storeOpenIdSession).not.toHaveBeenCalled();
expect(storeRefreshTokenBridge).toHaveBeenCalled();
expect(setRefreshTokenCookie).not.toHaveBeenCalled();
expect(setOpenIDMarkerCookies).not.toHaveBeenCalled();
expect(req.session.openidTokens.refreshToken).toBe('rt-old');
});
it('does not write the cookie when the IdP does not rotate the refresh token', async () => {
const refreshedExp = Math.floor(Date.now() / 1000) + 3600;
openIdClient.refreshTokenGrant.mockResolvedValueOnce({
access_token: makeJwt(refreshedExp),
id_token: makeJwt(refreshedExp),
// refresh_token omitted → preserved as 'rt-stable'
expires_in: 3600,
});
const req = buildReq(buildExpiredSession('rt-stable'));
const res = buildRes({ headersSent: false });
await refreshOpenIDSession(req, res, makeOpenIdUser(), 'access_token');
expect(setRefreshTokenCookie).not.toHaveBeenCalled();
expect(setOpenIDMarkerCookies).not.toHaveBeenCalled();
});
it('rejects a grant whose declared access-token lifetime has already elapsed', async () => {
openIdClient.refreshTokenGrant.mockResolvedValueOnce({
access_token: 'opaque-and-already-spent',
id_token: makeJwt(Math.floor(Date.now() / 1000) + 3600),
refresh_token: 'rt-rotated',
expires_in: 0,
});
const req = buildReq(buildExpiredSession('rt-old'));
const res = buildRes({ headersSent: false });
await expect(
refreshOpenIDSession(req, res, makeOpenIdUser(), 'access_token'),
).rejects.toThrow('already-expired access_token');
expect(setRefreshTokenCookie).not.toHaveBeenCalled();
expect(storeRefreshTokenBridge).not.toHaveBeenCalled();
expect(storeOpenIdSession).not.toHaveBeenCalled();
expect(req.session.openidTokens.refreshToken).toBe('rt-old');
});
it('publishes a grant whose access-token lifetime is unknown', async () => {
openIdClient.refreshTokenGrant.mockResolvedValueOnce({
access_token: 'opaque-unknown-lifetime',
id_token: makeJwt(Math.floor(Date.now() / 1000) + 3600),
refresh_token: 'rt-rotated',
});
const req = buildReq(buildExpiredSession('rt-old'));
const res = buildRes({ headersSent: false });
await refreshOpenIDSession(req, res, makeOpenIdUser(), 'access_token');
expect(req.session.openidTokens.refreshToken).toBe('rt-rotated');
expect(req.session.openidTokens.accessTokenExpiresAt).toBeUndefined();
});
it('repairs a stale browser cookie when a stable refresh omits refresh_token', async () => {
const refreshedExp = Math.floor(Date.now() / 1000) + 3600;
openIdClient.refreshTokenGrant.mockResolvedValueOnce({
access_token: makeJwt(refreshedExp),
id_token: makeJwt(refreshedExp),
expires_in: 3600,
});
const req = buildReq(buildExpiredSession('rt-session-current', 'rt-browser-stale'));
const res = buildRes({ headersSent: false });
await refreshOpenIDSession(req, res, makeOpenIdUser(), 'access_token');
expect(setRefreshTokenCookie).toHaveBeenCalledWith(
res,
'rt-session-current',
expect.any(Date),
);
expect(storeRefreshTokenBridge).toHaveBeenCalledWith(
expect.objectContaining({
oldRefreshToken: 'rt-browser-stale',
newRefreshToken: 'rt-session-current',
ttl: 60 * 1000,
}),
);
expect(req.session.openidTokens.refreshToken).toBe('rt-session-current');
expect(req.session.openidTokens.browserRefreshToken).toBe('rt-session-current');
});
it('revokes the advanced session token rather than the stale browser token on rotation', async () => {
const refreshedExp = Math.floor(Date.now() / 1000) + 3600;
openIdClient.refreshTokenGrant.mockResolvedValueOnce({
access_token: makeJwt(refreshedExp),
id_token: makeJwt(refreshedExp),
refresh_token: 'rt-next',
expires_in: 3600,
});
const req = buildReq(buildExpiredSession('rt-session-current', 'rt-browser-stale'));
const res = buildRes({ headersSent: false });
await refreshOpenIDSession(req, res, makeOpenIdUser(), 'access_token');
expect(storeOpenIdSession).toHaveBeenCalledWith(
expect.objectContaining({
refreshToken: 'rt-next',
previousRefreshToken: 'rt-session-current',
}),
{ upsertSession, deleteSession },
);
});
it('stores a bridge for stale browser cookies when a stable refresh cannot write cookies', async () => {
const refreshedExp = Math.floor(Date.now() / 1000) + 3600;
openIdClient.refreshTokenGrant.mockResolvedValueOnce({
access_token: makeJwt(refreshedExp),
id_token: makeJwt(refreshedExp),
expires_in: 3600,
});
const req = buildReq(buildExpiredSession('rt-session-current', 'rt-browser-stale'));
const res = buildRes({ headersSent: true });
await refreshOpenIDSession(req, res, makeOpenIdUser(), 'access_token');
expect(setRefreshTokenCookie).not.toHaveBeenCalled();
expect(setOpenIDMarkerCookies).not.toHaveBeenCalled();
expect(storeRefreshTokenBridge).toHaveBeenCalledWith({
oldRefreshToken: 'rt-browser-stale',
newRefreshToken: 'rt-session-current',
userId: 'local-id-1',
tenantId: 'tenant-1',
openidIssuer: 'https://issuer.example.com',
});
expect(req.session.openidTokens.browserRefreshToken).toBe('rt-browser-stale');
});
it('resolves bridge identity through the shared helper when identity context is absent', async () => {
const refreshedExp = Math.floor(Date.now() / 1000) + 3600;
openIdClient.refreshTokenGrant.mockResolvedValueOnce({
access_token: makeJwt(refreshedExp),
id_token: makeJwt(refreshedExp),
refresh_token: 'rt-rotated',
expires_in: 3600,
});
const req = buildReq({
...buildExpiredSession('rt-old'),
appUserId: 'mongo-id',
});
const res = buildRes({ headersSent: true });
await refreshOpenIDSession(
req,
res,
makeOpenIdUser({
id: 'public-id',
_id: { toString: () => 'mongo-id' },
tenantId: 'tenant-1',
openidIssuer: 'https://issuer.example.com',
}),
'access_token',
);
expect(storeRefreshTokenBridge).toHaveBeenCalledWith({
oldRefreshToken: 'rt-old',
newRefreshToken: 'rt-rotated',
userId: 'mongo-id',
tenantId: 'tenant-1',
openidIssuer: 'https://issuer.example.com',
});
});
it('syncs the rotated cookie and stores a short bridge before surfacing a session save failure', async () => {
const refreshedExp = Math.floor(Date.now() / 1000) + 3600;
openIdClient.refreshTokenGrant.mockResolvedValueOnce({
access_token: makeJwt(refreshedExp),
id_token: makeJwt(refreshedExp),
refresh_token: 'rt-rotated',
expires_in: 3600,
});
const req = buildReq(buildExpiredSession('rt-old'));
const res = buildRes({ headersSent: false });
req.session.save.mockImplementationOnce((cb) => cb(new Error('session store down')));
await expect(
refreshOpenIDSession(req, res, makeOpenIdUser(), 'access_token'),
).rejects.toThrow('session store down');
expect(setRefreshTokenCookie).toHaveBeenCalledWith(res, 'rt-rotated', expect.any(Date));
expect(setOpenIDMarkerCookies).toHaveBeenCalledTimes(1);
expect(storeRefreshTokenBridge).toHaveBeenCalledWith({
oldRefreshToken: 'rt-old',
newRefreshToken: 'rt-rotated',
userId: 'local-id-1',
tenantId: 'tenant-1',
openidIssuer: 'https://issuer.example.com',
ttl: 60 * 1000,
});
expect(req.session.openidTokens.refreshToken).toBe('rt-rotated');
expect(req.session.openidTokens.browserRefreshToken).toBe('rt-rotated');
});
it('stores a recovery bridge when response headers are already sent (streaming path)', async () => {
const refreshedExp = Math.floor(Date.now() / 1000) + 3600;
openIdClient.refreshTokenGrant.mockResolvedValueOnce({
access_token: makeJwt(refreshedExp),
id_token: makeJwt(refreshedExp),
refresh_token: 'rt-rotated',
expires_in: 3600,
});
const req = buildReq(buildExpiredSession('rt-old'));
const res = buildRes({ headersSent: true });
const user = makeOpenIdUser({
tenantId: 'tenant-1',
openidIssuer: 'https://issuer.example.com',
});
await refreshOpenIDSession(req, res, user, 'access_token');
expect(setRefreshTokenCookie).not.toHaveBeenCalled();
expect(setOpenIDMarkerCookies).not.toHaveBeenCalled();
expect(storeRefreshTokenBridge).toHaveBeenCalledWith({
oldRefreshToken: 'rt-old',
newRefreshToken: 'rt-rotated',
userId: 'local-id-1',
tenantId: 'tenant-1',
openidIssuer: 'https://issuer.example.com',
});
/** Session copy remains authoritative even when the cookie can't be set. */
expect(req.session.openidTokens.refreshToken).toBe('rt-rotated');
expect(req.session.openidTokens.browserRefreshToken).toBe('rt-old');
});
it('keeps bridging from the stale browser cookie across repeated rotations', async () => {
const refreshedExp = Math.floor(Date.now() / 1000) + 3600;
openIdClient.refreshTokenGrant.mockResolvedValueOnce({
access_token: makeJwt(refreshedExp),
id_token: makeJwt(refreshedExp),
refresh_token: 'rt-second-rotation',
expires_in: 3600,
});
const req = buildReq(buildExpiredSession('rt-first-rotation', 'rt-browser-cookie'));
const res = buildRes({ headersSent: true });
await refreshOpenIDSession(req, res, makeOpenIdUser(), 'access_token');
expect(storeRefreshTokenBridge).toHaveBeenCalledWith({
oldRefreshToken: 'rt-browser-cookie',
newRefreshToken: 'rt-second-rotation',
userId: 'local-id-1',
tenantId: 'tenant-1',
openidIssuer: 'https://issuer.example.com',
});
expect(req.session.openidTokens.refreshToken).toBe('rt-second-rotation');
expect(req.session.openidTokens.browserRefreshToken).toBe('rt-browser-cookie');
});
it('stores a recovery bridge when no res is provided', async () => {
const refreshedExp = Math.floor(Date.now() / 1000) + 3600;
openIdClient.refreshTokenGrant.mockResolvedValueOnce({
access_token: makeJwt(refreshedExp),
id_token: makeJwt(refreshedExp),
refresh_token: 'rt-rotated',
expires_in: 3600,
});
const req = buildReq(buildExpiredSession('rt-old'));
await expect(
refreshOpenIDSession(req, undefined, makeOpenIdUser(), 'access_token'),
).resolves.toBeDefined();
expect(setRefreshTokenCookie).not.toHaveBeenCalled();
expect(setOpenIDMarkerCookies).not.toHaveBeenCalled();
expect(storeRefreshTokenBridge).toHaveBeenCalledWith({
oldRefreshToken: 'rt-old',
newRefreshToken: 'rt-rotated',
userId: 'local-id-1',
tenantId: 'tenant-1',
openidIssuer: 'https://issuer.example.com',
});
});
it('stores a recovery bridge when res cannot write cookies', async () => {
const refreshedExp = Math.floor(Date.now() / 1000) + 3600;
openIdClient.refreshTokenGrant.mockResolvedValueOnce({
access_token: makeJwt(refreshedExp),
id_token: makeJwt(refreshedExp),
refresh_token: 'rt-rotated',
expires_in: 3600,
});
const req = buildReq(buildExpiredSession('rt-old'));
await refreshOpenIDSession(req, { headersSent: false }, makeOpenIdUser(), 'access_token');
expect(setRefreshTokenCookie).not.toHaveBeenCalled();
expect(setOpenIDMarkerCookies).not.toHaveBeenCalled();
expect(storeRefreshTokenBridge).toHaveBeenCalledWith({
oldRefreshToken: 'rt-old',
newRefreshToken: 'rt-rotated',
userId: 'local-id-1',
tenantId: 'tenant-1',
openidIssuer: 'https://issuer.example.com',
});
});
it('fails closed without mutating the session when bridge storage fails', async () => {
const refreshedExp = Math.floor(Date.now() / 1000) + 3600;
openIdClient.refreshTokenGrant.mockResolvedValueOnce({
access_token: makeJwt(refreshedExp),
id_token: makeJwt(refreshedExp),
refresh_token: 'rt-rotated',
expires_in: 3600,
});
storeRefreshTokenBridge.mockRejectedValueOnce(new Error('encrypt failed'));
const req = buildReq(buildExpiredSession('rt-old'));
const res = buildRes({ headersSent: true });
await expect(
refreshOpenIDSession(req, res, makeOpenIdUser(), 'access_token'),
).rejects.toThrow('encrypt failed');
expect(req.session.openidTokens.refreshToken).toBe('rt-old');
});
});
describe('single-flight coalescing', () => {
it('scopes the local refresh key by explicit identity context', () => {
const req = buildReq({ refreshToken: 'rt-shared' }, 'session-shared');
const user = makeOpenIdUser({ tenantId: undefined, openidIssuer: undefined });
const keyA = __internals.getSingleFlightKey(req, user, {
openidSubject: 'oidc-sub-123',
tenantId: 'tenant-a',
openidIssuer: 'https://issuer-a.example.com',
});
const keyB = __internals.getSingleFlightKey(req, user, {
openidSubject: 'oidc-sub-123',
tenantId: 'tenant-b',
openidIssuer: 'https://issuer-a.example.com',
});
const keyC = __internals.getSingleFlightKey(req, user, {
openidSubject: 'oidc-sub-123',
tenantId: 'tenant-a',
openidIssuer: 'https://issuer-b.example.com',
});
expect(keyA).not.toBe(keyB);
expect(keyA).not.toBe(keyC);
expect(keyA).not.toContain('rt-shared');
});
it('shares one refreshTokenGrant across new Express sessions with the same token', async () => {
const expiredExp = Math.floor(Date.now() / 1000) - 60;
const sessionTokens = {
accessToken: makeJwt(expiredExp),
idToken: makeJwt(expiredExp),
refreshToken: 'rt-shared',
};
let resolveGrant;
const grantPromise = new Promise((resolve) => {
resolveGrant = resolve;
});
openIdClient.refreshTokenGrant.mockReturnValueOnce(grantPromise);
const reqA = buildReq(sessionTokens, 'session-A');
const reqB = buildReq(sessionTokens, 'session-B');
const user = makeOpenIdUser();
const p1 = refreshOpenIDSession(reqA, undefined, user, 'access_token');
const p2 = refreshOpenIDSession(reqB, undefined, user, 'access_token');
await Promise.resolve();
// Both calls land before the IdP responds
expect(openIdClient.refreshTokenGrant).toHaveBeenCalledTimes(1);
const refreshedExp = Math.floor(Date.now() / 1000) + 3600;
resolveGrant({
access_token: makeJwt(refreshedExp),
id_token: makeJwt(refreshedExp),
refresh_token: 'rt-rotated',
expires_in: 3600,
});
const [r1, r2] = await Promise.all([p1, p2]);
expect(r1).toStrictEqual(r2);
expect(__internals.inFlightRefreshes.size).toBe(0);
});
it('does not share an in-flight refresh across distinct refresh tokens', async () => {
const expiredExp = Math.floor(Date.now() / 1000) - 60;
const reqA = buildReq(
{
accessToken: makeJwt(expiredExp),
idToken: makeJwt(expiredExp),
refreshToken: 'rt-A',
},
'session-A',
);
const reqB = buildReq(
{
accessToken: makeJwt(expiredExp),
idToken: makeJwt(expiredExp),
refreshToken: 'rt-B',
},
'session-B',
);
let resolveA;
let resolveB;
const promiseA = new Promise((resolve) => {
resolveA = resolve;
});
const promiseB = new Promise((resolve) => {
resolveB = resolve;
});
openIdClient.refreshTokenGrant.mockReturnValueOnce(promiseA).mockReturnValueOnce(promiseB);
const user = makeOpenIdUser();
const pA = refreshOpenIDSession(reqA, undefined, user, 'access_token');
const pB = refreshOpenIDSession(reqB, undefined, user, 'access_token');
await Promise.resolve();
// Two refreshes started, one per session
expect(openIdClient.refreshTokenGrant).toHaveBeenCalledTimes(2);
const refreshedExp = Math.floor(Date.now() / 1000) + 3600;
resolveA({
access_token: makeJwt(refreshedExp),
id_token: makeJwt(refreshedExp),
refresh_token: 'rt-A-rotated',
expires_in: 3600,
});
resolveB({
access_token: makeJwt(refreshedExp),
id_token: makeJwt(refreshedExp),
refresh_token: 'rt-B-rotated',
expires_in: 3600,
});
const [rA, rB] = await Promise.all([pA, pB]);
expect(rA).not.toBe(rB);
expect(reqA.session.openidTokens.refreshToken).toBe('rt-A-rotated');
expect(reqB.session.openidTokens.refreshToken).toBe('rt-B-rotated');
});
it('does NOT share an in-flight refresh in the same session when refresh tokens differ', async () => {
const expiredExp = Math.floor(Date.now() / 1000) - 60;
const reqOld = buildReq(
{
accessToken: makeJwt(expiredExp),
idToken: makeJwt(expiredExp),
refreshToken: 'rt-old',
},
'session-rotated',
);
const reqCurrent = buildReq(
{
accessToken: makeJwt(expiredExp),
idToken: makeJwt(expiredExp),
refreshToken: 'rt-current',
},
'session-rotated',
);
let resolveOld;
let resolveCurrent;
const oldPromise = new Promise((resolve) => {
resolveOld = resolve;
});
const currentPromise = new Promise((resolve) => {
resolveCurrent = resolve;
});
openIdClient.refreshTokenGrant
.mockReturnValueOnce(oldPromise)
.mockReturnValueOnce(currentPromise);
const user = makeOpenIdUser();
const oldRefresh = refreshOpenIDSession(reqOld, undefined, user, 'access_token');
const currentRefresh = refreshOpenIDSession(reqCurrent, undefined, user, 'access_token');
await Promise.resolve();
expect(openIdClient.refreshTokenGrant).toHaveBeenCalledTimes(2);
const refreshedExp = Math.floor(Date.now() / 1000) + 3600;
resolveOld({
access_token: makeJwt(refreshedExp),
id_token: makeJwt(refreshedExp),
refresh_token: 'rt-old-rotated',
expires_in: 3600,
});
resolveCurrent({
access_token: makeJwt(refreshedExp),
id_token: makeJwt(refreshedExp),
refresh_token: 'rt-current-rotated',
expires_in: 3600,
});
const [oldResult, currentResult] = await Promise.all([oldRefresh, currentRefresh]);
expect(oldResult).not.toBe(currentResult);
expect(reqOld.session.openidTokens.refreshToken).toBe('rt-old-rotated');
expect(reqCurrent.session.openidTokens.refreshToken).toBe('rt-current-rotated');
});
it('clears in-flight slot on rejection so subsequent attempts can retry', async () => {
const expiredExp = Math.floor(Date.now() / 1000) - 60;
const sessionTokens = {
accessToken: makeJwt(expiredExp),
idToken: makeJwt(expiredExp),
refreshToken: 'rt-flaky',
};
openIdClient.refreshTokenGrant.mockRejectedValueOnce(new Error('transient'));
const req = buildReq(sessionTokens);
const user = makeOpenIdUser();
await expect(refreshOpenIDSession(req, undefined, user, 'access_token')).rejects.toThrow(
'transient',
);
expect(__internals.inFlightRefreshes.size).toBe(0);
// Second attempt: succeed
const refreshedExp = Math.floor(Date.now() / 1000) + 3600;
openIdClient.refreshTokenGrant.mockResolvedValueOnce({
access_token: makeJwt(refreshedExp),
id_token: makeJwt(refreshedExp),
refresh_token: 'rt-recovered',
expires_in: 3600,
});
const result = await refreshOpenIDSession(req, undefined, user, 'access_token');
expect(result.refresh_token).toBe('rt-recovered');
});
it('hydrates a joining request that shares the session id but carries a distinct req', async () => {
const expiredExp = Math.floor(Date.now() / 1000) - 60;
const makeExpiredSession = (refreshToken) => ({
accessToken: makeJwt(expiredExp),
idToken: makeJwt(expiredExp),
refreshToken,
});
/** Two concurrent HTTP requests from the same browser session. */
const leaderReq = buildReq(makeExpiredSession('rt-stale'), 'session-joined');
const joinerReq = buildReq(makeExpiredSession('rt-stale'), 'session-joined');
const user = makeOpenIdUser();
let resolveGrant;
const grantPromise = new Promise((resolve) => {
resolveGrant = resolve;
});
openIdClient.refreshTokenGrant.mockReturnValueOnce(grantPromise);
const leaderPromise = refreshOpenIDSession(leaderReq, undefined, user, 'access_token');
const joinerPromise = refreshOpenIDSession(joinerReq, undefined, user, 'access_token');
await Promise.resolve();
// Only the leader hit the IdP; the joiner coalesced onto it.
expect(openIdClient.refreshTokenGrant).toHaveBeenCalledTimes(1);
const refreshedExp = Math.floor(Date.now() / 1000) + 3600;
resolveGrant({
access_token: makeJwt(refreshedExp),
id_token: makeJwt(refreshedExp),
refresh_token: 'rt-rotated',
expires_in: 3600,
});
const [leaderTokens, joinerTokens] = await Promise.all([leaderPromise, joinerPromise]);
expect(leaderTokens.refresh_token).toBe('rt-rotated');
expect(joinerTokens.refresh_token).toBe('rt-rotated');
// The joiner's OWN session is hydrated so a later OBO call won't replay rt-stale.
expect(joinerReq.session.openidTokens.refreshToken).toBe('rt-rotated');
expect(joinerReq.session.openidTokens.browserRefreshToken).toBe('rt-stale');
expect(joinerReq.session.save).toHaveBeenCalled();
});
it('checks revocation before an already-current local joiner returns', async () => {
const expiredExp = Math.floor(Date.now() / 1000) - 60;
const req = buildReq({
accessToken: makeJwt(expiredExp),
idToken: makeJwt(expiredExp),
refreshToken: 'rt-shared-request',
});
let resolveGrant;
openIdClient.refreshTokenGrant.mockReturnValueOnce(
new Promise((resolve) => {
resolveGrant = resolve;
}),
);
const leaderPromise = refreshOpenIDSession(req, buildRes(), makeOpenIdUser(), 'access_token');
const joinerPromise = refreshOpenIDSession(req, buildRes(), makeOpenIdUser(), 'access_token');
await Promise.resolve();
resolveGrant({
access_token: makeJwt(Math.floor(Date.now() / 1000) + 3600),
id_token: makeJwt(Math.floor(Date.now() / 1000) + 3600),
refresh_token: 'rt-shared-successor',
expires_in: 3600,
});
assertOpenIDRefreshFlightAvailable.mockRejectedValueOnce(ownershipLost('revoked by logout'));
await expect(leaderPromise).resolves.toBeDefined();
await expect(joinerPromise).rejects.toThrow('revoked by logout');
expect(assertOpenIDRefreshFlightAvailable).toHaveBeenCalledWith({
key: 'flight:session-A:rt-shared-request',
ownerId: 'owner-1',
});
});
it('keeps both local coalescing participants unpublished when publication is deferred', async () => {
const expiredExp = Math.floor(Date.now() / 1000) - 60;
const makeExpiredSession = () => ({
accessToken: makeJwt(expiredExp),
idToken: makeJwt(expiredExp),
refreshToken: 'rt-deferred',
browserRefreshToken: 'rt-deferred',
});
const leaderReq = buildReq(makeExpiredSession(), 'session-deferred-leader');
const joinerReq = buildReq(makeExpiredSession(), 'session-deferred-joiner');
const user = makeOpenIdUser();
let resolveGrant;
openIdClient.refreshTokenGrant.mockReturnValueOnce(
new Promise((resolve) => {
resolveGrant = resolve;
}),
);
const options = { forceRefresh: true, deferPublication: true };
const leaderPromise = refreshOpenIDSession(
leaderReq,
undefined,
user,
'access_token',
undefined,
options,
);
const joinerPromise = refreshOpenIDSession(
joinerReq,
undefined,
user,
'access_token',
undefined,
options,
);
await Promise.resolve();
resolveGrant({
access_token: makeJwt(Math.floor(Date.now() / 1000) + 3600),
id_token: makeJwt(Math.floor(Date.now() / 1000) + 3600),
refresh_token: 'rt-deferred-rotated',
expires_in: 3600,
});
const [leaderTokens, joinerTokens] = await Promise.all([leaderPromise, joinerPromise]);
expect(leaderTokens.refresh_token).toBe('rt-deferred-rotated');
expect(joinerTokens.refresh_token).toBe('rt-deferred-rotated');
expect(leaderReq.session.openidTokens.refreshToken).toBe('rt-deferred');
expect(joinerReq.session.openidTokens.refreshToken).toBe('rt-deferred');
expect(leaderReq.session.save).not.toHaveBeenCalled();
expect(joinerReq.session.save).not.toHaveBeenCalled();
expect(storeOpenIdSession).not.toHaveBeenCalled();
expect(setRefreshTokenCookie).not.toHaveBeenCalled();
});
it('hydrates a joining request with the rotated browser marker when the leader wrote cookies', async () => {
const expiredExp = Math.floor(Date.now() / 1000) - 60;
const makeExpiredSession = () => ({
accessToken: makeJwt(expiredExp),
idToken: makeJwt(expiredExp),
refreshToken: 'rt-stale',
browserRefreshToken: 'rt-stale',
});
const leaderReq = buildReq(makeExpiredSession(), 'session-cookie-joined');
const joinerReq = buildReq(makeExpiredSession(), 'session-cookie-joined');
const leaderRes = buildRes({ headersSent: false });
const joinerRes = buildRes({ headersSent: false });
const user = makeOpenIdUser();
let resolveGrant;
const grantPromise = new Promise((resolve) => {
resolveGrant = resolve;
});
openIdClient.refreshTokenGrant.mockReturnValueOnce(grantPromise);
const leaderPromise = refreshOpenIDSession(leaderReq, leaderRes, user, 'access_token');
const joinerPromise = refreshOpenIDSession(joinerReq, joinerRes, user, 'access_token');
await Promise.resolve();
expect(openIdClient.refreshTokenGrant).toHaveBeenCalledTimes(1);
const refreshedExp = Math.floor(Date.now() / 1000) + 3600;
resolveGrant({
access_token: makeJwt(refreshedExp),
id_token: makeJwt(refreshedExp),
refresh_token: 'rt-rotated',
expires_in: 3600,
});
const [leaderTokens, joinerTokens] = await Promise.all([leaderPromise, joinerPromise]);
expect(leaderTokens.refresh_token).toBe('rt-rotated');
expect(joinerTokens.refresh_token).toBe('rt-rotated');
expect(setRefreshTokenCookie).toHaveBeenCalledWith(leaderRes, 'rt-rotated', expect.any(Date));
expect(setRefreshTokenCookie).toHaveBeenCalledWith(joinerRes, 'rt-rotated', expect.any(Date));
expect(leaderReq.session.openidTokens.browserRefreshToken).toBe('rt-rotated');
expect(leaderReq.session.openidTokens).toEqual(
expect.objectContaining({
publicationFlightKey: 'flight:session-cookie-joined:rt-stale',
publicationFlightOwnerId: 'owner-1',
}),
);
expect(joinerReq.session.openidTokens.refreshToken).toBe('rt-rotated');
expect(joinerReq.session.openidTokens.browserRefreshToken).toBe('rt-rotated');
expect(Object.keys(joinerTokens)).not.toContain('__browserRefreshToken');
expect(joinerReq.session.save).toHaveBeenCalled();
});
it('hydrates a joining request when the refresh token stays stable', async () => {
const expiredExp = Math.floor(Date.now() / 1000) - 60;
const makeExpiredSession = () => ({
accessToken: makeJwt(expiredExp),
idToken: makeJwt(expiredExp),
refreshToken: 'rt-stable',
accessTokenExpiresAt: expiredExp,
});
const leaderReq = buildReq(makeExpiredSession(), 'session-stable-joined');
const joinerReq = buildReq(makeExpiredSession(), 'session-stable-joined');
const user = makeOpenIdUser();
let resolveGrant;
const grantPromise = new Promise((resolve) => {
resolveGrant = resolve;
});
openIdClient.refreshTokenGrant.mockReturnValueOnce(grantPromise);
const leaderPromise = refreshOpenIDSession(leaderReq, undefined, user, 'access_token');
const joinerPromise = refreshOpenIDSession(joinerReq, undefined, user, 'access_token');
await Promise.resolve();
expect(openIdClient.refreshTokenGrant).toHaveBeenCalledTimes(1);
const refreshedExp = Math.floor(Date.now() / 1000) + 3600;
const refreshedAccessToken = makeJwt(refreshedExp);
const refreshedIdToken = makeJwt(refreshedExp);
resolveGrant({
access_token: refreshedAccessToken,
id_token: refreshedIdToken,
refresh_token: 'rt-stable',
expires_in: 3600,
});
const [leaderTokens, joinerTokens] = await Promise.all([leaderPromise, joinerPromise]);
expect(leaderTokens.refresh_token).toBe('rt-stable');
expect(joinerTokens.refresh_token).toBe('rt-stable');
expect(joinerReq.session.openidTokens.accessToken).toBe(refreshedAccessToken);
expect(joinerReq.session.openidTokens.idToken).toBe(refreshedIdToken);
expect(joinerReq.session.openidTokens.refreshToken).toBe('rt-stable');
expect(joinerReq.session.openidTokens.accessTokenExpiresAt).toBe(joinerTokens.expires_at);
expect(joinerReq.session.openidTokens.accessTokenExpiresAt).toBeGreaterThan(expiredExp);
expect(joinerReq.session.save).toHaveBeenCalled();
});
it('joins a shared Mongo refresh flight when the local process has no in-flight entry', async () => {
const expiredExp = Math.floor(Date.now() / 1000) - 60;
const makeExpiredSession = () => ({
accessToken: makeJwt(expiredExp),
idToken: makeJwt(expiredExp),
refreshToken: 'rt-cross-worker',
});
const leaderReq = buildReq(makeExpiredSession(), 'session-cross-worker');
const joinerReq = buildReq(makeExpiredSession(), 'session-cross-worker');
const user = makeOpenIdUser();
let resolveGrant;
const grantPromise = new Promise((resolve) => {
resolveGrant = resolve;
});
openIdClient.refreshTokenGrant.mockReturnValueOnce(grantPromise);
acquireOpenIDRefreshFlight
.mockResolvedValueOnce({ acquired: true, ownerId: 'owner-leader' })
.mockResolvedValueOnce({ acquired: false, ownerId: 'owner-joiner' });
const refreshedExp = Math.floor(Date.now() / 1000) + 3600;
const sharedTokens = {
access_token: makeJwt(refreshedExp),
id_token: makeJwt(refreshedExp),
refresh_token: 'rt-cross-worker-rotated',
expires_at: refreshedExp,
};
Object.defineProperty(sharedTokens, '__flightOwnerId', {
value: 'owner-leader',
enumerable: false,
});
waitForOpenIDRefreshFlight.mockResolvedValueOnce(sharedTokens);
const leaderPromise = refreshOpenIDSession(leaderReq, undefined, user, 'access_token');
await Promise.resolve();
expect(openIdClient.refreshTokenGrant).toHaveBeenCalledTimes(1);
/**
* Simulate a second worker: it does not see this process-local Map, but
* it does see the Mongo flight for the same browser session/token.
*/
__internals.inFlightRefreshes.clear();
const joinerTokens = await refreshOpenIDSession(joinerReq, undefined, user, 'access_token');
expect(waitForOpenIDRefreshFlight).toHaveBeenCalledWith({
key: 'flight:session-cross-worker:rt-cross-worker',
});
expect(openIdClient.refreshTokenGrant).toHaveBeenCalledTimes(1);
expect(joinerTokens).toStrictEqual(sharedTokens);
expect(joinerReq.session.openidTokens.refreshToken).toBe('rt-cross-worker-rotated');
expect(joinerReq.session.openidTokens).toEqual(
expect.objectContaining({
publicationFlightKey: 'flight:session-cross-worker:rt-cross-worker',
publicationFlightOwnerId: 'owner-leader',
}),
);
expect(joinerReq.session.save).toHaveBeenCalled();
resolveGrant({
access_token: sharedTokens.access_token,
id_token: sharedTokens.id_token,
refresh_token: sharedTokens.refresh_token,
expires_in: 3600,
});
await expect(leaderPromise).resolves.toEqual(
expect.objectContaining({
access_token: sharedTokens.access_token,
id_token: sharedTokens.id_token,
refresh_token: sharedTokens.refresh_token,
expires_at: expect.any(Number),
}),
);
expect(completeOpenIDRefreshFlight).toHaveBeenCalledWith({
key: 'flight:session-cross-worker:rt-cross-worker',
ownerId: 'owner-leader',
tokens: expect.objectContaining({
access_token: sharedTokens.access_token,
id_token: sharedTokens.id_token,
refresh_token: sharedTokens.refresh_token,
expires_at: expect.any(Number),
}),
});
expect(withOpenIDRefreshFlightLease).toHaveBeenCalledWith({
key: 'flight:session-cross-worker:rt-cross-worker',
ownerId: 'owner-leader',
operation: expect.any(Function),
});
});
it('rolls back a follower replay when logout revokes the completed flight', async () => {
const expiredExp = Math.floor(Date.now() / 1000) - 60;
const predecessorAccessToken = makeJwt(expiredExp);
const req = buildReq({
accessToken: predecessorAccessToken,
idToken: makeJwt(expiredExp),
refreshToken: 'rt-predecessor',
browserRefreshToken: 'rt-predecessor',
});
const res = buildRes({ headersSent: false });
req.session.destroy = jest.fn((callback) => {
delete req.session.openidTokens;
callback();
});
acquireOpenIDRefreshFlight.mockResolvedValueOnce({ acquired: false, ownerId: 'follower' });
waitForOpenIDRefreshFlight.mockResolvedValueOnce({
access_token: makeJwt(Math.floor(Date.now() / 1000) + 3600),
id_token: makeJwt(Math.floor(Date.now() / 1000) + 3600),
refresh_token: 'rt-successor',
expires_at: Math.floor(Date.now() / 1000) + 3600,
__predecessorRefreshToken: 'rt-predecessor',
__predecessorAccessToken: predecessorAccessToken,
__flightOwnerId: 'generation-owner',
});
assertOpenIDRefreshFlightAvailable
.mockResolvedValueOnce({ status: 'completed' })
.mockResolvedValueOnce({ status: 'completed' })
.mockResolvedValueOnce({ status: 'completed' })
.mockResolvedValueOnce({ status: 'completed' })
.mockResolvedValueOnce({ status: 'completed' })
.mockRejectedValueOnce(ownershipLost('revoked by logout'));
await expect(
refreshOpenIDSession(req, res, makeOpenIdUser(), 'access_token'),
).rejects.toThrow('revoked by logout');
expect(storeOpenIdSession).toHaveBeenCalled();
expect(setRefreshTokenCookie).toHaveBeenCalled();
expect(req.session.save).toHaveBeenCalled();
expect(deleteSession).toHaveBeenCalledWith({ refreshToken: 'rt-successor' });
expect(req.session.destroy).toHaveBeenCalled();
expect(res.clearCookie).toHaveBeenCalledWith('refreshToken');
expect(assertOpenIDRefreshFlightAvailable).toHaveBeenCalledWith({
key: 'flight:session-A:rt-predecessor',
ownerId: 'generation-owner',
});
});
it('does not replay a stable-token flight over a newer access token', async () => {
const expiredExp = Math.floor(Date.now() / 1000) - 60;
const predecessorAccessToken = makeJwt(expiredExp);
const advancedAccessToken = makeJwt(Math.floor(Date.now() / 1000) + 7200);
const req = buildReq({
accessToken: predecessorAccessToken,
idToken: makeJwt(expiredExp),
refreshToken: 'rt-stable',
});
req.session.reload = jest.fn((callback) => {
req.session.openidTokens = {
...req.session.openidTokens,
accessToken: advancedAccessToken,
refreshToken: 'rt-stable',
};
callback();
});
acquireOpenIDRefreshFlight.mockResolvedValueOnce({ acquired: false, ownerId: 'follower' });
waitForOpenIDRefreshFlight.mockResolvedValueOnce({
access_token: makeJwt(Math.floor(Date.now() / 1000) + 3600),
id_token: makeJwt(Math.floor(Date.now() / 1000) + 3600),
refresh_token: 'rt-stable',
expires_at: Math.floor(Date.now() / 1000) + 3600,
__predecessorRefreshToken: 'rt-stable',
__predecessorAccessToken: predecessorAccessToken,
__flightOwnerId: 'stable-generation-owner',
});
const result = await refreshOpenIDSession(req, buildRes(), makeOpenIdUser(), 'access_token');
expect(req.session.openidTokens.accessToken).toBe(advancedAccessToken);
expect(result.access_token).toBe(advancedAccessToken);
expect(result.__predecessorAccessToken).toBe(predecessorAccessToken);
expect(storeOpenIdSession).not.toHaveBeenCalled();
expect(setRefreshTokenCookie).not.toHaveBeenCalled();
expect(req.session.save).not.toHaveBeenCalled();
});
it('does not publish a deferred local leader result through an immediate joiner', async () => {
const expiredExp = Math.floor(Date.now() / 1000) - 60;
const makeExpiredSession = () => ({
accessToken: makeJwt(expiredExp),
idToken: makeJwt(expiredExp),
refreshToken: 'rt-mixed-mode',
});
const leaderReq = buildReq(makeExpiredSession(), 'session-mixed-mode');
const joinerReq = buildReq(makeExpiredSession(), 'session-mixed-mode');
let resolveGrant;
openIdClient.refreshTokenGrant.mockReturnValueOnce(
new Promise((resolve) => {
resolveGrant = resolve;
}),
);
const leaderPromise = refreshOpenIDSession(
leaderReq,
undefined,
makeOpenIdUser(),
'access_token',
undefined,
{ forceRefresh: true, deferPublication: true },
);
const joinerPromise = refreshOpenIDSession(
joinerReq,
buildRes(),
makeOpenIdUser(),
'access_token',
);
await Promise.resolve();
resolveGrant({
access_token: makeJwt(Math.floor(Date.now() / 1000) + 3600),
id_token: makeJwt(Math.floor(Date.now() / 1000) + 3600),
refresh_token: 'rt-mixed-successor',
expires_in: 3600,
});
await expect(leaderPromise).resolves.toBeDefined();
await expect(joinerPromise).rejects.toThrow('awaiting identity validation');
expect(joinerReq.session.save).not.toHaveBeenCalled();
expect(storeOpenIdSession).not.toHaveBeenCalled();
expect(setRefreshTokenCookie).not.toHaveBeenCalled();
});
it('does not publish a deferred cross-replica result through an immediate follower', async () => {
const expiredExp = Math.floor(Date.now() / 1000) - 60;
const req = buildReq({
accessToken: makeJwt(expiredExp),
idToken: makeJwt(expiredExp),
refreshToken: 'rt-deferred-worker',
});
acquireOpenIDRefreshFlight.mockResolvedValueOnce({ acquired: false, ownerId: 'follower' });
waitForOpenIDRefreshFlight.mockResolvedValueOnce({
access_token: makeJwt(Math.floor(Date.now() / 1000) + 3600),
id_token: makeJwt(Math.floor(Date.now() / 1000) + 3600),
refresh_token: 'rt-deferred-successor',
expires_at: Math.floor(Date.now() / 1000) + 3600,
__deferredPublication: true,
});
await expect(
refreshOpenIDSession(req, buildRes(), makeOpenIdUser(), 'access_token'),
).rejects.toThrow('awaiting identity validation');
expect(storeOpenIdSession).not.toHaveBeenCalled();
expect(setRefreshTokenCookie).not.toHaveBeenCalled();
expect(req.session.save).not.toHaveBeenCalled();
});
it('fails closed when a shared flight times out instead of issuing a duplicate grant', async () => {
const expiredExp = Math.floor(Date.now() / 1000) - 60;
const req = buildReq(
{
accessToken: makeJwt(expiredExp),
idToken: makeJwt(expiredExp),
refreshToken: 'rt-cross-worker',
},
'session-cross-worker',
);
acquireOpenIDRefreshFlight.mockResolvedValueOnce({
acquired: false,
ownerId: 'owner-joiner',
});
waitForOpenIDRefreshFlight.mockResolvedValueOnce(null);
await expect(
refreshOpenIDSession(req, undefined, makeOpenIdUser(), 'access_token'),
).rejects.toThrow('OpenID refresh coordination is temporarily unavailable');
expect(openIdClient.refreshTokenGrant).not.toHaveBeenCalled();
expect(withOpenIDRefreshFlightLease).not.toHaveBeenCalled();
});
it('does not mutate session state after refresh-flight ownership is lost', async () => {
const expiredExp = Math.floor(Date.now() / 1000) - 60;
const sessionTokens = {
accessToken: makeJwt(expiredExp),
idToken: makeJwt(expiredExp),
refreshToken: 'rt-lost-owner',
};
const req = buildReq(sessionTokens, 'session-lost-owner');
withOpenIDRefreshFlightLease.mockImplementationOnce(({ operation }) =>
operation({
assertLeaseOwned: jest
.fn()
.mockRejectedValue(new Error('OpenID refresh coordination ownership was lost')),
markLeaseSettled: jest.fn(),
}),
);
openIdClient.refreshTokenGrant.mockResolvedValueOnce({
access_token: makeJwt(Math.floor(Date.now() / 1000) + 3600),
refresh_token: 'rt-rotated-by-stale-owner',
expires_in: 3600,
});
await expect(
refreshOpenIDSession(req, undefined, makeOpenIdUser(), 'access_token'),
).rejects.toThrow('ownership was lost');
expect(req.session.openidTokens).toEqual(
expect.objectContaining({
accessToken: sessionTokens.accessToken,
idToken: sessionTokens.idToken,
refreshToken: sessionTokens.refreshToken,
}),
);
expect(req.session.save).not.toHaveBeenCalled();
expect(completeOpenIDRefreshFlight).not.toHaveBeenCalled();
expect(failOpenIDRefreshFlight).toHaveBeenCalled();
});
it('keeps an indeterminate completed generation recoverable instead of marking it failed', async () => {
const expiredExp = Math.floor(Date.now() / 1000) - 60;
const req = buildReq({
accessToken: makeJwt(expiredExp),
idToken: makeJwt(expiredExp),
refreshToken: 'rt-predecessor',
});
openIdClient.refreshTokenGrant.mockResolvedValueOnce({
access_token: makeJwt(Math.floor(Date.now() / 1000) + 3600),
id_token: makeJwt(Math.floor(Date.now() / 1000) + 3600),
refresh_token: 'rt-successor',
expires_in: 3600,
});
completeOpenIDRefreshFlight.mockRejectedValueOnce(new Error('completion timed out'));
assertOpenIDRefreshFlightAvailable.mockRejectedValueOnce(new Error('read timed out'));
await expect(
refreshOpenIDSession(req, undefined, makeOpenIdUser(), 'access_token'),
).rejects.toThrow('completion timed out');
expect(req.session.openidTokens.refreshToken).toBe('rt-successor');
expect(failOpenIDRefreshFlight).not.toHaveBeenCalled();
});
it('accepts an observed completed generation after the completion acknowledgement is lost', async () => {
const expiredExp = Math.floor(Date.now() / 1000) - 60;
const req = buildReq({
accessToken: makeJwt(expiredExp),
idToken: makeJwt(expiredExp),
refreshToken: 'rt-predecessor',
});
openIdClient.refreshTokenGrant.mockResolvedValueOnce({
access_token: makeJwt(Math.floor(Date.now() / 1000) + 3600),
id_token: makeJwt(Math.floor(Date.now() / 1000) + 3600),
refresh_token: 'rt-successor',
expires_in: 3600,
});
completeOpenIDRefreshFlight.mockRejectedValueOnce(new Error('completion timed out'));
assertOpenIDRefreshFlightAvailable.mockResolvedValueOnce({
status: 'completed',
ownerId: 'owner-1',
});
await expect(
refreshOpenIDSession(req, undefined, makeOpenIdUser(), 'access_token'),
).resolves.toMatchObject({ refresh_token: 'rt-successor' });
expect(failOpenIDRefreshFlight).not.toHaveBeenCalled();
});
it('rolls back session and cookie publication when logout revokes the pending flight', async () => {
const expiredExp = Math.floor(Date.now() / 1000) - 60;
const req = buildReq({
accessToken: makeJwt(expiredExp),
idToken: makeJwt(expiredExp),
refreshToken: 'rt-predecessor',
browserRefreshToken: 'rt-predecessor',
});
const res = buildRes({ headersSent: false });
req.session.destroy = jest.fn((callback) => {
delete req.session.openidTokens;
callback();
});
openIdClient.refreshTokenGrant.mockResolvedValueOnce({
access_token: makeJwt(Math.floor(Date.now() / 1000) + 3600),
id_token: makeJwt(Math.floor(Date.now() / 1000) + 3600),
refresh_token: 'rt-successor',
expires_in: 3600,
});
completeOpenIDRefreshFlight.mockResolvedValueOnce(null);
await expect(
refreshOpenIDSession(req, res, makeOpenIdUser(), 'access_token'),
).rejects.toThrow('ownership was lost');
expect(storeOpenIdSession).toHaveBeenCalled();
expect(setRefreshTokenCookie).toHaveBeenCalledWith(res, 'rt-successor', expect.any(Date));
expect(deleteSession).toHaveBeenCalledWith({ refreshToken: 'rt-successor' });
expect(req.session.destroy).toHaveBeenCalled();
expect(deleteRefreshTokenBridges).toHaveBeenCalledWith({
refreshTokens: ['rt-predecessor'],
userId: 'local-id-1',
tenantId: 'tenant-1',
version: 'bridge-version-1',
});
expect(req.session.openidTokens).toBeUndefined();
expect(res.clearCookie).toHaveBeenCalledWith('refreshToken');
expect(res.clearCookie).toHaveBeenCalledWith('openid_user_id');
expect(res.clearCookie).toHaveBeenCalledWith('token_provider');
});
it('compare-deletes the final long-lived bridge when publication is revoked', async () => {
const expiredExp = Math.floor(Date.now() / 1000) - 60;
const req = buildReq({
accessToken: makeJwt(expiredExp),
idToken: makeJwt(expiredExp),
refreshToken: 'rt-predecessor',
browserRefreshToken: 'rt-predecessor',
});
req.session.destroy = jest.fn((callback) => {
delete req.session.openidTokens;
callback();
});
storeRefreshTokenBridge
.mockResolvedValueOnce('grace-bridge-version')
.mockResolvedValueOnce('final-bridge-version');
openIdClient.refreshTokenGrant.mockResolvedValueOnce({
access_token: makeJwt(Math.floor(Date.now() / 1000) + 3600),
id_token: makeJwt(Math.floor(Date.now() / 1000) + 3600),
refresh_token: 'rt-successor',
expires_in: 3600,
});
completeOpenIDRefreshFlight.mockResolvedValueOnce(null);
await expect(
refreshOpenIDSession(req, undefined, makeOpenIdUser(), 'access_token'),
).rejects.toThrow('ownership was lost');
expect(storeRefreshTokenBridge).toHaveBeenCalledTimes(2);
expect(deleteRefreshTokenBridges).toHaveBeenCalledWith({
refreshTokens: ['rt-predecessor'],
userId: 'local-id-1',
tenantId: 'tenant-1',
version: 'final-bridge-version',
});
});
it('does not destroy a newer Express session when another owner advanced it', async () => {
const expiredExp = Math.floor(Date.now() / 1000) - 60;
const advancedAccessToken = makeJwt(Math.floor(Date.now() / 1000) + 7200);
const req = buildReq({
accessToken: makeJwt(expiredExp),
idToken: makeJwt(expiredExp),
refreshToken: 'rt-predecessor',
browserRefreshToken: 'rt-predecessor',
});
const res = buildRes({ headersSent: false });
req.session.destroy = jest.fn((callback) => callback());
req.session.reload = jest.fn();
req.session.reload
.mockImplementationOnce((callback) => callback())
.mockImplementationOnce((callback) => {
req.session.openidTokens = {
...req.session.openidTokens,
accessToken: advancedAccessToken,
refreshToken: 'rt-new-owner',
};
callback();
});
openIdClient.refreshTokenGrant.mockResolvedValueOnce({
access_token: makeJwt(Math.floor(Date.now() / 1000) + 3600),
id_token: makeJwt(Math.floor(Date.now() / 1000) + 3600),
refresh_token: 'rt-stale-owner',
expires_in: 3600,
});
completeOpenIDRefreshFlight.mockResolvedValueOnce(null);
await expect(
refreshOpenIDSession(req, res, makeOpenIdUser(), 'access_token'),
).rejects.toThrow('ownership was lost');
expect(req.session.destroy).not.toHaveBeenCalled();
expect(req.session.openidTokens.accessToken).toBe(advancedAccessToken);
expect(req.session.openidTokens.refreshToken).toBe('rt-new-owner');
expect(deleteSession).toHaveBeenCalledWith({ refreshToken: 'rt-stale-owner' });
expect(res.clearCookie).toHaveBeenCalledWith('refreshToken');
});
it('fails closed when Mongo flight acquisition is unavailable', async () => {
const expiredExp = Math.floor(Date.now() / 1000) - 60;
const req = buildReq(
{
accessToken: makeJwt(expiredExp),
idToken: makeJwt(expiredExp),
refreshToken: 'rt-coordination-down',
},
'session-coordination-down',
);
acquireOpenIDRefreshFlight.mockRejectedValueOnce(new Error('mongo unavailable'));
await expect(
refreshOpenIDSession(req, undefined, makeOpenIdUser(), 'access_token'),
).rejects.toThrow('OpenID refresh coordination is temporarily unavailable');
expect(openIdClient.refreshTokenGrant).not.toHaveBeenCalled();
});
});
describe('createOpenIDSessionTokenProvider closure delegation', () => {
it('returns the live OIDCTokens shape from a valid session', async () => {
const farFutureExp = Math.floor(Date.now() / 1000) + 600;
const sessionTokens = {
accessToken: makeJwt(farFutureExp),
idToken: makeJwt(farFutureExp),
refreshToken: 'rt-1',
};
const provider = createOpenIDSessionTokenProvider({
req: buildReq(sessionTokens),
user: makeOpenIdUser(),
tokenPreference: 'access_token',
});
const result = await provider();
expect(result).toEqual({
access_token: sessionTokens.accessToken,
id_token: sessionTokens.idToken,
refresh_token: 'rt-1',
expires_at: farFutureExp,
});
});
it('rejects with the IdP error when refresh fails through the closure', async () => {
const expiredExp = Math.floor(Date.now() / 1000) - 60;
const sessionTokens = {
accessToken: makeJwt(expiredExp),
idToken: makeJwt(expiredExp),
refreshToken: 'rt-fail',
};
openIdClient.refreshTokenGrant.mockRejectedValueOnce(new Error('invalid_grant'));
const provider = createOpenIDSessionTokenProvider({
req: buildReq(sessionTokens),
user: makeOpenIdUser(),
tokenPreference: 'access_token',
});
await expect(provider()).rejects.toThrow('invalid_grant');
});
});
/**
* Codex Finding 4: opaque (non-JWT) access tokens make `decodeJwtExp` return
* null, which would force every OBO call to refresh even when the previous
* refresh response advertised a still-valid `expires_in`. The fix persists
* `accessTokenExpiresAt` (unix seconds) on each refresh and uses it as a
* fallback for the freshness check + `expires_at` derivation.
*/
describe('opaque access token support (accessTokenExpiresAt fallback)', () => {
it('reuses live opaque access_token when accessTokenExpiresAt is in the future', async () => {
const farFutureExp = Math.floor(Date.now() / 1000) + 600;
const sessionTokens = {
accessToken: 'opaque-blob-not-a-jwt',
idToken: makeJwt(farFutureExp),
refreshToken: 'rt-opaque',
accessTokenExpiresAt: farFutureExp,
};
const req = buildReq(sessionTokens);
const result = await refreshOpenIDSession(req, undefined, makeOpenIdUser(), 'access_token');
expect(openIdClient.refreshTokenGrant).not.toHaveBeenCalled();
expect(result).toEqual({
access_token: 'opaque-blob-not-a-jwt',
id_token: sessionTokens.idToken,
refresh_token: 'rt-opaque',
expires_at: farFutureExp,
});
});
it('refreshes opaque access_token when accessTokenExpiresAt has passed', async () => {
const expiredExp = Math.floor(Date.now() / 1000) - 60;
const refreshedExp = Math.floor(Date.now() / 1000) + 3600;
const sessionTokens = {
accessToken: 'opaque-stale',
idToken: makeJwt(refreshedExp), // id_token still valid
refreshToken: 'rt-opaque-stale',
accessTokenExpiresAt: expiredExp,
};
openIdClient.refreshTokenGrant.mockResolvedValueOnce({
access_token: 'opaque-fresh',
// IdP omits id_token (Auth0 rotation off / MS personal); we use expires_in
expires_in: 3600,
});
const req = buildReq(sessionTokens);
const result = await refreshOpenIDSession(req, undefined, makeOpenIdUser(), 'access_token');
expect(openIdClient.refreshTokenGrant).toHaveBeenCalledTimes(1);
expect(result.access_token).toBe('opaque-fresh');
});
it('refreshes opaque access_token when no JWT exp and no accessTokenExpiresAt are present', async () => {
const refreshedExp = Math.floor(Date.now() / 1000) + 3600;
const sessionTokens = {
accessToken: 'opaque-no-expiry',
idToken: makeJwt(refreshedExp),
refreshToken: 'rt-no-exp',
// accessTokenExpiresAt deliberately omitted
};
openIdClient.refreshTokenGrant.mockResolvedValueOnce({
access_token: 'opaque-fresh',
expires_in: 3600,
});
const req = buildReq(sessionTokens);
await refreshOpenIDSession(req, undefined, makeOpenIdUser(), 'access_token');
expect(openIdClient.refreshTokenGrant).toHaveBeenCalledTimes(1);
});
it('persists accessTokenExpiresAt to req.session.openidTokens after a refresh with expires_in', async () => {
const expiredExp = Math.floor(Date.now() / 1000) - 60;
const sessionTokens = {
accessToken: 'opaque-stale',
idToken: makeJwt(expiredExp),
refreshToken: 'rt-persist',
};
openIdClient.refreshTokenGrant.mockResolvedValueOnce({
access_token: 'opaque-fresh',
expires_in: 3600,
});
const req = buildReq(sessionTokens);
const beforeSec = Math.floor(Date.now() / 1000);
await refreshOpenIDSession(req, undefined, makeOpenIdUser(), 'access_token');
const persistedExp = req.session.openidTokens.accessTokenExpiresAt;
expect(typeof persistedExp).toBe('number');
expect(persistedExp).toBeGreaterThanOrEqual(beforeSec + 3590);
expect(persistedExp).toBeLessThanOrEqual(beforeSec + 3610);
});
it('persists accessTokenExpiresAt when the refreshed expires_in is a numeric string', async () => {
const expiredExp = Math.floor(Date.now() / 1000) - 60;
const sessionTokens = {
accessToken: 'opaque-stale',
idToken: makeJwt(expiredExp),
refreshToken: 'rt-string-expiry',
};
openIdClient.refreshTokenGrant.mockResolvedValueOnce({
access_token: 'opaque-fresh',
expires_in: '3600',
});
const req = buildReq(sessionTokens);
const beforeSec = Math.floor(Date.now() / 1000);
await refreshOpenIDSession(req, undefined, makeOpenIdUser(), 'access_token');
expect(req.session.openidTokens.accessTokenExpiresAt).toBeGreaterThanOrEqual(
beforeSec + 3590,
);
});
it('does not roll an advanced session backward with a stale completed flight', async () => {
const expiredExp = Math.floor(Date.now() / 1000) - 60;
const advancedExp = Math.floor(Date.now() / 1000) + 3600;
const req = buildReq(
{
accessToken: makeJwt(expiredExp),
idToken: makeJwt(expiredExp),
refreshToken: 'rt-predecessor',
},
'session-stale-follower',
);
req.session.reload = jest.fn((callback) => {
req.session.openidTokens = withSessionIdentity({
accessToken: makeJwt(advancedExp),
idToken: makeJwt(advancedExp),
refreshToken: 'rt-advanced',
accessTokenExpiresAt: advancedExp,
publicationFlightKey: 'advanced-publication-key',
publicationFlightOwnerId: 'advanced-publication-owner',
});
callback();
});
acquireOpenIDRefreshFlight.mockResolvedValueOnce({ acquired: false, ownerId: 'other' });
const staleResult = {
access_token: makeJwt(advancedExp - 60),
id_token: makeJwt(advancedExp - 60),
refresh_token: 'rt-stale-result',
expires_at: advancedExp - 60,
};
Object.defineProperty(staleResult, '__predecessorRefreshToken', {
value: 'rt-predecessor',
enumerable: false,
});
Object.defineProperty(staleResult, '__flightOwnerId', {
value: 'stale-publication-owner',
enumerable: false,
});
waitForOpenIDRefreshFlight.mockResolvedValueOnce(staleResult);
await refreshOpenIDSession(req, undefined, makeOpenIdUser(), 'access_token');
expect(req.session.reload).toHaveBeenCalled();
expect(req.session.openidTokens.refreshToken).toBe('rt-advanced');
expect(assertOpenIDRefreshSessionGenerationAvailable).toHaveBeenCalledWith({
key: 'advanced-publication-key',
ownerId: 'advanced-publication-owner',
});
expect(storeRefreshTokenBridge).not.toHaveBeenCalled();
expect(storeOpenIdSession).not.toHaveBeenCalled();
expect(setRefreshTokenCookie).not.toHaveBeenCalled();
expect(req.session.save).not.toHaveBeenCalled();
});
it('keeps the newer generation when stale and current flights contain identical token strings', async () => {
const expiredExp = Math.floor(Date.now() / 1000) - 60;
const liveExp = Math.floor(Date.now() / 1000) + 3600;
const req = buildReq(
{
accessToken: makeJwt(expiredExp),
idToken: makeJwt(expiredExp),
refreshToken: 'rt-stable',
},
'session-identical-generations',
);
req.session.reload = jest.fn((callback) => {
req.session.openidTokens = withSessionIdentity({
accessToken: 'identical-access',
idToken: 'identical-id',
refreshToken: 'rt-stable',
accessTokenExpiresAt: liveExp,
publicationFlightKey: 'newer-publication-key',
publicationFlightOwnerId: 'newer-publication-owner',
publicationFlightCreatedAt: 2000,
});
callback();
});
acquireOpenIDRefreshFlight.mockResolvedValueOnce({ acquired: false, ownerId: 'other' });
const staleResult = {
access_token: 'identical-access',
id_token: 'identical-id',
refresh_token: 'rt-stable',
expires_at: liveExp,
};
Object.defineProperty(staleResult, '__predecessorRefreshToken', {
value: 'rt-stable',
enumerable: false,
});
Object.defineProperty(staleResult, '__flightOwnerId', {
value: 'stale-publication-owner',
enumerable: false,
});
Object.defineProperty(staleResult, '__flightCreatedAt', {
value: 1000,
enumerable: false,
});
waitForOpenIDRefreshFlight.mockResolvedValueOnce(staleResult);
await refreshOpenIDSession(req, undefined, makeOpenIdUser(), 'access_token');
expect(req.session.openidTokens).toEqual(
expect.objectContaining({
accessToken: 'identical-access',
refreshToken: 'rt-stable',
publicationFlightKey: 'newer-publication-key',
publicationFlightOwnerId: 'newer-publication-owner',
publicationFlightCreatedAt: 2000,
}),
);
expect(req.session.save).not.toHaveBeenCalled();
});
it('drops a stale accessTokenExpiresAt when the new tokenset has neither expires_in nor a JWT access_token', async () => {
const expiredExp = Math.floor(Date.now() / 1000) - 60;
const sessionTokens = {
accessToken: 'opaque-old',
idToken: makeJwt(expiredExp),
refreshToken: 'rt-drop',
accessTokenExpiresAt: expiredExp, // stale carry-over
};
openIdClient.refreshTokenGrant.mockResolvedValueOnce({
access_token: 'opaque-fresh-no-meta',
// no expires_in, no JWT access_token, no id_token
});
const req = buildReq(sessionTokens);
await refreshOpenIDSession(req, undefined, makeOpenIdUser(), 'access_token');
expect(req.session.openidTokens).not.toHaveProperty('accessTokenExpiresAt');
});
it('getAccessTokenExp prefers JWT exp over the persisted accessTokenExpiresAt', () => {
const jwtExp = Math.floor(Date.now() / 1000) + 600;
const persistedExp = Math.floor(Date.now() / 1000) - 60; // stale
const result = __internals.getAccessTokenExp({
accessToken: makeJwt(jwtExp),
accessTokenExpiresAt: persistedExp,
});
expect(result).toBe(jwtExp);
});
it('getAccessTokenExp returns null when neither a decodable JWT nor a persisted expiry is present', () => {
const result = __internals.getAccessTokenExp({
accessToken: 'opaque',
});
expect(result).toBeNull();
});
/**
* Codex Finding 6: id_token TTL is governed by IdP session policy and is
* often longer than access-token TTL. Trusting it as access-token expiry
* would mark an opaque access token reusable past its real lifetime,
* sending an expired credential to the OBO IdP. The fallback chain must
* be expires_in → JWT access_token exp → unset (NOT id_token exp).
*/
it('does NOT fall back to id_token exp for accessTokenExpiresAt when expires_in is missing', async () => {
const longLivedIdTokenExp = Math.floor(Date.now() / 1000) + 86400; // 24h
const sessionTokens = {
accessToken: 'opaque-old',
idToken: makeJwt(Math.floor(Date.now() / 1000) - 60),
refreshToken: 'rt-no-fallback',
};
openIdClient.refreshTokenGrant.mockResolvedValueOnce({
access_token: 'opaque-fresh', // opaque, NOT a JWT
id_token: makeJwt(longLivedIdTokenExp), // long-lived id_token
// no expires_in
});
const req = buildReq(sessionTokens);
await refreshOpenIDSession(req, undefined, makeOpenIdUser(), 'access_token');
// The long-lived id_token exp must NOT have been borrowed for the access token.
expect(req.session.openidTokens).not.toHaveProperty('accessTokenExpiresAt');
});
it('falls back to JWT access_token exp for accessTokenExpiresAt when expires_in is missing', async () => {
const accessExp = Math.floor(Date.now() / 1000) + 1800; // 30min
const sessionTokens = {
accessToken: 'opaque-old',
idToken: makeJwt(Math.floor(Date.now() / 1000) - 60),
refreshToken: 'rt-jwt-access',
};
openIdClient.refreshTokenGrant.mockResolvedValueOnce({
access_token: makeJwt(accessExp), // JWT access token
id_token: makeJwt(Math.floor(Date.now() / 1000) + 86400), // long-lived; should NOT win
// no expires_in
});
const req = buildReq(sessionTokens);
await refreshOpenIDSession(req, undefined, makeOpenIdUser(), 'access_token');
// accessTokenExpiresAt comes from the access token's own JWT exp, not the id_token.
expect(req.session.openidTokens.accessTokenExpiresAt).toBe(accessExp);
});
});
});