mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-02-27 04:44:10 +01:00
🪪 feat: Add OPENID_EMAIL_CLAIM for Configurable OpenID User Identifier (#11699)
* Allow setting the claim field to be used when OpenID login is configured * fix(openid): harden getOpenIdEmail and expand test coverage Guard against non-string claim values in getOpenIdEmail to prevent a TypeError crash in isEmailDomainAllowed when domain restrictions are configured. Improve warning messages to name the fallback chain explicitly and distinguish missing vs. non-string claim values. Fix the domain-block error log to record the resolved identifier rather than userinfo.email, which was misleading when OPENID_EMAIL_CLAIM resolved to a different field (e.g. upn). Fix a latent test defect in openIdJwtStrategy.spec.js where the ~/server/services/Config mock exported getCustomConfig instead of getAppConfig, the symbol actually consumed by openidStrategy.js. Add refreshController tests covering the OPENID_EMAIL_CLAIM paths, which were previously untested despite being a stated fix target. Expand JWT strategy tests with null-payload, empty/whitespace OPENID_EMAIL_CLAIM, migration-via-preferred_username, and call-order assertions for the findUser lookup sequence. * test(auth): enhance AuthController and openIdJwtStrategy tests for openidId updates Added a new test in AuthController to verify that the openidId is updated correctly when a migration is triggered during the refresh process. Expanded the openIdJwtStrategy tests to include assertions for the updateUser function, ensuring that the correct parameters are passed when a user is found with a legacy email. This improves test coverage for OpenID-related functionality. --------- Co-authored-by: Danny Avila <danny@librechat.ai>
This commit is contained in:
parent
e978a934fc
commit
13df8ed67c
8 changed files with 447 additions and 13 deletions
|
|
@ -513,6 +513,9 @@ OPENID_ADMIN_ROLE_TOKEN_KIND=
|
|||
OPENID_USERNAME_CLAIM=
|
||||
# Set to determine which user info property returned from OpenID Provider to store as the User's name
|
||||
OPENID_NAME_CLAIM=
|
||||
# Set to determine which user info claim to use as the email/identifier for user matching (e.g., "upn" for Entra ID)
|
||||
# When not set, defaults to: email -> preferred_username -> upn
|
||||
OPENID_EMAIL_CLAIM=
|
||||
# Optional audience parameter for OpenID authorization requests
|
||||
OPENID_AUDIENCE=
|
||||
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ const {
|
|||
findUser,
|
||||
} = require('~/models');
|
||||
const { getGraphApiToken } = require('~/server/services/GraphTokenService');
|
||||
const { getOpenIdConfig } = require('~/strategies');
|
||||
const { getOpenIdConfig, getOpenIdEmail } = require('~/strategies');
|
||||
|
||||
const registrationController = async (req, res) => {
|
||||
try {
|
||||
|
|
@ -87,7 +87,7 @@ const refreshController = async (req, res) => {
|
|||
const claims = tokenset.claims();
|
||||
const { user, error, migration } = await findOpenIDUser({
|
||||
findUser,
|
||||
email: claims.email,
|
||||
email: getOpenIdEmail(claims),
|
||||
openidId: claims.sub,
|
||||
idOnTheSource: claims.oid,
|
||||
strategyName: 'refreshController',
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
jest.mock('@librechat/data-schemas', () => ({
|
||||
logger: { error: jest.fn(), debug: jest.fn(), warn: jest.fn() },
|
||||
logger: { error: jest.fn(), debug: jest.fn(), warn: jest.fn(), info: jest.fn() },
|
||||
}));
|
||||
jest.mock('~/server/services/GraphTokenService', () => ({
|
||||
getGraphApiToken: jest.fn(),
|
||||
|
|
@ -11,7 +11,8 @@ jest.mock('~/server/services/AuthService', () => ({
|
|||
setAuthTokens: jest.fn(),
|
||||
registerUser: jest.fn(),
|
||||
}));
|
||||
jest.mock('~/strategies', () => ({ getOpenIdConfig: jest.fn() }));
|
||||
jest.mock('~/strategies', () => ({ getOpenIdConfig: jest.fn(), getOpenIdEmail: jest.fn() }));
|
||||
jest.mock('openid-client', () => ({ refreshTokenGrant: jest.fn() }));
|
||||
jest.mock('~/models', () => ({
|
||||
deleteAllUserSessions: jest.fn(),
|
||||
getUserById: jest.fn(),
|
||||
|
|
@ -24,9 +25,13 @@ jest.mock('@librechat/api', () => ({
|
|||
findOpenIDUser: jest.fn(),
|
||||
}));
|
||||
|
||||
const { isEnabled } = require('@librechat/api');
|
||||
const openIdClient = require('openid-client');
|
||||
const { isEnabled, findOpenIDUser } = require('@librechat/api');
|
||||
const { graphTokenController, refreshController } = require('./AuthController');
|
||||
const { getGraphApiToken } = require('~/server/services/GraphTokenService');
|
||||
const { graphTokenController } = require('./AuthController');
|
||||
const { setOpenIDAuthTokens } = require('~/server/services/AuthService');
|
||||
const { getOpenIdConfig, getOpenIdEmail } = require('~/strategies');
|
||||
const { updateUser } = require('~/models');
|
||||
|
||||
describe('graphTokenController', () => {
|
||||
let req, res;
|
||||
|
|
@ -142,3 +147,156 @@ describe('graphTokenController', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('refreshController – OpenID path', () => {
|
||||
const mockTokenset = {
|
||||
claims: jest.fn(),
|
||||
access_token: 'new-access',
|
||||
id_token: 'new-id',
|
||||
refresh_token: 'new-refresh',
|
||||
};
|
||||
|
||||
const baseClaims = {
|
||||
sub: 'oidc-sub-123',
|
||||
oid: 'oid-456',
|
||||
email: 'user@example.com',
|
||||
exp: 9999999999,
|
||||
};
|
||||
|
||||
let req, res;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
isEnabled.mockReturnValue(true);
|
||||
getOpenIdConfig.mockReturnValue({ some: 'config' });
|
||||
openIdClient.refreshTokenGrant.mockResolvedValue(mockTokenset);
|
||||
mockTokenset.claims.mockReturnValue(baseClaims);
|
||||
getOpenIdEmail.mockReturnValue(baseClaims.email);
|
||||
setOpenIDAuthTokens.mockReturnValue('new-app-token');
|
||||
updateUser.mockResolvedValue({});
|
||||
|
||||
req = {
|
||||
headers: { cookie: 'token_provider=openid; refreshToken=stored-refresh' },
|
||||
session: {},
|
||||
};
|
||||
|
||||
res = {
|
||||
status: jest.fn().mockReturnThis(),
|
||||
send: jest.fn().mockReturnThis(),
|
||||
redirect: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
it('should call getOpenIdEmail with token claims and use result for findOpenIDUser', async () => {
|
||||
const user = {
|
||||
_id: 'user-db-id',
|
||||
email: baseClaims.email,
|
||||
openidId: baseClaims.sub,
|
||||
};
|
||||
findOpenIDUser.mockResolvedValue({ user, error: null, migration: false });
|
||||
|
||||
await refreshController(req, res);
|
||||
|
||||
expect(getOpenIdEmail).toHaveBeenCalledWith(baseClaims);
|
||||
expect(findOpenIDUser).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ email: baseClaims.email }),
|
||||
);
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
});
|
||||
|
||||
it('should use OPENID_EMAIL_CLAIM-resolved value when claim is present in token', async () => {
|
||||
const claimsWithUpn = { ...baseClaims, upn: 'user@corp.example.com' };
|
||||
mockTokenset.claims.mockReturnValue(claimsWithUpn);
|
||||
getOpenIdEmail.mockReturnValue('user@corp.example.com');
|
||||
|
||||
const user = {
|
||||
_id: 'user-db-id',
|
||||
email: 'user@corp.example.com',
|
||||
openidId: baseClaims.sub,
|
||||
};
|
||||
findOpenIDUser.mockResolvedValue({ user, error: null, migration: false });
|
||||
|
||||
await refreshController(req, res);
|
||||
|
||||
expect(getOpenIdEmail).toHaveBeenCalledWith(claimsWithUpn);
|
||||
expect(findOpenIDUser).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ email: 'user@corp.example.com' }),
|
||||
);
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
});
|
||||
|
||||
it('should fall back to claims.email when configured claim is absent from token claims', async () => {
|
||||
getOpenIdEmail.mockReturnValue(baseClaims.email);
|
||||
|
||||
const user = {
|
||||
_id: 'user-db-id',
|
||||
email: baseClaims.email,
|
||||
openidId: baseClaims.sub,
|
||||
};
|
||||
findOpenIDUser.mockResolvedValue({ user, error: null, migration: false });
|
||||
|
||||
await refreshController(req, res);
|
||||
|
||||
expect(findOpenIDUser).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ email: baseClaims.email }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should update openidId when migration is triggered on refresh', async () => {
|
||||
const user = { _id: 'user-db-id', email: baseClaims.email, openidId: null };
|
||||
findOpenIDUser.mockResolvedValue({ user, error: null, migration: true });
|
||||
|
||||
await refreshController(req, res);
|
||||
|
||||
expect(updateUser).toHaveBeenCalledWith(
|
||||
'user-db-id',
|
||||
expect.objectContaining({ provider: 'openid', openidId: baseClaims.sub }),
|
||||
);
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
});
|
||||
|
||||
it('should return 401 and redirect to /login when findOpenIDUser returns no user', async () => {
|
||||
findOpenIDUser.mockResolvedValue({ user: null, error: null, migration: false });
|
||||
|
||||
await refreshController(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(res.redirect).toHaveBeenCalledWith('/login');
|
||||
});
|
||||
|
||||
it('should return 401 and redirect when findOpenIDUser returns an error', async () => {
|
||||
findOpenIDUser.mockResolvedValue({ user: null, error: 'AUTH_FAILED', migration: false });
|
||||
|
||||
await refreshController(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(res.redirect).toHaveBeenCalledWith('/login');
|
||||
});
|
||||
|
||||
it('should skip OpenID path when token_provider is not openid', async () => {
|
||||
req.headers.cookie = 'token_provider=local; refreshToken=some-token';
|
||||
|
||||
await refreshController(req, res);
|
||||
|
||||
expect(openIdClient.refreshTokenGrant).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should skip OpenID path when OPENID_REUSE_TOKENS is disabled', async () => {
|
||||
isEnabled.mockReturnValue(false);
|
||||
|
||||
await refreshController(req, res);
|
||||
|
||||
expect(openIdClient.refreshTokenGrant).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return 200 with token not provided when refresh token is absent', async () => {
|
||||
req.headers.cookie = 'token_provider=openid';
|
||||
req.session = {};
|
||||
|
||||
await refreshController(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
expect(res.send).toHaveBeenCalledWith('Refresh token not provided');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
const { setupOpenId, getOpenIdConfig } = require('./openidStrategy');
|
||||
const { setupOpenId, getOpenIdConfig, getOpenIdEmail } = require('./openidStrategy');
|
||||
const openIdJwtLogin = require('./openIdJwtStrategy');
|
||||
const facebookLogin = require('./facebookStrategy');
|
||||
const discordLogin = require('./discordStrategy');
|
||||
|
|
@ -20,6 +20,7 @@ module.exports = {
|
|||
facebookLogin,
|
||||
setupOpenId,
|
||||
getOpenIdConfig,
|
||||
getOpenIdEmail,
|
||||
ldapLogin,
|
||||
setupSaml,
|
||||
openIdJwtLogin,
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ const { logger } = require('@librechat/data-schemas');
|
|||
const { HttpsProxyAgent } = require('https-proxy-agent');
|
||||
const { SystemRoles } = require('librechat-data-provider');
|
||||
const { isEnabled, findOpenIDUser, math } = require('@librechat/api');
|
||||
const { getOpenIdEmail } = require('./openidStrategy');
|
||||
const { Strategy: JwtStrategy, ExtractJwt } = require('passport-jwt');
|
||||
const { updateUser, findUser } = require('~/models');
|
||||
|
||||
|
|
@ -53,7 +54,7 @@ const openIdJwtLogin = (openIdConfig) => {
|
|||
|
||||
const { user, error, migration } = await findOpenIDUser({
|
||||
findUser,
|
||||
email: payload?.email,
|
||||
email: payload ? getOpenIdEmail(payload) : undefined,
|
||||
openidId: payload?.sub,
|
||||
idOnTheSource: payload?.oid,
|
||||
strategyName: 'openIdJwtLogin',
|
||||
|
|
|
|||
|
|
@ -29,10 +29,21 @@ jest.mock('~/models', () => ({
|
|||
findUser: jest.fn(),
|
||||
updateUser: jest.fn(),
|
||||
}));
|
||||
jest.mock('~/server/services/Files/strategies', () => ({
|
||||
getStrategyFunctions: jest.fn(() => ({
|
||||
saveBuffer: jest.fn().mockResolvedValue('/fake/path/to/avatar.png'),
|
||||
})),
|
||||
}));
|
||||
jest.mock('~/server/services/Config', () => ({
|
||||
getAppConfig: jest.fn().mockResolvedValue({}),
|
||||
}));
|
||||
jest.mock('~/cache/getLogStores', () =>
|
||||
jest.fn().mockReturnValue({ get: jest.fn(), set: jest.fn() }),
|
||||
);
|
||||
|
||||
const { findOpenIDUser } = require('@librechat/api');
|
||||
const { updateUser } = require('~/models');
|
||||
const openIdJwtLogin = require('./openIdJwtStrategy');
|
||||
const { findUser, updateUser } = require('~/models');
|
||||
|
||||
// Helper: build a mock openIdConfig
|
||||
const mockOpenIdConfig = {
|
||||
|
|
@ -181,3 +192,156 @@ describe('openIdJwtStrategy – token source handling', () => {
|
|||
expect(user.federatedTokens.access_token).not.toBe(user.federatedTokens.id_token);
|
||||
});
|
||||
});
|
||||
|
||||
describe('openIdJwtStrategy – OPENID_EMAIL_CLAIM', () => {
|
||||
const payload = {
|
||||
sub: 'oidc-123',
|
||||
email: 'test@example.com',
|
||||
preferred_username: 'testuser',
|
||||
upn: 'test@corp.example.com',
|
||||
exp: 9999999999,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
delete process.env.OPENID_EMAIL_CLAIM;
|
||||
|
||||
// Use real findOpenIDUser so it delegates to the findUser mock
|
||||
const realFindOpenIDUser = jest.requireActual('@librechat/api').findOpenIDUser;
|
||||
findOpenIDUser.mockImplementation(realFindOpenIDUser);
|
||||
|
||||
findUser.mockResolvedValue(null);
|
||||
updateUser.mockResolvedValue({});
|
||||
|
||||
openIdJwtLogin(mockOpenIdConfig);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.OPENID_EMAIL_CLAIM;
|
||||
});
|
||||
|
||||
it('should use the default email when OPENID_EMAIL_CLAIM is not set', async () => {
|
||||
const existingUser = {
|
||||
_id: 'user-id-1',
|
||||
provider: 'openid',
|
||||
openidId: payload.sub,
|
||||
email: payload.email,
|
||||
role: SystemRoles.USER,
|
||||
};
|
||||
findUser.mockImplementation(async (query) => {
|
||||
if (query.$or && query.$or.some((c) => c.openidId === payload.sub)) {
|
||||
return existingUser;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const req = { headers: { authorization: 'Bearer tok' }, session: {} };
|
||||
await invokeVerify(req, payload);
|
||||
|
||||
expect(findUser).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
$or: expect.arrayContaining([{ openidId: payload.sub }]),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should use OPENID_EMAIL_CLAIM when set for email lookup', async () => {
|
||||
process.env.OPENID_EMAIL_CLAIM = 'upn';
|
||||
findUser.mockResolvedValue(null);
|
||||
|
||||
const req = { headers: { authorization: 'Bearer tok' }, session: {} };
|
||||
const { user } = await invokeVerify(req, payload);
|
||||
|
||||
expect(findUser).toHaveBeenCalledTimes(2);
|
||||
expect(findUser.mock.calls[0][0]).toMatchObject({
|
||||
$or: expect.arrayContaining([{ openidId: payload.sub }]),
|
||||
});
|
||||
expect(findUser.mock.calls[1][0]).toEqual({ email: 'test@corp.example.com' });
|
||||
expect(user).toBe(false);
|
||||
});
|
||||
|
||||
it('should fall back to default chain when OPENID_EMAIL_CLAIM points to missing claim', async () => {
|
||||
process.env.OPENID_EMAIL_CLAIM = 'nonexistent_claim';
|
||||
findUser.mockResolvedValue(null);
|
||||
|
||||
const req = { headers: { authorization: 'Bearer tok' }, session: {} };
|
||||
const { user } = await invokeVerify(req, payload);
|
||||
|
||||
expect(findUser).toHaveBeenCalledWith({ email: payload.email });
|
||||
expect(user).toBe(false);
|
||||
});
|
||||
|
||||
it('should trim whitespace from OPENID_EMAIL_CLAIM', async () => {
|
||||
process.env.OPENID_EMAIL_CLAIM = ' upn ';
|
||||
findUser.mockResolvedValue(null);
|
||||
|
||||
const req = { headers: { authorization: 'Bearer tok' }, session: {} };
|
||||
await invokeVerify(req, payload);
|
||||
|
||||
expect(findUser).toHaveBeenCalledWith({ email: 'test@corp.example.com' });
|
||||
});
|
||||
|
||||
it('should ignore empty string OPENID_EMAIL_CLAIM and use default fallback', async () => {
|
||||
process.env.OPENID_EMAIL_CLAIM = '';
|
||||
findUser.mockResolvedValue(null);
|
||||
|
||||
const req = { headers: { authorization: 'Bearer tok' }, session: {} };
|
||||
await invokeVerify(req, payload);
|
||||
|
||||
expect(findUser).toHaveBeenCalledWith({ email: payload.email });
|
||||
});
|
||||
|
||||
it('should ignore whitespace-only OPENID_EMAIL_CLAIM and use default fallback', async () => {
|
||||
process.env.OPENID_EMAIL_CLAIM = ' ';
|
||||
findUser.mockResolvedValue(null);
|
||||
|
||||
const req = { headers: { authorization: 'Bearer tok' }, session: {} };
|
||||
await invokeVerify(req, payload);
|
||||
|
||||
expect(findUser).toHaveBeenCalledWith({ email: payload.email });
|
||||
});
|
||||
|
||||
it('should resolve undefined email when payload is null', async () => {
|
||||
const req = { headers: { authorization: 'Bearer tok' }, session: {} };
|
||||
const { user } = await invokeVerify(req, null);
|
||||
|
||||
expect(user).toBe(false);
|
||||
});
|
||||
|
||||
it('should attempt email lookup via preferred_username fallback when email claim is absent', async () => {
|
||||
const payloadNoEmail = {
|
||||
sub: 'oidc-new-sub',
|
||||
preferred_username: 'legacy@corp.com',
|
||||
upn: 'legacy@corp.com',
|
||||
exp: 9999999999,
|
||||
};
|
||||
|
||||
const legacyUser = {
|
||||
_id: 'legacy-db-id',
|
||||
email: 'legacy@corp.com',
|
||||
openidId: null,
|
||||
role: SystemRoles.USER,
|
||||
};
|
||||
|
||||
findUser.mockImplementation(async (query) => {
|
||||
if (query.$or) {
|
||||
return null;
|
||||
}
|
||||
if (query.email === 'legacy@corp.com') {
|
||||
return legacyUser;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const req = { headers: { authorization: 'Bearer tok' }, session: {} };
|
||||
const { user } = await invokeVerify(req, payloadNoEmail);
|
||||
|
||||
expect(findUser).toHaveBeenCalledTimes(2);
|
||||
expect(findUser.mock.calls[1][0]).toEqual({ email: 'legacy@corp.com' });
|
||||
expect(user).toBeTruthy();
|
||||
expect(updateUser).toHaveBeenCalledWith(
|
||||
'legacy-db-id',
|
||||
expect.objectContaining({ provider: 'openid', openidId: payloadNoEmail.sub }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -267,6 +267,34 @@ function getFullName(userinfo) {
|
|||
return userinfo.username || userinfo.email;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the user identifier from OpenID claims.
|
||||
* Configurable via OPENID_EMAIL_CLAIM; defaults to: email -> preferred_username -> upn.
|
||||
*
|
||||
* @param {Object} userinfo - The user information object from OpenID Connect
|
||||
* @returns {string|undefined} The resolved identifier string
|
||||
*/
|
||||
function getOpenIdEmail(userinfo) {
|
||||
const claimKey = process.env.OPENID_EMAIL_CLAIM?.trim();
|
||||
if (claimKey) {
|
||||
const value = userinfo[claimKey];
|
||||
if (typeof value === 'string' && value) {
|
||||
return value;
|
||||
}
|
||||
if (value !== undefined && value !== null) {
|
||||
logger.warn(
|
||||
`[openidStrategy] OPENID_EMAIL_CLAIM="${claimKey}" resolved to a non-string value (type: ${typeof value}). Falling back to: email -> preferred_username -> upn.`,
|
||||
);
|
||||
} else {
|
||||
logger.warn(
|
||||
`[openidStrategy] OPENID_EMAIL_CLAIM="${claimKey}" not present in userinfo. Falling back to: email -> preferred_username -> upn.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const fallback = userinfo.email || userinfo.preferred_username || userinfo.upn;
|
||||
return typeof fallback === 'string' ? fallback : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts an input into a string suitable for a username.
|
||||
* If the input is a string, it will be returned as is.
|
||||
|
|
@ -379,11 +407,10 @@ async function processOpenIDAuth(tokenset, existingUsersOnly = false) {
|
|||
}
|
||||
|
||||
const appConfig = await getAppConfig();
|
||||
/** Azure AD sometimes doesn't return email, use preferred_username as fallback */
|
||||
const email = userinfo.email || userinfo.preferred_username || userinfo.upn;
|
||||
const email = getOpenIdEmail(userinfo);
|
||||
if (!isEmailDomainAllowed(email, appConfig?.registration?.allowedDomains)) {
|
||||
logger.error(
|
||||
`[OpenID Strategy] Authentication blocked - email domain not allowed [Email: ${userinfo.email}]`,
|
||||
`[OpenID Strategy] Authentication blocked - email domain not allowed [Identifier: ${email}]`,
|
||||
);
|
||||
throw new Error('Email domain not allowed');
|
||||
}
|
||||
|
|
@ -728,4 +755,5 @@ function getOpenIdConfig() {
|
|||
module.exports = {
|
||||
setupOpenId,
|
||||
getOpenIdConfig,
|
||||
getOpenIdEmail,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
const undici = require('undici');
|
||||
const fetch = require('node-fetch');
|
||||
const jwtDecode = require('jsonwebtoken/decode');
|
||||
const undici = require('undici');
|
||||
const { ErrorTypes } = require('librechat-data-provider');
|
||||
const { findUser, createUser, updateUser } = require('~/models');
|
||||
const { setupOpenId } = require('./openidStrategy');
|
||||
|
|
@ -152,6 +152,7 @@ describe('setupOpenId', () => {
|
|||
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.PROXY;
|
||||
delete process.env.OPENID_USE_PKCE;
|
||||
|
||||
|
|
@ -1402,4 +1403,82 @@ describe('setupOpenId', () => {
|
|||
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'),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue