@korajs/auth 1.0.0-beta.12 → 1.0.0-beta.14

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 (101) hide show
  1. package/README.md +52 -47
  2. package/dist/{create-org-session-RsDj9cl4.d.cts → create-org-session-ChFdulEM.d.cts} +211 -17
  3. package/dist/{create-org-session-RsDj9cl4.d.ts → create-org-session-ChFdulEM.d.ts} +211 -17
  4. package/dist/index.cjs +645 -150
  5. package/dist/index.cjs.map +1 -1
  6. package/dist/index.d.cts +27 -9
  7. package/dist/index.d.ts +27 -9
  8. package/dist/index.js +644 -150
  9. package/dist/index.js.map +1 -1
  10. package/dist/{operation-encryptor-DRmKNWpF.d.cts → operation-encryptor-DDdlb9bm.d.cts} +16 -0
  11. package/dist/{operation-encryptor-DRmKNWpF.d.ts → operation-encryptor-DDdlb9bm.d.ts} +16 -0
  12. package/dist/react.d.cts +2 -2
  13. package/dist/react.d.ts +2 -2
  14. package/dist/server.cjs +2880 -1667
  15. package/dist/server.cjs.map +1 -1
  16. package/dist/server.d.cts +810 -169
  17. package/dist/server.d.ts +810 -169
  18. package/dist/server.js +2848 -1646
  19. package/dist/server.js.map +1 -1
  20. package/dist/svelte.cjs +2 -2
  21. package/dist/svelte.cjs.map +1 -1
  22. package/dist/svelte.d.cts +2 -2
  23. package/dist/svelte.d.ts +2 -2
  24. package/dist/svelte.js +2 -2
  25. package/dist/svelte.js.map +1 -1
  26. package/dist/vue.d.cts +1 -1
  27. package/dist/vue.d.ts +1 -1
  28. package/package.json +7 -7
  29. package/src/admin/admin-api.ts +327 -0
  30. package/src/admin/audit-log.ts +324 -0
  31. package/src/admin/webhooks.ts +576 -0
  32. package/src/bindings/create-auth-session.ts +184 -0
  33. package/src/bindings/create-org-session.ts +130 -0
  34. package/src/client/auth-client.ts +1592 -0
  35. package/src/client/auth-sync.ts +213 -0
  36. package/src/client/device-session.ts +104 -0
  37. package/src/client/org-client.ts +399 -0
  38. package/src/client/quickstart.ts +108 -0
  39. package/src/client/storage.ts +94 -0
  40. package/src/device/device-identity.ts +330 -0
  41. package/src/device/device-store.ts +379 -0
  42. package/src/encryption/auto-lock.ts +170 -0
  43. package/src/encryption/database-encryption.ts +265 -0
  44. package/src/encryption/key-derivation.ts +149 -0
  45. package/src/encryption/operation-encryptor.ts +361 -0
  46. package/src/index.ts +132 -0
  47. package/src/mfa/totp.ts +826 -0
  48. package/src/org/org-routes.ts +758 -0
  49. package/src/org/org-store.ts +490 -0
  50. package/src/org/org-types.ts +230 -0
  51. package/src/passkey/passkey-client.ts +597 -0
  52. package/src/passkey/passkey-server.ts +779 -0
  53. package/src/postgres/ensure-schema.ts +65 -0
  54. package/src/provider/adapter.ts +246 -0
  55. package/src/provider/built-in/auth-routes.ts +1313 -0
  56. package/src/provider/built-in/email-verification.ts +303 -0
  57. package/src/provider/built-in/password-hash.ts +118 -0
  58. package/src/provider/built-in/password-reset.ts +416 -0
  59. package/src/provider/built-in/postgres-user-store.ts +365 -0
  60. package/src/provider/built-in/quickstart-server.ts +760 -0
  61. package/src/provider/built-in/sqlite-user-store.ts +335 -0
  62. package/src/provider/built-in/sync-scopes.ts +85 -0
  63. package/src/provider/built-in/user-store.ts +465 -0
  64. package/src/provider/external/clerk-adapter.ts +157 -0
  65. package/src/provider/external/external-jwt-provider.ts +491 -0
  66. package/src/provider/external/supabase-adapter.ts +163 -0
  67. package/src/provider/oauth/linked-identity-store.ts +108 -0
  68. package/src/provider/oauth/oauth-flow.ts +550 -0
  69. package/src/provider/oauth/oauth-types.ts +184 -0
  70. package/src/provider/oauth/postgres-oauth-store.ts +296 -0
  71. package/src/provider/oauth/sqlite-oauth-store.ts +285 -0
  72. package/src/rbac/rbac-engine.ts +323 -0
  73. package/src/rbac/rbac-types.ts +210 -0
  74. package/src/rbac/scope-resolver.ts +140 -0
  75. package/src/react/AuthProvider.tsx +97 -0
  76. package/src/react/OrgProvider.tsx +41 -0
  77. package/src/react/auth-context.ts +26 -0
  78. package/src/react/hooks.ts +110 -0
  79. package/src/react/org-hooks.ts +214 -0
  80. package/src/react.ts +26 -0
  81. package/src/server.ts +338 -0
  82. package/src/session/session.ts +401 -0
  83. package/src/svelte/auth-context.ts +50 -0
  84. package/src/svelte/org-context.ts +32 -0
  85. package/src/svelte/org-hooks.ts +201 -0
  86. package/src/svelte/use-auth.ts +115 -0
  87. package/src/svelte.ts +25 -0
  88. package/src/tokens/encrypted-token-store.ts +360 -0
  89. package/src/tokens/jwt.ts +236 -0
  90. package/src/tokens/postgres-token-revocation-store.ts +140 -0
  91. package/src/tokens/sqlite-token-revocation-store.ts +121 -0
  92. package/src/tokens/token-manager.ts +821 -0
  93. package/src/tokens/token-store.ts +192 -0
  94. package/src/types.ts +394 -0
  95. package/src/vue/auth-context.ts +10 -0
  96. package/src/vue/auth-provider-types.ts +5 -0
  97. package/src/vue/auth-provider.ts +76 -0
  98. package/src/vue/org-hooks.ts +193 -0
  99. package/src/vue/org-provider.ts +49 -0
  100. package/src/vue/use-auth.ts +139 -0
  101. package/src/vue.ts +10 -0
@@ -0,0 +1,779 @@
1
+ import { randomBytes } from 'node:crypto'
2
+ import { KoraError } from '@korajs/core'
3
+ import { fromBase64Url, toBase64Url } from '../device/device-identity'
4
+ import { decodeCbor } from './passkey-client'
5
+
6
+ // ============================================================================
7
+ // Server-side passkey errors
8
+ // ============================================================================
9
+
10
+ /**
11
+ * Thrown when server-side passkey verification fails.
12
+ */
13
+ export class PasskeyVerificationError extends KoraError {
14
+ constructor(message: string, context?: Record<string, unknown>) {
15
+ super(message, 'PASSKEY_VERIFICATION_ERROR', context)
16
+ this.name = 'PasskeyVerificationError'
17
+ }
18
+ }
19
+
20
+ // ============================================================================
21
+ // Registration options generation
22
+ // ============================================================================
23
+
24
+ /** Options returned by generateRegistrationOptions for the client. */
25
+ export interface RegistrationOptions {
26
+ /** Base64url-encoded random challenge (32 bytes) */
27
+ challenge: string
28
+ /** Relying party ID (domain) */
29
+ rpId: string
30
+ /** Relying party display name */
31
+ rpName: string
32
+ /** Base64url-encoded user ID */
33
+ userId: string
34
+ /** User's email or username */
35
+ userName: string
36
+ /** Human-readable display name */
37
+ userDisplayName: string
38
+ /** Credential IDs to exclude (prevents re-registration) */
39
+ excludeCredentialIds: string[]
40
+ /** Authenticator selection criteria */
41
+ authenticatorSelection: {
42
+ authenticatorAttachment: 'platform'
43
+ residentKey: 'preferred'
44
+ userVerification: 'required'
45
+ }
46
+ /** Timeout in milliseconds */
47
+ timeout: number
48
+ }
49
+
50
+ /**
51
+ * Generate registration options for creating a new passkey.
52
+ *
53
+ * Creates a cryptographically random challenge and assembles the options
54
+ * object that should be sent to the client for `createPasskeyCredential()`.
55
+ *
56
+ * The server must store the challenge (keyed by user session or similar)
57
+ * for later verification when the client responds.
58
+ *
59
+ * @param params - Registration parameters
60
+ * @param params.rpId - Relying party ID (your domain, e.g. "example.com")
61
+ * @param params.rpName - Relying party display name
62
+ * @param params.userId - Unique user identifier
63
+ * @param params.userName - User's email or username
64
+ * @param params.userDisplayName - Human-readable display name
65
+ * @param params.existingCredentialIds - Base64url credential IDs to exclude
66
+ * @returns Registration options to send to the client, including the challenge
67
+ *
68
+ * @example
69
+ * ```typescript
70
+ * const options = generateRegistrationOptions({
71
+ * rpId: 'example.com',
72
+ * rpName: 'My App',
73
+ * userId: user.id,
74
+ * userName: user.email,
75
+ * userDisplayName: user.name,
76
+ * })
77
+ * // Store options.challenge in session for later verification
78
+ * // Send options to client
79
+ * ```
80
+ */
81
+ export function generateRegistrationOptions(params: {
82
+ rpId: string
83
+ rpName: string
84
+ userId: string
85
+ userName: string
86
+ userDisplayName: string
87
+ existingCredentialIds?: string[]
88
+ }): RegistrationOptions {
89
+ // Generate a 32-byte cryptographically random challenge
90
+ const challengeBytes = randomBytes(32)
91
+ const challenge = toBase64Url(
92
+ challengeBytes.buffer.slice(
93
+ challengeBytes.byteOffset,
94
+ challengeBytes.byteOffset + challengeBytes.byteLength,
95
+ ),
96
+ )
97
+
98
+ return {
99
+ challenge,
100
+ rpId: params.rpId,
101
+ rpName: params.rpName,
102
+ userId: params.userId,
103
+ userName: params.userName,
104
+ userDisplayName: params.userDisplayName,
105
+ excludeCredentialIds: params.existingCredentialIds ?? [],
106
+ authenticatorSelection: {
107
+ authenticatorAttachment: 'platform',
108
+ residentKey: 'preferred',
109
+ userVerification: 'required',
110
+ },
111
+ timeout: 60000,
112
+ }
113
+ }
114
+
115
+ // ============================================================================
116
+ // Registration verification
117
+ // ============================================================================
118
+
119
+ /** Result of verifying a registration response. */
120
+ export interface RegistrationVerificationResult {
121
+ /** Whether the registration response was verified successfully */
122
+ verified: boolean
123
+ /** Base64url-encoded credential ID */
124
+ credentialId: string
125
+ /** Base64url-encoded COSE public key (store this for future authentication) */
126
+ publicKey: string
127
+ /** Initial signature counter from the authenticator */
128
+ signCount: number
129
+ }
130
+
131
+ /**
132
+ * Verify a registration response from the client.
133
+ *
134
+ * Validates the attestation object and clientDataJSON returned by the browser's
135
+ * `navigator.credentials.create()` call. Extracts and returns the public key
136
+ * and credential ID to store in your database.
137
+ *
138
+ * This implementation supports the "none" attestation format, which is the most
139
+ * common and does not require trust in any attestation CA. For higher assurance
140
+ * scenarios, extend this to verify packed/tpm/android attestation formats.
141
+ *
142
+ * @param params - Verification parameters
143
+ * @param params.credential - The credential response from the client
144
+ * @param params.expectedChallenge - The challenge that was sent to the client (base64url)
145
+ * @param params.expectedOrigin - The expected origin (e.g. "https://example.com")
146
+ * @param params.expectedRpId - The expected relying party ID (e.g. "example.com")
147
+ * @returns Verification result with the credential ID and public key to store
148
+ * @throws {PasskeyVerificationError} If the response is invalid or tampered with
149
+ *
150
+ * @example
151
+ * ```typescript
152
+ * const result = await verifyRegistrationResponse({
153
+ * credential: clientResponse,
154
+ * expectedChallenge: storedChallenge,
155
+ * expectedOrigin: 'https://example.com',
156
+ * expectedRpId: 'example.com',
157
+ * })
158
+ * if (result.verified) {
159
+ * // Store result.credentialId, result.publicKey, result.signCount
160
+ * }
161
+ * ```
162
+ */
163
+ export async function verifyRegistrationResponse(params: {
164
+ credential: {
165
+ credentialId: string
166
+ publicKey: string
167
+ clientDataJSON: string
168
+ attestationObject: string
169
+ }
170
+ expectedChallenge: string
171
+ expectedOrigin: string
172
+ expectedRpId: string
173
+ /**
174
+ * Require the User Verified (UV) flag. Kora's options request
175
+ * `userVerification: 'required'`, so the response must honour it.
176
+ * @default true
177
+ */
178
+ requireUserVerification?: boolean
179
+ }): Promise<RegistrationVerificationResult> {
180
+ const { credential, expectedChallenge, expectedOrigin, expectedRpId } = params
181
+ const requireUserVerification = params.requireUserVerification ?? true
182
+
183
+ // Step 1: Decode and verify clientDataJSON
184
+ const clientDataBytes = fromBase64Url(credential.clientDataJSON)
185
+ const clientDataText = new TextDecoder().decode(clientDataBytes)
186
+ let clientData: { type: string; challenge: string; origin: string }
187
+ try {
188
+ clientData = JSON.parse(clientDataText) as {
189
+ type: string
190
+ challenge: string
191
+ origin: string
192
+ }
193
+ } catch {
194
+ throw new PasskeyVerificationError(
195
+ 'Failed to parse clientDataJSON. The response may be malformed.',
196
+ )
197
+ }
198
+
199
+ // Verify the type is "webauthn.create"
200
+ if (clientData.type !== 'webauthn.create') {
201
+ throw new PasskeyVerificationError(
202
+ `Expected clientData.type "webauthn.create" but received "${clientData.type}".`,
203
+ { type: clientData.type },
204
+ )
205
+ }
206
+
207
+ // Verify the challenge matches what we sent
208
+ if (clientData.challenge !== expectedChallenge) {
209
+ throw new PasskeyVerificationError(
210
+ 'Challenge mismatch. The response does not match the expected challenge. ' +
211
+ 'This may indicate a replay attack or session mismatch.',
212
+ )
213
+ }
214
+
215
+ // Verify the origin matches
216
+ if (clientData.origin !== expectedOrigin) {
217
+ throw new PasskeyVerificationError(
218
+ `Origin mismatch. Expected "${expectedOrigin}" but received "${clientData.origin}".`,
219
+ { expected: expectedOrigin, received: clientData.origin },
220
+ )
221
+ }
222
+
223
+ // Step 2: Decode the attestation object (CBOR)
224
+ const attestationBytes = fromBase64Url(credential.attestationObject)
225
+ const attestationResult = decodeCbor(attestationBytes, 0)
226
+ const attestationMap = attestationResult.value as Map<string, unknown>
227
+
228
+ // Verify attestation format
229
+ const fmt = attestationMap.get('fmt')
230
+ if (fmt !== 'none') {
231
+ // For Phase 3, we only support "none" attestation.
232
+ // Other formats (packed, tpm, android-key, etc.) can be added later.
233
+ throw new PasskeyVerificationError(
234
+ `Unsupported attestation format "${String(fmt)}". Only "none" attestation is currently supported.`,
235
+ { format: String(fmt) },
236
+ )
237
+ }
238
+
239
+ // Step 3: Parse the authenticator data
240
+ const authData = attestationMap.get('authData')
241
+ if (!(authData instanceof Uint8Array)) {
242
+ throw new PasskeyVerificationError(
243
+ 'Invalid attestation object: authData is missing or not a byte string.',
244
+ )
245
+ }
246
+
247
+ // Verify the RP ID hash (first 32 bytes of authData)
248
+ const rpIdHash = authData.slice(0, 32)
249
+ const expectedRpIdHash = await sha256(new TextEncoder().encode(expectedRpId))
250
+ if (!constantTimeEqual(rpIdHash, new Uint8Array(expectedRpIdHash))) {
251
+ throw new PasskeyVerificationError(
252
+ 'RP ID hash mismatch. The authenticator data does not match the expected relying party.',
253
+ )
254
+ }
255
+
256
+ // Parse flags (byte 32)
257
+ const flags = authData[32] as number
258
+
259
+ // Bit 0: User Present (UP) - must be set
260
+ if ((flags & 0x01) === 0) {
261
+ throw new PasskeyVerificationError(
262
+ 'User Present flag is not set in authenticator data. ' +
263
+ 'The authenticator did not confirm user presence.',
264
+ )
265
+ }
266
+
267
+ // Bit 2: User Verified (UV) - required unless explicitly relaxed (AUTH-14)
268
+ if (requireUserVerification && (flags & 0x04) === 0) {
269
+ throw new PasskeyVerificationError(
270
+ 'User Verified flag is not set in authenticator data, but user verification is required.',
271
+ )
272
+ }
273
+
274
+ // Bit 6: Attested Credential Data (AT) - must be set for registration
275
+ if ((flags & 0x40) === 0) {
276
+ throw new PasskeyVerificationError(
277
+ 'Attested Credential Data flag is not set. ' +
278
+ 'The authenticator did not include credential data.',
279
+ )
280
+ }
281
+
282
+ // Parse sign count (bytes 33-36, big-endian uint32)
283
+ const signCount =
284
+ ((authData[33] as number) << 24) |
285
+ ((authData[34] as number) << 16) |
286
+ ((authData[35] as number) << 8) |
287
+ (authData[36] as number)
288
+
289
+ // Parse attested credential data
290
+ // Skip rpIdHash (32) + flags (1) + signCount (4) = 37 bytes
291
+ let offset = 37
292
+
293
+ // aaguid: 16 bytes (we skip it — not needed for "none" attestation)
294
+ offset += 16
295
+
296
+ // credentialIdLength: 2 bytes, big-endian
297
+ const credentialIdLength = ((authData[offset] as number) << 8) | (authData[offset + 1] as number)
298
+ offset += 2
299
+
300
+ // credentialId: credentialIdLength bytes
301
+ const credentialIdBytes = authData.slice(offset, offset + credentialIdLength)
302
+ offset += credentialIdLength
303
+
304
+ // Verify the credential ID matches what the client sent
305
+ const expectedCredentialId = toBase64Url(credentialIdBytes.buffer as unknown as ArrayBuffer)
306
+ if (expectedCredentialId !== credential.credentialId) {
307
+ throw new PasskeyVerificationError(
308
+ 'Credential ID mismatch between attestation object and client response.',
309
+ )
310
+ }
311
+
312
+ // The remaining bytes are the COSE-encoded public key
313
+ const coseKeyResult = decodeCbor(authData, offset)
314
+ const coseKeyBytes = authData.slice(offset, coseKeyResult.offset)
315
+
316
+ // Verify the public key matches what the client sent
317
+ const publicKeyFromAttestation = toBase64Url(coseKeyBytes.buffer as unknown as ArrayBuffer)
318
+ if (publicKeyFromAttestation !== credential.publicKey) {
319
+ throw new PasskeyVerificationError(
320
+ 'Public key mismatch between attestation object and client response.',
321
+ )
322
+ }
323
+
324
+ return {
325
+ verified: true,
326
+ credentialId: credential.credentialId,
327
+ publicKey: credential.publicKey,
328
+ signCount: signCount >>> 0,
329
+ }
330
+ }
331
+
332
+ // ============================================================================
333
+ // Authentication options generation
334
+ // ============================================================================
335
+
336
+ /** Options returned by generateAuthenticationOptions for the client. */
337
+ export interface AuthenticationOptions {
338
+ /** Base64url-encoded random challenge (32 bytes) */
339
+ challenge: string
340
+ /** Relying party ID */
341
+ rpId: string
342
+ /** Credential IDs to allow (limit to specific credentials) */
343
+ allowCredentialIds?: string[]
344
+ /** User verification requirement */
345
+ userVerification: 'preferred'
346
+ /** Timeout in milliseconds */
347
+ timeout: number
348
+ }
349
+
350
+ /**
351
+ * Generate authentication options for signing in with a passkey.
352
+ *
353
+ * Creates a cryptographically random challenge and assembles the options
354
+ * object that should be sent to the client for `authenticateWithPasskey()`.
355
+ *
356
+ * The server must store the challenge for later verification.
357
+ *
358
+ * @param params - Authentication parameters
359
+ * @param params.rpId - Relying party ID (your domain)
360
+ * @param params.allowCredentialIds - Base64url credential IDs to allow (optional)
361
+ * @returns Authentication options to send to the client
362
+ *
363
+ * @example
364
+ * ```typescript
365
+ * const options = generateAuthenticationOptions({
366
+ * rpId: 'example.com',
367
+ * allowCredentialIds: user.credentialIds,
368
+ * })
369
+ * // Store options.challenge in session
370
+ * // Send options to client
371
+ * ```
372
+ */
373
+ export function generateAuthenticationOptions(params: {
374
+ rpId: string
375
+ allowCredentialIds?: string[]
376
+ }): AuthenticationOptions {
377
+ const challengeBytes = randomBytes(32)
378
+ const challenge = toBase64Url(
379
+ challengeBytes.buffer.slice(
380
+ challengeBytes.byteOffset,
381
+ challengeBytes.byteOffset + challengeBytes.byteLength,
382
+ ),
383
+ )
384
+
385
+ return {
386
+ challenge,
387
+ rpId: params.rpId,
388
+ allowCredentialIds: params.allowCredentialIds,
389
+ userVerification: 'preferred',
390
+ timeout: 60000,
391
+ }
392
+ }
393
+
394
+ // ============================================================================
395
+ // Authentication verification
396
+ // ============================================================================
397
+
398
+ /** Result of verifying an authentication response. */
399
+ export interface AuthenticationVerificationResult {
400
+ /** Whether the authentication response was verified successfully */
401
+ verified: boolean
402
+ /** Updated signature counter (store this to detect cloned authenticators) */
403
+ newSignCount: number
404
+ }
405
+
406
+ /**
407
+ * Verify an authentication response from the client.
408
+ *
409
+ * Validates the signed assertion returned by the browser's
410
+ * `navigator.credentials.get()` call. Checks the signature against the
411
+ * stored public key, verifies the challenge and origin, and validates
412
+ * the signature counter to detect cloned authenticators.
413
+ *
414
+ * This implementation supports ECDSA P-256 (ES256, COSE algorithm -7)
415
+ * signatures, which is the most common algorithm used by platform
416
+ * authenticators (Touch ID, Face ID, Windows Hello).
417
+ *
418
+ * @param params - Verification parameters
419
+ * @param params.assertion - The assertion response from the client
420
+ * @param params.expectedChallenge - The challenge that was sent to the client (base64url)
421
+ * @param params.expectedOrigin - The expected origin (e.g. "https://example.com")
422
+ * @param params.expectedRpId - The expected relying party ID
423
+ * @param params.publicKey - The stored COSE public key (base64url, from registration)
424
+ * @param params.previousSignCount - The previously stored signature counter
425
+ * @returns Verification result with the new signature counter
426
+ * @throws {PasskeyVerificationError} If the assertion is invalid
427
+ *
428
+ * @example
429
+ * ```typescript
430
+ * const result = await verifyAuthenticationResponse({
431
+ * assertion: clientAssertion,
432
+ * expectedChallenge: storedChallenge,
433
+ * expectedOrigin: 'https://example.com',
434
+ * expectedRpId: 'example.com',
435
+ * publicKey: storedCredential.publicKey,
436
+ * previousSignCount: storedCredential.signCount,
437
+ * })
438
+ * if (result.verified) {
439
+ * // Update stored sign count: storedCredential.signCount = result.newSignCount
440
+ * // Issue session tokens
441
+ * }
442
+ * ```
443
+ */
444
+ export async function verifyAuthenticationResponse(params: {
445
+ assertion: {
446
+ credentialId: string
447
+ authenticatorData: string
448
+ clientDataJSON: string
449
+ signature: string
450
+ userHandle: string | null
451
+ }
452
+ expectedChallenge: string
453
+ expectedOrigin: string
454
+ expectedRpId: string
455
+ publicKey: string
456
+ previousSignCount: number
457
+ /**
458
+ * Require the User Verified (UV) flag. Kora's options request
459
+ * `userVerification: 'required'`, so the assertion must honour it.
460
+ * @default true
461
+ */
462
+ requireUserVerification?: boolean
463
+ }): Promise<AuthenticationVerificationResult> {
464
+ const {
465
+ assertion,
466
+ expectedChallenge,
467
+ expectedOrigin,
468
+ expectedRpId,
469
+ publicKey,
470
+ previousSignCount,
471
+ } = params
472
+
473
+ // Step 1: Decode and verify clientDataJSON
474
+ const clientDataBytes = fromBase64Url(assertion.clientDataJSON)
475
+ const clientDataText = new TextDecoder().decode(clientDataBytes)
476
+ let clientData: { type: string; challenge: string; origin: string }
477
+ try {
478
+ clientData = JSON.parse(clientDataText) as {
479
+ type: string
480
+ challenge: string
481
+ origin: string
482
+ }
483
+ } catch {
484
+ throw new PasskeyVerificationError(
485
+ 'Failed to parse clientDataJSON. The assertion may be malformed.',
486
+ )
487
+ }
488
+
489
+ // Verify the type is "webauthn.get"
490
+ if (clientData.type !== 'webauthn.get') {
491
+ throw new PasskeyVerificationError(
492
+ `Expected clientData.type "webauthn.get" but received "${clientData.type}".`,
493
+ { type: clientData.type },
494
+ )
495
+ }
496
+
497
+ // Verify the challenge matches
498
+ if (clientData.challenge !== expectedChallenge) {
499
+ throw new PasskeyVerificationError(
500
+ 'Challenge mismatch. The assertion does not match the expected challenge.',
501
+ )
502
+ }
503
+
504
+ // Verify the origin matches
505
+ if (clientData.origin !== expectedOrigin) {
506
+ throw new PasskeyVerificationError(
507
+ `Origin mismatch. Expected "${expectedOrigin}" but received "${clientData.origin}".`,
508
+ { expected: expectedOrigin, received: clientData.origin },
509
+ )
510
+ }
511
+
512
+ // Step 2: Parse authenticator data
513
+ const authDataBytes = fromBase64Url(assertion.authenticatorData)
514
+
515
+ // Verify RP ID hash (first 32 bytes)
516
+ const rpIdHash = authDataBytes.slice(0, 32)
517
+ const expectedRpIdHash = await sha256(new TextEncoder().encode(expectedRpId))
518
+ if (!constantTimeEqual(rpIdHash, new Uint8Array(expectedRpIdHash))) {
519
+ throw new PasskeyVerificationError(
520
+ 'RP ID hash mismatch. The authenticator data does not match the expected relying party.',
521
+ )
522
+ }
523
+
524
+ // Parse flags (byte 32)
525
+ const flags = authDataBytes[32] as number
526
+
527
+ // Bit 0: User Present (UP) - must be set
528
+ if ((flags & 0x01) === 0) {
529
+ throw new PasskeyVerificationError('User Present flag is not set in authenticator data.')
530
+ }
531
+
532
+ // Bit 2: User Verified (UV) - required unless explicitly relaxed (AUTH-14)
533
+ if ((params.requireUserVerification ?? true) && (flags & 0x04) === 0) {
534
+ throw new PasskeyVerificationError(
535
+ 'User Verified flag is not set in authenticator data, but user verification is required.',
536
+ )
537
+ }
538
+
539
+ // Parse sign count (bytes 33-36, big-endian uint32)
540
+ const signCount =
541
+ (((authDataBytes[33] as number) << 24) |
542
+ ((authDataBytes[34] as number) << 16) |
543
+ ((authDataBytes[35] as number) << 8) |
544
+ (authDataBytes[36] as number)) >>>
545
+ 0
546
+
547
+ // Step 3: Validate sign count to detect cloned authenticators
548
+ // If both are 0, the authenticator doesn't support counters — skip check.
549
+ // If the new count is not greater than the previous, it may be cloned.
550
+ if (previousSignCount > 0 || signCount > 0) {
551
+ if (signCount <= previousSignCount) {
552
+ throw new PasskeyVerificationError(
553
+ `Signature counter did not increase. This may indicate a cloned authenticator. Previous count: ${previousSignCount}, received count: ${signCount}.`,
554
+ {
555
+ previousSignCount,
556
+ receivedSignCount: signCount,
557
+ },
558
+ )
559
+ }
560
+ }
561
+
562
+ // Step 4: Verify the signature
563
+ // The signature is over: authData || SHA-256(clientDataJSON)
564
+ const clientDataHash = await sha256(clientDataBytes)
565
+ const signedData = new Uint8Array(authDataBytes.length + clientDataHash.byteLength)
566
+ signedData.set(authDataBytes, 0)
567
+ signedData.set(new Uint8Array(clientDataHash), authDataBytes.length)
568
+
569
+ // Decode the COSE public key to get the raw EC key parameters
570
+ const coseKeyBytes = fromBase64Url(publicKey)
571
+ const coseKeyResult = decodeCbor(coseKeyBytes, 0)
572
+ const coseKeyMap = coseKeyResult.value as Map<number, unknown>
573
+
574
+ // COSE key map labels:
575
+ // 1: kty (key type) — 2 = EC2
576
+ // 3: alg (algorithm) — -7 = ES256
577
+ // -1: crv (curve) — 1 = P-256
578
+ // -2: x coordinate (byte string, 32 bytes)
579
+ // -3: y coordinate (byte string, 32 bytes)
580
+
581
+ const kty = coseKeyMap.get(1)
582
+ const alg = coseKeyMap.get(3)
583
+
584
+ if (kty !== 2) {
585
+ throw new PasskeyVerificationError(
586
+ `Unsupported COSE key type ${String(kty)}. Only EC2 (kty=2) is supported.`,
587
+ { kty: String(kty) },
588
+ )
589
+ }
590
+
591
+ if (alg !== -7) {
592
+ throw new PasskeyVerificationError(
593
+ `Unsupported COSE algorithm ${String(alg)}. Only ES256 (alg=-7) is supported.`,
594
+ { alg: String(alg) },
595
+ )
596
+ }
597
+
598
+ const xCoord = coseKeyMap.get(-2) as Uint8Array
599
+ const yCoord = coseKeyMap.get(-3) as Uint8Array
600
+
601
+ if (
602
+ !(xCoord instanceof Uint8Array) ||
603
+ !(yCoord instanceof Uint8Array) ||
604
+ xCoord.length !== 32 ||
605
+ yCoord.length !== 32
606
+ ) {
607
+ throw new PasskeyVerificationError(
608
+ 'Invalid COSE public key: x and y coordinates must be 32-byte arrays.',
609
+ )
610
+ }
611
+
612
+ // Import the public key as an ECDSA P-256 key for verification.
613
+ // We use the "raw" format: 0x04 || x || y (uncompressed point).
614
+ const rawPublicKey = new Uint8Array(65)
615
+ rawPublicKey[0] = 0x04 // Uncompressed point indicator
616
+ rawPublicKey.set(xCoord, 1)
617
+ rawPublicKey.set(yCoord, 33)
618
+
619
+ let cryptoKey: CryptoKey
620
+ try {
621
+ cryptoKey = await globalThis.crypto.subtle.importKey(
622
+ 'raw',
623
+ rawPublicKey.buffer as unknown as ArrayBuffer,
624
+ { name: 'ECDSA', namedCurve: 'P-256' },
625
+ false,
626
+ ['verify'],
627
+ )
628
+ } catch (error) {
629
+ throw new PasskeyVerificationError(
630
+ 'Failed to import COSE public key for signature verification.',
631
+ { cause: error instanceof Error ? error.message : String(error) },
632
+ )
633
+ }
634
+
635
+ // The WebAuthn signature is in ASN.1 DER format.
636
+ // Web Crypto's ECDSA verify expects the signature in IEEE P1363 format (r || s).
637
+ // Convert from DER to P1363.
638
+ const signatureBytes = fromBase64Url(assertion.signature)
639
+ const p1363Signature = derToP1363(signatureBytes, 32)
640
+
641
+ let verified: boolean
642
+ try {
643
+ verified = await globalThis.crypto.subtle.verify(
644
+ { name: 'ECDSA', hash: { name: 'SHA-256' } },
645
+ cryptoKey,
646
+ p1363Signature.buffer as unknown as ArrayBuffer,
647
+ signedData.buffer as unknown as ArrayBuffer,
648
+ )
649
+ } catch (error) {
650
+ throw new PasskeyVerificationError('Signature verification operation failed.', {
651
+ cause: error instanceof Error ? error.message : String(error),
652
+ })
653
+ }
654
+
655
+ if (!verified) {
656
+ return { verified: false, newSignCount: signCount }
657
+ }
658
+
659
+ return { verified: true, newSignCount: signCount }
660
+ }
661
+
662
+ // ============================================================================
663
+ // Internal helpers
664
+ // ============================================================================
665
+
666
+ /**
667
+ * Compute SHA-256 hash of the given data using Web Crypto API.
668
+ */
669
+ async function sha256(data: Uint8Array): Promise<ArrayBuffer> {
670
+ return globalThis.crypto.subtle.digest('SHA-256', data as unknown as ArrayBuffer)
671
+ }
672
+
673
+ /**
674
+ * Constant-time comparison of two byte arrays.
675
+ * Prevents timing attacks when comparing hashes or signatures.
676
+ */
677
+ function constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean {
678
+ if (a.length !== b.length) {
679
+ return false
680
+ }
681
+ let result = 0
682
+ for (let i = 0; i < a.length; i++) {
683
+ result |= (a[i] as number) ^ (b[i] as number)
684
+ }
685
+ return result === 0
686
+ }
687
+
688
+ /**
689
+ * Convert an ASN.1 DER-encoded ECDSA signature to IEEE P1363 format.
690
+ *
691
+ * DER format: 0x30 <len> 0x02 <r-len> <r> 0x02 <s-len> <s>
692
+ * P1363 format: <r-padded-to-n-bytes> <s-padded-to-n-bytes>
693
+ *
694
+ * This conversion is necessary because WebAuthn authenticators produce
695
+ * DER-encoded signatures, but the Web Crypto API expects P1363 format.
696
+ *
697
+ * @param derSignature - The DER-encoded signature bytes
698
+ * @param componentLength - The expected length of each component (32 for P-256)
699
+ * @returns The P1363-formatted signature
700
+ */
701
+ function derToP1363(derSignature: Uint8Array, componentLength: number): Uint8Array {
702
+ // Parse the DER structure
703
+ let offset = 0
704
+
705
+ // SEQUENCE tag (0x30)
706
+ if (derSignature[offset] !== 0x30) {
707
+ throw new PasskeyVerificationError('Invalid DER signature: expected SEQUENCE tag (0x30).')
708
+ }
709
+ offset += 1
710
+
711
+ // SEQUENCE length (may be 1 or 2 bytes)
712
+ if ((derSignature[offset] as number) & 0x80) {
713
+ // Long form: the lower 7 bits give the number of length bytes
714
+ const lengthBytes = (derSignature[offset] as number) & 0x7f
715
+ offset += 1 + lengthBytes
716
+ } else {
717
+ offset += 1
718
+ }
719
+
720
+ // First INTEGER (r)
721
+ if (derSignature[offset] !== 0x02) {
722
+ throw new PasskeyVerificationError(
723
+ 'Invalid DER signature: expected INTEGER tag (0x02) for r component.',
724
+ )
725
+ }
726
+ offset += 1
727
+
728
+ const rLength = derSignature[offset] as number
729
+ offset += 1
730
+
731
+ const rBytes = derSignature.slice(offset, offset + rLength)
732
+ offset += rLength
733
+
734
+ // Second INTEGER (s)
735
+ if (derSignature[offset] !== 0x02) {
736
+ throw new PasskeyVerificationError(
737
+ 'Invalid DER signature: expected INTEGER tag (0x02) for s component.',
738
+ )
739
+ }
740
+ offset += 1
741
+
742
+ const sLength = derSignature[offset] as number
743
+ offset += 1
744
+
745
+ const sBytes = derSignature.slice(offset, offset + sLength)
746
+
747
+ // Pad or trim r and s to componentLength bytes.
748
+ // DER integers may have a leading 0x00 byte to indicate positive sign,
749
+ // or may be shorter than componentLength if the leading bytes are zero.
750
+ const result = new Uint8Array(componentLength * 2)
751
+ copyComponentToP1363(rBytes, result, 0, componentLength)
752
+ copyComponentToP1363(sBytes, result, componentLength, componentLength)
753
+
754
+ return result
755
+ }
756
+
757
+ /**
758
+ * Copy a DER integer component into a fixed-width P1363 buffer.
759
+ * Handles leading zero padding (DER sign byte) and right-alignment.
760
+ */
761
+ function copyComponentToP1363(
762
+ component: Uint8Array,
763
+ target: Uint8Array,
764
+ targetOffset: number,
765
+ componentLength: number,
766
+ ): void {
767
+ if (component.length === componentLength) {
768
+ // Exact fit
769
+ target.set(component, targetOffset)
770
+ } else if (component.length > componentLength) {
771
+ // DER may have a leading 0x00 sign byte — strip it
772
+ const excess = component.length - componentLength
773
+ target.set(component.slice(excess), targetOffset)
774
+ } else {
775
+ // Component is shorter — right-align with zero padding
776
+ const padding = componentLength - component.length
777
+ target.set(component, targetOffset + padding)
778
+ }
779
+ }