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, + }; +}