@byline/admin 6.7.2 → 6.8.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 (86) hide show
  1. package/dist/modules/admin-account/components/change-password.js +1 -1
  2. package/dist/modules/admin-account/components/container.js +18 -3
  3. package/dist/modules/admin-account/components/container.module.js +2 -0
  4. package/dist/modules/admin-account/components/container_module.css +7 -0
  5. package/dist/modules/admin-account/components/preferences.js +1 -1
  6. package/dist/modules/admin-account/components/update.js +1 -1
  7. package/dist/modules/admin-roles/components/create.js +1 -1
  8. package/dist/modules/admin-roles/components/permissions.js +1 -1
  9. package/dist/modules/admin-roles/components/update.js +1 -1
  10. package/dist/modules/admin-users/abilities.d.ts +1 -0
  11. package/dist/modules/admin-users/abilities.js +8 -1
  12. package/dist/modules/admin-users/components/create.js +1 -1
  13. package/dist/modules/admin-users/components/roles.js +1 -1
  14. package/dist/modules/admin-users/components/set-password.js +1 -1
  15. package/dist/modules/admin-users/components/update.js +1 -1
  16. package/dist/modules/auth/auth-maintenance-repository.d.ts +31 -0
  17. package/dist/modules/auth/auth-maintenance-repository.js +1 -0
  18. package/dist/modules/auth/auth-maintenance.d.ts +11 -0
  19. package/dist/modules/auth/auth-maintenance.js +39 -0
  20. package/dist/modules/auth/auth-maintenance.test.node.d.ts +1 -0
  21. package/dist/modules/auth/components/passkey-browser.d.ts +14 -0
  22. package/dist/modules/auth/components/passkey-browser.js +118 -0
  23. package/dist/modules/auth/components/passkey-browser.test.node.d.ts +1 -0
  24. package/dist/modules/auth/components/passkey-manager.d.ts +26 -0
  25. package/dist/modules/auth/components/passkey-manager.js +406 -0
  26. package/dist/modules/auth/components/passkey-manager.module.js +22 -0
  27. package/dist/modules/auth/components/passkey-manager_module.css +104 -0
  28. package/dist/modules/auth/components/sign-in-form.js +62 -3
  29. package/dist/modules/auth/components/sign-in-form.module.js +5 -1
  30. package/dist/modules/auth/components/sign-in-form_module.css +40 -0
  31. package/dist/modules/auth/index.d.ts +5 -0
  32. package/dist/modules/auth/index.js +3 -0
  33. package/dist/modules/auth/jwt-session-provider.d.ts +12 -4
  34. package/dist/modules/auth/jwt-session-provider.js +135 -94
  35. package/dist/modules/auth/native-passkey-provider.d.ts +72 -0
  36. package/dist/modules/auth/native-passkey-provider.js +618 -0
  37. package/dist/modules/auth/passkey-rate-limiter.d.ts +14 -0
  38. package/dist/modules/auth/passkey-rate-limiter.js +60 -0
  39. package/dist/modules/auth/passkey-rate-limiter.test.node.d.ts +8 -0
  40. package/dist/modules/auth/passkey-storage-validation.d.ts +14 -0
  41. package/dist/modules/auth/passkey-storage-validation.js +59 -0
  42. package/dist/modules/auth/passkeys-repository.d.ts +96 -0
  43. package/dist/modules/auth/passkeys-repository.js +1 -0
  44. package/dist/modules/auth/refresh-tokens-repository.d.ts +1 -1
  45. package/dist/modules/auth/sign-in-rate-limiter.d.ts +2 -0
  46. package/dist/modules/auth/sign-in-rate-limiter.js +1 -1
  47. package/dist/services/admin-services-context.d.ts +1 -1
  48. package/dist/services/admin-services-types.d.ts +60 -0
  49. package/dist/store.d.ts +4 -0
  50. package/package.json +16 -9
  51. package/src/modules/admin-account/components/change-password.tsx +1 -1
  52. package/src/modules/admin-account/components/container.module.css +9 -0
  53. package/src/modules/admin-account/components/container.tsx +12 -3
  54. package/src/modules/admin-account/components/preferences.tsx +1 -1
  55. package/src/modules/admin-account/components/update.tsx +1 -1
  56. package/src/modules/admin-roles/components/create.tsx +1 -1
  57. package/src/modules/admin-roles/components/permissions.tsx +1 -1
  58. package/src/modules/admin-roles/components/update.tsx +1 -1
  59. package/src/modules/admin-users/abilities.ts +7 -0
  60. package/src/modules/admin-users/components/create.tsx +1 -1
  61. package/src/modules/admin-users/components/roles.tsx +1 -1
  62. package/src/modules/admin-users/components/set-password.tsx +1 -1
  63. package/src/modules/admin-users/components/update.tsx +1 -1
  64. package/src/modules/auth/auth-maintenance-repository.ts +26 -0
  65. package/src/modules/auth/auth-maintenance.test.node.ts +110 -0
  66. package/src/modules/auth/auth-maintenance.ts +62 -0
  67. package/src/modules/auth/components/passkey-browser.test.node.ts +188 -0
  68. package/src/modules/auth/components/passkey-browser.ts +139 -0
  69. package/src/modules/auth/components/passkey-manager.module.css +100 -0
  70. package/src/modules/auth/components/passkey-manager.test.tsx +189 -0
  71. package/src/modules/auth/components/passkey-manager.tsx +386 -0
  72. package/src/modules/auth/components/sign-in-form.module.css +37 -0
  73. package/src/modules/auth/components/sign-in-form.test.tsx +92 -0
  74. package/src/modules/auth/components/sign-in-form.tsx +65 -4
  75. package/src/modules/auth/index.ts +15 -0
  76. package/src/modules/auth/jwt-session-provider.ts +116 -44
  77. package/src/modules/auth/native-passkey-provider.ts +710 -0
  78. package/src/modules/auth/passkey-rate-limiter.test.node.ts +83 -0
  79. package/src/modules/auth/passkey-rate-limiter.ts +76 -0
  80. package/src/modules/auth/passkey-storage-validation.ts +108 -0
  81. package/src/modules/auth/passkeys-repository.ts +97 -0
  82. package/src/modules/auth/refresh-tokens-repository.ts +1 -1
  83. package/src/modules/auth/sign-in-rate-limiter.ts +1 -1
  84. package/src/services/admin-services-context.tsx +2 -0
  85. package/src/services/admin-services-types.ts +60 -0
  86. package/src/store.ts +4 -0
