Merge jcbartle's #13901 up to date with main (307 commits since its last
merge at 5ff282f90) and reconcile it with what landed in the meantime.
OBO exchange read `user.federatedTokens`, the snapshot captured at strategy
time, so a user who idled past their access-token lifetime got "No valid
OpenID access token is available for OBO exchange" while a still-refreshable
token sat unread in `req.session.openidTokens` (#13899). Exchange now reads
the live server-side session at MCP call time and refreshes the upstream
token inline, single-flighted across workers through a Mongo refresh-flight
record, with an encrypted bridge so a token rotated mid-SSE stream (headers
already sent, cookie unwritable) stays recoverable on the next /refresh.
Reconciled against main:
- Exchanged-token cache TTLs now carry the in-transit expiry buffer (#15320).
The PR's `mcp/oauth/expiry.ts` and main's `oauth/expiry.ts` (#15321) were
two homes for the same arithmetic; they are now one. `getTokenCacheTtlMs`
subtracts `OPENID_EXPIRY_BUFFER_SECONDS`, which also covers the Graph and
openidStrategy caches, and the skew helpers build on main's
`normalizeExpiresIn` so an elapsed lifetime still stays elapsed.
`OPENID_EXPIRY_BUFFER_SECONDS` moves to `oauth/expiry.ts`, where the rest
of the lifetime math lives: consumers that stub `~/utils/oidc` were
leaving the arithmetic reading `undefined`.
- The `openid_user_id` marker keeps the refresh-token binding main added in
#15252, now inside the shared `setOpenIDMarkerCookies` helper so the
inline-refresh path produces the same bound cookie as login.
- Every OpenID token issuance in `refreshController` — including bridge
recovery, which previously issued tokens without one — persists the
durable Session record through `sendOpenIDAuthResponse`.
- An inline refresh that rotates the token and writes the cookie moves the
durable Session record with it. Local image access for OpenID users is
authorized by that record keyed on the browser's cookie, so leaving it
behind 401s every image until the next /refresh. The bridge path
deliberately leaves it alone: there the browser keeps the old cookie.
Co-authored-by: jcbartle <7274202+jcbartle@users.noreply.github.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SxKWxwqxAGckYpRsYTqx3F