@exvio/os-backend-core 0.4.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.
@@ -0,0 +1,760 @@
1
+ import { AuthError } from './errors.ts'
2
+
3
+ export const PASSKEY_CHALLENGE_TTL_SECONDS = 5 * 60
4
+ export const PASSKEY_RECENT_AUTH_MAX_AGE_MS = 10 * 60 * 1_000
5
+
6
+ const MAX_RECORD_BYTES = 64 * 1024
7
+ const AUTH_TOKEN_PATTERN = /^pca_v1_[A-Za-z0-9_-]{43}$/
8
+ const CHALLENGE_PATTERN = /^[A-Za-z0-9_-]{16,2048}$/
9
+ const CREDENTIAL_ID_PATTERN = /^[A-Za-z0-9_-]{1,500}$/
10
+ const SHA256_BASE64URL_PATTERN = /^[A-Za-z0-9_-]{43}$/
11
+ const KEY_PREFIX_PATTERN = /^[A-Za-z0-9:_-]{1,96}$/
12
+ const encoder = new TextEncoder()
13
+
14
+ export type PasskeyChallengePurpose = 'register' | 'authenticate'
15
+
16
+ export interface PasskeyChallengeStore {
17
+ put(key: string, value: string, ttlSeconds: number): Promise<void>
18
+ putIfAbsent(key: string, value: string, ttlSeconds: number): Promise<boolean>
19
+ /** Must atomically return and remove the stored value. */
20
+ take(key: string): Promise<string | null>
21
+ }
22
+
23
+ interface PasskeyChallengeBase {
24
+ readonly version: 1
25
+ readonly purpose: PasskeyChallengePurpose
26
+ readonly challenge: string
27
+ readonly tenantId: number
28
+ readonly rpID: string
29
+ readonly origin: string
30
+ readonly issuedAt: number
31
+ readonly expiresAt: number
32
+ }
33
+
34
+ export interface RegistrationPasskeyChallenge extends PasskeyChallengeBase {
35
+ readonly purpose: 'register'
36
+ readonly userId: number
37
+ readonly initiatingSessionHash: string
38
+ readonly authenticatedAt: number
39
+ readonly excludedCredentialIds: readonly string[]
40
+ }
41
+
42
+ export interface AuthenticationPasskeyChallenge extends PasskeyChallengeBase {
43
+ readonly purpose: 'authenticate'
44
+ readonly userId: number | null
45
+ readonly allowedCredentialIds: readonly string[]
46
+ }
47
+
48
+ export class PasskeyAuthError extends AuthError {
49
+ constructor() {
50
+ super({ code: 'invalid_passkey_challenge', status: 400 })
51
+ this.name = 'PasskeyAuthError'
52
+ }
53
+ }
54
+
55
+ export interface PasskeyChallengeManager {
56
+ issueRegistration(input: {
57
+ readonly challenge: string
58
+ readonly tenantId: number
59
+ readonly userId: number
60
+ readonly initiatingSessionId: string
61
+ readonly authenticatedAt: number
62
+ readonly rpID: string
63
+ readonly origin: string
64
+ readonly excludedCredentialIds: readonly string[]
65
+ }): Promise<void>
66
+ takeRegistration(expected: {
67
+ readonly tenantId: number
68
+ readonly userId: number
69
+ readonly initiatingSessionId: string
70
+ readonly authenticatedAt: number
71
+ readonly rpID: string
72
+ readonly origin: string
73
+ }): Promise<RegistrationPasskeyChallenge>
74
+ issueAuthentication(input: {
75
+ readonly challenge: string
76
+ readonly tenantId: number
77
+ readonly userId: number | null
78
+ readonly rpID: string
79
+ readonly origin: string
80
+ readonly allowedCredentialIds: readonly string[]
81
+ }): Promise<string>
82
+ takeAuthentication(expected: {
83
+ readonly token: string
84
+ readonly tenantId: number
85
+ readonly rpID: string
86
+ readonly origin: string
87
+ readonly credentialId: string
88
+ }): Promise<AuthenticationPasskeyChallenge>
89
+ }
90
+
91
+ export interface CreatePasskeyChallengeManagerOptions {
92
+ readonly store: PasskeyChallengeStore
93
+ readonly now?: () => number
94
+ readonly generateToken?: () => string
95
+ readonly ttlSeconds?: number
96
+ readonly recentAuthMaxAgeMs?: number
97
+ readonly registrationKeyPrefix?: string
98
+ readonly authenticationKeyPrefix?: string
99
+ readonly maxSerializedBytes?: number
100
+ }
101
+
102
+ export function createPasskeyChallengeManager(
103
+ options: CreatePasskeyChallengeManagerOptions,
104
+ ): PasskeyChallengeManager {
105
+ if (
106
+ !isObject(options)
107
+ || !isObject(options.store)
108
+ || typeof options.store.put !== 'function'
109
+ || typeof options.store.putIfAbsent !== 'function'
110
+ || typeof options.store.take !== 'function'
111
+ ) invalidInput()
112
+
113
+ const ttlSeconds = options.ttlSeconds ?? PASSKEY_CHALLENGE_TTL_SECONDS
114
+ const recentAuthMaxAgeMs = options.recentAuthMaxAgeMs ?? PASSKEY_RECENT_AUTH_MAX_AGE_MS
115
+ const maxSerializedBytes = options.maxSerializedBytes ?? MAX_RECORD_BYTES
116
+ const registrationKeyPrefix = options.registrationKeyPrefix ?? 'passkey:reg:v1:'
117
+ const authenticationKeyPrefix = options.authenticationKeyPrefix ?? 'passkey:auth:v1:'
118
+ const now = options.now ?? Date.now
119
+ const generateToken = options.generateToken ?? createAuthenticationToken
120
+
121
+ if (
122
+ !Number.isSafeInteger(ttlSeconds)
123
+ || ttlSeconds < 30
124
+ || ttlSeconds > 600
125
+ || !Number.isSafeInteger(recentAuthMaxAgeMs)
126
+ || recentAuthMaxAgeMs < 60_000
127
+ || recentAuthMaxAgeMs > 3_600_000
128
+ || !Number.isSafeInteger(maxSerializedBytes)
129
+ || maxSerializedBytes < 1_024
130
+ || maxSerializedBytes > 131_072
131
+ || typeof now !== 'function'
132
+ || typeof generateToken !== 'function'
133
+ || !KEY_PREFIX_PATTERN.test(registrationKeyPrefix)
134
+ || !KEY_PREFIX_PATTERN.test(authenticationKeyPrefix)
135
+ ) invalidInput()
136
+
137
+ const ttlMs = ttlSeconds * 1_000
138
+ if (!Number.isSafeInteger(ttlMs)) invalidInput()
139
+
140
+ const snapshot = Object.freeze({
141
+ put: options.store.put.bind(options.store),
142
+ putIfAbsent: options.store.putIfAbsent.bind(options.store),
143
+ take: options.store.take.bind(options.store),
144
+ ttlSeconds,
145
+ ttlMs,
146
+ recentAuthMaxAgeMs,
147
+ maxSerializedBytes,
148
+ registrationKeyPrefix,
149
+ authenticationKeyPrefix,
150
+ now,
151
+ generateToken,
152
+ })
153
+
154
+ const registrationKey = async (
155
+ tenantId: number,
156
+ userId: number,
157
+ sessionHash: string,
158
+ ): Promise<string> => `${snapshot.registrationKeyPrefix}${tenantId}:${userId}:${sessionHash}`
159
+
160
+ const authenticationKey = async (token: string): Promise<string> => (
161
+ `${snapshot.authenticationKeyPrefix}${await sha256Base64Url(token)}`
162
+ )
163
+
164
+ const manager: PasskeyChallengeManager = {
165
+ async issueRegistration(input): Promise<void> {
166
+ assertIssueRegistration(input)
167
+ const issuedAt = checkedNow(snapshot.now)
168
+ assertAuthenticationTime(input.authenticatedAt, issuedAt, snapshot.recentAuthMaxAgeMs)
169
+ const expiresAt = checkedExpiry(issuedAt, snapshot.ttlMs)
170
+ const initiatingSessionHash = await sha256Base64Url(input.initiatingSessionId)
171
+ const record: RegistrationPasskeyChallenge = Object.freeze({
172
+ version: 1,
173
+ purpose: 'register',
174
+ challenge: input.challenge,
175
+ tenantId: input.tenantId,
176
+ userId: input.userId,
177
+ initiatingSessionHash,
178
+ authenticatedAt: input.authenticatedAt,
179
+ rpID: input.rpID,
180
+ origin: input.origin,
181
+ excludedCredentialIds: Object.freeze(validateCredentialIds(input.excludedCredentialIds)),
182
+ issuedAt,
183
+ expiresAt,
184
+ })
185
+ try {
186
+ await snapshot.put(
187
+ await registrationKey(input.tenantId, input.userId, initiatingSessionHash),
188
+ encodeRecord(record, snapshot.maxSerializedBytes),
189
+ snapshot.ttlSeconds,
190
+ )
191
+ } catch (error) {
192
+ if (error instanceof AuthError) throw error
193
+ storeUnavailable()
194
+ }
195
+ },
196
+
197
+ async takeRegistration(expected): Promise<RegistrationPasskeyChallenge> {
198
+ let currentTime: number
199
+ let initiatingSessionHash: string
200
+ try {
201
+ assertConsumeRegistration(expected)
202
+ currentTime = checkedNow(snapshot.now)
203
+ initiatingSessionHash = await sha256Base64Url(expected.initiatingSessionId)
204
+ } catch {
205
+ throw new PasskeyAuthError()
206
+ }
207
+
208
+ let raw: string | null
209
+ try {
210
+ raw = await snapshot.take(await registrationKey(
211
+ expected.tenantId,
212
+ expected.userId,
213
+ initiatingSessionHash,
214
+ ))
215
+ } catch {
216
+ storeUnavailable()
217
+ }
218
+ const record = parseRecord(raw, currentTime, snapshot.ttlMs, snapshot.maxSerializedBytes)
219
+ if (
220
+ record.purpose !== 'register'
221
+ || record.tenantId !== expected.tenantId
222
+ || record.userId !== expected.userId
223
+ || record.initiatingSessionHash !== initiatingSessionHash
224
+ || record.authenticatedAt !== expected.authenticatedAt
225
+ || currentTime - record.authenticatedAt > snapshot.recentAuthMaxAgeMs
226
+ || record.rpID !== expected.rpID
227
+ || record.origin !== expected.origin
228
+ ) throw new PasskeyAuthError()
229
+ return record
230
+ },
231
+
232
+ async issueAuthentication(input): Promise<string> {
233
+ assertIssueAuthentication(input)
234
+ const issuedAt = checkedNow(snapshot.now)
235
+ const record: AuthenticationPasskeyChallenge = Object.freeze({
236
+ version: 1,
237
+ purpose: 'authenticate',
238
+ challenge: input.challenge,
239
+ tenantId: input.tenantId,
240
+ userId: input.userId,
241
+ rpID: input.rpID,
242
+ origin: input.origin,
243
+ allowedCredentialIds: Object.freeze(validateCredentialIds(input.allowedCredentialIds)),
244
+ issuedAt,
245
+ expiresAt: checkedExpiry(issuedAt, snapshot.ttlMs),
246
+ })
247
+ const encoded = encodeRecord(record, snapshot.maxSerializedBytes)
248
+ for (let attempt = 0; attempt < 3; attempt += 1) {
249
+ let token: string
250
+ try {
251
+ token = snapshot.generateToken()
252
+ } catch {
253
+ invalidInput(500)
254
+ }
255
+ if (!AUTH_TOKEN_PATTERN.test(token)) invalidInput(500)
256
+ try {
257
+ const reserved = await snapshot.putIfAbsent(
258
+ await authenticationKey(token),
259
+ encoded,
260
+ snapshot.ttlSeconds,
261
+ )
262
+ if (typeof reserved !== 'boolean') storeUnavailable()
263
+ if (reserved) return token
264
+ } catch {
265
+ storeUnavailable()
266
+ }
267
+ }
268
+ throw new AuthError({ code: 'auth_store_unavailable', status: 503, retryable: true })
269
+ },
270
+
271
+ async takeAuthentication(expected): Promise<AuthenticationPasskeyChallenge> {
272
+ if (!isObject(expected) || typeof expected.token !== 'string' || !AUTH_TOKEN_PATTERN.test(expected.token)) {
273
+ throw new PasskeyAuthError()
274
+ }
275
+
276
+ let raw: string | null
277
+ try {
278
+ raw = await snapshot.take(await authenticationKey(expected.token))
279
+ } catch {
280
+ storeUnavailable()
281
+ }
282
+ const record = parseRecord(raw, checkedNow(snapshot.now), snapshot.ttlMs, snapshot.maxSerializedBytes)
283
+ try {
284
+ assertPositiveTenantId(expected.tenantId)
285
+ assertRpBinding(expected.rpID, expected.origin)
286
+ assertCredentialId(expected.credentialId)
287
+ } catch {
288
+ throw new PasskeyAuthError()
289
+ }
290
+ if (
291
+ record.purpose !== 'authenticate'
292
+ || record.tenantId !== expected.tenantId
293
+ || record.rpID !== expected.rpID
294
+ || record.origin !== expected.origin
295
+ || (record.allowedCredentialIds.length > 0
296
+ && !record.allowedCredentialIds.includes(expected.credentialId))
297
+ ) throw new PasskeyAuthError()
298
+ return record
299
+ },
300
+ }
301
+ return Object.freeze(manager)
302
+ }
303
+
304
+ export interface PasskeyRateLimitStoreInput {
305
+ readonly key: string
306
+ readonly cutoff: number
307
+ readonly now: number
308
+ readonly member: string
309
+ readonly limit: number
310
+ readonly ttlSeconds: number
311
+ }
312
+
313
+ export interface PasskeyRateLimitStoreResult {
314
+ readonly allowed: boolean
315
+ readonly remaining: number
316
+ readonly oldestTimestamp: number
317
+ }
318
+
319
+ export interface PasskeyRateLimitStore {
320
+ take(input: PasskeyRateLimitStoreInput): Promise<PasskeyRateLimitStoreResult>
321
+ }
322
+
323
+ export interface PasskeyRateLimitResult {
324
+ readonly allowed: boolean
325
+ readonly remaining: number
326
+ readonly retryAfterSeconds: number
327
+ }
328
+
329
+ export interface PasskeyRateLimiter {
330
+ take(input: {
331
+ readonly tenantId: number
332
+ readonly purpose: 'start' | 'finish'
333
+ readonly scope: 'client' | 'tenant'
334
+ readonly subject: string
335
+ readonly limit: number
336
+ readonly windowSeconds: number
337
+ }): Promise<PasskeyRateLimitResult>
338
+ }
339
+
340
+ export function createPasskeyRateLimiter(options: {
341
+ readonly store: PasskeyRateLimitStore
342
+ readonly now?: () => number
343
+ readonly generateMember?: () => string
344
+ readonly keyPrefix?: string
345
+ }): PasskeyRateLimiter {
346
+ if (!isObject(options) || !isObject(options.store) || typeof options.store.take !== 'function') {
347
+ invalidInput()
348
+ }
349
+ const now = options.now ?? Date.now
350
+ const generateMember = options.generateMember ?? (() => crypto.randomUUID())
351
+ const keyPrefix = options.keyPrefix ?? 'passkey:auth:rate:v1:'
352
+ if (typeof now !== 'function' || typeof generateMember !== 'function' || !KEY_PREFIX_PATTERN.test(keyPrefix)) {
353
+ invalidInput()
354
+ }
355
+ const take = options.store.take.bind(options.store)
356
+
357
+ const limiter: PasskeyRateLimiter = {
358
+ async take(input): Promise<PasskeyRateLimitResult> {
359
+ if (!isObject(input)) invalidInput()
360
+ assertPositiveTenantId(input.tenantId)
361
+ if (input.purpose !== 'start' && input.purpose !== 'finish') invalidInput()
362
+ if (input.scope !== 'client' && input.scope !== 'tenant') invalidInput()
363
+ if (typeof input.subject !== 'string' || input.subject.length === 0 || input.subject.length > 512) invalidInput()
364
+ if (!Number.isSafeInteger(input.limit) || input.limit <= 0) invalidInput()
365
+ if (!Number.isSafeInteger(input.windowSeconds) || input.windowSeconds <= 0) invalidInput()
366
+
367
+ const currentTime = checkedNow(now)
368
+ const windowMs = input.windowSeconds * 1_000
369
+ if (!Number.isSafeInteger(windowMs)) invalidInput()
370
+ let member: string
371
+ try {
372
+ member = generateMember()
373
+ } catch {
374
+ invalidInput(500)
375
+ }
376
+ if (typeof member !== 'string' || member.length === 0 || member.length > 256) invalidInput(500)
377
+
378
+ const subjectHash = await sha256Base64Url(`${input.scope}:${input.subject}`)
379
+ let result: PasskeyRateLimitStoreResult
380
+ try {
381
+ result = await take({
382
+ key: `${keyPrefix}${input.purpose}:${input.tenantId}:${subjectHash}`,
383
+ cutoff: currentTime - windowMs,
384
+ now: currentTime,
385
+ member,
386
+ limit: input.limit,
387
+ ttlSeconds: input.windowSeconds + 1,
388
+ })
389
+ } catch {
390
+ storeUnavailable()
391
+ }
392
+ if (
393
+ !isObject(result)
394
+ || typeof result.allowed !== 'boolean'
395
+ || !Number.isSafeInteger(result.remaining)
396
+ || result.remaining < 0
397
+ || !Number.isSafeInteger(result.oldestTimestamp)
398
+ || result.oldestTimestamp < 0
399
+ ) storeUnavailable()
400
+
401
+ return Object.freeze({
402
+ allowed: result.allowed,
403
+ remaining: result.remaining,
404
+ retryAfterSeconds: result.allowed
405
+ ? 0
406
+ : Math.max(1, Math.ceil((result.oldestTimestamp + windowMs - currentTime) / 1_000)),
407
+ })
408
+ },
409
+ }
410
+ return Object.freeze(limiter)
411
+ }
412
+
413
+ export function recentPasskeyAuthenticationAt(
414
+ loginHistory: readonly { readonly userId: number; readonly loginAt: string; readonly logoutAt: string | null }[],
415
+ userId: number,
416
+ options: { readonly now?: number; readonly maxAgeMs?: number } = {},
417
+ ): number | null {
418
+ if (!Array.isArray(loginHistory)) invalidInput()
419
+ assertPositiveId(userId)
420
+ const currentTime = options.now ?? Date.now()
421
+ const maxAgeMs = options.maxAgeMs ?? PASSKEY_RECENT_AUTH_MAX_AGE_MS
422
+ if (!Number.isSafeInteger(currentTime) || currentTime < 0) invalidInput()
423
+ if (!Number.isSafeInteger(maxAgeMs) || maxAgeMs <= 0) invalidInput()
424
+
425
+ for (let index = loginHistory.length - 1; index >= 0; index -= 1) {
426
+ const entry = loginHistory[index]
427
+ if (!entry || entry.userId !== userId || entry.logoutAt !== null) continue
428
+ const authenticatedAt = Date.parse(entry.loginAt)
429
+ const age = currentTime - authenticatedAt
430
+ return Number.isSafeInteger(authenticatedAt) && age >= 0 && age <= maxAgeMs
431
+ ? authenticatedAt
432
+ : null
433
+ }
434
+ return null
435
+ }
436
+
437
+ export async function createPasskeyUserHandle(
438
+ tenantId: number,
439
+ userId: number,
440
+ secret: string | Uint8Array,
441
+ ): Promise<Uint8Array<ArrayBuffer>> {
442
+ assertPositiveTenantId(tenantId)
443
+ assertPositiveId(userId)
444
+ const secretBytes = typeof secret === 'string'
445
+ ? encoder.encode(secret)
446
+ : secret instanceof Uint8Array
447
+ ? new Uint8Array(secret)
448
+ : invalidInput()
449
+ if (secretBytes.byteLength < 32) invalidInput()
450
+ try {
451
+ const key = await crypto.subtle.importKey(
452
+ 'raw',
453
+ secretBytes,
454
+ { name: 'HMAC', hash: 'SHA-256' },
455
+ false,
456
+ ['sign'],
457
+ )
458
+ const message = encoder.encode(`wellous-passkey-user:v1:${tenantId}:${userId}`)
459
+ return new Uint8Array(await crypto.subtle.sign('HMAC', key, message))
460
+ } catch {
461
+ invalidInput()
462
+ }
463
+ }
464
+
465
+ export interface PasskeyCounterStore {
466
+ compareAndSwap(input: {
467
+ readonly credentialRowId: number
468
+ readonly expectedCounter: number
469
+ readonly newCounter: number
470
+ readonly usedAt: Date
471
+ }): Promise<boolean>
472
+ readCounter(credentialRowId: number): Promise<number | null>
473
+ }
474
+
475
+ export async function commitPasskeyCounter(
476
+ store: PasskeyCounterStore,
477
+ input: {
478
+ readonly credentialRowId: number
479
+ readonly expectedCounter: number
480
+ readonly newCounter: number
481
+ readonly usedAt?: Date
482
+ },
483
+ ): Promise<boolean> {
484
+ if (!isObject(store) || typeof store.compareAndSwap !== 'function' || typeof store.readCounter !== 'function') {
485
+ invalidInput()
486
+ }
487
+ if (!isObject(input)) invalidInput()
488
+ assertPositiveId(input.credentialRowId)
489
+ assertCounter(input.expectedCounter)
490
+ assertCounter(input.newCounter)
491
+ const usedAtMs = input.usedAt === undefined
492
+ ? Date.now()
493
+ : input.usedAt instanceof Date
494
+ ? input.usedAt.getTime()
495
+ : invalidInput()
496
+ if (!Number.isSafeInteger(usedAtMs) || usedAtMs < 0 || usedAtMs > 8_640_000_000_000_000) invalidInput()
497
+
498
+ const counterless = input.expectedCounter === 0 && input.newCounter === 0
499
+ if (!counterless && input.newCounter <= input.expectedCounter) return false
500
+
501
+ let swapped: boolean
502
+ try {
503
+ swapped = await store.compareAndSwap({
504
+ credentialRowId: input.credentialRowId,
505
+ expectedCounter: input.expectedCounter,
506
+ newCounter: input.newCounter,
507
+ usedAt: new Date(usedAtMs),
508
+ })
509
+ } catch {
510
+ storeUnavailable()
511
+ }
512
+ if (typeof swapped !== 'boolean') storeUnavailable()
513
+ if (swapped) return true
514
+
515
+ if (!counterless) return false
516
+ try {
517
+ return await store.readCounter(input.credentialRowId) === 0
518
+ } catch {
519
+ storeUnavailable()
520
+ }
521
+ }
522
+
523
+ function assertIssueRegistration(input: unknown): asserts input is Parameters<PasskeyChallengeManager['issueRegistration']>[0] {
524
+ if (!isObject(input)) invalidInput()
525
+ assertCommonInput(input)
526
+ assertPositiveId(input.userId)
527
+ assertSessionBinding(input.initiatingSessionId)
528
+ validateCredentialIds(input.excludedCredentialIds)
529
+ }
530
+
531
+ function assertConsumeRegistration(input: unknown): asserts input is Parameters<PasskeyChallengeManager['takeRegistration']>[0] {
532
+ if (!isObject(input)) invalidInput()
533
+ assertPositiveTenantId(input.tenantId)
534
+ assertPositiveId(input.userId)
535
+ assertSessionBinding(input.initiatingSessionId)
536
+ if (!Number.isSafeInteger(input.authenticatedAt) || input.authenticatedAt < 0) invalidInput()
537
+ assertRpBinding(input.rpID, input.origin)
538
+ }
539
+
540
+ function assertIssueAuthentication(input: unknown): asserts input is Parameters<PasskeyChallengeManager['issueAuthentication']>[0] {
541
+ if (!isObject(input)) invalidInput()
542
+ assertCommonInput(input)
543
+ if (input.userId !== null) assertPositiveId(input.userId)
544
+ validateCredentialIds(input.allowedCredentialIds)
545
+ }
546
+
547
+ function assertCommonInput(input: Record<string, unknown>): void {
548
+ if (typeof input.challenge !== 'string' || !CHALLENGE_PATTERN.test(input.challenge)) invalidInput()
549
+ assertPositiveTenantId(input.tenantId)
550
+ assertRpBinding(input.rpID, input.origin)
551
+ }
552
+
553
+ function parseRecord(
554
+ raw: string | null,
555
+ currentTime: number,
556
+ expectedTtlMs: number,
557
+ maxSerializedBytes: number,
558
+ ): RegistrationPasskeyChallenge | AuthenticationPasskeyChallenge {
559
+ if (typeof raw !== 'string' || raw.length === 0 || encoder.encode(raw).byteLength > maxSerializedBytes) {
560
+ throw new PasskeyAuthError()
561
+ }
562
+ let value: unknown
563
+ try {
564
+ value = JSON.parse(raw)
565
+ } catch {
566
+ throw new PasskeyAuthError()
567
+ }
568
+ if (!isObject(value) || value.version !== 1) throw new PasskeyAuthError()
569
+
570
+ try {
571
+ assertPositiveTenantId(value.tenantId)
572
+ if (typeof value.challenge !== 'string' || !CHALLENGE_PATTERN.test(value.challenge)) invalidInput()
573
+ assertRpBinding(value.rpID, value.origin)
574
+ if (
575
+ !Number.isSafeInteger(value.issuedAt)
576
+ || value.issuedAt < 0
577
+ || value.issuedAt > currentTime
578
+ || !Number.isSafeInteger(value.expiresAt)
579
+ || value.expiresAt <= currentTime
580
+ || value.expiresAt <= value.issuedAt
581
+ || value.expiresAt - value.issuedAt !== expectedTtlMs
582
+ ) invalidInput()
583
+
584
+ if (value.purpose === 'register') {
585
+ assertExactKeys(value, [
586
+ 'version', 'purpose', 'challenge', 'tenantId', 'userId', 'initiatingSessionHash',
587
+ 'authenticatedAt', 'rpID', 'origin', 'excludedCredentialIds', 'issuedAt', 'expiresAt',
588
+ ])
589
+ assertPositiveId(value.userId)
590
+ if (typeof value.initiatingSessionHash !== 'string' || !SHA256_BASE64URL_PATTERN.test(value.initiatingSessionHash)) {
591
+ invalidInput()
592
+ }
593
+ assertAuthenticationTime(value.authenticatedAt, currentTime)
594
+ if (value.authenticatedAt > value.issuedAt) invalidInput()
595
+ return Object.freeze({
596
+ version: 1,
597
+ purpose: 'register',
598
+ challenge: value.challenge,
599
+ tenantId: value.tenantId,
600
+ userId: value.userId,
601
+ initiatingSessionHash: value.initiatingSessionHash,
602
+ authenticatedAt: value.authenticatedAt,
603
+ rpID: value.rpID,
604
+ origin: value.origin,
605
+ excludedCredentialIds: Object.freeze(validateCredentialIds(value.excludedCredentialIds)),
606
+ issuedAt: value.issuedAt,
607
+ expiresAt: value.expiresAt,
608
+ })
609
+ }
610
+ if (value.purpose === 'authenticate') {
611
+ assertExactKeys(value, [
612
+ 'version', 'purpose', 'challenge', 'tenantId', 'userId', 'rpID', 'origin',
613
+ 'allowedCredentialIds', 'issuedAt', 'expiresAt',
614
+ ])
615
+ if (value.userId !== null) assertPositiveId(value.userId)
616
+ return Object.freeze({
617
+ version: 1,
618
+ purpose: 'authenticate',
619
+ challenge: value.challenge,
620
+ tenantId: value.tenantId,
621
+ userId: value.userId,
622
+ rpID: value.rpID,
623
+ origin: value.origin,
624
+ allowedCredentialIds: Object.freeze(validateCredentialIds(value.allowedCredentialIds)),
625
+ issuedAt: value.issuedAt,
626
+ expiresAt: value.expiresAt,
627
+ })
628
+ }
629
+ invalidInput()
630
+ } catch {
631
+ throw new PasskeyAuthError()
632
+ }
633
+ }
634
+
635
+ function encodeRecord(
636
+ record: RegistrationPasskeyChallenge | AuthenticationPasskeyChallenge,
637
+ maxSerializedBytes: number,
638
+ ): string {
639
+ const encoded = JSON.stringify(record)
640
+ if (encoder.encode(encoded).byteLength > maxSerializedBytes) invalidInput()
641
+ return encoded
642
+ }
643
+
644
+ function validateCredentialIds(value: unknown): string[] {
645
+ if (!Array.isArray(value) || value.length > 128) invalidInput()
646
+ const result: string[] = []
647
+ const seen = new Set<string>()
648
+ for (const item of value) {
649
+ if (typeof item !== 'string' || !CREDENTIAL_ID_PATTERN.test(item)) invalidInput()
650
+ if (!seen.has(item)) {
651
+ seen.add(item)
652
+ result.push(item)
653
+ }
654
+ }
655
+ return result
656
+ }
657
+
658
+ function assertPositiveTenantId(value: unknown): asserts value is number {
659
+ if (!Number.isSafeInteger(value) || (value as number) <= 0) invalidInput()
660
+ }
661
+
662
+ function assertPositiveId(value: unknown): asserts value is number {
663
+ if (!Number.isSafeInteger(value) || (value as number) <= 0) invalidInput()
664
+ }
665
+
666
+ function assertCounter(value: unknown): asserts value is number {
667
+ if (!Number.isSafeInteger(value) || (value as number) < 0) invalidInput()
668
+ }
669
+
670
+ function assertCredentialId(value: unknown): asserts value is string {
671
+ if (typeof value !== 'string' || !CREDENTIAL_ID_PATTERN.test(value)) invalidInput()
672
+ }
673
+
674
+ function assertSessionBinding(value: unknown): asserts value is string {
675
+ if (typeof value !== 'string' || value.length < 16 || value.length > 512) invalidInput()
676
+ }
677
+
678
+ function assertAuthenticationTime(value: unknown, currentTime: number, maxAgeMs = Number.MAX_SAFE_INTEGER): asserts value is number {
679
+ if (
680
+ !Number.isSafeInteger(value)
681
+ || (value as number) < 0
682
+ || (value as number) > currentTime
683
+ || currentTime - (value as number) > maxAgeMs
684
+ ) invalidInput()
685
+ }
686
+
687
+ function assertRpBinding(rpID: unknown, origin: unknown): asserts rpID is string {
688
+ if (typeof rpID !== 'string' || rpID.length === 0 || rpID.length > 253 || typeof origin !== 'string') {
689
+ invalidInput()
690
+ }
691
+ let parsed: URL
692
+ try {
693
+ parsed = new URL(origin)
694
+ } catch {
695
+ invalidInput()
696
+ }
697
+ if (
698
+ parsed.origin !== origin
699
+ || parsed.hostname !== rpID
700
+ || (parsed.protocol !== 'https:' && !isLoopbackRp(parsed.hostname))
701
+ || parsed.username !== ''
702
+ || parsed.password !== ''
703
+ ) invalidInput()
704
+ }
705
+
706
+ function assertExactKeys(value: Record<string, unknown>, keys: readonly string[]): void {
707
+ const allowed = new Set(keys)
708
+ if (Object.keys(value).some(key => !allowed.has(key))) invalidInput()
709
+ }
710
+
711
+ function isLoopbackRp(hostname: string): boolean {
712
+ return hostname === 'localhost'
713
+ || hostname.endsWith('.localhost')
714
+ || hostname === '[::1]'
715
+ || /^127(?:\.\d{1,3}){3}$/u.test(hostname)
716
+ }
717
+
718
+ function checkedNow(now: () => number): number {
719
+ let value: number
720
+ try {
721
+ value = now()
722
+ } catch {
723
+ invalidInput(500)
724
+ }
725
+ if (!Number.isSafeInteger(value) || value < 0) invalidInput()
726
+ return value
727
+ }
728
+
729
+ function checkedExpiry(issuedAt: number, ttlMs: number): number {
730
+ const expiresAt = issuedAt + ttlMs
731
+ if (!Number.isSafeInteger(expiresAt)) invalidInput()
732
+ return expiresAt
733
+ }
734
+
735
+ async function sha256Base64Url(value: string): Promise<string> {
736
+ const hash = await crypto.subtle.digest('SHA-256', encoder.encode(value))
737
+ return base64Url(new Uint8Array(hash))
738
+ }
739
+
740
+ function createAuthenticationToken(): string {
741
+ return `pca_v1_${base64Url(crypto.getRandomValues(new Uint8Array(32)))}`
742
+ }
743
+
744
+ function base64Url(bytes: Uint8Array): string {
745
+ let binary = ''
746
+ for (const byte of bytes) binary += String.fromCharCode(byte)
747
+ return btoa(binary).replace(/\+/gu, '-').replace(/\//gu, '_').replace(/=+$/u, '')
748
+ }
749
+
750
+ function invalidInput(status = 400): never {
751
+ throw new AuthError({ code: 'invalid_input', status })
752
+ }
753
+
754
+ function storeUnavailable(): never {
755
+ throw new AuthError({ code: 'auth_store_unavailable', status: 503, retryable: true })
756
+ }
757
+
758
+ function isObject(value: unknown): value is Record<string, any> {
759
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
760
+ }