@stamhoofd/backend 2.137.4 → 2.138.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +26 -17
- package/src/boot.ts +5 -0
- package/src/crons/balance-emails.ts +2 -1
- package/src/crons/delete-expired-mfa-tokens.ts +35 -0
- package/src/crons/drip-emails.ts +2 -1
- package/src/crons/index.ts +1 -0
- package/src/crons/invoices.ts +5 -3
- package/src/crons.ts +4 -2
- package/src/email-recipient-loaders/orders.ts +2 -1
- package/src/email-recipient-loaders/organizations.ts +3 -2
- package/src/email-recipient-loaders/payments.ts +3 -2
- package/src/endpoints/auth/ConfirmTOTPEndpoint.ts +84 -0
- package/src/endpoints/auth/CreateAdminEndpoint.ts +4 -2
- package/src/endpoints/auth/CreateTokenEndpoint.test.ts +61 -1
- package/src/endpoints/auth/CreateTokenEndpoint.ts +135 -7
- package/src/endpoints/auth/DeletePasskeyEndpoint.ts +61 -0
- package/src/endpoints/auth/DeleteTOTPEndpoint.ts +62 -0
- package/src/endpoints/auth/DeleteUserEndpoint.ts +1 -1
- package/src/endpoints/auth/ForgotPasswordEndpoint.ts +4 -2
- package/src/endpoints/auth/GetMFAChallengeEndpoint.ts +56 -0
- package/src/endpoints/auth/GetMFAStatusEndpoint.ts +31 -0
- package/src/endpoints/auth/GetUserEndpoint.test.ts +32 -1
- package/src/endpoints/auth/MFA.security.test.ts +688 -0
- package/src/endpoints/auth/MFA.test.ts +1398 -0
- package/src/endpoints/auth/OpenIDConnectAuthTokenEndpoint.ts +2 -2
- package/src/endpoints/auth/PatchUserEndpoint.ts +6 -3
- package/src/endpoints/auth/RegenerateRecoveryCodesEndpoint.ts +51 -0
- package/src/endpoints/auth/RegisterPasskeyEndpoint.ts +99 -0
- package/src/endpoints/auth/RegisterPasskeyOptionsEndpoint.ts +49 -0
- package/src/endpoints/auth/RetryEmailVerificationEndpoint.ts +2 -1
- package/src/endpoints/auth/SetupTOTPEndpoint.ts +54 -0
- package/src/endpoints/auth/SignupEndpoint.ts +17 -4
- package/src/endpoints/auth/VerifyEmailEndpoint.ts +16 -0
- package/src/endpoints/global/email/CreateEmailEndpoint.ts +5 -3
- package/src/endpoints/global/email/GetAdminEmailsEndpoint.test.ts +1 -1
- package/src/endpoints/global/email/GetAdminEmailsEndpoint.ts +2 -1
- package/src/endpoints/global/email/GetEmailEndpoint.ts +2 -1
- package/src/endpoints/global/email/GetUserEmailsEndpoint.test.ts +1 -1
- package/src/endpoints/global/email/GetUserEmailsEndpoint.ts +2 -1
- package/src/endpoints/global/email/PatchEmailEndpoint.ts +5 -3
- package/src/endpoints/global/email-recipients/GetEmailRecipientsEndpoint.ts +2 -1
- package/src/endpoints/global/email-recipients/RetryEmailRecipientEndpoint.ts +2 -1
- package/src/endpoints/global/files/ExportToExcelEndpoint.ts +11 -6
- package/src/endpoints/global/files/UploadFile.test.ts +206 -0
- package/src/endpoints/global/files/UploadFile.ts +31 -6
- package/src/endpoints/global/files/UploadImage.test.ts +177 -0
- package/src/endpoints/global/files/UploadImage.ts +23 -4
- package/src/endpoints/global/files/upload-security.test.ts +837 -0
- package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.ts +1 -1
- package/src/endpoints/global/members/SendMemberSecurityCodeEndpoint.ts +2 -1
- package/src/endpoints/global/organizations/CreateOrganizationEndpoint.ts +2 -1
- package/src/endpoints/global/platform/GetPlatformAdminsEndpoint.ts +4 -1
- package/src/endpoints/global/platform/PatchPlatformEnpoint.test.ts +35 -1
- package/src/endpoints/global/platform/PatchPlatformEnpoint.ts +8 -0
- package/src/endpoints/global/platform/SignOutPlatformAdminsEndpoint.test.ts +92 -0
- package/src/endpoints/global/platform/SignOutPlatformAdminsEndpoint.ts +43 -0
- package/src/endpoints/organization/dashboard/documents/GetDocumentTemplateXML.ts +2 -1
- package/src/endpoints/organization/dashboard/organization/PatchOrganizationEndpoint.ts +1 -0
- package/src/endpoints/organization/dashboard/organization/SetOrganizationDomainEndpoint.ts +3 -2
- package/src/endpoints/organization/dashboard/receivable-balances/ChargeReceivableBalancesEndpoint.ts +3 -2
- package/src/endpoints/organization/dashboard/users/CreateApiUserEndpoint.test.ts +32 -3
- package/src/endpoints/organization/dashboard/users/CreateApiUserEndpoint.ts +4 -1
- package/src/endpoints/organization/dashboard/users/GetOrganizationAdminsEndpoint.test.ts +100 -0
- package/src/endpoints/organization/dashboard/users/GetOrganizationAdminsEndpoint.ts +4 -1
- package/src/endpoints/organization/dashboard/users/PatchApiUserEndpoint.test.ts +5 -5
- package/src/endpoints/organization/dashboard/users/PatchApiUserEndpoint.ts +5 -3
- package/src/endpoints/organization/dashboard/users/SignOutOrganizationAdminsEndpoint.test.ts +160 -0
- package/src/endpoints/organization/dashboard/users/SignOutOrganizationAdminsEndpoint.ts +44 -0
- package/src/endpoints/organization/dashboard/webshops/PatchWebshopOrdersEndpoint.ts +4 -3
- package/src/endpoints/organization/shared/GetDocumentHtml.ts +2 -1
- package/src/endpoints/organization/webshops/PlaceOrderEndpoint.ts +4 -3
- package/src/excel-loaders/balance-items.ts +1 -1
- package/src/excel-loaders/event-notifications.ts +6 -7
- package/src/excel-loaders/members.test.ts +2 -2
- package/src/excel-loaders/members.ts +9 -10
- package/src/excel-loaders/organizations.ts +14 -20
- package/src/excel-loaders/payments.ts +1 -1
- package/src/excel-loaders/platform-memberships.ts +7 -7
- package/src/excel-loaders/platform-sheets.test.ts +113 -0
- package/src/excel-loaders/receivable-balances.ts +1 -1
- package/src/excel-loaders/registrations.ts +9 -9
- package/src/helpers/AdminPermissionChecker.ts +1 -1
- package/src/helpers/AuthenticatedStructures.ts +21 -10
- package/src/helpers/Context.ts +114 -4
- package/src/helpers/EmailBuilder.test.ts +287 -0
- package/src/helpers/EmailBuilder.ts +811 -0
- package/src/helpers/EmailResumer.ts +2 -1
- package/src/helpers/ForwardHandler.ts +2 -1
- package/src/helpers/MFAEncryption.ts +34 -0
- package/src/helpers/MembershipCharger.ts +3 -2
- package/src/helpers/RecoveryCodeHelper.ts +81 -0
- package/src/helpers/TOTPHelper.ts +79 -0
- package/src/helpers/TenantContext.test.ts +96 -0
- package/src/helpers/TenantContext.ts +67 -0
- package/src/helpers/TwoFactorHelper.ts +356 -0
- package/src/helpers/WebauthnHelper.ts +211 -0
- package/src/helpers/data/aaguids.json +1006 -0
- package/src/middleware/TenantScopeMiddleware.test.ts +41 -0
- package/src/middleware/TenantScopeMiddleware.ts +24 -0
- package/src/seeds/1785417302-fill-user-last-active-at.sql +6 -0
- package/src/services/AdminSessionService.ts +41 -0
- package/src/services/BalanceItemService.ts +2 -1
- package/src/services/DocumentRenderService.test.ts +229 -0
- package/src/services/DocumentRenderService.ts +180 -0
- package/src/services/EmailPreviewService.test.ts +300 -0
- package/src/services/EmailPreviewService.ts +239 -0
- package/src/services/EmailSendService.test.ts +1218 -0
- package/src/services/EmailSendService.ts +705 -0
- package/src/services/EventNotificationService.ts +2 -1
- package/src/services/FileSignService.test.ts +85 -0
- package/src/services/FileSignService.ts +23 -1
- package/src/services/InvoicePdfService.ts +3 -3
- package/src/services/InvoiceService.ts +4 -2
- package/src/services/InvoiceXMLService.ts +2 -1
- package/src/services/OrderService.test.ts +308 -0
- package/src/services/OrderService.ts +215 -0
- package/src/services/OrganizationAdminService.test.ts +47 -0
- package/src/services/OrganizationAdminService.ts +139 -0
- package/src/services/OrganizationDNSService.test.ts +177 -0
- package/src/services/OrganizationDNSService.ts +282 -0
- package/src/services/OrganizationEmailService.test.ts +57 -0
- package/src/services/OrganizationEmailService.ts +212 -0
- package/src/services/PasswordForgotService.test.ts +99 -0
- package/src/services/PasswordForgotService.ts +38 -0
- package/src/services/PaymentService.ts +5 -3
- package/src/services/PlatformMembershipService.test.ts +180 -0
- package/src/services/PlatformMembershipService.ts +281 -4
- package/src/services/ReferralService.ts +3 -2
- package/src/services/RegistrationService.ts +4 -2
- package/src/services/SSOService.ts +106 -14
- package/src/services/STPackageService.test.ts +191 -0
- package/src/services/STPackageService.ts +115 -1
- package/src/services/TwoFactorAuditLogService.ts +64 -0
- package/src/services/VerificationCodeService.test.ts +71 -0
- package/src/services/VerificationCodeService.ts +101 -0
- package/tests/e2e/api-rate-limits.test.ts +1 -1
- package/tests/e2e/documents.test.ts +2 -1
- package/tests/e2e/private-files.test.ts +77 -14
- package/tests/helpers/MFATestHelper.ts +42 -0
- package/tests/helpers/TestServer.ts +4 -0
- package/tests/helpers/index.ts +1 -0
- package/tsconfig.build.json +2 -1
|
@@ -0,0 +1,1398 @@
|
|
|
1
|
+
import { Endpoint, Request } from '@simonbackx/simple-endpoints';
|
|
2
|
+
import { isSimpleError, isSimpleErrors, SimpleError } from '@simonbackx/simple-errors';
|
|
3
|
+
import { AuditLog, EmailVerificationCode, MFARecoveryCode, MFATOTP, MFAToken, Organization, PasswordToken, Platform, Token, User, UserFactory, OrganizationFactory, WebauthnChallenge, WebauthnCredential } from '@stamhoofd/models';
|
|
4
|
+
import { AuditLogReplacementType, AuditLogType, MFAMethodType, PermissionLevel, Permissions, Token as TokenStruct } from '@stamhoofd/structures';
|
|
5
|
+
import { authenticator } from 'otplib';
|
|
6
|
+
import crypto from 'crypto';
|
|
7
|
+
|
|
8
|
+
import { MFATestHelper } from '../../../tests/helpers/MFATestHelper.js';
|
|
9
|
+
import { testServer } from '../../../tests/helpers/TestServer.js';
|
|
10
|
+
import { RECOVERY_CODE_ALPHABET, RecoveryCodeHelper } from '../../helpers/RecoveryCodeHelper.js';
|
|
11
|
+
import { TwoFactorHelper } from '../../helpers/TwoFactorHelper.js';
|
|
12
|
+
import { WebauthnHelper } from '../../helpers/WebauthnHelper.js';
|
|
13
|
+
import { ConfirmTOTPEndpoint } from './ConfirmTOTPEndpoint.js';
|
|
14
|
+
import { CreateTokenEndpoint } from './CreateTokenEndpoint.js';
|
|
15
|
+
import { DeletePasskeyEndpoint } from './DeletePasskeyEndpoint.js';
|
|
16
|
+
import { DeleteTOTPEndpoint } from './DeleteTOTPEndpoint.js';
|
|
17
|
+
import { GetMFAChallengeEndpoint } from './GetMFAChallengeEndpoint.js';
|
|
18
|
+
import { GetMFAStatusEndpoint } from './GetMFAStatusEndpoint.js';
|
|
19
|
+
import { RegenerateRecoveryCodesEndpoint } from './RegenerateRecoveryCodesEndpoint.js';
|
|
20
|
+
import { RegisterPasskeyEndpoint } from './RegisterPasskeyEndpoint.js';
|
|
21
|
+
import { RegisterPasskeyOptionsEndpoint } from './RegisterPasskeyOptionsEndpoint.js';
|
|
22
|
+
import { SetupTOTPEndpoint } from './SetupTOTPEndpoint.js';
|
|
23
|
+
import { VerifyEmailEndpoint } from './VerifyEmailEndpoint.js';
|
|
24
|
+
|
|
25
|
+
const tokenEndpoint = new CreateTokenEndpoint();
|
|
26
|
+
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// Helpers
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
function firstError(e: unknown): SimpleError {
|
|
32
|
+
if (isSimpleErrors(e)) {
|
|
33
|
+
return e.errors[0];
|
|
34
|
+
}
|
|
35
|
+
if (isSimpleError(e)) {
|
|
36
|
+
return e;
|
|
37
|
+
}
|
|
38
|
+
throw new Error('Not a SimpleError: ' + String(e));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function captureError(promise: Promise<unknown>): Promise<SimpleError> {
|
|
42
|
+
try {
|
|
43
|
+
await promise;
|
|
44
|
+
}
|
|
45
|
+
catch (e) {
|
|
46
|
+
return firstError(e);
|
|
47
|
+
}
|
|
48
|
+
throw new Error('Expected the request to throw');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function addConfirmedTOTP(user: User): Promise<{ id: string; secret: string }> {
|
|
52
|
+
return await MFATestHelper.addConfirmedTOTP(user, 'Confirmed authenticator');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* A user without an organization of their own: the only kind that may use passkeys.
|
|
57
|
+
* In organization mode the factory only leaves organizationId null for platform admins.
|
|
58
|
+
*/
|
|
59
|
+
async function platformUser(): Promise<User> {
|
|
60
|
+
const user = await new UserFactory({ password, globalPermissions: Permissions.create({ level: PermissionLevel.None }) }).create();
|
|
61
|
+
expect(user.organizationId).toBeNull();
|
|
62
|
+
return user;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const twoFactorLogTypes = [AuditLogType.UserTwoFactorMethodAdded, AuditLogType.UserTwoFactorMethodDeleted, AuditLogType.UserRecoveryCodesRegenerated];
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The two-factor audit logs of a user, oldest first (the ids are uuidv7, so they sort by
|
|
69
|
+
* creation). Other logs about the same account (e.g. UserAdded) are left out.
|
|
70
|
+
*/
|
|
71
|
+
async function twoFactorLogs(user: User): Promise<AuditLog[]> {
|
|
72
|
+
const logs = await AuditLog.select().where('objectId', user.id).fetch();
|
|
73
|
+
return logs
|
|
74
|
+
.filter(l => twoFactorLogTypes.includes(l.type))
|
|
75
|
+
.sort((a, b) => a.id.localeCompare(b.id));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function addPasskey(user: User, credentialId?: string): Promise<WebauthnCredential> {
|
|
79
|
+
const credential = new WebauthnCredential();
|
|
80
|
+
credential.userId = user.id;
|
|
81
|
+
credential.credentialId = credentialId ?? 'cred-' + crypto.randomBytes(16).toString('base64url');
|
|
82
|
+
credential.publicKey = crypto.randomBytes(32).toString('base64url');
|
|
83
|
+
credential.counter = 0;
|
|
84
|
+
credential.name = 'Test passkey';
|
|
85
|
+
await credential.save();
|
|
86
|
+
return credential;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function passwordLogin(organization: Organization | null, email: string, password: string) {
|
|
90
|
+
return Request.buildJson('POST', '/oauth/token', organization?.getApiHost(), {
|
|
91
|
+
grant_type: 'password',
|
|
92
|
+
username: email,
|
|
93
|
+
password,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function mfaGrant(organization: Organization, body: Record<string, unknown>) {
|
|
98
|
+
return Request.buildJson('POST', '/oauth/token', organization.getApiHost(), { grant_type: 'mfa', ...body });
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function bearer(request: Request, token: Token) {
|
|
102
|
+
request.headers.authorization = 'Bearer ' + token.accessToken;
|
|
103
|
+
return request;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function freshToken(user: User): Promise<Token> {
|
|
107
|
+
return await Token.createToken(user, new Date());
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function staleToken(user: User): Promise<Token> {
|
|
111
|
+
// A token as produced by a refresh_token rotation (never authenticatedAt).
|
|
112
|
+
return await Token.createToken(user);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function requireMfa(organization: Organization | null, email: string, password: string): Promise<{ token: string; methods: string[]; webauthnAuthenticationOptions: unknown }> {
|
|
116
|
+
const err = await captureError(testServer.test(tokenEndpoint, passwordLogin(organization, email, password)));
|
|
117
|
+
expect(err.code).toBe('require_mfa');
|
|
118
|
+
return err.meta as { token: string; methods: string[]; webauthnAuthenticationOptions: unknown };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The platform row is shared by every test file, so tests that flip this have to reset it
|
|
123
|
+
* again (see the afterEach below).
|
|
124
|
+
*/
|
|
125
|
+
async function setPlatformRequiresTwoFactor(requireTwoFactor: boolean) {
|
|
126
|
+
const platform = await Platform.getForEditing();
|
|
127
|
+
platform.privateConfig.requireTwoFactor = requireTwoFactor;
|
|
128
|
+
await platform.save();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const password = 'test-password-1234';
|
|
132
|
+
|
|
133
|
+
describe('MFA', () => {
|
|
134
|
+
afterEach(async () => {
|
|
135
|
+
await setPlatformRequiresTwoFactor(false);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// -----------------------------------------------------------------------
|
|
139
|
+
// Login: challenge selection
|
|
140
|
+
// -----------------------------------------------------------------------
|
|
141
|
+
describe('login challenge', () => {
|
|
142
|
+
test('a user without factors logs in normally', async () => {
|
|
143
|
+
const organization = await new OrganizationFactory({}).create();
|
|
144
|
+
const user = await new UserFactory({ organization, password }).create();
|
|
145
|
+
|
|
146
|
+
const response = await testServer.test(tokenEndpoint, passwordLogin(organization, user.email, password));
|
|
147
|
+
expect(response.body).toBeInstanceOf(TokenStruct);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('a user with a TOTP is challenged with require_mfa (no session token)', async () => {
|
|
151
|
+
const organization = await new OrganizationFactory({}).create();
|
|
152
|
+
const user = await new UserFactory({ organization, password }).create();
|
|
153
|
+
await addConfirmedTOTP(user);
|
|
154
|
+
|
|
155
|
+
const challenge = await requireMfa(organization, user.email, password);
|
|
156
|
+
expect(challenge.token).toBeTruthy();
|
|
157
|
+
expect(challenge.methods).toContain('TOTP');
|
|
158
|
+
expect(challenge.methods).not.toContain('Passkey');
|
|
159
|
+
expect(challenge.webauthnAuthenticationOptions).toBeNull();
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test('a user with a passkey gets webauthn options in the challenge', async () => {
|
|
163
|
+
const organization = await new OrganizationFactory({}).create();
|
|
164
|
+
const user = await new UserFactory({ organization, password }).create();
|
|
165
|
+
await addPasskey(user);
|
|
166
|
+
await addConfirmedTOTP(user);
|
|
167
|
+
await RecoveryCodeHelper.regenerateForUser(user.id);
|
|
168
|
+
|
|
169
|
+
const challenge = await requireMfa(organization, user.email, password);
|
|
170
|
+
expect(challenge.methods).toEqual(expect.arrayContaining(['TOTP', 'Passkey', 'RecoveryCode']));
|
|
171
|
+
expect(challenge.webauthnAuthenticationOptions).not.toBeNull();
|
|
172
|
+
expect((challenge.webauthnAuthenticationOptions as { challenge?: string }).challenge).toBeTruthy();
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test('a wrong password is rejected before any challenge', async () => {
|
|
176
|
+
const organization = await new OrganizationFactory({}).create();
|
|
177
|
+
const user = await new UserFactory({ organization, password }).create();
|
|
178
|
+
await addConfirmedTOTP(user);
|
|
179
|
+
|
|
180
|
+
const err = await captureError(testServer.test(tokenEndpoint, passwordLogin(organization, user.email, 'wrong-password')));
|
|
181
|
+
expect(err.code).toBe('invalid_username_or_password');
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// -----------------------------------------------------------------------
|
|
186
|
+
// Login: TOTP grant
|
|
187
|
+
// -----------------------------------------------------------------------
|
|
188
|
+
describe('mfa grant - TOTP', () => {
|
|
189
|
+
test('valid code issues a fresh token and consumes the mfa token', async () => {
|
|
190
|
+
const organization = await new OrganizationFactory({}).create();
|
|
191
|
+
const user = await new UserFactory({ organization, password }).create();
|
|
192
|
+
const { secret } = await addConfirmedTOTP(user);
|
|
193
|
+
const challenge = await requireMfa(organization, user.email, password);
|
|
194
|
+
|
|
195
|
+
const response = await testServer.test(tokenEndpoint, mfaGrant(organization, { mfa_token: challenge.token, method: 'TOTP', code: authenticator.generate(secret) }));
|
|
196
|
+
expect(response.body).toBeInstanceOf(TokenStruct);
|
|
197
|
+
|
|
198
|
+
const dbToken = await Token.getByAccessToken((response.body as TokenStruct).accessToken);
|
|
199
|
+
expect(dbToken).toBeDefined();
|
|
200
|
+
expect(dbToken!.isFresh()).toBe(true);
|
|
201
|
+
|
|
202
|
+
// consumed
|
|
203
|
+
const replay = await captureError(testServer.test(tokenEndpoint, mfaGrant(organization, { mfa_token: challenge.token, method: 'TOTP', code: authenticator.generate(secret) })));
|
|
204
|
+
expect(replay.code).toBe('invalid_mfa_token');
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test('any of the user\'s multiple authenticators can complete the login', async () => {
|
|
208
|
+
const organization = await new OrganizationFactory({}).create();
|
|
209
|
+
const user = await new UserFactory({ organization, password }).create();
|
|
210
|
+
await addConfirmedTOTP(user);
|
|
211
|
+
const second = await addConfirmedTOTP(user);
|
|
212
|
+
|
|
213
|
+
const challenge = await requireMfa(organization, user.email, password);
|
|
214
|
+
const response = await testServer.test(tokenEndpoint, mfaGrant(organization, { mfa_token: challenge.token, method: 'TOTP', code: authenticator.generate(second.secret) }));
|
|
215
|
+
expect(response.body).toBeInstanceOf(TokenStruct);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test('wrong codes fail and lock the token after max tries', async () => {
|
|
219
|
+
const organization = await new OrganizationFactory({}).create();
|
|
220
|
+
const user = await new UserFactory({ organization, password }).create();
|
|
221
|
+
const { secret } = await addConfirmedTOTP(user);
|
|
222
|
+
const challenge = await requireMfa(organization, user.email, password);
|
|
223
|
+
|
|
224
|
+
for (let i = 0; i < 4; i++) {
|
|
225
|
+
const e = await captureError(testServer.test(tokenEndpoint, mfaGrant(organization, { mfa_token: challenge.token, method: 'TOTP', code: '000000' })));
|
|
226
|
+
expect(e.code).toBe('invalid_mfa_code');
|
|
227
|
+
}
|
|
228
|
+
const locked = await captureError(testServer.test(tokenEndpoint, mfaGrant(organization, { mfa_token: challenge.token, method: 'TOTP', code: '000000' })));
|
|
229
|
+
expect(locked.code).toBe('too_many_attempts');
|
|
230
|
+
|
|
231
|
+
// even a correct code no longer works
|
|
232
|
+
const afterLock = await captureError(testServer.test(tokenEndpoint, mfaGrant(organization, { mfa_token: challenge.token, method: 'TOTP', code: authenticator.generate(secret) })));
|
|
233
|
+
expect(afterLock.code).toBe('invalid_mfa_token');
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test('an expired mfa token is rejected', async () => {
|
|
237
|
+
const organization = await new OrganizationFactory({}).create();
|
|
238
|
+
const user = await new UserFactory({ organization, password }).create();
|
|
239
|
+
const { secret } = await addConfirmedTOTP(user);
|
|
240
|
+
const challenge = await requireMfa(organization, user.email, password);
|
|
241
|
+
|
|
242
|
+
const row = await MFAToken.select().where('token', challenge.token).first(true);
|
|
243
|
+
row.expiresAt = new Date(Date.now() - 1000);
|
|
244
|
+
await row.save();
|
|
245
|
+
|
|
246
|
+
const err = await captureError(testServer.test(tokenEndpoint, mfaGrant(organization, { mfa_token: challenge.token, method: 'TOTP', code: authenticator.generate(secret) })));
|
|
247
|
+
expect(err.code).toBe('invalid_mfa_token');
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test('an unknown mfa token is rejected', async () => {
|
|
251
|
+
const organization = await new OrganizationFactory({}).create();
|
|
252
|
+
const err = await captureError(testServer.test(tokenEndpoint, mfaGrant(organization, { mfa_token: 'does-not-exist', method: 'TOTP', code: '000000' })));
|
|
253
|
+
expect(err.code).toBe('invalid_mfa_token');
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test('a non-numeric code is rejected as invalid', async () => {
|
|
257
|
+
const organization = await new OrganizationFactory({}).create();
|
|
258
|
+
const user = await new UserFactory({ organization, password }).create();
|
|
259
|
+
await addConfirmedTOTP(user);
|
|
260
|
+
const challenge = await requireMfa(organization, user.email, password);
|
|
261
|
+
|
|
262
|
+
const err = await captureError(testServer.test(tokenEndpoint, mfaGrant(organization, { mfa_token: challenge.token, method: 'TOTP', code: 'abcdef' })));
|
|
263
|
+
expect(err.code).toBe('invalid_mfa_code');
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
// -----------------------------------------------------------------------
|
|
268
|
+
// Login: last activity
|
|
269
|
+
// -----------------------------------------------------------------------
|
|
270
|
+
describe('last activity', () => {
|
|
271
|
+
test('a password login only counts as activity once the second factor is passed', async () => {
|
|
272
|
+
const organization = await new OrganizationFactory({}).create();
|
|
273
|
+
const user = await new UserFactory({ organization, password }).create();
|
|
274
|
+
const { secret } = await addConfirmedTOTP(user);
|
|
275
|
+
|
|
276
|
+
const challenge = await requireMfa(organization, user.email, password);
|
|
277
|
+
expect((await User.getByID(user.id))!.lastActiveAt).toBeNull();
|
|
278
|
+
|
|
279
|
+
await testServer.test(tokenEndpoint, mfaGrant(organization, { mfa_token: challenge.token, method: 'TOTP', code: authenticator.generate(secret) }));
|
|
280
|
+
expect((await User.getByID(user.id))!.lastActiveAt).not.toBeNull();
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test('a wrong second factor does not count as activity', async () => {
|
|
284
|
+
const organization = await new OrganizationFactory({}).create();
|
|
285
|
+
const user = await new UserFactory({ organization, password }).create();
|
|
286
|
+
await addConfirmedTOTP(user);
|
|
287
|
+
|
|
288
|
+
const challenge = await requireMfa(organization, user.email, password);
|
|
289
|
+
const err = await captureError(testServer.test(tokenEndpoint, mfaGrant(organization, { mfa_token: challenge.token, method: 'TOTP', code: '000000' })));
|
|
290
|
+
expect(err.code).toBe('invalid_mfa_code');
|
|
291
|
+
|
|
292
|
+
expect((await User.getByID(user.id))!.lastActiveAt).toBeNull();
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
// -----------------------------------------------------------------------
|
|
297
|
+
// Login: recovery codes
|
|
298
|
+
// -----------------------------------------------------------------------
|
|
299
|
+
describe('mfa grant - recovery codes', () => {
|
|
300
|
+
test('a recovery code logs in, is single-use, and other codes still work', async () => {
|
|
301
|
+
const organization = await new OrganizationFactory({}).create();
|
|
302
|
+
const user = await new UserFactory({ organization, password }).create();
|
|
303
|
+
await addConfirmedTOTP(user);
|
|
304
|
+
const codes = await RecoveryCodeHelper.regenerateForUser(user.id);
|
|
305
|
+
|
|
306
|
+
const challenge = await requireMfa(organization, user.email, password);
|
|
307
|
+
const response = await testServer.test(tokenEndpoint, mfaGrant(organization, { mfa_token: challenge.token, method: 'RecoveryCode', code: codes[0] }));
|
|
308
|
+
expect(response.body).toBeInstanceOf(TokenStruct);
|
|
309
|
+
|
|
310
|
+
const challenge2 = await requireMfa(organization, user.email, password);
|
|
311
|
+
const reuse = await captureError(testServer.test(tokenEndpoint, mfaGrant(organization, { mfa_token: challenge2.token, method: 'RecoveryCode', code: codes[0] })));
|
|
312
|
+
expect(reuse.code).toBe('invalid_mfa_code');
|
|
313
|
+
|
|
314
|
+
const response2 = await testServer.test(tokenEndpoint, mfaGrant(organization, { mfa_token: challenge2.token, method: 'RecoveryCode', code: codes[1] }));
|
|
315
|
+
expect(response2.body).toBeInstanceOf(TokenStruct);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
test('an unknown recovery code is rejected', async () => {
|
|
319
|
+
const organization = await new OrganizationFactory({}).create();
|
|
320
|
+
const user = await new UserFactory({ organization, password }).create();
|
|
321
|
+
await addConfirmedTOTP(user);
|
|
322
|
+
await RecoveryCodeHelper.regenerateForUser(user.id);
|
|
323
|
+
const challenge = await requireMfa(organization, user.email, password);
|
|
324
|
+
|
|
325
|
+
const err = await captureError(testServer.test(tokenEndpoint, mfaGrant(organization, { mfa_token: challenge.token, method: 'RecoveryCode', code: 'AAAA-BBBB-CCCC-DDDD' })));
|
|
326
|
+
expect(err.code).toBe('invalid_mfa_code');
|
|
327
|
+
});
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
// -----------------------------------------------------------------------
|
|
331
|
+
// Login: passkey grant (crypto happy-path is covered by Playwright)
|
|
332
|
+
// -----------------------------------------------------------------------
|
|
333
|
+
describe('mfa grant - passkey (security/validation)', () => {
|
|
334
|
+
async function passkeyChallenge() {
|
|
335
|
+
const organization = await new OrganizationFactory({}).create();
|
|
336
|
+
const user = await new UserFactory({ organization, password }).create();
|
|
337
|
+
const credential = await addPasskey(user);
|
|
338
|
+
const challenge = await requireMfa(organization, user.email, password);
|
|
339
|
+
return { organization, user, credential, challenge };
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function validAssertionShape(credentialId: string) {
|
|
343
|
+
return {
|
|
344
|
+
id: credentialId,
|
|
345
|
+
rawId: credentialId,
|
|
346
|
+
type: 'public-key',
|
|
347
|
+
response: {
|
|
348
|
+
clientDataJSON: 'x',
|
|
349
|
+
authenticatorData: 'x',
|
|
350
|
+
signature: 'x',
|
|
351
|
+
userHandle: null,
|
|
352
|
+
},
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
test('a malformed (non-object) assertion is rejected with a decode error, not a crash', async () => {
|
|
357
|
+
const { organization, challenge } = await passkeyChallenge();
|
|
358
|
+
// captureError() only resolves for handled SimpleError(s); a 500/crash would rethrow.
|
|
359
|
+
const err = await captureError(testServer.test(tokenEndpoint, mfaGrant(organization, { mfa_token: challenge.token, method: 'Passkey', assertion: 'not-an-object' })));
|
|
360
|
+
expect(err.statusCode ?? 400).toBeLessThan(500);
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
test('an assertion for an unknown credential is rejected', async () => {
|
|
364
|
+
const { organization, challenge } = await passkeyChallenge();
|
|
365
|
+
const err = await captureError(testServer.test(tokenEndpoint, mfaGrant(organization, { mfa_token: challenge.token, method: 'Passkey', assertion: validAssertionShape('unknown-credential') })));
|
|
366
|
+
expect(err.code).toBe('invalid_mfa_code');
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
test('an assertion for another user\'s credential is rejected', async () => {
|
|
370
|
+
const { organization, challenge } = await passkeyChallenge();
|
|
371
|
+
const otherUser = await new UserFactory({ organization, password }).create();
|
|
372
|
+
const otherCredential = await addPasskey(otherUser);
|
|
373
|
+
|
|
374
|
+
const err = await captureError(testServer.test(tokenEndpoint, mfaGrant(organization, { mfa_token: challenge.token, method: 'Passkey', assertion: validAssertionShape(otherCredential.credentialId) })));
|
|
375
|
+
expect(err.code).toBe('invalid_mfa_code');
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
test('a well-formed but cryptographically invalid assertion is rejected', async () => {
|
|
379
|
+
const { organization, credential, challenge } = await passkeyChallenge();
|
|
380
|
+
const err = await captureError(testServer.test(tokenEndpoint, mfaGrant(organization, { mfa_token: challenge.token, method: 'Passkey', assertion: validAssertionShape(credential.credentialId) })));
|
|
381
|
+
expect(err.code).toBe('invalid_mfa_code');
|
|
382
|
+
});
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
// -----------------------------------------------------------------------
|
|
386
|
+
// Enforcement + forced enrollment
|
|
387
|
+
// -----------------------------------------------------------------------
|
|
388
|
+
describe('enforcement', () => {
|
|
389
|
+
async function orgRequiringTwoFactor() {
|
|
390
|
+
const organization = await new OrganizationFactory({}).create();
|
|
391
|
+
organization.privateMeta.requireTwoFactor = true;
|
|
392
|
+
await organization.save();
|
|
393
|
+
return organization;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
test('an org admin without a factor is forced to enroll when the org requires 2FA', async () => {
|
|
397
|
+
const organization = await orgRequiringTwoFactor();
|
|
398
|
+
const user = await new UserFactory({ organization, password, permissions: Permissions.create({ level: PermissionLevel.Full }) }).create();
|
|
399
|
+
|
|
400
|
+
const err = await captureError(testServer.test(tokenEndpoint, passwordLogin(organization, user.email, password)));
|
|
401
|
+
expect(err.code).toBe('require_mfa_setup');
|
|
402
|
+
expect((err.meta as { setupToken: string }).setupToken).toBeTruthy();
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
test('a member without permissions is NOT forced, even when the org requires 2FA', async () => {
|
|
406
|
+
const organization = await orgRequiringTwoFactor();
|
|
407
|
+
const user = await new UserFactory({ organization, password }).create();
|
|
408
|
+
|
|
409
|
+
const response = await testServer.test(tokenEndpoint, passwordLogin(organization, user.email, password));
|
|
410
|
+
expect(response.body).toBeInstanceOf(TokenStruct);
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
test('an org admin is NOT forced when the org does not require 2FA', async () => {
|
|
414
|
+
const organization = await new OrganizationFactory({}).create();
|
|
415
|
+
const user = await new UserFactory({ organization, password, permissions: Permissions.create({ level: PermissionLevel.Full }) }).create();
|
|
416
|
+
|
|
417
|
+
const response = await testServer.test(tokenEndpoint, passwordLogin(organization, user.email, password));
|
|
418
|
+
expect(response.body).toBeInstanceOf(TokenStruct);
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
test('a platform admin without a factor is forced to enroll when the platform requires 2FA', async () => {
|
|
422
|
+
await setPlatformRequiresTwoFactor(true);
|
|
423
|
+
const user = await new UserFactory({ password, globalPermissions: Permissions.create({ level: PermissionLevel.Full }) }).create();
|
|
424
|
+
|
|
425
|
+
const err = await captureError(testServer.test(tokenEndpoint, passwordLogin(null, user.email, password)));
|
|
426
|
+
expect(err.code).toBe('require_mfa_setup');
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
test('a platform admin is NOT forced when the platform does not require 2FA', async () => {
|
|
430
|
+
const user = await new UserFactory({ password, globalPermissions: Permissions.create({ level: PermissionLevel.Full }) }).create();
|
|
431
|
+
|
|
432
|
+
const response = await testServer.test(tokenEndpoint, passwordLogin(null, user.email, password));
|
|
433
|
+
expect(response.body).toBeInstanceOf(TokenStruct);
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
test('an org admin without platform permissions is NOT forced when only the platform requires 2FA', async () => {
|
|
437
|
+
await setPlatformRequiresTwoFactor(true);
|
|
438
|
+
const organization = await new OrganizationFactory({}).create();
|
|
439
|
+
const user = await new UserFactory({ organization, password, permissions: Permissions.create({ level: PermissionLevel.Full }) }).create();
|
|
440
|
+
|
|
441
|
+
const response = await testServer.test(tokenEndpoint, passwordLogin(organization, user.email, password));
|
|
442
|
+
expect(response.body).toBeInstanceOf(TokenStruct);
|
|
443
|
+
});
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
// -----------------------------------------------------------------------
|
|
447
|
+
// Forced enrollment flow (setup token)
|
|
448
|
+
// -----------------------------------------------------------------------
|
|
449
|
+
describe('forced enrollment (setup token)', () => {
|
|
450
|
+
async function startForcedSetup() {
|
|
451
|
+
const organization = await new OrganizationFactory({}).create();
|
|
452
|
+
organization.privateMeta.requireTwoFactor = true;
|
|
453
|
+
await organization.save();
|
|
454
|
+
const user = await new UserFactory({ organization, password, permissions: Permissions.create({ level: PermissionLevel.Full }) }).create();
|
|
455
|
+
const err = await captureError(testServer.test(tokenEndpoint, passwordLogin(organization, user.email, password)));
|
|
456
|
+
expect(err.code).toBe('require_mfa_setup');
|
|
457
|
+
return { organization, user, setupToken: (err.meta as { setupToken: string }).setupToken };
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function withSetup(request: Request, setupToken: string) {
|
|
461
|
+
request.headers.authorization = 'MFASetup ' + setupToken;
|
|
462
|
+
return request;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
test('TOTP setup + confirm issues a session token + recovery codes and consumes the setup token', async () => {
|
|
466
|
+
const { organization, setupToken } = await startForcedSetup();
|
|
467
|
+
|
|
468
|
+
const setupResponse = await testServer.test(new SetupTOTPEndpoint(), withSetup(Request.buildJson('POST', '/mfa/totp', organization.getApiHost()), setupToken));
|
|
469
|
+
const totpId = (setupResponse.body as { totpId: string }).totpId;
|
|
470
|
+
const secret = (setupResponse.body as { secret: string }).secret;
|
|
471
|
+
expect(totpId).toBeTruthy();
|
|
472
|
+
expect(secret).toBeTruthy();
|
|
473
|
+
|
|
474
|
+
const confirmResponse = await testServer.test(new ConfirmTOTPEndpoint(), withSetup(Request.buildJson('POST', `/mfa/totp/${totpId}/confirm`, organization.getApiHost(), { code: authenticator.generate(secret), name: 'My phone' }), setupToken));
|
|
475
|
+
const body = confirmResponse.body as { token: TokenStruct | null; recoveryCodes: { codes: string[] } | null };
|
|
476
|
+
expect(body.token).toBeTruthy();
|
|
477
|
+
expect(body.recoveryCodes?.codes.length).toBeGreaterThan(0);
|
|
478
|
+
expect(await Token.getByAccessToken(body.token!.accessToken)).toBeDefined();
|
|
479
|
+
|
|
480
|
+
// setup token consumed
|
|
481
|
+
const reuse = await captureError(testServer.test(new SetupTOTPEndpoint(), withSetup(Request.buildJson('POST', '/mfa/totp', organization.getApiHost()), setupToken)));
|
|
482
|
+
expect(reuse.code).toBe('mfa_setup_expired');
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
test('the temporary setup session does not count as activity until the factor is confirmed', async () => {
|
|
486
|
+
const { organization, user, setupToken } = await startForcedSetup();
|
|
487
|
+
expect((await User.getByID(user.id))!.lastActiveAt).toBeNull();
|
|
488
|
+
|
|
489
|
+
const setupResponse = await testServer.test(new SetupTOTPEndpoint(), withSetup(Request.buildJson('POST', '/mfa/totp', organization.getApiHost()), setupToken));
|
|
490
|
+
const { totpId, secret } = setupResponse.body as { totpId: string; secret: string };
|
|
491
|
+
expect((await User.getByID(user.id))!.lastActiveAt).toBeNull();
|
|
492
|
+
|
|
493
|
+
await testServer.test(new ConfirmTOTPEndpoint(), withSetup(Request.buildJson('POST', `/mfa/totp/${totpId}/confirm`, organization.getApiHost(), { code: authenticator.generate(secret), name: 'My phone' }), setupToken));
|
|
494
|
+
expect((await User.getByID(user.id))!.lastActiveAt).not.toBeNull();
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
test('an invalid setup token is rejected', async () => {
|
|
498
|
+
const organization = await new OrganizationFactory({}).create();
|
|
499
|
+
const err = await captureError(testServer.test(new SetupTOTPEndpoint(), withSetup(Request.buildJson('POST', '/mfa/totp', organization.getApiHost()), 'invalid-setup-token')));
|
|
500
|
+
expect(err.code).toBe('mfa_setup_expired');
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
test('a setup token cannot be used on a management endpoint (delete)', async () => {
|
|
504
|
+
const { organization, user, setupToken } = await startForcedSetup();
|
|
505
|
+
const totp = await addConfirmedTOTP(user);
|
|
506
|
+
|
|
507
|
+
const err = await captureError(testServer.test(new DeleteTOTPEndpoint(), withSetup(Request.buildJson('DELETE', `/mfa/totp/${totp.id}`, organization.getApiHost()), setupToken)));
|
|
508
|
+
// authenticateFresh() only accepts Bearer, so the MFASetup scheme is refused.
|
|
509
|
+
expect(err.statusCode).toBeGreaterThanOrEqual(400);
|
|
510
|
+
expect(['not_supported_authentication', 'not_authenticated'].includes(err.code)).toBe(true);
|
|
511
|
+
});
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
// -----------------------------------------------------------------------
|
|
515
|
+
// Enrollment + management while logged in (happy paths)
|
|
516
|
+
// -----------------------------------------------------------------------
|
|
517
|
+
describe('enrollment while logged in', () => {
|
|
518
|
+
test('setup returns a secret + QR and creates an unconfirmed authenticator', async () => {
|
|
519
|
+
const organization = await new OrganizationFactory({}).create();
|
|
520
|
+
const user = await new UserFactory({ organization, password }).create();
|
|
521
|
+
const token = await freshToken(user);
|
|
522
|
+
|
|
523
|
+
const response = await testServer.test(new SetupTOTPEndpoint(), bearer(Request.buildJson('POST', '/mfa/totp', organization.getApiHost()), token));
|
|
524
|
+
const body = response.body as { totpId: string; secret: string; otpauthUri: string };
|
|
525
|
+
expect(body.secret).toBeTruthy();
|
|
526
|
+
expect(body.otpauthUri).toContain('otpauth://');
|
|
527
|
+
|
|
528
|
+
const row = await MFATOTP.getByID(body.totpId);
|
|
529
|
+
expect(row?.confirmedAt).toBeNull();
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
test('confirming the first factor returns recovery codes and updates the status', async () => {
|
|
533
|
+
const organization = await new OrganizationFactory({}).create();
|
|
534
|
+
const user = await new UserFactory({ organization, password }).create();
|
|
535
|
+
const token = await freshToken(user);
|
|
536
|
+
|
|
537
|
+
const setup = await testServer.test(new SetupTOTPEndpoint(), bearer(Request.buildJson('POST', '/mfa/totp', organization.getApiHost()), token));
|
|
538
|
+
const { totpId, secret } = setup.body as { totpId: string; secret: string };
|
|
539
|
+
|
|
540
|
+
const confirm = await testServer.test(new ConfirmTOTPEndpoint(), bearer(Request.buildJson('POST', `/mfa/totp/${totpId}/confirm`, organization.getApiHost(), { code: authenticator.generate(secret), name: 'Phone' }), await freshToken(user)));
|
|
541
|
+
const body = confirm.body as { token: unknown; recoveryCodes: { codes: string[] } | null; status: { totp: unknown[] } };
|
|
542
|
+
expect(body.token).toBeNull(); // already logged in
|
|
543
|
+
expect(body.recoveryCodes?.codes.length).toBeGreaterThan(0);
|
|
544
|
+
expect(body.status.totp.length).toBe(1);
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
test('confirming a second factor does not re-issue recovery codes', async () => {
|
|
548
|
+
const organization = await new OrganizationFactory({}).create();
|
|
549
|
+
const user = await new UserFactory({ organization, password }).create();
|
|
550
|
+
await addConfirmedTOTP(user);
|
|
551
|
+
|
|
552
|
+
const setup = await testServer.test(new SetupTOTPEndpoint(), bearer(Request.buildJson('POST', '/mfa/totp', organization.getApiHost()), await freshToken(user)));
|
|
553
|
+
const { totpId, secret } = setup.body as { totpId: string; secret: string };
|
|
554
|
+
|
|
555
|
+
const confirm = await testServer.test(new ConfirmTOTPEndpoint(), bearer(Request.buildJson('POST', `/mfa/totp/${totpId}/confirm`, organization.getApiHost(), { code: authenticator.generate(secret), name: 'Phone 2' }), await freshToken(user)));
|
|
556
|
+
expect((confirm.body as { recoveryCodes: unknown }).recoveryCodes).toBeNull();
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
test('confirming with a wrong code fails and leaves the authenticator unconfirmed', async () => {
|
|
560
|
+
const organization = await new OrganizationFactory({}).create();
|
|
561
|
+
const user = await new UserFactory({ organization, password }).create();
|
|
562
|
+
const setup = await testServer.test(new SetupTOTPEndpoint(), bearer(Request.buildJson('POST', '/mfa/totp', organization.getApiHost()), await freshToken(user)));
|
|
563
|
+
const { totpId } = setup.body as { totpId: string };
|
|
564
|
+
|
|
565
|
+
const err = await captureError(testServer.test(new ConfirmTOTPEndpoint(), bearer(Request.buildJson('POST', `/mfa/totp/${totpId}/confirm`, organization.getApiHost(), { code: '000000', name: 'x' }), await freshToken(user))));
|
|
566
|
+
expect(err.code).toBe('invalid_mfa_code');
|
|
567
|
+
expect((await MFATOTP.getByID(totpId))?.confirmedAt).toBeNull();
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
test('confirming a non-existent authenticator returns not_found', async () => {
|
|
571
|
+
const organization = await new OrganizationFactory({}).create();
|
|
572
|
+
const user = await new UserFactory({ organization, password }).create();
|
|
573
|
+
const err = await captureError(testServer.test(new ConfirmTOTPEndpoint(), bearer(Request.buildJson('POST', '/mfa/totp/does-not-exist/confirm', organization.getApiHost(), { code: '000000', name: 'x' }), await freshToken(user))));
|
|
574
|
+
expect(err.code).toBe('not_found');
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
test('GetMFAStatus reflects the enrolled factors', async () => {
|
|
578
|
+
const organization = await new OrganizationFactory({}).create();
|
|
579
|
+
const user = await new UserFactory({ organization, password }).create();
|
|
580
|
+
await addConfirmedTOTP(user);
|
|
581
|
+
await addPasskey(user);
|
|
582
|
+
await RecoveryCodeHelper.regenerateForUser(user.id);
|
|
583
|
+
|
|
584
|
+
const response = await testServer.test(new GetMFAStatusEndpoint(), bearer(Request.buildJson('GET', '/mfa', organization.getApiHost()), await freshToken(user)));
|
|
585
|
+
const body = response.body as { totp: unknown[]; passkeys: unknown[]; hasRecoveryCodes: boolean; recoveryCodesRemaining: number };
|
|
586
|
+
expect(body.totp.length).toBe(1);
|
|
587
|
+
expect(body.passkeys.length).toBe(1);
|
|
588
|
+
expect(body.hasRecoveryCodes).toBe(true);
|
|
589
|
+
expect(body.recoveryCodesRemaining).toBeGreaterThan(0);
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
test('recovery codes can be regenerated, invalidating the old batch', async () => {
|
|
593
|
+
const organization = await new OrganizationFactory({}).create();
|
|
594
|
+
const user = await new UserFactory({ organization, password }).create();
|
|
595
|
+
await addConfirmedTOTP(user);
|
|
596
|
+
const oldCodes = await RecoveryCodeHelper.regenerateForUser(user.id);
|
|
597
|
+
|
|
598
|
+
const response = await testServer.test(new RegenerateRecoveryCodesEndpoint(), bearer(Request.buildJson('POST', '/mfa/recovery-codes', organization.getApiHost()), await freshToken(user)));
|
|
599
|
+
const newCodes = (response.body as { codes: string[] }).codes;
|
|
600
|
+
expect(newCodes.length).toBeGreaterThan(0);
|
|
601
|
+
|
|
602
|
+
// old code no longer works at login
|
|
603
|
+
const challenge = await requireMfa(organization, user.email, password);
|
|
604
|
+
const oldFails = await captureError(testServer.test(tokenEndpoint, mfaGrant(organization, { mfa_token: challenge.token, method: 'RecoveryCode', code: oldCodes[0] })));
|
|
605
|
+
expect(oldFails.code).toBe('invalid_mfa_code');
|
|
606
|
+
|
|
607
|
+
// a new code works exactly once
|
|
608
|
+
const ok = await testServer.test(tokenEndpoint, mfaGrant(organization, { mfa_token: challenge.token, method: 'RecoveryCode', code: newCodes[0] }));
|
|
609
|
+
expect(ok.body).toBeInstanceOf(TokenStruct);
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
test('recovery codes cannot be generated without any factor', async () => {
|
|
613
|
+
const organization = await new OrganizationFactory({}).create();
|
|
614
|
+
const user = await new UserFactory({ organization, password }).create();
|
|
615
|
+
const err = await captureError(testServer.test(new RegenerateRecoveryCodesEndpoint(), bearer(Request.buildJson('POST', '/mfa/recovery-codes', organization.getApiHost()), await freshToken(user))));
|
|
616
|
+
expect(err.code).toBe('no_factors');
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
test('passkey registration options are returned and the challenge is stored', async () => {
|
|
620
|
+
const organization = await new OrganizationFactory({}).create();
|
|
621
|
+
// Passkeys are limited to platform level accounts, see User.canUsePasskeys.
|
|
622
|
+
const user = await platformUser();
|
|
623
|
+
|
|
624
|
+
const response = await testServer.test(new RegisterPasskeyOptionsEndpoint(), bearer(Request.buildJson('POST', '/mfa/passkeys/options', organization.getApiHost()), await freshToken(user)));
|
|
625
|
+
const options = (response.body as { options: { challenge?: string } }).options;
|
|
626
|
+
expect(options.challenge).toBeTruthy();
|
|
627
|
+
expect(await WebauthnChallenge.consumeForUser(user.id)).toBeTruthy();
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
test('an invalid passkey registration response is rejected', async () => {
|
|
631
|
+
const organization = await new OrganizationFactory({}).create();
|
|
632
|
+
const user = await platformUser();
|
|
633
|
+
await testServer.test(new RegisterPasskeyOptionsEndpoint(), bearer(Request.buildJson('POST', '/mfa/passkeys/options', organization.getApiHost()), await freshToken(user)));
|
|
634
|
+
|
|
635
|
+
const err = await captureError(testServer.test(new RegisterPasskeyEndpoint(), bearer(Request.buildJson('POST', '/mfa/passkeys', organization.getApiHost(), {
|
|
636
|
+
name: 'My key',
|
|
637
|
+
response: { id: 'x', rawId: 'x', type: 'public-key', response: { clientDataJSON: 'x', attestationObject: 'x' } },
|
|
638
|
+
}), await freshToken(user))));
|
|
639
|
+
expect(err.code).toBe('invalid_passkey');
|
|
640
|
+
});
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
// -----------------------------------------------------------------------
|
|
644
|
+
// Deletion + last-factor protection
|
|
645
|
+
// -----------------------------------------------------------------------
|
|
646
|
+
describe('management', () => {
|
|
647
|
+
test('a TOTP authenticator can be deleted', async () => {
|
|
648
|
+
const organization = await new OrganizationFactory({}).create();
|
|
649
|
+
const user = await new UserFactory({ organization, password }).create();
|
|
650
|
+
const first = await addConfirmedTOTP(user);
|
|
651
|
+
await addConfirmedTOTP(user);
|
|
652
|
+
|
|
653
|
+
const response = await testServer.test(new DeleteTOTPEndpoint(), bearer(Request.buildJson('DELETE', `/mfa/totp/${first.id}`, organization.getApiHost()), await freshToken(user)));
|
|
654
|
+
expect((response.body as { totp: unknown[] }).totp.length).toBe(1);
|
|
655
|
+
expect(await MFATOTP.getByID(first.id)).toBeUndefined();
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
test('deleting an unknown authenticator returns not_found', async () => {
|
|
659
|
+
const organization = await new OrganizationFactory({}).create();
|
|
660
|
+
const user = await new UserFactory({ organization, password }).create();
|
|
661
|
+
const err = await captureError(testServer.test(new DeleteTOTPEndpoint(), bearer(Request.buildJson('DELETE', '/mfa/totp/unknown', organization.getApiHost()), await freshToken(user))));
|
|
662
|
+
expect(err.code).toBe('not_found');
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
test('a user cannot delete another user\'s authenticator', async () => {
|
|
666
|
+
const organization = await new OrganizationFactory({}).create();
|
|
667
|
+
const user = await new UserFactory({ organization, password }).create();
|
|
668
|
+
const other = await new UserFactory({ organization, password }).create();
|
|
669
|
+
const otherTotp = await addConfirmedTOTP(other);
|
|
670
|
+
|
|
671
|
+
const err = await captureError(testServer.test(new DeleteTOTPEndpoint(), bearer(Request.buildJson('DELETE', `/mfa/totp/${otherTotp.id}`, organization.getApiHost()), await freshToken(user))));
|
|
672
|
+
expect(err.code).toBe('not_found');
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
test('the last factor cannot be removed while 2FA is required', async () => {
|
|
676
|
+
const organization = await new OrganizationFactory({}).create();
|
|
677
|
+
organization.privateMeta.requireTwoFactor = true;
|
|
678
|
+
await organization.save();
|
|
679
|
+
const user = await new UserFactory({ organization, password, permissions: Permissions.create({ level: PermissionLevel.Full }) }).create();
|
|
680
|
+
const totp = await addConfirmedTOTP(user);
|
|
681
|
+
|
|
682
|
+
const err = await captureError(testServer.test(new DeleteTOTPEndpoint(), bearer(Request.buildJson('DELETE', `/mfa/totp/${totp.id}`, organization.getApiHost()), await freshToken(user))));
|
|
683
|
+
expect(err.code).toBe('cannot_remove_last_factor');
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
test('the last factor CAN be removed when 2FA is not required', async () => {
|
|
687
|
+
const organization = await new OrganizationFactory({}).create();
|
|
688
|
+
const user = await new UserFactory({ organization, password }).create();
|
|
689
|
+
const totp = await addConfirmedTOTP(user);
|
|
690
|
+
|
|
691
|
+
const response = await testServer.test(new DeleteTOTPEndpoint(), bearer(Request.buildJson('DELETE', `/mfa/totp/${totp.id}`, organization.getApiHost()), await freshToken(user)));
|
|
692
|
+
expect((response.body as { totp: unknown[] }).totp.length).toBe(0);
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
test('a passkey can be deleted', async () => {
|
|
696
|
+
const organization = await new OrganizationFactory({}).create();
|
|
697
|
+
const user = await new UserFactory({ organization, password }).create();
|
|
698
|
+
const passkey = await addPasskey(user);
|
|
699
|
+
await addConfirmedTOTP(user);
|
|
700
|
+
|
|
701
|
+
const response = await testServer.test(new DeletePasskeyEndpoint(), bearer(Request.buildJson('DELETE', `/mfa/passkeys/${passkey.id}`, organization.getApiHost()), await freshToken(user)));
|
|
702
|
+
expect((response.body as { passkeys: unknown[] }).passkeys.length).toBe(0);
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
test('deleting the last passkey is blocked when 2FA is required', async () => {
|
|
706
|
+
const organization = await new OrganizationFactory({}).create();
|
|
707
|
+
organization.privateMeta.requireTwoFactor = true;
|
|
708
|
+
await organization.save();
|
|
709
|
+
const user = await new UserFactory({ organization, password, permissions: Permissions.create({ level: PermissionLevel.Full }) }).create();
|
|
710
|
+
const passkey = await addPasskey(user);
|
|
711
|
+
|
|
712
|
+
const err = await captureError(testServer.test(new DeletePasskeyEndpoint(), bearer(Request.buildJson('DELETE', `/mfa/passkeys/${passkey.id}`, organization.getApiHost()), await freshToken(user))));
|
|
713
|
+
expect(err.code).toBe('cannot_remove_last_factor');
|
|
714
|
+
});
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
// -----------------------------------------------------------------------
|
|
718
|
+
// Audit logs
|
|
719
|
+
// -----------------------------------------------------------------------
|
|
720
|
+
describe('audit logs', () => {
|
|
721
|
+
afterEach(() => {
|
|
722
|
+
vi.restoreAllMocks();
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
test('confirming an authenticator app is logged with its name', async () => {
|
|
726
|
+
const organization = await new OrganizationFactory({}).create();
|
|
727
|
+
const user = await new UserFactory({ organization, password }).create();
|
|
728
|
+
|
|
729
|
+
const setup = await testServer.test(new SetupTOTPEndpoint(), bearer(Request.buildJson('POST', '/mfa/totp', organization.getApiHost()), await freshToken(user)));
|
|
730
|
+
const { totpId, secret } = setup.body as { totpId: string; secret: string };
|
|
731
|
+
// Starting a setup is not a change to the account yet, so it is not logged.
|
|
732
|
+
expect(await twoFactorLogs(user)).toHaveLength(0);
|
|
733
|
+
|
|
734
|
+
await testServer.test(new ConfirmTOTPEndpoint(), bearer(Request.buildJson('POST', `/mfa/totp/${totpId}/confirm`, organization.getApiHost(), { code: authenticator.generate(secret), name: 'GSM van Jan' }), await freshToken(user)));
|
|
735
|
+
|
|
736
|
+
const logs = await twoFactorLogs(user);
|
|
737
|
+
expect(logs).toHaveLength(1);
|
|
738
|
+
expect(logs[0].type).toBe(AuditLogType.UserTwoFactorMethodAdded);
|
|
739
|
+
expect(logs[0].userId).toBe(user.id);
|
|
740
|
+
expect(logs[0].organizationId).toBe(organization.id);
|
|
741
|
+
expect(logs[0].replacements.get('u')).toMatchObject({
|
|
742
|
+
id: user.id,
|
|
743
|
+
value: user.email,
|
|
744
|
+
type: AuditLogReplacementType.User,
|
|
745
|
+
});
|
|
746
|
+
expect(logs[0].replacements.get('method')).toMatchObject({
|
|
747
|
+
id: 'MFAMethodType',
|
|
748
|
+
value: MFAMethodType.TOTP,
|
|
749
|
+
type: AuditLogReplacementType.Enum,
|
|
750
|
+
});
|
|
751
|
+
expect(logs[0].replacements.get('name')?.value).toBe('GSM van Jan');
|
|
752
|
+
});
|
|
753
|
+
|
|
754
|
+
test('deleting an authenticator app is logged', async () => {
|
|
755
|
+
const organization = await new OrganizationFactory({}).create();
|
|
756
|
+
const user = await new UserFactory({ organization, password }).create();
|
|
757
|
+
const totp = await MFATestHelper.addConfirmedTOTP(user, 'Oude GSM');
|
|
758
|
+
await addConfirmedTOTP(user);
|
|
759
|
+
|
|
760
|
+
await testServer.test(new DeleteTOTPEndpoint(), bearer(Request.buildJson('DELETE', `/mfa/totp/${totp.id}`, organization.getApiHost()), await freshToken(user)));
|
|
761
|
+
|
|
762
|
+
const logs = await twoFactorLogs(user);
|
|
763
|
+
expect(logs).toHaveLength(1);
|
|
764
|
+
expect(logs[0].type).toBe(AuditLogType.UserTwoFactorMethodDeleted);
|
|
765
|
+
expect(logs[0].replacements.get('method')?.value).toBe(MFAMethodType.TOTP);
|
|
766
|
+
expect(logs[0].replacements.get('name')?.value).toBe('Oude GSM');
|
|
767
|
+
});
|
|
768
|
+
|
|
769
|
+
test('registering and deleting a passkey is logged, without an organization for a platform account', async () => {
|
|
770
|
+
const organization = await new OrganizationFactory({}).create();
|
|
771
|
+
const user = await platformUser();
|
|
772
|
+
|
|
773
|
+
await testServer.test(new RegisterPasskeyOptionsEndpoint(), bearer(Request.buildJson('POST', '/mfa/passkeys/options', organization.getApiHost()), await freshToken(user)));
|
|
774
|
+
|
|
775
|
+
// The browser part of the registration can't be simulated, so only the
|
|
776
|
+
// verification of the response is replaced.
|
|
777
|
+
const credentialId = 'cred-' + crypto.randomBytes(16).toString('base64url');
|
|
778
|
+
vi.spyOn(WebauthnHelper, 'verifyRegistration').mockResolvedValue({
|
|
779
|
+
rpId: 'stamhoofd.dev',
|
|
780
|
+
providerId: null,
|
|
781
|
+
providerName: null,
|
|
782
|
+
credentialId,
|
|
783
|
+
publicKey: crypto.randomBytes(32).toString('base64url'),
|
|
784
|
+
counter: 0,
|
|
785
|
+
transports: null,
|
|
786
|
+
backedUp: false,
|
|
787
|
+
backupEligible: false,
|
|
788
|
+
});
|
|
789
|
+
|
|
790
|
+
await testServer.test(new RegisterPasskeyEndpoint(), bearer(Request.buildJson('POST', '/mfa/passkeys', organization.getApiHost(), {
|
|
791
|
+
name: 'YubiKey',
|
|
792
|
+
response: { id: 'x', rawId: 'x', type: 'public-key', response: { clientDataJSON: 'x', attestationObject: 'x' } },
|
|
793
|
+
}), await freshToken(user)));
|
|
794
|
+
|
|
795
|
+
const credential = await WebauthnCredential.getByCredentialId(credentialId);
|
|
796
|
+
expect(credential).toBeDefined();
|
|
797
|
+
|
|
798
|
+
await testServer.test(new DeletePasskeyEndpoint(), bearer(Request.buildJson('DELETE', `/mfa/passkeys/${credential!.id}`, organization.getApiHost()), await freshToken(user)));
|
|
799
|
+
|
|
800
|
+
const logs = await twoFactorLogs(user);
|
|
801
|
+
expect(logs.map(l => l.type)).toEqual([AuditLogType.UserTwoFactorMethodAdded, AuditLogType.UserTwoFactorMethodDeleted]);
|
|
802
|
+
for (const log of logs) {
|
|
803
|
+
// A platform account has no organization of its own: the log stays private
|
|
804
|
+
// for the platform, even though the request was made on an organization host.
|
|
805
|
+
expect(log.organizationId).toBeNull();
|
|
806
|
+
expect(log.replacements.get('method')?.value).toBe(MFAMethodType.Passkey);
|
|
807
|
+
expect(log.replacements.get('name')?.value).toBe('YubiKey');
|
|
808
|
+
}
|
|
809
|
+
});
|
|
810
|
+
|
|
811
|
+
test('regenerating the recovery codes is logged', async () => {
|
|
812
|
+
const organization = await new OrganizationFactory({}).create();
|
|
813
|
+
const user = await new UserFactory({ organization, password }).create();
|
|
814
|
+
await addConfirmedTOTP(user);
|
|
815
|
+
|
|
816
|
+
await testServer.test(new RegenerateRecoveryCodesEndpoint(), bearer(Request.buildJson('POST', '/mfa/recovery-codes', organization.getApiHost()), await freshToken(user)));
|
|
817
|
+
|
|
818
|
+
const logs = await twoFactorLogs(user);
|
|
819
|
+
expect(logs).toHaveLength(1);
|
|
820
|
+
expect(logs[0].type).toBe(AuditLogType.UserRecoveryCodesRegenerated);
|
|
821
|
+
expect(logs[0].userId).toBe(user.id);
|
|
822
|
+
expect(logs[0].objectId).toBe(user.id);
|
|
823
|
+
expect(logs[0].replacements.get('u')?.value).toBe(user.email);
|
|
824
|
+
});
|
|
825
|
+
|
|
826
|
+
test('changes that are refused are not logged', async () => {
|
|
827
|
+
const organization = await new OrganizationFactory({}).create();
|
|
828
|
+
organization.privateMeta.requireTwoFactor = true;
|
|
829
|
+
await organization.save();
|
|
830
|
+
const user = await new UserFactory({ organization, password, permissions: Permissions.create({ level: PermissionLevel.Full }) }).create();
|
|
831
|
+
|
|
832
|
+
// A wrong confirmation code
|
|
833
|
+
const setup = await testServer.test(new SetupTOTPEndpoint(), bearer(Request.buildJson('POST', '/mfa/totp', organization.getApiHost()), await freshToken(user)));
|
|
834
|
+
const { totpId } = setup.body as { totpId: string };
|
|
835
|
+
await captureError(testServer.test(new ConfirmTOTPEndpoint(), bearer(Request.buildJson('POST', `/mfa/totp/${totpId}/confirm`, organization.getApiHost(), { code: '000000', name: 'x' }), await freshToken(user))));
|
|
836
|
+
|
|
837
|
+
// Removing the last factor while 2FA is required
|
|
838
|
+
const totp = await addConfirmedTOTP(user);
|
|
839
|
+
await captureError(testServer.test(new DeleteTOTPEndpoint(), bearer(Request.buildJson('DELETE', `/mfa/totp/${totp.id}`, organization.getApiHost()), await freshToken(user))));
|
|
840
|
+
|
|
841
|
+
expect(await twoFactorLogs(user)).toHaveLength(0);
|
|
842
|
+
});
|
|
843
|
+
});
|
|
844
|
+
|
|
845
|
+
// -----------------------------------------------------------------------
|
|
846
|
+
// Fresh-session requirement for ALL sensitive endpoints
|
|
847
|
+
// -----------------------------------------------------------------------
|
|
848
|
+
describe('sensitive endpoints require a fresh session', () => {
|
|
849
|
+
const cases: { name: string; endpoint: () => Endpoint<any, any, any, any>; build: (org: Organization) => Request }[] = [
|
|
850
|
+
{ name: 'SetupTOTP', endpoint: () => new SetupTOTPEndpoint(), build: org => Request.buildJson('POST', '/mfa/totp', org.getApiHost()) },
|
|
851
|
+
{ name: 'ConfirmTOTP', endpoint: () => new ConfirmTOTPEndpoint(), build: org => Request.buildJson('POST', '/mfa/totp/x/confirm', org.getApiHost(), { code: '000000', name: 'x' }) },
|
|
852
|
+
{ name: 'RegisterPasskeyOptions', endpoint: () => new RegisterPasskeyOptionsEndpoint(), build: org => Request.buildJson('POST', '/mfa/passkeys/options', org.getApiHost()) },
|
|
853
|
+
{ name: 'RegisterPasskey', endpoint: () => new RegisterPasskeyEndpoint(), build: org => Request.buildJson('POST', '/mfa/passkeys', org.getApiHost(), { name: 'x', response: {} }) },
|
|
854
|
+
{ name: 'DeleteTOTP', endpoint: () => new DeleteTOTPEndpoint(), build: org => Request.buildJson('DELETE', '/mfa/totp/x', org.getApiHost()) },
|
|
855
|
+
{ name: 'DeletePasskey', endpoint: () => new DeletePasskeyEndpoint(), build: org => Request.buildJson('DELETE', '/mfa/passkeys/x', org.getApiHost()) },
|
|
856
|
+
{ name: 'RegenerateRecoveryCodes', endpoint: () => new RegenerateRecoveryCodesEndpoint(), build: org => Request.buildJson('POST', '/mfa/recovery-codes', org.getApiHost()) },
|
|
857
|
+
];
|
|
858
|
+
|
|
859
|
+
for (const c of cases) {
|
|
860
|
+
test(`${c.name} rejects a non-fresh (refresh-derived) token`, async () => {
|
|
861
|
+
const organization = await new OrganizationFactory({}).create();
|
|
862
|
+
const user = await new UserFactory({ organization, password }).create();
|
|
863
|
+
const token = await staleToken(user);
|
|
864
|
+
|
|
865
|
+
const err = await captureError(testServer.test(c.endpoint() as never, bearer(c.build(organization), token)));
|
|
866
|
+
expect(err.code).toBe('require_fresh_auth');
|
|
867
|
+
});
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
test('GetMFAStatus does NOT require a fresh session (read-only)', async () => {
|
|
871
|
+
const organization = await new OrganizationFactory({}).create();
|
|
872
|
+
const user = await new UserFactory({ organization, password }).create();
|
|
873
|
+
const response = await testServer.test(new GetMFAStatusEndpoint(), bearer(Request.buildJson('GET', '/mfa', organization.getApiHost()), await staleToken(user)));
|
|
874
|
+
expect(response.body).toBeDefined();
|
|
875
|
+
});
|
|
876
|
+
});
|
|
877
|
+
|
|
878
|
+
// -----------------------------------------------------------------------
|
|
879
|
+
// MFA cannot be bypassed through alternate login grants
|
|
880
|
+
// -----------------------------------------------------------------------
|
|
881
|
+
describe('no MFA bypass via alternate grants', () => {
|
|
882
|
+
function passwordTokenLogin(organization: Organization, token: string) {
|
|
883
|
+
return Request.buildJson('POST', '/oauth/token', organization.getApiHost(), { grant_type: 'password_token', token });
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
test('password_token grant challenges MFA for a user with a factor (no bypass)', async () => {
|
|
887
|
+
const organization = await new OrganizationFactory({}).create();
|
|
888
|
+
const user = await new UserFactory({ organization, password }).create();
|
|
889
|
+
await addConfirmedTOTP(user);
|
|
890
|
+
|
|
891
|
+
const pwToken = await PasswordToken.createToken(user);
|
|
892
|
+
const err = await captureError(testServer.test(tokenEndpoint, passwordTokenLogin(organization, pwToken.token)));
|
|
893
|
+
expect(err.code).toBe('require_mfa');
|
|
894
|
+
});
|
|
895
|
+
|
|
896
|
+
test('password_token grant forces enrollment when 2FA is required but not set up', async () => {
|
|
897
|
+
const organization = await new OrganizationFactory({}).create();
|
|
898
|
+
organization.privateMeta.requireTwoFactor = true;
|
|
899
|
+
await organization.save();
|
|
900
|
+
const user = await new UserFactory({ organization, password, permissions: Permissions.create({ level: PermissionLevel.Full }) }).create();
|
|
901
|
+
|
|
902
|
+
const pwToken = await PasswordToken.createToken(user);
|
|
903
|
+
const err = await captureError(testServer.test(tokenEndpoint, passwordTokenLogin(organization, pwToken.token)));
|
|
904
|
+
expect(err.code).toBe('require_mfa_setup');
|
|
905
|
+
|
|
906
|
+
// A temporary session token is included: this flow is also used to choose a
|
|
907
|
+
// first password (invites), which the client can only do with a session. The
|
|
908
|
+
// user has no factor yet, so whoever holds the link could enroll one and get a
|
|
909
|
+
// session anyway.
|
|
910
|
+
const meta = err.meta as { setupToken: string; token: { access_token: string } | null };
|
|
911
|
+
expect(meta.setupToken).toBeTruthy();
|
|
912
|
+
expect(meta.token?.access_token).toBeTruthy();
|
|
913
|
+
|
|
914
|
+
const temporary = await Token.getByAccessToken(meta.token!.access_token);
|
|
915
|
+
expect(temporary?.user.id).toBe(user.id);
|
|
916
|
+
});
|
|
917
|
+
|
|
918
|
+
test('the password grant does not hand out a temporary session on forced enrollment', async () => {
|
|
919
|
+
const organization = await new OrganizationFactory({}).create();
|
|
920
|
+
organization.privateMeta.requireTwoFactor = true;
|
|
921
|
+
await organization.save();
|
|
922
|
+
const user = await new UserFactory({ organization, password, permissions: Permissions.create({ level: PermissionLevel.Full }) }).create();
|
|
923
|
+
|
|
924
|
+
const err = await captureError(testServer.test(tokenEndpoint, passwordLogin(organization, user.email, password)));
|
|
925
|
+
expect(err.code).toBe('require_mfa_setup');
|
|
926
|
+
expect((err.meta as { setupToken: string; token: unknown }).setupToken).toBeTruthy();
|
|
927
|
+
expect((err.meta as { token: unknown }).token).toBeNull();
|
|
928
|
+
});
|
|
929
|
+
|
|
930
|
+
test('password_token grant still issues a token when the user has no factors', async () => {
|
|
931
|
+
const organization = await new OrganizationFactory({}).create();
|
|
932
|
+
const user = await new UserFactory({ organization, password }).create();
|
|
933
|
+
|
|
934
|
+
const pwToken = await PasswordToken.createToken(user);
|
|
935
|
+
const response = await testServer.test(tokenEndpoint, passwordTokenLogin(organization, pwToken.token));
|
|
936
|
+
expect(response.body).toBeInstanceOf(TokenStruct);
|
|
937
|
+
});
|
|
938
|
+
|
|
939
|
+
test('verify-email challenges MFA for a user with a factor (no bypass)', async () => {
|
|
940
|
+
const organization = await new OrganizationFactory({}).create();
|
|
941
|
+
const user = await new UserFactory({ organization, password }).create();
|
|
942
|
+
await addConfirmedTOTP(user);
|
|
943
|
+
|
|
944
|
+
const code = await EmailVerificationCode.createFor(user, user.email);
|
|
945
|
+
const err = await captureError(testServer.test(new VerifyEmailEndpoint(), Request.buildJson('POST', '/verify-email', organization.getApiHost(), { token: code.token, code: code.code })));
|
|
946
|
+
expect(err.code).toBe('require_mfa');
|
|
947
|
+
});
|
|
948
|
+
|
|
949
|
+
test('verify-email still issues a token when the user has no factors', async () => {
|
|
950
|
+
const organization = await new OrganizationFactory({}).create();
|
|
951
|
+
const user = await new UserFactory({ organization, password }).create();
|
|
952
|
+
|
|
953
|
+
const code = await EmailVerificationCode.createFor(user, user.email);
|
|
954
|
+
const response = await testServer.test(new VerifyEmailEndpoint(), Request.buildJson('POST', '/verify-email', organization.getApiHost(), { token: code.token, code: code.code }));
|
|
955
|
+
expect(response.body).toBeInstanceOf(TokenStruct);
|
|
956
|
+
});
|
|
957
|
+
|
|
958
|
+
test('verify-email does not challenge a user that is already signed in (changing their email address)', async () => {
|
|
959
|
+
const organization = await new OrganizationFactory({}).create();
|
|
960
|
+
const user = await new UserFactory({ organization, password }).create();
|
|
961
|
+
await addConfirmedTOTP(user);
|
|
962
|
+
const newEmail = 'changed-' + user.email;
|
|
963
|
+
|
|
964
|
+
// The user passed their second factor when this session was created.
|
|
965
|
+
const session = await freshToken(user);
|
|
966
|
+
|
|
967
|
+
const code = await EmailVerificationCode.createFor(user, newEmail);
|
|
968
|
+
const response = await testServer.test(new VerifyEmailEndpoint(), bearer(Request.buildJson('POST', '/verify-email', organization.getApiHost(), { token: code.token, code: code.code }), session));
|
|
969
|
+
expect(response.body).toBeInstanceOf(TokenStruct);
|
|
970
|
+
|
|
971
|
+
const updated = await User.getByID(user.id);
|
|
972
|
+
expect(updated?.email).toBe(newEmail);
|
|
973
|
+
});
|
|
974
|
+
|
|
975
|
+
test('verify-email challenges when the session belongs to another user', async () => {
|
|
976
|
+
const organization = await new OrganizationFactory({}).create();
|
|
977
|
+
const user = await new UserFactory({ organization, password }).create();
|
|
978
|
+
await addConfirmedTOTP(user);
|
|
979
|
+
const other = await new UserFactory({ organization, password }).create();
|
|
980
|
+
|
|
981
|
+
const code = await EmailVerificationCode.createFor(user, user.email);
|
|
982
|
+
const err = await captureError(testServer.test(new VerifyEmailEndpoint(), bearer(Request.buildJson('POST', '/verify-email', organization.getApiHost(), { token: code.token, code: code.code }), await freshToken(other))));
|
|
983
|
+
expect(err.code).toBe('require_mfa');
|
|
984
|
+
});
|
|
985
|
+
});
|
|
986
|
+
|
|
987
|
+
// -----------------------------------------------------------------------
|
|
988
|
+
// TOTP replay protection
|
|
989
|
+
// -----------------------------------------------------------------------
|
|
990
|
+
describe('mfa grant - TOTP replay protection', () => {
|
|
991
|
+
test('a still-valid TOTP code cannot be reused for a second login', async () => {
|
|
992
|
+
const organization = await new OrganizationFactory({}).create();
|
|
993
|
+
const user = await new UserFactory({ organization, password }).create();
|
|
994
|
+
const { secret } = await addConfirmedTOTP(user);
|
|
995
|
+
|
|
996
|
+
// A single code, used for the first login and then replayed while still valid.
|
|
997
|
+
const code = authenticator.generate(secret);
|
|
998
|
+
|
|
999
|
+
const challenge1 = await requireMfa(organization, user.email, password);
|
|
1000
|
+
const ok = await testServer.test(tokenEndpoint, mfaGrant(organization, { mfa_token: challenge1.token, method: 'TOTP', code }));
|
|
1001
|
+
expect(ok.body).toBeInstanceOf(TokenStruct);
|
|
1002
|
+
|
|
1003
|
+
const challenge2 = await requireMfa(organization, user.email, password);
|
|
1004
|
+
const replay = await captureError(testServer.test(tokenEndpoint, mfaGrant(organization, { mfa_token: challenge2.token, method: 'TOTP', code })));
|
|
1005
|
+
expect(replay.code).toBe('invalid_mfa_code');
|
|
1006
|
+
});
|
|
1007
|
+
|
|
1008
|
+
test('a TOTP code used during confirmation cannot be replayed as a login', async () => {
|
|
1009
|
+
const organization = await new OrganizationFactory({}).create();
|
|
1010
|
+
const user = await new UserFactory({ organization, password }).create();
|
|
1011
|
+
const token = await freshToken(user);
|
|
1012
|
+
|
|
1013
|
+
const setup = await testServer.test(new SetupTOTPEndpoint(), bearer(Request.buildJson('POST', '/mfa/totp', organization.getApiHost()), token));
|
|
1014
|
+
const { totpId, secret } = setup.body as { totpId: string; secret: string };
|
|
1015
|
+
|
|
1016
|
+
const code = authenticator.generate(secret);
|
|
1017
|
+
await testServer.test(new ConfirmTOTPEndpoint(), bearer(Request.buildJson('POST', `/mfa/totp/${totpId}/confirm`, organization.getApiHost(), { code, name: 'Phone' }), await freshToken(user)));
|
|
1018
|
+
|
|
1019
|
+
const challenge = await requireMfa(organization, user.email, password);
|
|
1020
|
+
const replay = await captureError(testServer.test(tokenEndpoint, mfaGrant(organization, { mfa_token: challenge.token, method: 'TOTP', code })));
|
|
1021
|
+
expect(replay.code).toBe('invalid_mfa_code');
|
|
1022
|
+
});
|
|
1023
|
+
|
|
1024
|
+
test('a step counter is claimed by one request only', async () => {
|
|
1025
|
+
const organization = await new OrganizationFactory({}).create();
|
|
1026
|
+
const user = await new UserFactory({ organization, password }).create();
|
|
1027
|
+
const { id } = await addConfirmedTOTP(user);
|
|
1028
|
+
|
|
1029
|
+
// Two requests that read the authenticator before either of them wrote to it.
|
|
1030
|
+
const first = (await MFATOTP.getByID(id))!;
|
|
1031
|
+
const second = (await MFATOTP.getByID(id))!;
|
|
1032
|
+
|
|
1033
|
+
const claims = await Promise.all([first.claimCounter(42), second.claimCounter(42)]);
|
|
1034
|
+
expect(claims.filter(claimed => claimed)).toHaveLength(1);
|
|
1035
|
+
|
|
1036
|
+
// A later code is still accepted, an earlier one never again.
|
|
1037
|
+
expect(await (await MFATOTP.getByID(id))!.claimCounter(43)).toBe(true);
|
|
1038
|
+
expect(await (await MFATOTP.getByID(id))!.claimCounter(42)).toBe(false);
|
|
1039
|
+
});
|
|
1040
|
+
|
|
1041
|
+
test('one challenge hands out one session, even when it is completed twice at once', async () => {
|
|
1042
|
+
const organization = await new OrganizationFactory({}).create();
|
|
1043
|
+
const user = await new UserFactory({ organization, password }).create();
|
|
1044
|
+
const { secret } = await addConfirmedTOTP(user);
|
|
1045
|
+
const code = authenticator.generate(secret);
|
|
1046
|
+
|
|
1047
|
+
const challenge = await requireMfa(organization, user.email, password);
|
|
1048
|
+
const results = await Promise.allSettled([
|
|
1049
|
+
testServer.test(tokenEndpoint, mfaGrant(organization, { mfa_token: challenge.token, method: 'TOTP', code })),
|
|
1050
|
+
testServer.test(tokenEndpoint, mfaGrant(organization, { mfa_token: challenge.token, method: 'TOTP', code })),
|
|
1051
|
+
]);
|
|
1052
|
+
|
|
1053
|
+
expect(results.filter(r => r.status === 'fulfilled')).toHaveLength(1);
|
|
1054
|
+
expect(await Token.where({ userId: user.id })).toHaveLength(1);
|
|
1055
|
+
});
|
|
1056
|
+
});
|
|
1057
|
+
|
|
1058
|
+
// -----------------------------------------------------------------------
|
|
1059
|
+
// Aggregate (cross-token) brute-force protection
|
|
1060
|
+
// -----------------------------------------------------------------------
|
|
1061
|
+
describe('mfa grant - aggregate rate limiting', () => {
|
|
1062
|
+
test('once the aggregate limit is hit, even a correct code is rejected', async () => {
|
|
1063
|
+
const organization = await new OrganizationFactory({}).create();
|
|
1064
|
+
const user = await new UserFactory({ organization, password }).create();
|
|
1065
|
+
const { secret } = await addConfirmedTOTP(user);
|
|
1066
|
+
|
|
1067
|
+
// Each attempt uses a brand new challenge token so the per-token limit (5) never
|
|
1068
|
+
// trips; only the aggregate per-user limiter can stop this.
|
|
1069
|
+
for (let i = 0; i < 10; i++) {
|
|
1070
|
+
const challenge = await requireMfa(organization, user.email, password);
|
|
1071
|
+
const e = await captureError(testServer.test(tokenEndpoint, mfaGrant(organization, { mfa_token: challenge.token, method: 'TOTP', code: '000000' })));
|
|
1072
|
+
expect(e.code).toBe('invalid_mfa_code');
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
// The critical case: a *correct* code must NOT be a way around the rate limit.
|
|
1076
|
+
// The limiter is checked before verification, so brute-forcing until a lucky
|
|
1077
|
+
// (or leaked) code lands still gets rejected once the limit is exceeded.
|
|
1078
|
+
const challenge = await requireMfa(organization, user.email, password);
|
|
1079
|
+
const limited = await captureError(testServer.test(tokenEndpoint, mfaGrant(organization, { mfa_token: challenge.token, method: 'TOTP', code: authenticator.generate(secret) })));
|
|
1080
|
+
expect(limited.code).toBe('rate_limit');
|
|
1081
|
+
});
|
|
1082
|
+
});
|
|
1083
|
+
|
|
1084
|
+
// -----------------------------------------------------------------------
|
|
1085
|
+
// Recovery code generation + consumption
|
|
1086
|
+
// -----------------------------------------------------------------------
|
|
1087
|
+
describe('recovery codes', () => {
|
|
1088
|
+
test('uses a readable, duplicate-free 32-symbol alphabet', () => {
|
|
1089
|
+
expect(RECOVERY_CODE_ALPHABET.length).toBe(32);
|
|
1090
|
+
// No ambiguous characters (0/O, 1/I, L).
|
|
1091
|
+
expect(RECOVERY_CODE_ALPHABET).not.toMatch(/[0OIL]/);
|
|
1092
|
+
// base-x requires every symbol to be distinct.
|
|
1093
|
+
expect(new Set(RECOVERY_CODE_ALPHABET).size).toBe(RECOVERY_CODE_ALPHABET.length);
|
|
1094
|
+
});
|
|
1095
|
+
|
|
1096
|
+
test('generated codes only use the alphabet and the XXXX-XXXX-XXXX-XXXX format', async () => {
|
|
1097
|
+
const organization = await new OrganizationFactory({}).create();
|
|
1098
|
+
const user = await new UserFactory({ organization, password }).create();
|
|
1099
|
+
|
|
1100
|
+
const codes = await RecoveryCodeHelper.regenerateForUser(user.id);
|
|
1101
|
+
expect(codes.length).toBeGreaterThan(0);
|
|
1102
|
+
|
|
1103
|
+
const allowed = new RegExp(`^([${RECOVERY_CODE_ALPHABET}]{4}-){3}[${RECOVERY_CODE_ALPHABET}]{4}$`);
|
|
1104
|
+
for (const code of codes) {
|
|
1105
|
+
expect(code).toMatch(allowed);
|
|
1106
|
+
}
|
|
1107
|
+
});
|
|
1108
|
+
|
|
1109
|
+
test('the same recovery code cannot be consumed twice concurrently', async () => {
|
|
1110
|
+
const organization = await new OrganizationFactory({}).create();
|
|
1111
|
+
const user = await new UserFactory({ organization, password }).create();
|
|
1112
|
+
const codes = await RecoveryCodeHelper.regenerateForUser(user.id);
|
|
1113
|
+
|
|
1114
|
+
const results = await Promise.all([
|
|
1115
|
+
RecoveryCodeHelper.consume(user.id, codes[0]),
|
|
1116
|
+
RecoveryCodeHelper.consume(user.id, codes[0]),
|
|
1117
|
+
]);
|
|
1118
|
+
expect(results.filter(Boolean).length).toBe(1);
|
|
1119
|
+
});
|
|
1120
|
+
});
|
|
1121
|
+
|
|
1122
|
+
// -----------------------------------------------------------------------
|
|
1123
|
+
// Setup hygiene: don't leak orphaned unconfirmed authenticators
|
|
1124
|
+
// -----------------------------------------------------------------------
|
|
1125
|
+
describe('setup hygiene', () => {
|
|
1126
|
+
test('starting a new TOTP setup removes earlier unconfirmed authenticators', async () => {
|
|
1127
|
+
const organization = await new OrganizationFactory({}).create();
|
|
1128
|
+
const user = await new UserFactory({ organization, password }).create();
|
|
1129
|
+
|
|
1130
|
+
const first = await testServer.test(new SetupTOTPEndpoint(), bearer(Request.buildJson('POST', '/mfa/totp', organization.getApiHost()), await freshToken(user)));
|
|
1131
|
+
const firstId = (first.body as { totpId: string }).totpId;
|
|
1132
|
+
|
|
1133
|
+
const second = await testServer.test(new SetupTOTPEndpoint(), bearer(Request.buildJson('POST', '/mfa/totp', organization.getApiHost()), await freshToken(user)));
|
|
1134
|
+
const secondId = (second.body as { totpId: string }).totpId;
|
|
1135
|
+
|
|
1136
|
+
expect(await MFATOTP.getByID(firstId)).toBeUndefined();
|
|
1137
|
+
expect(await MFATOTP.getByID(secondId)).toBeDefined();
|
|
1138
|
+
});
|
|
1139
|
+
|
|
1140
|
+
test('starting a new TOTP setup keeps already-confirmed authenticators', async () => {
|
|
1141
|
+
const organization = await new OrganizationFactory({}).create();
|
|
1142
|
+
const user = await new UserFactory({ organization, password }).create();
|
|
1143
|
+
const confirmed = await addConfirmedTOTP(user);
|
|
1144
|
+
|
|
1145
|
+
await testServer.test(new SetupTOTPEndpoint(), bearer(Request.buildJson('POST', '/mfa/totp', organization.getApiHost()), await freshToken(user)));
|
|
1146
|
+
expect(await MFATOTP.getByID(confirmed.id)).toBeDefined();
|
|
1147
|
+
});
|
|
1148
|
+
|
|
1149
|
+
test('a new challenge replaces the previous one instead of piling up', async () => {
|
|
1150
|
+
const organization = await new OrganizationFactory({}).create();
|
|
1151
|
+
const user = await new UserFactory({ organization, password }).create();
|
|
1152
|
+
await addConfirmedTOTP(user);
|
|
1153
|
+
|
|
1154
|
+
const first = await requireMfa(organization, user.email, password);
|
|
1155
|
+
const second = await requireMfa(organization, user.email, password);
|
|
1156
|
+
|
|
1157
|
+
expect(await MFAToken.select().where('userId', user.id).fetch()).toHaveLength(1);
|
|
1158
|
+
expect(await MFAToken.getValid(first.token, 'login')).toBeUndefined();
|
|
1159
|
+
expect(await MFAToken.getValid(second.token, 'login')).toBeDefined();
|
|
1160
|
+
});
|
|
1161
|
+
|
|
1162
|
+
test('expired tokens and challenges are swept up', async () => {
|
|
1163
|
+
const organization = await new OrganizationFactory({}).create();
|
|
1164
|
+
const user = await new UserFactory({ organization, password }).create();
|
|
1165
|
+
|
|
1166
|
+
const token = await MFAToken.createFor(user.id, 'login');
|
|
1167
|
+
token.expiresAt = new Date(Date.now() - 1000);
|
|
1168
|
+
await token.save();
|
|
1169
|
+
|
|
1170
|
+
const challenge = await WebauthnChallenge.createFor(user.id, 'abandoned-challenge');
|
|
1171
|
+
challenge.expiresAt = new Date(Date.now() - 1000);
|
|
1172
|
+
await challenge.save();
|
|
1173
|
+
|
|
1174
|
+
expect(await MFAToken.deleteExpired()).toBeGreaterThanOrEqual(1);
|
|
1175
|
+
expect(await WebauthnChallenge.deleteExpired()).toBeGreaterThanOrEqual(1);
|
|
1176
|
+
|
|
1177
|
+
expect(await MFAToken.select().where('token', token.token).first(false)).toBeNull();
|
|
1178
|
+
expect(await WebauthnChallenge.getByID(challenge.id)).toBeUndefined();
|
|
1179
|
+
});
|
|
1180
|
+
});
|
|
1181
|
+
|
|
1182
|
+
// -----------------------------------------------------------------------
|
|
1183
|
+
// Recovery codes follow the factors they belong to
|
|
1184
|
+
// -----------------------------------------------------------------------
|
|
1185
|
+
describe('recovery code lifecycle', () => {
|
|
1186
|
+
test('removing the last factor also removes the recovery codes', async () => {
|
|
1187
|
+
const organization = await new OrganizationFactory({}).create();
|
|
1188
|
+
const user = await new UserFactory({ organization, password }).create();
|
|
1189
|
+
const totp = await addConfirmedTOTP(user);
|
|
1190
|
+
await RecoveryCodeHelper.regenerateForUser(user.id);
|
|
1191
|
+
|
|
1192
|
+
const response = await testServer.test(new DeleteTOTPEndpoint(), bearer(Request.buildJson('DELETE', `/mfa/totp/${totp.id}`, organization.getApiHost()), await freshToken(user)));
|
|
1193
|
+
|
|
1194
|
+
expect((response.body as { hasRecoveryCodes: boolean }).hasRecoveryCodes).toBe(false);
|
|
1195
|
+
expect(await MFARecoveryCode.getForUser(user.id)).toHaveLength(0);
|
|
1196
|
+
});
|
|
1197
|
+
|
|
1198
|
+
test('removing one of several factors keeps the recovery codes', async () => {
|
|
1199
|
+
const organization = await new OrganizationFactory({}).create();
|
|
1200
|
+
const user = await new UserFactory({ organization, password }).create();
|
|
1201
|
+
const totp = await addConfirmedTOTP(user);
|
|
1202
|
+
await addPasskey(user);
|
|
1203
|
+
const codes = await RecoveryCodeHelper.regenerateForUser(user.id);
|
|
1204
|
+
|
|
1205
|
+
await testServer.test(new DeleteTOTPEndpoint(), bearer(Request.buildJson('DELETE', `/mfa/totp/${totp.id}`, organization.getApiHost()), await freshToken(user)));
|
|
1206
|
+
|
|
1207
|
+
expect(await MFARecoveryCode.getUnusedForUser(user.id)).toHaveLength(codes.length);
|
|
1208
|
+
});
|
|
1209
|
+
});
|
|
1210
|
+
|
|
1211
|
+
// -----------------------------------------------------------------------
|
|
1212
|
+
// What SSO logins still have to do
|
|
1213
|
+
// -----------------------------------------------------------------------
|
|
1214
|
+
describe('second factor requirement per login method', () => {
|
|
1215
|
+
async function adminOfOrgRequiringTwoFactor({ withPassword }: { withPassword: boolean }) {
|
|
1216
|
+
const organization = await new OrganizationFactory({}).create();
|
|
1217
|
+
organization.privateMeta.requireTwoFactor = true;
|
|
1218
|
+
await organization.save();
|
|
1219
|
+
|
|
1220
|
+
const user = await new UserFactory({ organization, password, permissions: Permissions.create({ level: PermissionLevel.Full }) }).create();
|
|
1221
|
+
if (!withPassword) {
|
|
1222
|
+
// An SSO-only account: it was never given a password.
|
|
1223
|
+
user.password = null;
|
|
1224
|
+
await user.save();
|
|
1225
|
+
}
|
|
1226
|
+
return { organization, user };
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
test('an SSO login still has to pass an enrolled second factor', async () => {
|
|
1230
|
+
const { organization, user } = await adminOfOrgRequiringTwoFactor({ withPassword: false });
|
|
1231
|
+
await addConfirmedTOTP(user);
|
|
1232
|
+
|
|
1233
|
+
const requirement = await TwoFactorHelper.getSecondFactorRequirement(user, organization, { loginMethod: 'sso' });
|
|
1234
|
+
expect(requirement.type).toBe('challenge');
|
|
1235
|
+
});
|
|
1236
|
+
|
|
1237
|
+
test('an SSO-only account is not forced to enroll: the provider is the second factor', async () => {
|
|
1238
|
+
const { organization, user } = await adminOfOrgRequiringTwoFactor({ withPassword: false });
|
|
1239
|
+
|
|
1240
|
+
const requirement = await TwoFactorHelper.getSecondFactorRequirement(user, organization, { loginMethod: 'sso' });
|
|
1241
|
+
expect(requirement.type).toBe('none');
|
|
1242
|
+
});
|
|
1243
|
+
|
|
1244
|
+
test('an account that also has a password is forced to enroll, even through SSO', async () => {
|
|
1245
|
+
// The password stays a way in that skips whatever the provider enforces, so the
|
|
1246
|
+
// requirement is not satisfied by signing in through SSO.
|
|
1247
|
+
const { organization, user } = await adminOfOrgRequiringTwoFactor({ withPassword: true });
|
|
1248
|
+
|
|
1249
|
+
const requirement = await TwoFactorHelper.getSecondFactorRequirement(user, organization, { loginMethod: 'sso' });
|
|
1250
|
+
expect(requirement.type).toBe('setup');
|
|
1251
|
+
});
|
|
1252
|
+
|
|
1253
|
+
test('a password login is always forced to enroll', async () => {
|
|
1254
|
+
const { organization, user } = await adminOfOrgRequiringTwoFactor({ withPassword: true });
|
|
1255
|
+
|
|
1256
|
+
const requirement = await TwoFactorHelper.getSecondFactorRequirement(user, organization, { loginMethod: 'password' });
|
|
1257
|
+
expect(requirement.type).toBe('setup');
|
|
1258
|
+
});
|
|
1259
|
+
});
|
|
1260
|
+
|
|
1261
|
+
// -----------------------------------------------------------------------
|
|
1262
|
+
// Passkeys are bound to one domain, which limits who can use them
|
|
1263
|
+
// -----------------------------------------------------------------------
|
|
1264
|
+
describe('passkey availability', () => {
|
|
1265
|
+
test('an account of a single organization cannot start a passkey enrollment', async () => {
|
|
1266
|
+
// Those accounts are expected to move to their own authentication domain, which
|
|
1267
|
+
// would strand any passkey enrolled against the dashboard domain.
|
|
1268
|
+
const organization = await new OrganizationFactory({}).create();
|
|
1269
|
+
const user = await new UserFactory({ organization, password }).create();
|
|
1270
|
+
expect(user.organizationId).not.toBeNull();
|
|
1271
|
+
|
|
1272
|
+
const err = await captureError(testServer.test(new RegisterPasskeyOptionsEndpoint(), bearer(Request.buildJson('POST', '/mfa/passkeys/options', organization.getApiHost()), await freshToken(user))));
|
|
1273
|
+
expect(err.code).toBe('passkeys_not_available');
|
|
1274
|
+
});
|
|
1275
|
+
|
|
1276
|
+
test('an account of a single organization cannot register a passkey', async () => {
|
|
1277
|
+
const organization = await new OrganizationFactory({}).create();
|
|
1278
|
+
const user = await new UserFactory({ organization, password }).create();
|
|
1279
|
+
|
|
1280
|
+
const err = await captureError(testServer.test(new RegisterPasskeyEndpoint(), bearer(Request.buildJson('POST', '/mfa/passkeys', organization.getApiHost(), {
|
|
1281
|
+
name: 'My key',
|
|
1282
|
+
response: { id: 'x', rawId: 'x', type: 'public-key', response: { clientDataJSON: 'x', attestationObject: 'x' } },
|
|
1283
|
+
}), await freshToken(user))));
|
|
1284
|
+
expect(err.code).toBe('passkeys_not_available');
|
|
1285
|
+
});
|
|
1286
|
+
|
|
1287
|
+
test('the status tells the client whether passkeys are available', async () => {
|
|
1288
|
+
const organization = await new OrganizationFactory({}).create();
|
|
1289
|
+
const organizationUser = await new UserFactory({ organization, password }).create();
|
|
1290
|
+
const platformAdmin = await platformUser();
|
|
1291
|
+
|
|
1292
|
+
const scoped = await testServer.test(new GetMFAStatusEndpoint(), bearer(Request.buildJson('GET', '/mfa', organization.getApiHost()), await freshToken(organizationUser)));
|
|
1293
|
+
expect((scoped.body as { canUsePasskeys: boolean }).canUsePasskeys).toBe(false);
|
|
1294
|
+
|
|
1295
|
+
const platform = await testServer.test(new GetMFAStatusEndpoint(), bearer(Request.buildJson('GET', '/mfa', undefined), await freshToken(platformAdmin)));
|
|
1296
|
+
expect((platform.body as { canUsePasskeys: boolean }).canUsePasskeys).toBe(true);
|
|
1297
|
+
});
|
|
1298
|
+
|
|
1299
|
+
test('the forced enrollment error tells the client whether passkeys are available', async () => {
|
|
1300
|
+
const organization = await new OrganizationFactory({}).create();
|
|
1301
|
+
organization.privateMeta.requireTwoFactor = true;
|
|
1302
|
+
await organization.save();
|
|
1303
|
+
const user = await new UserFactory({ organization, password, permissions: Permissions.create({ level: PermissionLevel.Full }) }).create();
|
|
1304
|
+
|
|
1305
|
+
const err = await captureError(testServer.test(tokenEndpoint, passwordLogin(organization, user.email, password)));
|
|
1306
|
+
expect(err.code).toBe('require_mfa_setup');
|
|
1307
|
+
expect((err.meta as { canUsePasskeys: boolean }).canUsePasskeys).toBe(false);
|
|
1308
|
+
});
|
|
1309
|
+
|
|
1310
|
+
test('a passkey of another relying party is not offered at login', async () => {
|
|
1311
|
+
// What a credential from a different authentication domain would look like once
|
|
1312
|
+
// the platform serves more than one.
|
|
1313
|
+
const organization = await new OrganizationFactory({}).create();
|
|
1314
|
+
const user = await new UserFactory({ organization, password }).create();
|
|
1315
|
+
await addConfirmedTOTP(user);
|
|
1316
|
+
|
|
1317
|
+
const foreign = await addPasskey(user);
|
|
1318
|
+
foreign.rpId = 'someone-else.example.com';
|
|
1319
|
+
await foreign.save();
|
|
1320
|
+
|
|
1321
|
+
const challenge = await requireMfa(organization, user.email, password);
|
|
1322
|
+
expect(challenge.methods).toContain('TOTP');
|
|
1323
|
+
expect(challenge.methods).not.toContain('Passkey');
|
|
1324
|
+
expect(challenge.webauthnAuthenticationOptions).toBeNull();
|
|
1325
|
+
});
|
|
1326
|
+
|
|
1327
|
+
test('a passkey of our own relying party is still offered', async () => {
|
|
1328
|
+
const organization = await new OrganizationFactory({}).create();
|
|
1329
|
+
const user = await new UserFactory({ organization, password }).create();
|
|
1330
|
+
|
|
1331
|
+
const credential = await addPasskey(user);
|
|
1332
|
+
credential.rpId = WebauthnHelper.getRpID();
|
|
1333
|
+
await credential.save();
|
|
1334
|
+
|
|
1335
|
+
const challenge = await requireMfa(organization, user.email, password);
|
|
1336
|
+
expect(challenge.methods).toContain('Passkey');
|
|
1337
|
+
});
|
|
1338
|
+
|
|
1339
|
+
test('the native apps are accepted as an origin of the dashboard domain', async () => {
|
|
1340
|
+
// The app's web view is served from the dashboard host on a custom scheme, and
|
|
1341
|
+
// the app is listed in the associated domains of that host, so it gets handed
|
|
1342
|
+
// the same passkeys. Dropping this origin silently breaks passkeys in the apps.
|
|
1343
|
+
const rpId = WebauthnHelper.getRpID();
|
|
1344
|
+
const origins = WebauthnHelper.getExpectedOrigins(rpId);
|
|
1345
|
+
|
|
1346
|
+
expect(origins).toContain('https://' + rpId);
|
|
1347
|
+
expect(origins).toContain('capacitor://' + rpId);
|
|
1348
|
+
});
|
|
1349
|
+
|
|
1350
|
+
test('a credential without a stored relying party falls back to the platform one', async () => {
|
|
1351
|
+
// Rows created before rpId was stored.
|
|
1352
|
+
const organization = await new OrganizationFactory({}).create();
|
|
1353
|
+
const user = await new UserFactory({ organization, password }).create();
|
|
1354
|
+
|
|
1355
|
+
const credential = await addPasskey(user);
|
|
1356
|
+
expect(credential.rpId).toBeNull();
|
|
1357
|
+
|
|
1358
|
+
const challenge = await requireMfa(organization, user.email, password);
|
|
1359
|
+
expect(challenge.methods).toContain('Passkey');
|
|
1360
|
+
});
|
|
1361
|
+
});
|
|
1362
|
+
|
|
1363
|
+
// -----------------------------------------------------------------------
|
|
1364
|
+
// Resolving a challenge that arrived through a redirect (SSO)
|
|
1365
|
+
// -----------------------------------------------------------------------
|
|
1366
|
+
describe('challenge endpoint', () => {
|
|
1367
|
+
test('describes the methods of a pending login', async () => {
|
|
1368
|
+
const organization = await new OrganizationFactory({}).create();
|
|
1369
|
+
const user = await new UserFactory({ organization, password }).create();
|
|
1370
|
+
await addConfirmedTOTP(user);
|
|
1371
|
+
await RecoveryCodeHelper.regenerateForUser(user.id);
|
|
1372
|
+
|
|
1373
|
+
const mfaToken = await MFAToken.createFor(user.id, 'login');
|
|
1374
|
+
const response = await testServer.test(new GetMFAChallengeEndpoint(), Request.buildJson('POST', '/mfa/challenge', organization.getApiHost(), { token: mfaToken.token }));
|
|
1375
|
+
|
|
1376
|
+
const body = response.body as { token: string; methods: string[] };
|
|
1377
|
+
expect(body.token).toBe(mfaToken.token);
|
|
1378
|
+
expect(body.methods).toEqual(expect.arrayContaining(['TOTP', 'RecoveryCode']));
|
|
1379
|
+
});
|
|
1380
|
+
|
|
1381
|
+
test('stores a fresh webauthn challenge on the token', async () => {
|
|
1382
|
+
const organization = await new OrganizationFactory({}).create();
|
|
1383
|
+
const user = await new UserFactory({ organization, password }).create();
|
|
1384
|
+
await addPasskey(user);
|
|
1385
|
+
|
|
1386
|
+
const mfaToken = await MFAToken.createFor(user.id, 'login');
|
|
1387
|
+
expect(mfaToken.webauthnChallenge).toBeNull();
|
|
1388
|
+
|
|
1389
|
+
const response = await testServer.test(new GetMFAChallengeEndpoint(), Request.buildJson('POST', '/mfa/challenge', organization.getApiHost(), { token: mfaToken.token }));
|
|
1390
|
+
|
|
1391
|
+
const options = (response.body as { webauthnAuthenticationOptions: { challenge?: string } }).webauthnAuthenticationOptions;
|
|
1392
|
+
expect(options.challenge).toBeTruthy();
|
|
1393
|
+
|
|
1394
|
+
const stored = await MFAToken.select().where('token', mfaToken.token).first(true);
|
|
1395
|
+
expect(stored.webauthnChallenge).toBe(options.challenge);
|
|
1396
|
+
});
|
|
1397
|
+
});
|
|
1398
|
+
});
|