LibreChat/api/server/controllers/AuthController.spec.js
Claude cb26a6f7d2
fix: Keep Elapsed Expiries Elapsed and Revoke the Superseded Session
Two of the five findings from the Codex pass on fcdc15885 — the two that are
defects in code this branch introduced rather than design questions about the
bridge.

`getSkewedTokenExpiresAtMs` floored every result at a second in the future,
including an expiry the provider had already declared elapsed. An exchange
answering `expires_in: 0` or a past `expires_at` was handed to the MCP
connection stamped valid for another second, which only moves the failure
downstream. The floor now applies to a lifetime that is still live, which is
what it was for; an elapsed one stays elapsed so the caller rejects it. Same
for the cache TTL, which falls back to the elapsed-credential floor.

Bridge recovery left the stale token's durable Session behind. Only the token
it recovered through was passed as `existingRefreshToken`, so that one's
session was replaced while the token the browser actually presented kept its
record until its original expiry. That record, with the marker cookie still
bound to it, is what authorizes local image access for OpenID users — so a
copy of the stale cookie outlived the rotation it had lost. Revoked
explicitly on successful recovery.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SxKWxwqxAGckYpRsYTqx3F
2026-08-29 17:29:39 +00:00

1293 lines
41 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() },
runAsSystem: (callback) => callback(),
}));
jest.mock('~/server/services/GraphTokenService', () => ({
getGraphApiToken: jest.fn(),
}));
jest.mock('~/server/services/AuthService', () => ({
requestPasswordReset: jest.fn(),
setOpenIDAuthTokens: jest.fn(),
storeOpenIDSession: 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(),
deleteSession: jest.fn(),
getUserById: jest.fn(),
findSession: jest.fn(),
updateUser: jest.fn(),
findUser: jest.fn(),
}));
jest.mock('~/server/services/RefreshTokenBridge', () => ({
OPENID_REFRESH_BRIDGE_GRACE_MS: 60 * 1000,
getRefreshTokenBridge: jest.fn(),
storeRefreshTokenBridge: jest.fn(),
}));
jest.mock('@librechat/api', () => ({
OPENID_EXPIRY_BUFFER_SECONDS: 30,
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 { createHash } = require('node:crypto');
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,
storeOpenIDSession,
setCloudFrontAuthCookies,
setAuthTokens,
} = require('~/server/services/AuthService');
const { getOpenIdConfig, getOpenIdEmail } = require('~/strategies');
const { getUserById, findSession, updateUser, deleteSession } = 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' },
refreshToken = 'stored-refresh',
) =>
jwt.sign(
{
id,
refreshTokenHash: createHash('sha256').update(refreshToken).digest('base64url'),
},
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');
storeOpenIDSession.mockResolvedValue(true);
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,
});
expect(storeOpenIDSession).toHaveBeenCalledWith(
'user-db-id',
'new-refresh',
'tenant-1',
'stored-refresh',
);
};
it('prefers the browser refresh-token cookie when it differs from the session marker', async () => {
req.headers.cookie = 'token_provider=openid; refreshToken=rt-cookie-current';
req.session = {
openidTokens: {
refreshToken: 'rt-session-stale',
browserRefreshToken: 'rt-browser-stale',
},
};
await refreshController(req, res);
expect(openIdClient.refreshTokenGrant).toHaveBeenCalledWith(
{ some: 'config' },
'rt-cookie-current',
{},
);
expect(setOpenIDAuthTokens).toHaveBeenCalledWith(mockTokenset, req, res, {
userId: 'user-db-id',
existingRefreshToken: 'rt-cookie-current',
tenantId: 'tenant-1',
openidSubject: baseClaims.sub,
openidIssuer: baseClaims.iss,
});
});
it('refreshes with the browser cookie instead of reusing stale session tokens on drift', async () => {
const reusableIdToken = makeSessionToken();
req.headers.cookie = [
'token_provider=openid',
'refreshToken=rt-cookie-current',
`openid_user_id=${makeSignedUserId()}`,
].join('; ');
req.session = {
openidTokens: {
accessToken: 'session-access-token',
idToken: reusableIdToken,
refreshToken: 'rt-session-stale',
browserRefreshToken: 'rt-browser-stale',
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();
expect(openIdClient.refreshTokenGrant).toHaveBeenCalledWith(
{ some: 'config' },
'rt-cookie-current',
{},
);
expect(setOpenIDAuthTokens).toHaveBeenCalledWith(mockTokenset, req, res, {
userId: 'user-db-id',
existingRefreshToken: 'rt-cookie-current',
tenantId: 'tenant-1',
openidSubject: baseClaims.sub,
openidIssuer: baseClaims.iss,
});
});
it('prefers the browser refresh-token cookie when pre-marker session state differs', async () => {
req.headers.cookie = 'token_provider=openid; refreshToken=rt-cookie-current';
req.session = {
openidTokens: {
refreshToken: 'rt-session-stale',
},
};
await refreshController(req, res);
expect(openIdClient.refreshTokenGrant).toHaveBeenCalledWith(
{ some: 'config' },
'rt-cookie-current',
{},
);
expect(setOpenIDAuthTokens).toHaveBeenCalledWith(mockTokenset, req, res, {
userId: 'user-db-id',
existingRefreshToken: 'rt-cookie-current',
tenantId: 'tenant-1',
openidSubject: baseClaims.sub,
openidIssuer: baseClaims.iss,
});
});
it('keeps the session refresh token when the browser cookie matches the session marker', async () => {
req.headers.cookie = 'token_provider=openid; refreshToken=rt-browser-stale';
req.session = {
openidTokens: {
refreshToken: 'rt-session-current',
browserRefreshToken: 'rt-browser-stale',
},
};
await refreshController(req, res);
expect(openIdClient.refreshTokenGrant).toHaveBeenCalledWith(
{ some: 'config' },
'rt-session-current',
{},
);
expect(setOpenIDAuthTokens).toHaveBeenCalledWith(mockTokenset, req, res, {
userId: 'user-db-id',
existingRefreshToken: 'rt-session-current',
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('does not use the bridge when signed user-id cookie payload is invalid', async () => {
setOpenIDReuseCookies(jwt.sign({ id: 123 }, process.env.JWT_REFRESH_SECRET));
openIdClient.refreshTokenGrant.mockRejectedValue(new Error('invalid_grant'));
await refreshController(req, res);
expect(getUserById).not.toHaveBeenCalled();
expect(getRefreshTokenBridge).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(403);
});
it('does not use the bridge when the signed marker belongs to another refresh token', async () => {
setOpenIDReuseCookies(makeSignedUserId('user-db-id', { expiresIn: '1h' }, 'different-refresh'));
openIdClient.refreshTokenGrant.mockRejectedValue(new Error('invalid_grant'));
await refreshController(req, res);
expect(getUserById).not.toHaveBeenCalled();
expect(getRefreshTokenBridge).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(403);
});
it('does not use the bridge when the signed marker lacks a refresh-token binding', async () => {
setOpenIDReuseCookies(
jwt.sign({ id: 'user-db-id' }, process.env.JWT_REFRESH_SECRET, { expiresIn: '1h' }),
);
openIdClient.refreshTokenGrant.mockRejectedValue(new Error('invalid_grant'));
await refreshController(req, res);
expect(getUserById).not.toHaveBeenCalled();
expect(getRefreshTokenBridge).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(403);
});
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,
});
/** Recovery issues a fresh credential like any other refresh, so the durable session has to
* follow it — otherwise the record still names the token the bridge just replaced. */
expect(storeOpenIDSession).toHaveBeenCalledWith(
'user-db-id',
'new-refresh',
'tenant-1',
'bridged-refresh',
);
/** The stale token the browser presented is dead upstream, but its durable Session — and the
* marker bound to it — would otherwise keep authorizing local image access until expiry. */
expect(deleteSession).toHaveBeenCalledWith({ refreshToken: 'stored-refresh' });
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',
},
});
});
});