mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-11-12 09:45:47 +00:00
Some checks failed
Backend Unit Tests / Build packages (push) Waiting to run
Backend Unit Tests / Codegraph select (push) Waiting to run
Backend Unit Tests / TypeScript type checks (push) Blocked by required conditions
Backend Unit Tests / Circular dependency checks (push) Waiting to run
Backend Unit Tests / Tests: api (shard 1/3) (push) Blocked by required conditions
Backend Unit Tests / Tests: api (shard 2/3) (push) Blocked by required conditions
Backend Unit Tests / Tests: api (shard 3/3) (push) Blocked by required conditions
Backend Unit Tests / Tests: data-provider (push) Blocked by required conditions
Backend Unit Tests / Tests: data-schemas (push) Blocked by required conditions
Backend Unit Tests / Tests: @librechat/api (shard 1/4) (push) Blocked by required conditions
Backend Unit Tests / Tests: @librechat/api (shard 2/4) (push) Blocked by required conditions
Backend Unit Tests / Tests: @librechat/api (shard 3/4) (push) Blocked by required conditions
Backend Unit Tests / Tests: @librechat/api (shard 4/4) (push) Blocked by required conditions
Codegraph E2E Vote Gate / Gate (push) Waiting to run
Codegraph E2E Votes / vote (shard 1/10) (push) Waiting to run
Codegraph E2E Votes / vote (shard 10/10) (push) Waiting to run
Codegraph E2E Votes / vote (shard 2/10) (push) Waiting to run
Codegraph E2E Votes / vote (shard 3/10) (push) Waiting to run
Codegraph E2E Votes / vote (shard 4/10) (push) Waiting to run
Codegraph E2E Votes / vote (shard 5/10) (push) Waiting to run
Codegraph E2E Votes / vote (shard 6/10) (push) Waiting to run
Codegraph E2E Votes / vote (shard 7/10) (push) Waiting to run
Codegraph E2E Votes / vote (shard 8/10) (push) Waiting to run
Codegraph E2E Votes / vote (shard 9/10) (push) Waiting to run
Docker Dev Branch Images Build / publish (push) Waiting to run
Frontend Unit Tests / Client build recovery regression (push) Waiting to run
Frontend Unit Tests / Codegraph select (push) Waiting to run
Frontend Unit Tests / Build packages (push) Waiting to run
Frontend Unit Tests / TypeScript type checks (client) (push) Blocked by required conditions
Frontend Unit Tests / Tests: @librechat/chat (push) Blocked by required conditions
Frontend Unit Tests / Tests: @librechat/client (push) Blocked by required conditions
Frontend Unit Tests / Tests: Ubuntu (shard 1/2) (push) Blocked by required conditions
Frontend Unit Tests / Tests: Ubuntu (shard 2/2) (push) Blocked by required conditions
Frontend Unit Tests / Vite build verification (push) Blocked by required conditions
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Has been cancelled
* fix: Retire Credentials by Their Committing Write and on Opt-In 2FA Re-date password reset and enrollment cutoffs once the write that set them commits, so a credential minted while the write is in flight is retired. Opt-in 2FA confirmation now stamps twoFactorEnrolledAt and rotates the session like required enrollment. OpenID refresh ends a session established before a reset or enrollment instead of spending its IdP grant. * fix: Bind Opt-In 2FA Rotation to the Caller's Refresh Session Confirmation now requires the refresh session the request was issued with, rotates it in place and revokes only the others, so an access token on its own cannot trade enrollment for a long-lived session. The OpenID retirement check moves into the refresh recovery service and ends the session through the existing session-missing path, leaving the CJS controller unchanged. * fix: Clear a Retired OpenID Session Against the User It Was Checked For * refactor: Inject the Refresh Secret Into OpenID Recovery * fix: Report a Reset Whose Cutoff Could Not Be Re-Dated A failed re-stamp now propagates as RETIREMENT_RESTAMP_FAILED. The reset controller still revokes sessions and passkeys and answers CREDENTIAL_RETIREMENT_INCOMPLETE; the CLI re-stamps after its revocations and exits non-zero; enrollment revokes sessions before the re-stamp and mints nothing when it fails. * fix: Re-Date the Reset Cutoff Only After the Link Is Consumed The web reset re-stamps credentialsChangedAt in the controller once resetPassword has committed, consumed the token and sent the notice, so a failed re-stamp can no longer leave the link replayable. Revocations still run and the response reports CREDENTIAL_RETIREMENT_INCOMPLETE. * fix: Retire Credentials on Every Exit Path After a Reset Commits resetPasswordWithToken owns the post-commit steps: deleting the link and sending the notice no longer throw, and a link issued before the account's last credentialsChangedAt is refused, so it is spent even when its deletion fails. The reset write is conditioned on the cutoff it read, so one link commits once. The controller re-dates the cutoff and revokes sessions and passkeys whenever the password changed, and answers CREDENTIAL_RETIREMENT_INCOMPLETE if any of that fails. * fix: Spend Undated Reset Links and Move the Reset Notice Into TypeScript * fix: Tell the User When a Reset Could Not Sign Out Every Session The reset page now recognizes CREDENTIAL_RETIREMENT_INCOMPLETE, shared from librechat-data-provider, and shows a localized warning that the new password is active with a sign-in action, instead of reporting the spent link as invalid. * perf: Re-Date the Reset Cutoff From the Committed Write The committed reset hands its credentialsChangedAt back through PasswordResetResult, so the controller re-stamps without a second user read. * fix: Retire Every Superseded Credential Before Minting on 2FA Enrollment Opt-in confirmation and setup finalization now revoke sessions, re-date twoFactorEnrolledAt and wait for the auth cache to drop the pre-enrollment document, running every step even when one fails. If any step fails they mint nothing and answer CREDENTIAL_RETIREMENT_INCOMPLETE; the settings dialog keeps 2FA on with a warning and the setup screen sends the user back to sign in with the new factor. * test: Cover the Settings Warning When 2FA Enrollment Leaves Sessions Behind * fix: Send the User Back to Sign In After an Incomplete Opt-In Enrollment Enrollment retires the confirming tab's bearer, and an incomplete retirement mints no replacement, so the settings dialog now shows the backup codes and then signs out to the login page, whether the user completes setup or closes the dialog. * test: Run the Retirement Warning Scenarios Against Their Own Signed-Out or Fresh Accounts * test: Open the Sidebar Drawer Before the Account Menu on Phones * fix: Report a Committed Opt-In Enrollment When Rotating the Session Fails Once 2FA is promoted, a later failure in confirm2FA withdraws whatever session it may have minted and answers CREDENTIAL_RETIREMENT_INCOMPLETE, so the settings dialog hands over the backup codes and signs the user in again instead of reporting an invalid code. * test: Prove an Incomplete Reset Does Not Resume the Retired Session The Sign in action sends a browser with a live session through /login for about 150 ms before it lands in chat, so the signed-in version of the warning scenario only passed when the URL check caught that window. The warning scenario runs signed out, and a new scenario holds a pre-reset session and asserts its refresh is refused and the user stays on the login page. * fix: Settle the Auth Cache After the CLI Reset's Final Cutoff Write * test: Require the Login Form After Signing In From a Signed-Out Reset * fix: Explain an Incomplete Session Retirement Before Restarting Sign-In After Required 2FA Setup * perf: Start the Opt-In Enrollment User and Session Reads Together
743 lines
27 KiB
JavaScript
743 lines
27 KiB
JavaScript
const jwt = require('jsonwebtoken');
|
|
|
|
const mockSetAuthTokens = jest.fn(() => Promise.resolve('auth-token'));
|
|
const mockDeleteAllUserSessions = jest.fn(() => Promise.resolve({ deletedCount: 0 }));
|
|
const mockAwaitAuthUserDocEviction = jest.fn(() => Promise.resolve());
|
|
const mockGetTOTPSecret = jest.fn();
|
|
const mockVerifyTOTP = jest.fn();
|
|
const mockVerifyBackupCode = jest.fn();
|
|
|
|
jest.mock('@librechat/data-schemas', () => ({
|
|
logger: { error: jest.fn(), warn: jest.fn() },
|
|
}));
|
|
|
|
jest.mock('~/server/services/twoFactorService', () => ({
|
|
getTOTPSecret: (...args) => mockGetTOTPSecret(...args),
|
|
verifyTOTP: (...args) => mockVerifyTOTP(...args),
|
|
verifyBackupCode: (...args) => mockVerifyBackupCode(...args),
|
|
generateBackupCodes: (...args) => store.generateBackupCodes(...args),
|
|
}));
|
|
|
|
jest.mock('~/server/services/AuthService', () => ({
|
|
setAuthTokens: (...args) => mockSetAuthTokens(...args),
|
|
}));
|
|
|
|
jest.mock('~/models', () => ({
|
|
getUserById: (...args) => store.getUserById(...args),
|
|
updateTwoFactorEnrollment: (...args) => store.updateTwoFactorEnrollment(...args),
|
|
updateUser: (...args) => store.updateUser(...args),
|
|
deleteAllUserSessions: (...args) => mockDeleteAllUserSessions(...args),
|
|
awaitAuthUserDocEviction: (...args) => mockAwaitAuthUserDocEviction(...args),
|
|
}));
|
|
|
|
const ELIGIBLE_PROVIDERS = new Set(['local', 'ldap']);
|
|
|
|
/**
|
|
* Minimal in-memory user store with the same compare-and-swap semantics as the Mongo-backed
|
|
* `updateTwoFactorEnrollment`, so the controller chain is exercised against real lifecycle state.
|
|
*/
|
|
const store = {
|
|
doc: null,
|
|
codeCounter: 0,
|
|
reset(overrides = {}) {
|
|
this.codeCounter = 0;
|
|
this.doc = {
|
|
_id: 'user-1',
|
|
email: 'user@example.com',
|
|
password: 'password-hash',
|
|
provider: 'local',
|
|
/**
|
|
* These queries select with `+field` tokens only, which leaves Mongoose no projection to
|
|
* send, so the stored document arrives whole. The session records ride along with it.
|
|
*/
|
|
refreshToken: [{ refreshToken: 'live-session-token' }],
|
|
twoFactorEnabled: false,
|
|
totpSecret: null,
|
|
backupCodes: [],
|
|
pendingTotpSecret: 'encrypted-secret',
|
|
pendingBackupCodes: [{ codeHash: 'staged-hash', used: false }],
|
|
twoFactorAcknowledgementNonceHash: null,
|
|
twoFactorFinalizationNonceHash: null,
|
|
...overrides,
|
|
};
|
|
},
|
|
async getUserById() {
|
|
return this.doc ? { ...this.doc } : null;
|
|
},
|
|
async generateBackupCodes() {
|
|
this.codeCounter += 1;
|
|
return {
|
|
plainCodes: [`plain-${this.codeCounter}`],
|
|
codeObjects: [{ codeHash: `hash-${this.codeCounter}`, used: false, usedAt: null }],
|
|
};
|
|
},
|
|
async updateTwoFactorEnrollment(_userId, guard, update) {
|
|
if (this.doc.twoFactorEnabled) {
|
|
return null;
|
|
}
|
|
if (this.doc.provider != null && !ELIGIBLE_PROVIDERS.has(this.doc.provider)) {
|
|
return null;
|
|
}
|
|
const matches = Object.entries(guard).every(
|
|
([key, value]) => JSON.stringify(this.doc[key] ?? null) === JSON.stringify(value ?? null),
|
|
);
|
|
if (!matches) {
|
|
return null;
|
|
}
|
|
this.doc = { ...this.doc, ...update };
|
|
return { ...this.doc };
|
|
},
|
|
async updateUser(_userId, update, expectedState = {}) {
|
|
const matches = Object.entries(expectedState).every(
|
|
([key, value]) => JSON.stringify(this.doc[key] ?? null) === JSON.stringify(value ?? null),
|
|
);
|
|
if (!this.doc || !matches) {
|
|
return null;
|
|
}
|
|
this.doc = { ...this.doc, ...update };
|
|
return { ...this.doc };
|
|
},
|
|
};
|
|
|
|
const {
|
|
verify2FAWithTempToken,
|
|
confirm2FASetupWithTempToken,
|
|
acknowledge2FASetup,
|
|
finalize2FASetup,
|
|
} = require('./TwoFactorAuthController');
|
|
const {
|
|
generateTwoFactorLoginChallengeToken,
|
|
verifyTwoFactorSetupAcknowledgementToken,
|
|
verifyTwoFactorSetupFinalizationToken,
|
|
} = require('@librechat/api');
|
|
|
|
function createResponse() {
|
|
return {
|
|
status: jest.fn().mockReturnThis(),
|
|
json: jest.fn().mockReturnThis(),
|
|
clearCookie: jest.fn().mockReturnThis(),
|
|
};
|
|
}
|
|
|
|
/** Drives the request pipeline the routes wire up: credential validation, then the controller. */
|
|
async function runAcknowledge(acknowledgementToken) {
|
|
const credential = verifyTwoFactorSetupAcknowledgementToken(
|
|
acknowledgementToken,
|
|
process.env.JWT_SECRET,
|
|
);
|
|
const req = {
|
|
user: credential ? { id: credential.userId } : undefined,
|
|
twoFactorEnrollmentNonce: credential?.nonce,
|
|
body: { acknowledgementToken },
|
|
};
|
|
const res = createResponse();
|
|
await acknowledge2FASetup(req, res);
|
|
return res;
|
|
}
|
|
|
|
async function runFinalize(finalizationToken) {
|
|
const credential = verifyTwoFactorSetupFinalizationToken(
|
|
finalizationToken,
|
|
process.env.JWT_SECRET,
|
|
);
|
|
const req = {
|
|
user: credential ? { id: credential.userId } : undefined,
|
|
twoFactorEnrollmentNonce: credential?.nonce,
|
|
body: { finalizationToken },
|
|
};
|
|
const res = createResponse();
|
|
await finalize2FASetup(req, res);
|
|
return res;
|
|
}
|
|
|
|
async function runConfirm(token = '123456') {
|
|
const req = { user: { id: 'user-1' }, body: { token } };
|
|
const res = createResponse();
|
|
await confirm2FASetupWithTempToken(req, res);
|
|
return res;
|
|
}
|
|
|
|
const jsonPayload = (res) => res.json.mock.calls[0][0];
|
|
|
|
const originalSecret = process.env.JWT_SECRET;
|
|
const originalPolicy = process.env.ENFORCE_TWO_FACTOR_AUTHENTICATION;
|
|
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
process.env.JWT_SECRET = 'jwt-secret';
|
|
process.env.ENFORCE_TWO_FACTOR_AUTHENTICATION = 'true';
|
|
mockGetTOTPSecret.mockResolvedValue('plain-secret');
|
|
mockVerifyTOTP.mockResolvedValue(true);
|
|
store.reset();
|
|
});
|
|
|
|
afterAll(() => {
|
|
if (originalSecret === undefined) {
|
|
delete process.env.JWT_SECRET;
|
|
} else {
|
|
process.env.JWT_SECRET = originalSecret;
|
|
}
|
|
if (originalPolicy === undefined) {
|
|
delete process.env.ENFORCE_TWO_FACTOR_AUTHENTICATION;
|
|
} else {
|
|
process.env.ENFORCE_TWO_FACTOR_AUTHENTICATION = originalPolicy;
|
|
}
|
|
});
|
|
|
|
/** The recheck after minting is a read like any other, and it can fail after the cookies are set. */
|
|
const failRecheckAfterMint = () =>
|
|
mockSetAuthTokens.mockImplementationOnce(async () => {
|
|
jest.spyOn(store, 'getUserById').mockRejectedValueOnce(new Error('database unavailable'));
|
|
return 'auth-token';
|
|
});
|
|
|
|
describe('verify2FAWithTempToken', () => {
|
|
beforeEach(() => {
|
|
store.reset({ twoFactorEnabled: true, totpSecret: 'encrypted-secret' });
|
|
});
|
|
|
|
it('accepts a normal login challenge token and creates the app session', async () => {
|
|
const tempToken = jwt.sign(
|
|
{ userId: 'user-1', purpose: 'login_2fa_challenge' },
|
|
process.env.JWT_SECRET,
|
|
);
|
|
const req = { body: { tempToken, token: '123456' } };
|
|
const res = createResponse();
|
|
|
|
await verify2FAWithTempToken(req, res);
|
|
|
|
expect(mockSetAuthTokens).toHaveBeenCalledWith('user-1', res, null, req);
|
|
expect(res.status).toHaveBeenCalledWith(200);
|
|
});
|
|
|
|
it('refuses a federated password challenge under enforcement', async () => {
|
|
store.reset({ provider: 'openid', twoFactorEnabled: true, totpSecret: 'encrypted-secret' });
|
|
const tempToken = jwt.sign(
|
|
{ userId: 'user-1', purpose: 'login_2fa_challenge' },
|
|
process.env.JWT_SECRET,
|
|
);
|
|
const res = createResponse();
|
|
|
|
await verify2FAWithTempToken({ body: { tempToken, token: '123456' } }, res);
|
|
|
|
expect(res.status).toHaveBeenCalledWith(403);
|
|
expect(jsonPayload(res)).toEqual({
|
|
code: 'TWO_FACTOR_FEDERATED_LOGIN_BLOCKED',
|
|
message: 'Sign in with your identity provider to continue.',
|
|
});
|
|
expect(mockSetAuthTokens).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it.each([
|
|
'required_2fa_setup',
|
|
'required_2fa_acknowledgement',
|
|
'required_2fa_finalization',
|
|
undefined,
|
|
])('rejects a %s token before creating a session', async (purpose) => {
|
|
const claims = purpose
|
|
? { userId: 'user-1', purpose, nonce: 'nonce' }
|
|
: { userId: 'user-1', nonce: 'nonce' };
|
|
const tempToken = jwt.sign(claims, process.env.JWT_SECRET);
|
|
const res = createResponse();
|
|
|
|
await verify2FAWithTempToken({ body: { tempToken, token: '123456' } }, res);
|
|
|
|
expect(res.status).toHaveBeenCalledWith(401);
|
|
expect(mockSetAuthTokens).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('strips every secret and enrollment nonce from the session response', async () => {
|
|
const tempToken = jwt.sign(
|
|
{ userId: 'user-1', purpose: 'login_2fa_challenge' },
|
|
process.env.JWT_SECRET,
|
|
);
|
|
const res = createResponse();
|
|
|
|
await verify2FAWithTempToken({ body: { tempToken, token: '123456' } }, res);
|
|
|
|
expect(jsonPayload(res).user).toEqual({
|
|
_id: 'user-1',
|
|
id: 'user-1',
|
|
email: 'user@example.com',
|
|
provider: 'local',
|
|
twoFactorEnabled: true,
|
|
});
|
|
});
|
|
|
|
/**
|
|
* The challenge buys a session without presenting the password again, so recovery has to reach
|
|
* it: redeeming one minted beforehand would hand back exactly the access `deleteAllUserSessions`
|
|
* had just revoked.
|
|
*/
|
|
it('refuses a challenge minted before a password reset', async () => {
|
|
const tempToken = generateTwoFactorLoginChallengeToken('user-1', process.env.JWT_SECRET);
|
|
store.doc.credentialsChangedAt = new Date(Date.now() + 1000);
|
|
const res = createResponse();
|
|
|
|
await verify2FAWithTempToken({ body: { tempToken, token: '123456' } }, res);
|
|
|
|
expect(res.status).toHaveBeenCalledWith(401);
|
|
expect(mockSetAuthTokens).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('keeps a challenge minted after a password reset', async () => {
|
|
const tempToken = generateTwoFactorLoginChallengeToken('user-1', process.env.JWT_SECRET);
|
|
store.doc.credentialsChangedAt = new Date(Date.now() - 60_000);
|
|
const res = createResponse();
|
|
|
|
await verify2FAWithTempToken({ body: { tempToken, token: '123456' } }, res);
|
|
|
|
expect(res.status).toHaveBeenCalledWith(200);
|
|
expect(mockSetAuthTokens).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
/** `iat` cannot separate these two, so the millisecond claim is what decides both cases. */
|
|
it('orders the challenge against a reset landing in the same second', async () => {
|
|
const tempToken = generateTwoFactorLoginChallengeToken('user-1', process.env.JWT_SECRET);
|
|
const { issuedAtMs } = jwt.decode(tempToken);
|
|
|
|
store.doc.credentialsChangedAt = new Date(issuedAtMs - 1);
|
|
const accepted = createResponse();
|
|
await verify2FAWithTempToken({ body: { tempToken, token: '123456' } }, accepted);
|
|
expect(accepted.status).toHaveBeenCalledWith(200);
|
|
|
|
store.doc.credentialsChangedAt = new Date(issuedAtMs);
|
|
const refused = createResponse();
|
|
await verify2FAWithTempToken({ body: { tempToken, token: '123456' } }, refused);
|
|
expect(refused.status).toHaveBeenCalledWith(401);
|
|
});
|
|
|
|
it('surrenders the resetting second for a challenge minted before the claim existed', async () => {
|
|
const issuedAt = Math.floor(Date.now() / 1000);
|
|
const tempToken = jwt.sign(
|
|
{ userId: 'user-1', purpose: 'login_2fa_challenge', iat: issuedAt },
|
|
process.env.JWT_SECRET,
|
|
);
|
|
store.doc.credentialsChangedAt = new Date(issuedAt * 1000 + 500);
|
|
const res = createResponse();
|
|
|
|
await verify2FAWithTempToken({ body: { tempToken, token: '123456' } }, res);
|
|
|
|
expect(res.status).toHaveBeenCalledWith(401);
|
|
expect(mockSetAuthTokens).not.toHaveBeenCalled();
|
|
});
|
|
|
|
/**
|
|
* Verifying the factor is a decrypt and a comparison, so recovery can land inside it. The session
|
|
* minted afterwards postdates `credentialsChangedAt`, which is the one thing the cutoff cannot retire,
|
|
* and it is created after recovery has already swept the sessions away.
|
|
*/
|
|
describe('a password reset landing mid-verification', () => {
|
|
const resetDuring = (mock) =>
|
|
mock.mockImplementationOnce(async () => {
|
|
store.doc.credentialsChangedAt = new Date(Date.now() + 1000);
|
|
return true;
|
|
});
|
|
|
|
it('withdraws the session it had already minted for a TOTP code', async () => {
|
|
const tempToken = generateTwoFactorLoginChallengeToken('user-1', process.env.JWT_SECRET);
|
|
resetDuring(mockVerifyTOTP);
|
|
const res = createResponse();
|
|
|
|
await verify2FAWithTempToken({ body: { tempToken, token: '123456' } }, res);
|
|
|
|
expect(mockSetAuthTokens).toHaveBeenCalledTimes(1);
|
|
expect(mockDeleteAllUserSessions).toHaveBeenCalledWith({ userId: 'user-1' });
|
|
expect(res.clearCookie).toHaveBeenCalledWith('refreshToken');
|
|
expect(res.clearCookie).toHaveBeenCalledWith('token_provider');
|
|
expect(res.status).toHaveBeenCalledWith(401);
|
|
expect(jsonPayload(res)).toEqual({ message: 'Invalid or expired temporary token' });
|
|
});
|
|
|
|
/** Backup codes are the slower comparison of the two, so the window is wider here. */
|
|
it('withdraws the session it had already minted for a backup code', async () => {
|
|
const tempToken = generateTwoFactorLoginChallengeToken('user-1', process.env.JWT_SECRET);
|
|
resetDuring(mockVerifyBackupCode);
|
|
const res = createResponse();
|
|
|
|
await verify2FAWithTempToken({ body: { tempToken, backupCode: 'backup-1' } }, res);
|
|
|
|
expect(mockSetAuthTokens).toHaveBeenCalledTimes(1);
|
|
expect(mockDeleteAllUserSessions).toHaveBeenCalledWith({ userId: 'user-1' });
|
|
expect(res.status).toHaveBeenCalledWith(401);
|
|
});
|
|
|
|
it('withdraws the minted session when the recheck cannot complete', async () => {
|
|
const tempToken = generateTwoFactorLoginChallengeToken('user-1', process.env.JWT_SECRET);
|
|
mockVerifyTOTP.mockResolvedValueOnce(true);
|
|
failRecheckAfterMint();
|
|
const res = createResponse();
|
|
|
|
await verify2FAWithTempToken({ body: { tempToken, token: '123456' } }, res);
|
|
|
|
expect(mockDeleteAllUserSessions).toHaveBeenCalledWith({ userId: 'user-1' });
|
|
expect(mockDeleteAllUserSessions.mock.invocationCallOrder[0]).toBeGreaterThan(
|
|
mockSetAuthTokens.mock.invocationCallOrder[0],
|
|
);
|
|
expect(res.clearCookie).toHaveBeenCalledWith('refreshToken');
|
|
expect(res.status).toHaveBeenCalledWith(500);
|
|
});
|
|
|
|
it('leaves a verification no reset raced through alone', async () => {
|
|
const tempToken = generateTwoFactorLoginChallengeToken('user-1', process.env.JWT_SECRET);
|
|
const res = createResponse();
|
|
|
|
await verify2FAWithTempToken({ body: { tempToken, token: '123456' } }, res);
|
|
|
|
expect(res.status).toHaveBeenCalledWith(200);
|
|
expect(mockDeleteAllUserSessions).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('confirm2FASetupWithTempToken', () => {
|
|
it('returns deliverable codes and an acknowledgement credential without a session', async () => {
|
|
const res = await runConfirm();
|
|
|
|
expect(res.status).toHaveBeenCalledWith(200);
|
|
expect(jsonPayload(res).backupCodes).toEqual(['plain-1']);
|
|
expect(mockSetAuthTokens).not.toHaveBeenCalled();
|
|
expect(store.doc.twoFactorEnabled).toBe(false);
|
|
expect(store.doc.totpSecret).toBeNull();
|
|
expect(store.doc.pendingBackupCodes).toEqual([
|
|
{ codeHash: 'hash-1', used: false, usedAt: null },
|
|
]);
|
|
expect(
|
|
verifyTwoFactorSetupAcknowledgementToken(
|
|
jsonPayload(res).acknowledgementToken,
|
|
process.env.JWT_SECRET,
|
|
),
|
|
).toEqual({ userId: 'user-1', nonce: expect.any(String) });
|
|
});
|
|
|
|
it('is retryable after a lost response, rotating codes and retiring the old credential', async () => {
|
|
const first = await runConfirm();
|
|
const second = await runConfirm();
|
|
|
|
expect(jsonPayload(second).backupCodes).toEqual(['plain-2']);
|
|
expect(jsonPayload(second).acknowledgementToken).not.toBe(
|
|
jsonPayload(first).acknowledgementToken,
|
|
);
|
|
|
|
const staleAcknowledgement = await runAcknowledge(jsonPayload(first).acknowledgementToken);
|
|
|
|
expect(staleAcknowledgement.status).toHaveBeenCalledWith(400);
|
|
expect(store.doc.twoFactorEnabled).toBe(false);
|
|
});
|
|
|
|
it('does not issue auth tokens when the TOTP is wrong', async () => {
|
|
mockVerifyTOTP.mockResolvedValue(false);
|
|
|
|
const res = await runConfirm('000000');
|
|
|
|
expect(res.status).toHaveBeenCalledWith(400);
|
|
expect(mockSetAuthTokens).not.toHaveBeenCalled();
|
|
expect(store.doc.twoFactorAcknowledgementNonceHash).toBeNull();
|
|
});
|
|
|
|
it('rejects requests without a validated setup user', async () => {
|
|
const res = createResponse();
|
|
|
|
await confirm2FASetupWithTempToken({ body: { token: '123456' } }, res);
|
|
|
|
expect(res.status).toHaveBeenCalledWith(401);
|
|
expect(mockSetAuthTokens).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('acknowledge2FASetup', () => {
|
|
it('exchanges a one-time acknowledgement nonce for a finalization credential', async () => {
|
|
const confirmed = await runConfirm();
|
|
|
|
const res = await runAcknowledge(jsonPayload(confirmed).acknowledgementToken);
|
|
|
|
expect(res.status).toHaveBeenCalledWith(200);
|
|
expect(mockSetAuthTokens).not.toHaveBeenCalled();
|
|
expect(store.doc.twoFactorEnabled).toBe(false);
|
|
expect(store.doc.twoFactorAcknowledgementNonceHash).toBeNull();
|
|
expect(
|
|
verifyTwoFactorSetupFinalizationToken(
|
|
jsonPayload(res).finalizationToken,
|
|
process.env.JWT_SECRET,
|
|
),
|
|
).toEqual({ userId: 'user-1', nonce: expect.any(String) });
|
|
});
|
|
|
|
it('rejects a replayed acknowledgement credential without minting anything', async () => {
|
|
const confirmed = await runConfirm();
|
|
await runAcknowledge(jsonPayload(confirmed).acknowledgementToken);
|
|
|
|
const replay = await runAcknowledge(jsonPayload(confirmed).acknowledgementToken);
|
|
|
|
expect(replay.status).toHaveBeenCalledWith(400);
|
|
expect(jsonPayload(replay)).not.toHaveProperty('finalizationToken');
|
|
expect(mockSetAuthTokens).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('rejects acknowledgement without a validated identity', async () => {
|
|
const res = createResponse();
|
|
|
|
await acknowledge2FASetup({ body: {} }, res);
|
|
|
|
expect(res.status).toHaveBeenCalledWith(401);
|
|
expect(mockSetAuthTokens).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('finalize2FASetup', () => {
|
|
async function reachFinalization() {
|
|
const confirmed = await runConfirm();
|
|
const acknowledged = await runAcknowledge(jsonPayload(confirmed).acknowledgementToken);
|
|
return jsonPayload(acknowledged).finalizationToken;
|
|
}
|
|
|
|
it.each(['local', 'ldap', undefined])(
|
|
'promotes an eligible %s enrollment and only then creates the session',
|
|
async (provider) => {
|
|
store.reset({ provider });
|
|
const finalizationToken = await reachFinalization();
|
|
|
|
expect(mockSetAuthTokens).not.toHaveBeenCalled();
|
|
|
|
const res = await runFinalize(finalizationToken);
|
|
|
|
expect(mockSetAuthTokens).toHaveBeenCalledWith('user-1', res, null, expect.any(Object));
|
|
expect(store.doc).toMatchObject({
|
|
twoFactorEnabled: true,
|
|
totpSecret: 'encrypted-secret',
|
|
pendingTotpSecret: null,
|
|
pendingBackupCodes: [],
|
|
twoFactorAcknowledgementNonceHash: null,
|
|
twoFactorFinalizationNonceHash: null,
|
|
/** Dates the tokens issued before enrollment, so it is stamped but never published. */
|
|
twoFactorEnrolledAt: expect.any(Date),
|
|
});
|
|
expect(jsonPayload(res)).toEqual({
|
|
token: 'auth-token',
|
|
user: {
|
|
_id: 'user-1',
|
|
id: 'user-1',
|
|
email: 'user@example.com',
|
|
provider,
|
|
twoFactorEnabled: true,
|
|
},
|
|
});
|
|
},
|
|
);
|
|
|
|
it('revokes pre-enrollment sessions before minting the enrolled one', async () => {
|
|
const finalizationToken = await reachFinalization();
|
|
|
|
await runFinalize(finalizationToken);
|
|
|
|
expect(mockDeleteAllUserSessions).toHaveBeenCalledWith({ userId: 'user-1' });
|
|
/** Revoking after minting would drop the session this response hands back. */
|
|
expect(mockDeleteAllUserSessions.mock.invocationCallOrder[0]).toBeLessThan(
|
|
mockSetAuthTokens.mock.invocationCallOrder[0],
|
|
);
|
|
});
|
|
|
|
it('re-dates the enrollment cutoff once the promoting write has committed', async () => {
|
|
const finalizationToken = await reachFinalization();
|
|
const updateUser = jest.spyOn(store, 'updateUser');
|
|
let promotedAt;
|
|
const promote = store.updateTwoFactorEnrollment.bind(store);
|
|
jest.spyOn(store, 'updateTwoFactorEnrollment').mockImplementation(async (...args) => {
|
|
const promoted = await promote(...args);
|
|
promotedAt = promoted?.twoFactorEnrolledAt;
|
|
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
return promoted;
|
|
});
|
|
|
|
await runFinalize(finalizationToken);
|
|
|
|
expect(updateUser).toHaveBeenCalledWith(
|
|
'user-1',
|
|
{ twoFactorEnrolledAt: expect.any(Date) },
|
|
{ twoFactorEnrolledAt: promotedAt },
|
|
);
|
|
expect(store.doc.twoFactorEnrolledAt.getTime()).toBeGreaterThan(promotedAt.getTime());
|
|
expect(updateUser.mock.invocationCallOrder[0]).toBeLessThan(
|
|
mockSetAuthTokens.mock.invocationCallOrder[0],
|
|
);
|
|
jest.restoreAllMocks();
|
|
});
|
|
|
|
it('mints nothing and reports incomplete retirement when revoking sessions fails', async () => {
|
|
const finalizationToken = await reachFinalization();
|
|
mockDeleteAllUserSessions.mockRejectedValueOnce(new Error('down'));
|
|
|
|
const res = await runFinalize(finalizationToken);
|
|
|
|
expect(mockAwaitAuthUserDocEviction).toHaveBeenCalledWith('user-1');
|
|
expect(mockSetAuthTokens).not.toHaveBeenCalled();
|
|
expect(res.status).toHaveBeenCalledWith(500);
|
|
expect(jsonPayload(res)).toMatchObject({ code: 'CREDENTIAL_RETIREMENT_INCOMPLETE' });
|
|
expect(store.doc.twoFactorEnabled).toBe(true);
|
|
});
|
|
|
|
it('withdraws the enrolled session when the recheck cannot complete', async () => {
|
|
const finalizationToken = await reachFinalization();
|
|
failRecheckAfterMint();
|
|
|
|
const res = await runFinalize(finalizationToken);
|
|
|
|
expect(mockDeleteAllUserSessions).toHaveBeenCalledTimes(2);
|
|
expect(mockDeleteAllUserSessions.mock.invocationCallOrder[1]).toBeGreaterThan(
|
|
mockSetAuthTokens.mock.invocationCallOrder[0],
|
|
);
|
|
expect(res.clearCookie).toHaveBeenCalledWith('refreshToken');
|
|
expect(res.status).toHaveBeenCalledWith(500);
|
|
});
|
|
|
|
it('leaves existing sessions alone when finalization is rejected', async () => {
|
|
const finalizationToken = await reachFinalization();
|
|
store.doc.pendingTotpSecret = null;
|
|
|
|
const res = await runFinalize(finalizationToken);
|
|
|
|
expect(res.status).toHaveBeenCalledWith(400);
|
|
expect(mockDeleteAllUserSessions).not.toHaveBeenCalled();
|
|
expect(mockSetAuthTokens).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('rejects a replayed finalization credential without a second session', async () => {
|
|
const finalizationToken = await reachFinalization();
|
|
await runFinalize(finalizationToken);
|
|
mockSetAuthTokens.mockClear();
|
|
|
|
const replay = await runFinalize(finalizationToken);
|
|
|
|
expect(replay.status).toHaveBeenCalledWith(400);
|
|
expect(jsonPayload(replay)).not.toHaveProperty('token');
|
|
expect(mockSetAuthTokens).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('rejects a finalization credential retired by a later confirmation', async () => {
|
|
const finalizationToken = await reachFinalization();
|
|
await runConfirm();
|
|
|
|
const res = await runFinalize(finalizationToken);
|
|
|
|
expect(res.status).toHaveBeenCalledWith(400);
|
|
expect(store.doc.twoFactorEnabled).toBe(false);
|
|
expect(mockSetAuthTokens).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('rejects a user whose provider becomes federated before finalization', async () => {
|
|
const finalizationToken = await reachFinalization();
|
|
store.doc.provider = 'openid';
|
|
|
|
const res = await runFinalize(finalizationToken);
|
|
|
|
expect(res.status).toHaveBeenCalledWith(400);
|
|
expect(store.doc.twoFactorEnabled).toBe(false);
|
|
expect(mockSetAuthTokens).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('rejects finalization when global enforcement is disabled', async () => {
|
|
const finalizationToken = await reachFinalization();
|
|
process.env.ENFORCE_TWO_FACTOR_AUTHENTICATION = 'false';
|
|
|
|
const res = await runFinalize(finalizationToken);
|
|
|
|
expect(res.status).toHaveBeenCalledWith(400);
|
|
expect(store.doc.twoFactorEnabled).toBe(false);
|
|
expect(mockSetAuthTokens).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('withdraws the session when password recovery lands after the promotion', async () => {
|
|
const finalizationToken = await reachFinalization();
|
|
/**
|
|
* Recovery cannot be staged up front: clearing the pending enrollment first would fail the
|
|
* compare-and-swap, which is the half already covered. Landing it while the hand-off runs is
|
|
* the ordering that leaves a token postdating the cutoff.
|
|
*/
|
|
mockSetAuthTokens.mockImplementationOnce(async () => {
|
|
store.doc.credentialsChangedAt = new Date(Date.now() + 1000);
|
|
return 'auth-token';
|
|
});
|
|
|
|
const res = await runFinalize(finalizationToken);
|
|
|
|
expect(res.status).toHaveBeenCalledWith(401);
|
|
expect(jsonPayload(res)).not.toHaveProperty('token');
|
|
expect(res.clearCookie).toHaveBeenCalledWith('refreshToken');
|
|
expect(res.clearCookie).toHaveBeenCalledWith('token_provider');
|
|
/** The session minted in that gap has to go, not just the token this response withholds. */
|
|
expect(mockDeleteAllUserSessions).toHaveBeenCalledTimes(2);
|
|
expect(mockDeleteAllUserSessions.mock.invocationCallOrder[1]).toBeGreaterThan(
|
|
mockSetAuthTokens.mock.invocationCallOrder[0],
|
|
);
|
|
});
|
|
|
|
it('hands off normally when the account was recovered before the enrollment', async () => {
|
|
store.reset({ credentialsChangedAt: new Date(Date.now() - 60_000) });
|
|
const finalizationToken = await reachFinalization();
|
|
|
|
const res = await runFinalize(finalizationToken);
|
|
|
|
expect(res.status).toHaveBeenCalledWith(200);
|
|
expect(jsonPayload(res)).toHaveProperty('token', 'auth-token');
|
|
expect(mockDeleteAllUserSessions).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('rejects finalization once the pending setup has been cleared', async () => {
|
|
const finalizationToken = await reachFinalization();
|
|
store.doc.pendingTotpSecret = null;
|
|
store.doc.pendingBackupCodes = [];
|
|
|
|
const res = await runFinalize(finalizationToken);
|
|
|
|
expect(res.status).toHaveBeenCalledWith(400);
|
|
expect(mockSetAuthTokens).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('rejects finalization without a validated identity', async () => {
|
|
const res = createResponse();
|
|
|
|
await finalize2FASetup({ body: {} }, res);
|
|
|
|
expect(res.status).toHaveBeenCalledWith(401);
|
|
expect(mockSetAuthTokens).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('never leaks secrets or enrollment nonces in the session response', async () => {
|
|
const finalizationToken = await reachFinalization();
|
|
|
|
const res = await runFinalize(finalizationToken);
|
|
const { user } = jsonPayload(res);
|
|
|
|
for (const field of [
|
|
'password',
|
|
'totpSecret',
|
|
'backupCodes',
|
|
'refreshToken',
|
|
'pendingTotpSecret',
|
|
'pendingBackupCodes',
|
|
'twoFactorAcknowledgementNonceHash',
|
|
'twoFactorFinalizationNonceHash',
|
|
]) {
|
|
expect(user).not.toHaveProperty(field);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Naming the secrets one by one only ever catches the secrets already thought of, and this
|
|
* response is built from an unprojected document. Pinning the whole shape is what keeps a field
|
|
* added to the schema later from arriving here unannounced.
|
|
*/
|
|
it('sends only allowlisted fields in the session response', async () => {
|
|
const finalizationToken = await reachFinalization();
|
|
|
|
const res = await runFinalize(finalizationToken);
|
|
|
|
expect(jsonPayload(res).user).toEqual({
|
|
_id: 'user-1',
|
|
id: 'user-1',
|
|
email: 'user@example.com',
|
|
provider: 'local',
|
|
twoFactorEnabled: true,
|
|
});
|
|
});
|
|
});
|