From 13df8ed67c45438ba1da7b8000619c433c6e8d94 Mon Sep 17 00:00:00 2001 From: Juri Kuehn Date: Thu, 26 Feb 2026 04:31:03 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=AA=AA=20feat:=20Add=20OPENID=5FEMAIL=5FC?= =?UTF-8?q?LAIM=20for=20Configurable=20OpenID=20User=20Identifier=20(#1169?= =?UTF-8?q?9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- .env.example | 3 + api/server/controllers/AuthController.js | 4 +- api/server/controllers/AuthController.spec.js | 166 +++++++++++++++++- api/strategies/index.js | 3 +- api/strategies/openIdJwtStrategy.js | 3 +- api/strategies/openIdJwtStrategy.spec.js | 166 +++++++++++++++++- api/strategies/openidStrategy.js | 34 +++- api/strategies/openidStrategy.spec.js | 81 ++++++++- 8 files changed, 447 insertions(+), 13 deletions(-) diff --git a/.env.example b/.env.example index 3e94a0c63a..e19346c4bf 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/api/server/controllers/AuthController.js b/api/server/controllers/AuthController.js index 58d2427512..13d024cd03 100644 --- a/api/server/controllers/AuthController.js +++ b/api/server/controllers/AuthController.js @@ -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', diff --git a/api/server/controllers/AuthController.spec.js b/api/server/controllers/AuthController.spec.js index cbf72657fb..fef670baa8 100644 --- a/api/server/controllers/AuthController.spec.js +++ b/api/server/controllers/AuthController.spec.js @@ -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'); + }); +}); diff --git a/api/strategies/index.js b/api/strategies/index.js index b4f7bd3cac..9a1c58ad38 100644 --- a/api/strategies/index.js +++ b/api/strategies/index.js @@ -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, diff --git a/api/strategies/openIdJwtStrategy.js b/api/strategies/openIdJwtStrategy.js index 997dcec397..ececf8df54 100644 --- a/api/strategies/openIdJwtStrategy.js +++ b/api/strategies/openIdJwtStrategy.js @@ -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', diff --git a/api/strategies/openIdJwtStrategy.spec.js b/api/strategies/openIdJwtStrategy.spec.js index 566afe5a90..79af848046 100644 --- a/api/strategies/openIdJwtStrategy.spec.js +++ b/api/strategies/openIdJwtStrategy.spec.js @@ -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 }), + ); + }); +}); diff --git a/api/strategies/openidStrategy.js b/api/strategies/openidStrategy.js index 15e21f67ef..0ebdcb04e1 100644 --- a/api/strategies/openidStrategy.js +++ b/api/strategies/openidStrategy.js @@ -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, }; diff --git a/api/strategies/openidStrategy.spec.js b/api/strategies/openidStrategy.spec.js index 00c65106ad..485b77829e 100644 --- a/api/strategies/openidStrategy.spec.js +++ b/api/strategies/openidStrategy.spec.js @@ -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'), + ); + }); + }); });