LibreChat/api/server/controllers/AuthController.spec.js
Juri Kuehn 13df8ed67c
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
🪪 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>
2026-02-25 22:31:03 -05:00

302 lines
9.2 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

jest.mock('@librechat/data-schemas', () => ({
logger: { error: jest.fn(), debug: jest.fn(), warn: jest.fn(), info: jest.fn() },
}));
jest.mock('~/server/services/GraphTokenService', () => ({
getGraphApiToken: jest.fn(),
}));
jest.mock('~/server/services/AuthService', () => ({
requestPasswordReset: jest.fn(),
setOpenIDAuthTokens: jest.fn(),
resetPassword: jest.fn(),
setAuthTokens: jest.fn(),
registerUser: 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(),
findSession: jest.fn(),
updateUser: jest.fn(),
findUser: jest.fn(),
}));
jest.mock('@librechat/api', () => ({
isEnabled: jest.fn(),
findOpenIDUser: jest.fn(),
}));
const openIdClient = require('openid-client');
const { isEnabled, findOpenIDUser } = require('@librechat/api');
const { graphTokenController, refreshController } = require('./AuthController');
const { getGraphApiToken } = require('~/server/services/GraphTokenService');
const { setOpenIDAuthTokens } = require('~/server/services/AuthService');
const { getOpenIdConfig, getOpenIdEmail } = require('~/strategies');
const { updateUser } = require('~/models');
describe('graphTokenController', () => {
let req, res;
beforeEach(() => {
jest.clearAllMocks();
isEnabled.mockReturnValue(true);
req = {
user: {
openidId: 'oid-123',
provider: 'openid',
federatedTokens: {
access_token: 'federated-access-token',
id_token: 'federated-id-token',
},
},
headers: { authorization: 'Bearer app-jwt-which-is-id-token' },
query: { scopes: 'https://graph.microsoft.com/.default' },
};
res = {
status: jest.fn().mockReturnThis(),
json: jest.fn(),
};
getGraphApiToken.mockResolvedValue({
access_token: 'graph-access-token',
token_type: 'Bearer',
expires_in: 3600,
});
});
it('should pass federatedTokens.access_token as OBO assertion, not the auth header bearer token', async () => {
await graphTokenController(req, res);
expect(getGraphApiToken).toHaveBeenCalledWith(
req.user,
'federated-access-token',
'https://graph.microsoft.com/.default',
);
expect(getGraphApiToken).not.toHaveBeenCalledWith(
expect.anything(),
'app-jwt-which-is-id-token',
expect.anything(),
);
});
it('should return the graph token response on success', async () => {
await graphTokenController(req, res);
expect(res.json).toHaveBeenCalledWith({
access_token: 'graph-access-token',
token_type: 'Bearer',
expires_in: 3600,
});
});
it('should return 403 when user is not authenticated via Entra ID', async () => {
req.user.provider = 'google';
req.user.openidId = undefined;
await graphTokenController(req, res);
expect(res.status).toHaveBeenCalledWith(403);
expect(getGraphApiToken).not.toHaveBeenCalled();
});
it('should return 403 when OPENID_REUSE_TOKENS is not enabled', async () => {
isEnabled.mockReturnValue(false);
await graphTokenController(req, res);
expect(res.status).toHaveBeenCalledWith(403);
expect(getGraphApiToken).not.toHaveBeenCalled();
});
it('should return 400 when scopes query param is missing', async () => {
req.query.scopes = undefined;
await graphTokenController(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(getGraphApiToken).not.toHaveBeenCalled();
});
it('should return 401 when federatedTokens.access_token is missing', async () => {
req.user.federatedTokens = {};
await graphTokenController(req, res);
expect(res.status).toHaveBeenCalledWith(401);
expect(getGraphApiToken).not.toHaveBeenCalled();
});
it('should return 401 when federatedTokens is absent entirely', async () => {
req.user.federatedTokens = undefined;
await graphTokenController(req, res);
expect(res.status).toHaveBeenCalledWith(401);
expect(getGraphApiToken).not.toHaveBeenCalled();
});
it('should return 500 when getGraphApiToken throws', async () => {
getGraphApiToken.mockRejectedValue(new Error('OBO exchange failed'));
await graphTokenController(req, res);
expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith({
message: 'Failed to obtain Microsoft Graph token',
});
});
});
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');
});
});