mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-10-25 03:32:12 +00:00
🔐 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:
parent
f5dd053128
commit
5683706af5
24 changed files with 3005 additions and 128 deletions
|
|
@ -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}`,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
41
api/server/routes/agents/middleware.js
Normal file
41
api/server/routes/agents/middleware.js
Normal 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,
|
||||
};
|
||||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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": "*",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)',
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -9,3 +9,4 @@ export { tenantContextMiddleware } from './tenant';
|
|||
export { preAuthTenantMiddleware } from './preAuthTenant';
|
||||
export * from './concurrency';
|
||||
export * from './checkBalance';
|
||||
export * from './remoteAgentAuth';
|
||||
|
|
|
|||
1533
packages/api/src/middleware/remoteAgentAuth.spec.ts
Normal file
1533
packages/api/src/middleware/remoteAgentAuth.spec.ts
Normal file
File diff suppressed because it is too large
Load diff
562
packages/api/src/middleware/remoteAgentAuth.ts
Normal file
562
packages/api/src/middleware/remoteAgentAuth.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 } } },
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ export interface IUser extends Document {
|
|||
discordId?: string;
|
||||
appleId?: string;
|
||||
plugins?: string[];
|
||||
openidIssuer?: string;
|
||||
twoFactorEnabled?: boolean;
|
||||
totpSecret?: string;
|
||||
backupCodes?: Array<{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue