🔐 feat: OIDC Bearer Token Authentication for Remote Agent API (#12450)

* Remote Agent Auth middleware

* consider migration and update user

* fix eslint errors

* add scope validation

* fix codex review errors

* add filter for use: sig

* add jwks-rsa deps

* Fix remote agent OIDC auth review findings

* Polish remote agent OIDC timeout coverage

* Reject remote OIDC tokens without subject

* Use tenant context for remote agent auth config

* Harden remote agent OIDC scope handling

* Polish remote agent OIDC cache and scope tests

* Resolve remote agent auth review comments

* Reuse OpenID email claim resolver for remote auth

* Skip empty OpenID email fallback claims

* Use pre-auth tenant context for remote auth config

* Downgrade expected OIDC fallback logging

* Require secure remote OIDC endpoints

* Polish remote agent auth edge cases

* Enforce unique balance records

* Bind remote OpenID users to issuer

* Fix issuer-scoped OpenID indexes

* Avoid unique balance index requirement

* Fix remote OpenID issuer normalization boundaries

* Require issuer-bound OpenID lookups

* Enforce tenant API key policy after auth

* Fix remote auth tenant policy types

* Normalize remote OIDC discovery issuer

* Allow normalized remote OIDC issuer validation

* Enforce resolved tenant OIDC policy

* Polish OpenID issuer and scope validation

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
This commit is contained in:
Artyom Bogachenko 2026-05-05 00:06:35 +03:00 • committed by GitHub
parent f5dd053128
commit 5683706af5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 3005 additions and 128 deletions

View file

@ -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}`,

View file

@ -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);
});

View file

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

View file

@ -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);

View file

@ -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);

View file

@ -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;

View file

@ -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',
}),
);
});
});

View file

@ -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;

View file

@ -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}`,
}),

View file

@ -111,6 +111,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": "*",

View file

@ -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<AppConfig> {
async function getAppConfig(options: GetAppConfigOptions = {}): Promise<AppConfig> {
const { role, userId, tenantId, refresh, baseOnly } = options;
const baseConfig = await ensureBaseConfig(refresh);

View file

@ -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<UserMethods['findUser']>;
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,17 @@ 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,
@ -109,6 +173,130 @@ describe('findOpenIDUser', () => {
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,
error: null,
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)',
),
);
});
});

View file

@ -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<OpenIdIssuerSource | null | undefined>
): 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<IUser>[] {
if (!value || typeof value !== 'string') return [];
if (!openidIssuer) return [];
const conditions: FilterQuery<IUser>[] = [{ [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<OpenIdUserResolution> {
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 };
}
}

View file

@ -21,6 +21,27 @@ export interface BalanceMiddlewareOptions {
upsertBalanceFields: (userId: string, fields: IBalanceUpdate) => Promise<IBalance | null>;
}
const balanceUpdateLocks = new Map<string, Promise<void>>();
async function runBalanceUpdate(userId: string, task: () => Promise<void>): Promise<void> {
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) {

View file

@ -9,3 +9,4 @@ export { tenantContextMiddleware } from './tenant';
export { preAuthTenantMiddleware } from './preAuthTenant';
export * from './concurrency';
export * from './checkBalance';
export * from './remoteAgentAuth';

File diff suppressed because it is too large Load diff

View file

@ -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<AppConfig>;
}
type OidcConfig = NonNullable<
NonNullable<NonNullable<TAgentsEndpoint['remoteApi']>['auth']>['oidc']
>;
type AgentAuthConfig = NonNullable<NonNullable<TAgentsEndpoint['remoteApi']>['auth']>;
type EnabledOidcConfig = OidcConfig & { issuer: string };
type JwksCacheOptions = {
enabled: boolean;
maxAge: number;
};
type CacheEntry<T> = {
expiresAt: number;
promise: Promise<T>;
};
type ScopeClaim = string | string[] | undefined;
type UserResolution =
| { status: 'resolved'; user: IUser; updateData: Partial<IUser> }
| { 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<string, CacheEntry<string>>();
const jwksClientCache = new Map<string, CacheEntry<jwksRsa.JwksClient>>();
export function clearRemoteAgentAuthCache(): void {
jwksUriCache.clear();
jwksClientCache.clear();
}
function pruneExpiredEntries<T>(cache: Map<string, CacheEntry<T>>): void {
const now = Date.now();
for (const [key, entry] of cache) {
if (entry.expiresAt <= now) cache.delete(key);
}
}
function setCacheEntry<T>(
cache: Map<string, CacheEntry<T>>,
key: string,
entry: CacheEntry<T>,
): 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<string> {
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<string> {
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<jwksRsa.JwksClient> {
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<void> {
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<void> {
let postAuth: Promise<void> | 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<boolean> {
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<JwtPayload> {
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<JwtPayload> {
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<JwtPayload> {
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<UserResolution> {
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<IUser> = {};
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: <issuer>
* jwksUri: <jwksUri>
* audience: <audience>
* scope: <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;
}
};
}

View file

@ -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', () => {

View file

@ -420,6 +420,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(
@ -436,6 +491,7 @@ export const agentsEndpointSchema = baseEndpointSchema
.array(z.nativeEnum(AgentCapabilities))
.optional()
.default(defaultAgentCapabilities),
remoteApi: remoteApiSchema.optional(),
}),
)
.default({

View file

@ -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 () => {

View file

@ -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');
});
});
});

View file

@ -17,6 +17,7 @@ const SUPERSEDED_INDEXES: Record<string, string[]> = {
'googleId_1',
'facebookId_1',
'openidId_1',
'openidId_1_tenantId_1',
'samlId_1',
'ldapId_1',
'githubId_1',

View file

@ -74,6 +74,9 @@ const userSchema = new Schema<IUser>(
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 } } },

View file

@ -21,6 +21,7 @@ export interface IUser extends Document {
discordId?: string;
appleId?: string;
plugins?: string[];
openidIssuer?: string;
twoFactorEnabled?: boolean;
totpSecret?: string;
backupCodes?: Array<{