LibreChat/api/server/controllers/AuthController.spec.js
J.C. Bartle 8b5cea29bc 🛠️ fix: Bind OpenID session tokens to authenticated identity
Stamp OpenID session token state with the LibreChat user id, OpenID subject,
tenant id, and normalized issuer when tokens are stored.

Fail closed before OBO inline token reuse/refresh when the session token
identity does not match the current authenticated identity, preventing a stale
or mixed Express session from supplying another user's upstream assertion.

Also validate the normal /api/auth/refresh session-token reuse shortcut against
the signed marker-cookie user before returning cached session tokens.

Note: sessions created before this change carry no identity stamp and are
treated as a mismatch. This is self-healing — the reuse path forces a full IdP
refresh (which re-stamps the session) and the OBO path throws, surfacing as a
one-time re-authentication for active OBO users at deploy time. The session
re-stamps within one session lifetime (SESSION_EXPIRY, default 15 min).
2026-06-28 17:53:46 -04:00

1109 lines
35 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(),
setCloudFrontAuthCookies: 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('~/server/services/RefreshTokenBridge', () => ({
getRefreshTokenBridge: jest.fn(),
storeRefreshTokenBridge: jest.fn(),
}));
jest.mock('@librechat/api', () => ({
math: jest.fn((value, fallback) => fallback),
isEnabled: jest.fn(),
findOpenIDUser: jest.fn(),
getOpenIdIssuer: jest.fn(() => 'https://issuer.example.com'),
createAuthIdentityContext: jest.fn(({ user }) => ({
appUserId: user?._id?.toString?.() ?? user?.id,
openidSubject: user?.openidId,
tenantId: user?.tenantId,
openidIssuer: user?.openidIssuer,
})),
isOpenIDSessionIdentityMatch: jest.fn((sessionIdentity, expectedIdentity) => {
const normalize = (value) => {
if (value == null) {
return undefined;
}
const normalized = typeof value === 'string' ? value.trim() : value.toString().trim();
return normalized || undefined;
};
const normalizeIssuer = (value) => normalize(value)?.replace(/\/+$/, '');
return (
Boolean(normalize(sessionIdentity?.appUserId)) &&
Boolean(normalize(sessionIdentity?.openidSubject)) &&
normalize(sessionIdentity?.appUserId) === normalize(expectedIdentity?.appUserId) &&
normalize(sessionIdentity?.openidSubject) === normalize(expectedIdentity?.openidSubject) &&
normalize(sessionIdentity?.tenantId) === normalize(expectedIdentity?.tenantId) &&
normalizeIssuer(sessionIdentity?.openidIssuer) ===
normalizeIssuer(expectedIdentity?.openidIssuer)
);
}),
buildOpenIDRefreshParams: jest.fn(() => {
const params = {};
if (process.env.OPENID_SCOPE) {
params.scope = process.env.OPENID_SCOPE;
}
if (process.env.OPENID_REFRESH_AUDIENCE) {
params.audience = process.env.OPENID_REFRESH_AUDIENCE;
}
return params;
}),
}));
const openIdClient = require('openid-client');
const jwt = require('jsonwebtoken');
const { logger } = require('@librechat/data-schemas');
const { isEnabled, findOpenIDUser, buildOpenIDRefreshParams } = require('@librechat/api');
const { graphTokenController, refreshController } = require('./AuthController');
const { getGraphApiToken } = require('~/server/services/GraphTokenService');
const {
setOpenIDAuthTokens,
setCloudFrontAuthCookies,
setAuthTokens,
} = require('~/server/services/AuthService');
const { getOpenIdConfig, getOpenIdEmail } = require('~/strategies');
const { getUserById, findSession, updateUser } = require('~/models');
const {
getRefreshTokenBridge,
storeRefreshTokenBridge,
} = require('~/server/services/RefreshTokenBridge');
const ORIGINAL_OPENID_SCOPE = process.env.OPENID_SCOPE;
const ORIGINAL_OPENID_REFRESH_AUDIENCE = process.env.OPENID_REFRESH_AUDIENCE;
const ORIGINAL_JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET;
const ORIGINAL_NODE_ENV = process.env.NODE_ENV;
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',
expires_in: 3600,
};
const baseClaims = {
iss: 'https://issuer.example.com',
sub: 'oidc-sub-123',
oid: 'oid-456',
email: 'user@example.com',
exp: 9999999999,
};
const defaultUser = {
_id: 'user-db-id',
email: baseClaims.email,
openidId: baseClaims.sub,
tenantId: 'tenant-1',
openidIssuer: baseClaims.iss,
password: '$2b$10$hashedpassword',
__v: 0,
totpSecret: 'encrypted-totp-secret',
backupCodes: ['hashed-code-1', 'hashed-code-2'],
};
let req, res;
const idpSigningSecret = 'idp-signing-secret';
const makeSessionToken = (claims = {}) =>
jwt.sign(
{
sub: baseClaims.sub,
exp: Math.floor(Date.now() / 1000) + 3600,
...claims,
},
idpSigningSecret,
);
const makeSignedUserId = (id = 'user-db-id', options = { expiresIn: '1h' }) =>
jwt.sign({ id }, process.env.JWT_REFRESH_SECRET, options);
const setOpenIDReuseCookies = (signedUserId = makeSignedUserId()) => {
req.headers.cookie = [
'token_provider=openid',
'refreshToken=stored-refresh',
`openid_user_id=${signedUserId}`,
].join('; ');
};
beforeEach(() => {
jest.clearAllMocks();
delete process.env.OPENID_SCOPE;
delete process.env.OPENID_REFRESH_AUDIENCE;
process.env.JWT_REFRESH_SECRET = 'test-refresh-secret';
isEnabled.mockReturnValue(true);
getOpenIdConfig.mockReturnValue({ some: 'config' });
openIdClient.refreshTokenGrant.mockResolvedValue(mockTokenset);
mockTokenset.claims.mockReturnValue(baseClaims);
getOpenIdEmail.mockReturnValue(baseClaims.email);
setOpenIDAuthTokens.mockReturnValue('new-app-token');
setCloudFrontAuthCookies.mockReturnValue(true);
findOpenIDUser.mockResolvedValue({ user: { ...defaultUser }, error: null, migration: false });
getRefreshTokenBridge.mockResolvedValue(null);
storeRefreshTokenBridge.mockResolvedValue(undefined);
getUserById.mockResolvedValue({
_id: 'user-db-id',
email: baseClaims.email,
openidId: baseClaims.sub,
tenantId: 'tenant-1',
openidIssuer: baseClaims.iss,
});
updateUser.mockResolvedValue({});
req = {
headers: { cookie: 'token_provider=openid; refreshToken=stored-refresh' },
session: {},
};
res = {
status: jest.fn().mockReturnThis(),
send: jest.fn().mockReturnThis(),
redirect: jest.fn(),
};
});
afterAll(() => {
if (ORIGINAL_OPENID_SCOPE === undefined) {
delete process.env.OPENID_SCOPE;
} else {
process.env.OPENID_SCOPE = ORIGINAL_OPENID_SCOPE;
}
if (ORIGINAL_OPENID_REFRESH_AUDIENCE === undefined) {
delete process.env.OPENID_REFRESH_AUDIENCE;
} else {
process.env.OPENID_REFRESH_AUDIENCE = ORIGINAL_OPENID_REFRESH_AUDIENCE;
}
if (ORIGINAL_JWT_REFRESH_SECRET === undefined) {
delete process.env.JWT_REFRESH_SECRET;
} else {
process.env.JWT_REFRESH_SECRET = ORIGINAL_JWT_REFRESH_SECRET;
}
});
/** Asserts the full OpenID refresh grant was triggered using default mock state. */
const expectOpenIDRefreshGrant = () => {
expect(openIdClient.refreshTokenGrant).toHaveBeenCalledWith(
{ some: 'config' },
'stored-refresh',
{},
);
expect(setOpenIDAuthTokens).toHaveBeenCalledWith(mockTokenset, req, res, {
userId: 'user-db-id',
existingRefreshToken: 'stored-refresh',
tenantId: 'tenant-1',
openidSubject: baseClaims.sub,
openidIssuer: baseClaims.iss,
});
};
it('should call getOpenIdEmail with token claims and use result for findOpenIDUser', async () => {
await refreshController(req, res);
expect(buildOpenIDRefreshParams).toHaveBeenCalledTimes(1);
expect(getOpenIdEmail).toHaveBeenCalledWith(baseClaims);
expect(findOpenIDUser).toHaveBeenCalledWith(
expect.objectContaining({
email: baseClaims.email,
openidIssuer: baseClaims.iss,
}),
);
expect(res.status).toHaveBeenCalledWith(200);
});
it('reuses valid OpenID session tokens and refreshes CloudFront cookies', async () => {
const reusableIdToken = makeSessionToken();
const signedUserId = makeSignedUserId();
setOpenIDReuseCookies(signedUserId);
req.session = {
openidTokens: {
accessToken: 'session-access-token',
idToken: reusableIdToken,
refreshToken: 'stored-refresh',
lastRefreshedAt: Date.now(),
appUserId: 'user-db-id',
openidSubject: baseClaims.sub,
tenantId: 'tenant-1',
openidIssuer: baseClaims.iss,
},
};
const user = {
...defaultUser,
federatedTokens: { access_token: 'do-not-return' },
};
getUserById.mockResolvedValue(user);
await refreshController(req, res);
expect(openIdClient.refreshTokenGrant).not.toHaveBeenCalled();
expect(setOpenIDAuthTokens).not.toHaveBeenCalled();
expect(storeRefreshTokenBridge).not.toHaveBeenCalled();
expect(getUserById).toHaveBeenCalledWith(
'user-db-id',
'-password -__v -totpSecret -backupCodes -federatedTokens',
);
expect(setCloudFrontAuthCookies).toHaveBeenCalledWith(req, res, user);
expect(res.status).toHaveBeenCalledWith(200);
expect(res.send).toHaveBeenCalledWith({
token: reusableIdToken,
user: expect.objectContaining({
_id: 'user-db-id',
email: baseClaims.email,
openidId: baseClaims.sub,
}),
});
const sentPayload = res.send.mock.calls[0][0];
expect(sentPayload.user).not.toHaveProperty('password');
expect(sentPayload.user).not.toHaveProperty('totpSecret');
expect(sentPayload.user).not.toHaveProperty('backupCodes');
expect(sentPayload.user).not.toHaveProperty('federatedTokens');
expect(logger.debug).toHaveBeenCalledWith(
'[refreshController] OpenID session token reused',
expect.objectContaining({
token_type: 'id_token',
cloudfront_cookies_set: true,
}),
);
const debugOutput = JSON.stringify(logger.debug.mock.calls);
expect(debugOutput).not.toContain(reusableIdToken);
expect(debugOutput).not.toContain(signedUserId);
expect(debugOutput).not.toContain('session-access-token');
});
it('falls through to full OpenID refresh when reusable session token identity mismatches', async () => {
setOpenIDReuseCookies();
req.session = {
openidTokens: {
accessToken: 'session-access-token',
idToken: makeSessionToken(),
refreshToken: 'stored-refresh',
lastRefreshedAt: Date.now(),
appUserId: 'other-user-id',
openidSubject: baseClaims.sub,
tenantId: 'tenant-1',
openidIssuer: baseClaims.iss,
},
};
await refreshController(req, res);
expect(getUserById).toHaveBeenCalledWith(
'user-db-id',
'-password -__v -totpSecret -backupCodes -federatedTokens',
);
expect(setCloudFrontAuthCookies).not.toHaveBeenCalled();
expectOpenIDRefreshGrant();
expect(logger.warn).toHaveBeenCalledWith(
'[refreshController] OpenID session token identity mismatch; forcing refresh',
expect.objectContaining({
userId: 'user-db-id',
}),
);
});
it('falls through to full OpenID refresh when session tokens are expired', async () => {
const expiredToken = makeSessionToken({ exp: Math.floor(Date.now() / 1000) - 60 });
setOpenIDReuseCookies();
req.session = {
openidTokens: {
accessToken: expiredToken,
idToken: expiredToken,
refreshToken: 'stored-refresh',
lastRefreshedAt: Date.now(),
appUserId: 'user-db-id',
openidSubject: baseClaims.sub,
tenantId: 'tenant-1',
openidIssuer: baseClaims.iss,
},
};
await refreshController(req, res);
expect(getUserById).not.toHaveBeenCalled();
expect(setCloudFrontAuthCookies).not.toHaveBeenCalled();
expectOpenIDRefreshGrant();
});
it('falls through to full OpenID refresh when session tokens are near expiry', async () => {
const nearExpiryToken = makeSessionToken({ exp: Math.floor(Date.now() / 1000) + 5 });
setOpenIDReuseCookies();
req.session = {
openidTokens: {
accessToken: nearExpiryToken,
idToken: nearExpiryToken,
refreshToken: 'stored-refresh',
lastRefreshedAt: Date.now(),
},
};
await refreshController(req, res);
expect(getUserById).not.toHaveBeenCalled();
expectOpenIDRefreshGrant();
});
it('falls through to full OpenID refresh when session tokens have no exp claim', async () => {
const tokenWithoutExp = jwt.sign({ sub: baseClaims.sub }, idpSigningSecret);
setOpenIDReuseCookies();
req.session = {
openidTokens: {
accessToken: tokenWithoutExp,
idToken: tokenWithoutExp,
refreshToken: 'stored-refresh',
lastRefreshedAt: Date.now(),
},
};
await refreshController(req, res);
expect(getUserById).not.toHaveBeenCalled();
expectOpenIDRefreshGrant();
});
it('falls through to full OpenID refresh when the signed reuse user cookie is invalid', async () => {
setOpenIDReuseCookies('tampered-cookie');
req.session = {
openidTokens: {
accessToken: 'session-access-token',
idToken: makeSessionToken(),
refreshToken: 'stored-refresh',
lastRefreshedAt: Date.now(),
},
};
await refreshController(req, res);
expect(getUserById).not.toHaveBeenCalled();
expectOpenIDRefreshGrant();
});
it('falls through to full OpenID refresh when the reuse user no longer exists', async () => {
setOpenIDReuseCookies();
req.session = {
openidTokens: {
accessToken: 'session-access-token',
idToken: makeSessionToken(),
refreshToken: 'stored-refresh',
lastRefreshedAt: Date.now(),
},
};
getUserById.mockResolvedValueOnce(null);
await refreshController(req, res);
expect(getUserById).toHaveBeenCalledWith(
'user-db-id',
'-password -__v -totpSecret -backupCodes -federatedTokens',
);
expect(setCloudFrontAuthCookies).not.toHaveBeenCalled();
expectOpenIDRefreshGrant();
});
it('falls through to full OpenID refresh when session tokens are stale', async () => {
setOpenIDReuseCookies();
req.session = {
openidTokens: {
accessToken: 'session-access-token',
idToken: makeSessionToken(),
refreshToken: 'stored-refresh',
lastRefreshedAt: Date.now() - 16 * 60 * 1000,
},
};
await refreshController(req, res);
expect(getUserById).not.toHaveBeenCalled();
expectOpenIDRefreshGrant();
});
it('falls through to full OpenID refresh when session refresh timestamp is in the future', async () => {
setOpenIDReuseCookies();
req.session = {
openidTokens: {
accessToken: 'session-access-token',
idToken: makeSessionToken(),
refreshToken: 'stored-refresh',
lastRefreshedAt: Date.now() + 60 * 1000,
},
};
await refreshController(req, res);
expect(getUserById).not.toHaveBeenCalled();
expectOpenIDRefreshGrant();
});
it('falls through to full OpenID refresh for pre-upgrade sessions without lastRefreshedAt', async () => {
setOpenIDReuseCookies();
req.session = {
openidTokens: {
accessToken: 'session-access-token',
idToken: makeSessionToken(),
refreshToken: 'stored-refresh',
},
};
await refreshController(req, res);
expect(getUserById).not.toHaveBeenCalled();
expectOpenIDRefreshGrant();
});
it('sanitizes Mongoose-style user documents on the OpenID reuse path', async () => {
const reusableIdToken = makeSessionToken();
setOpenIDReuseCookies();
req.session = {
openidTokens: {
accessToken: 'session-access-token',
idToken: reusableIdToken,
refreshToken: 'stored-refresh',
lastRefreshedAt: Date.now(),
appUserId: 'user-db-id',
openidSubject: baseClaims.sub,
tenantId: 'tenant-1',
openidIssuer: baseClaims.iss,
},
};
const userDocument = {
toObject: () => ({
...defaultUser,
federatedTokens: { access_token: 'do-not-return' },
}),
};
getUserById.mockResolvedValue(userDocument);
await refreshController(req, res);
const sentPayload = res.send.mock.calls[0][0];
expect(setCloudFrontAuthCookies).toHaveBeenCalledWith(req, res, userDocument);
expect(sentPayload).toEqual({
token: reusableIdToken,
user: expect.objectContaining({
_id: 'user-db-id',
email: baseClaims.email,
}),
});
expect(sentPayload.user).not.toHaveProperty('password');
expect(sentPayload.user).not.toHaveProperty('federatedTokens');
});
it('should pass scope-only OpenID refresh params when OPENID_SCOPE is set', async () => {
process.env.OPENID_SCOPE = 'openid profile email';
await refreshController(req, res);
expect(openIdClient.refreshTokenGrant).toHaveBeenCalledWith(
{ some: 'config' },
'stored-refresh',
{ scope: 'openid profile email' },
);
});
it('should pass scope and audience OpenID refresh params when both are set', async () => {
process.env.OPENID_SCOPE = 'openid profile email';
process.env.OPENID_REFRESH_AUDIENCE = 'https://api.example.com';
await refreshController(req, res);
expect(openIdClient.refreshTokenGrant).toHaveBeenCalledWith(
{ some: 'config' },
'stored-refresh',
{
scope: 'openid profile email',
audience: 'https://api.example.com',
},
);
});
it('should pass audience-only OpenID refresh params when scope is unset', async () => {
process.env.OPENID_REFRESH_AUDIENCE = 'https://api.example.com';
await refreshController(req, res);
expect(openIdClient.refreshTokenGrant).toHaveBeenCalledWith(
{ some: 'config' },
'stored-refresh',
{ audience: 'https://api.example.com' },
);
});
it('should omit empty OpenID refresh audience', async () => {
process.env.OPENID_SCOPE = 'openid profile email';
process.env.OPENID_REFRESH_AUDIENCE = '';
await refreshController(req, res);
expect(openIdClient.refreshTokenGrant).toHaveBeenCalledWith(
{ some: 'config' },
'stored-refresh',
{ scope: 'openid profile email' },
);
});
it('should keep OpenID refresh diagnostics free of token and audience values', async () => {
process.env.OPENID_SCOPE = 'openid profile email';
process.env.OPENID_REFRESH_AUDIENCE = 'https://api.example.com';
await refreshController(req, res);
expect(logger.debug).toHaveBeenCalledWith('[refreshController] OpenID refresh params', {
has_scope: true,
has_refresh_audience: true,
});
expect(logger.debug).toHaveBeenCalledWith('[refreshController] OpenID refresh succeeded', {
has_access_token: true,
has_id_token: true,
has_refresh_token: true,
expires_in: 3600,
});
const debugOutput = JSON.stringify(logger.debug.mock.calls);
expect(debugOutput).not.toContain('stored-refresh');
expect(debugOutput).not.toContain('new-access');
expect(debugOutput).not.toContain('new-id');
expect(debugOutput).not.toContain('new-refresh');
expect(debugOutput).not.toContain('https://api.example.com');
});
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',
openidIssuer: baseClaims.iss,
}),
);
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);
await refreshController(req, res);
expect(findOpenIDUser).toHaveBeenCalledWith(
expect.objectContaining({
email: baseClaims.email,
openidIssuer: baseClaims.iss,
}),
);
});
it('should not expose sensitive fields or federatedTokens in refresh response', async () => {
await refreshController(req, res);
const sentPayload = res.send.mock.calls[0][0];
expect(sentPayload).toEqual({
token: 'new-app-token',
user: expect.objectContaining({
_id: 'user-db-id',
email: baseClaims.email,
openidId: baseClaims.sub,
}),
});
expect(sentPayload.user).not.toHaveProperty('federatedTokens');
expect(sentPayload.user).not.toHaveProperty('password');
expect(sentPayload.user).not.toHaveProperty('totpSecret');
expect(sentPayload.user).not.toHaveProperty('backupCodes');
expect(sentPayload.user).not.toHaveProperty('__v');
});
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,
openidIssuer: baseClaims.iss,
}),
);
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 preserve invalid OpenID refresh token behavior', async () => {
openIdClient.refreshTokenGrant.mockRejectedValue(new Error('invalid_grant'));
await refreshController(req, res);
expect(getRefreshTokenBridge).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(403);
expect(res.send).toHaveBeenCalledWith('Invalid OpenID refresh token');
});
it('recovers stale refresh-token cookies and keeps a short grace bridge', async () => {
setOpenIDReuseCookies();
req.session = {};
const bridgeUser = {
_id: 'user-db-id',
email: baseClaims.email,
openidId: baseClaims.sub,
tenantId: 'tenant-1',
openidIssuer: 'https://issuer.example.com',
};
getUserById.mockResolvedValue(bridgeUser);
getRefreshTokenBridge.mockResolvedValue('bridged-refresh');
openIdClient.refreshTokenGrant
.mockRejectedValueOnce(new Error('invalid_grant'))
.mockResolvedValueOnce(mockTokenset);
await refreshController(req, res);
expect(getUserById).toHaveBeenCalledWith(
'user-db-id',
'-password -__v -totpSecret -backupCodes -federatedTokens',
);
expect(getRefreshTokenBridge).toHaveBeenCalledWith({
oldRefreshToken: 'stored-refresh',
userId: 'user-db-id',
tenantId: 'tenant-1',
openidIssuer: 'https://issuer.example.com',
});
expect(openIdClient.refreshTokenGrant).toHaveBeenNthCalledWith(
1,
{ some: 'config' },
'stored-refresh',
{},
);
expect(openIdClient.refreshTokenGrant).toHaveBeenNthCalledWith(
2,
{ some: 'config' },
'bridged-refresh',
{},
);
expect(findOpenIDUser).toHaveBeenCalledWith(
expect.objectContaining({
strategyName: 'refreshController (bridge recovery)',
}),
);
expect(setOpenIDAuthTokens).toHaveBeenCalledTimes(1);
expect(setOpenIDAuthTokens).toHaveBeenCalledWith(mockTokenset, req, res, {
userId: 'user-db-id',
existingRefreshToken: 'bridged-refresh',
tenantId: 'tenant-1',
openidSubject: baseClaims.sub,
openidIssuer: baseClaims.iss,
});
expect(storeRefreshTokenBridge).toHaveBeenCalledWith({
oldRefreshToken: 'stored-refresh',
newRefreshToken: 'new-refresh',
userId: 'user-db-id',
tenantId: 'tenant-1',
openidIssuer: 'https://issuer.example.com',
ttl: 60000,
});
const lookupIdentity = getRefreshTokenBridge.mock.calls[0][0];
const graceIdentity = storeRefreshTokenBridge.mock.calls[0][0];
expect(graceIdentity).toEqual(
expect.objectContaining({
oldRefreshToken: lookupIdentity.oldRefreshToken,
userId: lookupIdentity.userId,
tenantId: lookupIdentity.tenantId,
openidIssuer: lookupIdentity.openidIssuer,
}),
);
expect(res.status).toHaveBeenCalledWith(200);
});
it('rejects bridge recovery when retry resolves a different user than the signed cookie', async () => {
setOpenIDReuseCookies(makeSignedUserId('cookie-user-id'));
req.session = {};
getUserById.mockResolvedValue({
_id: 'cookie-user-id',
tenantId: 'tenant-1',
openidIssuer: 'https://issuer.example.com',
});
getRefreshTokenBridge.mockResolvedValue('bridged-refresh');
findOpenIDUser.mockResolvedValueOnce({
user: { ...defaultUser, _id: 'different-user-id' },
error: null,
migration: false,
});
openIdClient.refreshTokenGrant
.mockRejectedValueOnce(new Error('invalid_grant'))
.mockResolvedValueOnce(mockTokenset);
await refreshController(req, res);
expect(openIdClient.refreshTokenGrant).toHaveBeenCalledTimes(2);
expect(setOpenIDAuthTokens).not.toHaveBeenCalled();
expect(storeRefreshTokenBridge).not.toHaveBeenCalled();
expect(logger.warn).toHaveBeenCalledWith(
'[refreshController] Bridge recovery resolved a different user; refusing token issuance',
{
cookieUserId: 'cookie-user-id',
resolvedUserId: 'different-user-id',
},
);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.send).toHaveBeenCalledWith('Invalid OpenID refresh token');
});
it('does not re-store the bridge when bridged refresh retry fails', async () => {
setOpenIDReuseCookies();
req.session = {};
getUserById.mockResolvedValue({
_id: 'user-db-id',
tenantId: 'tenant-1',
openidIssuer: 'https://issuer.example.com',
});
getRefreshTokenBridge.mockResolvedValue('bridged-refresh');
openIdClient.refreshTokenGrant
.mockRejectedValueOnce(new Error('invalid_grant'))
.mockRejectedValueOnce(new Error('temporarily unavailable'));
await refreshController(req, res);
expect(getRefreshTokenBridge).toHaveBeenCalled();
expect(storeRefreshTokenBridge).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(403);
});
it('returns success when bridge grace-period storage fails after bridged refresh succeeds', async () => {
setOpenIDReuseCookies();
req.session = {};
getUserById.mockResolvedValue({
_id: 'user-db-id',
email: baseClaims.email,
openidId: baseClaims.sub,
tenantId: 'tenant-1',
openidIssuer: 'https://issuer.example.com',
});
getRefreshTokenBridge.mockResolvedValue('bridged-refresh');
storeRefreshTokenBridge.mockRejectedValueOnce(new Error('grace failed'));
openIdClient.refreshTokenGrant
.mockRejectedValueOnce(new Error('invalid_grant'))
.mockResolvedValueOnce(mockTokenset);
await refreshController(req, res);
expect(setOpenIDAuthTokens).toHaveBeenCalledWith(mockTokenset, req, res, {
userId: 'user-db-id',
existingRefreshToken: 'bridged-refresh',
tenantId: 'tenant-1',
openidSubject: baseClaims.sub,
openidIssuer: baseClaims.iss,
});
expect(storeRefreshTokenBridge).toHaveBeenCalledWith({
oldRefreshToken: 'stored-refresh',
newRefreshToken: 'new-refresh',
userId: 'user-db-id',
tenantId: 'tenant-1',
openidIssuer: 'https://issuer.example.com',
ttl: 60000,
});
expect(logger.warn).toHaveBeenCalledWith(
'[refreshController] Bridge grace-period storage failed after successful recovery',
expect.any(Error),
);
expect(res.status).toHaveBeenCalledWith(200);
});
it('does not use the bridge for generic HTTP 400 errors without invalid_grant', async () => {
setOpenIDReuseCookies();
openIdClient.refreshTokenGrant.mockRejectedValue(
Object.assign(new Error('bad request'), { status: 400 }),
);
await refreshController(req, res);
expect(getRefreshTokenBridge).not.toHaveBeenCalled();
expect(storeRefreshTokenBridge).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(403);
});
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');
});
});
describe('refreshController LibreChat path', () => {
let req, res;
const refreshSecret = 'test-refresh-secret';
beforeEach(() => {
jest.clearAllMocks();
process.env.JWT_REFRESH_SECRET = refreshSecret;
process.env.NODE_ENV = 'test';
setAuthTokens.mockResolvedValue('local-app-token');
findSession.mockResolvedValue({ expiration: new Date(Date.now() + 60_000) });
const refreshToken = jwt.sign({ id: 'local-user-id' }, refreshSecret, {
expiresIn: '1h',
});
req = {
headers: { cookie: `refreshToken=${refreshToken}` },
query: {},
session: {},
};
res = {
status: jest.fn().mockReturnThis(),
send: jest.fn().mockReturnThis(),
redirect: jest.fn(),
};
});
afterAll(() => {
if (ORIGINAL_JWT_REFRESH_SECRET === undefined) {
delete process.env.JWT_REFRESH_SECRET;
} else {
process.env.JWT_REFRESH_SECRET = ORIGINAL_JWT_REFRESH_SECRET;
}
if (ORIGINAL_NODE_ENV === undefined) {
delete process.env.NODE_ENV;
} else {
process.env.NODE_ENV = ORIGINAL_NODE_ENV;
}
});
it('sanitizes user documents before returning local refresh responses', async () => {
getUserById.mockResolvedValue({
toObject: () => ({
_id: 'local-user-id',
email: 'local@example.com',
password: 'hashed-password',
__v: 1,
totpSecret: 'totp-secret',
backupCodes: ['backup-code'],
federatedTokens: { access_token: 'do-not-return' },
}),
});
await refreshController(req, res);
const sentPayload = res.send.mock.calls[0][0];
expect(setAuthTokens).toHaveBeenCalledWith(
'local-user-id',
res,
{ expiration: expect.any(Date) },
req,
);
expect(sentPayload).toEqual({
token: 'local-app-token',
user: {
_id: 'local-user-id',
email: 'local@example.com',
},
});
});
it('sanitizes user documents before returning CI refresh responses', async () => {
process.env.NODE_ENV = 'CI';
getUserById.mockResolvedValue({
toObject: () => ({
_id: 'local-user-id',
email: 'local@example.com',
password: 'hashed-password',
__v: 1,
totpSecret: 'totp-secret',
backupCodes: ['backup-code'],
federatedTokens: { access_token: 'do-not-return' },
}),
});
await refreshController(req, res);
const sentPayload = res.send.mock.calls[0][0];
expect(findSession).not.toHaveBeenCalled();
expect(setAuthTokens).toHaveBeenCalledWith('local-user-id', res, null, req);
expect(sentPayload).toEqual({
token: 'local-app-token',
user: {
_id: 'local-user-id',
email: 'local@example.com',
},
});
});
});