@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.
- package/README.md +466 -0
- package/package.json +48 -0
- package/src/ai/client.ts +1059 -0
- package/src/ai/errors.ts +114 -0
- package/src/ai/index.ts +27 -0
- package/src/ai/model-policy.ts +27 -0
- package/src/ai/pricing.ts +158 -0
- package/src/ai/providers/deepseek.ts +61 -0
- package/src/ai/providers/gemini.ts +919 -0
- package/src/ai/providers/openai-compatible.ts +731 -0
- package/src/ai/providers/sse.ts +163 -0
- package/src/ai/registry.ts +65 -0
- package/src/ai/schema.ts +382 -0
- package/src/ai/types.ts +282 -0
- package/src/auth/browser-exchange.ts +316 -0
- package/src/auth/errors.ts +57 -0
- package/src/auth/index.ts +29 -0
- package/src/auth/login-policy.ts +95 -0
- package/src/auth/oauth-state.ts +332 -0
- package/src/auth/passkey.ts +760 -0
- package/src/auth/redaction.ts +142 -0
- package/src/auth/session.ts +106 -0
- package/src/auth/types.ts +72 -0
- package/src/changelog/catalogue.ts +140 -0
- package/src/changelog/index.ts +5 -0
- package/src/changelog/locale.ts +122 -0
- package/src/changelog/service.ts +148 -0
- package/src/changelog/types.ts +122 -0
- package/src/db/bypass-tenant.ts +15 -0
- package/src/db/plugins/tenant-filter.ts +518 -0
- package/src/db/tenant-context.ts +51 -0
- package/src/guide/index.ts +5 -0
- package/src/guide/markdown.ts +108 -0
- package/src/guide/service.ts +345 -0
- package/src/guide/source.ts +116 -0
- package/src/guide/types.ts +177 -0
- package/src/index.ts +1 -0
- package/src/tenant.ts +14 -0
|
@@ -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
|
+
}
|