mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-07-02 12:22:22 +00:00
* 🔧 fix: Honor NO_PROXY for OpenID requests when PROXY is set openidStrategy routed every OIDC request (issuer discovery, JWKS, token endpoint, Microsoft Graph overage resolution) through undici.ProxyAgent whenever PROXY was set. undici.ProxyAgent does not consult NO_PROXY, so OIDC providers on internal networks that the corporate proxy cannot reach failed at startup with ECONNREFUSED or discovery timeouts, even when the issuer host was listed in NO_PROXY. Replace ProxyAgent with undici.EnvHttpProxyAgent configured to use PROXY for both protocols. EnvHttpProxyAgent applies the standard NO_PROXY/no_proxy exclusion list per request host (suffix matching, leading-dot domains, host:port entries, and *), so excluded hosts are requested directly. The agent is also memoized (keyed on PROXY + NO_PROXY) instead of being constructed per request, so repeated OIDC calls reuse one connection pool. Fixes #13705 * fix: move OpenID proxy helper to api package * chore: import order in openidStrategy.js * chore: import order in openidStrategy.spec.js --------- Co-authored-by: Danny Avila <danny@librechat.ai>
2593 lines
87 KiB
JavaScript
2593 lines
87 KiB
JavaScript
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, findRolesByNames } = require('~/models');
|
||
const {
|
||
getOpenIdProxyDispatcher,
|
||
resolveAppConfigForUser,
|
||
getOpenIdIssuer,
|
||
isEnabled,
|
||
} = require('@librechat/api');
|
||
const { resizeAvatar } = require('~/server/services/Files/images/avatar');
|
||
const { getAppConfig } = require('~/server/services/Config');
|
||
const { setupOpenId } = require('./openidStrategy');
|
||
|
||
const mockCloudfrontFileSource = FileSources.cloudfront ?? 'cloudfront';
|
||
|
||
// --- Mocks ---
|
||
jest.mock('node-fetch');
|
||
jest.mock('jsonwebtoken/decode');
|
||
jest.mock('undici', () => ({
|
||
fetch: jest.fn(),
|
||
}));
|
||
jest.mock('~/server/services/Files/strategies', () => ({
|
||
getStrategyFunctions: jest.fn(() => ({
|
||
saveBuffer: jest.fn().mockResolvedValue('/fake/path/to/avatar.png'),
|
||
})),
|
||
}));
|
||
jest.mock('~/server/services/Files/images/avatar', () => ({
|
||
resizeAvatar: jest.fn().mockResolvedValue(Buffer.from('safe avatar')),
|
||
}));
|
||
jest.mock('~/server/services/Config', () => ({
|
||
getAppConfig: jest.fn().mockResolvedValue({}),
|
||
}));
|
||
jest.mock('@librechat/api', () => {
|
||
const actual = jest.requireActual('@librechat/api');
|
||
const getStringClaim = (claims, claim) => {
|
||
const value = claims[claim];
|
||
return typeof value === 'string' && value ? value : undefined;
|
||
};
|
||
|
||
return {
|
||
...actual,
|
||
isEnabled: jest.fn(() => false),
|
||
isEmailDomainAllowed: jest.fn(() => true),
|
||
findOpenIDUser: actual.findOpenIDUser,
|
||
getOpenIdEmail: jest.fn((claims, strategyName = 'openidStrategy') => {
|
||
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;
|
||
}
|
||
|
||
const { logger } = require('@librechat/data-schemas');
|
||
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')
|
||
);
|
||
}),
|
||
getBalanceConfig: jest.fn(() => ({
|
||
enabled: false,
|
||
})),
|
||
getOpenIdIssuer: jest.fn(() => 'https://fake-issuer.com'),
|
||
getOpenIdProxyDispatcher: jest.fn(() => undefined),
|
||
getAvatarFileStrategy: jest.fn((config, fallbackStrategy) => {
|
||
const { FileSources } = jest.requireActual('librechat-data-provider');
|
||
if (config?.fileStrategies) {
|
||
return config.fileStrategies.avatar ?? config.fileStrategies.default ?? config.fileStrategy;
|
||
}
|
||
return config?.fileStrategy ?? fallbackStrategy ?? FileSources.local;
|
||
}),
|
||
getAvatarSaveParams: jest.fn((strategy, params) => {
|
||
const { FileSources } = jest.requireActual('librechat-data-provider');
|
||
return strategy === FileSources.s3 || strategy === mockCloudfrontFileSource
|
||
? { ...params, basePath: 'avatars' }
|
||
: params;
|
||
}),
|
||
resolveAppConfigForUser: jest.fn(async (_getAppConfig, _user) => ({})),
|
||
};
|
||
});
|
||
jest.mock('~/models', () => ({
|
||
findUser: jest.fn(),
|
||
createUser: jest.fn(),
|
||
updateUser: jest.fn(),
|
||
findRolesByNames: jest.fn(),
|
||
}));
|
||
jest.mock('@librechat/data-schemas', () => ({
|
||
...jest.requireActual('@librechat/api'),
|
||
logger: {
|
||
info: jest.fn(),
|
||
warn: jest.fn(),
|
||
debug: jest.fn(),
|
||
error: jest.fn(),
|
||
},
|
||
tenantStorage: {
|
||
run: jest.fn((_context, fn) => fn()),
|
||
},
|
||
hashToken: jest.fn().mockResolvedValue('hashed-token'),
|
||
}));
|
||
jest.mock('~/cache/getLogStores', () =>
|
||
jest.fn(() => ({
|
||
get: jest.fn(),
|
||
set: jest.fn(),
|
||
})),
|
||
);
|
||
|
||
// Mock the openid-client module and all its dependencies
|
||
jest.mock('openid-client', () => {
|
||
return {
|
||
discovery: jest.fn().mockResolvedValue({
|
||
clientId: 'fake_client_id',
|
||
clientSecret: 'fake_client_secret',
|
||
issuer: 'https://fake-issuer.com',
|
||
// Add any other properties needed by the implementation
|
||
}),
|
||
fetchUserInfo: jest.fn().mockImplementation(() => {
|
||
// Only return additional properties, but don't override any claims
|
||
return Promise.resolve({});
|
||
}),
|
||
genericGrantRequest: jest.fn().mockResolvedValue({
|
||
access_token: 'exchanged_graph_token',
|
||
expires_in: 3600,
|
||
}),
|
||
customFetch: Symbol('customFetch'),
|
||
};
|
||
});
|
||
|
||
jest.mock('openid-client/passport', () => {
|
||
/** Store callbacks by strategy name - 'openid' and 'openidAdmin' */
|
||
const verifyCallbacks = {};
|
||
const strategies = {};
|
||
let lastVerifyCallback;
|
||
|
||
const mockStrategy = jest.fn(function (options, verify) {
|
||
lastVerifyCallback = verify;
|
||
this.name = 'openid';
|
||
this.options = options;
|
||
this.verify = verify;
|
||
});
|
||
mockStrategy.prototype.authorizationRequestParams = jest.fn(() => new URLSearchParams());
|
||
|
||
return {
|
||
Strategy: mockStrategy,
|
||
/** Get the last registered callback (for backward compatibility) */
|
||
__getVerifyCallback: () => lastVerifyCallback,
|
||
__getStrategyByName: (name) => strategies[name],
|
||
/** Store callback by name when passport.use is called */
|
||
__setStrategy: (name, strategy) => {
|
||
strategies[name] = strategy;
|
||
if (strategy?.verify) {
|
||
verifyCallbacks[name] = strategy.verify;
|
||
}
|
||
},
|
||
/** Get callback by strategy name */
|
||
__getVerifyCallbackByName: (name) => verifyCallbacks[name],
|
||
};
|
||
});
|
||
|
||
// Mock passport - capture strategy name and callback
|
||
jest.mock('passport', () => ({
|
||
use: jest.fn((name, strategy) => {
|
||
const passportMock = require('openid-client/passport');
|
||
passportMock.__setStrategy(name, strategy);
|
||
}),
|
||
}));
|
||
|
||
describe('setupOpenId', () => {
|
||
// Store a reference to the verify callback once it's set up
|
||
let verifyCallback;
|
||
|
||
// Helper to wrap the verify callback in a promise
|
||
const validate = (tokenset) =>
|
||
new Promise((resolve, reject) => {
|
||
verifyCallback(tokenset, (err, user, details) => {
|
||
if (err) {
|
||
reject(err);
|
||
} else {
|
||
resolve({ user, details });
|
||
}
|
||
});
|
||
});
|
||
|
||
const tokenset = {
|
||
id_token: 'fake_id_token',
|
||
access_token: 'fake_access_token',
|
||
claims: () => ({
|
||
sub: '1234',
|
||
email: 'test@example.com',
|
||
email_verified: true,
|
||
given_name: 'First',
|
||
family_name: 'Last',
|
||
name: 'My Full',
|
||
preferred_username: 'testusername',
|
||
username: 'flast',
|
||
picture: 'https://example.com/avatar.png',
|
||
}),
|
||
};
|
||
|
||
beforeEach(async () => {
|
||
// 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(),
|
||
}));
|
||
getOpenIdProxyDispatcher.mockReturnValue(undefined);
|
||
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';
|
||
process.env.OPENID_CLIENT_ID = 'fake_client_id';
|
||
process.env.OPENID_CLIENT_SECRET = 'fake_client_secret';
|
||
process.env.DOMAIN_SERVER = 'https://example.com';
|
||
process.env.OPENID_CALLBACK_URL = '/callback';
|
||
process.env.OPENID_SCOPE = 'openid profile email';
|
||
process.env.OPENID_REQUIRED_ROLE = 'requiredRole';
|
||
process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'roles';
|
||
process.env.OPENID_REQUIRED_ROLE_TOKEN_KIND = 'id';
|
||
process.env.OPENID_ADMIN_ROLE = 'admin';
|
||
process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'permissions';
|
||
process.env.OPENID_ADMIN_ROLE_TOKEN_KIND = 'id';
|
||
delete process.env.OPENID_USERNAME_CLAIM;
|
||
delete process.env.OPENID_NAME_CLAIM;
|
||
delete process.env.OPENID_EMAIL_CLAIM;
|
||
delete process.env.OPENID_AUDIENCE;
|
||
delete process.env.OPENID_AVATAR_AUTHORIZED_ORIGINS;
|
||
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({
|
||
roles: ['requiredRole'],
|
||
permissions: ['admin'],
|
||
});
|
||
|
||
// By default, assume that no user is found, so createUser will be called
|
||
findUser.mockResolvedValue(null);
|
||
createUser.mockImplementation(async (userData) => {
|
||
// simulate created user with an _id property
|
||
return { _id: 'newUserId', ...userData };
|
||
});
|
||
updateUser.mockImplementation(async (id, userData) => {
|
||
return { _id: id, ...userData };
|
||
});
|
||
findRolesByNames.mockImplementation(async (roleNames) =>
|
||
roleNames.map((roleName) => ({ name: roleName })),
|
||
);
|
||
|
||
resizeAvatar.mockResolvedValue(Buffer.from('safe avatar'));
|
||
|
||
// Call the setup function and capture the verify callback for the regular 'openid' strategy
|
||
// (not 'openidAdmin' which requires existing users)
|
||
await setupOpenId();
|
||
verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid');
|
||
});
|
||
|
||
describe('clientMetadata construction in setupOpenId', () => {
|
||
let openidClient;
|
||
|
||
beforeEach(() => {
|
||
openidClient = require('openid-client');
|
||
openidClient.discovery.mockClear();
|
||
});
|
||
|
||
it('sets token_endpoint_auth_method to none for PKCE without a client secret', async () => {
|
||
process.env.OPENID_USE_PKCE = 'true';
|
||
delete process.env.OPENID_CLIENT_SECRET;
|
||
|
||
await setupOpenId();
|
||
|
||
const [, , metadata] = openidClient.discovery.mock.calls.at(-1);
|
||
expect(metadata.token_endpoint_auth_method).toBe('none');
|
||
expect(metadata.client_secret).toBeUndefined();
|
||
});
|
||
|
||
it('leaves token_endpoint_auth_method unset for secret-based clients without nonce', async () => {
|
||
process.env.OPENID_USE_PKCE = 'false';
|
||
process.env.OPENID_CLIENT_SECRET = 'my-secret';
|
||
|
||
await setupOpenId();
|
||
|
||
const [, , metadata] = openidClient.discovery.mock.calls.at(-1);
|
||
expect(metadata.client_secret).toBe('my-secret');
|
||
expect(metadata.token_endpoint_auth_method).toBeUndefined();
|
||
});
|
||
|
||
it('sets client_secret and client_secret_post when nonce generation is enabled', async () => {
|
||
process.env.OPENID_USE_PKCE = 'false';
|
||
process.env.OPENID_GENERATE_NONCE = 'true';
|
||
process.env.OPENID_CLIENT_SECRET = 'my-secret';
|
||
|
||
await setupOpenId();
|
||
|
||
const [, , metadata] = openidClient.discovery.mock.calls.at(-1);
|
||
expect(metadata.client_secret).toBe('my-secret');
|
||
expect(metadata.token_endpoint_auth_method).toBe('client_secret_post');
|
||
});
|
||
|
||
it('treats whitespace-only secret as absent', async () => {
|
||
process.env.OPENID_USE_PKCE = 'true';
|
||
process.env.OPENID_CLIENT_SECRET = ' ';
|
||
|
||
await setupOpenId();
|
||
|
||
const [, , metadata] = openidClient.discovery.mock.calls.at(-1);
|
||
expect(metadata.client_secret).toBeUndefined();
|
||
expect(metadata.token_endpoint_auth_method).toBe('none');
|
||
});
|
||
|
||
it('does not force an auth method when PKCE and a client secret are both configured without nonce', async () => {
|
||
process.env.OPENID_USE_PKCE = 'true';
|
||
process.env.OPENID_CLIENT_SECRET = 'my-secret';
|
||
|
||
await setupOpenId();
|
||
|
||
const [, , metadata] = openidClient.discovery.mock.calls.at(-1);
|
||
expect(metadata.client_secret).toBe('my-secret');
|
||
expect(metadata.token_endpoint_auth_method).toBeUndefined();
|
||
});
|
||
|
||
it('uses the shared OpenID proxy dispatcher for custom fetch requests', async () => {
|
||
const dispatcher = { dispatch: jest.fn() };
|
||
const response = { status: 204, statusText: 'No Content', headers: new Headers() };
|
||
getOpenIdProxyDispatcher.mockReturnValue(dispatcher);
|
||
undici.fetch.mockResolvedValue(response);
|
||
|
||
await setupOpenId();
|
||
|
||
const [, , , , options] = openidClient.discovery.mock.calls.at(-1);
|
||
const openIdFetch = options[openidClient.customFetch];
|
||
await expect(
|
||
openIdFetch('https://issuer.example.com/.well-known/openid-configuration', {
|
||
method: 'GET',
|
||
}),
|
||
).resolves.toBe(response);
|
||
|
||
expect(getOpenIdProxyDispatcher).toHaveBeenCalled();
|
||
expect(undici.fetch).toHaveBeenCalledWith(
|
||
'https://issuer.example.com/.well-known/openid-configuration',
|
||
{
|
||
method: 'GET',
|
||
dispatcher,
|
||
},
|
||
);
|
||
});
|
||
});
|
||
|
||
describe('authorizationRequestParams', () => {
|
||
const getLoginStrategy = () => require('openid-client/passport').__getStrategyByName('openid');
|
||
|
||
it('adds a single OpenID audience to authorization requests', () => {
|
||
process.env.OPENID_AUDIENCE = 'librechat';
|
||
|
||
const params = getLoginStrategy().authorizationRequestParams({}, { state: 'login-state' });
|
||
|
||
expect(params.get('audience')).toBe('librechat');
|
||
expect(params.get('state')).toBe('login-state');
|
||
});
|
||
|
||
it('uses the first non-empty audience when OPENID_AUDIENCE accepts multiple JWT audiences', () => {
|
||
process.env.OPENID_AUDIENCE = ' librechat , control-plane-web ';
|
||
|
||
const params = getLoginStrategy().authorizationRequestParams({}, {});
|
||
|
||
expect(params.get('audience')).toBe('librechat');
|
||
});
|
||
|
||
it('does not add an authorization audience when OPENID_AUDIENCE is empty', () => {
|
||
process.env.OPENID_AUDIENCE = ' , ';
|
||
|
||
const params = getLoginStrategy().authorizationRequestParams({}, {});
|
||
|
||
expect(params.has('audience')).toBe(false);
|
||
});
|
||
});
|
||
|
||
it('should create a new user with correct username when preferred_username claim exists', async () => {
|
||
// Arrange – our userinfo already has preferred_username 'testusername'
|
||
const userinfo = tokenset.claims();
|
||
|
||
// Act
|
||
const { user } = await validate(tokenset);
|
||
|
||
// 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}`,
|
||
}),
|
||
{ enabled: false },
|
||
true,
|
||
true,
|
||
);
|
||
});
|
||
|
||
it('should use username as username when preferred_username claim is missing', async () => {
|
||
// Arrange – remove preferred_username from userinfo
|
||
const userinfo = { ...tokenset.claims() };
|
||
delete userinfo.preferred_username;
|
||
// Expect the username to be the "username"
|
||
const expectUsername = userinfo.username;
|
||
|
||
// Act
|
||
const { user } = await validate({ ...tokenset, claims: () => userinfo });
|
||
|
||
// Assert
|
||
expect(user.username).toBe(expectUsername);
|
||
expect(createUser).toHaveBeenCalledWith(
|
||
expect.objectContaining({ username: expectUsername }),
|
||
{ enabled: false },
|
||
true,
|
||
true,
|
||
);
|
||
});
|
||
|
||
it('should use email as username when username and preferred_username are missing', async () => {
|
||
// Arrange – remove username and preferred_username
|
||
const userinfo = { ...tokenset.claims() };
|
||
delete userinfo.username;
|
||
delete userinfo.preferred_username;
|
||
const expectUsername = userinfo.email;
|
||
|
||
// Act
|
||
const { user } = await validate({ ...tokenset, claims: () => userinfo });
|
||
|
||
// Assert
|
||
expect(user.username).toBe(expectUsername);
|
||
expect(createUser).toHaveBeenCalledWith(
|
||
expect.objectContaining({ username: expectUsername }),
|
||
{ enabled: false },
|
||
true,
|
||
true,
|
||
);
|
||
});
|
||
|
||
it('should override username with OPENID_USERNAME_CLAIM when set', async () => {
|
||
// Arrange – set OPENID_USERNAME_CLAIM so that the sub claim is used
|
||
process.env.OPENID_USERNAME_CLAIM = 'sub';
|
||
const userinfo = tokenset.claims();
|
||
|
||
// Act
|
||
const { user } = await validate(tokenset);
|
||
|
||
// Assert – username should equal the sub (converted as-is)
|
||
expect(user.username).toBe(userinfo.sub);
|
||
expect(createUser).toHaveBeenCalledWith(
|
||
expect.objectContaining({ username: userinfo.sub }),
|
||
{ enabled: false },
|
||
true,
|
||
true,
|
||
);
|
||
});
|
||
|
||
it('should set the full name correctly when given_name and family_name exist', async () => {
|
||
// Arrange
|
||
const userinfo = tokenset.claims();
|
||
const expectedFullName = `${userinfo.given_name} ${userinfo.family_name}`;
|
||
|
||
// Act
|
||
const { user } = await validate(tokenset);
|
||
|
||
// Assert
|
||
expect(user.name).toBe(expectedFullName);
|
||
});
|
||
|
||
it('should override full name with OPENID_NAME_CLAIM when set', async () => {
|
||
// Arrange – use the name claim as the full name
|
||
process.env.OPENID_NAME_CLAIM = 'name';
|
||
const userinfo = { ...tokenset.claims(), name: 'Custom Name' };
|
||
|
||
// Act
|
||
const { user } = await validate({ ...tokenset, claims: () => userinfo });
|
||
|
||
// Assert
|
||
expect(user.name).toBe('Custom Name');
|
||
});
|
||
|
||
it('should update an existing user on login', async () => {
|
||
// Arrange – simulate that a user already exists with openid provider
|
||
const existingUser = {
|
||
_id: 'existingUserId',
|
||
provider: 'openid',
|
||
email: tokenset.claims().email,
|
||
openidId: '',
|
||
username: '',
|
||
name: '',
|
||
};
|
||
findUser.mockImplementation(async (query) => {
|
||
if (query.openidId === tokenset.claims().sub || query.email === tokenset.claims().email) {
|
||
return existingUser;
|
||
}
|
||
return null;
|
||
});
|
||
|
||
const userinfo = tokenset.claims();
|
||
|
||
// Act
|
||
await validate(tokenset);
|
||
|
||
// Assert – updateUser should be called and the user object updated
|
||
expect(updateUser).toHaveBeenCalledWith(
|
||
existingUser._id,
|
||
expect.objectContaining({
|
||
provider: 'openid',
|
||
openidId: userinfo.sub,
|
||
openidIssuer: 'https://fake-issuer.com',
|
||
username: userinfo.preferred_username,
|
||
name: `${userinfo.given_name} ${userinfo.family_name}`,
|
||
}),
|
||
);
|
||
});
|
||
|
||
it('should block login when email exists with different provider', async () => {
|
||
// Arrange – simulate that a user exists with same email but different provider
|
||
const existingUser = {
|
||
_id: 'existingUserId',
|
||
provider: 'google',
|
||
email: tokenset.claims().email,
|
||
googleId: 'some-google-id',
|
||
username: 'existinguser',
|
||
name: 'Existing User',
|
||
};
|
||
findUser.mockImplementation(async (query) => {
|
||
if (query.email === tokenset.claims().email && !query.provider) {
|
||
return existingUser;
|
||
}
|
||
return null;
|
||
});
|
||
|
||
// Act
|
||
const result = await validate(tokenset);
|
||
|
||
// Assert – verify that the strategy rejects login
|
||
expect(result.user).toBe(false);
|
||
expect(result.details.message).toBe(ErrorTypes.AUTH_FAILED);
|
||
expect(createUser).not.toHaveBeenCalled();
|
||
expect(updateUser).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('should block login when email fallback finds user with mismatched openidId', async () => {
|
||
const existingUser = {
|
||
_id: 'existingUserId',
|
||
provider: 'openid',
|
||
openidId: 'different-sub-claim',
|
||
email: tokenset.claims().email,
|
||
username: 'existinguser',
|
||
name: 'Existing User',
|
||
};
|
||
findUser.mockImplementation(async (query) => {
|
||
if (query.$or) {
|
||
return null;
|
||
}
|
||
if (query.email === tokenset.claims().email) {
|
||
return existingUser;
|
||
}
|
||
return null;
|
||
});
|
||
|
||
const result = await validate(tokenset);
|
||
|
||
expect(result.user).toBe(false);
|
||
expect(result.details.message).toBe(ErrorTypes.AUTH_FAILED);
|
||
expect(createUser).not.toHaveBeenCalled();
|
||
expect(updateUser).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('should enforce the required role and reject login if missing', async () => {
|
||
// Arrange – simulate a token without the required role.
|
||
jwtDecode.mockReturnValue({
|
||
roles: ['SomeOtherRole'],
|
||
});
|
||
|
||
// Act
|
||
const { user, details } = await validate(tokenset);
|
||
|
||
// Assert – verify that the strategy rejects login
|
||
expect(user).toBe(false);
|
||
expect(details.message).toBe('You must have "requiredRole" role to log in.');
|
||
});
|
||
|
||
it('should not treat substring matches in string roles as satisfying required role', async () => {
|
||
// Arrange – override required role to "read" then re-setup
|
||
process.env.OPENID_REQUIRED_ROLE = 'read';
|
||
await setupOpenId();
|
||
verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid');
|
||
|
||
// Token contains "bread" which *contains* "read" as a substring
|
||
jwtDecode.mockReturnValue({
|
||
roles: 'bread',
|
||
});
|
||
|
||
// Act
|
||
const { user, details } = await validate(tokenset);
|
||
|
||
// Assert – verify that substring match does not grant access
|
||
expect(user).toBe(false);
|
||
expect(details.message).toBe('You must have "read" role to log in.');
|
||
});
|
||
|
||
it('should allow login when roles claim is a space-separated string containing the required role', async () => {
|
||
// Arrange – IdP returns roles as a space-delimited string
|
||
jwtDecode.mockReturnValue({
|
||
roles: 'role1 role2 requiredRole',
|
||
});
|
||
|
||
// Act
|
||
const { user } = await validate(tokenset);
|
||
|
||
// Assert – login succeeds when required role is present after splitting
|
||
expect(user).toBeTruthy();
|
||
expect(createUser).toHaveBeenCalled();
|
||
});
|
||
|
||
it('should allow login when roles claim is a comma-separated string containing the required role', async () => {
|
||
// Arrange – IdP returns roles as a comma-delimited string
|
||
jwtDecode.mockReturnValue({
|
||
roles: 'role1,role2,requiredRole',
|
||
});
|
||
|
||
// Act
|
||
const { user } = await validate(tokenset);
|
||
|
||
// Assert – login succeeds when required role is present after splitting
|
||
expect(user).toBeTruthy();
|
||
expect(createUser).toHaveBeenCalled();
|
||
});
|
||
|
||
it('should allow login when roles claim is a mixed comma-and-space-separated string containing the required role', async () => {
|
||
// Arrange – IdP returns roles with comma-and-space delimiters
|
||
jwtDecode.mockReturnValue({
|
||
roles: 'role1, role2, requiredRole',
|
||
});
|
||
|
||
// Act
|
||
const { user } = await validate(tokenset);
|
||
|
||
// Assert – login succeeds when required role is present after splitting
|
||
expect(user).toBeTruthy();
|
||
expect(createUser).toHaveBeenCalled();
|
||
});
|
||
|
||
it('should reject login when roles claim is a space-separated string that does not contain the required role', async () => {
|
||
// Arrange – IdP returns a delimited string but required role is absent
|
||
jwtDecode.mockReturnValue({
|
||
roles: 'role1 role2 otherRole',
|
||
});
|
||
|
||
// Act
|
||
const { user, details } = await validate(tokenset);
|
||
|
||
// Assert – login is rejected with the correct error message
|
||
expect(user).toBe(false);
|
||
expect(details.message).toBe('You must have "requiredRole" role to log in.');
|
||
});
|
||
|
||
it('should allow login when single required role is present (backward compatibility)', async () => {
|
||
// Arrange – ensure single role configuration (as set in beforeEach)
|
||
// OPENID_REQUIRED_ROLE = 'requiredRole'
|
||
// Default jwtDecode mock in beforeEach already returns this role
|
||
jwtDecode.mockReturnValue({
|
||
roles: ['requiredRole', 'anotherRole'],
|
||
});
|
||
|
||
// Act
|
||
const { user } = await validate(tokenset);
|
||
|
||
// Assert – verify that login succeeds with single role configuration
|
||
expect(user).toBeTruthy();
|
||
expect(user.email).toBe(tokenset.claims().email);
|
||
expect(user.username).toBe(tokenset.claims().preferred_username);
|
||
expect(createUser).toHaveBeenCalled();
|
||
});
|
||
|
||
it('should allow login when required role is found in userinfo claims', async () => {
|
||
process.env.OPENID_REQUIRED_ROLE = 'requiredRole';
|
||
process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'roles';
|
||
process.env.OPENID_REQUIRED_ROLE_TOKEN_KIND = 'userinfo';
|
||
|
||
// The role is intentionally absent from the id_token and only present in
|
||
// the userinfo response — exercises the userinfo branch of the switch.
|
||
jwtDecode.mockReturnValue({});
|
||
require('openid-client').fetchUserInfo.mockResolvedValue({
|
||
roles: ['requiredRole'],
|
||
});
|
||
|
||
await setupOpenId();
|
||
verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid');
|
||
|
||
const { user } = await validate(tokenset);
|
||
|
||
expect(user).toBeTruthy();
|
||
expect(user.email).toBe(tokenset.claims().email);
|
||
});
|
||
|
||
it('should reject login when required role is missing from userinfo claims', async () => {
|
||
const { logger } = require('@librechat/data-schemas');
|
||
process.env.OPENID_REQUIRED_ROLE = 'requiredRole';
|
||
process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'roles';
|
||
process.env.OPENID_REQUIRED_ROLE_TOKEN_KIND = 'userinfo';
|
||
|
||
jwtDecode.mockReturnValue({});
|
||
require('openid-client').fetchUserInfo.mockResolvedValue({
|
||
other_claim: 'value',
|
||
});
|
||
|
||
await setupOpenId();
|
||
verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid');
|
||
|
||
const { user, details } = await validate(tokenset);
|
||
|
||
expect(user).toBe(false);
|
||
expect(details.message).toBe('You must have "requiredRole" role to log in.');
|
||
expect(logger.error).toHaveBeenCalledWith(
|
||
expect.stringContaining("Key 'roles' not found in userinfo token!"),
|
||
);
|
||
});
|
||
|
||
it('should reject login with invalid required role token kind', async () => {
|
||
const { logger } = require('@librechat/data-schemas');
|
||
process.env.OPENID_REQUIRED_ROLE = 'requiredRole';
|
||
process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'roles';
|
||
process.env.OPENID_REQUIRED_ROLE_TOKEN_KIND = 'invalid';
|
||
|
||
jwtDecode.mockReturnValue({
|
||
roles: ['requiredRole'],
|
||
});
|
||
|
||
await setupOpenId();
|
||
verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid');
|
||
|
||
await expect(validate(tokenset)).rejects.toThrow('Invalid required role token kind');
|
||
|
||
expect(logger.error).toHaveBeenCalledWith(
|
||
expect.stringContaining(
|
||
"Invalid required role token kind: invalid. Must be one of 'access', 'id', or 'userinfo'",
|
||
),
|
||
);
|
||
});
|
||
|
||
describe('group overage and groups handling', () => {
|
||
it.each([
|
||
['groups array contains required group', ['group-required', 'other-group'], true, undefined],
|
||
[
|
||
'groups array missing required group',
|
||
['other-group'],
|
||
false,
|
||
'You must have "group-required" role to log in.',
|
||
],
|
||
['groups string equals required group', 'group-required', true, undefined],
|
||
[
|
||
'groups string is other group',
|
||
'other-group',
|
||
false,
|
||
'You must have "group-required" role to log in.',
|
||
],
|
||
])(
|
||
'uses groups claim directly when %s (no overage)',
|
||
async (_label, groupsClaim, expectedAllowed, expectedMessage) => {
|
||
process.env.OPENID_REQUIRED_ROLE = 'group-required';
|
||
process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'groups';
|
||
process.env.OPENID_REQUIRED_ROLE_TOKEN_KIND = 'id';
|
||
|
||
jwtDecode.mockReturnValue({
|
||
groups: groupsClaim,
|
||
permissions: ['admin'],
|
||
});
|
||
|
||
await setupOpenId();
|
||
verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid');
|
||
|
||
const { user, details } = await validate(tokenset);
|
||
|
||
expect(undici.fetch).not.toHaveBeenCalled();
|
||
expect(Boolean(user)).toBe(expectedAllowed);
|
||
expect(details?.message).toBe(expectedMessage);
|
||
},
|
||
);
|
||
|
||
it.each([
|
||
['token kind is not id', { kind: 'access', path: 'groups', decoded: { hasgroups: true } }],
|
||
['parameter path is not groups', { kind: 'id', path: 'roles', decoded: { hasgroups: true } }],
|
||
['decoded token is falsy', { kind: 'id', path: 'groups', decoded: null }],
|
||
[
|
||
'no overage indicators in decoded token',
|
||
{
|
||
kind: 'id',
|
||
path: 'groups',
|
||
decoded: {
|
||
permissions: ['admin'],
|
||
},
|
||
},
|
||
],
|
||
[
|
||
'only _claim_names present (no _claim_sources)',
|
||
{
|
||
kind: 'id',
|
||
path: 'groups',
|
||
decoded: {
|
||
_claim_names: { groups: 'src1' },
|
||
permissions: ['admin'],
|
||
},
|
||
},
|
||
],
|
||
[
|
||
'only _claim_sources present (no _claim_names)',
|
||
{
|
||
kind: 'id',
|
||
path: 'groups',
|
||
decoded: {
|
||
_claim_sources: { src1: { endpoint: 'https://graph.windows.net/ignored' } },
|
||
permissions: ['admin'],
|
||
},
|
||
},
|
||
],
|
||
])('does not attempt overage resolution when %s', async (_label, cfg) => {
|
||
process.env.OPENID_REQUIRED_ROLE = 'group-required';
|
||
process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = cfg.path;
|
||
process.env.OPENID_REQUIRED_ROLE_TOKEN_KIND = cfg.kind;
|
||
|
||
jwtDecode.mockReturnValue(cfg.decoded);
|
||
|
||
await setupOpenId();
|
||
verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid');
|
||
|
||
const { user, details } = await validate(tokenset);
|
||
|
||
expect(undici.fetch).not.toHaveBeenCalled();
|
||
expect(user).toBe(false);
|
||
expect(details.message).toBe('You must have "group-required" role to log in.');
|
||
const { logger } = require('@librechat/data-schemas');
|
||
const expectedTokenKind = cfg.kind === 'access' ? 'access token' : 'id token';
|
||
expect(logger.error).toHaveBeenCalledWith(
|
||
expect.stringContaining(`Key '${cfg.path}' not found in ${expectedTokenKind}!`),
|
||
);
|
||
});
|
||
});
|
||
|
||
describe('resolving groups via Microsoft Graph', () => {
|
||
it('denies login and does not call Graph when access token is missing', async () => {
|
||
process.env.OPENID_REQUIRED_ROLE = 'group-required';
|
||
process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'groups';
|
||
process.env.OPENID_REQUIRED_ROLE_TOKEN_KIND = 'id';
|
||
|
||
const { logger } = require('@librechat/data-schemas');
|
||
|
||
jwtDecode.mockReturnValue({
|
||
hasgroups: true,
|
||
permissions: ['admin'],
|
||
});
|
||
|
||
await setupOpenId();
|
||
verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid');
|
||
|
||
const tokensetWithoutAccess = {
|
||
...tokenset,
|
||
access_token: undefined,
|
||
};
|
||
|
||
const { user, details } = await validate(tokensetWithoutAccess);
|
||
|
||
expect(user).toBe(false);
|
||
expect(details.message).toBe('You must have "group-required" role to log in.');
|
||
|
||
expect(undici.fetch).not.toHaveBeenCalled();
|
||
expect(logger.error).toHaveBeenCalledWith(
|
||
expect.stringContaining('Access token missing; cannot resolve group overage'),
|
||
);
|
||
});
|
||
|
||
it.each([
|
||
[
|
||
'Graph returns HTTP error',
|
||
async () => ({
|
||
ok: false,
|
||
status: 403,
|
||
statusText: 'Forbidden',
|
||
json: async () => ({}),
|
||
}),
|
||
[
|
||
'[openidStrategy] Failed to resolve groups via Microsoft Graph getMemberObjects: HTTP 403 Forbidden',
|
||
],
|
||
],
|
||
[
|
||
'Graph network error',
|
||
async () => {
|
||
throw new Error('network error');
|
||
},
|
||
[
|
||
'[openidStrategy] Error resolving groups via Microsoft Graph getMemberObjects:',
|
||
expect.any(Error),
|
||
],
|
||
],
|
||
[
|
||
'Graph returns unexpected shape (no value)',
|
||
async () => ({
|
||
ok: true,
|
||
status: 200,
|
||
statusText: 'OK',
|
||
json: async () => ({}),
|
||
}),
|
||
[
|
||
'[openidStrategy] Unexpected response format when resolving groups via Microsoft Graph getMemberObjects',
|
||
],
|
||
],
|
||
[
|
||
'Graph returns invalid value type',
|
||
async () => ({
|
||
ok: true,
|
||
status: 200,
|
||
statusText: 'OK',
|
||
json: async () => ({ value: 'not-an-array' }),
|
||
}),
|
||
[
|
||
'[openidStrategy] Unexpected response format when resolving groups via Microsoft Graph getMemberObjects',
|
||
],
|
||
],
|
||
])(
|
||
'denies login when overage resolution fails because %s',
|
||
async (_label, setupFetch, expectedErrorArgs) => {
|
||
process.env.OPENID_REQUIRED_ROLE = 'group-required';
|
||
process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'groups';
|
||
process.env.OPENID_REQUIRED_ROLE_TOKEN_KIND = 'id';
|
||
|
||
const { logger } = require('@librechat/data-schemas');
|
||
|
||
jwtDecode.mockReturnValue({
|
||
hasgroups: true,
|
||
permissions: ['admin'],
|
||
});
|
||
|
||
await setupOpenId();
|
||
verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid');
|
||
|
||
undici.fetch.mockImplementation(setupFetch);
|
||
|
||
const { user, details } = await validate(tokenset);
|
||
|
||
expect(undici.fetch).toHaveBeenCalled();
|
||
expect(user).toBe(false);
|
||
expect(details.message).toBe('You must have "group-required" role to log in.');
|
||
|
||
expect(logger.error).toHaveBeenCalledWith(...expectedErrorArgs);
|
||
},
|
||
);
|
||
|
||
it.each([
|
||
[
|
||
'hasgroups overage and Graph contains required group',
|
||
{
|
||
hasgroups: true,
|
||
},
|
||
['group-required', 'some-other-group'],
|
||
true,
|
||
],
|
||
[
|
||
'_claim_* overage and Graph contains required group',
|
||
{
|
||
_claim_names: { groups: 'src1' },
|
||
_claim_sources: { src1: { endpoint: 'https://graph.windows.net/ignored' } },
|
||
},
|
||
['group-required', 'some-other-group'],
|
||
true,
|
||
],
|
||
[
|
||
'hasgroups overage and Graph does NOT contain required group',
|
||
{
|
||
hasgroups: true,
|
||
},
|
||
['some-other-group'],
|
||
false,
|
||
],
|
||
[
|
||
'_claim_* overage and Graph does NOT contain required group',
|
||
{
|
||
_claim_names: { groups: 'src1' },
|
||
_claim_sources: { src1: { endpoint: 'https://graph.windows.net/ignored' } },
|
||
},
|
||
['some-other-group'],
|
||
false,
|
||
],
|
||
])(
|
||
'resolves groups via Microsoft Graph when %s',
|
||
async (_label, decodedTokenValue, graphGroups, expectedAllowed) => {
|
||
process.env.OPENID_REQUIRED_ROLE = 'group-required';
|
||
process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'groups';
|
||
process.env.OPENID_REQUIRED_ROLE_TOKEN_KIND = 'id';
|
||
|
||
const { logger } = require('@librechat/data-schemas');
|
||
|
||
jwtDecode.mockReturnValue(decodedTokenValue);
|
||
|
||
await setupOpenId();
|
||
verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid');
|
||
|
||
undici.fetch.mockResolvedValue({
|
||
ok: true,
|
||
status: 200,
|
||
statusText: 'OK',
|
||
json: async () => ({
|
||
value: graphGroups,
|
||
}),
|
||
});
|
||
|
||
const { user } = await validate(tokenset);
|
||
|
||
expect(undici.fetch).toHaveBeenCalledWith(
|
||
'https://graph.microsoft.com/v1.0/me/getMemberObjects',
|
||
expect.objectContaining({
|
||
method: 'POST',
|
||
headers: expect.objectContaining({
|
||
Authorization: 'Bearer exchanged_graph_token',
|
||
}),
|
||
}),
|
||
);
|
||
expect(Boolean(user)).toBe(expectedAllowed);
|
||
|
||
expect(logger.debug).toHaveBeenCalledWith(
|
||
expect.stringContaining(
|
||
`Successfully resolved ${graphGroups.length} groups via Microsoft Graph getMemberObjects`,
|
||
),
|
||
);
|
||
},
|
||
);
|
||
});
|
||
|
||
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';
|
||
process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'groups';
|
||
process.env.OPENID_REQUIRED_ROLE_TOKEN_KIND = 'id';
|
||
|
||
jwtDecode.mockReturnValue({ hasgroups: true });
|
||
|
||
await setupOpenId();
|
||
verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid');
|
||
|
||
undici.fetch.mockResolvedValue({
|
||
ok: true,
|
||
status: 200,
|
||
statusText: 'OK',
|
||
json: async () => ({ value: ['group-required'] }),
|
||
});
|
||
|
||
await validate(tokenset);
|
||
|
||
expect(openidClient.genericGrantRequest).toHaveBeenCalledWith(
|
||
expect.anything(),
|
||
'urn:ietf:params:oauth:grant-type:jwt-bearer',
|
||
expect.objectContaining({
|
||
scope: 'https://graph.microsoft.com/User.Read',
|
||
assertion: tokenset.access_token,
|
||
requested_token_use: 'on_behalf_of',
|
||
}),
|
||
);
|
||
|
||
expect(undici.fetch).toHaveBeenCalledWith(
|
||
'https://graph.microsoft.com/v1.0/me/getMemberObjects',
|
||
expect.objectContaining({
|
||
headers: expect.objectContaining({
|
||
Authorization: 'Bearer exchanged_graph_token',
|
||
}),
|
||
}),
|
||
);
|
||
});
|
||
|
||
it('caches the exchanged token and reuses it on subsequent calls', async () => {
|
||
const openidClient = require('openid-client');
|
||
const getLogStores = require('~/cache/getLogStores');
|
||
const mockSet = jest.fn();
|
||
const mockGet = jest
|
||
.fn()
|
||
.mockResolvedValueOnce(undefined)
|
||
.mockResolvedValueOnce({ access_token: 'exchanged_graph_token' });
|
||
getLogStores.mockReturnValue({ get: mockGet, set: mockSet });
|
||
|
||
process.env.OPENID_REQUIRED_ROLE = 'group-required';
|
||
process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'groups';
|
||
process.env.OPENID_REQUIRED_ROLE_TOKEN_KIND = 'id';
|
||
|
||
jwtDecode.mockReturnValue({ hasgroups: true });
|
||
|
||
await setupOpenId();
|
||
verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid');
|
||
|
||
undici.fetch.mockResolvedValue({
|
||
ok: true,
|
||
status: 200,
|
||
statusText: 'OK',
|
||
json: async () => ({ value: ['group-required'] }),
|
||
});
|
||
|
||
// First call: cache miss → OBO exchange → cache set
|
||
await validate(tokenset);
|
||
expect(mockSet).toHaveBeenCalledWith(
|
||
'1234:overage',
|
||
{ access_token: 'exchanged_graph_token' },
|
||
3600000,
|
||
);
|
||
expect(openidClient.genericGrantRequest).toHaveBeenCalledTimes(1);
|
||
|
||
// Second call: cache hit → no new OBO exchange
|
||
openidClient.genericGrantRequest.mockClear();
|
||
await validate(tokenset);
|
||
expect(openidClient.genericGrantRequest).not.toHaveBeenCalled();
|
||
});
|
||
});
|
||
|
||
describe('admin role group overage', () => {
|
||
it('resolves admin groups via Graph when overage is detected for admin role', async () => {
|
||
process.env.OPENID_REQUIRED_ROLE = 'group-required';
|
||
process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'groups';
|
||
process.env.OPENID_REQUIRED_ROLE_TOKEN_KIND = 'id';
|
||
process.env.OPENID_ADMIN_ROLE = 'admin-group-id';
|
||
process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'groups';
|
||
process.env.OPENID_ADMIN_ROLE_TOKEN_KIND = 'id';
|
||
|
||
jwtDecode.mockReturnValue({ hasgroups: true });
|
||
|
||
await setupOpenId();
|
||
verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid');
|
||
|
||
undici.fetch.mockResolvedValue({
|
||
ok: true,
|
||
status: 200,
|
||
statusText: 'OK',
|
||
json: async () => ({ value: ['group-required', 'admin-group-id'] }),
|
||
});
|
||
|
||
const { user } = await validate(tokenset);
|
||
|
||
expect(user.role).toBe('ADMIN');
|
||
});
|
||
|
||
it('does not grant admin when overage groups do not contain admin role', async () => {
|
||
process.env.OPENID_REQUIRED_ROLE = 'group-required';
|
||
process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'groups';
|
||
process.env.OPENID_REQUIRED_ROLE_TOKEN_KIND = 'id';
|
||
process.env.OPENID_ADMIN_ROLE = 'admin-group-id';
|
||
process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'groups';
|
||
process.env.OPENID_ADMIN_ROLE_TOKEN_KIND = 'id';
|
||
|
||
jwtDecode.mockReturnValue({ hasgroups: true });
|
||
|
||
await setupOpenId();
|
||
verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid');
|
||
|
||
undici.fetch.mockResolvedValue({
|
||
ok: true,
|
||
status: 200,
|
||
statusText: 'OK',
|
||
json: async () => ({ value: ['group-required', 'other-group'] }),
|
||
});
|
||
|
||
const { user } = await validate(tokenset);
|
||
|
||
expect(user).toBeTruthy();
|
||
expect(user.role).toBeUndefined();
|
||
});
|
||
|
||
it('reuses already-resolved overage groups for admin role check (no duplicate Graph call)', async () => {
|
||
process.env.OPENID_REQUIRED_ROLE = 'group-required';
|
||
process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'groups';
|
||
process.env.OPENID_REQUIRED_ROLE_TOKEN_KIND = 'id';
|
||
process.env.OPENID_ADMIN_ROLE = 'admin-group-id';
|
||
process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'groups';
|
||
process.env.OPENID_ADMIN_ROLE_TOKEN_KIND = 'id';
|
||
|
||
jwtDecode.mockReturnValue({ hasgroups: true });
|
||
|
||
await setupOpenId();
|
||
verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid');
|
||
|
||
undici.fetch.mockResolvedValue({
|
||
ok: true,
|
||
status: 200,
|
||
statusText: 'OK',
|
||
json: async () => ({ value: ['group-required', 'admin-group-id'] }),
|
||
});
|
||
|
||
await validate(tokenset);
|
||
|
||
// Graph API should be called only once (for required role), admin role reuses the result
|
||
expect(undici.fetch).toHaveBeenCalledTimes(1);
|
||
});
|
||
|
||
it('demotes existing admin when overage groups no longer contain admin role', async () => {
|
||
process.env.OPENID_REQUIRED_ROLE = 'group-required';
|
||
process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'groups';
|
||
process.env.OPENID_REQUIRED_ROLE_TOKEN_KIND = 'id';
|
||
process.env.OPENID_ADMIN_ROLE = 'admin-group-id';
|
||
process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'groups';
|
||
process.env.OPENID_ADMIN_ROLE_TOKEN_KIND = 'id';
|
||
|
||
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({ hasgroups: true });
|
||
|
||
await setupOpenId();
|
||
verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid');
|
||
|
||
undici.fetch.mockResolvedValue({
|
||
ok: true,
|
||
status: 200,
|
||
statusText: 'OK',
|
||
json: async () => ({ value: ['group-required'] }),
|
||
});
|
||
|
||
const { user } = await validate(tokenset);
|
||
|
||
expect(user.role).toBe('USER');
|
||
});
|
||
|
||
it('does not attempt overage for admin role when token kind is not id', async () => {
|
||
process.env.OPENID_REQUIRED_ROLE = 'requiredRole';
|
||
process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'roles';
|
||
process.env.OPENID_REQUIRED_ROLE_TOKEN_KIND = 'id';
|
||
process.env.OPENID_ADMIN_ROLE = 'admin';
|
||
process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'groups';
|
||
process.env.OPENID_ADMIN_ROLE_TOKEN_KIND = 'access';
|
||
|
||
jwtDecode.mockReturnValue({
|
||
roles: ['requiredRole'],
|
||
hasgroups: true,
|
||
});
|
||
|
||
await setupOpenId();
|
||
verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid');
|
||
|
||
const { user } = await validate(tokenset);
|
||
|
||
// No Graph call since admin uses access token (not id)
|
||
expect(undici.fetch).not.toHaveBeenCalled();
|
||
expect(user.role).toBeUndefined();
|
||
});
|
||
|
||
it('resolves admin via Graph independently when OPENID_REQUIRED_ROLE is not configured', async () => {
|
||
delete process.env.OPENID_REQUIRED_ROLE;
|
||
process.env.OPENID_ADMIN_ROLE = 'admin-group-id';
|
||
process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'groups';
|
||
process.env.OPENID_ADMIN_ROLE_TOKEN_KIND = 'id';
|
||
|
||
jwtDecode.mockReturnValue({ hasgroups: true });
|
||
await setupOpenId();
|
||
verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid');
|
||
|
||
undici.fetch.mockResolvedValue({
|
||
ok: true,
|
||
status: 200,
|
||
statusText: 'OK',
|
||
json: async () => ({ value: ['admin-group-id'] }),
|
||
});
|
||
|
||
const { user } = await validate(tokenset);
|
||
expect(user.role).toBe('ADMIN');
|
||
expect(undici.fetch).toHaveBeenCalledTimes(1);
|
||
});
|
||
|
||
it('denies admin when OPENID_REQUIRED_ROLE is absent and Graph does not contain admin group', async () => {
|
||
delete process.env.OPENID_REQUIRED_ROLE;
|
||
process.env.OPENID_ADMIN_ROLE = 'admin-group-id';
|
||
process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'groups';
|
||
process.env.OPENID_ADMIN_ROLE_TOKEN_KIND = 'id';
|
||
|
||
jwtDecode.mockReturnValue({ hasgroups: true });
|
||
await setupOpenId();
|
||
verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid');
|
||
|
||
undici.fetch.mockResolvedValue({
|
||
ok: true,
|
||
status: 200,
|
||
statusText: 'OK',
|
||
json: async () => ({ value: ['other-group'] }),
|
||
});
|
||
|
||
const { user } = await validate(tokenset);
|
||
expect(user).toBeTruthy();
|
||
expect(user.role).toBeUndefined();
|
||
});
|
||
|
||
it('denies login and logs error when OBO exchange throws', async () => {
|
||
const openidClient = require('openid-client');
|
||
process.env.OPENID_REQUIRED_ROLE = 'group-required';
|
||
process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'groups';
|
||
process.env.OPENID_REQUIRED_ROLE_TOKEN_KIND = 'id';
|
||
|
||
jwtDecode.mockReturnValue({ hasgroups: true });
|
||
openidClient.genericGrantRequest.mockRejectedValueOnce(new Error('OBO exchange rejected'));
|
||
|
||
await setupOpenId();
|
||
verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid');
|
||
|
||
const { user, details } = await validate(tokenset);
|
||
expect(user).toBe(false);
|
||
expect(details.message).toBe('You must have "group-required" role to log in.');
|
||
expect(undici.fetch).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('denies login when OBO exchange returns no access_token', async () => {
|
||
const openidClient = require('openid-client');
|
||
process.env.OPENID_REQUIRED_ROLE = 'group-required';
|
||
process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'groups';
|
||
process.env.OPENID_REQUIRED_ROLE_TOKEN_KIND = 'id';
|
||
|
||
jwtDecode.mockReturnValue({ hasgroups: true });
|
||
openidClient.genericGrantRequest.mockResolvedValueOnce({ expires_in: 3600 });
|
||
|
||
await setupOpenId();
|
||
verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid');
|
||
|
||
const { user, details } = await validate(tokenset);
|
||
expect(user).toBe(false);
|
||
expect(details.message).toBe('You must have "group-required" role to log in.');
|
||
expect(undici.fetch).not.toHaveBeenCalled();
|
||
});
|
||
});
|
||
|
||
it('should process and save the avatar through the shared avatar path if picture is provided', async () => {
|
||
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
|
||
|
||
// Act
|
||
const { user } = await validate(tokenset);
|
||
const strategyResult =
|
||
getStrategyFunctions.mock.results[getStrategyFunctions.mock.results.length - 1];
|
||
const { saveBuffer } = strategyResult.value;
|
||
const [saveParams] = saveBuffer.mock.calls[0];
|
||
|
||
expect(resizeAvatar).toHaveBeenCalledWith({
|
||
userId: 'newUserId',
|
||
input: 'https://example.com/avatar.png',
|
||
});
|
||
expect(fetch).not.toHaveBeenCalled();
|
||
expect(saveParams).toEqual(
|
||
expect.objectContaining({
|
||
fileName: 'hashed-token.png',
|
||
userId: 'newUserId',
|
||
buffer: expect.any(Buffer),
|
||
}),
|
||
);
|
||
expect(saveParams).not.toHaveProperty('basePath');
|
||
// Our mock getStrategyFunctions.saveBuffer returns '/fake/path/to/avatar.png'
|
||
expect(user.avatar).toBe('/fake/path/to/avatar.png');
|
||
});
|
||
|
||
it('uses only the shared avatar processor for OpenID picture URLs', async () => {
|
||
await validate(tokenset);
|
||
|
||
expect(resizeAvatar).toHaveBeenCalledWith({
|
||
userId: 'newUserId',
|
||
input: 'https://example.com/avatar.png',
|
||
});
|
||
expect(fetch).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('adds auth headers for configured OpenID avatar origins', async () => {
|
||
process.env.OPENID_AVATAR_AUTHORIZED_ORIGINS = 'https://example.com';
|
||
|
||
await validate(tokenset);
|
||
|
||
expect(resizeAvatar).toHaveBeenCalledWith({
|
||
userId: 'newUserId',
|
||
input: 'https://example.com/avatar.png',
|
||
fetchOptions: {
|
||
headers: {
|
||
Authorization: 'Bearer fake_access_token',
|
||
},
|
||
},
|
||
});
|
||
expect(fetch).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('continues login when shared avatar processing rejects the picture URL', async () => {
|
||
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
|
||
resizeAvatar.mockRejectedValueOnce(new Error('avatar processing failed'));
|
||
|
||
const { user } = await validate(tokenset);
|
||
|
||
expect(user).toBeTruthy();
|
||
expect(user.avatar).toBeUndefined();
|
||
expect(getStrategyFunctions).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('should save CloudFront IdP avatars under the shared avatar prefix', async () => {
|
||
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
|
||
getAppConfig.mockResolvedValueOnce({ fileStrategy: mockCloudfrontFileSource });
|
||
|
||
const { user } = await validate(tokenset);
|
||
const strategyResult =
|
||
getStrategyFunctions.mock.results[getStrategyFunctions.mock.results.length - 1];
|
||
const { saveBuffer } = strategyResult.value;
|
||
const [saveParams] = saveBuffer.mock.calls[0];
|
||
|
||
expect(getStrategyFunctions).toHaveBeenLastCalledWith(mockCloudfrontFileSource);
|
||
expect(resizeAvatar).toHaveBeenCalledWith({
|
||
userId: 'newUserId',
|
||
input: 'https://example.com/avatar.png',
|
||
});
|
||
expect(fetch).not.toHaveBeenCalled();
|
||
expect(saveParams).toEqual(
|
||
expect.objectContaining({
|
||
basePath: 'avatars',
|
||
fileName: 'hashed-token.png',
|
||
userId: 'newUserId',
|
||
}),
|
||
);
|
||
expect(user.avatar).toBe('/fake/path/to/avatar.png');
|
||
});
|
||
|
||
it('should not attempt to download avatar if picture is not provided', async () => {
|
||
// Arrange – remove picture
|
||
const userinfo = { ...tokenset.claims() };
|
||
delete userinfo.picture;
|
||
|
||
// Act
|
||
await validate({ ...tokenset, claims: () => userinfo });
|
||
|
||
// Assert – fetch should not be called and avatar should remain undefined or empty
|
||
expect(fetch).not.toHaveBeenCalled();
|
||
expect(resizeAvatar).not.toHaveBeenCalled();
|
||
// Depending on your implementation, user.avatar may be undefined or an empty string.
|
||
});
|
||
|
||
it('should support comma-separated multiple roles', async () => {
|
||
// Arrange
|
||
process.env.OPENID_REQUIRED_ROLE = 'someRole,anotherRole,admin';
|
||
await setupOpenId(); // Re-initialize the strategy
|
||
verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid');
|
||
jwtDecode.mockReturnValue({
|
||
roles: ['anotherRole', 'aThirdRole'],
|
||
});
|
||
|
||
// Act
|
||
const { user } = await validate(tokenset);
|
||
|
||
// Assert
|
||
expect(user).toBeTruthy();
|
||
expect(user.email).toBe(tokenset.claims().email);
|
||
});
|
||
|
||
it('should reject login when user has none of the required multiple roles', async () => {
|
||
// Arrange
|
||
process.env.OPENID_REQUIRED_ROLE = 'someRole,anotherRole,admin';
|
||
await setupOpenId(); // Re-initialize the strategy
|
||
verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid');
|
||
jwtDecode.mockReturnValue({
|
||
roles: ['aThirdRole', 'aFourthRole'],
|
||
});
|
||
|
||
// Act
|
||
const { user, details } = await validate(tokenset);
|
||
|
||
// Assert
|
||
expect(user).toBe(false);
|
||
expect(details.message).toBe(
|
||
'You must have one of: "someRole", "anotherRole", "admin" role to log in.',
|
||
);
|
||
});
|
||
|
||
it('should handle spaces in comma-separated roles', async () => {
|
||
// Arrange
|
||
process.env.OPENID_REQUIRED_ROLE = ' someRole , anotherRole , admin ';
|
||
await setupOpenId(); // Re-initialize the strategy
|
||
verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid');
|
||
jwtDecode.mockReturnValue({
|
||
roles: ['someRole'],
|
||
});
|
||
|
||
// Act
|
||
const { user } = await validate(tokenset);
|
||
|
||
// Assert
|
||
expect(user).toBeTruthy();
|
||
});
|
||
|
||
it('should default to usePKCE false when OPENID_USE_PKCE is not defined', async () => {
|
||
const OpenIDStrategy = require('openid-client/passport').Strategy;
|
||
|
||
delete process.env.OPENID_USE_PKCE;
|
||
await setupOpenId();
|
||
|
||
const callOptions = OpenIDStrategy.mock.calls[OpenIDStrategy.mock.calls.length - 1][0];
|
||
expect(callOptions.usePKCE).toBe(false);
|
||
expect(callOptions.params?.code_challenge_method).toBeUndefined();
|
||
});
|
||
|
||
it('should attach federatedTokens to user object for token propagation', async () => {
|
||
// Arrange - setup tokenset with access token, id token, refresh token, and expiration
|
||
const tokensetWithTokens = {
|
||
...tokenset,
|
||
access_token: 'mock_access_token_abc123',
|
||
id_token: 'mock_id_token_def456',
|
||
refresh_token: 'mock_refresh_token_xyz789',
|
||
expires_at: 1234567890,
|
||
};
|
||
|
||
// Act - validate with the tokenset containing tokens
|
||
const { user } = await validate(tokensetWithTokens);
|
||
|
||
// Assert - verify federatedTokens object is attached with correct values
|
||
expect(user.federatedTokens).toBeDefined();
|
||
expect(user.federatedTokens).toEqual({
|
||
access_token: 'mock_access_token_abc123',
|
||
id_token: 'mock_id_token_def456',
|
||
refresh_token: 'mock_refresh_token_xyz789',
|
||
expires_at: 1234567890,
|
||
});
|
||
});
|
||
|
||
it('should include id_token in federatedTokens distinct from access_token', async () => {
|
||
// Arrange - use different values for access_token and id_token
|
||
const tokensetWithTokens = {
|
||
...tokenset,
|
||
access_token: 'the_access_token',
|
||
id_token: 'the_id_token',
|
||
refresh_token: 'the_refresh_token',
|
||
expires_at: 9999999999,
|
||
};
|
||
|
||
// Act
|
||
const { user } = await validate(tokensetWithTokens);
|
||
|
||
// Assert - id_token and access_token must be different values
|
||
expect(user.federatedTokens.access_token).toBe('the_access_token');
|
||
expect(user.federatedTokens.id_token).toBe('the_id_token');
|
||
expect(user.federatedTokens.id_token).not.toBe(user.federatedTokens.access_token);
|
||
});
|
||
|
||
it('should include tokenset along with federatedTokens', async () => {
|
||
// Arrange
|
||
const tokensetWithTokens = {
|
||
...tokenset,
|
||
access_token: 'test_access_token',
|
||
id_token: 'test_id_token',
|
||
refresh_token: 'test_refresh_token',
|
||
expires_at: 9999999999,
|
||
};
|
||
|
||
// Act
|
||
const { user } = await validate(tokensetWithTokens);
|
||
|
||
// Assert - both tokenset and federatedTokens should be present
|
||
expect(user.tokenset).toBeDefined();
|
||
expect(user.federatedTokens).toBeDefined();
|
||
expect(user.tokenset.access_token).toBe('test_access_token');
|
||
expect(user.tokenset.id_token).toBe('test_id_token');
|
||
expect(user.federatedTokens.access_token).toBe('test_access_token');
|
||
expect(user.federatedTokens.id_token).toBe('test_id_token');
|
||
});
|
||
|
||
it('should set role to "ADMIN" if OPENID_ADMIN_ROLE is set and user has that role', async () => {
|
||
// Act
|
||
const { user } = await validate(tokenset);
|
||
|
||
// Assert – verify that the user role is set to "ADMIN"
|
||
expect(user.role).toBe('ADMIN');
|
||
});
|
||
|
||
it('should not set user role if OPENID_ADMIN_ROLE is set but the user does not have that role', async () => {
|
||
// Arrange – simulate a token without the admin permission
|
||
jwtDecode.mockReturnValue({
|
||
roles: ['requiredRole'],
|
||
permissions: ['not-admin'],
|
||
});
|
||
|
||
// Act
|
||
const { user } = await validate(tokenset);
|
||
|
||
// Assert – verify that the user role is not defined
|
||
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 = {
|
||
_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;
|
||
});
|
||
|
||
// Token without admin permission
|
||
jwtDecode.mockReturnValue({
|
||
roles: ['requiredRole'],
|
||
permissions: ['not-admin'],
|
||
});
|
||
|
||
const { logger } = require('@librechat/data-schemas');
|
||
|
||
// Act
|
||
const { user } = await validate(tokenset);
|
||
|
||
// Assert – verify that the user was demoted
|
||
expect(user.role).toBe('USER');
|
||
expect(updateUser).toHaveBeenCalledWith(
|
||
existingAdminUser._id,
|
||
expect.objectContaining({
|
||
role: 'USER',
|
||
}),
|
||
);
|
||
expect(logger.info).toHaveBeenCalledWith(
|
||
expect.stringContaining('demoted from admin - role no longer present in token'),
|
||
);
|
||
});
|
||
|
||
it('should NOT demote admin user when admin role env vars are not configured', async () => {
|
||
// Arrange – remove admin role env vars
|
||
delete process.env.OPENID_ADMIN_ROLE;
|
||
delete process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH;
|
||
delete process.env.OPENID_ADMIN_ROLE_TOKEN_KIND;
|
||
|
||
await setupOpenId();
|
||
verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid');
|
||
|
||
// Simulate an existing admin user
|
||
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'],
|
||
});
|
||
|
||
// Act
|
||
const { user } = await validate(tokenset);
|
||
|
||
// Assert – verify that the admin user was NOT demoted
|
||
expect(user.role).toBe('ADMIN');
|
||
expect(updateUser).toHaveBeenCalledWith(
|
||
existingAdminUser._id,
|
||
expect.objectContaining({
|
||
role: 'ADMIN',
|
||
}),
|
||
);
|
||
});
|
||
|
||
describe('lodash get - nested path extraction', () => {
|
||
it('should extract roles from deeply nested token path', async () => {
|
||
process.env.OPENID_REQUIRED_ROLE = 'app-user';
|
||
process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'resource_access.my-client.roles';
|
||
|
||
jwtDecode.mockReturnValue({
|
||
resource_access: {
|
||
'my-client': {
|
||
roles: ['app-user', 'viewer'],
|
||
},
|
||
},
|
||
});
|
||
|
||
await setupOpenId();
|
||
verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid');
|
||
|
||
const { user } = await validate(tokenset);
|
||
|
||
expect(user).toBeTruthy();
|
||
expect(user.email).toBe(tokenset.claims().email);
|
||
});
|
||
|
||
it('should extract roles from three-level nested path', async () => {
|
||
process.env.OPENID_REQUIRED_ROLE = 'editor';
|
||
process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'data.access.permissions.roles';
|
||
|
||
jwtDecode.mockReturnValue({
|
||
data: {
|
||
access: {
|
||
permissions: {
|
||
roles: ['editor', 'reader'],
|
||
},
|
||
},
|
||
},
|
||
});
|
||
|
||
await setupOpenId();
|
||
verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid');
|
||
|
||
const { user } = await validate(tokenset);
|
||
|
||
expect(user).toBeTruthy();
|
||
});
|
||
|
||
it('should log error and reject login when required role path does not exist in token', async () => {
|
||
const { logger } = require('@librechat/data-schemas');
|
||
process.env.OPENID_REQUIRED_ROLE = 'app-user';
|
||
process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'resource_access.nonexistent.roles';
|
||
|
||
jwtDecode.mockReturnValue({
|
||
resource_access: {
|
||
'my-client': {
|
||
roles: ['app-user'],
|
||
},
|
||
},
|
||
});
|
||
|
||
await setupOpenId();
|
||
verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid');
|
||
|
||
const { user, details } = await validate(tokenset);
|
||
|
||
expect(logger.error).toHaveBeenCalledWith(
|
||
expect.stringContaining("Key 'resource_access.nonexistent.roles' not found in id token!"),
|
||
);
|
||
expect(user).toBe(false);
|
||
expect(details.message).toContain('role to log in');
|
||
});
|
||
|
||
it('should handle missing intermediate nested path gracefully', async () => {
|
||
const { logger } = require('@librechat/data-schemas');
|
||
process.env.OPENID_REQUIRED_ROLE = 'user';
|
||
process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'org.team.roles';
|
||
|
||
jwtDecode.mockReturnValue({
|
||
org: {
|
||
other: 'value',
|
||
},
|
||
});
|
||
|
||
await setupOpenId();
|
||
verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid');
|
||
|
||
const { user } = await validate(tokenset);
|
||
|
||
expect(logger.error).toHaveBeenCalledWith(
|
||
expect.stringContaining("Key 'org.team.roles' not found in id token!"),
|
||
);
|
||
expect(user).toBe(false);
|
||
});
|
||
|
||
it('should extract admin role from nested path in access token', async () => {
|
||
process.env.OPENID_ADMIN_ROLE = 'admin';
|
||
process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'realm_access.roles';
|
||
process.env.OPENID_ADMIN_ROLE_TOKEN_KIND = 'access';
|
||
|
||
jwtDecode.mockImplementation((token) => {
|
||
if (token === 'fake_access_token') {
|
||
return {
|
||
realm_access: {
|
||
roles: ['admin', 'user'],
|
||
},
|
||
};
|
||
}
|
||
return {
|
||
roles: ['requiredRole'],
|
||
};
|
||
});
|
||
|
||
await setupOpenId();
|
||
verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid');
|
||
|
||
const { user } = await validate(tokenset);
|
||
|
||
expect(user.role).toBe('ADMIN');
|
||
});
|
||
|
||
it('should extract admin role from nested path in userinfo', async () => {
|
||
process.env.OPENID_ADMIN_ROLE = 'admin';
|
||
process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'organization.permissions';
|
||
process.env.OPENID_ADMIN_ROLE_TOKEN_KIND = 'userinfo';
|
||
|
||
const userinfoWithNestedGroups = {
|
||
...tokenset.claims(),
|
||
organization: {
|
||
permissions: ['admin', 'write'],
|
||
},
|
||
};
|
||
|
||
require('openid-client').fetchUserInfo.mockResolvedValue({
|
||
organization: {
|
||
permissions: ['admin', 'write'],
|
||
},
|
||
});
|
||
|
||
jwtDecode.mockReturnValue({
|
||
roles: ['requiredRole'],
|
||
});
|
||
|
||
await setupOpenId();
|
||
verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid');
|
||
|
||
const { user } = await validate({
|
||
...tokenset,
|
||
claims: () => userinfoWithNestedGroups,
|
||
});
|
||
|
||
expect(user.role).toBe('ADMIN');
|
||
});
|
||
|
||
it('should handle boolean admin role value', async () => {
|
||
process.env.OPENID_ADMIN_ROLE = 'admin';
|
||
process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'is_admin';
|
||
|
||
jwtDecode.mockReturnValue({
|
||
roles: ['requiredRole'],
|
||
is_admin: true,
|
||
});
|
||
|
||
await setupOpenId();
|
||
verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid');
|
||
|
||
const { user } = await validate(tokenset);
|
||
|
||
expect(user.role).toBe('ADMIN');
|
||
});
|
||
|
||
it('should handle string admin role value matching exactly', async () => {
|
||
process.env.OPENID_ADMIN_ROLE = 'super-admin';
|
||
process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'role';
|
||
|
||
jwtDecode.mockReturnValue({
|
||
roles: ['requiredRole'],
|
||
role: 'super-admin',
|
||
});
|
||
|
||
await setupOpenId();
|
||
verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid');
|
||
|
||
const { user } = await validate(tokenset);
|
||
|
||
expect(user.role).toBe('ADMIN');
|
||
});
|
||
|
||
it('should not set admin role when string value does not match', async () => {
|
||
process.env.OPENID_ADMIN_ROLE = 'super-admin';
|
||
process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'role';
|
||
|
||
jwtDecode.mockReturnValue({
|
||
roles: ['requiredRole'],
|
||
role: 'regular-user',
|
||
});
|
||
|
||
await setupOpenId();
|
||
verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid');
|
||
|
||
const { user } = await validate(tokenset);
|
||
|
||
expect(user.role).toBeUndefined();
|
||
});
|
||
|
||
it('should handle array admin role value', async () => {
|
||
process.env.OPENID_ADMIN_ROLE = 'site-admin';
|
||
process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'app_roles';
|
||
|
||
jwtDecode.mockReturnValue({
|
||
roles: ['requiredRole'],
|
||
app_roles: ['user', 'site-admin', 'moderator'],
|
||
});
|
||
|
||
await setupOpenId();
|
||
verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid');
|
||
|
||
const { user } = await validate(tokenset);
|
||
|
||
expect(user.role).toBe('ADMIN');
|
||
});
|
||
|
||
it('should not set admin when role is not in array', async () => {
|
||
process.env.OPENID_ADMIN_ROLE = 'site-admin';
|
||
process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'app_roles';
|
||
|
||
jwtDecode.mockReturnValue({
|
||
roles: ['requiredRole'],
|
||
app_roles: ['user', 'moderator'],
|
||
});
|
||
|
||
await setupOpenId();
|
||
verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid');
|
||
|
||
const { user } = await validate(tokenset);
|
||
|
||
expect(user.role).toBeUndefined();
|
||
});
|
||
|
||
it('should grant admin when admin role claim is a space-separated string containing the admin role', async () => {
|
||
// Arrange – IdP returns admin roles as a space-delimited string
|
||
process.env.OPENID_ADMIN_ROLE = 'site-admin';
|
||
process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'app_roles';
|
||
|
||
jwtDecode.mockReturnValue({
|
||
roles: ['requiredRole'],
|
||
app_roles: 'user site-admin moderator',
|
||
});
|
||
|
||
await setupOpenId();
|
||
verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid');
|
||
|
||
// Act
|
||
const { user } = await validate(tokenset);
|
||
|
||
// Assert – admin role is granted after splitting the delimited string
|
||
expect(user.role).toBe('ADMIN');
|
||
});
|
||
|
||
it('should not grant admin when admin role claim is a space-separated string that does not contain the admin role', async () => {
|
||
// Arrange – delimited string present but admin role is absent
|
||
process.env.OPENID_ADMIN_ROLE = 'site-admin';
|
||
process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'app_roles';
|
||
|
||
jwtDecode.mockReturnValue({
|
||
roles: ['requiredRole'],
|
||
app_roles: 'user moderator',
|
||
});
|
||
|
||
await setupOpenId();
|
||
verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid');
|
||
|
||
// Act
|
||
const { user } = await validate(tokenset);
|
||
|
||
// Assert – admin role is not granted
|
||
expect(user.role).toBeUndefined();
|
||
});
|
||
|
||
it('should handle nested path with special characters in keys', async () => {
|
||
process.env.OPENID_REQUIRED_ROLE = 'app-user';
|
||
process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'resource_access.my-app-123.roles';
|
||
|
||
jwtDecode.mockReturnValue({
|
||
resource_access: {
|
||
'my-app-123': {
|
||
roles: ['app-user'],
|
||
},
|
||
},
|
||
});
|
||
|
||
await setupOpenId();
|
||
verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid');
|
||
|
||
const { user } = await validate(tokenset);
|
||
|
||
expect(user).toBeTruthy();
|
||
});
|
||
|
||
it('should handle empty object at nested path', async () => {
|
||
const { logger } = require('@librechat/data-schemas');
|
||
process.env.OPENID_REQUIRED_ROLE = 'user';
|
||
process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'access.roles';
|
||
|
||
jwtDecode.mockReturnValue({
|
||
access: {},
|
||
});
|
||
|
||
await setupOpenId();
|
||
verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid');
|
||
|
||
const { user } = await validate(tokenset);
|
||
|
||
expect(logger.error).toHaveBeenCalledWith(
|
||
expect.stringContaining("Key 'access.roles' not found in id token!"),
|
||
);
|
||
expect(user).toBe(false);
|
||
});
|
||
|
||
it('should handle null value at intermediate path', async () => {
|
||
const { logger } = require('@librechat/data-schemas');
|
||
process.env.OPENID_REQUIRED_ROLE = 'user';
|
||
process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'data.roles';
|
||
|
||
jwtDecode.mockReturnValue({
|
||
data: null,
|
||
});
|
||
|
||
await setupOpenId();
|
||
verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid');
|
||
|
||
const { user } = await validate(tokenset);
|
||
|
||
expect(logger.error).toHaveBeenCalledWith(
|
||
expect.stringContaining("Key 'data.roles' not found in id token!"),
|
||
);
|
||
expect(user).toBe(false);
|
||
});
|
||
|
||
it('should reject login with invalid admin role token kind', async () => {
|
||
process.env.OPENID_ADMIN_ROLE = 'admin';
|
||
process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'roles';
|
||
process.env.OPENID_ADMIN_ROLE_TOKEN_KIND = 'invalid';
|
||
|
||
const { logger } = require('@librechat/data-schemas');
|
||
|
||
jwtDecode.mockReturnValue({
|
||
roles: ['requiredRole', 'admin'],
|
||
});
|
||
|
||
await setupOpenId();
|
||
verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid');
|
||
|
||
await expect(validate(tokenset)).rejects.toThrow('Invalid admin role token kind');
|
||
|
||
expect(logger.error).toHaveBeenCalledWith(
|
||
expect.stringContaining(
|
||
"Invalid admin role token kind: invalid. Must be one of 'access', 'id', or 'userinfo'",
|
||
),
|
||
);
|
||
});
|
||
|
||
it('should reject login when roles path returns invalid type (object)', async () => {
|
||
const { logger } = require('@librechat/data-schemas');
|
||
process.env.OPENID_REQUIRED_ROLE = 'app-user';
|
||
process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'roles';
|
||
|
||
jwtDecode.mockReturnValue({
|
||
roles: { admin: true, user: false },
|
||
});
|
||
|
||
await setupOpenId();
|
||
verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid');
|
||
|
||
const { user, details } = await validate(tokenset);
|
||
|
||
expect(logger.error).toHaveBeenCalledWith(
|
||
expect.stringContaining("Key 'roles' not found in id token!"),
|
||
);
|
||
expect(user).toBe(false);
|
||
expect(details.message).toContain('role to log in');
|
||
});
|
||
|
||
it('should reject login when roles path returns invalid type (number)', async () => {
|
||
const { logger } = require('@librechat/data-schemas');
|
||
process.env.OPENID_REQUIRED_ROLE = 'user';
|
||
process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'roleCount';
|
||
|
||
jwtDecode.mockReturnValue({
|
||
roleCount: 5,
|
||
});
|
||
|
||
await setupOpenId();
|
||
verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid');
|
||
|
||
const { user } = await validate(tokenset);
|
||
|
||
expect(logger.error).toHaveBeenCalledWith(
|
||
expect.stringContaining("Key 'roleCount' not found in id token!"),
|
||
);
|
||
expect(user).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe('OPENID_EMAIL_CLAIM', () => {
|
||
it('should use the default email when OPENID_EMAIL_CLAIM is not set', async () => {
|
||
const { user } = await validate(tokenset);
|
||
expect(user.email).toBe('test@example.com');
|
||
});
|
||
|
||
it('should use the configured claim when OPENID_EMAIL_CLAIM is set', async () => {
|
||
process.env.OPENID_EMAIL_CLAIM = 'upn';
|
||
const userinfo = { ...tokenset.claims(), upn: 'user@corp.example.com' };
|
||
|
||
const { user } = await validate({ ...tokenset, claims: () => userinfo });
|
||
|
||
expect(user.email).toBe('user@corp.example.com');
|
||
expect(createUser).toHaveBeenCalledWith(
|
||
expect.objectContaining({ email: 'user@corp.example.com' }),
|
||
expect.anything(),
|
||
true,
|
||
true,
|
||
);
|
||
});
|
||
|
||
it('should fall back to preferred_username when email is missing and OPENID_EMAIL_CLAIM is not set', async () => {
|
||
const userinfo = { ...tokenset.claims() };
|
||
delete userinfo.email;
|
||
|
||
const { user } = await validate({ ...tokenset, claims: () => userinfo });
|
||
|
||
expect(user.email).toBe('testusername');
|
||
});
|
||
|
||
it('should fall back to upn when email and preferred_username are missing and OPENID_EMAIL_CLAIM is not set', async () => {
|
||
const userinfo = { ...tokenset.claims(), upn: 'user@corp.example.com' };
|
||
delete userinfo.email;
|
||
delete userinfo.preferred_username;
|
||
|
||
const { user } = await validate({ ...tokenset, claims: () => userinfo });
|
||
|
||
expect(user.email).toBe('user@corp.example.com');
|
||
});
|
||
|
||
it('should ignore empty string OPENID_EMAIL_CLAIM and use default fallback', async () => {
|
||
process.env.OPENID_EMAIL_CLAIM = '';
|
||
|
||
const { user } = await validate(tokenset);
|
||
|
||
expect(user.email).toBe('test@example.com');
|
||
});
|
||
|
||
it('should trim whitespace from OPENID_EMAIL_CLAIM and resolve correctly', async () => {
|
||
process.env.OPENID_EMAIL_CLAIM = ' upn ';
|
||
const userinfo = { ...tokenset.claims(), upn: 'user@corp.example.com' };
|
||
|
||
const { user } = await validate({ ...tokenset, claims: () => userinfo });
|
||
|
||
expect(user.email).toBe('user@corp.example.com');
|
||
});
|
||
|
||
it('should ignore whitespace-only OPENID_EMAIL_CLAIM and use default fallback', async () => {
|
||
process.env.OPENID_EMAIL_CLAIM = ' ';
|
||
|
||
const { user } = await validate(tokenset);
|
||
|
||
expect(user.email).toBe('test@example.com');
|
||
});
|
||
|
||
it('should fall back to default chain with warning when configured claim is missing from userinfo', async () => {
|
||
const { logger } = require('@librechat/data-schemas');
|
||
process.env.OPENID_EMAIL_CLAIM = 'nonexistent_claim';
|
||
|
||
const { user } = await validate(tokenset);
|
||
|
||
expect(user.email).toBe('test@example.com');
|
||
expect(logger.warn).toHaveBeenCalledWith(
|
||
expect.stringContaining('OPENID_EMAIL_CLAIM="nonexistent_claim" not present in userinfo'),
|
||
);
|
||
});
|
||
});
|
||
|
||
describe('Tenant-scoped config', () => {
|
||
it('should call resolveAppConfigForUser for tenant user', async () => {
|
||
const existingUser = {
|
||
_id: 'openid-tenant-user',
|
||
provider: 'openid',
|
||
openidId: '1234',
|
||
email: 'test@example.com',
|
||
tenantId: 'tenant-d',
|
||
role: 'USER',
|
||
};
|
||
findUser.mockResolvedValue(existingUser);
|
||
|
||
await validate(tokenset);
|
||
|
||
expect(resolveAppConfigForUser).toHaveBeenCalledWith(getAppConfig, existingUser);
|
||
});
|
||
|
||
it('should use baseConfig for new user without calling resolveAppConfigForUser', async () => {
|
||
findUser.mockResolvedValue(null);
|
||
|
||
await validate(tokenset);
|
||
|
||
expect(resolveAppConfigForUser).not.toHaveBeenCalled();
|
||
expect(getAppConfig).toHaveBeenCalledWith({ baseOnly: true });
|
||
});
|
||
|
||
it('should block login when tenant config restricts the domain', async () => {
|
||
const { isEmailDomainAllowed } = require('@librechat/api');
|
||
const existingUser = {
|
||
_id: 'openid-tenant-blocked',
|
||
provider: 'openid',
|
||
openidId: '1234',
|
||
email: 'test@example.com',
|
||
tenantId: 'tenant-restrict',
|
||
role: 'USER',
|
||
};
|
||
findUser.mockResolvedValue(existingUser);
|
||
resolveAppConfigForUser.mockResolvedValue({
|
||
registration: { allowedDomains: ['other.com'] },
|
||
});
|
||
isEmailDomainAllowed.mockReturnValueOnce(true).mockReturnValueOnce(false);
|
||
|
||
const { user, details } = await validate(tokenset);
|
||
expect(user).toBe(false);
|
||
expect(details).toEqual({ message: 'Email domain not allowed' });
|
||
});
|
||
});
|
||
});
|
||
|
||
describe('getRoleSource', () => {
|
||
const { getRoleSource } = require('./openidStrategy');
|
||
const { logger } = require('@librechat/data-schemas');
|
||
|
||
const accessClaims = { roles: ['from-access'] };
|
||
const idClaims = { roles: ['from-id'] };
|
||
const userinfo = { roles: ['from-userinfo'] };
|
||
const tokenset = { access_token: 'access.jwt', id_token: 'id.jwt' };
|
||
|
||
beforeEach(() => {
|
||
jest.clearAllMocks();
|
||
jwtDecode.mockImplementation((token) => {
|
||
if (token === 'access.jwt') return accessClaims;
|
||
if (token === 'id.jwt') return idClaims;
|
||
return {};
|
||
});
|
||
});
|
||
|
||
it.each([
|
||
['access', accessClaims],
|
||
['id', idClaims],
|
||
['userinfo', userinfo],
|
||
])('returns the expected source object for kind=%s', (kind, expected) => {
|
||
expect(getRoleSource(kind, 'required role', tokenset, userinfo)).toEqual(expected);
|
||
});
|
||
|
||
it.each([
|
||
['undefined', undefined],
|
||
['empty string', ''],
|
||
['unknown kind', 'bogus'],
|
||
])('throws and logs for invalid kind: %s', (_name, kind) => {
|
||
expect(() => getRoleSource(kind, 'required role', tokenset, userinfo)).toThrow(
|
||
'Invalid required role token kind',
|
||
);
|
||
expect(logger.error).toHaveBeenCalledWith(
|
||
expect.stringContaining(`Invalid required role token kind: ${kind}`),
|
||
);
|
||
});
|
||
|
||
it('uses the provided label in the error message and thrown error', () => {
|
||
expect(() => getRoleSource('bogus', 'admin role', tokenset, userinfo)).toThrow(
|
||
'Invalid admin role token kind',
|
||
);
|
||
expect(logger.error).toHaveBeenCalledWith(
|
||
expect.stringContaining('Invalid admin role token kind: bogus'),
|
||
);
|
||
});
|
||
|
||
it('propagates jwtDecode errors when the requested token is missing', () => {
|
||
jwtDecode.mockImplementation(() => {
|
||
throw new Error('Invalid token specified');
|
||
});
|
||
expect(() => getRoleSource('access', 'required role', {}, userinfo)).toThrow(
|
||
'Invalid token specified',
|
||
);
|
||
});
|
||
});
|