@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.
Files changed (142) hide show
  1. package/package.json +26 -17
  2. package/src/boot.ts +5 -0
  3. package/src/crons/balance-emails.ts +2 -1
  4. package/src/crons/delete-expired-mfa-tokens.ts +35 -0
  5. package/src/crons/drip-emails.ts +2 -1
  6. package/src/crons/index.ts +1 -0
  7. package/src/crons/invoices.ts +5 -3
  8. package/src/crons.ts +4 -2
  9. package/src/email-recipient-loaders/orders.ts +2 -1
  10. package/src/email-recipient-loaders/organizations.ts +3 -2
  11. package/src/email-recipient-loaders/payments.ts +3 -2
  12. package/src/endpoints/auth/ConfirmTOTPEndpoint.ts +84 -0
  13. package/src/endpoints/auth/CreateAdminEndpoint.ts +4 -2
  14. package/src/endpoints/auth/CreateTokenEndpoint.test.ts +61 -1
  15. package/src/endpoints/auth/CreateTokenEndpoint.ts +135 -7
  16. package/src/endpoints/auth/DeletePasskeyEndpoint.ts +61 -0
  17. package/src/endpoints/auth/DeleteTOTPEndpoint.ts +62 -0
  18. package/src/endpoints/auth/DeleteUserEndpoint.ts +1 -1
  19. package/src/endpoints/auth/ForgotPasswordEndpoint.ts +4 -2
  20. package/src/endpoints/auth/GetMFAChallengeEndpoint.ts +56 -0
  21. package/src/endpoints/auth/GetMFAStatusEndpoint.ts +31 -0
  22. package/src/endpoints/auth/GetUserEndpoint.test.ts +32 -1
  23. package/src/endpoints/auth/MFA.security.test.ts +688 -0
  24. package/src/endpoints/auth/MFA.test.ts +1398 -0
  25. package/src/endpoints/auth/OpenIDConnectAuthTokenEndpoint.ts +2 -2
  26. package/src/endpoints/auth/PatchUserEndpoint.ts +6 -3
  27. package/src/endpoints/auth/RegenerateRecoveryCodesEndpoint.ts +51 -0
  28. package/src/endpoints/auth/RegisterPasskeyEndpoint.ts +99 -0
  29. package/src/endpoints/auth/RegisterPasskeyOptionsEndpoint.ts +49 -0
  30. package/src/endpoints/auth/RetryEmailVerificationEndpoint.ts +2 -1
  31. package/src/endpoints/auth/SetupTOTPEndpoint.ts +54 -0
  32. package/src/endpoints/auth/SignupEndpoint.ts +17 -4
  33. package/src/endpoints/auth/VerifyEmailEndpoint.ts +16 -0
  34. package/src/endpoints/global/email/CreateEmailEndpoint.ts +5 -3
  35. package/src/endpoints/global/email/GetAdminEmailsEndpoint.test.ts +1 -1
  36. package/src/endpoints/global/email/GetAdminEmailsEndpoint.ts +2 -1
  37. package/src/endpoints/global/email/GetEmailEndpoint.ts +2 -1
  38. package/src/endpoints/global/email/GetUserEmailsEndpoint.test.ts +1 -1
  39. package/src/endpoints/global/email/GetUserEmailsEndpoint.ts +2 -1
  40. package/src/endpoints/global/email/PatchEmailEndpoint.ts +5 -3
  41. package/src/endpoints/global/email-recipients/GetEmailRecipientsEndpoint.ts +2 -1
  42. package/src/endpoints/global/email-recipients/RetryEmailRecipientEndpoint.ts +2 -1
  43. package/src/endpoints/global/files/ExportToExcelEndpoint.ts +11 -6
  44. package/src/endpoints/global/files/UploadFile.test.ts +206 -0
  45. package/src/endpoints/global/files/UploadFile.ts +31 -6
  46. package/src/endpoints/global/files/UploadImage.test.ts +177 -0
  47. package/src/endpoints/global/files/UploadImage.ts +23 -4
  48. package/src/endpoints/global/files/upload-security.test.ts +837 -0
  49. package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.ts +1 -1
  50. package/src/endpoints/global/members/SendMemberSecurityCodeEndpoint.ts +2 -1
  51. package/src/endpoints/global/organizations/CreateOrganizationEndpoint.ts +2 -1
  52. package/src/endpoints/global/platform/GetPlatformAdminsEndpoint.ts +4 -1
  53. package/src/endpoints/global/platform/PatchPlatformEnpoint.test.ts +35 -1
  54. package/src/endpoints/global/platform/PatchPlatformEnpoint.ts +8 -0
  55. package/src/endpoints/global/platform/SignOutPlatformAdminsEndpoint.test.ts +92 -0
  56. package/src/endpoints/global/platform/SignOutPlatformAdminsEndpoint.ts +43 -0
  57. package/src/endpoints/organization/dashboard/documents/GetDocumentTemplateXML.ts +2 -1
  58. package/src/endpoints/organization/dashboard/organization/PatchOrganizationEndpoint.ts +1 -0
  59. package/src/endpoints/organization/dashboard/organization/SetOrganizationDomainEndpoint.ts +3 -2
  60. package/src/endpoints/organization/dashboard/receivable-balances/ChargeReceivableBalancesEndpoint.ts +3 -2
  61. package/src/endpoints/organization/dashboard/users/CreateApiUserEndpoint.test.ts +32 -3
  62. package/src/endpoints/organization/dashboard/users/CreateApiUserEndpoint.ts +4 -1
  63. package/src/endpoints/organization/dashboard/users/GetOrganizationAdminsEndpoint.test.ts +100 -0
  64. package/src/endpoints/organization/dashboard/users/GetOrganizationAdminsEndpoint.ts +4 -1
  65. package/src/endpoints/organization/dashboard/users/PatchApiUserEndpoint.test.ts +5 -5
  66. package/src/endpoints/organization/dashboard/users/PatchApiUserEndpoint.ts +5 -3
  67. package/src/endpoints/organization/dashboard/users/SignOutOrganizationAdminsEndpoint.test.ts +160 -0
  68. package/src/endpoints/organization/dashboard/users/SignOutOrganizationAdminsEndpoint.ts +44 -0
  69. package/src/endpoints/organization/dashboard/webshops/PatchWebshopOrdersEndpoint.ts +4 -3
  70. package/src/endpoints/organization/shared/GetDocumentHtml.ts +2 -1
  71. package/src/endpoints/organization/webshops/PlaceOrderEndpoint.ts +4 -3
  72. package/src/excel-loaders/balance-items.ts +1 -1
  73. package/src/excel-loaders/event-notifications.ts +6 -7
  74. package/src/excel-loaders/members.test.ts +2 -2
  75. package/src/excel-loaders/members.ts +9 -10
  76. package/src/excel-loaders/organizations.ts +14 -20
  77. package/src/excel-loaders/payments.ts +1 -1
  78. package/src/excel-loaders/platform-memberships.ts +7 -7
  79. package/src/excel-loaders/platform-sheets.test.ts +113 -0
  80. package/src/excel-loaders/receivable-balances.ts +1 -1
  81. package/src/excel-loaders/registrations.ts +9 -9
  82. package/src/helpers/AdminPermissionChecker.ts +1 -1
  83. package/src/helpers/AuthenticatedStructures.ts +21 -10
  84. package/src/helpers/Context.ts +114 -4
  85. package/src/helpers/EmailBuilder.test.ts +287 -0
  86. package/src/helpers/EmailBuilder.ts +811 -0
  87. package/src/helpers/EmailResumer.ts +2 -1
  88. package/src/helpers/ForwardHandler.ts +2 -1
  89. package/src/helpers/MFAEncryption.ts +34 -0
  90. package/src/helpers/MembershipCharger.ts +3 -2
  91. package/src/helpers/RecoveryCodeHelper.ts +81 -0
  92. package/src/helpers/TOTPHelper.ts +79 -0
  93. package/src/helpers/TenantContext.test.ts +96 -0
  94. package/src/helpers/TenantContext.ts +67 -0
  95. package/src/helpers/TwoFactorHelper.ts +356 -0
  96. package/src/helpers/WebauthnHelper.ts +211 -0
  97. package/src/helpers/data/aaguids.json +1006 -0
  98. package/src/middleware/TenantScopeMiddleware.test.ts +41 -0
  99. package/src/middleware/TenantScopeMiddleware.ts +24 -0
  100. package/src/seeds/1785417302-fill-user-last-active-at.sql +6 -0
  101. package/src/services/AdminSessionService.ts +41 -0
  102. package/src/services/BalanceItemService.ts +2 -1
  103. package/src/services/DocumentRenderService.test.ts +229 -0
  104. package/src/services/DocumentRenderService.ts +180 -0
  105. package/src/services/EmailPreviewService.test.ts +300 -0
  106. package/src/services/EmailPreviewService.ts +239 -0
  107. package/src/services/EmailSendService.test.ts +1218 -0
  108. package/src/services/EmailSendService.ts +705 -0
  109. package/src/services/EventNotificationService.ts +2 -1
  110. package/src/services/FileSignService.test.ts +85 -0
  111. package/src/services/FileSignService.ts +23 -1
  112. package/src/services/InvoicePdfService.ts +3 -3
  113. package/src/services/InvoiceService.ts +4 -2
  114. package/src/services/InvoiceXMLService.ts +2 -1
  115. package/src/services/OrderService.test.ts +308 -0
  116. package/src/services/OrderService.ts +215 -0
  117. package/src/services/OrganizationAdminService.test.ts +47 -0
  118. package/src/services/OrganizationAdminService.ts +139 -0
  119. package/src/services/OrganizationDNSService.test.ts +177 -0
  120. package/src/services/OrganizationDNSService.ts +282 -0
  121. package/src/services/OrganizationEmailService.test.ts +57 -0
  122. package/src/services/OrganizationEmailService.ts +212 -0
  123. package/src/services/PasswordForgotService.test.ts +99 -0
  124. package/src/services/PasswordForgotService.ts +38 -0
  125. package/src/services/PaymentService.ts +5 -3
  126. package/src/services/PlatformMembershipService.test.ts +180 -0
  127. package/src/services/PlatformMembershipService.ts +281 -4
  128. package/src/services/ReferralService.ts +3 -2
  129. package/src/services/RegistrationService.ts +4 -2
  130. package/src/services/SSOService.ts +106 -14
  131. package/src/services/STPackageService.test.ts +191 -0
  132. package/src/services/STPackageService.ts +115 -1
  133. package/src/services/TwoFactorAuditLogService.ts +64 -0
  134. package/src/services/VerificationCodeService.test.ts +71 -0
  135. package/src/services/VerificationCodeService.ts +101 -0
  136. package/tests/e2e/api-rate-limits.test.ts +1 -1
  137. package/tests/e2e/documents.test.ts +2 -1
  138. package/tests/e2e/private-files.test.ts +77 -14
  139. package/tests/helpers/MFATestHelper.ts +42 -0
  140. package/tests/helpers/TestServer.ts +4 -0
  141. package/tests/helpers/index.ts +1 -0
  142. package/tsconfig.build.json +2 -1
