Commit graph

6 commits

Author SHA1 Message Date
Dustin Healy
bd158905b3 🔒 fix: Harden admin OAuth refresh against user bans, tenant scope gaps, and cross-tenant migration
Post-identity-resolution ban check: the initial checkBan middleware fires before the
refresh token is exchanged and req.user is populated, so it can only evaluate IP bans.
After applyGoogleAdminRefresh/applyAdminRefresh resolves the user identity, we now
synthesize req.user and re-run checkBan against the resolved user's id before emitting
the JWT, so a user-level ban is enforced even from a fresh IP.

Domain allowlist now includes userId: the getAppConfig call in isEmailAllowedForUser
was passing only role, missing user and group-level allowedDomains overrides that the
initial OAuth callback's checkDomainAllowed enforces via userId. Both branches now
pass userId so buildPrincipals takes the full user+group+role resolution path. The
tenant branch is also inlined (replacing resolveAppConfigForUser) to accept userId,
wrapped in tenantStorage.run for correct Mongoose scoping and cache-key resolution.

Cross-tenant email-fallback migration: the Passport verify callback fires before
tenantContextMiddleware, so findUser({email}) is unscoped and can return a same-email
user from another tenant. Writing googleId onto that document permanently corrupts
the other tenant's account. Migration is now blocked for users with a tenantId;
single-tenant users are unaffected.
2026-06-22 10:42:20 -07:00
Dustin Healy
fcdb66bb6b 🔒 fix: Apply brutal-review hardening to Google admin refresh
Tighten the Google OAuth refresh flow against all outstanding code review
findings: enforce JWT aud claim verification against the configured clientId
(ISSUER_MISMATCH on mismatch), reject ambiguous googleId matches (limit:2 in
findUsers, USER_ID_MISMATCH when multiple rows match), scope the authInfo
refresh-token carrier to the Google provider only, add TOCTOU re-read defense
after the admin googleId migration write in socialLogin, deduplicate
canAccessAdmin/mintToken closures via buildAdminRefreshClosures shared by both
OpenID and Google refresh paths, document rotation semantics on
AdminExchangeResponse.refreshToken, standardise all log prefixes to
[admin/oauth/refresh], and expand test coverage for all new paths.
2026-06-22 09:34:41 -07:00
Dustin Healy
21922eea78 🧹 refactor: Move Google admin refresh into TypeScript @librechat/api helper
Per repo guidance (CLAUDE.md): all new backend code must be TypeScript in
/packages/api, and /api is a thin JS wrapper. The previous commit landed the
Google admin refresh flow as ~120 lines of new JS inside
api/server/routes/admin/auth.js, which violates that. This commit extracts
the flow into a new TS helper at packages/api/src/auth/googleRefresh.ts and
reduces the route handler to a thin dep-wiring wrapper.

The helper exports applyGoogleAdminRefresh(deps, options) with the same
shape as the OpenID applyAdminRefresh: callers pass findUsers, getUserById,
canAccessAdmin, and mintToken as deps so the package stays free of /api
model imports and capability/session helpers. The route handler now builds
those deps from the existing model + capability + token modules and calls
the helper, mapping AdminRefreshError to the documented HTTP responses.

While moving the code, the helper now guards getUserById with
Types.ObjectId.isValid before the direct-lookup branch, matching the
OpenID admin path at packages/api/src/auth/refresh.ts. Without this guard
a malformed user_id from the admin client would hit Mongoose findById's
CastError and surface as a 500 INTERNAL_ERROR instead of falling through
to the documented sub-based lookup.

Tests move with the code: packages/api/src/auth/googleRefresh.spec.ts now
owns the helper's behavior (token endpoint, userinfo fallback, ObjectId
guard, USER_ID_MISMATCH/TENANT_MISMATCH/USER_NOT_FOUND/FORBIDDEN, rotated
refresh-token pass-through, GOOGLE_NOT_CONFIGURED, IDP_INCOMPLETE on
non-JSON body, CLAIMS_INCOMPLETE when both id_token and userinfo miss).
The route-level api/server/routes/admin/auth.refresh.test.js drops the
duplicated end-to-end Google cases and keeps a smaller surface: route
delegates to applyGoogleAdminRefresh with the right deps + options, maps
AdminRefreshError to HTTP status/code, falls through to 500 for unknown
errors, and rejects unknown providers with INVALID_PROVIDER.
2026-06-18 12:18:46 -07:00
Dustin Healy
1dddf97c4a 🔁 fix: Harden Google admin refresh against bot review findings
Five validated findings from the initial bot pass:

