mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-11-12 09:45:47 +00:00
* fix: Only Hand Out OpenID Access Tokens the Bearer Verifier Accepts The OpenID reuse path in refreshController and the post-refresh fallback in getOpenIDAppAuthToken returned the cached access token as the app auth token whenever its exp was in the future. Entra issues Microsoft Graph access tokens to sign-in clients by default; their aud names Graph, so the bearer verifier rejects them and every protected request answers 401 until the client loops. Both paths now require the access token's aud to name the client id or an OPENID_AUDIENCE entry, the same audiences the verifier accepts. The reuse path refreshes with the IdP instead, and the post-refresh fallback keeps opaque tokens as before. Audience parsing is shared with openIdJwtStrategy. Related to #17791 * test: Exercise Real OpenID Audience Helpers in AuthService Spec The AuthService spec stubbed the audience helpers as pass-throughs, so it could not catch a foreign-audience access token escaping the post-refresh fallback. Use the real exports and cover Graph-audience, configured-audience and opaque access tokens through setOpenIDAuthTokens.
3362 lines
115 KiB
JavaScript
3362 lines
115 KiB
JavaScript
let mockActiveTenantId;
|
||
const mockRunAsSystem = jest.fn(async (fn) => {
|
||
const previousTenantId = mockActiveTenantId;
|
||
mockActiveTenantId = '__SYSTEM__';
|
||
try {
|
||
return await fn();
|
||
} finally {
|
||
mockActiveTenantId = previousTenantId;
|
||
}
|
||
});
|
||
const mockTenantStorageRun = jest.fn(async (context, fn) => {
|
||
const previousTenantId = mockActiveTenantId;
|
||
mockActiveTenantId = context.tenantId;
|
||
try {
|
||
return await fn();
|
||
} finally {
|
||
mockActiveTenantId = previousTenantId;
|
||
}
|
||
});
|
||
jest.mock('@librechat/data-schemas', () => ({
|
||
logger: { error: jest.fn(), debug: jest.fn(), warn: jest.fn(), info: jest.fn() },
|
||
runAsSystem: (fn) => mockRunAsSystem(fn),
|
||
tenantStorage: { run: (context, fn) => mockTenantStorageRun(context, fn) },
|
||
}));
|
||
jest.mock('~/server/services/GraphTokenService', () => ({
|
||
getGraphApiToken: jest.fn(),
|
||
}));
|
||
jest.mock('~/server/services/AuthService', () => ({
|
||
clearOpenIDAuthTokens: jest.fn(),
|
||
getOpenIDAppAuthToken: jest.fn(),
|
||
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', () => ({
|
||
deleteSession: jest.fn(),
|
||
deleteAllUserSessions: jest.fn(),
|
||
deletePasskeysByUser: jest.fn(),
|
||
awaitAuthUserDocEviction: jest.fn(),
|
||
getUserById: jest.fn(),
|
||
findSession: jest.fn(),
|
||
updateUser: jest.fn(),
|
||
findUser: jest.fn(),
|
||
deleteTokens: jest.fn(),
|
||
}));
|
||
jest.mock('~/server/services/RefreshTokenBridge', () => ({
|
||
OPENID_REFRESH_BRIDGE_GRACE_MS: 60 * 1000,
|
||
createRefreshTokenBridgeFlightKey: jest.fn(() => 'bridge-flight-key'),
|
||
deleteRefreshTokenBridges: jest.fn(),
|
||
getRefreshTokenBridge: jest.fn(),
|
||
storeRefreshTokenBridge: jest.fn(),
|
||
}));
|
||
jest.mock('~/server/services/OpenIDRefreshFlight', () => ({
|
||
acquireOpenIDRefreshFlight: jest.fn(),
|
||
assertOpenIDRefreshFlightDeliveryAvailable: jest.fn(),
|
||
assertOpenIDRefreshFlightAvailable: jest.fn(),
|
||
assertOpenIDRefreshSessionGenerationAvailable: jest.fn(),
|
||
claimOpenIDRefreshFlightDelivery: jest.fn(),
|
||
completeOpenIDRefreshFlight: jest.fn(),
|
||
createOpenIDRefreshFlightKey: jest.fn(),
|
||
failOpenIDRefreshFlight: jest.fn(),
|
||
releaseOpenIDRefreshFlightDelivery: jest.fn(),
|
||
revokeOpenIDRefreshFlights: jest.fn(),
|
||
waitForOpenIDRefreshFlight: jest.fn(),
|
||
withOpenIDRefreshFlightLease: jest.fn(),
|
||
}));
|
||
jest.mock('~/server/services/OpenIDSessionRefresh', () => ({
|
||
refreshOpenIDSession: jest.fn(),
|
||
}));
|
||
jest.mock('@librechat/api', () => ({
|
||
...jest.requireActual('@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;
|
||
}),
|
||
clearCloudFrontCookies: jest.fn(),
|
||
createLocalRefreshHandler: jest.requireActual('@librechat/api').createLocalRefreshHandler,
|
||
getValidOpenIDReuseCredential: jest.requireActual('@librechat/api').getValidOpenIDReuseCredential,
|
||
sanitizeUserForAuthResponse: jest.requireActual('@librechat/api').sanitizeUserForAuthResponse,
|
||
generateTwoFactorSetupToken: jest.fn(() => 'setup-token'),
|
||
isTwoFactorEnrollmentRequired: jest.fn(() => false),
|
||
/** The retirement cutoff is the assertion under test, so it runs for real rather than stubbed. */
|
||
isTokenRetired: jest.requireActual('@librechat/api').isTokenRetired,
|
||
TOKEN_RETIREMENT_FIELDS: jest.requireActual('@librechat/api').TOKEN_RETIREMENT_FIELDS,
|
||
}));
|
||
|
||
const http = require('node:http');
|
||
const { createHash } = require('node:crypto');
|
||
const openIdClient = require('openid-client');
|
||
const jwt = require('jsonwebtoken');
|
||
const { logger } = require('@librechat/data-schemas');
|
||
const {
|
||
isEnabled,
|
||
clearCloudFrontCookies,
|
||
findOpenIDUser,
|
||
buildOpenIDRefreshParams,
|
||
generateTwoFactorSetupToken,
|
||
isTwoFactorEnrollmentRequired,
|
||
} = require('@librechat/api');
|
||
const {
|
||
graphTokenController,
|
||
refreshController,
|
||
registrationController,
|
||
resetPasswordController,
|
||
resetPasswordRequestController,
|
||
} = require('./AuthController');
|
||
const { getGraphApiToken } = require('~/server/services/GraphTokenService');
|
||
const {
|
||
clearOpenIDAuthTokens,
|
||
getOpenIDAppAuthToken,
|
||
setOpenIDAuthTokens,
|
||
storeOpenIDSession,
|
||
setCloudFrontAuthCookies,
|
||
setAuthTokens,
|
||
registerUser,
|
||
resetPassword,
|
||
requestPasswordReset,
|
||
} = require('~/server/services/AuthService');
|
||
const { getOpenIdConfig, getOpenIdEmail } = require('~/strategies');
|
||
const {
|
||
deleteSession,
|
||
getUserById,
|
||
findSession,
|
||
updateUser,
|
||
deleteTokens,
|
||
deleteAllUserSessions,
|
||
deletePasskeysByUser,
|
||
awaitAuthUserDocEviction,
|
||
} = require('~/models');
|
||
const {
|
||
createRefreshTokenBridgeFlightKey,
|
||
deleteRefreshTokenBridges,
|
||
getRefreshTokenBridge,
|
||
storeRefreshTokenBridge,
|
||
} = require('~/server/services/RefreshTokenBridge');
|
||
const {
|
||
acquireOpenIDRefreshFlight,
|
||
assertOpenIDRefreshFlightDeliveryAvailable,
|
||
assertOpenIDRefreshFlightAvailable,
|
||
assertOpenIDRefreshSessionGenerationAvailable,
|
||
claimOpenIDRefreshFlightDelivery,
|
||
completeOpenIDRefreshFlight,
|
||
createOpenIDRefreshFlightKey,
|
||
failOpenIDRefreshFlight,
|
||
releaseOpenIDRefreshFlightDelivery,
|
||
revokeOpenIDRefreshFlights,
|
||
waitForOpenIDRefreshFlight,
|
||
withOpenIDRefreshFlightLease,
|
||
} = require('~/server/services/OpenIDRefreshFlight');
|
||
const { refreshOpenIDSession } = require('~/server/services/OpenIDSessionRefresh');
|
||
const { revokeOpenIDRefreshTokenChain } = require('~/server/services/OpenIDRefreshRecovery');
|
||
|
||
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;
|
||
|
||
const { createOpenIDRefreshOwnershipError } = jest.requireActual('@librechat/api');
|
||
const ownershipLost = (message) => createOpenIDRefreshOwnershipError(message);
|
||
|
||
describe('OpenID logout refresh chain', () => {
|
||
beforeEach(() => {
|
||
jest.clearAllMocks();
|
||
createOpenIDRefreshFlightKey.mockImplementation(
|
||
({ refreshToken }) => `session:${refreshToken}`,
|
||
);
|
||
createRefreshTokenBridgeFlightKey.mockImplementation(
|
||
({ oldRefreshToken }) => `bridge:${oldRefreshToken}`,
|
||
);
|
||
});
|
||
|
||
afterEach(() => {
|
||
createRefreshTokenBridgeFlightKey.mockImplementation(() => 'bridge-flight-key');
|
||
});
|
||
|
||
it('tombstones every discovered successor generation before logout completes', async () => {
|
||
createOpenIDRefreshFlightKey.mockImplementation(
|
||
({ refreshToken, identityContext }) =>
|
||
`session:${identityContext.openidSubject}:${refreshToken}`,
|
||
);
|
||
createRefreshTokenBridgeFlightKey.mockImplementation(
|
||
({ oldRefreshToken, userId, openidIssuer }) =>
|
||
`bridge:${userId}:${openidIssuer}:${oldRefreshToken}`,
|
||
);
|
||
const acceptedIdentity = {
|
||
appUserId: 'user-2',
|
||
openidSubject: 'subject-2',
|
||
tenantId: 'tenant-1',
|
||
openidIssuer: 'https://issuer-2.example.com',
|
||
};
|
||
revokeOpenIDRefreshFlights
|
||
.mockResolvedValueOnce([{ refresh_token: 'rt-successor-1', acceptedIdentity }, null])
|
||
.mockResolvedValueOnce([{ tokenset: { refresh_token: 'rt-successor-2' } }, null, null, null])
|
||
.mockResolvedValueOnce([null, null, null, null]);
|
||
const req = { user: { _id: 'user-1', openidId: 'subject-1' } };
|
||
const user = req.user;
|
||
const identityContext = {
|
||
appUserId: 'user-1',
|
||
openidSubject: 'subject-1',
|
||
tenantId: 'tenant-1',
|
||
openidIssuer: 'https://issuer.example.com',
|
||
};
|
||
|
||
await expect(
|
||
revokeOpenIDRefreshTokenChain({
|
||
req,
|
||
user,
|
||
identityContext,
|
||
refreshTokens: ['rt-predecessor'],
|
||
publicationKeys: ['recorded-publication-key'],
|
||
ttl: 60_000,
|
||
}),
|
||
).resolves.toEqual(['rt-predecessor', 'rt-successor-1', 'rt-successor-2']);
|
||
|
||
expect(revokeOpenIDRefreshFlights).toHaveBeenNthCalledWith(1, {
|
||
keys: [
|
||
'recorded-publication-key',
|
||
'session:subject-1:rt-predecessor',
|
||
'bridge:user-1:https://issuer.example.com:rt-predecessor',
|
||
],
|
||
ttl: 60_000,
|
||
});
|
||
expect(revokeOpenIDRefreshFlights).toHaveBeenNthCalledWith(2, {
|
||
keys: [
|
||
'session:subject-1:rt-successor-1',
|
||
'bridge:user-1:https://issuer.example.com:rt-successor-1',
|
||
'session:subject-2:rt-successor-1',
|
||
'bridge:user-2:https://issuer-2.example.com:rt-successor-1',
|
||
],
|
||
ttl: 60_000,
|
||
});
|
||
expect(revokeOpenIDRefreshFlights).toHaveBeenNthCalledWith(3, {
|
||
keys: [
|
||
'session:subject-1:rt-successor-2',
|
||
'bridge:user-1:https://issuer.example.com:rt-successor-2',
|
||
'session:subject-2:rt-successor-2',
|
||
'bridge:user-2:https://issuer-2.example.com:rt-successor-2',
|
||
],
|
||
ttl: 60_000,
|
||
});
|
||
});
|
||
});
|
||
|
||
describe('graphTokenController', () => {
|
||
let req, res;
|
||
|
||
beforeEach(() => {
|
||
jest.clearAllMocks();
|
||
isEnabled.mockReturnValue(true);
|
||
assertOpenIDRefreshSessionGenerationAvailable.mockResolvedValue(true);
|
||
claimOpenIDRefreshFlightDelivery.mockResolvedValue({
|
||
status: 'completed',
|
||
ownerId: 'publication-owner',
|
||
deliveryId: 'delivery-1',
|
||
});
|
||
assertOpenIDRefreshFlightDeliveryAvailable.mockResolvedValue(undefined);
|
||
releaseOpenIDRefreshFlightDelivery.mockResolvedValue(undefined);
|
||
|
||
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('leases the session generation across a Graph OBO exchange and response delivery', async () => {
|
||
req.user.federatedTokens.access_token = 'session-access-token';
|
||
req.session = {
|
||
openidTokens: {
|
||
accessToken: 'session-access-token',
|
||
appUserId: 'user-1',
|
||
tenantId: 'tenant-1',
|
||
publicationFlightKey: 'publication-key',
|
||
publicationFlightOwnerId: 'publication-owner',
|
||
publicationFlightCreatedAt: 1000,
|
||
},
|
||
};
|
||
|
||
await graphTokenController(req, res);
|
||
|
||
expect(claimOpenIDRefreshFlightDelivery).toHaveBeenCalledWith({
|
||
key: 'publication-key',
|
||
ownerId: 'publication-owner',
|
||
createdAt: 1000,
|
||
});
|
||
expect(assertOpenIDRefreshFlightDeliveryAvailable).toHaveBeenCalledWith({
|
||
key: 'publication-key',
|
||
ownerId: 'publication-owner',
|
||
deliveryId: 'delivery-1',
|
||
});
|
||
expect(getGraphApiToken).toHaveBeenCalled();
|
||
expect(res.json).toHaveBeenCalledWith(
|
||
expect.objectContaining({ access_token: 'graph-access-token' }),
|
||
);
|
||
expect(releaseOpenIDRefreshFlightDelivery).toHaveBeenCalledWith({
|
||
key: 'publication-key',
|
||
ownerId: 'publication-owner',
|
||
deliveryId: 'delivery-1',
|
||
});
|
||
});
|
||
|
||
describe('with a Node HTTP response', () => {
|
||
let server;
|
||
|
||
/** Resolves with the server response once the request arrives, optionally after the client disconnects. */
|
||
const receiveResponse = ({ disconnect }) =>
|
||
new Promise((resolve) => {
|
||
const client = http.request({
|
||
host: '127.0.0.1',
|
||
port: server.address().port,
|
||
method: 'POST',
|
||
});
|
||
client.on('error', () => {});
|
||
server.once('request', (_incoming, response) => {
|
||
response.json = (body) => response.end(JSON.stringify(body));
|
||
if (!disconnect) {
|
||
resolve(response);
|
||
return;
|
||
}
|
||
response.once('close', () => resolve(response));
|
||
client.destroy();
|
||
});
|
||
client.end();
|
||
});
|
||
|
||
beforeEach(async () => {
|
||
req.user.federatedTokens.access_token = 'session-access-token';
|
||
req.session = {
|
||
openidTokens: {
|
||
accessToken: 'session-access-token',
|
||
appUserId: 'user-1',
|
||
tenantId: 'tenant-1',
|
||
publicationFlightKey: 'publication-key',
|
||
publicationFlightOwnerId: 'publication-owner',
|
||
publicationFlightCreatedAt: 1000,
|
||
},
|
||
};
|
||
server = http.createServer();
|
||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||
});
|
||
|
||
afterEach(async () => {
|
||
server.closeAllConnections();
|
||
await new Promise((resolve) => server.close(resolve));
|
||
});
|
||
|
||
it('releases the delivery lease when the client disconnected before delivery', async () => {
|
||
const response = await receiveResponse({ disconnect: true });
|
||
|
||
await graphTokenController(req, response);
|
||
|
||
expect(response.closed).toBe(true);
|
||
expect(releaseOpenIDRefreshFlightDelivery).toHaveBeenCalledWith({
|
||
key: 'publication-key',
|
||
ownerId: 'publication-owner',
|
||
deliveryId: 'delivery-1',
|
||
});
|
||
});
|
||
|
||
it('holds the delivery lease until an open response finishes', async () => {
|
||
const response = await receiveResponse({ disconnect: false });
|
||
const finished = new Promise((resolve) => response.once('finish', resolve));
|
||
|
||
await graphTokenController(req, response);
|
||
await finished;
|
||
|
||
expect(releaseOpenIDRefreshFlightDelivery).toHaveBeenCalledTimes(1);
|
||
expect(releaseOpenIDRefreshFlightDelivery).toHaveBeenCalledWith({
|
||
key: 'publication-key',
|
||
ownerId: 'publication-owner',
|
||
deliveryId: 'delivery-1',
|
||
});
|
||
});
|
||
});
|
||
|
||
it('does not exchange a session-backed Graph token after logout tombstones its generation', async () => {
|
||
req.user.federatedTokens.access_token = 'session-access-token';
|
||
req.session = {
|
||
openidTokens: {
|
||
accessToken: 'session-access-token',
|
||
appUserId: 'user-1',
|
||
tenantId: 'tenant-1',
|
||
publicationFlightKey: 'publication-key',
|
||
publicationFlightOwnerId: 'publication-owner',
|
||
},
|
||
};
|
||
assertOpenIDRefreshSessionGenerationAvailable.mockRejectedValueOnce(
|
||
ownershipLost('revoked by logout'),
|
||
);
|
||
|
||
await graphTokenController(req, res);
|
||
|
||
expect(getGraphApiToken).not.toHaveBeenCalled();
|
||
expect(clearOpenIDAuthTokens).toHaveBeenCalledWith(req, res, undefined, 'tenant-1');
|
||
expect(res.status).toHaveBeenCalledWith(401);
|
||
});
|
||
|
||
it('does not fall back to a stale Graph token snapshot after the Express session is cleared', async () => {
|
||
req.session = {};
|
||
|
||
await graphTokenController(req, res);
|
||
|
||
expect(getGraphApiToken).not.toHaveBeenCalled();
|
||
expect(clearOpenIDAuthTokens).toHaveBeenCalled();
|
||
expect(res.status).toHaveBeenCalledWith(401);
|
||
});
|
||
|
||
it('withholds a minted Graph token when logout revokes its delivery lease', async () => {
|
||
req.user.federatedTokens.access_token = 'session-access-token';
|
||
req.session = {
|
||
openidTokens: {
|
||
accessToken: 'session-access-token',
|
||
appUserId: 'user-1',
|
||
tenantId: 'tenant-1',
|
||
publicationFlightKey: 'publication-key',
|
||
publicationFlightOwnerId: 'publication-owner',
|
||
},
|
||
};
|
||
assertOpenIDRefreshFlightDeliveryAvailable.mockRejectedValueOnce(
|
||
ownershipLost('logout requested revocation'),
|
||
);
|
||
|
||
await graphTokenController(req, res);
|
||
|
||
expect(getGraphApiToken).toHaveBeenCalled();
|
||
expect(res.json).not.toHaveBeenCalledWith(
|
||
expect.objectContaining({ access_token: 'graph-access-token' }),
|
||
);
|
||
expect(releaseOpenIDRefreshFlightDelivery).toHaveBeenCalled();
|
||
expect(res.status).toHaveBeenCalledWith(401);
|
||
});
|
||
|
||
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,
|
||
);
|
||
|
||
/** `setOpenIDAuthTokens` stamps this cookie to the millisecond, so the cutoffs can date it */
|
||
const makeSignedReuseCookie = (issuedAtMs, id = 'user-db-id') =>
|
||
jwt.sign(
|
||
{
|
||
id,
|
||
issuedAtMs,
|
||
refreshTokenHash: createHash('sha256').update('stored-refresh').digest('base64url'),
|
||
},
|
||
process.env.JWT_REFRESH_SECRET,
|
||
{ expiresIn: '1h' },
|
||
);
|
||
|
||
const setOpenIDReuseCookies = (signedUserId = makeSignedUserId()) => {
|
||
req.headers.cookie = [
|
||
'token_provider=openid',
|
||
'refreshToken=stored-refresh',
|
||
`openid_user_id=${signedUserId}`,
|
||
].join('; ');
|
||
};
|
||
|
||
beforeEach(() => {
|
||
jest.clearAllMocks();
|
||
mockActiveTenantId = undefined;
|
||
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');
|
||
getOpenIDAppAuthToken.mockReturnValue('new-app-token');
|
||
storeOpenIDSession.mockResolvedValue(true);
|
||
setCloudFrontAuthCookies.mockReturnValue(true);
|
||
findOpenIDUser.mockResolvedValue({ user: { ...defaultUser }, error: null, migration: false });
|
||
getRefreshTokenBridge.mockResolvedValue(null);
|
||
storeRefreshTokenBridge.mockResolvedValue('bridge-version-1');
|
||
acquireOpenIDRefreshFlight.mockResolvedValue({ acquired: true, ownerId: 'bridge-owner' });
|
||
assertOpenIDRefreshFlightAvailable.mockResolvedValue({
|
||
status: 'completed',
|
||
ownerId: 'bridge-owner',
|
||
});
|
||
assertOpenIDRefreshFlightDeliveryAvailable.mockResolvedValue(undefined);
|
||
assertOpenIDRefreshSessionGenerationAvailable.mockResolvedValue(true);
|
||
claimOpenIDRefreshFlightDelivery.mockResolvedValue({
|
||
status: 'completed',
|
||
ownerId: 'publication-owner',
|
||
deliveryId: 'delivery-1',
|
||
});
|
||
completeOpenIDRefreshFlight.mockResolvedValue({ status: 'completed' });
|
||
failOpenIDRefreshFlight.mockResolvedValue({ status: 'failed' });
|
||
releaseOpenIDRefreshFlightDelivery.mockResolvedValue(undefined);
|
||
waitForOpenIDRefreshFlight.mockResolvedValue(null);
|
||
withOpenIDRefreshFlightLease.mockImplementation(({ operation }) =>
|
||
operation({
|
||
assertLeaseOwned: jest.fn().mockResolvedValue(true),
|
||
markLeaseSettled: jest.fn(),
|
||
}),
|
||
);
|
||
refreshOpenIDSession.mockImplementation(
|
||
async (refreshReq, _res, _user, _preference, _identity, options = {}) => {
|
||
const activeRefreshToken = refreshReq.session.openidTokens.refreshToken;
|
||
const refreshParams = buildOpenIDRefreshParams();
|
||
logger.debug('[refreshController] OpenID refresh params', {
|
||
has_scope: Boolean(process.env.OPENID_SCOPE),
|
||
has_refresh_audience: Boolean(process.env.OPENID_REFRESH_AUDIENCE),
|
||
});
|
||
const tokenset = await openIdClient.refreshTokenGrant(
|
||
getOpenIdConfig(),
|
||
activeRefreshToken,
|
||
refreshParams,
|
||
);
|
||
if (options.assertLeaseOwned) {
|
||
await options.assertLeaseOwned();
|
||
}
|
||
logger.debug('[refreshController] OpenID refresh succeeded', {
|
||
has_access_token: Boolean(tokenset.access_token),
|
||
has_id_token: Boolean(tokenset.id_token),
|
||
has_refresh_token: Boolean(tokenset.refresh_token),
|
||
expires_in: tokenset.expires_in,
|
||
});
|
||
const resolvedTokenset = tokenset.refresh_token
|
||
? tokenset
|
||
: { ...tokenset, refresh_token: activeRefreshToken };
|
||
if (!options.deferPublication) {
|
||
refreshReq.session.openidTokens = {
|
||
...refreshReq.session.openidTokens,
|
||
accessToken: tokenset.access_token,
|
||
idToken: tokenset.id_token,
|
||
refreshToken: resolvedTokenset.refresh_token,
|
||
};
|
||
}
|
||
return resolvedTokenset;
|
||
},
|
||
);
|
||
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; openid_user_id=${makeSignedUserId()}`,
|
||
},
|
||
session: {},
|
||
};
|
||
|
||
res = {
|
||
status: jest.fn().mockReturnThis(),
|
||
send: jest.fn().mockReturnThis(),
|
||
redirect: jest.fn(),
|
||
clearCookie: 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('falls back to the browser token only after the advanced session token is rejected', async () => {
|
||
req.headers.cookie = `token_provider=openid; refreshToken=rt-cookie-current; openid_user_id=${makeSignedUserId()}`;
|
||
req.session = {
|
||
openidTokens: {
|
||
refreshToken: 'rt-session-stale',
|
||
browserRefreshToken: 'rt-browser-stale',
|
||
appUserId: 'user-db-id',
|
||
},
|
||
reload: jest.fn((callback) => {
|
||
req.session.openidTokens = {
|
||
accessToken: 'rejected-access',
|
||
refreshToken: 'rt-session-stale',
|
||
};
|
||
callback();
|
||
}),
|
||
};
|
||
openIdClient.refreshTokenGrant
|
||
.mockRejectedValueOnce(new Error('invalid_grant'))
|
||
.mockResolvedValueOnce(mockTokenset);
|
||
|
||
await refreshController(req, res);
|
||
|
||
expect(openIdClient.refreshTokenGrant).toHaveBeenNthCalledWith(
|
||
1,
|
||
{ some: 'config' },
|
||
'rt-session-stale',
|
||
{},
|
||
);
|
||
expect(req.session.reload).toHaveBeenCalled();
|
||
expect(refreshOpenIDSession).toHaveBeenNthCalledWith(
|
||
1,
|
||
req,
|
||
res,
|
||
expect.objectContaining({ _id: 'user-db-id' }),
|
||
'id_token',
|
||
expect.objectContaining({ appUserId: 'user-db-id' }),
|
||
{ deferPublication: true, forceRefresh: true },
|
||
);
|
||
expect(refreshOpenIDSession).toHaveBeenNthCalledWith(
|
||
2,
|
||
req,
|
||
res,
|
||
expect.objectContaining({ _id: 'user-db-id' }),
|
||
'id_token',
|
||
expect.objectContaining({ appUserId: 'user-db-id' }),
|
||
{ deferPublication: true, forceRefresh: true },
|
||
);
|
||
expect(openIdClient.refreshTokenGrant).toHaveBeenNthCalledWith(
|
||
2,
|
||
{ 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 advanced session token when an older browser cookie arrives 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).toHaveBeenCalled();
|
||
expect(setCloudFrontAuthCookies).not.toHaveBeenCalled();
|
||
expect(openIdClient.refreshTokenGrant).toHaveBeenCalledWith(
|
||
{ some: 'config' },
|
||
'rt-session-stale',
|
||
{},
|
||
);
|
||
expect(setOpenIDAuthTokens).toHaveBeenCalledWith(mockTokenset, req, res, {
|
||
userId: 'user-db-id',
|
||
existingRefreshToken: 'rt-session-stale',
|
||
tenantId: 'tenant-1',
|
||
openidSubject: baseClaims.sub,
|
||
openidIssuer: baseClaims.iss,
|
||
});
|
||
});
|
||
|
||
it('prefers the session token when pre-marker session state differs', async () => {
|
||
req.headers.cookie = `token_provider=openid; refreshToken=rt-cookie-current; openid_user_id=${makeSignedUserId()}`;
|
||
req.session = {
|
||
openidTokens: {
|
||
refreshToken: 'rt-session-stale',
|
||
appUserId: 'user-db-id',
|
||
},
|
||
};
|
||
|
||
await refreshController(req, res);
|
||
|
||
expect(openIdClient.refreshTokenGrant).toHaveBeenCalledWith(
|
||
{ some: 'config' },
|
||
'rt-session-stale',
|
||
{},
|
||
);
|
||
expect(setOpenIDAuthTokens).toHaveBeenCalledWith(mockTokenset, req, res, {
|
||
userId: 'user-db-id',
|
||
existingRefreshToken: 'rt-session-stale',
|
||
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; openid_user_id=${makeSignedUserId()}`;
|
||
req.session = {
|
||
openidTokens: {
|
||
refreshToken: 'rt-session-current',
|
||
browserRefreshToken: 'rt-browser-stale',
|
||
appUserId: 'user-db-id',
|
||
},
|
||
};
|
||
|
||
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('stores a recovery bridge when durable rotation fails after the IdP grant', async () => {
|
||
storeOpenIDSession.mockRejectedValueOnce(new Error('durable transition failed'));
|
||
|
||
await refreshController(req, res);
|
||
|
||
expect(storeRefreshTokenBridge).toHaveBeenCalledWith({
|
||
oldRefreshToken: 'stored-refresh',
|
||
newRefreshToken: 'new-refresh',
|
||
userId: 'user-db-id',
|
||
tenantId: 'tenant-1',
|
||
openidIssuer: baseClaims.iss,
|
||
ttl: 60 * 1000,
|
||
});
|
||
expect(setOpenIDAuthTokens).not.toHaveBeenCalled();
|
||
expect(res.status).toHaveBeenCalledWith(403);
|
||
});
|
||
|
||
it('refreshes with the cookie token when the persisted session expired', async () => {
|
||
req.session.reload = jest.fn((callback) => callback(new Error('failed to load session')));
|
||
|
||
await refreshController(req, res);
|
||
|
||
expect(clearOpenIDAuthTokens).not.toHaveBeenCalled();
|
||
expect(setOpenIDAuthTokens).toHaveBeenCalled();
|
||
expect(res.status).toHaveBeenCalledWith(200);
|
||
expect(res.send).not.toHaveBeenCalledWith({ code: 'OPENID_SESSION_MISSING' });
|
||
});
|
||
|
||
it('does not classify a session store outage as a missing session', async () => {
|
||
req.session.reload = jest.fn((callback) => callback(new Error('connection unavailable')));
|
||
|
||
await refreshController(req, res);
|
||
|
||
expect(clearOpenIDAuthTokens).not.toHaveBeenCalled();
|
||
expect(res.status).toHaveBeenCalledWith(403);
|
||
expect(setOpenIDAuthTokens).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('uses a reloaded advanced session instead of publishing a stale flight result', async () => {
|
||
req.session.reload = jest.fn((callback) => {
|
||
req.session.openidTokens = {
|
||
accessToken: 'advanced-access',
|
||
idToken: 'advanced-id',
|
||
refreshToken: 'advanced-refresh',
|
||
accessTokenExpiresAt: Math.floor(Date.now() / 1000) + 3600,
|
||
appUserId: 'advanced-user-id',
|
||
openidSubject: 'advanced-subject',
|
||
tenantId: 'advanced-tenant',
|
||
openidIssuer: 'https://advanced-issuer.example.com',
|
||
};
|
||
callback();
|
||
});
|
||
|
||
await refreshController(req, res);
|
||
|
||
expect(storeOpenIDSession).toHaveBeenCalledWith(
|
||
'advanced-user-id',
|
||
'advanced-refresh',
|
||
'advanced-tenant',
|
||
'advanced-refresh',
|
||
);
|
||
expect(setOpenIDAuthTokens).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
access_token: 'advanced-access',
|
||
id_token: 'advanced-id',
|
||
refresh_token: 'advanced-refresh',
|
||
}),
|
||
req,
|
||
res,
|
||
{
|
||
userId: 'advanced-user-id',
|
||
existingRefreshToken: 'advanced-refresh',
|
||
tenantId: 'advanced-tenant',
|
||
openidSubject: 'advanced-subject',
|
||
openidIssuer: 'https://advanced-issuer.example.com',
|
||
},
|
||
);
|
||
});
|
||
|
||
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(mockRunAsSystem).toHaveBeenCalledTimes(1);
|
||
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('rejects a late-saved session whose publication generation was tombstoned', async () => {
|
||
setOpenIDReuseCookies();
|
||
req.session = {
|
||
openidTokens: {
|
||
accessToken: 'session-access-token',
|
||
idToken: makeSessionToken(),
|
||
refreshToken: 'stored-refresh',
|
||
lastRefreshedAt: Date.now(),
|
||
appUserId: 'user-db-id',
|
||
openidSubject: baseClaims.sub,
|
||
tenantId: 'tenant-1',
|
||
openidIssuer: baseClaims.iss,
|
||
publicationFlightKey: 'publication-key',
|
||
publicationFlightOwnerId: 'publication-owner',
|
||
},
|
||
};
|
||
assertOpenIDRefreshSessionGenerationAvailable.mockRejectedValueOnce(
|
||
ownershipLost('revoked by logout'),
|
||
);
|
||
|
||
await refreshController(req, res);
|
||
|
||
expect(assertOpenIDRefreshSessionGenerationAvailable).toHaveBeenCalledWith({
|
||
key: 'publication-key',
|
||
ownerId: 'publication-owner',
|
||
});
|
||
expect(clearOpenIDAuthTokens).toHaveBeenCalledWith(req, res, 'user-db-id', 'tenant-1');
|
||
expect(getUserById).not.toHaveBeenCalled();
|
||
expect(setCloudFrontAuthCookies).not.toHaveBeenCalled();
|
||
expect(res.status).toHaveBeenCalledWith(403);
|
||
});
|
||
|
||
it('withholds a reusable response when logout revokes its delivery lease', async () => {
|
||
setOpenIDReuseCookies();
|
||
req.session = {
|
||
openidTokens: {
|
||
accessToken: 'session-access-token',
|
||
idToken: makeSessionToken(),
|
||
refreshToken: 'stored-refresh',
|
||
lastRefreshedAt: Date.now(),
|
||
appUserId: 'user-db-id',
|
||
openidSubject: baseClaims.sub,
|
||
tenantId: 'tenant-1',
|
||
openidIssuer: baseClaims.iss,
|
||
publicationFlightKey: 'publication-key',
|
||
publicationFlightOwnerId: 'publication-owner',
|
||
publicationFlightCreatedAt: 1000,
|
||
},
|
||
};
|
||
assertOpenIDRefreshFlightDeliveryAvailable.mockRejectedValueOnce(
|
||
ownershipLost('logout won during user lookup'),
|
||
);
|
||
|
||
await refreshController(req, res);
|
||
|
||
expect(claimOpenIDRefreshFlightDelivery).toHaveBeenCalledWith({
|
||
key: 'publication-key',
|
||
ownerId: 'publication-owner',
|
||
createdAt: 1000,
|
||
});
|
||
expect(assertOpenIDRefreshFlightDeliveryAvailable).toHaveBeenCalledWith({
|
||
key: 'publication-key',
|
||
ownerId: 'publication-owner',
|
||
deliveryId: 'delivery-1',
|
||
});
|
||
expect(releaseOpenIDRefreshFlightDelivery).toHaveBeenCalled();
|
||
expect(clearOpenIDAuthTokens).toHaveBeenCalledWith(req, res, 'user-db-id', 'tenant-1');
|
||
expect(setCloudFrontAuthCookies).not.toHaveBeenCalled();
|
||
expect(res.send).not.toHaveBeenCalledWith(
|
||
expect.objectContaining({ token: expect.any(String) }),
|
||
);
|
||
expect(res.status).toHaveBeenCalledWith(403);
|
||
});
|
||
|
||
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,
|
||
},
|
||
};
|
||
findOpenIDUser.mockImplementationOnce(async () => {
|
||
expect(mockActiveTenantId).toBe('tenant-1');
|
||
return { user: { ...defaultUser }, error: null, migration: false };
|
||
});
|
||
|
||
await refreshController(req, res);
|
||
|
||
expect(getUserById).toHaveBeenCalled();
|
||
expect(mockRunAsSystem).toHaveBeenCalledTimes(1);
|
||
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).toHaveBeenCalled();
|
||
expectOpenIDRefreshGrant();
|
||
});
|
||
|
||
describe('access token fallback audience', () => {
|
||
const originalClientId = process.env.OPENID_CLIENT_ID;
|
||
const originalAudience = process.env.OPENID_AUDIENCE;
|
||
|
||
beforeEach(() => {
|
||
process.env.OPENID_CLIENT_ID = 'app-client-id';
|
||
process.env.OPENID_AUDIENCE = 'api://app-resource';
|
||
});
|
||
|
||
afterEach(() => {
|
||
for (const [key, value] of [
|
||
['OPENID_CLIENT_ID', originalClientId],
|
||
['OPENID_AUDIENCE', originalAudience],
|
||
]) {
|
||
if (value === undefined) {
|
||
delete process.env[key];
|
||
} else {
|
||
process.env[key] = value;
|
||
}
|
||
}
|
||
});
|
||
|
||
const armReuse = (accessToken) => {
|
||
setOpenIDReuseCookies();
|
||
req.session = {
|
||
openidTokens: {
|
||
accessToken,
|
||
idToken: makeSessionToken({ exp: Math.floor(Date.now() / 1000) - 60 }),
|
||
refreshToken: 'stored-refresh',
|
||
lastRefreshedAt: Date.now(),
|
||
appUserId: 'user-db-id',
|
||
openidSubject: baseClaims.sub,
|
||
tenantId: 'tenant-1',
|
||
openidIssuer: baseClaims.iss,
|
||
},
|
||
};
|
||
};
|
||
|
||
it('refreshes instead of reusing a time-valid access token for another resource', async () => {
|
||
const graphAccessToken = makeSessionToken({
|
||
aud: '00000003-0000-0000-c000-000000000000',
|
||
scp: 'User.Read',
|
||
});
|
||
armReuse(graphAccessToken);
|
||
|
||
await refreshController(req, res);
|
||
|
||
expectOpenIDRefreshGrant();
|
||
expect(res.send).not.toHaveBeenCalledWith(
|
||
expect.objectContaining({ token: graphAccessToken }),
|
||
);
|
||
});
|
||
|
||
it('reuses a time-valid access token minted for a configured audience', async () => {
|
||
const appAccessToken = makeSessionToken({ aud: 'api://app-resource', scp: 'access' });
|
||
armReuse(appAccessToken);
|
||
getUserById.mockResolvedValue({ ...defaultUser });
|
||
|
||
await refreshController(req, res);
|
||
|
||
expect(openIdClient.refreshTokenGrant).not.toHaveBeenCalled();
|
||
expect(res.status).toHaveBeenCalledWith(200);
|
||
expect(res.send).toHaveBeenCalledWith(expect.objectContaining({ token: appAccessToken }));
|
||
});
|
||
});
|
||
|
||
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).toHaveBeenCalled();
|
||
expectOpenIDRefreshGrant();
|
||
});
|
||
|
||
it('rejects refresh when neither the session nor signed marker identifies the user', 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();
|
||
expect(openIdClient.refreshTokenGrant).not.toHaveBeenCalled();
|
||
expect(res.status).toHaveBeenCalledWith(403);
|
||
});
|
||
|
||
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();
|
||
});
|
||
|
||
/** Arms the reuse shortcut so only the cookie's mint time decides whether it is taken. */
|
||
const armOpenIDReuse = (issuedAtMs) => {
|
||
setOpenIDReuseCookies(makeSignedReuseCookie(issuedAtMs));
|
||
req.session = {
|
||
openidTokens: {
|
||
accessToken: 'session-access-token',
|
||
idToken: makeSessionToken(),
|
||
refreshToken: 'stored-refresh',
|
||
lastRefreshedAt: Date.now(),
|
||
appUserId: defaultUser._id,
|
||
openidSubject: baseClaims.sub,
|
||
tenantId: defaultUser.tenantId,
|
||
openidIssuer: baseClaims.iss,
|
||
},
|
||
};
|
||
};
|
||
|
||
/** A retired session is ended outright: the IdP grant would mint a bearer the strategy refuses. */
|
||
const expectRetiredOpenIDSessionRefused = () => {
|
||
expect(setCloudFrontAuthCookies).not.toHaveBeenCalled();
|
||
expect(openIdClient.refreshTokenGrant).not.toHaveBeenCalled();
|
||
expect(setOpenIDAuthTokens).not.toHaveBeenCalled();
|
||
/** Cleared against the user the check ran for, whatever identity the session tokens carry. */
|
||
expect(clearOpenIDAuthTokens).toHaveBeenCalledWith(req, res, 'user-db-id', 'tenant-1');
|
||
expect(res.send).toHaveBeenCalledWith({ code: 'OPENID_SESSION_MISSING' });
|
||
};
|
||
|
||
it('ends a session whose identity cookie was minted before enrollment', async () => {
|
||
const twoFactorEnrolledAt = new Date();
|
||
armOpenIDReuse(twoFactorEnrolledAt.getTime() - 1);
|
||
getUserById.mockResolvedValue({ ...defaultUser, twoFactorEnrolledAt });
|
||
|
||
await refreshController(req, res);
|
||
|
||
expect(res.status).toHaveBeenCalledWith(401);
|
||
expectRetiredOpenIDSessionRefused();
|
||
});
|
||
|
||
it('ends a session whose identity cookie was minted in the resetting millisecond', async () => {
|
||
const credentialsChangedAt = new Date();
|
||
armOpenIDReuse(credentialsChangedAt.getTime());
|
||
getUserById.mockResolvedValue({ ...defaultUser, credentialsChangedAt });
|
||
|
||
await refreshController(req, res);
|
||
|
||
expect(res.status).toHaveBeenCalledWith(401);
|
||
expectRetiredOpenIDSessionRefused();
|
||
});
|
||
|
||
it('ends a stale session minted before a password reset instead of spending its grant', async () => {
|
||
const credentialsChangedAt = new Date();
|
||
setOpenIDReuseCookies(makeSignedReuseCookie(credentialsChangedAt.getTime() - 60_000));
|
||
req.session = {
|
||
openidTokens: {
|
||
accessToken: 'session-access-token',
|
||
idToken: makeSessionToken(),
|
||
refreshToken: 'stored-refresh',
|
||
lastRefreshedAt: Date.now() - 16 * 60 * 1000,
|
||
appUserId: defaultUser._id,
|
||
},
|
||
};
|
||
getUserById.mockResolvedValue({ ...defaultUser, credentialsChangedAt });
|
||
|
||
await refreshController(req, res);
|
||
|
||
expect(res.status).toHaveBeenCalledWith(401);
|
||
expectRetiredOpenIDSessionRefused();
|
||
});
|
||
|
||
it('dates a session without an identity cookie by its last server-side rotation', async () => {
|
||
const credentialsChangedAt = new Date();
|
||
req.headers.cookie = ['token_provider=openid', 'refreshToken=stored-refresh'].join('; ');
|
||
req.session = {
|
||
openidTokens: {
|
||
refreshToken: 'stored-refresh',
|
||
lastRefreshedAt: credentialsChangedAt.getTime() - 60_000,
|
||
appUserId: defaultUser._id,
|
||
},
|
||
};
|
||
getUserById.mockResolvedValue({ ...defaultUser, credentialsChangedAt });
|
||
|
||
await refreshController(req, res);
|
||
|
||
expect(res.status).toHaveBeenCalledWith(401);
|
||
expectRetiredOpenIDSessionRefused();
|
||
});
|
||
|
||
it('clears a retired session dated only by its identity cookie against that user', async () => {
|
||
const credentialsChangedAt = new Date();
|
||
setOpenIDReuseCookies(makeSignedReuseCookie(credentialsChangedAt.getTime() - 60_000));
|
||
req.session = {};
|
||
getUserById.mockResolvedValue({ ...defaultUser, credentialsChangedAt });
|
||
|
||
await refreshController(req, res);
|
||
|
||
expect(res.status).toHaveBeenCalledWith(401);
|
||
expectRetiredOpenIDSessionRefused();
|
||
});
|
||
|
||
it('refreshes a session without an identity cookie rotated after the reset', async () => {
|
||
req.headers.cookie = ['token_provider=openid', 'refreshToken=stored-refresh'].join('; ');
|
||
req.session = {
|
||
openidTokens: {
|
||
refreshToken: 'stored-refresh',
|
||
lastRefreshedAt: Date.now(),
|
||
appUserId: defaultUser._id,
|
||
},
|
||
};
|
||
getUserById.mockResolvedValue({
|
||
...defaultUser,
|
||
credentialsChangedAt: new Date(Date.now() - 60_000),
|
||
});
|
||
|
||
await refreshController(req, res);
|
||
|
||
expect(clearOpenIDAuthTokens).not.toHaveBeenCalled();
|
||
expectOpenIDRefreshGrant();
|
||
});
|
||
|
||
it('keeps the reuse shortcut for a cookie minted after both cutoffs', async () => {
|
||
const twoFactorEnrolledAt = new Date();
|
||
armOpenIDReuse(twoFactorEnrolledAt.getTime() + 1);
|
||
getUserById.mockResolvedValue({
|
||
...defaultUser,
|
||
twoFactorEnrolledAt,
|
||
credentialsChangedAt: new Date(twoFactorEnrolledAt.getTime() - 60_000),
|
||
});
|
||
|
||
await refreshController(req, res);
|
||
|
||
expect(openIdClient.refreshTokenGrant).not.toHaveBeenCalled();
|
||
expect(setCloudFrontAuthCookies).toHaveBeenCalled();
|
||
expect(res.status).toHaveBeenCalledWith(200);
|
||
});
|
||
|
||
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).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).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).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('rejects a refreshed identity that resolves to a different user', async () => {
|
||
findOpenIDUser.mockResolvedValue({
|
||
user: { ...defaultUser, _id: 'different-user-id' },
|
||
error: null,
|
||
migration: false,
|
||
});
|
||
|
||
await refreshController(req, res);
|
||
|
||
expect(setOpenIDAuthTokens).not.toHaveBeenCalled();
|
||
expect(logger.warn).toHaveBeenCalledWith(
|
||
'[refreshController] Refreshed identity resolved a different user; refusing token issuance',
|
||
{
|
||
refreshUserId: 'user-db-id',
|
||
resolvedUserId: 'different-user-id',
|
||
},
|
||
);
|
||
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).toHaveBeenCalled();
|
||
expect(res.status).toHaveBeenCalledWith(403);
|
||
expect(res.send).toHaveBeenCalledWith('Invalid OpenID refresh token');
|
||
});
|
||
|
||
it('retires the readable session hint when the IdP rejects the refresh token', async () => {
|
||
openIdClient.refreshTokenGrant.mockRejectedValue(new Error('invalid_grant'));
|
||
|
||
await refreshController(req, res);
|
||
|
||
expect(res.status).toHaveBeenCalledWith(403);
|
||
expect(res.clearCookie).toHaveBeenCalledWith('session_hint');
|
||
});
|
||
|
||
it('retires the readable session hint when the refreshed identity resolves a different user', async () => {
|
||
findOpenIDUser.mockResolvedValue({
|
||
user: { ...defaultUser, _id: 'different-user-id' },
|
||
error: null,
|
||
migration: false,
|
||
});
|
||
|
||
await refreshController(req, res);
|
||
|
||
expect(res.redirect).toHaveBeenCalledWith('/login');
|
||
expect(res.clearCookie).toHaveBeenCalledWith('session_hint');
|
||
});
|
||
|
||
it('retires the readable session hint when the refresh owner no longer exists', async () => {
|
||
getUserById.mockResolvedValue(null);
|
||
|
||
await refreshController(req, res);
|
||
|
||
expect(res.status).toHaveBeenCalledWith(403);
|
||
expect(res.clearCookie).toHaveBeenCalledWith('session_hint');
|
||
});
|
||
|
||
it('keeps the readable session hint on a successful OpenID refresh', async () => {
|
||
await refreshController(req, res);
|
||
|
||
expect(res.status).toHaveBeenCalledWith(200);
|
||
expect(res.clearCookie).not.toHaveBeenCalledWith('session_hint');
|
||
});
|
||
|
||
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).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).toHaveBeenCalled();
|
||
expect(getRefreshTokenBridge).not.toHaveBeenCalled();
|
||
expect(res.status).toHaveBeenCalledWith(403);
|
||
});
|
||
|
||
it('refuses bridge recovery when a reset lands during the failed grant', async () => {
|
||
setOpenIDReuseCookies(makeSignedReuseCookie(Date.now() - 1_000));
|
||
req.session = {};
|
||
const bridgeUser = {
|
||
_id: 'user-db-id',
|
||
email: baseClaims.email,
|
||
openidId: baseClaims.sub,
|
||
tenantId: 'tenant-1',
|
||
openidIssuer: 'https://issuer.example.com',
|
||
};
|
||
getUserById
|
||
.mockResolvedValueOnce(bridgeUser)
|
||
.mockResolvedValueOnce({ ...bridgeUser, credentialsChangedAt: new Date() });
|
||
openIdClient.refreshTokenGrant.mockRejectedValueOnce(new Error('invalid_grant'));
|
||
getRefreshTokenBridge.mockResolvedValueOnce('bridged-refresh');
|
||
|
||
await refreshController(req, res);
|
||
|
||
expect(getRefreshTokenBridge).toHaveBeenCalled();
|
||
expect(openIdClient.refreshTokenGrant).toHaveBeenCalledTimes(1);
|
||
expect(setOpenIDAuthTokens).not.toHaveBeenCalled();
|
||
expect(clearOpenIDAuthTokens).toHaveBeenCalledWith(req, res, 'user-db-id', 'tenant-1');
|
||
expect(res.status).toHaveBeenCalledWith(403);
|
||
expect(res.send).toHaveBeenCalledWith('Invalid OpenID refresh token');
|
||
});
|
||
|
||
/** The recovery grant need not rotate. When it does not, the browser must still be moved onto
|
||
* the bridged token rather than back onto the stale one the bridge exists to retire. */
|
||
it('installs the bridged token when the recovery grant does not rotate', 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.mockImplementationOnce(async () => {
|
||
expect(mockActiveTenantId).toBe('tenant-1');
|
||
return 'bridged-refresh';
|
||
});
|
||
const nonRotatingTokenset = { ...mockTokenset };
|
||
delete nonRotatingTokenset.refresh_token;
|
||
openIdClient.refreshTokenGrant
|
||
.mockRejectedValueOnce(new Error('invalid_grant'))
|
||
.mockResolvedValueOnce(nonRotatingTokenset);
|
||
|
||
await refreshController(req, res);
|
||
|
||
expect(storeOpenIDSession).toHaveBeenCalledWith(
|
||
'user-db-id',
|
||
'bridged-refresh',
|
||
'tenant-1',
|
||
'stored-refresh',
|
||
);
|
||
expect(setOpenIDAuthTokens).toHaveBeenCalledWith(
|
||
expect.objectContaining({ refresh_token: 'bridged-refresh' }),
|
||
req,
|
||
res,
|
||
expect.any(Object),
|
||
);
|
||
expect(res.status).toHaveBeenCalledWith(200);
|
||
});
|
||
|
||
/** A recovery grant that omits `id_token` leaves the rebuilt token set with no identity
|
||
* material of its own; the refresh carries the stripped token in a non-enumerable marker so
|
||
* claims still resolve without that expired token re-entering the auth response. */
|
||
it('resolves identity from the marker when the refresh stripped an expired id_token', 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');
|
||
let refreshCall = 0;
|
||
refreshOpenIDSession.mockImplementation(async () => {
|
||
refreshCall += 1;
|
||
if (refreshCall === 1) {
|
||
throw new Error('invalid_grant');
|
||
}
|
||
const stripped = {
|
||
access_token: 'new-access',
|
||
refresh_token: 'bridged-refresh',
|
||
expires_at: Math.floor(Date.now() / 1000) + 3600,
|
||
};
|
||
Object.defineProperty(stripped, '__identityIdToken', {
|
||
value: jwt.sign(baseClaims, 'idp-secret'),
|
||
enumerable: false,
|
||
configurable: true,
|
||
});
|
||
return stripped;
|
||
});
|
||
|
||
await refreshController(req, res);
|
||
|
||
expect(res.status).toHaveBeenCalledWith(200);
|
||
expect(setOpenIDAuthTokens).toHaveBeenCalledWith(
|
||
expect.objectContaining({ refresh_token: 'bridged-refresh' }),
|
||
req,
|
||
res,
|
||
expect.any(Object),
|
||
);
|
||
expect(setOpenIDAuthTokens.mock.calls.at(-1)[0].id_token).toBeUndefined();
|
||
});
|
||
|
||
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: 'stored-refresh',
|
||
tenantId: 'tenant-1',
|
||
openidSubject: baseClaims.sub,
|
||
openidIssuer: baseClaims.iss,
|
||
});
|
||
expect(completeOpenIDRefreshFlight).toHaveBeenCalledWith({
|
||
key: 'bridge-flight-key',
|
||
ownerId: 'bridge-owner',
|
||
tokens: expect.objectContaining({
|
||
appAuthToken: 'new-app-token',
|
||
claims: baseClaims,
|
||
tokenset: mockTokenset,
|
||
}),
|
||
});
|
||
expect(completeOpenIDRefreshFlight.mock.invocationCallOrder[0]).toBeLessThan(
|
||
setOpenIDAuthTokens.mock.invocationCallOrder[0],
|
||
);
|
||
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',
|
||
'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('joins an existing stale-cookie recovery without rotating the bridged token again', 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'));
|
||
acquireOpenIDRefreshFlight.mockResolvedValue({ acquired: false, ownerId: 'other-owner' });
|
||
waitForOpenIDRefreshFlight.mockResolvedValue({
|
||
appAuthToken: 'shared-app-token',
|
||
__flightOwnerId: 'shared-owner',
|
||
tokenset: { ...mockTokenset },
|
||
claims: baseClaims,
|
||
openidIssuer: baseClaims.iss,
|
||
});
|
||
getOpenIDAppAuthToken.mockReturnValueOnce('shared-app-token');
|
||
setOpenIDAuthTokens.mockReturnValueOnce('shared-app-token');
|
||
|
||
await refreshController(req, res);
|
||
|
||
expect(createRefreshTokenBridgeFlightKey).toHaveBeenCalledWith({
|
||
oldRefreshToken: 'stored-refresh',
|
||
userId: 'user-db-id',
|
||
tenantId: 'tenant-1',
|
||
openidIssuer: 'https://issuer.example.com',
|
||
});
|
||
expect(openIdClient.refreshTokenGrant).toHaveBeenCalledTimes(1);
|
||
expect(withOpenIDRefreshFlightLease).not.toHaveBeenCalled();
|
||
expect(setOpenIDAuthTokens).toHaveBeenCalledWith(
|
||
expect.objectContaining({ refresh_token: 'new-refresh' }),
|
||
req,
|
||
res,
|
||
expect.objectContaining({
|
||
userId: 'user-db-id',
|
||
existingRefreshToken: 'stored-refresh',
|
||
}),
|
||
);
|
||
expect(storeOpenIDSession).not.toHaveBeenCalled();
|
||
expect(res.send).toHaveBeenCalledWith(expect.objectContaining({ token: 'shared-app-token' }));
|
||
expect(res.status).toHaveBeenCalledWith(200);
|
||
});
|
||
|
||
it('publishes an ordinary completed result to a cross-replica follower', async () => {
|
||
setOpenIDReuseCookies();
|
||
req.session = {};
|
||
getUserById.mockResolvedValue({
|
||
...defaultUser,
|
||
openidIssuer: 'https://predecessor.example.com',
|
||
});
|
||
acquireOpenIDRefreshFlight.mockResolvedValue({ acquired: false, ownerId: 'other-owner' });
|
||
waitForOpenIDRefreshFlight.mockResolvedValue({
|
||
appAuthToken: 'shared-app-token',
|
||
__flightOwnerId: 'shared-owner',
|
||
tokenset: { ...mockTokenset },
|
||
claims: baseClaims,
|
||
openidIssuer: baseClaims.iss,
|
||
});
|
||
getOpenIDAppAuthToken.mockReturnValueOnce('shared-app-token');
|
||
setOpenIDAuthTokens.mockReturnValueOnce('shared-app-token');
|
||
|
||
await refreshController(req, res);
|
||
|
||
expect(createRefreshTokenBridgeFlightKey).toHaveBeenCalledWith({
|
||
oldRefreshToken: 'stored-refresh',
|
||
userId: 'user-db-id',
|
||
tenantId: 'tenant-1',
|
||
openidIssuer: 'https://predecessor.example.com',
|
||
});
|
||
expect(storeOpenIDSession).not.toHaveBeenCalled();
|
||
expect(setOpenIDAuthTokens).toHaveBeenCalledWith(
|
||
expect.objectContaining({ refresh_token: 'new-refresh' }),
|
||
req,
|
||
res,
|
||
expect.objectContaining({
|
||
userId: 'user-db-id',
|
||
existingRefreshToken: 'stored-refresh',
|
||
openidIssuer: baseClaims.iss,
|
||
}),
|
||
);
|
||
expect(res.send).toHaveBeenCalledWith(expect.objectContaining({ token: 'shared-app-token' }));
|
||
expect(res.status).toHaveBeenCalledWith(200);
|
||
});
|
||
|
||
it('claims the completed generation before delivering a newly refreshed app token', async () => {
|
||
setOpenIDReuseCookies();
|
||
req.session = {};
|
||
setOpenIDAuthTokens.mockImplementationOnce(() => {
|
||
req.session.openidTokens = {
|
||
accessToken: 'new-access',
|
||
refreshToken: 'new-refresh',
|
||
appUserId: 'user-db-id',
|
||
tenantId: 'tenant-1',
|
||
};
|
||
return 'new-app-token';
|
||
});
|
||
|
||
await refreshController(req, res);
|
||
|
||
expect(claimOpenIDRefreshFlightDelivery).toHaveBeenCalledWith({
|
||
key: 'bridge-flight-key',
|
||
ownerId: 'bridge-owner',
|
||
createdAt: expect.any(Number),
|
||
});
|
||
expect(assertOpenIDRefreshFlightDeliveryAvailable).toHaveBeenCalledWith({
|
||
key: 'bridge-flight-key',
|
||
ownerId: 'bridge-owner',
|
||
deliveryId: 'delivery-1',
|
||
});
|
||
expect(res.send).toHaveBeenCalledWith(expect.objectContaining({ token: 'new-app-token' }));
|
||
expect(releaseOpenIDRefreshFlightDelivery).toHaveBeenCalled();
|
||
});
|
||
|
||
it('does not deliver a newly refreshed app token after logout requests revocation', async () => {
|
||
setOpenIDReuseCookies();
|
||
req.session = {};
|
||
setOpenIDAuthTokens.mockImplementationOnce(() => {
|
||
req.session.openidTokens = {
|
||
accessToken: 'new-access',
|
||
refreshToken: 'new-refresh',
|
||
appUserId: 'user-db-id',
|
||
tenantId: 'tenant-1',
|
||
};
|
||
return 'new-app-token';
|
||
});
|
||
assertOpenIDRefreshFlightDeliveryAvailable.mockRejectedValueOnce(
|
||
ownershipLost('logout requested revocation'),
|
||
);
|
||
|
||
await refreshController(req, res);
|
||
|
||
expect(releaseOpenIDRefreshFlightDelivery).toHaveBeenCalled();
|
||
expect(clearOpenIDAuthTokens).toHaveBeenCalled();
|
||
expect(res.send).not.toHaveBeenCalledWith(expect.objectContaining({ token: 'new-app-token' }));
|
||
expect(res.status).toHaveBeenCalledWith(403);
|
||
});
|
||
|
||
it('clears a follower publication when logout revokes its generation after emission', async () => {
|
||
setOpenIDReuseCookies();
|
||
req.session = {};
|
||
getUserById.mockResolvedValue(defaultUser);
|
||
acquireOpenIDRefreshFlight.mockResolvedValue({ acquired: false, ownerId: 'other-owner' });
|
||
waitForOpenIDRefreshFlight.mockResolvedValue({
|
||
appAuthToken: 'shared-app-token',
|
||
__flightOwnerId: 'shared-owner',
|
||
tokenset: { ...mockTokenset },
|
||
claims: baseClaims,
|
||
openidIssuer: baseClaims.iss,
|
||
});
|
||
getOpenIDAppAuthToken.mockReturnValueOnce('shared-app-token');
|
||
setOpenIDAuthTokens.mockImplementationOnce(() => {
|
||
req.session.openidTokens = {
|
||
accessToken: 'new-access',
|
||
refreshToken: 'new-refresh',
|
||
};
|
||
return 'shared-app-token';
|
||
});
|
||
assertOpenIDRefreshFlightAvailable
|
||
.mockResolvedValueOnce({ status: 'completed', ownerId: 'shared-owner' })
|
||
.mockResolvedValueOnce({ status: 'completed', ownerId: 'shared-owner' })
|
||
.mockResolvedValueOnce({ status: 'completed', ownerId: 'shared-owner' })
|
||
.mockRejectedValueOnce(ownershipLost('revoked after emission'));
|
||
|
||
await refreshController(req, res);
|
||
|
||
expect(setOpenIDAuthTokens).toHaveBeenCalled();
|
||
expect(req.session.openidTokens).toEqual(
|
||
expect.objectContaining({
|
||
publicationFlightKey: 'bridge-flight-key',
|
||
publicationFlightOwnerId: 'shared-owner',
|
||
}),
|
||
);
|
||
expect(clearOpenIDAuthTokens).toHaveBeenCalledWith(req, res, 'user-db-id', 'tenant-1');
|
||
expect(deleteSession).toHaveBeenCalledWith({ refreshToken: 'new-refresh' });
|
||
expect(res.status).toHaveBeenCalledWith(403);
|
||
expect(res.send).not.toHaveBeenCalledWith(
|
||
expect.objectContaining({ token: 'shared-app-token' }),
|
||
);
|
||
});
|
||
|
||
it('returns a newer stable-refresh session instead of a stale publication result', async () => {
|
||
setOpenIDReuseCookies();
|
||
req.session = {
|
||
reload: jest.fn((callback) => {
|
||
req.session.openidTokens = {
|
||
accessToken: 'advanced-access',
|
||
idToken: 'advanced-id',
|
||
refreshToken: 'stored-refresh',
|
||
accessTokenExpiresAt: Math.floor(Date.now() / 1000) + 7200,
|
||
appUserId: 'user-db-id',
|
||
openidSubject: baseClaims.sub,
|
||
tenantId: 'tenant-1',
|
||
openidIssuer: baseClaims.iss,
|
||
publicationFlightKey: 'advanced-publication-key',
|
||
publicationFlightOwnerId: 'advanced-publication-owner',
|
||
};
|
||
callback();
|
||
}),
|
||
};
|
||
getUserById.mockResolvedValue(defaultUser);
|
||
acquireOpenIDRefreshFlight.mockResolvedValue({ acquired: false, ownerId: 'other-owner' });
|
||
waitForOpenIDRefreshFlight.mockResolvedValue({
|
||
appAuthToken: 'stale-app-token',
|
||
__flightOwnerId: 'shared-owner',
|
||
tokenset: {
|
||
access_token: 'stale-access',
|
||
id_token: 'stale-id',
|
||
refresh_token: 'stored-refresh',
|
||
expires_in: 3600,
|
||
},
|
||
claims: baseClaims,
|
||
openidIssuer: baseClaims.iss,
|
||
predecessorAccessToken: 'predecessor-access',
|
||
});
|
||
getOpenIDAppAuthToken.mockReturnValueOnce('advanced-app-token');
|
||
setOpenIDAuthTokens.mockReturnValueOnce('advanced-app-token');
|
||
|
||
await refreshController(req, res);
|
||
|
||
expect(setOpenIDAuthTokens).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
access_token: 'advanced-access',
|
||
refresh_token: 'stored-refresh',
|
||
}),
|
||
req,
|
||
res,
|
||
expect.any(Object),
|
||
);
|
||
expect(res.send).toHaveBeenCalledWith(expect.objectContaining({ token: 'advanced-app-token' }));
|
||
expect(req.session.openidTokens).toEqual(
|
||
expect.objectContaining({
|
||
publicationFlightKey: 'advanced-publication-key',
|
||
publicationFlightOwnerId: 'advanced-publication-owner',
|
||
}),
|
||
);
|
||
});
|
||
|
||
it('recovers with serialized identity claims when the refreshed ID token is omitted', async () => {
|
||
setOpenIDReuseCookies();
|
||
req.session = {};
|
||
getRefreshTokenBridge.mockResolvedValue('bridged-refresh');
|
||
refreshOpenIDSession
|
||
.mockRejectedValueOnce(Object.assign(new Error('invalid_grant'), { error: 'invalid_grant' }))
|
||
.mockResolvedValueOnce({
|
||
access_token: 'new-access',
|
||
refresh_token: 'new-refresh',
|
||
expires_at: Math.floor(Date.now() / 1000) + 3600,
|
||
__identityClaims: baseClaims,
|
||
});
|
||
|
||
await refreshController(req, res);
|
||
|
||
expect(findOpenIDUser).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
email: baseClaims.email,
|
||
openidId: baseClaims.sub,
|
||
strategyName: 'refreshController (bridge recovery)',
|
||
}),
|
||
);
|
||
expect(setOpenIDAuthTokens).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
access_token: 'new-access',
|
||
__identityClaims: baseClaims,
|
||
}),
|
||
req,
|
||
res,
|
||
expect.objectContaining({ openidSubject: baseClaims.sub }),
|
||
);
|
||
expect(setOpenIDAuthTokens.mock.calls[0][0]).not.toHaveProperty('id_token');
|
||
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: 'stored-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('removes a grace bridge published concurrently with logout revocation', 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');
|
||
openIdClient.refreshTokenGrant
|
||
.mockRejectedValueOnce(new Error('invalid_grant'))
|
||
.mockResolvedValueOnce(mockTokenset);
|
||
const assertLeaseOwned = jest
|
||
.fn()
|
||
.mockResolvedValueOnce(true)
|
||
.mockResolvedValueOnce(true)
|
||
.mockRejectedValueOnce(ownershipLost('revoked by logout'));
|
||
withOpenIDRefreshFlightLease.mockImplementationOnce(({ operation }) =>
|
||
operation({ assertLeaseOwned, markLeaseSettled: jest.fn() }),
|
||
);
|
||
|
||
await refreshController(req, res);
|
||
|
||
expect(deleteRefreshTokenBridges).toHaveBeenCalledWith({
|
||
refreshTokens: ['stored-refresh'],
|
||
userId: 'user-db-id',
|
||
tenantId: 'tenant-1',
|
||
version: 'bridge-version-1',
|
||
});
|
||
expect(completeOpenIDRefreshFlight).not.toHaveBeenCalled();
|
||
expect(setOpenIDAuthTokens).not.toHaveBeenCalled();
|
||
expect(res.status).toHaveBeenCalledWith(403);
|
||
});
|
||
|
||
it('keeps the grace bridge when the ownership check fails for an undetermined reason', 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');
|
||
openIdClient.refreshTokenGrant
|
||
.mockRejectedValueOnce(new Error('invalid_grant'))
|
||
.mockResolvedValueOnce(mockTokenset);
|
||
const assertLeaseOwned = jest
|
||
.fn()
|
||
.mockResolvedValueOnce(true)
|
||
.mockResolvedValueOnce(true)
|
||
.mockRejectedValueOnce(new Error('connection timed out'));
|
||
withOpenIDRefreshFlightLease.mockImplementationOnce(({ operation }) =>
|
||
operation({ assertLeaseOwned, markLeaseSettled: jest.fn() }),
|
||
);
|
||
|
||
await refreshController(req, res);
|
||
|
||
expect(deleteRefreshTokenBridges).not.toHaveBeenCalled();
|
||
expect(completeOpenIDRefreshFlight).not.toHaveBeenCalled();
|
||
expect(setOpenIDAuthTokens).not.toHaveBeenCalled();
|
||
expect(res.status).toHaveBeenCalledWith(403);
|
||
});
|
||
|
||
it('removes a prepared durable session when logout wins before publication commits', async () => {
|
||
setOpenIDReuseCookies();
|
||
req.session = {};
|
||
getRefreshTokenBridge.mockResolvedValue('bridged-refresh');
|
||
openIdClient.refreshTokenGrant
|
||
.mockRejectedValueOnce(new Error('invalid_grant'))
|
||
.mockResolvedValueOnce(mockTokenset);
|
||
const assertLeaseOwned = jest
|
||
.fn()
|
||
.mockResolvedValueOnce(true)
|
||
.mockResolvedValueOnce(true)
|
||
.mockResolvedValueOnce(true)
|
||
.mockResolvedValueOnce(true)
|
||
.mockResolvedValueOnce(true)
|
||
.mockRejectedValueOnce(ownershipLost('revoked by logout'));
|
||
withOpenIDRefreshFlightLease.mockImplementationOnce(({ operation }) =>
|
||
operation({ assertLeaseOwned, markLeaseSettled: jest.fn() }),
|
||
);
|
||
|
||
await refreshController(req, res);
|
||
|
||
expect(storeOpenIDSession).toHaveBeenCalledWith(
|
||
'user-db-id',
|
||
'new-refresh',
|
||
'tenant-1',
|
||
'stored-refresh',
|
||
);
|
||
expect(deleteSession).toHaveBeenCalledWith({ refreshToken: 'new-refresh' });
|
||
expect(clearOpenIDAuthTokens).toHaveBeenCalledWith(req, res, 'user-db-id', 'tenant-1');
|
||
expect(completeOpenIDRefreshFlight).not.toHaveBeenCalled();
|
||
expect(setOpenIDAuthTokens).not.toHaveBeenCalled();
|
||
expect(res.status).toHaveBeenCalledWith(403);
|
||
});
|
||
|
||
it('cleans the prepared successor before emission when logout wins the final commit', async () => {
|
||
setOpenIDReuseCookies();
|
||
req.session = {};
|
||
getRefreshTokenBridge.mockResolvedValue('bridged-refresh');
|
||
openIdClient.refreshTokenGrant
|
||
.mockRejectedValueOnce(new Error('invalid_grant'))
|
||
.mockResolvedValueOnce(mockTokenset);
|
||
completeOpenIDRefreshFlight.mockResolvedValueOnce(null);
|
||
|
||
await refreshController(req, res);
|
||
|
||
expect(setOpenIDAuthTokens).not.toHaveBeenCalled();
|
||
expect(deleteSession).toHaveBeenCalledWith({ refreshToken: 'new-refresh' });
|
||
expect(clearOpenIDAuthTokens).toHaveBeenCalledWith(req, res, 'user-db-id', 'tenant-1');
|
||
expect(res.status).toHaveBeenCalledWith(403);
|
||
});
|
||
|
||
it('preserves the successor and bridge when publication completion is indeterminate', async () => {
|
||
setOpenIDReuseCookies();
|
||
req.session = {};
|
||
getRefreshTokenBridge.mockResolvedValue('bridged-refresh');
|
||
openIdClient.refreshTokenGrant
|
||
.mockRejectedValueOnce(new Error('invalid_grant'))
|
||
.mockResolvedValueOnce(mockTokenset);
|
||
completeOpenIDRefreshFlight.mockRejectedValueOnce(new Error('mongo timeout'));
|
||
assertOpenIDRefreshFlightAvailable.mockRejectedValueOnce(new Error('mongo read timeout'));
|
||
|
||
await refreshController(req, res);
|
||
|
||
expect(storeOpenIDSession).toHaveBeenCalledWith(
|
||
'user-db-id',
|
||
'new-refresh',
|
||
'tenant-1',
|
||
'stored-refresh',
|
||
);
|
||
expect(storeRefreshTokenBridge).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
oldRefreshToken: 'stored-refresh',
|
||
newRefreshToken: 'new-refresh',
|
||
ttl: 60000,
|
||
}),
|
||
);
|
||
expect(deleteSession).not.toHaveBeenCalledWith({ refreshToken: 'new-refresh' });
|
||
expect(clearOpenIDAuthTokens).not.toHaveBeenCalled();
|
||
expect(setOpenIDAuthTokens).not.toHaveBeenCalled();
|
||
expect(failOpenIDRefreshFlight).not.toHaveBeenCalled();
|
||
expect(res.status).toHaveBeenCalledWith(403);
|
||
});
|
||
|
||
it('does not publish a nested bridge refresh after the outer logout fence is revoked', 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');
|
||
openIdClient.refreshTokenGrant
|
||
.mockRejectedValueOnce(new Error('invalid_grant'))
|
||
.mockResolvedValueOnce(mockTokenset);
|
||
const assertLeaseOwned = jest.fn().mockRejectedValueOnce(ownershipLost('revoked by logout'));
|
||
withOpenIDRefreshFlightLease.mockImplementationOnce(({ operation }) =>
|
||
operation({ assertLeaseOwned, markLeaseSettled: jest.fn() }),
|
||
);
|
||
|
||
await refreshController(req, res);
|
||
|
||
expect(refreshOpenIDSession).toHaveBeenCalledWith(
|
||
expect.anything(),
|
||
res,
|
||
expect.objectContaining({ _id: 'user-db-id' }),
|
||
'id_token',
|
||
expect.anything(),
|
||
expect.objectContaining({
|
||
assertLeaseOwned,
|
||
deferPublication: true,
|
||
forceRefresh: true,
|
||
}),
|
||
);
|
||
expect(storeRefreshTokenBridge).not.toHaveBeenCalled();
|
||
expect(completeOpenIDRefreshFlight).not.toHaveBeenCalled();
|
||
expect(setOpenIDAuthTokens).not.toHaveBeenCalled();
|
||
expect(req.session.openidTokens?.accessToken).toBeUndefined();
|
||
expect(res.status).toHaveBeenCalledWith(403);
|
||
});
|
||
|
||
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');
|
||
expect(res.clearCookie).toHaveBeenCalledWith('session_hint');
|
||
});
|
||
});
|
||
|
||
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({ _id: 'session-id', expiration: new Date(Date.now() + 60_000) });
|
||
isTwoFactorEnrollmentRequired.mockReturnValue(false);
|
||
|
||
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(),
|
||
clearCookie: 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,
|
||
expect.objectContaining({ expiration: expect.any(Date) }),
|
||
req,
|
||
);
|
||
expect(sentPayload).toEqual({
|
||
token: 'local-app-token',
|
||
user: {
|
||
_id: 'local-user-id',
|
||
email: 'local@example.com',
|
||
},
|
||
});
|
||
});
|
||
|
||
it('requires enrollment before issuing tokens from an existing local refresh session', async () => {
|
||
const user = {
|
||
_id: 'local-user-id',
|
||
provider: 'local',
|
||
twoFactorEnabled: false,
|
||
email: 'local@example.com',
|
||
};
|
||
getUserById.mockResolvedValue(user);
|
||
isTwoFactorEnrollmentRequired.mockReturnValue(true);
|
||
|
||
await refreshController(req, res);
|
||
|
||
expect(generateTwoFactorSetupToken).toHaveBeenCalledWith(
|
||
'local-user-id',
|
||
process.env.JWT_SECRET,
|
||
);
|
||
expect(res.send).toHaveBeenCalledWith({
|
||
code: 'TWO_FACTOR_ENROLLMENT_REQUIRED',
|
||
twoFAPending: true,
|
||
twoFASetupRequired: true,
|
||
tempToken: 'setup-token',
|
||
});
|
||
expect(findSession).toHaveBeenCalledWith(
|
||
{
|
||
userId: 'local-user-id',
|
||
refreshToken: expect.any(String),
|
||
},
|
||
{ lean: false },
|
||
);
|
||
expect(setAuthTokens).not.toHaveBeenCalled();
|
||
expect(deleteSession).toHaveBeenCalledWith({ sessionId: 'session-id' });
|
||
expect(res.clearCookie).toHaveBeenCalledWith('refreshToken');
|
||
expect(clearCloudFrontCookies).toHaveBeenCalledWith(res, {
|
||
userId: 'local-user-id',
|
||
tenantId: undefined,
|
||
storageRegion: undefined,
|
||
});
|
||
|
||
findSession.mockResolvedValue(null);
|
||
jest.clearAllMocks();
|
||
isTwoFactorEnrollmentRequired.mockReturnValue(true);
|
||
|
||
await refreshController(req, res);
|
||
|
||
expect(generateTwoFactorSetupToken).not.toHaveBeenCalled();
|
||
expect(res.status).toHaveBeenCalledWith(401);
|
||
});
|
||
|
||
/**
|
||
* `findSession` resolves before anything is minted, so recovery landing in that gap is caught by
|
||
* neither gate: the token handed back postdates `credentialsChangedAt`, and `setAuthTokens` re-saves
|
||
* the very session document recovery had already deleted.
|
||
*/
|
||
describe('a password reset landing after the session lookup', () => {
|
||
const racingRead = (user) => {
|
||
getUserById
|
||
.mockResolvedValueOnce(user)
|
||
.mockResolvedValueOnce({ credentialsChangedAt: new Date(Date.now() + 60_000) });
|
||
};
|
||
|
||
it('withdraws the session it had already minted', async () => {
|
||
racingRead({ _id: 'local-user-id', provider: 'local', twoFactorEnabled: true });
|
||
|
||
await refreshController(req, res);
|
||
|
||
expect(setAuthTokens).toHaveBeenCalled();
|
||
expect(deleteAllUserSessions).toHaveBeenCalledWith({ userId: 'local-user-id' });
|
||
expect(res.status).toHaveBeenCalledWith(401);
|
||
expect(res.send).toHaveBeenCalledWith('Refresh token expired or not found for this user');
|
||
expect(res.clearCookie).toHaveBeenCalledWith('refreshToken');
|
||
expect(res.clearCookie).toHaveBeenCalledWith('token_provider');
|
||
});
|
||
|
||
it('withdraws the session it had already minted when the recheck cannot complete', async () => {
|
||
getUserById
|
||
.mockResolvedValueOnce({ _id: 'local-user-id', provider: 'local', twoFactorEnabled: true })
|
||
.mockRejectedValueOnce(new Error('database unavailable'));
|
||
|
||
await refreshController(req, res);
|
||
|
||
expect(setAuthTokens).toHaveBeenCalled();
|
||
expect(deleteAllUserSessions).toHaveBeenCalledWith({ userId: 'local-user-id' });
|
||
expect(res.clearCookie).toHaveBeenCalledWith('refreshToken');
|
||
expect(res.status).toHaveBeenCalledWith(403);
|
||
expect(res.send).not.toHaveBeenCalledWith(
|
||
expect.objectContaining({ token: 'local-app-token' }),
|
||
);
|
||
});
|
||
|
||
it('withholds the enrollment token it had already minted', async () => {
|
||
isTwoFactorEnrollmentRequired.mockReturnValue(true);
|
||
racingRead({ _id: 'local-user-id', provider: 'local', twoFactorEnabled: false });
|
||
|
||
await refreshController(req, res);
|
||
|
||
expect(generateTwoFactorSetupToken).toHaveBeenCalled();
|
||
expect(deleteAllUserSessions).toHaveBeenCalledWith({ userId: 'local-user-id' });
|
||
expect(res.status).toHaveBeenCalledWith(401);
|
||
expect(res.send).not.toHaveBeenCalledWith(
|
||
expect.objectContaining({ tempToken: 'setup-token' }),
|
||
);
|
||
});
|
||
|
||
it('reads the cutoff again only once the credential exists', async () => {
|
||
const order = [];
|
||
getUserById
|
||
.mockResolvedValueOnce({ _id: 'local-user-id', provider: 'local', twoFactorEnabled: true })
|
||
.mockImplementationOnce(async () => {
|
||
order.push('recheck');
|
||
return {};
|
||
});
|
||
setAuthTokens.mockImplementationOnce(async () => {
|
||
order.push('mint');
|
||
return 'local-app-token';
|
||
});
|
||
|
||
await refreshController(req, res);
|
||
|
||
expect(order).toEqual(['mint', 'recheck']);
|
||
expect(getUserById).toHaveBeenLastCalledWith(
|
||
'local-user-id',
|
||
'twoFactorEnrolledAt credentialsChangedAt',
|
||
);
|
||
expect(res.status).toHaveBeenCalledWith(200);
|
||
});
|
||
});
|
||
|
||
it('refuses a refresh credential that predates required enrollment', async () => {
|
||
getUserById.mockResolvedValue({
|
||
_id: 'local-user-id',
|
||
provider: 'local',
|
||
twoFactorEnabled: true,
|
||
twoFactorEnrolledAt: new Date(Date.now() + 60_000),
|
||
});
|
||
|
||
await refreshController(req, res);
|
||
|
||
/** Promotion lands before revocation, so the cutoff is what denies the older cookie. */
|
||
expect(setAuthTokens).not.toHaveBeenCalled();
|
||
expect(res.status).toHaveBeenCalledWith(401);
|
||
expect(res.clearCookie).toHaveBeenCalledWith('refreshToken');
|
||
});
|
||
|
||
it('refuses a stale refresh credential even on the CI shortcut', async () => {
|
||
process.env.NODE_ENV = 'CI';
|
||
getUserById.mockResolvedValue({
|
||
_id: 'local-user-id',
|
||
provider: 'local',
|
||
twoFactorEnabled: true,
|
||
twoFactorEnrolledAt: new Date(Date.now() + 60_000),
|
||
});
|
||
|
||
await refreshController(req, res);
|
||
|
||
process.env.NODE_ENV = 'test';
|
||
expect(setAuthTokens).not.toHaveBeenCalled();
|
||
expect(res.status).toHaveBeenCalledWith(401);
|
||
});
|
||
|
||
it('accepts a refresh credential minted after enrollment', async () => {
|
||
getUserById.mockResolvedValue({
|
||
_id: 'local-user-id',
|
||
email: 'local@example.com',
|
||
provider: 'local',
|
||
twoFactorEnabled: true,
|
||
twoFactorEnrolledAt: new Date(Date.now() - 60_000),
|
||
});
|
||
|
||
await refreshController(req, res);
|
||
|
||
expect(setAuthTokens).toHaveBeenCalled();
|
||
expect(res.send).toHaveBeenCalledWith(expect.objectContaining({ token: 'local-app-token' }));
|
||
});
|
||
|
||
it('leaves refresh alone for accounts that never enrolled through the policy', async () => {
|
||
getUserById.mockResolvedValue({
|
||
_id: 'local-user-id',
|
||
email: 'local@example.com',
|
||
provider: 'local',
|
||
twoFactorEnabled: true,
|
||
twoFactorEnrolledAt: null,
|
||
});
|
||
|
||
await refreshController(req, res);
|
||
|
||
expect(setAuthTokens).toHaveBeenCalled();
|
||
});
|
||
|
||
it('does not issue an enrollment token for a revoked local refresh session', async () => {
|
||
getUserById.mockResolvedValue({
|
||
_id: 'local-user-id',
|
||
provider: 'local',
|
||
twoFactorEnabled: false,
|
||
});
|
||
isTwoFactorEnrollmentRequired.mockReturnValue(true);
|
||
findSession.mockResolvedValue(null);
|
||
|
||
await refreshController(req, res);
|
||
|
||
expect(generateTwoFactorSetupToken).not.toHaveBeenCalled();
|
||
expect(setAuthTokens).not.toHaveBeenCalled();
|
||
expect(res.status).toHaveBeenCalledWith(401);
|
||
expect(res.send).toHaveBeenCalledWith('Refresh token expired or not found for this user');
|
||
});
|
||
|
||
it('does not issue an enrollment token for an expired local refresh session', async () => {
|
||
getUserById.mockResolvedValue({
|
||
_id: 'local-user-id',
|
||
provider: 'local',
|
||
twoFactorEnabled: false,
|
||
});
|
||
isTwoFactorEnrollmentRequired.mockReturnValue(true);
|
||
findSession.mockResolvedValue({ expiration: new Date(Date.now() - 60_000) });
|
||
|
||
await refreshController(req, res);
|
||
|
||
expect(generateTwoFactorSetupToken).not.toHaveBeenCalled();
|
||
expect(setAuthTokens).not.toHaveBeenCalled();
|
||
expect(res.status).toHaveBeenCalledWith(401);
|
||
expect(res.send).toHaveBeenCalledWith('Refresh token expired or not found for this user');
|
||
});
|
||
|
||
it('clears the readable session hint when no refresh token is sent', async () => {
|
||
req.headers.cookie = '';
|
||
|
||
await refreshController(req, res);
|
||
|
||
expect(res.send).toHaveBeenCalledWith('Refresh token not provided');
|
||
expect(res.clearCookie).toHaveBeenCalledWith('session_hint');
|
||
});
|
||
|
||
it('clears the readable session hint when the refresh session is gone', async () => {
|
||
getUserById.mockResolvedValue({ _id: 'local-user-id', provider: 'local' });
|
||
findSession.mockResolvedValue(null);
|
||
|
||
await refreshController(req, res);
|
||
|
||
expect(setAuthTokens).not.toHaveBeenCalled();
|
||
expect(res.clearCookie).toHaveBeenCalledWith('session_hint');
|
||
expect(res.status).toHaveBeenCalledWith(401);
|
||
});
|
||
|
||
it('clears the readable session hint when the refresh token is invalid', async () => {
|
||
req.headers.cookie = 'refreshToken=not-a-jwt';
|
||
|
||
await refreshController(req, res);
|
||
|
||
expect(res.clearCookie).toHaveBeenCalledWith('session_hint');
|
||
expect(res.status).toHaveBeenCalledWith(403);
|
||
});
|
||
|
||
it('keeps the readable session hint on a successful refresh', async () => {
|
||
getUserById.mockResolvedValue({ _id: 'local-user-id', provider: 'local' });
|
||
|
||
await refreshController(req, res);
|
||
|
||
expect(setAuthTokens).toHaveBeenCalled();
|
||
expect(res.clearCookie).not.toHaveBeenCalledWith('session_hint');
|
||
});
|
||
|
||
it('preserves CI refresh compatibility without session lookup', async () => {
|
||
process.env.NODE_ENV = 'CI';
|
||
getUserById.mockResolvedValue({
|
||
_id: 'local-user-id',
|
||
provider: 'local',
|
||
twoFactorEnabled: false,
|
||
});
|
||
isTwoFactorEnrollmentRequired.mockReturnValue(true);
|
||
|
||
await refreshController(req, res);
|
||
|
||
expect(findSession).not.toHaveBeenCalled();
|
||
expect(generateTwoFactorSetupToken).not.toHaveBeenCalled();
|
||
expect(setAuthTokens).toHaveBeenCalledWith('local-user-id', res, null, req);
|
||
});
|
||
|
||
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',
|
||
},
|
||
});
|
||
});
|
||
});
|
||
|
||
describe('registrationController - invite consumption', () => {
|
||
const invite = { token: 'hashed-invite', email: 'invitee@example.com' };
|
||
|
||
const buildRes = () => {
|
||
const res = {};
|
||
res.status = jest.fn(() => res);
|
||
res.send = jest.fn(() => res);
|
||
res.json = jest.fn(() => res);
|
||
return res;
|
||
};
|
||
|
||
beforeEach(() => {
|
||
jest.clearAllMocks();
|
||
});
|
||
|
||
it('consumes the invite once the account exists', async () => {
|
||
registerUser.mockResolvedValue({ status: 200, message: 'ok', userCreated: true });
|
||
|
||
await registrationController({ body: {}, invite }, buildRes());
|
||
|
||
expect(deleteTokens).toHaveBeenCalledWith({ token: 'hashed-invite' });
|
||
});
|
||
|
||
it('leaves the invite when registration is rejected', () => {
|
||
/** A mistyped password confirmation is the common case; it has to stay retryable. */
|
||
registerUser.mockResolvedValue({ status: 404, message: 'The passwords did not match' });
|
||
|
||
return registrationController({ body: {}, invite }, buildRes()).then(() => {
|
||
expect(deleteTokens).not.toHaveBeenCalled();
|
||
});
|
||
});
|
||
|
||
it('leaves the invite when the email is already in use, despite the 200', async () => {
|
||
/** `registerUser` returns the same status and message whether it created an account
|
||
* or found the email taken, so the status alone cannot drive this decision. */
|
||
registerUser.mockResolvedValue({ status: 200, message: 'ok' });
|
||
|
||
await registrationController({ body: {}, invite }, buildRes());
|
||
|
||
expect(deleteTokens).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('does not attempt a deletion for an uninvited registration', async () => {
|
||
registerUser.mockResolvedValue({ status: 200, message: 'ok', userCreated: true });
|
||
|
||
await registrationController({ body: {} }, buildRes());
|
||
|
||
expect(deleteTokens).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('still reports success when consuming the invite fails', async () => {
|
||
/** The account exists by this point; reporting failure would be worse than
|
||
* leaving a usable invite behind. */
|
||
registerUser.mockResolvedValue({ status: 200, message: 'ok', userCreated: true });
|
||
deleteTokens.mockRejectedValue(new Error('mongo unavailable'));
|
||
const res = buildRes();
|
||
|
||
await registrationController({ body: {}, invite }, res);
|
||
|
||
expect(res.status).toHaveBeenCalledWith(200);
|
||
expect(res.send).toHaveBeenCalledWith({ message: 'ok' });
|
||
expect(logger.error).toHaveBeenCalled();
|
||
});
|
||
|
||
it('answers an unexpected registration failure generically and logs safe metadata', async () => {
|
||
registerUser.mockRejectedValue(new Error('smtp rejected leak@example.com'));
|
||
const res = buildRes();
|
||
|
||
await registrationController({ body: {}, invite }, res);
|
||
|
||
expect(res.status).toHaveBeenCalledWith(500);
|
||
expect(res.json).toHaveBeenCalledWith({ message: 'Something went wrong' });
|
||
expect(logger.error).toHaveBeenCalledWith('[registrationController]', { type: 'Error' });
|
||
expect(JSON.stringify(logger.error.mock.calls)).not.toContain('leak@example.com');
|
||
});
|
||
|
||
it('answers a failed password reset request generically and logs safe metadata', async () => {
|
||
requestPasswordReset.mockRejectedValue(new Error('smtp rejected leak@example.com'));
|
||
const res = buildRes();
|
||
|
||
await resetPasswordRequestController({ body: { email: 'leak@example.com' } }, res);
|
||
|
||
expect(res.status).toHaveBeenCalledWith(400);
|
||
expect(res.json).toHaveBeenCalledWith({ message: 'Something went wrong' });
|
||
expect(logger.error).toHaveBeenCalledWith('[resetPasswordRequestController]', {
|
||
type: 'Error',
|
||
});
|
||
expect(JSON.stringify(logger.error.mock.calls)).not.toContain('leak@example.com');
|
||
});
|
||
|
||
it('never forwards the creation signal to the client', async () => {
|
||
registerUser.mockResolvedValue({ status: 200, message: 'ok', userCreated: true });
|
||
const res = buildRes();
|
||
|
||
await registrationController({ body: {}, invite }, res);
|
||
|
||
expect(res.send).toHaveBeenCalledWith({ message: 'ok' });
|
||
});
|
||
});
|
||
|
||
describe('resetPasswordController', () => {
|
||
let req, res;
|
||
const serviceResult = { ok: true, tokenConsumed: true, credentialsChangedAt: new Date() };
|
||
const successBody = { message: 'Password reset was successful' };
|
||
const incompleteBody = expect.objectContaining({ code: 'CREDENTIAL_RETIREMENT_INCOMPLETE' });
|
||
|
||
beforeEach(() => {
|
||
jest.clearAllMocks();
|
||
resetPassword.mockReset().mockResolvedValue(serviceResult);
|
||
updateUser.mockReset().mockResolvedValue({});
|
||
deleteAllUserSessions.mockReset().mockResolvedValue(undefined);
|
||
deletePasskeysByUser.mockReset().mockResolvedValue(undefined);
|
||
awaitAuthUserDocEviction.mockReset().mockResolvedValue(undefined);
|
||
req = {
|
||
body: {
|
||
userId: 'user-123',
|
||
token: 'reset-token',
|
||
password: 'new-password',
|
||
},
|
||
};
|
||
res = {
|
||
status: jest.fn().mockReturnThis(),
|
||
json: jest.fn(),
|
||
clearCookie: jest.fn(),
|
||
};
|
||
});
|
||
|
||
it('revokes sessions and passkeys on successful password reset', async () => {
|
||
resetPassword.mockResolvedValue(serviceResult);
|
||
deleteAllUserSessions.mockResolvedValue(undefined);
|
||
deletePasskeysByUser.mockResolvedValue(undefined);
|
||
|
||
await resetPasswordController(req, res);
|
||
|
||
expect(resetPassword).toHaveBeenCalledWith('user-123', 'reset-token', 'new-password');
|
||
expect(deleteAllUserSessions).toHaveBeenCalledWith({ userId: 'user-123' });
|
||
expect(deletePasskeysByUser).toHaveBeenCalledWith('user-123');
|
||
expect(res.clearCookie).toHaveBeenCalledWith('session_hint');
|
||
expect(updateUser).toHaveBeenCalledWith(
|
||
'user-123',
|
||
{ credentialsChangedAt: expect.any(Date) },
|
||
{ credentialsChangedAt: expect.any(Date) },
|
||
);
|
||
expect(res.status).toHaveBeenCalledWith(200);
|
||
expect(res.json).toHaveBeenCalledWith(successBody);
|
||
});
|
||
|
||
it('confirms the reset only after revoking and settling the auth cache', async () => {
|
||
let finishEviction;
|
||
awaitAuthUserDocEviction.mockReturnValue(
|
||
new Promise((resolve) => {
|
||
finishEviction = resolve;
|
||
}),
|
||
);
|
||
|
||
const reset = resetPasswordController(req, res);
|
||
await new Promise((resolve) => setImmediate(resolve));
|
||
const responseCalls = res.status.mock.calls.length;
|
||
finishEviction();
|
||
await reset;
|
||
|
||
expect(awaitAuthUserDocEviction).toHaveBeenCalledWith('user-123');
|
||
expect(deleteAllUserSessions.mock.invocationCallOrder[0]).toBeLessThan(
|
||
awaitAuthUserDocEviction.mock.invocationCallOrder[0],
|
||
);
|
||
expect(deletePasskeysByUser.mock.invocationCallOrder[0]).toBeLessThan(
|
||
awaitAuthUserDocEviction.mock.invocationCallOrder[0],
|
||
);
|
||
expect(responseCalls).toBe(0);
|
||
expect(res.status).toHaveBeenCalledWith(200);
|
||
});
|
||
|
||
it('does not revoke sessions or passkeys when reset fails', async () => {
|
||
resetPassword.mockResolvedValue({ ok: false });
|
||
|
||
await resetPasswordController(req, res);
|
||
|
||
expect(deleteAllUserSessions).not.toHaveBeenCalled();
|
||
expect(deletePasskeysByUser).not.toHaveBeenCalled();
|
||
expect(awaitAuthUserDocEviction).not.toHaveBeenCalled();
|
||
expect(updateUser).not.toHaveBeenCalled();
|
||
expect(res.status).toHaveBeenCalledWith(400);
|
||
expect(res.json).toHaveBeenCalledWith({ message: 'Invalid or expired password reset token' });
|
||
});
|
||
|
||
it.each([
|
||
['sessions', true, false],
|
||
['passkeys', false, true],
|
||
['sessions and passkeys', true, true],
|
||
])(
|
||
'reports an incomplete reset when revoking %s fails',
|
||
async (_, failSessions, failPasskeys) => {
|
||
const sessionError = new Error('Session store unavailable');
|
||
const passkeyError = new Error('Passkey store unavailable');
|
||
if (failSessions) {
|
||
deleteAllUserSessions.mockRejectedValue(sessionError);
|
||
}
|
||
if (failPasskeys) {
|
||
deletePasskeysByUser.mockRejectedValue(passkeyError);
|
||
}
|
||
|
||
await resetPasswordController(req, res);
|
||
|
||
expect(deleteAllUserSessions).toHaveBeenCalledWith({ userId: 'user-123' });
|
||
expect(deletePasskeysByUser).toHaveBeenCalledWith('user-123');
|
||
expect(awaitAuthUserDocEviction).toHaveBeenCalledWith('user-123');
|
||
expect(res.status).toHaveBeenCalledWith(500);
|
||
expect(res.json).toHaveBeenCalledWith(incompleteBody);
|
||
if (failSessions) {
|
||
expect(logger.error).toHaveBeenCalledWith(
|
||
'[resetPasswordController] Failed to revoke sessions for user user-123',
|
||
sessionError,
|
||
);
|
||
}
|
||
if (failPasskeys) {
|
||
expect(logger.error).toHaveBeenCalledWith(
|
||
'[resetPasswordController] Failed to revoke passkeys for user user-123',
|
||
passkeyError,
|
||
);
|
||
}
|
||
},
|
||
);
|
||
|
||
it('starts passkey revocation while session revocation is still pending', async () => {
|
||
let finishSessions;
|
||
deleteAllUserSessions.mockReturnValue(
|
||
new Promise((resolve) => {
|
||
finishSessions = resolve;
|
||
}),
|
||
);
|
||
|
||
const reset = resetPasswordController(req, res);
|
||
await new Promise((resolve) => setImmediate(resolve));
|
||
const passkeyCalls = deletePasskeysByUser.mock.calls.length;
|
||
const responseCalls = res.status.mock.calls.length;
|
||
finishSessions();
|
||
await reset;
|
||
|
||
expect(passkeyCalls).toBe(1);
|
||
expect(responseCalls).toBe(0);
|
||
expect(res.status).toHaveBeenCalledWith(200);
|
||
});
|
||
|
||
it.each(['sessions', 'passkeys'])('handles a synchronous %s revocation failure', async (kind) => {
|
||
const error = new Error('Revocation failed');
|
||
const revoke = kind === 'sessions' ? deleteAllUserSessions : deletePasskeysByUser;
|
||
revoke.mockImplementation(() => {
|
||
throw error;
|
||
});
|
||
|
||
await resetPasswordController(req, res);
|
||
|
||
expect(deleteAllUserSessions).toHaveBeenCalledWith({ userId: 'user-123' });
|
||
expect(deletePasskeysByUser).toHaveBeenCalledWith('user-123');
|
||
expect(res.status).toHaveBeenCalledWith(500);
|
||
expect(res.json).toHaveBeenCalledWith(incompleteBody);
|
||
expect(logger.error).toHaveBeenCalledWith(
|
||
`[resetPasswordController] Failed to revoke ${kind} for user user-123`,
|
||
error,
|
||
);
|
||
});
|
||
|
||
it.each([
|
||
[
|
||
'the link could not be deleted',
|
||
() => resetPassword.mockResolvedValue({ ...serviceResult, tokenConsumed: false }),
|
||
],
|
||
['the cutoff could not be re-dated', () => updateUser.mockRejectedValue(new Error('down'))],
|
||
[
|
||
'the auth cache did not settle',
|
||
() => awaitAuthUserDocEviction.mockRejectedValue(new Error('down')),
|
||
],
|
||
])('still retires credentials and reports an incomplete reset when %s', async (_, arrange) => {
|
||
arrange();
|
||
|
||
await resetPasswordController(req, res);
|
||
|
||
expect(deleteAllUserSessions).toHaveBeenCalledWith({ userId: 'user-123' });
|
||
expect(deletePasskeysByUser).toHaveBeenCalledWith('user-123');
|
||
expect(updateUser).toHaveBeenCalled();
|
||
expect(res.status).toHaveBeenCalledWith(500);
|
||
expect(res.json).toHaveBeenCalledWith(incompleteBody);
|
||
});
|
||
|
||
it('re-dates the cutoff the committed write returned without reading the user again', async () => {
|
||
await resetPasswordController(req, res);
|
||
|
||
expect(getUserById).not.toHaveBeenCalled();
|
||
expect(updateUser).toHaveBeenCalledWith(
|
||
'user-123',
|
||
{ credentialsChangedAt: expect.any(Date) },
|
||
{ credentialsChangedAt: serviceResult.credentialsChangedAt },
|
||
);
|
||
});
|
||
|
||
it('does not revoke sessions or passkeys when the reset service throws', async () => {
|
||
const resetError = new Error('Password update failed');
|
||
resetPassword.mockRejectedValue(resetError);
|
||
|
||
await resetPasswordController(req, res);
|
||
|
||
expect(deleteAllUserSessions).not.toHaveBeenCalled();
|
||
expect(deletePasskeysByUser).not.toHaveBeenCalled();
|
||
expect(res.status).toHaveBeenCalledWith(400);
|
||
expect(res.json).toHaveBeenCalledWith({ message: 'Password reset failed' });
|
||
expect(logger.error).toHaveBeenCalledWith('[resetPasswordController]', resetError);
|
||
});
|
||
});
|