mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-09-03 04:21:30 +00:00
* 🪪 fix: Preserve Stored Access Token Expiry Over ID Token Exp extractOpenIDTokenInfo let the ID token exp claim overwrite the token set's stored expires_at. The ID token is minted at login and never refreshed, so once a session outlives the ID token TTL, isOpenIDTokenValid reports the access token as expired even when expires_at is hours in the future, and OpenID placeholder substitution silently stops: MCP headers configured with {{LIBRECHAT_OPENID_ACCESS_TOKEN}} ship the literal placeholder string as the bearer credential and the receiving server rejects every connection with an unparseable JWT until the user fully logs out and back in. The ID token exp now only fills a missing expiresAt instead of overriding a stored one. Identity claim enrichment from the ID token is unchanged, and the exp fallback for token sets without expires_at is preserved. * 🪪 fix: Validate ID Token Expiry Before ID Token Placeholder Substitution The precedence fix made isOpenIDTokenValid track only the access token expiry, so an MCP header using {{LIBRECHAT_OPENID_ID_TOKEN}} could substitute an ID token that had already expired. The ID token exp is now preserved separately as idTokenExpiresAt and checked at the ID token substitution site, so an expired ID token substitutes empty rather than a stale credential while access token substitution is unaffected. * 🪪 fix: Address OpenID Expiry Review Round Fix expires_at at the source in the OpenID JWT strategy. The stored value described the incoming bearer's exp even when access_token came from the session or a cookie, so it could describe a different credential entirely. A new decodeJwtExpiry helper reads the exp of the token actually stored, and payload.exp is kept only when the raw bearer is the resolved access token. Opaque session or cookie tokens now store no expiry rather than a wrong one. Apply a 30 second clock skew buffer in isOpenIDTokenValid and isIdTokenCurrent via a new exported OPENID_EXPIRY_BUFFER_SECONDS, mirroring OPENID_REUSE_EXPIRY_BUFFER_SECONDS in AuthController. Tokens that would expire in transit are treated as already expired. Make isIdTokenCurrent fail closed when idTokenExpiresAt is absent. exp is REQUIRED in an ID token, so a missing value means the token is malformed or the claims parse threw. The check uses == null so an exp of 0 counts as present and therefore expired. Read the ID token exp with a numeric type check so an exp of 0 records idTokenExpiresAt and fails closed downstream while a non-numeric exp is ignored, and compare the stored expiry with != null so a gap filled expiry of 0 reads as expired instead of as no expiry at all. Raise an actionable re authentication error for the ID token placeholder instead of substituting an empty string. An empty substitution produced a malformed Authorization header and a 400 downstream rather than a clean signal that the user must re authenticate. Raise the same re authentication error from processSingleValue when a user has an OpenID identity, the stored token set is no longer valid, and the value still contains a credential bearing OpenID placeholder, so the expired access token case that motivated this PR signals re auth instead of silently shipping or stripping the placeholder. Only the access token, ID token, and generic token names raise: identity metadata resolves from the user document and an expiry hint never needed a token, so those keep their existing literal then strip behaviour. Unknown placeholder names also stay literal and diagnosable, matching the existing resolvable placeholder policy. Add the comments the review asked for on the exp fallback heuristic, the EXPIRES_AT placeholder semantics, why stale ID token claims stay usable for identity fields, and the advisory nature of the freshness check. * 🪪 fix: Honour Opaque Access Tokens And Type The OpenID Re-Auth Error Drop the ID token exp fallback in extractOpenIDTokenInfo. Storing the access token expiry honestly means an opaque access token now records no expiry, and the fallback then handed the ID token exp authority over a credential it does not describe. A deployment issuing opaque access tokens alongside a short lived ID token saw isOpenIDTokenValid go false and the credential guard reject a perfectly good access token, which worked before this branch. An unknown access token expiry is now treated as no expiry, and the ID token exp only ever gates ID token substitution through idTokenExpiresAt. Give the re-authentication signal a type. OpenIDReauthRequiredError is raised at both the ID token placeholder and the credential placeholder guard, ErrorController maps it to a 401 carrying the actionable message, and the class exposes statusCode so the agent generation path answers 401 instead of a bare 500 for the same condition. Omit rather than blank a header whose credential placeholder is still unresolved on a final resolution pass, since an empty bearer credential is malformed under RFC 6750 while an absent header lets the upstream answer its own challenge. Identity placeholders keep stripping to an empty string. Move the resolvable placeholder docblock onto the pattern it describes, resolve an EXPIRES_AT of 0 as the string 0 for consistency with the neighbouring null checks, and let AuthController consume the exported OPENID_EXPIRY_BUFFER_SECONDS so the 30 second skew allowance has a single definition.
843 lines
26 KiB
JavaScript
843 lines
26 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('@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'),
|
||
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 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,
|
||
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 });
|
||
getUserById.mockResolvedValue({
|
||
_id: 'user-db-id',
|
||
email: baseClaims.email,
|
||
openidId: baseClaims.sub,
|
||
});
|
||
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: undefined,
|
||
});
|
||
};
|
||
|
||
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(),
|
||
},
|
||
};
|
||
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(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 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(),
|
||
},
|
||
};
|
||
|
||
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(),
|
||
},
|
||
};
|
||
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(res.status).toHaveBeenCalledWith(403);
|
||
expect(res.send).toHaveBeenCalledWith('Invalid OpenID refresh token');
|
||
});
|
||
|
||
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',
|
||
},
|
||
});
|
||
});
|
||
});
|