diff --git a/api/server/controllers/AuthController.js b/api/server/controllers/AuthController.js index eb44feffa4c0..d61bcc28449d 100644 --- a/api/server/controllers/AuthController.js +++ b/api/server/controllers/AuthController.js @@ -2,7 +2,7 @@ const cookies = require('cookie'); const jwt = require('jsonwebtoken'); const openIdClient = require('openid-client'); const { logger } = require('@librechat/data-schemas'); -const { isEnabled, findOpenIDUser } = require('@librechat/api'); +const { isEnabled, findOpenIDUser, getOpenIdIssuer } = require('@librechat/api'); const { requestPasswordReset, setOpenIDAuthTokens, @@ -85,10 +85,12 @@ const refreshController = async (req, res) => { refreshParams, ); const claims = tokenset.claims(); + const openidIssuer = getOpenIdIssuer(claims, openIdConfig); const { user, error, migration } = await findOpenIDUser({ findUser, email: getOpenIdEmail(claims), openidId: claims.sub, + openidIssuer, idOnTheSource: claims.oid, strategyName: 'refreshController', }); @@ -111,6 +113,7 @@ const refreshController = async (req, res) => { await updateUser(user._id.toString(), { provider: 'openid', openidId: claims.sub, + ...(openidIssuer ? { openidIssuer } : {}), }); logger.info( `[refreshController] Updated user ${user.email} openidId (${reason}): ${user.openidId ?? 'null'} -> ${claims.sub}`, diff --git a/api/server/controllers/AuthController.spec.js b/api/server/controllers/AuthController.spec.js index 964947def9d1..8b19c28f36f8 100644 --- a/api/server/controllers/AuthController.spec.js +++ b/api/server/controllers/AuthController.spec.js @@ -23,6 +23,7 @@ jest.mock('~/models', () => ({ jest.mock('@librechat/api', () => ({ isEnabled: jest.fn(), findOpenIDUser: jest.fn(), + getOpenIdIssuer: jest.fn(() => 'https://issuer.example.com'), })); const openIdClient = require('openid-client'); @@ -157,6 +158,7 @@ describe('refreshController – OpenID path', () => { }; const baseClaims = { + iss: 'https://issuer.example.com', sub: 'oidc-sub-123', oid: 'oid-456', email: 'user@example.com', @@ -204,7 +206,10 @@ describe('refreshController – OpenID path', () => { expect(getOpenIdEmail).toHaveBeenCalledWith(baseClaims); expect(findOpenIDUser).toHaveBeenCalledWith( - expect.objectContaining({ email: baseClaims.email }), + expect.objectContaining({ + email: baseClaims.email, + openidIssuer: baseClaims.iss, + }), ); expect(res.status).toHaveBeenCalledWith(200); }); @@ -225,7 +230,10 @@ describe('refreshController – OpenID path', () => { expect(getOpenIdEmail).toHaveBeenCalledWith(claimsWithUpn); expect(findOpenIDUser).toHaveBeenCalledWith( - expect.objectContaining({ email: 'user@corp.example.com' }), + expect.objectContaining({ + email: 'user@corp.example.com', + openidIssuer: baseClaims.iss, + }), ); expect(res.status).toHaveBeenCalledWith(200); }); @@ -236,7 +244,10 @@ describe('refreshController – OpenID path', () => { await refreshController(req, res); expect(findOpenIDUser).toHaveBeenCalledWith( - expect.objectContaining({ email: baseClaims.email }), + expect.objectContaining({ + email: baseClaims.email, + openidIssuer: baseClaims.iss, + }), ); }); @@ -267,7 +278,11 @@ describe('refreshController – OpenID path', () => { expect(updateUser).toHaveBeenCalledWith( 'user-db-id', - expect.objectContaining({ provider: 'openid', openidId: baseClaims.sub }), + expect.objectContaining({ + provider: 'openid', + openidId: baseClaims.sub, + openidIssuer: baseClaims.iss, + }), ); expect(res.status).toHaveBeenCalledWith(200); }); diff --git a/api/server/routes/agents/middleware.js b/api/server/routes/agents/middleware.js new file mode 100644 index 000000000000..f71c25c6f8fd --- /dev/null +++ b/api/server/routes/agents/middleware.js @@ -0,0 +1,41 @@ +const { PermissionTypes, Permissions } = require('librechat-data-provider'); +const { + generateCheckAccess, + preAuthTenantMiddleware, + createRequireApiKeyAuth, + createRemoteAgentAuth, + createCheckRemoteAgentAccess, +} = require('@librechat/api'); +const { getEffectivePermissions } = require('~/server/services/PermissionService'); +const { getAppConfig } = require('~/server/services/Config'); +const db = require('~/models'); + +const apiKeyMiddleware = createRequireApiKeyAuth({ + validateAgentApiKey: db.validateAgentApiKey, + findUser: db.findUser, +}); + +const requireRemoteAgentAuth = createRemoteAgentAuth({ + apiKeyMiddleware, + findUser: db.findUser, + updateUser: db.updateUser, + getAppConfig, +}); + +const checkRemoteAgentsFeature = generateCheckAccess({ + permissionType: PermissionTypes.REMOTE_AGENTS, + permissions: [Permissions.USE], + getRoleByName: db.getRoleByName, +}); + +const checkAgentPermission = createCheckRemoteAgentAccess({ + getAgent: db.getAgent, + getEffectivePermissions, +}); + +module.exports = { + checkAgentPermission, + preAuthTenantMiddleware, + requireRemoteAgentAuth, + checkRemoteAgentsFeature, +}; diff --git a/api/server/routes/agents/openai.js b/api/server/routes/agents/openai.js index 72e3da6c5a0e..fa7f9b26c811 100644 --- a/api/server/routes/agents/openai.js +++ b/api/server/routes/agents/openai.js @@ -17,40 +17,23 @@ * } */ const express = require('express'); -const { PermissionTypes, Permissions } = require('librechat-data-provider'); -const { - generateCheckAccess, - createRequireApiKeyAuth, - createCheckRemoteAgentAccess, -} = require('@librechat/api'); const { OpenAIChatCompletionController, ListModelsController, GetModelController, } = require('~/server/controllers/agents/openai'); -const { getEffectivePermissions } = require('~/server/services/PermissionService'); const { configMiddleware } = require('~/server/middleware'); -const db = require('~/models'); +const { + checkAgentPermission, + preAuthTenantMiddleware, + requireRemoteAgentAuth, + checkRemoteAgentsFeature, +} = require('./middleware'); const router = express.Router(); -const requireApiKeyAuth = createRequireApiKeyAuth({ - validateAgentApiKey: db.validateAgentApiKey, - findUser: db.findUser, -}); - -const checkRemoteAgentsFeature = generateCheckAccess({ - permissionType: PermissionTypes.REMOTE_AGENTS, - permissions: [Permissions.USE], - getRoleByName: db.getRoleByName, -}); - -const checkAgentPermission = createCheckRemoteAgentAccess({ - getAgent: db.getAgent, - getEffectivePermissions, -}); - -router.use(requireApiKeyAuth); +router.use(preAuthTenantMiddleware); +router.use(requireRemoteAgentAuth); router.use(configMiddleware); router.use(checkRemoteAgentsFeature); diff --git a/api/server/routes/agents/responses.js b/api/server/routes/agents/responses.js index 2c118e059712..401025bfd62a 100644 --- a/api/server/routes/agents/responses.js +++ b/api/server/routes/agents/responses.js @@ -20,40 +20,23 @@ * @see https://openresponses.org/specification */ const express = require('express'); -const { PermissionTypes, Permissions } = require('librechat-data-provider'); -const { - generateCheckAccess, - createRequireApiKeyAuth, - createCheckRemoteAgentAccess, -} = require('@librechat/api'); const { createResponse, getResponse, listModels, } = require('~/server/controllers/agents/responses'); -const { getEffectivePermissions } = require('~/server/services/PermissionService'); const { configMiddleware } = require('~/server/middleware'); -const db = require('~/models'); +const { + checkAgentPermission, + preAuthTenantMiddleware, + requireRemoteAgentAuth, + checkRemoteAgentsFeature, +} = require('./middleware'); const router = express.Router(); -const requireApiKeyAuth = createRequireApiKeyAuth({ - validateAgentApiKey: db.validateAgentApiKey, - findUser: db.findUser, -}); - -const checkRemoteAgentsFeature = generateCheckAccess({ - permissionType: PermissionTypes.REMOTE_AGENTS, - permissions: [Permissions.USE], - getRoleByName: db.getRoleByName, -}); - -const checkAgentPermission = createCheckRemoteAgentAccess({ - getAgent: db.getAgent, - getEffectivePermissions, -}); - -router.use(requireApiKeyAuth); +router.use(preAuthTenantMiddleware); +router.use(requireRemoteAgentAuth); router.use(configMiddleware); router.use(checkRemoteAgentsFeature); diff --git a/api/strategies/openIdJwtStrategy.js b/api/strategies/openIdJwtStrategy.js index 83a40bf9487c..8af11bf99c5e 100644 --- a/api/strategies/openIdJwtStrategy.js +++ b/api/strategies/openIdJwtStrategy.js @@ -3,9 +3,14 @@ const jwksRsa = require('jwks-rsa'); const { logger } = require('@librechat/data-schemas'); const { HttpsProxyAgent } = require('https-proxy-agent'); const { SystemRoles } = require('librechat-data-provider'); -const { isEnabled, findOpenIDUser, math } = require('@librechat/api'); const { Strategy: JwtStrategy, ExtractJwt } = require('passport-jwt'); -const { getOpenIdEmail } = require('./openidStrategy'); +const { + isEnabled, + findOpenIDUser, + getOpenIdEmail, + getOpenIdIssuer, + math, +} = require('@librechat/api'); const { updateUser, findUser } = require('~/models'); /** @@ -27,7 +32,9 @@ const { updateUser, findUser } = require('~/models'); */ const openIdJwtLogin = (openIdConfig) => { let jwksRsaOptions = { - cache: isEnabled(process.env.OPENID_JWKS_URL_CACHE_ENABLED) || true, + cache: process.env.OPENID_JWKS_URL_CACHE_ENABLED + ? isEnabled(process.env.OPENID_JWKS_URL_CACHE_ENABLED) + : true, cacheMaxAge: math(process.env.OPENID_JWKS_URL_CACHE_TIME, 60000), jwksUri: openIdConfig.serverMetadata().jwks_uri, }; @@ -51,11 +58,13 @@ const openIdJwtLogin = (openIdConfig) => { try { const authHeader = req.headers.authorization; const rawToken = authHeader?.replace('Bearer ', ''); + const openidIssuer = getOpenIdIssuer(payload, openIdConfig); const { user, error, migration } = await findOpenIDUser({ findUser, email: payload ? getOpenIdEmail(payload) : undefined, openidId: payload?.sub, + openidIssuer, idOnTheSource: payload?.oid, strategyName: 'openIdJwtLogin', }); @@ -72,6 +81,9 @@ const openIdJwtLogin = (openIdConfig) => { if (migration) { updateData.provider = 'openid'; updateData.openidId = payload?.sub; + if (openidIssuer) { + updateData.openidIssuer = openidIssuer; + } } if (!user.role) { user.role = SystemRoles.USER; diff --git a/api/strategies/openIdJwtStrategy.spec.js b/api/strategies/openIdJwtStrategy.spec.js index fd710f1ebd47..e3bc9f6e2865 100644 --- a/api/strategies/openIdJwtStrategy.spec.js +++ b/api/strategies/openIdJwtStrategy.spec.js @@ -23,6 +23,8 @@ jest.mock('@librechat/data-schemas', () => ({ jest.mock('@librechat/api', () => ({ isEnabled: jest.fn(() => false), findOpenIDUser: jest.fn(), + getOpenIdEmail: jest.requireActual('@librechat/api').getOpenIdEmail, + getOpenIdIssuer: jest.fn(() => 'https://issuer.example.com'), math: jest.fn((val, fallback) => fallback), })); jest.mock('~/models', () => ({ @@ -47,7 +49,10 @@ const { findUser, updateUser } = require('~/models'); // Helper: build a mock openIdConfig const mockOpenIdConfig = { - serverMetadata: () => ({ jwks_uri: 'https://example.com/.well-known/jwks.json' }), + serverMetadata: () => ({ + issuer: 'https://issuer.example.com', + jwks_uri: 'https://example.com/.well-known/jwks.json', + }), }; // Helper: invoke the captured verify callback @@ -225,6 +230,7 @@ describe('openIdJwtStrategy – OPENID_EMAIL_CLAIM', () => { _id: 'user-id-1', provider: 'openid', openidId: payload.sub, + openidIssuer: 'https://issuer.example.com', email: payload.email, role: SystemRoles.USER, }; @@ -240,7 +246,9 @@ describe('openIdJwtStrategy – OPENID_EMAIL_CLAIM', () => { expect(findUser).toHaveBeenCalledWith( expect.objectContaining({ - $or: expect.arrayContaining([{ openidId: payload.sub }]), + $or: expect.arrayContaining([ + { openidId: payload.sub, openidIssuer: 'https://issuer.example.com' }, + ]), }), ); }); @@ -254,7 +262,9 @@ describe('openIdJwtStrategy – OPENID_EMAIL_CLAIM', () => { expect(findUser).toHaveBeenCalledTimes(2); expect(findUser.mock.calls[0][0]).toMatchObject({ - $or: expect.arrayContaining([{ openidId: payload.sub }]), + $or: expect.arrayContaining([ + { openidId: payload.sub, openidIssuer: 'https://issuer.example.com' }, + ]), }); expect(findUser.mock.calls[1][0]).toEqual({ email: 'test@corp.example.com' }); expect(user).toBe(false); @@ -367,7 +377,11 @@ describe('openIdJwtStrategy – OPENID_EMAIL_CLAIM', () => { expect(user).toBeTruthy(); expect(updateUser).toHaveBeenCalledWith( 'legacy-db-id', - expect.objectContaining({ provider: 'openid', openidId: payloadNoEmail.sub }), + expect.objectContaining({ + provider: 'openid', + openidId: payloadNoEmail.sub, + openidIssuer: 'https://issuer.example.com', + }), ); }); }); diff --git a/api/strategies/openidStrategy.js b/api/strategies/openidStrategy.js index 7314a84e1567..6d08fa1e5979 100644 --- a/api/strategies/openidStrategy.js +++ b/api/strategies/openidStrategy.js @@ -13,6 +13,8 @@ const { logHeaders, safeStringify, findOpenIDUser, + getOpenIdEmail, + getOpenIdIssuer, getBalanceConfig, isEmailDomainAllowed, resolveAppConfigForUser, @@ -268,34 +270,6 @@ function getFullName(userinfo) { return userinfo.username || userinfo.email; } -/** - * Resolves the user identifier from OpenID claims. - * Configurable via OPENID_EMAIL_CLAIM; defaults to: email -> preferred_username -> upn. - * - * @param {Object} userinfo - The user information object from OpenID Connect - * @returns {string|undefined} The resolved identifier string - */ -function getOpenIdEmail(userinfo) { - const claimKey = process.env.OPENID_EMAIL_CLAIM?.trim(); - if (claimKey) { - const value = userinfo[claimKey]; - if (typeof value === 'string' && value) { - return value; - } - if (value !== undefined && value !== null) { - logger.warn( - `[openidStrategy] OPENID_EMAIL_CLAIM="${claimKey}" resolved to a non-string value (type: ${typeof value}). Falling back to: email -> preferred_username -> upn.`, - ); - } else { - logger.warn( - `[openidStrategy] OPENID_EMAIL_CLAIM="${claimKey}" not present in userinfo. Falling back to: email -> preferred_username -> upn.`, - ); - } - } - const fallback = userinfo.email || userinfo.preferred_username || userinfo.upn; - return typeof fallback === 'string' ? fallback : undefined; -} - /** * Converts an input into a string suitable for a username. * If the input is a string, it will be returned as is. @@ -470,6 +444,7 @@ async function processOpenIDAuth(tokenset, existingUsersOnly = false) { } const email = getOpenIdEmail(userinfo); + const openidIssuer = getOpenIdIssuer(claims, openidConfig); const baseConfig = await getAppConfig({ baseOnly: true }); if (!isEmailDomainAllowed(email, baseConfig?.registration?.allowedDomains)) { @@ -483,6 +458,7 @@ async function processOpenIDAuth(tokenset, existingUsersOnly = false) { findUser, email: email, openidId: claims.sub || userinfo.sub, + openidIssuer, idOnTheSource: claims.oid || userinfo.oid, strategyName: 'openidStrategy', }); @@ -588,6 +564,7 @@ async function processOpenIDAuth(tokenset, existingUsersOnly = false) { emailVerified: userinfo.email_verified || false, name: fullName, idOnTheSource: userinfo.oid, + openidIssuer, }; const balanceConfig = getBalanceConfig(appConfig); @@ -595,6 +572,9 @@ async function processOpenIDAuth(tokenset, existingUsersOnly = false) { } else { user.provider = 'openid'; user.openidId = userinfo.sub; + if (openidIssuer) { + user.openidIssuer = openidIssuer; + } user.username = username; user.name = fullName; user.idOnTheSource = userinfo.oid; diff --git a/api/strategies/openidStrategy.spec.js b/api/strategies/openidStrategy.spec.js index 6d824176f7ff..2812341e5cbe 100644 --- a/api/strategies/openidStrategy.spec.js +++ b/api/strategies/openidStrategy.spec.js @@ -3,7 +3,7 @@ const fetch = require('node-fetch'); const jwtDecode = require('jsonwebtoken/decode'); const { ErrorTypes } = require('librechat-data-provider'); const { findUser, createUser, updateUser } = require('~/models'); -const { resolveAppConfigForUser } = require('@librechat/api'); +const { getOpenIdIssuer, resolveAppConfigForUser } = require('@librechat/api'); const { getAppConfig } = require('~/server/services/Config'); const { setupOpenId } = require('./openidStrategy'); @@ -27,9 +27,11 @@ jest.mock('@librechat/api', () => ({ isEnabled: jest.fn(() => false), isEmailDomainAllowed: jest.fn(() => true), findOpenIDUser: jest.requireActual('@librechat/api').findOpenIDUser, + getOpenIdEmail: jest.requireActual('@librechat/api').getOpenIdEmail, getBalanceConfig: jest.fn(() => ({ enabled: false, })), + getOpenIdIssuer: jest.fn(() => 'https://fake-issuer.com'), resolveAppConfigForUser: jest.fn(async (_getAppConfig, _user) => ({})), })); jest.mock('~/models', () => ({ @@ -202,10 +204,17 @@ describe('setupOpenId', () => { // Assert expect(user.username).toBe(userinfo.preferred_username); + expect(getOpenIdIssuer).toHaveBeenCalledTimes(1); + expect(getOpenIdIssuer.mock.calls[0]).toHaveLength(2); + expect(getOpenIdIssuer.mock.calls[0][0]).toEqual(userinfo); + expect(getOpenIdIssuer.mock.calls[0][1]).toEqual( + expect.objectContaining({ issuer: 'https://fake-issuer.com' }), + ); expect(createUser).toHaveBeenCalledWith( expect.objectContaining({ provider: 'openid', openidId: userinfo.sub, + openidIssuer: 'https://fake-issuer.com', username: userinfo.preferred_username, email: userinfo.email, name: `${userinfo.given_name} ${userinfo.family_name}`, @@ -326,6 +335,7 @@ describe('setupOpenId', () => { expect.objectContaining({ provider: 'openid', openidId: userinfo.sub, + openidIssuer: 'https://fake-issuer.com', username: userinfo.preferred_username, name: `${userinfo.given_name} ${userinfo.family_name}`, }), diff --git a/packages/api/package.json b/packages/api/package.json index 2d9c1f123f77..493cff1c5a74 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -112,6 +112,7 @@ "ioredis": "^5.3.2", "js-yaml": "^4.1.1", "jsonwebtoken": "^9.0.0", + "jwks-rsa": "^3.2.0", "keyv": "^5.3.2", "keyv-file": "^5.1.2", "librechat-data-provider": "*", diff --git a/packages/api/src/app/service.ts b/packages/api/src/app/service.ts index 952544f02cb4..613c428fca77 100644 --- a/packages/api/src/app/service.ts +++ b/packages/api/src/app/service.ts @@ -48,6 +48,15 @@ export interface AppConfigServiceDeps { overrideCacheTtl?: number; } +export interface GetAppConfigOptions { + role?: string; + userId?: string; + tenantId?: string; + refresh?: boolean; + /** When true, return only the YAML-derived base config — no DB override queries. */ + baseOnly?: boolean; +} + // ── Helpers ────────────────────────────────────────────────────────── let _strictOverride: boolean | undefined; @@ -140,16 +149,7 @@ export function createAppConfigService(deps: AppConfigServiceDeps) { * `role`, `userId`, and `tenantId` are ignored in this mode. * Use this for startup, auth strategies, and other pre-tenant code paths. */ - async function getAppConfig( - options: { - role?: string; - userId?: string; - tenantId?: string; - refresh?: boolean; - /** When true, return only the YAML-derived base config — no DB override queries. */ - baseOnly?: boolean; - } = {}, - ): Promise { + async function getAppConfig(options: GetAppConfigOptions = {}): Promise { const { role, userId, tenantId, refresh, baseOnly } = options; const baseConfig = await ensureBaseConfig(refresh); diff --git a/packages/api/src/auth/openid.spec.ts b/packages/api/src/auth/openid.spec.ts index 2cf3992cdfa6..deaba8c5c044 100644 --- a/packages/api/src/auth/openid.spec.ts +++ b/packages/api/src/auth/openid.spec.ts @@ -1,8 +1,8 @@ import { Types } from 'mongoose'; -import { ErrorTypes } from 'librechat-data-provider'; import { logger } from '@librechat/data-schemas'; +import { ErrorTypes } from 'librechat-data-provider'; import type { IUser, UserMethods } from '@librechat/data-schemas'; -import { findOpenIDUser } from './openid'; +import { findOpenIDUser, getOpenIdEmail, getOpenIdIssuer, normalizeOpenIdIssuer } from './openid'; function newId() { return new Types.ObjectId(); @@ -16,22 +16,76 @@ jest.mock('@librechat/data-schemas', () => ({ }, })); +describe('normalizeOpenIdIssuer', () => { + it('normalizes blank, trailing-slash, and discovery-document issuers', () => { + expect(normalizeOpenIdIssuer('')).toBeUndefined(); + expect(normalizeOpenIdIssuer(' ')).toBeUndefined(); + expect(normalizeOpenIdIssuer('https://issuer.example.com/')).toBe('https://issuer.example.com'); + expect( + normalizeOpenIdIssuer('https://issuer.example.com/.well-known/openid-configuration/'), + ).toBe('https://issuer.example.com'); + expect( + normalizeOpenIdIssuer('https://issuer.example.com/realm/.well-known/openid-configuration'), + ).toBe('https://issuer.example.com/realm'); + }); +}); + +describe('getOpenIdIssuer', () => { + const originalOpenIdIssuer = process.env.OPENID_ISSUER; + + afterEach(() => { + if (originalOpenIdIssuer == null) { + delete process.env.OPENID_ISSUER; + return; + } + + process.env.OPENID_ISSUER = originalOpenIdIssuer; + }); + + it('prefers token issuer and falls back to metadata and env issuer', () => { + process.env.OPENID_ISSUER = 'https://env.example.com/.well-known/openid-configuration'; + + expect( + getOpenIdIssuer( + { iss: 'https://token.example.com/' }, + { serverMetadata: () => ({ issuer: 'https://metadata.example.com' }) }, + ), + ).toBe('https://token.example.com'); + expect( + getOpenIdIssuer({}, { serverMetadata: () => ({ issuer: 'https://metadata.example.com/' }) }), + ).toBe('https://metadata.example.com'); + expect(getOpenIdIssuer({})).toBe('https://env.example.com'); + }); +}); + describe('findOpenIDUser', () => { let mockFindUser: jest.MockedFunction; + const originalOpenIdIssuer = process.env.OPENID_ISSUER; + const issuer = 'https://issuer.example.com'; beforeEach(() => { mockFindUser = jest.fn(); + delete process.env.OPENID_ISSUER; jest.clearAllMocks(); (logger.warn as jest.Mock).mockClear(); (logger.info as jest.Mock).mockClear(); }); + afterAll(() => { + if (originalOpenIdIssuer == null) { + delete process.env.OPENID_ISSUER; + return; + } + process.env.OPENID_ISSUER = originalOpenIdIssuer; + }); + describe('Primary condition searches', () => { it('should find user by openidId', async () => { const mockUser: IUser = { _id: newId(), provider: 'openid', openidId: 'openid_123', + openidIssuer: issuer, email: 'user@example.com', username: 'testuser', } as IUser; @@ -40,12 +94,13 @@ describe('findOpenIDUser', () => { const result = await findOpenIDUser({ openidId: 'openid_123', + openidIssuer: issuer, findUser: mockFindUser, email: 'user@example.com', }); expect(mockFindUser).toHaveBeenCalledWith({ - $or: [{ openidId: 'openid_123' }], + $or: [{ openidId: 'openid_123', openidIssuer: issuer }], }); expect(result).toEqual({ user: mockUser, @@ -67,12 +122,16 @@ describe('findOpenIDUser', () => { const result = await findOpenIDUser({ openidId: 'openid_123', + openidIssuer: issuer, findUser: mockFindUser, idOnTheSource: 'source_123', }); expect(mockFindUser).toHaveBeenCalledWith({ - $or: [{ openidId: 'openid_123' }, { idOnTheSource: 'source_123' }], + $or: [ + { openidId: 'openid_123', openidIssuer: issuer }, + { idOnTheSource: 'source_123', openidIssuer: issuer }, + ], }); expect(result).toEqual({ user: mockUser, @@ -87,6 +146,7 @@ describe('findOpenIDUser', () => { provider: 'openid', openidId: 'openid_123', idOnTheSource: 'source_123', + openidIssuer: issuer, email: 'user@example.com', username: 'testuser', } as IUser; @@ -95,13 +155,46 @@ describe('findOpenIDUser', () => { const result = await findOpenIDUser({ openidId: 'openid_123', + openidIssuer: issuer, findUser: mockFindUser, idOnTheSource: 'source_123', email: 'user@example.com', }); expect(mockFindUser).toHaveBeenCalledWith({ - $or: [{ openidId: 'openid_123' }, { idOnTheSource: 'source_123' }], + $or: [ + { openidId: 'openid_123', openidIssuer: issuer }, + { idOnTheSource: 'source_123', openidIssuer: issuer }, + ], + }); + expect(result).toEqual({ + user: mockUser, + error: null, + migration: false, + }); + }); + + it('should bind primary lookup to issuer', async () => { + const mockUser: IUser = { + _id: newId(), + provider: 'openid', + openidId: 'openid_123', + openidIssuer: 'https://issuer.example.com', + email: 'user@example.com', + username: 'testuser', + } as IUser; + + mockFindUser.mockResolvedValueOnce(mockUser); + + const result = await findOpenIDUser({ + openidId: 'openid_123', + openidIssuer: 'https://issuer.example.com/', + findUser: mockFindUser, + email: 'user@example.com', + }); + + expect(mockFindUser).toHaveBeenCalledWith({ + $or: [{ openidId: 'openid_123', openidIssuer: 'https://issuer.example.com' }], }); expect(result).toEqual({ user: mockUser, @@ -109,6 +202,101 @@ describe('findOpenIDUser', () => { migration: false, }); }); + + it('should allow legacy issuer-less lookup only for the configured OpenID login issuer', async () => { + process.env.OPENID_ISSUER = 'https://issuer.example.com/'; + const mockUser: IUser = { + _id: newId(), + provider: 'openid', + openidId: 'openid_123', + email: 'user@example.com', + username: 'testuser', + } as IUser; + + mockFindUser.mockResolvedValueOnce(mockUser); + + const result = await findOpenIDUser({ + openidId: 'openid_123', + openidIssuer: 'https://issuer.example.com', + findUser: mockFindUser, + }); + + expect(mockFindUser).toHaveBeenCalledWith({ + $or: [ + { openidId: 'openid_123', openidIssuer: 'https://issuer.example.com' }, + { + openidId: 'openid_123', + $or: [ + { openidIssuer: { $exists: false } }, + { openidIssuer: null }, + { openidIssuer: '' }, + ], + }, + ], + }); + expect(result).toEqual({ + user: { ...mockUser, openidIssuer: 'https://issuer.example.com' }, + error: null, + migration: true, + }); + }); + + it('should allow legacy issuer-less lookup when login issuer is a discovery document URL', async () => { + process.env.OPENID_ISSUER = 'https://issuer.example.com/.well-known/openid-configuration'; + const mockUser: IUser = { + _id: newId(), + provider: 'openid', + openidId: 'openid_123', + email: 'user@example.com', + username: 'testuser', + } as IUser; + + mockFindUser.mockResolvedValueOnce(mockUser); + + const result = await findOpenIDUser({ + openidId: 'openid_123', + openidIssuer: 'https://issuer.example.com', + findUser: mockFindUser, + }); + + expect(mockFindUser).toHaveBeenCalledWith({ + $or: [ + { openidId: 'openid_123', openidIssuer: 'https://issuer.example.com' }, + { + openidId: 'openid_123', + $or: [ + { openidIssuer: { $exists: false } }, + { openidIssuer: null }, + { openidIssuer: '' }, + ], + }, + ], + }); + expect(result).toEqual({ + user: { ...mockUser, openidIssuer: 'https://issuer.example.com' }, + error: null, + migration: true, + }); + }); + + it('should skip primary ID lookup when issuer context is missing', async () => { + mockFindUser.mockResolvedValueOnce(null); + + const result = await findOpenIDUser({ + openidId: 'openid_123', + idOnTheSource: 'source_123', + findUser: mockFindUser, + email: 'user@example.com', + }); + + expect(mockFindUser).toHaveBeenCalledTimes(1); + expect(mockFindUser).toHaveBeenCalledWith({ email: 'user@example.com' }); + expect(result).toEqual({ + user: null, + error: null, + migration: false, + }); + }); }); describe('Email-based searches', () => { @@ -117,6 +305,7 @@ describe('findOpenIDUser', () => { _id: newId(), provider: 'openid', openidId: 'openid_123', + openidIssuer: issuer, email: 'user@example.com', username: 'testuser', } as IUser; @@ -125,12 +314,13 @@ describe('findOpenIDUser', () => { const result = await findOpenIDUser({ openidId: 'openid_123', + openidIssuer: issuer, findUser: mockFindUser, email: 'user@example.com', }); expect(mockFindUser).toHaveBeenNthCalledWith(1, { - $or: [{ openidId: 'openid_123' }], + $or: [{ openidId: 'openid_123', openidIssuer: issuer }], }); expect(mockFindUser).toHaveBeenNthCalledWith(2, { email: 'user@example.com' }); expect(result).toEqual({ @@ -147,6 +337,7 @@ describe('findOpenIDUser', () => { const result = await findOpenIDUser({ openidId: 'openid_123', + openidIssuer: issuer, findUser: mockFindUser, email: 'user@example.com', }); @@ -164,12 +355,13 @@ describe('findOpenIDUser', () => { const result = await findOpenIDUser({ openidId: 'openid_123', + openidIssuer: issuer, findUser: mockFindUser, }); expect(mockFindUser).toHaveBeenCalledTimes(1); expect(mockFindUser).toHaveBeenCalledWith({ - $or: [{ openidId: 'openid_123' }], + $or: [{ openidId: 'openid_123', openidIssuer: issuer }], }); expect(result).toEqual({ user: null, @@ -194,6 +386,7 @@ describe('findOpenIDUser', () => { const result = await findOpenIDUser({ openidId: 'openid_123', + openidIssuer: issuer, findUser: mockFindUser, email: 'user@example.com', }); @@ -218,6 +411,7 @@ describe('findOpenIDUser', () => { const result = await findOpenIDUser({ openidId: 'openid_123', + openidIssuer: issuer, findUser: mockFindUser, email: 'user@example.com', }); @@ -234,6 +428,7 @@ describe('findOpenIDUser', () => { _id: newId(), provider: 'openid', openidId: 'openid_123', + openidIssuer: issuer, email: 'user@example.com', username: 'testuser', } as IUser; @@ -242,6 +437,7 @@ describe('findOpenIDUser', () => { const result = await findOpenIDUser({ openidId: 'openid_123', + openidIssuer: issuer, findUser: mockFindUser, email: 'user@example.com', }); @@ -252,6 +448,58 @@ describe('findOpenIDUser', () => { migration: false, }); }); + + it('should reject email fallback when stored openidIssuer does not match token issuer', async () => { + const mockUser: IUser = { + _id: newId(), + provider: 'openid', + openidId: 'openid_123', + openidIssuer: 'https://issuer-a.example.com', + email: 'user@example.com', + username: 'testuser', + } as IUser; + + mockFindUser.mockResolvedValueOnce(null).mockResolvedValueOnce(mockUser); + + const result = await findOpenIDUser({ + openidId: 'openid_123', + openidIssuer: 'https://issuer-b.example.com', + findUser: mockFindUser, + email: 'user@example.com', + }); + + expect(result).toEqual({ + user: null, + error: ErrorTypes.AUTH_FAILED, + migration: false, + }); + }); + + it('should reject legacy email fallback when token issuer is not the configured OpenID login issuer', async () => { + process.env.OPENID_ISSUER = 'https://issuer-a.example.com'; + const mockUser: IUser = { + _id: newId(), + provider: 'openid', + openidId: 'openid_123', + email: 'user@example.com', + username: 'testuser', + } as IUser; + + mockFindUser.mockResolvedValueOnce(null).mockResolvedValueOnce(mockUser); + + const result = await findOpenIDUser({ + openidId: 'openid_123', + openidIssuer: 'https://issuer-b.example.com', + findUser: mockFindUser, + email: 'user@example.com', + }); + + expect(result).toEqual({ + user: null, + error: ErrorTypes.AUTH_FAILED, + migration: false, + }); + }); }); describe('User migration scenarios', () => { @@ -269,6 +517,7 @@ describe('findOpenIDUser', () => { const result = await findOpenIDUser({ openidId: 'openid_123', + openidIssuer: issuer, findUser: mockFindUser, email: 'user@example.com', }); @@ -278,6 +527,35 @@ describe('findOpenIDUser', () => { ...mockUser, provider: 'openid', openidId: 'openid_123', + openidIssuer: issuer, + }, + error: null, + migration: true, + }); + }); + + it('should persist issuer when migrating a user by email', async () => { + const mockUser: IUser = { + _id: newId(), + email: 'user@example.com', + username: 'testuser', + } as IUser; + + mockFindUser.mockResolvedValueOnce(null).mockResolvedValueOnce(mockUser); + + const result = await findOpenIDUser({ + openidId: 'openid_123', + openidIssuer: 'https://issuer.example.com', + findUser: mockFindUser, + email: 'user@example.com', + }); + + expect(result).toEqual({ + user: { + ...mockUser, + provider: 'openid', + openidId: 'openid_123', + openidIssuer: 'https://issuer.example.com', }, error: null, migration: true, @@ -297,6 +575,7 @@ describe('findOpenIDUser', () => { const result = await findOpenIDUser({ openidId: 'openid_123', + openidIssuer: issuer, findUser: mockFindUser, email: 'user@example.com', }); @@ -321,6 +600,7 @@ describe('findOpenIDUser', () => { const result = await findOpenIDUser({ openidId: 'openid_123', + openidIssuer: issuer, findUser: mockFindUser, email: 'user@example.com', }); @@ -388,12 +668,13 @@ describe('findOpenIDUser', () => { const result = await findOpenIDUser({ openidId: 'openid_123', + openidIssuer: issuer, findUser: mockFindUser, idOnTheSource: '', }); expect(mockFindUser).toHaveBeenCalledWith({ - $or: [{ openidId: 'openid_123' }], + $or: [{ openidId: 'openid_123', openidIssuer: issuer }], }); expect(result).toEqual({ user: null, @@ -420,6 +701,7 @@ describe('findOpenIDUser', () => { _id: newId(), provider: 'openid', openidId: 'openid_123', + openidIssuer: issuer, email: 'user@example.com', username: 'testuser', } as IUser; @@ -428,6 +710,7 @@ describe('findOpenIDUser', () => { const result = await findOpenIDUser({ openidId: 'openid_123', + openidIssuer: issuer, findUser: mockFindUser, email: 'User@Example.COM', }); @@ -446,6 +729,7 @@ describe('findOpenIDUser', () => { await expect( findOpenIDUser({ openidId: 'openid_123', + openidIssuer: issuer, findUser: mockFindUser, }), ).rejects.toThrow('Database error'); @@ -478,3 +762,77 @@ describe('findOpenIDUser', () => { }); }); }); + +describe('getOpenIdEmail', () => { + const originalEmailClaim = process.env.OPENID_EMAIL_CLAIM; + + beforeEach(() => { + jest.clearAllMocks(); + delete process.env.OPENID_EMAIL_CLAIM; + }); + + afterAll(() => { + if (originalEmailClaim == null) { + delete process.env.OPENID_EMAIL_CLAIM; + return; + } + process.env.OPENID_EMAIL_CLAIM = originalEmailClaim; + }); + + it('uses the default claim order', () => { + expect( + getOpenIdEmail({ + email: 'user@example.com', + preferred_username: 'preferred@example.com', + upn: 'upn@example.com', + }), + ).toBe('user@example.com'); + }); + + it('returns undefined when default claims are absent', () => { + expect(getOpenIdEmail({})).toBeUndefined(); + }); + + it('skips empty fallback claims', () => { + expect( + getOpenIdEmail({ + email: '', + preferred_username: 'preferred@example.com', + upn: 'upn@example.com', + }), + ).toBe('preferred@example.com'); + }); + + it('uses OPENID_EMAIL_CLAIM when present', () => { + process.env.OPENID_EMAIL_CLAIM = 'custom_identifier'; + + expect( + getOpenIdEmail({ + email: 'user@example.com', + custom_identifier: 'agent@corp.example.com', + }), + ).toBe('agent@corp.example.com'); + }); + + it('falls back with a warning when OPENID_EMAIL_CLAIM is missing', () => { + process.env.OPENID_EMAIL_CLAIM = 'missing_identifier'; + + expect(getOpenIdEmail({ email: 'user@example.com' }, 'remoteAgentAuth')).toBe( + 'user@example.com', + ); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('OPENID_EMAIL_CLAIM="missing_identifier" not present in userinfo'), + ); + }); + + it('falls back with a warning when OPENID_EMAIL_CLAIM is not a string', () => { + process.env.OPENID_EMAIL_CLAIM = 'groups'; + + expect(getOpenIdEmail({ email: 'user@example.com', groups: ['a'] })).toBe('user@example.com'); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining( + 'OPENID_EMAIL_CLAIM="groups" resolved to a non-string value (type: object)', + ), + ); + }); +}); diff --git a/packages/api/src/auth/openid.ts b/packages/api/src/auth/openid.ts index 12ff48b2a9be..e647e09f1714 100644 --- a/packages/api/src/auth/openid.ts +++ b/packages/api/src/auth/openid.ts @@ -1,6 +1,144 @@ import { logger } from '@librechat/data-schemas'; import { ErrorTypes } from 'librechat-data-provider'; import type { IUser, UserMethods } from '@librechat/data-schemas'; +import type { FilterQuery } from 'mongoose'; + +export type OpenIdEmailClaims = { + email?: unknown; + preferred_username?: unknown; + upn?: unknown; + [claim: string]: unknown; +}; + +export type OpenIdIssuerSource = { + iss?: string; + issuer?: string; + serverMetadata?: () => { issuer?: string } | undefined; +}; + +type OpenIdLookupField = 'openidId' | 'idOnTheSource'; +type OpenIdUserResolution = { user: IUser | null; error: string | null; migration: boolean }; + +const OPENID_DISCOVERY_PATH = '/.well-known/openid-configuration'; + +export function normalizeOpenIdIssuer(issuer: string | undefined): string | undefined { + const normalized = issuer?.trim().replace(/\/+$/, ''); + if (!normalized) return undefined; + if (!normalized.endsWith(OPENID_DISCOVERY_PATH)) return normalized; + return normalized.slice(0, -OPENID_DISCOVERY_PATH.length) || undefined; +} + +function getIssuerFromSource(source: OpenIdIssuerSource | null | undefined): string | undefined { + if (source == null) return undefined; + + const issuer = source.iss || source.serverMetadata?.()?.issuer || source.issuer; + return normalizeOpenIdIssuer(issuer); +} + +function getStringClaim(claims: OpenIdEmailClaims, claim: string): string | undefined { + const value = claims[claim]; + return typeof value === 'string' && value ? value : undefined; +} + +export function getOpenIdIssuer( + ...sources: Array +): string | undefined { + for (const source of sources) { + const issuer = getIssuerFromSource(source); + if (issuer) return issuer; + } + + return normalizeOpenIdIssuer(process.env.OPENID_ISSUER); +} + +function isLegacyOpenIdIssuer(openidIssuer: string | undefined): boolean { + const loginIssuer = normalizeOpenIdIssuer(process.env.OPENID_ISSUER); + return openidIssuer != null && loginIssuer != null && openidIssuer === loginIssuer; +} + +function getIssuerBoundConditions( + field: OpenIdLookupField, + value: string | undefined, + openidIssuer: string | undefined, +): FilterQuery[] { + if (!value || typeof value !== 'string') return []; + if (!openidIssuer) return []; + + const conditions: FilterQuery[] = [{ [field]: value, openidIssuer }]; + + if (isLegacyOpenIdIssuer(openidIssuer)) { + conditions.push({ + [field]: value, + $or: [{ openidIssuer: { $exists: false } }, { openidIssuer: null }, { openidIssuer: '' }], + }); + } + + return conditions; +} + +function isUserIssuerAllowed(user: IUser, openidIssuer: string | undefined): boolean { + if (!openidIssuer) return true; + + const userIssuer = normalizeOpenIdIssuer(user.openidIssuer); + if (userIssuer) return userIssuer === openidIssuer; + + return isLegacyOpenIdIssuer(openidIssuer); +} + +function resolveIssuerBoundUser( + user: IUser | null, + normalizedIssuer: string | undefined, + strategyName: string, + context: string, +): OpenIdUserResolution | null { + if (!user?.openidId) return null; + + if (!isUserIssuerAllowed(user, normalizedIssuer)) { + logger.warn( + `[${strategyName}] Rejected ${context} for ${user.email}: stored openidIssuer does not match token issuer`, + ); + return { user: null, error: ErrorTypes.AUTH_FAILED, migration: false }; + } + + if (normalizedIssuer && !normalizeOpenIdIssuer(user.openidIssuer)) { + user.openidIssuer = normalizedIssuer; + return { user, error: null, migration: true }; + } + + return null; +} + +/** + * Resolves the OpenID user identifier claim, honoring OPENID_EMAIL_CLAIM before + * email/preferred_username/upn fallbacks. + */ +export function getOpenIdEmail( + claims: OpenIdEmailClaims | null | undefined, + strategyName = 'openidStrategy', +): string | undefined { + if (claims == null) return undefined; + + const claimKey = process.env.OPENID_EMAIL_CLAIM?.trim(); + if (claimKey) { + const value = claims[claimKey]; + if (typeof value === 'string' && value) return value; + if (value != null) { + logger.warn( + `[${strategyName}] OPENID_EMAIL_CLAIM="${claimKey}" resolved to a non-string value (type: ${typeof value}). Falling back to: email -> preferred_username -> upn.`, + ); + } else { + logger.warn( + `[${strategyName}] OPENID_EMAIL_CLAIM="${claimKey}" not present in userinfo. Falling back to: email -> preferred_username -> upn.`, + ); + } + } + + return ( + getStringClaim(claims, 'email') ?? + getStringClaim(claims, 'preferred_username') ?? + getStringClaim(claims, 'upn') + ); +} /** * Finds or migrates a user for OpenID authentication @@ -10,29 +148,36 @@ export async function findOpenIDUser({ openidId, findUser, email, + openidIssuer, idOnTheSource, strategyName = 'openid', }: { openidId: string; findUser: UserMethods['findUser']; email?: string; + openidIssuer?: string; idOnTheSource?: string; strategyName?: string; -}): Promise<{ user: IUser | null; error: string | null; migration: boolean }> { - const primaryConditions = []; - - if (openidId && typeof openidId === 'string') { - primaryConditions.push({ openidId }); - } - - if (idOnTheSource && typeof idOnTheSource === 'string') { - primaryConditions.push({ idOnTheSource }); - } +}): Promise { + const normalizedIssuer = normalizeOpenIdIssuer(openidIssuer); + const primaryConditions = [ + ...getIssuerBoundConditions('openidId', openidId, normalizedIssuer), + ...getIssuerBoundConditions('idOnTheSource', idOnTheSource, normalizedIssuer), + ]; let user = null; if (primaryConditions.length > 0) { user = await findUser({ $or: primaryConditions }); } + + const primaryIssuerResolution = resolveIssuerBoundUser( + user, + normalizedIssuer, + strategyName, + 'OpenID lookup', + ); + if (primaryIssuerResolution) return primaryIssuerResolution; + if (!user && email) { user = await findUser({ email }); logger.warn( @@ -54,12 +199,21 @@ export async function findOpenIDUser({ return { user: null, error: ErrorTypes.AUTH_FAILED, migration: false }; } + const emailIssuerResolution = resolveIssuerBoundUser( + user, + normalizedIssuer, + strategyName, + 'email fallback', + ); + if (emailIssuerResolution) return emailIssuerResolution; + if (user && !user.openidId) { logger.info( `[${strategyName}] Preparing user ${user.email} for migration to OpenID with sub: ${openidId}`, ); user.provider = 'openid'; user.openidId = openidId; + if (normalizedIssuer) user.openidIssuer = normalizedIssuer; return { user, error: null, migration: true }; } } diff --git a/packages/api/src/middleware/balance.ts b/packages/api/src/middleware/balance.ts index 19719680ec62..4b746820f43a 100644 --- a/packages/api/src/middleware/balance.ts +++ b/packages/api/src/middleware/balance.ts @@ -21,6 +21,27 @@ export interface BalanceMiddlewareOptions { upsertBalanceFields: (userId: string, fields: IBalanceUpdate) => Promise; } +const balanceUpdateLocks = new Map>(); + +async function runBalanceUpdate(userId: string, task: () => Promise): Promise { + const previous = balanceUpdateLocks.get(userId) ?? Promise.resolve(); + const current = previous.catch(() => undefined).then(task); + const tail = current.then( + () => undefined, + () => undefined, + ); + + balanceUpdateLocks.set(userId, tail); + + try { + await current; + } finally { + if (balanceUpdateLocks.get(userId) === tail) { + balanceUpdateLocks.delete(userId); + } + } +} + /** * Build an object containing fields that need updating * @param config - The balance configuration @@ -112,14 +133,16 @@ export function createSetBalanceConfig({ return next(); } const userId = typeof user._id === 'string' ? user._id : user._id.toString(); - const userBalanceRecord = await findBalanceByUser(userId); - const updateFields = buildUpdateFields(balanceConfig, userBalanceRecord, userId); + await runBalanceUpdate(userId, async () => { + const userBalanceRecord = await findBalanceByUser(userId); + const updateFields = buildUpdateFields(balanceConfig, userBalanceRecord, userId); - if (Object.keys(updateFields).length === 0) { - return next(); - } + if (Object.keys(updateFields).length === 0) { + return; + } - await upsertBalanceFields(userId, updateFields); + await upsertBalanceFields(userId, updateFields); + }); next(); } catch (error) { diff --git a/packages/api/src/middleware/index.ts b/packages/api/src/middleware/index.ts index b91fee2999bb..9fccfa6780d1 100644 --- a/packages/api/src/middleware/index.ts +++ b/packages/api/src/middleware/index.ts @@ -9,3 +9,4 @@ export { tenantContextMiddleware } from './tenant'; export { preAuthTenantMiddleware } from './preAuthTenant'; export * from './concurrency'; export * from './checkBalance'; +export * from './remoteAgentAuth'; diff --git a/packages/api/src/middleware/remoteAgentAuth.spec.ts b/packages/api/src/middleware/remoteAgentAuth.spec.ts new file mode 100644 index 000000000000..c0ae2ca090d5 --- /dev/null +++ b/packages/api/src/middleware/remoteAgentAuth.spec.ts @@ -0,0 +1,1533 @@ +import type { AppConfig, IUser, UserMethods } from '@librechat/data-schemas'; +import type { TAgentsEndpoint } from 'librechat-data-provider'; +import type { JwtPayload, VerifyOptions } from 'jsonwebtoken'; +import type { Request, Response } from 'express'; +import type { RequestInit } from 'undici'; + +jest.mock('@librechat/data-schemas', () => { + const actual = jest.requireActual('@librechat/data-schemas'); + return { + ...actual, + logger: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + }, + }; +}); + +jest.mock('~/utils', () => ({ + isEnabled: jest.fn(() => false), + math: jest.fn(() => 60000), +})); + +const mockGetSigningKey = jest.fn(); +const mockGetSigningKeys = jest.fn(); + +jest.mock('jwks-rsa', () => + jest.fn(() => ({ getSigningKey: mockGetSigningKey, getSigningKeys: mockGetSigningKeys })), +); + +jest.mock('undici', () => ({ + fetch: jest.fn(), + ProxyAgent: jest.fn((proxy: string) => ({ proxy })), +})); + +jest.mock('jsonwebtoken', () => ({ + decode: jest.fn(), + verify: jest.fn(), +})); + +jest.mock('../auth/openid', () => { + const actual = jest.requireActual('../auth/openid') as typeof import('../auth/openid'); + return { ...actual, findOpenIDUser: jest.fn(actual.findOpenIDUser) }; +}); + +import jwt from 'jsonwebtoken'; +import jwksRsa from 'jwks-rsa'; +import { ProxyAgent, fetch as undiciFetch } from 'undici'; +import { logger, tenantStorage } from '@librechat/data-schemas'; +import { clearRemoteAgentAuthCache, createRemoteAgentAuth } from './remoteAgentAuth'; +import { findOpenIDUser, getOpenIdEmail } from '../auth/openid'; +import { math } from '~/utils'; + +const mockFetch = undiciFetch as jest.Mock; +const mockProxyAgent = ProxyAgent as unknown as jest.Mock; +const mockMath = math as jest.Mock; +const realFindOpenIDUser = + jest.requireActual('../auth/openid').findOpenIDUser; +const mockFindOpenIDUser = findOpenIDUser as jest.MockedFunction; +const FAKE_TOKEN = 'header.payload.signature'; +const BASE_ISSUER = 'https://auth.example.com/realms/test'; +const BASE_JWKS_URI = `${BASE_ISSUER}/protocol/openid-connect/certs`; +const ENV_KEYS = [ + 'OPENID_EMAIL_CLAIM', + 'OPENID_JWKS_URL', + 'OPENID_JWKS_URL_CACHE_ENABLED', + 'OPENID_JWKS_URL_CACHE_TIME', + 'PROXY', +] as const; + +type AgentAuthConfig = NonNullable['auth']>; +type OidcConfig = NonNullable; +type ApiKeyConfig = NonNullable; +type JwtVerifyCallback = (err: Error | null, payload?: JwtPayload) => void; +type FindUserValue = IUser['_id'] | string | null | { $exists: boolean }; +type FindUserCondition = { + _id?: FindUserValue; + email?: FindUserValue; + openidId?: FindUserValue; + openidIssuer?: FindUserValue; + idOnTheSource?: FindUserValue; + $or?: FindUserCondition[]; +}; +type FindUserQuery = FindUserCondition & { $or?: FindUserCondition[] }; + +const mockUser = { _id: 'uid123', id: 'uid123', email: 'agent@test.com' }; +const originalEnv = ENV_KEYS.reduce>( + (env, key) => ({ ...env, [key]: process.env[key] }), + { + OPENID_EMAIL_CLAIM: undefined, + OPENID_JWKS_URL: undefined, + OPENID_JWKS_URL_CACHE_ENABLED: undefined, + OPENID_JWKS_URL_CACHE_TIME: undefined, + PROXY: undefined, + }, +); + +function deleteEnvKeys() { + ENV_KEYS.forEach((key) => { + delete process.env[key]; + }); +} + +function restoreOriginalEnv() { + ENV_KEYS.forEach((key) => { + const value = originalEnv[key]; + if (value == null) { + delete process.env[key]; + return; + } + process.env[key] = value; + }); +} + +function makeRes() { + const json = jest.fn(); + const status = jest.fn().mockReturnValue({ json }); + return { res: { status, json } as unknown as Response, status, json }; +} + +function makeReq(headers: Record = {}): Partial { + return { headers }; +} + +function makeConfig( + oidcOverrides?: Partial, + apiKeyOverrides?: Partial, +): AppConfig { + return { + endpoints: { + agents: { + remoteApi: { + auth: { + oidc: { + enabled: true, + issuer: BASE_ISSUER, + jwksUri: BASE_JWKS_URI, + ...oidcOverrides, + }, + apiKey: { enabled: true, ...apiKeyOverrides }, + }, + }, + }, + }, + } as unknown as AppConfig; +} + +function makeUser(overrides: Partial = {}): IUser { + return { + ...mockUser, + role: 'user', + provider: 'openid', + openidId: 'sub123', + openidIssuer: BASE_ISSUER, + ...overrides, + } as IUser; +} + +function matchesValue(value: FindUserValue | undefined, condition: FindUserValue): boolean { + if (condition && typeof condition === 'object' && '$exists' in condition) { + return condition.$exists ? value != null : value == null; + } + return value === condition; +} + +function matchesCondition(user: IUser, condition: FindUserCondition): boolean { + if (condition.$or && !condition.$or.some((nested) => matchesCondition(user, nested))) { + return false; + } + if (condition._id !== undefined && !matchesValue(user._id, condition._id)) return false; + if (condition.email !== undefined && !matchesValue(user.email, condition.email)) return false; + if (condition.openidId !== undefined && !matchesValue(user.openidId, condition.openidId)) { + return false; + } + if ( + condition.openidIssuer !== undefined && + !matchesValue(user.openidIssuer, condition.openidIssuer) + ) { + return false; + } + if ( + condition.idOnTheSource !== undefined && + !matchesValue(user.idOnTheSource, condition.idOnTheSource) + ) { + return false; + } + return true; +} + +function makeFindUser(...users: IUser[]): jest.MockedFunction { + return jest.fn(async (query) => { + const userQuery = query as FindUserQuery; + const conditions = userQuery.$or ?? [userQuery]; + return ( + users.find((user) => conditions.some((condition) => matchesCondition(user, condition))) ?? + null + ); + }) as jest.MockedFunction; +} + +function makeDeps(appConfig: AppConfig = makeConfig()) { + return { + findUser: makeFindUser(makeUser()), + updateUser: jest.fn(), + getAppConfig: jest.fn().mockResolvedValue(appConfig), + apiKeyMiddleware: jest.fn((_req: unknown, _res: unknown, next: () => void) => next()), + }; +} + +function setupOidcMocks(payload: JwtPayload, kid: string | null = 'test-kid') { + (jwt.decode as jest.Mock).mockReturnValue({ header: kid == null ? {} : { kid }, payload }); + mockGetSigningKey.mockResolvedValue({ getPublicKey: () => 'public-key' }); + mockGetSigningKeys.mockResolvedValue([ + { kid: kid ?? 'test-kid', getPublicKey: () => 'public-key' }, + ]); + (jwt.verify as jest.Mock).mockImplementation( + (_t: string, _k: string, _o: VerifyOptions, cb: JwtVerifyCallback) => cb(null, payload), + ); +} + +describe('createRemoteAgentAuth', () => { + let mockNext: jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + deleteEnvKeys(); + clearRemoteAgentAuthCache(); + mockFetch.mockReset(); + mockMath.mockReturnValue(60000); + mockFindOpenIDUser.mockImplementation(realFindOpenIDUser); + mockNext = jest.fn(); + }); + + afterEach(() => { + deleteEnvKeys(); + clearRemoteAgentAuthCache(); + }); + + afterAll(() => { + restoreOriginalEnv(); + }); + + describe('when OIDC is not enabled', () => { + it('returns 401 when oidc.enabled is false and apiKey is disabled', async () => { + const deps = makeDeps(makeConfig({ enabled: false }, { enabled: false })); + const { res, status, json } = makeRes(); + + await createRemoteAgentAuth(deps)(makeReq() as Request, res, mockNext); + + expect(status).toHaveBeenCalledWith(401); + expect(json).toHaveBeenCalledWith({ error: 'Authentication required' }); + expect(mockNext).not.toHaveBeenCalled(); + expect(deps.apiKeyMiddleware).not.toHaveBeenCalled(); + }); + + it('falls back to apiKeyMiddleware when oidc.enabled is false', async () => { + const deps = makeDeps(makeConfig({ enabled: false })); + await createRemoteAgentAuth(deps)(makeReq() as Request, makeRes().res, mockNext); + expect(deps.apiKeyMiddleware).toHaveBeenCalled(); + }); + + it('loads base config before authentication when tenant context is absent', async () => { + const deps = makeDeps(makeConfig({ enabled: false })); + + await createRemoteAgentAuth(deps)(makeReq() as Request, makeRes().res, mockNext); + + expect(deps.getAppConfig).toHaveBeenCalledWith({ baseOnly: true }); + expect(deps.apiKeyMiddleware).toHaveBeenCalled(); + }); + + it('rejects API key auth when the resolved user tenant disables API keys', async () => { + const deps = makeDeps(makeConfig({ enabled: false }, { enabled: true })); + deps.getAppConfig.mockImplementation(async (options) => + options?.tenantId === 'tenant-oidc-only' + ? makeConfig({ enabled: true }, { enabled: false }) + : makeConfig({ enabled: false }, { enabled: true }), + ); + deps.apiKeyMiddleware.mockImplementation((req: unknown, _res: unknown, next) => { + (req as Request).user = makeUser({ tenantId: 'tenant-oidc-only' }); + next(); + }); + const { res, status, json } = makeRes(); + + await createRemoteAgentAuth(deps)(makeReq() as Request, res, mockNext); + + expect(deps.getAppConfig).toHaveBeenNthCalledWith(1, { baseOnly: true }); + expect(deps.getAppConfig).toHaveBeenNthCalledWith(2, { tenantId: 'tenant-oidc-only' }); + expect(status).toHaveBeenCalledWith(401); + expect(json).toHaveBeenCalledWith({ error: 'Unauthorized' }); + expect(mockNext).not.toHaveBeenCalled(); + }); + + it('loads tenant config from pre-auth tenant context before authentication', async () => { + const deps = makeDeps(makeConfig({ enabled: false })); + + await tenantStorage.run({ tenantId: 'tenant-preauth' }, async () => { + await createRemoteAgentAuth(deps)(makeReq() as Request, makeRes().res, mockNext); + }); + + expect(deps.getAppConfig).toHaveBeenCalledWith({ tenantId: 'tenant-preauth' }); + expect(deps.apiKeyMiddleware).toHaveBeenCalled(); + }); + + it('enforces tenant auth policy from pre-auth tenant context', async () => { + const deps = makeDeps(makeConfig({ enabled: false })); + deps.getAppConfig.mockImplementation(async (options) => + options?.tenantId === 'tenant-oidc-only' + ? makeConfig({ enabled: false }, { enabled: false }) + : makeConfig({ enabled: false }, { enabled: true }), + ); + const { res, status } = makeRes(); + + await tenantStorage.run({ tenantId: 'tenant-oidc-only' }, async () => { + await createRemoteAgentAuth(deps)(makeReq() as Request, res, mockNext); + }); + + expect(deps.getAppConfig).toHaveBeenCalledWith({ tenantId: 'tenant-oidc-only' }); + expect(status).toHaveBeenCalledWith(401); + expect(deps.apiKeyMiddleware).not.toHaveBeenCalled(); + }); + + it('loads tenant config when an authenticated user is already present', async () => { + const deps = makeDeps(makeConfig({ enabled: false })); + const req = makeReq(); + req.user = makeUser({ tenantId: 'tenant-a' }); + + await createRemoteAgentAuth(deps)(req as Request, makeRes().res, mockNext); + + expect(deps.getAppConfig).toHaveBeenCalledWith({ tenantId: 'tenant-a' }); + expect(deps.apiKeyMiddleware).toHaveBeenCalled(); + }); + + it('falls back to apiKeyMiddleware when remoteApi auth is absent', async () => { + const deps = makeDeps({ endpoints: { agents: {} } } as unknown as AppConfig); + await createRemoteAgentAuth(deps)(makeReq() as Request, makeRes().res, mockNext); + expect(deps.apiKeyMiddleware).toHaveBeenCalled(); + }); + + it('returns 401 when remoteApi auth is absent and apiKey is disabled', async () => { + const config = { + endpoints: { agents: { remoteApi: { auth: { apiKey: { enabled: false } } } } }, + } as unknown as AppConfig; + const deps = makeDeps(config); + const { res, status } = makeRes(); + + await createRemoteAgentAuth(deps)(makeReq() as Request, res, mockNext); + + expect(status).toHaveBeenCalledWith(401); + expect(mockNext).not.toHaveBeenCalled(); + expect(deps.apiKeyMiddleware).not.toHaveBeenCalled(); + }); + }); + + describe('when OIDC enabled but no Bearer token', () => { + it('falls back to apiKeyMiddleware when apiKey is enabled', async () => { + const deps = makeDeps(makeConfig({}, { enabled: true })); + const { res } = makeRes(); + + await createRemoteAgentAuth(deps)(makeReq() as Request, res, mockNext); + + expect(deps.apiKeyMiddleware).toHaveBeenCalled(); + expect(mockNext).toHaveBeenCalled(); + }); + + it('returns 401 when apiKey is disabled and no token present', async () => { + const deps = makeDeps(makeConfig({}, { enabled: false })); + const { res, status, json } = makeRes(); + + await createRemoteAgentAuth(deps)(makeReq() as Request, res, mockNext); + + expect(status).toHaveBeenCalledWith(401); + expect(json).toHaveBeenCalledWith({ error: 'Bearer token required' }); + expect(mockNext).not.toHaveBeenCalled(); + }); + + it('returns 500 when OIDC is enabled without an issuer', async () => { + const deps = makeDeps(makeConfig({ issuer: undefined }, { enabled: false })); + const { res, status, json } = makeRes(); + + await createRemoteAgentAuth(deps)(makeReq() as Request, res, mockNext); + + expect(status).toHaveBeenCalledWith(500); + expect(json).toHaveBeenCalledWith({ error: 'Internal server error' }); + expect(mockNext).not.toHaveBeenCalled(); + }); + }); + + describe('when OIDC verification succeeds', () => { + it('sets req.user and calls next()', async () => { + setupOidcMocks({ sub: 'sub123', email: 'agent@test.com', exp: 9999999999 }); + const deps = makeDeps(); + const req = makeReq({ authorization: `Bearer ${FAKE_TOKEN}` }); + const { res } = makeRes(); + + await createRemoteAgentAuth(deps)(req as Request, res, mockNext); + + expect(req.user).toMatchObject({ id: 'uid123', email: 'agent@test.com' }); + expect(mockNext).toHaveBeenCalledWith(); + expect(deps.apiKeyMiddleware).not.toHaveBeenCalled(); + }); + + it('re-evaluates OIDC auth config after resolving the user tenant', async () => { + setupOidcMocks({ sub: 'sub123', email: 'agent@test.com', scope: 'remote_agent' }); + const deps = makeDeps(); + deps.findUser = makeFindUser(makeUser({ tenantId: 'tenant-strict' })); + deps.getAppConfig.mockImplementation(async (options) => + options?.tenantId === 'tenant-strict' + ? makeConfig({ audience: 'tenant-audience', scope: 'remote_agent' }, { enabled: false }) + : makeConfig({ audience: undefined, scope: undefined }, { enabled: true }), + ); + const req = makeReq({ authorization: `Bearer ${FAKE_TOKEN}` }); + + await createRemoteAgentAuth(deps)(req as Request, makeRes().res, mockNext); + + expect(deps.getAppConfig).toHaveBeenNthCalledWith(1, { baseOnly: true }); + expect(deps.getAppConfig).toHaveBeenNthCalledWith(2, { tenantId: 'tenant-strict' }); + expect(jwt.verify).toHaveBeenCalledTimes(2); + expect((jwt.verify as jest.Mock).mock.calls[1][2]).toEqual( + expect.objectContaining({ audience: 'tenant-audience' }), + ); + expect(req.user).toMatchObject({ id: 'uid123', tenantId: 'tenant-strict' }); + expect(mockNext).toHaveBeenCalledWith(); + expect(deps.apiKeyMiddleware).not.toHaveBeenCalled(); + }); + + it('rejects OIDC auth when the resolved user tenant requires a missing scope', async () => { + setupOidcMocks({ sub: 'sub123', email: 'agent@test.com', scope: 'openid profile' }); + const deps = makeDeps(); + deps.findUser = makeFindUser( + makeUser({ + tenantId: 'tenant-strict', + provider: undefined, + openidId: undefined, + openidIssuer: undefined, + }), + ); + deps.getAppConfig.mockImplementation(async (options) => + options?.tenantId === 'tenant-strict' + ? makeConfig({ scope: 'remote_agent' }, { enabled: false }) + : makeConfig({ scope: undefined }, { enabled: true }), + ); + const req = makeReq({ authorization: `Bearer ${FAKE_TOKEN}` }); + const { res, status, json } = makeRes(); + + await createRemoteAgentAuth(deps)(req as Request, res, mockNext); + + expect(deps.getAppConfig).toHaveBeenNthCalledWith(1, { baseOnly: true }); + expect(deps.getAppConfig).toHaveBeenNthCalledWith(2, { tenantId: 'tenant-strict' }); + expect(jwt.verify).toHaveBeenCalledTimes(2); + expect(status).toHaveBeenCalledWith(401); + expect(json).toHaveBeenCalledWith({ error: 'Unauthorized' }); + expect(req.user).toBeUndefined(); + expect(mockNext).not.toHaveBeenCalled(); + expect(deps.apiKeyMiddleware).not.toHaveBeenCalled(); + expect(deps.updateUser).not.toHaveBeenCalled(); + }); + + it('rejects OIDC auth when the resolved user tenant disables OIDC', async () => { + setupOidcMocks({ sub: 'sub123', email: 'agent@test.com' }); + const deps = makeDeps(); + deps.findUser = makeFindUser( + makeUser({ + tenantId: 'tenant-api-key-only', + provider: undefined, + openidId: undefined, + openidIssuer: undefined, + }), + ); + deps.getAppConfig.mockImplementation(async (options) => + options?.tenantId === 'tenant-api-key-only' + ? makeConfig({ enabled: false }, { enabled: true }) + : makeConfig({}, { enabled: true }), + ); + const req = makeReq({ authorization: `Bearer ${FAKE_TOKEN}` }); + const { res, status, json } = makeRes(); + + await createRemoteAgentAuth(deps)(req as Request, res, mockNext); + + expect(deps.getAppConfig).toHaveBeenNthCalledWith(1, { baseOnly: true }); + expect(deps.getAppConfig).toHaveBeenNthCalledWith(2, { tenantId: 'tenant-api-key-only' }); + expect(jwt.verify).toHaveBeenCalledTimes(1); + expect(status).toHaveBeenCalledWith(401); + expect(json).toHaveBeenCalledWith({ error: 'Unauthorized' }); + expect(req.user).toBeUndefined(); + expect(mockNext).not.toHaveBeenCalled(); + expect(deps.apiKeyMiddleware).not.toHaveBeenCalled(); + expect(deps.updateUser).not.toHaveBeenCalled(); + }); + + it('allows exact and normalized configured issuers when verifying JWTs', async () => { + setupOidcMocks({ sub: 'sub123', email: 'agent@test.com' }); + const issuer = `${BASE_ISSUER}/`; + const deps = makeDeps(makeConfig({ issuer })); + + await createRemoteAgentAuth(deps)( + makeReq({ authorization: `Bearer ${FAKE_TOKEN}` }) as Request, + makeRes().res, + mockNext, + ); + + expect((jwt.verify as jest.Mock).mock.calls[0][2]).toEqual( + expect.objectContaining({ issuer: [issuer, BASE_ISSUER] }), + ); + expect(mockNext).toHaveBeenCalledWith(); + }); + + it('accepts case-insensitive Bearer auth scheme', async () => { + setupOidcMocks({ sub: 'sub123', email: 'agent@test.com' }); + const deps = makeDeps(); + const req = makeReq({ authorization: `bearer ${FAKE_TOKEN}` }); + + await createRemoteAgentAuth(deps)(req as Request, makeRes().res, mockNext); + + expect(jwt.verify).toHaveBeenCalledWith( + FAKE_TOKEN, + 'public-key', + expect.any(Object), + expect.any(Function), + ); + expect(req.user).toMatchObject({ id: 'uid123', email: 'agent@test.com' }); + expect(mockNext).toHaveBeenCalledWith(); + }); + + it('trims trailing whitespace from Bearer tokens', async () => { + setupOidcMocks({ sub: 'sub123', email: 'agent@test.com' }); + const deps = makeDeps(); + const req = makeReq({ authorization: `Bearer ${FAKE_TOKEN} ` }); + + await createRemoteAgentAuth(deps)(req as Request, makeRes().res, mockNext); + + expect(jwt.verify).toHaveBeenCalledWith( + FAKE_TOKEN, + 'public-key', + expect.any(Object), + expect.any(Function), + ); + expect(req.user).toMatchObject({ id: 'uid123', email: 'agent@test.com' }); + expect(mockNext).toHaveBeenCalledWith(); + }); + + it('allows RSA, RSA-PSS, and ECDSA signing algorithms', async () => { + setupOidcMocks({ sub: 'sub123', email: 'agent@test.com' }); + const deps = makeDeps(); + + await createRemoteAgentAuth(deps)( + makeReq({ authorization: `Bearer ${FAKE_TOKEN}` }) as Request, + makeRes().res, + mockNext, + ); + + expect((jwt.verify as jest.Mock).mock.calls[0][2]).toEqual( + expect.objectContaining({ + algorithms: expect.arrayContaining([ + 'RS256', + 'RS384', + 'RS512', + 'PS256', + 'PS384', + 'PS512', + 'ES256', + 'ES384', + 'ES512', + ]), + }), + ); + }); + + it('does not allow HMAC signing algorithms', async () => { + setupOidcMocks({ sub: 'sub123', email: 'agent@test.com' }); + const deps = makeDeps(); + + await createRemoteAgentAuth(deps)( + makeReq({ authorization: `Bearer ${FAKE_TOKEN}` }) as Request, + makeRes().res, + mockNext, + ); + + expect((jwt.verify as jest.Mock).mock.calls[0][2]).toEqual( + expect.objectContaining({ + algorithms: expect.not.arrayContaining(['HS256', 'HS384', 'HS512']), + }), + ); + }); + + it('tries signing keys until a token without kid verifies', async () => { + const payload = { sub: 'sub123', email: 'agent@test.com' }; + setupOidcMocks(payload, null); + mockGetSigningKeys.mockResolvedValue([ + { kid: 'first-kid', getPublicKey: () => 'first-public-key' }, + { kid: 'second-kid', getPublicKey: () => 'second-public-key' }, + ]); + (jwt.verify as jest.Mock).mockImplementation( + (_t: string, key: string, _o: VerifyOptions, cb: JwtVerifyCallback) => { + if (key === 'first-public-key') { + cb(new Error('invalid signature')); + return; + } + cb(null, payload); + }, + ); + + const deps = makeDeps(); + const req = makeReq({ authorization: `Bearer ${FAKE_TOKEN}` }); + + await createRemoteAgentAuth(deps)(req as Request, makeRes().res, mockNext); + + expect(mockGetSigningKey).not.toHaveBeenCalled(); + expect(jwt.verify).toHaveBeenCalledTimes(2); + expect((jwt.verify as jest.Mock).mock.calls[0][1]).toBe('first-public-key'); + expect((jwt.verify as jest.Mock).mock.calls[1][1]).toBe('second-public-key'); + expect(req.user).toMatchObject({ id: 'uid123', email: 'agent@test.com' }); + expect(mockNext).toHaveBeenCalledWith(); + }); + + it('attaches federatedTokens with access_token and expires_at', async () => { + const exp = 1234567890; + setupOidcMocks({ sub: 'sub123', email: 'agent@test.com', exp }); + const deps = makeDeps(); + const req = makeReq({ authorization: `Bearer ${FAKE_TOKEN}` }); + + await createRemoteAgentAuth(deps)(req as Request, makeRes().res, mockNext); + + expect((req.user as IUser).federatedTokens).toEqual({ + access_token: FAKE_TOKEN, + expires_at: exp, + }); + }); + + it('omits federatedTokens expires_at when exp is absent', async () => { + setupOidcMocks({ sub: 'sub123', email: 'agent@test.com' }); + const deps = makeDeps(); + const req = makeReq({ authorization: `Bearer ${FAKE_TOKEN}` }); + + await createRemoteAgentAuth(deps)(req as Request, makeRes().res, mockNext); + + expect((req.user as IUser).federatedTokens).toEqual({ + access_token: FAKE_TOKEN, + }); + }); + + it('falls back to apiKeyMiddleware when user is not found and apiKey is enabled', async () => { + setupOidcMocks({ sub: 'sub123', email: 'agent@test.com' }); + + const deps = makeDeps(makeConfig({}, { enabled: true })); + deps.findUser.mockResolvedValue(null); + const { res } = makeRes(); + + await createRemoteAgentAuth(deps)( + makeReq({ authorization: `Bearer ${FAKE_TOKEN}` }) as Request, + res, + mockNext, + ); + + expect(deps.apiKeyMiddleware).toHaveBeenCalled(); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('no matching LibreChat user'), + ); + }); + + it('returns 401 when user is not found and apiKey is disabled', async () => { + setupOidcMocks({ sub: 'sub123', email: 'agent@test.com' }); + + const deps = makeDeps(makeConfig({}, { enabled: false })); + deps.findUser.mockResolvedValue(null); + const { res, status, json } = makeRes(); + + await createRemoteAgentAuth(deps)( + makeReq({ authorization: `Bearer ${FAKE_TOKEN}` }) as Request, + res, + mockNext, + ); + + expect(status).toHaveBeenCalledWith(401); + expect(json).toHaveBeenCalledWith({ error: 'Unauthorized' }); + expect(mockNext).not.toHaveBeenCalled(); + }); + + it('does not resolve a colliding legacy openidId from a different issuer', async () => { + const issuer = 'https://issuer-b.example.com'; + setupOidcMocks({ sub: 'shared-sub', email: 'attacker@example.com' }); + + const deps = makeDeps(makeConfig({ issuer }, { enabled: false })); + deps.findUser = makeFindUser( + makeUser({ + email: 'victim@example.com', + openidId: 'shared-sub', + openidIssuer: undefined, + }), + ); + const req = makeReq({ authorization: `Bearer ${FAKE_TOKEN}` }); + const { res, status, json } = makeRes(); + + await createRemoteAgentAuth(deps)(req as Request, res, mockNext); + + expect(status).toHaveBeenCalledWith(401); + expect(json).toHaveBeenCalledWith({ error: 'Unauthorized' }); + expect(req.user).toBeUndefined(); + expect(mockNext).not.toHaveBeenCalled(); + expect(deps.apiKeyMiddleware).not.toHaveBeenCalled(); + }); + + it('returns 401 without user lookup when sub claim is missing', async () => { + setupOidcMocks({ email: 'agent@test.com' }); + + const deps = makeDeps(makeConfig({}, { enabled: true })); + const { res, status, json } = makeRes(); + + await createRemoteAgentAuth(deps)( + makeReq({ authorization: `Bearer ${FAKE_TOKEN}` }) as Request, + res, + mockNext, + ); + + expect(status).toHaveBeenCalledWith(401); + expect(json).toHaveBeenCalledWith({ error: 'Unauthorized' }); + expect(findOpenIDUser).not.toHaveBeenCalled(); + expect(deps.apiKeyMiddleware).not.toHaveBeenCalled(); + expect(mockNext).not.toHaveBeenCalled(); + }); + + it('returns 401 without API key fallback when OpenID user resolution is rejected', async () => { + setupOidcMocks({ sub: 'sub123', email: 'agent@test.com' }); + + const deps = makeDeps(makeConfig({}, { enabled: true })); + deps.findUser + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(makeUser({ provider: 'google', openidId: undefined })); + const { res, status, json } = makeRes(); + + await createRemoteAgentAuth(deps)( + makeReq({ authorization: `Bearer ${FAKE_TOKEN}` }) as Request, + res, + mockNext, + ); + + expect(status).toHaveBeenCalledWith(401); + expect(json).toHaveBeenCalledWith({ error: 'Unauthorized' }); + expect(deps.apiKeyMiddleware).not.toHaveBeenCalled(); + expect(mockNext).not.toHaveBeenCalled(); + }); + }); + + describe('when OIDC verification fails', () => { + beforeEach(() => { + (jwt.decode as jest.Mock).mockReturnValue({ header: { kid: 'kid' }, payload: {} }); + mockGetSigningKey.mockRejectedValue(new Error('Signing key not found')); + }); + + it('falls back to apiKeyMiddleware when apiKey is enabled', async () => { + const deps = makeDeps(makeConfig({}, { enabled: true })); + const { res } = makeRes(); + + await createRemoteAgentAuth(deps)( + makeReq({ authorization: `Bearer ${FAKE_TOKEN}` }) as Request, + res, + mockNext, + ); + + expect(deps.apiKeyMiddleware).toHaveBeenCalled(); + expect(logger.error).not.toHaveBeenCalled(); + expect(logger.debug).toHaveBeenCalledWith( + expect.stringContaining('trying API key auth'), + expect.any(Error), + ); + }); + + it('returns 401 when apiKey is disabled', async () => { + const deps = makeDeps(makeConfig({}, { enabled: false })); + const { res, status, json } = makeRes(); + + await createRemoteAgentAuth(deps)( + makeReq({ authorization: `Bearer ${FAKE_TOKEN}` }) as Request, + res, + mockNext, + ); + + expect(status).toHaveBeenCalledWith(401); + expect(json).toHaveBeenCalledWith({ error: 'Unauthorized' }); + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining('OIDC verification failed'), + expect.any(Error), + ); + }); + + it('returns 401 when JWT cannot be decoded', async () => { + (jwt.decode as jest.Mock).mockReturnValue(null); + const deps = makeDeps(makeConfig({}, { enabled: false })); + const { res, status } = makeRes(); + + await createRemoteAgentAuth(deps)( + makeReq({ authorization: 'Bearer not.a.jwt' }) as Request, + res, + mockNext, + ); + + expect(status).toHaveBeenCalledWith(401); + }); + + it('returns 401 when verifier rejects a HMAC-signed token', async () => { + const payload = { sub: 'sub123', email: 'agent@test.com' }; + setupOidcMocks(payload); + (jwt.decode as jest.Mock).mockReturnValue({ + header: { alg: 'HS256', kid: 'test-kid' }, + payload, + }); + (jwt.verify as jest.Mock).mockImplementation( + (_t: string, _k: string, _options: VerifyOptions, cb: JwtVerifyCallback) => { + cb(new Error('invalid algorithm')); + }, + ); + + const deps = makeDeps(makeConfig({}, { enabled: false })); + const { res, status, json } = makeRes(); + + await createRemoteAgentAuth(deps)( + makeReq({ authorization: `Bearer ${FAKE_TOKEN}` }) as Request, + res, + mockNext, + ); + + expect(status).toHaveBeenCalledWith(401); + expect(json).toHaveBeenCalledWith({ error: 'Unauthorized' }); + expect((jwt.verify as jest.Mock).mock.calls[0][2]).toEqual( + expect.objectContaining({ + algorithms: expect.not.arrayContaining(['HS256', 'HS384', 'HS512']), + }), + ); + expect(mockNext).not.toHaveBeenCalled(); + }); + }); + + describe('unexpected errors', () => { + it('returns 500 when getAppConfig throws', async () => { + const deps = { + ...makeDeps(), + getAppConfig: jest.fn().mockRejectedValue(new Error('DB down')), + }; + const { res, status, json } = makeRes(); + + await createRemoteAgentAuth(deps)(makeReq() as Request, res, mockNext); + + expect(status).toHaveBeenCalledWith(500); + expect(json).toHaveBeenCalledWith({ error: 'Internal server error' }); + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining('Unexpected error'), + expect.any(Error), + ); + }); + + it('returns 500 when findOpenIDUser throws', async () => { + setupOidcMocks({ sub: 'sub123', email: 'agent@test.com' }); + mockFindOpenIDUser.mockRejectedValue(new Error('DB error')); + + const deps = makeDeps(makeConfig({}, { enabled: true })); + const { res, status, json } = makeRes(); + + await createRemoteAgentAuth(deps)( + makeReq({ authorization: `Bearer ${FAKE_TOKEN}` }) as Request, + res, + mockNext, + ); + + expect(status).toHaveBeenCalledWith(500); + expect(json).toHaveBeenCalledWith({ error: 'Internal server error' }); + expect(deps.apiKeyMiddleware).not.toHaveBeenCalled(); + }); + }); + + describe('JWKS URI resolution', () => { + beforeEach(() => { + setupOidcMocks({ sub: 'sub123', email: 'a@b.com' }); + }); + + it('uses jwksUri from config and skips discovery', async () => { + const deps = makeDeps( + makeConfig({ + jwksUri: 'https://explicit-1.example.com/jwks', + issuer: 'https://issuer-explicit-1.example.com', + }), + ); + + await createRemoteAgentAuth(deps)( + makeReq({ authorization: `Bearer ${FAKE_TOKEN}` }) as Request, + makeRes().res, + mockNext, + ); + + expect(mockFetch).not.toHaveBeenCalled(); + expect(mockNext).toHaveBeenCalled(); + }); + + it('uses OPENID_JWKS_URL env var and skips discovery', async () => { + process.env.OPENID_JWKS_URL = 'https://env.example.com/jwks'; + const deps = makeDeps( + makeConfig({ jwksUri: undefined, issuer: 'https://issuer-env-1.example.com' }), + ); + + await createRemoteAgentAuth(deps)( + makeReq({ authorization: `Bearer ${FAKE_TOKEN}` }) as Request, + makeRes().res, + mockNext, + ); + + expect(mockFetch).not.toHaveBeenCalled(); + expect(mockNext).toHaveBeenCalled(); + }); + + it('rejects insecure OPENID_JWKS_URL values outside localhost', async () => { + process.env.OPENID_JWKS_URL = 'http://env.example.com/jwks'; + const deps = makeDeps( + makeConfig( + { jwksUri: undefined, issuer: 'https://issuer-env-insecure.example.com' }, + { enabled: false }, + ), + ); + const { res, status } = makeRes(); + + await createRemoteAgentAuth(deps)( + makeReq({ authorization: `Bearer ${FAKE_TOKEN}` }) as Request, + res, + mockNext, + ); + + expect(status).toHaveBeenCalledWith(401); + expect(mockFetch).not.toHaveBeenCalled(); + expect(jwksRsa).not.toHaveBeenCalled(); + expect(mockNext).not.toHaveBeenCalled(); + }); + + it('rejects insecure issuer values outside localhost before JWKS resolution', async () => { + process.env.OPENID_JWKS_URL = 'https://env.example.com/jwks'; + const deps = makeDeps( + makeConfig( + { jwksUri: undefined, issuer: 'http://issuer-env-insecure.example.com' }, + { enabled: false }, + ), + ); + const { res, status } = makeRes(); + + await createRemoteAgentAuth(deps)( + makeReq({ authorization: `Bearer ${FAKE_TOKEN}` }) as Request, + res, + mockNext, + ); + + expect(status).toHaveBeenCalledWith(401); + expect(mockFetch).not.toHaveBeenCalled(); + expect(jwksRsa).not.toHaveBeenCalled(); + expect(mockNext).not.toHaveBeenCalled(); + }); + + it('allows localhost HTTP OPENID_JWKS_URL values for development', async () => { + process.env.OPENID_JWKS_URL = 'http://localhost:8080/jwks'; + const deps = makeDeps( + makeConfig({ jwksUri: undefined, issuer: 'http://localhost:8080/realms/test' }), + ); + + await createRemoteAgentAuth(deps)( + makeReq({ authorization: `Bearer ${FAKE_TOKEN}` }) as Request, + makeRes().res, + mockNext, + ); + + expect(mockFetch).not.toHaveBeenCalled(); + expect(jwksRsa).toHaveBeenCalledWith( + expect.objectContaining({ jwksUri: 'http://localhost:8080/jwks' }), + ); + expect(mockNext).toHaveBeenCalled(); + }); + + it('fetches discovery document when jwksUri and env var are absent', async () => { + const issuer = 'https://issuer-discovery-1.example.com'; + + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ jwks_uri: `${issuer}/protocol/openid-connect/certs` }), + }); + + const deps = makeDeps(makeConfig({ jwksUri: undefined, issuer })); + + await createRemoteAgentAuth(deps)( + makeReq({ authorization: `Bearer ${FAKE_TOKEN}` }) as Request, + makeRes().res, + mockNext, + ); + + expect(mockFetch).toHaveBeenCalledWith( + `${issuer}/.well-known/openid-configuration`, + expect.objectContaining({ signal: expect.any(Object) }), + ); + expect(mockNext).toHaveBeenCalled(); + }); + + it('normalizes discovery document issuer URL for discovery and issuer validation', async () => { + const issuer = 'https://issuer-discovery-url.example.com/.well-known/openid-configuration'; + const normalizedIssuer = 'https://issuer-discovery-url.example.com'; + + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ jwks_uri: `${normalizedIssuer}/protocol/openid-connect/certs` }), + }); + + const deps = makeDeps(makeConfig({ jwksUri: undefined, issuer })); + + await createRemoteAgentAuth(deps)( + makeReq({ authorization: `Bearer ${FAKE_TOKEN}` }) as Request, + makeRes().res, + mockNext, + ); + + expect(mockFetch).toHaveBeenCalledWith( + `${normalizedIssuer}/.well-known/openid-configuration`, + expect.objectContaining({ signal: expect.any(Object) }), + ); + expect((jwt.verify as jest.Mock).mock.calls[0][2]).toEqual( + expect.objectContaining({ issuer: [issuer, normalizedIssuer] }), + ); + expect(mockNext).toHaveBeenCalled(); + }); + + it('rejects insecure JWKS URIs returned by discovery', async () => { + const issuer = 'https://issuer-discovery-insecure.example.com'; + + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ jwks_uri: 'http://issuer-discovery-insecure.example.com/jwks' }), + }); + + const deps = makeDeps(makeConfig({ jwksUri: undefined, issuer }, { enabled: false })); + const { res, status } = makeRes(); + + await createRemoteAgentAuth(deps)( + makeReq({ authorization: `Bearer ${FAKE_TOKEN}` }) as Request, + res, + mockNext, + ); + + expect(status).toHaveBeenCalledWith(401); + expect(jwksRsa).not.toHaveBeenCalled(); + expect(mockNext).not.toHaveBeenCalled(); + }); + + it('uses a proxy agent for discovery when PROXY is set', async () => { + process.env.PROXY = 'http://proxy.example.com'; + const issuer = 'https://issuer-proxy.example.com'; + + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ jwks_uri: `${issuer}/protocol/openid-connect/certs` }), + }); + + const deps = makeDeps(makeConfig({ jwksUri: undefined, issuer })); + + await createRemoteAgentAuth(deps)( + makeReq({ authorization: `Bearer ${FAKE_TOKEN}` }) as Request, + makeRes().res, + mockNext, + ); + + expect(mockProxyAgent).toHaveBeenCalledWith('http://proxy.example.com'); + expect(mockFetch).toHaveBeenCalledWith( + `${issuer}/.well-known/openid-configuration`, + expect.objectContaining({ dispatcher: { proxy: 'http://proxy.example.com' } }), + ); + }); + + it('caches JWKS clients by resolved URI', async () => { + process.env.OPENID_JWKS_URL = 'https://env-one.example.com/jwks'; + const deps = makeDeps( + makeConfig({ jwksUri: undefined, issuer: 'https://issuer-env-cache.example.com' }), + ); + + await createRemoteAgentAuth(deps)( + makeReq({ authorization: `Bearer ${FAKE_TOKEN}` }) as Request, + makeRes().res, + mockNext, + ); + + process.env.OPENID_JWKS_URL = 'https://env-two.example.com/jwks'; + + await createRemoteAgentAuth(deps)( + makeReq({ authorization: `Bearer ${FAKE_TOKEN}` }) as Request, + makeRes().res, + mockNext, + ); + + expect(jwksRsa).toHaveBeenCalledWith( + expect.objectContaining({ jwksUri: 'https://env-one.example.com/jwks' }), + ); + expect(jwksRsa).toHaveBeenCalledWith( + expect.objectContaining({ jwksUri: 'https://env-two.example.com/jwks' }), + ); + }); + + it('honors disabled JWKS caching', async () => { + process.env.OPENID_JWKS_URL_CACHE_ENABLED = 'false'; + const deps = makeDeps( + makeConfig({ + jwksUri: 'https://cache-disabled.example.com/jwks', + issuer: 'https://issuer-cache-disabled.example.com', + }), + ); + + await createRemoteAgentAuth(deps)( + makeReq({ authorization: `Bearer ${FAKE_TOKEN}` }) as Request, + makeRes().res, + mockNext, + ); + + await createRemoteAgentAuth(deps)( + makeReq({ authorization: `Bearer ${FAKE_TOKEN}` }) as Request, + makeRes().res, + mockNext, + ); + + expect(jwksRsa).toHaveBeenCalledTimes(2); + }); + + it('evicts the oldest JWKS client entry when the cache exceeds its limit', async () => { + const runRequest = async (index: number) => { + const deps = makeDeps( + makeConfig({ + jwksUri: `https://cache-${index}.example.com/jwks`, + issuer: `https://issuer-cache-${index}.example.com`, + }), + ); + + await createRemoteAgentAuth(deps)( + makeReq({ authorization: `Bearer ${FAKE_TOKEN}` }) as Request, + makeRes().res, + mockNext, + ); + }; + + for (let i = 0; i < 101; i++) { + await runRequest(i); + } + await runRequest(0); + + expect(jwksRsa).toHaveBeenCalledTimes(102); + expect(jwksRsa).toHaveBeenLastCalledWith( + expect.objectContaining({ jwksUri: 'https://cache-0.example.com/jwks' }), + ); + }); + + it('prunes expired JWKS client entries before evicting valid entries', async () => { + const nowSpy = jest.spyOn(Date, 'now').mockReturnValue(0); + const runRequest = async (key: string) => { + const deps = makeDeps( + makeConfig({ + jwksUri: `https://cache-prune-${key}.example.com/jwks`, + issuer: `https://issuer-cache-prune-${key}.example.com`, + }), + ); + + await createRemoteAgentAuth(deps)( + makeReq({ authorization: `Bearer ${FAKE_TOKEN}` }) as Request, + makeRes().res, + mockNext, + ); + }; + + try { + mockMath.mockReturnValue(120000); + await runRequest('keeper'); + + mockMath.mockReturnValue(1000); + for (let i = 0; i < 99; i++) { + await runRequest(`expired-${i}`); + } + + nowSpy.mockReturnValue(2000); + mockMath.mockReturnValue(60000); + await runRequest('new'); + + expect(jwksRsa).toHaveBeenCalledTimes(101); + + await runRequest('keeper'); + + expect(jwksRsa).toHaveBeenCalledTimes(101); + } finally { + nowSpy.mockRestore(); + } + }); + + it('aborts discovery when the timeout expires', async () => { + jest.useFakeTimers(); + + try { + mockFetch.mockImplementation( + (_url: string, init?: RequestInit) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => reject(new Error('aborted'))); + }), + ); + + const deps = makeDeps( + makeConfig( + { jwksUri: undefined, issuer: 'https://issuer-discovery-timeout.example.com' }, + { enabled: false }, + ), + ); + const { res, status } = makeRes(); + const request = createRemoteAgentAuth(deps)( + makeReq({ authorization: `Bearer ${FAKE_TOKEN}` }) as Request, + res, + mockNext, + ); + + await Promise.resolve(); + jest.advanceTimersByTime(10000); + await request; + + expect(status).toHaveBeenCalledWith(401); + expect(mockNext).not.toHaveBeenCalled(); + } finally { + jest.useRealTimers(); + } + }); + + it('returns 401 when discovery returns non-ok response', async () => { + mockFetch.mockResolvedValue({ ok: false, status: 404, statusText: 'Not Found' }); + + const deps = makeDeps( + makeConfig( + { jwksUri: undefined, issuer: 'https://issuer-discovery-fail-1.example.com' }, + { enabled: false }, + ), + ); + const { res, status } = makeRes(); + + await createRemoteAgentAuth(deps)( + makeReq({ authorization: `Bearer ${FAKE_TOKEN}` }) as Request, + res, + mockNext, + ); + + expect(status).toHaveBeenCalledWith(401); + }); + + it('returns 401 when discovery response is missing jwks_uri field', async () => { + mockFetch.mockResolvedValue({ ok: true, json: async () => ({}) }); + + const deps = makeDeps( + makeConfig( + { jwksUri: undefined, issuer: 'https://issuer-missing-jwks-1.example.com' }, + { enabled: false }, + ), + ); + const { res, status } = makeRes(); + + await createRemoteAgentAuth(deps)( + makeReq({ authorization: `Bearer ${FAKE_TOKEN}` }) as Request, + res, + mockNext, + ); + + expect(status).toHaveBeenCalledWith(401); + }); + }); + + describe('email claim resolution', () => { + async function captureEmailArg(claims: JwtPayload): Promise { + setupOidcMocks(claims); + + const deps = makeDeps(); + deps.findUser + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(makeUser({ email: getOpenIdEmail(claims), openidId: claims.sub })); + await createRemoteAgentAuth(deps)( + makeReq({ authorization: `Bearer ${FAKE_TOKEN}` }) as Request, + makeRes().res, + mockNext, + ); + + return (deps.findUser.mock.calls[1]?.[0] as { email?: string } | undefined)?.email; + } + + it('uses email claim', async () => { + expect(await captureEmailArg({ sub: 's1', email: 'user@example.com' })).toBe( + 'user@example.com', + ); + }); + + it('falls back to preferred_username when email is absent', async () => { + expect(await captureEmailArg({ sub: 's2', preferred_username: 'agent-user' })).toBe( + 'agent-user', + ); + }); + + it('falls back to preferred_username when email is empty', async () => { + expect( + await captureEmailArg({ + sub: 's2-empty', + email: '', + preferred_username: 'agent-user', + }), + ).toBe('agent-user'); + }); + + it('falls back to upn when email and preferred_username are absent', async () => { + expect(await captureEmailArg({ sub: 's3', upn: 'upn@corp.com' })).toBe('upn@corp.com'); + }); + + it('uses OPENID_EMAIL_CLAIM when configured', async () => { + process.env.OPENID_EMAIL_CLAIM = 'custom_identifier'; + + expect( + await captureEmailArg({ + sub: 's4', + email: 'user@example.com', + custom_identifier: 'agent@corp.example.com', + }), + ).toBe('agent@corp.example.com'); + }); + }); + + describe('update user and migration scenarios', () => { + it('persists openidId binding when migration is needed', async () => { + const mockUpdateUser = jest.fn().mockResolvedValue(undefined); + setupOidcMocks({ sub: 'sub-new', email: 'existing@test.com' }); + + const deps = { ...makeDeps(), updateUser: mockUpdateUser }; + deps.findUser.mockResolvedValueOnce(null).mockResolvedValueOnce( + makeUser({ + email: 'existing@test.com', + openidId: undefined, + provider: undefined, + role: 'user', + }), + ); + await createRemoteAgentAuth(deps)( + makeReq({ authorization: `Bearer ${FAKE_TOKEN}` }) as Request, + makeRes().res, + mockNext, + ); + + expect(mockUpdateUser).toHaveBeenCalledWith( + mockUser.id, + expect.objectContaining({ + provider: 'openid', + openidId: 'sub-new', + openidIssuer: BASE_ISSUER, + }), + ); + expect(mockNext).toHaveBeenCalled(); + }); + + it('returns 500 when migration update fails', async () => { + const mockUpdateUser = jest.fn().mockRejectedValue(new Error('DB write failed')); + setupOidcMocks({ sub: 'sub-new', email: 'existing@test.com' }); + + const deps = { ...makeDeps(makeConfig({}, { enabled: true })), updateUser: mockUpdateUser }; + deps.findUser.mockResolvedValueOnce(null).mockResolvedValueOnce( + makeUser({ + email: 'existing@test.com', + openidId: undefined, + provider: undefined, + role: 'user', + }), + ); + const { res, status, json } = makeRes(); + + await createRemoteAgentAuth(deps)( + makeReq({ authorization: `Bearer ${FAKE_TOKEN}` }) as Request, + res, + mockNext, + ); + + expect(status).toHaveBeenCalledWith(500); + expect(json).toHaveBeenCalledWith({ error: 'Internal server error' }); + expect(deps.apiKeyMiddleware).not.toHaveBeenCalled(); + expect(mockNext).not.toHaveBeenCalled(); + }); + + it('does not call updateUser when migration is false and role exists', async () => { + const mockUpdateUser = jest.fn(); + setupOidcMocks({ sub: 'sub123', email: 'agent@test.com' }); + + const deps = { ...makeDeps(), updateUser: mockUpdateUser }; + await createRemoteAgentAuth(deps)( + makeReq({ authorization: `Bearer ${FAKE_TOKEN}` }) as Request, + makeRes().res, + mockNext, + ); + + expect(mockUpdateUser).not.toHaveBeenCalled(); + }); + }); + + describe('scope validation', () => { + it('returns 401 when required scope is missing from token', async () => { + setupOidcMocks({ sub: 'sub123', email: 'agent@test.com', scope: 'openid profile' }); + + const deps = makeDeps(makeConfig({ scope: 'remote_agent' }, { enabled: false })); + const { res, status, json } = makeRes(); + + await createRemoteAgentAuth(deps)( + makeReq({ authorization: `Bearer ${FAKE_TOKEN}` }) as Request, + res, + mockNext, + ); + + expect(status).toHaveBeenCalledWith(401); + expect(json).toHaveBeenCalledWith({ error: 'Unauthorized' }); + }); + + it('does not fall back to apiKeyMiddleware when a verified token is missing scope', async () => { + setupOidcMocks({ sub: 'sub123', email: 'agent@test.com', scope: 'openid profile' }); + + const deps = makeDeps(makeConfig({ scope: 'remote_agent' }, { enabled: true })); + const { res, status, json } = makeRes(); + + await createRemoteAgentAuth(deps)( + makeReq({ authorization: `Bearer ${FAKE_TOKEN}` }) as Request, + res, + mockNext, + ); + + expect(status).toHaveBeenCalledWith(401); + expect(json).toHaveBeenCalledWith({ error: 'Unauthorized' }); + expect(deps.apiKeyMiddleware).not.toHaveBeenCalled(); + expect(mockNext).not.toHaveBeenCalled(); + }); + + it('passes when required scope is present in token', async () => { + setupOidcMocks({ + sub: 'sub123', + email: 'agent@test.com', + scope: 'openid profile remote_agent', + }); + + const deps = makeDeps(makeConfig({ scope: 'remote_agent' })); + await createRemoteAgentAuth(deps)( + makeReq({ authorization: `Bearer ${FAKE_TOKEN}` }) as Request, + makeRes().res, + mockNext, + ); + + expect(mockNext).toHaveBeenCalled(); + }); + + it('passes when scp is an array containing the required scope', async () => { + setupOidcMocks({ + sub: 'sub123', + email: 'agent@test.com', + scp: ['openid', 'remote_agent'], + }); + + const deps = makeDeps(makeConfig({ scope: 'remote_agent' })); + await createRemoteAgentAuth(deps)( + makeReq({ authorization: `Bearer ${FAKE_TOKEN}` }) as Request, + makeRes().res, + mockNext, + ); + + expect(mockNext).toHaveBeenCalled(); + }); + + it('passes when all configured scopes are present', async () => { + setupOidcMocks({ + sub: 'sub123', + email: 'agent@test.com', + scp: ['openid', 'remote_agent', 'admin'], + }); + + const deps = makeDeps(makeConfig({ scope: 'remote_agent admin' })); + await createRemoteAgentAuth(deps)( + makeReq({ authorization: `Bearer ${FAKE_TOKEN}` }) as Request, + makeRes().res, + mockNext, + ); + + expect(mockNext).toHaveBeenCalled(); + }); + + it('returns 401 when any configured scope is missing', async () => { + setupOidcMocks({ + sub: 'sub123', + email: 'agent@test.com', + scp: ['openid', 'remote_agent'], + }); + + const deps = makeDeps(makeConfig({ scope: 'remote_agent admin' }, { enabled: false })); + const { res, status, json } = makeRes(); + + await createRemoteAgentAuth(deps)( + makeReq({ authorization: `Bearer ${FAKE_TOKEN}` }) as Request, + res, + mockNext, + ); + + expect(status).toHaveBeenCalledWith(401); + expect(json).toHaveBeenCalledWith({ error: 'Unauthorized' }); + expect(mockNext).not.toHaveBeenCalled(); + }); + + it('treats comma-separated configured scopes as one invalid scope token', async () => { + setupOidcMocks({ + sub: 'sub123', + email: 'agent@test.com', + scope: 'remote_agent admin', + }); + + const deps = makeDeps(makeConfig({ scope: 'remote_agent,admin' }, { enabled: false })); + const { res, status, json } = makeRes(); + + await createRemoteAgentAuth(deps)( + makeReq({ authorization: `Bearer ${FAKE_TOKEN}` }) as Request, + res, + mockNext, + ); + + expect(status).toHaveBeenCalledWith(401); + expect(json).toHaveBeenCalledWith({ error: 'Unauthorized' }); + expect(mockNext).not.toHaveBeenCalled(); + }); + + it('passes when scope is not configured (backward compat)', async () => { + setupOidcMocks({ sub: 'sub123', email: 'agent@test.com' }); + + const deps = makeDeps(makeConfig({ scope: undefined })); + await createRemoteAgentAuth(deps)( + makeReq({ authorization: `Bearer ${FAKE_TOKEN}` }) as Request, + makeRes().res, + mockNext, + ); + + expect(mockNext).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/api/src/middleware/remoteAgentAuth.ts b/packages/api/src/middleware/remoteAgentAuth.ts new file mode 100644 index 000000000000..ab06583c4149 --- /dev/null +++ b/packages/api/src/middleware/remoteAgentAuth.ts @@ -0,0 +1,562 @@ +import jwt from 'jsonwebtoken'; +import jwksRsa from 'jwks-rsa'; +import { HttpsProxyAgent } from 'https-proxy-agent'; +import { ProxyAgent, fetch as undiciFetch } from 'undici'; +import { getTenantId, logger } from '@librechat/data-schemas'; +import { SystemRoles, isRemoteOidcUrlAllowed } from 'librechat-data-provider'; +import type { RequestHandler, Request, Response, NextFunction } from 'express'; +import type { AppConfig, IUser, UserMethods } from '@librechat/data-schemas'; +import type { Algorithm, JwtPayload, VerifyOptions } from 'jsonwebtoken'; +import type { TAgentsEndpoint } from 'librechat-data-provider'; +import type { RequestInit } from 'undici'; +import type { GetAppConfigOptions } from '../app/service'; +import { findOpenIDUser, getOpenIdEmail, normalizeOpenIdIssuer } from '../auth/openid'; +import { isEnabled, math } from '~/utils'; + +export interface RemoteAgentAuthDeps { + apiKeyMiddleware: RequestHandler; + findUser: UserMethods['findUser']; + updateUser: UserMethods['updateUser']; + getAppConfig: (options?: GetAppConfigOptions) => Promise; +} + +type OidcConfig = NonNullable< + NonNullable['auth']>['oidc'] +>; + +type AgentAuthConfig = NonNullable['auth']>; +type EnabledOidcConfig = OidcConfig & { issuer: string }; +type JwksCacheOptions = { + enabled: boolean; + maxAge: number; +}; +type CacheEntry = { + expiresAt: number; + promise: Promise; +}; +type ScopeClaim = string | string[] | undefined; +type UserResolution = + | { status: 'resolved'; user: IUser; updateData: Partial } + | { status: 'missing' } + | { status: 'rejected'; error: string }; + +const OIDC_DISCOVERY_TIMEOUT_MS = 10000; +const MAX_JWKS_CACHE_ENTRIES = 100; +const JWT_ALGORITHMS: Algorithm[] = [ + 'RS256', + 'RS384', + 'RS512', + 'PS256', + 'PS384', + 'PS512', + 'ES256', + 'ES384', + 'ES512', +]; +const jwksUriCache = new Map>(); +const jwksClientCache = new Map>(); + +export function clearRemoteAgentAuthCache(): void { + jwksUriCache.clear(); + jwksClientCache.clear(); +} + +function pruneExpiredEntries(cache: Map>): void { + const now = Date.now(); + for (const [key, entry] of cache) { + if (entry.expiresAt <= now) cache.delete(key); + } +} + +function setCacheEntry( + cache: Map>, + key: string, + entry: CacheEntry, +): void { + pruneExpiredEntries(cache); + + while (cache.size >= MAX_JWKS_CACHE_ENTRIES) { + const oldestKey = cache.keys().next().value; + if (oldestKey == null) break; + cache.delete(oldestKey); + } + + cache.set(key, entry); +} + +function extractBearer(authHeader: string | undefined): string | null { + const match = authHeader?.match(/^Bearer\s+(\S+)\s*$/i); + return match?.[1] ?? null; +} + +function splitScopes(scopes: string): string[] { + return scopes.trim().split(/\s+/).filter(Boolean); +} + +function getTokenScopes(scopeClaim: ScopeClaim): string[] { + if (Array.isArray(scopeClaim)) return scopeClaim.flatMap(splitScopes); + return scopeClaim ? splitScopes(scopeClaim) : []; +} + +function hasRequiredScopes(requiredScope: string | undefined, payload: JwtPayload): boolean { + if (!requiredScope) return true; + + const requiredScopes = splitScopes(requiredScope); + if (requiredScopes.length === 0) return true; + + const rawScope = (payload['scp'] ?? payload['scope']) as ScopeClaim; + const tokenScopes = getTokenScopes(rawScope); + return requiredScopes.every((scope) => tokenScopes.includes(scope)); +} + +function getJwksCacheOptions(): JwksCacheOptions { + return { + enabled: process.env.OPENID_JWKS_URL_CACHE_ENABLED + ? isEnabled(process.env.OPENID_JWKS_URL_CACHE_ENABLED) + : true, + maxAge: Math.max(math(process.env.OPENID_JWKS_URL_CACHE_TIME, 60000), 0), + }; +} + +function buildDiscoveryOptions(controller: AbortController): RequestInit { + const options: RequestInit = { signal: controller.signal }; + + if (process.env.PROXY) { + options.dispatcher = new ProxyAgent(process.env.PROXY); + } + + return options; +} + +function ensureRemoteOidcUrlAllowed(value: string, label: string): string { + if (isRemoteOidcUrlAllowed(value)) return value; + throw new Error(`${label} must use https:// unless targeting localhost`); +} + +async function discoverJwksUri(issuer: string): Promise { + const normalizedIssuer = normalizeOpenIdIssuer(ensureRemoteOidcUrlAllowed(issuer, 'OIDC issuer')); + if (!normalizedIssuer) throw new Error('OIDC issuer is required'); + + const discoveryUrl = `${normalizedIssuer}/.well-known/openid-configuration`; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), OIDC_DISCOVERY_TIMEOUT_MS); + + try { + const res = await undiciFetch(discoveryUrl, buildDiscoveryOptions(controller)); + if (!res.ok) throw new Error(`OIDC discovery failed: ${res.status} ${res.statusText}`); + + const meta = (await res.json()) as { jwks_uri?: string }; + if (!meta.jwks_uri) throw new Error('OIDC discovery response missing jwks_uri'); + + return ensureRemoteOidcUrlAllowed(meta.jwks_uri, 'OIDC JWKS URI'); + } finally { + clearTimeout(timeout); + } +} + +async function resolveJwksUri( + oidcConfig: EnabledOidcConfig, + cacheOptions: JwksCacheOptions, +): Promise { + if (oidcConfig.jwksUri) return ensureRemoteOidcUrlAllowed(oidcConfig.jwksUri, 'OIDC JWKS URI'); + if (process.env.OPENID_JWKS_URL) { + return ensureRemoteOidcUrlAllowed(process.env.OPENID_JWKS_URL, 'OIDC JWKS URI'); + } + + if (!cacheOptions.enabled) return discoverJwksUri(oidcConfig.issuer); + + const cacheKey = oidcConfig.issuer; + const cached = jwksUriCache.get(cacheKey); + if (cached != null && cached.expiresAt > Date.now()) return cached.promise; + if (cached != null) jwksUriCache.delete(cacheKey); + + const promise = discoverJwksUri(oidcConfig.issuer).catch((err) => { + jwksUriCache.delete(cacheKey); + throw err; + }); + + setCacheEntry(jwksUriCache, cacheKey, { + promise, + expiresAt: Date.now() + cacheOptions.maxAge, + }); + return promise; +} + +function buildJwksClient(uri: string, cacheOptions: JwksCacheOptions): jwksRsa.JwksClient { + const options: jwksRsa.Options = { + cache: cacheOptions.enabled, + cacheMaxAge: cacheOptions.maxAge, + jwksUri: uri, + }; + + if (process.env.PROXY) { + options.requestAgent = new HttpsProxyAgent(process.env.PROXY); + } + + return jwksRsa(options); +} + +async function getJwksClient(oidcConfig: EnabledOidcConfig): Promise { + const cacheOptions = getJwksCacheOptions(); + const uri = await resolveJwksUri(oidcConfig, cacheOptions); + + if (!cacheOptions.enabled) return buildJwksClient(uri, cacheOptions); + + const cacheKey = uri; + const cached = jwksClientCache.get(cacheKey); + if (cached != null && cached.expiresAt > Date.now()) return cached.promise; + if (cached != null) jwksClientCache.delete(cacheKey); + + let client: jwksRsa.JwksClient; + try { + client = buildJwksClient(uri, cacheOptions); + } catch (err) { + jwksClientCache.delete(cacheKey); + throw err; + } + + const promise = Promise.resolve(client); + + setCacheEntry(jwksClientCache, cacheKey, { + promise, + expiresAt: Date.now() + cacheOptions.maxAge, + }); + return promise; +} + +function getVerifyOptions(oidcConfig: EnabledOidcConfig): VerifyOptions { + const normalizedIssuer = normalizeOpenIdIssuer(oidcConfig.issuer); + const issuer = + normalizedIssuer && normalizedIssuer !== oidcConfig.issuer + ? [oidcConfig.issuer, normalizedIssuer] + : oidcConfig.issuer; + + return { + algorithms: JWT_ALGORITHMS, + issuer, + ...(oidcConfig.audience ? { audience: oidcConfig.audience } : {}), + }; +} + +function getConfigOptions(req: Request): GetAppConfigOptions { + const user = req.user as { tenantId?: string } | undefined; + const tenantId = user?.tenantId ?? getTenantId(); + + if (tenantId) return { tenantId }; + return { baseOnly: true }; +} + +function getUserConfigOptions(user: IUser): GetAppConfigOptions { + if (user.tenantId) return { tenantId: user.tenantId }; + return { baseOnly: true }; +} + +function isResolvedUserConfigScope(initialOptions: GetAppConfigOptions, user: IUser): boolean { + const userOptions = getUserConfigOptions(user); + return ( + initialOptions.tenantId === userOptions.tenantId && + initialOptions.baseOnly === userOptions.baseOnly + ); +} + +function getRemoteAuthConfig(config: AppConfig): AgentAuthConfig | undefined { + return config.endpoints?.agents?.remoteApi?.auth; +} + +function getEnabledOidcConfig( + authConfig: AgentAuthConfig | undefined, +): EnabledOidcConfig | undefined { + if (authConfig?.oidc?.enabled !== true) return undefined; + if (!authConfig.oidc.issuer) throw new Error('OIDC issuer is required when OIDC auth is enabled'); + return { ...authConfig.oidc, issuer: authConfig.oidc.issuer }; +} + +function isApiKeyEnabled(config: AppConfig): boolean { + return getRemoteAuthConfig(config)?.apiKey?.enabled !== false; +} + +async function enforceApiKeyTenantPolicy( + req: Request, + res: Response, + next: NextFunction, + getAppConfig: RemoteAgentAuthDeps['getAppConfig'], +): Promise { + const config = await getAppConfig(getConfigOptions(req)); + + if (!isApiKeyEnabled(config)) { + logger.warn('[remoteAgentAuth] API key rejected by resolved tenant auth policy'); + res.status(401).json({ error: 'Unauthorized' }); + return; + } + + next(); +} + +async function runApiKeyAuth( + req: Request, + res: Response, + next: NextFunction, + apiKeyMiddleware: RequestHandler, + getAppConfig: RemoteAgentAuthDeps['getAppConfig'], +): Promise { + let postAuth: Promise | undefined; + + const wrappedNext: NextFunction = (err?: unknown) => { + if (err != null) { + next(err); + return; + } + + postAuth = enforceApiKeyTenantPolicy(req, res, next, getAppConfig); + }; + + await Promise.resolve(apiKeyMiddleware(req, res, wrappedNext)); + if (postAuth) await postAuth; +} + +async function enforceOidcTenantPolicy( + token: string, + user: IUser, + initialOptions: GetAppConfigOptions, + getAppConfig: RemoteAgentAuthDeps['getAppConfig'], +): Promise { + if (isResolvedUserConfigScope(initialOptions, user)) return true; + + const config = await getAppConfig(getUserConfigOptions(user)); + const oidcConfig = getEnabledOidcConfig(getRemoteAuthConfig(config)); + if (!oidcConfig) { + logger.warn('[remoteAgentAuth] OIDC rejected by resolved tenant auth policy'); + return false; + } + + try { + const payload = await verifyOidcBearer(token, oidcConfig); + if (hasRequiredScopes(oidcConfig.scope, payload)) return true; + logger.warn( + `[remoteAgentAuth] Token missing resolved tenant required scope: ${oidcConfig.scope}`, + ); + } catch (err) { + logger.warn('[remoteAgentAuth] OIDC token rejected by resolved tenant auth policy:', err); + } + + return false; +} + +function verifyJwt( + token: string, + signingKey: jwksRsa.SigningKey, + oidcConfig: EnabledOidcConfig, +): Promise { + return new Promise((resolve, reject) => { + jwt.verify(token, signingKey.getPublicKey(), getVerifyOptions(oidcConfig), (err, payload) => { + if (err != null || payload == null) return reject(err ?? new Error('Empty payload')); + if (typeof payload === 'string') return reject(new Error('Invalid JWT payload')); + resolve(payload); + }); + }); +} + +async function verifyWithSigningKeys( + token: string, + signingKeys: jwksRsa.SigningKey[], + oidcConfig: EnabledOidcConfig, +): Promise { + let lastError: Error | null = null; + + for (const signingKey of signingKeys) { + try { + return await verifyJwt(token, signingKey, oidcConfig); + } catch (err) { + lastError = err instanceof Error ? err : new Error(String(err)); + } + } + + throw lastError ?? new Error('No signing keys in JWKS'); +} + +async function verifyOidcBearer(token: string, oidcConfig: EnabledOidcConfig): Promise { + ensureRemoteOidcUrlAllowed(oidcConfig.issuer, 'OIDC issuer'); + + const decoded = jwt.decode(token, { complete: true }); + if (decoded == null || typeof decoded === 'string') throw new Error('Invalid JWT: cannot decode'); + + const kid = typeof decoded.header?.kid === 'string' ? decoded.header.kid : undefined; + const client = await getJwksClient(oidcConfig); + + if (kid != null) { + const signingKey = await client.getSigningKey(kid); + return verifyJwt(token, signingKey, oidcConfig); + } + + return verifyWithSigningKeys(token, await client.getSigningKeys(), oidcConfig); +} + +async function resolveUser( + token: string, + payload: JwtPayload, + oidcConfig: EnabledOidcConfig, + findUser: UserMethods['findUser'], +): Promise { + if (typeof payload.sub !== 'string' || payload.sub.trim() === '') { + return { status: 'rejected', error: 'missing_sub_claim' }; + } + + const { user, error, migration } = await findOpenIDUser({ + findUser, + email: getOpenIdEmail(payload, 'remoteAgentAuth'), + openidId: payload.sub, + openidIssuer: oidcConfig.issuer, + idOnTheSource: payload['oid'] as string | undefined, + strategyName: 'remoteAgentAuth', + }); + + if (error != null) return { status: 'rejected', error }; + if (user == null) return { status: 'missing' }; + + user.id = String(user._id); + + const updateData: Partial = {}; + + if (migration) { + updateData.provider = 'openid'; + updateData.openidId = payload.sub; + updateData.openidIssuer = normalizeOpenIdIssuer(oidcConfig.issuer); + } + + if (!user.role) { + user.role = SystemRoles.USER; + updateData.role = SystemRoles.USER; + } + + user.federatedTokens = { + access_token: token, + ...(payload.exp != null ? { expires_at: payload.exp } : {}), + }; + return { status: 'resolved', user, updateData }; +} + +/** + * Factory for Remote Agent API auth middleware. + * + * Validates Bearer tokens against configured OIDC issuer via JWKS, + * falling back to API key auth when enabled. Stateless — no session dependency. + * + * ```yaml + * endpoints: + * agents: + * remoteApi: + * auth: + * apiKey: + * enabled: false + * oidc: + * enabled: true + * issuer: + * jwksUri: + * audience: + * scope: + * ``` + */ +export function createRemoteAgentAuth({ + apiKeyMiddleware, + findUser, + updateUser, + getAppConfig, +}: RemoteAgentAuthDeps): RequestHandler { + return async (req: Request, res: Response, next: NextFunction) => { + try { + const initialConfigOptions = getConfigOptions(req); + const config = await getAppConfig(initialConfigOptions); + const authConfig = getRemoteAuthConfig(config); + const apiKeyEnabled = isApiKeyEnabled(config); + + if (authConfig?.oidc?.enabled !== true) { + if (apiKeyEnabled) { + await runApiKeyAuth(req, res, next, apiKeyMiddleware, getAppConfig); + return; + } + res.status(401).json({ error: 'Authentication required' }); + return; + } + + if (!authConfig.oidc.issuer) { + logger.error('[remoteAgentAuth] OIDC issuer is required when OIDC auth is enabled'); + res.status(500).json({ error: 'Internal server error' }); + return; + } + + const oidcConfig = getEnabledOidcConfig(authConfig); + if (!oidcConfig) throw new Error('OIDC issuer is required when OIDC auth is enabled'); + + const token = extractBearer(req.headers.authorization); + if (token == null) { + if (apiKeyEnabled) { + await runApiKeyAuth(req, res, next, apiKeyMiddleware, getAppConfig); + return; + } + res.status(401).json({ error: 'Bearer token required' }); + return; + } + + let payload: JwtPayload; + + try { + payload = await verifyOidcBearer(token, oidcConfig); + if (!hasRequiredScopes(oidcConfig.scope, payload)) { + logger.warn(`[remoteAgentAuth] Token missing required scope: ${oidcConfig.scope}`); + res.status(401).json({ error: 'Unauthorized' }); + return; + } + } catch (oidcErr) { + if (apiKeyEnabled) { + logger.debug('[remoteAgentAuth] OIDC verification failed; trying API key auth:', oidcErr); + await runApiKeyAuth(req, res, next, apiKeyMiddleware, getAppConfig); + return; + } + logger.error('[remoteAgentAuth] OIDC verification failed:', oidcErr); + res.status(401).json({ error: 'Unauthorized' }); + return; + } + + const userResolution = await resolveUser(token, payload, oidcConfig, findUser); + + if (userResolution.status === 'rejected') { + logger.warn(`[remoteAgentAuth] OpenID user rejected: ${userResolution.error}`); + res.status(401).json({ error: 'Unauthorized' }); + return; + } + + if (userResolution.status === 'missing') { + logger.warn('[remoteAgentAuth] OIDC token valid but no matching LibreChat user'); + if (apiKeyEnabled) { + await runApiKeyAuth(req, res, next, apiKeyMiddleware, getAppConfig); + return; + } + res.status(401).json({ error: 'Unauthorized' }); + return; + } + + if ( + !(await enforceOidcTenantPolicy( + token, + userResolution.user, + initialConfigOptions, + getAppConfig, + )) + ) { + res.status(401).json({ error: 'Unauthorized' }); + return; + } + + if (Object.keys(userResolution.updateData).length > 0) { + await updateUser(userResolution.user.id, userResolution.updateData); + } + + req.user = userResolution.user; + return next(); + } catch (err) { + logger.error('[remoteAgentAuth] Unexpected error', err); + res.status(500).json({ error: 'Internal server error' }); + return; + } + }; +} diff --git a/packages/data-provider/specs/config-schemas.spec.ts b/packages/data-provider/specs/config-schemas.spec.ts index d0b5dbb591f0..94f3cc8edb69 100644 --- a/packages/data-provider/specs/config-schemas.spec.ts +++ b/packages/data-provider/specs/config-schemas.spec.ts @@ -352,6 +352,104 @@ describe('agentsEndpointSchema', () => { expect(result.data).not.toHaveProperty('baseURL'); } }); + + it('allows explicitly disabled remote OIDC auth without issuer', () => { + const result = agentsEndpointSchema.safeParse({ + remoteApi: { + auth: { + oidc: { + enabled: false, + }, + }, + }, + }); + + expect(result.success).toBe(true); + }); + + it('requires a valid issuer when remote OIDC auth is enabled', () => { + const missingIssuer = agentsEndpointSchema.safeParse({ + remoteApi: { + auth: { + oidc: { + enabled: true, + }, + }, + }, + }); + const invalidIssuer = agentsEndpointSchema.safeParse({ + remoteApi: { + auth: { + oidc: { + enabled: true, + issuer: 'my-realm', + }, + }, + }, + }); + + expect(missingIssuer.success).toBe(false); + expect(invalidIssuer.success).toBe(false); + }); + + it('requires HTTPS remote OIDC issuer and JWKS URLs outside localhost', () => { + const insecureIssuer = agentsEndpointSchema.safeParse({ + remoteApi: { + auth: { + oidc: { + enabled: true, + issuer: 'http://auth.example.com', + }, + }, + }, + }); + const insecureJwksUri = agentsEndpointSchema.safeParse({ + remoteApi: { + auth: { + oidc: { + enabled: true, + issuer: 'https://auth.example.com', + jwksUri: 'http://auth.example.com/jwks', + }, + }, + }, + }); + + expect(insecureIssuer.success).toBe(false); + expect(insecureJwksUri.success).toBe(false); + }); + + it('allows localhost HTTP remote OIDC URLs for development', () => { + const result = agentsEndpointSchema.safeParse({ + remoteApi: { + auth: { + oidc: { + enabled: true, + issuer: 'http://localhost:8080/realms/test', + jwksUri: 'http://127.0.0.1:8080/realms/test/protocol/openid-connect/certs', + }, + }, + }, + }); + + expect(result.success).toBe(true); + }); + + it('requires space-separated remote OIDC scopes', () => { + const result = agentsEndpointSchema.safeParse({ + remoteApi: { + auth: { + oidc: { + enabled: true, + issuer: 'https://auth.example.com', + scope: 'remote_agent,admin', + }, + }, + }, + }); + + expect(result.success).toBe(false); + }); }); describe('azureEndpointSchema', () => { diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index c3043765b6d0..2c94af57fb8d 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -323,6 +323,61 @@ export const defaultAgentCapabilities = [ AgentCapabilities.ocr, ]; +const LOCAL_REMOTE_OIDC_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]']); + +export function isRemoteOidcUrlAllowed(value: string): boolean { + try { + const url = new URL(value); + if (url.protocol === 'https:') return true; + if (url.protocol !== 'http:') return false; + + const hostname = url.hostname.toLowerCase(); + return LOCAL_REMOTE_OIDC_HOSTS.has(hostname) || hostname.endsWith('.localhost'); + } catch { + return false; + } +} + +const remoteApiOidcUrlSchema = z + .string() + .url() + .refine(isRemoteOidcUrlAllowed, { message: 'must use https:// unless targeting localhost' }); + +const remoteApiOidcScopeSchema = z.string().refine((scope) => !scope.includes(','), { + message: 'scopes must be space-separated', +}); + +const remoteApiOidcSchema = z + .object({ + enabled: z.boolean().default(false), + issuer: remoteApiOidcUrlSchema.optional(), + audience: z.string().optional(), + jwksUri: remoteApiOidcUrlSchema.optional(), + scope: remoteApiOidcScopeSchema.optional(), + }) + .superRefine((oidc, ctx) => { + if (oidc.enabled === true && !oidc.issuer) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['issuer'], + message: 'issuer is required when OIDC auth is enabled', + }); + } + }); + +const remoteApiAuthSchema = z.object({ + apiKey: z + .object({ + enabled: z.boolean().default(true), + }) + .optional(), + oidc: remoteApiOidcSchema.optional(), +}); + +const remoteApiSchema = z.object({ + auth: remoteApiAuthSchema.optional(), +}); + export const agentsEndpointSchema = baseEndpointSchema .omit({ baseURL: true }) .merge( @@ -339,6 +394,7 @@ export const agentsEndpointSchema = baseEndpointSchema .array(z.nativeEnum(AgentCapabilities)) .optional() .default(defaultAgentCapabilities), + remoteApi: remoteApiSchema.optional(), }), ) .default({ diff --git a/packages/data-schemas/src/methods/user.methods.spec.ts b/packages/data-schemas/src/methods/user.methods.spec.ts index 90f9100d1dbc..091cdf4f763e 100644 --- a/packages/data-schemas/src/methods/user.methods.spec.ts +++ b/packages/data-schemas/src/methods/user.methods.spec.ts @@ -37,6 +37,37 @@ beforeEach(async () => { await mongoose.connection.dropDatabase(); }); +describe('User schema indexes', () => { + test('should allow the same OpenID subject from different issuers', async () => { + await User.syncIndexes(); + + await User.create({ + email: 'issuer-a@example.com', + provider: 'openid', + openidId: 'shared-sub', + openidIssuer: 'https://issuer-a.example.com', + }); + + await expect( + User.create({ + email: 'issuer-b@example.com', + provider: 'openid', + openidId: 'shared-sub', + openidIssuer: 'https://issuer-b.example.com', + }), + ).resolves.toBeTruthy(); + + await expect( + User.create({ + email: 'issuer-a-duplicate@example.com', + provider: 'openid', + openidId: 'shared-sub', + openidIssuer: 'https://issuer-a.example.com', + }), + ).rejects.toThrow(/duplicate key/); + }); +}); + describe('User Methods - Database Tests', () => { describe('findUser', () => { test('should find user by exact email', async () => { diff --git a/packages/data-schemas/src/migrations/tenantIndexes.spec.ts b/packages/data-schemas/src/migrations/tenantIndexes.spec.ts index 6a0987d757b3..a2fb2fef0185 100644 --- a/packages/data-schemas/src/migrations/tenantIndexes.spec.ts +++ b/packages/data-schemas/src/migrations/tenantIndexes.spec.ts @@ -22,7 +22,7 @@ afterAll(async () => { }); describe('dropSupersededTenantIndexes', () => { - describe('with pre-existing single-field unique indexes (simulates upgrade)', () => { + describe('with pre-existing superseded unique indexes (simulates upgrade)', () => { beforeAll(async () => { const db = mongoose.connection.db!; @@ -35,6 +35,10 @@ describe('dropSupersededTenantIndexes', () => { { unique: true, sparse: true, name: 'facebookId_1' }, ); await users.createIndex({ openidId: 1 }, { unique: true, sparse: true, name: 'openidId_1' }); + await users.createIndex( + { openidId: 1, tenantId: 1 }, + { unique: true, name: 'openidId_1_tenantId_1' }, + ); await users.createIndex({ samlId: 1 }, { unique: true, sparse: true, name: 'samlId_1' }); await users.createIndex({ ldapId: 1 }, { unique: true, sparse: true, name: 'ldapId_1' }); await users.createIndex({ githubId: 1 }, { unique: true, sparse: true, name: 'githubId_1' }); @@ -139,6 +143,7 @@ describe('dropSupersededTenantIndexes', () => { expect(indexNames).not.toContain('email_1'); expect(indexNames).not.toContain('googleId_1'); expect(indexNames).not.toContain('openidId_1'); + expect(indexNames).not.toContain('openidId_1_tenantId_1'); expect(indexNames).toContain('_id_'); }); @@ -290,11 +295,12 @@ describe('dropSupersededTenantIndexes', () => { } }); - it('users collection lists all 9 OAuth ID indexes plus email', () => { - expect(SUPERSEDED_INDEXES.users).toHaveLength(9); + it('users collection lists all superseded OAuth and email indexes', () => { + expect(SUPERSEDED_INDEXES.users).toHaveLength(10); expect(SUPERSEDED_INDEXES.users).toContain('email_1'); expect(SUPERSEDED_INDEXES.users).toContain('googleId_1'); expect(SUPERSEDED_INDEXES.users).toContain('openidId_1'); + expect(SUPERSEDED_INDEXES.users).toContain('openidId_1_tenantId_1'); }); }); }); diff --git a/packages/data-schemas/src/migrations/tenantIndexes.ts b/packages/data-schemas/src/migrations/tenantIndexes.ts index 6536423ad2f7..e511bb17364b 100644 --- a/packages/data-schemas/src/migrations/tenantIndexes.ts +++ b/packages/data-schemas/src/migrations/tenantIndexes.ts @@ -17,6 +17,7 @@ const SUPERSEDED_INDEXES: Record = { 'googleId_1', 'facebookId_1', 'openidId_1', + 'openidId_1_tenantId_1', 'samlId_1', 'ldapId_1', 'githubId_1', diff --git a/packages/data-schemas/src/schema/user.ts b/packages/data-schemas/src/schema/user.ts index 78ea91ca8395..3e2756c3d402 100644 --- a/packages/data-schemas/src/schema/user.ts +++ b/packages/data-schemas/src/schema/user.ts @@ -74,6 +74,9 @@ const userSchema = new Schema( openidId: { type: String, }, + openidIssuer: { + type: String, + }, samlId: { type: String, }, @@ -178,6 +181,14 @@ const oAuthIdFields = [ ] as const; for (const field of oAuthIdFields) { + if (field === 'openidId') { + userSchema.index( + { openidId: 1, openidIssuer: 1, tenantId: 1 }, + { unique: true, partialFilterExpression: { openidId: { $exists: true } } }, + ); + continue; + } + userSchema.index( { [field]: 1, tenantId: 1 }, { unique: true, partialFilterExpression: { [field]: { $exists: true } } }, diff --git a/packages/data-schemas/src/types/user.ts b/packages/data-schemas/src/types/user.ts index 0bbf704014ff..d4ab3356f914 100644 --- a/packages/data-schemas/src/types/user.ts +++ b/packages/data-schemas/src/types/user.ts @@ -21,6 +21,7 @@ export interface IUser extends Document { discordId?: string; appleId?: string; plugins?: string[]; + openidIssuer?: string; twoFactorEnabled?: boolean; totpSecret?: string; backupCodes?: Array<{