@@ -0,0 +1,710 @@
1
+ /**
2
+ * This Source Code Form is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/.
5
+ * Copyright (c) Infonomic Company Limited
6
+ */
7
+
8
+ import { createHash, randomBytes } from 'node:crypto'
9
+
10
+ import {
11
+ type AdminAuth,
12
+ AuthError,
13
+ AuthErrorCodes,
14
+ ERR_FORBIDDEN,
15
+ ERR_INVALID_CREDENTIALS,
16
+ ERR_INVALID_TOKEN,
17
+ ERR_SESSION_CHANGED,
18
+ type PasskeyAdmissionReason,
19
+ type PasskeyAuthenticationOptions,
20
+ type PasskeyCeremony,
21
+ type PasskeyCredentialSummary,
22
+ type PasskeyManagementAction,
23
+ type PasskeyManagementGrant,
24
+ type PasskeyOperation,
25
+ type PasskeyProvider,
26
+ type PasskeyRegistrationOptions,
27
+ type PasskeyRequestMetadata,
28
+ type PasskeySession,
29
+ type SignInResult,
30
+ type SignInWithPasswordArgs,
31
+ } from '@byline/auth'
32
+ import { type PasskeyConfig, resolvePasskeyConfig } from '@byline/core'
33
+ import { passwordSignInSchema } from '@byline/core/validation'
34
+ import {
35
+ type AuthenticationResponseJSON,
36
+ generateAuthenticationOptions,
37
+ generateRegistrationOptions,
38
+ type RegistrationResponseJSON,
39
+ verifyAuthenticationResponse,
40
+ verifyRegistrationResponse,
41
+ } from '@simplewebauthn/server'
42
+ import { v7 as uuidv7, validate as validUuid } from 'uuid'
43
+
44
+ import { ADMIN_USERS_ABILITIES } from '../admin-users/abilities.js'
45
+ import { assertPasskeyBytes } from './passkey-storage-validation.js'
46
+ import { verifyPassword } from './password.js'
47
+ import type { AdminStore } from '../../store.js'
48
+ import type { AdminUserWithPasswordRow } from '../admin-users/repository.js'
49
+ import type { NativeSessionEvent } from './jwt-session-provider.js'
50
+ import type { PasskeyAttemptRow, PasskeyCredentialRow } from './passkeys-repository.js'
51
+
52
+ type Authenticated = { actor: AdminAuth; user: AdminUserWithPasswordRow; sessionId: string }
53
+ type SignInArgs = Pick<
54
+ SignInWithPasswordArgs,
55
+ 'previousAccessToken' | 'previousRefreshToken' | 'ip' | 'userAgent'
56
+ >
57
+ type PasskeyEvent = Extract<NativeSessionEvent, { purpose: string }>
58
+ interface NativeDependencies {
59
+ store: AdminStore
60
+ now(): Date
61
+ authenticate(session: PasskeySession, store?: AdminStore): Promise<Authenticated>
62
+ /** Private constructor closure supplied only by JwtSessionProvider; never publicly exported. */
63
+ signIn(
64
+ snapshot: AdminUserWithPasswordRow,
65
+ args: SignInArgs,
66
+ recheck: (store: AdminStore, user: AdminUserWithPasswordRow) => Promise<void>
67
+ ): Promise<SignInResult>
68
+ emit(event: PasskeyEvent): void
69
+ }
70
+ const MAX_PENDING = 5
71
+ const MAX_CREDENTIALS = 10
72
+ const LIFETIME_MS = 300_000
73
+
74
+ /** Internal native implementation. Public callers enter through SessionProvider.passkeys. */
75
+ export class NativePasskeyProvider implements PasskeyProvider {
76
+ readonly #trust: PasskeyConfig
77
+ constructor(
78
+ trust: PasskeyConfig,
79
+ private readonly deps: NativeDependencies
80
+ ) {
81
+ const resolved = resolvePasskeyConfig(trust, {
82
+ capabilities: { passkeys: true, passwordChange: true, magicLink: false, sso: false },
83
+ passkeys: this,
84
+ })
85
+ if (!resolved) throw new Error('Passkey trust configuration is required')
86
+ this.#trust = resolved
87
+ }
88
+
89
+ recordAdmissionDenied(purpose: PasskeyOperation, reason: PasskeyAdmissionReason): void {
90
+ this.deps.emit({ type: 'passkey_admission_denied', purpose, reason })
91
+ }
92
+
93
+ signInOptions(
94
+ _metadata: PasskeyRequestMetadata
95
+ ): Promise<PasskeyCeremony<PasskeyAuthenticationOptions>> {
96
+ return this.#run('sign-in-options', async () => {
97
+ const { raw, row } = this.#newAttempt('sign-in')
98
+ const options = await this.#authenticationOptions(row.challenge!)
99
+ await this.deps.store.passkeys.createAttempt(row)
100
+ return { ceremonyId: raw, expiresAt: row.expires_at.toISOString(), options }
101
+ })
102
+ }
103
+
104
+ verifySignIn(
105
+ args: SignInArgs & { ceremonyId: string; response: unknown }
106
+ ): Promise<SignInResult> {
107
+ return this.#run('sign-in', async () => {
108
+ const attempt = await this.deps.store.passkeys.claimAttempt({
109
+ tokenHash: hashBearer(args.ceremonyId),
110
+ purpose: 'sign-in',
111
+ binding: { actor_user_id: null, actor_login_id: null, actor_generation: null },
112
+ now: this.deps.now(),
113
+ })
114
+ if (!attempt) throw expired()
115
+ const response = parseResponse(args.response, 'webauthn.get') as AuthenticationResponseJSON
116
+ const key = active(await this.deps.store.passkeys.findCredentialByCredentialId(response.id))
117
+ const user = await this.deps.store.adminUsers.getByIdForSignIn(key.admin_user_id)
118
+ if (!user?.is_enabled) throw invalidProof()
119
+ const verified = await this.#verifyAuthentication(response, key, attempt)
120
+ return this.deps.signIn(user, args, async (store) => {
121
+ this.#unexpired(attempt)
122
+ await this.#applyVerifiedKey(store, key, verified)
123
+ })
124
+ })
125
+ }
126
+
127
+ passwordProof(
128
+ args: PasskeySession & { password: string; action: PasskeyManagementAction }
129
+ ): Promise<PasskeyManagementGrant> {
130
+ return this.#run('password-proof', async () => {
131
+ const observed = await this.deps.authenticate(args)
132
+ const parsed = passwordSignInSchema.safeParse({
133
+ email: observed.user.email,
134
+ password: args.password,
135
+ })
136
+ if (
137
+ !parsed.success ||
138
+ !(await verifyPassword(parsed.data.password, observed.user.password_hash))
139
+ )
140
+ throw invalidProof()
141
+ return this.deps.store.withSessionLock(observed.user.id, async (store) => {
142
+ const current = await this.deps.authenticate(args, store)
143
+ if (
144
+ current.user.password_hash !== observed.user.password_hash ||
145
+ current.user.session_version !== observed.user.session_version
146
+ )
147
+ throw invalidProof()
148
+ const action = await this.#authorizeAction(args.action, current, store)
149
+ return this.#grant(store, current, action, null)
150
+ })
151
+ })
152
+ }
153
+
154
+ proofOptions(
155
+ args: PasskeySession & { action: PasskeyManagementAction }
156
+ ): Promise<PasskeyCeremony<PasskeyAuthenticationOptions>> {
157
+ return this.#run('proof-options', async () => {
158
+ const observed = await this.deps.authenticate(args)
159
+ return this.deps.store.withSessionLock(observed.user.id, async (store) => {
160
+ const current = await this.deps.authenticate(args, store)
161
+ const action = await this.#authorizeAction(args.action, current, store)
162
+ await this.#capacity(store, current.sessionId)
163
+ const { raw, row } = this.#newAttempt('fresh-authentication', current, action)
164
+ // Discoverable proof requires a returned user handle, just like sign-in.
165
+ const options = await this.#authenticationOptions(row.challenge!)
166
+ await store.passkeys.createAttempt(row)
167
+ return { ceremonyId: raw, expiresAt: row.expires_at.toISOString(), options }
168
+ })
169
+ })
170
+ }
171
+
172
+ verifyProof(
173
+ args: PasskeySession & { ceremonyId: string; response: unknown }
174
+ ): Promise<PasskeyManagementGrant> {
175
+ return this.#run('proof', async () => {
176
+ const observed = await this.deps.authenticate(args)
177
+ const attempt = await this.#claimAuthenticated(
178
+ args.ceremonyId,
179
+ 'fresh-authentication',
180
+ observed
181
+ )
182
+ const response = parseResponse(args.response, 'webauthn.get') as AuthenticationResponseJSON
183
+ const key = active(await this.deps.store.passkeys.findCredentialByCredentialId(response.id))
184
+ if (key.admin_user_id !== observed.user.id) throw invalidProof()
185
+ const verified = await this.#verifyAuthentication(response, key, attempt)
186
+ return this.deps.store.withSessionLock(observed.user.id, async (store) => {
187
+ const current = await this.deps.authenticate(args, store)
188
+ await this.#recheckAuthority(store, attempt, current)
189
+ const action = await this.#authorizeAction(actionOf(attempt), current, store)
190
+ await this.#applyVerifiedKey(store, key, verified)
191
+ return this.#grant(store, current, action, key.id)
192
+ })
193
+ })
194
+ }
195
+
196
+ enrollmentOptions(
197
+ args: PasskeySession & { grantId: string }
198
+ ): Promise<PasskeyCeremony<PasskeyRegistrationOptions>> {
199
+ return this.#run('enrollment-options', async () => {
200
+ const observed = await this.deps.authenticate(args)
201
+ const grant = await this.#claimAuthenticated(args.grantId, 'grant', observed, 'enroll')
202
+ return this.deps.store.withSessionLock(observed.user.id, async (store) => {
203
+ const current = await this.deps.authenticate(args, store)
204
+ await this.#recheckAuthority(store, grant, current)
205
+ await this.#authorizeAction(actionOf(grant), current, store)
206
+ await this.#capacity(store, current.sessionId)
207
+ const existing = await store.passkeys.listCredentials(current.user.id)
208
+ if (existing.length >= MAX_CREDENTIALS) throw limit()
209
+ const handle = await store.passkeys.ensureUserHandle(current.user.id, opaque())
210
+ const { raw, row } = this.#newAttempt('enrollment', current, {
211
+ action: 'enroll',
212
+ target_user_id: current.user.id,
213
+ credential_record_id: null,
214
+ })
215
+ row.expires_at = grant.expires_at
216
+ row.proof_credential_id = grant.proof_credential_id
217
+ const generated = await generateRegistrationOptions({
218
+ rpID: this.#trust.rpId,
219
+ rpName: this.#trust.rpName,
220
+ userName: current.user.email,
221
+ userID: Buffer.from(handle, 'base64url'),
222
+ userDisplayName: current.user.given_name || current.user.email,
223
+ challenge: Buffer.from(row.challenge!, 'base64url'),
224
+ timeout: Math.max(1, grant.expires_at.getTime() - this.deps.now().getTime()),
225
+ attestationType: 'none',
226
+ authenticatorSelection: {
227
+ residentKey: 'required',
228
+ requireResidentKey: true,
229
+ userVerification: 'required',
230
+ },
231
+ excludeCredentials: existing.map((key) => ({ id: key.credential_id })),
232
+ extensions: { credProps: true },
233
+ })
234
+ const options: PasskeyRegistrationOptions = {
235
+ ...generated,
236
+ excludeCredentials: existing.map((key) => ({
237
+ id: key.credential_id,
238
+ type: 'public-key' as const,
239
+ })),
240
+ rp: { id: this.#trust.rpId, name: this.#trust.rpName },
241
+ attestation: 'none',
242
+ authenticatorSelection: {
243
+ residentKey: 'required',
244
+ requireResidentKey: true,
245
+ userVerification: 'required',
246
+ },
247
+ extensions: { credProps: true },
248
+ }
249
+ this.#unexpired(row)
250
+ await store.passkeys.createAttempt(row)
251
+ return { ceremonyId: raw, expiresAt: row.expires_at.toISOString(), options }
252
+ })
253
+ })
254
+ }
255
+
256
+ verifyEnrollment(
257
+ args: PasskeySession & { ceremonyId: string; response: unknown; name: string }
258
+ ): Promise<PasskeyCredentialSummary> {
259
+ return this.#run('enrollment', async () => {
260
+ const observed = await this.deps.authenticate(args)
261
+ const attempt = await this.#claimAuthenticated(
262
+ args.ceremonyId,
263
+ 'enrollment',
264
+ observed,
265
+ 'enroll'
266
+ )
267
+ const name = displayName(args.name)
268
+ const response = parseResponse(args.response, 'webauthn.create') as RegistrationResponseJSON
269
+ // residentKey: 'required' / requireResidentKey enforce discoverability during create().
270
+ // credProps reports client properties and may be omitted; only an explicit false contradicts it.
271
+ if (response.clientExtensionResults.credProps?.rk === false) throw invalidProof()
272
+ const verified = await verifyRegistrationResponse({
273
+ response,
274
+ expectedChallenge: attempt.challenge!,
275
+ expectedOrigin: [...this.#trust.origins],
276
+ expectedRPID: this.#trust.rpId,
277
+ expectedType: 'webauthn.create',
278
+ requireUserVerification: true,
279
+ requireUserPresence: true,
280
+ }).catch(() => {
281
+ throw invalidProof()
282
+ })
283
+ if (
284
+ !verified.verified ||
285
+ !verified.registrationInfo.userVerified ||
286
+ verified.registrationInfo.credential.id !== response.id
287
+ )
288
+ throw invalidProof()
289
+ const info = verified.registrationInfo
290
+ return this.deps.store.withSessionLock(observed.user.id, async (store) => {
291
+ const current = await this.deps.authenticate(args, store)
292
+ await this.#recheckAuthority(store, attempt, current)
293
+ await this.#authorizeAction(actionOf(attempt), current, store)
294
+ if ((await store.passkeys.listCredentials(current.user.id)).length >= MAX_CREDENTIALS)
295
+ throw limit()
296
+ if (await store.passkeys.findCredentialByCredentialId(info.credential.id))
297
+ throw invalidProof()
298
+ const row = {
299
+ id: uuidv7(),
300
+ admin_user_id: current.user.id,
301
+ credential_id: info.credential.id,
302
+ public_key: Buffer.from(info.credential.publicKey).toString('base64url'),
303
+ counter: info.credential.counter,
304
+ transports: info.credential.transports ?? [],
305
+ backup_eligible: info.credentialDeviceType === 'multiDevice',
306
+ backed_up: info.credentialBackedUp,
307
+ name,
308
+ created_at: this.deps.now(),
309
+ last_used_at: null,
310
+ revoked_at: null,
311
+ }
312
+ await store.passkeys.createCredential(row)
313
+ return summary(row)
314
+ })
315
+ })
316
+ }
317
+
318
+ listCredentials(
319
+ args: PasskeySession & { targetUserId?: string }
320
+ ): Promise<PasskeyCredentialSummary[]> {
321
+ return this.#run('list', async () => {
322
+ const observed = await this.deps.authenticate(args)
323
+ const targetId = args.targetUserId ?? observed.user.id
324
+ if (!validUuid(targetId)) throw forbidden()
325
+ return this.deps.store.withSessionLocks([observed.user.id, targetId], async (store) => {
326
+ const current = await this.deps.authenticate(args, store)
327
+ await this.#authorizeTarget(targetId, current, store)
328
+ return (await store.passkeys.listCredentials(targetId)).map(summary)
329
+ })
330
+ })
331
+ }
332
+
333
+ renameCredential(
334
+ args: PasskeySession & { credentialRecordId: string; name: string }
335
+ ): Promise<PasskeyCredentialSummary> {
336
+ return this.#run('rename', async () => {
337
+ const observed = await this.deps.authenticate(args)
338
+ const name = displayName(args.name)
339
+ if (!validUuid(args.credentialRecordId)) throw forbidden()
340
+ return this.deps.store.withSessionLock(observed.user.id, async (store) => {
341
+ const current = await this.deps.authenticate(args, store)
342
+ const key = active(await store.passkeys.findCredential(args.credentialRecordId))
343
+ if (key.admin_user_id !== current.user.id) throw forbidden()
344
+ await store.passkeys.updateCredential(key.id, { name })
345
+ return summary({ ...key, name })
346
+ })
347
+ })
348
+ }
349
+
350
+ removeCredential(args: PasskeySession & { grantId: string }): Promise<{ signedOut: boolean }> {
351
+ return this.#run('remove', async () => {
352
+ const observed = await this.deps.authenticate(args)
353
+ const grant = await this.#claimAuthenticated(args.grantId, 'grant', observed, 'remove')
354
+ const action = actionOf(grant)
355
+ if (action.purpose !== 'remove') throw expired()
356
+ const audit = {
357
+ actorId: observed.user.id,
358
+ targetId: action.targetUserId,
359
+ credentialId: action.credentialRecordId,
360
+ }
361
+ try {
362
+ return await this.deps.store.withSessionLocks(
363
+ [observed.user.id, action.targetUserId],
364
+ async (store) => {
365
+ const current = await this.deps.authenticate(args, store)
366
+ await this.#recheckAuthority(store, grant, current)
367
+ await this.#authorizeAction(action, current, store)
368
+ await store.passkeys.revokeCredential(action.credentialRecordId, this.deps.now())
369
+ await store.passkeys.invalidateAccountLogins(action.targetUserId, this.deps.now())
370
+ if (current.user.id !== action.targetUserId)
371
+ await store.passkeys.appendRemovalAudit({ ...audit, outcome: 'success' })
372
+ return { signedOut: current.user.id === action.targetUserId }
373
+ }
374
+ )
375
+ } catch (error) {
376
+ if (observed.user.id !== action.targetUserId) {
377
+ await this.deps.store.passkeys
378
+ .appendRemovalAudit({
379
+ ...audit,
380
+ outcome:
381
+ error instanceof AuthError && error.code === AuthErrorCodes.FORBIDDEN
382
+ ? 'denied'
383
+ : 'failed',
384
+ })
385
+ .catch(() => {})
386
+ }
387
+ throw error
388
+ }
389
+ })
390
+ }
391
+
392
+ async #run<T>(purpose: PasskeyEvent['purpose'], work: () => Promise<T>): Promise<T> {
393
+ this.deps.emit({ type: 'passkey_attempted', purpose })
394
+ try {
395
+ const result = await work()
396
+ this.deps.emit({ type: 'passkey_completed', purpose })
397
+ return result
398
+ } catch (error) {
399
+ const reason =
400
+ error instanceof AuthError
401
+ ? error.code === AuthErrorCodes.FORBIDDEN
402
+ ? 'forbidden'
403
+ : error.code === AuthErrorCodes.PASSKEY_LIMIT
404
+ ? 'limit'
405
+ : error.code === AuthErrorCodes.INVALID_CREDENTIALS
406
+ ? 'invalid-proof'
407
+ : 'stale-authority'
408
+ : 'unavailable'
409
+ this.deps.emit({ type: 'passkey_failed', purpose, reason })
410
+ // Never propagate driver errors or verification-library details containing credential material.
411
+ if (error instanceof AuthError) throw new AuthError(error.code, { message: error.message })
412
+ throw new AuthError(AuthErrorCodes.PASSKEY_UNAVAILABLE, {
413
+ message: 'Passkey service unavailable',
414
+ })
415
+ }
416
+ }
417
+
418
+ async #authenticationOptions(challenge: string): Promise<PasskeyAuthenticationOptions> {
419
+ const generated = await generateAuthenticationOptions({
420
+ rpID: this.#trust.rpId,
421
+ challenge: Buffer.from(challenge, 'base64url'),
422
+ timeout: LIFETIME_MS,
423
+ userVerification: 'required',
424
+ })
425
+ return {
426
+ challenge: generated.challenge,
427
+ timeout: generated.timeout,
428
+ rpId: this.#trust.rpId,
429
+ userVerification: 'required',
430
+ }
431
+ }
432
+
433
+ #newAttempt(
434
+ purpose: PasskeyAttemptRow['purpose'],
435
+ actor?: Authenticated,
436
+ action?: Pick<PasskeyAttemptRow, 'action' | 'target_user_id' | 'credential_record_id'>
437
+ ) {
438
+ const raw = opaque()
439
+ const now = this.deps.now()
440
+ const row: Omit<PasskeyAttemptRow, 'consumed_at'> = {
441
+ token_hash: hashBearer(raw),
442
+ purpose,
443
+ challenge: purpose === 'grant' ? null : opaque(),
444
+ actor_user_id: actor?.user.id ?? null,
445
+ actor_login_id: actor?.sessionId ?? null,
446
+ actor_generation: actor?.user.session_version ?? null,
447
+ action: action?.action ?? null,
448
+ target_user_id: action?.target_user_id ?? null,
449
+ credential_record_id: action?.credential_record_id ?? null,
450
+ proof_credential_id: null,
451
+ created_at: now,
452
+ expires_at: new Date(now.getTime() + LIFETIME_MS),
453
+ }
454
+ return { raw, row }
455
+ }
456
+
457
+ async #claimAuthenticated(
458
+ raw: string,
459
+ purpose: 'fresh-authentication' | 'enrollment' | 'grant',
460
+ actor: Authenticated,
461
+ requiredAction?: 'enroll' | 'remove'
462
+ ) {
463
+ const tokenHash = hashBearer(raw)
464
+ const observed = await this.deps.store.passkeys.findAttempt(tokenHash)
465
+ if (!observed?.action || observed.purpose !== purpose) throw expired()
466
+ const row = await this.deps.store.passkeys.claimAttempt({
467
+ tokenHash,
468
+ purpose,
469
+ action: requiredAction ?? observed.action,
470
+ binding: {
471
+ actor_user_id: actor.user.id,
472
+ actor_login_id: actor.sessionId,
473
+ actor_generation: actor.user.session_version,
474
+ },
475
+ now: this.deps.now(),
476
+ })
477
+ if (!row) throw expired()
478
+ return row
479
+ }
480
+
481
+ #unexpired(row: Pick<PasskeyAttemptRow, 'expires_at'>): void {
482
+ if (row.expires_at.getTime() <= this.deps.now().getTime()) throw expired()
483
+ }
484
+
485
+ async #recheckAuthority(
486
+ store: AdminStore,
487
+ row: PasskeyAttemptRow,
488
+ actor: Authenticated
489
+ ): Promise<void> {
490
+ this.#unexpired(row)
491
+ if (
492
+ row.actor_user_id !== actor.user.id ||
493
+ row.actor_login_id !== actor.sessionId ||
494
+ row.actor_generation !== actor.user.session_version
495
+ ) {
496
+ throw ERR_SESSION_CHANGED({ message: 'Fresh proof authority changed; start again' })
497
+ }
498
+ if (row.proof_credential_id) {
499
+ const key = active(await store.passkeys.findCredential(row.proof_credential_id))
500
+ if (key.admin_user_id !== actor.user.id) throw invalidProof()
501
+ }
502
+ }
503
+
504
+ async #authorizeTarget(targetId: string, actor: Authenticated, store: AdminStore) {
505
+ if (!validUuid(targetId)) throw forbidden()
506
+ const target = await store.adminUsers.getById(targetId)
507
+ if (!target) throw forbidden()
508
+ if (targetId !== actor.user.id) {
509
+ actor.actor.assertAbility(ADMIN_USERS_ABILITIES.managePasskeys)
510
+ if (target.is_super_admin && !actor.actor.isSuperAdmin) throw forbidden()
511
+ }
512
+ return target
513
+ }
514
+
515
+ async #authorizeAction(action: PasskeyManagementAction, actor: Authenticated, store: AdminStore) {
516
+ if (action?.purpose === 'enroll')
517
+ return {
518
+ action: 'enroll' as const,
519
+ target_user_id: actor.user.id,
520
+ credential_record_id: null,
521
+ }
522
+ if (action?.purpose !== 'remove' || !validUuid(action.credentialRecordId)) throw forbidden()
523
+ await this.#authorizeTarget(action.targetUserId, actor, store)
524
+ const key = active(await store.passkeys.findCredential(action.credentialRecordId))
525
+ if (key.admin_user_id !== action.targetUserId) throw forbidden()
526
+ return {
527
+ action: 'remove' as const,
528
+ target_user_id: action.targetUserId,
529
+ credential_record_id: key.id,
530
+ }
531
+ }
532
+
533
+ async #capacity(store: AdminStore, sessionId: string) {
534
+ if ((await store.passkeys.countPendingForLogin(sessionId, this.deps.now())) >= MAX_PENDING)
535
+ throw limit()
536
+ }
537
+
538
+ async #grant(
539
+ store: AdminStore,
540
+ actor: Authenticated,
541
+ action: Pick<PasskeyAttemptRow, 'action' | 'target_user_id' | 'credential_record_id'>,
542
+ proofCredential: string | null
543
+ ): Promise<PasskeyManagementGrant> {
544
+ await this.#capacity(store, actor.sessionId)
545
+ const { raw, row } = this.#newAttempt('grant', actor, action)
546
+ row.proof_credential_id = proofCredential
547
+ await store.passkeys.createAttempt(row)
548
+ return { grantId: raw, expiresAt: row.expires_at.toISOString() }
549
+ }
550
+
551
+ async #verifyAuthentication(
552
+ response: AuthenticationResponseJSON,
553
+ key: PasskeyCredentialRow & { admin_user_id: string },
554
+ attempt: PasskeyAttemptRow
555
+ ) {
556
+ const handle = await this.deps.store.passkeys.getUserHandle(key.admin_user_id)
557
+ if (!handle || response.response.userHandle !== handle) throw invalidProof()
558
+ const result = await verifyAuthenticationResponse({
559
+ response,
560
+ expectedChallenge: attempt.challenge!,
561
+ expectedOrigin: [...this.#trust.origins],
562
+ expectedRPID: this.#trust.rpId,
563
+ expectedType: 'webauthn.get',
564
+ requireUserVerification: true,
565
+ credential: {
566
+ id: key.credential_id,
567
+ publicKey: Buffer.from(key.public_key, 'base64url'),
568
+ counter: key.counter,
569
+ },
570
+ }).catch(() => {
571
+ throw invalidProof()
572
+ })
573
+ if (
574
+ !result.verified ||
575
+ !result.authenticationInfo.userVerified ||
576
+ (result.authenticationInfo.credentialDeviceType === 'multiDevice') !== key.backup_eligible
577
+ )
578
+ throw invalidProof()
579
+ return result.authenticationInfo
580
+ }
581
+
582
+ async #applyVerifiedKey(
583
+ store: AdminStore,
584
+ snapshot: PasskeyCredentialRow & { admin_user_id: string },
585
+ verified: { newCounter: number; credentialBackedUp: boolean }
586
+ ) {
587
+ const current = active(await store.passkeys.findCredential(snapshot.id))
588
+ for (const field of [
589
+ 'admin_user_id',
590
+ 'credential_id',
591
+ 'public_key',
592
+ 'counter',
593
+ 'backup_eligible',
594
+ 'backed_up',
595
+ ] as const) {
596
+ if (current[field] !== snapshot[field]) throw invalidProof()
597
+ }
598
+ await store.passkeys.updateCredential(current.id, {
599
+ counter: verified.newCounter,
600
+ backed_up: verified.credentialBackedUp,
601
+ last_used_at: this.deps.now(),
602
+ })
603
+ }
604
+ }
605
+
606
+ function opaque() {
607
+ return randomBytes(32).toString('base64url')
608
+ }
609
+ function hashBearer(raw: string) {
610
+ try {
611
+ assertPasskeyBytes(raw, 32, 32)
612
+ } catch {
613
+ throw expired()
614
+ }
615
+ return createHash('sha256').update(raw).digest('hex')
616
+ }
617
+ function expired() {
618
+ return ERR_INVALID_TOKEN({ message: 'Passkey attempt expired or already used; start again' })
619
+ }
620
+ function invalidProof() {
621
+ return ERR_INVALID_CREDENTIALS({ message: 'Passkey verification failed; start again' })
622
+ }
623
+ function forbidden() {
624
+ return ERR_FORBIDDEN({ message: 'Passkey management is not permitted' })
625
+ }
626
+ function limit() {
627
+ return new AuthError(AuthErrorCodes.PASSKEY_LIMIT, {
628
+ message: 'Passkey credential or pending-operation limit reached',
629
+ })
630
+ }
631
+ function active(
632
+ row: PasskeyCredentialRow | null
633
+ ): PasskeyCredentialRow & { admin_user_id: string } {
634
+ if (!row || row.revoked_at !== null || row.admin_user_id === null) throw invalidProof()
635
+ return row as PasskeyCredentialRow & { admin_user_id: string }
636
+ }
637
+ function displayName(value: string) {
638
+ if (typeof value !== 'string' || !value.trim() || value.length > 80) throw forbidden()
639
+ return value
640
+ }
641
+ function summary(row: PasskeyCredentialRow): PasskeyCredentialSummary {
642
+ return {
643
+ id: row.id,
644
+ name: row.name,
645
+ createdAt: row.created_at.toISOString(),
646
+ lastUsedAt: row.last_used_at?.toISOString() ?? null,
647
+ }
648
+ }
649
+ function actionOf(row: PasskeyAttemptRow): PasskeyManagementAction {
650
+ if (row.action === 'enroll') return { purpose: 'enroll' }
651
+ if (row.action === 'remove' && row.target_user_id && row.credential_record_id) {
652
+ return {
653
+ purpose: 'remove',
654
+ targetUserId: row.target_user_id,
655
+ credentialRecordId: row.credential_record_id,
656
+ }
657
+ }
658
+ throw expired()
659
+ }
660
+
661
+ /** Bound structure and reject iframe ceremonies without changing the signed bytes. */
662
+ function parseResponse(
663
+ value: unknown,
664
+ expectedType: 'webauthn.get' | 'webauthn.create'
665
+ ): AuthenticationResponseJSON | RegistrationResponseJSON {
666
+ try {
667
+ if (
668
+ !value ||
669
+ typeof value !== 'object' ||
670
+ Array.isArray(value) ||
671
+ Buffer.byteLength(JSON.stringify(value)) > 64 * 1024
672
+ )
673
+ throw invalidProof()
674
+ const response = value as AuthenticationResponseJSON | RegistrationResponseJSON
675
+ if (
676
+ response.type !== 'public-key' ||
677
+ response.id !== response.rawId ||
678
+ !response.response ||
679
+ typeof response.response !== 'object' ||
680
+ !response.clientExtensionResults ||
681
+ typeof response.clientExtensionResults !== 'object'
682
+ )
683
+ throw invalidProof()
684
+ assertPasskeyBytes(response.id, 1024)
685
+ assertPasskeyBytes(response.response.clientDataJSON, 16 * 1024)
686
+ const client = JSON.parse(
687
+ Buffer.from(response.response.clientDataJSON, 'base64url').toString('utf8')
688
+ )
689
+ if (
690
+ !client ||
691
+ client.type !== expectedType ||
692
+ (client.crossOrigin !== undefined && client.crossOrigin !== false)
693
+ )
694
+ throw invalidProof()
695
+ if (expectedType === 'webauthn.get') {
696
+ const auth = response as AuthenticationResponseJSON
697
+ assertPasskeyBytes(auth.response.authenticatorData, 16 * 1024)
698
+ assertPasskeyBytes(auth.response.signature, 16 * 1024)
699
+ assertPasskeyBytes(auth.response.userHandle ?? '', 32, 32)
700
+ } else {
701
+ assertPasskeyBytes(
702
+ (response as RegistrationResponseJSON).response.attestationObject,
703
+ 48 * 1024
704
+ )
705
+ }
706
+ return response
707
+ } catch {
708
+ throw invalidProof()
709
+ }
710
+ }