@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,597 @@
1
+ import { KoraError } from '@korajs/core'
2
+ import { fromBase64Url, toBase64Url } from '../device/device-identity'
3
+
4
+ // ============================================================================
5
+ // Passkey-specific errors
6
+ // ============================================================================
7
+
8
+ /**
9
+ * Thrown when a passkey operation fails (registration, authentication,
10
+ * or browser API interaction).
11
+ */
12
+ export class PasskeyError extends KoraError {
13
+ constructor(message: string, context?: Record<string, unknown>) {
14
+ super(message, 'PASSKEY_ERROR', context)
15
+ this.name = 'PasskeyError'
16
+ }
17
+ }
18
+
19
+ /**
20
+ * Thrown when the browser does not support WebAuthn.
21
+ * Passkey authentication requires a modern browser with the
22
+ * Web Authentication API (navigator.credentials).
23
+ */
24
+ export class PasskeyUnsupportedError extends KoraError {
25
+ constructor() {
26
+ super(
27
+ 'WebAuthn is not supported in this browser. Passkey authentication requires a modern browser with WebAuthn support.',
28
+ 'PASSKEY_UNSUPPORTED',
29
+ )
30
+ this.name = 'PasskeyUnsupportedError'
31
+ }
32
+ }
33
+
34
+ // ============================================================================
35
+ // Passkey registration response
36
+ // ============================================================================
37
+
38
+ /** Response from a passkey registration (credential creation). */
39
+ export interface PasskeyRegistrationResponse {
40
+ /** Base64url-encoded credential ID */
41
+ credentialId: string
42
+ /** Base64url-encoded public key (COSE format) */
43
+ publicKey: string
44
+ /** Base64url-encoded clientDataJSON */
45
+ clientDataJSON: string
46
+ /** Base64url-encoded attestation object */
47
+ attestationObject: string
48
+ }
49
+
50
+ // ============================================================================
51
+ // Passkey authentication response
52
+ // ============================================================================
53
+
54
+ /** Response from a passkey authentication (assertion). */
55
+ export interface PasskeyAuthenticationResponse {
56
+ /** Base64url-encoded credential ID */
57
+ credentialId: string
58
+ /** Base64url-encoded authenticator data */
59
+ authenticatorData: string
60
+ /** Base64url-encoded clientDataJSON */
61
+ clientDataJSON: string
62
+ /** Base64url-encoded ECDSA signature */
63
+ signature: string
64
+ /** Base64url-encoded user handle (may be null for non-resident keys) */
65
+ userHandle: string | null
66
+ }
67
+
68
+ // ============================================================================
69
+ // Feature detection
70
+ // ============================================================================
71
+
72
+ /**
73
+ * Check if WebAuthn/passkeys are supported in the current environment.
74
+ *
75
+ * Returns true if the `navigator.credentials` API is available and supports
76
+ * the `create` and `get` methods required for WebAuthn.
77
+ *
78
+ * @returns `true` if WebAuthn is available, `false` otherwise
79
+ *
80
+ * @example
81
+ * ```typescript
82
+ * if (isPasskeySupported()) {
83
+ * // Show passkey login option
84
+ * }
85
+ * ```
86
+ */
87
+ export function isPasskeySupported(): boolean {
88
+ return (
89
+ typeof globalThis.navigator !== 'undefined' &&
90
+ typeof globalThis.navigator.credentials !== 'undefined' &&
91
+ typeof globalThis.navigator.credentials.create === 'function' &&
92
+ typeof globalThis.navigator.credentials.get === 'function'
93
+ )
94
+ }
95
+
96
+ /**
97
+ * Check if a platform authenticator (biometric) is available.
98
+ *
99
+ * A platform authenticator is built into the device (Touch ID, Face ID,
100
+ * Windows Hello). Returns false if only roaming authenticators (security
101
+ * keys) are available, or if WebAuthn is not supported.
102
+ *
103
+ * @returns `true` if a platform authenticator is available
104
+ *
105
+ * @example
106
+ * ```typescript
107
+ * if (await isPlatformAuthenticatorAvailable()) {
108
+ * // Show "Sign in with Touch ID" button
109
+ * }
110
+ * ```
111
+ */
112
+ export async function isPlatformAuthenticatorAvailable(): Promise<boolean> {
113
+ if (!isPasskeySupported()) {
114
+ return false
115
+ }
116
+
117
+ // PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable is
118
+ // the standard way to check for biometric/platform authenticator support.
119
+ if (
120
+ typeof PublicKeyCredential !== 'undefined' &&
121
+ typeof PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable === 'function'
122
+ ) {
123
+ try {
124
+ return await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable()
125
+ } catch {
126
+ return false
127
+ }
128
+ }
129
+
130
+ return false
131
+ }
132
+
133
+ // ============================================================================
134
+ // Registration (credential creation)
135
+ // ============================================================================
136
+
137
+ /**
138
+ * Create a passkey credential (registration flow).
139
+ *
140
+ * Called during user registration. The server provides a challenge and user info,
141
+ * the browser prompts for biometric verification, and this function returns the
142
+ * credential data to send back to the server for verification.
143
+ *
144
+ * @param options - Registration options from the server
145
+ * @param options.challenge - Base64url-encoded challenge from server
146
+ * @param options.rpId - Relying party ID (your domain, e.g. "example.com")
147
+ * @param options.rpName - Relying party display name (e.g. "My App")
148
+ * @param options.userId - User ID as base64url-encoded opaque bytes
149
+ * @param options.userName - User's email or username for display
150
+ * @param options.userDisplayName - Human-readable display name
151
+ * @param options.excludeCredentialIds - Credential IDs to exclude (prevents re-registration)
152
+ * @param options.authenticatorSelection - Authenticator selection criteria
153
+ * @returns The credential response to send to the server for verification
154
+ * @throws {PasskeyUnsupportedError} If WebAuthn is not available
155
+ * @throws {PasskeyError} If credential creation fails or is cancelled by the user
156
+ *
157
+ * @example
158
+ * ```typescript
159
+ * const credential = await createPasskeyCredential({
160
+ * challenge: serverOptions.challenge,
161
+ * rpId: 'example.com',
162
+ * rpName: 'My App',
163
+ * userId: serverOptions.userId,
164
+ * userName: 'alice@example.com',
165
+ * userDisplayName: 'Alice',
166
+ * })
167
+ * // Send `credential` to server for verification
168
+ * ```
169
+ */
170
+ export async function createPasskeyCredential(options: {
171
+ challenge: string
172
+ rpId: string
173
+ rpName: string
174
+ userId: string
175
+ userName: string
176
+ userDisplayName: string
177
+ excludeCredentialIds?: string[]
178
+ authenticatorSelection?: {
179
+ authenticatorAttachment?: 'platform' | 'cross-platform'
180
+ residentKey?: 'required' | 'preferred' | 'discouraged'
181
+ userVerification?: 'required' | 'preferred' | 'discouraged'
182
+ }
183
+ }): Promise<PasskeyRegistrationResponse> {
184
+ if (!isPasskeySupported()) {
185
+ throw new PasskeyUnsupportedError()
186
+ }
187
+
188
+ // Build the excludeCredentials list from base64url credential IDs
189
+ const excludeCredentials: PublicKeyCredentialDescriptor[] = (
190
+ options.excludeCredentialIds ?? []
191
+ ).map((id) => ({
192
+ type: 'public-key' as const,
193
+ id: fromBase64Url(id).buffer as unknown as ArrayBuffer,
194
+ }))
195
+
196
+ // Build the authenticator selection criteria with sensible defaults
197
+ const authenticatorSelection: AuthenticatorSelectionCriteria = {
198
+ authenticatorAttachment: options.authenticatorSelection?.authenticatorAttachment ?? 'platform',
199
+ residentKey: options.authenticatorSelection?.residentKey ?? 'preferred',
200
+ userVerification: options.authenticatorSelection?.userVerification ?? 'required',
201
+ }
202
+
203
+ // If residentKey is 'required', requireResidentKey must also be true
204
+ // for backwards compatibility with older browsers.
205
+ if (authenticatorSelection.residentKey === 'required') {
206
+ authenticatorSelection.requireResidentKey = true
207
+ }
208
+
209
+ const publicKeyOptions: PublicKeyCredentialCreationOptions = {
210
+ challenge: fromBase64Url(options.challenge).buffer as unknown as ArrayBuffer,
211
+ rp: {
212
+ id: options.rpId,
213
+ name: options.rpName,
214
+ },
215
+ user: {
216
+ id: fromBase64Url(options.userId).buffer as unknown as ArrayBuffer,
217
+ name: options.userName,
218
+ displayName: options.userDisplayName,
219
+ },
220
+ pubKeyCredParams: [
221
+ // ES256 (ECDSA w/ SHA-256) — the most widely supported algorithm
222
+ { type: 'public-key', alg: -7 },
223
+ // RS256 (RSASSA-PKCS1-v1_5 w/ SHA-256) — fallback for older authenticators
224
+ { type: 'public-key', alg: -257 },
225
+ ],
226
+ excludeCredentials,
227
+ authenticatorSelection,
228
+ timeout: 60000,
229
+ attestation: 'none',
230
+ }
231
+
232
+ let credential: PublicKeyCredential
233
+ try {
234
+ const result = await navigator.credentials.create({
235
+ publicKey: publicKeyOptions,
236
+ })
237
+
238
+ if (result === null) {
239
+ throw new PasskeyError(
240
+ 'Credential creation returned null. The user may have cancelled the operation.',
241
+ { rpId: options.rpId },
242
+ )
243
+ }
244
+
245
+ credential = result as PublicKeyCredential
246
+ } catch (error) {
247
+ if (error instanceof PasskeyError) {
248
+ throw error
249
+ }
250
+
251
+ // Map common WebAuthn DOMException names to user-friendly messages
252
+ const domError = error as DOMException
253
+ if (domError.name === 'NotAllowedError') {
254
+ throw new PasskeyError(
255
+ 'Passkey creation was cancelled or not allowed. The user may have dismissed the prompt or the operation timed out.',
256
+ { rpId: options.rpId, errorName: domError.name },
257
+ )
258
+ }
259
+ if (domError.name === 'InvalidStateError') {
260
+ throw new PasskeyError(
261
+ 'A passkey already exists for this user on this authenticator. Use the existing passkey to sign in.',
262
+ { rpId: options.rpId, errorName: domError.name },
263
+ )
264
+ }
265
+
266
+ throw new PasskeyError(
267
+ `Passkey creation failed: ${error instanceof Error ? error.message : String(error)}`,
268
+ {
269
+ rpId: options.rpId,
270
+ errorName: error instanceof Error ? error.name : undefined,
271
+ },
272
+ )
273
+ }
274
+
275
+ const response = credential.response as AuthenticatorAttestationResponse
276
+
277
+ // Extract the public key from the attestation response.
278
+ // getPublicKey() returns the SubjectPublicKeyInfo (SPKI) encoded public key.
279
+ // We also need the raw COSE public key from the attestation for server-side storage.
280
+ // The attestation object contains the credential public key in COSE format.
281
+ const attestationObject = new Uint8Array(response.attestationObject)
282
+ const clientDataJSON = new Uint8Array(response.clientDataJSON)
283
+
284
+ // Extract the COSE public key from the authenticator data within the attestation object.
285
+ // The attestation object is CBOR-encoded and contains authData which includes the
286
+ // credential public key in COSE_Key format.
287
+ const publicKeyBytes = extractPublicKeyFromAttestationObject(attestationObject)
288
+
289
+ return {
290
+ credentialId: toBase64Url(credential.rawId),
291
+ publicKey: toBase64Url(publicKeyBytes.buffer as unknown as ArrayBuffer),
292
+ clientDataJSON: toBase64Url(clientDataJSON.buffer as unknown as ArrayBuffer),
293
+ attestationObject: toBase64Url(attestationObject.buffer as unknown as ArrayBuffer),
294
+ }
295
+ }
296
+
297
+ // ============================================================================
298
+ // Authentication (assertion)
299
+ // ============================================================================
300
+
301
+ /**
302
+ * Authenticate with a passkey (assertion flow).
303
+ *
304
+ * Called during login. The server provides a challenge, the browser prompts
305
+ * for biometric verification, and this function returns the signed assertion
306
+ * to send back to the server for verification.
307
+ *
308
+ * @param options - Authentication options from the server
309
+ * @param options.challenge - Base64url-encoded challenge from server
310
+ * @param options.rpId - Relying party ID (your domain)
311
+ * @param options.allowCredentialIds - Limit authentication to specific credentials
312
+ * @param options.userVerification - User verification requirement (default: 'preferred')
313
+ * @param options.timeout - Timeout in milliseconds (default: 60000)
314
+ * @returns The assertion response to send to the server for verification
315
+ * @throws {PasskeyUnsupportedError} If WebAuthn is not available
316
+ * @throws {PasskeyError} If authentication fails or is cancelled by the user
317
+ *
318
+ * @example
319
+ * ```typescript
320
+ * const assertion = await authenticateWithPasskey({
321
+ * challenge: serverOptions.challenge,
322
+ * rpId: 'example.com',
323
+ * })
324
+ * // Send `assertion` to server for verification
325
+ * ```
326
+ */
327
+ export async function authenticateWithPasskey(options: {
328
+ challenge: string
329
+ rpId: string
330
+ allowCredentialIds?: string[]
331
+ userVerification?: 'required' | 'preferred' | 'discouraged'
332
+ timeout?: number
333
+ }): Promise<PasskeyAuthenticationResponse> {
334
+ if (!isPasskeySupported()) {
335
+ throw new PasskeyUnsupportedError()
336
+ }
337
+
338
+ // Build allowCredentials list from base64url credential IDs
339
+ const allowCredentials: PublicKeyCredentialDescriptor[] | undefined =
340
+ options.allowCredentialIds?.map((id) => ({
341
+ type: 'public-key' as const,
342
+ id: fromBase64Url(id).buffer as unknown as ArrayBuffer,
343
+ }))
344
+
345
+ const publicKeyOptions: PublicKeyCredentialRequestOptions = {
346
+ challenge: fromBase64Url(options.challenge).buffer as unknown as ArrayBuffer,
347
+ rpId: options.rpId,
348
+ allowCredentials,
349
+ userVerification: options.userVerification ?? 'preferred',
350
+ timeout: options.timeout ?? 60000,
351
+ }
352
+
353
+ let credential: PublicKeyCredential
354
+ try {
355
+ const result = await navigator.credentials.get({
356
+ publicKey: publicKeyOptions,
357
+ })
358
+
359
+ if (result === null) {
360
+ throw new PasskeyError(
361
+ 'Authentication returned null. The user may have cancelled the operation.',
362
+ { rpId: options.rpId },
363
+ )
364
+ }
365
+
366
+ credential = result as PublicKeyCredential
367
+ } catch (error) {
368
+ if (error instanceof PasskeyError) {
369
+ throw error
370
+ }
371
+
372
+ const domError = error as DOMException
373
+ if (domError.name === 'NotAllowedError') {
374
+ throw new PasskeyError(
375
+ 'Passkey authentication was cancelled or not allowed. The user may have dismissed the prompt or the operation timed out.',
376
+ { rpId: options.rpId, errorName: domError.name },
377
+ )
378
+ }
379
+
380
+ throw new PasskeyError(
381
+ `Passkey authentication failed: ${error instanceof Error ? error.message : String(error)}`,
382
+ {
383
+ rpId: options.rpId,
384
+ errorName: error instanceof Error ? error.name : undefined,
385
+ },
386
+ )
387
+ }
388
+
389
+ const response = credential.response as AuthenticatorAssertionResponse
390
+
391
+ const authenticatorData = new Uint8Array(response.authenticatorData)
392
+ const clientDataJSON = new Uint8Array(response.clientDataJSON)
393
+ const signature = new Uint8Array(response.signature)
394
+ const userHandle =
395
+ response.userHandle !== null && response.userHandle.byteLength > 0
396
+ ? toBase64Url(response.userHandle)
397
+ : null
398
+
399
+ return {
400
+ credentialId: toBase64Url(credential.rawId),
401
+ authenticatorData: toBase64Url(authenticatorData.buffer as unknown as ArrayBuffer),
402
+ clientDataJSON: toBase64Url(clientDataJSON.buffer as unknown as ArrayBuffer),
403
+ signature: toBase64Url(signature.buffer as unknown as ArrayBuffer),
404
+ userHandle,
405
+ }
406
+ }
407
+
408
+ // ============================================================================
409
+ // Internal: Extract public key from attestation object
410
+ // ============================================================================
411
+
412
+ /**
413
+ * Extracts the COSE-encoded public key from a CBOR-encoded attestation object.
414
+ *
415
+ * The attestation object structure (CBOR map):
416
+ * - "fmt": attestation format (e.g. "none")
417
+ * - "attStmt": attestation statement (empty map for "none")
418
+ * - "authData": authenticator data (byte string)
419
+ *
420
+ * The authenticator data structure:
421
+ * - rpIdHash (32 bytes)
422
+ * - flags (1 byte)
423
+ * - signCount (4 bytes, big-endian)
424
+ * - [if flags.AT set] attestedCredentialData:
425
+ * - aaguid (16 bytes)
426
+ * - credentialIdLength (2 bytes, big-endian)
427
+ * - credentialId (credentialIdLength bytes)
428
+ * - credentialPublicKey (CBOR-encoded COSE_Key, remaining bytes)
429
+ */
430
+ function extractPublicKeyFromAttestationObject(attestationObject: Uint8Array): Uint8Array {
431
+ // Decode the top-level CBOR map to get authData
432
+ const decoded = decodeCbor(attestationObject, 0)
433
+ const topMap = decoded.value as Map<string, unknown>
434
+ const authData = topMap.get('authData')
435
+
436
+ if (!(authData instanceof Uint8Array)) {
437
+ throw new PasskeyError('Invalid attestation object: authData is missing or not a byte string.')
438
+ }
439
+
440
+ // Parse authenticator data to find the credential public key
441
+ let offset = 0
442
+
443
+ // rpIdHash: 32 bytes
444
+ offset += 32
445
+
446
+ // flags: 1 byte
447
+ const flags = authData[offset] as number
448
+ offset += 1
449
+
450
+ // signCount: 4 bytes
451
+ offset += 4
452
+
453
+ // Check if attestedCredentialData is present (bit 6 of flags)
454
+ const hasAttestedCredentialData = (flags & 0x40) !== 0
455
+ if (!hasAttestedCredentialData) {
456
+ throw new PasskeyError(
457
+ 'Attestation object does not contain attested credential data. ' +
458
+ 'The authenticator did not include a public key.',
459
+ )
460
+ }
461
+
462
+ // aaguid: 16 bytes
463
+ offset += 16
464
+
465
+ // credentialIdLength: 2 bytes, big-endian
466
+ const credentialIdLength = ((authData[offset] as number) << 8) | (authData[offset + 1] as number)
467
+ offset += 2
468
+
469
+ // credentialId: credentialIdLength bytes
470
+ offset += credentialIdLength
471
+
472
+ // The remaining bytes in authData from this offset are the CBOR-encoded
473
+ // COSE public key. We need to extract exactly those bytes.
474
+ // To find the exact length, we decode the CBOR value and use the consumed byte count.
475
+ const coseKeyResult = decodeCbor(authData, offset)
476
+ const coseKeyLength = coseKeyResult.offset - offset
477
+
478
+ return authData.slice(offset, offset + coseKeyLength)
479
+ }
480
+
481
+ // ============================================================================
482
+ // Minimal CBOR decoder
483
+ // ============================================================================
484
+
485
+ /**
486
+ * Minimal CBOR decoder supporting only the types needed for WebAuthn:
487
+ * - Major type 0: Unsigned integer
488
+ * - Major type 1: Negative integer
489
+ * - Major type 2: Byte string
490
+ * - Major type 3: Text string
491
+ * - Major type 4: Array
492
+ * - Major type 5: Map
493
+ *
494
+ * This decoder handles the subset of CBOR used in WebAuthn attestation
495
+ * objects and COSE keys. It does not support tags, floats, or indefinite-length
496
+ * encodings, which are not used in the WebAuthn specification.
497
+ */
498
+
499
+ interface CborDecodeResult {
500
+ value: unknown
501
+ /** Byte offset after the decoded value (used for sequential decoding) */
502
+ offset: number
503
+ }
504
+
505
+ function decodeCbor(data: Uint8Array, offset: number): CborDecodeResult {
506
+ let pos = offset
507
+
508
+ if (pos >= data.length) {
509
+ throw new PasskeyError('CBOR decode error: unexpected end of data.')
510
+ }
511
+
512
+ const initialByte = data[pos] as number
513
+ const majorType = initialByte >> 5
514
+ const additionalInfo = initialByte & 0x1f
515
+ pos += 1
516
+
517
+ // Decode the argument (length or value) based on additionalInfo
518
+ let argument: number
519
+ if (additionalInfo < 24) {
520
+ argument = additionalInfo
521
+ } else if (additionalInfo === 24) {
522
+ argument = data[pos] as number
523
+ pos += 1
524
+ } else if (additionalInfo === 25) {
525
+ argument = ((data[pos] as number) << 8) | (data[pos + 1] as number)
526
+ pos += 2
527
+ } else if (additionalInfo === 26) {
528
+ argument =
529
+ ((data[pos] as number) << 24) |
530
+ ((data[pos + 1] as number) << 16) |
531
+ ((data[pos + 2] as number) << 8) |
532
+ (data[pos + 3] as number)
533
+ // Handle unsigned 32-bit properly (bitwise ops produce signed 32-bit in JS)
534
+ argument = argument >>> 0
535
+ pos += 4
536
+ } else {
537
+ throw new PasskeyError(
538
+ `CBOR decode error: unsupported additional info ${additionalInfo} at byte ${pos - 1}. This CBOR decoder only supports definite-length encodings.`,
539
+ )
540
+ }
541
+
542
+ switch (majorType) {
543
+ // Major type 0: Unsigned integer
544
+ case 0:
545
+ return { value: argument, offset: pos }
546
+
547
+ // Major type 1: Negative integer (-1 - argument)
548
+ case 1:
549
+ return { value: -1 - argument, offset: pos }
550
+
551
+ // Major type 2: Byte string
552
+ case 2: {
553
+ const bytes = data.slice(pos, pos + argument)
554
+ return { value: bytes, offset: pos + argument }
555
+ }
556
+
557
+ // Major type 3: Text string (UTF-8)
558
+ case 3: {
559
+ const textBytes = data.slice(pos, pos + argument)
560
+ const text = new TextDecoder().decode(textBytes)
561
+ return { value: text, offset: pos + argument }
562
+ }
563
+
564
+ // Major type 4: Array
565
+ case 4: {
566
+ const arr: unknown[] = []
567
+ let currentOffset = pos
568
+ for (let i = 0; i < argument; i++) {
569
+ const item = decodeCbor(data, currentOffset)
570
+ arr.push(item.value)
571
+ currentOffset = item.offset
572
+ }
573
+ return { value: arr, offset: currentOffset }
574
+ }
575
+
576
+ // Major type 5: Map
577
+ case 5: {
578
+ const map = new Map<string | number, unknown>()
579
+ let currentOffset = pos
580
+ for (let i = 0; i < argument; i++) {
581
+ const keyResult = decodeCbor(data, currentOffset)
582
+ const valResult = decodeCbor(data, keyResult.offset)
583
+ map.set(keyResult.value as string | number, valResult.value)
584
+ currentOffset = valResult.offset
585
+ }
586
+ return { value: map, offset: currentOffset }
587
+ }
588
+
589
+ default:
590
+ throw new PasskeyError(
591
+ `CBOR decode error: unsupported major type ${majorType} at byte ${pos - 1}. This CBOR decoder only supports types 0-5 (integers, byte/text strings, arrays, maps).`,
592
+ )
593
+ }
594
+ }
595
+
596
+ // Export the CBOR decoder for testing purposes (used by passkey-server too)
597
+ export { decodeCbor, type CborDecodeResult }