socialLogin.js: mirror the OpenID migrate-or-reject pattern on the email
fallback. When an existing user is found by email and the stored provider
id is empty, persist the refreshed sub so the refresh path can later bind
to it. When the stored id is present and differs, reject as AUTH_FAILED
to prevent identity-swap, matching the existing OpenID behavior in
packages/api/src/auth/openid.ts.

oauth.js: scope the non-OpenID admin refresh-token forwarding to
provider === 'google'. The previous else branch would have forwarded a
Discord refresh token (passport-discord supplies one) into the admin
exchange payload even though /api/admin/oauth/refresh only accepts
openid or google, leaving the admin client with a token it could not
refresh.

admin/auth.js (refreshGoogleAdminSession): drop id_token from the
mandatory-fields check. Google's OAuth refresh response is documented to
include id_token only conditionally, so the previous mandatory check
broke refresh whenever Google omitted it. Decode id_token when present
(fast path); when absent, call Google's userinfo endpoint with the
access token to read sub. Wrap tokenResponse.json() in try/catch and
return IDP_INCOMPLETE on parse failure instead of a generic 500.
Tighten access_token to a typeof string check.

admin/auth.js (refreshGoogleAdminSession): reuse serializeUserForExchange
for the response user so the Google refresh shape matches /oauth/exchange
and the OpenID branch exactly (full _id, id, email, name, username, role,
avatar, provider, openidId). The previous Google-specific subset dropped
fields the admin client relies on for later provider-specific refreshes
and disambiguation.

Tests cover each fix: socialLogin's migration and rejection cases, the
oauth.js Discord-gating case, the userinfo fallback path on missing
id_token, CLAIMS_INCOMPLETE when both id_token and userinfo are absent,
IDP_INCOMPLETE on a non-JSON token body, and the full response shape on
the happy path.
2026-06-18 11:50:52 -07:00
Dustin Healy
d40c51616e 🔑 feat: Refresh-Capable Google Admin OAuth Sessions
Google admin sessions cannot be refreshed today. Three gaps add up to that:
passport.authenticate('googleAdmin', ...) in api/server/routes/admin/auth.js
never sets access_type=offline, so Google omits the refresh_token from its
token response; createOAuthHandler in api/server/controllers/auth/oauth.js
only forwards a refresh token into the admin exchange payload when the user's
provider is 'openid' AND OPENID_REUSE_TOKENS is enabled; and
/api/admin/oauth/refresh is openid-only, calling openid-client.refreshTokenGrant
against the configured OIDC issuer. OpenID admins refresh transparently
because all three are in place for them.

This PR closes all three. The googleAdmin authenticate call now passes
accessType: 'offline' and prompt: 'consent' so Google issues a refresh token
on consent; the chat-side googleLogin is untouched. The shared socialLogin
verify callback now passes the IdP refreshToken through as passport's third
argument (info), landing on req.authInfo, with the two-argument call shape
preserved when no refresh token is present so existing strategy tests stay
valid. createOAuthHandler reads req.authInfo?.refreshToken for non-OpenID
admin providers and forwards it into the exchange code; the OpenID branch
and its OPENID_REUSE_TOKENS gate are unchanged. /api/admin/oauth/refresh
now accepts an optional provider field ('openid' | 'google', default 'openid').
The new Google branch POSTs grant_type=refresh_token to
https://oauth2.googleapis.com/token, decodes the returned id_token for the sub
claim, looks up the admin user by googleId, enforces tenant scope and
ACCESS_ADMIN, and mints a fresh LibreChat JWT in the same response shape
/oauth/exchange returns. It is gated on GOOGLE_CLIENT_ID and
GOOGLE_CLIENT_SECRET being set (returns 503 GOOGLE_NOT_CONFIGURED otherwise);
unknown provider values return 400 INVALID_PROVIDER.
2026-06-18 07:54:30 -07:00
Danny Avila
0a7255b234
🎭 feat: Support OpenID Audience On Refresh Grants (#13077) 2026-05-11 17:40:30 -04:00