From e262219c8fe13d6f9c6852fed5e21c052553a045 Mon Sep 17 00:00:00 2001 From: Dustin Healy <54083382+dustinhealy@users.noreply.github.com> Date: Fri, 8 May 2026 14:23:02 -0700 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=84=20feat:=20Cross-Origin=20Admin=20O?= =?UTF-8?q?Auth=20Refresh=20(#13007)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(admin-panel): add /api/admin/oauth/refresh endpoint for cross-origin BFF refresh The cookie-based /api/auth/refresh controller can't be reached cross-origin from a separately-hosted admin panel because the refresh-token cookie isn't sent on cross-origin fetches. Add a dedicated POST /api/admin/oauth/refresh endpoint that accepts the refresh token in the request body, exchanges it at the IdP via openid-client refreshTokenGrant, and returns the same response shape as /api/admin/oauth/exchange. Implementation lives in packages/api/src/auth/refresh.ts as the applyAdminRefresh helper. It validates the refreshed tokenset, looks up the admin user by openidId (with optional user_id disambiguation when multiple user docs share an openidId), mints the bearer via an injected mintToken hook, and runs an optional onRefreshSuccess hook for downstream forks that need to update server-side session state. The default mintToken passed by the OSS route signs an HS256 LibreChat JWT via generateToken so admin panel callers continue to use the existing local JWT strategy. Forks that prefer to hand back an IdP-signed token (e.g. for deployments where the JWT auth gate is JWKS-only) override mintToken without changing the helper or the route. Also threads expiresAt through AdminExchangeData and AdminExchangeResponse so admin panel clients can drive proactive refresh before the bearer expires. Defaults the OSS exchange flow to Date.now() + sessionExpiry. * fix(admin-panel): address review feedback on /api/admin/oauth/refresh mintToken now returns {token, expiresAt} so the minter is authoritative for the bearer's lifetime instead of deriving it from the IdP `exp` claim. The refresh response would otherwise lie to the admin panel and trigger premature or late refresh cycles. The helper now falls back to the inbound refresh_token when the IdP omits one on rotation (Auth0 with rotation off, Microsoft personal accounts). Without this the admin panel loses its refresh capability after one cycle. Other hardening: resolveAdminUser validates user_id with Types.ObjectId.isValid before hitting Mongoose, avoiding a CastError that would surface as a generic 500 with no useful information for the client. If user_id resolves to a user whose openidId does not match the refreshed sub, throw USER_ID_MISMATCH (401) instead of silently swapping in a different user matching the sub. Wrap tokenset.claims() in readClaims so an IdP that returns a tokenset without a usable id_token gets mapped to CLAIMS_INCOMPLETE (502) rather than bubbling a raw exception. findUsers now uses the same SAFE_USER_PROJECTION as getUserById so the fallback path no longer pulls password/totpSecret/backupCodes into memory. Removed dead fields (email on AdminRefreshClaims, id_token on RefreshTokenset) and fixed import ordering per AGENTS.md. Adds packages/api/src/auth/refresh.spec.ts: 18 tests covering the happy path, userId disambiguation (match, invalid ObjectId, null, mismatch), all error branches (IDP_INCOMPLETE, CLAIMS_INCOMPLETE for both throw and missing sub, USER_NOT_FOUND, mintToken/onRefreshSuccess propagation), and refresh-token preservation under rotation/no-rotation. * chore(admin-panel): polish per re-review on /api/admin/oauth/refresh readClaims now logs the original error name/message at warn before mapping to CLAIMS_INCOMPLETE so a programming bug doesn't get silently rebadged as an IdP problem in production logs. The route handler's JSDoc now enumerates every error response (status + error_code) so admin-panel implementors can plan for each branch without reading the source. Tightens the helper's surface: removed the now-dead `exp` field from `AdminRefreshClaims` (only `sub` is read since the v2 mintToken refactor), and tightened `AdminRefreshDeps.findUsers`'s projection parameter from `string | null` to `string` so the contract matches actual usage. Test polish: the userId-resolves-to-null fallthrough test now asserts the exact `findUsers` and `getUserById` call arguments so a regression in the fallthrough query shape is caught. The "skips onRefreshSuccess" test now asserts a populated response shape rather than just `toBeDefined`. Declined per prior triage and re-confirmed: a role guard inside `applyAdminRefresh` (downstream `/api/admin/*` already enforces ACCESS_ADMIN via requireCapability) and moving the IdP grant call out of the JS route into TypeScript (matches existing oauth.js / openidStrategy pattern; package-boundary refactor belongs in a separate PR). * fix(admin-panel): reject /api/admin/oauth/refresh tokensets from foreign issuers When the route handler can resolve the configured OpenID issuer, it now threads it into applyAdminRefresh as expectedIssuer. The helper compares that against the tokenset claims iss (after normalizeOpenIdIssuer on both sides to absorb trailing-slash differences) and throws ISSUER_MISMATCH (401) on mismatch. The check is skipped when either side is unset so behavior is unchanged for IdPs that don't return iss on a refresh-grant id_token, and for older deployments where the OpenID config doesn't expose serverMetadata. This is a defense-in-depth measure for the refresh path only. The deeper OIDC posture fix (binding IUser lookup to (sub, iss) as a pair) is pre-existing debt across openidStrategy.js and the regular exchange flow as well, and belongs in a separate PR with the schema change and backfill migration. * fix(admin-panel): bind refresh user lookup to (sub, iss) and handle getOpenIdConfig throw Two fixes raised on the PR thread that I previously misdescribed: The user lookup in resolveAdminUser was keyed on openidId alone, so a tokenset from a different issuer that happened to share the same sub could resolve to a local user from a different IdP. Now exports getIssuerBoundConditions and isUserIssuerAllowed from openid.ts (the helpers findOpenIDUser already uses) and reuses them. The findUsers filter becomes ($or of getIssuerBoundConditions for openidId) when an expectedIssuer is provided, with the same legacy backward-compat clause for users whose openidIssuer field was never populated. The direct user_id path now also checks isUserIssuerAllowed and throws USER_ID_MISMATCH if the stored openidIssuer disagrees with the configured issuer. The route's getOpenIdConfig() call was previously documented as returning null when uninitialized; the actual implementation throws. That made the if (!openIdConfig) guard unreachable, and an unconfigured server would surface as 500 INTERNAL_ERROR rather than 503 OPENID_NOT_CONFIGURED. Wraps the call in try/catch so the documented 503 response is what callers actually receive. Adds 4 tests covering the new lookup binding behavior. * fix(admin-panel): re-check ACCESS_ADMIN on /api/admin/oauth/refresh The IdP refresh token can outlive a capability/role change, so the initial requireAdminAccess on the OAuth callback isn't sufficient. Inject canAccessAdmin via the existing capability model (hasCapability with SystemCapabilities.ACCESS_ADMIN, matching requireAdminAccess so custom roles and user grants are honored) and gate token minting on it. Capability backend errors are warn-and-denied to keep the bearer-mint path fail-closed. * fix(admin-panel): scope /api/admin/oauth/refresh to the request tenant The same (openidId, openidIssuer) pair is allowed across tenants by the user schema's unique index. The refresh helper was wrapping both the direct getUserById and the fallback findUsers in runAsSystem, bypassing tenant isolation, so an IdP identity that exists in two tenants could resolve to the wrong tenant's user and mint a JWT bound to that tenant. Drop the runAsSystem wrappers, add a trusted tenantId option to applyAdminRefresh, AND it into the fallback findUsers filter, and assert it against the direct getUserById result. Mount preAuthTenantMiddleware on the refresh route so the deployment's X-Tenant-Id header drives the trusted tenant via ALS. Single-tenant deploys (no header) keep the existing openidId-only behaviour. Adds TENANT_MISMATCH (401) and a regression covering duplicate (sub, iss) across tenants plus the direct-userId tenant assertion. * fix(admin-panel): gate /api/admin/oauth/refresh on OPENID_REUSE_TOKENS The OSS refreshController only refreshes OpenID tokensets when OPENID_REUSE_TOKENS is enabled. The body-based admin variant was unconditionally calling refreshTokenGrant, which made the flag ineffective for the admin OAuth flow and let admin sessions keep renewing in deployments that explicitly turned token reuse off. Add the same isEnabled(process.env.OPENID_REUSE_TOKENS) check up front and return 403 TOKEN_REUSE_DISABLED so the admin panel BFF can surface the configuration mismatch instead of silently churning through retries. --- api/server/controllers/auth/oauth.js | 2 + api/server/routes/admin/auth.js | 150 ++++++- packages/api/src/auth/exchange.ts | 11 +- packages/api/src/auth/index.ts | 1 + packages/api/src/auth/openid.ts | 4 +- packages/api/src/auth/refresh.spec.ts | 542 ++++++++++++++++++++++++++ packages/api/src/auth/refresh.ts | 254 ++++++++++++ 7 files changed, 958 insertions(+), 6 deletions(-) create mode 100644 packages/api/src/auth/refresh.spec.ts create mode 100644 packages/api/src/auth/refresh.ts diff --git a/api/server/controllers/auth/oauth.js b/api/server/controllers/auth/oauth.js index 31499f7de6..3502be8a4c 100644 --- a/api/server/controllers/auth/oauth.js +++ b/api/server/controllers/auth/oauth.js @@ -46,6 +46,7 @@ function createOAuthHandler(redirectUri = domains.client) { /** Get refresh token from tokenset for OpenID users */ const refreshToken = req.user.tokenset?.refresh_token || req.user.federatedTokens?.refresh_token; + const expiresAt = Date.now() + sessionExpiry; const callbackUrl = new URL(redirectUri); const exchangeCode = await generateAdminExchangeCode( @@ -55,6 +56,7 @@ function createOAuthHandler(redirectUri = domains.client) { refreshToken, callbackUrl.origin, req.pkceChallenge, + expiresAt, ); callbackUrl.searchParams.set('code', exchangeCode); logger.info(`[OAuth] Admin panel redirect with exchange code for user: ${req.user.email}`); diff --git a/api/server/routes/admin/auth.js b/api/server/routes/admin/auth.js index 9b0ecb66a5..a385177e1f 100644 --- a/api/server/routes/admin/auth.js +++ b/api/server/routes/admin/auth.js @@ -1,19 +1,35 @@ const express = require('express'); const passport = require('passport'); const crypto = require('node:crypto'); +const openIdClient = require('openid-client'); const { CacheKeys } = require('librechat-data-provider'); -const { logger, SystemCapabilities } = require('@librechat/data-schemas'); const { + logger, + DEFAULT_SESSION_EXPIRY, + SystemCapabilities, + getTenantId, +} = require('@librechat/data-schemas'); +const { + isEnabled, getAdminPanelUrl, exchangeAdminCode, createSetBalanceConfig, storeAndStripChallenge, tenantContextMiddleware, + preAuthTenantMiddleware, + applyAdminRefresh, + AdminRefreshError, } = require('@librechat/api'); const { loginController } = require('~/server/controllers/auth/LoginController'); -const { requireCapability } = require('~/server/middleware/roles/capabilities'); +const { hasCapability, requireCapability } = require('~/server/middleware/roles/capabilities'); const { createOAuthHandler } = require('~/server/controllers/auth/oauth'); -const { findBalanceByUser, upsertBalanceFields } = require('~/models'); +const { + findBalanceByUser, + findUsers, + generateToken, + getUserById, + upsertBalanceFields, +} = require('~/models'); const { getAppConfig } = require('~/server/services/Config'); const getLogStores = require('~/cache/getLogStores'); const { getOpenIdConfig } = require('~/strategies'); @@ -464,4 +480,132 @@ router.post('/oauth/exchange', middleware.loginLimiter, async (req, res) => { } }); +/** + * Admin-panel-shaped token refresh. + * + * The standard `/api/auth/refresh` controller reads the refresh token from + * cookies, which a cross-origin admin panel can't set. This endpoint accepts + * the refresh token in the request body, exchanges it at the IdP, mints a + * fresh LibreChat JWT, and returns the same response shape as + * `/api/admin/oauth/exchange`. + * + * POST /api/admin/oauth/refresh + * Body: { refresh_token: string, user_id?: string } + * Response: { token: string, refreshToken?: string, user: object, expiresAt: number } + * + * Errors (all responses are `{ error: string, error_code: string }`): + * 400 MISSING_REFRESH_TOKEN — refresh_token absent or empty + * 401 REFRESH_FAILED — IdP rejected the refresh grant + * 401 USER_NOT_FOUND — no LibreChat user matches the refreshed sub + * 401 USER_ID_MISMATCH — supplied user_id resolves to a user with a different openidId + * 401 ISSUER_MISMATCH — refreshed tokenset was issued by an unexpected issuer + * 401 TENANT_MISMATCH — resolved user belongs to a different tenant than the request + * 403 FORBIDDEN — resolved user no longer holds ACCESS_ADMIN + * 403 TOKEN_REUSE_DISABLED — OPENID_REUSE_TOKENS is not enabled on the server + * 502 IDP_INCOMPLETE — IdP returned a tokenset missing access_token + * 502 CLAIMS_INCOMPLETE — IdP tokenset has no readable claims or no sub + * 503 OPENID_NOT_CONFIGURED — OpenID is not configured on this server + * 500 INTERNAL_ERROR — anything else (logged server-side) + */ +router.post( + '/oauth/refresh', + middleware.loginLimiter, + preAuthTenantMiddleware, + async (req, res) => { + try { + const { refresh_token: refreshToken, user_id: userId } = req.body ?? {}; + if (typeof refreshToken !== 'string' || refreshToken.length === 0) { + return res.status(400).json({ + error: 'Missing refresh_token', + error_code: 'MISSING_REFRESH_TOKEN', + }); + } + + if (!isEnabled(process.env.OPENID_REUSE_TOKENS)) { + return res.status(403).json({ + error: 'OpenID token reuse is not enabled', + error_code: 'TOKEN_REUSE_DISABLED', + }); + } + + let openIdConfig; + try { + openIdConfig = getOpenIdConfig(); + } catch { + return res.status(503).json({ + error: 'OpenID is not configured', + error_code: 'OPENID_NOT_CONFIGURED', + }); + } + + const refreshParams = process.env.OPENID_SCOPE ? { scope: process.env.OPENID_SCOPE } : {}; + let tokenset; + try { + tokenset = await openIdClient.refreshTokenGrant(openIdConfig, refreshToken, refreshParams); + } catch (err) { + logger.warn('[admin/oauth/refresh] IdP refresh grant failed', { + code: err?.code, + name: err?.name, + }); + return res.status(401).json({ + error: 'Refresh failed', + error_code: 'REFRESH_FAILED', + }); + } + + const sessionExpiry = Number(process.env.SESSION_EXPIRY) || DEFAULT_SESSION_EXPIRY; + const expectedIssuer = openIdConfig.serverMetadata?.()?.issuer; + + try { + const result = await applyAdminRefresh( + tokenset, + { + findUsers, + getUserById, + canAccessAdmin: async (user) => { + try { + return await hasCapability( + { + id: user.id ?? user._id?.toString(), + role: user.role ?? '', + tenantId: user.tenantId, + }, + SystemCapabilities.ACCESS_ADMIN, + ); + } catch (err) { + logger.warn( + `[admin/oauth/refresh] capability check failed, denying: ${err?.message}`, + ); + return false; + } + }, + mintToken: async (user) => ({ + token: await generateToken(user, sessionExpiry), + expiresAt: Date.now() + sessionExpiry, + }), + }, + { + userId: typeof userId === 'string' && userId.length > 0 ? userId : undefined, + previousRefreshToken: refreshToken, + expectedIssuer, + tenantId: getTenantId(), + }, + ); + return res.json(result); + } catch (err) { + if (err instanceof AdminRefreshError) { + return res.status(err.status).json({ error: err.message, error_code: err.code }); + } + throw err; + } + } catch (error) { + logger.error('[admin/oauth/refresh] Error:', error); + res.status(500).json({ + error: 'Internal server error', + error_code: 'INTERNAL_ERROR', + }); + } + }, +); + module.exports = router; diff --git a/packages/api/src/auth/exchange.ts b/packages/api/src/auth/exchange.ts index 931c251d55..650b006e38 100644 --- a/packages/api/src/auth/exchange.ts +++ b/packages/api/src/auth/exchange.ts @@ -30,7 +30,10 @@ export interface AdminExchangeUser { } /** - * Data stored in cache for admin OAuth exchange + * Data stored in cache for admin OAuth exchange. + * + * `expiresAt` is the absolute expiry of `token` (ms epoch). Used by admin + * panel clients to drive proactive refresh before the bearer expires. */ export interface AdminExchangeData { userId: string; @@ -39,6 +42,7 @@ export interface AdminExchangeData { refreshToken?: string; origin?: string; codeChallenge?: string; + expiresAt?: number; } /** @@ -48,6 +52,7 @@ export interface AdminExchangeResponse { token: string; refreshToken?: string; user: AdminExchangeUser; + expiresAt?: number; } /** @@ -94,6 +99,7 @@ export function verifyCodeChallenge(verifier: string, challenge: string): boolea * @param refreshToken - Optional refresh token for OpenID users * @param origin - The admin panel origin (scheme://host:port) for origin binding * @param codeChallenge - PKCE code_challenge (hex-encoded SHA-256 of code_verifier) + * @param expiresAt - Optional absolute expiry of `token` (ms epoch); admin panel uses for proactive refresh * @returns The generated exchange code */ export async function generateAdminExchangeCode( @@ -103,6 +109,7 @@ export async function generateAdminExchangeCode( refreshToken?: string, origin?: string, codeChallenge?: string, + expiresAt?: number, ): Promise { const exchangeCode = crypto.randomBytes(32).toString('hex'); @@ -113,6 +120,7 @@ export async function generateAdminExchangeCode( refreshToken, origin, codeChallenge, + expiresAt, }; await cache.set(exchangeCode, data); @@ -165,6 +173,7 @@ export async function exchangeAdminCode( token: data.token, refreshToken: data.refreshToken, user: data.user, + expiresAt: data.expiresAt, }; } diff --git a/packages/api/src/auth/index.ts b/packages/api/src/auth/index.ts index 5dd0bb01e0..d3c8a02896 100644 --- a/packages/api/src/auth/index.ts +++ b/packages/api/src/auth/index.ts @@ -1,6 +1,7 @@ export * from './domain'; export * from './openid'; export * from './exchange'; +export * from './refresh'; export * from './agent'; export * from './password'; export * from './invite'; diff --git a/packages/api/src/auth/openid.ts b/packages/api/src/auth/openid.ts index e647e09f17..89432e6dd7 100644 --- a/packages/api/src/auth/openid.ts +++ b/packages/api/src/auth/openid.ts @@ -56,7 +56,7 @@ function isLegacyOpenIdIssuer(openidIssuer: string | undefined): boolean { return openidIssuer != null && loginIssuer != null && openidIssuer === loginIssuer; } -function getIssuerBoundConditions( +export function getIssuerBoundConditions( field: OpenIdLookupField, value: string | undefined, openidIssuer: string | undefined, @@ -76,7 +76,7 @@ function getIssuerBoundConditions( return conditions; } -function isUserIssuerAllowed(user: IUser, openidIssuer: string | undefined): boolean { +export function isUserIssuerAllowed(user: IUser, openidIssuer: string | undefined): boolean { if (!openidIssuer) return true; const userIssuer = normalizeOpenIdIssuer(user.openidIssuer); diff --git a/packages/api/src/auth/refresh.spec.ts b/packages/api/src/auth/refresh.spec.ts new file mode 100644 index 0000000000..97cea77ee4 --- /dev/null +++ b/packages/api/src/auth/refresh.spec.ts @@ -0,0 +1,542 @@ +import { Types } from 'mongoose'; +import { logger } from '@librechat/data-schemas'; + +import type { IUser } from '@librechat/data-schemas'; +import type { AdminRefreshDeps, RefreshTokenset } from './refresh'; + +import { applyAdminRefresh, AdminRefreshError } from './refresh'; + +jest.mock('@librechat/data-schemas', () => ({ + ...jest.requireActual('@librechat/data-schemas'), + logger: { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + }, +})); + +const SUB = 'idp-sub-12345'; + +function makeUser(overrides: Partial = {}): IUser { + const _id = overrides._id ?? new Types.ObjectId(); + return { + _id, + email: 'admin@example.com', + name: 'Admin User', + username: 'admin', + role: 'ADMIN', + provider: 'openid', + openidId: SUB, + ...overrides, + } as unknown as IUser; +} + +function makeTokenset(overrides: Partial = {}): RefreshTokenset { + return { + access_token: 'new-access', + refresh_token: 'new-refresh', + claims: () => ({ sub: SUB }), + ...overrides, + }; +} + +function makeDeps(user: IUser | undefined, overrides: Partial = {}) { + const findUsers = jest.fn().mockResolvedValue(user ? [user] : []); + const getUserById = jest.fn().mockResolvedValue(user ?? null); + const mintToken = jest.fn().mockResolvedValue({ token: 'minted-jwt', expiresAt: 1234567890 }); + const onRefreshSuccess = jest.fn().mockResolvedValue(undefined); + return { + findUsers, + getUserById, + mintToken, + onRefreshSuccess, + ...overrides, + }; +} + +describe('applyAdminRefresh', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('happy path', () => { + it('returns a fully-populated AdminExchangeResponse', async () => { + const user = makeUser(); + const deps = makeDeps(user); + const tokenset = makeTokenset(); + + const result = await applyAdminRefresh(tokenset, deps); + + expect(result).toEqual({ + token: 'minted-jwt', + refreshToken: 'new-refresh', + user: expect.objectContaining({ + email: 'admin@example.com', + openidId: SUB, + }), + expiresAt: 1234567890, + }); + expect(deps.mintToken).toHaveBeenCalledWith(user, tokenset); + expect(deps.onRefreshSuccess).toHaveBeenCalledWith(user, tokenset); + }); + + it('uses findUsers when no userId is supplied', async () => { + const user = makeUser(); + const deps = makeDeps(user); + + await applyAdminRefresh(makeTokenset(), deps); + + expect(deps.findUsers).toHaveBeenCalledWith( + { openidId: SUB }, + '-password -__v -totpSecret -backupCodes', + { sort: { updatedAt: -1 }, limit: 1 }, + ); + expect(deps.getUserById).not.toHaveBeenCalled(); + }); + + it('skips onRefreshSuccess when not provided', async () => { + const user = makeUser(); + const { onRefreshSuccess: _omit, ...rest } = makeDeps(user); + const deps: AdminRefreshDeps = rest; + + const result = await applyAdminRefresh(makeTokenset(), deps); + expect(result.token).toBe('minted-jwt'); + expect(result.user.email).toBe('admin@example.com'); + }); + }); + + describe('userId disambiguation', () => { + it('returns the direct user when userId resolves and openidId matches', async () => { + const id = new Types.ObjectId(); + const user = makeUser({ _id: id }); + const deps = makeDeps(user); + + await applyAdminRefresh(makeTokenset(), deps, { userId: id.toString() }); + + expect(deps.getUserById).toHaveBeenCalledWith( + id.toString(), + '-password -__v -totpSecret -backupCodes', + ); + expect(deps.findUsers).not.toHaveBeenCalled(); + }); + + it('falls through to findUsers when userId is not a valid ObjectId', async () => { + const user = makeUser(); + const deps = makeDeps(user); + + await applyAdminRefresh(makeTokenset(), deps, { userId: 'not-a-valid-id' }); + + expect(deps.getUserById).not.toHaveBeenCalled(); + expect(deps.findUsers).toHaveBeenCalled(); + }); + + it('falls through to findUsers when userId resolves to null', async () => { + const user = makeUser(); + const id = new Types.ObjectId().toString(); + const deps = makeDeps(user, { getUserById: jest.fn().mockResolvedValue(null) }); + + await applyAdminRefresh(makeTokenset(), deps, { userId: id }); + + expect(deps.getUserById).toHaveBeenCalledWith(id, '-password -__v -totpSecret -backupCodes'); + expect(deps.findUsers).toHaveBeenCalledWith( + { openidId: SUB }, + '-password -__v -totpSecret -backupCodes', + { sort: { updatedAt: -1 }, limit: 1 }, + ); + }); + + it('throws USER_ID_MISMATCH when the resolved user has a different openidId', async () => { + const id = new Types.ObjectId(); + const user = makeUser({ _id: id, openidId: 'different-sub' }); + const deps = makeDeps(user, { + getUserById: jest.fn().mockResolvedValue(user), + findUsers: jest.fn(), + }); + + await expect( + applyAdminRefresh(makeTokenset(), deps, { userId: id.toString() }), + ).rejects.toMatchObject({ + name: 'AdminRefreshError', + code: 'USER_ID_MISMATCH', + status: 401, + }); + expect(deps.findUsers).not.toHaveBeenCalled(); + }); + }); + + describe('error branches', () => { + it('throws IDP_INCOMPLETE when access_token is missing', async () => { + const deps = makeDeps(makeUser()); + const tokenset = makeTokenset({ access_token: undefined }); + + await expect(applyAdminRefresh(tokenset, deps)).rejects.toMatchObject({ + code: 'IDP_INCOMPLETE', + status: 502, + }); + expect(deps.mintToken).not.toHaveBeenCalled(); + }); + + it('throws CLAIMS_INCOMPLETE when claims() throws', async () => { + const deps = makeDeps(makeUser()); + const tokenset = makeTokenset({ + claims: () => { + throw new Error('id_token missing'); + }, + }); + + await expect(applyAdminRefresh(tokenset, deps)).rejects.toMatchObject({ + code: 'CLAIMS_INCOMPLETE', + status: 502, + }); + }); + + it('throws CLAIMS_INCOMPLETE when sub is missing', async () => { + const deps = makeDeps(makeUser()); + const tokenset = makeTokenset({ claims: () => ({}) }); + + await expect(applyAdminRefresh(tokenset, deps)).rejects.toMatchObject({ + code: 'CLAIMS_INCOMPLETE', + status: 502, + }); + }); + + it('throws USER_NOT_FOUND when no user matches the refreshed identity', async () => { + const deps = makeDeps(undefined); + + await expect(applyAdminRefresh(makeTokenset(), deps)).rejects.toMatchObject({ + code: 'USER_NOT_FOUND', + status: 401, + }); + expect(deps.mintToken).not.toHaveBeenCalled(); + }); + + it('propagates errors thrown from mintToken', async () => { + const deps = makeDeps(makeUser(), { + mintToken: jest.fn().mockRejectedValue(new Error('signing failure')), + }); + + await expect(applyAdminRefresh(makeTokenset(), deps)).rejects.toThrow('signing failure'); + }); + + it('propagates errors thrown from onRefreshSuccess', async () => { + const deps = makeDeps(makeUser(), { + onRefreshSuccess: jest.fn().mockRejectedValue(new Error('cache write failed')), + }); + + await expect(applyAdminRefresh(makeTokenset(), deps)).rejects.toThrow('cache write failed'); + }); + }); + + describe('issuer-bound user lookup', () => { + it('binds findUsers to (openidId, openidIssuer) when expectedIssuer is provided', async () => { + const user = makeUser(); + const deps = makeDeps(user); + const tokenset = makeTokenset({ + claims: () => ({ sub: SUB, iss: 'https://issuer.example.com' }), + }); + + await applyAdminRefresh(tokenset, deps, { + expectedIssuer: 'https://issuer.example.com', + }); + + const [filter] = (deps.findUsers as jest.Mock).mock.calls[0]; + expect(filter).toMatchObject({ + $or: expect.arrayContaining([ + { openidId: SUB, openidIssuer: 'https://issuer.example.com' }, + ]), + }); + }); + + it('falls back to openidId-only lookup when expectedIssuer is not provided', async () => { + const user = makeUser(); + const deps = makeDeps(user); + + await applyAdminRefresh(makeTokenset(), deps); + + expect(deps.findUsers).toHaveBeenCalledWith( + { openidId: SUB }, + '-password -__v -totpSecret -backupCodes', + { sort: { updatedAt: -1 }, limit: 1 }, + ); + }); + + it('throws USER_ID_MISMATCH when direct user_id resolves but issuer differs', async () => { + const id = new Types.ObjectId(); + const user = makeUser({ _id: id, openidIssuer: 'https://other-issuer.example.com' }); + const deps = makeDeps(user, { + getUserById: jest.fn().mockResolvedValue(user), + findUsers: jest.fn(), + }); + const tokenset = makeTokenset({ + claims: () => ({ sub: SUB, iss: 'https://issuer.example.com' }), + }); + + await expect( + applyAdminRefresh(tokenset, deps, { + userId: id.toString(), + expectedIssuer: 'https://issuer.example.com', + }), + ).rejects.toMatchObject({ code: 'USER_ID_MISMATCH', status: 401 }); + expect(deps.findUsers).not.toHaveBeenCalled(); + }); + + it('accepts direct user_id when stored openidIssuer matches the expected issuer', async () => { + const id = new Types.ObjectId(); + const user = makeUser({ _id: id, openidIssuer: 'https://issuer.example.com' }); + const deps = makeDeps(user, { getUserById: jest.fn().mockResolvedValue(user) }); + const tokenset = makeTokenset({ + claims: () => ({ sub: SUB, iss: 'https://issuer.example.com' }), + }); + + await expect( + applyAdminRefresh(tokenset, deps, { + userId: id.toString(), + expectedIssuer: 'https://issuer.example.com', + }), + ).resolves.toBeDefined(); + }); + }); + + describe('issuer guard', () => { + it('passes when expected and actual issuers match', async () => { + const deps = makeDeps(makeUser()); + const tokenset = makeTokenset({ + claims: () => ({ sub: SUB, iss: 'https://issuer.example.com' }), + }); + + await expect( + applyAdminRefresh(tokenset, deps, { expectedIssuer: 'https://issuer.example.com' }), + ).resolves.toBeDefined(); + }); + + it('passes when expected and actual issuers match modulo trailing slash', async () => { + const deps = makeDeps(makeUser()); + const tokenset = makeTokenset({ + claims: () => ({ sub: SUB, iss: 'https://issuer.example.com/' }), + }); + + await expect( + applyAdminRefresh(tokenset, deps, { expectedIssuer: 'https://issuer.example.com' }), + ).resolves.toBeDefined(); + }); + + it('throws ISSUER_MISMATCH when issuers differ', async () => { + const deps = makeDeps(makeUser()); + const tokenset = makeTokenset({ + claims: () => ({ sub: SUB, iss: 'https://attacker.example.com' }), + }); + + await expect( + applyAdminRefresh(tokenset, deps, { expectedIssuer: 'https://issuer.example.com' }), + ).rejects.toMatchObject({ code: 'ISSUER_MISMATCH', status: 401 }); + expect(deps.findUsers).not.toHaveBeenCalled(); + expect(deps.mintToken).not.toHaveBeenCalled(); + }); + + it('skips the check when expectedIssuer is not provided', async () => { + const deps = makeDeps(makeUser()); + const tokenset = makeTokenset({ + claims: () => ({ sub: SUB, iss: 'https://attacker.example.com' }), + }); + + await expect(applyAdminRefresh(tokenset, deps)).resolves.toBeDefined(); + }); + + it('skips the check when the tokenset has no iss claim', async () => { + const deps = makeDeps(makeUser()); + + await expect( + applyAdminRefresh(makeTokenset(), deps, { + expectedIssuer: 'https://issuer.example.com', + }), + ).resolves.toBeDefined(); + }); + }); + + describe('refresh-token preservation', () => { + it('preserves the inbound refresh token when the IdP does not rotate', async () => { + const deps = makeDeps(makeUser()); + const tokenset = makeTokenset({ refresh_token: undefined }); + + const result = await applyAdminRefresh(tokenset, deps, { + previousRefreshToken: 'inbound-refresh', + }); + + expect(result.refreshToken).toBe('inbound-refresh'); + }); + + it('prefers the rotated refresh token over the previousRefreshToken fallback', async () => { + const deps = makeDeps(makeUser()); + const tokenset = makeTokenset({ refresh_token: 'rotated-refresh' }); + + const result = await applyAdminRefresh(tokenset, deps, { + previousRefreshToken: 'inbound-refresh', + }); + + expect(result.refreshToken).toBe('rotated-refresh'); + }); + + it('returns undefined refreshToken when neither rotation nor fallback is available', async () => { + const deps = makeDeps(makeUser()); + const tokenset = makeTokenset({ refresh_token: undefined }); + + const result = await applyAdminRefresh(tokenset, deps); + + expect(result.refreshToken).toBeUndefined(); + }); + }); + + describe('tenant scoping', () => { + it('constrains the fallback findUsers filter to options.tenantId', async () => { + const user = makeUser({ tenantId: 'tenant-a' } as Partial); + const deps = makeDeps(user); + + await applyAdminRefresh(makeTokenset(), deps, { tenantId: 'tenant-a' }); + + const [filter] = (deps.findUsers as jest.Mock).mock.calls[0]; + expect(filter).toMatchObject({ openidId: SUB, tenantId: 'tenant-a' }); + }); + + it('returns the tenant-A user when two users share (sub, iss) across tenants', async () => { + const userA = makeUser({ tenantId: 'tenant-a', email: 'a@example.com' } as Partial); + const userB = makeUser({ tenantId: 'tenant-b', email: 'b@example.com' } as Partial); + + const findUsers = jest.fn().mockImplementation(async (filter: { tenantId?: string }) => { + if (filter.tenantId === 'tenant-a') return [userA]; + if (filter.tenantId === 'tenant-b') return [userB]; + return [userA, userB]; + }); + const deps = makeDeps(userA, { findUsers }); + + const result = await applyAdminRefresh(makeTokenset(), deps, { tenantId: 'tenant-a' }); + + expect(result.user.email).toBe('a@example.com'); + expect(findUsers).toHaveBeenCalledWith( + { openidId: SUB, tenantId: 'tenant-a' }, + '-password -__v -totpSecret -backupCodes', + { sort: { updatedAt: -1 }, limit: 1 }, + ); + }); + + it('throws TENANT_MISMATCH when direct user_id resolves to a different tenant', async () => { + const id = new Types.ObjectId(); + const user = makeUser({ _id: id, tenantId: 'tenant-b' } as Partial); + const deps = makeDeps(user, { + getUserById: jest.fn().mockResolvedValue(user), + findUsers: jest.fn(), + }); + + await expect( + applyAdminRefresh(makeTokenset(), deps, { + userId: id.toString(), + tenantId: 'tenant-a', + }), + ).rejects.toMatchObject({ + name: 'AdminRefreshError', + code: 'TENANT_MISMATCH', + status: 401, + }); + expect(deps.findUsers).not.toHaveBeenCalled(); + expect(deps.mintToken).not.toHaveBeenCalled(); + }); + + it('accepts direct user_id when stored tenantId matches options.tenantId', async () => { + const id = new Types.ObjectId(); + const user = makeUser({ _id: id, tenantId: 'tenant-a' } as Partial); + const deps = makeDeps(user, { + getUserById: jest.fn().mockResolvedValue(user), + findUsers: jest.fn(), + }); + + const result = await applyAdminRefresh(makeTokenset(), deps, { + userId: id.toString(), + tenantId: 'tenant-a', + }); + + expect(result.token).toBe('minted-jwt'); + expect(deps.findUsers).not.toHaveBeenCalled(); + }); + + it('falls back to openidId-only filter when options.tenantId is omitted', async () => { + const user = makeUser(); + const deps = makeDeps(user); + + await applyAdminRefresh(makeTokenset(), deps); + + const [filter] = (deps.findUsers as jest.Mock).mock.calls[0]; + expect(filter).toEqual({ openidId: SUB }); + expect(filter).not.toHaveProperty('tenantId'); + }); + }); + + describe('admin-access guard', () => { + it('throws FORBIDDEN and does not mint when canAccessAdmin returns false', async () => { + const user = makeUser(); + const canAccessAdmin = jest.fn().mockResolvedValue(false); + const deps = makeDeps(user, { canAccessAdmin }); + + await expect(applyAdminRefresh(makeTokenset(), deps)).rejects.toMatchObject({ + name: 'AdminRefreshError', + code: 'FORBIDDEN', + status: 403, + }); + + expect(canAccessAdmin).toHaveBeenCalledWith(user); + expect(deps.mintToken).not.toHaveBeenCalled(); + expect(deps.onRefreshSuccess).not.toHaveBeenCalled(); + }); + + it('mints when canAccessAdmin returns true', async () => { + const user = makeUser(); + const canAccessAdmin = jest.fn().mockResolvedValue(true); + const deps = makeDeps(user, { canAccessAdmin }); + + const result = await applyAdminRefresh(makeTokenset(), deps); + + expect(canAccessAdmin).toHaveBeenCalledWith(user); + expect(deps.mintToken).toHaveBeenCalledWith(user, expect.any(Object)); + expect(result.token).toBe('minted-jwt'); + }); + + it('skips the check when canAccessAdmin is not provided', async () => { + const user = makeUser(); + const { canAccessAdmin: _omit, ...rest } = makeDeps(user); + const deps: AdminRefreshDeps = rest; + + const result = await applyAdminRefresh(makeTokenset(), deps); + expect(result.token).toBe('minted-jwt'); + }); + + it('propagates errors thrown by canAccessAdmin without minting', async () => { + const user = makeUser(); + const boom = new Error('capability backend exploded'); + const canAccessAdmin = jest.fn().mockRejectedValue(boom); + const deps = makeDeps(user, { canAccessAdmin }); + + await expect(applyAdminRefresh(makeTokenset(), deps)).rejects.toBe(boom); + expect(deps.mintToken).not.toHaveBeenCalled(); + expect(deps.onRefreshSuccess).not.toHaveBeenCalled(); + }); + }); + + describe('logging', () => { + it('logs the refreshed user email at debug', async () => { + const deps = makeDeps(makeUser()); + + await applyAdminRefresh(makeTokenset(), deps); + + expect(logger.debug).toHaveBeenCalledWith(expect.stringContaining('admin@example.com')); + }); + }); +}); + +describe('AdminRefreshError', () => { + it('captures code, status, and message', () => { + const err = new AdminRefreshError('SOME_CODE', 418, 'teapot'); + expect(err.name).toBe('AdminRefreshError'); + expect(err.code).toBe('SOME_CODE'); + expect(err.status).toBe(418); + expect(err.message).toBe('teapot'); + expect(err).toBeInstanceOf(Error); + }); +}); diff --git a/packages/api/src/auth/refresh.ts b/packages/api/src/auth/refresh.ts new file mode 100644 index 0000000000..9185998100 --- /dev/null +++ b/packages/api/src/auth/refresh.ts @@ -0,0 +1,254 @@ +import { Types } from 'mongoose'; +import { logger } from '@librechat/data-schemas'; + +import type { IUser } from '@librechat/data-schemas'; +import type { FilterQuery } from 'mongoose'; +import type { AdminExchangeResponse } from '~/auth/exchange'; + +import { + isUserIssuerAllowed, + normalizeOpenIdIssuer, + getIssuerBoundConditions, +} from '~/auth/openid'; +import { serializeUserForExchange } from '~/auth/exchange'; + +const SAFE_USER_PROJECTION = '-password -__v -totpSecret -backupCodes'; + +interface AdminRefreshClaims { + sub?: string; + iss?: string; +} + +/** + * The minimal shape this module needs from an `openid-client` tokenset. + * Avoids a hard import dependency on `openid-client` types in this package. + */ +export interface RefreshTokenset { + access_token?: string; + refresh_token?: string; + claims: () => AdminRefreshClaims; +} + +export interface MintedToken { + /** Bearer the admin panel will send on subsequent requests. */ + token: string; + /** Absolute expiry of `token` (ms epoch). Drives the admin panel's proactive refresh. */ + expiresAt: number; +} + +export interface AdminRefreshDeps { + findUsers: ( + filter: FilterQuery, + projection: string, + options: { sort: Record; limit: number }, + ) => Promise; + getUserById: (id: string, projection: string) => Promise; + /** + * Mints the bearer the admin panel will send on subsequent requests, and + * reports its absolute expiry. The minter is authoritative for the bearer's + * lifetime — the helper does not attempt to derive expiry from the IdP's + * `exp` claim. Default OSS callers pass `generateToken(user, sessionExpiry)` + * and `Date.now() + sessionExpiry`. + */ + mintToken: (user: IUser, tokenset: RefreshTokenset) => Promise; + /** + * Authorizes the resolved user against the same admin-access invariant + * the initial OAuth callback enforces. The IdP refresh token can outlive + * a capability/role change, so the bearer must not be reissued for a + * user who no longer holds `ACCESS_ADMIN`. Optional only for backward + * compatibility with existing callers; route handlers that mint admin + * bearers should always inject this. + */ + canAccessAdmin?: (user: IUser) => Promise; + /** + * Optional post-success hook for forks that need to do additional work + * with the refreshed tokenset and resolved user (e.g. update a server-side + * token cache or reconcile downstream session state). Errors thrown here + * propagate to the route handler. + */ + onRefreshSuccess?: (user: IUser, tokenset: RefreshTokenset) => Promise; +} + +export interface AdminRefreshOptions { + /** + * Optional user `_id` from the previous admin-panel session. When provided, + * the resolved user must have a matching `openidId` — otherwise the refresh + * is rejected as a possible identity-swap attempt. + */ + userId?: string; + /** + * The refresh token the admin panel sent in. Preserved as the response + * `refreshToken` when the IdP doesn't rotate (Auth0 with rotation off, + * Microsoft personal accounts in some flows). Without this fallback, the + * admin panel would receive `undefined` and lose its refresh capability. + */ + previousRefreshToken?: string; + /** + * Issuer URL of the OpenID provider this server trusts. When provided + * alongside an `iss` claim on the refreshed tokenset, the helper rejects + * the refresh if the two don't match. Defends against tokensets emitted + * by an unexpected issuer, even though `IUser` lookup is still keyed by + * `openidId` alone. + */ + expectedIssuer?: string; + /** + * Trusted tenant id resolved by the route layer (e.g. `getTenantId()` from + * the pre-auth tenant ALS scope). When provided, the helper: + * - constrains the fallback `findUsers` lookup with `tenantId`, and + * - asserts a direct `getUserById` result has a matching `tenantId`, + * so the same `(sub, iss)` existing in another tenant can never resolve + * here. Multi-tenant deployments MUST pass this. Single-tenant deployments + * may omit it. + */ + tenantId?: string; +} + +export class AdminRefreshError extends Error { + constructor( + public readonly code: string, + public readonly status: number, + message: string, + ) { + super(message); + this.name = 'AdminRefreshError'; + } +} + +async function resolveAdminUser( + deps: AdminRefreshDeps, + openidId: string, + normalizedIssuer: string | undefined, + options: AdminRefreshOptions, +): Promise { + const expectedTenantId = options.tenantId; + + if (options.userId && Types.ObjectId.isValid(options.userId)) { + const direct = await deps.getUserById(options.userId as string, SAFE_USER_PROJECTION); + if (direct) { + if (direct.openidId !== openidId) { + throw new AdminRefreshError( + 'USER_ID_MISMATCH', + 401, + 'Provided user_id does not match the refreshed identity', + ); + } + if (!isUserIssuerAllowed(direct, normalizedIssuer)) { + throw new AdminRefreshError( + 'USER_ID_MISMATCH', + 401, + 'Provided user_id matches sub but issuer differs from the refreshed identity', + ); + } + if (expectedTenantId && direct.tenantId !== expectedTenantId) { + throw new AdminRefreshError( + 'TENANT_MISMATCH', + 401, + 'Provided user_id resolves outside the request tenant', + ); + } + return direct; + } + logger.debug( + `[adminRefresh] user_id ${options.userId} not found; falling through to openidId lookup`, + ); + } + + const issuerBound = getIssuerBoundConditions('openidId', openidId, normalizedIssuer); + const baseFilter: FilterQuery = + issuerBound.length > 0 ? { $or: issuerBound } : ({ openidId } as FilterQuery); + const filter: FilterQuery = expectedTenantId + ? ({ ...baseFilter, tenantId: expectedTenantId } as FilterQuery) + : baseFilter; + + const [user] = await deps.findUsers(filter, SAFE_USER_PROJECTION, { + sort: { updatedAt: -1 }, + limit: 1, + }); + return user; +} + +function readClaims(tokenset: RefreshTokenset): AdminRefreshClaims { + try { + return tokenset.claims(); + } catch (err) { + const error = err as { name?: string; message?: string }; + logger.warn('[adminRefresh] tokenset.claims() threw', { + name: error?.name, + message: error?.message, + }); + throw new AdminRefreshError( + 'CLAIMS_INCOMPLETE', + 502, + 'IdP returned a tokenset whose claims could not be read (no id_token?)', + ); + } +} + +/** + * Looks up the active admin user for a freshly-refreshed OpenID tokenset, + * mints the bearer the admin panel should send on subsequent requests, and + * returns the response shape used by `/api/admin/oauth/exchange`. + * + * The route handler is responsible for the IdP `refreshTokenGrant` call; + * this helper takes the resulting tokenset and turns it into an admin-panel + * exchange response. + */ +export async function applyAdminRefresh( + tokenset: RefreshTokenset, + deps: AdminRefreshDeps, + options: AdminRefreshOptions = {}, +): Promise { + if (!tokenset.access_token) { + throw new AdminRefreshError( + 'IDP_INCOMPLETE', + 502, + 'IdP returned an incomplete tokenset (missing access_token)', + ); + } + + const claims = readClaims(tokenset); + const openidId = claims.sub; + if (!openidId) { + throw new AdminRefreshError( + 'CLAIMS_INCOMPLETE', + 502, + 'IdP returned a tokenset missing the required `sub` claim', + ); + } + + const expected = normalizeOpenIdIssuer(options.expectedIssuer); + const actual = normalizeOpenIdIssuer(claims.iss); + if (expected && actual && expected !== actual) { + throw new AdminRefreshError( + 'ISSUER_MISMATCH', + 401, + 'Refreshed tokenset was issued by an unexpected issuer', + ); + } + + const user = await resolveAdminUser(deps, openidId, expected, options); + if (!user) { + throw new AdminRefreshError('USER_NOT_FOUND', 401, 'No user found for the refreshed identity'); + } + + if (deps.canAccessAdmin && !(await deps.canAccessAdmin(user))) { + throw new AdminRefreshError('FORBIDDEN', 403, 'User does not have admin access'); + } + + const minted = await deps.mintToken(user, tokenset); + + if (deps.onRefreshSuccess) { + await deps.onRefreshSuccess(user, tokenset); + } + + const responseUser = serializeUserForExchange(user); + + logger.debug(`[adminRefresh] Refreshed tokens for user: ${responseUser.email}`); + + return { + token: minted.token, + refreshToken: tokenset.refresh_token ?? options.previousRefreshToken, + user: responseUser, + expiresAt: minted.expiresAt, + }; +}