mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-09-09 08:02:04 +00:00
Reuse getValidOpenIDReuseUserId for the bridge-recovery user lookup in
refreshController instead of re-verifying openid_user_id inline. The shared
helper enforces the JWT_REFRESH_SECRET presence check and a strict
typeof payload.id === 'string' guard, rejecting tokens whose id claim is
present but not a string (e.g. a numeric id) that the inline check accepted.
Fail closed on issuer mismatch in getRefreshTokenBridge. Both the stored and
the expected issuer are now normalized and compared for equality, so a bridge
is recovered only when both sides agree (both absent, or both present and
equal after normalization). Previously the check was skipped whenever the
stored issuer was absent, allowing recovery across mismatched issuer context.
Drop the unused {oldRefreshTokenHash, userId, tenantId, openidIssuer} index
and the openidIssuer field on RefreshTokenBridgeQuery. The data-layer filter
only queries the 3-field {oldRefreshTokenHash, userId, tenantId} index; the
issuer is verified in application code, not the query. Hoist the repeated
model accessor into getRefreshTokenBridgeModel.
Note: issuer is now load-bearing for recovery. A bridge stored with an issuer
recovers only when the lookup supplies a matching issuer; the recovery lookup
reads user.openidIssuer via AUTH_REFRESH_USER_PROJECTION (an exclusion
projection that retains the field). If a user's persisted openidIssuer is
empty while the stored bridge has one, recovery fails closed (falls through to
normal re-authentication) until the bridge TTLs out — no security regression.
Tests cover invalid signed-cookie payloads bypassing the bridge, both
asymmetric issuer-presence cases, issuer normalization before comparison, and
an index-alignment assertion guarding against re-adding the dropped index.
1234 lines
39 KiB
JavaScript
1234 lines
39 KiB
JavaScript
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', () => ({
|
||
OPENID_REFRESH_BRIDGE_GRACE_MS: 60 * 1000,
|
||
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('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('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',
|
||
},
|
||
});
|
||
});
|
||
});
|