Skip to content

🔄 feat: Body-Based OpenID Refresh for Cross-Origin Admin Panels#46

Merged
danny-avila merged 4 commits into
mainfrom
feat/admin-oauth-refresh-bff
May 8, 2026
Merged

🔄 feat: Body-Based OpenID Refresh for Cross-Origin Admin Panels#46
danny-avila merged 4 commits into
mainfrom
feat/admin-oauth-refresh-bff

Conversation

@dustinhealy
Copy link
Copy Markdown
Contributor

Summary

The cookie-based /api/auth/refresh controller can't be reached cross-origin because the refresh-token cookie doesn't ship on a fetch from a different host than the LibreChat backend. The result: cross-origin admin panel sessions silently die at JWT expiry with no recovery.

This routes openid sessions through the new POST /api/admin/oauth/refresh endpoint (danny-avila/LibreChat#13007) which accepts the refresh token in the request body and returns the same shape as /api/admin/oauth/exchange: { token, refreshToken, user, expiresAt }. Non-openid (librechat) sessions still use the legacy cookie-based path.

Changes

  • src/server/auth.tsrefreshAdminToken posts to /api/admin/oauth/refresh for openid sessions, taking a new userId argument that's forwarded as user_id in the body for disambiguation when multiple user docs share the same OpenID sub. Threads expiresAt from the response into the session.
  • src/types/server.ts — adds optional expiresAt?: number (ms epoch) to SessionData and OAuthExchangeResponse so the admin panel can drive proactive refresh before the bearer expires.

Compatibility

  • librechat (local) sessions: unchanged, still use /api/auth/refresh with the cookie-based path.
  • openid sessions on backends without the new endpoint: refresh returns 404, the admin panel clears the session and redirects to login. Same end-state as before, just one cycle later.

Testing

  • The upstream /api/admin/oauth/refresh endpoint (danny-avila/LibreChat#13007) was validated via direct POST against a Microsoft Entra (Azure AD) personal-account tenant: a stored Entra refresh token returned a 200 with a valid { token, refreshToken, user, expiresAt } payload (rotated refresh_token, fresh HS256 LC JWT, ~24h expiresAt).
  • This admin-panel-side change calls that same endpoint with the same body shape and parses the same response shape, so the integration is mechanical. Panel-driven refresh has not been exercised end-to-end yet (the bearer's natural expiry would take ~1 hour to test through the UI).
  • Type-check and ESLint clean.

The cookie-based `/api/auth/refresh` controller can't be reached cross-origin
because the refresh-token cookie doesn't ship on a fetch from a different host.
Cross-origin admin panel sessions silently die at JWT expiry with no recovery.

This routes `openid` sessions through the new `POST /api/admin/oauth/refresh`
endpoint (danny-avila/LibreChat#13007) which accepts the refresh token in
the request body and returns the same shape as `/api/admin/oauth/exchange`:
`{ token, refreshToken, user, expiresAt }`. Non-openid sessions still use
the legacy cookie-based path.

The `expiresAt` field (ms epoch) is now threaded through `SessionData` and
`OAuthExchangeResponse` so the admin panel can drive proactive refresh
before the bearer expires.

`refreshAdminToken` takes a new `userId` argument that's forwarded as
`user_id` in the refresh request body for disambiguation when multiple
user docs share the same OpenID `sub`.
@dustinhealy dustinhealy marked this pull request as ready for review May 8, 2026 13:55
@danny-avila
Copy link
Copy Markdown
Contributor

One follow-up needed now that danny-avila/LibreChat#13007 scopes /api/admin/oauth/refresh by tenant:

  • Please forward the trusted X-Tenant-Id header on the BFF refresh request. The backend now reads tenant context via preAuthTenantMiddleware and passes getTenantId() into applyAdminRefresh. If this server-to-server call keeps sending only Content-Type, getTenantId() is undefined and the backend falls back to single-tenant behavior, so the multi-tenant duplicate (sub, iss) protection won't activate. Suggested shape: read getRequestHeader('x-tenant-id') and forward it as X-Tenant-Id on /api/admin/oauth/refresh.
  • Please actually use expiresAt before admin API calls. This PR stores it, but apiFetch still sends the session bearer as-is. If an admin stays on a page past JWT expiry, the next query/mutation can fail with 401 before route-level verification refreshes. Prefer centralizing token freshness in apiFetch: refresh when expiresAt is within a small skew window, persist any rotated refresh token, and retry once on 401.

Suggested tests: tenant header forwarded/omitted correctly, apiFetch proactively refreshes near expiry, rotated refresh token is persisted, and 401 retry happens only once.

dustinhealy and others added 2 commits May 8, 2026 13:11
Two follow-ups for the BFF refresh path now that the LibreChat
backend scopes /api/admin/oauth/refresh by tenant.

apiFetch was sending the session bearer as-is. If a query landed
past JWT expiry it would fail before the 60s revalidation interval
kicked in. apiFetch now reads expiresAt, refreshes proactively when
the bearer is within 30s of expiry, persists any rotated refresh
token, and retries the original request exactly once on a 401.

The OpenID refresh request now forwards the deployment's X-Tenant-Id
header so the backend's preAuthTenantMiddleware can scope the user
lookup. Without this the backend would fall back to single-tenant
behavior and the multi-tenant duplicate (sub, iss) protection added
in danny-avila/LibreChat#13007 wouldn't activate.

Refresh logic moves to a shared src/server/utils/refresh.ts so the
verify path and apiFetch share one implementation. Concurrent
callers are deduped on the refresh token so two React Query
subscribers can't both consume a rotating token in the same BFF
process.

Adds 19 tests covering tenant header forwarded/omitted, dedupe,
proactive refresh inside/outside skew, rotation persistence, and
single-retry 401 behavior.
@danny-avila
Copy link
Copy Markdown
Contributor

@codex review

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d6aa8680e1

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/server/utils/refresh.ts Outdated
Keying the in-flight refresh map on refreshToken alone meant two
concurrent calls that happen to share a token string but differ by
userId, tokenProvider, or tenant would coalesce, and the second
caller would receive the first caller's bearer and persist it into
the wrong session.

Build the dedupe key from tokenProvider, userId, the request's
X-Tenant-Id header, and the refresh token (joined by NUL). Adds
three regression tests covering each discriminator.
@danny-avila danny-avila merged commit 8ceace7 into main May 8, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants