mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-03 22:32:42 +00:00
🪜 feat: Add OpenID Role Sync (#13415)
* Shared Role-Sync Core
* Environment Configuration
* Browser OpenID Wiring & improved shared component
* API Auth Wiring
* Improved Role Lookup
* added example for sync env
* small simplification
* protect existing manual assigned ADMIN Roles
* fix: Apply OpenID role-sync fallback for present-but-empty claims
Both role-sync call sites skipped on a falsy `openIdRoleValues`, treating an
empty claim string ('') the same as a missing claim and returning before
`selectOpenIdRole` could apply the configured fallback role. An IdP emitting
an empty roles claim for a user with no mapped groups left the stale local
role in place instead of the authoritative fallback.
Skip only when the helper returns `undefined` (missing/invalid), letting an
empty string flow through to fallback selection — consistent with how an
empty array is already handled. Adds regression coverage on both the OpenID
strategy and the remote-agent API auth paths.
* refactor: Address OpenID role-sync review feedback
- role.ts: reuse the shared escapeRegExp util instead of a local escapeRegex
duplicate, matching prompt/skill/user/userGroup methods (Copilot).
- openidStrategy.js / remoteAgentAuth.ts: make the tenantStorage.run callbacks
async so the documented ALS contract is satisfied and tenant context cannot
be lost during Mongoose execution; the wrapped lookups/updates are already
async, so behavior is unchanged (codex P2).
* fix: Harden OpenID role-sync claim and fallback handling
Addresses the second Codex review cycle (P2 findings):
- Apply fallback when the claim is absent: getOpenIdRolesForOpenIdSync now
returns an empty list (not undefined) when the token source exists but has
no usable claim value, so callers still run selection and assign the
configured fallback instead of leaving a stale elevated role. A truly
unavailable source still returns undefined and skips sync.
- Resolve group overage for access tokens too: the _claim_names/_claim_sources
overage path previously only ran for claimSource 'id'; Entra also moves an
oversized groups claim into access tokens, so 'access'+'groups' (the only
source supported by remote-agent API sync) now resolves overage as well.
- Allow system fallback roles for tenant users: getLibreChatRolesForOpenIdSync
treats SystemRoles (e.g. USER) as always-available canonical names, since
they are provisioned globally at startup and a tenant-scoped lookup may not
return them — preventing a spurious 'configured roles do not exist: USER'.
Adds unit and strategy-level coverage for all three.
* fix: Tighten OpenID role-sync tenant scoping and config validation
Addresses the third Codex review cycle:
- Constrain base-user role lookups to base roles (P2): findRolesByNames now
filters to roles with an unset tenantId when no tenant ALS context is active,
so a base user cannot match — and be assigned — a role that only exists within
a tenant. Tenant-scoped lookups remain controlled by the isolation plugin.
- Re-enforce tenant login policy after role sync (P2): when role sync changes a
tenant user's role, the OpenID strategy re-resolves the tenant appConfig and
re-checks allowedDomains, so a token cannot complete login under the previous
role's looser policy.
- Skip role-sync-specific validation when disabled (P3): getOpenIdRoleSyncOptions
returns disabled options before validating role-sync settings, so a stale or
mistyped value no longer breaks OpenID login while the feature is off.
Adds unit and strategy-level coverage for all three.
* fix: Run base role lookups under system context for strict isolation
Follow-up to the base-role scoping fix (Codex P1). With TENANT_ISOLATION_STRICT=true,
the tenant-isolation pre('find') hook throws on a context-less query before the manual
tenantId filter is honored, so base OpenID/remote-agent auth would 500 instead of
validating base roles. findRolesByNames now runs the no-context lookup inside
runAsSystem (SYSTEM_TENANT_ID), bypassing strict-mode injection while still applying an
explicit base-role (tenantId unset) filter. Adds a strict-mode regression test.
---------
Co-authored-by: Peter Rothlaender <peter.rothlaender@ginkgo.com>
This commit is contained in:
parent
502dcde215
commit
83d8ac0682
11 changed files with 1605 additions and 9 deletions
10
.env.example
10
.env.example
|
|
@ -615,6 +615,16 @@ OPENID_REQUIRED_ROLE_PARAMETER_PATH=
|
|||
OPENID_ADMIN_ROLE=
|
||||
OPENID_ADMIN_ROLE_PARAMETER_PATH=
|
||||
OPENID_ADMIN_ROLE_TOKEN_KIND=
|
||||
# Generic OpenID role sync maps non-admin IdP roles/groups to one LibreChat role.
|
||||
# ADMIN cannot be assigned by generic role sync; use OPENID_ADMIN_ROLE for admin elevation.
|
||||
# Role priority is ordered from most important to least important.
|
||||
OPENID_ROLE_SYNC_ENABLED=false
|
||||
OPENID_ROLE_SYNC_API_ENABLED=false
|
||||
OPENID_ROLE_SYNC_SOURCE=id
|
||||
OPENID_ROLE_SYNC_CLAIM=
|
||||
OPENID_ROLE_SYNC_ROLE_PRIORITY=
|
||||
# Fallback is authoritative when configured: if no priority role matches, this role is assigned.
|
||||
OPENID_ROLE_SYNC_FALLBACK_ROLE=
|
||||
# Set to determine which user info property returned from OpenID Provider to store as the User's username
|
||||
OPENID_USERNAME_CLAIM=
|
||||
# Set to determine which user info property returned from OpenID Provider to store as the User's name
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ const apiKeyMiddleware = createRequireApiKeyAuth({
|
|||
const requireRemoteAgentAuth = createRemoteAgentAuth({
|
||||
apiKeyMiddleware,
|
||||
findUser: db.findUser,
|
||||
getRolesByNames: db.findRolesByNames,
|
||||
updateUser: db.updateUser,
|
||||
getAppConfig,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ const { get } = require('lodash');
|
|||
const passport = require('passport');
|
||||
const client = require('openid-client');
|
||||
const jwtDecode = require('jsonwebtoken/decode');
|
||||
const { hashToken, logger } = require('@librechat/data-schemas');
|
||||
const { hashToken, logger, tenantStorage } = require('@librechat/data-schemas');
|
||||
const { Strategy: OpenIDStrategy } = require('openid-client/passport');
|
||||
const { CacheKeys, ErrorTypes, SystemRoles } = require('librechat-data-provider');
|
||||
const {
|
||||
|
|
@ -17,11 +17,15 @@ const {
|
|||
isEmailDomainAllowed,
|
||||
getAvatarFileStrategy,
|
||||
getAvatarSaveParams,
|
||||
selectOpenIdRole,
|
||||
getOpenIdRoleSyncOptions,
|
||||
getOpenIdRolesForOpenIdSync,
|
||||
getLibreChatRolesForOpenIdSync,
|
||||
resolveAppConfigForUser,
|
||||
} = require('@librechat/api');
|
||||
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
|
||||
const { resizeAvatar } = require('~/server/services/Files/images/avatar');
|
||||
const { findUser, createUser, updateUser } = require('~/models');
|
||||
const { findUser, createUser, updateUser, findRolesByNames } = require('~/models');
|
||||
const { getAppConfig } = require('~/server/services/Config');
|
||||
const getLogStores = require('~/cache/getLogStores');
|
||||
|
||||
|
|
@ -471,6 +475,78 @@ function getRoleSource(kind, label, tokenset, userinfo) {
|
|||
throw new Error(`Invalid ${label} token kind`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies generic OpenID role sync to the request-local user before the existing final update.
|
||||
*/
|
||||
async function applyOpenIdRoleSync({
|
||||
user,
|
||||
username,
|
||||
tokenset,
|
||||
claims,
|
||||
userinfo,
|
||||
resolvedOverageGroups,
|
||||
}) {
|
||||
const options = getOpenIdRoleSyncOptions();
|
||||
if (!options.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (user.role === SystemRoles.ADMIN) {
|
||||
logger.info(
|
||||
`[openidStrategy] OpenID role sync skipped for ${username}; existing ADMIN role is not managed by generic role sync`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const resolveGroupOverage = async () =>
|
||||
resolvedOverageGroups || (await resolveGroupsFromOverage(tokenset.access_token, claims.sub));
|
||||
|
||||
const openIdRoleValues = await getOpenIdRolesForOpenIdSync({
|
||||
options,
|
||||
accessToken: tokenset.access_token,
|
||||
idToken: tokenset.id_token,
|
||||
claims,
|
||||
userinfo,
|
||||
decodeToken: jwtDecode,
|
||||
resolveGroupOverage,
|
||||
});
|
||||
if (openIdRoleValues === undefined) {
|
||||
logger.warn(
|
||||
`[openidStrategy] OpenID role sync skipped; claim '${options.claim}' was not found, invalid, or unresolved`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const libreChatRoles = {
|
||||
getRolesByNames: findRolesByNames,
|
||||
rolePriority: options.rolePriority,
|
||||
fallbackRole: options.fallbackRole,
|
||||
logPrefix: '[openidStrategy]',
|
||||
};
|
||||
|
||||
/** Role definitions are tenant-scoped, so validate configured roles in the matched user's tenant. */
|
||||
const { rolePriority, fallbackRole } = user?.tenantId
|
||||
? await tenantStorage.run({ tenantId: user.tenantId }, async () =>
|
||||
getLibreChatRolesForOpenIdSync(libreChatRoles),
|
||||
)
|
||||
: await getLibreChatRolesForOpenIdSync(libreChatRoles);
|
||||
const result = selectOpenIdRole({
|
||||
currentRole: user.role,
|
||||
openIdRoleValues,
|
||||
rolePriority,
|
||||
fallbackRole,
|
||||
});
|
||||
|
||||
if (!result.selectedRole || result.selectedRole === user.role) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`[openidStrategy] OpenID role sync updated role for ${username}: ${user.role || 'unset'} -> ${result.selectedRole}`,
|
||||
);
|
||||
user.role = result.selectedRole;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process OpenID authentication tokenset and userinfo
|
||||
* This is the core logic extracted from the passport strategy callback
|
||||
|
|
@ -630,6 +706,7 @@ async function processOpenIDAuth(tokenset, existingUsersOnly = false) {
|
|||
const adminRole = process.env.OPENID_ADMIN_ROLE;
|
||||
const adminRoleParameterPath = process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH;
|
||||
const adminRoleTokenKind = process.env.OPENID_ADMIN_ROLE_TOKEN_KIND;
|
||||
let adminRoleGranted = false;
|
||||
|
||||
if (adminRole && adminRoleParameterPath && adminRoleTokenKind) {
|
||||
const adminRoleObject = getRoleSource(adminRoleTokenKind, 'admin role', tokenset, userinfo);
|
||||
|
|
@ -662,6 +739,7 @@ async function processOpenIDAuth(tokenset, existingUsersOnly = false) {
|
|||
|
||||
if (adminRoles && (adminRoles === true || adminRoleValues.includes(adminRole))) {
|
||||
user.role = SystemRoles.ADMIN;
|
||||
adminRoleGranted = true;
|
||||
logger.info(`[openidStrategy] User ${username} is an admin based on role: ${adminRole}`);
|
||||
} else if (user.role === SystemRoles.ADMIN) {
|
||||
user.role = SystemRoles.USER;
|
||||
|
|
@ -671,6 +749,33 @@ async function processOpenIDAuth(tokenset, existingUsersOnly = false) {
|
|||
}
|
||||
}
|
||||
|
||||
if (!adminRoleGranted) {
|
||||
const roleBeforeSync = user.role;
|
||||
await applyOpenIdRoleSync({
|
||||
user,
|
||||
username,
|
||||
tokenset,
|
||||
claims,
|
||||
userinfo,
|
||||
resolvedOverageGroups,
|
||||
});
|
||||
/**
|
||||
* The earlier login-policy check ran with the pre-sync role. If role sync moved a
|
||||
* tenant user into a different role, re-resolve the tenant config and re-enforce
|
||||
* `allowedDomains` so role-scoped overrides for the new role are honored and a token
|
||||
* cannot complete login under the previous role's looser policy.
|
||||
*/
|
||||
if (user?.tenantId && user.role !== roleBeforeSync) {
|
||||
const postSyncConfig = await resolveAppConfigForUser(getAppConfig, user);
|
||||
if (!isEmailDomainAllowed(email, postSyncConfig?.registration?.allowedDomains)) {
|
||||
logger.error(
|
||||
`[OpenID Strategy] Authentication blocked after role sync - email domain not allowed [Identifier: ${email}]`,
|
||||
);
|
||||
throw new Error('Email domain not allowed');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!!userinfo && userinfo.picture && !user.avatar?.includes('manual=true')) {
|
||||
/** @type {string | undefined} */
|
||||
const imageUrl = userinfo.picture;
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ const undici = require('undici');
|
|||
const fetch = require('node-fetch');
|
||||
const jwtDecode = require('jsonwebtoken/decode');
|
||||
const { ErrorTypes, FileSources } = require('librechat-data-provider');
|
||||
const { findUser, createUser, updateUser } = require('~/models');
|
||||
const { findUser, createUser, updateUser, findRolesByNames } = require('~/models');
|
||||
const { getOpenIdIssuer, resolveAppConfigForUser, isEnabled } = require('@librechat/api');
|
||||
const { resizeAvatar } = require('~/server/services/Files/images/avatar');
|
||||
const { getAppConfig } = require('~/server/services/Config');
|
||||
|
|
@ -94,6 +94,7 @@ jest.mock('~/models', () => ({
|
|||
findUser: jest.fn(),
|
||||
createUser: jest.fn(),
|
||||
updateUser: jest.fn(),
|
||||
findRolesByNames: jest.fn(),
|
||||
}));
|
||||
jest.mock('@librechat/data-schemas', () => ({
|
||||
...jest.requireActual('@librechat/api'),
|
||||
|
|
@ -103,6 +104,9 @@ jest.mock('@librechat/data-schemas', () => ({
|
|||
debug: jest.fn(),
|
||||
error: jest.fn(),
|
||||
},
|
||||
tenantStorage: {
|
||||
run: jest.fn((_context, fn) => fn()),
|
||||
},
|
||||
hashToken: jest.fn().mockResolvedValue('hashed-token'),
|
||||
}));
|
||||
jest.mock('~/cache/getLogStores', () =>
|
||||
|
|
@ -202,6 +206,15 @@ describe('setupOpenId', () => {
|
|||
// Clear previous mock calls and reset implementations
|
||||
jest.clearAllMocks();
|
||||
isEnabled.mockImplementation(jest.requireActual('@librechat/api').isEnabled);
|
||||
require('~/cache/getLogStores').mockImplementation(() => ({
|
||||
get: jest.fn(),
|
||||
set: jest.fn(),
|
||||
}));
|
||||
require('openid-client').genericGrantRequest.mockReset();
|
||||
require('openid-client').genericGrantRequest.mockResolvedValue({
|
||||
access_token: 'exchanged_graph_token',
|
||||
expires_in: 3600,
|
||||
});
|
||||
|
||||
// Reset environment variables needed by the strategy
|
||||
process.env.OPENID_ISSUER = 'https://fake-issuer.com';
|
||||
|
|
@ -223,6 +236,14 @@ describe('setupOpenId', () => {
|
|||
delete process.env.PROXY;
|
||||
delete process.env.OPENID_USE_PKCE;
|
||||
delete process.env.OPENID_GENERATE_NONCE;
|
||||
delete process.env.OPENID_ROLE_SYNC_ENABLED;
|
||||
delete process.env.OPENID_ROLE_SYNC_API_ENABLED;
|
||||
delete process.env.OPENID_ROLE_SYNC_SOURCE;
|
||||
delete process.env.OPENID_ROLE_SYNC_CLAIM;
|
||||
delete process.env.OPENID_ROLE_SYNC_ROLE_PRIORITY;
|
||||
delete process.env.OPENID_ROLE_SYNC_FALLBACK_ROLE;
|
||||
delete process.env.OPENID_ON_BEHALF_FLOW_FOR_USERINFO_REQUIRED;
|
||||
delete process.env.OPENID_ON_BEHALF_FLOW_USERINFO_SCOPE;
|
||||
|
||||
// Default jwtDecode mock returns a token that includes the required role.
|
||||
jwtDecode.mockReturnValue({
|
||||
|
|
@ -239,6 +260,9 @@ describe('setupOpenId', () => {
|
|||
updateUser.mockImplementation(async (id, userData) => {
|
||||
return { _id: id, ...userData };
|
||||
});
|
||||
findRolesByNames.mockImplementation(async (roleNames) =>
|
||||
roleNames.map((roleName) => ({ name: roleName })),
|
||||
);
|
||||
|
||||
resizeAvatar.mockResolvedValue(Buffer.from('safe avatar'));
|
||||
|
||||
|
|
@ -974,6 +998,10 @@ describe('setupOpenId', () => {
|
|||
});
|
||||
|
||||
describe('OBO token exchange for overage', () => {
|
||||
beforeEach(() => {
|
||||
delete process.env.OPENID_ADMIN_ROLE;
|
||||
});
|
||||
|
||||
it('exchanges access token via OBO before calling Graph API', async () => {
|
||||
const openidClient = require('openid-client');
|
||||
process.env.OPENID_REQUIRED_ROLE = 'group-required';
|
||||
|
|
@ -1534,6 +1562,296 @@ describe('setupOpenId', () => {
|
|||
expect(user.role).toBeUndefined();
|
||||
});
|
||||
|
||||
describe('OpenID role sync', () => {
|
||||
beforeEach(() => {
|
||||
process.env.OPENID_ROLE_SYNC_ENABLED = 'true';
|
||||
process.env.OPENID_ROLE_SYNC_SOURCE = 'id';
|
||||
process.env.OPENID_ROLE_SYNC_CLAIM = 'roles';
|
||||
process.env.OPENID_ROLE_SYNC_ROLE_PRIORITY = 'STANDARD-USER,BASIC-USER';
|
||||
process.env.OPENID_ROLE_SYNC_FALLBACK_ROLE = 'USER';
|
||||
});
|
||||
|
||||
it('selects the highest configured matching role from the OpenID token', async () => {
|
||||
jwtDecode.mockReturnValue({
|
||||
roles: ['requiredRole', 'BASIC-USER', 'STANDARD-USER'],
|
||||
permissions: ['not-admin'],
|
||||
});
|
||||
|
||||
const { user } = await validate(tokenset);
|
||||
|
||||
expect(user.role).toBe('STANDARD-USER');
|
||||
expect(updateUser).toHaveBeenCalledWith(
|
||||
'newUserId',
|
||||
expect.objectContaining({ role: 'STANDARD-USER' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not run when disabled', async () => {
|
||||
delete process.env.OPENID_ROLE_SYNC_ENABLED;
|
||||
jwtDecode.mockReturnValue({
|
||||
roles: ['requiredRole', 'STANDARD-USER'],
|
||||
permissions: ['not-admin'],
|
||||
});
|
||||
|
||||
const { user } = await validate(tokenset);
|
||||
|
||||
expect(user.role).toBeUndefined();
|
||||
expect(findRolesByNames).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('leaves ADMIN authoritative when OPENID_ADMIN_ROLE grants admin', async () => {
|
||||
jwtDecode.mockReturnValue({
|
||||
roles: ['requiredRole', 'STANDARD-USER'],
|
||||
permissions: ['admin'],
|
||||
});
|
||||
|
||||
const { user } = await validate(tokenset);
|
||||
|
||||
expect(user.role).toBe('ADMIN');
|
||||
});
|
||||
|
||||
it('preserves an existing ADMIN role when admin is manually assigned', async () => {
|
||||
delete process.env.OPENID_ADMIN_ROLE;
|
||||
delete process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH;
|
||||
delete process.env.OPENID_ADMIN_ROLE_TOKEN_KIND;
|
||||
const existingAdminUser = {
|
||||
_id: 'existingAdminId',
|
||||
provider: 'openid',
|
||||
email: tokenset.claims().email,
|
||||
openidId: tokenset.claims().sub,
|
||||
username: 'adminuser',
|
||||
name: 'Admin User',
|
||||
role: 'ADMIN',
|
||||
};
|
||||
|
||||
findUser.mockImplementation(async (query) => {
|
||||
if (query.openidId === tokenset.claims().sub || query.email === tokenset.claims().email) {
|
||||
return existingAdminUser;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
jwtDecode.mockReturnValue({
|
||||
roles: ['requiredRole', 'STANDARD-USER'],
|
||||
permissions: ['not-admin'],
|
||||
});
|
||||
|
||||
const { user } = await validate(tokenset);
|
||||
|
||||
expect(user.role).toBe('ADMIN');
|
||||
expect(findRolesByNames).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses fallback when a valid role claim has no configured role match', async () => {
|
||||
jwtDecode.mockReturnValue({
|
||||
roles: ['requiredRole', 'external-role'],
|
||||
permissions: ['not-admin'],
|
||||
});
|
||||
|
||||
const { user } = await validate(tokenset);
|
||||
|
||||
expect(user.role).toBe('USER');
|
||||
});
|
||||
|
||||
it('uses fallback when the role claim is present but empty', async () => {
|
||||
// The required-role gate reads the same `roles` claim this test empties, so
|
||||
// disable it to model an IdP that authenticates the user yet emits no roles.
|
||||
delete process.env.OPENID_REQUIRED_ROLE;
|
||||
jwtDecode.mockReturnValue({
|
||||
roles: '',
|
||||
permissions: ['not-admin'],
|
||||
});
|
||||
|
||||
const { user } = await validate(tokenset);
|
||||
|
||||
expect(user.role).toBe('USER');
|
||||
expect(updateUser).toHaveBeenCalledWith(
|
||||
'newUserId',
|
||||
expect.objectContaining({ role: 'USER' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('applies fallback when the role claim is absent from the token', async () => {
|
||||
// Required-role gate reads the same `roles` claim; disable it to model an IdP
|
||||
// that authenticates the user but stops emitting the role claim entirely.
|
||||
delete process.env.OPENID_REQUIRED_ROLE;
|
||||
jwtDecode.mockReturnValue({
|
||||
permissions: ['not-admin'],
|
||||
});
|
||||
|
||||
const { user } = await validate(tokenset);
|
||||
|
||||
expect(user.role).toBe('USER');
|
||||
expect(updateUser).toHaveBeenCalledWith(
|
||||
'newUserId',
|
||||
expect.objectContaining({ role: 'USER' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects login when configured sync roles do not exist', async () => {
|
||||
findRolesByNames.mockImplementation(async (roleNames) =>
|
||||
roleNames
|
||||
.filter((roleName) => roleName !== 'STANDARD-USER')
|
||||
.map((roleName) => ({ name: roleName })),
|
||||
);
|
||||
jwtDecode.mockReturnValue({
|
||||
roles: ['requiredRole', 'STANDARD-USER'],
|
||||
permissions: ['not-admin'],
|
||||
});
|
||||
|
||||
await expect(validate(tokenset)).rejects.toThrow(
|
||||
'OpenID role sync configured roles do not exist: STANDARD-USER',
|
||||
);
|
||||
});
|
||||
|
||||
it('can assign a non-admin role after the existing admin demotion path runs', async () => {
|
||||
const existingAdminUser = {
|
||||
_id: 'existingAdminId',
|
||||
provider: 'openid',
|
||||
email: tokenset.claims().email,
|
||||
openidId: tokenset.claims().sub,
|
||||
username: 'adminuser',
|
||||
name: 'Admin User',
|
||||
role: 'ADMIN',
|
||||
};
|
||||
|
||||
findUser.mockImplementation(async (query) => {
|
||||
if (query.openidId === tokenset.claims().sub || query.email === tokenset.claims().email) {
|
||||
return existingAdminUser;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
jwtDecode.mockReturnValue({
|
||||
roles: ['requiredRole', 'STANDARD-USER'],
|
||||
permissions: ['not-admin'],
|
||||
});
|
||||
|
||||
const { user } = await validate(tokenset);
|
||||
|
||||
expect(user.role).toBe('STANDARD-USER');
|
||||
expect(updateUser).toHaveBeenCalledWith(
|
||||
existingAdminUser._id,
|
||||
expect.objectContaining({ role: 'STANDARD-USER' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('wraps role lookup in tenant context for tenant users', async () => {
|
||||
const existingUser = {
|
||||
_id: 'existingTenantUserId',
|
||||
provider: 'openid',
|
||||
email: tokenset.claims().email,
|
||||
openidId: tokenset.claims().sub,
|
||||
username: 'tenantuser',
|
||||
name: 'Tenant User',
|
||||
tenantId: 'tenant-a',
|
||||
role: 'USER',
|
||||
};
|
||||
const { tenantStorage } = require('@librechat/data-schemas');
|
||||
|
||||
findUser.mockImplementation(async (query) => {
|
||||
if (query.openidId === tokenset.claims().sub || query.email === tokenset.claims().email) {
|
||||
return existingUser;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
jwtDecode.mockReturnValue({
|
||||
roles: ['requiredRole', 'BASIC-USER'],
|
||||
permissions: ['not-admin'],
|
||||
});
|
||||
|
||||
const { user } = await validate(tokenset);
|
||||
|
||||
expect(user.role).toBe('BASIC-USER');
|
||||
expect(tenantStorage.run).toHaveBeenCalledWith(
|
||||
{ tenantId: 'tenant-a' },
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('re-enforces tenant login policy after role sync changes the role', async () => {
|
||||
const existingUser = {
|
||||
_id: 'existingTenantUserId',
|
||||
provider: 'openid',
|
||||
email: tokenset.claims().email,
|
||||
openidId: tokenset.claims().sub,
|
||||
username: 'tenantuser',
|
||||
name: 'Tenant User',
|
||||
tenantId: 'tenant-a',
|
||||
role: 'USER',
|
||||
};
|
||||
const { isEmailDomainAllowed } = require('@librechat/api');
|
||||
|
||||
findUser.mockImplementation(async (query) => {
|
||||
if (query.openidId === tokenset.claims().sub || query.email === tokenset.claims().email) {
|
||||
return existingUser;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
jwtDecode.mockReturnValue({
|
||||
roles: ['requiredRole', 'BASIC-USER'],
|
||||
permissions: ['not-admin'],
|
||||
});
|
||||
// Pre-sync domain check passes; the post-sync re-resolved config rejects the domain.
|
||||
isEmailDomainAllowed.mockReturnValueOnce(true).mockReturnValueOnce(false);
|
||||
resolveAppConfigForUser.mockResolvedValue({
|
||||
registration: { allowedDomains: ['restricted.com'] },
|
||||
});
|
||||
|
||||
const { user, details } = await validate(tokenset);
|
||||
|
||||
expect(user).toBe(false);
|
||||
expect(details).toEqual({ message: 'Email domain not allowed' });
|
||||
});
|
||||
|
||||
it('reuses required-role overage groups for role sync', async () => {
|
||||
process.env.OPENID_REQUIRED_ROLE = 'group-required';
|
||||
process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'groups';
|
||||
process.env.OPENID_ROLE_SYNC_CLAIM = 'groups';
|
||||
|
||||
jwtDecode.mockReturnValue({
|
||||
hasgroups: true,
|
||||
permissions: ['not-admin'],
|
||||
});
|
||||
undici.fetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ value: ['group-required', 'STANDARD-USER'] }),
|
||||
});
|
||||
|
||||
const { user } = await validate(tokenset);
|
||||
|
||||
expect(user.role).toBe('STANDARD-USER');
|
||||
expect(undici.fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('leaves the role unchanged when role-sync group overage cannot be resolved', async () => {
|
||||
process.env.OPENID_ROLE_SYNC_CLAIM = 'groups';
|
||||
const existingUser = {
|
||||
_id: 'existingUserId',
|
||||
provider: 'openid',
|
||||
email: tokenset.claims().email,
|
||||
openidId: tokenset.claims().sub,
|
||||
username: 'existinguser',
|
||||
name: 'Existing User',
|
||||
role: 'BASIC-USER',
|
||||
};
|
||||
|
||||
findUser.mockImplementation(async (query) => {
|
||||
if (query.openidId === tokenset.claims().sub || query.email === tokenset.claims().email) {
|
||||
return existingUser;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
jwtDecode.mockReturnValue({
|
||||
roles: ['requiredRole'],
|
||||
hasgroups: true,
|
||||
permissions: ['not-admin'],
|
||||
});
|
||||
|
||||
const { user } = await validate({ ...tokenset, access_token: undefined });
|
||||
|
||||
expect(user.role).toBe('BASIC-USER');
|
||||
});
|
||||
});
|
||||
|
||||
it('should demote existing admin user when admin role is removed from token', async () => {
|
||||
// Arrange – simulate an existing user who is currently an admin
|
||||
const existingAdminUser = {
|
||||
|
|
|
|||
|
|
@ -6,3 +6,4 @@ export * from './agent';
|
|||
export * from './password';
|
||||
export * from './invite';
|
||||
export * from './codeapi';
|
||||
export * from './openidRoleSync';
|
||||
|
|
|
|||
398
packages/api/src/auth/openidRoleSync.spec.ts
Normal file
398
packages/api/src/auth/openidRoleSync.spec.ts
Normal file
|
|
@ -0,0 +1,398 @@
|
|||
import { SystemRoles } from 'librechat-data-provider';
|
||||
import {
|
||||
getOpenIdRoleSyncOptions,
|
||||
getOpenIdRolesForOpenIdSync,
|
||||
getLibreChatRolesForOpenIdSync,
|
||||
selectOpenIdRole,
|
||||
} from './openidRoleSync';
|
||||
|
||||
describe('getOpenIdRoleSyncOptions', () => {
|
||||
it('defaults role sync and API role sync to disabled', () => {
|
||||
expect(getOpenIdRoleSyncOptions({})).toEqual({
|
||||
enabled: false,
|
||||
apiEnabled: false,
|
||||
claimSource: 'id',
|
||||
claim: undefined,
|
||||
rolePriority: [],
|
||||
fallbackRole: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('parses enabled config and comma-separated priority roles', () => {
|
||||
expect(
|
||||
getOpenIdRoleSyncOptions({
|
||||
OPENID_ROLE_SYNC_ENABLED: 'true',
|
||||
OPENID_ROLE_SYNC_API_ENABLED: 'true',
|
||||
OPENID_ROLE_SYNC_SOURCE: 'access',
|
||||
OPENID_ROLE_SYNC_CLAIM: 'roles',
|
||||
OPENID_ROLE_SYNC_ROLE_PRIORITY: 'STANDARD-USER, BASIC-USER',
|
||||
OPENID_ROLE_SYNC_FALLBACK_ROLE: SystemRoles.USER,
|
||||
}),
|
||||
).toEqual({
|
||||
enabled: true,
|
||||
apiEnabled: true,
|
||||
claimSource: 'access',
|
||||
claim: 'roles',
|
||||
rolePriority: ['STANDARD-USER', 'BASIC-USER'],
|
||||
fallbackRole: SystemRoles.USER,
|
||||
});
|
||||
});
|
||||
|
||||
it('requires claim when role sync is enabled', () => {
|
||||
expect(() => getOpenIdRoleSyncOptions({ OPENID_ROLE_SYNC_ENABLED: 'true' })).toThrow(
|
||||
'OPENID_ROLE_SYNC_CLAIM is required',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects invalid claim sources when enabled', () => {
|
||||
expect(() =>
|
||||
getOpenIdRoleSyncOptions({
|
||||
OPENID_ROLE_SYNC_ENABLED: 'true',
|
||||
OPENID_ROLE_SYNC_CLAIM: 'roles',
|
||||
OPENID_ROLE_SYNC_SOURCE: 'profile',
|
||||
}),
|
||||
).toThrow('OPENID_ROLE_SYNC_SOURCE must be one of');
|
||||
});
|
||||
|
||||
it('ignores role-sync-specific settings when the feature is disabled', () => {
|
||||
expect(
|
||||
getOpenIdRoleSyncOptions({
|
||||
OPENID_ROLE_SYNC_SOURCE: 'profile',
|
||||
OPENID_ROLE_SYNC_ROLE_PRIORITY: SystemRoles.ADMIN,
|
||||
OPENID_ROLE_SYNC_FALLBACK_ROLE: SystemRoles.ADMIN,
|
||||
}),
|
||||
).toMatchObject({ enabled: false, apiEnabled: false });
|
||||
});
|
||||
|
||||
it('rejects API role sync when global role sync is disabled', () => {
|
||||
expect(() =>
|
||||
getOpenIdRoleSyncOptions({
|
||||
OPENID_ROLE_SYNC_API_ENABLED: 'true',
|
||||
}),
|
||||
).toThrow('OPENID_ROLE_SYNC_API_ENABLED requires OPENID_ROLE_SYNC_ENABLED=true');
|
||||
});
|
||||
|
||||
it('rejects ADMIN in role priority and fallback role when enabled', () => {
|
||||
expect(() =>
|
||||
getOpenIdRoleSyncOptions({
|
||||
OPENID_ROLE_SYNC_ENABLED: 'true',
|
||||
OPENID_ROLE_SYNC_CLAIM: 'roles',
|
||||
OPENID_ROLE_SYNC_ROLE_PRIORITY: `STANDARD-USER,${SystemRoles.ADMIN}`,
|
||||
}),
|
||||
).toThrow('OPENID_ROLE_SYNC_ROLE_PRIORITY cannot include ADMIN');
|
||||
|
||||
expect(() =>
|
||||
getOpenIdRoleSyncOptions({
|
||||
OPENID_ROLE_SYNC_ENABLED: 'true',
|
||||
OPENID_ROLE_SYNC_CLAIM: 'roles',
|
||||
OPENID_ROLE_SYNC_FALLBACK_ROLE: SystemRoles.ADMIN,
|
||||
}),
|
||||
).toThrow('OPENID_ROLE_SYNC_FALLBACK_ROLE cannot be ADMIN');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getOpenIdRolesForOpenIdSync', () => {
|
||||
const options = {
|
||||
enabled: true,
|
||||
apiEnabled: false,
|
||||
claimSource: 'id' as const,
|
||||
claim: 'roles',
|
||||
rolePriority: ['STANDARD-USER'],
|
||||
};
|
||||
|
||||
it('extracts a configured claim from the selected source', async () => {
|
||||
await expect(
|
||||
getOpenIdRolesForOpenIdSync({
|
||||
options,
|
||||
idToken: 'id-token',
|
||||
decodeToken: () => ({ roles: ['STANDARD-USER'] }),
|
||||
}),
|
||||
).resolves.toEqual(['STANDARD-USER']);
|
||||
});
|
||||
|
||||
it('returns an empty list when the source lacks a usable claim value (applies fallback)', async () => {
|
||||
await expect(
|
||||
getOpenIdRolesForOpenIdSync({
|
||||
options,
|
||||
idToken: 'id-token',
|
||||
decodeToken: () => ({ roles: { STANDARD_USER: true } }),
|
||||
}),
|
||||
).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it('returns undefined when no token source is available (skips sync)', async () => {
|
||||
await expect(
|
||||
getOpenIdRolesForOpenIdSync({
|
||||
options,
|
||||
decodeToken: () => ({ roles: ['STANDARD-USER'] }),
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('uses overage resolution for id token groups', async () => {
|
||||
await expect(
|
||||
getOpenIdRolesForOpenIdSync({
|
||||
options: { ...options, claim: 'groups' },
|
||||
idToken: 'id-token',
|
||||
decodeToken: () => ({ hasgroups: true }),
|
||||
resolveGroupOverage: async () => ['group-a'],
|
||||
}),
|
||||
).resolves.toEqual(['group-a']);
|
||||
});
|
||||
|
||||
it('decodes only the configured access token source', async () => {
|
||||
const decodeToken = jest.fn((token: string) => ({ token }));
|
||||
|
||||
await expect(
|
||||
getOpenIdRolesForOpenIdSync({
|
||||
options: { ...options, claimSource: 'access' },
|
||||
accessToken: 'access-token',
|
||||
idToken: 'id-token',
|
||||
decodeToken,
|
||||
}),
|
||||
).resolves.toEqual([]);
|
||||
expect(decodeToken).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('resolves group overage from an access token source (not just id)', async () => {
|
||||
await expect(
|
||||
getOpenIdRolesForOpenIdSync({
|
||||
options: { ...options, claimSource: 'access', claim: 'groups' },
|
||||
accessClaims: {
|
||||
_claim_names: { groups: 'src1' },
|
||||
_claim_sources: { src1: { endpoint: 'https://graph' } },
|
||||
},
|
||||
decodeToken: () => ({}),
|
||||
resolveGroupOverage: async () => ['group-a'],
|
||||
}),
|
||||
).resolves.toEqual(['group-a']);
|
||||
});
|
||||
|
||||
it('uses claims for the id source when no id token is available', async () => {
|
||||
const decodeToken = jest.fn();
|
||||
const claims = { roles: ['STANDARD-USER'] };
|
||||
|
||||
await expect(
|
||||
getOpenIdRolesForOpenIdSync({
|
||||
options,
|
||||
claims,
|
||||
decodeToken,
|
||||
}),
|
||||
).resolves.toEqual(['STANDARD-USER']);
|
||||
expect(decodeToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses userinfo directly for the userinfo source', async () => {
|
||||
const userinfo = { roles: ['STANDARD-USER'] };
|
||||
|
||||
await expect(
|
||||
getOpenIdRolesForOpenIdSync({
|
||||
options: { ...options, claimSource: 'userinfo' },
|
||||
userinfo,
|
||||
decodeToken: jest.fn(),
|
||||
}),
|
||||
).resolves.toEqual(['STANDARD-USER']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLibreChatRolesForOpenIdSync', () => {
|
||||
it('deduplicates configured roles and returns canonical role names', async () => {
|
||||
const getRolesByNames = jest.fn(async (roleNames: string[]) =>
|
||||
roleNames.map((roleName) => ({
|
||||
name: roleName.toLowerCase() === 'standard-user' ? 'STANDARD-USER' : roleName,
|
||||
})),
|
||||
);
|
||||
|
||||
await expect(
|
||||
getLibreChatRolesForOpenIdSync({
|
||||
getRolesByNames,
|
||||
rolePriority: [' standard-user ', 'STANDARD-USER'],
|
||||
fallbackRole: SystemRoles.USER,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
rolePriority: ['STANDARD-USER', 'STANDARD-USER'],
|
||||
fallbackRole: SystemRoles.USER,
|
||||
});
|
||||
expect(getRolesByNames).toHaveBeenCalledTimes(1);
|
||||
expect(getRolesByNames).toHaveBeenCalledWith(['standard-user', SystemRoles.USER], 'name');
|
||||
});
|
||||
|
||||
it('rejects configured roles that do not exist', async () => {
|
||||
const getRolesByNames = jest.fn(async (roleNames: string[]) =>
|
||||
roleNames
|
||||
.filter((roleName) => roleName !== 'MISSING')
|
||||
.map((roleName) => ({ name: roleName })),
|
||||
);
|
||||
|
||||
await expect(
|
||||
getLibreChatRolesForOpenIdSync({
|
||||
getRolesByNames,
|
||||
rolePriority: ['STANDARD-USER', 'MISSING'],
|
||||
logPrefix: '[openidStrategy]',
|
||||
}),
|
||||
).rejects.toThrow('[openidStrategy] OpenID role sync configured roles do not exist: MISSING');
|
||||
});
|
||||
|
||||
it('accepts a system fallback role even when a tenant-scoped lookup omits it', async () => {
|
||||
// Tenant-scoped getRolesByNames returns only the tenant's own roles, not the
|
||||
// globally-provisioned system USER role.
|
||||
const getRolesByNames = jest.fn(async (roleNames: string[]) =>
|
||||
roleNames
|
||||
.filter((roleName) => roleName === 'STANDARD-USER')
|
||||
.map((roleName) => ({ name: roleName })),
|
||||
);
|
||||
|
||||
await expect(
|
||||
getLibreChatRolesForOpenIdSync({
|
||||
getRolesByNames,
|
||||
rolePriority: ['STANDARD-USER'],
|
||||
fallbackRole: SystemRoles.USER,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
rolePriority: ['STANDARD-USER'],
|
||||
fallbackRole: SystemRoles.USER,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('selectOpenIdRole', () => {
|
||||
it('selects the highest-priority configured role from matching OpenID values', () => {
|
||||
expect(
|
||||
selectOpenIdRole({
|
||||
currentRole: SystemRoles.USER,
|
||||
openIdRoleValues: 'BASIC-USER STANDARD-USER',
|
||||
rolePriority: ['STANDARD-USER', 'BASIC-USER'],
|
||||
}),
|
||||
).toEqual({
|
||||
selectedRole: 'STANDARD-USER',
|
||||
reason: 'matched_priority',
|
||||
});
|
||||
});
|
||||
|
||||
it('matches roles case-insensitively and returns the configured canonical role name', () => {
|
||||
expect(
|
||||
selectOpenIdRole({
|
||||
openIdRoleValues: ['standard-user'],
|
||||
rolePriority: ['STANDARD-USER'],
|
||||
}).selectedRole,
|
||||
).toBe('STANDARD-USER');
|
||||
});
|
||||
|
||||
it('uses rolePriority as the ordered assignable role list', () => {
|
||||
expect(
|
||||
selectOpenIdRole({
|
||||
currentRole: 'BASIC-USER',
|
||||
openIdRoleValues: ['BASIC-USER', 'STANDARD-USER'],
|
||||
rolePriority: ['BASIC-USER', 'STANDARD-USER'],
|
||||
}),
|
||||
).toEqual({
|
||||
selectedRole: 'BASIC-USER',
|
||||
reason: 'matched_priority',
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores token values that are not in rolePriority or fallbackRole', () => {
|
||||
expect(
|
||||
selectOpenIdRole({
|
||||
currentRole: SystemRoles.USER,
|
||||
openIdRoleValues: ['BASIC-USER', 'STANDARD-USER'],
|
||||
rolePriority: [],
|
||||
}),
|
||||
).toEqual({
|
||||
reason: 'no_matching_role',
|
||||
});
|
||||
});
|
||||
|
||||
it('applies fallback only when no configured role matches', () => {
|
||||
expect(
|
||||
selectOpenIdRole({
|
||||
currentRole: 'BASIC-USER',
|
||||
openIdRoleValues: ['UNKNOWN'],
|
||||
rolePriority: ['BASIC-USER'],
|
||||
fallbackRole: SystemRoles.USER,
|
||||
}),
|
||||
).toEqual({
|
||||
selectedRole: SystemRoles.USER,
|
||||
reason: 'fallback',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not apply fallback when a priority role already matches', () => {
|
||||
expect(
|
||||
selectOpenIdRole({
|
||||
openIdRoleValues: ['BASIC-USER'],
|
||||
rolePriority: ['BASIC-USER'],
|
||||
fallbackRole: SystemRoles.USER,
|
||||
}),
|
||||
).toEqual({
|
||||
selectedRole: 'BASIC-USER',
|
||||
reason: 'matched_priority',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the current fallback role when it is present in the OpenID values', () => {
|
||||
expect(
|
||||
selectOpenIdRole({
|
||||
currentRole: SystemRoles.USER,
|
||||
openIdRoleValues: [SystemRoles.USER],
|
||||
rolePriority: ['BASIC-USER'],
|
||||
fallbackRole: SystemRoles.USER,
|
||||
}),
|
||||
).toEqual({
|
||||
selectedRole: SystemRoles.USER,
|
||||
reason: 'kept_current',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not keep a current role that is outside rolePriority and fallbackRole', () => {
|
||||
expect(
|
||||
selectOpenIdRole({
|
||||
currentRole: 'LOCAL-ROLE',
|
||||
openIdRoleValues: ['LOCAL-ROLE'],
|
||||
rolePriority: ['STANDARD-USER'],
|
||||
fallbackRole: SystemRoles.USER,
|
||||
}),
|
||||
).toEqual({
|
||||
selectedRole: SystemRoles.USER,
|
||||
reason: 'fallback',
|
||||
});
|
||||
});
|
||||
|
||||
it('selects fallback when the fallback role is present and no priority role matches', () => {
|
||||
expect(
|
||||
selectOpenIdRole({
|
||||
currentRole: 'BASIC-USER',
|
||||
openIdRoleValues: [SystemRoles.USER],
|
||||
rolePriority: ['STANDARD-USER'],
|
||||
fallbackRole: SystemRoles.USER,
|
||||
}),
|
||||
).toEqual({
|
||||
selectedRole: SystemRoles.USER,
|
||||
reason: 'fallback',
|
||||
});
|
||||
});
|
||||
|
||||
it('excludes ADMIN from generic matching and fallback assignment', () => {
|
||||
expect(
|
||||
selectOpenIdRole({
|
||||
openIdRoleValues: [SystemRoles.ADMIN],
|
||||
rolePriority: [SystemRoles.ADMIN],
|
||||
fallbackRole: SystemRoles.ADMIN,
|
||||
}),
|
||||
).toEqual({
|
||||
reason: 'no_matching_role',
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores unknown normalized values without affecting priority selection', () => {
|
||||
expect(
|
||||
selectOpenIdRole({
|
||||
openIdRoleValues: ['UNKNOWN', 'unknown', 'BASIC-USER', 'ignored'],
|
||||
rolePriority: ['BASIC-USER'],
|
||||
}),
|
||||
).toEqual({
|
||||
selectedRole: 'BASIC-USER',
|
||||
reason: 'matched_priority',
|
||||
});
|
||||
});
|
||||
});
|
||||
314
packages/api/src/auth/openidRoleSync.ts
Normal file
314
packages/api/src/auth/openidRoleSync.ts
Normal file
|
|
@ -0,0 +1,314 @@
|
|||
import { get } from 'lodash';
|
||||
import { SystemRoles } from 'librechat-data-provider';
|
||||
import { isEnabled } from '~/utils';
|
||||
|
||||
export type OpenIdRoleSyncClaimSource = 'access' | 'id' | 'userinfo';
|
||||
|
||||
export type OpenIdRoleSyncOptions = {
|
||||
enabled: boolean;
|
||||
apiEnabled: boolean;
|
||||
claimSource: OpenIdRoleSyncClaimSource;
|
||||
claim?: string;
|
||||
rolePriority: string[];
|
||||
fallbackRole?: string;
|
||||
};
|
||||
|
||||
type OpenIdRoleSyncSelectionInput = {
|
||||
currentRole?: string;
|
||||
openIdRoleValues?: string | unknown[];
|
||||
rolePriority: string[];
|
||||
fallbackRole?: string;
|
||||
};
|
||||
|
||||
export type OpenIdRoleSyncSelectionResult = {
|
||||
selectedRole?: string;
|
||||
reason?: 'matched_priority' | 'kept_current' | 'fallback' | 'no_matching_role';
|
||||
};
|
||||
|
||||
type OpenIdRolesForOpenIdSyncInput = {
|
||||
options: OpenIdRoleSyncOptions;
|
||||
accessToken?: string;
|
||||
accessClaims?: unknown;
|
||||
idToken?: string;
|
||||
claims?: unknown;
|
||||
userinfo?: unknown;
|
||||
decodeToken: (token: string) => unknown;
|
||||
resolveGroupOverage?: () => Promise<string[] | null>;
|
||||
};
|
||||
|
||||
type OpenIdRoleSyncRolesLookup = (
|
||||
roleNames: string[],
|
||||
fieldsToSelect?: string | string[] | null,
|
||||
) => Promise<Array<{ name?: string | null }> | null | undefined>;
|
||||
|
||||
type LibreChatRolesForOpenIdSyncInput = {
|
||||
rolePriority: string[];
|
||||
fallbackRole?: string;
|
||||
getRolesByNames: OpenIdRoleSyncRolesLookup;
|
||||
logPrefix?: string;
|
||||
};
|
||||
|
||||
type LibreChatRolesForOpenIdSync = {
|
||||
rolePriority: string[];
|
||||
fallbackRole?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Reads and validates the OPENID_ROLE_SYNC_* environment configuration.
|
||||
*/
|
||||
export function getOpenIdRoleSyncOptions(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): OpenIdRoleSyncOptions {
|
||||
const enabled = isEnabled(env.OPENID_ROLE_SYNC_ENABLED);
|
||||
const apiEnabled = isEnabled(env.OPENID_ROLE_SYNC_API_ENABLED);
|
||||
const rawSource = env.OPENID_ROLE_SYNC_SOURCE?.trim() || 'id';
|
||||
const claimSource = rawSource as OpenIdRoleSyncClaimSource;
|
||||
const claim = env.OPENID_ROLE_SYNC_CLAIM?.trim() || undefined;
|
||||
const rolePriority =
|
||||
env.OPENID_ROLE_SYNC_ROLE_PRIORITY?.split(',')
|
||||
.map((role) => role.trim())
|
||||
.filter(Boolean) ?? [];
|
||||
const fallbackRole = env.OPENID_ROLE_SYNC_FALLBACK_ROLE?.trim() || undefined;
|
||||
|
||||
if (apiEnabled && !enabled) {
|
||||
throw new Error(
|
||||
'[openidRoleSync] OPENID_ROLE_SYNC_API_ENABLED requires OPENID_ROLE_SYNC_ENABLED=true',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Only validate role-sync-specific settings once the feature is enabled. A
|
||||
* disabled deployment must not fail OpenID login just because a stale or
|
||||
* mistyped OPENID_ROLE_SYNC_* value is left in the environment.
|
||||
*/
|
||||
if (!enabled) {
|
||||
return { enabled, apiEnabled, claimSource, claim, rolePriority, fallbackRole };
|
||||
}
|
||||
|
||||
if (!['access', 'id', 'userinfo'].includes(claimSource)) {
|
||||
throw new Error(
|
||||
`[openidRoleSync] OPENID_ROLE_SYNC_SOURCE must be one of: access, id, userinfo`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!claim) {
|
||||
throw new Error(
|
||||
'[openidRoleSync] OPENID_ROLE_SYNC_CLAIM is required when role sync is enabled',
|
||||
);
|
||||
}
|
||||
|
||||
if (rolePriority.some((role) => role.toLowerCase() === SystemRoles.ADMIN.toLowerCase())) {
|
||||
throw new Error('[openidRoleSync] OPENID_ROLE_SYNC_ROLE_PRIORITY cannot include ADMIN');
|
||||
}
|
||||
|
||||
if (fallbackRole?.toLowerCase() === SystemRoles.ADMIN.toLowerCase()) {
|
||||
throw new Error('[openidRoleSync] OPENID_ROLE_SYNC_FALLBACK_ROLE cannot be ADMIN');
|
||||
}
|
||||
|
||||
return { enabled, apiEnabled, claimSource, claim, rolePriority, fallbackRole };
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the configured role claim from the configured OpenID source.
|
||||
* Callers provide token decoding and optional group-overage resolution.
|
||||
*/
|
||||
export async function getOpenIdRolesForOpenIdSync({
|
||||
options,
|
||||
accessToken,
|
||||
accessClaims,
|
||||
idToken,
|
||||
claims,
|
||||
userinfo,
|
||||
decodeToken,
|
||||
resolveGroupOverage,
|
||||
}: OpenIdRolesForOpenIdSyncInput): Promise<string | unknown[] | undefined> {
|
||||
let source: unknown;
|
||||
|
||||
switch (options.claimSource) {
|
||||
case 'access':
|
||||
source = accessClaims ?? (accessToken ? decodeToken(accessToken) : undefined);
|
||||
break;
|
||||
case 'id':
|
||||
source = idToken ? decodeToken(idToken) : claims;
|
||||
break;
|
||||
case 'userinfo':
|
||||
source = userinfo;
|
||||
break;
|
||||
}
|
||||
|
||||
if (!source || !options.claim) {
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Azure AD/Entra moves an oversized `groups` claim into `_claim_names`/`_claim_sources`
|
||||
* for both ID and access tokens, so overage resolution must cover both sources — not
|
||||
* just the ID token — or `access`-sourced syncs silently see no groups for those users.
|
||||
*/
|
||||
const supportsGroupOverage =
|
||||
options.claim === 'groups' &&
|
||||
(options.claimSource === 'id' || options.claimSource === 'access');
|
||||
if (supportsGroupOverage) {
|
||||
const claimsData = source as {
|
||||
hasgroups?: unknown;
|
||||
_claim_names?: { groups?: string };
|
||||
_claim_sources?: Record<string, unknown>;
|
||||
};
|
||||
const groupSource = claimsData._claim_names?.groups;
|
||||
const hasGroupOverage = Boolean(
|
||||
claimsData.hasgroups || (groupSource && claimsData._claim_sources?.[groupSource]),
|
||||
);
|
||||
|
||||
if (hasGroupOverage) {
|
||||
return (await resolveGroupOverage?.()) ?? undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const openIdRoleValues = get(source, options.claim);
|
||||
if (Array.isArray(openIdRoleValues) || typeof openIdRoleValues === 'string') {
|
||||
return openIdRoleValues;
|
||||
}
|
||||
/**
|
||||
* The source is available but carries no usable value for the configured claim
|
||||
* (absent, null, or a non-string/array type). Return an empty list rather than
|
||||
* `undefined` so callers still run selection and apply the configured fallback,
|
||||
* instead of leaving a stale elevated role in place.
|
||||
*/
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the configured LibreChat roles that OpenID role sync is allowed to assign.
|
||||
* The names are read from config, checked against storage, and returned as canonical role names.
|
||||
*/
|
||||
export async function getLibreChatRolesForOpenIdSync(
|
||||
input: LibreChatRolesForOpenIdSyncInput,
|
||||
): Promise<LibreChatRolesForOpenIdSync> {
|
||||
const roleNames = input.fallbackRole
|
||||
? [...input.rolePriority, input.fallbackRole]
|
||||
: input.rolePriority;
|
||||
const uniqueRoleNames: string[] = [];
|
||||
const seenRoleKeys = new Set<string>();
|
||||
|
||||
for (const roleName of roleNames) {
|
||||
const trimmed = roleName.trim();
|
||||
const key = trimmed.toLowerCase();
|
||||
|
||||
if (!trimmed || seenRoleKeys.has(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seenRoleKeys.add(key);
|
||||
uniqueRoleNames.push(trimmed);
|
||||
}
|
||||
|
||||
const roles = (await input.getRolesByNames(uniqueRoleNames, 'name')) ?? [];
|
||||
const existingRoleNames = new Map(
|
||||
roles
|
||||
.filter((role): role is { name: string } => typeof role?.name === 'string')
|
||||
.map((role) => [role.name.trim().toLowerCase(), role.name.trim()]),
|
||||
);
|
||||
/**
|
||||
* System roles (e.g. USER) are provisioned globally at startup without a tenant
|
||||
* context, so a tenant-scoped lookup may not return them even though they exist.
|
||||
* Treat them as always-available canonical names so a documented system fallback
|
||||
* role does not fail validation for tenant users.
|
||||
*/
|
||||
for (const systemRole of Object.values(SystemRoles)) {
|
||||
const key = systemRole.toLowerCase();
|
||||
if (!existingRoleNames.has(key)) {
|
||||
existingRoleNames.set(key, systemRole);
|
||||
}
|
||||
}
|
||||
const missingRoleNames = uniqueRoleNames.filter(
|
||||
(roleName) => !existingRoleNames.has(roleName.toLowerCase()),
|
||||
);
|
||||
|
||||
if (missingRoleNames.length > 0) {
|
||||
throw new Error(
|
||||
`${input.logPrefix ?? '[openidRoleSync]'} OpenID role sync configured roles do not exist: ${missingRoleNames.join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
rolePriority: input.rolePriority.map(
|
||||
(roleName) => existingRoleNames.get(roleName.trim().toLowerCase()) ?? roleName.trim(),
|
||||
),
|
||||
fallbackRole: input.fallbackRole
|
||||
? (existingRoleNames.get(input.fallbackRole.trim().toLowerCase()) ??
|
||||
input.fallbackRole.trim())
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Chooses the LibreChat role to assign from normalized OpenID token values and validated config.
|
||||
* Priority roles win first, then the current fallback role can be preserved, then fallback applies.
|
||||
*/
|
||||
export function selectOpenIdRole(
|
||||
input: OpenIdRoleSyncSelectionInput,
|
||||
): OpenIdRoleSyncSelectionResult {
|
||||
const assignableRoles = input.fallbackRole
|
||||
? [...input.rolePriority, input.fallbackRole]
|
||||
: input.rolePriority;
|
||||
const openIdRoleValues = Array.isArray(input.openIdRoleValues)
|
||||
? input.openIdRoleValues
|
||||
: (input.openIdRoleValues?.split(/[\s,]+/) ?? []);
|
||||
const assignableRoleKeys = new Set(
|
||||
assignableRoles
|
||||
.map((role) => role.trim().toLowerCase())
|
||||
.filter((role) => role && role !== SystemRoles.ADMIN.toLowerCase()),
|
||||
);
|
||||
const openIdRoleKeys = new Set<string>();
|
||||
|
||||
// Keep only OpenID roles that can map to configured LibreChat roles.
|
||||
for (const value of openIdRoleValues) {
|
||||
if (typeof value !== 'string') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
const key = trimmed.toLowerCase();
|
||||
|
||||
if (
|
||||
!trimmed ||
|
||||
key === SystemRoles.ADMIN.toLowerCase() ||
|
||||
(assignableRoleKeys.size > 0 && !assignableRoleKeys.has(key))
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
openIdRoleKeys.add(key);
|
||||
}
|
||||
|
||||
for (const role of input.rolePriority) {
|
||||
const trimmed = role.trim();
|
||||
|
||||
if (
|
||||
trimmed &&
|
||||
trimmed.toLowerCase() !== SystemRoles.ADMIN.toLowerCase() &&
|
||||
openIdRoleKeys.has(trimmed.toLowerCase())
|
||||
) {
|
||||
return { selectedRole: trimmed, reason: 'matched_priority' };
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
input.currentRole &&
|
||||
input.fallbackRole &&
|
||||
input.currentRole.trim().toLowerCase() === input.fallbackRole.trim().toLowerCase() &&
|
||||
openIdRoleKeys.has(input.currentRole.trim().toLowerCase())
|
||||
) {
|
||||
return { selectedRole: input.currentRole, reason: 'kept_current' };
|
||||
}
|
||||
|
||||
if (input.fallbackRole) {
|
||||
const trimmed = input.fallbackRole.trim();
|
||||
|
||||
if (trimmed && trimmed.toLowerCase() !== SystemRoles.ADMIN.toLowerCase()) {
|
||||
return { selectedRole: trimmed, reason: 'fallback' };
|
||||
}
|
||||
}
|
||||
|
||||
return { reason: 'no_matching_role' };
|
||||
}
|
||||
|
|
@ -46,15 +46,17 @@ jest.mock('../auth/openid', () => {
|
|||
|
||||
import jwt from 'jsonwebtoken';
|
||||
import jwksRsa from 'jwks-rsa';
|
||||
import { SystemRoles } from 'librechat-data-provider';
|
||||
import { ProxyAgent, fetch as undiciFetch } from 'undici';
|
||||
import { logger, tenantStorage } from '@librechat/data-schemas';
|
||||
import { clearRemoteAgentAuthCache, createRemoteAgentAuth } from './remoteAgentAuth';
|
||||
import { findOpenIDUser, getOpenIdEmail } from '../auth/openid';
|
||||
import { math } from '~/utils';
|
||||
import { isEnabled, math } from '~/utils';
|
||||
|
||||
const mockFetch = undiciFetch as jest.Mock;
|
||||
const mockProxyAgent = ProxyAgent as unknown as jest.Mock;
|
||||
const mockMath = math as jest.Mock;
|
||||
const mockIsEnabled = isEnabled as jest.Mock;
|
||||
const realFindOpenIDUser =
|
||||
jest.requireActual<typeof import('../auth/openid')>('../auth/openid').findOpenIDUser;
|
||||
const mockFindOpenIDUser = findOpenIDUser as jest.MockedFunction<typeof findOpenIDUser>;
|
||||
|
|
@ -66,6 +68,12 @@ const ENV_KEYS = [
|
|||
'OPENID_JWKS_URL',
|
||||
'OPENID_JWKS_URL_CACHE_ENABLED',
|
||||
'OPENID_JWKS_URL_CACHE_TIME',
|
||||
'OPENID_ROLE_SYNC_ENABLED',
|
||||
'OPENID_ROLE_SYNC_API_ENABLED',
|
||||
'OPENID_ROLE_SYNC_SOURCE',
|
||||
'OPENID_ROLE_SYNC_CLAIM',
|
||||
'OPENID_ROLE_SYNC_ROLE_PRIORITY',
|
||||
'OPENID_ROLE_SYNC_FALLBACK_ROLE',
|
||||
'PROXY',
|
||||
] as const;
|
||||
|
||||
|
|
@ -92,6 +100,12 @@ const originalEnv = ENV_KEYS.reduce<Record<(typeof ENV_KEYS)[number], string | u
|
|||
OPENID_JWKS_URL: undefined,
|
||||
OPENID_JWKS_URL_CACHE_ENABLED: undefined,
|
||||
OPENID_JWKS_URL_CACHE_TIME: undefined,
|
||||
OPENID_ROLE_SYNC_ENABLED: undefined,
|
||||
OPENID_ROLE_SYNC_API_ENABLED: undefined,
|
||||
OPENID_ROLE_SYNC_SOURCE: undefined,
|
||||
OPENID_ROLE_SYNC_CLAIM: undefined,
|
||||
OPENID_ROLE_SYNC_ROLE_PRIORITY: undefined,
|
||||
OPENID_ROLE_SYNC_FALLBACK_ROLE: undefined,
|
||||
PROXY: undefined,
|
||||
},
|
||||
);
|
||||
|
|
@ -204,6 +218,9 @@ function makeDeps(appConfig: AppConfig = makeConfig()) {
|
|||
return {
|
||||
findUser: makeFindUser(makeUser()),
|
||||
updateUser: jest.fn(),
|
||||
getRolesByNames: jest.fn(async (roleNames: string[]) =>
|
||||
roleNames.map((roleName) => ({ name: roleName })),
|
||||
),
|
||||
getAppConfig: jest.fn().mockResolvedValue(appConfig),
|
||||
apiKeyMiddleware: jest.fn((_req: unknown, _res: unknown, next: () => void) => next()),
|
||||
};
|
||||
|
|
@ -229,6 +246,7 @@ describe('createRemoteAgentAuth', () => {
|
|||
clearRemoteAgentAuthCache();
|
||||
mockFetch.mockReset();
|
||||
mockMath.mockReturnValue(60000);
|
||||
mockIsEnabled.mockImplementation((value?: string) => value === 'true');
|
||||
mockFindOpenIDUser.mockImplementation(realFindOpenIDUser);
|
||||
mockNext = jest.fn();
|
||||
});
|
||||
|
|
@ -1416,6 +1434,186 @@ describe('createRemoteAgentAuth', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('OpenID role sync', () => {
|
||||
function enableApiRoleSync(overrides: Record<string, string> = {}) {
|
||||
process.env.OPENID_ROLE_SYNC_ENABLED = 'true';
|
||||
process.env.OPENID_ROLE_SYNC_API_ENABLED = 'true';
|
||||
process.env.OPENID_ROLE_SYNC_SOURCE = 'access';
|
||||
process.env.OPENID_ROLE_SYNC_CLAIM = 'roles';
|
||||
process.env.OPENID_ROLE_SYNC_ROLE_PRIORITY = 'STANDARD-USER,BASIC-USER';
|
||||
process.env.OPENID_ROLE_SYNC_FALLBACK_ROLE = 'USER';
|
||||
|
||||
for (const [key, value] of Object.entries(overrides)) {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
it('does not run unless API role sync is explicitly enabled', async () => {
|
||||
process.env.OPENID_ROLE_SYNC_ENABLED = 'true';
|
||||
process.env.OPENID_ROLE_SYNC_SOURCE = 'access';
|
||||
process.env.OPENID_ROLE_SYNC_CLAIM = 'roles';
|
||||
process.env.OPENID_ROLE_SYNC_ROLE_PRIORITY = 'STANDARD-USER';
|
||||
setupOidcMocks({ sub: 'sub123', email: 'agent@test.com', roles: ['STANDARD-USER'] });
|
||||
|
||||
const deps = makeDeps();
|
||||
await createRemoteAgentAuth(deps)(
|
||||
makeReq({ authorization: `Bearer ${FAKE_TOKEN}` }) as Request,
|
||||
makeRes().res,
|
||||
mockNext,
|
||||
);
|
||||
|
||||
expect(deps.getRolesByNames).not.toHaveBeenCalled();
|
||||
expect(deps.updateUser).not.toHaveBeenCalled();
|
||||
expect(mockNext).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('selects the highest configured matching role from the verified bearer payload', async () => {
|
||||
enableApiRoleSync();
|
||||
setupOidcMocks({
|
||||
sub: 'sub123',
|
||||
email: 'agent@test.com',
|
||||
roles: ['BASIC-USER', 'STANDARD-USER'],
|
||||
});
|
||||
|
||||
const deps = makeDeps();
|
||||
const req = makeReq({ authorization: `Bearer ${FAKE_TOKEN}` });
|
||||
await createRemoteAgentAuth(deps)(req as Request, makeRes().res, mockNext);
|
||||
|
||||
expect(deps.updateUser).toHaveBeenCalledWith('uid123', { role: 'STANDARD-USER' });
|
||||
expect(req.user).toMatchObject({ role: 'STANDARD-USER' });
|
||||
expect(mockNext).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('applies fallback when the verified payload has no configured role match', async () => {
|
||||
enableApiRoleSync();
|
||||
setupOidcMocks({
|
||||
sub: 'sub123',
|
||||
email: 'agent@test.com',
|
||||
roles: ['external-role'],
|
||||
});
|
||||
|
||||
const deps = makeDeps();
|
||||
const req = makeReq({ authorization: `Bearer ${FAKE_TOKEN}` });
|
||||
await createRemoteAgentAuth(deps)(req as Request, makeRes().res, mockNext);
|
||||
|
||||
expect(deps.updateUser).toHaveBeenCalledWith('uid123', { role: 'USER' });
|
||||
expect(req.user).toMatchObject({ role: 'USER' });
|
||||
});
|
||||
|
||||
it('applies fallback when the role claim is present but empty', async () => {
|
||||
enableApiRoleSync();
|
||||
setupOidcMocks({
|
||||
sub: 'sub123',
|
||||
email: 'agent@test.com',
|
||||
roles: '',
|
||||
});
|
||||
|
||||
const deps = makeDeps();
|
||||
const req = makeReq({ authorization: `Bearer ${FAKE_TOKEN}` });
|
||||
await createRemoteAgentAuth(deps)(req as Request, makeRes().res, mockNext);
|
||||
|
||||
expect(deps.updateUser).toHaveBeenCalledWith('uid123', { role: 'USER' });
|
||||
expect(req.user).toMatchObject({ role: 'USER' });
|
||||
});
|
||||
|
||||
it('preserves an existing ADMIN role because generic role sync cannot manage admin', async () => {
|
||||
enableApiRoleSync();
|
||||
setupOidcMocks({
|
||||
sub: 'sub123',
|
||||
email: 'agent@test.com',
|
||||
roles: ['STANDARD-USER'],
|
||||
});
|
||||
|
||||
const deps = makeDeps();
|
||||
deps.findUser = makeFindUser(makeUser({ role: SystemRoles.ADMIN }));
|
||||
const req = makeReq({ authorization: `Bearer ${FAKE_TOKEN}` });
|
||||
await createRemoteAgentAuth(deps)(req as Request, makeRes().res, mockNext);
|
||||
|
||||
expect(deps.getRolesByNames).not.toHaveBeenCalled();
|
||||
expect(deps.updateUser).not.toHaveBeenCalled();
|
||||
expect(req.user).toMatchObject({ role: SystemRoles.ADMIN });
|
||||
});
|
||||
|
||||
it('leaves the role unchanged when the configured source is unavailable to API auth', async () => {
|
||||
enableApiRoleSync({ OPENID_ROLE_SYNC_SOURCE: 'id' });
|
||||
setupOidcMocks({ sub: 'sub123', email: 'agent@test.com', roles: ['STANDARD-USER'] });
|
||||
|
||||
const deps = makeDeps();
|
||||
const req = makeReq({ authorization: `Bearer ${FAKE_TOKEN}` });
|
||||
await createRemoteAgentAuth(deps)(req as Request, makeRes().res, mockNext);
|
||||
|
||||
expect(deps.getRolesByNames).not.toHaveBeenCalled();
|
||||
expect(deps.updateUser).not.toHaveBeenCalled();
|
||||
expect(req.user).toMatchObject({ role: 'user' });
|
||||
});
|
||||
|
||||
it('does not apply fallback when API group overage is unresolved', async () => {
|
||||
enableApiRoleSync({ OPENID_ROLE_SYNC_CLAIM: 'groups' });
|
||||
setupOidcMocks({
|
||||
sub: 'sub123',
|
||||
email: 'agent@test.com',
|
||||
hasgroups: true,
|
||||
});
|
||||
|
||||
const deps = makeDeps();
|
||||
const req = makeReq({ authorization: `Bearer ${FAKE_TOKEN}` });
|
||||
await createRemoteAgentAuth(deps)(req as Request, makeRes().res, mockNext);
|
||||
|
||||
expect(deps.updateUser).not.toHaveBeenCalled();
|
||||
expect(req.user).toMatchObject({ role: 'user' });
|
||||
});
|
||||
|
||||
it('runs role lookup and persistence in the resolved user tenant context', async () => {
|
||||
enableApiRoleSync();
|
||||
setupOidcMocks({ sub: 'sub123', email: 'agent@test.com', roles: ['BASIC-USER'] });
|
||||
|
||||
const deps = makeDeps();
|
||||
deps.findUser = makeFindUser(makeUser({ tenantId: 'tenant-role-sync' }));
|
||||
deps.getAppConfig.mockImplementation(async (options) =>
|
||||
options?.tenantId === 'tenant-role-sync'
|
||||
? makeConfig({ scope: undefined }, { enabled: false })
|
||||
: makeConfig({ scope: undefined }, { enabled: true }),
|
||||
);
|
||||
const req = makeReq({ authorization: `Bearer ${FAKE_TOKEN}` });
|
||||
|
||||
await createRemoteAgentAuth(deps)(req as Request, makeRes().res, mockNext);
|
||||
|
||||
expect(deps.getRolesByNames).toHaveBeenCalledWith(
|
||||
['STANDARD-USER', 'BASIC-USER', 'USER'],
|
||||
'name',
|
||||
);
|
||||
expect(deps.updateUser).toHaveBeenCalledWith('uid123', { role: 'BASIC-USER' });
|
||||
expect(req.user).toMatchObject({ tenantId: 'tenant-role-sync', role: 'BASIC-USER' });
|
||||
});
|
||||
|
||||
it('re-checks resolved user policy after role sync changes the role', async () => {
|
||||
enableApiRoleSync();
|
||||
setupOidcMocks({ sub: 'sub123', email: 'agent@test.com', roles: ['STANDARD-USER'] });
|
||||
|
||||
const deps = makeDeps();
|
||||
deps.findUser = makeFindUser(makeUser({ tenantId: 'tenant-role-sync', role: 'BASIC-USER' }));
|
||||
deps.getAppConfig.mockImplementation(async (options) => {
|
||||
if (options?.tenantId !== 'tenant-role-sync') {
|
||||
return makeConfig({ scope: undefined }, { enabled: true });
|
||||
}
|
||||
|
||||
return options.role === 'STANDARD-USER'
|
||||
? makeConfig({ enabled: false }, { enabled: false })
|
||||
: makeConfig({ scope: undefined }, { enabled: false });
|
||||
});
|
||||
const req = makeReq({ authorization: `Bearer ${FAKE_TOKEN}` });
|
||||
const { res, status, json } = makeRes();
|
||||
|
||||
await createRemoteAgentAuth(deps)(req as Request, res, mockNext);
|
||||
|
||||
expect(status).toHaveBeenCalledWith(401);
|
||||
expect(json).toHaveBeenCalledWith({ error: 'Unauthorized' });
|
||||
expect(deps.updateUser).not.toHaveBeenCalled();
|
||||
expect(req.user).toBeUndefined();
|
||||
expect(mockNext).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('scope validation', () => {
|
||||
it('returns 401 when required scope is missing from token', async () => {
|
||||
setupOidcMocks({ sub: 'sub123', email: 'agent@test.com', scope: 'openid profile' });
|
||||
|
|
|
|||
|
|
@ -2,20 +2,27 @@ 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 { getTenantId, logger, tenantStorage } 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 { AppConfig, IUser, RoleMethods, 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 {
|
||||
getLibreChatRolesForOpenIdSync,
|
||||
getOpenIdRolesForOpenIdSync,
|
||||
getOpenIdRoleSyncOptions,
|
||||
selectOpenIdRole,
|
||||
} from '../auth/openidRoleSync';
|
||||
import { isEnabled, math } from '~/utils';
|
||||
|
||||
export interface RemoteAgentAuthDeps {
|
||||
apiKeyMiddleware: RequestHandler;
|
||||
findUser: UserMethods['findUser'];
|
||||
getRolesByNames: RoleMethods['findRolesByNames'];
|
||||
updateUser: UserMethods['updateUser'];
|
||||
getAppConfig: (options?: GetAppConfigOptions) => Promise<AppConfig>;
|
||||
}
|
||||
|
|
@ -444,6 +451,87 @@ async function resolveUser(
|
|||
return { status: 'resolved', user, updateData };
|
||||
}
|
||||
|
||||
async function selectOpenIdRoleForOpenIdSync(
|
||||
payload: JwtPayload,
|
||||
user: IUser,
|
||||
getRolesByNames: RemoteAgentAuthDeps['getRolesByNames'],
|
||||
): Promise<string | undefined> {
|
||||
const options = getOpenIdRoleSyncOptions();
|
||||
if (!options.enabled || !options.apiEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (user.role === SystemRoles.ADMIN) {
|
||||
logger.info(
|
||||
`[remoteAgentAuth] OpenID role sync skipped for ${user.id}; existing ADMIN role is not managed by generic role sync`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.claimSource !== 'access') {
|
||||
logger.warn(
|
||||
`[remoteAgentAuth] OpenID role sync skipped; source '${options.claimSource}' is not available for API auth`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const openIdRoleValues = await getOpenIdRolesForOpenIdSync({
|
||||
options,
|
||||
accessClaims: payload,
|
||||
decodeToken: () => payload,
|
||||
});
|
||||
if (openIdRoleValues === undefined) {
|
||||
logger.warn(
|
||||
`[remoteAgentAuth] OpenID role sync skipped; claim '${options.claim}' was not found or invalid`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const loadLibreChatRoles = async () =>
|
||||
getLibreChatRolesForOpenIdSync({
|
||||
getRolesByNames,
|
||||
rolePriority: options.rolePriority,
|
||||
fallbackRole: options.fallbackRole,
|
||||
logPrefix: '[remoteAgentAuth]',
|
||||
});
|
||||
const { rolePriority, fallbackRole } =
|
||||
user.tenantId && getTenantId() !== user.tenantId
|
||||
? await tenantStorage.run({ tenantId: user.tenantId }, loadLibreChatRoles)
|
||||
: await loadLibreChatRoles();
|
||||
const result = selectOpenIdRole({
|
||||
currentRole: user.role,
|
||||
openIdRoleValues,
|
||||
rolePriority,
|
||||
fallbackRole,
|
||||
});
|
||||
|
||||
if (!result.selectedRole || result.selectedRole === user.role) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`[remoteAgentAuth] OpenID role sync selected role for ${user.id}: ${user.role || 'unset'} -> ${result.selectedRole}`,
|
||||
);
|
||||
return result.selectedRole;
|
||||
}
|
||||
|
||||
async function updateResolvedUser(
|
||||
userResolution: Extract<UserResolution, { status: 'resolved' }>,
|
||||
updateUser: RemoteAgentAuthDeps['updateUser'],
|
||||
): Promise<void> {
|
||||
if (Object.keys(userResolution.updateData).length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const update = async () => updateUser(userResolution.user.id, userResolution.updateData);
|
||||
if (userResolution.user.tenantId && getTenantId() !== userResolution.user.tenantId) {
|
||||
await tenantStorage.run({ tenantId: userResolution.user.tenantId }, update);
|
||||
return;
|
||||
}
|
||||
|
||||
await update();
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory for Remote Agent API auth middleware.
|
||||
*
|
||||
|
|
@ -468,6 +556,7 @@ async function resolveUser(
|
|||
export function createRemoteAgentAuth({
|
||||
apiKeyMiddleware,
|
||||
findUser,
|
||||
getRolesByNames,
|
||||
updateUser,
|
||||
getAppConfig,
|
||||
}: RemoteAgentAuthDeps): RequestHandler {
|
||||
|
|
@ -569,10 +658,32 @@ export function createRemoteAgentAuth({
|
|||
return;
|
||||
}
|
||||
|
||||
if (Object.keys(userResolution.updateData).length > 0) {
|
||||
await updateUser(userResolution.user.id, userResolution.updateData);
|
||||
const selectedRole = await selectOpenIdRoleForOpenIdSync(
|
||||
payload,
|
||||
userResolution.user,
|
||||
getRolesByNames,
|
||||
);
|
||||
const roleChanged = Boolean(selectedRole);
|
||||
if (selectedRole) {
|
||||
userResolution.user.role = selectedRole;
|
||||
userResolution.updateData.role = selectedRole;
|
||||
}
|
||||
|
||||
if (
|
||||
roleChanged &&
|
||||
!(await enforceOidcTenantPolicy(
|
||||
token,
|
||||
userResolution.user,
|
||||
initialConfigOptions,
|
||||
getAppConfig,
|
||||
))
|
||||
) {
|
||||
res.status(401).json({ error: 'Unauthorized' });
|
||||
return;
|
||||
}
|
||||
|
||||
await updateResolvedUser(userResolution, updateUser);
|
||||
|
||||
req.user = userResolution.user;
|
||||
return next();
|
||||
} catch (err) {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import { SystemRoles, Permissions, roleDefaults, PermissionTypes } from 'librech
|
|||
import type { IRole, IUser, RolePermissions } from '..';
|
||||
import { createRoleMethods } from './role';
|
||||
import { createModels } from '../models';
|
||||
import { _resetStrictCache } from '../models/plugins/tenantIsolation';
|
||||
import { tenantStorage } from '~/config/tenantContext';
|
||||
|
||||
jest.mock('~/config/winston', () => ({
|
||||
error: jest.fn(),
|
||||
|
|
@ -23,6 +25,7 @@ const mockGetCache = jest.fn().mockReturnValue(mockCache);
|
|||
let Role: mongoose.Model<IRole>;
|
||||
let User: mongoose.Model<IUser>;
|
||||
let getRoleByName: ReturnType<typeof createRoleMethods>['getRoleByName'];
|
||||
let findRolesByNames: ReturnType<typeof createRoleMethods>['findRolesByNames'];
|
||||
let updateAccessPermissions: ReturnType<typeof createRoleMethods>['updateAccessPermissions'];
|
||||
let initializeRoles: ReturnType<typeof createRoleMethods>['initializeRoles'];
|
||||
let createRoleByName: ReturnType<typeof createRoleMethods>['createRoleByName'];
|
||||
|
|
@ -44,6 +47,7 @@ beforeAll(async () => {
|
|||
User = mongoose.models.User as mongoose.Model<IUser>;
|
||||
const methods = createRoleMethods(mongoose, { getCache: mockGetCache });
|
||||
getRoleByName = methods.getRoleByName;
|
||||
findRolesByNames = methods.findRolesByNames;
|
||||
updateAccessPermissions = methods.updateAccessPermissions;
|
||||
initializeRoles = methods.initializeRoles;
|
||||
createRoleByName = methods.createRoleByName;
|
||||
|
|
@ -70,6 +74,90 @@ beforeEach(async () => {
|
|||
mockCache.del.mockClear();
|
||||
});
|
||||
|
||||
describe('findRolesByNames', () => {
|
||||
it('queries storage without reading or writing the role cache', async () => {
|
||||
await Role.create([
|
||||
{ name: 'STANDARD-USER', permissions: {} },
|
||||
{ name: 'BASIC-USER', permissions: {} },
|
||||
]);
|
||||
|
||||
await expect(findRolesByNames(['STANDARD-USER', 'BASIC-USER'], 'name')).resolves.toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ name: 'STANDARD-USER' }),
|
||||
expect.objectContaining({ name: 'BASIC-USER' }),
|
||||
]),
|
||||
);
|
||||
expect(mockGetCache).not.toHaveBeenCalled();
|
||||
expect(mockCache.get).not.toHaveBeenCalled();
|
||||
expect(mockCache.set).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('matches role names case-insensitively without using pagination', async () => {
|
||||
const roles = Array.from({ length: 75 }, (_value, index) => ({
|
||||
name: `ROLE-${index}`,
|
||||
permissions: {},
|
||||
}));
|
||||
await Role.create([...roles, { name: 'STANDARD-USER', permissions: {} }]);
|
||||
|
||||
await expect(findRolesByNames(['standard-user'], 'name')).resolves.toEqual([
|
||||
expect.objectContaining({ name: 'STANDARD-USER' }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('uses the active tenant context for matching role names', async () => {
|
||||
await tenantStorage.run({ tenantId: 'tenant-a' }, async () => {
|
||||
await Role.create({ name: 'TENANT-ROLE', permissions: {} });
|
||||
});
|
||||
await tenantStorage.run({ tenantId: 'tenant-b' }, async () => {
|
||||
await Role.create({ name: 'TENANT-ROLE', permissions: {} });
|
||||
});
|
||||
|
||||
const tenantARoles = await tenantStorage.run({ tenantId: 'tenant-a' }, async () =>
|
||||
findRolesByNames(['TENANT-ROLE'], 'name tenantId'),
|
||||
);
|
||||
const tenantBRoles = await tenantStorage.run({ tenantId: 'tenant-b' }, async () =>
|
||||
findRolesByNames(['TENANT-ROLE'], 'name tenantId'),
|
||||
);
|
||||
|
||||
expect(tenantARoles).toEqual([
|
||||
expect.objectContaining({ name: 'TENANT-ROLE', tenantId: 'tenant-a' }),
|
||||
]);
|
||||
expect(tenantBRoles).toEqual([
|
||||
expect.objectContaining({ name: 'TENANT-ROLE', tenantId: 'tenant-b' }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('matches only base roles when no tenant context is active', async () => {
|
||||
await Role.create({ name: 'SCOPED-ROLE', permissions: {} });
|
||||
await tenantStorage.run({ tenantId: 'tenant-a' }, async () => {
|
||||
await Role.create({ name: 'TENANT-ONLY-ROLE', permissions: {} });
|
||||
});
|
||||
|
||||
const baseMatches = await findRolesByNames(
|
||||
['SCOPED-ROLE', 'TENANT-ONLY-ROLE'],
|
||||
'name tenantId',
|
||||
);
|
||||
|
||||
expect(baseMatches).toEqual([expect.objectContaining({ name: 'SCOPED-ROLE' })]);
|
||||
expect(baseMatches).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('matches base roles without a tenant context even under strict isolation', async () => {
|
||||
await Role.create({ name: 'STRICT-BASE-ROLE', permissions: {} });
|
||||
process.env.TENANT_ISOLATION_STRICT = 'true';
|
||||
_resetStrictCache();
|
||||
|
||||
try {
|
||||
await expect(findRolesByNames(['STRICT-BASE-ROLE'], 'name')).resolves.toEqual([
|
||||
expect.objectContaining({ name: 'STRICT-BASE-ROLE' }),
|
||||
]);
|
||||
} finally {
|
||||
delete process.env.TENANT_ISOLATION_STRICT;
|
||||
_resetStrictCache();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateAccessPermissions', () => {
|
||||
it('should update permissions when changes are needed', async () => {
|
||||
await new Role({
|
||||
|
|
|
|||
|
|
@ -7,7 +7,8 @@ import {
|
|||
} from 'librechat-data-provider';
|
||||
import type { Model } from 'mongoose';
|
||||
import type { IRole, IUser } from '~/types';
|
||||
import { scopedCacheKey } from '~/config/tenantContext';
|
||||
import { scopedCacheKey, getTenantId, runAsSystem, SYSTEM_TENANT_ID } from '~/config/tenantContext';
|
||||
import { escapeRegExp } from '~/utils/string';
|
||||
import logger from '~/config/winston';
|
||||
|
||||
const systemRoleValues = new Set<string>(Object.values(SystemRoles));
|
||||
|
|
@ -123,6 +124,56 @@ export function createRoleMethods(mongoose: typeof import('mongoose'), deps: Rol
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find roles by name without using or populating the shared role-name cache.
|
||||
* Use this for tenant-scoped authorization lookups where the active ALS tenant context must control the query.
|
||||
*
|
||||
* When a non-system tenant context is active, the tenant-isolation plugin scopes the
|
||||
* query to that tenant. When no tenant context is active (base/global users), the lookup
|
||||
* runs under an explicit system context — so strict-mode isolation does not reject the
|
||||
* context-less query — while an explicit base-role filter (`tenantId` unset) ensures a
|
||||
* base user cannot match, and be assigned, a role that only exists within some tenant.
|
||||
*/
|
||||
async function findRolesByNames(
|
||||
roleNames: string[],
|
||||
fieldsToSelect: string | string[] | null = null,
|
||||
) {
|
||||
try {
|
||||
const uniqueRoleNames = [
|
||||
...new Set(roleNames.map((roleName) => roleName.trim()).filter(Boolean)),
|
||||
];
|
||||
if (uniqueRoleNames.length === 0) {
|
||||
return [] as IRole[];
|
||||
}
|
||||
|
||||
const Role = mongoose.models.Role;
|
||||
const nameFilter = {
|
||||
$or: uniqueRoleNames.map((roleName) => ({
|
||||
name: new RegExp(`^${escapeRegExp(roleName)}$`, 'i'),
|
||||
})),
|
||||
};
|
||||
|
||||
const runQuery = (filter: Record<string, unknown>) => {
|
||||
let query = Role.find(filter);
|
||||
if (fieldsToSelect) {
|
||||
query = query.select(fieldsToSelect);
|
||||
}
|
||||
return query.lean<IRole[]>().exec();
|
||||
};
|
||||
|
||||
const tenantId = getTenantId();
|
||||
if (tenantId && tenantId !== SYSTEM_TENANT_ID) {
|
||||
return await runQuery(nameFilter);
|
||||
}
|
||||
|
||||
return await runAsSystem(() =>
|
||||
runQuery({ ...nameFilter, tenantId: { $in: [null, undefined] } }),
|
||||
);
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to retrieve roles: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update role values by name.
|
||||
*/
|
||||
|
|
@ -510,6 +561,7 @@ export function createRoleMethods(mongoose: typeof import('mongoose'), deps: Rol
|
|||
countRoles,
|
||||
initializeRoles,
|
||||
getRoleByName,
|
||||
findRolesByNames,
|
||||
updateRoleByName,
|
||||
updateAccessPermissions,
|
||||
migrateRoleSchema,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue