From fa913148fb8bfab80479f7721cf67e4eb85ea675 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sat, 29 Aug 2026 23:55:58 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=92=20fix:=20Refresh=20MCP=20OBO=20Tok?= =?UTF-8?q?ens=20From=20the=20Live=20OpenID=20Session=20(#15334)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 🧊 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`, 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 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 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 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 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 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 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 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 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 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 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 Co-authored-by: jbartle Co-authored-by: Claude Co-authored-by: jcbartle <7274202+jcbartle@users.noreply.github.com> --- api/app/clients/tools/util/handleTools.js | 24 +- api/server/controllers/AuthController.js | 478 +++- api/server/controllers/AuthController.spec.js | 1487 +++++++++- api/server/controllers/assistants/v1.js | 2 + api/server/controllers/assistants/v2.js | 2 + .../controllers/auth/LogoutController.js | 52 +- .../controllers/auth/LogoutController.spec.js | 107 +- api/server/controllers/auth/oauth.js | 23 +- api/server/controllers/auth/oauth.spec.js | 33 +- api/server/routes/__tests__/mcp.spec.js | 32 + api/server/routes/mcp.js | 14 + api/server/services/AuthService.js | 165 +- api/server/services/AuthService.spec.js | 448 ++- api/server/services/GraphApiService.spec.js | 4 +- api/server/services/MCP.js | 84 +- api/server/services/MCP.spec.js | 202 ++ api/server/services/OboTokenService.js | 77 +- api/server/services/OboTokenService.spec.js | 186 +- api/server/services/OpenIDRefreshFlight.js | 10 + .../services/OpenIDRefreshFlight.spec.js | 640 +++++ api/server/services/OpenIDRefreshRecovery.js | 45 + api/server/services/OpenIDSessionRefresh.js | 46 + .../services/OpenIDSessionRefresh.spec.js | 2495 +++++++++++++++++ api/server/services/RefreshTokenBridge.js | 22 + .../services/RefreshTokenBridge.spec.js | 282 ++ api/server/services/ToolService.js | 27 +- api/server/services/Tools/mcp.js | 8 + api/server/services/Tools/mcp.spec.js | 16 + api/server/services/__tests__/MCP.spec.js | 33 +- .../services/__tests__/ToolService.spec.js | 56 + api/strategies/openidStrategy.spec.js | 4 +- packages/api/src/auth/index.ts | 1 + packages/api/src/auth/openid/bridge.ts | 220 ++ packages/api/src/auth/openid/errors.ts | 26 + packages/api/src/auth/openid/flight.ts | 666 +++++ packages/api/src/auth/openid/index.ts | 5 + packages/api/src/auth/openid/recovery.ts | 1010 +++++++ packages/api/src/auth/openid/session.ts | 2043 ++++++++++++++ packages/api/src/auth/openid/types.ts | 175 ++ packages/api/src/mcp/MCPConnectionFactory.ts | 153 +- packages/api/src/mcp/MCPManager.ts | 31 +- packages/api/src/mcp/UserConnectionManager.ts | 4 + .../__tests__/MCPConnectionFactory.test.ts | 159 +- .../api/src/mcp/__tests__/MCPManager.test.ts | 41 + packages/api/src/mcp/oauth/obo.spec.ts | 357 ++- packages/api/src/mcp/oauth/obo.ts | 160 +- .../src/mcp/registry/MCPServersRegistry.ts | 1 + .../__tests__/MCPServersInitializer.test.ts | 37 + .../__tests__/ensureConfigServers.test.ts | 17 + packages/api/src/mcp/types/index.ts | 9 +- packages/api/src/oauth/csrf.spec.ts | 189 +- packages/api/src/oauth/csrf.ts | 78 + packages/api/src/oauth/expiry.spec.ts | 101 +- packages/api/src/oauth/expiry.ts | 84 +- packages/api/src/utils/env.spec.ts | 27 +- packages/api/src/utils/identity.spec.ts | 148 + packages/api/src/utils/identity.ts | 290 ++ packages/api/src/utils/index.ts | 1 + packages/api/src/utils/oidc.ts | 4 +- packages/data-schemas/src/methods/index.ts | 14 + .../src/methods/openidRefreshFlight.spec.ts | 424 +++ .../src/methods/openidRefreshFlight.ts | 464 +++ .../src/methods/refreshTokenBridge.spec.ts | 215 ++ .../src/methods/refreshTokenBridge.ts | 126 + packages/data-schemas/src/models/index.ts | 6 + .../src/models/openidRefreshFlight.ts | 16 + .../plugins/tenantIsolation.coverage.spec.ts | 13 +- .../src/models/refreshTokenBridge.ts | 17 + packages/data-schemas/src/schema/index.ts | 2 + .../src/schema/openidRefreshFlight.ts | 59 + .../src/schema/refreshTokenBridge.ts | 45 + packages/data-schemas/src/types/index.ts | 2 + .../src/types/openidRefreshFlight.ts | 74 + .../src/types/refreshTokenBridge.ts | 35 + 74 files changed, 14152 insertions(+), 501 deletions(-) create mode 100644 api/server/services/OpenIDRefreshFlight.js create mode 100644 api/server/services/OpenIDRefreshFlight.spec.js create mode 100644 api/server/services/OpenIDRefreshRecovery.js create mode 100644 api/server/services/OpenIDSessionRefresh.js create mode 100644 api/server/services/OpenIDSessionRefresh.spec.js create mode 100644 api/server/services/RefreshTokenBridge.js create mode 100644 api/server/services/RefreshTokenBridge.spec.js create mode 100644 packages/api/src/auth/openid/bridge.ts create mode 100644 packages/api/src/auth/openid/errors.ts create mode 100644 packages/api/src/auth/openid/flight.ts create mode 100644 packages/api/src/auth/openid/index.ts create mode 100644 packages/api/src/auth/openid/recovery.ts create mode 100644 packages/api/src/auth/openid/session.ts create mode 100644 packages/api/src/auth/openid/types.ts create mode 100644 packages/api/src/utils/identity.spec.ts create mode 100644 packages/api/src/utils/identity.ts create mode 100644 packages/data-schemas/src/methods/openidRefreshFlight.spec.ts create mode 100644 packages/data-schemas/src/methods/openidRefreshFlight.ts create mode 100644 packages/data-schemas/src/methods/refreshTokenBridge.spec.ts create mode 100644 packages/data-schemas/src/methods/refreshTokenBridge.ts create mode 100644 packages/data-schemas/src/models/openidRefreshFlight.ts create mode 100644 packages/data-schemas/src/models/refreshTokenBridge.ts create mode 100644 packages/data-schemas/src/schema/openidRefreshFlight.ts create mode 100644 packages/data-schemas/src/schema/refreshTokenBridge.ts create mode 100644 packages/data-schemas/src/types/openidRefreshFlight.ts create mode 100644 packages/data-schemas/src/types/refreshTokenBridge.ts diff --git a/api/app/clients/tools/util/handleTools.js b/api/app/clients/tools/util/handleTools.js index aa20ba06c8..ec75adea76 100644 --- a/api/app/clients/tools/util/handleTools.js +++ b/api/app/clients/tools/util/handleTools.js @@ -1,9 +1,10 @@ -const { logger } = require('@librechat/data-schemas'); +const { logger, getTenantId } = require('@librechat/data-schemas'); const { Calculator, createSearchTool, createCodeExecutionTool } = require('@librechat/agents'); const { checkAccess, toolkitParent, createSafeUser, + createAuthIdentityContext, mcpToolPattern, loadWebSearchAuth, splitMCPToolKey, @@ -56,6 +57,7 @@ const { resolveCollisionAuditNames, } = require('~/server/services/MCP'); const { getMCPRequestContext } = require('~/server/services/MCPRequestContext'); +const { createOpenIDSessionTokenProvider } = require('~/server/services/OpenIDSessionRefresh'); const { createFileSearchTool, primeFiles: primeSearchFiles } = require('./fileSearch'); const { primeFiles: primeCodeFiles } = require('~/server/services/Files/Code/process'); const { getUserPluginAuthValue } = require('~/server/services/PluginService'); @@ -593,6 +595,24 @@ const loadTools = async ({ const safeUser = createSafeUser(options.req?.user); const requestScopedConnections = options.requestScopedConnections ?? getMCPRequestContext(options.req, options.res); + /** + * Build the OBO upstream-token closure once at the request boundary (where + * `req`/`res` are in scope) and thread the function into MCP handling, so the + * MCP layer never receives the raw Express request. The closure reads/refreshes + * the live `req.session.openidTokens` at tool-call time and mirrors rotations + * to the `refreshToken` cookie when the response is still writable. + */ + const oboIdentityContext = createAuthIdentityContext({ + user: options.req?.user, + tenantId: getTenantId(), + }); + const upstreamTokenProvider = createOpenIDSessionTokenProvider({ + req: options.req, + res: options.res, + user: options.req?.user, + identityContext: oboIdentityContext, + tokenPreference: 'access_token', + }); for (const [serverName, toolConfigs] of Object.entries(requestedMCPTools)) { index++; @@ -613,6 +633,8 @@ const loadTools = async ({ requestBody: options.requestBody ?? options.req?.body, requestScopedConnections, res: options.res, + upstreamTokenProvider, + oboIdentityContext, streamId: options.req?._resumableStreamId || null, jobCreatedAt: options.jobCreatedAt, model: agent?.model ?? model, diff --git a/api/server/controllers/AuthController.js b/api/server/controllers/AuthController.js index 2445991ecc..10cd6766a3 100644 --- a/api/server/controllers/AuthController.js +++ b/api/server/controllers/AuthController.js @@ -1,33 +1,38 @@ const cookies = require('cookie'); const jwt = require('jsonwebtoken'); -const openIdClient = require('openid-client'); +const crypto = require('node:crypto'); const { logger } = require('@librechat/data-schemas'); const { math, isEnabled, - findOpenIDUser, - getOpenIdIssuer, - buildOpenIDRefreshParams, + createAuthIdentityContext, + createOpenIDRefreshOwnershipError, + isOpenIDRefreshOwnershipError, + isOpenIDSessionIdentityMatch, OPENID_EXPIRY_BUFFER_SECONDS, } = require('@librechat/api'); const { requestPasswordReset, - setOpenIDAuthTokens, - storeOpenIDSession, + clearOpenIDAuthTokens, setCloudFrontAuthCookies, resetPassword, setAuthTokens, registerUser, } = require('~/server/services/AuthService'); -const { - deleteAllUserSessions, - getUserById, - findSession, - updateUser, - findUser, -} = require('~/models'); +const { deleteAllUserSessions, getUserById, findSession, updateUser } = require('~/models'); const { getGraphApiToken } = require('~/server/services/GraphTokenService'); -const { getOpenIdConfig, getOpenIdEmail } = require('~/strategies'); +const { getRefreshTokenBridge } = require('~/server/services/RefreshTokenBridge'); +const { + recoverOpenIDRefreshBridge, + refreshOpenIDUser, + sendOpenIDAuthResponse, +} = require('~/server/services/OpenIDRefreshRecovery'); +const { + assertOpenIDRefreshFlightDeliveryAvailable, + assertOpenIDRefreshSessionGenerationAvailable, + claimOpenIDRefreshFlightDelivery, + releaseOpenIDRefreshFlightDelivery, +} = require('~/server/services/OpenIDRefreshFlight'); const AUTH_REFRESH_USER_PROJECTION = '-password -__v -totpSecret -backupCodes -federatedTokens'; /** @@ -67,7 +72,7 @@ const sanitizeUserForAuthResponse = (user) => { return safeUser; }; -const getValidOpenIDReuseUserId = (parsedCookies) => { +const getValidOpenIDReuseUserId = (parsedCookies, refreshToken) => { const openidUserId = parsedCookies.openid_user_id; if (!openidUserId || !process.env.JWT_REFRESH_SECRET) { return null; @@ -75,14 +80,51 @@ const getValidOpenIDReuseUserId = (parsedCookies) => { try { const payload = jwt.verify(openidUserId, process.env.JWT_REFRESH_SECRET); - return typeof payload === 'object' && payload != null && typeof payload.id === 'string' - ? payload.id - : null; + if (typeof payload !== 'object' || payload == null || typeof payload.id !== 'string') { + return null; + } + if (refreshToken == null) { + return payload.id; + } + if (typeof payload.refreshTokenHash !== 'string') { + return null; + } + const refreshTokenHash = crypto.createHash('sha256').update(refreshToken).digest('base64url'); + return payload.refreshTokenHash === refreshTokenHash ? payload.id : null; } catch { return null; } }; +const selectOpenIDRefreshToken = (openidTokens, parsedCookies) => { + const sessionRefreshToken = openidTokens?.refreshToken; + const browserRefreshToken = parsedCookies.refreshToken; + const lastSyncedBrowserRefreshToken = openidTokens?.browserRefreshToken; + const hasKnownBrowserRefreshTokenMarker = + typeof lastSyncedBrowserRefreshToken === 'string' && lastSyncedBrowserRefreshToken.length > 0; + const driftReference = hasKnownBrowserRefreshTokenMarker + ? lastSyncedBrowserRefreshToken + : sessionRefreshToken; + + if (browserRefreshToken && driftReference && browserRefreshToken !== driftReference) { + logger.info('[refreshController] OpenID refresh token cookie differs from session state'); + return { + refreshToken: sessionRefreshToken || browserRefreshToken, + fallbackRefreshToken: + sessionRefreshToken && browserRefreshToken !== sessionRefreshToken + ? browserRefreshToken + : null, + cookieDiffersFromSession: true, + }; + } + + return { + refreshToken: sessionRefreshToken || browserRefreshToken, + fallbackRefreshToken: null, + cookieDiffersFromSession: false, + }; +}; + const isRecentOpenIDSessionRefresh = (openidTokens) => { const lastRefreshedAt = Number(openidTokens?.lastRefreshedAt); const elapsed = Date.now() - lastRefreshedAt; @@ -91,6 +133,40 @@ const isRecentOpenIDSessionRefresh = (openidTokens) => { ); }; +const isInvalidGrantError = (error) => { + const values = [ + error?.message, + error?.error, + error?.code, + error?.response?.data?.error, + error?.response?.data?.error_description, + error?.body?.error, + error?.body?.error_description, + ]; + + return values.some( + (value) => typeof value === 'string' && value.toLowerCase().includes('invalid_grant'), + ); +}; + +const getAuthIdentitySource = (user) => + typeof user?.toObject === 'function' ? user.toObject() : user; + +const isReusableOpenIDSessionIdentity = (openidTokens, user) => { + const identitySource = getAuthIdentitySource(user); + const expectedIdentity = createAuthIdentityContext({ user: identitySource }); + const matches = isOpenIDSessionIdentityMatch(openidTokens, expectedIdentity); + if (!matches) { + logger.warn('[refreshController] OpenID session token identity mismatch; forcing refresh', { + userId: expectedIdentity.appUserId, + has_session_user_id: Boolean(openidTokens?.appUserId), + has_session_subject: Boolean(openidTokens?.openidSubject), + has_session_issuer: Boolean(openidTokens?.openidIssuer), + }); + } + return matches; +}; + const getReusableOpenIDSessionToken = (openidTokens) => { if (!isRecentOpenIDSessionRefresh(openidTokens)) { return null; @@ -120,6 +196,89 @@ const getReusableOpenIDSessionToken = (openidTokens) => { return null; }; +const assertReusableOpenIDSessionGeneration = async (openidTokens) => + assertOpenIDRefreshSessionGenerationAvailable({ + key: openidTokens?.publicationFlightKey, + ownerId: openidTokens?.publicationFlightOwnerId, + }); + +/** + * Serializes response delivery for one durable OpenID publication generation. A logout that + * reaches the same flight either tombstones it before this claim or waits for the response to + * finish before returning. The send callback keeps the final authorization check adjacent to the + * synchronous Express write while allowing callers to do slow preparation under the lease. + */ +const withOpenIDResponseDelivery = async ({ res, openidTokens, context }, operation) => { + let delivery; + let responseSent = false; + let releaseStarted = false; + let listenersArmed = false; + const releaseDelivery = async () => { + if (!delivery || releaseStarted) { + return; + } + releaseStarted = true; + for (let attempt = 1; attempt <= 3; attempt++) { + try { + await releaseOpenIDRefreshFlightDelivery(delivery); + return; + } catch (error) { + if (attempt === 3) { + logger.warn(`[${context}] Failed to release OpenID response delivery`, { + error: error instanceof Error ? error.message : error, + }); + return; + } + await new Promise((resolve) => setTimeout(resolve, 25)); + } + } + }; + + try { + await assertReusableOpenIDSessionGeneration(openidTokens); + if (openidTokens?.publicationFlightKey && openidTokens?.publicationFlightOwnerId) { + const claimed = await claimOpenIDRefreshFlightDelivery({ + key: openidTokens.publicationFlightKey, + ownerId: openidTokens.publicationFlightOwnerId, + createdAt: openidTokens.publicationFlightCreatedAt, + }); + if (!claimed.deliveryId) { + throw new Error('OpenID response delivery claim returned no owner'); + } + delivery = { + key: openidTokens.publicationFlightKey, + ownerId: openidTokens.publicationFlightOwnerId, + deliveryId: claimed.deliveryId, + }; + } + + const sendAuthorized = async (send) => { + if (delivery) { + await assertOpenIDRefreshFlightDeliveryAvailable(delivery); + if (!listenersArmed && typeof res.once === 'function') { + listenersArmed = true; + res.once('finish', () => void releaseDelivery()); + res.once('close', () => void releaseDelivery()); + } + } else { + await assertReusableOpenIDSessionGeneration(openidTokens); + } + const response = send(); + responseSent = true; + if (delivery && typeof res.once !== 'function') { + await releaseDelivery(); + } + return response; + }; + + return await operation(sendAuthorized); + } finally { + if (delivery && !responseSent) { + await releaseDelivery(); + } + } +}; + const resetPasswordRequestController = async (req, res) => { try { const resetService = await requestPasswordReset(req); @@ -158,8 +317,9 @@ const refreshController = async (req, res) => { const token_provider = parsedCookies.token_provider; if (token_provider === 'openid' && isEnabled(process.env.OPENID_REUSE_TOKENS)) { - /** For OpenID users, read refresh token from session to avoid large cookie issues */ - const refreshToken = req.session?.openidTokens?.refreshToken || parsedCookies.refreshToken; + /** Prefer session refresh tokens unless the browser cookie proves the session is stale. */ + const { refreshToken, fallbackRefreshToken, cookieDiffersFromSession } = + selectOpenIDRefreshToken(req.session?.openidTokens, parsedCookies); if (!refreshToken) { return res.status(200).send('Refresh token not provided'); @@ -171,56 +331,91 @@ const refreshController = async (req, res) => { * Stale, missing, or near-expiry tokens fall through to refreshTokenGrant so * upstream revocations and cookie/session extension are checked regularly. */ - const reusableSessionToken = getReusableOpenIDSessionToken(req.session?.openidTokens); + const reusableSessionToken = cookieDiffersFromSession + ? null + : getReusableOpenIDSessionToken(req.session?.openidTokens); const reuseUserId = reusableSessionToken ? getValidOpenIDReuseUserId(parsedCookies) : null; if (reuseUserId) { - const user = await getUserById(reuseUserId, AUTH_REFRESH_USER_PROJECTION); - if (user) { - const cloudFrontCookiesSet = setCloudFrontAuthCookies(req, res, user); - logger.debug('[refreshController] OpenID session token reused', { - token_type: reusableSessionToken.type, - has_id_token: Boolean(req.session?.openidTokens?.idToken), - has_access_token: Boolean(req.session?.openidTokens?.accessToken), - cloudfront_cookies_set: cloudFrontCookiesSet, - }); - return res.status(200).send({ - token: reusableSessionToken.token, - user: sanitizeUserForAuthResponse(user), - }); + const reuseSessionTokens = req.session?.openidTokens; + try { + const response = await withOpenIDResponseDelivery( + { + res, + openidTokens: reuseSessionTokens, + context: 'refreshController', + }, + async (sendAuthorized) => { + const user = await getUserById(reuseUserId, AUTH_REFRESH_USER_PROJECTION); + if (!user || !isReusableOpenIDSessionIdentity(reuseSessionTokens, user)) { + return undefined; + } + return sendAuthorized(() => { + const cloudFrontCookiesSet = setCloudFrontAuthCookies(req, res, user); + logger.debug('[refreshController] OpenID session token reused', { + token_type: reusableSessionToken.type, + has_id_token: Boolean(reuseSessionTokens?.idToken), + has_access_token: Boolean(reuseSessionTokens?.accessToken), + cloudfront_cookies_set: cloudFrontCookiesSet, + }); + return res.status(200).send({ + token: reusableSessionToken.token, + user: sanitizeUserForAuthResponse(user), + }); + }); + }, + ); + if (response !== undefined) { + return response; + } + } catch (error) { + if (!isOpenIDRefreshOwnershipError(error)) { + throw error; + } + clearOpenIDAuthTokens(req, res, reuseUserId, reuseSessionTokens?.tenantId); + return res.status(403).send('Invalid OpenID refresh token'); } } - const openIdConfig = getOpenIdConfig(); - const refreshParams = buildOpenIDRefreshParams(); - logger.debug('[refreshController] OpenID refresh params', { - has_scope: Boolean(process.env.OPENID_SCOPE), - has_refresh_audience: Boolean(process.env.OPENID_REFRESH_AUDIENCE), - }); - const tokenset = await openIdClient.refreshTokenGrant( - openIdConfig, - refreshToken, - refreshParams, - ); - logger.debug('[refreshController] OpenID refresh succeeded', { - has_access_token: Boolean(tokenset.access_token), - has_id_token: Boolean(tokenset.id_token), - has_refresh_token: Boolean(tokenset.refresh_token), - expires_in: tokenset.expires_in, - }); - const claims = tokenset.claims(); - const openidIssuer = getOpenIdIssuer(claims, openIdConfig); - const { user, error, migration } = await findOpenIDUser({ - findUser, - email: getOpenIdEmail(claims), - openidId: claims.sub, - openidIssuer, - idOnTheSource: claims.oid, - strategyName: 'refreshController', - }); + const refreshUserId = + req.session?.openidTokens?.appUserId ?? getValidOpenIDReuseUserId(parsedCookies); + const refreshUser = refreshUserId + ? await getUserById(refreshUserId, AUTH_REFRESH_USER_PROJECTION) + : null; + if (!refreshUser) { + return res.status(403).send('Invalid OpenID refresh token'); + } - logger.debug( - `[refreshController] findOpenIDUser result: user=${user?.email ?? 'null'}, error=${error ?? 'null'}, migration=${migration}, userOpenidId=${user?.openidId ?? 'null'}, claimsSub=${claims.sub}`, - ); + let successfulRefreshToken = refreshToken; + let refreshResult; + try { + refreshResult = await refreshOpenIDUser({ + req, + res, + user: refreshUser, + refreshToken, + browserRefreshToken: parsedCookies.refreshToken, + strategyName: 'refreshController', + deferPublication: true, + }); + } catch (error) { + if (!fallbackRefreshToken || !isInvalidGrantError(error)) { + throw error; + } + logger.info( + '[refreshController] Session refresh token was rejected; retrying the distinct browser token', + ); + successfulRefreshToken = fallbackRefreshToken; + refreshResult = await refreshOpenIDUser({ + req, + res, + user: refreshUser, + refreshToken: fallbackRefreshToken, + browserRefreshToken: parsedCookies.refreshToken, + strategyName: 'refreshController (browser fallback)', + deferPublication: true, + }); + } + const { tokenset, claims, openidIssuer, user, error, migration } = refreshResult; if (error || !user) { logger.warn( @@ -243,22 +438,118 @@ const refreshController = async (req, res) => { ); } - const activeRefreshToken = tokenset.refresh_token || refreshToken; - await storeOpenIDSession( - user._id.toString(), - activeRefreshToken, - user.tenantId, - refreshToken, - ); - const token = setOpenIDAuthTokens(tokenset, req, res, { - userId: user._id.toString(), - existingRefreshToken: refreshToken, - tenantId: user.tenantId, - }); + if ( + successfulRefreshToken !== refreshToken && + req.session?.openidTokens?.refreshToken === refreshToken + ) { + delete req.session.openidTokens; + } - return res.status(200).send({ token, user: sanitizeUserForAuthResponse(user) }); + const token = await sendOpenIDAuthResponse({ + tokenset, + user, + existingRefreshToken: successfulRefreshToken, + openidSubject: claims?.sub, + openidIssuer, + predecessorIdentity: { + userId: refreshUser._id.toString(), + tenantId: refreshUser.tenantId, + openidIssuer: refreshUser.openidIssuer, + }, + rejectedRefreshTokens: successfulRefreshToken === refreshToken ? [] : [refreshToken], + req, + res, + }); + return await withOpenIDResponseDelivery( + { + res, + openidTokens: req.session?.openidTokens, + context: 'refreshController', + }, + (sendAuthorized) => + sendAuthorized(() => + res.status(200).send({ token, user: sanitizeUserForAuthResponse(user) }), + ), + ); } catch (error) { + if (isOpenIDRefreshOwnershipError(error)) { + clearOpenIDAuthTokens( + req, + res, + req.session?.openidTokens?.appUserId, + req.session?.openidTokens?.tenantId, + ); + } logger.error('[refreshController] OpenID token refresh error', error); + + /** + * Detect and recover from stale refresh-token cookie after SSE-triggered rotation. + * If the initial refresh with the cookie fails with invalid_grant, check if a + * recovery bridge exists. Bridges are stored when an OBO refresh rotates the token + * but cannot set the browser cookie (headers already sent during SSE streaming). + */ + const bridgeSourceToken = parsedCookies.refreshToken; + if (isInvalidGrantError(error) && bridgeSourceToken) { + // Bridge lookup uses the signed user-id cookie because /refresh is unauthenticated. + const userId = getValidOpenIDReuseUserId(parsedCookies, bridgeSourceToken); + if (userId) { + try { + const bridgeUser = await getUserById(userId, AUTH_REFRESH_USER_PROJECTION); + if (!bridgeUser) { + return res.status(403).send('Invalid OpenID refresh token'); + } + + const bridgedRefreshToken = await getRefreshTokenBridge({ + oldRefreshToken: bridgeSourceToken, + userId, + tenantId: bridgeUser.tenantId, + openidIssuer: bridgeUser.openidIssuer, + }); + + if (bridgedRefreshToken) { + logger.info( + '[refreshController] Recovered via refresh-token bridge after invalid_grant', + { + userId, + }, + ); + + try { + const { appAuthToken } = await recoverOpenIDRefreshBridge({ + req, + res, + refreshToken: bridgeSourceToken, + bridgedRefreshToken, + bridgeUser, + }); + + return await withOpenIDResponseDelivery( + { + res, + openidTokens: req.session?.openidTokens, + context: 'refreshController', + }, + (sendAuthorized) => + sendAuthorized(() => + res.status(200).send({ + token: appAuthToken, + user: sanitizeUserForAuthResponse(bridgeUser), + }), + ), + ); + } catch (retryError) { + logger.error('[refreshController] Bridge recovery retry failed', retryError); + // Fall through to generic error response + } + } + } catch (bridgeError) { + logger.warn('[refreshController] Refresh-token bridge lookup failed', { + error: bridgeError instanceof Error ? bridgeError.message : bridgeError, + }); + } + } + } + return res.status(403).send('Invalid OpenID refresh token'); } } @@ -340,12 +631,37 @@ const graphTokenController = async (req, res) => { }); } - const tokenResponse = await getGraphApiToken(req.user, accessToken, scopes); - - res.json(tokenResponse); + const sessionTokens = req.session?.openidTokens; + const usesSessionToken = Boolean( + sessionTokens?.accessToken && sessionTokens.accessToken === accessToken, + ); + const requestBearer = req.headers?.authorization?.match(/^Bearer\s+(.+)$/i)?.[1]; + if (req.session && !usesSessionToken && requestBearer !== accessToken) { + throw createOpenIDRefreshOwnershipError('OpenID session tokens are no longer available'); + } + const exchangeAndSend = async (sendAuthorized) => { + const tokenResponse = await getGraphApiToken(req.user, accessToken, scopes); + return sendAuthorized(() => res.json(tokenResponse)); + }; + if (usesSessionToken) { + return await withOpenIDResponseDelivery( + { + res, + openidTokens: sessionTokens, + context: 'graphTokenController', + }, + exchangeAndSend, + ); + } + return await exchangeAndSend((send) => send()); } catch (error) { + if (isOpenIDRefreshOwnershipError(error)) { + const userId = req.user?.id ?? req.user?._id?.toString?.(); + clearOpenIDAuthTokens(req, res, userId, req.session?.openidTokens?.tenantId); + return res.status(401).json({ message: 'OpenID session is no longer authorized' }); + } logger.error('[graphTokenController] Failed to obtain Graph API token:', error); - res.status(500).json({ + return res.status(500).json({ message: 'Failed to obtain Microsoft Graph token', }); } diff --git a/api/server/controllers/AuthController.spec.js b/api/server/controllers/AuthController.spec.js index 7524df63d0..b9fd676bae 100644 --- a/api/server/controllers/AuthController.spec.js +++ b/api/server/controllers/AuthController.spec.js @@ -5,6 +5,8 @@ jest.mock('~/server/services/GraphTokenService', () => ({ getGraphApiToken: jest.fn(), })); jest.mock('~/server/services/AuthService', () => ({ + clearOpenIDAuthTokens: jest.fn(), + getOpenIDAppAuthToken: jest.fn(), requestPasswordReset: jest.fn(), setOpenIDAuthTokens: jest.fn(), storeOpenIDSession: jest.fn(), @@ -16,18 +18,69 @@ jest.mock('~/server/services/AuthService', () => ({ jest.mock('~/strategies', () => ({ getOpenIdConfig: jest.fn(), getOpenIdEmail: jest.fn() })); jest.mock('openid-client', () => ({ refreshTokenGrant: jest.fn() })); jest.mock('~/models', () => ({ + deleteSession: jest.fn(), deleteAllUserSessions: jest.fn(), getUserById: jest.fn(), findSession: jest.fn(), updateUser: jest.fn(), findUser: jest.fn(), })); +jest.mock('~/server/services/RefreshTokenBridge', () => ({ + OPENID_REFRESH_BRIDGE_GRACE_MS: 60 * 1000, + createRefreshTokenBridgeFlightKey: jest.fn(() => 'bridge-flight-key'), + deleteRefreshTokenBridges: jest.fn(), + getRefreshTokenBridge: jest.fn(), + storeRefreshTokenBridge: jest.fn(), +})); +jest.mock('~/server/services/OpenIDRefreshFlight', () => ({ + acquireOpenIDRefreshFlight: jest.fn(), + assertOpenIDRefreshFlightDeliveryAvailable: jest.fn(), + assertOpenIDRefreshFlightAvailable: jest.fn(), + assertOpenIDRefreshSessionGenerationAvailable: jest.fn(), + claimOpenIDRefreshFlightDelivery: jest.fn(), + completeOpenIDRefreshFlight: jest.fn(), + createOpenIDRefreshFlightKey: jest.fn(), + failOpenIDRefreshFlight: jest.fn(), + releaseOpenIDRefreshFlightDelivery: jest.fn(), + revokeOpenIDRefreshFlights: jest.fn(), + waitForOpenIDRefreshFlight: jest.fn(), + withOpenIDRefreshFlightLease: jest.fn(), +})); +jest.mock('~/server/services/OpenIDSessionRefresh', () => ({ + refreshOpenIDSession: jest.fn(), +})); jest.mock('@librechat/api', () => ({ + ...jest.requireActual('@librechat/api'), OPENID_EXPIRY_BUFFER_SECONDS: 30, math: jest.fn((value, fallback) => fallback), isEnabled: jest.fn(), findOpenIDUser: jest.fn(), getOpenIdIssuer: jest.fn(() => 'https://issuer.example.com'), + createAuthIdentityContext: jest.fn(({ user }) => ({ + appUserId: user?._id?.toString?.() ?? user?.id, + openidSubject: user?.openidId, + tenantId: user?.tenantId, + openidIssuer: user?.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(/\/+$/, ''); + return ( + Boolean(normalize(sessionIdentity?.appUserId)) && + Boolean(normalize(sessionIdentity?.openidSubject)) && + normalize(sessionIdentity?.appUserId) === normalize(expectedIdentity?.appUserId) && + normalize(sessionIdentity?.openidSubject) === normalize(expectedIdentity?.openidSubject) && + normalize(sessionIdentity?.tenantId) === normalize(expectedIdentity?.tenantId) && + normalizeIssuer(sessionIdentity?.openidIssuer) === + normalizeIssuer(expectedIdentity?.openidIssuer) + ); + }), buildOpenIDRefreshParams: jest.fn(() => { const params = {}; if (process.env.OPENID_SCOPE) { @@ -40,6 +93,7 @@ jest.mock('@librechat/api', () => ({ }), })); +const { createHash } = require('node:crypto'); const openIdClient = require('openid-client'); const jwt = require('jsonwebtoken'); const { logger } = require('@librechat/data-schemas'); @@ -47,25 +101,143 @@ const { isEnabled, findOpenIDUser, buildOpenIDRefreshParams } = require('@librec const { graphTokenController, refreshController } = require('./AuthController'); const { getGraphApiToken } = require('~/server/services/GraphTokenService'); const { + clearOpenIDAuthTokens, + getOpenIDAppAuthToken, setOpenIDAuthTokens, storeOpenIDSession, setCloudFrontAuthCookies, setAuthTokens, } = require('~/server/services/AuthService'); const { getOpenIdConfig, getOpenIdEmail } = require('~/strategies'); -const { getUserById, findSession, updateUser } = require('~/models'); +const { deleteSession, getUserById, findSession, updateUser } = require('~/models'); +const { + createRefreshTokenBridgeFlightKey, + deleteRefreshTokenBridges, + getRefreshTokenBridge, + storeRefreshTokenBridge, +} = require('~/server/services/RefreshTokenBridge'); +const { + acquireOpenIDRefreshFlight, + assertOpenIDRefreshFlightDeliveryAvailable, + assertOpenIDRefreshFlightAvailable, + assertOpenIDRefreshSessionGenerationAvailable, + claimOpenIDRefreshFlightDelivery, + completeOpenIDRefreshFlight, + createOpenIDRefreshFlightKey, + failOpenIDRefreshFlight, + releaseOpenIDRefreshFlightDelivery, + revokeOpenIDRefreshFlights, + waitForOpenIDRefreshFlight, + withOpenIDRefreshFlightLease, +} = require('~/server/services/OpenIDRefreshFlight'); +const { refreshOpenIDSession } = require('~/server/services/OpenIDSessionRefresh'); +const { revokeOpenIDRefreshTokenChain } = require('~/server/services/OpenIDRefreshRecovery'); const ORIGINAL_OPENID_SCOPE = process.env.OPENID_SCOPE; const ORIGINAL_OPENID_REFRESH_AUDIENCE = process.env.OPENID_REFRESH_AUDIENCE; const ORIGINAL_JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET; const ORIGINAL_NODE_ENV = process.env.NODE_ENV; +const { createOpenIDRefreshOwnershipError } = jest.requireActual('@librechat/api'); +const ownershipLost = (message) => createOpenIDRefreshOwnershipError(message); + +describe('OpenID logout refresh chain', () => { + beforeEach(() => { + jest.clearAllMocks(); + createOpenIDRefreshFlightKey.mockImplementation( + ({ refreshToken }) => `session:${refreshToken}`, + ); + createRefreshTokenBridgeFlightKey.mockImplementation( + ({ oldRefreshToken }) => `bridge:${oldRefreshToken}`, + ); + }); + + afterEach(() => { + createRefreshTokenBridgeFlightKey.mockImplementation(() => 'bridge-flight-key'); + }); + + it('tombstones every discovered successor generation before logout completes', async () => { + createOpenIDRefreshFlightKey.mockImplementation( + ({ refreshToken, identityContext }) => + `session:${identityContext.openidSubject}:${refreshToken}`, + ); + createRefreshTokenBridgeFlightKey.mockImplementation( + ({ oldRefreshToken, userId, openidIssuer }) => + `bridge:${userId}:${openidIssuer}:${oldRefreshToken}`, + ); + const acceptedIdentity = { + appUserId: 'user-2', + openidSubject: 'subject-2', + tenantId: 'tenant-1', + openidIssuer: 'https://issuer-2.example.com', + }; + revokeOpenIDRefreshFlights + .mockResolvedValueOnce([{ refresh_token: 'rt-successor-1', acceptedIdentity }, null]) + .mockResolvedValueOnce([{ tokenset: { refresh_token: 'rt-successor-2' } }, null, null, null]) + .mockResolvedValueOnce([null, null, null, null]); + const req = { user: { _id: 'user-1', openidId: 'subject-1' } }; + const user = req.user; + const identityContext = { + appUserId: 'user-1', + openidSubject: 'subject-1', + tenantId: 'tenant-1', + openidIssuer: 'https://issuer.example.com', + }; + + await expect( + revokeOpenIDRefreshTokenChain({ + req, + user, + identityContext, + refreshTokens: ['rt-predecessor'], + publicationKeys: ['recorded-publication-key'], + ttl: 60_000, + }), + ).resolves.toEqual(['rt-predecessor', 'rt-successor-1', 'rt-successor-2']); + + expect(revokeOpenIDRefreshFlights).toHaveBeenNthCalledWith(1, { + keys: [ + 'recorded-publication-key', + 'session:subject-1:rt-predecessor', + 'bridge:user-1:https://issuer.example.com:rt-predecessor', + ], + ttl: 60_000, + }); + expect(revokeOpenIDRefreshFlights).toHaveBeenNthCalledWith(2, { + keys: [ + 'session:subject-1:rt-successor-1', + 'bridge:user-1:https://issuer.example.com:rt-successor-1', + 'session:subject-2:rt-successor-1', + 'bridge:user-2:https://issuer-2.example.com:rt-successor-1', + ], + ttl: 60_000, + }); + expect(revokeOpenIDRefreshFlights).toHaveBeenNthCalledWith(3, { + keys: [ + 'session:subject-1:rt-successor-2', + 'bridge:user-1:https://issuer.example.com:rt-successor-2', + 'session:subject-2:rt-successor-2', + 'bridge:user-2:https://issuer-2.example.com:rt-successor-2', + ], + ttl: 60_000, + }); + }); +}); + describe('graphTokenController', () => { let req, res; beforeEach(() => { jest.clearAllMocks(); isEnabled.mockReturnValue(true); + assertOpenIDRefreshSessionGenerationAvailable.mockResolvedValue(true); + claimOpenIDRefreshFlightDelivery.mockResolvedValue({ + status: 'completed', + ownerId: 'publication-owner', + deliveryId: 'delivery-1', + }); + assertOpenIDRefreshFlightDeliveryAvailable.mockResolvedValue(undefined); + releaseOpenIDRefreshFlightDelivery.mockResolvedValue(undefined); req = { user: { @@ -117,6 +289,99 @@ describe('graphTokenController', () => { }); }); + it('leases the session generation across a Graph OBO exchange and response delivery', async () => { + req.user.federatedTokens.access_token = 'session-access-token'; + req.session = { + openidTokens: { + accessToken: 'session-access-token', + appUserId: 'user-1', + tenantId: 'tenant-1', + publicationFlightKey: 'publication-key', + publicationFlightOwnerId: 'publication-owner', + publicationFlightCreatedAt: 1000, + }, + }; + + await graphTokenController(req, res); + + expect(claimOpenIDRefreshFlightDelivery).toHaveBeenCalledWith({ + key: 'publication-key', + ownerId: 'publication-owner', + createdAt: 1000, + }); + expect(assertOpenIDRefreshFlightDeliveryAvailable).toHaveBeenCalledWith({ + key: 'publication-key', + ownerId: 'publication-owner', + deliveryId: 'delivery-1', + }); + expect(getGraphApiToken).toHaveBeenCalled(); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ access_token: 'graph-access-token' }), + ); + expect(releaseOpenIDRefreshFlightDelivery).toHaveBeenCalledWith({ + key: 'publication-key', + ownerId: 'publication-owner', + deliveryId: 'delivery-1', + }); + }); + + it('does not exchange a session-backed Graph token after logout tombstones its generation', async () => { + req.user.federatedTokens.access_token = 'session-access-token'; + req.session = { + openidTokens: { + accessToken: 'session-access-token', + appUserId: 'user-1', + tenantId: 'tenant-1', + publicationFlightKey: 'publication-key', + publicationFlightOwnerId: 'publication-owner', + }, + }; + assertOpenIDRefreshSessionGenerationAvailable.mockRejectedValueOnce( + ownershipLost('revoked by logout'), + ); + + await graphTokenController(req, res); + + expect(getGraphApiToken).not.toHaveBeenCalled(); + expect(clearOpenIDAuthTokens).toHaveBeenCalledWith(req, res, undefined, 'tenant-1'); + expect(res.status).toHaveBeenCalledWith(401); + }); + + it('does not fall back to a stale Graph token snapshot after the Express session is cleared', async () => { + req.session = {}; + + await graphTokenController(req, res); + + expect(getGraphApiToken).not.toHaveBeenCalled(); + expect(clearOpenIDAuthTokens).toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(401); + }); + + it('withholds a minted Graph token when logout revokes its delivery lease', async () => { + req.user.federatedTokens.access_token = 'session-access-token'; + req.session = { + openidTokens: { + accessToken: 'session-access-token', + appUserId: 'user-1', + tenantId: 'tenant-1', + publicationFlightKey: 'publication-key', + publicationFlightOwnerId: 'publication-owner', + }, + }; + assertOpenIDRefreshFlightDeliveryAvailable.mockRejectedValueOnce( + ownershipLost('logout requested revocation'), + ); + + await graphTokenController(req, res); + + expect(getGraphApiToken).toHaveBeenCalled(); + expect(res.json).not.toHaveBeenCalledWith( + expect.objectContaining({ access_token: 'graph-access-token' }), + ); + expect(releaseOpenIDRefreshFlightDelivery).toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(401); + }); + it('should return 403 when user is not authenticated via Entra ID', async () => { req.user.provider = 'google'; req.user.openidId = undefined; @@ -196,6 +461,8 @@ describe('refreshController – OpenID path', () => { _id: 'user-db-id', email: baseClaims.email, openidId: baseClaims.sub, + tenantId: 'tenant-1', + openidIssuer: baseClaims.iss, password: '$2b$10$hashedpassword', __v: 0, totpSecret: 'encrypted-totp-secret', @@ -215,8 +482,19 @@ describe('refreshController – OpenID path', () => { idpSigningSecret, ); - const makeSignedUserId = (id = 'user-db-id', options = { expiresIn: '1h' }) => - jwt.sign({ id }, process.env.JWT_REFRESH_SECRET, options); + const makeSignedUserId = ( + id = 'user-db-id', + options = { expiresIn: '1h' }, + refreshToken = 'stored-refresh', + ) => + jwt.sign( + { + id, + refreshTokenHash: createHash('sha256').update(refreshToken).digest('base64url'), + }, + process.env.JWT_REFRESH_SECRET, + options, + ); const setOpenIDReuseCookies = (signedUserId = makeSignedUserId()) => { req.headers.cookie = [ @@ -238,18 +516,83 @@ describe('refreshController – OpenID path', () => { mockTokenset.claims.mockReturnValue(baseClaims); getOpenIdEmail.mockReturnValue(baseClaims.email); setOpenIDAuthTokens.mockReturnValue('new-app-token'); + getOpenIDAppAuthToken.mockReturnValue('new-app-token'); storeOpenIDSession.mockResolvedValue(true); setCloudFrontAuthCookies.mockReturnValue(true); findOpenIDUser.mockResolvedValue({ user: { ...defaultUser }, error: null, migration: false }); + getRefreshTokenBridge.mockResolvedValue(null); + storeRefreshTokenBridge.mockResolvedValue('bridge-version-1'); + acquireOpenIDRefreshFlight.mockResolvedValue({ acquired: true, ownerId: 'bridge-owner' }); + assertOpenIDRefreshFlightAvailable.mockResolvedValue({ + status: 'completed', + ownerId: 'bridge-owner', + }); + assertOpenIDRefreshFlightDeliveryAvailable.mockResolvedValue(undefined); + assertOpenIDRefreshSessionGenerationAvailable.mockResolvedValue(true); + claimOpenIDRefreshFlightDelivery.mockResolvedValue({ + status: 'completed', + ownerId: 'publication-owner', + deliveryId: 'delivery-1', + }); + completeOpenIDRefreshFlight.mockResolvedValue({ status: 'completed' }); + failOpenIDRefreshFlight.mockResolvedValue({ status: 'failed' }); + releaseOpenIDRefreshFlightDelivery.mockResolvedValue(undefined); + waitForOpenIDRefreshFlight.mockResolvedValue(null); + withOpenIDRefreshFlightLease.mockImplementation(({ operation }) => + operation({ + assertLeaseOwned: jest.fn().mockResolvedValue(true), + markLeaseSettled: jest.fn(), + }), + ); + refreshOpenIDSession.mockImplementation( + async (refreshReq, _res, _user, _preference, _identity, options = {}) => { + const activeRefreshToken = refreshReq.session.openidTokens.refreshToken; + const refreshParams = buildOpenIDRefreshParams(); + logger.debug('[refreshController] OpenID refresh params', { + has_scope: Boolean(process.env.OPENID_SCOPE), + has_refresh_audience: Boolean(process.env.OPENID_REFRESH_AUDIENCE), + }); + const tokenset = await openIdClient.refreshTokenGrant( + getOpenIdConfig(), + activeRefreshToken, + refreshParams, + ); + if (options.assertLeaseOwned) { + await options.assertLeaseOwned(); + } + logger.debug('[refreshController] OpenID refresh succeeded', { + has_access_token: Boolean(tokenset.access_token), + has_id_token: Boolean(tokenset.id_token), + has_refresh_token: Boolean(tokenset.refresh_token), + expires_in: tokenset.expires_in, + }); + const resolvedTokenset = tokenset.refresh_token + ? tokenset + : { ...tokenset, refresh_token: activeRefreshToken }; + if (!options.deferPublication) { + refreshReq.session.openidTokens = { + ...refreshReq.session.openidTokens, + accessToken: tokenset.access_token, + idToken: tokenset.id_token, + refreshToken: resolvedTokenset.refresh_token, + }; + } + return resolvedTokenset; + }, + ); getUserById.mockResolvedValue({ _id: 'user-db-id', email: baseClaims.email, openidId: baseClaims.sub, + tenantId: 'tenant-1', + openidIssuer: baseClaims.iss, }); updateUser.mockResolvedValue({}); req = { - headers: { cookie: 'token_provider=openid; refreshToken=stored-refresh' }, + headers: { + cookie: `token_provider=openid; refreshToken=stored-refresh; openid_user_id=${makeSignedUserId()}`, + }, session: {}, }; @@ -290,16 +633,170 @@ describe('refreshController – OpenID path', () => { expect(setOpenIDAuthTokens).toHaveBeenCalledWith(mockTokenset, req, res, { userId: 'user-db-id', existingRefreshToken: 'stored-refresh', - tenantId: undefined, + tenantId: 'tenant-1', + openidSubject: baseClaims.sub, + openidIssuer: baseClaims.iss, }); expect(storeOpenIDSession).toHaveBeenCalledWith( 'user-db-id', 'new-refresh', - undefined, + 'tenant-1', 'stored-refresh', ); }; + it('falls back to the browser token only after the advanced session token is rejected', async () => { + req.headers.cookie = `token_provider=openid; refreshToken=rt-cookie-current; openid_user_id=${makeSignedUserId()}`; + req.session = { + openidTokens: { + refreshToken: 'rt-session-stale', + browserRefreshToken: 'rt-browser-stale', + appUserId: 'user-db-id', + }, + reload: jest.fn((callback) => { + req.session.openidTokens = { + accessToken: 'rejected-access', + refreshToken: 'rt-session-stale', + }; + callback(); + }), + }; + openIdClient.refreshTokenGrant + .mockRejectedValueOnce(new Error('invalid_grant')) + .mockResolvedValueOnce(mockTokenset); + + await refreshController(req, res); + + expect(openIdClient.refreshTokenGrant).toHaveBeenNthCalledWith( + 1, + { some: 'config' }, + 'rt-session-stale', + {}, + ); + expect(req.session.reload).toHaveBeenCalled(); + expect(refreshOpenIDSession).toHaveBeenNthCalledWith( + 1, + req, + res, + expect.objectContaining({ _id: 'user-db-id' }), + 'id_token', + expect.objectContaining({ appUserId: 'user-db-id' }), + { deferPublication: true, forceRefresh: true }, + ); + expect(refreshOpenIDSession).toHaveBeenNthCalledWith( + 2, + req, + res, + expect.objectContaining({ _id: 'user-db-id' }), + 'id_token', + expect.objectContaining({ appUserId: 'user-db-id' }), + { deferPublication: true, forceRefresh: true }, + ); + expect(openIdClient.refreshTokenGrant).toHaveBeenNthCalledWith( + 2, + { some: 'config' }, + 'rt-cookie-current', + {}, + ); + expect(setOpenIDAuthTokens).toHaveBeenCalledWith(mockTokenset, req, res, { + userId: 'user-db-id', + existingRefreshToken: 'rt-cookie-current', + tenantId: 'tenant-1', + openidSubject: baseClaims.sub, + openidIssuer: baseClaims.iss, + }); + }); + + it('keeps the advanced session token when an older browser cookie arrives on drift', async () => { + const reusableIdToken = makeSessionToken(); + req.headers.cookie = [ + 'token_provider=openid', + 'refreshToken=rt-cookie-current', + `openid_user_id=${makeSignedUserId()}`, + ].join('; '); + req.session = { + openidTokens: { + accessToken: 'session-access-token', + idToken: reusableIdToken, + refreshToken: 'rt-session-stale', + browserRefreshToken: 'rt-browser-stale', + lastRefreshedAt: Date.now(), + appUserId: 'user-db-id', + openidSubject: baseClaims.sub, + tenantId: 'tenant-1', + openidIssuer: baseClaims.iss, + }, + }; + + await refreshController(req, res); + + expect(getUserById).toHaveBeenCalled(); + expect(setCloudFrontAuthCookies).not.toHaveBeenCalled(); + expect(openIdClient.refreshTokenGrant).toHaveBeenCalledWith( + { some: 'config' }, + 'rt-session-stale', + {}, + ); + expect(setOpenIDAuthTokens).toHaveBeenCalledWith(mockTokenset, req, res, { + userId: 'user-db-id', + existingRefreshToken: 'rt-session-stale', + tenantId: 'tenant-1', + openidSubject: baseClaims.sub, + openidIssuer: baseClaims.iss, + }); + }); + + it('prefers the session token when pre-marker session state differs', async () => { + req.headers.cookie = `token_provider=openid; refreshToken=rt-cookie-current; openid_user_id=${makeSignedUserId()}`; + req.session = { + openidTokens: { + refreshToken: 'rt-session-stale', + appUserId: 'user-db-id', + }, + }; + + await refreshController(req, res); + + expect(openIdClient.refreshTokenGrant).toHaveBeenCalledWith( + { some: 'config' }, + 'rt-session-stale', + {}, + ); + expect(setOpenIDAuthTokens).toHaveBeenCalledWith(mockTokenset, req, res, { + userId: 'user-db-id', + existingRefreshToken: 'rt-session-stale', + tenantId: 'tenant-1', + openidSubject: baseClaims.sub, + openidIssuer: baseClaims.iss, + }); + }); + + it('keeps the session refresh token when the browser cookie matches the session marker', async () => { + req.headers.cookie = `token_provider=openid; refreshToken=rt-browser-stale; openid_user_id=${makeSignedUserId()}`; + req.session = { + openidTokens: { + refreshToken: 'rt-session-current', + browserRefreshToken: 'rt-browser-stale', + appUserId: 'user-db-id', + }, + }; + + await refreshController(req, res); + + expect(openIdClient.refreshTokenGrant).toHaveBeenCalledWith( + { some: 'config' }, + 'rt-session-current', + {}, + ); + expect(setOpenIDAuthTokens).toHaveBeenCalledWith(mockTokenset, req, res, { + userId: 'user-db-id', + existingRefreshToken: 'rt-session-current', + tenantId: 'tenant-1', + openidSubject: baseClaims.sub, + openidIssuer: baseClaims.iss, + }); + }); + it('should call getOpenIdEmail with token claims and use result for findOpenIDUser', async () => { await refreshController(req, res); @@ -314,6 +811,64 @@ describe('refreshController – OpenID path', () => { expect(res.status).toHaveBeenCalledWith(200); }); + it('stores a recovery bridge when durable rotation fails after the IdP grant', async () => { + storeOpenIDSession.mockRejectedValueOnce(new Error('durable transition failed')); + + await refreshController(req, res); + + expect(storeRefreshTokenBridge).toHaveBeenCalledWith({ + oldRefreshToken: 'stored-refresh', + newRefreshToken: 'new-refresh', + userId: 'user-db-id', + tenantId: 'tenant-1', + openidIssuer: baseClaims.iss, + ttl: 60 * 1000, + }); + expect(setOpenIDAuthTokens).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(403); + }); + + it('uses a reloaded advanced session instead of publishing a stale flight result', async () => { + req.session.reload = jest.fn((callback) => { + req.session.openidTokens = { + accessToken: 'advanced-access', + idToken: 'advanced-id', + refreshToken: 'advanced-refresh', + accessTokenExpiresAt: Math.floor(Date.now() / 1000) + 3600, + appUserId: 'advanced-user-id', + openidSubject: 'advanced-subject', + tenantId: 'advanced-tenant', + openidIssuer: 'https://advanced-issuer.example.com', + }; + callback(); + }); + + await refreshController(req, res); + + expect(storeOpenIDSession).toHaveBeenCalledWith( + 'advanced-user-id', + 'advanced-refresh', + 'advanced-tenant', + 'advanced-refresh', + ); + expect(setOpenIDAuthTokens).toHaveBeenCalledWith( + expect.objectContaining({ + access_token: 'advanced-access', + id_token: 'advanced-id', + refresh_token: 'advanced-refresh', + }), + req, + res, + { + userId: 'advanced-user-id', + existingRefreshToken: 'advanced-refresh', + tenantId: 'advanced-tenant', + openidSubject: 'advanced-subject', + openidIssuer: 'https://advanced-issuer.example.com', + }, + ); + }); + it('reuses valid OpenID session tokens and refreshes CloudFront cookies', async () => { const reusableIdToken = makeSessionToken(); const signedUserId = makeSignedUserId(); @@ -324,6 +879,10 @@ describe('refreshController – OpenID path', () => { idToken: reusableIdToken, refreshToken: 'stored-refresh', lastRefreshedAt: Date.now(), + appUserId: 'user-db-id', + openidSubject: baseClaims.sub, + tenantId: 'tenant-1', + openidIssuer: baseClaims.iss, }, }; const user = { @@ -336,6 +895,7 @@ describe('refreshController – OpenID path', () => { expect(openIdClient.refreshTokenGrant).not.toHaveBeenCalled(); expect(setOpenIDAuthTokens).not.toHaveBeenCalled(); + expect(storeRefreshTokenBridge).not.toHaveBeenCalled(); expect(getUserById).toHaveBeenCalledWith( 'user-db-id', '-password -__v -totpSecret -backupCodes -federatedTokens', @@ -369,6 +929,111 @@ describe('refreshController – OpenID path', () => { expect(debugOutput).not.toContain('session-access-token'); }); + it('rejects a late-saved session whose publication generation was tombstoned', async () => { + setOpenIDReuseCookies(); + req.session = { + openidTokens: { + accessToken: 'session-access-token', + idToken: makeSessionToken(), + refreshToken: 'stored-refresh', + lastRefreshedAt: Date.now(), + appUserId: 'user-db-id', + openidSubject: baseClaims.sub, + tenantId: 'tenant-1', + openidIssuer: baseClaims.iss, + publicationFlightKey: 'publication-key', + publicationFlightOwnerId: 'publication-owner', + }, + }; + assertOpenIDRefreshSessionGenerationAvailable.mockRejectedValueOnce( + ownershipLost('revoked by logout'), + ); + + await refreshController(req, res); + + expect(assertOpenIDRefreshSessionGenerationAvailable).toHaveBeenCalledWith({ + key: 'publication-key', + ownerId: 'publication-owner', + }); + expect(clearOpenIDAuthTokens).toHaveBeenCalledWith(req, res, 'user-db-id', 'tenant-1'); + expect(getUserById).not.toHaveBeenCalled(); + expect(setCloudFrontAuthCookies).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(403); + }); + + it('withholds a reusable response when logout revokes its delivery lease', async () => { + setOpenIDReuseCookies(); + req.session = { + openidTokens: { + accessToken: 'session-access-token', + idToken: makeSessionToken(), + refreshToken: 'stored-refresh', + lastRefreshedAt: Date.now(), + appUserId: 'user-db-id', + openidSubject: baseClaims.sub, + tenantId: 'tenant-1', + openidIssuer: baseClaims.iss, + publicationFlightKey: 'publication-key', + publicationFlightOwnerId: 'publication-owner', + publicationFlightCreatedAt: 1000, + }, + }; + assertOpenIDRefreshFlightDeliveryAvailable.mockRejectedValueOnce( + ownershipLost('logout won during user lookup'), + ); + + await refreshController(req, res); + + expect(claimOpenIDRefreshFlightDelivery).toHaveBeenCalledWith({ + key: 'publication-key', + ownerId: 'publication-owner', + createdAt: 1000, + }); + expect(assertOpenIDRefreshFlightDeliveryAvailable).toHaveBeenCalledWith({ + key: 'publication-key', + ownerId: 'publication-owner', + deliveryId: 'delivery-1', + }); + expect(releaseOpenIDRefreshFlightDelivery).toHaveBeenCalled(); + expect(clearOpenIDAuthTokens).toHaveBeenCalledWith(req, res, 'user-db-id', 'tenant-1'); + expect(setCloudFrontAuthCookies).not.toHaveBeenCalled(); + expect(res.send).not.toHaveBeenCalledWith( + expect.objectContaining({ token: expect.any(String) }), + ); + expect(res.status).toHaveBeenCalledWith(403); + }); + + it('falls through to full OpenID refresh when reusable session token identity mismatches', async () => { + setOpenIDReuseCookies(); + req.session = { + openidTokens: { + accessToken: 'session-access-token', + idToken: makeSessionToken(), + refreshToken: 'stored-refresh', + lastRefreshedAt: Date.now(), + appUserId: 'other-user-id', + openidSubject: baseClaims.sub, + tenantId: 'tenant-1', + openidIssuer: baseClaims.iss, + }, + }; + + await refreshController(req, res); + + expect(getUserById).toHaveBeenCalledWith( + 'user-db-id', + '-password -__v -totpSecret -backupCodes -federatedTokens', + ); + expect(setCloudFrontAuthCookies).not.toHaveBeenCalled(); + expectOpenIDRefreshGrant(); + expect(logger.warn).toHaveBeenCalledWith( + '[refreshController] OpenID session token identity mismatch; forcing refresh', + expect.objectContaining({ + userId: 'user-db-id', + }), + ); + }); + it('falls through to full OpenID refresh when session tokens are expired', async () => { const expiredToken = makeSessionToken({ exp: Math.floor(Date.now() / 1000) - 60 }); setOpenIDReuseCookies(); @@ -378,12 +1043,16 @@ describe('refreshController – OpenID path', () => { idToken: expiredToken, refreshToken: 'stored-refresh', lastRefreshedAt: Date.now(), + appUserId: 'user-db-id', + openidSubject: baseClaims.sub, + tenantId: 'tenant-1', + openidIssuer: baseClaims.iss, }, }; await refreshController(req, res); - expect(getUserById).not.toHaveBeenCalled(); + expect(getUserById).toHaveBeenCalled(); expect(setCloudFrontAuthCookies).not.toHaveBeenCalled(); expectOpenIDRefreshGrant(); }); @@ -402,7 +1071,7 @@ describe('refreshController – OpenID path', () => { await refreshController(req, res); - expect(getUserById).not.toHaveBeenCalled(); + expect(getUserById).toHaveBeenCalled(); expectOpenIDRefreshGrant(); }); @@ -420,11 +1089,11 @@ describe('refreshController – OpenID path', () => { await refreshController(req, res); - expect(getUserById).not.toHaveBeenCalled(); + expect(getUserById).toHaveBeenCalled(); expectOpenIDRefreshGrant(); }); - it('falls through to full OpenID refresh when the signed reuse user cookie is invalid', async () => { + it('rejects refresh when neither the session nor signed marker identifies the user', async () => { setOpenIDReuseCookies('tampered-cookie'); req.session = { openidTokens: { @@ -438,7 +1107,8 @@ describe('refreshController – OpenID path', () => { await refreshController(req, res); expect(getUserById).not.toHaveBeenCalled(); - expectOpenIDRefreshGrant(); + expect(openIdClient.refreshTokenGrant).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(403); }); it('falls through to full OpenID refresh when the reuse user no longer exists', async () => { @@ -476,7 +1146,7 @@ describe('refreshController – OpenID path', () => { await refreshController(req, res); - expect(getUserById).not.toHaveBeenCalled(); + expect(getUserById).toHaveBeenCalled(); expectOpenIDRefreshGrant(); }); @@ -493,7 +1163,7 @@ describe('refreshController – OpenID path', () => { await refreshController(req, res); - expect(getUserById).not.toHaveBeenCalled(); + expect(getUserById).toHaveBeenCalled(); expectOpenIDRefreshGrant(); }); @@ -509,7 +1179,7 @@ describe('refreshController – OpenID path', () => { await refreshController(req, res); - expect(getUserById).not.toHaveBeenCalled(); + expect(getUserById).toHaveBeenCalled(); expectOpenIDRefreshGrant(); }); @@ -522,6 +1192,10 @@ describe('refreshController – OpenID path', () => { idToken: reusableIdToken, refreshToken: 'stored-refresh', lastRefreshedAt: Date.now(), + appUserId: 'user-db-id', + openidSubject: baseClaims.sub, + tenantId: 'tenant-1', + openidIssuer: baseClaims.iss, }, }; const userDocument = { @@ -720,10 +1394,795 @@ describe('refreshController – OpenID path', () => { await refreshController(req, res); + expect(getRefreshTokenBridge).toHaveBeenCalled(); expect(res.status).toHaveBeenCalledWith(403); expect(res.send).toHaveBeenCalledWith('Invalid OpenID refresh token'); }); + it('does not use the bridge when signed user-id cookie payload is invalid', async () => { + setOpenIDReuseCookies(jwt.sign({ id: 123 }, process.env.JWT_REFRESH_SECRET)); + openIdClient.refreshTokenGrant.mockRejectedValue(new Error('invalid_grant')); + + await refreshController(req, res); + + expect(getUserById).not.toHaveBeenCalled(); + expect(getRefreshTokenBridge).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(403); + }); + + it('does not use the bridge when the signed marker belongs to another refresh token', async () => { + setOpenIDReuseCookies(makeSignedUserId('user-db-id', { expiresIn: '1h' }, 'different-refresh')); + openIdClient.refreshTokenGrant.mockRejectedValue(new Error('invalid_grant')); + + await refreshController(req, res); + + expect(getUserById).toHaveBeenCalled(); + expect(getRefreshTokenBridge).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(403); + }); + + it('does not use the bridge when the signed marker lacks a refresh-token binding', async () => { + setOpenIDReuseCookies( + jwt.sign({ id: 'user-db-id' }, process.env.JWT_REFRESH_SECRET, { expiresIn: '1h' }), + ); + openIdClient.refreshTokenGrant.mockRejectedValue(new Error('invalid_grant')); + + await refreshController(req, res); + + expect(getUserById).toHaveBeenCalled(); + expect(getRefreshTokenBridge).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(403); + }); + + /** The recovery grant need not rotate. When it does not, the browser must still be moved onto + * the bridged token rather than back onto the stale one the bridge exists to retire. */ + it('installs the bridged token when the recovery grant does not rotate', async () => { + setOpenIDReuseCookies(); + req.session = {}; + getUserById.mockResolvedValue({ + _id: 'user-db-id', + email: baseClaims.email, + openidId: baseClaims.sub, + tenantId: 'tenant-1', + openidIssuer: 'https://issuer.example.com', + }); + getRefreshTokenBridge.mockResolvedValue('bridged-refresh'); + const nonRotatingTokenset = { ...mockTokenset }; + delete nonRotatingTokenset.refresh_token; + openIdClient.refreshTokenGrant + .mockRejectedValueOnce(new Error('invalid_grant')) + .mockResolvedValueOnce(nonRotatingTokenset); + + await refreshController(req, res); + + expect(storeOpenIDSession).toHaveBeenCalledWith( + 'user-db-id', + 'bridged-refresh', + 'tenant-1', + 'stored-refresh', + ); + expect(setOpenIDAuthTokens).toHaveBeenCalledWith( + expect.objectContaining({ refresh_token: 'bridged-refresh' }), + req, + res, + expect.any(Object), + ); + expect(res.status).toHaveBeenCalledWith(200); + }); + + /** A recovery grant that omits `id_token` leaves the rebuilt token set with no identity + * material of its own; the refresh carries the stripped token in a non-enumerable marker so + * claims still resolve without that expired token re-entering the auth response. */ + it('resolves identity from the marker when the refresh stripped an expired id_token', async () => { + setOpenIDReuseCookies(); + req.session = {}; + getUserById.mockResolvedValue({ + _id: 'user-db-id', + email: baseClaims.email, + openidId: baseClaims.sub, + tenantId: 'tenant-1', + openidIssuer: 'https://issuer.example.com', + }); + getRefreshTokenBridge.mockResolvedValue('bridged-refresh'); + let refreshCall = 0; + refreshOpenIDSession.mockImplementation(async () => { + refreshCall += 1; + if (refreshCall === 1) { + throw new Error('invalid_grant'); + } + const stripped = { + access_token: 'new-access', + refresh_token: 'bridged-refresh', + expires_at: Math.floor(Date.now() / 1000) + 3600, + }; + Object.defineProperty(stripped, '__identityIdToken', { + value: jwt.sign(baseClaims, 'idp-secret'), + enumerable: false, + configurable: true, + }); + return stripped; + }); + + await refreshController(req, res); + + expect(res.status).toHaveBeenCalledWith(200); + expect(setOpenIDAuthTokens).toHaveBeenCalledWith( + expect.objectContaining({ refresh_token: 'bridged-refresh' }), + req, + res, + expect.any(Object), + ); + expect(setOpenIDAuthTokens.mock.calls.at(-1)[0].id_token).toBeUndefined(); + }); + + it('recovers stale refresh-token cookies and keeps a short grace bridge', async () => { + setOpenIDReuseCookies(); + req.session = {}; + const bridgeUser = { + _id: 'user-db-id', + email: baseClaims.email, + openidId: baseClaims.sub, + tenantId: 'tenant-1', + openidIssuer: 'https://issuer.example.com', + }; + getUserById.mockResolvedValue(bridgeUser); + getRefreshTokenBridge.mockResolvedValue('bridged-refresh'); + openIdClient.refreshTokenGrant + .mockRejectedValueOnce(new Error('invalid_grant')) + .mockResolvedValueOnce(mockTokenset); + + await refreshController(req, res); + + expect(getUserById).toHaveBeenCalledWith( + 'user-db-id', + '-password -__v -totpSecret -backupCodes -federatedTokens', + ); + expect(getRefreshTokenBridge).toHaveBeenCalledWith({ + oldRefreshToken: 'stored-refresh', + userId: 'user-db-id', + tenantId: 'tenant-1', + openidIssuer: 'https://issuer.example.com', + }); + expect(openIdClient.refreshTokenGrant).toHaveBeenNthCalledWith( + 1, + { some: 'config' }, + 'stored-refresh', + {}, + ); + expect(openIdClient.refreshTokenGrant).toHaveBeenNthCalledWith( + 2, + { some: 'config' }, + 'bridged-refresh', + {}, + ); + expect(findOpenIDUser).toHaveBeenCalledWith( + expect.objectContaining({ + strategyName: 'refreshController (bridge recovery)', + }), + ); + expect(setOpenIDAuthTokens).toHaveBeenCalledTimes(1); + expect(setOpenIDAuthTokens).toHaveBeenCalledWith(mockTokenset, req, res, { + userId: 'user-db-id', + existingRefreshToken: 'stored-refresh', + tenantId: 'tenant-1', + openidSubject: baseClaims.sub, + openidIssuer: baseClaims.iss, + }); + expect(completeOpenIDRefreshFlight).toHaveBeenCalledWith({ + key: 'bridge-flight-key', + ownerId: 'bridge-owner', + tokens: expect.objectContaining({ + appAuthToken: 'new-app-token', + claims: baseClaims, + tokenset: mockTokenset, + }), + }); + expect(completeOpenIDRefreshFlight.mock.invocationCallOrder[0]).toBeLessThan( + setOpenIDAuthTokens.mock.invocationCallOrder[0], + ); + expect(storeRefreshTokenBridge).toHaveBeenCalledWith({ + oldRefreshToken: 'stored-refresh', + newRefreshToken: 'new-refresh', + userId: 'user-db-id', + tenantId: 'tenant-1', + openidIssuer: 'https://issuer.example.com', + ttl: 60000, + }); + /** Recovery issues a fresh credential like any other refresh, so the durable session has to + * follow it β€” otherwise the record still names the token the bridge just replaced. */ + expect(storeOpenIDSession).toHaveBeenCalledWith( + 'user-db-id', + 'new-refresh', + 'tenant-1', + 'stored-refresh', + ); + const lookupIdentity = getRefreshTokenBridge.mock.calls[0][0]; + const graceIdentity = storeRefreshTokenBridge.mock.calls[0][0]; + expect(graceIdentity).toEqual( + expect.objectContaining({ + oldRefreshToken: lookupIdentity.oldRefreshToken, + userId: lookupIdentity.userId, + tenantId: lookupIdentity.tenantId, + openidIssuer: lookupIdentity.openidIssuer, + }), + ); + expect(res.status).toHaveBeenCalledWith(200); + }); + + it('joins an existing stale-cookie recovery without rotating the bridged token again', async () => { + setOpenIDReuseCookies(); + req.session = {}; + const bridgeUser = { + _id: 'user-db-id', + email: baseClaims.email, + openidId: baseClaims.sub, + tenantId: 'tenant-1', + openidIssuer: 'https://issuer.example.com', + }; + getUserById.mockResolvedValue(bridgeUser); + getRefreshTokenBridge.mockResolvedValue('bridged-refresh'); + openIdClient.refreshTokenGrant.mockRejectedValueOnce(new Error('invalid_grant')); + acquireOpenIDRefreshFlight.mockResolvedValue({ acquired: false, ownerId: 'other-owner' }); + waitForOpenIDRefreshFlight.mockResolvedValue({ + appAuthToken: 'shared-app-token', + __flightOwnerId: 'shared-owner', + tokenset: { ...mockTokenset }, + claims: baseClaims, + openidIssuer: baseClaims.iss, + }); + getOpenIDAppAuthToken.mockReturnValueOnce('shared-app-token'); + setOpenIDAuthTokens.mockReturnValueOnce('shared-app-token'); + + await refreshController(req, res); + + expect(createRefreshTokenBridgeFlightKey).toHaveBeenCalledWith({ + oldRefreshToken: 'stored-refresh', + userId: 'user-db-id', + tenantId: 'tenant-1', + openidIssuer: 'https://issuer.example.com', + }); + expect(openIdClient.refreshTokenGrant).toHaveBeenCalledTimes(1); + expect(withOpenIDRefreshFlightLease).not.toHaveBeenCalled(); + expect(setOpenIDAuthTokens).toHaveBeenCalledWith( + expect.objectContaining({ refresh_token: 'new-refresh' }), + req, + res, + expect.objectContaining({ + userId: 'user-db-id', + existingRefreshToken: 'stored-refresh', + }), + ); + expect(storeOpenIDSession).not.toHaveBeenCalled(); + expect(res.send).toHaveBeenCalledWith(expect.objectContaining({ token: 'shared-app-token' })); + expect(res.status).toHaveBeenCalledWith(200); + }); + + it('publishes an ordinary completed result to a cross-replica follower', async () => { + setOpenIDReuseCookies(); + req.session = {}; + getUserById.mockResolvedValue({ + ...defaultUser, + openidIssuer: 'https://predecessor.example.com', + }); + acquireOpenIDRefreshFlight.mockResolvedValue({ acquired: false, ownerId: 'other-owner' }); + waitForOpenIDRefreshFlight.mockResolvedValue({ + appAuthToken: 'shared-app-token', + __flightOwnerId: 'shared-owner', + tokenset: { ...mockTokenset }, + claims: baseClaims, + openidIssuer: baseClaims.iss, + }); + getOpenIDAppAuthToken.mockReturnValueOnce('shared-app-token'); + setOpenIDAuthTokens.mockReturnValueOnce('shared-app-token'); + + await refreshController(req, res); + + expect(createRefreshTokenBridgeFlightKey).toHaveBeenCalledWith({ + oldRefreshToken: 'stored-refresh', + userId: 'user-db-id', + tenantId: 'tenant-1', + openidIssuer: 'https://predecessor.example.com', + }); + expect(storeOpenIDSession).not.toHaveBeenCalled(); + expect(setOpenIDAuthTokens).toHaveBeenCalledWith( + expect.objectContaining({ refresh_token: 'new-refresh' }), + req, + res, + expect.objectContaining({ + userId: 'user-db-id', + existingRefreshToken: 'stored-refresh', + openidIssuer: baseClaims.iss, + }), + ); + expect(res.send).toHaveBeenCalledWith(expect.objectContaining({ token: 'shared-app-token' })); + expect(res.status).toHaveBeenCalledWith(200); + }); + + it('claims the completed generation before delivering a newly refreshed app token', async () => { + setOpenIDReuseCookies(); + req.session = {}; + setOpenIDAuthTokens.mockImplementationOnce(() => { + req.session.openidTokens = { + accessToken: 'new-access', + refreshToken: 'new-refresh', + appUserId: 'user-db-id', + tenantId: 'tenant-1', + }; + return 'new-app-token'; + }); + + await refreshController(req, res); + + expect(claimOpenIDRefreshFlightDelivery).toHaveBeenCalledWith({ + key: 'bridge-flight-key', + ownerId: 'bridge-owner', + createdAt: expect.any(Number), + }); + expect(assertOpenIDRefreshFlightDeliveryAvailable).toHaveBeenCalledWith({ + key: 'bridge-flight-key', + ownerId: 'bridge-owner', + deliveryId: 'delivery-1', + }); + expect(res.send).toHaveBeenCalledWith(expect.objectContaining({ token: 'new-app-token' })); + expect(releaseOpenIDRefreshFlightDelivery).toHaveBeenCalled(); + }); + + it('does not deliver a newly refreshed app token after logout requests revocation', async () => { + setOpenIDReuseCookies(); + req.session = {}; + setOpenIDAuthTokens.mockImplementationOnce(() => { + req.session.openidTokens = { + accessToken: 'new-access', + refreshToken: 'new-refresh', + appUserId: 'user-db-id', + tenantId: 'tenant-1', + }; + return 'new-app-token'; + }); + assertOpenIDRefreshFlightDeliveryAvailable.mockRejectedValueOnce( + ownershipLost('logout requested revocation'), + ); + + await refreshController(req, res); + + expect(releaseOpenIDRefreshFlightDelivery).toHaveBeenCalled(); + expect(clearOpenIDAuthTokens).toHaveBeenCalled(); + expect(res.send).not.toHaveBeenCalledWith(expect.objectContaining({ token: 'new-app-token' })); + expect(res.status).toHaveBeenCalledWith(403); + }); + + it('clears a follower publication when logout revokes its generation after emission', async () => { + setOpenIDReuseCookies(); + req.session = {}; + getUserById.mockResolvedValue(defaultUser); + acquireOpenIDRefreshFlight.mockResolvedValue({ acquired: false, ownerId: 'other-owner' }); + waitForOpenIDRefreshFlight.mockResolvedValue({ + appAuthToken: 'shared-app-token', + __flightOwnerId: 'shared-owner', + tokenset: { ...mockTokenset }, + claims: baseClaims, + openidIssuer: baseClaims.iss, + }); + getOpenIDAppAuthToken.mockReturnValueOnce('shared-app-token'); + setOpenIDAuthTokens.mockImplementationOnce(() => { + req.session.openidTokens = { + accessToken: 'new-access', + refreshToken: 'new-refresh', + }; + return 'shared-app-token'; + }); + assertOpenIDRefreshFlightAvailable + .mockResolvedValueOnce({ status: 'completed', ownerId: 'shared-owner' }) + .mockResolvedValueOnce({ status: 'completed', ownerId: 'shared-owner' }) + .mockResolvedValueOnce({ status: 'completed', ownerId: 'shared-owner' }) + .mockRejectedValueOnce(ownershipLost('revoked after emission')); + + await refreshController(req, res); + + expect(setOpenIDAuthTokens).toHaveBeenCalled(); + expect(req.session.openidTokens).toEqual( + expect.objectContaining({ + publicationFlightKey: 'bridge-flight-key', + publicationFlightOwnerId: 'shared-owner', + }), + ); + expect(clearOpenIDAuthTokens).toHaveBeenCalledWith(req, res, 'user-db-id', 'tenant-1'); + expect(deleteSession).toHaveBeenCalledWith({ refreshToken: 'new-refresh' }); + expect(res.status).toHaveBeenCalledWith(403); + expect(res.send).not.toHaveBeenCalledWith( + expect.objectContaining({ token: 'shared-app-token' }), + ); + }); + + it('returns a newer stable-refresh session instead of a stale publication result', async () => { + setOpenIDReuseCookies(); + req.session = { + reload: jest.fn((callback) => { + req.session.openidTokens = { + accessToken: 'advanced-access', + idToken: 'advanced-id', + refreshToken: 'stored-refresh', + accessTokenExpiresAt: Math.floor(Date.now() / 1000) + 7200, + appUserId: 'user-db-id', + openidSubject: baseClaims.sub, + tenantId: 'tenant-1', + openidIssuer: baseClaims.iss, + publicationFlightKey: 'advanced-publication-key', + publicationFlightOwnerId: 'advanced-publication-owner', + }; + callback(); + }), + }; + getUserById.mockResolvedValue(defaultUser); + acquireOpenIDRefreshFlight.mockResolvedValue({ acquired: false, ownerId: 'other-owner' }); + waitForOpenIDRefreshFlight.mockResolvedValue({ + appAuthToken: 'stale-app-token', + __flightOwnerId: 'shared-owner', + tokenset: { + access_token: 'stale-access', + id_token: 'stale-id', + refresh_token: 'stored-refresh', + expires_in: 3600, + }, + claims: baseClaims, + openidIssuer: baseClaims.iss, + predecessorAccessToken: 'predecessor-access', + }); + getOpenIDAppAuthToken.mockReturnValueOnce('advanced-app-token'); + setOpenIDAuthTokens.mockReturnValueOnce('advanced-app-token'); + + await refreshController(req, res); + + expect(setOpenIDAuthTokens).toHaveBeenCalledWith( + expect.objectContaining({ + access_token: 'advanced-access', + refresh_token: 'stored-refresh', + }), + req, + res, + expect.any(Object), + ); + expect(res.send).toHaveBeenCalledWith(expect.objectContaining({ token: 'advanced-app-token' })); + expect(req.session.openidTokens).toEqual( + expect.objectContaining({ + publicationFlightKey: 'advanced-publication-key', + publicationFlightOwnerId: 'advanced-publication-owner', + }), + ); + }); + + it('recovers with serialized identity claims when the refreshed ID token is omitted', async () => { + setOpenIDReuseCookies(); + req.session = {}; + getRefreshTokenBridge.mockResolvedValue('bridged-refresh'); + refreshOpenIDSession + .mockRejectedValueOnce(Object.assign(new Error('invalid_grant'), { error: 'invalid_grant' })) + .mockResolvedValueOnce({ + access_token: 'new-access', + refresh_token: 'new-refresh', + expires_at: Math.floor(Date.now() / 1000) + 3600, + __identityClaims: baseClaims, + }); + + await refreshController(req, res); + + expect(findOpenIDUser).toHaveBeenCalledWith( + expect.objectContaining({ + email: baseClaims.email, + openidId: baseClaims.sub, + strategyName: 'refreshController (bridge recovery)', + }), + ); + expect(setOpenIDAuthTokens).toHaveBeenCalledWith( + expect.objectContaining({ + access_token: 'new-access', + __identityClaims: baseClaims, + }), + req, + res, + expect.objectContaining({ openidSubject: baseClaims.sub }), + ); + expect(setOpenIDAuthTokens.mock.calls[0][0]).not.toHaveProperty('id_token'); + expect(res.status).toHaveBeenCalledWith(200); + }); + + it('rejects bridge recovery when retry resolves a different user than the signed cookie', async () => { + setOpenIDReuseCookies(makeSignedUserId('cookie-user-id')); + req.session = {}; + getUserById.mockResolvedValue({ + _id: 'cookie-user-id', + tenantId: 'tenant-1', + openidIssuer: 'https://issuer.example.com', + }); + getRefreshTokenBridge.mockResolvedValue('bridged-refresh'); + findOpenIDUser.mockResolvedValueOnce({ + user: { ...defaultUser, _id: 'different-user-id' }, + error: null, + migration: false, + }); + openIdClient.refreshTokenGrant + .mockRejectedValueOnce(new Error('invalid_grant')) + .mockResolvedValueOnce(mockTokenset); + + await refreshController(req, res); + + expect(openIdClient.refreshTokenGrant).toHaveBeenCalledTimes(2); + expect(setOpenIDAuthTokens).not.toHaveBeenCalled(); + expect(storeRefreshTokenBridge).not.toHaveBeenCalled(); + expect(logger.warn).toHaveBeenCalledWith( + '[refreshController] Bridge recovery resolved a different user; refusing token issuance', + { + cookieUserId: 'cookie-user-id', + resolvedUserId: 'different-user-id', + }, + ); + expect(res.status).toHaveBeenCalledWith(403); + expect(res.send).toHaveBeenCalledWith('Invalid OpenID refresh token'); + }); + + it('does not re-store the bridge when bridged refresh retry fails', async () => { + setOpenIDReuseCookies(); + req.session = {}; + getUserById.mockResolvedValue({ + _id: 'user-db-id', + tenantId: 'tenant-1', + openidIssuer: 'https://issuer.example.com', + }); + getRefreshTokenBridge.mockResolvedValue('bridged-refresh'); + openIdClient.refreshTokenGrant + .mockRejectedValueOnce(new Error('invalid_grant')) + .mockRejectedValueOnce(new Error('temporarily unavailable')); + + await refreshController(req, res); + + expect(getRefreshTokenBridge).toHaveBeenCalled(); + expect(storeRefreshTokenBridge).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(403); + }); + + it('returns success when bridge grace-period storage fails after bridged refresh succeeds', async () => { + setOpenIDReuseCookies(); + req.session = {}; + getUserById.mockResolvedValue({ + _id: 'user-db-id', + email: baseClaims.email, + openidId: baseClaims.sub, + tenantId: 'tenant-1', + openidIssuer: 'https://issuer.example.com', + }); + getRefreshTokenBridge.mockResolvedValue('bridged-refresh'); + storeRefreshTokenBridge.mockRejectedValueOnce(new Error('grace failed')); + openIdClient.refreshTokenGrant + .mockRejectedValueOnce(new Error('invalid_grant')) + .mockResolvedValueOnce(mockTokenset); + + await refreshController(req, res); + + expect(setOpenIDAuthTokens).toHaveBeenCalledWith(mockTokenset, req, res, { + userId: 'user-db-id', + existingRefreshToken: 'stored-refresh', + tenantId: 'tenant-1', + openidSubject: baseClaims.sub, + openidIssuer: baseClaims.iss, + }); + expect(storeRefreshTokenBridge).toHaveBeenCalledWith({ + oldRefreshToken: 'stored-refresh', + newRefreshToken: 'new-refresh', + userId: 'user-db-id', + tenantId: 'tenant-1', + openidIssuer: 'https://issuer.example.com', + ttl: 60000, + }); + expect(logger.warn).toHaveBeenCalledWith( + '[refreshController] Bridge grace-period storage failed after successful recovery', + expect.any(Error), + ); + expect(res.status).toHaveBeenCalledWith(200); + }); + + it('removes a grace bridge published concurrently with logout revocation', async () => { + setOpenIDReuseCookies(); + req.session = {}; + getUserById.mockResolvedValue({ + _id: 'user-db-id', + email: baseClaims.email, + openidId: baseClaims.sub, + tenantId: 'tenant-1', + openidIssuer: 'https://issuer.example.com', + }); + getRefreshTokenBridge.mockResolvedValue('bridged-refresh'); + openIdClient.refreshTokenGrant + .mockRejectedValueOnce(new Error('invalid_grant')) + .mockResolvedValueOnce(mockTokenset); + const assertLeaseOwned = jest + .fn() + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(true) + .mockRejectedValueOnce(ownershipLost('revoked by logout')); + withOpenIDRefreshFlightLease.mockImplementationOnce(({ operation }) => + operation({ assertLeaseOwned, markLeaseSettled: jest.fn() }), + ); + + await refreshController(req, res); + + expect(deleteRefreshTokenBridges).toHaveBeenCalledWith({ + refreshTokens: ['stored-refresh'], + userId: 'user-db-id', + tenantId: 'tenant-1', + version: 'bridge-version-1', + }); + expect(completeOpenIDRefreshFlight).not.toHaveBeenCalled(); + expect(setOpenIDAuthTokens).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(403); + }); + + it('keeps the grace bridge when the ownership check fails for an undetermined reason', async () => { + setOpenIDReuseCookies(); + req.session = {}; + getUserById.mockResolvedValue({ + _id: 'user-db-id', + email: baseClaims.email, + openidId: baseClaims.sub, + tenantId: 'tenant-1', + openidIssuer: 'https://issuer.example.com', + }); + getRefreshTokenBridge.mockResolvedValue('bridged-refresh'); + openIdClient.refreshTokenGrant + .mockRejectedValueOnce(new Error('invalid_grant')) + .mockResolvedValueOnce(mockTokenset); + const assertLeaseOwned = jest + .fn() + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(true) + .mockRejectedValueOnce(new Error('connection timed out')); + withOpenIDRefreshFlightLease.mockImplementationOnce(({ operation }) => + operation({ assertLeaseOwned, markLeaseSettled: jest.fn() }), + ); + + await refreshController(req, res); + + expect(deleteRefreshTokenBridges).not.toHaveBeenCalled(); + expect(completeOpenIDRefreshFlight).not.toHaveBeenCalled(); + expect(setOpenIDAuthTokens).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(403); + }); + + it('removes a prepared durable session when logout wins before publication commits', async () => { + setOpenIDReuseCookies(); + req.session = {}; + getRefreshTokenBridge.mockResolvedValue('bridged-refresh'); + openIdClient.refreshTokenGrant + .mockRejectedValueOnce(new Error('invalid_grant')) + .mockResolvedValueOnce(mockTokenset); + const assertLeaseOwned = jest + .fn() + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(true) + .mockRejectedValueOnce(ownershipLost('revoked by logout')); + withOpenIDRefreshFlightLease.mockImplementationOnce(({ operation }) => + operation({ assertLeaseOwned, markLeaseSettled: jest.fn() }), + ); + + await refreshController(req, res); + + expect(storeOpenIDSession).toHaveBeenCalledWith( + 'user-db-id', + 'new-refresh', + 'tenant-1', + 'stored-refresh', + ); + expect(deleteSession).toHaveBeenCalledWith({ refreshToken: 'new-refresh' }); + expect(clearOpenIDAuthTokens).toHaveBeenCalledWith(req, res, 'user-db-id', 'tenant-1'); + expect(completeOpenIDRefreshFlight).not.toHaveBeenCalled(); + expect(setOpenIDAuthTokens).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(403); + }); + + it('cleans the prepared successor before emission when logout wins the final commit', async () => { + setOpenIDReuseCookies(); + req.session = {}; + getRefreshTokenBridge.mockResolvedValue('bridged-refresh'); + openIdClient.refreshTokenGrant + .mockRejectedValueOnce(new Error('invalid_grant')) + .mockResolvedValueOnce(mockTokenset); + completeOpenIDRefreshFlight.mockResolvedValueOnce(null); + + await refreshController(req, res); + + expect(setOpenIDAuthTokens).not.toHaveBeenCalled(); + expect(deleteSession).toHaveBeenCalledWith({ refreshToken: 'new-refresh' }); + expect(clearOpenIDAuthTokens).toHaveBeenCalledWith(req, res, 'user-db-id', 'tenant-1'); + expect(res.status).toHaveBeenCalledWith(403); + }); + + it('preserves the successor and bridge when publication completion is indeterminate', async () => { + setOpenIDReuseCookies(); + req.session = {}; + getRefreshTokenBridge.mockResolvedValue('bridged-refresh'); + openIdClient.refreshTokenGrant + .mockRejectedValueOnce(new Error('invalid_grant')) + .mockResolvedValueOnce(mockTokenset); + completeOpenIDRefreshFlight.mockRejectedValueOnce(new Error('mongo timeout')); + assertOpenIDRefreshFlightAvailable.mockRejectedValueOnce(new Error('mongo read timeout')); + + await refreshController(req, res); + + expect(storeOpenIDSession).toHaveBeenCalledWith( + 'user-db-id', + 'new-refresh', + 'tenant-1', + 'stored-refresh', + ); + expect(storeRefreshTokenBridge).toHaveBeenCalledWith( + expect.objectContaining({ + oldRefreshToken: 'stored-refresh', + newRefreshToken: 'new-refresh', + ttl: 60000, + }), + ); + expect(deleteSession).not.toHaveBeenCalledWith({ refreshToken: 'new-refresh' }); + expect(clearOpenIDAuthTokens).not.toHaveBeenCalled(); + expect(setOpenIDAuthTokens).not.toHaveBeenCalled(); + expect(failOpenIDRefreshFlight).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(403); + }); + + it('does not publish a nested bridge refresh after the outer logout fence is revoked', async () => { + setOpenIDReuseCookies(); + req.session = {}; + getUserById.mockResolvedValue({ + _id: 'user-db-id', + email: baseClaims.email, + openidId: baseClaims.sub, + tenantId: 'tenant-1', + openidIssuer: 'https://issuer.example.com', + }); + getRefreshTokenBridge.mockResolvedValue('bridged-refresh'); + openIdClient.refreshTokenGrant + .mockRejectedValueOnce(new Error('invalid_grant')) + .mockResolvedValueOnce(mockTokenset); + const assertLeaseOwned = jest.fn().mockRejectedValueOnce(ownershipLost('revoked by logout')); + withOpenIDRefreshFlightLease.mockImplementationOnce(({ operation }) => + operation({ assertLeaseOwned, markLeaseSettled: jest.fn() }), + ); + + await refreshController(req, res); + + expect(refreshOpenIDSession).toHaveBeenCalledWith( + expect.anything(), + res, + expect.objectContaining({ _id: 'user-db-id' }), + 'id_token', + expect.anything(), + expect.objectContaining({ + assertLeaseOwned, + deferPublication: true, + forceRefresh: true, + }), + ); + expect(storeRefreshTokenBridge).not.toHaveBeenCalled(); + expect(completeOpenIDRefreshFlight).not.toHaveBeenCalled(); + expect(setOpenIDAuthTokens).not.toHaveBeenCalled(); + expect(req.session.openidTokens?.accessToken).toBeUndefined(); + expect(res.status).toHaveBeenCalledWith(403); + }); + + it('does not use the bridge for generic HTTP 400 errors without invalid_grant', async () => { + setOpenIDReuseCookies(); + openIdClient.refreshTokenGrant.mockRejectedValue( + Object.assign(new Error('bad request'), { status: 400 }), + ); + + await refreshController(req, res); + + expect(getRefreshTokenBridge).not.toHaveBeenCalled(); + expect(storeRefreshTokenBridge).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(403); + }); + it('should skip OpenID path when token_provider is not openid', async () => { req.headers.cookie = 'token_provider=local; refreshToken=some-token'; diff --git a/api/server/controllers/assistants/v1.js b/api/server/controllers/assistants/v1.js index 7116edda75..2f53c07632 100644 --- a/api/server/controllers/assistants/v1.js +++ b/api/server/controllers/assistants/v1.js @@ -44,6 +44,7 @@ const createAssistant = async (req, res) => { const { toolDefinitions, accessibleServerNames } = await getAssistantToolDefinitions({ req, + res, tools, }); const healedTools = await healMcpToolNames({ @@ -172,6 +173,7 @@ const patchAssistant = async (req, res) => { const { toolDefinitions, accessibleServerNames } = await getAssistantToolDefinitions({ req, + res, tools: updateData.tools, }); const healedTools = await healMcpToolNames({ diff --git a/api/server/controllers/assistants/v2.js b/api/server/controllers/assistants/v2.js index afaf04f5d0..e6e9a5d0ef 100644 --- a/api/server/controllers/assistants/v2.js +++ b/api/server/controllers/assistants/v2.js @@ -34,6 +34,7 @@ const createAssistant = async (req, res) => { const { toolDefinitions, accessibleServerNames } = await getAssistantToolDefinitions({ req, + res, tools, }); const healedTools = await healMcpToolNames({ @@ -153,6 +154,7 @@ const updateAssistant = async ({ req, openai, assistant_id, updateData }) => { let hasFileSearch = false; const { toolDefinitions, accessibleServerNames } = await getAssistantToolDefinitions({ req, + res: req.res, tools: updateData.tools, }); const healedTools = await healMcpToolNames({ diff --git a/api/server/controllers/auth/LogoutController.js b/api/server/controllers/auth/LogoutController.js index 95e6c8965c..cdf5404569 100644 --- a/api/server/controllers/auth/LogoutController.js +++ b/api/server/controllers/auth/LogoutController.js @@ -1,7 +1,9 @@ const cookies = require('cookie'); -const { isEnabled, clearCloudFrontCookies } = require('@librechat/api'); -const { logger } = require('@librechat/data-schemas'); +const { isEnabled, math, clearCloudFrontCookies } = require('@librechat/api'); +const { logger, DEFAULT_REFRESH_TOKEN_EXPIRY } = require('@librechat/data-schemas'); const { logoutUser } = require('~/server/services/AuthService'); +const { deleteAllRefreshTokenBridges } = require('~/server/services/RefreshTokenBridge'); +const { revokeOpenIDRefreshTokenChain } = require('~/server/services/OpenIDRefreshRecovery'); const { getOpenIdConfig } = require('~/strategies'); /** Parses and validates OPENID_MAX_LOGOUT_URL_LENGTH, returning defaultValue on invalid input */ @@ -27,16 +29,54 @@ const logoutController = async (req, res) => { let refreshToken; let idToken; + let sessionRefreshToken; if (isOpenIdUser && req.session?.openidTokens) { - refreshToken = req.session.openidTokens.refreshToken; + sessionRefreshToken = req.session.openidTokens.refreshToken; idToken = req.session.openidTokens.idToken; - delete req.session.openidTokens; } - refreshToken = refreshToken || parsedCookies.refreshToken; + /** Both can name distinct durable sessions when an older browser request races rotation. */ + refreshToken = parsedCookies.refreshToken || sessionRefreshToken; idToken = idToken || parsedCookies.openid_id_token; + const logoutTokens = isOpenIdUser + ? [...new Set([parsedCookies.refreshToken, sessionRefreshToken].filter(Boolean))] + : [refreshToken]; try { - const logout = await logoutUser(req, refreshToken); + if (isOpenIdUser) { + const userId = req.user?.id ?? req.user?._id?.toString?.(); + const refreshIdentity = { + appUserId: userId, + openidSubject: req.session?.openidTokens?.openidSubject ?? req.user?.openidId, + tenantId: req.session?.openidTokens?.tenantId ?? req.user?.tenantId, + openidIssuer: req.session?.openidTokens?.openidIssuer ?? req.user?.openidIssuer, + }; + const revokedRefreshTokens = await revokeOpenIDRefreshTokenChain({ + req, + user: req.user, + identityContext: refreshIdentity, + refreshTokens: [...logoutTokens], + publicationKeys: [req.session?.openidTokens?.publicationFlightKey].filter(Boolean), + ttl: math(process.env.REFRESH_TOKEN_EXPIRY, DEFAULT_REFRESH_TOKEN_EXPIRY), + }); + logoutTokens.push(...revokedRefreshTokens); + await deleteAllRefreshTokenBridges({ + userId, + tenantId: req.user?.tenantId, + }); + if (req.session?.openidTokens) { + delete req.session.openidTokens; + } + } + if (logoutTokens.length === 0) { + logoutTokens.push(undefined); + } + let logout = { status: 200, message: 'Logout successful' }; + for (const token of new Set(logoutTokens)) { + const result = await logoutUser(req, token); + if (result.status !== 200) { + logout = result; + } + } const { status, message } = logout; res.clearCookie('refreshToken'); diff --git a/api/server/controllers/auth/LogoutController.spec.js b/api/server/controllers/auth/LogoutController.spec.js index e6c3149217..06a051a66a 100644 --- a/api/server/controllers/auth/LogoutController.spec.js +++ b/api/server/controllers/auth/LogoutController.spec.js @@ -5,16 +5,28 @@ const mockLogger = { warn: jest.fn(), error: jest.fn(), debug: jest.fn() }; const mockIsEnabled = jest.fn(); const mockGetOpenIdConfig = jest.fn(); const mockClearCloudFrontCookies = jest.fn(); +const mockDeleteAllRefreshTokenBridges = jest.fn(); +const mockRevokeOpenIDRefreshTokenChain = jest.fn(); jest.mock('cookie'); jest.mock('@librechat/api', () => ({ isEnabled: (...args) => mockIsEnabled(...args), + math: (_value, fallback) => fallback, clearCloudFrontCookies: (...args) => mockClearCloudFrontCookies(...args), })); -jest.mock('@librechat/data-schemas', () => ({ logger: mockLogger })); +jest.mock('@librechat/data-schemas', () => ({ + logger: mockLogger, + DEFAULT_REFRESH_TOKEN_EXPIRY: 7 * 24 * 60 * 60 * 1000, +})); jest.mock('~/server/services/AuthService', () => ({ logoutUser: (...args) => mockLogoutUser(...args), })); +jest.mock('~/server/services/RefreshTokenBridge', () => ({ + deleteAllRefreshTokenBridges: (...args) => mockDeleteAllRefreshTokenBridges(...args), +})); +jest.mock('~/server/services/OpenIDRefreshRecovery', () => ({ + revokeOpenIDRefreshTokenChain: (...args) => mockRevokeOpenIDRefreshTokenChain(...args), +})); jest.mock('~/strategies', () => ({ getOpenIdConfig: () => mockGetOpenIdConfig() })); const { logoutController } = require('./LogoutController'); @@ -24,7 +36,11 @@ function buildReq(overrides = {}) { user: { _id: 'user1', openidId: 'oid1', provider: 'openid' }, headers: { cookie: 'refreshToken=rt1' }, session: { - openidTokens: { refreshToken: 'srt', idToken: 'small-id-token' }, + openidTokens: { + refreshToken: 'srt', + idToken: 'small-id-token', + publicationFlightKey: 'recorded-publication-key', + }, destroy: jest.fn(), }, ...overrides, @@ -54,6 +70,8 @@ beforeEach(() => { }; cookies.parse.mockReturnValue({ refreshToken: 'cookie-rt' }); mockLogoutUser.mockResolvedValue({ status: 200, message: 'Logout successful' }); + mockDeleteAllRefreshTokenBridges.mockResolvedValue({ acknowledged: true, deletedCount: 1 }); + mockRevokeOpenIDRefreshTokenChain.mockResolvedValue(['cookie-rt', 'srt']); mockIsEnabled.mockReturnValue(true); mockGetOpenIdConfig.mockReturnValue({ serverMetadata: () => ({ @@ -246,6 +264,91 @@ describe('LogoutController', () => { }); }); + describe('bridge revocation', () => { + it('revokes all predecessor bridges and deletes the browser durable session', async () => { + const req = buildReq({ + user: { + _id: 'user1', + openidId: 'oid1', + provider: 'openid', + tenantId: 'tenantA', + }, + }); + const res = buildRes(); + + await logoutController(req, res); + + expect(mockDeleteAllRefreshTokenBridges).toHaveBeenCalledWith({ + userId: 'user1', + tenantId: 'tenantA', + }); + expect(mockRevokeOpenIDRefreshTokenChain).toHaveBeenCalledWith({ + req, + user: req.user, + identityContext: { + appUserId: 'user1', + openidSubject: 'oid1', + tenantId: 'tenantA', + openidIssuer: undefined, + }, + refreshTokens: ['cookie-rt', 'srt'], + publicationKeys: ['recorded-publication-key'], + ttl: 7 * 24 * 60 * 60 * 1000, + }); + expect(mockRevokeOpenIDRefreshTokenChain.mock.invocationCallOrder[0]).toBeLessThan( + mockDeleteAllRefreshTokenBridges.mock.invocationCallOrder[0], + ); + expect(mockLogoutUser).toHaveBeenCalledWith(req, 'cookie-rt'); + expect(mockLogoutUser).toHaveBeenCalledWith(req, 'srt'); + expect(mockLogoutUser).toHaveBeenCalledTimes(2); + expect(req.session.openidTokens).toBeUndefined(); + }); + + it('deletes successors retained by completed flights before a late refresh response arrives', async () => { + mockRevokeOpenIDRefreshTokenChain.mockResolvedValue([ + 'cookie-rt', + 'srt', + 'grant-successor', + 'publication-successor', + ]); + const req = buildReq(); + const res = buildRes(); + + await logoutController(req, res); + + expect(mockLogoutUser).toHaveBeenCalledWith(req, 'cookie-rt'); + expect(mockLogoutUser).toHaveBeenCalledWith(req, 'srt'); + expect(mockLogoutUser).toHaveBeenCalledWith(req, 'grant-successor'); + expect(mockLogoutUser).toHaveBeenCalledWith(req, 'publication-successor'); + expect(mockLogoutUser).toHaveBeenCalledTimes(4); + }); + + it('fails closed before logout when bridge revocation fails', async () => { + mockDeleteAllRefreshTokenBridges.mockRejectedValue(new Error('bridge delete failed')); + const req = buildReq(); + const res = buildRes(); + + await logoutController(req, res); + + expect(mockLogoutUser).not.toHaveBeenCalled(); + expect(req.session.openidTokens).toBeDefined(); + expect(res.status).toHaveBeenCalledWith(500); + }); + + it('fails closed before deleting auth state when the refresh-flight fence fails', async () => { + mockRevokeOpenIDRefreshTokenChain.mockRejectedValue(new Error('flight fence failed')); + const req = buildReq(); + const res = buildRes(); + + await logoutController(req, res); + + expect(mockDeleteAllRefreshTokenBridges).not.toHaveBeenCalled(); + expect(mockLogoutUser).not.toHaveBeenCalled(); + expect(req.session.openidTokens).toBeDefined(); + expect(res.status).toHaveBeenCalledWith(500); + }); + }); + describe('cookie clearing', () => { it('clears all auth cookies on successful logout', async () => { const req = buildReq(); diff --git a/api/server/controllers/auth/oauth.js b/api/server/controllers/auth/oauth.js index 368f8729f5..bdfcd102bc 100644 --- a/api/server/controllers/auth/oauth.js +++ b/api/server/controllers/auth/oauth.js @@ -7,11 +7,8 @@ const { generateAdminExchangeCode, } = require('@librechat/api'); const { syncUserEntraGroupMemberships } = require('~/server/services/PermissionService'); -const { - setAuthTokens, - setOpenIDAuthTokens, - storeOpenIDSession, -} = require('~/server/services/AuthService'); +const { setAuthTokens } = require('~/server/services/AuthService'); +const { sendOpenIDAuthResponse } = require('~/server/services/OpenIDRefreshRecovery'); const getLogStores = require('~/cache/getLogStores'); const { checkBan } = require('~/server/middleware'); const { generateToken } = require('~/models'); @@ -80,14 +77,14 @@ function createOAuthHandler(redirectUri = domains.client) { isEnabled(process.env.OPENID_REUSE_TOKENS) === true ) { await syncUserEntraGroupMemberships(req.user, req.user.tokenset.access_token); - await storeOpenIDSession( - req.user._id.toString(), - req.user.tokenset.refresh_token, - req.user.tenantId, - ); - setOpenIDAuthTokens(req.user.tokenset, req, res, { - userId: req.user._id.toString(), - tenantId: req.user.tenantId, + await sendOpenIDAuthResponse({ + tokenset: req.user.tokenset, + user: req.user, + existingRefreshToken: req.user.tokenset.refresh_token, + openidSubject: req.user.openidId, + openidIssuer: req.user.openidIssuer, + req, + res, }); } else { await setAuthTokens(req.user._id, res, null, req); diff --git a/api/server/controllers/auth/oauth.spec.js b/api/server/controllers/auth/oauth.spec.js index 62290210e2..c0b6f09e76 100644 --- a/api/server/controllers/auth/oauth.spec.js +++ b/api/server/controllers/auth/oauth.spec.js @@ -4,8 +4,7 @@ const mockIsAdminPanelRedirect = jest.fn(); const mockGenerateAdminExchangeCode = jest.fn(); const mockSyncUserEntraGroupMemberships = jest.fn(); const mockSetAuthTokens = jest.fn(); -const mockSetOpenIDAuthTokens = jest.fn(); -const mockStoreOpenIDSession = jest.fn(); +const mockSendOpenIDAuthResponse = jest.fn(); const mockGetLogStores = jest.fn(); const mockCheckBan = jest.fn(); const mockGenerateToken = jest.fn(); @@ -33,8 +32,10 @@ jest.mock('~/server/services/PermissionService', () => ({ jest.mock('~/server/services/AuthService', () => ({ setAuthTokens: (...args) => mockSetAuthTokens(...args), - setOpenIDAuthTokens: (...args) => mockSetOpenIDAuthTokens(...args), - storeOpenIDSession: (...args) => mockStoreOpenIDSession(...args), +})); + +jest.mock('~/server/services/OpenIDRefreshRecovery', () => ({ + sendOpenIDAuthResponse: (...args) => mockSendOpenIDAuthResponse(...args), })); jest.mock( @@ -94,7 +95,7 @@ describe('createOAuthHandler', () => { mockCheckBan.mockResolvedValue(undefined); mockGenerateToken.mockResolvedValue('jwt-token'); mockGenerateAdminExchangeCode.mockResolvedValue('exchange-code'); - mockStoreOpenIDSession.mockResolvedValue(true); + mockSendOpenIDAuthResponse.mockResolvedValue('app-token'); }); afterAll(() => { @@ -121,7 +122,7 @@ describe('createOAuthHandler', () => { expect(res.redirect).toHaveBeenCalledWith( 'http://admin.example.com/auth/openid/callback?code=exchange-code', ); - expect(mockSetOpenIDAuthTokens).not.toHaveBeenCalled(); + expect(mockSendOpenIDAuthResponse).not.toHaveBeenCalled(); expect(mockSetAuthTokens).not.toHaveBeenCalled(); expect(next).not.toHaveBeenCalled(); }); @@ -147,12 +148,12 @@ describe('createOAuthHandler', () => { expect(res.redirect).toHaveBeenCalledWith( 'http://admin.example.com/auth/openid/callback?code=exchange-code', ); - expect(mockSetOpenIDAuthTokens).not.toHaveBeenCalled(); + expect(mockSendOpenIDAuthResponse).not.toHaveBeenCalled(); expect(mockSetAuthTokens).not.toHaveBeenCalled(); expect(next).not.toHaveBeenCalled(); }); - it('stores the OpenID refresh token before setting cookies for the standard app', async () => { + it('publishes the standard OpenID login through the durable generation service', async () => { process.env.OPENID_REUSE_TOKENS = 'true'; mockIsAdminPanelRedirect.mockReturnValue(false); const handler = createOAuthHandler('http://localhost:3080'); @@ -162,14 +163,14 @@ describe('createOAuthHandler', () => { await handler(req, res, next); - expect(mockStoreOpenIDSession).toHaveBeenCalledWith( - 'user-123', - 'openid-refresh-token', - undefined, - ); - expect(mockSetOpenIDAuthTokens).toHaveBeenCalledWith(req.user.tokenset, req, res, { - userId: 'user-123', - tenantId: undefined, + expect(mockSendOpenIDAuthResponse).toHaveBeenCalledWith({ + tokenset: req.user.tokenset, + user: req.user, + existingRefreshToken: 'openid-refresh-token', + openidSubject: undefined, + openidIssuer: undefined, + req, + res, }); expect(res.redirect).toHaveBeenCalledWith('http://localhost:3080'); }); diff --git a/api/server/routes/__tests__/mcp.spec.js b/api/server/routes/__tests__/mcp.spec.js index 9fcb49a1aa..17b6d4e678 100644 --- a/api/server/routes/__tests__/mcp.spec.js +++ b/api/server/routes/__tests__/mcp.spec.js @@ -2449,6 +2449,38 @@ describe('MCP Routes', () => { }); }); + /** + * Codex Finding 2 regression: the reinitialize route must forward a + * session-aware upstream-token closure to reinitMCPServer so OBO servers + * can mint a downstream token. The closure is built at the route boundary + * (from req/res) and passed as `upstreamTokenProvider`; the raw `req` is + * NOT threaded into the MCP layer. + */ + it('forwards an upstream-token closure (not req) into reinitMCPServer for OBO session access', async () => { + const mockMcpManager = { + disconnectUserConnection: jest.fn().mockResolvedValue(), + }; + mockRegistryInstance.getServerConfig.mockResolvedValue({}); + require('~/config').getMCPManager.mockReturnValue(mockMcpManager); + require('~/config').getFlowStateManager.mockReturnValue({}); + require('~/cache').getLogStores.mockReturnValue({}); + const reinitSpy = require('~/server/services/Tools/mcp').reinitMCPServer; + reinitSpy.mockResolvedValue({ success: true, serverName: 'obo-server' }); + + await request(app).post('/api/mcp/obo-server/reinitialize'); + + expect(reinitSpy).toHaveBeenCalledWith( + expect.objectContaining({ + upstreamTokenProvider: expect.any(Function), + oboIdentityContext: expect.objectContaining({ + appUserId: 'test-user-id', + }), + }), + ); + const [params] = reinitSpy.mock.calls[reinitSpy.mock.calls.length - 1]; + expect(params).not.toHaveProperty('req'); + }); + it('should return 500 when unexpected error occurs', async () => { const mockMcpManager = { disconnectUserConnection: jest.fn(), diff --git a/api/server/routes/mcp.js b/api/server/routes/mcp.js index 32bca863d8..e430a4a324 100644 --- a/api/server/routes/mcp.js +++ b/api/server/routes/mcp.js @@ -10,6 +10,7 @@ const { const { getBasePath, createSafeUser, + createAuthIdentityContext, MCPOAuthHandler, MCPTokenStorage, setOAuthSession, @@ -51,6 +52,7 @@ const { getUserPluginAuthValue } = require('~/server/services/PluginService'); const { invalidateCachedTools } = require('~/server/services/Config'); const { updateMCPServerTools } = require('~/server/services/Config/mcp'); const { reinitMCPServer } = require('~/server/services/Tools/mcp'); +const { createOpenIDSessionTokenProvider } = require('~/server/services/OpenIDSessionRefresh'); const { getLogStores } = require('~/cache'); const db = require('~/models'); @@ -839,6 +841,10 @@ router.post( findPluginAuthsByKeys: db.findPluginAuthsByKeys, }); } + const oboIdentityContext = createAuthIdentityContext({ + user: req.user, + tenantId: getTenantId(), + }); const result = await reinitMCPServer({ user, @@ -846,6 +852,14 @@ router.post( serverConfig, configServers, userMCPAuthMap, + upstreamTokenProvider: createOpenIDSessionTokenProvider({ + req, + res, + user: req.user, + identityContext: oboIdentityContext, + tokenPreference: 'access_token', + }), + oboIdentityContext, }); if (!result) { diff --git a/api/server/services/AuthService.js b/api/server/services/AuthService.js index 224e8436bf..fb736ded81 100644 --- a/api/server/services/AuthService.js +++ b/api/server/services/AuthService.js @@ -1,6 +1,6 @@ const bcrypt = require('bcryptjs'); const jwt = require('jsonwebtoken'); -const { createHash, webcrypto } = require('node:crypto'); +const { webcrypto } = require('node:crypto'); const { logger, getTenantId, @@ -19,6 +19,11 @@ const { CLOUDFRONT_SCOPE_COOKIE, isEmailDomainAllowed, shouldUseSecureCookie, + setRefreshTokenCookie, + setOpenIDMarkerCookies, + clearCloudFrontCookies, + normalizeExpiresIn, + createOpenIDSessionIdentity, resolveAppConfigForUser, } = require('@librechat/api'); const { @@ -154,6 +159,21 @@ const getPasswordResetTokenDeleteQuery = (passwordResetToken) => { }; }; +const isExpiredOpenIDIdToken = (idToken) => { + if (!idToken) { + return false; + } + + const decoded = jwt.decode(idToken); + if (!decoded || typeof decoded !== 'object' || typeof decoded.exp !== 'number') { + return false; + } + + return ( + decoded.exp <= Math.floor(Date.now() / 1000) + OPENID_SESSION_ID_TOKEN_EXPIRY_BUFFER_SECONDS + ); +}; + const getUnexpiredOpenIDSessionIdToken = (idToken) => { if (!idToken) { return; @@ -170,6 +190,27 @@ const getUnexpiredOpenIDSessionIdToken = (idToken) => { } }; +const getOpenIDAppAuthToken = (tokenset, sessionIdToken) => + (isExpiredOpenIDIdToken(tokenset?.id_token) ? undefined : tokenset?.id_token) || + getUnexpiredOpenIDSessionIdToken(sessionIdToken) || + tokenset?.access_token; + +const clearOpenIDAuthTokens = (req, res, userId, tenantId) => { + if (req.session?.openidTokens) { + delete req.session.openidTokens; + } + for (const name of [ + 'refreshToken', + 'openid_access_token', + 'openid_id_token', + 'openid_user_id', + 'token_provider', + ]) { + res.clearCookie?.(name); + } + clearCloudFrontCookies(res, { userId, tenantId }); +}; + /** * Logout user * @@ -701,7 +742,9 @@ const resolveOpenIDAuthTokenOptions = (optionsOrUserId, existingRefreshToken, te if ( 'userId' in optionsOrUserId || 'existingRefreshToken' in optionsOrUserId || - 'tenantId' in optionsOrUserId + 'tenantId' in optionsOrUserId || + 'openidSubject' in optionsOrUserId || + 'openidIssuer' in optionsOrUserId ) { return optionsOrUserId; } @@ -711,6 +754,44 @@ const resolveOpenIDAuthTokenOptions = (optionsOrUserId, existingRefreshToken, te return { userId: optionsOrUserId, existingRefreshToken, tenantId }; }; +const getOpenIDTokenClaims = (tokenset) => { + if (typeof tokenset?.claims === 'function') { + try { + const claims = tokenset.claims(); + return claims && typeof claims === 'object' ? claims : {}; + } catch (error) { + logger.debug('[setOpenIDAuthTokens] Unable to read tokenset claims', error?.message); + } + } + + if (typeof tokenset?.id_token !== 'string') { + return {}; + } + + const decoded = jwt.decode(tokenset.id_token); + return decoded && typeof decoded === 'object' ? decoded : {}; +}; + +const getStringClaim = (claims, claim) => { + const value = claims?.[claim]; + return typeof value === 'string' && value ? value : undefined; +}; + +const applyOpenIDSessionIdentity = (sessionOpenidTokens, identity) => { + if (identity.appUserId) { + sessionOpenidTokens.appUserId = identity.appUserId; + } + if (identity.openidSubject) { + sessionOpenidTokens.openidSubject = identity.openidSubject; + } + if (identity.tenantId) { + sessionOpenidTokens.tenantId = identity.tenantId; + } + if (identity.openidIssuer) { + sessionOpenidTokens.openidIssuer = identity.openidIssuer; + } +}; + /** * @function setOpenIDAuthTokens * Set OpenID Authentication Tokens @@ -725,6 +806,8 @@ const resolveOpenIDAuthTokenOptions = (optionsOrUserId, existingRefreshToken, te * @param {string} [options.userId] - Optional MongoDB user ID for image path validation * @param {string} [options.existingRefreshToken] - Optional existing refresh token to preserve * @param {string} [options.tenantId] - Optional tenant identifier for CloudFront cookie scoping + * @param {string} [options.openidSubject] - Optional OpenID subject bound to the session tokens + * @param {string} [options.openidIssuer] - Optional OpenID issuer bound to the session tokens * @returns {String} - id_token (preferred) or access_token as the app auth token */ const setOpenIDAuthTokens = ( @@ -736,11 +819,8 @@ const setOpenIDAuthTokens = ( tenantIdArg, ) => { try { - const { userId, existingRefreshToken, tenantId } = resolveOpenIDAuthTokenOptions( - optionsOrUserId, - existingRefreshTokenArg, - tenantIdArg, - ); + const { userId, existingRefreshToken, tenantId, openidSubject, openidIssuer } = + resolveOpenIDAuthTokenOptions(optionsOrUserId, existingRefreshTokenArg, tenantIdArg); if (!tokenset) { logger.error('[setOpenIDAuthTokens] No tokenset found in request'); @@ -771,11 +851,22 @@ const setOpenIDAuthTokens = ( * Falls back to access_token for providers where id_token is not available. */ const sessionIdToken = req.session?.openidTokens?.idToken; - const appAuthToken = - tokenset.id_token || - getUnexpiredOpenIDSessionIdToken(sessionIdToken) || - tokenset.access_token; + /** + * 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. 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 and fail JWKS validation. + */ + const appAuthToken = getOpenIDAppAuthToken(tokenset, sessionIdToken); const logoutIdToken = tokenset.id_token || sessionIdToken; + const claims = getOpenIDTokenClaims(tokenset); + const sessionIdentity = createOpenIDSessionIdentity({ + user: req?.user, + userId, + openidSubject: openidSubject ?? getStringClaim(claims, 'sub'), + tenantId, + openidIssuer: openidIssuer ?? getStringClaim(claims, 'iss'), + }); /** * Always set refresh token cookie so it survives express session expiry. @@ -786,22 +877,34 @@ const setOpenIDAuthTokens = ( * The refresh token is small (opaque string) so it doesn't hit the HTTP/2 header * size limits that motivated session storage for the larger access_token/id_token. */ - res.cookie('refreshToken', refreshToken, { - expires: expirationDate, - httpOnly: true, - secure: shouldUseSecureCookie(), - sameSite: 'strict', - }); + setRefreshTokenCookie(res, refreshToken, expirationDate); /** Store tokens server-side in session to avoid large cookies */ if (req.session) { - req.session.openidTokens = { + const sessionOpenidTokens = { accessToken: tokenset.access_token, idToken: logoutIdToken, refreshToken: refreshToken, + browserRefreshToken: refreshToken, expiresAt: expirationDate.getTime(), lastRefreshedAt: Date.now(), }; + applyOpenIDSessionIdentity(sessionOpenidTokens, sessionIdentity); + /** + * Capture the access-token's own expiry (unix seconds) when the IdP + * advertises one. Lets downstream consumers β€” notably the OBO inline- + * refresh path in `OpenIDSessionRefresh.js` β€” reuse opaque (non-JWT) + * access tokens without burning an IdP refresh on the first tool call. + * Without this, the very first OBO call after login or SPA refresh would + * always trigger a redundant inline refresh whenever the IdP issues + * opaque access tokens (e.g. Microsoft Graph audiences). + */ + const accessTokenExpiresIn = normalizeExpiresIn(tokenset.expires_in); + if (accessTokenExpiresIn != null) { + sessionOpenidTokens.accessTokenExpiresAt = + Math.floor(Date.now() / 1000) + accessTokenExpiresIn; + } + req.session.openidTokens = sessionOpenidTokens; } else { logger.warn('[setOpenIDAuthTokens] No session available, falling back to cookies'); res.cookie('openid_access_token', tokenset.access_token, { @@ -820,28 +923,12 @@ const setOpenIDAuthTokens = ( } } - /** Small cookie to indicate token provider (required for auth middleware) */ - res.cookie('token_provider', 'openid', { + setOpenIDMarkerCookies(res, { + userId, expires: expirationDate, - httpOnly: true, - secure: shouldUseSecureCookie(), - sameSite: 'strict', + refreshExpiryMs: expiryInMilliseconds, + refreshToken, }); - if (userId && isEnabled(process.env.OPENID_REUSE_TOKENS)) { - /** Bind image cookie identity to the durable refresh-token session. */ - const refreshTokenHash = createHash('sha256').update(refreshToken).digest('base64url'); - const signedUserId = jwt.sign( - { id: userId, refreshTokenHash }, - process.env.JWT_REFRESH_SECRET, - { expiresIn: expiryInMilliseconds / 1000 }, - ); - res.cookie('openid_user_id', signedUserId, { - expires: expirationDate, - httpOnly: true, - secure: shouldUseSecureCookie(), - sameSite: 'strict', - }); - } setCloudFrontAuthCookies(req, res, req.user, { userId, tenantId }); @@ -927,6 +1014,8 @@ module.exports = { registerUser, setAuthTokens, resetPassword, + clearOpenIDAuthTokens, + getOpenIDAppAuthToken, setOpenIDAuthTokens, storeOpenIDSession, setCloudFrontAuthCookies, diff --git a/api/server/services/AuthService.spec.js b/api/server/services/AuthService.spec.js index fb74692f9a..d6182e1334 100644 --- a/api/server/services/AuthService.spec.js +++ b/api/server/services/AuthService.spec.js @@ -20,25 +20,80 @@ jest.mock( ); jest.mock( '@librechat/api', - () => ({ - isEnabled: jest.fn((val) => val === 'true' || val === true), - checkEmailConfig: jest.fn(), - isEmailDomainAllowed: jest.fn(), - math: jest.fn((val, fallback) => (val ? Number(val) : fallback)), - storeOpenIdSession: jest.fn(), - shouldUseSecureCookie: jest.fn(() => false), - resolveAppConfigForUser: jest.fn(async (_getAppConfig, _user) => ({})), - setCloudFrontCookies: jest.fn(() => true), - getCloudFrontConfig: jest.fn(() => ({ - domain: 'https://cdn.example.com', - imageSigning: 'cookies', - cookieDomain: '.example.com', - privateKey: 'test-private-key', - keyPairId: 'K123ABC', - })), - parseCloudFrontCookieScope: jest.fn(() => null), - CLOUDFRONT_SCOPE_COOKIE: 'LibreChat-CloudFront-Scope', - }), + () => { + const shouldUseSecureCookie = jest.fn(() => false); + return { + isEnabled: jest.fn((val) => val === 'true' || val === true), + checkEmailConfig: jest.fn(), + isEmailDomainAllowed: jest.fn(), + math: jest.fn((val, fallback) => (val ? Number(val) : fallback)), + storeOpenIdSession: jest.fn(), + shouldUseSecureCookie, + setRefreshTokenCookie: jest.fn((res, refreshToken, expires) => { + res.cookie('refreshToken', refreshToken, { + expires, + httpOnly: true, + secure: shouldUseSecureCookie(), + sameSite: 'strict', + }); + }), + setOpenIDMarkerCookies: jest.fn((res, { userId, expires }) => { + res.cookie('token_provider', 'openid', { + expires, + httpOnly: true, + secure: shouldUseSecureCookie(), + sameSite: 'strict', + }); + if (userId) { + res.cookie('openid_user_id', `signed:${userId}`, { + expires, + httpOnly: true, + secure: shouldUseSecureCookie(), + sameSite: 'strict', + }); + } + }), + normalizeExpiresIn: (value) => { + const normalized = typeof value === 'string' && value.trim() ? Number(value) : value; + return typeof normalized === 'number' && Number.isFinite(normalized) + ? normalized + : undefined; + }, + resolveAppConfigForUser: jest.fn(async (_getAppConfig, _user) => ({})), + createOpenIDSessionIdentity: jest.fn( + ({ user, userId, openidSubject, tenantId, openidIssuer }) => { + 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(/\/\.well-known\/openid-configuration$/, '') + .replace(/\/+$/, ''); + return { + appUserId: normalize(userId) ?? normalize(user?._id) ?? normalize(user?.id), + openidSubject: normalize(openidSubject) ?? normalize(user?.openidId), + tenantId: normalize(tenantId) ?? normalize(user?.tenantId), + openidIssuer: normalizeIssuer(openidIssuer) ?? normalizeIssuer(user?.openidIssuer), + }; + }, + ), + setCloudFrontCookies: jest.fn(() => true), + clearCloudFrontCookies: jest.fn(), + getCloudFrontConfig: jest.fn(() => ({ + domain: 'https://cdn.example.com', + imageSigning: 'cookies', + cookieDomain: '.example.com', + privateKey: 'test-private-key', + keyPairId: 'K123ABC', + })), + parseCloudFrontCookieScope: jest.fn(() => null), + CLOUDFRONT_SCOPE_COOKIE: 'LibreChat-CloudFront-Scope', + }; + }, { virtual: true }, ); jest.mock('~/models', () => ({ @@ -75,48 +130,88 @@ jest.mock('~/strategies/validators', () => ({ jest.mock('~/server/services/Config', () => ({ getAppConfig: jest.fn() })); jest.mock('~/server/utils', () => ({ sendEmail: jest.fn() })); -const { - checkEmailConfig, - shouldUseSecureCookie, - isEmailDomainAllowed, - resolveAppConfigForUser, - setCloudFrontCookies, - getCloudFrontConfig, - parseCloudFrontCookieScope, - storeOpenIdSession, -} = require('@librechat/api'); -const jwt = require('jsonwebtoken'); -const { createHash } = require('node:crypto'); -const { logger, getTenantId } = require('@librechat/data-schemas'); -const { - findUser, - findToken, - createUser, - updateUser, - countUsers, - getUserById, - generateToken, - generateRefreshToken, - createSession, - upsertSession, - deleteSession, - createToken, - deleteTokens, -} = require('~/models'); -const { getAppConfig } = require('~/server/services/Config'); -const { sendEmail } = require('~/server/utils'); -const bcrypt = require('bcryptjs'); -const { - setOpenIDAuthTokens, - storeOpenIDSession, - requestPasswordReset, - registerUser, - resetPassword, - resendVerificationEmail, - setAuthTokens, - setCloudFrontAuthCookies, - verifyEmail, -} = require('./AuthService'); +let checkEmailConfig; +let isEmailDomainAllowed; +let resolveAppConfigForUser; +let setRefreshTokenCookie; +let setOpenIDMarkerCookies; +let setCloudFrontCookies; +let getCloudFrontConfig; +let parseCloudFrontCookieScope; +let storeOpenIdSession; +let jwt; +let logger; +let getTenantId; +let findUser; +let findToken; +let createUser; +let updateUser; +let countUsers; +let getUserById; +let generateToken; +let generateRefreshToken; +let createSession; +let upsertSession; +let deleteSession; +let createToken; +let deleteTokens; +let getAppConfig; +let sendEmail; +let bcrypt; +let setOpenIDAuthTokens; +let storeOpenIDSession; +let requestPasswordReset; +let registerUser; +let resetPassword; +let resendVerificationEmail; +let setAuthTokens; +let setCloudFrontAuthCookies; +let verifyEmail; + +jest.isolateModules(() => { + ({ + checkEmailConfig, + isEmailDomainAllowed, + resolveAppConfigForUser, + setRefreshTokenCookie, + setOpenIDMarkerCookies, + setCloudFrontCookies, + getCloudFrontConfig, + parseCloudFrontCookieScope, + storeOpenIdSession, + } = require('@librechat/api')); + jwt = require('jsonwebtoken'); + ({ logger, getTenantId } = require('@librechat/data-schemas')); + ({ + findUser, + findToken, + createUser, + updateUser, + countUsers, + getUserById, + generateToken, + generateRefreshToken, + createSession, + upsertSession, + deleteSession, + createToken, + deleteTokens, + } = require('~/models')); + ({ getAppConfig } = require('~/server/services/Config')); + ({ sendEmail } = require('~/server/utils')); + bcrypt = require('bcryptjs'); + ({ + setOpenIDAuthTokens, + storeOpenIDSession, + requestPasswordReset, + registerUser, + resetPassword, + resendVerificationEmail, + setAuthTokens, + setCloudFrontAuthCookies, + verifyEmail, + } = require('./AuthService')); +}); /** Helper to build a mock Express response */ function mockResponse() { @@ -236,9 +331,96 @@ describe('setOpenIDAuthTokens', () => { expect(req.session.openidTokens.accessToken).toBe('the-access-token'); expect(req.session.openidTokens.idToken).toBe('the-id-token'); expect(req.session.openidTokens.refreshToken).toBe('the-refresh-token'); + expect(req.session.openidTokens.browserRefreshToken).toBe('the-refresh-token'); expect(req.session.openidTokens.lastRefreshedAt).toEqual(expect.any(Number)); }); + it('should bind session tokens to the OpenID user identity', () => { + const tokenset = { + id_token: 'the-id-token', + access_token: 'the-access-token', + refresh_token: 'the-refresh-token', + }; + const req = mockRequest(); + const res = mockResponse(); + + setOpenIDAuthTokens(tokenset, req, res, { + userId: 'user-123', + openidSubject: 'oidc-sub-123', + tenantId: 'tenantA', + openidIssuer: 'https://issuer.example.com/.well-known/openid-configuration', + }); + + expect(req.session.openidTokens).toEqual( + expect.objectContaining({ + appUserId: 'user-123', + openidSubject: 'oidc-sub-123', + tenantId: 'tenantA', + openidIssuer: 'https://issuer.example.com', + }), + ); + }); + + /** + * Codex Finding 5: persist the access-token's expiry (unix seconds) so the + * first OBO call after login or SPA refresh can reuse a still-valid OPAQUE + * access token without burning a redundant inline refresh. The expiry comes + * from the IdP's `tokenset.expires_in`; downstream consumers (notably + * `OpenIDSessionRefresh.getAccessTokenExp`) read it as a fallback when the + * access token isn't a JWT and can't be decoded. + */ + it('should persist accessTokenExpiresAt when tokenset.expires_in is provided', () => { + const tokenset = { + id_token: 'the-id-token', + access_token: 'the-access-token', + refresh_token: 'the-refresh-token', + expires_in: 3600, + }; + const req = mockRequest(); + const res = mockResponse(); + const beforeSec = Math.floor(Date.now() / 1000); + + setOpenIDAuthTokens(tokenset, req, res, 'user-123'); + + const persisted = req.session.openidTokens.accessTokenExpiresAt; + expect(typeof persisted).toBe('number'); + expect(persisted).toBeGreaterThanOrEqual(beforeSec + 3590); + expect(persisted).toBeLessThanOrEqual(beforeSec + 3610); + }); + + it('should persist accessTokenExpiresAt when tokenset.expires_in is a numeric string', () => { + const tokenset = { + id_token: 'the-id-token', + access_token: 'the-access-token', + refresh_token: 'the-refresh-token', + expires_in: '3600', + }; + const req = mockRequest(); + const res = mockResponse(); + const beforeSec = Math.floor(Date.now() / 1000); + + setOpenIDAuthTokens(tokenset, req, res, 'user-123'); + + expect(req.session.openidTokens.accessTokenExpiresAt).toBeGreaterThanOrEqual( + beforeSec + 3590, + ); + }); + + it('should NOT persist accessTokenExpiresAt when tokenset.expires_in is missing', () => { + const tokenset = { + id_token: 'the-id-token', + access_token: 'the-access-token', + refresh_token: 'the-refresh-token', + // expires_in deliberately omitted + }; + const req = mockRequest(); + const res = mockResponse(); + + setOpenIDAuthTokens(tokenset, req, res, 'user-123'); + + expect(req.session.openidTokens).not.toHaveProperty('accessTokenExpiresAt'); + }); + it('should return the existing unexpired session id_token when refresh omits one', () => { const existingIdToken = jwt.sign( { sub: 'user-123', exp: Math.floor(Date.now() / 1000) + 3600 }, @@ -291,6 +473,43 @@ describe('setOpenIDAuthTokens', () => { expect(req.session.openidTokens.accessToken).toBe('new-access-token'); }); + it('falls back to access_token when the refresh carried an expired id_token forward', () => { + const expiredIdToken = jwt.sign( + { sub: 'user-123', exp: Math.floor(Date.now() / 1000) - 60 }, + 'idp-signing-secret', + ); + const tokenset = { + id_token: expiredIdToken, + access_token: 'new-access-token', + refresh_token: 'new-refresh-token', + }; + const req = mockRequest({ + openidTokens: { + accessToken: 'old-access-token', + idToken: expiredIdToken, + refreshToken: 'old-refresh-token', + }, + }); + const res = mockResponse(); + + const result = setOpenIDAuthTokens(tokenset, req, res, 'user-123'); + + expect(result).toBe('new-access-token'); + expect(req.session.openidTokens.idToken).toBe(expiredIdToken); + }); + + it('still prefers an id_token whose expiry cannot be read', () => { + const tokenset = { + id_token: 'opaque-but-not-a-jwt', + access_token: 'new-access-token', + refresh_token: 'new-refresh-token', + }; + const req = mockRequest(); + const res = mockResponse(); + + expect(setOpenIDAuthTokens(tokenset, req, res, 'user-123')).toBe('opaque-but-not-a-jwt'); + }); + it('should fall back to access_token when the existing session id_token is near expiry', () => { const nearExpiryIdToken = jwt.sign( { sub: 'user-123', exp: Math.floor(Date.now() / 1000) + 10 }, @@ -317,25 +536,6 @@ describe('setOpenIDAuthTokens', () => { }); }); - it('binds the signed OpenID user cookie to its refresh token', () => { - const tokenset = { - id_token: 'the-id-token', - access_token: 'the-access-token', - refresh_token: 'the-refresh-token', - }; - const req = mockRequest(); - const res = mockResponse(); - - setOpenIDAuthTokens(tokenset, req, res, 'user-123'); - - expect(jwt.verify(res._cookies.openid_user_id.value, process.env.JWT_REFRESH_SECRET)).toEqual( - expect.objectContaining({ - id: 'user-123', - refreshTokenHash: createHash('sha256').update(tokenset.refresh_token).digest('base64url'), - }), - ); - }); - it('stores an OpenID refresh token for durable revocation checks', async () => { storeOpenIdSession.mockResolvedValue(true); @@ -368,8 +568,8 @@ describe('setOpenIDAuthTokens', () => { ); }); - describe('cookie secure flag', () => { - it('should call shouldUseSecureCookie for every cookie set', () => { + describe('OpenID cookie delegation', () => { + it('delegates session-path refresh and marker cookies to shared cookie helpers', () => { const tokenset = { id_token: 'the-id-token', access_token: 'the-access-token', @@ -380,37 +580,30 @@ describe('setOpenIDAuthTokens', () => { setOpenIDAuthTokens(tokenset, req, res, 'user-123'); - // token_provider + openid_user_id (session path, so no refreshToken/openid_access_token cookies) - const secureCalls = shouldUseSecureCookie.mock.calls.length; - expect(secureCalls).toBeGreaterThanOrEqual(2); - - // Verify all cookies use the result of shouldUseSecureCookie - for (const [, cookie] of Object.entries(res._cookies)) { - expect(cookie.options.secure).toBe(false); - } + expect(setRefreshTokenCookie).toHaveBeenCalledWith( + res, + 'the-refresh-token', + expect.any(Date), + ); + expect(setOpenIDMarkerCookies).toHaveBeenCalledWith(res, { + userId: 'user-123', + expires: expect.any(Date), + refreshExpiryMs: 604800000, + refreshToken: 'the-refresh-token', + }); + expect(res.cookie).not.toHaveBeenCalledWith( + 'openid_access_token', + expect.any(String), + expect.any(Object), + ); + expect(res.cookie).not.toHaveBeenCalledWith( + 'openid_id_token', + expect.any(String), + expect.any(Object), + ); }); - it('should set secure: true when shouldUseSecureCookie returns true', () => { - shouldUseSecureCookie.mockReturnValue(true); - - const tokenset = { - id_token: 'the-id-token', - access_token: 'the-access-token', - refresh_token: 'the-refresh-token', - }; - const req = mockRequest(); - const res = mockResponse(); - - setOpenIDAuthTokens(tokenset, req, res, 'user-123'); - - for (const [, cookie] of Object.entries(res._cookies)) { - expect(cookie.options.secure).toBe(true); - } - }); - - it('should use shouldUseSecureCookie for cookie fallback path (no session)', () => { - shouldUseSecureCookie.mockReturnValue(false); - + it('uses cookie fallback for OpenID access tokens when no session is available', () => { const tokenset = { id_token: 'the-id-token', access_token: 'the-access-token', @@ -421,21 +614,34 @@ describe('setOpenIDAuthTokens', () => { setOpenIDAuthTokens(tokenset, req, res, 'user-123'); - // In the cookie fallback path, we get: refreshToken, openid_access_token, token_provider, openid_user_id - expect(res.cookie).toHaveBeenCalledWith( - 'refreshToken', - expect.any(String), - expect.objectContaining({ secure: false }), + expect(setRefreshTokenCookie).toHaveBeenCalledWith( + res, + 'the-refresh-token', + expect.any(Date), ); + expect(setOpenIDMarkerCookies).toHaveBeenCalledWith(res, { + userId: 'user-123', + expires: expect.any(Date), + refreshExpiryMs: 604800000, + refreshToken: 'the-refresh-token', + }); expect(res.cookie).toHaveBeenCalledWith( 'openid_access_token', - expect.any(String), - expect.objectContaining({ secure: false }), + 'the-access-token', + expect.objectContaining({ + expires: expect.any(Date), + httpOnly: true, + sameSite: 'strict', + }), ); expect(res.cookie).toHaveBeenCalledWith( - 'token_provider', - 'openid', - expect.objectContaining({ secure: false }), + 'openid_id_token', + 'the-id-token', + expect.objectContaining({ + expires: expect.any(Date), + httpOnly: true, + sameSite: 'strict', + }), ); }); }); diff --git a/api/server/services/GraphApiService.spec.js b/api/server/services/GraphApiService.spec.js index 0a625e77e1..3f085dabb7 100644 --- a/api/server/services/GraphApiService.spec.js +++ b/api/server/services/GraphApiService.spec.js @@ -208,10 +208,12 @@ describe('GraphApiService', () => { ); } + /** The entry expires 30s (OPENID_EXPIRY_BUFFER_SECONDS) before the credential it holds, so a + * token served from cache cannot expire in transit and 401 against Graph. */ expect(mockTokensCache.set).toHaveBeenCalledWith( 'test-user:graph', { access_token: 'mocked-graph-token' }, - 3600000, + 3570000, ); expect(result).toBe('mocked-graph-token'); diff --git a/api/server/services/MCP.js b/api/server/services/MCP.js index 3e5cb87b0e..3f636d7591 100644 --- a/api/server/services/MCP.js +++ b/api/server/services/MCP.js @@ -38,6 +38,7 @@ const { requiresOAuthMachinery, hasRuntimeUrlPlaceholders, containsGraphTokenPlaceholder, + createAuthIdentityContext, isOAuthServer, OpenIDReauthRequiredError, } = require('@librechat/api'); @@ -60,6 +61,7 @@ const { findToken, createToken, updateToken, deleteTokens, findPluginAuthsByKeys const { getGraphApiToken } = require('./GraphTokenService'); const { exchangeOboToken } = require('./OboTokenService'); const { createOboTrustChecker } = require('./OboPolicyService'); +const { createOpenIDSessionTokenProvider } = require('./OpenIDSessionRefresh'); const { reinitMCPServer } = require('./Tools/mcp'); const { getAppConfig, @@ -367,12 +369,24 @@ async function healMcpToolNames({ req, tools, toolDefinitions, accessibleServerN * server slices instead of relying on the static aggregate cache. * @param {object} params * @param {ServerRequest} params.req + * @param {ServerResponse} [params.res] * @param {Array} [params.tools] * @returns {Promise} */ -async function getAssistantToolDefinitions({ req, tools }) { +async function getAssistantToolDefinitions({ req, res, tools }) { const registry = getMCPServersRegistry(); const appConfig = await getAppConfigForRequest(req); + const oboIdentityContext = createAuthIdentityContext({ + user: req.user, + tenantId: getTenantId(), + }); + const upstreamTokenProvider = createOpenIDSessionTokenProvider({ + req, + res: res ?? req.res, + user: req.user, + identityContext: oboIdentityContext, + tokenPreference: 'access_token', + }); return await loadAssistantToolDefinitions( { user: req.user, @@ -404,6 +418,8 @@ async function getAssistantToolDefinitions({ req, tools }) { serverName, serverConfig, userMCPAuthMap, + upstreamTokenProvider, + oboIdentityContext, }); return result?.availableTools ?? null; }, @@ -669,9 +685,29 @@ function createOAuthCallback({ runStepEmitter, runStepDeltaEmitter }) { }; } +function resolveToolCallUserId({ effectiveUser, capturedUser, invocationUserId, serverConfig }) { + if (serverConfig?.obo == null) { + return effectiveUser?.id || invocationUserId || capturedUser?.id; + } + + const effectiveUserId = effectiveUser?.id; + const capturedUserId = capturedUser?.id; + if (!effectiveUserId || !capturedUserId) { + throw new Error('OBO tool calls require matching captured and effective user ids'); + } + + if (effectiveUserId !== capturedUserId) { + throw new Error('OBO tool call user mismatch'); + } + + return effectiveUserId; +} + /** * @param {Object} params * @param {ServerResponse} params.res - The Express response object for sending events. + * @param {import('@librechat/api').UpstreamTokenProvider} [params.upstreamTokenProvider] - Live upstream-token closure for OBO, built at the request boundary so this layer never receives the raw Express request. + * @param {import('@librechat/api').AuthIdentityContext} [params.oboIdentityContext] - Non-template-visible OBO identity context built from the real request user. * @param {IUser} params.user - The user from the request object. * @param {string} params.serverName * @param {AbortSignal} params.signal @@ -695,6 +731,8 @@ async function reconnectServer({ userMCPAuthMap, requestBody, requestScopedConnections, + upstreamTokenProvider, + oboIdentityContext, streamId = null, jobCreatedAt, }) { @@ -760,6 +798,8 @@ async function reconnectServer({ userMCPAuthMap, requestBody, requestScopedConnections, + upstreamTokenProvider, + oboIdentityContext, forceNew: true, returnOnOAuth: false, connectionTimeout: Time.THIRTY_SECONDS, @@ -787,6 +827,8 @@ async function reconnectServer({ * @param {import('@librechat/api').RequestBody} [params.requestBody] * @param {import('@librechat/api').RequestScopedMCPConnectionStore} [params.requestScopedConnections] * @param {Record>} [params.userMCPAuthMap] + * @param {import('@librechat/api').UpstreamTokenProvider} [params.upstreamTokenProvider] - Live upstream-token closure for OBO, built at the request boundary. + * @param {import('@librechat/api').AuthIdentityContext} [params.oboIdentityContext] - Non-template-visible OBO identity context built from the real request user. * @returns { Promise unknown}>> } An object with `_call` method to execute the tool input. */ async function createMCPTools({ @@ -802,6 +844,8 @@ async function createMCPTools({ userMCPAuthMap, requestBody, requestScopedConnections, + upstreamTokenProvider, + oboIdentityContext, streamId = null, jobCreatedAt, }) { @@ -842,6 +886,8 @@ async function createMCPTools({ userMCPAuthMap, requestBody, requestScopedConnections, + upstreamTokenProvider, + oboIdentityContext, streamId, jobCreatedAt, }); @@ -877,6 +923,8 @@ async function createMCPTools({ toolKey: `${keyToolNames.get(tool.name) ?? tool.name}${Constants.mcp_delimiter}${keyServerName}`, requestBody, requestScopedConnections, + upstreamTokenProvider, + oboIdentityContext, config: serverConfig, }); if (toolInstance) { @@ -904,6 +952,9 @@ async function createMCPTools({ * @param {import('@librechat/api').RequestScopedMCPConnectionStore} [params.requestScopedConnections] * @param {Record>} [params.userMCPAuthMap] * @param {import('@librechat/api').ParsedServerConfig} [params.config] + * @param {import('@librechat/api').UpstreamTokenProvider} [params.upstreamTokenProvider] - Live upstream-token closure for OBO, built at the request boundary. + * @param {import('@librechat/api').AuthIdentityContext} [params.oboIdentityContext] - Non-template-visible OBO identity context built from the real request user. + * @param {string} [params.serverName] - Resolved raw MCP server name from tool loading. * @param {(availableTools: LCAvailableTools) => void} [params.onAvailableTools] * @param {number} [params.jobCreatedAt] - The generation epoch that owns emitted events. * @returns { Promise unknown}> } An object with `_call` method to execute the tool input. @@ -922,6 +973,8 @@ async function createMCPTool({ requestScopedConnections, config, configServers, + upstreamTokenProvider, + oboIdentityContext, serverName: resolvedServerName, onAvailableTools, streamId = null, @@ -1050,6 +1103,8 @@ async function createMCPTool({ userMCPAuthMap, requestBody, requestScopedConnections, + upstreamTokenProvider, + oboIdentityContext, streamId, jobCreatedAt, }); @@ -1092,6 +1147,8 @@ async function createMCPTool({ serverName, serverConfig, toolDefinition: toolEntry['function'], + upstreamTokenProvider, + oboIdentityContext, streamId, jobCreatedAt, }); @@ -1110,6 +1167,8 @@ function createToolInstance({ serverConfig: capturedServerConfig, toolDefinition, provider: capturedProvider, + upstreamTokenProvider: capturedUpstreamTokenProvider = null, + oboIdentityContext: capturedOboIdentityContext = null, streamId = null, jobCreatedAt, }) { @@ -1141,8 +1200,16 @@ function createToolInstance({ const _call = async (toolArguments, config) => { const effectiveUser = config?.configurable?.user ?? capturedUser; const permissionUser = effectiveUser; - const userId = effectiveUser?.id || config?.configurable?.user_id || capturedUser?.id; + /** @type {string | undefined} */ + let userId; + try { + userId = resolveToolCallUserId({ + effectiveUser, + capturedUser, + invocationUserId: config?.configurable?.user_id, + serverConfig: capturedServerConfig, + }); const provider = (config?.metadata?.provider || capturedProvider)?.toLowerCase(); const canUseMCP = mcpPermissionContext ? await mcpPermissionContext.canUseServers(permissionUser) @@ -1180,6 +1247,17 @@ function createToolInstance({ const customUserVars = config?.configurable?.userMCPAuthMap?.[`${Constants.mcp_prefix}${serverName}`]; + /** + * The upstream-token closure is built at the request boundary (where + * `req`/`res` are in scope) and captured here, so this layer never holds + * the raw Express request. The closure reads/refreshes the LIVE + * `req.session.openidTokens` at call time and persists rotations; it is a + * no-op when reuse is off or the user is non-OpenID. A browser request whose session loses + * openidTokens rejects instead of falling back to a stale strategy snapshot. + * `tokenPreference: 'access_token'` (set at construction) + * is required for OBO since the grant sends the access token to the IdP + * as the jwt-bearer assertion. + */ const result = await mcpManager.callTool({ serverName, serverConfig: capturedServerConfig, @@ -1208,6 +1286,8 @@ function createToolInstance({ graphTokenResolver: getGraphApiToken, oboTokenResolver: exchangeOboToken, oboTrustChecker: createOboTrustChecker(), + upstreamTokenProvider: capturedUpstreamTokenProvider, + oboIdentityContext: capturedOboIdentityContext, }); if (isAssistantsEndpoint(provider) && Array.isArray(result)) { diff --git a/api/server/services/MCP.spec.js b/api/server/services/MCP.spec.js index 36a1ac57ed..86a0681c28 100644 --- a/api/server/services/MCP.spec.js +++ b/api/server/services/MCP.spec.js @@ -91,6 +91,14 @@ jest.mock('./GraphTokenService', () => ({ getGraphApiToken: jest.fn(), })); +jest.mock('./OboTokenService', () => ({ + exchangeOboToken: jest.fn(), +})); + +jest.mock('./OboPolicyService', () => ({ + createOboTrustChecker: jest.fn(() => jest.fn()), +})); + describe('tests for the new helper functions used by the MCP connection status endpoints', () => { let mockGetMCPManager; let mockGetFlowStateManager; @@ -2333,6 +2341,200 @@ describe('User parameter passing tests', () => { }), ); }); + + it('forwards the pre-built upstream-token closure to callTool without receiving req', async () => { + const mockUser = { + id: 'obo-user', + email: 'obo@example.com', + role: 'USER', + provider: 'openid', + }; + const mockRes = { write: jest.fn(), flush: jest.fn() }; + const { getRoleByName } = require('~/models'); + getRoleByName.mockResolvedValue({ + permissions: { + [PermissionTypes.MCP_SERVERS]: { + [Permissions.USE]: true, + }, + }, + }); + + const sentinelClosure = async () => null; + + const mockCallTool = jest.fn().mockResolvedValue(['ok', null]); + mockGetMCPManager.mockReturnValue({ callTool: mockCallTool }); + + const mcpTool = await createMCPTool({ + res: mockRes, + user: mockUser, + toolKey: `test-tool${D}test-server`, + provider: 'openai', + userMCPAuthMap: {}, + upstreamTokenProvider: sentinelClosure, + availableTools: { + [`test-tool${D}test-server`]: { + function: { + description: 'Cached tool', + parameters: { type: 'object', properties: {} }, + }, + }, + }, + }); + + await expect( + mcpTool.invoke( + {}, + { + configurable: { user: mockUser }, + metadata: { provider: 'openai', thread_id: 't1', run_id: 'r1' }, + toolCall: {}, + }, + ), + ).resolves.toBe('ok'); + + expect(mockCallTool).toHaveBeenCalledWith( + expect.objectContaining({ + upstreamTokenProvider: sentinelClosure, + }), + ); + }); + + it('should reject OBO tool execution when effective and captured users differ', async () => { + const capturedUser = { id: 'captured-user', email: 'captured@example.com', role: 'USER' }; + const effectiveUser = { id: 'effective-user', email: 'effective@example.com', role: 'USER' }; + const mockRes = { write: jest.fn(), flush: jest.fn() }; + + const mcpTool = await createMCPTool({ + res: mockRes, + user: capturedUser, + toolKey: `test-tool${D}obo-server`, + provider: 'openai', + userMCPAuthMap: {}, + config: { + url: 'https://obo.example.com', + obo: { scopes: 'api://obo-server/Mcp.Tools.ReadWrite' }, + }, + availableTools: { + [`test-tool${D}obo-server`]: { + function: { + description: 'Cached OBO tool', + parameters: { type: 'object', properties: {} }, + }, + }, + }, + }); + + await expect( + mcpTool.invoke( + {}, + { + configurable: { user: effectiveUser }, + metadata: { provider: 'openai', thread_id: 't1', run_id: 'r1' }, + toolCall: {}, + }, + ), + ).rejects.toThrow('OBO tool call user mismatch'); + + expect(mockGetMCPManager).not.toHaveBeenCalled(); + }); + + it('should reject OBO tool execution when an effective or captured user id is missing', async () => { + const capturedUser = { email: 'captured@example.com', role: 'USER' }; + const effectiveUser = { id: 'effective-user', email: 'effective@example.com', role: 'USER' }; + const mockRes = { write: jest.fn(), flush: jest.fn() }; + + const mcpTool = await createMCPTool({ + res: mockRes, + user: capturedUser, + toolKey: `test-tool${D}obo-server`, + provider: 'openai', + userMCPAuthMap: {}, + config: { + url: 'https://obo.example.com', + obo: { scopes: 'api://obo-server/Mcp.Tools.ReadWrite' }, + }, + availableTools: { + [`test-tool${D}obo-server`]: { + function: { + description: 'Cached OBO tool', + parameters: { type: 'object', properties: {} }, + }, + }, + }, + }); + + await expect( + mcpTool.invoke( + {}, + { + configurable: { user: effectiveUser }, + metadata: { provider: 'openai', thread_id: 't1', run_id: 'r1' }, + toolCall: {}, + }, + ), + ).rejects.toThrow('OBO tool calls require matching captured and effective user ids'); + + expect(mockGetMCPManager).not.toHaveBeenCalled(); + }); + + it('should execute OBO tools when effective and captured user ids match', async () => { + const capturedUser = { id: 'obo-user', email: 'captured@example.com', role: 'USER' }; + const effectiveUser = { id: 'obo-user', email: 'effective@example.com', role: 'USER' }; + const mockRes = { write: jest.fn(), flush: jest.fn() }; + const { getRoleByName } = require('~/models'); + getRoleByName.mockResolvedValue({ + permissions: { + [PermissionTypes.MCP_SERVERS]: { + [Permissions.USE]: true, + }, + }, + }); + + const mockCallTool = jest.fn().mockResolvedValue(['ok', null]); + mockGetMCPManager.mockReturnValue({ callTool: mockCallTool }); + + const mcpTool = await createMCPTool({ + res: mockRes, + user: capturedUser, + toolKey: `test-tool${D}obo-server`, + provider: 'openai', + userMCPAuthMap: {}, + upstreamTokenProvider: async () => null, + config: { + url: 'https://obo.example.com', + obo: { scopes: 'api://obo-server/Mcp.Tools.ReadWrite' }, + }, + availableTools: { + [`test-tool${D}obo-server`]: { + function: { + description: 'Cached OBO tool', + parameters: { type: 'object', properties: {} }, + }, + }, + }, + }); + + await expect( + mcpTool.invoke( + {}, + { + configurable: { + user: effectiveUser, + user_id: 'third-user', + }, + metadata: { provider: 'openai', thread_id: 't1', run_id: 'r1' }, + toolCall: {}, + }, + ), + ).resolves.toBe('ok'); + + expect(mockGetMCPManager).toHaveBeenCalledWith('obo-user'); + expect(mockCallTool).toHaveBeenCalledWith( + expect.objectContaining({ + user: effectiveUser, + }), + ); + }); }); describe('reinitMCPServer (via reconnectServer)', () => { diff --git a/api/server/services/OboTokenService.js b/api/server/services/OboTokenService.js index 86edf15b5a..fc1a7a45d9 100644 --- a/api/server/services/OboTokenService.js +++ b/api/server/services/OboTokenService.js @@ -1,9 +1,14 @@ +const crypto = require('node:crypto'); const client = require('openid-client'); const { logger } = require('@librechat/data-schemas'); const { CacheKeys } = require('librechat-data-provider'); const { + createOpenIDOboIdentityTuple, + getSkewedTokenCacheTtlMs, + getTokenExpiresAtMs, + hasUsableTokenExpiry, normalizeExpiresIn, - getTokenCacheTtlMs, + serializeAuthIdentityTuple, DEFAULT_OAUTH_TOKEN_TTL_SECONDS, } = require('@librechat/api'); const { getOpenIdConfig } = require('~/strategies/openidStrategy'); @@ -14,7 +19,7 @@ const RETRYABLE_ERROR_CODES = new Set(['ETIMEDOUT', 'ECONNRESET', 'EAI_AGAIN', ' const OBO_RETRY_DELAY_MS = 300; /** - * In-flight OBO exchanges keyed by `${openidId}:${scopes}`. + * In-flight OBO exchanges keyed by identity tuple, scopes, and upstream assertion hash. * * Without coalescing, parallel tool calls that arrive on a cache miss each issue * their own jwt-bearer request to the IdP. Under fan-out, Entra intermittently @@ -64,6 +69,26 @@ function tagOboExchangeError(error, retryable) { return error; } +function createEmptyOboExchangeResponseError() { + const error = new Error('The identity provider returned no access token for the OBO exchange'); + error.retryable = false; + error.oboFailureReason = 'empty_exchange_response'; + return error; +} + +function sha256(value) { + return crypto.createHash('sha256').update(value).digest('hex'); +} + +function buildOboCacheKey({ user, accessToken, scopes, identityContext }) { + const tuple = createOpenIDOboIdentityTuple({ user, identityContext }); + if (!tuple) { + return null; + } + + return [serializeAuthIdentityTuple(tuple), scopes, sha256(accessToken)].join('\x1f'); +} + async function delay(ms) { await new Promise((resolve) => setTimeout(resolve, ms)); } @@ -98,18 +123,22 @@ async function performOboExchange({ user, accessToken, scopes, config, tokensCac } } + if (!grantResponse?.access_token) { + throw createEmptyOboExchangeResponseError(); + } + + const expiresIn = normalizeExpiresIn(grantResponse.expires_in) ?? DEFAULT_OAUTH_TOKEN_TTL_SECONDS; + const now = Date.now(); + const expiresAt = getTokenExpiresAtMs({ expiresIn, now }); const tokenResponse = { access_token: grantResponse.access_token, token_type: 'Bearer', - expires_in: normalizeExpiresIn(grantResponse.expires_in) ?? DEFAULT_OAUTH_TOKEN_TTL_SECONDS, + expires_in: expiresIn, + expires_at: expiresAt, scope: scopes, }; - await tokensCache.set( - cacheKey, - tokenResponse, - getTokenCacheTtlMs(grantResponse.expires_in, DEFAULT_OAUTH_TOKEN_TTL_SECONDS), - ); + await tokensCache.set(cacheKey, tokenResponse, getSkewedTokenCacheTtlMs(expiresAt, now)); logger.debug( `[OboTokenService] Successfully obtained and cached OBO token for user: ${user.openidId}`, @@ -121,7 +150,7 @@ async function performOboExchange({ user, accessToken, scopes, config, tokensCac * Exchange a user's access token for a downstream-scoped token via the * OAuth 2.0 On-Behalf-Of (jwt-bearer) grant. * - * Concurrent callers for the same `${openidId}:${scopes}` key share a single + * Concurrent callers for the same identity/scopes/assertion key share a single * upstream exchange (see `inFlightExchanges`) so a fan-out of tool calls right * after a cache miss does not produce N parallel requests to the IdP. * @@ -130,13 +159,11 @@ async function performOboExchange({ user, accessToken, scopes, config, tokensCac * @param {string} scopes - Scopes to request for the downstream service * @param {boolean} [fromCache=true] - When true, read from cache and join any * in-flight exchange. When false, bypass both and force a fresh exchange. + * @param {import('@librechat/api').AuthIdentityContext} [identityContext] - Real request identity + * context used to scope OBO cache keys without exposing tenant/issuer placeholders. * @returns {Promise} Token response with access_token and expires_in */ -async function exchangeOboToken(user, accessToken, scopes, fromCache = true) { - if (!user.openidId) { - throw new Error('User must be authenticated via OpenID to perform OBO token exchange'); - } - +async function exchangeOboToken(user, accessToken, scopes, fromCache = true, identityContext) { if (!accessToken) { throw new Error('Access token is required for OBO exchange'); } @@ -150,14 +177,27 @@ async function exchangeOboToken(user, accessToken, scopes, fromCache = true) { throw new Error('OpenID configuration not available'); } - const cacheKey = `${user.openidId}:${scopes}`; + const cacheKey = buildOboCacheKey({ + user, + accessToken, + scopes, + identityContext, + }); + if (!cacheKey) { + throw new Error('User must be authenticated via OpenID to perform OBO token exchange'); + } const tokensCache = getLogStores(CacheKeys.OPENID_EXCHANGED_TOKENS); if (fromCache) { const cachedToken = await tokensCache.get(cacheKey); if (cachedToken) { - logger.debug(`[OboTokenService] Using cached token for user: ${user.openidId}`); - return cachedToken; + if (hasUsableTokenExpiry(cachedToken.expires_at)) { + logger.debug(`[OboTokenService] Using cached token for user: ${user.openidId}`); + return cachedToken; + } + logger.debug( + `[OboTokenService] Ignoring cached OBO token without usable expiry for user: ${user.openidId}`, + ); } const inFlight = inFlightExchanges.get(cacheKey); @@ -200,4 +240,7 @@ async function exchangeOboToken(user, accessToken, scopes, fromCache = true) { module.exports = { exchangeOboToken, + __internals: { + buildOboCacheKey, + }, }; diff --git a/api/server/services/OboTokenService.spec.js b/api/server/services/OboTokenService.spec.js index 122ddee4b2..f08eef4405 100644 --- a/api/server/services/OboTokenService.spec.js +++ b/api/server/services/OboTokenService.spec.js @@ -12,7 +12,7 @@ jest.mock('@librechat/data-schemas', () => ({ const client = require('openid-client'); const { getOpenIdConfig } = require('~/strategies/openidStrategy'); const getLogStores = require('~/cache/getLogStores'); -const { exchangeOboToken } = require('./OboTokenService'); +const { exchangeOboToken, __internals } = require('./OboTokenService'); describe('OboTokenService', () => { let mockTokensCache; @@ -20,10 +20,15 @@ describe('OboTokenService', () => { const mockUser = { openidId: 'oidc-sub-123', + tenantId: 'tenant-a', + openidIssuer: 'https://issuer-a.example.com', email: 'test@example.com', name: 'Test User', }; + const expectedCacheKey = (user, accessToken, scopes, identityContext) => + __internals.buildOboCacheKey({ user, accessToken, scopes, identityContext }); + beforeEach(() => { jest.clearAllMocks(); @@ -79,6 +84,7 @@ describe('OboTokenService', () => { access_token: 'cached-obo-token', token_type: 'Bearer', expires_in: 1800, + expires_at: Date.now() + 1800 * 1000, scope: 'api://mcp-server/Scope.Read', }; mockTokensCache.get.mockResolvedValue(cachedToken); @@ -91,10 +97,33 @@ describe('OboTokenService', () => { ); expect(result).toBe(cachedToken); - expect(mockTokensCache.get).toHaveBeenCalledWith('oidc-sub-123:api://mcp-server/Scope.Read'); + expect(mockTokensCache.get).toHaveBeenCalledWith( + expectedCacheKey(mockUser, 'access-token', 'api://mcp-server/Scope.Read'), + ); expect(client.genericGrantRequest).not.toHaveBeenCalled(); }); + it('should ignore legacy cached tokens without absolute expiry', async () => { + const cachedToken = { + access_token: 'cached-obo-token', + token_type: 'Bearer', + expires_in: 1800, + scope: 'api://mcp-server/Scope.Read', + }; + mockTokensCache.get.mockResolvedValue(cachedToken); + + const result = await exchangeOboToken( + mockUser, + 'access-token', + 'api://mcp-server/Scope.Read', + true, + ); + + expect(result.access_token).toBe('exchanged-obo-token'); + expect(client.genericGrantRequest).toHaveBeenCalledTimes(1); + expect(mockTokensCache.set).toHaveBeenCalledTimes(1); + }); + it('should skip cache when fromCache is false', async () => { const cachedToken = { access_token: 'cached-obo-token' }; mockTokensCache.get.mockResolvedValue(cachedToken); @@ -116,7 +145,9 @@ describe('OboTokenService', () => { await exchangeOboToken(mockUser, 'access-token', 'api://scope'); - expect(mockTokensCache.get).toHaveBeenCalledWith('oidc-sub-123:api://scope'); + expect(mockTokensCache.get).toHaveBeenCalledWith( + expectedCacheKey(mockUser, 'access-token', 'api://scope'), + ); }); }); @@ -146,11 +177,12 @@ describe('OboTokenService', () => { access_token: 'exchanged-obo-token', token_type: 'Bearer', expires_in: 3600, + expires_at: expect.any(Number), scope: 'api://mcp-server/Tools.ReadWrite', }); }); - it('should cache the exchanged token with correct TTL', async () => { + it('should cache the exchanged token with a skewed TTL', async () => { client.genericGrantRequest.mockResolvedValue({ access_token: 'new-obo-token', expires_in: 1800, @@ -159,14 +191,15 @@ describe('OboTokenService', () => { await exchangeOboToken(mockUser, 'access-token', 'api://scope'); expect(mockTokensCache.set).toHaveBeenCalledWith( - 'oidc-sub-123:api://scope', + expectedCacheKey(mockUser, 'access-token', 'api://scope'), { access_token: 'new-obo-token', token_type: 'Bearer', expires_in: 1800, + expires_at: expect.any(Number), scope: 'api://scope', }, - 1800 * 1000, + 1770 * 1000, ); }); @@ -179,9 +212,37 @@ describe('OboTokenService', () => { expect(result.expires_in).toBe(3600); expect(mockTokensCache.set).toHaveBeenCalledWith( - 'oidc-sub-123:api://scope', + expectedCacheKey(mockUser, 'access-token', 'api://scope'), expect.objectContaining({ expires_in: 3600 }), - 3600 * 1000, + 3570 * 1000, + ); + }); + + it('should not cache an exchange response without access_token', async () => { + client.genericGrantRequest.mockResolvedValue({ + expires_in: 3600, + }); + + await expect(exchangeOboToken(mockUser, 'access-token', 'api://scope')).rejects.toThrow( + 'The identity provider returned no access token for the OBO exchange', + ); + + expect(mockTokensCache.set).not.toHaveBeenCalled(); + }); + + it('should keep a positive TTL for very short-lived tokens', async () => { + client.genericGrantRequest.mockResolvedValue({ + access_token: 'short-lived-token', + expires_in: 10, + }); + + const result = await exchangeOboToken(mockUser, 'access-token', 'api://scope'); + + expect(result.expires_in).toBe(10); + expect(mockTokensCache.set).toHaveBeenCalledWith( + expectedCacheKey(mockUser, 'access-token', 'api://scope'), + expect.objectContaining({ access_token: 'short-lived-token', expires_in: 10 }), + 1000, ); }); @@ -215,6 +276,7 @@ describe('OboTokenService', () => { access_token: 'retried-obo-token', token_type: 'Bearer', expires_in: 1800, + expires_at: expect.any(Number), scope: 'api://scope', }); } finally { @@ -237,12 +299,23 @@ describe('OboTokenService', () => { }); describe('cache key isolation', () => { + it('does not include the raw upstream assertion in cache keys', async () => { + await exchangeOboToken(mockUser, 'sensitive-access-token', 'api://scope'); + + expect(mockTokensCache.get.mock.calls[0][0]).not.toContain('sensitive-access-token'); + expect(mockTokensCache.set.mock.calls[0][0]).not.toContain('sensitive-access-token'); + }); + it('should use different cache keys for different scopes', async () => { await exchangeOboToken(mockUser, 'access-token', 'api://server-a/Scope.A'); await exchangeOboToken(mockUser, 'access-token', 'api://server-b/Scope.B'); - expect(mockTokensCache.get).toHaveBeenCalledWith('oidc-sub-123:api://server-a/Scope.A'); - expect(mockTokensCache.get).toHaveBeenCalledWith('oidc-sub-123:api://server-b/Scope.B'); + expect(mockTokensCache.get).toHaveBeenCalledWith( + expectedCacheKey(mockUser, 'access-token', 'api://server-a/Scope.A'), + ); + expect(mockTokensCache.get).toHaveBeenCalledWith( + expectedCacheKey(mockUser, 'access-token', 'api://server-b/Scope.B'), + ); }); it('should use different cache keys for different users', async () => { @@ -251,8 +324,88 @@ describe('OboTokenService', () => { await exchangeOboToken(mockUser, 'access-token', 'api://scope'); await exchangeOboToken(otherUser, 'access-token', 'api://scope'); - expect(mockTokensCache.get).toHaveBeenCalledWith('oidc-sub-123:api://scope'); - expect(mockTokensCache.get).toHaveBeenCalledWith('oidc-sub-456:api://scope'); + expect(mockTokensCache.get).toHaveBeenCalledWith( + expectedCacheKey(mockUser, 'access-token', 'api://scope'), + ); + expect(mockTokensCache.get).toHaveBeenCalledWith( + expectedCacheKey(otherUser, 'access-token', 'api://scope'), + ); + }); + + it('should use different cache keys for different tenants with the same OpenID subject', async () => { + const identityA = { + openidSubject: 'shared-sub', + tenantId: 'tenant-a', + openidIssuer: 'https://issuer.example.com', + }; + const identityB = { + openidSubject: 'shared-sub', + tenantId: 'tenant-b', + openidIssuer: 'https://issuer.example.com', + }; + const safeUser = { openidId: 'shared-sub', email: 'test@example.com' }; + + await exchangeOboToken(safeUser, 'access-token', 'api://scope', true, identityA); + await exchangeOboToken(safeUser, 'access-token', 'api://scope', true, identityB); + + expect(mockTokensCache.get).toHaveBeenCalledWith( + expectedCacheKey(safeUser, 'access-token', 'api://scope', identityA), + ); + expect(mockTokensCache.get).toHaveBeenCalledWith( + expectedCacheKey(safeUser, 'access-token', 'api://scope', identityB), + ); + }); + + it('should use identity context when the forwarded user lacks OpenID fields', async () => { + const identityContext = { + openidSubject: 'context-sub', + tenantId: 'tenant-a', + openidIssuer: 'https://issuer.example.com', + }; + const safeUser = { id: 'safe-user-id', email: 'test@example.com' }; + + await exchangeOboToken(safeUser, 'access-token', 'api://scope', true, identityContext); + + expect(mockTokensCache.get).toHaveBeenCalledWith( + expectedCacheKey(safeUser, 'access-token', 'api://scope', identityContext), + ); + expect(client.genericGrantRequest).toHaveBeenCalledTimes(1); + }); + + it('should use different cache keys for different issuers with the same OpenID subject', async () => { + const identityA = { + openidSubject: 'shared-sub', + tenantId: 'tenant-a', + openidIssuer: 'https://issuer-a.example.com', + }; + const identityB = { + openidSubject: 'shared-sub', + tenantId: 'tenant-a', + openidIssuer: 'https://issuer-b.example.com', + }; + const safeUser = { openidId: 'shared-sub', email: 'test@example.com' }; + + await exchangeOboToken(safeUser, 'access-token', 'api://scope', true, identityA); + await exchangeOboToken(safeUser, 'access-token', 'api://scope', true, identityB); + + expect(mockTokensCache.get).toHaveBeenCalledWith( + expectedCacheKey(safeUser, 'access-token', 'api://scope', identityA), + ); + expect(mockTokensCache.get).toHaveBeenCalledWith( + expectedCacheKey(safeUser, 'access-token', 'api://scope', identityB), + ); + }); + + it('should use different cache keys when the upstream assertion rotates', async () => { + await exchangeOboToken(mockUser, 'access-token-old', 'api://scope'); + await exchangeOboToken(mockUser, 'access-token-new', 'api://scope'); + + expect(mockTokensCache.get).toHaveBeenCalledWith( + expectedCacheKey(mockUser, 'access-token-old', 'api://scope'), + ); + expect(mockTokensCache.get).toHaveBeenCalledWith( + expectedCacheKey(mockUser, 'access-token-new', 'api://scope'), + ); }); }); @@ -292,6 +445,15 @@ describe('OboTokenService', () => { expect(client.genericGrantRequest).toHaveBeenCalledTimes(2); }); + it('does not coalesce exchanges for rotated upstream assertions', async () => { + await Promise.all([ + exchangeOboToken(mockUser, 'access-token-old', 'api://scope'), + exchangeOboToken(mockUser, 'access-token-new', 'api://scope'), + ]); + + expect(client.genericGrantRequest).toHaveBeenCalledTimes(2); + }); + it('clears the in-flight slot after a successful exchange', async () => { await exchangeOboToken(mockUser, 'access-token', 'api://scope'); expect(client.genericGrantRequest).toHaveBeenCalledTimes(1); diff --git a/api/server/services/OpenIDRefreshFlight.js b/api/server/services/OpenIDRefreshFlight.js new file mode 100644 index 0000000000..691833d85c --- /dev/null +++ b/api/server/services/OpenIDRefreshFlight.js @@ -0,0 +1,10 @@ +const { createOpenIDRefreshFlightService } = require('@librechat/api'); +const { logger, encryptV2, decryptV2 } = require('@librechat/data-schemas'); +const db = require('~/models'); + +module.exports = createOpenIDRefreshFlightService({ + db, + logger, + encrypt: encryptV2, + decrypt: decryptV2, +}); diff --git a/api/server/services/OpenIDRefreshFlight.spec.js b/api/server/services/OpenIDRefreshFlight.spec.js new file mode 100644 index 0000000000..b19ac1753f --- /dev/null +++ b/api/server/services/OpenIDRefreshFlight.spec.js @@ -0,0 +1,640 @@ +jest.mock('@librechat/data-schemas', () => ({ + logger: { + warn: jest.fn(), + }, + encryptV2: jest.fn(async (value) => `encrypted:${value}`), + decryptV2: jest.fn(async (value) => value.replace(/^encrypted:/, '')), +})); + +jest.mock('@librechat/api', () => ({ + ...jest.requireActual('@librechat/api'), + createOpenIDRefreshIdentityTuple: ({ user, requestUser }) => { + const subject = user?.openidId || user?.id || requestUser?.openidId || requestUser?.id; + if (!subject) { + return null; + } + return { + tenantId: user?.tenantId || requestUser?.tenantId || 'no-tenant', + openidIssuer: user?.openidIssuer || requestUser?.openidIssuer || 'no-issuer', + subject, + }; + }, + serializeAuthIdentityTuple: ({ tenantId, openidIssuer, subject }) => + [tenantId, openidIssuer, subject].join('\x1f'), +})); + +jest.mock('~/models', () => ({ + acquireOpenIDRefreshFlight: jest.fn(), + claimOpenIDRefreshFlightDelivery: jest.fn(), + completeOpenIDRefreshFlight: jest.fn(), + failOpenIDRefreshFlight: jest.fn(), + findOpenIDRefreshFlight: jest.fn(), + revokeOpenIDRefreshFlight: jest.fn(), + renewOpenIDRefreshFlight: jest.fn(), + releaseOpenIDRefreshFlightDelivery: jest.fn(), +})); + +const { encryptV2, decryptV2 } = require('@librechat/data-schemas'); +const db = require('~/models'); +const { + acquireOpenIDRefreshFlight, + assertOpenIDRefreshFlightAvailable, + assertOpenIDRefreshFlightDeliveryAvailable, + assertOpenIDRefreshSessionGenerationAvailable, + claimOpenIDRefreshFlightDelivery, + completeOpenIDRefreshFlight, + createOpenIDRefreshFlightKey, + failOpenIDRefreshFlight, + renewOpenIDRefreshFlight, + releaseOpenIDRefreshFlightDelivery, + revokeOpenIDRefreshFlights, + waitForOpenIDRefreshFlight, + withOpenIDRefreshFlightLease, + __internals, +} = require('./OpenIDRefreshFlight'); + +describe('OpenIDRefreshFlight', () => { + beforeEach(() => { + jest.clearAllMocks(); + db.acquireOpenIDRefreshFlight.mockResolvedValue({ acquired: true, flight: null }); + db.claimOpenIDRefreshFlightDelivery.mockResolvedValue({ + status: 'completed', + ownerId: 'owner-1', + deliveryId: 'delivery-1', + }); + db.completeOpenIDRefreshFlight.mockResolvedValue({}); + db.failOpenIDRefreshFlight.mockResolvedValue({}); + db.findOpenIDRefreshFlight.mockResolvedValue(null); + db.renewOpenIDRefreshFlight.mockResolvedValue({ ownerId: 'owner-1', status: 'pending' }); + db.releaseOpenIDRefreshFlightDelivery.mockResolvedValue({ status: 'completed' }); + db.revokeOpenIDRefreshFlight.mockResolvedValue({ status: 'revoked' }); + }); + + it('creates a stable hash key from session, user, issuer, tenant, and refresh token', () => { + const req = { + sessionID: 'session-1', + user: { tenantId: 'tenant-1', openidIssuer: 'issuer-1' }, + }; + const user = { openidId: 'oidc-sub-1' }; + + const keyA = createOpenIDRefreshFlightKey({ req, user, refreshToken: 'rt-old' }); + const keyB = createOpenIDRefreshFlightKey({ req, user, refreshToken: 'rt-old' }); + const keyC = createOpenIDRefreshFlightKey({ req, user, refreshToken: 'rt-other' }); + const keyFromNewSession = createOpenIDRefreshFlightKey({ + req: { ...req, sessionID: 'session-2' }, + user, + refreshToken: 'rt-old', + }); + + expect(keyA).toBe(keyB); + expect(keyA).toBe(keyFromNewSession); + expect(keyA).not.toBe(keyC); + expect(keyA).not.toContain('rt-old'); + }); + + it('allows follower publication only while the completed flight remains available', async () => { + db.findOpenIDRefreshFlight.mockResolvedValueOnce({ status: 'completed', ownerId: 'owner-1' }); + + await expect( + assertOpenIDRefreshFlightAvailable({ key: 'flight-key', ownerId: 'owner-1' }), + ).resolves.toMatchObject({ status: 'completed' }); + + db.findOpenIDRefreshFlight.mockResolvedValueOnce({ status: 'completed', ownerId: 'owner-2' }); + await expect( + assertOpenIDRefreshFlightAvailable({ key: 'flight-key', ownerId: 'owner-1' }), + ).rejects.toMatchObject({ code: 'OPENID_REFRESH_OWNERSHIP_LOST' }); + }); + + it('rejects tombstoned or replaced session generations but permits expired records', async () => { + db.findOpenIDRefreshFlight.mockResolvedValueOnce({ status: 'revoked', ownerId: 'owner-1' }); + await expect( + assertOpenIDRefreshSessionGenerationAvailable({ key: 'flight-key', ownerId: 'owner-1' }), + ).rejects.toMatchObject({ code: 'OPENID_REFRESH_OWNERSHIP_LOST' }); + + db.findOpenIDRefreshFlight.mockResolvedValueOnce({ status: 'completed', ownerId: 'owner-2' }); + await expect( + assertOpenIDRefreshSessionGenerationAvailable({ key: 'flight-key', ownerId: 'owner-1' }), + ).rejects.toMatchObject({ code: 'OPENID_REFRESH_OWNERSHIP_LOST' }); + + db.findOpenIDRefreshFlight.mockResolvedValueOnce(null); + await expect( + assertOpenIDRefreshSessionGenerationAvailable({ key: 'flight-key', ownerId: 'owner-1' }), + ).resolves.toBe(true); + }); + + it('claims and releases a durable response-delivery lease for the exact generation', async () => { + const createdAt = Date.now() - 1000; + const claimed = await claimOpenIDRefreshFlightDelivery({ + key: 'flight-key', + ownerId: 'owner-1', + createdAt, + deliveryId: 'delivery-1', + ttl: 5000, + }); + + expect(claimed.deliveryId).toBe('delivery-1'); + expect(db.claimOpenIDRefreshFlightDelivery).toHaveBeenCalledWith({ + key: 'flight-key', + ownerId: 'owner-1', + deliveryId: 'delivery-1', + deliveryExpiresAt: expect.any(Date), + createdAt: new Date(createdAt), + }); + + await releaseOpenIDRefreshFlightDelivery({ + key: 'flight-key', + ownerId: 'owner-1', + deliveryId: 'delivery-1', + }); + expect(db.releaseOpenIDRefreshFlightDelivery).toHaveBeenCalledWith({ + key: 'flight-key', + ownerId: 'owner-1', + deliveryId: 'delivery-1', + }); + }); + + it('retries an expired synthetic generation when its previous delivery releases between reads', async () => { + db.claimOpenIDRefreshFlightDelivery.mockResolvedValueOnce(null).mockResolvedValueOnce({ + status: 'completed', + ownerId: 'owner-1', + deliveryId: 'delivery-2', + }); + db.findOpenIDRefreshFlight.mockResolvedValueOnce(null); + + await expect( + claimOpenIDRefreshFlightDelivery({ + key: 'flight-key', + ownerId: 'owner-1', + createdAt: Date.now() - 1000, + deliveryId: 'delivery-2', + ttl: 1000, + }), + ).resolves.toMatchObject({ deliveryId: 'delivery-2' }); + expect(db.claimOpenIDRefreshFlightDelivery).toHaveBeenCalledTimes(2); + }); + + it('authorizes only the active, unrevoked response-delivery lease', async () => { + db.findOpenIDRefreshFlight.mockResolvedValueOnce({ + status: 'completed', + ownerId: 'owner-1', + deliveryId: 'delivery-1', + deliveryExpiresAt: new Date(Date.now() + 5000), + }); + await expect( + assertOpenIDRefreshFlightDeliveryAvailable({ + key: 'flight-key', + ownerId: 'owner-1', + deliveryId: 'delivery-1', + }), + ).resolves.toBeUndefined(); + + db.findOpenIDRefreshFlight.mockResolvedValueOnce({ + status: 'completed', + ownerId: 'owner-1', + deliveryId: 'delivery-1', + deliveryExpiresAt: new Date(Date.now() + 5000), + revocationRequestedAt: new Date(), + }); + await expect( + assertOpenIDRefreshFlightDeliveryAvailable({ + key: 'flight-key', + ownerId: 'owner-1', + deliveryId: 'delivery-1', + }), + ).rejects.toMatchObject({ code: 'OPENID_REFRESH_OWNERSHIP_LOST' }); + }); + + it('uses explicit identity context when safe user lacks tenant and issuer', () => { + const req = { + sessionID: 'session-1', + user: { id: 'safe-user' }, + }; + const user = { id: 'safe-user' }; + + const keyA = createOpenIDRefreshFlightKey({ + req, + user, + refreshToken: 'rt-old', + identityContext: { + openidSubject: 'oidc-sub-1', + tenantId: 'tenant-a', + openidIssuer: 'https://issuer-a.example.com', + }, + }); + const keyB = createOpenIDRefreshFlightKey({ + req, + user, + refreshToken: 'rt-old', + identityContext: { + openidSubject: 'oidc-sub-1', + tenantId: 'tenant-b', + openidIssuer: 'https://issuer-a.example.com', + }, + }); + + expect(keyA).not.toBe(keyB); + }); + + it('returns null key when identity or refresh token is unavailable', () => { + expect(createOpenIDRefreshFlightKey({ req: {}, user: {}, refreshToken: 'rt' })).toBeNull(); + expect( + createOpenIDRefreshFlightKey({ + req: { user: { id: 'user-1' } }, + user: undefined, + refreshToken: undefined, + }), + ).toBeNull(); + }); + + it('acquires a Mongo flight with owner and expiry metadata', async () => { + const result = await acquireOpenIDRefreshFlight({ + key: 'flight-key', + ownerId: 'owner-1', + ttl: 60000, + lockTtl: 30000, + }); + + expect(result.acquired).toBe(true); + expect(result.key).toBe('flight-key'); + expect(result.ownerId).toBe('owner-1'); + expect(db.acquireOpenIDRefreshFlight).toHaveBeenCalledWith({ + key: 'flight-key', + ownerId: 'owner-1', + lockExpiresAt: expect.any(Date), + expiresAt: expect.any(Date), + }); + }); + + it('renews only the owning pending flight lease', async () => { + await renewOpenIDRefreshFlight({ + key: 'flight-key', + ownerId: 'owner-1', + lockTtl: 30000, + ttl: 60000, + }); + + expect(db.renewOpenIDRefreshFlight).toHaveBeenCalledWith({ + key: 'flight-key', + ownerId: 'owner-1', + lockExpiresAt: expect.any(Date), + expiresAt: expect.any(Date), + }); + }); + + it('keeps renewing a leader lease until its refresh operation settles', async () => { + jest.useFakeTimers(); + let resolveOperation; + const operation = jest.fn( + () => + new Promise((resolve) => { + resolveOperation = resolve; + }), + ); + + try { + const resultPromise = withOpenIDRefreshFlightLease({ + key: 'flight-key', + ownerId: 'owner-1', + heartbeatInterval: 1000, + lockTtl: 30000, + ttl: 60000, + operation, + }); + + await jest.advanceTimersByTimeAsync(1000); + expect(db.renewOpenIDRefreshFlight).toHaveBeenCalledTimes(1); + + resolveOperation('tokens'); + await expect(resultPromise).resolves.toBe('tokens'); + + await jest.advanceTimersByTimeAsync(2000); + expect(db.renewOpenIDRefreshFlight).toHaveBeenCalledTimes(1); + } finally { + jest.useRealTimers(); + } + }); + + it('rejects a stale owner after the heartbeat observes lease loss', async () => { + jest.useFakeTimers(); + db.renewOpenIDRefreshFlight.mockResolvedValueOnce(null); + let finishOperation; + const operation = jest.fn( + () => + new Promise((resolve) => { + finishOperation = resolve; + }), + ); + + try { + const resultPromise = withOpenIDRefreshFlightLease({ + key: 'flight-key', + ownerId: 'owner-1', + heartbeatInterval: 1000, + operation, + }); + + await jest.advanceTimersByTimeAsync(1000); + finishOperation('tokens'); + await expect(resultPromise).rejects.toThrow('ownership was lost'); + } finally { + jest.useRealTimers(); + } + }); + + it("does not treat the owner's completed flight as heartbeat lease loss", async () => { + jest.useFakeTimers(); + db.renewOpenIDRefreshFlight.mockResolvedValueOnce(null); + db.findOpenIDRefreshFlight.mockResolvedValueOnce({ + ownerId: 'owner-1', + status: 'completed', + }); + let finishOperation; + const operation = jest.fn( + () => + new Promise((resolve) => { + finishOperation = resolve; + }), + ); + + try { + const resultPromise = withOpenIDRefreshFlightLease({ + key: 'flight-key', + ownerId: 'owner-1', + heartbeatInterval: 1000, + operation, + }); + + await jest.advanceTimersByTimeAsync(1000); + finishOperation('tokens'); + await expect(resultPromise).resolves.toBe('tokens'); + expect(db.findOpenIDRefreshFlight).toHaveBeenCalledWith({ key: 'flight-key' }); + } finally { + jest.useRealTimers(); + } + }); + + it('does not let a trailing renewal failure replace a completed result', async () => { + jest.useFakeTimers(); + let rejectRenewal; + db.renewOpenIDRefreshFlight.mockReturnValueOnce( + new Promise((_resolve, reject) => { + rejectRenewal = reject; + }), + ); + let finishOperation; + const operation = jest.fn( + ({ markLeaseSettled }) => + new Promise((resolve) => { + finishOperation = (value) => { + markLeaseSettled(); + resolve(value); + }; + }), + ); + + try { + const resultPromise = withOpenIDRefreshFlightLease({ + key: 'flight-key', + ownerId: 'owner-1', + heartbeatInterval: 1000, + operation, + }); + + /** Heartbeat issues a renewal that is still in flight when the operation finishes. */ + await jest.advanceTimersByTimeAsync(1000); + finishOperation('tokens'); + rejectRenewal(new Error('connection timed out')); + + await expect(resultPromise).resolves.toBe('tokens'); + } finally { + jest.useRealTimers(); + } + }); + + it('encrypts completed token results before storing them', async () => { + const tokens = { + access_token: 'access', + id_token: 'id', + refresh_token: 'refresh', + expires_at: 123, + appAuthToken: 'app-auth-token', + __identityClaims: { sub: 'user-123', iss: 'https://issuer.example.com' }, + }; + + await completeOpenIDRefreshFlight({ + key: 'flight-key', + ownerId: 'owner-1', + tokens, + ttl: 60000, + }); + + expect(encryptV2).toHaveBeenCalledWith(JSON.stringify(tokens)); + expect(db.completeOpenIDRefreshFlight).toHaveBeenCalledWith({ + key: 'flight-key', + ownerId: 'owner-1', + encryptedResult: `encrypted:${JSON.stringify(tokens)}`, + expiresAt: expect.any(Date), + }); + }); + + it('expires a completed flight at the access token usable-lifetime boundary', async () => { + const now = Date.now(); + jest.spyOn(Date, 'now').mockReturnValue(now); + const tokens = { + access_token: 'short-access', + expires_at: Math.floor(now / 1000) + 45, + }; + + await completeOpenIDRefreshFlight({ + key: 'flight-key', + ownerId: 'owner-1', + tokens, + ttl: 60000, + }); + + const storedExpiry = db.completeOpenIDRefreshFlight.mock.calls[0][0].expiresAt; + expect(storedExpiry.getTime()).toBeLessThanOrEqual(now + 15_000); + expect(storedExpiry.getTime()).toBeGreaterThan(now); + Date.now.mockRestore(); + }); + + it('preserves non-enumerable publication metadata for shared flight joiners', async () => { + const tokens = { + access_token: 'access', + refresh_token: 'refresh', + expires_at: 123, + }; + Object.defineProperty(tokens, '__browserRefreshToken', { + value: 'browser-refresh', + enumerable: false, + }); + Object.defineProperty(tokens, '__predecessorRefreshToken', { + value: 'predecessor-refresh', + enumerable: false, + }); + Object.defineProperty(tokens, '__predecessorAccessToken', { + value: 'predecessor-access', + enumerable: false, + }); + Object.defineProperty(tokens, '__deferredPublication', { + value: true, + enumerable: false, + }); + + await completeOpenIDRefreshFlight({ + key: 'flight-key', + ownerId: 'owner-1', + tokens, + ttl: 60000, + }); + + const serializedTokens = JSON.parse(encryptV2.mock.calls[0][0]); + expect(serializedTokens.__browserRefreshToken).toBe('browser-refresh'); + expect(serializedTokens.__predecessorRefreshToken).toBe('predecessor-refresh'); + expect(serializedTokens.__predecessorAccessToken).toBe('predecessor-access'); + expect(serializedTokens.__deferredPublication).toBe(true); + expect(Object.keys(tokens)).not.toContain('__browserRefreshToken'); + }); + + it('marks a flight failed with a non-sensitive message', async () => { + await failOpenIDRefreshFlight({ + key: 'flight-key', + ownerId: 'owner-1', + error: new Error('invalid_grant'), + ttl: 60000, + }); + + expect(db.failOpenIDRefreshFlight).toHaveBeenCalledWith({ + key: 'flight-key', + ownerId: 'owner-1', + errorMessage: 'invalid_grant', + expiresAt: expect.any(Date), + }); + }); + + it('persists logout revocation fences for every distinct flight key', async () => { + await revokeOpenIDRefreshFlights({ + keys: ['flight-a', 'flight-b', 'flight-a', null], + ttl: 60000, + }); + + expect(db.revokeOpenIDRefreshFlight).toHaveBeenCalledTimes(2); + expect(db.revokeOpenIDRefreshFlight).toHaveBeenCalledWith({ + key: 'flight-a', + expiresAt: expect.any(Date), + }); + expect(db.revokeOpenIDRefreshFlight).toHaveBeenCalledWith({ + key: 'flight-b', + expiresAt: expect.any(Date), + }); + }); + + it('returns the retained successor from a completed flight during revocation', async () => { + const result = { + tokenset: { access_token: 'access', refresh_token: 'successor' }, + claims: { sub: 'subject' }, + appAuthToken: 'app-token', + }; + db.revokeOpenIDRefreshFlight.mockResolvedValueOnce({ + status: 'revoked', + encryptedResult: `encrypted:${JSON.stringify(result)}`, + }); + + await expect(revokeOpenIDRefreshFlights({ keys: ['flight-a'] })).resolves.toEqual([result]); + }); + + it('waits for and decrypts a completed flight result', async () => { + const tokens = { + access_token: 'access', + refresh_token: 'refresh', + expires_at: Math.floor(Date.now() / 1000) + 3600, + }; + db.findOpenIDRefreshFlight.mockResolvedValueOnce({ + status: 'completed', + encryptedResult: `encrypted:${JSON.stringify(tokens)}`, + }); + + const result = await waitForOpenIDRefreshFlight({ + key: 'flight-key', + timeoutMs: 1, + intervalMs: 1, + }); + + expect(decryptV2).toHaveBeenCalledWith(`encrypted:${JSON.stringify(tokens)}`); + expect(result).toEqual(tokens); + }); + + it('throws when another worker records a failed flight', async () => { + db.findOpenIDRefreshFlight.mockResolvedValueOnce({ + status: 'failed', + errorMessage: 'invalid_grant', + }); + + await expect( + waitForOpenIDRefreshFlight({ key: 'flight-key', timeoutMs: 1, intervalMs: 1 }), + ).rejects.toThrow('invalid_grant'); + }); + + it('throws when logout revokes the flight', async () => { + db.findOpenIDRefreshFlight.mockResolvedValueOnce({ + status: 'revoked', + errorMessage: 'OpenID refresh was revoked by logout', + }); + + await expect( + waitForOpenIDRefreshFlight({ key: 'flight-key', timeoutMs: 1, intervalMs: 1 }), + ).rejects.toThrow('revoked by logout'); + }); + + it('waits for the full renewable-flight lifetime by default', () => { + expect(__internals.DEFAULT_WAIT_TIMEOUT_MS).toBe(__internals.DEFAULT_FLIGHT_TTL_MS); + expect(__internals.DEFAULT_WAIT_TIMEOUT_MS).toBeGreaterThan(__internals.DEFAULT_LOCK_TTL_MS); + const initialDeadline = Date.now() + __internals.DEFAULT_WAIT_TIMEOUT_MS; + const renewedExpiry = new Date(initialDeadline + 60_000); + expect(__internals.getRenewedWaitDeadline(initialDeadline, { expiresAt: renewedExpiry })).toBe( + renewedExpiry.getTime(), + ); + }); + + it('does not reuse a completed result inside the access-token expiry buffer', async () => { + const tokens = { + access_token: 'near-expiry', + expires_at: Math.floor(Date.now() / 1000) + 10, + }; + await expect( + __internals.readCompletedFlight({ + status: 'completed', + encryptedResult: `encrypted:${JSON.stringify(tokens)}`, + }), + ).resolves.toBeNull(); + }); + + it('exposes completed-flight parsing for focused tests', async () => { + const tokens = { access_token: 'access' }; + await expect( + __internals.readCompletedFlight({ + status: 'completed', + encryptedResult: `encrypted:${JSON.stringify(tokens)}`, + }), + ).resolves.toEqual(tokens); + }); + + it('restores publication metadata as non-enumerable', async () => { + const createdAt = new Date('2026-08-29T12:00:00.000Z'); + const result = await __internals.readCompletedFlight({ + status: 'completed', + ownerId: 'generation-owner', + createdAt, + encryptedResult: + 'encrypted:{"access_token":"access","__browserRefreshToken":"browser-refresh","__predecessorRefreshToken":"predecessor-refresh","__predecessorAccessToken":"predecessor-access","__deferredPublication":true}', + }); + + expect(result.__browserRefreshToken).toBe('browser-refresh'); + expect(result.__predecessorRefreshToken).toBe('predecessor-refresh'); + expect(result.__predecessorAccessToken).toBe('predecessor-access'); + expect(result.__deferredPublication).toBe(true); + expect(result.__flightOwnerId).toBe('generation-owner'); + expect(result.__flightCreatedAt).toBe(createdAt.getTime()); + expect(Object.keys(result)).toEqual(['access_token']); + }); +}); diff --git a/api/server/services/OpenIDRefreshRecovery.js b/api/server/services/OpenIDRefreshRecovery.js new file mode 100644 index 0000000000..a041e27336 --- /dev/null +++ b/api/server/services/OpenIDRefreshRecovery.js @@ -0,0 +1,45 @@ +const jwt = require('jsonwebtoken'); +const api = require('@librechat/api'); +const { logger } = require('@librechat/data-schemas'); +const { deleteSession, findUser } = require('~/models'); +const { getOpenIdConfig, getOpenIdEmail } = require('~/strategies'); +const { + clearOpenIDAuthTokens, + getOpenIDAppAuthToken, + setOpenIDAuthTokens, + storeOpenIDSession, +} = require('./AuthService'); +const bridge = require('./RefreshTokenBridge'); +const flight = require('./OpenIDRefreshFlight'); +const { refreshOpenIDSession } = require('./OpenIDSessionRefresh'); + +module.exports = api.createOpenIDRefreshRecoveryService({ + jwt, + logger, + findOpenIDUser: api.findOpenIDUser, + findUser, + getOpenIdConfig, + getOpenIdEmail, + getOpenIdIssuer: api.getOpenIdIssuer, + createAuthIdentityContext: api.createAuthIdentityContext, + refreshOpenIDSession, + storeOpenIDSession, + setOpenIDAuthTokens, + clearOpenIDAuthTokens, + getOpenIDAppAuthToken, + deleteOpenIDSession: (refreshToken) => deleteSession({ refreshToken }), + createRefreshTokenBridgeFlightKey: bridge.createRefreshTokenBridgeFlightKey, + createOpenIDRefreshFlightKey: flight.createOpenIDRefreshFlightKey, + storeRefreshTokenBridge: bridge.storeRefreshTokenBridge, + deleteRefreshTokenBridges: bridge.deleteRefreshTokenBridges, + acquireOpenIDRefreshFlight: flight.acquireOpenIDRefreshFlight, + completeOpenIDRefreshFlight: flight.completeOpenIDRefreshFlight, + failOpenIDRefreshFlight: flight.failOpenIDRefreshFlight, + waitForOpenIDRefreshFlight: flight.waitForOpenIDRefreshFlight, + assertOpenIDRefreshFlightAvailable: flight.assertOpenIDRefreshFlightAvailable, + assertOpenIDRefreshSessionGenerationAvailable: + flight.assertOpenIDRefreshSessionGenerationAvailable, + revokeOpenIDRefreshFlights: flight.revokeOpenIDRefreshFlights, + withOpenIDRefreshFlightLease: flight.withOpenIDRefreshFlightLease, + bridgeGraceMs: bridge.OPENID_REFRESH_BRIDGE_GRACE_MS, +}); diff --git a/api/server/services/OpenIDSessionRefresh.js b/api/server/services/OpenIDSessionRefresh.js new file mode 100644 index 0000000000..297a174a9b --- /dev/null +++ b/api/server/services/OpenIDSessionRefresh.js @@ -0,0 +1,46 @@ +const jwt = require('jsonwebtoken'); +const cookies = require('cookie'); +const crypto = require('node:crypto'); +const openIdClient = require('openid-client'); +const api = require('@librechat/api'); +const { logger, DEFAULT_REFRESH_TOKEN_EXPIRY } = require('@librechat/data-schemas'); +const { upsertSession, deleteSession } = require('~/models'); +const { getOpenIdConfig } = require('~/strategies/openidStrategy'); +const bridge = require('./RefreshTokenBridge'); +const flight = require('./OpenIDRefreshFlight'); + +module.exports = api.createOpenIDSessionRefreshService({ + jwt, + cookies, + crypto, + openIdClient, + logger, + defaultRefreshTokenExpiry: DEFAULT_REFRESH_TOKEN_EXPIRY, + isEnabled: api.isEnabled, + math: api.math, + createAuthIdentityContext: api.createAuthIdentityContext, + isOpenIDSessionIdentityMatch: api.isOpenIDSessionIdentityMatch, + createOpenIDRefreshIdentityTuple: api.createOpenIDRefreshIdentityTuple, + createRefreshTokenBridgeIdentity: api.createRefreshTokenBridgeIdentity, + serializeAuthIdentityTuple: api.serializeAuthIdentityTuple, + buildOpenIDRefreshParams: api.buildOpenIDRefreshParams, + setRefreshTokenCookie: api.setRefreshTokenCookie, + setOpenIDMarkerCookies: api.setOpenIDMarkerCookies, + storeOpenIdSession: api.storeOpenIdSession, + normalizeExpiresIn: api.normalizeExpiresIn, + upsertSession, + deleteSession, + getOpenIdConfig, + OPENID_REFRESH_BRIDGE_GRACE_MS: bridge.OPENID_REFRESH_BRIDGE_GRACE_MS, + storeRefreshTokenBridge: bridge.storeRefreshTokenBridge, + deleteRefreshTokenBridges: bridge.deleteRefreshTokenBridges, + acquireOpenIDRefreshFlight: flight.acquireOpenIDRefreshFlight, + assertOpenIDRefreshFlightAvailable: flight.assertOpenIDRefreshFlightAvailable, + assertOpenIDRefreshSessionGenerationAvailable: + flight.assertOpenIDRefreshSessionGenerationAvailable, + completeOpenIDRefreshFlight: flight.completeOpenIDRefreshFlight, + createOpenIDRefreshFlightKey: flight.createOpenIDRefreshFlightKey, + failOpenIDRefreshFlight: flight.failOpenIDRefreshFlight, + waitForOpenIDRefreshFlight: flight.waitForOpenIDRefreshFlight, + withOpenIDRefreshFlightLease: flight.withOpenIDRefreshFlightLease, +}); diff --git a/api/server/services/OpenIDSessionRefresh.spec.js b/api/server/services/OpenIDSessionRefresh.spec.js new file mode 100644 index 0000000000..b48061a89d --- /dev/null +++ b/api/server/services/OpenIDSessionRefresh.spec.js @@ -0,0 +1,2495 @@ +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); + }); + }); +}); diff --git a/api/server/services/RefreshTokenBridge.js b/api/server/services/RefreshTokenBridge.js new file mode 100644 index 0000000000..f0faf367ae --- /dev/null +++ b/api/server/services/RefreshTokenBridge.js @@ -0,0 +1,22 @@ +const { + createRefreshTokenBridgeIdentity, + createRefreshTokenBridgeService, + math, +} = require('@librechat/api'); +const { + logger, + encryptV2, + decryptV2, + DEFAULT_REFRESH_TOKEN_EXPIRY, +} = require('@librechat/data-schemas'); +const db = require('~/models'); + +module.exports = createRefreshTokenBridgeService({ + db, + logger, + encrypt: encryptV2, + decrypt: decryptV2, + math, + defaultRefreshTokenExpiry: DEFAULT_REFRESH_TOKEN_EXPIRY, + createIdentity: createRefreshTokenBridgeIdentity, +}); diff --git a/api/server/services/RefreshTokenBridge.spec.js b/api/server/services/RefreshTokenBridge.spec.js new file mode 100644 index 0000000000..d3e8cc21bb --- /dev/null +++ b/api/server/services/RefreshTokenBridge.spec.js @@ -0,0 +1,282 @@ +jest.mock('@librechat/data-schemas', () => ({ + logger: { + error: jest.fn(), + debug: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + }, + DEFAULT_REFRESH_TOKEN_EXPIRY: 604800000, + encryptV2: jest.fn(async (value) => `encrypted:${value}`), + decryptV2: jest.fn(async (value) => value.replace(/^encrypted:/, '')), +})); + +jest.mock('@librechat/api', () => ({ + ...jest.requireActual('@librechat/api'), + math: jest.fn((_value, fallback) => fallback), + createRefreshTokenBridgeIdentity: ({ userId, tenantId, openidIssuer }) => ({ + userId: userId?.trim(), + tenantId: tenantId?.trim() || undefined, + openidIssuer: + openidIssuer?.trim().replace('/.well-known/openid-configuration', '').replace(/\/+$/, '') || + undefined, + }), +})); + +jest.mock('~/models', () => ({ + deleteRefreshTokenBridges: jest.fn(), + upsertRefreshTokenBridge: jest.fn(), + findRefreshTokenBridge: jest.fn(), +})); + +const { encryptV2, decryptV2 } = require('@librechat/data-schemas'); +const { math } = require('@librechat/api'); +const db = require('~/models'); +const { + createRefreshTokenBridgeFlightKey, + deleteAllRefreshTokenBridges, + deleteRefreshTokenBridges, + storeRefreshTokenBridge, + getRefreshTokenBridge, + __internals, +} = require('./RefreshTokenBridge'); + +describe('RefreshTokenBridge', () => { + beforeEach(() => { + jest.clearAllMocks(); + db.upsertRefreshTokenBridge.mockResolvedValue({}); + db.findRefreshTokenBridge.mockResolvedValue(null); + db.deleteRefreshTokenBridges.mockResolvedValue({ acknowledged: true, deletedCount: 1 }); + }); + + describe('storeRefreshTokenBridge', () => { + it('stores an encrypted Mongo bridge with required fields', async () => { + const before = Date.now(); + + await storeRefreshTokenBridge({ + oldRefreshToken: 'rt-old', + newRefreshToken: 'rt-new', + userId: 'user-123', + }); + + expect(encryptV2).toHaveBeenCalledWith('rt-new'); + expect(db.upsertRefreshTokenBridge).toHaveBeenCalledWith({ + oldRefreshTokenHash: __internals.hashRefreshToken('rt-old'), + encryptedNewRefreshToken: 'encrypted:rt-new', + userId: 'user-123', + tenantId: undefined, + openidIssuer: undefined, + version: expect.any(String), + expiresAt: expect.any(Date), + }); + const stored = db.upsertRefreshTokenBridge.mock.calls[0][0]; + expect(JSON.stringify(stored)).not.toContain('"rt-new"'); + expect(stored.expiresAt.getTime()).toBeGreaterThanOrEqual(before + 604800000 - 1000); + }); + + it('stores optional tenant and issuer context', async () => { + await storeRefreshTokenBridge({ + oldRefreshToken: 'rt-old', + newRefreshToken: 'rt-new', + userId: ' user-123 ', + tenantId: ' tenant-1 ', + openidIssuer: 'https://issuer.example.com/.well-known/openid-configuration', + }); + + expect(db.upsertRefreshTokenBridge).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'user-123', + tenantId: 'tenant-1', + openidIssuer: 'https://issuer.example.com', + }), + ); + }); + + it('does not store a bridge without required fields', async () => { + await storeRefreshTokenBridge({ + oldRefreshToken: 'rt-old', + userId: 'user-123', + }); + + expect(db.upsertRefreshTokenBridge).not.toHaveBeenCalled(); + }); + + it('honors an explicit ttl override', async () => { + const before = Date.now(); + + await storeRefreshTokenBridge({ + oldRefreshToken: 'rt-old', + newRefreshToken: 'rt-new', + userId: 'user-123', + ttl: 1000, + }); + + const stored = db.upsertRefreshTokenBridge.mock.calls[0][0]; + expect(stored.expiresAt.getTime()).toBeGreaterThanOrEqual(before + 1000); + expect(stored.expiresAt.getTime()).toBeLessThanOrEqual(Date.now() + 1000); + }); + + it('derives the default ttl from REFRESH_TOKEN_EXPIRY', async () => { + await storeRefreshTokenBridge({ + oldRefreshToken: 'rt-old', + newRefreshToken: 'rt-new', + userId: 'user-123', + }); + + expect(math).toHaveBeenCalledWith(process.env.REFRESH_TOKEN_EXPIRY, 604800000); + }); + }); + + describe('coordination and revocation', () => { + it('creates a stable opaque flight key scoped to token and identity', () => { + const key = createRefreshTokenBridgeFlightKey({ + oldRefreshToken: 'rt-old', + userId: ' user-123 ', + tenantId: ' tenant-1 ', + openidIssuer: 'https://issuer.example.com/', + }); + + expect(key).toHaveLength(64); + expect(key).not.toContain('rt-old'); + expect(key).toBe( + createRefreshTokenBridgeFlightKey({ + oldRefreshToken: 'rt-old', + userId: 'user-123', + tenantId: 'tenant-1', + openidIssuer: 'https://issuer.example.com', + }), + ); + }); + + it('deletes bridge hashes for all distinct logout tokens', async () => { + await deleteRefreshTokenBridges({ + refreshTokens: ['cookie-token', 'session-token', 'cookie-token'], + userId: ' user-123 ', + tenantId: ' tenant-1 ', + }); + + expect(db.deleteRefreshTokenBridges).toHaveBeenCalledWith({ + oldRefreshTokenHashes: [ + __internals.hashRefreshToken('cookie-token'), + __internals.hashRefreshToken('session-token'), + ], + userId: 'user-123', + tenantId: 'tenant-1', + }); + }); + + it('deletes every predecessor bridge for a logout identity', async () => { + await deleteAllRefreshTokenBridges({ userId: ' user-123 ', tenantId: ' tenant-1 ' }); + + expect(db.deleteRefreshTokenBridges).toHaveBeenCalledWith({ + userId: 'user-123', + tenantId: 'tenant-1', + }); + }); + }); + + describe('getRefreshTokenBridge', () => { + it('retrieves and decrypts a matching bridge', async () => { + db.findRefreshTokenBridge.mockResolvedValue({ + encryptedNewRefreshToken: 'encrypted:rt-new', + userId: 'user-123', + tenantId: 'tenant-1', + openidIssuer: 'https://issuer.example.com', + createdAt: new Date(Date.now() - 100), + }); + + const result = await getRefreshTokenBridge({ + oldRefreshToken: 'rt-old', + userId: ' user-123 ', + tenantId: ' tenant-1 ', + openidIssuer: 'https://issuer.example.com/.well-known/openid-configuration', + }); + + expect(db.findRefreshTokenBridge).toHaveBeenCalledWith({ + oldRefreshTokenHash: __internals.hashRefreshToken('rt-old'), + userId: 'user-123', + tenantId: 'tenant-1', + }); + expect(decryptV2).toHaveBeenCalledWith('encrypted:rt-new'); + expect(result).toBe('rt-new'); + }); + + it('returns null when bridge does not exist', async () => { + await expect( + getRefreshTokenBridge({ + oldRefreshToken: 'rt-nonexistent', + userId: 'user-123', + }), + ).resolves.toBeNull(); + }); + + it('returns null when stored issuer does not match', async () => { + db.findRefreshTokenBridge.mockResolvedValue({ + encryptedNewRefreshToken: 'encrypted:rt-new', + userId: 'user-123', + openidIssuer: 'https://issuer1.example.com', + createdAt: new Date(), + }); + + const result = await getRefreshTokenBridge({ + oldRefreshToken: 'rt-old', + userId: 'user-123', + openidIssuer: 'https://issuer2.example.com', + }); + + expect(result).toBeNull(); + expect(decryptV2).not.toHaveBeenCalled(); + }); + + it('returns null when only the expected issuer is present', async () => { + db.findRefreshTokenBridge.mockResolvedValue({ + encryptedNewRefreshToken: 'encrypted:rt-new', + userId: 'user-123', + createdAt: new Date(), + }); + + const result = await getRefreshTokenBridge({ + oldRefreshToken: 'rt-old', + userId: 'user-123', + openidIssuer: 'https://issuer.example.com', + }); + + expect(result).toBeNull(); + expect(decryptV2).not.toHaveBeenCalled(); + }); + + it('returns null when only the stored issuer is present', async () => { + db.findRefreshTokenBridge.mockResolvedValue({ + encryptedNewRefreshToken: 'encrypted:rt-new', + userId: 'user-123', + openidIssuer: 'https://issuer.example.com', + createdAt: new Date(), + }); + + const result = await getRefreshTokenBridge({ + oldRefreshToken: 'rt-old', + userId: 'user-123', + }); + + expect(result).toBeNull(); + expect(decryptV2).not.toHaveBeenCalled(); + }); + + it('normalizes the stored issuer before validation', async () => { + db.findRefreshTokenBridge.mockResolvedValue({ + encryptedNewRefreshToken: 'encrypted:rt-new', + userId: 'user-123', + openidIssuer: 'https://issuer.example.com/.well-known/openid-configuration', + createdAt: new Date(), + }); + + const result = await getRefreshTokenBridge({ + oldRefreshToken: 'rt-old', + userId: 'user-123', + openidIssuer: 'https://issuer.example.com/', + }); + + expect(decryptV2).toHaveBeenCalledWith('encrypted:rt-new'); + expect(result).toBe('rt-new'); + }); + }); +}); diff --git a/api/server/services/ToolService.js b/api/server/services/ToolService.js index 7d5a161896..200f81fbd0 100644 --- a/api/server/services/ToolService.js +++ b/api/server/services/ToolService.js @@ -1,4 +1,4 @@ -const { logger, redactMessage } = require('@librechat/data-schemas'); +const { logger, redactMessage, getTenantId } = require('@librechat/data-schemas'); const { tool: toolFn, DynamicStructuredTool } = require('@librechat/agents/langchain/tools'); const { sleep, @@ -11,6 +11,7 @@ const { sendEvent, getToolkitKey, getUserMCPAuthMap, + createAuthIdentityContext, loadToolDefinitions, GenerationJobManager, isActionDomainAllowed, @@ -95,6 +96,7 @@ const { getAccessibleMcpServerNames, resolveCollisionAuditNames, } = require('~/server/services/MCP'); +const { createOpenIDSessionTokenProvider } = require('~/server/services/OpenIDSessionRefresh'); const { getMCPRequestContext } = require('~/server/services/MCPRequestContext'); const { recordUsage } = require('~/server/services/Threads'); const { loadTools } = require('~/app/clients/tools/util'); @@ -921,6 +923,23 @@ async function loadToolDefinitionsWrapper({ /** @type {Record} */ const mcpAvailableTools = {}; const requestScopedConnections = getMCPRequestContext(req, res); + /** + * Build the OBO upstream-token closure once at this request boundary and pass + * the function into MCP handling, so `reinitMCPServer` never receives the raw + * Express request. `res` is forwarded so a rotated refresh token can be + * mirrored to the `refreshToken` cookie when the response is still writable. + */ + const oboIdentityContext = createAuthIdentityContext({ + user: req.user, + tenantId: getTenantId(), + }); + const upstreamTokenProvider = createOpenIDSessionTokenProvider({ + req, + res, + user: req.user, + identityContext: oboIdentityContext, + tokenPreference: 'access_token', + }); const rememberMCPAvailableTools = (serverName, availableTools) => { if (!availableTools || Object.keys(availableTools).length === 0) { return; @@ -1106,6 +1125,8 @@ async function loadToolDefinitionsWrapper({ userMCPAuthMap, requestBody: runtimeRequestBody, requestScopedConnections, + upstreamTokenProvider, + oboIdentityContext, }); rememberMCPAvailableTools(serverName, result?.availableTools); @@ -1133,6 +1154,8 @@ async function loadToolDefinitionsWrapper({ userMCPAuthMap, requestBody: runtimeRequestBody, requestScopedConnections, + upstreamTokenProvider, + oboIdentityContext, }); rememberMCPAvailableTools(serverName, result?.availableTools); @@ -1278,6 +1301,8 @@ async function loadToolDefinitionsWrapper({ oauthStart, oauthEnd: createOAuthEndEmitter(serverName), connectionTimeout: Time.TWO_MINUTES, + upstreamTokenProvider, + oboIdentityContext, }); if (result?.availableTools && Object.keys(result.availableTools).length > 0) { diff --git a/api/server/services/Tools/mcp.js b/api/server/services/Tools/mcp.js index 9532d526b7..c74b80ce10 100644 --- a/api/server/services/Tools/mcp.js +++ b/api/server/services/Tools/mcp.js @@ -26,6 +26,8 @@ const MCP_REINITIALIZE_FAILURE_REASONS = { * (per MCP spec, tool listing should be possible without auth). * @param {Object} params * @param {IUser} params.user - The user from the request object. + * @param {import('@librechat/api').UpstreamTokenProvider} [params.upstreamTokenProvider] - Live upstream-token closure for OBO connection establishment, built at the request boundary so this layer never receives the raw Express request. + * @param {import('@librechat/api').AuthIdentityContext} [params.oboIdentityContext] - Non-template-visible OBO identity context built from the real request user. * @param {string} params.serverName - The name of the MCP server * @param {boolean} params.returnOnOAuth - Whether to initiate OAuth and return, or wait for OAuth flow to finish * @param {AbortSignal} [params.signal] - The abort signal to handle cancellation. @@ -52,6 +54,8 @@ async function reinitMCPServer({ serverConfig: providedConfig, requestBody, requestScopedConnections, + upstreamTokenProvider, + oboIdentityContext, oauthEnd, }) { /** @type {MCPConnection | null} */ @@ -201,6 +205,8 @@ async function reinitMCPServer({ graphTokenResolver: getGraphApiToken, oboTokenResolver: exchangeOboToken, oboTrustChecker: createOboTrustChecker(), + upstreamTokenProvider, + oboIdentityContext, }); logger.info('[MCP Reinitialize] Successfully established connection'); @@ -236,6 +242,8 @@ async function reinitMCPServer({ graphTokenResolver: getGraphApiToken, oboTokenResolver: exchangeOboToken, oboTrustChecker: createOboTrustChecker(), + upstreamTokenProvider, + oboIdentityContext, }); if (discoveryResult.tools && discoveryResult.tools.length > 0) { diff --git a/api/server/services/Tools/mcp.spec.js b/api/server/services/Tools/mcp.spec.js index 1b0736baba..d5bdeff5f9 100644 --- a/api/server/services/Tools/mcp.spec.js +++ b/api/server/services/Tools/mcp.spec.js @@ -251,6 +251,22 @@ describe('reinitMCPServer β€” customUserVars gating (issue #10969)', () => { ); }); + it('forwards the pre-built upstreamTokenProvider closure into connection creation', async () => { + mockGetConnection.mockResolvedValue({ fetchTools: jest.fn().mockResolvedValue([]) }); + const upstreamTokenProvider = jest.fn().mockResolvedValue(null); + + await reinitMCPServer({ + user, + serverName, + serverConfig: { type: 'streamable-http', url: 'https://thingy.example.com/mcp' }, + upstreamTokenProvider, + }); + + expect(mockGetConnection).toHaveBeenCalledWith( + expect.objectContaining({ upstreamTokenProvider }), + ); + }); + it('passes request body and Graph resolver into OAuth discovery fallback', async () => { mockGetConnection.mockRejectedValue(new Error('OAuth authentication required')); mockDiscoverServerTools.mockResolvedValue({ tools: [], oauthRequired: true, oauthUrl: null }); diff --git a/api/server/services/__tests__/MCP.spec.js b/api/server/services/__tests__/MCP.spec.js index 0fae7a3058..e2b8ebca36 100644 --- a/api/server/services/__tests__/MCP.spec.js +++ b/api/server/services/__tests__/MCP.spec.js @@ -2,6 +2,8 @@ const mockRegistry = { ensureConfigServers: jest.fn(), getAllServerConfigs: jest.fn(), }; +const mockUpstreamTokenProvider = jest.fn().mockResolvedValue(null); +const mockCreateOpenIDSessionTokenProvider = jest.fn(() => mockUpstreamTokenProvider); jest.mock('~/config', () => ({ getMCPServersRegistry: jest.fn(() => mockRegistry), @@ -34,6 +36,12 @@ jest.mock('@librechat/api', () => ({ GenerationJobManager: jest.fn(), buildOAuthToolCallName: jest.fn((name) => name), getUserMCPAuthMap: jest.fn(), + createAuthIdentityContext: ({ user, tenantId }) => ({ + appUserId: user?._id?.toString?.() ?? user?.id, + openidSubject: user?.openidId, + tenantId: tenantId ?? user?.tenantId, + openidIssuer: user?.openidIssuer, + }), /** Mirrors the real resolver so these tests still exercise the wrapper's own * plumbing - loading the request config and degrading on failure - rather than * the resolution logic, which is unit-tested in packages/api. Like the real @@ -66,6 +74,9 @@ jest.mock('~/server/services/OboTokenService', () => ({ jest.mock('~/server/services/OboPolicyService', () => ({ createOboTrustChecker: jest.fn(() => async () => true), })); +jest.mock('~/server/services/OpenIDSessionRefresh', () => ({ + createOpenIDSessionTokenProvider: (...args) => mockCreateOpenIDSessionTokenProvider(...args), +})); jest.mock('~/server/services/Tools/mcp', () => ({ reinitMCPServer: jest.fn(), })); @@ -159,10 +170,11 @@ describe('getAssistantToolDefinitions', () => { const getServerToolFunctionsSnapshot = jest.fn().mockResolvedValue({ tools: null }); require('~/config').getMCPManager.mockReturnValue({ getServerToolFunctionsSnapshot }); const userMCPAuthMap = { 'mcp_app-server': { API_KEY: 'saved' } }; + const res = { cookie: jest.fn() }; getUserMCPAuthMap.mockResolvedValue(userMCPAuthMap); reinitMCPServer.mockResolvedValue({ availableTools: { [toolKey]: mcpDefinition } }); - await expect(getAssistantToolDefinitions({ req, tools: [toolKey] })).resolves.toEqual({ + await expect(getAssistantToolDefinitions({ req, res, tools: [toolKey] })).resolves.toEqual({ toolDefinitions: { [toolKey]: mcpDefinition }, accessibleServerNames: ['app-server'], }); @@ -171,6 +183,25 @@ describe('getAssistantToolDefinitions', () => { serverName: 'app-server', serverConfig, userMCPAuthMap, + upstreamTokenProvider: mockUpstreamTokenProvider, + oboIdentityContext: { + appUserId: 'u1', + openidSubject: undefined, + tenantId: 'tenant-1', + openidIssuer: undefined, + }, + }); + expect(mockCreateOpenIDSessionTokenProvider).toHaveBeenCalledWith({ + req, + res, + user: req.user, + identityContext: { + appUserId: 'u1', + openidSubject: undefined, + tenantId: 'tenant-1', + openidIssuer: undefined, + }, + tokenPreference: 'access_token', }); expect(getUserMCPAuthMap).toHaveBeenCalledWith({ userId: 'u1', diff --git a/api/server/services/__tests__/ToolService.spec.js b/api/server/services/__tests__/ToolService.spec.js index 4281e485ea..c92edd971b 100644 --- a/api/server/services/__tests__/ToolService.spec.js +++ b/api/server/services/__tests__/ToolService.spec.js @@ -69,6 +69,12 @@ jest.mock('@librechat/api', () => ({ ['AGENT_EXPECTED_MCP_TOOLS_UNAVAILABLE', 'resource_recovery_required'].includes(error?.code), loadToolDefinitions: (...args) => mockLoadToolDefinitions(...args), getUserMCPAuthMap: (...args) => mockGetUserMCPAuthMap(...args), + createAuthIdentityContext: ({ user, tenantId }) => ({ + appUserId: user?._id?.toString?.() ?? user?.id, + openidSubject: user?.openidId, + tenantId: tenantId ?? user?.tenantId, + openidIssuer: user?.openidIssuer, + }), sendEvent: (...args) => mockSendEvent(...args), GenerationJobManager: { emitChunk: (...args) => mockEmitChunk(...args), @@ -2069,6 +2075,56 @@ describe('ToolService - Action Capability Gating', () => { ); }); + it('forwards OBO context through forced MCP catalog refreshes', async () => { + const serverName = 'OBO-Refresh'; + const mcpTool = `search${Constants.mcp_delimiter}${serverName}`; + const capabilities = [AgentCapabilities.tools]; + const req = createMockReq(capabilities); + req.user = { + id: 'user_123', + provider: 'openid', + openidId: 'oidc-sub-123', + tenantId: 'tenant-1', + openidIssuer: 'https://issuer.example.com', + }; + + mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities)); + mockGetServerConfig.mockResolvedValue({ + type: 'streamable-http', + url: 'https://mcp.example.com/obo', + obo: { scopes: 'api://obo/Mcp.Tools.ReadWrite' }, + }); + mockLoadToolDefinitions.mockImplementation(async (params, dependencies) => { + await dependencies.refreshMCPServerTools(params.userId, serverName); + return { + toolDefinitions: [], + toolRegistry: new Map(), + hasDeferredTools: false, + }; + }); + reinitMCPServer.mockResolvedValue({ availableTools: {} }); + + await loadAgentTools({ + req, + agent: { id: 'agent_123', tools: [mcpTool] }, + definitionsOnly: true, + }); + + expect(reinitMCPServer).toHaveBeenCalledWith( + expect.objectContaining({ + serverName, + forceNew: true, + upstreamTokenProvider: expect.any(Function), + oboIdentityContext: expect.objectContaining({ + appUserId: 'user_123', + openidSubject: 'oidc-sub-123', + tenantId: 'tenant-1', + openidIssuer: 'https://issuer.example.com', + }), + }), + ); + }); + it('returns run-scoped MCP tool definitions for request-scoped servers', async () => { const serverName = 'ClickHouse'; const mcpTool = `list_tables${Constants.mcp_delimiter}${serverName}`; diff --git a/api/strategies/openidStrategy.spec.js b/api/strategies/openidStrategy.spec.js index 36a491a068..641619d100 100644 --- a/api/strategies/openidStrategy.spec.js +++ b/api/strategies/openidStrategy.spec.js @@ -1138,10 +1138,12 @@ describe('setupOpenId', () => { // First call: cache miss β†’ OBO exchange β†’ cache set await validate(tokenset); + /** The entry expires 30s (OPENID_EXPIRY_BUFFER_SECONDS) before the credential it holds, so a + * token served from cache cannot expire in transit and 401 downstream. */ expect(mockSet).toHaveBeenCalledWith( '1234:overage', { access_token: 'exchanged_graph_token' }, - 3600000, + 3570000, ); expect(openidClient.genericGrantRequest).toHaveBeenCalledTimes(1); diff --git a/packages/api/src/auth/index.ts b/packages/api/src/auth/index.ts index 5e1da8e1c5..f6e8956439 100644 --- a/packages/api/src/auth/index.ts +++ b/packages/api/src/auth/index.ts @@ -12,3 +12,4 @@ export * from './codeapi'; export * from './openidRoleSync'; export * from './userDocCache'; export * from './reuse'; +export * from './openid/index'; diff --git a/packages/api/src/auth/openid/bridge.ts b/packages/api/src/auth/openid/bridge.ts new file mode 100644 index 0000000000..e86c7c9766 --- /dev/null +++ b/packages/api/src/auth/openid/bridge.ts @@ -0,0 +1,220 @@ +import crypto from 'node:crypto'; +import type { + RefreshTokenBridgeDeleteInput, + RefreshTokenBridgeIdentity, + RefreshTokenBridgeInput, + OpenIDLogger, +} from './types'; + +interface StoredRefreshTokenBridge { + encryptedNewRefreshToken: string; + userId: string; + tenantId?: string; + openidIssuer?: string; + version?: string; + createdAt: Date | string; +} + +interface BridgeQuery { + oldRefreshTokenHash?: string; + oldRefreshTokenHashes?: string[]; + encryptedNewRefreshToken?: string; + userId: string; + tenantId?: string; + openidIssuer?: string; + version?: string; + expiresAt?: Date; +} + +type IdentityInput = Partial; + +export interface RefreshTokenBridgeService { + OPENID_REFRESH_BRIDGE_GRACE_MS: number; + createRefreshTokenBridgeFlightKey: (args: { + oldRefreshToken?: string; + userId?: string; + tenantId?: string; + openidIssuer?: string; + }) => string | null; + deleteAllRefreshTokenBridges: (args: { + userId?: string; + tenantId?: string; + }) => Promise; + deleteRefreshTokenBridges: (args: RefreshTokenBridgeDeleteInput) => Promise; + storeRefreshTokenBridge: (args: RefreshTokenBridgeInput) => Promise; + getRefreshTokenBridge: (args: { + oldRefreshToken?: string; + userId?: string; + tenantId?: string; + openidIssuer?: string; + }) => Promise; + __internals: { + hashRefreshToken: (refreshToken: string) => string; + getBridgeTtlMs: () => number; + resolveBridgeIdentity: (input: IdentityInput) => RefreshTokenBridgeIdentity | null; + }; +} + +export interface RefreshTokenBridgeDeps { + db: { + upsertRefreshTokenBridge: (data: BridgeQuery) => Promise; + findRefreshTokenBridge: (data: BridgeQuery) => Promise; + deleteRefreshTokenBridges: (data: BridgeQuery) => Promise; + }; + logger: Pick; + encrypt: (value: string) => Promise; + decrypt: (value: string) => Promise; + math: (value: string | undefined, fallback: number) => number; + defaultRefreshTokenExpiry: number; + createIdentity: (data: IdentityInput) => RefreshTokenBridgeIdentity | null; +} + +export function createRefreshTokenBridgeService( + deps: RefreshTokenBridgeDeps, +): RefreshTokenBridgeService { + const { db, logger, encrypt, decrypt, math, defaultRefreshTokenExpiry, createIdentity } = deps; + const OPENID_REFRESH_BRIDGE_GRACE_MS = math( + process.env.OPENID_REFRESH_BRIDGE_GRACE_MS, + 60 * 1000, + ); + const getBridgeTtlMs = () => math(process.env.REFRESH_TOKEN_EXPIRY, defaultRefreshTokenExpiry); + const resolveBridgeIdentity = ({ userId, tenantId, openidIssuer }: IdentityInput) => + createIdentity({ userId, tenantId, openidIssuer }); + const hashRefreshToken = (refreshToken: string) => + crypto.createHash('sha256').update(refreshToken).digest('hex'); + + function createRefreshTokenBridgeFlightKey({ + oldRefreshToken, + userId, + tenantId, + openidIssuer, + }: { + oldRefreshToken?: string; + userId?: string; + tenantId?: string; + openidIssuer?: string; + }) { + const identity = resolveBridgeIdentity({ userId, tenantId, openidIssuer }); + if (!oldRefreshToken || !identity) return null; + return hashRefreshToken( + [ + 'bridge-recovery', + identity.userId, + identity.tenantId ?? '', + identity.openidIssuer ?? '', + hashRefreshToken(oldRefreshToken), + ].join('\x1f'), + ); + } + + async function storeRefreshTokenBridge({ + oldRefreshToken, + newRefreshToken, + userId, + tenantId, + openidIssuer, + ttl, + }: RefreshTokenBridgeInput): Promise { + const identity = resolveBridgeIdentity({ userId, tenantId, openidIssuer }); + if (!oldRefreshToken || !newRefreshToken || !identity) { + logger.warn('[RefreshTokenBridge] Attempted to store bridge with missing required fields'); + return null; + } + const oldRefreshTokenHash = hashRefreshToken(oldRefreshToken); + const bridgeTtl = ttl ?? getBridgeTtlMs(); + const version = crypto.randomUUID(); + await db.upsertRefreshTokenBridge({ + oldRefreshTokenHash, + encryptedNewRefreshToken: await encrypt(newRefreshToken), + userId: identity.userId, + tenantId: identity.tenantId, + openidIssuer: identity.openidIssuer, + version, + expiresAt: new Date(Date.now() + bridgeTtl), + }); + logger.debug('[RefreshTokenBridge] Stored recovery bridge', { + tokenHash: oldRefreshTokenHash, + userId: identity.userId, + ttl: bridgeTtl, + }); + return version; + } + + async function getRefreshTokenBridge({ + oldRefreshToken, + userId, + tenantId, + openidIssuer, + }: { + oldRefreshToken?: string; + userId?: string; + tenantId?: string; + openidIssuer?: string; + }): Promise { + const identity = resolveBridgeIdentity({ userId, tenantId, openidIssuer }); + if (!oldRefreshToken || !identity) return null; + const oldRefreshTokenHash = hashRefreshToken(oldRefreshToken); + const bridge = await db.findRefreshTokenBridge({ + oldRefreshTokenHash, + userId: identity.userId, + tenantId: identity.tenantId, + }); + if (!bridge) return null; + const bridgeIdentity = resolveBridgeIdentity({ + userId: bridge.userId, + tenantId: bridge.tenantId, + openidIssuer: bridge.openidIssuer, + }); + if (!bridgeIdentity || bridgeIdentity.openidIssuer !== identity.openidIssuer) { + logger.warn('[RefreshTokenBridge] Bridge lookup failed: issuer mismatch', { + tokenHash: oldRefreshTokenHash, + }); + return null; + } + logger.info('[RefreshTokenBridge] Successfully resolved recovery bridge', { + tokenHash: oldRefreshTokenHash, + userId: identity.userId, + age: Date.now() - new Date(bridge.createdAt).getTime(), + }); + return decrypt(bridge.encryptedNewRefreshToken); + } + + async function deleteRefreshTokenBridges({ + refreshTokens, + userId, + tenantId, + version, + }: RefreshTokenBridgeDeleteInput): Promise { + const identity = resolveBridgeIdentity({ userId, tenantId }); + const tokens = [...new Set((refreshTokens ?? []).filter(Boolean))]; + if (!identity || tokens.length === 0) return null; + return db.deleteRefreshTokenBridges({ + oldRefreshTokenHashes: tokens.map(hashRefreshToken), + userId: identity.userId, + tenantId: identity.tenantId, + version, + }); + } + + async function deleteAllRefreshTokenBridges({ + userId, + tenantId, + }: { + userId?: string; + tenantId?: string; + }): Promise { + const identity = resolveBridgeIdentity({ userId, tenantId }); + if (!identity) return null; + return db.deleteRefreshTokenBridges({ userId: identity.userId, tenantId: identity.tenantId }); + } + + return { + OPENID_REFRESH_BRIDGE_GRACE_MS, + createRefreshTokenBridgeFlightKey, + deleteAllRefreshTokenBridges, + deleteRefreshTokenBridges, + storeRefreshTokenBridge, + getRefreshTokenBridge, + __internals: { hashRefreshToken, getBridgeTtlMs, resolveBridgeIdentity }, + }; +} diff --git a/packages/api/src/auth/openid/errors.ts b/packages/api/src/auth/openid/errors.ts new file mode 100644 index 0000000000..9c762cf972 --- /dev/null +++ b/packages/api/src/auth/openid/errors.ts @@ -0,0 +1,26 @@ +import type { LogArgument } from './types'; + +export const OPENID_REFRESH_OWNERSHIP_LOST = 'OPENID_REFRESH_OWNERSHIP_LOST'; + +export function toOpenIDLogArgument(error: unknown): LogArgument { + return error instanceof Error ? error : String(error); +} + +/** + * Marks the one failure mode that proves another worker owns this refresh: the coordination + * record is no longer pending under our owner id. Callers that compensate a published side + * effect must distinguish it from a transient coordination read failure, where ownership is + * merely unknown and the side effect is still the caller's only usable credential. + */ +export function createOpenIDRefreshOwnershipError(message: string): Error { + return Object.assign(new Error(message), { code: OPENID_REFRESH_OWNERSHIP_LOST }); +} + +export function isOpenIDRefreshOwnershipError(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + error.code === OPENID_REFRESH_OWNERSHIP_LOST + ); +} diff --git a/packages/api/src/auth/openid/flight.ts b/packages/api/src/auth/openid/flight.ts new file mode 100644 index 0000000000..7500e3cbed --- /dev/null +++ b/packages/api/src/auth/openid/flight.ts @@ -0,0 +1,666 @@ +import crypto from 'node:crypto'; +import { setTimeout as delay } from 'node:timers/promises'; +import type { + AuthIdentityContext, + LeaseContext, + OpenIDLogger, + OpenIDClaims, + OpenIDTokenSet, + RefreshFlightAcquireResult, + RefreshFlightRecord, + RefreshKeyInput, +} from './types'; +import { + createOpenIDRefreshOwnershipError, + isOpenIDRefreshOwnershipError, + toOpenIDLogArgument, +} from './errors'; +import { createOpenIDRefreshIdentityTuple, serializeAuthIdentityTuple } from '~/utils/identity'; +import { OPENID_EXPIRY_BUFFER_SECONDS } from '~/oauth/expiry'; + +const DEFAULT_FLIGHT_TTL_MS = 2 * 60 * 1000; +const DEFAULT_LOCK_TTL_MS = 30 * 1000; +const DEFAULT_WAIT_TIMEOUT_MS = DEFAULT_FLIGHT_TTL_MS; +const DEFAULT_WAIT_INTERVAL_MS = 100; +const DEFAULT_HEARTBEAT_INTERVAL_MS = 10 * 1000; +const DEFAULT_DELIVERY_TTL_MS = 30 * 1000; +const INTERNAL_BROWSER_REFRESH_TOKEN_FIELD = '__browserRefreshToken'; +const INTERNAL_PREDECESSOR_REFRESH_TOKEN_FIELD = '__predecessorRefreshToken'; +const INTERNAL_PREDECESSOR_ACCESS_TOKEN_FIELD = '__predecessorAccessToken'; +const INTERNAL_DEFERRED_PUBLICATION_FIELD = '__deferredPublication'; +const INTERNAL_FLIGHT_OWNER_FIELD = '__flightOwnerId'; +const INTERNAL_FLIGHT_CREATED_AT_FIELD = '__flightCreatedAt'; + +export interface TokenResult extends Omit { + tokenset?: OpenIDTokenSet; + claims?: OpenIDClaims | (() => OpenIDClaims); + openidIssuer?: string; + __browserRefreshToken?: string; + __predecessorRefreshToken?: string; + __predecessorAccessToken?: string; + __deferredPublication?: boolean; + __flightOwnerId?: string; + __flightCreatedAt?: number; + predecessorAccessToken?: string; + acceptedIdentity?: AuthIdentityContext; +} + +interface FlightAcquireData { + key: string; + ownerId: string; + lockExpiresAt: Date; + expiresAt: Date; +} + +interface FlightOwnerData { + key: string; + ownerId: string; + expiresAt: Date; +} + +interface FlightCompleteData extends FlightOwnerData { + encryptedResult: string; +} + +interface FlightRenewData extends FlightOwnerData { + lockExpiresAt: Date; +} + +interface FlightFailData extends FlightOwnerData { + errorMessage: string; +} + +interface FlightDeliveryData { + key: string; + ownerId: string; + deliveryId: string; +} + +export interface OpenIDRefreshFlightService { + acquireOpenIDRefreshFlight: (args: { + key?: string | null; + ownerId?: string; + ttl?: number; + lockTtl?: number; + }) => Promise; + completeOpenIDRefreshFlight: (args: { + key?: string | null; + ownerId?: string; + tokens?: TokenResult | null; + ttl?: number; + }) => Promise; + createOpenIDRefreshFlightKey: (input: RefreshKeyInput) => string | null; + failOpenIDRefreshFlight: (args: { + key?: string | null; + ownerId?: string; + error?: Error | { message?: string } | null; + ttl?: number; + }) => Promise; + renewOpenIDRefreshFlight: (args: { + key?: string | null; + ownerId?: string; + lockTtl?: number; + ttl?: number; + }) => Promise; + assertOpenIDRefreshFlightAvailable: (args: { + key?: string | null; + ownerId?: string; + }) => Promise; + assertOpenIDRefreshSessionGenerationAvailable: (args: { + key?: string | null; + ownerId?: string; + }) => Promise; + claimOpenIDRefreshFlightDelivery: (args: { + key: string; + ownerId: string; + createdAt?: number; + deliveryId?: string; + ttl?: number; + }) => Promise; + assertOpenIDRefreshFlightDeliveryAvailable: (args: FlightDeliveryData) => Promise; + releaseOpenIDRefreshFlightDelivery: (args: FlightDeliveryData) => Promise; + revokeOpenIDRefreshFlights: (args: { + keys?: Array; + ttl?: number; + }) => Promise>; + waitForOpenIDRefreshFlight: (args: { + key?: string | null; + timeoutMs?: number; + intervalMs?: number; + }) => Promise; + withOpenIDRefreshFlightLease: (args: { + key?: string | null; + ownerId?: string; + operation: (context: LeaseContext) => Promise; + heartbeatInterval?: number; + lockTtl?: number; + ttl?: number; + }) => Promise; + __internals: { + sha256: (value: string) => string; + readCompletedFlight: (flight: RefreshFlightRecord | null) => Promise; + DEFAULT_FLIGHT_TTL_MS: number; + DEFAULT_LOCK_TTL_MS: number; + DEFAULT_WAIT_TIMEOUT_MS: number; + DEFAULT_WAIT_INTERVAL_MS: number; + DEFAULT_HEARTBEAT_INTERVAL_MS: number; + DEFAULT_DELIVERY_TTL_MS: number; + INTERNAL_PREDECESSOR_REFRESH_TOKEN_FIELD: string; + getRenewedWaitDeadline: (deadline: number, flight: RefreshFlightRecord | null) => number; + }; +} + +export interface OpenIDRefreshFlightDeps { + db: { + acquireOpenIDRefreshFlight: ( + data: FlightAcquireData, + ) => Promise<{ acquired: boolean; flight?: RefreshFlightRecord | null }>; + completeOpenIDRefreshFlight: (data: FlightCompleteData) => Promise; + renewOpenIDRefreshFlight: (data: FlightRenewData) => Promise; + failOpenIDRefreshFlight: (data: FlightFailData) => Promise; + revokeOpenIDRefreshFlight: (data: { + key: string; + expiresAt: Date; + }) => Promise; + findOpenIDRefreshFlight: (data: { key: string }) => Promise; + claimOpenIDRefreshFlightDelivery: ( + data: FlightDeliveryData & { deliveryExpiresAt: Date; createdAt?: Date }, + ) => Promise; + releaseOpenIDRefreshFlightDelivery: ( + data: FlightDeliveryData, + ) => Promise; + }; + logger: Pick; + encrypt: (value: string) => Promise; + decrypt: (value: string) => Promise; +} + +export function createOpenIDRefreshFlightService({ + db, + logger, + encrypt, + decrypt, +}: OpenIDRefreshFlightDeps): OpenIDRefreshFlightService { + const sha256 = (value: string): string => crypto.createHash('sha256').update(value).digest('hex'); + + function createOpenIDRefreshFlightKey({ + req, + user, + refreshToken, + identityContext, + }: RefreshKeyInput): string | null { + const identitySource = identityContext + ? { + id: identityContext.appUserId, + openidId: identityContext.openidSubject, + tenantId: identityContext.tenantId, + openidIssuer: identityContext.openidIssuer, + } + : user; + const tuple = createOpenIDRefreshIdentityTuple({ + user: identitySource, + requestUser: req?.user, + }); + if (!tuple || !refreshToken) return null; + return sha256([serializeAuthIdentityTuple(tuple), sha256(refreshToken)].join('\x1f')); + } + + async function acquireOpenIDRefreshFlight({ + key, + ownerId = crypto.randomUUID(), + ttl = DEFAULT_FLIGHT_TTL_MS, + lockTtl = DEFAULT_LOCK_TTL_MS, + }: { + key?: string | null; + ownerId?: string; + ttl?: number; + lockTtl?: number; + }): Promise { + if (!key) return { acquired: true, key: null, ownerId, flight: null }; + const acquired = await db.acquireOpenIDRefreshFlight({ + key, + ownerId, + lockExpiresAt: new Date(Date.now() + lockTtl), + expiresAt: new Date(Date.now() + ttl), + }); + return { ...acquired, key, ownerId }; + } + + async function completeOpenIDRefreshFlight({ + key, + ownerId, + tokens, + ttl = DEFAULT_FLIGHT_TTL_MS, + }: { + key?: string | null; + ownerId?: string; + tokens?: TokenResult | null; + ttl?: number; + }): Promise { + if (!key || !ownerId || !tokens) return null; + const serializedTokens: TokenResult = { ...tokens }; + if (tokens.__browserRefreshToken) { + serializedTokens.__browserRefreshToken = tokens.__browserRefreshToken; + } + if (tokens.__predecessorRefreshToken) { + serializedTokens.__predecessorRefreshToken = tokens.__predecessorRefreshToken; + } + if (tokens.__predecessorAccessToken) { + serializedTokens.__predecessorAccessToken = tokens.__predecessorAccessToken; + } + if (tokens.__deferredPublication) { + serializedTokens.__deferredPublication = true; + } + const accessTokenExpiresAt = Number(tokens.expires_at) * 1000; + const usableTokenTtl = Number.isFinite(accessTokenExpiresAt) + ? Math.max(1, accessTokenExpiresAt - Date.now() - OPENID_EXPIRY_BUFFER_SECONDS * 1000) + : ttl; + return db.completeOpenIDRefreshFlight({ + key, + ownerId, + encryptedResult: await encrypt(JSON.stringify(serializedTokens)), + expiresAt: new Date(Date.now() + Math.min(ttl, usableTokenTtl)), + }); + } + + async function renewOpenIDRefreshFlight({ + key, + ownerId, + lockTtl = DEFAULT_LOCK_TTL_MS, + ttl = DEFAULT_FLIGHT_TTL_MS, + }: { + key?: string | null; + ownerId?: string; + lockTtl?: number; + ttl?: number; + }): Promise { + if (!key || !ownerId) return null; + return db.renewOpenIDRefreshFlight({ + key, + ownerId, + lockExpiresAt: new Date(Date.now() + lockTtl), + expiresAt: new Date(Date.now() + ttl), + }); + } + + async function assertOpenIDRefreshFlightAvailable({ + key, + ownerId, + }: { + key?: string | null; + ownerId?: string; + }): Promise { + if (!key) return true; + const flight = await db.findOpenIDRefreshFlight({ key }); + if ( + flight?.status === 'completed' && + ownerId && + flight.ownerId === ownerId && + !flight.revocationRequestedAt + ) { + return flight; + } + throw createOpenIDRefreshOwnershipError( + 'OpenID refresh result is no longer available for publication', + ); + } + + /** + * Validates a generation already installed in an Express session. Completed-flight rows may + * expire before the session reuse window, so absence is acceptable; an extant row must still + * name the same completed generation. Logout tombstones and replacement generations fail closed. + */ + async function assertOpenIDRefreshSessionGenerationAvailable({ + key, + ownerId, + }: { + key?: string | null; + ownerId?: string; + }): Promise { + if (!key && !ownerId) return true; + if (!key || !ownerId) { + throw createOpenIDRefreshOwnershipError( + 'OpenID session publication generation is incomplete', + ); + } + const flight = await db.findOpenIDRefreshFlight({ key }); + if ( + !flight || + (flight.status === 'completed' && flight.ownerId === ownerId && !flight.revocationRequestedAt) + ) { + return flight ?? true; + } + throw createOpenIDRefreshOwnershipError( + 'OpenID session publication generation is no longer available', + ); + } + + async function claimOpenIDRefreshFlightDelivery({ + key, + ownerId, + createdAt, + deliveryId = crypto.randomUUID(), + ttl = DEFAULT_DELIVERY_TTL_MS, + }: { + key: string; + ownerId: string; + createdAt?: number; + deliveryId?: string; + ttl?: number; + }): Promise { + const deadline = Date.now() + ttl; + while (Date.now() <= deadline) { + const deliveryExpiresAt = new Date(Date.now() + ttl); + const delivery = await db.claimOpenIDRefreshFlightDelivery({ + key, + ownerId, + deliveryId, + deliveryExpiresAt, + ...(Number.isFinite(createdAt) ? { createdAt: new Date(createdAt as number) } : {}), + }); + if (delivery) return delivery; + + const current = await db.findOpenIDRefreshFlight({ key }); + if (!current && Number.isFinite(createdAt)) { + await delay(DEFAULT_WAIT_INTERVAL_MS); + continue; + } + if ( + current?.status !== 'completed' || + current.ownerId !== ownerId || + current.revocationRequestedAt + ) { + throw createOpenIDRefreshOwnershipError( + 'OpenID refresh generation is unavailable for response delivery', + ); + } + await delay(DEFAULT_WAIT_INTERVAL_MS); + } + throw new Error('Timed out waiting to deliver the OpenID refresh generation'); + } + + async function assertOpenIDRefreshFlightDeliveryAvailable({ + key, + ownerId, + deliveryId, + }: FlightDeliveryData): Promise { + const delivery = await db.findOpenIDRefreshFlight({ key }); + const deliveryExpiresAt = delivery?.deliveryExpiresAt + ? new Date(delivery.deliveryExpiresAt).getTime() + : NaN; + if ( + delivery?.status === 'completed' && + delivery.ownerId === ownerId && + delivery.deliveryId === deliveryId && + !delivery.revocationRequestedAt && + Number.isFinite(deliveryExpiresAt) && + deliveryExpiresAt > Date.now() + ) { + return; + } + throw createOpenIDRefreshOwnershipError( + 'OpenID refresh response delivery authorization was revoked', + ); + } + + async function releaseOpenIDRefreshFlightDelivery({ + key, + ownerId, + deliveryId, + }: FlightDeliveryData): Promise { + await db.releaseOpenIDRefreshFlightDelivery({ key, ownerId, deliveryId }); + } + + async function withOpenIDRefreshFlightLease({ + key, + ownerId, + operation, + heartbeatInterval = DEFAULT_HEARTBEAT_INTERVAL_MS, + lockTtl = DEFAULT_LOCK_TTL_MS, + ttl = DEFAULT_FLIGHT_TTL_MS, + }: { + key?: string | null; + ownerId?: string; + operation: (context: LeaseContext) => Promise; + heartbeatInterval?: number; + lockTtl?: number; + ttl?: number; + }): Promise { + if (!key || !ownerId) + return operation({ assertLeaseOwned: async () => true, markLeaseSettled: () => {} }); + let renewalPromise: Promise | null = null; + let ownershipLost = false; + let settled = false; + const ownershipError = () => + createOpenIDRefreshOwnershipError( + 'OpenID refresh coordination ownership was lost before completion', + ); + const renewLease = async () => { + if (ownershipLost) throw ownershipError(); + if (!renewalPromise) + renewalPromise = renewOpenIDRefreshFlight({ key, ownerId, lockTtl, ttl }).finally(() => { + renewalPromise = null; + }); + const flight = await renewalPromise; + if (!flight) { + if (settled) return null; + const terminalFlight = await db.findOpenIDRefreshFlight({ key }); + if (terminalFlight?.ownerId === ownerId && terminalFlight?.status === 'completed') { + return terminalFlight; + } + ownershipLost = true; + throw ownershipError(); + } + return flight; + }; + const heartbeat = setInterval(() => { + renewLease().catch((error) => + logger.warn('[OpenIDRefreshFlight] Refresh flight lease renewal failed', { + key, + error: error?.message, + }), + ); + }, heartbeatInterval); + heartbeat.unref?.(); + let result: T; + try { + result = await operation({ + assertLeaseOwned: renewLease, + markLeaseSettled: () => { + settled = true; + }, + }); + if (ownershipLost) throw ownershipError(); + } catch (error) { + clearInterval(heartbeat); + if (renewalPromise) { + try { + await renewalPromise; + } catch (cleanupError) { + logger.warn('[OpenIDRefreshFlight] Lease cleanup also failed after the operation', { + key, + error: toOpenIDLogArgument(cleanupError), + }); + } + } + throw error; + } + clearInterval(heartbeat); + if (renewalPromise) { + try { + await renewalPromise; + } catch (error) { + if (!settled || isOpenIDRefreshOwnershipError(error)) { + throw error; + } + } + } + if (ownershipLost) { + throw ownershipError(); + } + return result; + } + + async function failOpenIDRefreshFlight({ + key, + ownerId, + error, + ttl = DEFAULT_FLIGHT_TTL_MS, + }: { + key?: string | null; + ownerId?: string; + error?: Error | { message?: string } | null; + ttl?: number; + }): Promise { + if (!key || !ownerId) return null; + const errorMessage = + typeof error?.message === 'string' && error.message ? error.message : 'OpenID refresh failed'; + return db.failOpenIDRefreshFlight({ + key, + ownerId, + errorMessage, + expiresAt: new Date(Date.now() + ttl), + }); + } + + async function revokeOpenIDRefreshFlights({ + keys, + ttl = DEFAULT_FLIGHT_TTL_MS, + }: { + keys?: Array; + ttl?: number; + }): Promise> { + const uniqueKeys = [...new Set((keys ?? []).filter((key): key is string => !!key))]; + if (uniqueKeys.length === 0) return []; + const expiresAt = new Date(Date.now() + ttl); + const revoked = await Promise.all( + uniqueKeys.map((key) => db.revokeOpenIDRefreshFlight({ key, expiresAt })), + ); + return Promise.all( + revoked.map(async (flight) => { + if (!flight?.encryptedResult) return null; + return restoreInternalTokenFields( + JSON.parse(await decrypt(flight.encryptedResult)) as TokenResult, + ); + }), + ); + } + + function restoreInternalTokenFields(tokens: TokenResult): TokenResult { + for (const [field, value] of [ + [INTERNAL_BROWSER_REFRESH_TOKEN_FIELD, tokens.__browserRefreshToken], + [INTERNAL_PREDECESSOR_REFRESH_TOKEN_FIELD, tokens.__predecessorRefreshToken], + [INTERNAL_PREDECESSOR_ACCESS_TOKEN_FIELD, tokens.__predecessorAccessToken], + [INTERNAL_DEFERRED_PUBLICATION_FIELD, tokens.__deferredPublication], + ] as const) { + if (value) { + delete tokens[field]; + Object.defineProperty(tokens, field, { value, enumerable: false, configurable: true }); + } + } + return tokens; + } + + function attachFlightOwner( + tokens: TokenResult, + ownerId?: string, + createdAt?: Date | string, + ): TokenResult { + if (!ownerId) return tokens; + Object.defineProperty(tokens, INTERNAL_FLIGHT_OWNER_FIELD, { + value: ownerId, + enumerable: false, + configurable: true, + }); + const createdAtMs = createdAt ? new Date(createdAt).getTime() : NaN; + if (Number.isFinite(createdAtMs)) { + Object.defineProperty(tokens, INTERNAL_FLIGHT_CREATED_AT_FIELD, { + value: createdAtMs, + enumerable: false, + configurable: true, + }); + } + return tokens; + } + + async function readCompletedFlight( + flight: RefreshFlightRecord | null, + ): Promise { + if (!flight) return null; + if (flight.status === 'revoked') + throw new Error(flight.errorMessage || 'OpenID refresh was revoked by logout'); + if (flight.status === 'failed') + throw new Error(flight.errorMessage || 'OpenID refresh failed in another worker'); + if (flight.status !== 'completed' || flight.revocationRequestedAt || !flight.encryptedResult) + return null; + const tokens = JSON.parse(await decrypt(flight.encryptedResult)) as TokenResult; + const accessTokenExpiresAt = Number(tokens.expires_at) * 1000; + if ( + Number.isFinite(accessTokenExpiresAt) && + accessTokenExpiresAt <= Date.now() + OPENID_EXPIRY_BUFFER_SECONDS * 1000 + ) { + return null; + } + return attachFlightOwner(restoreInternalTokenFields(tokens), flight.ownerId, flight.createdAt); + } + + function getRenewedWaitDeadline(deadline: number, flight: RefreshFlightRecord | null): number { + const renewedExpiry = flight?.expiresAt ? new Date(flight.expiresAt).getTime() : NaN; + return Number.isFinite(renewedExpiry) ? Math.max(deadline, renewedExpiry) : deadline; + } + + async function waitForOpenIDRefreshFlight({ + key, + timeoutMs, + intervalMs = DEFAULT_WAIT_INTERVAL_MS, + }: { + key?: string | null; + timeoutMs?: number; + intervalMs?: number; + }): Promise { + if (!key) return null; + const followRenewals = timeoutMs == null; + let deadline = Date.now() + (timeoutMs ?? DEFAULT_WAIT_TIMEOUT_MS); + while (Date.now() <= deadline) { + const flight = await db.findOpenIDRefreshFlight({ key }); + const completed = await readCompletedFlight(flight); + if (completed) return completed; + if (flight?.status === 'completed') return null; + if (!flight) return null; + if (followRenewals) { + deadline = getRenewedWaitDeadline(deadline, flight); + } + await delay(intervalMs); + } + logger.warn('[OpenIDRefreshFlight] Timed out waiting for refresh flight', { key }); + return null; + } + + return { + acquireOpenIDRefreshFlight, + assertOpenIDRefreshFlightDeliveryAvailable, + assertOpenIDRefreshFlightAvailable, + assertOpenIDRefreshSessionGenerationAvailable, + claimOpenIDRefreshFlightDelivery, + completeOpenIDRefreshFlight, + createOpenIDRefreshFlightKey, + failOpenIDRefreshFlight, + renewOpenIDRefreshFlight, + releaseOpenIDRefreshFlightDelivery, + revokeOpenIDRefreshFlights, + waitForOpenIDRefreshFlight, + withOpenIDRefreshFlightLease, + __internals: { + sha256, + readCompletedFlight, + DEFAULT_FLIGHT_TTL_MS, + DEFAULT_LOCK_TTL_MS, + DEFAULT_WAIT_TIMEOUT_MS, + DEFAULT_WAIT_INTERVAL_MS, + DEFAULT_HEARTBEAT_INTERVAL_MS, + DEFAULT_DELIVERY_TTL_MS, + INTERNAL_PREDECESSOR_REFRESH_TOKEN_FIELD, + getRenewedWaitDeadline, + }, + }; +} diff --git a/packages/api/src/auth/openid/index.ts b/packages/api/src/auth/openid/index.ts new file mode 100644 index 0000000000..4d5d56550b --- /dev/null +++ b/packages/api/src/auth/openid/index.ts @@ -0,0 +1,5 @@ +export * from './errors'; +export * from './flight'; +export * from './bridge'; +export * from './recovery'; +export * from './session'; diff --git a/packages/api/src/auth/openid/recovery.ts b/packages/api/src/auth/openid/recovery.ts new file mode 100644 index 0000000000..f08e3edee9 --- /dev/null +++ b/packages/api/src/auth/openid/recovery.ts @@ -0,0 +1,1010 @@ +import type { + AuthIdentityContext, + LeaseAssertion, + LeaseContext, + OpenIDClaims, + OpenIDLogger, + OpenIDPublicationGeneration, + OpenIDRefreshResolution, + OpenIDRequest, + OpenIDResponse, + OpenIDTokenSet, + OpenIDUser, + RefreshFlightAcquireResult, + RefreshFlightRecord, + RefreshTokenBridgeDeleteInput, + RefreshTokenBridgeIdentity, + RefreshTokenBridgeInput, + SharedOpenIDRefreshResult, + TokenPreference, +} from './types'; +import type { TokenResult } from './flight'; +import { + createOpenIDRefreshOwnershipError, + isOpenIDRefreshOwnershipError, + toOpenIDLogArgument, +} from './errors'; + +type FindUser = (...args: Array) => Promise; + +interface FindOpenIDUserArgs { + findUser: FindUser; + email: string; + openidId: string; + openidIssuer?: string; + idOnTheSource?: string; + strategyName: string; +} + +interface RefreshOpenIDUserArgs { + req: OpenIDRequest; + res?: OpenIDResponse; + user: OpenIDUser; + refreshToken: string; + browserRefreshToken?: string; + strategyName: string; + assertLeaseOwned?: LeaseAssertion; + deferPublication?: boolean; +} + +interface BridgeUser extends OpenIDUser { + _id: string | number | { toString(): string }; +} + +interface ResolveOpenIDRefreshInput { + tokenset: OpenIDTokenSet | null; + strategyName: string; +} + +type SeedRefreshSessionInput = Omit< + RefreshOpenIDUserArgs, + 'strategyName' | 'assertLeaseOwned' | 'deferPublication' +>; + +interface RecoverOpenIDRefreshBridgeInput { + req: OpenIDRequest; + res: OpenIDResponse; + refreshToken: string; + bridgedRefreshToken: string; + bridgeUser: BridgeUser; +} + +interface RevokeOpenIDRefreshTokenChainInput { + req: OpenIDRequest; + user: OpenIDUser; + identityContext: AuthIdentityContext; + refreshTokens: string[]; + publicationKeys?: string[]; + ttl: number; +} + +interface SendOpenIDAuthResponseInput { + tokenset: OpenIDTokenSet; + user: BridgeUser; + existingRefreshToken?: string; + openidSubject?: string; + openidIssuer?: string; + predecessorIdentity?: RefreshTokenBridgeIdentity; + predecessorAccessToken?: string; + rejectedRefreshTokens?: string[]; + req: OpenIDRequest; + res: OpenIDResponse; + assertLeaseOwned?: LeaseAssertion; + publicationGeneration?: OpenIDPublicationGeneration; + commitPublication?: ( + appAuthToken: string, + publishedTokenset: OpenIDTokenSet, + metadata: { + predecessorAccessToken?: string; + acceptedIdentity: AuthIdentityContext; + }, + ) => Promise; + preparePublication?: boolean; +} + +export interface OpenIDRefreshRecoveryService { + recoverOpenIDRefreshBridge: ( + input: RecoverOpenIDRefreshBridgeInput, + ) => Promise; + refreshOpenIDUser: (input: RefreshOpenIDUserArgs) => Promise; + resolveOpenIDRefreshResult: ( + input: ResolveOpenIDRefreshInput, + ) => Promise; + sendOpenIDAuthResponse: (input: SendOpenIDAuthResponseInput) => Promise; + revokeOpenIDRefreshTokenChain: (input: RevokeOpenIDRefreshTokenChainInput) => Promise; + __internals: { + getTokenClaims: (tokenset: OpenIDTokenSet) => OpenIDClaims; + seedRefreshSession: (input: SeedRefreshSessionInput) => AuthIdentityContext; + }; +} + +export interface OpenIDRefreshRecoveryDeps { + jwt: { decode: (token: string) => OpenIDClaims | string | null }; + logger: Pick; + findOpenIDUser: (args: FindOpenIDUserArgs) => Promise<{ + user?: OpenIDUser | null; + error?: string | null; + migration?: boolean; + }>; + findUser: FindUser; + getOpenIdConfig: () => object; + getOpenIdEmail: (claims: OpenIDClaims) => string; + getOpenIdIssuer: (claims: OpenIDClaims, config: object) => string | undefined; + createAuthIdentityContext: (args: { + user?: OpenIDUser; + requestUser?: OpenIDUser; + }) => AuthIdentityContext; + refreshOpenIDSession: ( + req: OpenIDRequest, + res: OpenIDResponse | undefined, + user: OpenIDUser, + preference: TokenPreference, + identity: AuthIdentityContext, + options: { + forceRefresh: boolean; + assertLeaseOwned?: LeaseAssertion; + deferPublication?: boolean; + }, + ) => Promise; + storeOpenIDSession: ( + userId: string, + refreshToken: string, + tenantId?: string, + previousRefreshToken?: string, + ) => Promise; + setOpenIDAuthTokens: ( + tokens: OpenIDTokenSet, + req: OpenIDRequest, + res: OpenIDResponse, + identity: { + userId: string; + existingRefreshToken?: string; + tenantId?: string; + openidSubject?: string; + openidIssuer?: string; + }, + ) => string | undefined; + getOpenIDAppAuthToken: (tokens: OpenIDTokenSet, sessionIdToken?: string) => string | undefined; + clearOpenIDAuthTokens: ( + req: OpenIDRequest, + res: OpenIDResponse, + userId: string, + tenantId?: string, + ) => void; + deleteOpenIDSession: (refreshToken: string) => Promise; + createRefreshTokenBridgeFlightKey: (args: { + oldRefreshToken: string; + userId: string; + tenantId?: string; + openidIssuer?: string; + }) => string | null; + createOpenIDRefreshFlightKey: (args: { + req: OpenIDRequest; + user: OpenIDUser; + refreshToken: string; + identityContext: AuthIdentityContext; + }) => string | null; + storeRefreshTokenBridge: (args: RefreshTokenBridgeInput) => Promise; + deleteRefreshTokenBridges: (args: RefreshTokenBridgeDeleteInput) => Promise; + acquireOpenIDRefreshFlight: (args: { key: string }) => Promise; + completeOpenIDRefreshFlight: (args: { + key: string; + ownerId: string; + tokens: SharedOpenIDRefreshResult; + }) => Promise; + failOpenIDRefreshFlight: (args: { + key: string; + ownerId: string; + error: Error; + }) => Promise; + waitForOpenIDRefreshFlight: (args: { key: string }) => Promise; + assertOpenIDRefreshFlightAvailable: (args: { + key: string; + ownerId: string; + }) => Promise; + assertOpenIDRefreshSessionGenerationAvailable: (args: { + key?: string | null; + ownerId?: string; + }) => Promise; + revokeOpenIDRefreshFlights: (args: { + keys: Array; + ttl: number; + }) => Promise>; + withOpenIDRefreshFlightLease: (args: { + key: string; + ownerId: string; + operation: (context: LeaseContext) => Promise; + }) => Promise; + bridgeGraceMs: number; +} + +export function createOpenIDRefreshRecoveryService( + deps: OpenIDRefreshRecoveryDeps, +): OpenIDRefreshRecoveryService { + const { + jwt, + logger, + findOpenIDUser, + findUser, + getOpenIdConfig, + getOpenIdEmail, + getOpenIdIssuer, + createAuthIdentityContext, + refreshOpenIDSession, + storeOpenIDSession, + setOpenIDAuthTokens, + getOpenIDAppAuthToken, + clearOpenIDAuthTokens, + deleteOpenIDSession, + createRefreshTokenBridgeFlightKey, + createOpenIDRefreshFlightKey, + storeRefreshTokenBridge, + deleteRefreshTokenBridges, + acquireOpenIDRefreshFlight, + completeOpenIDRefreshFlight, + failOpenIDRefreshFlight, + waitForOpenIDRefreshFlight, + assertOpenIDRefreshFlightAvailable, + assertOpenIDRefreshSessionGenerationAvailable, + revokeOpenIDRefreshFlights, + withOpenIDRefreshFlightLease, + bridgeGraceMs, + } = deps; + + const MAX_LOGOUT_REFRESH_CHAIN_DEPTH = 16; + const MAX_LOGOUT_REFRESH_TARGETS = 128; + + async function revokeOpenIDRefreshTokenChain({ + req, + user, + identityContext, + refreshTokens, + publicationKeys = [], + ttl, + }: RevokeOpenIDRefreshTokenChainInput): Promise { + const userId = identityContext.appUserId; + if (!userId) { + throw new Error('OpenID logout identity is unavailable'); + } + const identityKey = (identity: AuthIdentityContext): string => + [ + identity.appUserId ?? '', + identity.tenantId ?? '', + identity.openidIssuer ?? '', + identity.openidSubject ?? '', + ].join('\x1f'); + const discovered = new Set(refreshTokens.filter(Boolean)); + const scheduled = new Set(); + let frontier = [...discovered].map((refreshToken) => ({ + refreshToken, + identity: identityContext, + })); + for (const target of frontier) { + scheduled.add(`${target.refreshToken}\x1e${identityKey(target.identity)}`); + } + let directPublicationKeys = [...new Set(publicationKeys.filter(Boolean))]; + + for (let depth = 0; frontier.length > 0 || directPublicationKeys.length > 0; depth++) { + if (depth >= MAX_LOGOUT_REFRESH_CHAIN_DEPTH) { + throw new Error('OpenID logout refresh chain exceeded the safety limit'); + } + const keys = [ + ...directPublicationKeys, + ...frontier.flatMap(({ refreshToken, identity }) => [ + createOpenIDRefreshFlightKey({ req, user, refreshToken, identityContext: identity }), + createRefreshTokenBridgeFlightKey({ + oldRefreshToken: refreshToken, + userId: identity.appUserId ?? userId, + tenantId: identity.tenantId, + openidIssuer: identity.openidIssuer, + }), + ]), + ]; + directPublicationKeys = []; + const revoked = await revokeOpenIDRefreshFlights({ keys, ttl }); + const inheritedIdentities = frontier.map(({ identity }) => identity); + const acceptedIdentities = revoked.flatMap((result) => { + if (result?.acceptedIdentity) return [result.acceptedIdentity]; + const claims = result?.__identityClaims; + if (!claims?.sub) return []; + return [ + { + ...identityContext, + openidSubject: claims.sub, + openidIssuer: result?.openidIssuer ?? claims.iss ?? identityContext.openidIssuer, + }, + ]; + }); + const identities = [...inheritedIdentities, ...acceptedIdentities].filter( + (identity, index, all) => + all.findIndex((candidate) => identityKey(candidate) === identityKey(identity)) === index, + ); + const successors = revoked.flatMap((result) => + [result?.refresh_token, result?.tokenset?.refresh_token].filter((token): token is string => + Boolean(token), + ), + ); + frontier = successors.flatMap((refreshToken) => { + discovered.add(refreshToken); + return identities.flatMap((identity) => { + const targetKey = `${refreshToken}\x1e${identityKey(identity)}`; + if (scheduled.has(targetKey)) return []; + if (scheduled.size >= MAX_LOGOUT_REFRESH_TARGETS) { + throw new Error('OpenID logout refresh chain exceeded the target safety limit'); + } + scheduled.add(targetKey); + return [{ refreshToken, identity }]; + }); + }); + } + + return [...discovered]; + } + + function getTokenClaims(tokenset: OpenIDTokenSet): OpenIDClaims { + if (typeof tokenset?.claims === 'function') { + return tokenset.claims(); + } + if (tokenset.__identityClaims?.sub) { + return tokenset.__identityClaims; + } + const identityToken = tokenset.id_token ?? tokenset.__identityIdToken; + const decoded = identityToken ? jwt.decode(identityToken) : null; + if (!decoded || typeof decoded !== 'object') { + throw new Error('OpenID refresh returned no usable identity claims'); + } + return decoded as OpenIDClaims; + } + + async function resolveOpenIDRefreshResult({ + tokenset, + strategyName, + }: ResolveOpenIDRefreshInput): Promise { + if (!tokenset?.access_token) { + throw new Error('OpenID refresh returned no access token'); + } + const claims = getTokenClaims(tokenset); + const openIdConfig = getOpenIdConfig(); + const openidIssuer = getOpenIdIssuer(claims, openIdConfig); + const { user, error, migration } = await findOpenIDUser({ + findUser, + email: getOpenIdEmail(claims), + openidId: claims.sub, + openidIssuer, + idOnTheSource: claims.oid, + strategyName, + }); + + logger.debug( + `[refreshController] findOpenIDUser result: user=${user?.email ?? 'null'}, error=${error ?? 'null'}, migration=${migration}, userOpenidId=${user?.openidId ?? 'null'}, claimsSub=${claims.sub}`, + ); + return { tokenset, claims, openidIssuer, user, error, migration }; + } + + function seedRefreshSession({ + req, + user, + refreshToken, + browserRefreshToken, + }: SeedRefreshSessionInput): AuthIdentityContext { + if (!req.session) { + throw new Error('OpenID refresh requires an Express session'); + } + const identity = createAuthIdentityContext({ user, requestUser: req.user }); + req.session.openidTokens = { + ...(req.session.openidTokens ?? {}), + refreshToken, + browserRefreshToken: browserRefreshToken || refreshToken, + appUserId: identity.appUserId, + openidSubject: identity.openidSubject, + ...(identity.tenantId ? { tenantId: identity.tenantId } : {}), + ...(identity.openidIssuer ? { openidIssuer: identity.openidIssuer } : {}), + }; + return identity; + } + + async function refreshOpenIDUser({ + req, + res, + user, + refreshToken, + browserRefreshToken, + strategyName, + assertLeaseOwned, + deferPublication = false, + }: RefreshOpenIDUserArgs): Promise { + const previousSessionTokens = deferPublication ? req.session?.openidTokens : undefined; + const hadSessionTokens = Boolean(req.session && 'openidTokens' in req.session); + const identityContext = seedRefreshSession({ + req, + user, + refreshToken, + browserRefreshToken, + }); + let tokenset: OpenIDTokenSet | null; + try { + tokenset = await refreshOpenIDSession(req, res, user, 'id_token', identityContext, { + forceRefresh: true, + ...(assertLeaseOwned ? { assertLeaseOwned } : {}), + ...(deferPublication ? { deferPublication: true } : {}), + }); + } finally { + if (deferPublication && req.session) { + if (hadSessionTokens) { + req.session.openidTokens = previousSessionTokens; + } else { + delete req.session.openidTokens; + } + } + } + return resolveOpenIDRefreshResult({ tokenset, strategyName }); + } + + async function recoverOpenIDRefreshBridge({ + req, + res, + refreshToken, + bridgedRefreshToken, + bridgeUser, + }: RecoverOpenIDRefreshBridgeInput): Promise { + const userId = bridgeUser._id.toString(); + const key = createRefreshTokenBridgeFlightKey({ + oldRefreshToken: refreshToken, + userId, + tenantId: bridgeUser.tenantId, + openidIssuer: bridgeUser.openidIssuer, + }); + if (!key) { + throw new Error('OpenID refresh bridge coordination key is unavailable'); + } + + const flight = await acquireOpenIDRefreshFlight({ key }); + if (!flight.acquired) { + const resolved = await waitForOpenIDRefreshFlight({ key }); + if (!resolved?.appAuthToken || !resolved.__flightOwnerId) { + throw new Error('OpenID refresh bridge coordination is temporarily unavailable'); + } + const publicationGeneration = { + key, + ownerId: resolved.__flightOwnerId, + createdAt: resolved.__flightCreatedAt, + }; + const publishedAppAuthToken = await sendOpenIDAuthResponse({ + tokenset: resolved.tokenset, + user: bridgeUser, + existingRefreshToken: refreshToken, + openidSubject: resolved.claims.sub, + openidIssuer: resolved.openidIssuer, + predecessorIdentity: { + userId, + tenantId: bridgeUser.tenantId, + openidIssuer: bridgeUser.openidIssuer, + }, + predecessorAccessToken: resolved.predecessorAccessToken, + req, + res, + assertLeaseOwned: () => assertOpenIDRefreshFlightAvailable(publicationGeneration), + publicationGeneration, + commitPublication: async () => {}, + preparePublication: false, + }); + return { ...resolved, appAuthToken: publishedAppAuthToken ?? resolved.appAuthToken }; + } + + return withOpenIDRefreshFlightLease({ + key, + ownerId: flight.ownerId, + operation: async ({ assertLeaseOwned, markLeaseSettled }: LeaseContext) => { + let completionIndeterminate = false; + try { + const resolved = await refreshOpenIDUser({ + req, + res, + user: bridgeUser, + refreshToken: bridgedRefreshToken, + browserRefreshToken: refreshToken, + strategyName: 'refreshController (bridge recovery)', + assertLeaseOwned, + deferPublication: true, + }); + const { tokenset, user, error } = resolved; + const resolvedUserId = user?._id?.toString(); + if (!user || error || !resolvedUserId || resolvedUserId !== userId) { + if (resolvedUserId && resolvedUserId !== userId) { + logger.warn( + '[refreshController] Bridge recovery resolved a different user; refusing token issuance', + { cookieUserId: userId, resolvedUserId }, + ); + } + throw new Error('Invalid OpenID refresh token'); + } + + await assertLeaseOwned(); + let graceBridgeVersion: string | null = null; + try { + graceBridgeVersion = await storeRefreshTokenBridge({ + oldRefreshToken: refreshToken, + newRefreshToken: tokenset.refresh_token || bridgedRefreshToken, + userId, + tenantId: bridgeUser.tenantId, + openidIssuer: bridgeUser.openidIssuer, + ttl: bridgeGraceMs, + }); + } catch (graceError) { + logger.warn( + '[refreshController] Bridge grace-period storage failed after successful recovery', + toOpenIDLogArgument(graceError), + ); + } + if (graceBridgeVersion) { + try { + await assertLeaseOwned(); + } catch (ownershipError) { + /** Ownership must be proven, not merely unreadable; see the note in `session.ts`. */ + if (!isOpenIDRefreshOwnershipError(ownershipError)) { + logger.warn( + '[refreshController] Keeping the grace bridge; lease ownership is undetermined', + { userId, error: (ownershipError as Error)?.message }, + ); + throw ownershipError; + } + try { + await deleteRefreshTokenBridges({ + refreshTokens: [refreshToken], + userId, + tenantId: bridgeUser.tenantId, + version: graceBridgeVersion, + }); + } catch (cleanupError) { + logger.warn( + '[refreshController] Failed to remove grace bridge after ownership loss', + toOpenIDLogArgument(cleanupError), + ); + } + throw ownershipError; + } + } + + const publication: { result?: SharedOpenIDRefreshResult } = {}; + const sharedResult = { + tokenset, + claims: resolved.claims, + openidIssuer: resolved.openidIssuer, + expires_at: tokenset.expires_at, + }; + const appAuthToken = await sendOpenIDAuthResponse({ + tokenset, + user: bridgeUser, + existingRefreshToken: refreshToken, + openidSubject: resolved.claims.sub, + openidIssuer: resolved.openidIssuer, + predecessorIdentity: { + userId, + tenantId: bridgeUser.tenantId, + openidIssuer: bridgeUser.openidIssuer, + }, + req, + res, + assertLeaseOwned, + publicationGeneration: { + key, + ownerId: flight.ownerId, + createdAt: flight.flight?.createdAt + ? new Date(flight.flight.createdAt).getTime() + : Date.now(), + }, + commitPublication: async (preparedAppAuthToken, publishedTokenset, metadata) => { + const result = { + ...sharedResult, + tokenset: publishedTokenset, + expires_at: publishedTokenset.expires_at, + appAuthToken: preparedAppAuthToken, + ...metadata, + }; + let completed: RefreshFlightRecord | null = null; + try { + completed = await completeOpenIDRefreshFlight({ + key, + ownerId: flight.ownerId, + tokens: result, + }); + } catch (completionError) { + completionIndeterminate = true; + try { + const observed = await assertOpenIDRefreshFlightAvailable({ + key, + ownerId: flight.ownerId, + }); + if (typeof observed === 'object') { + completed = observed; + completionIndeterminate = false; + } + } catch { + /** Preserve the pending generation when completion cannot be observed. */ + } + if (!completed) { + throw completionError; + } + } + if (!completed) { + throw createOpenIDRefreshOwnershipError( + 'OpenID refresh bridge coordination ownership was lost', + ); + } + publication.result = result; + markLeaseSettled(); + }, + }); + if (!publication.result || publication.result.appAuthToken !== appAuthToken) { + throw new Error('OpenID refresh bridge publication did not settle'); + } + return publication.result; + } catch (error) { + if (!completionIndeterminate) { + try { + await failOpenIDRefreshFlight({ + key, + ownerId: flight.ownerId, + error: error instanceof Error ? error : new Error('OpenID bridge recovery failed'), + }); + } catch (flightError) { + logger.warn('[refreshController] Failed to mark refresh bridge flight failed', { + error: (flightError as Error)?.message, + }); + } + } else { + logger.warn( + '[refreshController] Keeping an indeterminate bridge generation recoverable', + ); + } + throw error; + } + }, + }); + } + + async function sendOpenIDAuthResponse({ + tokenset, + user, + existingRefreshToken, + openidSubject, + openidIssuer, + predecessorIdentity, + predecessorAccessToken, + rejectedRefreshTokens = [], + req, + res, + assertLeaseOwned, + publicationGeneration, + commitPublication, + preparePublication = true, + }: SendOpenIDAuthResponseInput): Promise { + const userId = user._id.toString(); + const publicationIdentity = predecessorIdentity ?? { + userId, + tenantId: user.tenantId, + openidIssuer: user.openidIssuer, + }; + if (!commitPublication && existingRefreshToken) { + const key = createRefreshTokenBridgeFlightKey({ + oldRefreshToken: existingRefreshToken, + userId: publicationIdentity.userId, + tenantId: publicationIdentity.tenantId, + openidIssuer: publicationIdentity.openidIssuer, + }); + if (key) { + const flight = await acquireOpenIDRefreshFlight({ key }); + if (!flight.acquired) { + const shared = await waitForOpenIDRefreshFlight({ key }); + if (!shared?.appAuthToken || !shared.__flightOwnerId) { + throw new Error('OpenID authentication publication is temporarily unavailable'); + } + const sharedGeneration = { + key, + ownerId: shared.__flightOwnerId, + createdAt: shared.__flightCreatedAt, + }; + return sendOpenIDAuthResponse({ + tokenset: shared.tokenset, + user, + existingRefreshToken, + openidSubject: shared.claims.sub, + openidIssuer: shared.openidIssuer, + predecessorIdentity: publicationIdentity, + predecessorAccessToken: shared.predecessorAccessToken, + rejectedRefreshTokens, + req, + res, + assertLeaseOwned: () => assertOpenIDRefreshFlightAvailable(sharedGeneration), + publicationGeneration: sharedGeneration, + commitPublication: async () => {}, + preparePublication: false, + }); + } + return withOpenIDRefreshFlightLease({ + key, + ownerId: flight.ownerId, + operation: async ({ assertLeaseOwned, markLeaseSettled }) => + sendOpenIDAuthResponse({ + tokenset, + user, + existingRefreshToken, + openidSubject, + openidIssuer, + predecessorIdentity: publicationIdentity, + rejectedRefreshTokens, + req, + res, + assertLeaseOwned, + publicationGeneration: { + key, + ownerId: flight.ownerId, + createdAt: flight.flight?.createdAt + ? new Date(flight.flight.createdAt).getTime() + : Date.now(), + }, + commitPublication: async (appAuthToken, publishedTokenset, metadata) => { + const completed = await completeOpenIDRefreshFlight({ + key, + ownerId: flight.ownerId, + tokens: { + tokenset: publishedTokenset, + claims: { sub: openidSubject ?? user.openidId ?? userId }, + openidIssuer: openidIssuer ?? user.openidIssuer, + expires_at: publishedTokenset.expires_at, + appAuthToken, + ...metadata, + }, + }); + if (!completed) { + throw createOpenIDRefreshOwnershipError( + 'OpenID authentication publication was revoked before completion', + ); + } + markLeaseSettled(); + }, + }), + }); + } + } + if (assertLeaseOwned) { + await assertLeaseOwned(); + } + if (typeof req?.session?.reload === 'function') { + const reload = req.session.reload.bind(req.session); + await new Promise((resolve, reject) => { + reload((error?: Error | null) => (error ? reject(error) : resolve())); + }); + } + let effectiveTokenset = tokenset; + let effectiveExistingRefreshToken = existingRefreshToken; + let usesAdvancedSession = false; + const currentSessionTokens = req?.session?.openidTokens; + const proposedRefreshToken = tokenset.refresh_token || existingRefreshToken; + const refreshTokenAdvanced = Boolean( + currentSessionTokens?.refreshToken && + !rejectedRefreshTokens.includes(currentSessionTokens.refreshToken) && + currentSessionTokens.refreshToken !== existingRefreshToken && + currentSessionTokens.refreshToken !== proposedRefreshToken, + ); + const candidatePredecessorAccessToken = + predecessorAccessToken ?? tokenset.__predecessorAccessToken; + const accessTokenAdvanced = Boolean( + candidatePredecessorAccessToken && + currentSessionTokens?.accessToken && + currentSessionTokens.accessToken !== candidatePredecessorAccessToken && + currentSessionTokens.accessToken !== tokenset.access_token, + ); + if ((refreshTokenAdvanced || accessTokenAdvanced) && currentSessionTokens) { + if (!currentSessionTokens.accessToken) { + throw new Error('OpenID refresh result was superseded by an incomplete session state'); + } + logger.debug( + '[refreshController] Using the advanced session instead of a stale flight result', + ); + effectiveExistingRefreshToken = currentSessionTokens.refreshToken; + effectiveTokenset = { + access_token: currentSessionTokens.accessToken, + id_token: currentSessionTokens.idToken, + refresh_token: currentSessionTokens.refreshToken, + expires_at: currentSessionTokens.accessTokenExpiresAt, + }; + usesAdvancedSession = true; + } + const advancedSessionGeneration = usesAdvancedSession + ? (() => { + const key = currentSessionTokens?.publicationFlightKey; + const ownerId = currentSessionTokens?.publicationFlightOwnerId; + const createdAt = currentSessionTokens?.publicationFlightCreatedAt; + if (!key && !ownerId) return undefined; + if (!key || !ownerId) { + throw createOpenIDRefreshOwnershipError( + 'OpenID advanced session publication generation is incomplete', + ); + } + return { key, ownerId, createdAt }; + })() + : undefined; + if (advancedSessionGeneration) { + await assertOpenIDRefreshSessionGenerationAvailable(advancedSessionGeneration); + } + const effectiveSessionGeneration = advancedSessionGeneration ?? publicationGeneration; + const acceptedSessionIdentity: AuthIdentityContext = usesAdvancedSession + ? { + appUserId: currentSessionTokens?.appUserId ?? userId, + openidSubject: + currentSessionTokens?.openidSubject ?? openidSubject ?? user.openidId ?? userId, + tenantId: currentSessionTokens?.tenantId ?? user.tenantId, + openidIssuer: currentSessionTokens?.openidIssuer ?? openidIssuer ?? user.openidIssuer, + } + : { + appUserId: userId, + openidSubject: openidSubject ?? user.openidId ?? userId, + tenantId: user.tenantId, + openidIssuer: openidIssuer ?? user.openidIssuer, + }; + const acceptedBridgeIdentity = { + userId: acceptedSessionIdentity.appUserId ?? userId, + tenantId: acceptedSessionIdentity.tenantId, + openidIssuer: acceptedSessionIdentity.openidIssuer, + }; + const assertSettledPublicationAvailable = async (): Promise => { + if (publicationGeneration) { + await assertOpenIDRefreshFlightAvailable(publicationGeneration); + } + if ( + advancedSessionGeneration && + (advancedSessionGeneration.key !== publicationGeneration?.key || + advancedSessionGeneration.ownerId !== publicationGeneration?.ownerId) + ) { + await assertOpenIDRefreshSessionGenerationAvailable(advancedSessionGeneration); + } + }; + const nextRefreshToken = effectiveTokenset.refresh_token || effectiveExistingRefreshToken; + if (!nextRefreshToken) { + throw new Error('OpenID refresh returned no refresh token'); + } + + let authTokenset = effectiveTokenset; + const effectiveExpiresAt = effectiveTokenset.expires_at; + if (effectiveTokenset.expires_in == null && Number.isFinite(effectiveExpiresAt)) { + authTokenset = { + ...effectiveTokenset, + expires_in: Math.max(0, Math.floor((effectiveExpiresAt as number) - Date.now() / 1000)), + }; + } + const preparedAppAuthToken = getOpenIDAppAuthToken( + authTokenset, + req.session?.openidTokens?.idToken, + ); + if (!preparedAppAuthToken) { + throw new Error('OpenID refresh returned no application authentication token'); + } + let bridgeVersion: string | null = null; + const rotated = + !!effectiveExistingRefreshToken && nextRefreshToken !== effectiveExistingRefreshToken; + + if (preparePublication) { + if (assertLeaseOwned) { + await assertLeaseOwned(); + } + try { + await storeOpenIDSession( + acceptedSessionIdentity.appUserId ?? userId, + nextRefreshToken, + acceptedSessionIdentity.tenantId, + effectiveExistingRefreshToken, + ); + } catch (error) { + if (rotated && effectiveExistingRefreshToken) { + try { + await storeRefreshTokenBridge({ + oldRefreshToken: effectiveExistingRefreshToken, + newRefreshToken: nextRefreshToken, + ...acceptedBridgeIdentity, + ttl: bridgeGraceMs, + }); + } catch (bridgeError) { + logger.warn( + '[refreshController] Failed to preserve a rotated token after durable-session failure', + toOpenIDLogArgument(bridgeError), + ); + } + } + throw error; + } + + if (rotated && effectiveExistingRefreshToken) { + try { + bridgeVersion = await storeRefreshTokenBridge({ + oldRefreshToken: effectiveExistingRefreshToken, + newRefreshToken: nextRefreshToken, + ...acceptedBridgeIdentity, + ttl: bridgeGraceMs, + }); + } catch (bridgeError) { + logger.warn( + '[refreshController] Failed to store the publication recovery bridge', + toOpenIDLogArgument(bridgeError), + ); + } + } + } + + try { + if (assertLeaseOwned) { + await assertLeaseOwned(); + } + await commitPublication?.(preparedAppAuthToken, authTokenset, { + predecessorAccessToken: candidatePredecessorAccessToken, + acceptedIdentity: acceptedSessionIdentity, + }); + await assertSettledPublicationAvailable(); + + const publishedAppAuthToken = setOpenIDAuthTokens(authTokenset, req, res, { + userId: acceptedSessionIdentity.appUserId ?? userId, + existingRefreshToken: effectiveExistingRefreshToken, + tenantId: acceptedSessionIdentity.tenantId, + openidSubject: acceptedSessionIdentity.openidSubject, + openidIssuer: acceptedSessionIdentity.openidIssuer, + }); + if (req.session?.openidTokens && effectiveSessionGeneration) { + req.session.openidTokens.publicationFlightKey = effectiveSessionGeneration.key; + req.session.openidTokens.publicationFlightOwnerId = effectiveSessionGeneration.ownerId; + req.session.openidTokens.publicationFlightCreatedAt = effectiveSessionGeneration.createdAt; + } + await assertSettledPublicationAvailable(); + if (publishedAppAuthToken !== preparedAppAuthToken) { + throw new Error('OpenID authentication publication returned an inconsistent token'); + } + return publishedAppAuthToken; + } catch (error) { + if (!isOpenIDRefreshOwnershipError(error)) { + logger.warn( + '[refreshController] Keeping the prepared successor after an indeterminate publication failure', + toOpenIDLogArgument(error), + ); + throw error; + } + try { + await deleteOpenIDSession(nextRefreshToken); + } catch (cleanupError) { + logger.warn( + '[refreshController] Failed to remove prepared session after publication failure', + toOpenIDLogArgument(cleanupError), + ); + } + if (bridgeVersion && effectiveExistingRefreshToken) { + try { + await deleteRefreshTokenBridges({ + refreshTokens: [effectiveExistingRefreshToken], + userId: acceptedBridgeIdentity.userId, + tenantId: acceptedBridgeIdentity.tenantId, + version: bridgeVersion, + }); + } catch (cleanupError) { + logger.warn( + '[refreshController] Failed to remove the owned bridge after publication revocation', + toOpenIDLogArgument(cleanupError), + ); + } + } + clearOpenIDAuthTokens( + req, + res, + acceptedSessionIdentity.appUserId ?? userId, + acceptedSessionIdentity.tenantId, + ); + throw error; + } + } + + return { + recoverOpenIDRefreshBridge, + revokeOpenIDRefreshTokenChain, + refreshOpenIDUser, + resolveOpenIDRefreshResult, + sendOpenIDAuthResponse, + __internals: { getTokenClaims, seedRefreshSession }, + }; +} diff --git a/packages/api/src/auth/openid/session.ts b/packages/api/src/auth/openid/session.ts new file mode 100644 index 0000000000..a402874e00 --- /dev/null +++ b/packages/api/src/auth/openid/session.ts @@ -0,0 +1,2043 @@ +import type { + AuthIdentityContext, + AuthIdentitySource, + AuthIdentityTuple, + LeaseAssertion, + LeaseContext, + OIDCTokens, + OpenIDClaims, + OpenIDLogger, + OpenIDRequest, + OpenIDResponse, + OpenIDPublicationGeneration, + OpenIDSessionIdentitySource, + OpenIDTokenSet, + OpenIDUser, + RefreshFlightAcquireResult, + RefreshFlightRecord, + RefreshKeyInput, + RefreshTokenBridgeDeleteInput, + RefreshTokenBridgeIdentity, + RefreshTokenBridgeInput, + SessionOpenIDTokens, + TokenPreference, +} from './types'; +import type { OpenIdSessionDeps, OpenIdSessionParams } from '~/images/session'; +import type { TokenResult } from './flight'; +import { + createOpenIDRefreshOwnershipError, + isOpenIDRefreshOwnershipError, + toOpenIDLogArgument, +} from './errors'; + +interface OpenIDSessionRefreshDeps { + jwt: { + decode: (token: string) => (Partial & { exp?: number }) | string | null; + verify: (token: string, secret: string) => { id?: string; refreshTokenHash?: string } | string; + }; + cookies: { parse: (header: string) => Record }; + crypto: { + createHash: (algorithm: string) => { + update: (value: string) => { digest: (encoding: 'hex' | 'base64url') => string }; + }; + }; + openIdClient: { + refreshTokenGrant: ( + config: object, + refreshToken: string, + params: Record, + ) => Promise; + }; + logger: OpenIDLogger; + defaultRefreshTokenExpiry: number; + isEnabled: (value?: string) => boolean; + math: (value: string | undefined, fallback: number) => number; + createAuthIdentityContext: (args: { + user?: AuthIdentitySource | null; + requestUser?: AuthIdentitySource | null; + tenantId?: string; + openidIssuer?: string; + }) => AuthIdentityContext; + isOpenIDSessionIdentityMatch: ( + session: OpenIDSessionIdentitySource, + expected: OpenIDSessionIdentitySource, + ) => boolean; + createOpenIDRefreshIdentityTuple: (args: { + user?: AuthIdentitySource | null; + requestUser?: AuthIdentitySource | null; + }) => AuthIdentityTuple | null; + createRefreshTokenBridgeIdentity: (args: { + user?: AuthIdentitySource | null; + requestUser?: AuthIdentitySource | null; + userId?: string; + tenantId?: string; + openidIssuer?: string; + }) => RefreshTokenBridgeIdentity | null; + serializeAuthIdentityTuple: (tuple: AuthIdentityTuple) => string; + buildOpenIDRefreshParams: () => Record; + setRefreshTokenCookie: (res: OpenIDResponse, token: string, expires: Date) => void; + setOpenIDMarkerCookies: ( + res: OpenIDResponse, + args: { + userId?: string; + expires: Date; + refreshExpiryMs: number; + refreshToken: string; + }, + ) => void; + storeOpenIdSession: (data: OpenIdSessionParams, methods: OpenIdSessionDeps) => Promise; + normalizeExpiresIn: (value?: number | string) => number | undefined; + upsertSession: OpenIdSessionDeps['upsertSession']; + deleteSession: OpenIdSessionDeps['deleteSession']; + getOpenIdConfig: () => object; + OPENID_REFRESH_BRIDGE_GRACE_MS: number; + storeRefreshTokenBridge: (input: RefreshTokenBridgeInput) => Promise; + deleteRefreshTokenBridges: (input: RefreshTokenBridgeDeleteInput) => Promise; + acquireOpenIDRefreshFlight: (args: { + key?: string | null; + }) => Promise; + completeOpenIDRefreshFlight: (args: { + key?: string | null; + ownerId?: string; + tokens?: TokenResult | null; + }) => Promise; + createOpenIDRefreshFlightKey: (input: RefreshKeyInput) => string | null; + failOpenIDRefreshFlight: (args: { + key?: string | null; + ownerId?: string; + error?: Error | null; + }) => Promise; + waitForOpenIDRefreshFlight: (args: { key?: string | null }) => Promise; + assertOpenIDRefreshFlightAvailable: (args: { + key?: string | null; + ownerId?: string; + }) => Promise; + assertOpenIDRefreshSessionGenerationAvailable: (args: { + key?: string | null; + ownerId?: string; + }) => Promise; + withOpenIDRefreshFlightLease: (args: { + key?: string | null; + ownerId?: string; + operation: (context: LeaseContext) => Promise; + }) => Promise; +} + +interface MarkedOIDCTokens extends OIDCTokens { + __browserRefreshToken?: string; + __identityClaims?: OpenIDClaims; + __predecessorRefreshToken?: string; + __predecessorAccessToken?: string; + __deferredPublication?: boolean; + __flightOwnerId?: string; + __flightCreatedAt?: number; + __identityIdToken?: string; +} + +interface RefreshSessionOptions { + forceRefresh?: boolean; + assertLeaseOwned?: LeaseAssertion; + deferPublication?: boolean; +} + +interface SessionPublicationEffects { + durableSession: boolean; + browserCookies: boolean; + expressSession: boolean; + bridge?: { + version: string; + predecessorRefreshToken: string; + identity: RefreshTokenBridgeIdentity; + }; +} + +interface CreateOpenIDSessionTokenProviderInput { + req?: OpenIDRequest; + res?: OpenIDResponse; + user?: OpenIDUser; + tokenPreference: TokenPreference; + identityContext?: AuthIdentityContext; +} + +export interface OpenIDSessionRefreshService { + createOpenIDSessionTokenProvider: ( + input: CreateOpenIDSessionTokenProviderInput, + ) => () => Promise; + refreshOpenIDSession: ( + req: OpenIDRequest, + res: OpenIDResponse | undefined, + user: OpenIDUser, + tokenPreference: TokenPreference, + identityContext?: AuthIdentityContext, + options?: RefreshSessionOptions, + ) => Promise; + __internals: { + UPSTREAM_TOKEN_EXPIRY_BUFFER_SECONDS: number; + inFlightRefreshes: Map>; + getSingleFlightKey: ( + req: OpenIDRequest, + user: OpenIDUser, + identityContext?: AuthIdentityContext, + ) => string | null; + isLiveSessionTokenStillValid: ( + sessionTokens: SessionOpenIDTokens, + tokenPreference: TokenPreference, + ) => boolean; + getAccessTokenExp: (sessionTokens: SessionOpenIDTokens) => number | null; + }; +} + +/** + * OpenID session refresh implementation. Runtime-only Express, model, and strategy dependencies + * are supplied by the thin /api wrapper; the authentication and coordination logic lives here. + */ +export function createOpenIDSessionRefreshService( + deps: OpenIDSessionRefreshDeps, +): OpenIDSessionRefreshService { + const { + jwt, + cookies, + crypto, + openIdClient, + logger, + defaultRefreshTokenExpiry: DEFAULT_REFRESH_TOKEN_EXPIRY, + isEnabled, + math, + createAuthIdentityContext, + isOpenIDSessionIdentityMatch, + createOpenIDRefreshIdentityTuple, + createRefreshTokenBridgeIdentity, + serializeAuthIdentityTuple, + buildOpenIDRefreshParams, + setRefreshTokenCookie, + setOpenIDMarkerCookies, + storeOpenIdSession, + normalizeExpiresIn, + upsertSession, + deleteSession, + getOpenIdConfig, + OPENID_REFRESH_BRIDGE_GRACE_MS, + storeRefreshTokenBridge, + deleteRefreshTokenBridges, + acquireOpenIDRefreshFlight, + completeOpenIDRefreshFlight, + createOpenIDRefreshFlightKey, + failOpenIDRefreshFlight, + waitForOpenIDRefreshFlight, + assertOpenIDRefreshFlightAvailable, + assertOpenIDRefreshSessionGenerationAvailable, + withOpenIDRefreshFlightLease, + } = deps; + + /** + * Shape of `req.session.openidTokens`. Established by `setOpenIDAuthTokens` + * (`api/server/services/AuthService.js`) on login/refresh, mutated in place by + * this module on inline refresh, and consumed by `refreshController` and + * `LogoutController`. Distinct from the snake_case `OIDCTokens` type in + * `@librechat/data-schemas` (which describes `IUser.federatedTokens` / + * `IUser.openidTokens` β€” model fields, not the express-session field). + * + * Express-session's SessionData is open by design, so this contract lives in + * comments rather than a TS interface; keep this and AuthService.js in sync + * when the shape changes. + * + * @typedef {Object} SessionOpenIDTokens + * @property {string} [accessToken] β€” IdP access token (may be opaque). + * @property {string} [idToken] β€” IdP ID token (always JWT). + * @property {string} [refreshToken] β€” IdP refresh token. + * @property {string} [browserRefreshToken] β€” refresh token last known to be written to + * the browser cookie. + * @property {number} [expiresAt] β€” SESSION cookie expiry (ms). + * @property {number} [lastRefreshedAt] β€” wall-clock ms of the last server-side rotation. + * @property {string} [appUserId] β€” LibreChat user id bound to these session tokens. + * @property {string} [openidSubject] β€” OpenID `sub` bound to these session tokens. + * @property {string} [tenantId] β€” tenant bound to these session tokens. + * @property {string} [openidIssuer] β€” normalized issuer bound to these session tokens. + * @property {number} [accessTokenExpiresAt] β€” access token expiry (unix seconds), captured + * from the IdP `tokenset.expires_in` so opaque + * access tokens can still be reused without + * redundant refreshes. + * @property {string} [publicationFlightKey] β€” durable publication key authorizing this state. + * @property {string} [publicationFlightOwnerId] β€” exact completed generation for that key. + */ + + /** + * Skew buffer for the upstream access-token expiry check. Mirrors + * `OPENID_REUSE_EXPIRY_BUFFER_SECONDS` in `AuthController.js` so that a token + * which the controller is about to rotate also triggers an inline refresh here. + */ + const UPSTREAM_TOKEN_EXPIRY_BUFFER_SECONDS = 30; + const INTERNAL_BROWSER_REFRESH_TOKEN_FIELD = '__browserRefreshToken'; + const INTERNAL_PREDECESSOR_REFRESH_TOKEN_FIELD = '__predecessorRefreshToken'; + const INTERNAL_PREDECESSOR_ACCESS_TOKEN_FIELD = '__predecessorAccessToken'; + const INTERNAL_DEFERRED_PUBLICATION_FIELD = '__deferredPublication'; + const INTERNAL_IDENTITY_ID_TOKEN_FIELD = '__identityIdToken'; + const IDENTITY_PART_SEPARATOR = '\x1f'; + + /** + * In-flight upstream refreshes keyed by `getSingleFlightKey(req, user, identityContext)` β€” + * a composite of `tenantId:openidIssuer:subject:refreshTokenHash`. + * See that helper for the rationale on why each component is needed; in short, + * tenant+issuer keying prevents cross-tenant token crossover when distinct users + * share an IdP `sub`, and refresh-token keying makes every request holding the same + * rotating credential join the same logical grant across sessions and replicas. + * + * A fan-out of tool calls carrying the same expired credential coalesces into + * one IdP refresh-token grant. Mirrors the + * single-flight pattern in `OboTokenService.js`. + * + * Process-local coalescing is backed by a renewable Mongo lease in + * `performIdpRefresh`, so distinct workers do not admit parallel rotating-token + * grants for the same key. + */ + const inFlightRefreshes = new Map>(); + + /** + * Returns the single-flight key for a refresh attempt, composed from the user's + * tenant (if any), the IdP issuer + sub, and the current refresh token. + * Tightening past `openidId` alone serves two purposes: + * + * 1. Same credential, multiple Express sessions: every holder joins one grant, + * so token rotation cannot admit duplicate IdP refreshes merely because an + * Express session expired or a request landed on another replica. + * 2. Multi-tenant deployments where two distinct users share an IdP `sub` + * (different issuers, same sub): tenant + issuer disambiguates them so + * tokens never cross tenant boundaries via shared in-flight Promises. + * + * Concurrent tool calls inside the SAME session with the SAME refresh token + * still coalesce β€” the common case the single-flight is designed for (a fan-out + * of MCP tool calls in one agent run) is unaffected. + * + * Returns null when there's no usable identity at all; callers fall through + * to a non-coalesced refresh, which is safe but missing the optimization. + */ + function getSingleFlightKey( + req: OpenIDRequest, + user: OpenIDUser, + identityContext?: AuthIdentityContext, + ): string | null { + const identitySource = identityContext + ? { + id: identityContext.appUserId, + openidId: identityContext.openidSubject, + tenantId: identityContext.tenantId, + openidIssuer: identityContext.openidIssuer, + } + : user; + const tuple = createOpenIDRefreshIdentityTuple({ + user: identitySource, + requestUser: req?.user, + }); + const refreshToken = req?.session?.openidTokens?.refreshToken; + if (!tuple || !refreshToken) { + return null; + } + const refreshTokenHash = crypto.createHash('sha256').update(refreshToken).digest('hex'); + return [serializeAuthIdentityTuple(tuple), refreshTokenHash].join(IDENTITY_PART_SEPARATOR); + } + + /** + * Returns a short SHA-256 prefix of the single-flight key for use in logs. + * Preserves correlation across "started" / "joined" / "completed" log events + * for the same refresh attempt without leaking the underlying values: + * + * - refresh-token hashes are still credential-derived and remain private. + * - openidId (the IdP `sub`) and openidIssuer are tenant/user fingerprints. + * + * 12 hex chars = 48 bits of entropy: ~7Γ—10^14 distinct keys before a 50% + * collision chance β€” more than enough for correlating concurrent refreshes. + */ + function hashKeyForLogs(key: string): string { + return crypto.createHash('sha256').update(key).digest('hex').slice(0, 12); + } + + function resolveExpectedOpenIDSessionIdentity( + req: OpenIDRequest, + user: OpenIDUser, + identityContext?: AuthIdentityContext, + ): AuthIdentityContext { + if (!identityContext) { + return createAuthIdentityContext({ + user, + requestUser: req?.user, + }); + } + + return createAuthIdentityContext({ + user: { + id: identityContext.appUserId, + openidId: identityContext.openidSubject, + tenantId: identityContext.tenantId, + openidIssuer: identityContext.openidIssuer, + }, + requestUser: user ?? req?.user, + tenantId: identityContext.tenantId, + openidIssuer: identityContext.openidIssuer, + }); + } + + function hasAnyOpenIDSessionIdentity(sessionTokens: SessionOpenIDTokens): boolean { + const identityFields: Array = [ + 'appUserId', + 'openidSubject', + 'tenantId', + 'openidIssuer', + ]; + return identityFields.some((field) => sessionTokens?.[field] != null); + } + + function canBindLegacyOpenIDSession( + req: OpenIDRequest, + sessionTokens: SessionOpenIDTokens, + expectedIdentity: AuthIdentityContext, + ): boolean { + if ( + hasAnyOpenIDSessionIdentity(sessionTokens) || + !expectedIdentity.appUserId || + !expectedIdentity.openidSubject || + !process.env.JWT_REFRESH_SECRET + ) { + return false; + } + + const parsedCookies = req?.headers?.cookie ? cookies.parse(req.headers.cookie) : {}; + const browserRefreshToken = parsedCookies.refreshToken; + const expectedBrowserRefreshToken = + sessionTokens.browserRefreshToken || sessionTokens.refreshToken; + if ( + !browserRefreshToken || + !expectedBrowserRefreshToken || + browserRefreshToken !== expectedBrowserRefreshToken || + !parsedCookies.openid_user_id + ) { + return false; + } + + try { + const marker = jwt.verify(parsedCookies.openid_user_id, process.env.JWT_REFRESH_SECRET); + if ( + typeof marker !== 'object' || + marker == null || + marker.id !== expectedIdentity.appUserId || + typeof marker.refreshTokenHash !== 'string' + ) { + return false; + } + const refreshTokenHash = crypto + .createHash('sha256') + .update(browserRefreshToken) + .digest('base64url'); + return marker.refreshTokenHash === refreshTokenHash; + } catch { + return false; + } + } + + function assertOpenIDSessionIdentityMatch( + req: OpenIDRequest, + user: OpenIDUser, + identityContext?: AuthIdentityContext, + ): Promise | undefined { + const sessionTokens = req?.session?.openidTokens; + if (!sessionTokens) { + return; + } + + const expectedIdentity = resolveExpectedOpenIDSessionIdentity(req, user, identityContext); + if (isOpenIDSessionIdentityMatch(sessionTokens, expectedIdentity)) { + return; + } + + /** + * Sessions minted before identity stamping was deployed have none of these + * fields. During a rolling upgrade, bind that legacy record only when the + * signed browser marker proves the current app user and refresh-token cookie + * are the ones that created it. Partial or unverifiable metadata still fails + * closed, preventing cross-user token adoption. + */ + if (canBindLegacyOpenIDSession(req, sessionTokens, expectedIdentity)) { + Object.assign(sessionTokens, expectedIdentity); + return persistSession(req).then(() => { + logger.info('[OpenIDSessionRefresh] Bound verified legacy OpenID session identity', { + userId: expectedIdentity.appUserId, + }); + }); + } + + logger.warn('[OpenIDSessionRefresh] OpenID session token identity mismatch; refusing reuse', { + userId: expectedIdentity.appUserId, + has_session_user_id: Boolean(sessionTokens.appUserId), + has_session_subject: Boolean(sessionTokens.openidSubject), + has_session_issuer: Boolean(sessionTokens.openidIssuer), + }); + throw new Error('OpenID session token identity mismatch'); + } + + function decodeJwtExp(token?: string): number | null { + if (typeof token !== 'string' || token.length === 0) { + return null; + } + try { + const decoded = jwt.decode(token); + if (!decoded || typeof decoded !== 'object') { + return null; + } + return typeof decoded.exp === 'number' ? decoded.exp : null; + } catch (error) { + logger.debug( + '[OpenIDSessionRefresh] JWT decode failed (non-fatal)', + (error as Error)?.message, + ); + return null; + } + } + + /** + * Returns the access token's expiry in unix seconds, preferring the JWT `exp` + * claim and falling back to the persisted `accessTokenExpiresAt` written from + * the IdP's `tokenset.expires_in` on the previous refresh. + * + * The fallback exists because some IdPs (Microsoft Entra for Graph audiences, + * Auth0 without a custom audience) issue OPAQUE access tokens whose expiry + * cannot be decoded locally. Without this lookup, every OBO call would treat + * the session as expired and burn an IdP refresh, risking refresh-token + * rotation thrash under concurrent tool calls. + * + * @param {{ accessToken?: string, accessTokenExpiresAt?: number }} sessionTokens + * @returns {number | null} unix seconds, or null when no source proves an expiry + */ + function getAccessTokenExp(sessionTokens: SessionOpenIDTokens): number | null { + const fromJwt = decodeJwtExp(sessionTokens?.accessToken); + if (fromJwt != null) { + return fromJwt; + } + const persisted = sessionTokens?.accessTokenExpiresAt; + return typeof persisted === 'number' ? persisted : null; + } + + function canWriteRefreshTokenCookie(res?: OpenIDResponse): res is OpenIDResponse & { + cookie: NonNullable; + } { + return !!res && typeof res.cookie === 'function' && !res.headersSent; + } + + /** + * Returns true when the session token nominated by `tokenPreference` is still + * valid for at least the skew buffer. Required argument (no default) so every + * caller is explicit about which token's freshness gates this check. + * + * Use 'access_token' for OBO and any flow whose downstream sends the access + * token to the IdP as an assertion (jwt-bearer / on-behalf-of) β€” those flows + * fail when the access token is expired even if the id_token is still fresh. + * Access-token expiry is read via `getAccessTokenExp`, which handles opaque + * (non-JWT) tokens by falling back to the persisted `accessTokenExpiresAt`. + * + * Use 'id_token' for flows whose downstream is the LibreChat backend itself + * (e.g. session-token reuse in `refreshController`); the id_token is the + * standard JWT signed for the client_id audience and is the bearer the SPA + * sends back to LibreChat. + * + * @param {{ accessToken?: string, idToken?: string, accessTokenExpiresAt?: number }} sessionTokens + * @param {'access_token' | 'id_token'} tokenPreference + */ + function isLiveSessionTokenStillValid( + sessionTokens: SessionOpenIDTokens, + tokenPreference: TokenPreference, + ): boolean { + if (tokenPreference !== 'access_token' && tokenPreference !== 'id_token') { + throw new Error( + `[OpenIDSessionRefresh] tokenPreference must be 'access_token' or 'id_token', got: ${tokenPreference}`, + ); + } + const now = Math.floor(Date.now() / 1000); + const exp = + tokenPreference === 'access_token' + ? getAccessTokenExp(sessionTokens) + : decodeJwtExp(sessionTokens?.idToken); + return exp != null && exp > now + UPSTREAM_TOKEN_EXPIRY_BUFFER_SECONDS; + } + + /** + * Builds the OIDCTokens shape consumed by `resolveOboToken`. Required + * `tokenPreference` selects which token's expiry becomes `expires_at` β€” + * caller intent must match what the downstream consumer actually validates. + * `expiresAtOverride` (unix seconds) wins when the caller has an authoritative + * value such as the IdP's `tokenset.expires_in` from a fresh refresh response; + * use it after refresh so we never attribute a prior token's `exp` to a freshly + * rotated counterpart. For 'access_token', the fallback uses `getAccessTokenExp` + * so opaque tokens are handled correctly via the persisted `accessTokenExpiresAt`. + * + * @param {{ accessToken?: string, idToken?: string, refreshToken?: string, accessTokenExpiresAt?: number }} sessionTokens + * @param {'access_token' | 'id_token'} tokenPreference + * @param {number} [expiresAtOverride] β€” unix seconds (preferred when present) + */ + function buildOIDCTokensFromSession( + sessionTokens: SessionOpenIDTokens, + tokenPreference: TokenPreference, + expiresAtOverride?: number, + ): MarkedOIDCTokens { + if (tokenPreference !== 'access_token' && tokenPreference !== 'id_token') { + throw new Error( + `[OpenIDSessionRefresh] tokenPreference must be 'access_token' or 'id_token', got: ${tokenPreference}`, + ); + } + let expiresAt = expiresAtOverride; + if (expiresAt == null) { + expiresAt = + tokenPreference === 'access_token' + ? (getAccessTokenExp(sessionTokens) ?? undefined) + : (decodeJwtExp(sessionTokens?.idToken) ?? undefined); + } + return { + access_token: sessionTokens?.accessToken, + id_token: sessionTokens?.idToken, + refresh_token: sessionTokens?.refreshToken, + expires_at: expiresAt ?? undefined, + }; + } + + function resolveRefreshIdentityClaims( + tokenset: OpenIDTokenSet, + fallbackIdToken?: string, + ): OpenIDClaims | null { + if (typeof tokenset.claims === 'function') { + const claims = tokenset.claims(); + if (claims?.sub) { + return claims; + } + } + const idToken = tokenset.id_token || fallbackIdToken; + const decoded = idToken ? jwt.decode(idToken) : null; + if (!decoded || typeof decoded !== 'object' || typeof decoded.sub !== 'string') { + return null; + } + return decoded as OpenIDClaims; + } + + function attachBrowserRefreshTokenMarker( + tokens: T, + browserRefreshToken?: string, + ): T { + if (!tokens || !browserRefreshToken) { + return tokens; + } + Object.defineProperty(tokens, INTERNAL_BROWSER_REFRESH_TOKEN_FIELD, { + value: browserRefreshToken, + enumerable: false, + configurable: true, + }); + return tokens; + } + + function getBrowserRefreshTokenMarker(tokens: MarkedOIDCTokens): string | null { + const browserRefreshToken = tokens?.[INTERNAL_BROWSER_REFRESH_TOKEN_FIELD]; + return typeof browserRefreshToken === 'string' && browserRefreshToken + ? browserRefreshToken + : null; + } + + function attachIdentityIdTokenMarker( + tokens: T, + idToken?: string, + ): T { + if (!tokens || !idToken) return tokens; + Object.defineProperty(tokens, INTERNAL_IDENTITY_ID_TOKEN_FIELD, { + value: idToken, + enumerable: false, + configurable: true, + }); + return tokens; + } + + function attachPredecessorRefreshTokenMarker( + tokens: T, + predecessorRefreshToken?: string, + ): T { + if (!tokens || !predecessorRefreshToken) return tokens; + Object.defineProperty(tokens, INTERNAL_PREDECESSOR_REFRESH_TOKEN_FIELD, { + value: predecessorRefreshToken, + enumerable: false, + configurable: true, + }); + return tokens; + } + + function attachPredecessorAccessTokenMarker( + tokens: T, + predecessorAccessToken?: string, + ): T { + if (!tokens || !predecessorAccessToken) return tokens; + Object.defineProperty(tokens, INTERNAL_PREDECESSOR_ACCESS_TOKEN_FIELD, { + value: predecessorAccessToken, + enumerable: false, + configurable: true, + }); + return tokens; + } + + function attachDeferredPublicationMarker( + tokens: T, + deferred: boolean, + ): T { + if (!tokens || !deferred) return tokens; + Object.defineProperty(tokens, INTERNAL_DEFERRED_PUBLICATION_FIELD, { + value: true, + enumerable: false, + configurable: true, + }); + return tokens; + } + + function attachFlightOwnerMarker( + tokens: T, + ownerId?: string, + createdAt?: number, + ): T { + if (!tokens || !ownerId) return tokens; + Object.defineProperty(tokens, '__flightOwnerId', { + value: ownerId, + enumerable: false, + configurable: true, + }); + if (Number.isFinite(createdAt)) { + Object.defineProperty(tokens, '__flightCreatedAt', { + value: createdAt, + enumerable: false, + configurable: true, + }); + } + return tokens; + } + + function getPredecessorRefreshTokenMarker(tokens: MarkedOIDCTokens): string | null { + const predecessor = tokens?.[INTERNAL_PREDECESSOR_REFRESH_TOKEN_FIELD]; + return typeof predecessor === 'string' && predecessor ? predecessor : null; + } + + function cloneResolvedTokens(tokens: MarkedOIDCTokens): MarkedOIDCTokens { + const clone = { ...tokens }; + attachBrowserRefreshTokenMarker(clone, getBrowserRefreshTokenMarker(tokens) ?? undefined); + attachPredecessorRefreshTokenMarker( + clone, + getPredecessorRefreshTokenMarker(tokens) ?? undefined, + ); + attachPredecessorAccessTokenMarker(clone, tokens.__predecessorAccessToken); + attachDeferredPublicationMarker(clone, tokens.__deferredPublication === true); + attachFlightOwnerMarker(clone, tokens.__flightOwnerId, tokens.__flightCreatedAt); + attachIdentityIdTokenMarker(clone, tokens.__identityIdToken); + return clone; + } + + function hasSessionAdvancedPastResult( + existing: SessionOpenIDTokens, + resolvedTokens: MarkedOIDCTokens, + predecessorOverride?: string, + ): boolean { + const predecessorRefreshToken = + getPredecessorRefreshTokenMarker(resolvedTokens) ?? predecessorOverride; + const refreshTokenAdvanced = Boolean( + predecessorRefreshToken && + existing.refreshToken && + existing.refreshToken !== predecessorRefreshToken && + existing.refreshToken !== resolvedTokens.refresh_token, + ); + const predecessorAccessToken = resolvedTokens.__predecessorAccessToken; + const accessTokenAdvanced = Boolean( + predecessorAccessToken && + existing.accessToken && + existing.accessToken !== predecessorAccessToken && + existing.accessToken !== resolvedTokens.access_token, + ); + return refreshTokenAdvanced || accessTokenAdvanced; + } + + async function persistSession(req: OpenIDRequest): Promise { + if (typeof req?.session?.save !== 'function') { + return; + } + const save = req.session.save.bind(req.session); + await new Promise((resolve, reject) => { + save((err?: Error | null) => { + if (err) { + reject(err); + } else { + resolve(); + } + }); + }); + } + + /** + * Writes the rotated refresh token and OpenID marker cookies to the browser so + * they stay in sync with the session copy. These cookies outlive the shorter + * express-session cookie and are the fallback `refreshController` reads when + * the session is gone; without this sync an OBO-triggered rotation would leave + * stale or mismatched cookies and sign the user out on the next refresh. + * + * When no cookie-capable response is available, or `res.headersSent` is true + * (streaming SSE path), the cookie cannot be set. In this case, store a + * server-side recovery bridge so that if the session is later lost, + * `refreshController` can look up the rotated token by hash of the stale cookie + * token. + * + * @param {object} args + * @param {import('express').Response} [args.res] + * @param {string} args.newRefreshToken β€” the rotated token to sync + * @param {string} [args.oldRefreshToken] β€” the browser-cookie token to bridge from + * @param {string} [args.previousSessionRefreshToken] β€” durable session token to revoke + * @param {string} [args.userId] β€” user._id (required for bridge verification) + * @param {string} [args.tenantId] β€” user.tenantId (optional, verified on bridge lookup) + * @param {string} [args.openidIssuer] β€” user.openidIssuer (optional, verified on bridge lookup) + */ + async function syncRefreshTokenCookie({ + res, + newRefreshToken, + oldRefreshToken, + previousSessionRefreshToken, + userId, + tenantId, + openidIssuer, + assertLeaseOwned, + }: { + res?: OpenIDResponse; + newRefreshToken: string; + oldRefreshToken?: string; + previousSessionRefreshToken?: string; + userId?: string; + tenantId?: string; + openidIssuer?: string; + assertLeaseOwned?: LeaseAssertion; + }): Promise { + if (assertLeaseOwned) { + await assertLeaseOwned(); + } + + if (canWriteRefreshTokenCookie(res)) { + const expiryInMilliseconds = math( + process.env.REFRESH_TOKEN_EXPIRY, + DEFAULT_REFRESH_TOKEN_EXPIRY, + ); + const expirationDate = new Date(Date.now() + expiryInMilliseconds); + /** + * The durable Session record is what authorizes local image access for OpenID users + * (`authenticateRequest` in `packages/api/src/images/authorization.ts` looks it up by the + * browser's refresh-token cookie). The cookie just moved to the rotated token, so the record + * has to move with it β€” otherwise every image request 401s until the next `/refresh`. + * The bridge branch below deliberately leaves the record alone: there the browser keeps the + * old cookie, and `refreshController` rewrites both once it recovers through the bridge. + */ + if (userId) { + try { + await storeOpenIdSession( + { + userId, + refreshToken: newRefreshToken, + tenantId, + previousRefreshToken: previousSessionRefreshToken ?? oldRefreshToken, + }, + { upsertSession, deleteSession }, + ); + } catch (error) { + /** + * The durable transition is an upsert followed by deletion. If deletion fails after + * the upsert succeeds, the IdP has already spent the old token while the browser still + * carries it. Persist a short predecessor bridge before surfacing the failure so the + * next request can recover the only viable credential. + */ + await storeSessionSaveFailureBridge({ + oldRefreshToken, + newRefreshToken, + bridgeIdentity: { userId, tenantId, openidIssuer }, + assertLeaseOwned, + }); + throw error; + } + } + if (assertLeaseOwned) { + try { + await assertLeaseOwned(); + } catch (error) { + if (!isOpenIDRefreshOwnershipError(error)) { + await storeSessionSaveFailureBridge({ + oldRefreshToken, + newRefreshToken, + bridgeIdentity: userId ? { userId, tenantId, openidIssuer } : null, + }); + throw error; + } + if (userId) { + try { + await deleteSession({ refreshToken: newRefreshToken }); + } catch (cleanupError) { + logger.warn( + '[OpenIDSessionRefresh] Failed to remove the successor after ownership loss', + toOpenIDLogArgument(cleanupError), + ); + } + } + throw error; + } + } + setRefreshTokenCookie(res, newRefreshToken, expirationDate); + setOpenIDMarkerCookies(res, { + userId, + expires: expirationDate, + refreshExpiryMs: expiryInMilliseconds, + refreshToken: newRefreshToken, + }); + return null; + } + + if (oldRefreshToken && userId) { + const bridgeVersion = await storeRefreshTokenBridgeWithLease({ + oldRefreshToken, + newRefreshToken, + userId, + tenantId, + openidIssuer, + assertLeaseOwned, + }); + logger.debug('[OpenIDSessionRefresh] Stored refresh-token recovery bridge', { + userId, + responseAvailable: !!res, + headersSent: !!res?.headersSent, + hasCookieWriter: typeof res?.cookie === 'function', + }); + return bridgeVersion; + } else { + logger.warn( + '[OpenIDSessionRefresh] Cannot set refresh-token cookie and insufficient context to store bridge', + { + responseAvailable: !!res, + headersSent: !!res?.headersSent, + hasCookieWriter: typeof res?.cookie === 'function', + hasOldToken: !!oldRefreshToken, + hasUserId: !!userId, + }, + ); + } + return null; + } + + async function storeRefreshTokenBridgeWithLease({ + assertLeaseOwned, + ...bridge + }: RefreshTokenBridgeInput & { assertLeaseOwned?: LeaseAssertion }): Promise { + if (assertLeaseOwned) { + await assertLeaseOwned(); + } + const bridgeVersion = await storeRefreshTokenBridge(bridge); + if (!assertLeaseOwned) { + return bridgeVersion; + } + try { + await assertLeaseOwned(); + } catch (error) { + /** + * Only a proven ownership loss justifies removing what we just published. A coordination + * read that merely failed leaves ownership unknown, and on the headers-already-sent path + * this bridge is the only mapping from the token the browser still holds to the one the + * IdP has already rotated to β€” deleting it on a transient error signs the user out. + */ + if (!isOpenIDRefreshOwnershipError(error)) { + logger.warn( + '[OpenIDSessionRefresh] Keeping the recovery bridge; lease ownership is undetermined', + { userId: bridge.userId, error: (error as Error)?.message }, + ); + throw error; + } + try { + await deleteRefreshTokenBridges({ + refreshTokens: [bridge.oldRefreshToken], + userId: bridge.userId, + tenantId: bridge.tenantId, + ...(bridgeVersion ? { version: bridgeVersion } : {}), + }); + } catch (cleanupError) { + logger.error( + '[OpenIDSessionRefresh] Failed to remove bridge after refresh ownership loss', + toOpenIDLogArgument(cleanupError), + ); + } + throw error; + } + return bridgeVersion; + } + + async function storeSessionSaveFailureBridge({ + oldRefreshToken, + newRefreshToken, + bridgeIdentity, + assertLeaseOwned, + }: { + oldRefreshToken?: string; + newRefreshToken?: string; + bridgeIdentity?: RefreshTokenBridgeIdentity | null; + assertLeaseOwned?: LeaseAssertion; + }): Promise { + if (!oldRefreshToken || !newRefreshToken || !bridgeIdentity?.userId) { + return; + } + + try { + await storeRefreshTokenBridgeWithLease({ + oldRefreshToken, + newRefreshToken, + userId: bridgeIdentity.userId, + tenantId: bridgeIdentity.tenantId, + openidIssuer: bridgeIdentity.openidIssuer, + ttl: OPENID_REFRESH_BRIDGE_GRACE_MS, + assertLeaseOwned, + }); + logger.warn( + '[OpenIDSessionRefresh] Stored short refresh-token bridge after session save failure', + { + userId: bridgeIdentity.userId, + ttl: OPENID_REFRESH_BRIDGE_GRACE_MS, + }, + ); + } catch (bridgeError) { + logger.warn( + '[OpenIDSessionRefresh] Failed to store refresh-token bridge after session save failure', + toOpenIDLogArgument(bridgeError), + ); + } + } + + async function performIdpRefreshGrant( + req: OpenIDRequest, + res: OpenIDResponse | undefined, + user: OpenIDUser, + tokenPreference: TokenPreference, + identityContext: AuthIdentityContext | undefined, + assertLeaseOwned?: LeaseAssertion, + deferPublication = false, + ): Promise { + const sessionTokens = req?.session?.openidTokens; + const refreshToken = sessionTokens?.refreshToken; + if (!refreshToken) { + logger.debug( + '[OpenIDSessionRefresh] Session lacks refresh_token; cannot refresh upstream token', + ); + return null; + } + + const config = getOpenIdConfig(); + const refreshParams = buildOpenIDRefreshParams(); + logger.debug('[OpenIDSessionRefresh] Performing inline IdP refresh-token grant'); + const tokenset = await openIdClient.refreshTokenGrant(config, refreshToken, refreshParams); + + /** + * A rotating grant can finish after this worker's Mongo lease was reclaimed. Re-prove + * ownership before mutating the Express session, cookies, bridge, or durable session so a + * stale owner cannot publish credentials after another worker has taken over. + */ + if (assertLeaseOwned) { + await assertLeaseOwned(); + } + + if (!tokenset?.access_token) { + throw new Error('IdP refresh returned no access_token'); + } + + /** + * Preserve previous values when the IdP omits `id_token` / `refresh_token` + * on rotation (Auth0 with rotation off, MS personal accounts in some flows). + * Same fallback behavior as setOpenIDAuthTokens. + */ + const nextIdToken = tokenset.id_token || sessionTokens.idToken; + const nextRefreshToken = tokenset.refresh_token || refreshToken; + const browserRefreshToken = sessionTokens.browserRefreshToken || refreshToken; + const needsRefreshTokenSync = nextRefreshToken !== browserRefreshToken; + const willWriteRefreshTokenCookie = + !deferPublication && needsRefreshTokenSync && canWriteRefreshTokenCookie(res); + + /** + * Capture the freshly-issued access-token's expiry (unix seconds) so the + * next OBO call can reuse it without a redundant refresh β€” critical for + * opaque (non-JWT) access tokens whose expiry isn't readable from the + * token itself. Source order: + * 1. tokenset.expires_in β€” IdP's authoritative value for the new access + * token. Always preferred when present. + * 2. decodeJwtExp(tokenset.access_token) β€” only when access_token is + * itself a JWT. Decoding is a fact about THIS token, not a guess. + * + * Deliberately do NOT fall back to id_token's exp: id_token TTL is governed + * by IdP session policy and is often longer than access-token TTL. Trusting + * it would mark an opaque access token reusable past its real lifetime, so + * a stale token would be sent to the OBO IdP and rejected. When neither + * source proves an expiry, leave `accessTokenExpiresAt` unset; the next + * freshness check will correctly fall through to refresh. + */ + let nextAccessTokenExp = null; + const accessTokenExpiresIn = normalizeExpiresIn(tokenset.expires_in); + if (accessTokenExpiresIn != null) { + nextAccessTokenExp = Math.floor(Date.now() / 1000) + accessTokenExpiresIn; + } else { + nextAccessTokenExp = decodeJwtExp(tokenset.access_token); + } + /** + * `normalizeExpiresIn` preserves a zero or negative lifetime rather than discarding it, so a + * grant can succeed while declaring a credential that is already spent. Publishing it rotates + * the refresh token and hands the caller a token every freshness check rejects, which turns + * each OBO call into another rotation. An unknown expiry is not an elapsed one and still + * publishes. + */ + if (nextAccessTokenExp != null && nextAccessTokenExp <= Math.floor(Date.now() / 1000)) { + throw new Error('IdP refresh returned an already-expired access_token'); + } + + const updatedSessionTokens = { + ...sessionTokens, + accessToken: tokenset.access_token, + idToken: nextIdToken, + refreshToken: nextRefreshToken, + browserRefreshToken: willWriteRefreshTokenCookie ? nextRefreshToken : browserRefreshToken, + lastRefreshedAt: Date.now(), + }; + if (nextAccessTokenExp != null) { + updatedSessionTokens.accessTokenExpiresAt = nextAccessTokenExp; + } else { + /** Drop a stale value rather than carry it across an unknown-expiry rotation. */ + delete updatedSessionTokens.accessTokenExpiresAt; + } + + const resolvedTokens = buildOIDCTokensFromSession( + updatedSessionTokens, + tokenPreference, + nextAccessTokenExp ?? undefined, + ); + attachPredecessorAccessTokenMarker(resolvedTokens, sessionTokens.accessToken); + const identityClaims = resolveRefreshIdentityClaims(tokenset, sessionTokens.idToken); + if (identityClaims) { + resolvedTokens.__identityClaims = identityClaims; + } + const fallbackIdTokenExp = decodeJwtExp(sessionTokens.idToken); + if ( + !tokenset.id_token && + (fallbackIdTokenExp == null || + fallbackIdTokenExp <= Math.floor(Date.now() / 1000) + UPSTREAM_TOKEN_EXPIRY_BUFFER_SECONDS) + ) { + delete resolvedTokens.id_token; + /** + * The stripped token is still the only identity material this rotation left behind, and a + * rebuilt token set carries no provider `claims()`, so `getTokenClaims` would have nothing + * left to read. Keep it reachable for identity resolution without letting it back into the + * authentication response. + */ + attachIdentityIdTokenMarker(resolvedTokens, sessionTokens.idToken); + } + + if (deferPublication) { + return attachBrowserRefreshTokenMarker( + resolvedTokens, + updatedSessionTokens.browserRefreshToken, + ); + } + + /** + * Keep the browser refresh-token cookie in sync with the session token. If headers are + * already sent (SSE streaming), store a recovery bridge instead. Do this before the + * session save so a transient session-store failure cannot lose an IdP-rotated token. + */ + let bridgeIdentity = null; + if (needsRefreshTokenSync) { + bridgeIdentity = createRefreshTokenBridgeIdentity({ + user, + requestUser: req?.user, + userId: identityContext?.appUserId, + tenantId: identityContext?.tenantId, + openidIssuer: identityContext?.openidIssuer, + }); + + await syncRefreshTokenCookie({ + res, + newRefreshToken: nextRefreshToken, + oldRefreshToken: browserRefreshToken, + previousSessionRefreshToken: refreshToken, + userId: bridgeIdentity?.userId, + tenantId: bridgeIdentity?.tenantId, + openidIssuer: bridgeIdentity?.openidIssuer, + assertLeaseOwned, + }); + } + + /** Cookie/bridge synchronization may involve I/O; do not persist after losing the lease. */ + if (assertLeaseOwned) { + await assertLeaseOwned(); + } + + if (!req.session) { + throw new Error('OpenID refresh requires an Express session'); + } + req.session.openidTokens = updatedSessionTokens; + + try { + await persistSession(req); + } catch (error) { + if (needsRefreshTokenSync && willWriteRefreshTokenCookie) { + await storeSessionSaveFailureBridge({ + oldRefreshToken: browserRefreshToken, + newRefreshToken: nextRefreshToken, + bridgeIdentity, + assertLeaseOwned, + }); + } + throw error; + } + + logger.info('[OpenIDSessionRefresh] Inline refresh succeeded'); + /** + * Pass the same expiry as the explicit `expiresAtOverride` so the returned + * OIDCTokens carries it directly, regardless of token preference. After + * refresh the IdP's value is authoritative and supersedes any decode. + */ + return attachBrowserRefreshTokenMarker( + resolvedTokens, + updatedSessionTokens.browserRefreshToken, + ); + } + + async function publishResolvedSessionTokens({ + req, + res, + user, + identityContext, + resolvedTokens, + predecessorRefreshToken, + tokenPreference, + assertLeaseOwned, + publicationGeneration, + effects, + }: { + req: OpenIDRequest; + res?: OpenIDResponse; + user: OpenIDUser; + identityContext?: AuthIdentityContext; + resolvedTokens: MarkedOIDCTokens | null; + predecessorRefreshToken?: string; + tokenPreference: TokenPreference; + assertLeaseOwned?: LeaseAssertion; + publicationGeneration?: OpenIDPublicationGeneration; + effects?: SessionPublicationEffects; + }): Promise { + if (!resolvedTokens?.access_token) return null; + if (assertLeaseOwned) await assertLeaseOwned(); + if (typeof req.session?.reload === 'function') { + const reload = req.session.reload.bind(req.session); + await new Promise((resolve, reject) => { + reload((error?: Error | null) => (error ? reject(error) : resolve())); + }); + } + if (assertLeaseOwned) await assertLeaseOwned(); + const requestTokens = cloneResolvedTokens(resolvedTokens); + if ( + req.session?.openidTokens && + hasSessionAdvancedPastResult(req.session.openidTokens, requestTokens, predecessorRefreshToken) + ) { + logger.info( + '[OpenIDSessionRefresh] Skipping stale flight publication because the session advanced', + ); + await assertOpenIDRefreshSessionGenerationAvailable({ + key: req.session.openidTokens.publicationFlightKey, + ownerId: req.session.openidTokens.publicationFlightOwnerId, + }); + const effectiveTokens = buildOIDCTokensFromSession(req.session.openidTokens, tokenPreference); + attachPredecessorRefreshTokenMarker( + effectiveTokens, + getPredecessorRefreshTokenMarker(requestTokens) ?? predecessorRefreshToken, + ); + attachPredecessorAccessTokenMarker(effectiveTokens, requestTokens.__predecessorAccessToken); + return effectiveTokens; + } + const nextRefreshToken = requestTokens.refresh_token ?? predecessorRefreshToken; + const browserRefreshToken = + getBrowserRefreshTokenMarker(requestTokens) ?? predecessorRefreshToken; + if (nextRefreshToken && nextRefreshToken !== browserRefreshToken) { + const writesBrowserCookie = canWriteRefreshTokenCookie(res); + const bridgeIdentity = createRefreshTokenBridgeIdentity({ + user, + requestUser: req.user, + userId: identityContext?.appUserId, + tenantId: identityContext?.tenantId, + openidIssuer: identityContext?.openidIssuer, + }); + const bridgeVersion = await syncRefreshTokenCookie({ + res, + newRefreshToken: nextRefreshToken, + oldRefreshToken: browserRefreshToken, + previousSessionRefreshToken: predecessorRefreshToken, + userId: bridgeIdentity?.userId, + tenantId: bridgeIdentity?.tenantId, + openidIssuer: bridgeIdentity?.openidIssuer, + assertLeaseOwned, + }); + if (effects && bridgeVersion && browserRefreshToken && bridgeIdentity) { + effects.bridge = { + version: bridgeVersion, + predecessorRefreshToken: browserRefreshToken, + identity: bridgeIdentity, + }; + } + if (writesBrowserCookie) { + if (effects) { + effects.durableSession = Boolean(bridgeIdentity?.userId); + effects.browserCookies = true; + } + attachBrowserRefreshTokenMarker(requestTokens, nextRefreshToken); + } + } + if (assertLeaseOwned) await assertLeaseOwned(); + const hydrated = await hydrateSessionFromResolvedTokens( + req, + requestTokens, + predecessorRefreshToken, + false, + publicationGeneration, + ); + if (effects && hydrated) { + effects.expressSession = true; + } + if (assertLeaseOwned) await assertLeaseOwned(); + return requestTokens; + } + + async function rollbackSessionPublication( + req: OpenIDRequest, + res: OpenIDResponse | undefined, + resolvedTokens: MarkedOIDCTokens | null, + effects: SessionPublicationEffects, + successorRefreshToken?: string, + ): Promise { + if (effects.durableSession && successorRefreshToken) { + try { + await deleteSession({ refreshToken: successorRefreshToken }); + } catch (error) { + logger.warn( + '[OpenIDSessionRefresh] Failed to remove successor during publication rollback', + toOpenIDLogArgument(error), + ); + } + } + let shouldClearExpressSession = effects.expressSession; + if (shouldClearExpressSession && typeof req.session?.reload === 'function') { + try { + const reload = req.session.reload.bind(req.session); + await new Promise((resolve, reject) => { + reload((error?: Error | null) => (error ? reject(error) : resolve())); + }); + const current = req.session.openidTokens; + shouldClearExpressSession = Boolean( + current && + current.accessToken === resolvedTokens?.access_token && + current.refreshToken === successorRefreshToken, + ); + } catch { + shouldClearExpressSession = true; + } + } + if (shouldClearExpressSession && typeof req.session?.destroy === 'function') { + try { + const destroy = req.session.destroy.bind(req.session); + await new Promise((resolve, reject) => { + destroy((error?: Error | null) => (error ? reject(error) : resolve())); + }); + } catch (error) { + logger.warn( + '[OpenIDSessionRefresh] Failed to destroy Express session during publication rollback', + toOpenIDLogArgument(error), + ); + } + } else if (shouldClearExpressSession && req.session?.openidTokens) { + delete req.session.openidTokens; + try { + await persistSession(req); + } catch (error) { + logger.warn( + '[OpenIDSessionRefresh] Failed to clear Express session during publication rollback', + toOpenIDLogArgument(error), + ); + } + } + if (effects.browserCookies) { + for (const name of [ + 'refreshToken', + 'openid_access_token', + 'openid_id_token', + 'openid_user_id', + 'token_provider', + ]) { + res?.clearCookie?.(name); + } + } + if (effects.bridge) { + try { + await deleteRefreshTokenBridges({ + refreshTokens: [effects.bridge.predecessorRefreshToken], + userId: effects.bridge.identity.userId, + tenantId: effects.bridge.identity.tenantId, + version: effects.bridge.version, + }); + } catch (error) { + logger.warn( + '[OpenIDSessionRefresh] Failed to remove publication bridge during rollback', + toOpenIDLogArgument(error), + ); + } + } + } + + function hasPublicationEffects(effects: SessionPublicationEffects): boolean { + return Boolean( + effects.durableSession || effects.browserCookies || effects.expressSession || effects.bridge, + ); + } + + function createSessionPublicationEffects(): SessionPublicationEffects { + return { + durableSession: false, + browserCookies: false, + expressSession: false, + }; + } + + async function publishCompletedFlightTokens({ + key, + req, + res, + user, + identityContext, + resolvedTokens, + predecessorRefreshToken, + tokenPreference, + }: { + key: string; + req: OpenIDRequest; + res?: OpenIDResponse; + user: OpenIDUser; + identityContext?: AuthIdentityContext; + resolvedTokens: MarkedOIDCTokens; + predecessorRefreshToken?: string; + tokenPreference: TokenPreference; + }): Promise { + if (resolvedTokens.__deferredPublication) { + throw new Error('OpenID refresh result is awaiting identity validation'); + } + if (!resolvedTokens.__flightOwnerId) { + throw new Error('OpenID refresh result is missing its publication generation'); + } + const publicationGeneration = { + key, + ownerId: resolvedTokens.__flightOwnerId, + createdAt: resolvedTokens.__flightCreatedAt, + }; + const effects = createSessionPublicationEffects(); + try { + const effectiveTokens = await publishResolvedSessionTokens({ + req, + res, + user, + identityContext, + resolvedTokens, + predecessorRefreshToken, + tokenPreference, + assertLeaseOwned: () => assertOpenIDRefreshFlightAvailable(publicationGeneration), + publicationGeneration, + effects, + }); + if (!effectiveTokens) { + throw new Error('OpenID refresh result is unavailable for publication'); + } + return effectiveTokens; + } catch (error) { + if (isOpenIDRefreshOwnershipError(error) && hasPublicationEffects(effects)) { + await rollbackSessionPublication( + req, + res, + resolvedTokens, + effects, + resolvedTokens.refresh_token ?? predecessorRefreshToken, + ); + } + throw error; + } + } + + async function performIdpRefresh( + req: OpenIDRequest, + res: OpenIDResponse | undefined, + user: OpenIDUser, + tokenPreference: TokenPreference, + identityContext?: AuthIdentityContext, + deferPublication = false, + ): Promise { + const refreshToken = req?.session?.openidTokens?.refreshToken; + const predecessorAccessToken = req?.session?.openidTokens?.accessToken; + const key = createOpenIDRefreshFlightKey({ req, user, refreshToken, identityContext }); + if (!key) { + return performIdpRefreshGrant( + req, + res, + user, + tokenPreference, + identityContext, + undefined, + deferPublication, + ); + } + + let flight; + try { + flight = await acquireOpenIDRefreshFlight({ key }); + } catch (error) { + logger.warn( + '[OpenIDSessionRefresh] Failed to acquire shared refresh flight', + toOpenIDLogArgument(error), + ); + throw new Error('OpenID refresh coordination is temporarily unavailable', { cause: error }); + } + + if (!flight.acquired) { + logger.debug('[OpenIDSessionRefresh] Joining shared refresh flight', { + key: hashKeyForLogs(key), + }); + const resolvedTokens = await waitForOpenIDRefreshFlight({ key }); + if (resolvedTokens) { + if (!deferPublication) { + return publishCompletedFlightTokens({ + key, + req, + res, + user, + identityContext, + resolvedTokens, + predecessorRefreshToken: refreshToken, + tokenPreference, + }); + } + return resolvedTokens; + } + + logger.warn('[OpenIDSessionRefresh] Shared refresh flight remained unresolved', { + key: hashKeyForLogs(key), + }); + throw new Error('OpenID refresh coordination is temporarily unavailable'); + } + + return withOpenIDRefreshFlightLease({ + key, + ownerId: flight.ownerId, + operation: async ({ assertLeaseOwned, markLeaseSettled }: LeaseContext) => { + let recoveryBridgeVersion: string | null = null; + let recoveryBridgeIdentity: RefreshTokenBridgeIdentity | null = null; + let recoveryBridgePredecessor: string | undefined; + let resolvedTokens: MarkedOIDCTokens | null = null; + let successorRefreshToken: string | undefined; + let completionIndeterminate = false; + const publicationEffects = createSessionPublicationEffects(); + try { + resolvedTokens = await performIdpRefreshGrant( + req, + res, + user, + tokenPreference, + identityContext, + assertLeaseOwned, + true, + ); + attachPredecessorRefreshTokenMarker(resolvedTokens, refreshToken); + attachPredecessorAccessTokenMarker(resolvedTokens, predecessorAccessToken); + attachDeferredPublicationMarker(resolvedTokens, deferPublication); + const flightCreatedAt = flight.flight?.createdAt + ? new Date(flight.flight.createdAt).getTime() + : Date.now(); + attachFlightOwnerMarker(resolvedTokens, flight.ownerId, flightCreatedAt); + successorRefreshToken = resolvedTokens?.refresh_token ?? refreshToken; + const browserRefreshToken = resolvedTokens + ? (getBrowserRefreshTokenMarker(resolvedTokens) ?? refreshToken) + : refreshToken; + if ( + !deferPublication && + successorRefreshToken && + browserRefreshToken && + successorRefreshToken !== browserRefreshToken + ) { + recoveryBridgeIdentity = createRefreshTokenBridgeIdentity({ + user, + requestUser: req.user, + userId: identityContext?.appUserId, + tenantId: identityContext?.tenantId, + openidIssuer: identityContext?.openidIssuer, + }); + recoveryBridgePredecessor = browserRefreshToken; + if (recoveryBridgeIdentity) { + recoveryBridgeVersion = await storeRefreshTokenBridgeWithLease({ + oldRefreshToken: browserRefreshToken, + newRefreshToken: successorRefreshToken, + userId: recoveryBridgeIdentity.userId, + tenantId: recoveryBridgeIdentity.tenantId, + openidIssuer: recoveryBridgeIdentity.openidIssuer, + ttl: OPENID_REFRESH_BRIDGE_GRACE_MS, + assertLeaseOwned, + }); + } + } + if (!deferPublication) { + await publishResolvedSessionTokens({ + req, + res, + user, + identityContext, + resolvedTokens, + predecessorRefreshToken: refreshToken, + tokenPreference, + assertLeaseOwned, + publicationGeneration: { key, ownerId: flight.ownerId, createdAt: flightCreatedAt }, + effects: publicationEffects, + }); + } + let completedFlight: RefreshFlightRecord | null = null; + try { + completedFlight = await completeOpenIDRefreshFlight({ + key, + ownerId: flight.ownerId, + tokens: resolvedTokens, + }); + } catch (completionError) { + completionIndeterminate = true; + try { + const observed = await assertOpenIDRefreshFlightAvailable({ + key, + ownerId: flight.ownerId, + }); + if (typeof observed === 'object') { + completedFlight = observed; + completionIndeterminate = false; + } + } catch { + /** Keep the pending generation recoverable when completion cannot be observed. */ + } + if (!completedFlight) { + throw completionError; + } + } + if (!completedFlight) { + throw createOpenIDRefreshOwnershipError( + 'OpenID refresh coordination ownership was lost before completion', + ); + } + attachFlightOwnerMarker(resolvedTokens, completedFlight.ownerId ?? flight.ownerId); + markLeaseSettled(); + return resolvedTokens; + } catch (error) { + if (isOpenIDRefreshOwnershipError(error) && hasPublicationEffects(publicationEffects)) { + await rollbackSessionPublication( + req, + res, + resolvedTokens, + publicationEffects, + successorRefreshToken, + ); + } + if ( + isOpenIDRefreshOwnershipError(error) && + recoveryBridgeVersion && + recoveryBridgeIdentity && + recoveryBridgePredecessor && + !publicationEffects.bridge + ) { + try { + await deleteRefreshTokenBridges({ + refreshTokens: [recoveryBridgePredecessor], + userId: recoveryBridgeIdentity.userId, + tenantId: recoveryBridgeIdentity.tenantId, + version: recoveryBridgeVersion, + }); + } catch (cleanupError) { + logger.warn( + '[OpenIDSessionRefresh] Failed to remove the owned bridge after refresh revocation', + toOpenIDLogArgument(cleanupError), + ); + } + } + if (!completionIndeterminate) { + try { + await failOpenIDRefreshFlight({ + key, + ownerId: flight.ownerId, + error: error instanceof Error ? error : new Error('OpenID session refresh failed'), + }); + } catch (flightError) { + logger.warn('[OpenIDSessionRefresh] Failed to mark shared refresh flight failed', { + key: hashKeyForLogs(key), + error: (flightError as Error)?.message, + }); + } + } else { + logger.warn( + '[OpenIDSessionRefresh] Keeping an indeterminate publication generation recoverable', + { key: hashKeyForLogs(key) }, + ); + } + throw error; + } + }, + }); + } + + /** + * Hydrates `req.session.openidTokens` from a resolved OIDCTokens result and + * persists it. Used by joining requests in the single-flight path: the leader + * mutates only its own `req.session`, so a joiner carrying a distinct `req` + * (including a renewed Express session) would otherwise re-read + * stale tokens on its next OBO call. This includes stable-refresh-token IdPs, + * where the refresh token remains unchanged but the access token and expiry + * were refreshed by the leader. + * Idempotent when the joiner shares the leader's `req` object. + */ + async function hydrateSessionFromResolvedTokens( + req: OpenIDRequest, + resolvedTokens: MarkedOIDCTokens | null, + predecessorOverride?: string, + reloadSession = true, + publicationGeneration?: OpenIDPublicationGeneration, + ): Promise { + if (!req?.session || !resolvedTokens?.access_token) { + return false; + } + if (reloadSession && typeof req.session.reload === 'function') { + const reload = req.session.reload.bind(req.session); + await new Promise((resolve, reject) => { + reload((error?: Error | null) => (error ? reject(error) : resolve())); + }); + } + const existing = req.session.openidTokens ?? {}; + const generationDiffers = Boolean( + publicationGeneration && + existing.publicationFlightKey && + (existing.publicationFlightKey !== publicationGeneration.key || + existing.publicationFlightOwnerId !== publicationGeneration.ownerId), + ); + const existingGenerationIsNewer = Boolean( + generationDiffers && + existing.publicationFlightCreatedAt != null && + (publicationGeneration?.createdAt == null || + existing.publicationFlightCreatedAt >= publicationGeneration.createdAt), + ); + if (existingGenerationIsNewer) { + logger.info( + '[OpenIDSessionRefresh] Skipping stale flight hydration because its generation is older', + ); + return false; + } + if (hasSessionAdvancedPastResult(existing, resolvedTokens, predecessorOverride)) { + logger.info( + '[OpenIDSessionRefresh] Skipping stale flight hydration because the session advanced', + ); + return false; + } + const accessTokenChanged = existing.accessToken !== resolvedTokens.access_token; + const idTokenChanged = + resolvedTokens.id_token != null && existing.idToken !== resolvedTokens.id_token; + const refreshTokenChanged = + resolvedTokens.refresh_token != null && + existing.refreshToken !== resolvedTokens.refresh_token; + const resolvedBrowserRefreshToken = getBrowserRefreshTokenMarker(resolvedTokens); + const browserRefreshTokenChanged = + resolvedBrowserRefreshToken != null && + existing.browserRefreshToken !== resolvedBrowserRefreshToken; + const hasResolvedExpiry = typeof resolvedTokens.expires_at === 'number'; + const expiresAtChanged = hasResolvedExpiry + ? existing.accessTokenExpiresAt !== resolvedTokens.expires_at + : accessTokenChanged && existing.accessTokenExpiresAt !== undefined; + const publicationGenerationChanged = publicationGeneration + ? existing.publicationFlightKey !== publicationGeneration.key || + existing.publicationFlightOwnerId !== publicationGeneration.ownerId + : false; + + if ( + !accessTokenChanged && + !idTokenChanged && + !refreshTokenChanged && + !browserRefreshTokenChanged && + !expiresAtChanged && + !publicationGenerationChanged + ) { + return false; + } + + const nextSessionTokens = { + ...existing, + accessToken: resolvedTokens.access_token, + idToken: resolvedTokens.id_token ?? existing.idToken, + refreshToken: resolvedTokens.refresh_token ?? existing.refreshToken, + browserRefreshToken: resolvedBrowserRefreshToken ?? existing.browserRefreshToken, + lastRefreshedAt: Date.now(), + ...(publicationGeneration + ? { + publicationFlightKey: publicationGeneration.key, + publicationFlightOwnerId: publicationGeneration.ownerId, + publicationFlightCreatedAt: publicationGeneration.createdAt, + } + : {}), + }; + if (hasResolvedExpiry) { + nextSessionTokens.accessTokenExpiresAt = resolvedTokens.expires_at; + } else if (accessTokenChanged) { + delete nextSessionTokens.accessTokenExpiresAt; + } + req.session.openidTokens = nextSessionTokens; + await persistSession(req); + return true; + } + + async function refreshOrReuseSession( + req: OpenIDRequest, + res: OpenIDResponse | undefined, + user: OpenIDUser, + tokenPreference: TokenPreference, + identityContext?: AuthIdentityContext, + forceRefresh = false, + deferPublication = false, + ): Promise { + const sessionTokens = req?.session?.openidTokens; + if (!sessionTokens) { + logger.debug('[OpenIDSessionRefresh] No session tokens to refresh from'); + return null; + } + + if (!forceRefresh && isLiveSessionTokenStillValid(sessionTokens, tokenPreference)) { + await assertOpenIDRefreshSessionGenerationAvailable({ + key: sessionTokens.publicationFlightKey, + ownerId: sessionTokens.publicationFlightOwnerId, + }); + logger.debug('[OpenIDSessionRefresh] Live session token reused'); + return buildOIDCTokensFromSession(sessionTokens, tokenPreference); + } + + return performIdpRefresh(req, res, user, tokenPreference, identityContext, deferPublication); + } + + /** + * Single-flighted entry point. Concurrent callers for the same user share one + * in-flight refresh. The map is cleared in finally so a failed refresh does + * not pin subsequent retries. + * + * @param {import('express').Request} req + * @param {import('express').Response} [res] β€” when present and writable, the + * rotated refresh token is mirrored to the `refreshToken` cookie. + * @param {import('@librechat/data-schemas').IUser} user + * @param {'access_token' | 'id_token'} tokenPreference β€” required; selects + * which token's `exp` gates the live-vs-refresh decision and populates the + * returned `expires_at`. OBO callers pass 'access_token'. + */ + async function refreshOpenIDSession( + req: OpenIDRequest, + res: OpenIDResponse | undefined, + user: OpenIDUser, + tokenPreference: TokenPreference, + identityContext?: AuthIdentityContext, + options: RefreshSessionOptions = {}, + ): Promise { + const identityBinding = assertOpenIDSessionIdentityMatch(req, user, identityContext); + if (identityBinding) { + await identityBinding; + } + if (options.assertLeaseOwned) { + return performIdpRefreshGrant( + req, + res, + user, + tokenPreference, + identityContext, + options.assertLeaseOwned, + options.deferPublication, + ); + } + const key = getSingleFlightKey(req, user, identityContext); + if (!key) { + return refreshOrReuseSession( + req, + res, + user, + tokenPreference, + identityContext, + options.forceRefresh, + options.deferPublication, + ); + } + + const inFlight = inFlightRefreshes.get(key); + if (inFlight) { + const predecessorRefreshToken = req?.session?.openidTokens?.refreshToken; + const sharedFlightKey = createOpenIDRefreshFlightKey({ + req, + user, + refreshToken: predecessorRefreshToken, + identityContext, + }); + logger.debug(`[OpenIDSessionRefresh] Joining in-flight refresh (key=${hashKeyForLogs(key)})`); + const resolvedTokens = await inFlight; + /** + * The leader mutated only its own request's session. Copy the resolved + * tokens into THIS request's session so a later OBO call on the joiner + * reads the rotated refresh token instead of replaying the stale one. + */ + if (!options.deferPublication) { + if (resolvedTokens?.__deferredPublication) { + throw new Error('OpenID refresh result is awaiting identity validation'); + } + const currentSessionTokens = req.session?.openidTokens; + const alreadyCurrent = Boolean( + currentSessionTokens?.accessToken === resolvedTokens?.access_token && + currentSessionTokens?.refreshToken === + (resolvedTokens?.refresh_token ?? predecessorRefreshToken), + ); + if (alreadyCurrent) { + if (resolvedTokens?.__flightOwnerId) { + if (!sharedFlightKey) { + throw new Error('OpenID refresh coordination key is unavailable for publication'); + } + await assertOpenIDRefreshFlightAvailable({ + key: sharedFlightKey, + ownerId: resolvedTokens.__flightOwnerId, + }); + } + return resolvedTokens; + } + if (!sharedFlightKey || !resolvedTokens) { + throw new Error('OpenID refresh coordination key is unavailable for publication'); + } + return publishCompletedFlightTokens({ + key: sharedFlightKey, + req, + res, + user, + identityContext, + resolvedTokens, + predecessorRefreshToken, + tokenPreference, + }); + } + return resolvedTokens; + } + + const promise = refreshOrReuseSession( + req, + res, + user, + tokenPreference, + identityContext, + options.forceRefresh, + options.deferPublication, + ).finally(() => { + if (inFlightRefreshes.get(key) === promise) { + inFlightRefreshes.delete(key); + } + }); + inFlightRefreshes.set(key, promise); + /** Swallow rejection on the cleanup chain; the original is delivered to the awaiter. */ + promise.catch(() => {}); + return promise; + } + + /** + * Returns true when this user is in scope for OIDC session refresh. Non-OIDC + * users and deployments without `OPENID_REUSE_TOKENS` never had a populated + * `req.session.openidTokens` to begin with. Bearer-authenticated remote-agent requests may use + * their current verified bearer; browser requests whose session capability disappeared reject. + */ + function isOIDCRefreshApplicable(user?: OpenIDUser): user is OpenIDUser { + if (!isEnabled(process.env.OPENID_REUSE_TOKENS)) { + return false; + } + if (!user) { + return false; + } + return user.provider === 'openid' || Boolean(user.openidId); + } + + /** + * Builds the UpstreamTokenProvider closure forwarded into the MCP layer. + * The closure closes over `req` so it reads `req.session.openidTokens` at OBO + * call time (not at request validation), which is what makes the walk-away + * failure mode recover without a user-visible re-authentication. + * + * `tokenPreference` is required and identifies which upstream token's freshness + * gates the closure. OBO needs 'access_token' because the OBO exchange uses + * the access token as the jwt-bearer assertion; using id_token preference here + * would let an expired access token reach the IdP under a still-fresh id_token. + * + * Closure contract (matches `UpstreamTokenProvider` in obo.ts): + * - resolves to non-null OIDCTokens when fresh tokens are available. + * - resolves to null when refresh is not applicable or the request itself carries the + * verified upstream bearer (the remote-agent flow). + * - rejects when an Express session existed but its OpenID capability was cleared, so a + * strategy-time `user.federatedTokens` snapshot cannot bypass logout. + * - rejects when session identity metadata does not match the current user. + * - rejects when refresh was attempted and rejected by the IdP. The MCP + * layer wraps the rejection as `session_refresh_failed`. + * + * @param {object} args + * @param {import('express').Request} [args.req] + * @param {import('express').Response} [args.res] β€” forwarded so a rotated + * refresh token can be mirrored to the `refreshToken` cookie when the + * response is still writable (no-op on the streaming tool-call path). + * @param {import('@librechat/data-schemas').IUser} [args.user] + * @param {import('@librechat/api').AuthIdentityContext} [args.identityContext] + * @param {'access_token' | 'id_token'} args.tokenPreference + * @returns {() => Promise} + */ + function createOpenIDSessionTokenProvider({ + req, + res, + user, + tokenPreference, + identityContext, + }: CreateOpenIDSessionTokenProviderInput): () => Promise { + if (tokenPreference !== 'access_token' && tokenPreference !== 'id_token') { + throw new Error( + `[OpenIDSessionRefresh] createOpenIDSessionTokenProvider requires tokenPreference 'access_token' or 'id_token', got: ${tokenPreference}`, + ); + } + return async function upstreamTokenProvider() { + if (!isOIDCRefreshApplicable(user)) { + return null; + } + if (!req?.session?.openidTokens) { + const authorization = req?.headers?.authorization; + const bearerToken = authorization?.match(/^Bearer\s+(.+)$/i)?.[1]; + const carriesCurrentUpstreamBearer = Boolean( + bearerToken && bearerToken === user?.federatedTokens?.access_token, + ); + if (req?.session && !carriesCurrentUpstreamBearer) { + throw createOpenIDRefreshOwnershipError('OpenID session tokens are no longer available'); + } + logger.debug( + '[OpenIDSessionRefresh] No session.openidTokens available on req; closure returning null', + ); + return null; + } + const resolvedIdentityContext = + identityContext ?? + createAuthIdentityContext({ + user, + requestUser: req?.user, + }); + return refreshOpenIDSession(req, res, user, tokenPreference, resolvedIdentityContext); + }; + } + + return { + createOpenIDSessionTokenProvider, + refreshOpenIDSession, + /** Exposed for tests; not a public API. */ + __internals: { + UPSTREAM_TOKEN_EXPIRY_BUFFER_SECONDS, + inFlightRefreshes, + getSingleFlightKey, + isLiveSessionTokenStillValid, + getAccessTokenExp, + }, + }; +} diff --git a/packages/api/src/auth/openid/types.ts b/packages/api/src/auth/openid/types.ts new file mode 100644 index 0000000000..ff1c42e947 --- /dev/null +++ b/packages/api/src/auth/openid/types.ts @@ -0,0 +1,175 @@ +import type { OIDCTokens } from '@librechat/data-schemas'; +import type { + AuthIdentityContext, + AuthIdentitySource, + AuthIdentityTuple, + OpenIDSessionIdentitySource, + RefreshTokenBridgeIdentity, +} from '~/utils/identity'; + +export type TokenPreference = 'access_token' | 'id_token'; +export type AsyncVoidCallback = (error?: Error | null) => void; +export type LeaseAssertion = () => Promise; +export type LogArgument = string | number | boolean | Error | object | null | undefined; + +export interface OpenIDPublicationGeneration { + key: string; + ownerId: string; + createdAt?: number; +} + +export interface OpenIDClaims { + sub: string; + oid?: string; + email?: string; + iss?: string; +} + +export interface OpenIDTokenSet extends OIDCTokens { + access_token?: string; + expires_in?: number | string; + claims?: () => OpenIDClaims; + /** + * Set non-enumerably when an inline refresh strips an expired carried-forward `id_token` from + * the result. It is identity material only β€” never an authentication response token. + */ + __identityIdToken?: string; + /** Serializable identity evidence for shared-flight followers. */ + __identityClaims?: OpenIDClaims; + /** Access token that the shared candidate advanced from. */ + __predecessorAccessToken?: string; +} + +export interface SharedOpenIDRefreshResult { + tokenset: OpenIDTokenSet; + claims: OpenIDClaims; + openidIssuer?: string; + expires_at?: number; + appAuthToken: string; + predecessorAccessToken?: string; + acceptedIdentity?: AuthIdentityContext; + /** Non-enumerable durable generation marker restored by the flight service. */ + __flightOwnerId?: string; + __flightCreatedAt?: number; +} + +export interface SessionOpenIDTokens { + accessToken?: string; + idToken?: string; + refreshToken?: string; + browserRefreshToken?: string; + expiresAt?: number; + lastRefreshedAt?: number; + appUserId?: string; + openidSubject?: string; + tenantId?: string; + openidIssuer?: string; + accessTokenExpiresAt?: number; + /** Durable coordination generation that authorized this session publication. */ + publicationFlightKey?: string; + publicationFlightOwnerId?: string; + publicationFlightCreatedAt?: number; +} + +export interface OpenIDSession { + openidTokens?: SessionOpenIDTokens; + save?: (callback: AsyncVoidCallback) => void; + reload?: (callback: AsyncVoidCallback) => void; + destroy?: (callback: AsyncVoidCallback) => void; +} + +export interface OpenIDRequest { + headers?: { authorization?: string; cookie?: string }; + session?: OpenIDSession; + sessionID?: string; + user?: OpenIDUser; +} + +export interface OpenIDResponse { + headersSent?: boolean; + cookie?: (name: string, value: string, options?: { expires?: Date }) => void; + clearCookie?: (name: string) => void; +} + +export interface OpenIDUser extends AuthIdentitySource { + _id?: string | number | { toString(): string }; + id?: string; + email?: string; + provider?: string; + openidId?: string; + tenantId?: string; + openidIssuer?: string; + federatedTokens?: OIDCTokens; +} + +export interface OpenIDRefreshResolution { + tokenset: OpenIDTokenSet; + claims: OpenIDClaims; + openidIssuer?: string; + user?: OpenIDUser | null; + error?: string | null; + migration?: boolean; +} + +export interface OpenIDLogger { + debug: (...args: LogArgument[]) => void; + info: (...args: LogArgument[]) => void; + warn: (...args: LogArgument[]) => void; + error: (...args: LogArgument[]) => void; +} + +export interface LeaseContext { + assertLeaseOwned: LeaseAssertion; + markLeaseSettled: () => void; +} + +export interface RefreshFlightAcquireResult { + acquired: boolean; + key: string | null; + ownerId: string; + flight?: RefreshFlightRecord | null; +} + +export interface RefreshFlightRecord { + status?: 'pending' | 'completed' | 'failed' | 'revoked'; + ownerId?: string; + createdAt?: Date | string; + deliveryId?: string; + deliveryExpiresAt?: Date | string; + revocationRequestedAt?: Date | string; + encryptedResult?: string; + errorMessage?: string; + expiresAt?: Date | string; +} + +export interface RefreshTokenBridgeInput { + oldRefreshToken: string; + newRefreshToken: string; + userId: string; + tenantId?: string; + openidIssuer?: string; + ttl?: number; +} + +export interface RefreshTokenBridgeDeleteInput { + refreshTokens: string[]; + userId: string; + tenantId?: string; + version?: string; +} + +export interface RefreshKeyInput { + req?: OpenIDRequest; + user?: OpenIDUser; + refreshToken?: string; + identityContext?: AuthIdentityContext; +} + +export type { + AuthIdentityContext, + AuthIdentitySource, + AuthIdentityTuple, + OpenIDSessionIdentitySource, + OIDCTokens, + RefreshTokenBridgeIdentity, +}; diff --git a/packages/api/src/mcp/MCPConnectionFactory.ts b/packages/api/src/mcp/MCPConnectionFactory.ts index 3aedb7b7a6..e545c1b102 100644 --- a/packages/api/src/mcp/MCPConnectionFactory.ts +++ b/packages/api/src/mcp/MCPConnectionFactory.ts @@ -11,7 +11,8 @@ import type { OAuthStoredClientMetadata, OAuthClientSource, } from '~/mcp/oauth'; -import type { OboTokenResolver, OboTrustChecker } from '~/mcp/oauth/obo'; +import type { OboTokenResolver, OboTrustChecker, UpstreamTokenProvider } from '~/mcp/oauth/obo'; +import type { AuthIdentityContext } from '~/utils/identity'; import type { FlowStateManager } from '~/flow/manager'; import type * as t from './types'; import { @@ -74,6 +75,8 @@ export class MCPConnectionFactory { protected readonly connectionTimeout?: number; protected readonly oboTokenResolver?: OboTokenResolver; protected readonly oboTrustChecker?: OboTrustChecker; + protected readonly upstreamTokenProvider?: UpstreamTokenProvider; + protected readonly oboIdentityContext?: AuthIdentityContext; private connectionReady = false; /** * Snapshot of the tenant context at factory construction time. Captured eagerly @@ -169,71 +172,93 @@ export class MCPConnectionFactory { protected async discoverToolsInternal(): Promise { const oauthUrl: string | null = null; let oauthRequired = false; + let shouldAttemptAuthenticatedDiscovery = true; let oauthTokens: MCPOAuthTokens | null = null; if (this.usesObo) { - oauthTokens = await this.getOboTokens(); + try { + oauthTokens = await this.getOboTokens(); + } catch (error) { + if (!(error instanceof OboTokenResolutionError)) { + throw error; + } + oauthRequired = true; + shouldAttemptAuthenticatedDiscovery = false; + logger.debug( + `${this.logPrefix} [Discovery] OBO token resolution failed, attempting unauthenticated tool listing`, + error, + ); + } } else if (this.useOAuth) { oauthTokens = await this.getOAuthTokens(); } - const connection = new MCPConnection({ - serverName: this.serverName, - serverConfig: this.serverConfig, - userId: this.userId, - oauthTokens, - useSSRFProtection: this.useSSRFProtection, - allowedAddresses: this.allowedAddresses, - ephemeralConnection: this.ephemeralConnection, - }); - const oauthHandler = () => { - logger.info( - `${this.logPrefix} [Discovery] OAuth required; skipping URL generation in discovery mode`, - ); - oauthRequired = true; - connection.emit('oauthFailed', new Error('OAuth required during tool discovery')); - }; + let connection: MCPConnection | null = null; + let oauthHandler: (() => void) | null = null; + if (shouldAttemptAuthenticatedDiscovery) { + connection = new MCPConnection({ + serverName: this.serverName, + serverConfig: this.serverConfig, + userId: this.userId, + oauthTokens, + useSSRFProtection: this.useSSRFProtection, + allowedAddresses: this.allowedAddresses, + ephemeralConnection: this.ephemeralConnection, + }); - // Register unconditionally: non-OAuth servers that return 401 also emit 'oauthRequired', - // and without this listener, connectClient()'s oauthHandledPromise hangs for 30s+. - connection.once('oauthRequired', oauthHandler); + oauthHandler = () => { + logger.info( + `${this.logPrefix} [Discovery] OAuth required; skipping URL generation in discovery mode`, + ); + oauthRequired = true; + connection?.emit('oauthFailed', new Error('OAuth required during tool discovery')); + }; - try { - const connectTimeout = this.connectionTimeout ?? this.serverConfig.initTimeout ?? 30000; - await withTimeout( - connection.connect(), - connectTimeout, - `Connection timeout after ${connectTimeout}ms`, - ); + // Register unconditionally: non-OAuth servers that return 401 also emit 'oauthRequired', + // and without this listener, connectClient()'s oauthHandledPromise hangs for 30s+. + connection.once('oauthRequired', oauthHandler); - if (await connection.isConnected()) { - const snapshot = await connection.fetchOrderedToolsSnapshot(); - connection.removeListener('oauthRequired', oauthHandler); - return { - tools: snapshot.complete ? snapshot.tools : null, - connection, - oauthRequired: false, - oauthUrl: null, - }; + try { + const connectTimeout = this.connectionTimeout ?? this.serverConfig.initTimeout ?? 30000; + await withTimeout( + connection.connect(), + connectTimeout, + `Connection timeout after ${connectTimeout}ms`, + ); + + if (await connection.isConnected()) { + const snapshot = await connection.fetchOrderedToolsSnapshot(); + connection.removeListener('oauthRequired', oauthHandler); + return { + tools: snapshot.complete ? snapshot.tools : null, + connection, + oauthRequired: false, + oauthUrl: null, + }; + } + } catch { + MCPConnection.decrementCycleCount(this.serverName); + logger.debug( + `${this.logPrefix} [Discovery] Connection failed, attempting unauthenticated tool listing`, + ); } - } catch { - MCPConnection.decrementCycleCount(this.serverName); - logger.debug( - `${this.logPrefix} [Discovery] Connection failed, attempting unauthenticated tool listing`, - ); } try { const tools = await this.attemptUnauthenticatedToolListing(); - connection.removeListener('oauthRequired', oauthHandler); + if (connection && oauthHandler) { + connection.removeListener('oauthRequired', oauthHandler); + } if (tools && tools.length > 0) { logger.info( `${this.logPrefix} [Discovery] Successfully discovered ${tools.length} tools without auth`, ); - try { - await connection.dispose(); - } catch { - // Ignore cleanup errors + if (connection) { + try { + await connection.dispose(); + } catch { + // Ignore cleanup errors + } } return { tools, connection: null, oauthRequired, oauthUrl }; } @@ -243,12 +268,16 @@ export class MCPConnectionFactory { logger.debug(`${this.logPrefix} [Discovery] Unauthenticated tool listing failed`); } - connection.removeListener('oauthRequired', oauthHandler); + if (connection && oauthHandler) { + connection.removeListener('oauthRequired', oauthHandler); + } - try { - await connection.dispose(); - } catch { - // Ignore cleanup errors + if (connection) { + try { + await connection.dispose(); + } catch { + // Ignore cleanup errors + } } return { tools: null, connection: null, oauthRequired, oauthUrl }; @@ -333,6 +362,8 @@ export class MCPConnectionFactory { this.returnOnOAuth = options.returnOnOAuth; this.oboTokenResolver = options.oboTokenResolver; this.oboTrustChecker = options.oboTrustChecker; + this.upstreamTokenProvider = options.upstreamTokenProvider; + this.oboIdentityContext = options.oboIdentityContext; } else { this.useOAuth = false; } @@ -345,6 +376,14 @@ export class MCPConnectionFactory { return null; } + if (!this.upstreamTokenProvider) { + throw new Error( + `${this.logPrefix} Internal: upstreamTokenProvider not plumbed for OBO connection. ` + + 'OBO requires a live upstream-token closure; the caller must construct one via ' + + 'createOpenIDSessionTokenProvider() and forward it through the MCP connection options.', + ); + } + if (this.oboTrustChecker) { const config = this.serverConfig as t.ParsedServerConfig; const trusted = await this.oboTrustChecker({ @@ -360,8 +399,14 @@ export class MCPConnectionFactory { } } - logger.info(`${this.logPrefix} Resolving OBO token`); - return resolveOboToken(this.user, oboConfig, this.oboTokenResolver); + logger.info(`${this.logPrefix} Resolving OBO token for scopes: ${oboConfig.scopes}`); + return resolveOboToken( + this.user, + oboConfig, + this.oboTokenResolver, + this.upstreamTokenProvider, + this.oboIdentityContext, + ); } /** Returns true if this server uses OBO instead of standard OAuth */ diff --git a/packages/api/src/mcp/MCPManager.ts b/packages/api/src/mcp/MCPManager.ts index aa10fdfb77..5a465d44be 100644 --- a/packages/api/src/mcp/MCPManager.ts +++ b/packages/api/src/mcp/MCPManager.ts @@ -4,7 +4,8 @@ import { Permissions, PermissionTypes } from 'librechat-data-provider'; import { CallToolResultSchema, ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js'; import type { RequestOptions } from '@modelcontextprotocol/sdk/shared/protocol.js'; import type { TokenMethods, IUser } from '@librechat/data-schemas'; -import type { OboTokenResolver, OboTrustChecker } from '~/mcp/oauth/obo'; +import type { OboTokenResolver, OboTrustChecker, UpstreamTokenProvider } from '~/mcp/oauth/obo'; +import type { AuthIdentityContext } from '~/utils/identity'; import type { GraphTokenResolver } from '~/utils/graph'; import type { FlowStateManager } from '~/flow/manager'; import type { MCPOAuthTokens } from './oauth'; @@ -46,6 +47,8 @@ function createOboToolCallErrorMessage( failureSuffix = 'Please retry.'; } else if (error.reason === 'exchange_failed') { failureSuffix = 'Re-authenticate the user or verify the configured OBO scopes and retry.'; + } else if (error.reason === 'session_refresh_failed') { + failureSuffix = 'Please sign in again.'; } return `${logPrefix} ${error.userMessage} Cannot execute tool ${toolName}. ${failureSuffix}`; @@ -369,6 +372,8 @@ export class MCPManager extends UserConnectionManager { connectionTimeout: args.connectionTimeout, oboTokenResolver: args.oboTokenResolver, oboTrustChecker: args.oboTrustChecker, + upstreamTokenProvider: args.upstreamTokenProvider, + oboIdentityContext: args.oboIdentityContext, }); return finalizeDiscoveryResult(result); @@ -788,6 +793,8 @@ Please follow these instructions when using tools from the respective MCP server graphTokenResolver, oboTokenResolver, oboTrustChecker, + upstreamTokenProvider, + oboIdentityContext, }: { user?: IUser; serverName: string; @@ -807,6 +814,8 @@ Please follow these instructions when using tools from the respective MCP server graphTokenResolver?: GraphTokenResolver; oboTokenResolver?: OboTokenResolver; oboTrustChecker?: OboTrustChecker; + upstreamTokenProvider?: UpstreamTokenProvider; + oboIdentityContext?: AuthIdentityContext; }): Promise { const userId = user?.id; const logPrefix = userId ? `[MCP][User: ${userId}][${serverName}]` : `[MCP][${serverName}]`; @@ -866,6 +875,8 @@ Please follow these instructions when using tools from the respective MCP server oauthEnd, oboTokenResolver, oboTrustChecker, + upstreamTokenProvider, + oboIdentityContext, graphTokenResolver, signal: options?.signal, customUserVars, @@ -955,9 +966,17 @@ Please follow these instructions when using tools from the respective MCP server const resolvedHeaders: Record = 'headers' in currentOptions ? { ...(currentOptions.headers || {}) } : {}; - /** Refresh OBO token on each tool call to ensure it's current */ + /** Resolve the current OBO token for this tool call; the resolver may serve cached tokens. */ const oboConfig = rawConfig.obo; if (oboConfig && oboTokenResolver && user) { + if (!upstreamTokenProvider) { + throw new McpError( + ErrorCode.InternalError, + `${logPrefix} Internal: upstreamTokenProvider not plumbed for OBO tool call. ` + + 'OBO requires a live upstream-token closure; the caller must construct one via ' + + 'createOpenIDSessionTokenProvider() and forward it through callTool().', + ); + } const oboTrusted = oboTrustChecker ? await oboTrustChecker({ source: rawConfig.source, @@ -976,7 +995,13 @@ Please follow these instructions when using tools from the respective MCP server } let oboTokens: MCPOAuthTokens; try { - oboTokens = await resolveOboToken(user, oboConfig, oboTokenResolver); + oboTokens = await resolveOboToken( + user, + oboConfig, + oboTokenResolver, + upstreamTokenProvider, + oboIdentityContext, + ); } catch (error) { if (error instanceof OboTokenResolutionError) { throw new McpError( diff --git a/packages/api/src/mcp/UserConnectionManager.ts b/packages/api/src/mcp/UserConnectionManager.ts index 888f79d910..9d3ab98071 100644 --- a/packages/api/src/mcp/UserConnectionManager.ts +++ b/packages/api/src/mcp/UserConnectionManager.ts @@ -472,6 +472,8 @@ export abstract class UserConnectionManager { oauthEnd, oboTokenResolver, oboTrustChecker, + upstreamTokenProvider, + oboIdentityContext, signal, returnOnOAuth = false, connectionTimeout, @@ -679,6 +681,8 @@ export abstract class UserConnectionManager { oauthEnd: oauthEnd, oboTokenResolver: oboTokenResolver, oboTrustChecker: oboTrustChecker, + upstreamTokenProvider: upstreamTokenProvider, + oboIdentityContext, graphTokenResolver, returnOnOAuth: returnOnOAuth, requestBody: requestBody, diff --git a/packages/api/src/mcp/__tests__/MCPConnectionFactory.test.ts b/packages/api/src/mcp/__tests__/MCPConnectionFactory.test.ts index 991690725b..6e2b5e3477 100644 --- a/packages/api/src/mcp/__tests__/MCPConnectionFactory.test.ts +++ b/packages/api/src/mcp/__tests__/MCPConnectionFactory.test.ts @@ -3,8 +3,8 @@ import type { TokenMethods, IUser } from '@librechat/data-schemas'; import type { FlowStateManager } from '~/flow/manager'; import type { MCPOAuthTokens } from '~/mcp/oauth'; import type * as t from '~/mcp/types'; +import { MCPOAuthHandler, MCPTokenStorage, OboTokenResolutionError } from '~/mcp/oauth'; import { MCPConnectionFactory } from '~/mcp/MCPConnectionFactory'; -import { MCPOAuthHandler, MCPTokenStorage } from '~/mcp/oauth'; import { preProcessGraphTokens } from '~/utils/graph'; import { PENDING_STALE_MS } from '~/flow/manager'; import { MCPConnection } from '~/mcp/connection'; @@ -16,7 +16,15 @@ jest.mock('~/utils/graph', () => ({ ...jest.requireActual('~/utils/graph'), preProcessGraphTokens: jest.fn(async (options) => options), })); -jest.mock('~/utils'); +jest.mock('~/utils', () => { + const { isEnabled } = jest.requireActual('~/utils/common'); + const { math } = jest.requireActual('~/utils/math'); + return { + isEnabled, + math, + processMCPEnv: jest.fn(), + }; +}); jest.mock('@librechat/data-schemas', () => ({ logger: { info: jest.fn(), @@ -836,6 +844,10 @@ describe('MCPConnectionFactory', () => { }); describe('OAuth event handling', () => { + beforeEach(() => { + mockConnectionInstance.connect.mockRejectedValue(new Error('OAuth authentication required')); + }); + it('should handle oauthRequired event for returnOnOAuth scenario', async () => { const basicOptions = { serverName: 'test-server', @@ -4084,6 +4096,66 @@ describe('MCPConnectionFactory', () => { expect(result.connection).toBeNull(); }); + it('should degrade OBO resolution failures to unauthenticated discovery', async () => { + const { resolveOboToken } = jest.requireMock('~/mcp/oauth') as { + resolveOboToken: jest.Mock; + }; + const serverConfig = { + type: 'sse' as const, + url: 'https://obo.example.com', + requiresOAuth: false, + obo: { scopes: 'api://obo-server/Mcp.Tools.ReadWrite' }, + } as unknown as t.MCPOptions; + const oboError = new OboTokenResolutionError( + 'session_refresh_failed', + 'Your sign-in session expired and could not be refreshed. Please sign in again.', + ); + + mockProcessMCPEnv.mockReturnValue(serverConfig); + resolveOboToken.mockRejectedValueOnce(oboError); + mockConnectionInstance.connect.mockResolvedValue(undefined); + mockConnectionInstance.isConnected.mockResolvedValue(true); + mockConnectionInstance.fetchOrderedToolsSnapshot = jest.fn().mockResolvedValue({ + tools: mockTools, + complete: true, + }); + mockConnectionInstance.dispose = jest.fn().mockResolvedValue(undefined); + + const result = await MCPConnectionFactory.discoverTools( + { serverName: 'obo-srv', serverConfig }, + { + useOAuth: true, + user: mockUser as IUser, + flowManager: mockFlowManager, + tokenMethods: { + findToken: jest.fn(), + createToken: jest.fn(), + updateToken: jest.fn(), + deleteTokens: jest.fn(), + }, + oboTokenResolver: jest.fn(), + upstreamTokenProvider: jest.fn(), + }, + ); + + expect(result.tools).toEqual(mockTools); + expect(result.connection).toBeNull(); + expect(result.oauthRequired).toBe(true); + expect(result.oauthUrl).toBeNull(); + expect(mockMCPConnection).toHaveBeenCalledTimes(1); + expect(mockMCPConnection).toHaveBeenCalledWith( + expect.objectContaining({ + oauthTokens: null, + }), + ); + expect(mockLogger.debug).toHaveBeenCalledWith( + expect.stringContaining( + '[Discovery] OBO token resolution failed, attempting unauthenticated tool listing', + ), + oboError, + ); + }); + it('should return null tools when discovery fails completely', async () => { const basicOptions = { serverName: 'test-server', @@ -4546,4 +4618,87 @@ describe('MCPConnectionFactory', () => { expect(mockMCPOAuthHandler.initiateOAuthFlow).not.toHaveBeenCalled(); }); }); + + describe('OBO upstreamTokenProvider plumbing', () => { + const oboServerConfig = { + type: 'sse' as const, + url: 'https://obo.example.com', + requiresOAuth: false, + obo: { scopes: 'api://obo-server/Mcp.Tools.ReadWrite' }, + } as unknown as t.MCPOptions; + + beforeEach(() => { + mockProcessMCPEnv.mockReturnValue(oboServerConfig); + mockConnectionInstance.connect.mockResolvedValue(undefined); + mockConnectionInstance.isConnected.mockResolvedValue(true); + }); + + it('forwards upstreamTokenProvider into resolveOboToken at connection establishment', async () => { + const { resolveOboToken } = jest.requireMock('~/mcp/oauth') as { + resolveOboToken: jest.Mock; + }; + resolveOboToken.mockResolvedValue({ + access_token: 'connection-time-obo-token', + token_type: 'Bearer', + obtained_at: Date.now(), + expires_at: Date.now() + 3600_000, + }); + + const upstreamTokenProvider = jest.fn(); + const oboTokenResolver = jest.fn(); + + await MCPConnectionFactory.create( + { serverName: 'obo-srv', serverConfig: oboServerConfig }, + { + useOAuth: true, + user: mockUser, + flowManager: mockFlowManager, + tokenMethods: { + findToken: jest.fn(), + createToken: jest.fn(), + updateToken: jest.fn(), + deleteTokens: jest.fn(), + }, + oboTokenResolver, + upstreamTokenProvider, + }, + ); + + expect(resolveOboToken).toHaveBeenCalledWith( + mockUser, + oboServerConfig.obo, + oboTokenResolver, + upstreamTokenProvider, + undefined, + ); + }); + + it('throws an internal error when upstreamTokenProvider is omitted on an OBO connection', async () => { + const { resolveOboToken } = jest.requireMock('~/mcp/oauth') as { + resolveOboToken: jest.Mock; + }; + resolveOboToken.mockClear(); + const oboTokenResolver = jest.fn(); + + await expect( + MCPConnectionFactory.create( + { serverName: 'obo-srv', serverConfig: oboServerConfig }, + { + useOAuth: true, + user: mockUser, + flowManager: mockFlowManager, + tokenMethods: { + findToken: jest.fn(), + createToken: jest.fn(), + updateToken: jest.fn(), + deleteTokens: jest.fn(), + }, + oboTokenResolver, + /** upstreamTokenProvider intentionally omitted */ + }, + ), + ).rejects.toThrow(/upstreamTokenProvider not plumbed/); + expect(resolveOboToken).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/api/src/mcp/__tests__/MCPManager.test.ts b/packages/api/src/mcp/__tests__/MCPManager.test.ts index 530bd71c2f..64e1dc7131 100644 --- a/packages/api/src/mcp/__tests__/MCPManager.test.ts +++ b/packages/api/src/mcp/__tests__/MCPManager.test.ts @@ -2576,6 +2576,11 @@ describe('MCPManager', () => { } as unknown as MCPConnection; const mockOboTokenResolver = jest.fn(); + const mockUpstreamTokenProvider = jest.fn().mockResolvedValue({ + access_token: 'live-access-token', + id_token: 'live-id-token', + expires_at: Math.floor(Date.now() / 1000) + 3600, + }); const serverConfig: t.SSEOptions & { obo: { scopes: string } } = { type: 'sse', @@ -2590,6 +2595,7 @@ describe('MCPManager', () => { beforeEach(() => { mockResolveOboToken.mockReset(); + mockUpstreamTokenProvider.mockClear(); }); it('should bypass shared app connections for OBO servers and use a user-scoped connection', async () => { @@ -2645,6 +2651,7 @@ describe('MCPManager', () => { typeof manager.callTool >[0]['flowManager'], oboTokenResolver: mockOboTokenResolver, + upstreamTokenProvider: mockUpstreamTokenProvider, }); expect(appConnections.get).not.toHaveBeenCalled(); @@ -2695,12 +2702,15 @@ describe('MCPManager', () => { typeof manager.callTool >[0]['flowManager'], oboTokenResolver: mockOboTokenResolver, + upstreamTokenProvider: mockUpstreamTokenProvider, }); expect(mockResolveOboToken).toHaveBeenCalledWith( mockUser, serverConfig.obo, mockOboTokenResolver, + mockUpstreamTokenProvider, + undefined, ); expect(appConnections.get).not.toHaveBeenCalled(); expect(getUserConnectionSpy).toHaveBeenCalled(); @@ -2744,6 +2754,7 @@ describe('MCPManager', () => { typeof manager.callTool >[0]['flowManager'], oboTokenResolver: mockOboTokenResolver, + upstreamTokenProvider: mockUpstreamTokenProvider, }), ).rejects.toMatchObject({ message: expect.stringContaining('Temporary OBO token exchange failure.'), @@ -2790,6 +2801,7 @@ describe('MCPManager', () => { typeof manager.callTool >[0]['flowManager'], oboTokenResolver: mockOboTokenResolver, + upstreamTokenProvider: mockUpstreamTokenProvider, }), ).rejects.toMatchObject({ message: expect.stringContaining('verify the configured OBO scopes'), @@ -2800,6 +2812,35 @@ describe('MCPManager', () => { expect(mockConnection.setRequestHeaders).not.toHaveBeenCalled(); expect(mockConnection.client.request).not.toHaveBeenCalled(); }); + + it('should fail with an internal error when upstreamTokenProvider is omitted on an OBO call', async () => { + const appConnections = { + get: jest.fn().mockResolvedValue(mockConnection), + }; + + mockAppConnections(appConnections); + (mockRegistryInstance.getServerConfig as jest.Mock).mockResolvedValue(serverConfig); + + const manager = await MCPManager.createInstance(newMCPServersConfig()); + jest.spyOn(manager, 'getUserConnection').mockResolvedValue(mockConnection); + + await expect( + manager.callTool({ + user: mockUser as IUser, + serverName, + toolName: 'test_tool', + provider: 'openai', + flowManager: mockFlowManager as unknown as Parameters< + typeof manager.callTool + >[0]['flowManager'], + oboTokenResolver: mockOboTokenResolver, + /** upstreamTokenProvider intentionally omitted */ + }), + ).rejects.toMatchObject({ + message: expect.stringContaining('upstreamTokenProvider not plumbed'), + }); + expect(mockResolveOboToken).not.toHaveBeenCalled(); + }); }); describe('getConnection', () => { diff --git a/packages/api/src/mcp/oauth/obo.spec.ts b/packages/api/src/mcp/oauth/obo.spec.ts index 039f13da7d..9a837694b7 100644 --- a/packages/api/src/mcp/oauth/obo.spec.ts +++ b/packages/api/src/mcp/oauth/obo.spec.ts @@ -1,6 +1,6 @@ -import type { IUser } from '@librechat/data-schemas'; import { Permissions, PermissionTypes } from 'librechat-data-provider'; -import type { OboTokenResolver } from './obo'; +import type { IUser } from '@librechat/data-schemas'; +import type { OboTokenResolver, UpstreamTokenProvider } from './obo'; import { isOboConfigStillTrusted, resolveOboToken } from './obo'; jest.mock('@librechat/data-schemas', () => ({ @@ -12,14 +12,26 @@ jest.mock('@librechat/data-schemas', () => ({ })); jest.mock('~/utils/oidc', () => ({ - extractOpenIDTokenInfo: jest.fn(), isOpenIDTokenValid: jest.fn(), + extractOpenIDTokenInfo: jest.fn(), })); -import { extractOpenIDTokenInfo, isOpenIDTokenValid } from '~/utils/oidc'; +import { isOpenIDTokenValid, extractOpenIDTokenInfo } from '~/utils/oidc'; -const mockExtractOpenIDTokenInfo = extractOpenIDTokenInfo as jest.Mock; const mockIsOpenIDTokenValid = isOpenIDTokenValid as jest.Mock; +const mockExtractOpenIDTokenInfo = extractOpenIDTokenInfo as jest.Mock; + +const farFutureExp = Math.floor(Date.now() / 1000) + 3600; + +const liveTokens = { + access_token: 'live-access-token', + id_token: 'live-id-token', + refresh_token: 'live-refresh-token', + expires_at: farFutureExp, +}; + +const liveProvider: UpstreamTokenProvider = jest.fn().mockResolvedValue(liveTokens); +const nullProvider: UpstreamTokenProvider = jest.fn().mockResolvedValue(null); describe('resolveOboToken', () => { const mockUser: Partial = { @@ -28,11 +40,6 @@ describe('resolveOboToken', () => { openidId: 'oidc-sub-456', email: 'test@example.com', name: 'Test User', - federatedTokens: { - access_token: 'federated-access-token', - id_token: 'federated-id-token', - expires_at: Math.floor(Date.now() / 1000) + 3600, - }, }; const oboConfig = { scopes: 'api://mcp-server-id/Mcp.Tools.ReadWrite' }; @@ -44,106 +51,198 @@ describe('resolveOboToken', () => { beforeEach(() => { jest.clearAllMocks(); - }); - - it('should throw when user has no valid OpenID token info', async () => { + mockIsOpenIDTokenValid.mockReturnValue(true); + /** Default: no federated-token fallback unless a test opts in. */ mockExtractOpenIDTokenInfo.mockReturnValue(null); + (liveProvider as jest.Mock).mockResolvedValue(liveTokens); + (mockResolver as jest.Mock).mockResolvedValue({ + access_token: 'exchanged-mcp-token', + expires_in: 3600, + }); + }); - await expect(resolveOboToken(mockUser as IUser, oboConfig, mockResolver)).rejects.toMatchObject( - { - reason: 'missing_upstream_token', - retryable: false, - }, - ); + it('throws missing_upstream_token when provider returns null and no federated fallback', async () => { + await expect( + resolveOboToken(mockUser as IUser, oboConfig, mockResolver, nullProvider), + ).rejects.toMatchObject({ + reason: 'missing_upstream_token', + retryable: false, + }); expect(mockResolver).not.toHaveBeenCalled(); }); - it('should throw when OpenID token is not valid (expired)', async () => { - mockExtractOpenIDTokenInfo.mockReturnValue({ accessToken: 'some-token' }); - mockIsOpenIDTokenValid.mockReturnValue(false); - - await expect(resolveOboToken(mockUser as IUser, oboConfig, mockResolver)).rejects.toMatchObject( - { - reason: 'missing_upstream_token', - retryable: false, - }, - ); - expect(mockResolver).not.toHaveBeenCalled(); - }); - - it('should throw when access token is missing from token info', async () => { - mockExtractOpenIDTokenInfo.mockReturnValue({ userId: 'user-123' }); + it('falls back to user.federatedTokens for the OBO exchange when provider returns null', async () => { + /** OIDC remote-agent flow: bearer token on the user, no Express session. */ + mockExtractOpenIDTokenInfo.mockReturnValue({ + accessToken: 'federated-access-token', + idToken: 'federated-id-token', + expiresAt: farFutureExp, + userId: 'oidc-sub-456', + }); mockIsOpenIDTokenValid.mockReturnValue(true); - await expect(resolveOboToken(mockUser as IUser, oboConfig, mockResolver)).rejects.toMatchObject( - { - reason: 'missing_upstream_access_token', - retryable: false, - }, - ); - expect(mockResolver).not.toHaveBeenCalled(); - }); - - it('should call the resolver with correct arguments and return MCPOAuthTokens', async () => { - mockExtractOpenIDTokenInfo.mockReturnValue({ accessToken: 'federated-access-token' }); - mockIsOpenIDTokenValid.mockReturnValue(true); - - const beforeCall = Date.now(); - const result = await resolveOboToken(mockUser as IUser, oboConfig, mockResolver); - const afterCall = Date.now(); + const result = await resolveOboToken(mockUser as IUser, oboConfig, mockResolver, nullProvider); + expect(mockExtractOpenIDTokenInfo).toHaveBeenCalledWith(mockUser); expect(mockResolver).toHaveBeenCalledWith( mockUser, 'federated-access-token', 'api://mcp-server-id/Mcp.Tools.ReadWrite', true, + undefined, ); - - expect(result).not.toBeNull(); - expect(result!.access_token).toBe('exchanged-mcp-token'); - expect(result!.token_type).toBe('Bearer'); - expect(result!.obtained_at).toBeGreaterThanOrEqual(beforeCall); - expect(result!.obtained_at).toBeLessThanOrEqual(afterCall); - expect(result!.expires_at).toBe(result!.obtained_at + 3600 * 1000); + expect(result.access_token).toBe('exchanged-mcp-token'); }); - it('should default expires_in to 3600 when not provided by resolver', async () => { - mockExtractOpenIDTokenInfo.mockReturnValue({ accessToken: 'federated-access-token' }); + it('throws missing_upstream_token when federated fallback token is invalid', async () => { + mockExtractOpenIDTokenInfo.mockReturnValue({ + accessToken: 'federated-access-token', + expiresAt: farFutureExp, + }); + mockIsOpenIDTokenValid.mockReturnValue(false); + + await expect( + resolveOboToken(mockUser as IUser, oboConfig, mockResolver, nullProvider), + ).rejects.toMatchObject({ + reason: 'missing_upstream_token', + retryable: false, + }); + expect(mockResolver).not.toHaveBeenCalled(); + }); + + it('throws session_refresh_failed when provider rejects', async () => { + const failingProvider: UpstreamTokenProvider = jest + .fn() + .mockRejectedValue(new Error('invalid_grant')); + + await expect( + resolveOboToken(mockUser as IUser, oboConfig, mockResolver, failingProvider), + ).rejects.toMatchObject({ + reason: 'session_refresh_failed', + retryable: false, + userMessage: expect.stringContaining('Please sign in again'), + }); + expect(mockResolver).not.toHaveBeenCalled(); + }); + + it('marks transient session refresh failures as retryable', async () => { + const failingProvider: UpstreamTokenProvider = jest + .fn() + .mockRejectedValue(Object.assign(new Error('service unavailable'), { status: 503 })); + + await expect( + resolveOboToken(mockUser as IUser, oboConfig, mockResolver, failingProvider), + ).rejects.toMatchObject({ + reason: 'session_refresh_failed', + retryable: true, + userMessage: 'Temporary sign-in session refresh failure.', + }); + expect(mockResolver).not.toHaveBeenCalled(); + }); + + it('throws missing_upstream_token when isOpenIDTokenValid returns false (live token expired)', async () => { + mockIsOpenIDTokenValid.mockReturnValue(false); + + await expect( + resolveOboToken(mockUser as IUser, oboConfig, mockResolver, liveProvider), + ).rejects.toMatchObject({ + reason: 'missing_upstream_token', + retryable: false, + }); + expect(mockResolver).not.toHaveBeenCalled(); + }); + + it('throws missing_upstream_access_token when live tokens lack an access_token', async () => { + (liveProvider as jest.Mock).mockResolvedValueOnce({ + access_token: undefined, + id_token: 'live-id-token', + expires_at: farFutureExp, + }); + /** isOpenIDTokenValid is mocked to true here to isolate the access_token guard */ mockIsOpenIDTokenValid.mockReturnValue(true); + await expect( + resolveOboToken(mockUser as IUser, oboConfig, mockResolver, liveProvider), + ).rejects.toMatchObject({ + reason: 'missing_upstream_access_token', + retryable: false, + }); + }); + + it('uses live token from provider for the OBO exchange and returns MCPOAuthTokens', async () => { + mockIsOpenIDTokenValid.mockReturnValue(true); + + const beforeCall = Date.now(); + const result = await resolveOboToken(mockUser as IUser, oboConfig, mockResolver, liveProvider); + const afterCall = Date.now(); + + expect(liveProvider).toHaveBeenCalledTimes(1); + expect(mockResolver).toHaveBeenCalledWith( + mockUser, + 'live-access-token', + 'api://mcp-server-id/Mcp.Tools.ReadWrite', + true, + undefined, + ); + + expect(result.access_token).toBe('exchanged-mcp-token'); + expect(result.token_type).toBe('Bearer'); + expect(result.obtained_at).toBeGreaterThanOrEqual(beforeCall); + expect(result.obtained_at).toBeLessThanOrEqual(afterCall); + expect(result.expires_at).toBe(result.obtained_at + 3570 * 1000); + }); + + it('defaults expires_in to 3600 when not provided by resolver', async () => { const resolverNoExpiry: OboTokenResolver = jest.fn().mockResolvedValue({ access_token: 'exchanged-token', }); - const result = await resolveOboToken(mockUser as IUser, oboConfig, resolverNoExpiry); + const result = await resolveOboToken( + mockUser as IUser, + oboConfig, + resolverNoExpiry, + liveProvider, + ); - expect(result).not.toBeNull(); - expect(result!.expires_at).toBe(result!.obtained_at + 3600 * 1000); + expect(result.expires_at).toBe(result.obtained_at + 3570 * 1000); }); - it('should throw when resolver returns no access_token', async () => { - mockExtractOpenIDTokenInfo.mockReturnValue({ accessToken: 'federated-access-token' }); - mockIsOpenIDTokenValid.mockReturnValue(true); - + it('throws when resolver returns no access_token', async () => { const emptyResolver: OboTokenResolver = jest.fn().mockResolvedValue({}); await expect( - resolveOboToken(mockUser as IUser, oboConfig, emptyResolver), + resolveOboToken(mockUser as IUser, oboConfig, emptyResolver, liveProvider), ).rejects.toMatchObject({ reason: 'empty_exchange_response', retryable: false, }); }); - it('should throw a retryable error when resolver reports a transient failure', async () => { - mockExtractOpenIDTokenInfo.mockReturnValue({ accessToken: 'federated-access-token' }); - mockIsOpenIDTokenValid.mockReturnValue(true); + it('preserves empty_exchange_response when the resolver rejects a malformed exchange', async () => { + const malformedError = Object.assign( + new Error('The identity provider returned no access token for the OBO exchange'), + { + oboFailureReason: 'empty_exchange_response', + retryable: false, + }, + ); + const emptyResolver: OboTokenResolver = jest.fn().mockRejectedValue(malformedError); + await expect( + resolveOboToken(mockUser as IUser, oboConfig, emptyResolver, liveProvider), + ).rejects.toMatchObject({ + reason: 'empty_exchange_response', + retryable: false, + userMessage: 'The identity provider returned no access token for the OBO exchange.', + }); + }); + + it('throws a retryable error when resolver reports a transient failure', async () => { const failingResolver: OboTokenResolver = jest .fn() .mockRejectedValue(Object.assign(new Error('temporary timeout'), { retryable: true })); await expect( - resolveOboToken(mockUser as IUser, oboConfig, failingResolver), + resolveOboToken(mockUser as IUser, oboConfig, failingResolver, liveProvider), ).rejects.toMatchObject({ reason: 'exchange_failed', retryable: true, @@ -151,16 +250,13 @@ describe('resolveOboToken', () => { }); }); - it('should throw a non-retryable error when resolver reports a permanent failure', async () => { - mockExtractOpenIDTokenInfo.mockReturnValue({ accessToken: 'federated-access-token' }); - mockIsOpenIDTokenValid.mockReturnValue(true); - + it('throws a non-retryable error when resolver reports a permanent failure', async () => { const failingResolver: OboTokenResolver = jest .fn() .mockRejectedValue(new Error('invalid_grant: assertion invalid')); await expect( - resolveOboToken(mockUser as IUser, oboConfig, failingResolver), + resolveOboToken(mockUser as IUser, oboConfig, failingResolver, liveProvider), ).rejects.toMatchObject({ reason: 'exchange_failed', retryable: false, @@ -168,34 +264,119 @@ describe('resolveOboToken', () => { }); }); - it('should use the correct scopes from oboConfig', async () => { - mockExtractOpenIDTokenInfo.mockReturnValue({ accessToken: 'federated-access-token' }); - mockIsOpenIDTokenValid.mockReturnValue(true); - + it('uses the correct scopes from oboConfig', async () => { const customConfig = { scopes: 'api://other-app/Custom.Scope' }; - await resolveOboToken(mockUser as IUser, customConfig, mockResolver); + await resolveOboToken(mockUser as IUser, customConfig, mockResolver, liveProvider); expect(mockResolver).toHaveBeenCalledWith( mockUser, - 'federated-access-token', + 'live-access-token', 'api://other-app/Custom.Scope', true, + undefined, ); }); - it('should respect custom expires_in from resolver', async () => { - mockExtractOpenIDTokenInfo.mockReturnValue({ accessToken: 'federated-access-token' }); - mockIsOpenIDTokenValid.mockReturnValue(true); + it('forwards identity context to the OBO resolver', async () => { + const identityContext = { + openidSubject: 'oidc-sub-456', + tenantId: 'tenant-a', + openidIssuer: 'https://issuer.example.com', + }; + await resolveOboToken( + mockUser as IUser, + oboConfig, + mockResolver, + liveProvider, + identityContext, + ); + + expect(mockResolver).toHaveBeenCalledWith( + mockUser, + 'live-access-token', + 'api://mcp-server-id/Mcp.Tools.ReadWrite', + true, + identityContext, + ); + }); + + it('respects custom expires_in from resolver', async () => { const shortLivedResolver: OboTokenResolver = jest.fn().mockResolvedValue({ access_token: 'short-lived-token', expires_in: 300, }); - const result = await resolveOboToken(mockUser as IUser, oboConfig, shortLivedResolver); + const result = await resolveOboToken( + mockUser as IUser, + oboConfig, + shortLivedResolver, + liveProvider, + ); - expect(result).not.toBeNull(); - expect(result!.expires_at).toBe(result!.obtained_at + 300 * 1000); + expect(result.expires_at).toBe(result.obtained_at + 270 * 1000); + }); + + it('respects absolute expires_at from cached resolver responses', async () => { + const absoluteExpiresAt = Date.now() + 120 * 1000; + const cachedResolver: OboTokenResolver = jest.fn().mockResolvedValue({ + access_token: 'cached-token', + expires_in: 3600, + expires_at: absoluteExpiresAt, + }); + + const result = await resolveOboToken( + mockUser as IUser, + oboConfig, + cachedResolver, + liveProvider, + ); + + expect(result.expires_at).toBe(absoluteExpiresAt - 30 * 1000); + }); + + /** A credential the IdP declares already spent must not reach the MCP connection: `callTool` + * checks only for an access token before setting the Authorization header. */ + it.each([0, -60])( + 'rejects an exchange response whose declared expiry has already elapsed: %s', + async (expiresIn) => { + const expiredResolver: OboTokenResolver = jest.fn().mockResolvedValue({ + access_token: 'already-expired-token', + expires_in: expiresIn, + }); + + await expect( + resolveOboToken(mockUser as IUser, oboConfig, expiredResolver, liveProvider), + ).rejects.toMatchObject({ reason: 'exchange_failed', retryable: true }); + }, + ); + + it('rejects an exchange response carrying an absolute expiry in the past', async () => { + const expiredResolver: OboTokenResolver = jest.fn().mockResolvedValue({ + access_token: 'already-expired-token', + expires_in: 3600, + expires_at: Date.now() - 60_000, + }); + + await expect( + resolveOboToken(mockUser as IUser, oboConfig, expiredResolver, liveProvider), + ).rejects.toMatchObject({ reason: 'exchange_failed' }); + }); + + it('keeps a positive expires_at lifetime for very short-lived OBO tokens', async () => { + const shortLivedResolver: OboTokenResolver = jest.fn().mockResolvedValue({ + access_token: 'very-short-lived-token', + expires_in: 10, + }); + + const result = await resolveOboToken( + mockUser as IUser, + oboConfig, + shortLivedResolver, + liveProvider, + ); + + expect(result.expires_at).toBe(result.obtained_at + 1000); }); }); diff --git a/packages/api/src/mcp/oauth/obo.ts b/packages/api/src/mcp/oauth/obo.ts index 91b994a682..a87e444e86 100644 --- a/packages/api/src/mcp/oauth/obo.ts +++ b/packages/api/src/mcp/oauth/obo.ts @@ -1,8 +1,12 @@ import { logger } from '@librechat/data-schemas'; import { Permissions, PermissionTypes } from 'librechat-data-provider'; -import type { IUser } from '@librechat/data-schemas'; -import { extractOpenIDTokenInfo, isOpenIDTokenValid } from '~/utils/oidc'; +import type { IUser, OIDCTokens } from '@librechat/data-schemas'; +import type { TRole } from 'librechat-data-provider'; +import type { AuthIdentityContext } from '~/utils/identity'; +import type { OpenIDTokenInfo } from '~/utils/oidc'; import type { MCPOAuthTokens } from './types'; +import { getSkewedTokenExpiresAtMs, getTokenExpiresAtMs } from '~/oauth/expiry'; +import { extractOpenIDTokenInfo, isOpenIDTokenValid } from '~/utils/oidc'; export interface OboConfig { scopes: string; @@ -17,13 +21,31 @@ export type OboTokenResolver = ( accessToken: string, scopes: string, fromCache?: boolean, -) => Promise<{ access_token: string; expires_in?: number }>; + identityContext?: AuthIdentityContext, +) => Promise<{ access_token: string; expires_in?: number; expires_at?: number }>; + +/** + * Provides the LIVE upstream OpenID tokens at OBO call time, refreshing the + * server-side session via the IdP refresh-token grant when the access token + * has expired. Closes over the active Express request so it can read/write + * `req.session.openidTokens` in place. + * + * Contract: + * - non-null result: `access_token` MUST be populated; the closure enforces + * this internally so callers do not defend against missing access_token. + * - null: not applicable, or a bearer-authenticated remote-agent request whose + * current upstream token can be read from `user.federatedTokens`. + * - throws: refresh was attempted and the IdP rejected it. Caller wraps as + * `session_refresh_failed`. + */ +export type UpstreamTokenProvider = () => Promise; export type OboTokenResolutionReason = | 'missing_upstream_token' | 'missing_upstream_access_token' | 'empty_exchange_response' - | 'exchange_failed'; + | 'exchange_failed' + | 'session_refresh_failed'; const RETRYABLE_OBO_STATUS_CODES = new Set([429, 500, 502, 503, 504]); const RETRYABLE_OBO_ERROR_CODES = new Set(['ETIMEDOUT', 'ECONNRESET', 'EAI_AGAIN', 'ENOTFOUND']); @@ -60,6 +82,19 @@ function getErrorRetryableFlag(error: unknown): boolean | undefined { return typeof retryable === 'boolean' ? retryable : undefined; } +function getOboFailureReason(error: unknown): OboTokenResolutionReason | undefined { + if (!error || typeof error !== 'object' || !('oboFailureReason' in error)) { + return undefined; + } + + const reason = (error as { oboFailureReason?: unknown }).oboFailureReason; + if (reason === 'empty_exchange_response') { + return reason; + } + + return undefined; +} + export class OboTokenResolutionError extends Error { public readonly reason: OboTokenResolutionReason; public readonly retryable: boolean; @@ -113,19 +148,78 @@ function isRetryableOboExchangeError(error: unknown): boolean { ); } +/** + * Resolves the upstream OpenID token info used for the OBO exchange. The live + * session (via `upstreamTokenProvider`) is the preferred source because it can + * inline-refresh an expired access token. When no live session exists β€” the + * OIDC remote-agent flow verifies a bearer token and attaches it to + * `user.federatedTokens` without an Express session β€” fall back to the token + * snapshot on the user via `extractOpenIDTokenInfo`. Returns null when neither + * source yields a token; the caller maps that to `missing_upstream_token`. + */ +function buildUpstreamTokenInfo( + user: IUser, + liveTokens: OIDCTokens | null, +): OpenIDTokenInfo | null { + if (liveTokens) { + return { + accessToken: liveTokens.access_token, + idToken: liveTokens.id_token, + expiresAt: liveTokens.expires_at, + userId: user.openidId || user.id, + userEmail: user.email, + userName: user.name || user.username, + }; + } + return extractOpenIDTokenInfo(user); +} + /** * Performs an OBO token exchange for the given user and MCP server OBO config. * Returns MCPOAuthTokens suitable for injection into the MCP connection. + * + * The `upstreamTokenProvider` closure is the authoritative source of the user's + * upstream OpenID access token at call time β€” it reads from the live session and + * may inline-refresh via the IdP refresh-token grant when the token has expired. + * This avoids relying on a stale snapshot frozen onto `user.federatedTokens` at + * request validation, which is what previously caused the walk-away failure mode + * ("No valid OpenID access token is available for OBO exchange") on long-running + * tool calls. Required (not optional) so wiring bugs surface at compile time. + * + * When the provider yields no live session (it resolves to null), this falls + * back to `user.federatedTokens` so the OIDC remote-agent flow β€” whose request + * itself carries that verified upstream bearer β€” still works. Browser requests + * whose Express session was cleared reject in the provider instead of reaching + * this fallback with a stale strategy-time snapshot. */ export async function resolveOboToken( user: IUser, oboConfig: OboConfig, oboTokenResolver: OboTokenResolver, + upstreamTokenProvider: UpstreamTokenProvider, + identityContext?: AuthIdentityContext, ): Promise { - const tokenInfo = extractOpenIDTokenInfo(user); + let liveTokens: OIDCTokens | null; + try { + liveTokens = await upstreamTokenProvider(); + } catch (error) { + logger.error('[OBO] Upstream session refresh failed:', error); + const retryable = isRetryableOboExchangeError(error); + throw new OboTokenResolutionError( + 'session_refresh_failed', + retryable + ? 'Temporary sign-in session refresh failure.' + : 'Your sign-in session expired and could not be refreshed. Please sign in again.', + retryable, + error, + ); + } + + const tokenInfo = buildUpstreamTokenInfo(user, liveTokens); + if (!tokenInfo || !isOpenIDTokenValid(tokenInfo)) { logger.warn( - `[OBO] No valid OpenID token available for OBO exchange (provider: ${user.provider}, hasOpenidId: ${!!user.openidId}, hasFederatedTokens: ${!!user.federatedTokens})`, + `[OBO] No valid OpenID token available for OBO exchange (provider: ${user.provider}, hasOpenidId: ${!!user.openidId}, hasFederatedTokens: ${!!user.federatedTokens}, hadLiveSession: ${!!liveTokens})`, ); throw new OboTokenResolutionError( 'missing_upstream_token', @@ -142,7 +236,13 @@ export async function resolveOboToken( } try { - const response = await oboTokenResolver(user, tokenInfo.accessToken, oboConfig.scopes, true); + const response = await oboTokenResolver( + user, + tokenInfo.accessToken, + oboConfig.scopes, + true, + identityContext, + ); if (!response?.access_token) { logger.warn('[OBO] Token exchange did not return an access token'); @@ -153,19 +253,51 @@ export async function resolveOboToken( } const now = Date.now(); - const expiresIn = response.expires_in ?? 3600; + const expiresAt = getTokenExpiresAtMs({ + expiresAt: response.expires_at, + expiresIn: response.expires_in, + now, + }); + + /** + * Preserving an elapsed expiry only helps if someone acts on it. `MCPManager.callTool` checks + * the access token and nothing else before setting the Authorization header, so a credential + * the IdP already declared spent would be sent downstream to fail there. Rejected here, where + * the reason is still known, and retryably: the exchange itself worked, so another attempt + * with a fresh grant can succeed. + */ + const skewedExpiresAt = getSkewedTokenExpiresAtMs(expiresAt, now); + if (skewedExpiresAt <= now) { + logger.warn('[OBO] Token exchange returned a credential that is already expired'); + throw new OboTokenResolutionError( + 'exchange_failed', + 'The identity provider returned an already-expired token for the OBO exchange.', + true, + ); + } return { access_token: response.access_token, token_type: 'Bearer', obtained_at: now, - expires_at: now + expiresIn * 1000, + expires_at: skewedExpiresAt, }; } catch (error) { if (error instanceof OboTokenResolutionError) { throw error; } + const failureReason = getOboFailureReason(error); + if (failureReason === 'empty_exchange_response') { + logger.warn('[OBO] Token exchange did not return an access token'); + throw new OboTokenResolutionError( + failureReason, + 'The identity provider returned no access token for the OBO exchange.', + false, + error, + ); + } + logger.error('[OBO] Failed to exchange token:', error); const retryable = isRetryableOboExchangeError(error); throw new OboTokenResolutionError( @@ -193,9 +325,11 @@ export async function resolveOboToken( * - role missing the CONFIGURE_OBO bit */ export type GetUserRoleByAuthorId = (authorId: string) => Promise; -export type GetRolePermissions = ( - roleName: string, -) => Promise> | null | undefined>; +type RolePermissions = Partial<{ + [K in keyof TRole['permissions']]: Partial; +}>; + +export type GetRolePermissions = (roleName: string) => Promise; export async function isOboConfigStillTrusted({ authorId, @@ -219,7 +353,7 @@ export async function isOboConfigStillTrusted({ if (!roleName) { return false; } - let permissions: Record> | null | undefined; + let permissions: RolePermissions | null | undefined; try { permissions = await getRolePermissions(roleName); } catch (err) { diff --git a/packages/api/src/mcp/registry/MCPServersRegistry.ts b/packages/api/src/mcp/registry/MCPServersRegistry.ts index e3382f3bbb..ad2eedf0ef 100644 --- a/packages/api/src/mcp/registry/MCPServersRegistry.ts +++ b/packages/api/src/mcp/registry/MCPServersRegistry.ts @@ -106,6 +106,7 @@ const ADMIN_CONFIGURABLE_FIELDS = [ 'apiKey', 'oauth', 'oauth_headers', + 'obo', 'title', 'description', 'iconPath', diff --git a/packages/api/src/mcp/registry/__tests__/MCPServersInitializer.test.ts b/packages/api/src/mcp/registry/__tests__/MCPServersInitializer.test.ts index d5a97b239b..19379e39b4 100644 --- a/packages/api/src/mcp/registry/__tests__/MCPServersInitializer.test.ts +++ b/packages/api/src/mcp/registry/__tests__/MCPServersInitializer.test.ts @@ -533,4 +533,41 @@ describe('MCPServersInitializer', () => { expect(mockInspect).not.toHaveBeenCalled(); }); }); + + describe('OBO initialization without browser token reuse', () => { + const oboConfigs: t.MCPServers = { + obo_server: { + type: 'streamable-http', + url: 'https://obo.example.com/mcp', + obo: { scopes: 'api://obo-server/Mcp.Tools.ReadWrite' }, + } as unknown as t.MCPOptions, + }; + + const originalReuse = process.env.OPENID_REUSE_TOKENS; + + afterEach(() => { + if (originalReuse == null) { + delete process.env.OPENID_REUSE_TOKENS; + } else { + process.env.OPENID_REUSE_TOKENS = originalReuse; + } + }); + + it('does not declare OBO unusable because bearer-auth flows remain valid', async () => { + delete process.env.OPENID_REUSE_TOKENS; + mockInspect.mockImplementationOnce( + async (_n, raw) => + ({ + ...raw, + requiresOAuth: false, + }) as unknown as t.ParsedServerConfig, + ); + + await MCPServersInitializer.initialize(oboConfigs); + + const warnCalls = mockLogger.warn.mock.calls.flat().join(' | '); + expect(warnCalls).not.toMatch(/OBO is configured/); + expect(await registry.getServerConfig('obo_server')).toBeDefined(); + }); + }); }); diff --git a/packages/api/src/mcp/registry/__tests__/ensureConfigServers.test.ts b/packages/api/src/mcp/registry/__tests__/ensureConfigServers.test.ts index 933ff7cec2..bbee631e60 100644 --- a/packages/api/src/mcp/registry/__tests__/ensureConfigServers.test.ts +++ b/packages/api/src/mcp/registry/__tests__/ensureConfigServers.test.ts @@ -153,6 +153,23 @@ describe('MCPServersRegistry β€” ensureConfigServers', () => { expect(inspectSpy).toHaveBeenCalledTimes(1); }); + it('should lazy-init YAML server when admin overrides only the OBO field', async () => { + await registry.addServer('yaml_remote', sseConfig, 'CACHE'); + inspectSpy.mockClear(); + + const overrideConfig: t.MCPOptions = { + ...sseConfig, + obo: { scopes: 'api://mcp-server/Mcp.Tools.ReadWrite' }, + }; + const result = await registry.ensureConfigServers({ + yaml_remote: overrideConfig, + }); + + expect(result).toHaveProperty('yaml_remote'); + expect(result.yaml_remote.obo).toEqual({ scopes: 'api://mcp-server/Mcp.Tools.ReadWrite' }); + expect(inspectSpy).toHaveBeenCalledTimes(1); + }); + it('should not re-init YAML server when only the difference is an inspector-derived field absent from rawConfig', async () => { const yamlWithInferred: t.MCPOptions = { ...sseConfig, diff --git a/packages/api/src/mcp/types/index.ts b/packages/api/src/mcp/types/index.ts index e823f421b1..1dba82518c 100644 --- a/packages/api/src/mcp/types/index.ts +++ b/packages/api/src/mcp/types/index.ts @@ -19,7 +19,8 @@ import type { import type { SearchResultData, UIResource, TPlugin } from 'librechat-data-provider'; import type { TokenMethods, IUser } from '@librechat/data-schemas'; import type { LCTool } from '@librechat/agents'; -import type { OboTokenResolver, OboTrustChecker } from '~/mcp/oauth/obo'; +import type { OboTokenResolver, OboTrustChecker, UpstreamTokenProvider } from '~/mcp/oauth/obo'; +import type { AuthIdentityContext } from '~/utils/identity'; import type { GraphTokenResolver } from '~/utils/graph'; import type { FlowStateManager } from '~/flow/manager'; import type { RequestBody } from '~/types/http'; @@ -250,6 +251,8 @@ export interface OAuthConnectionOptions extends UserConnectionContext { returnOnOAuth?: boolean; oboTokenResolver?: OboTokenResolver; oboTrustChecker?: OboTrustChecker; + upstreamTokenProvider?: UpstreamTokenProvider; + oboIdentityContext?: AuthIdentityContext; } /** Options accepted by UserConnectionManager.getUserConnection. OAuth fields are optional. */ @@ -266,6 +269,8 @@ export interface UserMCPConnectionOptions extends UserConnectionContext { returnOnOAuth?: boolean; oboTokenResolver?: OboTokenResolver; oboTrustChecker?: OboTrustChecker; + upstreamTokenProvider?: UpstreamTokenProvider; + oboIdentityContext?: AuthIdentityContext; } export interface ToolDiscoveryOptions { @@ -283,6 +288,8 @@ export interface ToolDiscoveryOptions { configServers?: Record; oboTokenResolver?: OboTokenResolver; oboTrustChecker?: OboTrustChecker; + upstreamTokenProvider?: UpstreamTokenProvider; + oboIdentityContext?: AuthIdentityContext; } export interface ToolDiscoveryResult { diff --git a/packages/api/src/oauth/csrf.spec.ts b/packages/api/src/oauth/csrf.spec.ts index 1199dea297..5fb21a886a 100644 --- a/packages/api/src/oauth/csrf.spec.ts +++ b/packages/api/src/oauth/csrf.spec.ts @@ -1,4 +1,13 @@ -import { shouldUseSecureCookie } from './csrf'; +import crypto from 'crypto'; +import jwt from 'jsonwebtoken'; +import { + shouldUseSecureCookie, + setRefreshTokenCookie, + setOpenIDMarkerCookies, + REFRESH_TOKEN_COOKIE, + TOKEN_PROVIDER_COOKIE, + OPENID_USER_ID_COOKIE, +} from './csrf'; describe('shouldUseSecureCookie', () => { const originalEnv = process.env; @@ -126,3 +135,181 @@ describe('shouldUseSecureCookie', () => { }); }); }); + +describe('setRefreshTokenCookie', () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + delete process.env.SESSION_COOKIE_SECURE; + }); + + afterAll(() => { + process.env = originalEnv; + }); + + it('writes the refresh token cookie with httpOnly + strict sameSite and the given expiry', () => { + process.env.NODE_ENV = 'production'; + process.env.DOMAIN_SERVER = 'https://myapp.example.com'; + const res = { cookie: jest.fn() } as unknown as import('express').Response; + const expires = new Date(Date.now() + 1000); + + setRefreshTokenCookie(res, 'rt-value', expires); + + expect(res.cookie).toHaveBeenCalledWith(REFRESH_TOKEN_COOKIE, 'rt-value', { + expires, + httpOnly: true, + secure: true, + sameSite: 'strict', + }); + }); + + it('uses an insecure cookie on localhost', () => { + process.env.NODE_ENV = 'production'; + process.env.DOMAIN_SERVER = 'http://localhost:3080'; + const res = { cookie: jest.fn() } as unknown as import('express').Response; + + setRefreshTokenCookie(res, 'rt-value', new Date()); + + expect(res.cookie).toHaveBeenCalledWith( + REFRESH_TOKEN_COOKIE, + 'rt-value', + expect.objectContaining({ secure: false }), + ); + }); +}); + +describe('setOpenIDMarkerCookies', () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { + ...originalEnv, + JWT_REFRESH_SECRET: 'marker-secret', + OPENID_REUSE_TOKENS: 'true', + }; + delete process.env.SESSION_COOKIE_SECURE; + }); + + afterAll(() => { + process.env = originalEnv; + }); + + it('writes OpenID provider and signed user-id marker cookies with the same expiry', () => { + process.env.NODE_ENV = 'production'; + process.env.DOMAIN_SERVER = 'https://myapp.example.com'; + const res = { cookie: jest.fn() } as unknown as import('express').Response; + const expires = new Date(Date.now() + 604800000); + + setOpenIDMarkerCookies(res, { + userId: 'user-123', + expires, + refreshExpiryMs: 604800000, + }); + + expect(res.cookie).toHaveBeenCalledWith(TOKEN_PROVIDER_COOKIE, 'openid', { + expires, + httpOnly: true, + secure: true, + sameSite: 'strict', + }); + expect(res.cookie).toHaveBeenCalledWith( + OPENID_USER_ID_COOKIE, + expect.any(String), + expect.objectContaining({ expires, secure: true }), + ); + + const signedUserId = (res.cookie as jest.Mock).mock.calls.find( + ([name]) => name === OPENID_USER_ID_COOKIE, + )?.[1]; + expect(jwt.verify(signedUserId, 'marker-secret')).toMatchObject({ id: 'user-123' }); + }); + + /** Preserves the marker's binding to the durable refresh-token session: a marker signed for one + * session must not stand in for another once the refresh token has rotated. */ + it('binds the signed user marker to the refresh token it was issued with', () => { + const res = { cookie: jest.fn() } as unknown as import('express').Response; + const expires = new Date(Date.now() + 604800000); + + setOpenIDMarkerCookies(res, { + userId: 'user-123', + expires, + refreshExpiryMs: 604800000, + refreshToken: 'the-refresh-token', + }); + + const signedUserId = (res.cookie as jest.Mock).mock.calls.find( + ([name]) => name === OPENID_USER_ID_COOKIE, + )?.[1]; + expect(jwt.verify(signedUserId, 'marker-secret')).toMatchObject({ + id: 'user-123', + refreshTokenHash: crypto.createHash('sha256').update('the-refresh-token').digest('base64url'), + }); + }); + + it('omits the binding when no refresh token is supplied', () => { + const res = { cookie: jest.fn() } as unknown as import('express').Response; + const expires = new Date(Date.now() + 604800000); + + setOpenIDMarkerCookies(res, { userId: 'user-123', expires, refreshExpiryMs: 604800000 }); + + const signedUserId = (res.cookie as jest.Mock).mock.calls.find( + ([name]) => name === OPENID_USER_ID_COOKIE, + )?.[1]; + expect(jwt.verify(signedUserId, 'marker-secret')).not.toHaveProperty('refreshTokenHash'); + }); + + it('updates token_provider even when the signed user marker is not applicable', () => { + const res = { cookie: jest.fn() } as unknown as import('express').Response; + const expires = new Date(Date.now() + 604800000); + + setOpenIDMarkerCookies(res, { + expires, + refreshExpiryMs: 604800000, + reuseTokens: false, + }); + + expect(res.cookie).toHaveBeenCalledTimes(1); + expect(res.cookie).toHaveBeenCalledWith( + TOKEN_PROVIDER_COOKIE, + 'openid', + expect.objectContaining({ expires }), + ); + }); + + it('uses integer seconds for fractional refresh expiry durations', () => { + const res = { cookie: jest.fn() } as unknown as import('express').Response; + const expires = new Date(Date.now() + 604800999); + + setOpenIDMarkerCookies(res, { + userId: 'user-123', + expires, + refreshExpiryMs: 604800999, + }); + + const signedUserId = (res.cookie as jest.Mock).mock.calls.find( + ([name]) => name === OPENID_USER_ID_COOKIE, + )?.[1]; + const payload = jwt.verify(signedUserId, 'marker-secret') as jwt.JwtPayload; + if (typeof payload.exp !== 'number' || typeof payload.iat !== 'number') { + throw new Error('Expected signed marker JWT to include numeric exp and iat'); + } + expect(payload.exp - payload.iat).toBe(604800); + }); + + it.each([0, -1000, 999, Number.NaN, Number.POSITIVE_INFINITY])( + 'throws when the refresh expiry duration is invalid: %p', + (refreshExpiryMs) => { + const res = { cookie: jest.fn() } as unknown as import('express').Response; + const expires = new Date(Date.now() + 999); + + expect(() => + setOpenIDMarkerCookies(res, { + userId: 'user-123', + expires, + refreshExpiryMs, + }), + ).toThrow('refreshExpiryMs must be a positive duration for OpenID marker cookies'); + }, + ); +}); diff --git a/packages/api/src/oauth/csrf.ts b/packages/api/src/oauth/csrf.ts index 802655f2ee..0912815dc4 100644 --- a/packages/api/src/oauth/csrf.ts +++ b/packages/api/src/oauth/csrf.ts @@ -1,4 +1,5 @@ import crypto from 'crypto'; +import jwt from 'jsonwebtoken'; import type { Request, Response, NextFunction } from 'express'; import { isEnabled } from '~/utils/common'; @@ -47,6 +48,83 @@ export function shouldUseSecureCookie(): boolean { return isProduction && !isLocalhost; } +export const REFRESH_TOKEN_COOKIE = 'refreshToken'; +export const TOKEN_PROVIDER_COOKIE = 'token_provider'; +export const OPENID_USER_ID_COOKIE = 'openid_user_id'; + +/** + * Writes the IdP refresh token to the `refreshToken` cookie. Single source of + * truth for the cookie's options so the login/refresh path + * (`setOpenIDAuthTokens`) and the inline OBO refresh path (`performIdpRefresh`) + * stay byte-for-byte in sync. The cookie outlives the (shorter) express-session + * cookie and is the fallback `refreshController` reads when the session copy is + * gone, so a rotated refresh token must land here too β€” otherwise a later + * session loss replays an invalidated token and signs the user out. + */ +export function setRefreshTokenCookie(res: Response, refreshToken: string, expires: Date): void { + res.cookie(REFRESH_TOKEN_COOKIE, refreshToken, { + expires, + httpOnly: true, + secure: shouldUseSecureCookie(), + sameSite: 'strict', + }); +} + +export interface OpenIDMarkerCookieOptions { + userId?: string | null; + expires: Date; + refreshExpiryMs: number; + reuseTokens?: boolean; + /** Binds the marker to the refresh token it was issued alongside. */ + refreshToken?: string | null; +} + +export function setOpenIDMarkerCookies( + res: Response, + { + userId, + expires, + refreshExpiryMs, + reuseTokens = isEnabled(process.env.OPENID_REUSE_TOKENS), + refreshToken, + }: OpenIDMarkerCookieOptions, +): void { + const cookieOptions = { + expires, + httpOnly: true, + secure: shouldUseSecureCookie(), + sameSite: 'strict' as const, + }; + + res.cookie(TOKEN_PROVIDER_COOKIE, 'openid', cookieOptions); + + if (!userId || !reuseTokens) { + return; + } + + const secret = process.env.JWT_REFRESH_SECRET; + if (!secret) { + throw new Error('JWT_REFRESH_SECRET is required for OpenID marker cookies'); + } + + const refreshExpirySeconds = Math.floor(refreshExpiryMs / 1000); + if (!Number.isFinite(refreshExpirySeconds) || refreshExpirySeconds <= 0) { + throw new Error('refreshExpiryMs must be a positive duration for OpenID marker cookies'); + } + + /** Bind the marker to the durable refresh-token session it was issued with, so a + * marker lifted from one session cannot stand in for another's. */ + const refreshTokenHash = refreshToken + ? crypto.createHash('sha256').update(refreshToken).digest('base64url') + : undefined; + const signedUserId = jwt.sign( + refreshTokenHash ? { id: userId, refreshTokenHash } : { id: userId }, + secret, + { expiresIn: refreshExpirySeconds }, + ); + res.cookie(OPENID_USER_ID_COOKIE, signedUserId, cookieOptions); +} + /** Generates an HMAC-based token for OAuth CSRF protection */ export function generateOAuthCsrfToken(flowId: string, secret?: string): string { const key = secret || process.env.JWT_SECRET; diff --git a/packages/api/src/oauth/expiry.spec.ts b/packages/api/src/oauth/expiry.spec.ts index 3a489bfe8f..0e982da296 100644 --- a/packages/api/src/oauth/expiry.spec.ts +++ b/packages/api/src/oauth/expiry.spec.ts @@ -1,8 +1,13 @@ import { DEFAULT_OAUTH_TOKEN_TTL_SECONDS, + getSkewedTokenCacheTtlMs, + getSkewedTokenExpiresAtMs, getTokenCacheTtlMs, getTokenExpiresAt, + getTokenExpiresAtMs, + hasUsableTokenExpiry, normalizeExpiresIn, + OPENID_EXPIRY_BUFFER_SECONDS, } from './expiry'; describe('normalizeExpiresIn', () => { @@ -74,10 +79,29 @@ describe('normalizeExpiresIn', () => { }); describe('getTokenCacheTtlMs', () => { - it('converts a declared lifetime to milliseconds', () => { - expect(getTokenCacheTtlMs(1800, DEFAULT_OAUTH_TOKEN_TTL_SECONDS)).toBe(1_800_000); + it('converts a declared lifetime to milliseconds, less the in-transit buffer', () => { + expect(getTokenCacheTtlMs(1800, DEFAULT_OAUTH_TOKEN_TTL_SECONDS)).toBe( + (1800 - OPENID_EXPIRY_BUFFER_SECONDS) * 1000, + ); }); + /** The read side re-serves whatever is cached, so the entry must expire before the credential: + * a token handed out in its final seconds expires in transit and 401s downstream. */ + it('drops a credential from the cache before its last usable moment', () => { + expect(getTokenCacheTtlMs(3600, DEFAULT_OAUTH_TOKEN_TTL_SECONDS)).toBeLessThan(3_600_000); + expect(getTokenCacheTtlMs(3600, DEFAULT_OAUTH_TOKEN_TTL_SECONDS)).toBe(3_570_000); + }); + + /** A lifetime shorter than the buffer has no safe window left, but the credential is still real: + * it gets the minimum usable TTL rather than a negative one or the elapsed-credential floor. */ + it('floors a live lifetime shorter than the buffer instead of going negative', () => { + expect(getTokenCacheTtlMs(10, DEFAULT_OAUTH_TOKEN_TTL_SECONDS)).toBe(1000); + expect(getTokenCacheTtlMs(OPENID_EXPIRY_BUFFER_SECONDS, DEFAULT_OAUTH_TOKEN_TTL_SECONDS)).toBe( + 1000, + ); + }); + + /** An unknown lifetime has no declared expiry to protect, so the fallback is used unshortened */ it('falls back rather than returning NaN when the provider omits `expires_in`', () => { expect(getTokenCacheTtlMs(undefined, DEFAULT_OAUTH_TOKEN_TTL_SECONDS)).toBe(3_600_000); expect(getTokenCacheTtlMs(NaN, 60)).toBe(60_000); @@ -147,3 +171,76 @@ describe('getTokenExpiresAt', () => { } }); }); + +describe('getTokenExpiresAtMs', () => { + const now = 1_700_000_000_000; + + it('prefers an absolute expiry the caller already holds', () => { + expect(getTokenExpiresAtMs({ expiresAt: now + 120_000, expiresIn: 3600, now })).toBe( + now + 120_000, + ); + }); + + it('derives an expiry from the declared lifetime when no absolute one is given', () => { + expect(getTokenExpiresAtMs({ expiresIn: 300, now })).toBe(now + 300_000); + expect(getTokenExpiresAtMs({ expiresAt: NaN, expiresIn: '120', now })).toBe(now + 120_000); + }); + + it('falls back when the provider declares no usable lifetime', () => { + expect(getTokenExpiresAtMs({ expiresIn: undefined, now })).toBe( + now + DEFAULT_OAUTH_TOKEN_TTL_SECONDS * 1000, + ); + expect(getTokenExpiresAtMs({ expiresIn: 'abc', fallbackSeconds: 60, now })).toBe(now + 60_000); + }); + + /** An explicitly elapsed lifetime stays elapsed rather than taking the fallback */ + it('keeps an elapsed lifetime in the past', () => { + expect(getTokenExpiresAtMs({ expiresIn: 0, now })).toBe(now); + expect(getTokenExpiresAtMs({ expiresIn: -60, now })).toBe(now - 60_000); + }); +}); + +describe('skew helpers', () => { + const now = 1_700_000_000_000; + const bufferMs = OPENID_EXPIRY_BUFFER_SECONDS * 1000; + + it('pulls an expiry back by the in-transit buffer', () => { + const expiresAt = now + 120_000; + + expect(getSkewedTokenExpiresAtMs(expiresAt, now)).toBe(now + 120_000 - bufferMs); + expect(getSkewedTokenCacheTtlMs(expiresAt, now)).toBe(120_000 - bufferMs); + }); + + it('floors a live lifetime shorter than the buffer to a usable minimum, never 0', () => { + const expiresAt = now + 10_000; + + expect(getSkewedTokenExpiresAtMs(expiresAt, now)).toBe(now + 1000); + expect(getSkewedTokenCacheTtlMs(expiresAt, now)).toBe(1000); + }); + + /** The floor exists to keep a short-but-real credential usable, not to revive a dead one: a + * provider that declares an elapsed expiry must not have it stamped into the future. */ + it('leaves an already-elapsed expiry elapsed', () => { + expect(getSkewedTokenExpiresAtMs(now - 60_000, now)).toBe(now - 60_000); + expect(getSkewedTokenExpiresAtMs(now, now)).toBe(now); + expect(getSkewedTokenCacheTtlMs(now - 60_000, now)).toBe(1); + expect(getSkewedTokenCacheTtlMs(now, now)).toBe(1); + }); +}); + +describe('hasUsableTokenExpiry', () => { + const now = 1_700_000_000_000; + const bufferMs = OPENID_EXPIRY_BUFFER_SECONDS * 1000; + + it('requires the credential to outlive the trip downstream', () => { + expect(hasUsableTokenExpiry(now + bufferMs + 1, now)).toBe(true); + expect(hasUsableTokenExpiry(now + bufferMs, now)).toBe(false); + expect(hasUsableTokenExpiry(now + bufferMs - 1, now)).toBe(false); + }); + + it('rejects a missing or unusable expiry rather than assuming it is fresh', () => { + expect(hasUsableTokenExpiry(null, now)).toBe(false); + expect(hasUsableTokenExpiry(undefined, now)).toBe(false); + expect(hasUsableTokenExpiry(NaN, now)).toBe(false); + }); +}); diff --git a/packages/api/src/oauth/expiry.ts b/packages/api/src/oauth/expiry.ts index 9aaec7aa86..84981d01f7 100644 --- a/packages/api/src/oauth/expiry.ts +++ b/packages/api/src/oauth/expiry.ts @@ -18,6 +18,15 @@ */ export const DEFAULT_OAUTH_TOKEN_TTL_SECONDS = 3600; +/** + * Margin a credential needs to survive the trip to whoever will use it. A token served inside this + * window expires in transit and is rejected downstream, so it is treated as already gone β€” both + * when validating the user's own federated token (`isOpenIDTokenValid`, `isIdTokenCurrent`) and + * when caching a token obtained by exchange. Lives here, with the rest of the lifetime math, so a + * consumer that stubs OpenID token validation cannot leave the arithmetic reading `undefined`. + */ +export const OPENID_EXPIRY_BUFFER_SECONDS = 30; + /** * Floor for a cache TTL derived from an already-elapsed lifetime. Keyv reads a TTL of exactly `0` * as "no expiry" (`data.ttl === 0` becomes `undefined`), so a credential the provider declared @@ -25,6 +34,14 @@ export const DEFAULT_OAUTH_TOKEN_TTL_SECONDS = 3600; */ const EXPIRED_CACHE_TTL_MS = 1; +/** + * Floor for a credential that is still alive but whose remaining lifetime is shorter than the + * in-transit buffer. Distinct from {@link EXPIRED_CACHE_TTL_MS}: a lifetime the provider declared + * elapsed is dead and must not be reused, while a short one is real and gets a usable moment + * rather than a value that expires before the caller can act on it. + */ +const MIN_LIVE_TOKEN_TTL_MS = 1000; + /** * Longest lifetime that can still produce a valid `Date`. The ECMAScript time value range ends at * Β±8.64e15 ms, and every derived timestamp adds `Date.now()`, so the bound is halved to leave room @@ -68,13 +85,78 @@ export function normalizeExpiresIn(expiresIn: unknown): number | undefined { * Cache TTL in milliseconds for a token response. A provider that omits `expires_in` gets * `fallbackSeconds` rather than an entry that outlives the credential it holds; one that declares * an elapsed lifetime gets the shortest positive TTL rather than `0`, which Keyv reads as no expiry. + * + * The declared lifetime is shortened by {@link OPENID_EXPIRY_BUFFER_SECONDS} β€” the same margin + * `isOpenIDTokenValid` applies to the user's own federated token. A credential served in the final + * seconds of its life expires in transit and is rejected downstream, and because the read side + * re-serves whatever the cache still holds, that entry must be gone before the credential is. + * An unknown lifetime takes the fallback unshortened: there is no declared expiry to protect. */ export function getTokenCacheTtlMs(expiresIn: unknown, fallbackSeconds: number): number { const seconds = normalizeExpiresIn(expiresIn); if (seconds == null) { return fallbackSeconds * 1000; } - return Math.max(seconds * 1000, EXPIRED_CACHE_TTL_MS); + if (seconds <= 0) { + return EXPIRED_CACHE_TTL_MS; + } + return Math.max((seconds - OPENID_EXPIRY_BUFFER_SECONDS) * 1000, MIN_LIVE_TOKEN_TTL_MS); +} + +/** + * Absolute expiry in epoch milliseconds for a token response, preferring an expiry the caller + * already holds. `fallbackSeconds` covers a response that declares no usable lifetime. + */ +export function getTokenExpiresAtMs({ + expiresAt, + expiresIn, + fallbackSeconds = DEFAULT_OAUTH_TOKEN_TTL_SECONDS, + now, +}: { + expiresAt?: number | null; + expiresIn?: unknown; + fallbackSeconds?: number; + now: number; +}): number { + if (expiresAt != null && Number.isFinite(expiresAt)) { + return expiresAt; + } + return now + (normalizeExpiresIn(expiresIn) ?? fallbackSeconds) * 1000; +} + +/** + * The expiry a downstream consumer should honour: the real one pulled back by the in-transit + * buffer, so a credential handed on with this stamp cannot be accepted into its final seconds. + */ +export function getSkewedTokenExpiresAtMs(expiresAt: number, now: number): number { + /** An expiry already in the past is the provider saying the credential is dead. Flooring it to a + * moment in the future would hand a consumer a token that cannot work, so it stays elapsed and + * the caller rejects the exchange instead of failing downstream. */ + if (expiresAt <= now) { + return expiresAt; + } + return Math.max(now + MIN_LIVE_TOKEN_TTL_MS, expiresAt - OPENID_EXPIRY_BUFFER_SECONDS * 1000); +} + +/** Cache TTL for a token whose absolute expiry is already known, buffered as above. */ +export function getSkewedTokenCacheTtlMs(expiresAt: number, now: number): number { + if (expiresAt <= now) { + return EXPIRED_CACHE_TTL_MS; + } + return Math.max(MIN_LIVE_TOKEN_TTL_MS, expiresAt - now - OPENID_EXPIRY_BUFFER_SECONDS * 1000); +} + +/** + * Whether a cached credential still has enough life to survive the trip downstream. A cache entry + * that outlived its TTL check β€” a shared store with a coarser clock, an entry written before the + * buffer existed β€” is rejected here rather than handed out to fail at the far end. + */ +export function hasUsableTokenExpiry(expiresAt?: number | null, now: number = Date.now()): boolean { + return ( + expiresAt != null && + Number.isFinite(expiresAt) && + expiresAt > now + OPENID_EXPIRY_BUFFER_SECONDS * 1000 + ); } /** diff --git a/packages/api/src/utils/env.spec.ts b/packages/api/src/utils/env.spec.ts index f33f1645e4..25db696d31 100644 --- a/packages/api/src/utils/env.spec.ts +++ b/packages/api/src/utils/env.spec.ts @@ -3,11 +3,11 @@ import { TokenExchangeMethodEnum } from 'librechat-data-provider'; import type { MCPOptions } from 'librechat-data-provider'; import type { IUser } from '@librechat/data-schemas'; import { - resolveNestedObject, - encodeHeaderValue, - resolveHeaders, createSafeUser, + resolveHeaders, + resolveNestedObject, processMCPEnv, + encodeHeaderValue, } from './env'; function isStdioOptions(options: MCPOptions): options is Extract { @@ -472,6 +472,27 @@ describe('resolveHeaders', () => { expect(result['X-User-TermsAccepted']).toBe('true'); }); + it('should not expose tenant or issuer fields through safe user placeholders', () => { + const user = createTestUser({ + tenantId: 'tenant-secret', + openidIssuer: 'https://issuer.example.com', + }); + const safeUser = createSafeUser(user); + const headers = { + 'X-Tenant': '{{LIBRECHAT_USER_TENANTID}}', + 'X-Issuer': '{{LIBRECHAT_USER_OPENIDISSUER}}', + }; + + expect(safeUser).not.toHaveProperty('tenantId'); + expect(safeUser).not.toHaveProperty('openidIssuer'); + expect(resolveHeaders({ headers, user: safeUser })['X-Tenant']).toBe( + '{{LIBRECHAT_USER_TENANTID}}', + ); + expect(resolveHeaders({ headers, user: safeUser })['X-Issuer']).toBe( + '{{LIBRECHAT_USER_OPENIDISSUER}}', + ); + }); + it('should handle multiple placeholders in one value', () => { const user = { id: 'abc', email: 'me@example.com' }; const headers = { diff --git a/packages/api/src/utils/identity.spec.ts b/packages/api/src/utils/identity.spec.ts new file mode 100644 index 0000000000..761489351b --- /dev/null +++ b/packages/api/src/utils/identity.spec.ts @@ -0,0 +1,148 @@ +import { + createAuthIdentityContext, + createOpenIDOboIdentityTuple, + createOpenIDSessionIdentity, + createOpenIDRefreshIdentityTuple, + createRefreshTokenBridgeIdentity, + isOpenIDSessionIdentityMatch, + resolveAppUserId, + serializeAuthIdentityTuple, +} from './identity'; + +describe('auth identity helpers', () => { + it('resolves app user id from _id before id', () => { + expect(resolveAppUserId({ _id: { toString: () => 'mongo-id' }, id: 'virtual-id' })).toBe( + 'mongo-id', + ); + }); + + it('uses explicit tenant and normalized issuer in context', () => { + expect( + createAuthIdentityContext({ + user: { + id: 'user-id', + openidId: 'oidc-sub', + tenantId: 'user-tenant', + openidIssuer: 'https://issuer.example.com/.well-known/openid-configuration', + }, + tenantId: 'ambient-tenant', + }), + ).toEqual({ + appUserId: 'user-id', + openidSubject: 'oidc-sub', + tenantId: 'ambient-tenant', + openidIssuer: 'https://issuer.example.com', + }); + }); + + it('creates session identity from explicit token metadata before request user fallback', () => { + expect( + createOpenIDSessionIdentity({ + user: { + id: 'request-user', + openidId: 'request-sub', + tenantId: 'request-tenant', + openidIssuer: 'https://request.example.com', + }, + userId: 'session-user', + openidSubject: 'session-sub', + tenantId: 'session-tenant', + openidIssuer: 'https://issuer.example.com/.well-known/openid-configuration', + }), + ).toEqual({ + appUserId: 'session-user', + openidSubject: 'session-sub', + tenantId: 'session-tenant', + openidIssuer: 'https://issuer.example.com', + }); + }); + + it('requires stamped OpenID session identity metadata to match exactly', () => { + const expected = { + appUserId: 'user-123', + openidSubject: 'oidc-sub', + tenantId: 'tenant-a', + openidIssuer: 'https://issuer.example.com', + }; + + expect( + isOpenIDSessionIdentityMatch( + { + ...expected, + openidIssuer: 'https://issuer.example.com/', + }, + expected, + ), + ).toBe(true); + expect( + isOpenIDSessionIdentityMatch( + { + ...expected, + openidSubject: 'different-sub', + }, + expected, + ), + ).toBe(false); + expect( + isOpenIDSessionIdentityMatch( + { + ...expected, + openidIssuer: undefined, + }, + expected, + ), + ).toBe(false); + }); + + it('allows refresh tuple to fall back to app id when openidId is absent', () => { + expect( + createOpenIDRefreshIdentityTuple({ + user: { id: 'app-user' }, + requestUser: { openidId: 'request-sub' }, + tenantId: 'tenant-a', + openidIssuer: 'https://issuer-a.example.com/', + }), + ).toEqual({ + subject: 'app-user', + tenantId: 'tenant-a', + openidIssuer: 'https://issuer-a.example.com', + }); + }); + + it('creates refresh-token bridge identity from app user id and normalized issuer', () => { + expect( + createRefreshTokenBridgeIdentity({ + userId: ' user-123 ', + tenantId: ' tenant-a ', + openidIssuer: 'https://issuer.example.com/.well-known/openid-configuration', + }), + ).toEqual({ + userId: 'user-123', + tenantId: 'tenant-a', + openidIssuer: 'https://issuer.example.com', + }); + }); + + it('requires an app user id for refresh-token bridge identity', () => { + expect( + createRefreshTokenBridgeIdentity({ + user: { openidId: 'oidc-sub' }, + tenantId: 'tenant-a', + }), + ).toBeNull(); + }); + + it('requires an OpenID subject for OBO identity tuples', () => { + expect(createOpenIDOboIdentityTuple({ user: { id: 'app-user' } })).toBeNull(); + }); + + it('serializes tuple parts with a stable separator', () => { + expect( + serializeAuthIdentityTuple({ + tenantId: 'tenant-a', + openidIssuer: 'issuer-a', + subject: 'sub-a', + }), + ).toBe('tenant-a\x1fissuer-a\x1fsub-a'); + }); +}); diff --git a/packages/api/src/utils/identity.ts b/packages/api/src/utils/identity.ts new file mode 100644 index 0000000000..f40928b332 --- /dev/null +++ b/packages/api/src/utils/identity.ts @@ -0,0 +1,290 @@ +import { normalizeOpenIdIssuer } from '~/auth/openid'; + +/** + * Auth-boundary identity helpers for token/cache scoping. + * Do not use these as a blanket replacement for app ownership checks, and keep + * tenant/issuer data out of placeholder-visible safe user fields. + */ +type StringableId = string | number | { toString(): string }; + +export type AuthIdentitySource = { + id?: string | null; + _id?: StringableId | null; + openidId?: string | null; + openidIssuer?: string | null; + tenantId?: string | null; +}; + +export type AuthIdentityContext = { + appUserId?: string; + openidSubject?: string; + tenantId?: string; + openidIssuer?: string; +}; + +export type AuthIdentityTuple = { + tenantId: string; + openidIssuer: string; + subject: string; +}; + +export type RefreshTokenBridgeIdentity = { + userId: string; + tenantId?: string; + openidIssuer?: string; +}; + +export type OpenIDSessionIdentitySource = { + appUserId?: string | null; + openidSubject?: string | null; + tenantId?: string | null; + openidIssuer?: string | null; +}; + +const NO_TENANT = 'no-tenant'; +const NO_ISSUER = 'no-issuer'; +const IDENTITY_PART_SEPARATOR = '\x1f'; + +function normalizeIdentityValue(value: string | null | undefined): string | undefined { + const normalized = value?.trim(); + return normalized ? normalized : undefined; +} + +function stringifyId(value: StringableId | null | undefined): string | undefined { + if (value == null) { + return undefined; + } + + const id = + typeof value === 'string' || typeof value === 'number' ? String(value) : value.toString(); + return normalizeIdentityValue(id); +} + +export function resolveAppUserId( + ...sources: Array +): string | undefined { + for (const source of sources) { + const id = stringifyId(source?._id) ?? normalizeIdentityValue(source?.id); + if (id) { + return id; + } + } + + return undefined; +} + +export function resolveOpenIDSubject( + source: AuthIdentitySource | null | undefined, +): string | undefined { + return normalizeIdentityValue(source?.openidId); +} + +export function resolveRefreshSubject( + ...sources: Array +): string | undefined { + for (const source of sources) { + const subject = resolveOpenIDSubject(source) ?? resolveAppUserId(source); + if (subject) { + return subject; + } + } + + return undefined; +} + +export function resolveTenantId({ + tenantId, + user, + requestUser, +}: { + tenantId?: string | null; + user?: AuthIdentitySource | null; + requestUser?: AuthIdentitySource | null; +}): string | undefined { + return ( + normalizeIdentityValue(tenantId) ?? + normalizeIdentityValue(user?.tenantId) ?? + normalizeIdentityValue(requestUser?.tenantId) + ); +} + +export function resolveAuthOpenIDIssuer({ + openidIssuer, + user, + requestUser, +}: { + openidIssuer?: string | null; + user?: AuthIdentitySource | null; + requestUser?: AuthIdentitySource | null; +}): string | undefined { + return ( + normalizeOpenIdIssuer(openidIssuer ?? undefined) ?? + normalizeOpenIdIssuer(user?.openidIssuer ?? undefined) ?? + normalizeOpenIdIssuer(requestUser?.openidIssuer ?? undefined) + ); +} + +export function createAuthIdentityContext({ + user, + requestUser, + tenantId, + openidIssuer, +}: { + user?: AuthIdentitySource | null; + requestUser?: AuthIdentitySource | null; + tenantId?: string | null; + openidIssuer?: string | null; +}): AuthIdentityContext { + return { + appUserId: resolveAppUserId(user, requestUser), + openidSubject: resolveOpenIDSubject(user) ?? resolveOpenIDSubject(requestUser), + tenantId: resolveTenantId({ tenantId, user, requestUser }), + openidIssuer: resolveAuthOpenIDIssuer({ openidIssuer, user, requestUser }), + }; +} + +export function createOpenIDSessionIdentity({ + user, + requestUser, + userId, + openidSubject, + tenantId, + openidIssuer, +}: { + user?: AuthIdentitySource | null; + requestUser?: AuthIdentitySource | null; + userId?: string | null; + openidSubject?: string | null; + tenantId?: string | null; + openidIssuer?: string | null; +}): AuthIdentityContext { + return createAuthIdentityContext({ + user: { + id: userId, + openidId: openidSubject, + tenantId, + openidIssuer, + }, + requestUser: user ?? requestUser, + tenantId, + openidIssuer, + }); +} + +function normalizeOpenIDSessionIdentity( + identity: OpenIDSessionIdentitySource | null | undefined, +): AuthIdentityContext | null { + const appUserId = normalizeIdentityValue(identity?.appUserId); + const openidSubject = normalizeIdentityValue(identity?.openidSubject); + if (!appUserId || !openidSubject) { + return null; + } + + return { + appUserId, + openidSubject, + tenantId: normalizeIdentityValue(identity?.tenantId), + openidIssuer: normalizeOpenIdIssuer(identity?.openidIssuer ?? undefined), + }; +} + +export function isOpenIDSessionIdentityMatch( + sessionIdentity: OpenIDSessionIdentitySource | null | undefined, + expectedIdentity: OpenIDSessionIdentitySource | null | undefined, +): boolean { + const session = normalizeOpenIDSessionIdentity(sessionIdentity); + const expected = normalizeOpenIDSessionIdentity(expectedIdentity); + + return ( + session != null && + expected != null && + session.appUserId === expected.appUserId && + session.openidSubject === expected.openidSubject && + session.tenantId === expected.tenantId && + session.openidIssuer === expected.openidIssuer + ); +} + +export function createRefreshTokenBridgeIdentity({ + user, + requestUser, + userId, + tenantId, + openidIssuer, +}: { + user?: AuthIdentitySource | null; + requestUser?: AuthIdentitySource | null; + userId?: string | null; + tenantId?: string | null; + openidIssuer?: string | null; +}): RefreshTokenBridgeIdentity | null { + const appUserId = normalizeIdentityValue(userId) ?? resolveAppUserId(user, requestUser); + if (!appUserId) { + return null; + } + + return { + userId: appUserId, + tenantId: resolveTenantId({ tenantId, user, requestUser }), + openidIssuer: resolveAuthOpenIDIssuer({ openidIssuer, user, requestUser }), + }; +} + +export function createOpenIDRefreshIdentityTuple({ + user, + requestUser, + tenantId, + openidIssuer, +}: { + user?: AuthIdentitySource | null; + requestUser?: AuthIdentitySource | null; + tenantId?: string | null; + openidIssuer?: string | null; +}): AuthIdentityTuple | null { + const subject = resolveRefreshSubject(user, requestUser); + if (!subject) { + return null; + } + + return { + subject, + tenantId: resolveTenantId({ tenantId, user, requestUser }) ?? NO_TENANT, + openidIssuer: resolveAuthOpenIDIssuer({ openidIssuer, user, requestUser }) ?? NO_ISSUER, + }; +} + +export function createOpenIDOboIdentityTuple({ + user, + identityContext, + tenantId, + openidIssuer, +}: { + user?: AuthIdentitySource | null; + identityContext?: AuthIdentityContext | null; + tenantId?: string | null; + openidIssuer?: string | null; +}): AuthIdentityTuple | null { + const subject = + normalizeIdentityValue(identityContext?.openidSubject) ?? resolveOpenIDSubject(user); + if (!subject) { + return null; + } + + return { + subject, + tenantId: + normalizeIdentityValue(tenantId) ?? + normalizeIdentityValue(identityContext?.tenantId) ?? + normalizeIdentityValue(user?.tenantId) ?? + NO_TENANT, + openidIssuer: + normalizeOpenIdIssuer(openidIssuer ?? undefined) ?? + normalizeOpenIdIssuer(identityContext?.openidIssuer) ?? + normalizeOpenIdIssuer(user?.openidIssuer ?? undefined) ?? + NO_ISSUER, + }; +} + +export function serializeAuthIdentityTuple(tuple: AuthIdentityTuple): string { + return [tuple.tenantId, tuple.openidIssuer, tuple.subject].join(IDENTITY_PART_SEPARATOR); +} diff --git a/packages/api/src/utils/index.ts b/packages/api/src/utils/index.ts index 32cd8cc391..06e63a62eb 100644 --- a/packages/api/src/utils/index.ts +++ b/packages/api/src/utils/index.ts @@ -12,6 +12,7 @@ export * from './import'; export * from './generators'; export * from './graph'; export * from './headers'; +export * from './identity'; export * from './path'; export * from './key'; export * from './latex'; diff --git a/packages/api/src/utils/oidc.ts b/packages/api/src/utils/oidc.ts index f03b0a54f8..b2516cd754 100644 --- a/packages/api/src/utils/oidc.ts +++ b/packages/api/src/utils/oidc.ts @@ -1,5 +1,6 @@ import { logger } from '@librechat/data-schemas'; import type { IUser, OIDCTokens } from '@librechat/data-schemas'; +import { OPENID_EXPIRY_BUFFER_SECONDS } from '~/oauth/expiry'; export interface OpenIDTokenInfo { accessToken?: string; @@ -43,9 +44,6 @@ export const GRAPH_TOKEN_PLACEHOLDER = '{{LIBRECHAT_GRAPH_ACCESS_TOKEN}}'; */ export const DEFAULT_GRAPH_SCOPES = 'https://graph.microsoft.com/.default'; -/** Shared with AuthController's OpenID session reuse check: a token within the buffer would expire in transit and 401 downstream */ -export const OPENID_EXPIRY_BUFFER_SECONDS = 30; - /** Claims consulted when deciding whether a verified JWT is an access token rather than an ID token. */ export interface JwtTypeClaims { aud?: string | string[]; diff --git a/packages/data-schemas/src/methods/index.ts b/packages/data-schemas/src/methods/index.ts index eaa6a58cd5..dbcd724fa8 100644 --- a/packages/data-schemas/src/methods/index.ts +++ b/packages/data-schemas/src/methods/index.ts @@ -1,4 +1,12 @@ import type { RoleMethods, RoleDeps } from './role'; +import { + createOpenIDRefreshFlightMethods, + type OpenIDRefreshFlightMethods, +} from './openidRefreshFlight'; +import { + createRefreshTokenBridgeMethods, + type RefreshTokenBridgeMethods, +} from './refreshTokenBridge'; import { createSessionMethods, DEFAULT_REFRESH_TOKEN_EXPIRY, type SessionMethods } from './session'; import { createUserMethods, DEFAULT_SESSION_EXPIRY, type UserMethods } from './user'; import { createFileMethods, type FileMethods, type FileOwnerScope } from './file'; @@ -183,6 +191,8 @@ export { AgentTriggerDeliveryConflictError }; export type AllMethods = UserMethods & SessionMethods & TokenMethods & + RefreshTokenBridgeMethods & + OpenIDRefreshFlightMethods & RoleMethods & KeyMethods & FileMethods & @@ -331,6 +341,8 @@ export function createMethods( ...createUserMethods(mongoose, { getCache: deps.getCache }), ...createSessionMethods(mongoose), ...createTokenMethods(mongoose), + ...createRefreshTokenBridgeMethods(mongoose), + ...createOpenIDRefreshFlightMethods(mongoose), ...roleMethods, ...createKeyMethods(mongoose), ...createFileMethods(mongoose), @@ -382,6 +394,8 @@ export type { UserMethods, SessionMethods, TokenMethods, + RefreshTokenBridgeMethods, + OpenIDRefreshFlightMethods, RoleMethods, KeyMethods, FileMethods, diff --git a/packages/data-schemas/src/methods/openidRefreshFlight.spec.ts b/packages/data-schemas/src/methods/openidRefreshFlight.spec.ts new file mode 100644 index 0000000000..a4c71c0d17 --- /dev/null +++ b/packages/data-schemas/src/methods/openidRefreshFlight.spec.ts @@ -0,0 +1,424 @@ +import mongoose from 'mongoose'; +import { MongoMemoryServer } from 'mongodb-memory-server'; +import type * as t from '~/types'; +import { createOpenIDRefreshFlightMethods } from './openidRefreshFlight'; +import openidRefreshFlightSchema from '~/schema/openidRefreshFlight'; + +jest.mock('~/config/winston', () => ({ + error: jest.fn(), + info: jest.fn(), + debug: jest.fn(), +})); + +let mongoServer: MongoMemoryServer; +let methods: ReturnType; + +beforeAll(async () => { + mongoServer = await MongoMemoryServer.create(); + await mongoose.connect(mongoServer.getUri()); + if (!mongoose.models.OpenIDRefreshFlight) { + mongoose.model('OpenIDRefreshFlight', openidRefreshFlightSchema); + } +}); + +afterAll(async () => { + await mongoose.disconnect(); + await mongoServer?.stop(); +}); + +beforeEach(async () => { + await mongoose.connection.dropDatabase(); + methods = createOpenIDRefreshFlightMethods(mongoose); +}); + +describe('OpenIDRefreshFlight Methods', () => { + it('creates coordination indexes before the first acquisition', async () => { + await methods.acquireOpenIDRefreshFlight({ + key: 'flight-key', + ownerId: 'owner-1', + lockExpiresAt: new Date(Date.now() + 30000), + expiresAt: new Date(Date.now() + 60000), + }); + + const indexes = await mongoose.models.OpenIDRefreshFlight.listIndexes(); + expect(indexes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ key: { key: 1 }, unique: true }), + expect.objectContaining({ key: { expiresAt: 1 }, expireAfterSeconds: 0 }), + ]), + ); + }); + + it('acquires a new pending flight and returns existing flight to joiners', async () => { + const first = await methods.acquireOpenIDRefreshFlight({ + key: 'flight-key', + ownerId: 'owner-1', + lockExpiresAt: new Date(Date.now() + 30000), + expiresAt: new Date(Date.now() + 60000), + }); + + const second = await methods.acquireOpenIDRefreshFlight({ + key: 'flight-key', + ownerId: 'owner-2', + lockExpiresAt: new Date(Date.now() + 30000), + expiresAt: new Date(Date.now() + 60000), + }); + + expect(first.acquired).toBe(true); + expect(first.flight?.ownerId).toBe('owner-1'); + expect(second.acquired).toBe(false); + expect(second.flight?.ownerId).toBe('owner-1'); + expect(second.flight?.status).toBe('pending'); + }); + + it('reclaims an expired pending lock', async () => { + await methods.acquireOpenIDRefreshFlight({ + key: 'flight-key', + ownerId: 'owner-1', + lockExpiresAt: new Date(Date.now() - 1000), + expiresAt: new Date(Date.now() + 60000), + }); + await mongoose.models.OpenIDRefreshFlight.updateOne( + { key: 'flight-key' }, + { $set: { createdAt: new Date('2020-01-01T00:00:00.000Z') } }, + ); + + const reclaimed = await methods.acquireOpenIDRefreshFlight({ + key: 'flight-key', + ownerId: 'owner-2', + lockExpiresAt: new Date(Date.now() + 30000), + expiresAt: new Date(Date.now() + 60000), + }); + + expect(reclaimed.acquired).toBe(true); + expect(reclaimed.flight?.ownerId).toBe('owner-2'); + expect(reclaimed.flight?.createdAt.getTime()).toBeGreaterThan( + new Date('2020-01-01T00:00:00.000Z').getTime(), + ); + }); + + it('reclaims a failed flight immediately so transient failures can retry', async () => { + await methods.acquireOpenIDRefreshFlight({ + key: 'flight-key', + ownerId: 'owner-1', + lockExpiresAt: new Date(Date.now() + 30000), + expiresAt: new Date(Date.now() + 60000), + }); + + await methods.failOpenIDRefreshFlight({ + key: 'flight-key', + ownerId: 'owner-1', + errorMessage: 'upstream timeout', + expiresAt: new Date(Date.now() + 60000), + }); + + const reclaimed = await methods.acquireOpenIDRefreshFlight({ + key: 'flight-key', + ownerId: 'owner-2', + lockExpiresAt: new Date(Date.now() + 30000), + expiresAt: new Date(Date.now() + 60000), + }); + + expect(reclaimed.acquired).toBe(true); + expect(reclaimed.flight?.ownerId).toBe('owner-2'); + expect(reclaimed.flight?.status).toBe('pending'); + expect(reclaimed.flight?.errorMessage).toBeUndefined(); + }); + + it('renews a lease only for the owning pending worker', async () => { + await methods.acquireOpenIDRefreshFlight({ + key: 'flight-key', + ownerId: 'owner-1', + lockExpiresAt: new Date(Date.now() + 30000), + expiresAt: new Date(Date.now() + 60000), + }); + + const nextLockExpiry = new Date(Date.now() + 45000); + const nextFlightExpiry = new Date(Date.now() + 90000); + await expect( + methods.renewOpenIDRefreshFlight({ + key: 'flight-key', + ownerId: 'owner-2', + lockExpiresAt: nextLockExpiry, + expiresAt: nextFlightExpiry, + }), + ).resolves.toBeNull(); + + const renewed = await methods.renewOpenIDRefreshFlight({ + key: 'flight-key', + ownerId: 'owner-1', + lockExpiresAt: nextLockExpiry, + expiresAt: nextFlightExpiry, + }); + + expect(renewed?.ownerId).toBe('owner-1'); + expect(renewed?.status).toBe('pending'); + expect(renewed?.lockExpiresAt.getTime()).toBe(nextLockExpiry.getTime()); + expect(renewed?.expiresAt.getTime()).toBe(nextFlightExpiry.getTime()); + }); + + it('completes a flight only for the owning pending worker', async () => { + await methods.acquireOpenIDRefreshFlight({ + key: 'flight-key', + ownerId: 'owner-1', + lockExpiresAt: new Date(Date.now() + 30000), + expiresAt: new Date(Date.now() + 60000), + }); + + await expect( + methods.completeOpenIDRefreshFlight({ + key: 'flight-key', + ownerId: 'owner-2', + encryptedResult: 'encrypted-wrong', + expiresAt: new Date(Date.now() + 60000), + }), + ).resolves.toBeNull(); + + const completed = await methods.completeOpenIDRefreshFlight({ + key: 'flight-key', + ownerId: 'owner-1', + encryptedResult: 'encrypted-result', + expiresAt: new Date(Date.now() + 60000), + }); + + expect(completed?.status).toBe('completed'); + expect(completed?.encryptedResult).toBe('encrypted-result'); + }); + + it('finds completed unexpired flights and ignores expired ones', async () => { + await methods.acquireOpenIDRefreshFlight({ + key: 'flight-key', + ownerId: 'owner-1', + lockExpiresAt: new Date(Date.now() + 30000), + expiresAt: new Date(Date.now() + 60000), + }); + await methods.completeOpenIDRefreshFlight({ + key: 'flight-key', + ownerId: 'owner-1', + encryptedResult: 'encrypted-result', + expiresAt: new Date(Date.now() + 60000), + }); + + await expect(methods.findOpenIDRefreshFlight({ key: 'flight-key' })).resolves.toMatchObject({ + status: 'completed', + encryptedResult: 'encrypted-result', + }); + + const OpenIDRefreshFlight = mongoose.models.OpenIDRefreshFlight; + await OpenIDRefreshFlight.updateOne( + { key: 'flight-key' }, + { $set: { expiresAt: new Date(Date.now() - 1000) } }, + ); + + await expect(methods.findOpenIDRefreshFlight({ key: 'flight-key' })).resolves.toBeNull(); + }); + + it('persists a logout revocation fence that an active owner cannot publish through', async () => { + await methods.acquireOpenIDRefreshFlight({ + key: 'flight-key', + ownerId: 'owner-1', + lockExpiresAt: new Date(Date.now() + 30000), + expiresAt: new Date(Date.now() + 60000), + }); + const revocationExpiry = new Date(Date.now() + 3600000); + + const revoked = await methods.revokeOpenIDRefreshFlight({ + key: 'flight-key', + expiresAt: revocationExpiry, + }); + + expect(revoked?.status).toBe('revoked'); + await expect( + methods.completeOpenIDRefreshFlight({ + key: 'flight-key', + ownerId: 'owner-1', + encryptedResult: 'late-result', + expiresAt: revocationExpiry, + }), + ).resolves.toBeNull(); + const reacquire = await methods.acquireOpenIDRefreshFlight({ + key: 'flight-key', + ownerId: 'owner-2', + lockExpiresAt: new Date(Date.now() + 30000), + expiresAt: new Date(Date.now() + 60000), + }); + expect(reacquire.acquired).toBe(false); + expect(reacquire.flight?.status).toBe('revoked'); + }); + + it('retains a completed result when logout atomically revokes its delivery', async () => { + const expiresAt = new Date(Date.now() + 60000); + await methods.acquireOpenIDRefreshFlight({ + key: 'completed-flight', + ownerId: 'owner-1', + lockExpiresAt: new Date(Date.now() + 30000), + expiresAt, + }); + await methods.completeOpenIDRefreshFlight({ + key: 'completed-flight', + ownerId: 'owner-1', + encryptedResult: 'encrypted-successor', + expiresAt, + }); + + const revoked = await methods.revokeOpenIDRefreshFlight({ + key: 'completed-flight', + expiresAt: new Date(Date.now() + 3600000), + }); + + expect(revoked?.status).toBe('revoked'); + expect(revoked?.encryptedResult).toBe('encrypted-successor'); + }); + + it('serializes response delivery without changing the completed publication state', async () => { + const expiresAt = new Date(Date.now() + 60000); + await methods.acquireOpenIDRefreshFlight({ + key: 'delivery-flight', + ownerId: 'owner-1', + lockExpiresAt: new Date(Date.now() + 30000), + expiresAt, + }); + await methods.completeOpenIDRefreshFlight({ + key: 'delivery-flight', + ownerId: 'owner-1', + encryptedResult: 'encrypted-result', + expiresAt, + }); + + const first = await methods.claimOpenIDRefreshFlightDelivery({ + key: 'delivery-flight', + ownerId: 'owner-1', + deliveryId: 'delivery-1', + deliveryExpiresAt: new Date(Date.now() + 30000), + }); + expect(first).toMatchObject({ status: 'completed', deliveryId: 'delivery-1' }); + + await expect( + methods.claimOpenIDRefreshFlightDelivery({ + key: 'delivery-flight', + ownerId: 'owner-1', + deliveryId: 'delivery-2', + deliveryExpiresAt: new Date(Date.now() + 30000), + }), + ).resolves.toBeNull(); + + const released = await methods.releaseOpenIDRefreshFlightDelivery({ + key: 'delivery-flight', + ownerId: 'owner-1', + deliveryId: 'delivery-1', + }); + expect(released).toMatchObject({ status: 'completed', encryptedResult: 'encrypted-result' }); + expect(released?.deliveryId).toBeUndefined(); + }); + + it('recreates an expired generation only for delivery and removes the synthetic row on release', async () => { + const createdAt = new Date('2026-08-29T12:00:00.000Z'); + const claimed = await methods.claimOpenIDRefreshFlightDelivery({ + key: 'expired-generation', + ownerId: 'owner-1', + deliveryId: 'delivery-1', + deliveryExpiresAt: new Date(Date.now() + 30000), + createdAt, + }); + + expect(claimed).toMatchObject({ + key: 'expired-generation', + ownerId: 'owner-1', + status: 'completed', + deliveryId: 'delivery-1', + }); + expect(claimed?.createdAt.getTime()).toBe(createdAt.getTime()); + + await methods.releaseOpenIDRefreshFlightDelivery({ + key: 'expired-generation', + ownerId: 'owner-1', + deliveryId: 'delivery-1', + }); + await expect( + mongoose.models.OpenIDRefreshFlight.findOne({ key: 'expired-generation' }).lean(), + ).resolves.toBeNull(); + }); + + it('makes logout wait for an active delivery and revokes it when the response releases', async () => { + const expiresAt = new Date(Date.now() + 60000); + await methods.acquireOpenIDRefreshFlight({ + key: 'logout-delivery-flight', + ownerId: 'owner-1', + lockExpiresAt: new Date(Date.now() + 30000), + expiresAt, + }); + await methods.completeOpenIDRefreshFlight({ + key: 'logout-delivery-flight', + ownerId: 'owner-1', + encryptedResult: 'encrypted-result', + expiresAt, + }); + await methods.claimOpenIDRefreshFlightDelivery({ + key: 'logout-delivery-flight', + ownerId: 'owner-1', + deliveryId: 'delivery-1', + deliveryExpiresAt: new Date(Date.now() + 30000), + }); + + let logoutSettled = false; + const logout = methods + .revokeOpenIDRefreshFlight({ + key: 'logout-delivery-flight', + expiresAt: new Date(Date.now() + 3600000), + }) + .finally(() => { + logoutSettled = true; + }); + + let revocationRequested = false; + for (let attempt = 0; attempt < 20 && !revocationRequested; attempt++) { + const flight = await mongoose.models.OpenIDRefreshFlight.findOne({ + key: 'logout-delivery-flight', + }).lean(); + revocationRequested = Boolean(flight?.revocationRequestedAt); + if (!revocationRequested) { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + } + expect(revocationRequested).toBe(true); + expect(logoutSettled).toBe(false); + + const released = await methods.releaseOpenIDRefreshFlightDelivery({ + key: 'logout-delivery-flight', + ownerId: 'owner-1', + deliveryId: 'delivery-1', + }); + expect(released?.status).toBe('revoked'); + await expect(logout).resolves.toMatchObject({ + status: 'revoked', + encryptedResult: 'encrypted-result', + }); + }); + + it('treats a malformed delivery without an expiry as abandoned during logout', async () => { + const expiresAt = new Date(Date.now() + 60000); + await methods.acquireOpenIDRefreshFlight({ + key: 'abandoned-delivery-flight', + ownerId: 'owner-1', + lockExpiresAt: new Date(Date.now() + 30000), + expiresAt, + }); + await methods.completeOpenIDRefreshFlight({ + key: 'abandoned-delivery-flight', + ownerId: 'owner-1', + encryptedResult: 'encrypted-result', + expiresAt, + }); + await mongoose.models.OpenIDRefreshFlight.updateOne( + { key: 'abandoned-delivery-flight' }, + { $set: { deliveryId: 'orphaned-delivery' }, $unset: { deliveryExpiresAt: '' } }, + ); + + await expect( + methods.revokeOpenIDRefreshFlight({ + key: 'abandoned-delivery-flight', + expiresAt: new Date(Date.now() + 3600000), + }), + ).resolves.toMatchObject({ status: 'revoked' }); + }); +}); diff --git a/packages/data-schemas/src/methods/openidRefreshFlight.ts b/packages/data-schemas/src/methods/openidRefreshFlight.ts new file mode 100644 index 0000000000..140c658ad8 --- /dev/null +++ b/packages/data-schemas/src/methods/openidRefreshFlight.ts @@ -0,0 +1,464 @@ +import { setTimeout as delay } from 'node:timers/promises'; +import type { Model } from 'mongoose'; +import type { + IOpenIDRefreshFlight, + OpenIDRefreshFlightCreateData, + OpenIDRefreshFlightCompleteData, + OpenIDRefreshFlightRenewData, + OpenIDRefreshFlightFailData, + OpenIDRefreshFlightRevokeData, + OpenIDRefreshFlightQuery, + OpenIDRefreshFlightAcquireResult, + OpenIDRefreshFlightClaimDeliveryData, + OpenIDRefreshFlightReleaseDeliveryData, +} from '~/types'; +import { createIndexesWithRetry } from '~/utils/retry'; +import logger from '~/config/winston'; + +const DELIVERY_RELEASE_POLL_MS = 100; + +function hasErrorCode(error: unknown): error is { code: number } { + return ( + typeof error === 'object' && + error != null && + 'code' in error && + typeof (error as { code: unknown }).code === 'number' + ); +} + +function isDuplicateKeyError(error: unknown): boolean { + return hasErrorCode(error) && error.code === 11000; +} + +export function createOpenIDRefreshFlightMethods(mongoose: typeof import('mongoose')): { + acquireOpenIDRefreshFlight: ( + data: OpenIDRefreshFlightCreateData, + ) => Promise; + completeOpenIDRefreshFlight: ( + data: OpenIDRefreshFlightCompleteData, + ) => Promise; + renewOpenIDRefreshFlight: ( + data: OpenIDRefreshFlightRenewData, + ) => Promise; + failOpenIDRefreshFlight: ( + data: OpenIDRefreshFlightFailData, + ) => Promise; + revokeOpenIDRefreshFlight: ( + data: OpenIDRefreshFlightRevokeData, + ) => Promise; + claimOpenIDRefreshFlightDelivery: ( + data: OpenIDRefreshFlightClaimDeliveryData, + ) => Promise; + releaseOpenIDRefreshFlightDelivery: ( + data: OpenIDRefreshFlightReleaseDeliveryData, + ) => Promise; + findOpenIDRefreshFlight: ( + query: OpenIDRefreshFlightQuery, + ) => Promise; +} { + let indexesPromise: Promise | null = null; + + function ensureIndexes(): Promise { + if (!indexesPromise) { + const OpenIDRefreshFlight = mongoose.models + .OpenIDRefreshFlight as Model; + indexesPromise = createIndexesWithRetry(OpenIDRefreshFlight).catch((error) => { + indexesPromise = null; + throw error; + }); + } + return indexesPromise; + } + + async function acquireOpenIDRefreshFlight( + data: OpenIDRefreshFlightCreateData, + ): Promise { + const OpenIDRefreshFlight = mongoose.models.OpenIDRefreshFlight as Model; + const now = new Date(); + + await ensureIndexes(); + + try { + const flight = await OpenIDRefreshFlight.create({ + ...data, + status: 'pending', + createdAt: now, + updatedAt: now, + }); + return { acquired: true, flight }; + } catch (error) { + if (!isDuplicateKeyError(error)) { + logger.debug('[acquireOpenIDRefreshFlight] Error creating flight:', error); + throw error; + } + } + + try { + const reclaimed = await OpenIDRefreshFlight.findOneAndUpdate( + { + key: data.key, + $or: [ + { status: 'failed' }, + { expiresAt: { $lte: now } }, + { status: 'pending', lockExpiresAt: { $lte: now } }, + ], + }, + { + $set: { + ownerId: data.ownerId, + status: 'pending', + createdAt: now, + lockExpiresAt: data.lockExpiresAt, + expiresAt: data.expiresAt, + updatedAt: now, + }, + $unset: { + encryptedResult: '', + errorMessage: '', + deliveryId: '', + deliveryExpiresAt: '', + revocationRequestedAt: '', + }, + }, + { new: true }, + ).lean(); + + if (reclaimed) { + return { acquired: true, flight: reclaimed }; + } + + const existing = await OpenIDRefreshFlight.findOne({ + key: data.key, + expiresAt: { $gt: now }, + }).lean(); + + return { acquired: false, flight: existing }; + } catch (error) { + logger.debug('[acquireOpenIDRefreshFlight] Error acquiring flight:', error); + throw error; + } + } + + async function completeOpenIDRefreshFlight( + data: OpenIDRefreshFlightCompleteData, + ): Promise { + try { + const OpenIDRefreshFlight = mongoose.models + .OpenIDRefreshFlight as Model; + return await OpenIDRefreshFlight.findOneAndUpdate( + { + key: data.key, + ownerId: data.ownerId, + status: 'pending', + }, + { + $set: { + status: 'completed', + encryptedResult: data.encryptedResult, + expiresAt: data.expiresAt, + updatedAt: new Date(), + }, + $unset: { + errorMessage: '', + deliveryId: '', + deliveryExpiresAt: '', + revocationRequestedAt: '', + }, + }, + { new: true }, + ).lean(); + } catch (error) { + logger.debug('[completeOpenIDRefreshFlight] Error completing flight:', error); + throw error; + } + } + + async function renewOpenIDRefreshFlight( + data: OpenIDRefreshFlightRenewData, + ): Promise { + try { + const OpenIDRefreshFlight = mongoose.models + .OpenIDRefreshFlight as Model; + return await OpenIDRefreshFlight.findOneAndUpdate( + { + key: data.key, + ownerId: data.ownerId, + status: 'pending', + }, + { + $set: { + lockExpiresAt: data.lockExpiresAt, + expiresAt: data.expiresAt, + updatedAt: new Date(), + }, + }, + { new: true }, + ).lean(); + } catch (error) { + logger.debug('[renewOpenIDRefreshFlight] Error renewing flight:', error); + throw error; + } + } + + async function failOpenIDRefreshFlight( + data: OpenIDRefreshFlightFailData, + ): Promise { + try { + const OpenIDRefreshFlight = mongoose.models + .OpenIDRefreshFlight as Model; + return await OpenIDRefreshFlight.findOneAndUpdate( + { + key: data.key, + ownerId: data.ownerId, + status: 'pending', + }, + { + $set: { + status: 'failed', + errorMessage: data.errorMessage, + expiresAt: data.expiresAt, + updatedAt: new Date(), + }, + $unset: { + encryptedResult: '', + deliveryId: '', + deliveryExpiresAt: '', + revocationRequestedAt: '', + }, + }, + { new: true }, + ).lean(); + } catch (error) { + logger.debug('[failOpenIDRefreshFlight] Error failing flight:', error); + throw error; + } + } + + async function findOpenIDRefreshFlight( + query: OpenIDRefreshFlightQuery, + ): Promise { + try { + const OpenIDRefreshFlight = mongoose.models + .OpenIDRefreshFlight as Model; + return await OpenIDRefreshFlight.findOne({ + key: query.key, + expiresAt: { $gt: new Date() }, + }).lean(); + } catch (error) { + logger.debug('[findOpenIDRefreshFlight] Error finding flight:', error); + throw error; + } + } + + async function claimOpenIDRefreshFlightDelivery( + data: OpenIDRefreshFlightClaimDeliveryData, + ): Promise { + try { + const OpenIDRefreshFlight = mongoose.models + .OpenIDRefreshFlight as Model; + try { + return await OpenIDRefreshFlight.findOneAndUpdate( + { + key: data.key, + ownerId: data.ownerId, + status: 'completed', + revocationRequestedAt: { $exists: false }, + $or: [ + { deliveryId: { $exists: false } }, + { deliveryExpiresAt: { $exists: false } }, + { deliveryExpiresAt: { $lte: new Date() } }, + ], + }, + { + $set: { + deliveryId: data.deliveryId, + deliveryExpiresAt: data.deliveryExpiresAt, + updatedAt: new Date(), + }, + $max: { expiresAt: data.deliveryExpiresAt }, + ...(data.createdAt + ? { + $setOnInsert: { + createdAt: data.createdAt, + lockExpiresAt: data.deliveryExpiresAt, + }, + } + : {}), + }, + { new: true, upsert: Boolean(data.createdAt) }, + ).lean(); + } catch (error) { + if (isDuplicateKeyError(error)) return null; + throw error; + } + } catch (error) { + logger.debug('[claimOpenIDRefreshFlightDelivery] Error claiming delivery:', error); + throw error; + } + } + + async function releaseOpenIDRefreshFlightDelivery( + data: OpenIDRefreshFlightReleaseDeliveryData, + ): Promise { + const OpenIDRefreshFlight = mongoose.models.OpenIDRefreshFlight as Model; + const delivery = { + key: data.key, + ownerId: data.ownerId, + deliveryId: data.deliveryId, + status: 'completed', + } as const; + try { + const revoked = await OpenIDRefreshFlight.findOneAndUpdate( + { ...delivery, revocationRequestedAt: { $exists: true } }, + { + $set: { + ownerId: 'revoked', + status: 'revoked', + errorMessage: 'OpenID refresh was revoked by logout', + updatedAt: new Date(), + }, + $unset: { + deliveryId: '', + deliveryExpiresAt: '', + revocationRequestedAt: '', + }, + }, + { new: true }, + ).lean(); + if (revoked) return revoked; + + const synthetic = await OpenIDRefreshFlight.findOneAndDelete({ + ...delivery, + encryptedResult: { $exists: false }, + revocationRequestedAt: { $exists: false }, + }).lean(); + if (synthetic) return null; + + const completed = await OpenIDRefreshFlight.findOneAndUpdate( + { + ...delivery, + encryptedResult: { $exists: true }, + revocationRequestedAt: { $exists: false }, + }, + { + $set: { updatedAt: new Date() }, + $unset: { deliveryId: '', deliveryExpiresAt: '' }, + }, + { new: true }, + ).lean(); + if (completed) return completed; + + return await OpenIDRefreshFlight.findOneAndUpdate( + { ...delivery, revocationRequestedAt: { $exists: true } }, + { + $set: { + ownerId: 'revoked', + status: 'revoked', + errorMessage: 'OpenID refresh was revoked by logout', + updatedAt: new Date(), + }, + $unset: { + deliveryId: '', + deliveryExpiresAt: '', + revocationRequestedAt: '', + }, + }, + { new: true }, + ).lean(); + } catch (error) { + logger.debug('[releaseOpenIDRefreshFlightDelivery] Error releasing delivery:', error); + throw error; + } + } + + async function revokeOpenIDRefreshFlight( + data: OpenIDRefreshFlightRevokeData, + ): Promise { + const OpenIDRefreshFlight = mongoose.models.OpenIDRefreshFlight as Model; + const now = new Date(); + await ensureIndexes(); + try { + for (;;) { + const revoked = await OpenIDRefreshFlight.findOneAndUpdate( + { + key: data.key, + $or: [ + { deliveryId: { $exists: false } }, + { deliveryExpiresAt: { $exists: false } }, + { deliveryExpiresAt: { $lte: new Date() } }, + ], + }, + { + $set: { + ownerId: 'revoked', + status: 'revoked', + errorMessage: 'OpenID refresh was revoked by logout', + lockExpiresAt: data.expiresAt, + expiresAt: data.expiresAt, + updatedAt: new Date(), + }, + $unset: { + deliveryId: '', + deliveryExpiresAt: '', + revocationRequestedAt: '', + }, + }, + { new: true }, + ).lean(); + if (revoked) return revoked; + + const delivering = await OpenIDRefreshFlight.findOneAndUpdate( + { + key: data.key, + status: 'completed', + deliveryId: { $exists: true }, + deliveryExpiresAt: { $gt: new Date() }, + }, + { + $set: { + revocationRequestedAt: new Date(), + expiresAt: data.expiresAt, + updatedAt: new Date(), + }, + }, + { new: true }, + ).lean(); + if (delivering) { + await delay(DELIVERY_RELEASE_POLL_MS); + continue; + } + + try { + return await OpenIDRefreshFlight.create({ + key: data.key, + ownerId: 'revoked', + status: 'revoked', + errorMessage: 'OpenID refresh was revoked by logout', + lockExpiresAt: data.expiresAt, + expiresAt: data.expiresAt, + createdAt: now, + updatedAt: now, + }); + } catch (error) { + if (!isDuplicateKeyError(error)) throw error; + } + } + } catch (error) { + logger.debug('[revokeOpenIDRefreshFlight] Error revoking flight:', error); + throw error; + } + } + + return { + acquireOpenIDRefreshFlight, + claimOpenIDRefreshFlightDelivery, + renewOpenIDRefreshFlight, + completeOpenIDRefreshFlight, + failOpenIDRefreshFlight, + revokeOpenIDRefreshFlight, + releaseOpenIDRefreshFlightDelivery, + findOpenIDRefreshFlight, + }; +} + +export type OpenIDRefreshFlightMethods = ReturnType; diff --git a/packages/data-schemas/src/methods/refreshTokenBridge.spec.ts b/packages/data-schemas/src/methods/refreshTokenBridge.spec.ts new file mode 100644 index 0000000000..e3a0da7c36 --- /dev/null +++ b/packages/data-schemas/src/methods/refreshTokenBridge.spec.ts @@ -0,0 +1,215 @@ +import mongoose from 'mongoose'; +import { MongoMemoryServer } from 'mongodb-memory-server'; +import type * as t from '~/types'; +import { createRefreshTokenBridgeMethods } from './refreshTokenBridge'; +import refreshTokenBridgeSchema from '~/schema/refreshTokenBridge'; + +jest.mock('~/config/winston', () => ({ + error: jest.fn(), + info: jest.fn(), + debug: jest.fn(), +})); + +let mongoServer: MongoMemoryServer; +let methods: ReturnType; + +beforeAll(async () => { + mongoServer = await MongoMemoryServer.create(); + await mongoose.connect(mongoServer.getUri()); + if (!mongoose.models.RefreshTokenBridge) { + mongoose.model('RefreshTokenBridge', refreshTokenBridgeSchema); + } +}); + +afterAll(async () => { + await mongoose.disconnect(); + await mongoServer?.stop(); +}); + +beforeEach(async () => { + await mongoose.connection.dropDatabase(); + methods = createRefreshTokenBridgeMethods(mongoose); +}); + +describe('RefreshTokenBridge Methods', () => { + it('creates uniqueness and TTL indexes before the first write', async () => { + await methods.upsertRefreshTokenBridge({ + oldRefreshTokenHash: 'old-hash', + encryptedNewRefreshToken: 'encrypted-new', + userId: 'user-1', + tenantId: 'tenant-1', + expiresAt: new Date(Date.now() + 60000), + }); + + const indexes = await mongoose.models.RefreshTokenBridge.listIndexes(); + expect(indexes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + key: { oldRefreshTokenHash: 1, userId: 1, tenantId: 1 }, + unique: true, + }), + expect.objectContaining({ key: { expiresAt: 1 }, expireAfterSeconds: 0 }), + ]), + ); + }); + + it('keeps the lookup indexes aligned with the data-layer query shape', () => { + const indexKeys = mongoose.models.RefreshTokenBridge.schema.indexes().map(([key]) => key); + + expect(indexKeys).toContainEqual({ oldRefreshTokenHash: 1, userId: 1, tenantId: 1 }); + expect(indexKeys).not.toContainEqual({ + oldRefreshTokenHash: 1, + userId: 1, + tenantId: 1, + openidIssuer: 1, + }); + }); + + it('upserts and finds a bridge by old token hash, user, and tenant', async () => { + await methods.upsertRefreshTokenBridge({ + oldRefreshTokenHash: 'old-hash', + encryptedNewRefreshToken: 'encrypted-new', + userId: 'user-1', + tenantId: 'tenant-1', + openidIssuer: 'https://issuer.example.com', + expiresAt: new Date(Date.now() + 60000), + }); + + const found = await methods.findRefreshTokenBridge({ + oldRefreshTokenHash: 'old-hash', + userId: 'user-1', + tenantId: 'tenant-1', + }); + + expect(found?.encryptedNewRefreshToken).toBe('encrypted-new'); + expect(found?.openidIssuer).toBe('https://issuer.example.com'); + }); + + it('replaces the encrypted token and expiry on repeated stores', async () => { + await methods.upsertRefreshTokenBridge({ + oldRefreshTokenHash: 'old-hash', + encryptedNewRefreshToken: 'encrypted-old', + userId: 'user-1', + expiresAt: new Date(Date.now() + 60000), + }); + + const nextExpiresAt = new Date(Date.now() + 120000); + await methods.upsertRefreshTokenBridge({ + oldRefreshTokenHash: 'old-hash', + encryptedNewRefreshToken: 'encrypted-new', + userId: 'user-1', + expiresAt: nextExpiresAt, + }); + + const found = await methods.findRefreshTokenBridge({ + oldRefreshTokenHash: 'old-hash', + userId: 'user-1', + }); + + expect(found?.encryptedNewRefreshToken).toBe('encrypted-new'); + expect(found?.expiresAt.getTime()).toBe(nextExpiresAt.getTime()); + expect(await mongoose.models.RefreshTokenBridge.countDocuments()).toBe(1); + }); + + it('does not return expired bridges before Mongo TTL cleanup runs', async () => { + await methods.upsertRefreshTokenBridge({ + oldRefreshTokenHash: 'old-hash', + encryptedNewRefreshToken: 'encrypted-new', + userId: 'user-1', + expiresAt: new Date(Date.now() - 1000), + }); + + await expect( + methods.findRefreshTokenBridge({ + oldRefreshTokenHash: 'old-hash', + userId: 'user-1', + }), + ).resolves.toBeNull(); + }); + + it('deletes only the requested user and tenant bridge hashes', async () => { + for (const [oldRefreshTokenHash, userId, tenantId] of [ + ['hash-a', 'user-1', 'tenant-1'], + ['hash-b', 'user-1', 'tenant-1'], + ['hash-a', 'user-2', 'tenant-1'], + ['hash-a', 'user-1', 'tenant-2'], + ]) { + await methods.upsertRefreshTokenBridge({ + oldRefreshTokenHash, + encryptedNewRefreshToken: `encrypted-${oldRefreshTokenHash}`, + userId, + tenantId, + expiresAt: new Date(Date.now() + 60000), + }); + } + + const result = await methods.deleteRefreshTokenBridges({ + oldRefreshTokenHashes: ['hash-a', 'hash-b'], + userId: 'user-1', + tenantId: 'tenant-1', + }); + + expect(result.deletedCount).toBe(2); + expect(await mongoose.models.RefreshTokenBridge.countDocuments()).toBe(2); + }); + + it('does not let a stale owner delete a replacement bridge version', async () => { + const expiresAt = new Date(Date.now() + 60000); + await methods.upsertRefreshTokenBridge({ + oldRefreshTokenHash: 'old-hash', + encryptedNewRefreshToken: 'encrypted-old', + userId: 'user-1', + tenantId: 'tenant-1', + version: 'owner-version', + expiresAt, + }); + await methods.upsertRefreshTokenBridge({ + oldRefreshTokenHash: 'old-hash', + encryptedNewRefreshToken: 'encrypted-winner', + userId: 'user-1', + tenantId: 'tenant-1', + version: 'winner-version', + expiresAt, + }); + + const staleDelete = await methods.deleteRefreshTokenBridges({ + oldRefreshTokenHashes: ['old-hash'], + userId: 'user-1', + tenantId: 'tenant-1', + version: 'owner-version', + }); + const winner = await methods.findRefreshTokenBridge({ + oldRefreshTokenHash: 'old-hash', + userId: 'user-1', + tenantId: 'tenant-1', + }); + + expect(staleDelete.deletedCount).toBe(0); + expect(winner?.encryptedNewRefreshToken).toBe('encrypted-winner'); + expect(winner?.version).toBe('winner-version'); + }); + + it('deletes all predecessor bridges for one user and tenant', async () => { + for (const [oldRefreshTokenHash, userId, tenantId] of [ + ['hash-a', 'user-1', 'tenant-1'], + ['hash-b', 'user-1', 'tenant-1'], + ['hash-c', 'user-2', 'tenant-1'], + ]) { + await methods.upsertRefreshTokenBridge({ + oldRefreshTokenHash, + encryptedNewRefreshToken: `encrypted-${oldRefreshTokenHash}`, + userId, + tenantId, + expiresAt: new Date(Date.now() + 60000), + }); + } + + const result = await methods.deleteRefreshTokenBridges({ + userId: 'user-1', + tenantId: 'tenant-1', + }); + + expect(result.deletedCount).toBe(2); + expect(await mongoose.models.RefreshTokenBridge.countDocuments()).toBe(1); + }); +}); diff --git a/packages/data-schemas/src/methods/refreshTokenBridge.ts b/packages/data-schemas/src/methods/refreshTokenBridge.ts new file mode 100644 index 0000000000..b42761a833 --- /dev/null +++ b/packages/data-schemas/src/methods/refreshTokenBridge.ts @@ -0,0 +1,126 @@ +import type { DeleteResult, FilterQuery, Model, UpdateQuery } from 'mongoose'; +import type { + IRefreshTokenBridge, + RefreshTokenBridgeCreateData, + RefreshTokenBridgeDeleteData, + RefreshTokenBridgeQuery, +} from '~/types'; +import { createIndexesWithRetry } from '~/utils/retry'; +import logger from '~/config/winston'; + +function bridgeFilter({ + oldRefreshTokenHash, + userId, + tenantId, +}: RefreshTokenBridgeQuery): FilterQuery { + return { + oldRefreshTokenHash, + userId, + tenantId: tenantId ?? { $exists: false }, + }; +} + +export function createRefreshTokenBridgeMethods(mongoose: typeof import('mongoose')): { + upsertRefreshTokenBridge: ( + bridgeData: RefreshTokenBridgeCreateData, + ) => Promise; + findRefreshTokenBridge: (query: RefreshTokenBridgeQuery) => Promise; + deleteRefreshTokenBridges: (data: RefreshTokenBridgeDeleteData) => Promise; +} { + let indexesPromise: Promise | null = null; + + const getRefreshTokenBridgeModel = () => + mongoose.models.RefreshTokenBridge as Model; + + /** + * A bridge holds an encrypted refresh token, and the TTL index is the only thing that ever + * deletes one. `MONGO_AUTO_INDEX=false` is a supported deployment setting, and under it Mongoose + * builds neither that index nor the compound uniqueness the upsert relies on β€” so bridges would + * accumulate for the life of the collection and concurrent writes could leave duplicates. The + * indexes are therefore installed before the first write, and a failed build is retried on the + * next attempt rather than cached. + */ + function ensureIndexes(): Promise { + if (!indexesPromise) { + indexesPromise = createIndexesWithRetry(getRefreshTokenBridgeModel()).catch((error) => { + indexesPromise = null; + throw error; + }); + } + return indexesPromise; + } + + async function upsertRefreshTokenBridge( + bridgeData: RefreshTokenBridgeCreateData, + ): Promise { + try { + await ensureIndexes(); + const RefreshTokenBridge = getRefreshTokenBridgeModel(); + const filter = bridgeFilter(bridgeData); + const update: UpdateQuery = { + $set: { + encryptedNewRefreshToken: bridgeData.encryptedNewRefreshToken, + version: bridgeData.version, + expiresAt: bridgeData.expiresAt, + ...(bridgeData.openidIssuer != null && { openidIssuer: bridgeData.openidIssuer }), + }, + $setOnInsert: { + oldRefreshTokenHash: bridgeData.oldRefreshTokenHash, + userId: bridgeData.userId, + ...(bridgeData.tenantId != null && { tenantId: bridgeData.tenantId }), + createdAt: new Date(), + }, + ...(bridgeData.openidIssuer == null && { $unset: { openidIssuer: '' } }), + }; + return await RefreshTokenBridge.findOneAndUpdate(filter, update, { + upsert: true, + new: true, + }).lean(); + } catch (error) { + logger.debug('[upsertRefreshTokenBridge] Error storing bridge:', error); + throw error; + } + } + + async function findRefreshTokenBridge( + query: RefreshTokenBridgeQuery, + ): Promise { + try { + const RefreshTokenBridge = getRefreshTokenBridgeModel(); + return await RefreshTokenBridge.findOne({ + ...bridgeFilter(query), + expiresAt: { $gt: new Date() }, + }).lean(); + } catch (error) { + logger.debug('[findRefreshTokenBridge] Error finding bridge:', error); + throw error; + } + } + + async function deleteRefreshTokenBridges( + data: RefreshTokenBridgeDeleteData, + ): Promise { + try { + const RefreshTokenBridge = getRefreshTokenBridgeModel(); + return await RefreshTokenBridge.deleteMany({ + ...(data.oldRefreshTokenHashes && { + oldRefreshTokenHash: { $in: data.oldRefreshTokenHashes }, + }), + ...(data.version && { version: data.version }), + userId: data.userId, + tenantId: data.tenantId ?? { $exists: false }, + }); + } catch (error) { + logger.debug('[deleteRefreshTokenBridges] Error deleting bridges:', error); + throw error; + } + } + + return { + upsertRefreshTokenBridge, + findRefreshTokenBridge, + deleteRefreshTokenBridges, + }; +} + +export type RefreshTokenBridgeMethods = ReturnType; diff --git a/packages/data-schemas/src/models/index.ts b/packages/data-schemas/src/models/index.ts index 1da5a3eb3a..ed68a2fd43 100644 --- a/packages/data-schemas/src/models/index.ts +++ b/packages/data-schemas/src/models/index.ts @@ -1,7 +1,9 @@ import { createAgentTriggerLaneSequenceModel } from './triggerLaneSequence'; import { createScheduleModel, createScheduleRunModel } from './schedule'; import { createSkillSyncCredentialModel } from './skillSyncCredential'; +import { createOpenIDRefreshFlightModel } from './openidRefreshFlight'; import { createAgentTriggerUserPurgeModel } from './triggerUserPurge'; +import { createRefreshTokenBridgeModel } from './refreshTokenBridge'; import { createAgentTriggerDeliveryModel } from './triggerDelivery'; import { createSkillSyncStatusModel } from './skillSyncStatus'; import { createConversationTagModel } from './conversationTag'; @@ -87,6 +89,8 @@ export function createModels(mongoose: typeof import('mongoose')): { AgentTriggerUserPurge: ReturnType; Schedule: ReturnType; ScheduleRun: ReturnType; + RefreshTokenBridge: ReturnType; + OpenIDRefreshFlight: ReturnType; } { const models = { User: createUserModel(mongoose), @@ -131,6 +135,8 @@ export function createModels(mongoose: typeof import('mongoose')): { AgentTriggerUserPurge: createAgentTriggerUserPurgeModel(mongoose), Schedule: createScheduleModel(mongoose), ScheduleRun: createScheduleRunModel(mongoose), + RefreshTokenBridge: createRefreshTokenBridgeModel(mongoose), + OpenIDRefreshFlight: createOpenIDRefreshFlightModel(mongoose), }; /** * Background index builds fail silently unless an 'index' listener is diff --git a/packages/data-schemas/src/models/openidRefreshFlight.ts b/packages/data-schemas/src/models/openidRefreshFlight.ts new file mode 100644 index 0000000000..55c07c2e94 --- /dev/null +++ b/packages/data-schemas/src/models/openidRefreshFlight.ts @@ -0,0 +1,16 @@ +import { Model } from 'mongoose'; +import type * as t from '~/types'; +import openidRefreshFlightSchema from '~/schema/openidRefreshFlight'; + +/** + * Short-lived cross-worker coordination for inline OIDC refreshes. These + * documents are keyed by hashed token/session context and expire via TTL. + */ +export function createOpenIDRefreshFlightModel( + mongoose: typeof import('mongoose'), +): Model { + return ( + mongoose.models.OpenIDRefreshFlight || + mongoose.model('OpenIDRefreshFlight', openidRefreshFlightSchema) + ); +} diff --git a/packages/data-schemas/src/models/plugins/tenantIsolation.coverage.spec.ts b/packages/data-schemas/src/models/plugins/tenantIsolation.coverage.spec.ts index 6f318abe10..3af1e34b79 100644 --- a/packages/data-schemas/src/models/plugins/tenantIsolation.coverage.spec.ts +++ b/packages/data-schemas/src/models/plugins/tenantIsolation.coverage.spec.ts @@ -15,10 +15,17 @@ const TENANT_ISOLATION_APPLIED = Symbol.for('librechat:tenantIsolation'); * status rows and tenant-scoped override rows, so its methods apply explicit * tenant filters instead of ambient ALS scoping. AuditLog composes its tenant * filter from the JWT-resolved caller and uses `{ tenantId: { $exists: false } }` - * for platform-level entries. Adding an entry here must be a deliberate, - * reviewed decision β€” that is the whole point of this guard. + * for platform-level entries. RefreshTokenBridge resolves tenant context from + * the signed OpenID marker cookie during unauthenticated refresh recovery, and + * its methods apply explicit tenant filters. Adding an entry here must be a + * deliberate, reviewed decision β€” that is the whole point of this guard. */ -const MANUAL_TENANT_SCOPING = new Set(['SystemGrant', 'SkillSyncStatus', 'AuditLog']); +const MANUAL_TENANT_SCOPING = new Set([ + 'SystemGrant', + 'SkillSyncStatus', + 'AuditLog', + 'RefreshTokenBridge', +]); function isPluginApplied(schema: mongoose.Schema): boolean { return (schema as unknown as { [key: symbol]: boolean })[TENANT_ISOLATION_APPLIED] === true; diff --git a/packages/data-schemas/src/models/refreshTokenBridge.ts b/packages/data-schemas/src/models/refreshTokenBridge.ts new file mode 100644 index 0000000000..f55063c4b2 --- /dev/null +++ b/packages/data-schemas/src/models/refreshTokenBridge.ts @@ -0,0 +1,17 @@ +import { Model } from 'mongoose'; +import type * as t from '~/types'; +import refreshTokenBridgeSchema from '~/schema/refreshTokenBridge'; + +/** + * Refresh-token bridges are looked up from unauthenticated refresh requests + * after user context is recovered from a signed cookie. Methods apply explicit + * tenant checks, so automatic tenant isolation would be the wrong boundary here. + */ +export function createRefreshTokenBridgeModel( + mongoose: typeof import('mongoose'), +): Model { + return ( + mongoose.models.RefreshTokenBridge || + mongoose.model('RefreshTokenBridge', refreshTokenBridgeSchema) + ); +} diff --git a/packages/data-schemas/src/schema/index.ts b/packages/data-schemas/src/schema/index.ts index 84aa7d4ec0..210a765b90 100644 --- a/packages/data-schemas/src/schema/index.ts +++ b/packages/data-schemas/src/schema/index.ts @@ -17,6 +17,8 @@ export { default as pluginAuthSchema } from './pluginAuth'; export { default as presetSchema } from './preset'; export { default as promptSchema } from './prompt'; export { default as promptGroupSchema } from './promptGroup'; +export { default as openidRefreshFlightSchema } from './openidRefreshFlight'; +export { default as refreshTokenBridgeSchema } from './refreshTokenBridge'; export { default as roleSchema } from './role'; export { default as sessionSchema } from './session'; export { default as shareSchema } from './share'; diff --git a/packages/data-schemas/src/schema/openidRefreshFlight.ts b/packages/data-schemas/src/schema/openidRefreshFlight.ts new file mode 100644 index 0000000000..ed852a9b9a --- /dev/null +++ b/packages/data-schemas/src/schema/openidRefreshFlight.ts @@ -0,0 +1,59 @@ +import { Schema } from 'mongoose'; +import type { IOpenIDRefreshFlight } from '~/types'; + +const openidRefreshFlightSchema: Schema = new Schema({ + key: { + type: String, + required: true, + unique: true, + }, + ownerId: { + type: String, + required: true, + }, + status: { + type: String, + enum: ['pending', 'completed', 'failed', 'revoked'], + required: true, + default: 'pending', + index: true, + }, + encryptedResult: { + type: String, + }, + errorMessage: { + type: String, + }, + deliveryId: { + type: String, + }, + deliveryExpiresAt: { + type: Date, + }, + revocationRequestedAt: { + type: Date, + }, + createdAt: { + type: Date, + required: true, + default: Date.now, + }, + updatedAt: { + type: Date, + required: true, + default: Date.now, + }, + lockExpiresAt: { + type: Date, + required: true, + index: true, + }, + expiresAt: { + type: Date, + required: true, + }, +}); + +openidRefreshFlightSchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 }); + +export default openidRefreshFlightSchema; diff --git a/packages/data-schemas/src/schema/refreshTokenBridge.ts b/packages/data-schemas/src/schema/refreshTokenBridge.ts new file mode 100644 index 0000000000..0f8658f4cb --- /dev/null +++ b/packages/data-schemas/src/schema/refreshTokenBridge.ts @@ -0,0 +1,45 @@ +import { Schema } from 'mongoose'; +import type { IRefreshTokenBridge } from '~/types'; + +const refreshTokenBridgeSchema: Schema = new Schema({ + oldRefreshTokenHash: { + type: String, + required: true, + }, + encryptedNewRefreshToken: { + type: String, + required: true, + }, + userId: { + type: String, + required: true, + index: true, + }, + tenantId: { + type: String, + index: true, + }, + openidIssuer: { + type: String, + }, + version: { + type: String, + }, + createdAt: { + type: Date, + required: true, + default: Date.now, + }, + expiresAt: { + type: Date, + required: true, + }, +}); + +refreshTokenBridgeSchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 }); +refreshTokenBridgeSchema.index( + { oldRefreshTokenHash: 1, userId: 1, tenantId: 1 }, + { unique: true }, +); + +export default refreshTokenBridgeSchema; diff --git a/packages/data-schemas/src/types/index.ts b/packages/data-schemas/src/types/index.ts index c3c9aecd61..6f29fe93e6 100644 --- a/packages/data-schemas/src/types/index.ts +++ b/packages/data-schemas/src/types/index.ts @@ -5,6 +5,8 @@ export * from './app'; export * from './cache'; export * from './user'; export * from './token'; +export * from './refreshTokenBridge'; +export * from './openidRefreshFlight'; export * from './convo'; export * from './chatProject'; export * from './session'; diff --git a/packages/data-schemas/src/types/openidRefreshFlight.ts b/packages/data-schemas/src/types/openidRefreshFlight.ts new file mode 100644 index 0000000000..2da103909f --- /dev/null +++ b/packages/data-schemas/src/types/openidRefreshFlight.ts @@ -0,0 +1,74 @@ +import type { Document } from 'mongoose'; + +export type OpenIDRefreshFlightStatus = 'pending' | 'completed' | 'failed' | 'revoked'; + +export interface IOpenIDRefreshFlight extends Document { + key: string; + ownerId: string; + status: OpenIDRefreshFlightStatus; + encryptedResult?: string; + errorMessage?: string; + deliveryId?: string; + deliveryExpiresAt?: Date; + revocationRequestedAt?: Date; + createdAt: Date; + updatedAt: Date; + lockExpiresAt: Date; + expiresAt: Date; +} + +export interface OpenIDRefreshFlightCreateData { + key: string; + ownerId: string; + lockExpiresAt: Date; + expiresAt: Date; +} + +export interface OpenIDRefreshFlightCompleteData { + key: string; + ownerId: string; + encryptedResult: string; + expiresAt: Date; +} + +export interface OpenIDRefreshFlightRenewData { + key: string; + ownerId: string; + lockExpiresAt: Date; + expiresAt: Date; +} + +export interface OpenIDRefreshFlightFailData { + key: string; + ownerId: string; + errorMessage: string; + expiresAt: Date; +} + +export interface OpenIDRefreshFlightRevokeData { + key: string; + expiresAt: Date; +} + +export interface OpenIDRefreshFlightClaimDeliveryData { + key: string; + ownerId: string; + deliveryId: string; + deliveryExpiresAt: Date; + createdAt?: Date; +} + +export interface OpenIDRefreshFlightReleaseDeliveryData { + key: string; + ownerId: string; + deliveryId: string; +} + +export interface OpenIDRefreshFlightQuery { + key: string; +} + +export interface OpenIDRefreshFlightAcquireResult { + acquired: boolean; + flight: IOpenIDRefreshFlight | null; +} diff --git a/packages/data-schemas/src/types/refreshTokenBridge.ts b/packages/data-schemas/src/types/refreshTokenBridge.ts new file mode 100644 index 0000000000..6db7cd8f14 --- /dev/null +++ b/packages/data-schemas/src/types/refreshTokenBridge.ts @@ -0,0 +1,35 @@ +import type { Document } from 'mongoose'; + +export interface IRefreshTokenBridge extends Document { + oldRefreshTokenHash: string; + encryptedNewRefreshToken: string; + userId: string; + tenantId?: string; + openidIssuer?: string; + version?: string; + createdAt: Date; + expiresAt: Date; +} + +export interface RefreshTokenBridgeCreateData { + oldRefreshTokenHash: string; + encryptedNewRefreshToken: string; + userId: string; + tenantId?: string; + openidIssuer?: string; + version?: string; + expiresAt: Date; +} + +export interface RefreshTokenBridgeQuery { + oldRefreshTokenHash: string; + userId: string; + tenantId?: string; +} + +export interface RefreshTokenBridgeDeleteData { + oldRefreshTokenHashes?: string[]; + userId: string; + tenantId?: string; + version?: string; +}