@@ -0,0 +1,356 @@
1
+ import { SimpleError } from '@simonbackx/simple-errors';
2
+ import type { User } from '@stamhoofd/models';
3
+ import { MFARecoveryCode, MFATOTP, MFAToken, Organization, Platform, RateLimiter, Token, WebauthnCredential } from '@stamhoofd/models';
4
+ import type { User as UserStruct } from '@stamhoofd/structures';
5
+ import { MFAChallengeResponse, MFAEnrollmentResult, MFAMethodType, MFASetupResponse, MFAStatus, PasskeyCredential, RecoveryCodes, TOTPCredential, Token as TokenStruct } from '@stamhoofd/structures';
6
+
7
+ import type { PublicKeyCredentialRequestOptionsJSON } from '@simplewebauthn/server';
8
+
9
+ import { RecoveryCodeHelper } from './RecoveryCodeHelper.js';
10
+ import { WebauthnHelper } from './WebauthnHelper.js';
11
+ import { Formatter, Sorter } from '@stamhoofd/utility';
12
+
13
+ /**
14
+ * Aggregate brute-force protection for second-factor verification, keyed by user id.
15
+ * A single per-token try counter is not enough: an attacker who knows the password can
16
+ * mint unlimited login MFA tokens, so we also cap the total number of failed verification
17
+ * attempts per user across all of their tokens.
18
+ */
19
+ export const mfaVerificationRateLimiter = new RateLimiter({
20
+ limits: [
21
+ {
22
+ limit: 10,
23
+ duration: 60 * 1000, // 10 failed attempts per minute
24
+ },
25
+ {
26
+ limit: 30,
27
+ duration: 60 * 60 * 1000, // 30 failed attempts per hour
28
+ },
29
+ ],
30
+ });
31
+
32
+ /**
33
+ * What still has to happen before a session may be issued for a user whose primary
34
+ * credential was just accepted.
35
+ */
36
+ export type SecondFactorRequirement
37
+ = | { type: 'none' }
38
+ | { type: 'challenge'; challenge: MFAChallengeResponse }
39
+ | { type: 'setup'; setupToken: MFAToken };
40
+
41
+ export class TwoFactorHelper {
42
+ /**
43
+ * Whether the user is required to have 2FA. Evaluated only on the password grant;
44
+ * SSO/Google logins are trusted to provide their own 2FA.
45
+ */
46
+ static async isTwoFactorRequired(user: User, organization: Organization | null): Promise<boolean> {
47
+ const perms = user.permissions;
48
+ if (!perms) {
49
+ return false;
50
+ }
51
+ // The platform can require 2FA for its users with platform permissions.
52
+ if (perms.globalPermissions !== null) {
53
+ const platform = await Platform.getSharedPrivateStruct();
54
+ if (platform.privateConfig.requireTwoFactor) {
55
+ return true;
56
+ }
57
+ }
58
+
59
+ // Organizations can require 2FA for their users with permissions.
60
+ //
61
+ // This looks at EVERY organization the user has permissions in, not only the one
62
+ // in scope. The scope is derived from the API host, so an admin could otherwise
63
+ // sidestep their organization's requirement by signing in on the platform
64
+ // (unscoped) host — the resulting session is not restricted to that host and
65
+ // gives them access to the organization anyway. For the same reason it also
66
+ // decides whether the last factor may be removed.
67
+ const organizationIds = [...perms.organizationPermissions.keys()];
68
+ if (organizationIds.length === 0) {
69
+ return false;
70
+ }
71
+
72
+ // Reuse the organization we already have in memory instead of fetching it again.
73
+ if (organization && organizationIds.includes(organization.id) && organization.privateMeta?.requireTwoFactor) {
74
+ return true;
75
+ }
76
+
77
+ const remaining = organizationIds.filter(id => id !== organization?.id);
78
+ if (remaining.length === 0) {
79
+ return false;
80
+ }
81
+
82
+ for (const other of await Organization.getByIDs(...remaining)) {
83
+ if (other.privateMeta?.requireTwoFactor) {
84
+ return true;
85
+ }
86
+ }
87
+ return false;
88
+ }
89
+
90
+ /**
91
+ * What still has to happen before a session may be handed out, after a primary
92
+ * credential was accepted.
93
+ *
94
+ * `loginMethod` describes the credential that was just verified:
95
+ * - 'password': a password, password token or email verification code. All of these
96
+ * are single credentials owned by the user, so a required second factor must be
97
+ * set up here if the user does not have one yet.
98
+ * - 'sso': an external identity provider already authenticated the user, and is
99
+ * trusted to apply its own second factor. An enrolled factor is still verified
100
+ * (the user asked us to protect their account), but we do not force enrollment —
101
+ * unless the account ALSO has a password, because then the password remains a way
102
+ * in that bypasses whatever the provider enforces.
103
+ */
104
+ static async getSecondFactorRequirement(user: User, organization: Organization | null, { loginMethod }: { loginMethod: 'password' | 'sso' }): Promise<SecondFactorRequirement> {
105
+ if (await TwoFactorHelper.userHasFactors(user.id)) {
106
+ return { type: 'challenge', challenge: await TwoFactorHelper.createLoginChallenge(user) };
107
+ }
108
+
109
+ if (loginMethod === 'sso' && !user.hasPasswordBasedAccount()) {
110
+ return { type: 'none' };
111
+ }
112
+
113
+ if (await TwoFactorHelper.isTwoFactorRequired(user, organization)) {
114
+ return { type: 'setup', setupToken: await MFAToken.createFor(user.id, 'setup') };
115
+ }
116
+
117
+ return { type: 'none' };
118
+ }
119
+
120
+ /**
121
+ * Enforce the second-factor / forced-enrollment step after a successful primary
122
+ * authentication (password login, password-reset token, email verification). Throws a
123
+ * `require_mfa` or `require_mfa_setup` error when the user must still complete a second
124
+ * factor before a session may be issued; returns normally when the login may proceed.
125
+ *
126
+ * This MUST be called from every path that mints a full session from a single primary
127
+ * credential, otherwise that path becomes an MFA bypass. The SSO callback is a redirect
128
+ * instead of a request/response pair, so it uses getSecondFactorRequirement() directly.
129
+ *
130
+ * `allowTemporarySession` adds a normal session token to the forced-enrollment error.
131
+ * Only pass it for the password-token grant: the user has no second factor yet, so
132
+ * whoever holds the link could enroll one and get a session anyway, and the client
133
+ * needs a session to let the user choose a password before enrolling.
134
+ */
135
+ static async assertSecondFactorOrThrow(user: User, organization: Organization | null, version: number, { allowTemporarySession = false }: { allowTemporarySession?: boolean } = {}): Promise<void> {
136
+ const requirement = await TwoFactorHelper.getSecondFactorRequirement(user, organization, { loginMethod: 'password' });
137
+
138
+ if (requirement.type === 'challenge') {
139
+ throw new SimpleError({
140
+ code: 'require_mfa',
141
+ message: 'Two-factor authentication required',
142
+ human: $t('%ZhQ'),
143
+ meta: requirement.challenge.encode({ version }),
144
+ statusCode: 403,
145
+ });
146
+ }
147
+
148
+ if (requirement.type === 'setup') {
149
+ const temporaryToken = allowTemporarySession ? new TokenStruct(await Token.createToken(user, new Date())) : null;
150
+
151
+ throw new SimpleError({
152
+ code: 'require_mfa_setup',
153
+ message: 'Two-factor authentication setup required',
154
+ human: $t('%Zh8'),
155
+ meta: MFASetupResponse.create({
156
+ setupToken: requirement.setupToken.token,
157
+ token: temporaryToken,
158
+ canUsePasskeys: user.canUsePasskeys(),
159
+ }).encode({ version }),
160
+ statusCode: 403,
161
+ });
162
+ }
163
+ }
164
+
165
+ static async userHasFactors(userId: string): Promise<boolean> {
166
+ const totp = await MFATOTP.getConfirmedForUser(userId);
167
+ if (totp.length > 0) {
168
+ return true;
169
+ }
170
+ const passkeys = await WebauthnCredential.getForUser(userId);
171
+ return passkeys.length > 0;
172
+ }
173
+
174
+ /**
175
+ * Which of these users have at least one enrolled factor.
176
+ *
177
+ * Batched on purpose: the admin lists show this for every row, so asking per user
178
+ * would be a query per row.
179
+ */
180
+ static async filterUserIdsWithFactors(userIds: string[]): Promise<Set<string>> {
181
+ const unique = Formatter.uniqueArray(userIds);
182
+ if (unique.length === 0) {
183
+ return new Set();
184
+ }
185
+
186
+ const [totp, passkeys] = await Promise.all([
187
+ MFATOTP.select().where('userId', unique).where('confirmedAt', '!=', null).fetch(),
188
+ WebauthnCredential.select().where('userId', unique).fetch(),
189
+ ]);
190
+
191
+ return new Set([...totp.map(t => t.userId), ...passkeys.map(p => p.userId)]);
192
+ }
193
+
194
+ /**
195
+ * Fill in `hasTwoFactor` on already built user structures. This is not part of
196
+ * User.getStructure() because it lives in other tables: it is loaded for all the
197
+ * users of a response at once.
198
+ */
199
+ static async fillTwoFactorStatus(users: UserStruct[]): Promise<void> {
200
+ if (users.length === 0) {
201
+ return;
202
+ }
203
+
204
+ const withFactors = await TwoFactorHelper.filterUserIdsWithFactors(users.map(u => u.id));
205
+ for (const user of users) {
206
+ user.hasTwoFactor = withFactors.has(user.id);
207
+ }
208
+ }
209
+
210
+ static async getEnrolledMethods(userId: string): Promise<{ methods: MFAMethodType[]; passkeys: WebauthnCredential[] }> {
211
+ const totp = await MFATOTP.getConfirmedForUser(userId);
212
+ const recovery = await MFARecoveryCode.getUnusedForUser(userId);
213
+
214
+ // Only passkeys of the RP ID we authenticate against can actually be used; the
215
+ // authenticator will not release the others.
216
+ const passkeys = WebauthnHelper.filterForCurrentRpID(await WebauthnCredential.getForUser(userId));
217
+
218
+ const methods: MFAMethodType[] = [];
219
+ if (totp.length > 0) {
220
+ methods.push(MFAMethodType.TOTP);
221
+ }
222
+ if (passkeys.length > 0) {
223
+ methods.push(MFAMethodType.Passkey);
224
+ }
225
+ if (recovery.length > 0) {
226
+ methods.push(MFAMethodType.RecoveryCode);
227
+ }
228
+ return { methods, passkeys };
229
+ }
230
+
231
+ /**
232
+ * Create a login MFA session token and the challenge payload returned to the client.
233
+ */
234
+ static async createLoginChallenge(user: User): Promise<MFAChallengeResponse> {
235
+ const { methods, passkeys } = await TwoFactorHelper.getEnrolledMethods(user.id);
236
+
237
+ let webauthnOptions: PublicKeyCredentialRequestOptionsJSON | null = null;
238
+ let challenge: string | null = null;
239
+ if (passkeys.length > 0) {
240
+ webauthnOptions = await WebauthnHelper.generateAuthentication(passkeys);
241
+ challenge = webauthnOptions.challenge;
242
+ }
243
+
244
+ const mfaToken = await MFAToken.createFor(user.id, 'login', challenge);
245
+
246
+ return MFAChallengeResponse.create({
247
+ token: mfaToken.token,
248
+ methods,
249
+ webauthnAuthenticationOptions: webauthnOptions,
250
+ });
251
+ }
252
+
253
+ /**
254
+ * Rebuild the challenge payload for a login MFA token that already exists.
255
+ *
256
+ * The SSO callback is a browser redirect, so it can only pass the token itself back to
257
+ * the client; the client then asks for the rest of the challenge. A new WebAuthn
258
+ * challenge is generated and stored on the token, replacing the previous one (the
259
+ * client never received it, and only the stored one is accepted).
260
+ */
261
+ static async describeLoginChallenge(mfaToken: MFAToken): Promise<MFAChallengeResponse> {
262
+ const { methods, passkeys } = await TwoFactorHelper.getEnrolledMethods(mfaToken.userId);
263
+
264
+ let webauthnOptions: PublicKeyCredentialRequestOptionsJSON | null = null;
265
+ if (passkeys.length > 0) {
266
+ webauthnOptions = await WebauthnHelper.generateAuthentication(passkeys);
267
+ mfaToken.webauthnChallenge = webauthnOptions.challenge;
268
+ await mfaToken.save();
269
+ }
270
+
271
+ return MFAChallengeResponse.create({
272
+ token: mfaToken.token,
273
+ methods,
274
+ webauthnAuthenticationOptions: webauthnOptions,
275
+ });
276
+ }
277
+
278
+ /**
279
+ * Finish an enrollment action. Generates recovery codes when this was the user's
280
+ * first factor, and (during forced enrollment) consumes the setup token and issues a
281
+ * full, fresh session token. `wasFirstFactor` must be computed before persisting the
282
+ * new factor.
283
+ *
284
+ * `currentToken` is the session the request was made with (null during forced
285
+ * enrollment, where there is no session yet). Every OTHER session of the user is
286
+ * signed out: enrolling a factor is how a user reacts to a suspected compromise, so it
287
+ * has to end the sessions they did not make this request from.
288
+ */
289
+ static async completeEnrollment(user: User, setupToken: MFAToken | null, wasFirstFactor: boolean, currentToken: Token | null): Promise<MFAEnrollmentResult> {
290
+ let recoveryCodes: RecoveryCodes | null = null;
291
+ if (wasFirstFactor) {
292
+ const codes = await RecoveryCodeHelper.regenerateForUser(user.id);
293
+ recoveryCodes = RecoveryCodes.create({ codes });
294
+ }
295
+
296
+ // Before minting the new session, so it is never signed out by its own enrollment.
297
+ await Token.deleteOtherSessions(user.id, currentToken?.accessToken ?? null);
298
+
299
+ let token: TokenStruct | null = null;
300
+ if (setupToken) {
301
+ await setupToken.consume();
302
+ const t = await Token.createToken(user, new Date());
303
+ await user.markActive();
304
+ token = new TokenStruct(t);
305
+ }
306
+
307
+ const status = await TwoFactorHelper.buildStatus(user);
308
+ return MFAEnrollmentResult.create({ status, token, recoveryCodes });
309
+ }
310
+
311
+ /**
312
+ * Clean up after a factor was deleted.
313
+ *
314
+ * Recovery codes exist to get back in when the enrolled factors are unavailable, so
315
+ * they are meaningless — and an unnecessary long-lived credential — once the last
316
+ * factor is gone. Every other session is signed out for the same reason enrollment
317
+ * does it: removing a factor is a security-relevant change to the account.
318
+ */
319
+ static async completeRemoval(user: User, currentToken: Token | null): Promise<MFAStatus> {
320
+ if (!(await TwoFactorHelper.userHasFactors(user.id))) {
321
+ await MFARecoveryCode.deleteForUser(user.id);
322
+ }
323
+
324
+ await Token.deleteOtherSessions(user.id, currentToken?.accessToken ?? null);
325
+
326
+ return await TwoFactorHelper.buildStatus(user);
327
+ }
328
+
329
+ static async buildStatus(user: User): Promise<MFAStatus> {
330
+ const userId = user.id;
331
+ const totp = await MFATOTP.getConfirmedForUser(userId);
332
+ const passkeys = await WebauthnCredential.getForUser(userId);
333
+ const recovery = await MFARecoveryCode.getUnusedForUser(userId);
334
+
335
+ return MFAStatus.create({
336
+ totp: totp.map(t => TOTPCredential.create({
337
+ id: t.id,
338
+ name: t.name,
339
+ createdAt: t.createdAt,
340
+ lastUsedAt: t.lastUsedAt,
341
+ })).sort((a, b) => Sorter.byDateValue(a.lastUsedAt ?? a.createdAt, b.lastUsedAt ?? b.createdAt)),
342
+ passkeys: passkeys.map(p => PasskeyCredential.create({
343
+ id: p.id,
344
+ name: p.name,
345
+ createdAt: p.createdAt,
346
+ lastUsedAt: p.lastUsedAt,
347
+ providerId: p.providerId,
348
+ providerName: p.providerName,
349
+ transports: p.transportsArray,
350
+ })).sort((a, b) => Sorter.byDateValue(a.lastUsedAt ?? a.createdAt, b.lastUsedAt ?? b.createdAt)),
351
+ hasRecoveryCodes: recovery.length > 0,
352
+ recoveryCodesRemaining: recovery.length,
353
+ canUsePasskeys: user.canUsePasskeys(),
354
+ });
355
+ }
356
+ }
@@ -0,0 +1,211 @@
1
+ import {
2
+ generateAuthenticationOptions,
3
+ generateRegistrationOptions,
4
+ verifyAuthenticationResponse,
5
+ verifyRegistrationResponse,
6
+ } from '@simplewebauthn/server';
7
+ import type { AuthenticationResponseJSON, AuthenticatorTransportFuture, RegistrationResponseJSON } from '@simplewebauthn/server';
8
+ import type { User, WebauthnCredential } from '@stamhoofd/models';
9
+ import type { WebauthnAuthenticationCredential, WebauthnRegistrationCredential } from '@stamhoofd/structures';
10
+ import { getWebauthnRpId } from '@stamhoofd/structures';
11
+ import aaguids from './data/aaguids.json' with { type: 'json' };
12
+ const RP_NAME = 'Stamhoofd';
13
+
14
+ const VALID_TRANSPORTS: AuthenticatorTransportFuture[] = ['ble', 'cable', 'hybrid', 'internal', 'nfc', 'smart-card', 'usb'];
15
+
16
+ /**
17
+ * Narrow an untrusted string[] to valid AuthenticatorTransport values (no unchecked casts).
18
+ */
19
+ function coerceTransports(transports: string[] | null | undefined): AuthenticatorTransportFuture[] | undefined {
20
+ if (!transports) {
21
+ return undefined;
22
+ }
23
+ const filtered = transports.filter((t): t is AuthenticatorTransportFuture => (VALID_TRANSPORTS as string[]).includes(t));
24
+ return filtered.length > 0 ? filtered : undefined;
25
+ }
26
+
27
+ /**
28
+ * The WebAuthn Relying Party ID new passkeys are registered against. Passkeys are bound to
29
+ * a single registrable domain, so we scope them to the dashboard domain (admin/staff
30
+ * logins). Shared with the client through @stamhoofd/structures, so both sides agree on
31
+ * which domain passkeys live on.
32
+ */
33
+ function getRpID(): string {
34
+ const rpId = getWebauthnRpId();
35
+ if (!rpId) {
36
+ throw new Error('Dashboard domain is required for WebAuthn');
37
+ }
38
+ return rpId;
39
+ }
40
+
41
+ /**
42
+ * The RP ID an existing credential has to be verified against.
43
+ *
44
+ * Credentials created before the RP ID was stored per credential all used the platform RP
45
+ * ID of the time, so that is the fallback.
46
+ */
47
+ function getCredentialRpID(credential: WebauthnCredential): string {
48
+ return credential.rpId ?? getRpID();
49
+ }
50
+
51
+ /**
52
+ * The origins a passkey for `rpId` may be presented from.
53
+ *
54
+ * The web apps run on https. The Capacitor app serves its web view from the same host but
55
+ * a custom scheme (see frontend/app/mobile/capacitor.config.json): iOS reports
56
+ * `capacitor://<host>`, while Android is configured with the https scheme and so already
57
+ * matches the first entry.
58
+ *
59
+ * Widening the origin does not widen who can use these passkeys: the app only gets to see
60
+ * them because it is listed in the associated domains (webcredentials) of the RP ID, which
61
+ * the operating system verifies before it releases a credential to any app.
62
+ */
63
+ function getExpectedOrigins(rpId: string): string[] {
64
+ return ['https://' + rpId, 'capacitor://' + rpId];
65
+ }
66
+
67
+ export const WebauthnHelper = {
68
+ getRpID,
69
+ getExpectedOrigins,
70
+
71
+ async generateRegistration(user: User, existingCredentials: WebauthnCredential[]) {
72
+ return await generateRegistrationOptions({
73
+ rpName: RP_NAME,
74
+ rpID: getRpID(),
75
+ userName: user.email,
76
+ userID: new Uint8Array(Buffer.from(user.id)),
77
+ userDisplayName: user.name || user.email,
78
+ attestationType: 'direct',
79
+ excludeCredentials: existingCredentials.map(c => ({
80
+ id: c.credentialId,
81
+ transports: coerceTransports(c.transportsArray),
82
+ })),
83
+ authenticatorSelection: {
84
+ residentKey: 'preferred',
85
+ userVerification: 'preferred',
86
+ },
87
+ });
88
+ },
89
+
90
+ /**
91
+ * @returns the verified credential info to persist, or null when verification failed.
92
+ */
93
+ async verifyRegistration(credential: WebauthnRegistrationCredential, expectedChallenge: string) {
94
+ const response: RegistrationResponseJSON = {
95
+ id: credential.id,
96
+ rawId: credential.rawId,
97
+ type: 'public-key',
98
+ clientExtensionResults: {},
99
+ response: {
100
+ clientDataJSON: credential.response.clientDataJSON,
101
+ attestationObject: credential.response.attestationObject,
102
+ authenticatorData: credential.response.authenticatorData ?? undefined,
103
+ transports: coerceTransports(credential.response.transports),
104
+ publicKeyAlgorithm: credential.response.publicKeyAlgorithm ?? undefined,
105
+ publicKey: credential.response.publicKey ?? undefined,
106
+ },
107
+ };
108
+
109
+ const rpId = getRpID();
110
+
111
+ let verification: Awaited<ReturnType<typeof verifyRegistrationResponse>>;
112
+ try {
113
+ verification = await verifyRegistrationResponse({
114
+ response,
115
+ expectedChallenge,
116
+ expectedOrigin: getExpectedOrigins(rpId),
117
+ expectedRPID: rpId,
118
+ requireUserVerification: false,
119
+ });
120
+ } catch (e) {
121
+ // Malformed / tampered input makes the library throw; treat as a failed
122
+ // verification instead of surfacing a 500.
123
+ return null;
124
+ }
125
+
126
+ if (!verification.verified || !verification.registrationInfo) {
127
+ return null;
128
+ }
129
+
130
+ const { credential: verifiedCredential, credentialBackedUp, credentialDeviceType, aaguid } = verification.registrationInfo;
131
+ return {
132
+ rpId,
133
+ providerId: aaguid || null,
134
+ providerName: aaguids[aaguid]?.name || null,
135
+ credentialId: verifiedCredential.id,
136
+ publicKey: Buffer.from(verifiedCredential.publicKey).toString('base64url'),
137
+ counter: verifiedCredential.counter,
138
+ transports: verifiedCredential.transports ?? null,
139
+ backedUp: credentialBackedUp,
140
+ backupEligible: credentialDeviceType === 'multiDevice',
141
+ };
142
+ },
143
+
144
+ /**
145
+ * Only credentials of the RP ID we are authenticating against can take part: an
146
+ * authenticator will not release a passkey for a different domain. Today they all match,
147
+ * but this is what keeps the challenge correct once more than one domain is in play.
148
+ */
149
+ filterForCurrentRpID(credentials: WebauthnCredential[]): WebauthnCredential[] {
150
+ const rpId = getRpID();
151
+ return credentials.filter(c => getCredentialRpID(c) === rpId);
152
+ },
153
+
154
+ async generateAuthentication(credentials: WebauthnCredential[]) {
155
+ return await generateAuthenticationOptions({
156
+ rpID: getRpID(),
157
+ allowCredentials: WebauthnHelper.filterForCurrentRpID(credentials).map(c => ({
158
+ id: c.credentialId,
159
+ transports: coerceTransports(c.transportsArray),
160
+ })),
161
+ userVerification: 'preferred',
162
+ });
163
+ },
164
+
165
+ /**
166
+ * @returns the new signature counter on success, or null when verification failed.
167
+ */
168
+ async verifyAuthentication(credential: WebauthnAuthenticationCredential, expectedChallenge: string, storedCredential: WebauthnCredential): Promise<number | null> {
169
+ const response: AuthenticationResponseJSON = {
170
+ id: credential.id,
171
+ rawId: credential.rawId,
172
+ type: 'public-key',
173
+ clientExtensionResults: {},
174
+ response: {
175
+ clientDataJSON: credential.response.clientDataJSON,
176
+ authenticatorData: credential.response.authenticatorData,
177
+ signature: credential.response.signature,
178
+ userHandle: credential.response.userHandle ?? undefined,
179
+ },
180
+ };
181
+
182
+ // Verify against the RP ID this credential was created for, not the current one.
183
+ const rpId = getCredentialRpID(storedCredential);
184
+
185
+ let verification: Awaited<ReturnType<typeof verifyAuthenticationResponse>>;
186
+ try {
187
+ verification = await verifyAuthenticationResponse({
188
+ response,
189
+ expectedChallenge,
190
+ expectedOrigin: getExpectedOrigins(rpId),
191
+ expectedRPID: rpId,
192
+ credential: {
193
+ id: storedCredential.credentialId,
194
+ publicKey: new Uint8Array(Buffer.from(storedCredential.publicKey, 'base64url')),
195
+ counter: storedCredential.counter,
196
+ transports: coerceTransports(storedCredential.transportsArray),
197
+ },
198
+ requireUserVerification: false,
199
+ });
200
+ } catch (e) {
201
+ // Malformed / tampered input makes the library throw; treat as a failed
202
+ // verification instead of surfacing a 500.
203
+ return null;
204
+ }
205
+
206
+ if (!verification.verified) {
207
+ return null;
208
+ }
209
+ return verification.authenticationInfo.newCounter;
210
+ },
211
+ };