@udibo/oauth2 0.41.0 → 0.43.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +1 -0
  2. package/dist/errors.d.ts +5 -0
  3. package/dist/errors.js +9 -0
  4. package/dist/identity/errors.d.ts +2 -2
  5. package/dist/identity/errors.js +2 -0
  6. package/dist/identity/events.d.ts +44 -2
  7. package/dist/identity/events.js +2 -1
  8. package/dist/identity/passkeys/mod.d.ts +55 -0
  9. package/dist/identity/passkeys/mod.js +55 -0
  10. package/dist/identity/passkeys/service.d.ts +469 -0
  11. package/dist/identity/passkeys/service.js +558 -0
  12. package/dist/models/token.d.ts +7 -0
  13. package/dist/react/components/internal.d.ts +5 -1
  14. package/dist/react/components/internal.js +9 -0
  15. package/dist/react/components/mfa-challenge-form.d.ts +9 -3
  16. package/dist/react/components/mfa-challenge-form.js +3 -3
  17. package/dist/react/components/mod.d.ts +1 -1
  18. package/dist/react/components/sign-in-form.d.ts +11 -3
  19. package/dist/react/components/sign-in-form.js +4 -4
  20. package/dist/react/components/types.d.ts +12 -0
  21. package/dist/server/_resource-indicator.d.ts +7 -0
  22. package/dist/server/_resource-indicator.js +67 -0
  23. package/dist/server/authorization-server.js +2 -0
  24. package/dist/server/grants/client-credentials.d.ts +6 -0
  25. package/dist/server/grants/client-credentials.js +22 -3
  26. package/dist/server/grants/grant.d.ts +4 -2
  27. package/dist/server/grants/grant.js +17 -3
  28. package/dist/server/grants/refresh-token.d.ts +2 -0
  29. package/dist/server/grants/refresh-token.js +8 -1
  30. package/dist/server/mod.d.ts +1 -1
  31. package/dist/server/mod.js +1 -1
  32. package/dist/server/services/token.d.ts +20 -2
  33. package/dist/server/services/token.js +1 -1
  34. package/dist/server/signing-keys.d.ts +3 -1
  35. package/dist/server/signing-keys.js +2 -2
  36. package/dist/testing/contract/mod.d.ts +5 -3
  37. package/dist/testing/contract/mod.js +5 -3
  38. package/dist/testing/contract/passkey-store.d.ts +66 -0
  39. package/dist/testing/contract/passkey-store.js +196 -0
  40. package/dist/testing/contract/token.d.ts +7 -0
  41. package/dist/testing/contract/token.js +17 -0
  42. package/dist/testing/mod.d.ts +5 -0
  43. package/dist/testing/mod.js +5 -0
  44. package/dist/testing/passkey-authenticator.d.ts +86 -0
  45. package/dist/testing/passkey-authenticator.js +199 -0
  46. package/dist/testing/services.js +4 -0
  47. package/package.json +7 -1
@@ -0,0 +1,558 @@
1
+ /**
2
+ * The passkey orchestrator: WebAuthn registration and authentication
3
+ * ceremonies over an app-owned {@link PasskeyStore} and
4
+ * {@link PasskeyChallengeStore}. Cryptographic verification is delegated to
5
+ * `@simplewebauthn/server`; this layer owns the policy around it — no
6
+ * attestation, discoverable credentials, single-use challenges, a per-user
7
+ * cap, sign-count clone detection, and the usual identity seams.
8
+ *
9
+ * @module
10
+ */
11
+ import { generateAuthenticationOptions, generateRegistrationOptions, verifyAuthenticationResponse, verifyRegistrationResponse, } from "@simplewebauthn/server";
12
+ import { base64urlEncode, randomToken } from "../../utils/crypto.js";
13
+ import { IdentityError } from "../errors.js";
14
+ import { dispatchIdentityEvent, } from "../events.js";
15
+ import { enforceRateLimit } from "../rate-limit.js";
16
+ /**
17
+ * The RFC 8176 / OpenID EAP `amr` value for a passkey: proof of possession of
18
+ * a key. `hwk` and `swk` would claim to know where the key lives, which an
19
+ * unattested passkey cannot show.
20
+ */
21
+ export const PASSKEY_AMR = "pop";
22
+ const DEFAULT_MAX_PASSKEYS = 25;
23
+ const DEFAULT_CHALLENGE_TTL_MS = 5 * 60 * 1000;
24
+ const MAX_NAME_LENGTH = 64;
25
+ const MAX_USER_HANDLE_BYTES = 64;
26
+ const SUPPORTED_ALGORITHMS = [-8, -7, -257];
27
+ /**
28
+ * Orchestrates passkey registration, sign-in, and management over app-owned
29
+ * stores. Every passkey is discoverable, attestation is never requested, and
30
+ * user verification is preferred and reported rather than required, so
31
+ * security keys without a PIN still work as a second factor. Deciding what a
32
+ * verified passkey is worth — a first factor, a second factor, whether it
33
+ * satisfies your MFA policy — and minting the session are the app's job.
34
+ *
35
+ * @example Register, then sign in
36
+ * ```ts
37
+ * import {
38
+ * MemoryPasskeyChallengeStore,
39
+ * MemoryPasskeyStore,
40
+ * PasskeyService,
41
+ * } from "@udibo/oauth2/identity/passkeys";
42
+ *
43
+ * const passkeys = new PasskeyService({
44
+ * store: new MemoryPasskeyStore(),
45
+ * challenges: new MemoryPasskeyChallengeStore(),
46
+ * });
47
+ * const relyingParty = {
48
+ * id: "login.example.com",
49
+ * name: "Example",
50
+ * origins: ["https://login.example.com"],
51
+ * };
52
+ * const user = { id: "user-1", name: "ada@example.com" };
53
+ *
54
+ * const registration = await passkeys.startRegistration({ relyingParty, user });
55
+ * // keep registration.ceremonyId in an HttpOnly cookie; the browser runs
56
+ * // navigator.credentials.create() with registration.options and posts back:
57
+ * declare const createResponse: import("@udibo/oauth2/identity/passkeys").PasskeyRegistrationResponse;
58
+ * await passkeys.finishRegistration({
59
+ * relyingParty,
60
+ * ceremonyId: registration.ceremonyId,
61
+ * userId: user.id,
62
+ * response: createResponse,
63
+ * });
64
+ *
65
+ * const signIn = await passkeys.startAuthentication({ relyingParty });
66
+ * declare const getResponse: import("@udibo/oauth2/identity/passkeys").PasskeyAuthenticationResponse;
67
+ * const result = await passkeys.finishAuthentication({
68
+ * relyingParty,
69
+ * ceremonyId: signIn.ceremonyId,
70
+ * response: getResponse,
71
+ * });
72
+ * if (result.valid) {
73
+ * // run your MFA policy for result.userId, then start a session
74
+ * }
75
+ * ```
76
+ */
77
+ export class PasskeyService {
78
+ #store;
79
+ #challenges;
80
+ #maxPasskeys;
81
+ #challengeTtlMs;
82
+ #rateLimiter;
83
+ #enforce;
84
+ #onEvent;
85
+ /**
86
+ * Builds the service around app-owned stores.
87
+ *
88
+ * @throws {TypeError} when `maxPasskeysPerUser` or `challengeTtlMs` is not a
89
+ * positive integer, so a miswired deploy fails at startup.
90
+ */
91
+ constructor(options) {
92
+ const max = options.maxPasskeysPerUser ?? DEFAULT_MAX_PASSKEYS;
93
+ const ttl = options.challengeTtlMs ?? DEFAULT_CHALLENGE_TTL_MS;
94
+ if (!Number.isInteger(max) || max <= 0) {
95
+ throw new TypeError("maxPasskeysPerUser must be a positive integer");
96
+ }
97
+ if (!Number.isInteger(ttl) || ttl <= 0) {
98
+ throw new TypeError("challengeTtlMs must be a positive integer");
99
+ }
100
+ this.#store = options.store;
101
+ this.#challenges = options.challenges;
102
+ this.#maxPasskeys = max;
103
+ this.#challengeTtlMs = ttl;
104
+ this.#rateLimiter = options.rateLimiter;
105
+ this.#enforce = (options.protectionMode ?? "enforce") === "enforce";
106
+ this.#onEvent = options.onEvent;
107
+ }
108
+ /** The most passkeys one user may register. */
109
+ get maxPasskeysPerUser() {
110
+ return this.#maxPasskeys;
111
+ }
112
+ /**
113
+ * Begin adding a passkey to a signed-in account. The user's existing
114
+ * passkeys under this relying party are excluded so an authenticator is not
115
+ * registered twice. Call it only after confirming it is really the account
116
+ * holder — a stolen session must not be able to add its own passkey.
117
+ *
118
+ * @throws {IdentityError} `passkey_limit_reached` when the user already
119
+ * holds the maximum number of passkeys.
120
+ * @throws {TypeError} when the user id is longer than 64 UTF-8 bytes or the
121
+ * relying party lists no origins.
122
+ */
123
+ async startRegistration(options) {
124
+ const { relyingParty, user } = options;
125
+ assertRelyingParty(relyingParty);
126
+ const userHandle = new TextEncoder().encode(user.id);
127
+ if (userHandle.byteLength === 0 ||
128
+ userHandle.byteLength > MAX_USER_HANDLE_BYTES) {
129
+ throw new TypeError("user.id must be 1 to 64 UTF-8 bytes");
130
+ }
131
+ const existing = await this.#store.listPasskeys(user.id);
132
+ if (existing.length >= this.#maxPasskeys) {
133
+ throw new IdentityError("passkey_limit_reached", `a user may register at most ${this.#maxPasskeys} passkeys`);
134
+ }
135
+ const passkeyOptions = await generateRegistrationOptions({
136
+ rpID: relyingParty.id,
137
+ rpName: relyingParty.name,
138
+ userID: userHandle,
139
+ userName: user.name,
140
+ userDisplayName: user.displayName ?? user.name,
141
+ challenge: crypto.getRandomValues(new Uint8Array(32)),
142
+ timeout: this.#challengeTtlMs,
143
+ attestationType: "none",
144
+ excludeCredentials: existing
145
+ .filter((passkey) => passkey.rpId === relyingParty.id)
146
+ .map((passkey) => ({
147
+ id: passkey.credentialId,
148
+ transports: passkey.transports,
149
+ })),
150
+ authenticatorSelection: {
151
+ residentKey: "required",
152
+ requireResidentKey: true,
153
+ userVerification: "preferred",
154
+ },
155
+ supportedAlgorithmIDs: SUPPORTED_ALGORITHMS,
156
+ });
157
+ const ceremonyId = await this.#putChallenge({
158
+ challenge: passkeyOptions.challenge,
159
+ ceremony: "registration",
160
+ rpId: relyingParty.id,
161
+ userId: user.id,
162
+ });
163
+ return { ceremonyId, options: passkeyOptions };
164
+ }
165
+ /**
166
+ * Verify the browser's response and store the new passkey. The challenge
167
+ * is spent whatever the outcome. Attestation is never relied on: a
168
+ * statement the authenticator volunteers must still be well-formed, but the
169
+ * passkey is stored as unattested and no authenticator is preferred.
170
+ */
171
+ async finishRegistration(options) {
172
+ const { relyingParty, userId } = options;
173
+ const challenge = await this.#takeChallenge(options.ceremonyId);
174
+ if (!challenge) {
175
+ return await this.#registrationFailed(userId, "expired_challenge");
176
+ }
177
+ if (challenge.ceremony !== "registration" ||
178
+ challenge.rpId !== relyingParty.id ||
179
+ challenge.userId !== userId) {
180
+ return await this.#registrationFailed(userId, "invalid");
181
+ }
182
+ let verified;
183
+ try {
184
+ verified = await verifyRegistrationResponse({
185
+ response: options.response,
186
+ expectedChallenge: challenge.challenge,
187
+ expectedOrigin: [...relyingParty.origins],
188
+ expectedRPID: relyingParty.id,
189
+ requireUserPresence: true,
190
+ requireUserVerification: false,
191
+ supportedAlgorithmIDs: SUPPORTED_ALGORITHMS,
192
+ });
193
+ }
194
+ catch {
195
+ return await this.#registrationFailed(userId, "invalid");
196
+ }
197
+ if (!verified.verified) {
198
+ return await this.#registrationFailed(userId, "invalid");
199
+ }
200
+ const info = verified.registrationInfo;
201
+ const passkey = {
202
+ credentialId: info.credential.id,
203
+ userId,
204
+ rpId: relyingParty.id,
205
+ publicKey: new Uint8Array(info.credential.publicKey),
206
+ signCount: info.credential.counter,
207
+ transports: [...(info.credential.transports ?? [])],
208
+ aaguid: info.aaguid,
209
+ multiDevice: info.credentialDeviceType === "multiDevice",
210
+ backedUp: info.credentialBackedUp,
211
+ name: normalizeName(options.name),
212
+ createdAt: new Date(),
213
+ lastUsedAt: null,
214
+ };
215
+ const added = await this.#store.addPasskey(passkey, this.#maxPasskeys);
216
+ if (added !== "added") {
217
+ return await this.#registrationFailed(userId, added);
218
+ }
219
+ await this.#emit({
220
+ type: "passkey.registered",
221
+ userId,
222
+ credentialId: passkey.credentialId,
223
+ multiDevice: passkey.multiDevice,
224
+ });
225
+ return { registered: true, passkey, userVerified: info.userVerified };
226
+ }
227
+ /**
228
+ * Begin a passkey sign-in. Without `userId` the browser offers every
229
+ * passkey it holds for this relying party (the account is learned from the
230
+ * response); with it, only that user's passkeys are allowed.
231
+ *
232
+ * @throws {IdentityError} `passkey_not_registered` when `userId` is given
233
+ * and the user has no passkey under this relying party.
234
+ */
235
+ async startAuthentication(options) {
236
+ const { relyingParty, userId } = options;
237
+ assertRelyingParty(relyingParty);
238
+ let allowCredentials;
239
+ if (userId !== undefined) {
240
+ const passkeys = await this.#store.listPasskeys(userId, relyingParty.id);
241
+ if (passkeys.length === 0) {
242
+ throw new IdentityError("passkey_not_registered", "the user has no passkey for this relying party");
243
+ }
244
+ allowCredentials = passkeys.map((passkey) => ({
245
+ id: passkey.credentialId,
246
+ transports: passkey.transports,
247
+ }));
248
+ }
249
+ const passkeyOptions = await generateAuthenticationOptions({
250
+ rpID: relyingParty.id,
251
+ challenge: crypto.getRandomValues(new Uint8Array(32)),
252
+ timeout: this.#challengeTtlMs,
253
+ userVerification: "preferred",
254
+ ...(allowCredentials ? { allowCredentials } : {}),
255
+ });
256
+ const ceremonyId = await this.#putChallenge({
257
+ challenge: passkeyOptions.challenge,
258
+ ceremony: "authentication",
259
+ rpId: relyingParty.id,
260
+ ...(userId !== undefined ? { userId } : {}),
261
+ });
262
+ return { ceremonyId, options: passkeyOptions };
263
+ }
264
+ /**
265
+ * Verify a sign-in response. The challenge is spent whatever the outcome;
266
+ * on success the passkey's sign count and last-used time are recorded and
267
+ * the rate-limit window resets. This never starts a session — apply your
268
+ * account checks and MFA policy to `userId` first.
269
+ *
270
+ * @throws {IdentityError} `rate_limited` when a `rateLimiter` is configured,
271
+ * `protectionMode` is `"enforce"`, and the limit is hit.
272
+ */
273
+ async finishAuthentication(options) {
274
+ const { relyingParty, response } = options;
275
+ const challenge = await this.#takeChallenge(options.ceremonyId);
276
+ const credentialId = credentialIdOf(response);
277
+ const expectedUserId = options.userId;
278
+ const boundUserId = challenge?.userId;
279
+ const throttledUserId = expectedUserId ?? boundUserId;
280
+ const rateKey = throttledUserId !== undefined
281
+ ? `passkey:verify:${throttledUserId}`
282
+ : options.rateLimitKey !== undefined
283
+ ? `passkey:verify:anonymous:${options.rateLimitKey}`
284
+ : undefined;
285
+ if (rateKey !== undefined) {
286
+ await this.#throttle(rateKey, throttledUserId, credentialId);
287
+ }
288
+ if (!challenge) {
289
+ return await this.#verifyFailed("expired_challenge", credentialId, expectedUserId);
290
+ }
291
+ if (challenge.ceremony !== "authentication" ||
292
+ challenge.rpId !== relyingParty.id ||
293
+ (expectedUserId !== undefined && boundUserId !== expectedUserId)) {
294
+ return await this.#verifyFailed("invalid", credentialId, throttledUserId);
295
+ }
296
+ const passkey = credentialId
297
+ ? await this.#store.findPasskey(credentialId)
298
+ : undefined;
299
+ if (!passkey || passkey.rpId !== relyingParty.id) {
300
+ return await this.#verifyFailed("unknown_passkey", credentialId, boundUserId);
301
+ }
302
+ if ((boundUserId !== undefined && passkey.userId !== boundUserId) ||
303
+ !userHandleMatches(response.response?.userHandle, passkey.userId)) {
304
+ return await this.#verifyFailed("invalid", credentialId, boundUserId ?? passkey.userId);
305
+ }
306
+ let verified;
307
+ try {
308
+ verified = await verifyAuthenticationResponse({
309
+ response,
310
+ expectedChallenge: challenge.challenge,
311
+ expectedOrigin: [...relyingParty.origins],
312
+ expectedRPID: relyingParty.id,
313
+ credential: {
314
+ id: passkey.credentialId,
315
+ publicKey: new Uint8Array(passkey.publicKey),
316
+ counter: 0,
317
+ transports: passkey.transports,
318
+ },
319
+ requireUserVerification: false,
320
+ });
321
+ }
322
+ catch {
323
+ return await this.#verifyFailed("invalid", credentialId, passkey.userId);
324
+ }
325
+ if (!verified.verified) {
326
+ return await this.#verifyFailed("invalid", credentialId, passkey.userId);
327
+ }
328
+ const info = verified.authenticationInfo;
329
+ if ((info.newCounter > 0 || passkey.signCount > 0) &&
330
+ info.newCounter <= passkey.signCount) {
331
+ return await this.#verifyFailed("sign_count", credentialId, passkey.userId);
332
+ }
333
+ const usedAt = new Date();
334
+ const recorded = await this.#store.recordUse(credentialId, {
335
+ previousSignCount: passkey.signCount,
336
+ signCount: info.newCounter,
337
+ backedUp: info.credentialBackedUp,
338
+ usedAt,
339
+ });
340
+ if (!recorded) {
341
+ return await this.#verifyFailed("replayed", credentialId, passkey.userId);
342
+ }
343
+ if (rateKey !== undefined)
344
+ await this.#resetThrottle(rateKey);
345
+ await this.#emit({
346
+ type: "passkey.verify.succeeded",
347
+ userId: passkey.userId,
348
+ credentialId,
349
+ userVerified: info.userVerified,
350
+ });
351
+ return {
352
+ valid: true,
353
+ userId: passkey.userId,
354
+ userVerified: info.userVerified,
355
+ passkey: {
356
+ ...passkey,
357
+ signCount: info.newCounter,
358
+ backedUp: info.credentialBackedUp,
359
+ lastUsedAt: usedAt,
360
+ },
361
+ };
362
+ }
363
+ /** The user's passkeys, oldest first; only those under `rpId` when given. */
364
+ listPasskeys(userId, rpId) {
365
+ return this.#store.listPasskeys(userId, rpId);
366
+ }
367
+ /**
368
+ * Rename one of the user's passkeys. The name is trimmed and capped at 64
369
+ * characters; an empty name becomes `"Passkey"`. Returns `false` when the
370
+ * passkey is not the user's.
371
+ */
372
+ async renamePasskey(userId, credentialId, name) {
373
+ const renamed = await this.#store.renamePasskey(userId, credentialId, normalizeName(name));
374
+ if (renamed) {
375
+ await this.#emit({ type: "passkey.renamed", userId, credentialId });
376
+ }
377
+ return renamed;
378
+ }
379
+ /**
380
+ * Remove one of the user's passkeys. Returns `false` when it is not theirs.
381
+ * Gate the route behind a fresh confirmation, and refuse it yourself when it
382
+ * would leave the account with no way to sign in — only the app knows
383
+ * which other methods are usable.
384
+ */
385
+ async removePasskey(userId, credentialId) {
386
+ const removed = await this.#store.removePasskey(userId, credentialId);
387
+ if (removed) {
388
+ await this.#emit({ type: "passkey.removed", userId, credentialId });
389
+ }
390
+ return removed;
391
+ }
392
+ async #putChallenge(challenge) {
393
+ const ceremonyId = randomToken(32);
394
+ await this.#challenges.put(ceremonyId, {
395
+ ...challenge,
396
+ expiresAt: Date.now() + this.#challengeTtlMs,
397
+ });
398
+ return ceremonyId;
399
+ }
400
+ async #takeChallenge(ceremonyId) {
401
+ if (typeof ceremonyId !== "string" || ceremonyId === "")
402
+ return undefined;
403
+ const challenge = await this.#challenges.take(ceremonyId);
404
+ if (!challenge || challenge.expiresAt <= Date.now())
405
+ return undefined;
406
+ return challenge;
407
+ }
408
+ async #registrationFailed(userId, reason) {
409
+ await this.#emit({ type: "passkey.registration.failed", userId, reason });
410
+ return { registered: false, reason };
411
+ }
412
+ async #verifyFailed(reason, credentialId, userId) {
413
+ await this.#emit({
414
+ type: "passkey.verify.failed",
415
+ reason,
416
+ ...(credentialId ? { credentialId } : {}),
417
+ ...(userId !== undefined ? { userId } : {}),
418
+ });
419
+ return { valid: false, reason };
420
+ }
421
+ #throttle(key, userId, credentialId) {
422
+ return enforceRateLimit({
423
+ rateLimiter: this.#rateLimiter,
424
+ key,
425
+ message: "too many passkey attempts",
426
+ enforce: this.#enforce,
427
+ event: {
428
+ type: "passkey.verify.rate_limited",
429
+ ...(userId !== undefined ? { userId } : {}),
430
+ ...(credentialId ? { credentialId } : {}),
431
+ },
432
+ emit: (event) => this.#emit(event),
433
+ });
434
+ }
435
+ async #resetThrottle(key) {
436
+ try {
437
+ await this.#rateLimiter?.reset(key);
438
+ }
439
+ catch (error) {
440
+ console.error("[@udibo/oauth2] passkey rate-limit reset failed; the window stays " +
441
+ "filled until it lapses:", error instanceof Error ? error.message : error);
442
+ }
443
+ }
444
+ #emit(event) {
445
+ return dispatchIdentityEvent(this.#onEvent, "passkeys", event);
446
+ }
447
+ }
448
+ function assertRelyingParty(relyingParty) {
449
+ if (!relyingParty.id || relyingParty.origins.length === 0) {
450
+ throw new TypeError("relyingParty needs an id and at least one origin");
451
+ }
452
+ }
453
+ function normalizeName(name) {
454
+ const trimmed = (name ?? "").trim().slice(0, MAX_NAME_LENGTH);
455
+ return trimmed === "" ? "Passkey" : trimmed;
456
+ }
457
+ const MAX_CREDENTIAL_ID_LENGTH = 1366;
458
+ const BASE64URL = /^[A-Za-z0-9_-]+$/;
459
+ function credentialIdOf(response) {
460
+ const id = response?.id;
461
+ return typeof id === "string" &&
462
+ id.length <= MAX_CREDENTIAL_ID_LENGTH &&
463
+ BASE64URL.test(id)
464
+ ? id
465
+ : "";
466
+ }
467
+ function userHandleMatches(userHandle, userId) {
468
+ if (userHandle === undefined || userHandle === "")
469
+ return true;
470
+ return userHandle === base64urlEncode(new TextEncoder().encode(userId));
471
+ }
472
+ /**
473
+ * In-memory {@link PasskeyStore} for development and tests. State is lost on
474
+ * restart.
475
+ */
476
+ export class MemoryPasskeyStore {
477
+ #passkeys = new Map();
478
+ /** The user's passkeys, oldest first. */
479
+ listPasskeys(userId, rpId) {
480
+ return Promise.resolve([...this.#passkeys.values()]
481
+ .filter((passkey) => passkey.userId === userId &&
482
+ (rpId === undefined || passkey.rpId === rpId))
483
+ .map(copyPasskey));
484
+ }
485
+ /** The passkey with this credential id, or `undefined`. */
486
+ findPasskey(credentialId) {
487
+ const passkey = this.#passkeys.get(credentialId);
488
+ return Promise.resolve(passkey ? copyPasskey(passkey) : undefined);
489
+ }
490
+ /** Insert unless duplicate or over the per-user limit. */
491
+ addPasskey(passkey, limit) {
492
+ if (this.#passkeys.has(passkey.credentialId)) {
493
+ return Promise.resolve("duplicate");
494
+ }
495
+ const held = [...this.#passkeys.values()].filter((existing) => existing.userId === passkey.userId).length;
496
+ if (held >= limit)
497
+ return Promise.resolve("limit_reached");
498
+ this.#passkeys.set(passkey.credentialId, copyPasskey(passkey));
499
+ return Promise.resolve("added");
500
+ }
501
+ /** Record a use while the sign count still matches. */
502
+ recordUse(credentialId, use) {
503
+ const passkey = this.#passkeys.get(credentialId);
504
+ if (!passkey || passkey.signCount !== use.previousSignCount) {
505
+ return Promise.resolve(false);
506
+ }
507
+ passkey.signCount = use.signCount;
508
+ passkey.backedUp = use.backedUp;
509
+ passkey.lastUsedAt = use.usedAt;
510
+ return Promise.resolve(true);
511
+ }
512
+ /** Rename one of the user's passkeys. */
513
+ renamePasskey(userId, credentialId, name) {
514
+ const passkey = this.#passkeys.get(credentialId);
515
+ if (passkey?.userId !== userId)
516
+ return Promise.resolve(false);
517
+ passkey.name = name;
518
+ return Promise.resolve(true);
519
+ }
520
+ /** Delete one of the user's passkeys. */
521
+ removePasskey(userId, credentialId) {
522
+ if (this.#passkeys.get(credentialId)?.userId !== userId) {
523
+ return Promise.resolve(false);
524
+ }
525
+ this.#passkeys.delete(credentialId);
526
+ return Promise.resolve(true);
527
+ }
528
+ }
529
+ /**
530
+ * In-memory {@link PasskeyChallengeStore} for development and tests. Expired
531
+ * entries are dropped as new ones arrive.
532
+ */
533
+ export class MemoryPasskeyChallengeStore {
534
+ #challenges = new Map();
535
+ /** Store a challenge. */
536
+ put(ceremonyId, challenge) {
537
+ const now = Date.now();
538
+ for (const [id, stored] of this.#challenges) {
539
+ if (stored.expiresAt <= now)
540
+ this.#challenges.delete(id);
541
+ }
542
+ this.#challenges.set(ceremonyId, { ...challenge });
543
+ return Promise.resolve();
544
+ }
545
+ /** Remove and return a challenge. */
546
+ take(ceremonyId) {
547
+ const challenge = this.#challenges.get(ceremonyId);
548
+ this.#challenges.delete(ceremonyId);
549
+ return Promise.resolve(challenge);
550
+ }
551
+ }
552
+ function copyPasskey(passkey) {
553
+ return {
554
+ ...passkey,
555
+ publicKey: new Uint8Array(passkey.publicKey),
556
+ transports: [...passkey.transports],
557
+ };
558
+ }
@@ -18,6 +18,13 @@ export interface Token<Client extends ClientInterface, User, Scope extends Abstr
18
18
  client: Client;
19
19
  /** The user this token was issued for (if applicable). */
20
20
  user?: User;
21
+ /**
22
+ * Exact resource URI accepted by an opted-in client-credentials policy
23
+ * (RFC 8707). Persist and restore it on every token read; never derive it
24
+ * from a later request or the client's current configuration. Absent for
25
+ * legacy issuance. Resource authority still belongs to the application.
26
+ */
27
+ resource?: string;
21
28
  /** The scope granted to this token. */
22
29
  scope?: Scope | null;
23
30
  /**
@@ -1,6 +1,6 @@
1
1
  import { type ReactNode, type Ref } from "react";
2
2
  import type { AuthFieldProps, AuthFormState } from "./use-auth-form.ts";
3
- import type { AuthFormClassNames, AuthFormValues, SocialProvidersProps } from "./types.ts";
3
+ import type { PasskeyOption, AuthFormClassNames, AuthFormValues, SocialProvidersProps } from "./types.ts";
4
4
  /**
5
5
  * What every default form component returns: the shared root, `<form>` and
6
6
  * error summary, or the caller's `render` output instead, with the resolved
@@ -47,5 +47,9 @@ export declare function ErrorSummary(props: {
47
47
  */
48
48
  export declare function useFocusFirstError<V extends AuthFormValues>(form: AuthFormState<V>): void;
49
49
  export declare function SocialButtons(props: SocialProvidersProps): ReactNode;
50
+ export declare function PasskeyButton(props: {
51
+ option: PasskeyOption | undefined;
52
+ defaultLabel: string;
53
+ }): ReactNode;
50
54
  export declare function combineClassNames(className: string | undefined, slot: string | undefined): string | undefined;
51
55
  export declare function displayName(user: Record<string, unknown>): string;
@@ -69,6 +69,15 @@ export function SocialButtons(props) {
69
69
  const label = (provider) => (_jsxs(_Fragment, { children: [provider.iconSlot ? (_jsx("span", { "data-oauth2-social-icon": "", children: provider.iconSlot })) : null, _jsxs("span", { "data-oauth2-social-label": "", children: ["Continue with ", provider.name] })] }));
70
70
  return (_jsxs(_Fragment, { children: [_jsx("div", { "data-oauth2-social": "", className: classNames.socialSection, children: socialProviders.map((provider) => socialHref ? (_jsx("a", { href: socialHref(provider), "data-oauth2-social-button": provider.id, className: classNames.socialButton, children: label(provider) }, provider.id)) : (_jsx("button", { type: "button", onClick: () => onSocialSelect?.(provider), "data-oauth2-social-button": provider.id, className: classNames.socialButton, children: label(provider) }, provider.id))) }), _jsx("div", { "data-oauth2-social-divider": "", "aria-hidden": "true", className: classNames.socialDivider })] }));
71
71
  }
72
+ export function PasskeyButton(props) {
73
+ const classNames = useAuthFormClassNames();
74
+ const { option } = props;
75
+ if (!option)
76
+ return null;
77
+ return (_jsx("button", { type: "button", onClick: () => {
78
+ void option.onSelect();
79
+ }, "data-oauth2-passkey-button": "", className: classNames.socialButton, children: option.label ?? props.defaultLabel }));
80
+ }
72
81
  export function combineClassNames(className, slot) {
73
82
  return [className, slot].filter(Boolean).join(" ") || undefined;
74
83
  }
@@ -1,14 +1,15 @@
1
1
  /**
2
2
  * {@link MfaChallengeForm} — a default, accessible, unstyled second-factor
3
3
  * challenge form built on {@link useAuthForm}. Collects a one-time code and,
4
- * when allowed, toggles to accepting a recovery code instead. The active
5
- * method is forwarded to `onSubmit` so your endpoint knows how to verify it.
4
+ * when allowed, toggles to accepting a recovery code instead, and offers a
5
+ * passkey when `passkey` is supplied. The active method is forwarded to
6
+ * `onSubmit` so your endpoint knows how to verify it.
6
7
  *
7
8
  * @module
8
9
  */
9
10
  import type { ReactNode } from "react";
10
11
  import type { AuthFormState } from "./use-auth-form.ts";
11
- import type { BaseAuthFormProps } from "./types.ts";
12
+ import type { BaseAuthFormProps, PasskeyOption } from "./types.ts";
12
13
  /** Which credential the user is submitting. */
13
14
  export type MfaChallengeMethod = "totp" | "recovery";
14
15
  /** Values collected by {@link MfaChallengeForm}. */
@@ -39,6 +40,11 @@ export interface MfaChallengeFormProps extends BaseAuthFormProps<MfaChallengeVal
39
40
  allowRecoveryCode?: boolean;
40
41
  /** Text overrides. */
41
42
  labels?: MfaChallengeLabels;
43
+ /**
44
+ * Offer a passkey as the second factor: renders a "Use a passkey" button
45
+ * whose `onSelect` runs the ceremony against the already-identified user.
46
+ */
47
+ passkey?: PasskeyOption;
42
48
  /**
43
49
  * Render-prop escape hatch. When provided, you own the markup and receive
44
50
  * the live {@link AuthFormState}. Toggle `method` with `setValue`.
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useAuthForm } from "./use-auth-form.js";
3
- import { AuthFormShell, SubmitButton, TextField, useFocusFirstError, } from "./internal.js";
3
+ import { AuthFormShell, PasskeyButton, SubmitButton, TextField, useFocusFirstError, } from "./internal.js";
4
4
  import { useAuthFormClassNames } from "./class-names.js";
5
5
  /**
6
6
  * A ready-to-use MFA challenge form. Wire `onSubmit` to your verification
@@ -21,7 +21,7 @@ import { useAuthFormClassNames } from "./class-names.js";
21
21
  * ```
22
22
  */
23
23
  export function MfaChallengeForm(props) {
24
- const { onSubmit, className, allowRecoveryCode = true, labels, children, } = props;
24
+ const { onSubmit, className, allowRecoveryCode = true, labels, passkey, children, } = props;
25
25
  const form = useAuthForm({
26
26
  initialValues: { code: "", method: "totp" },
27
27
  onSubmit,
@@ -36,7 +36,7 @@ export function MfaChallengeForm(props) {
36
36
  code: "",
37
37
  });
38
38
  };
39
- return (_jsxs(AuthFormShell, { name: "mfa-challenge", form: form, className: className, classNames: classNames, render: children, action: props.action, method: props.method, formRef: props.formRef, rootProps: { "data-oauth2-method": form.values.method }, children: [_jsx(TextField, { fieldProps: form.getFieldProps("code"), label: isRecovery
39
+ return (_jsxs(AuthFormShell, { name: "mfa-challenge", form: form, className: className, classNames: classNames, render: children, action: props.action, method: props.method, formRef: props.formRef, rootProps: { "data-oauth2-method": form.values.method }, aboveForm: _jsx(PasskeyButton, { option: passkey, defaultLabel: "Use a passkey" }), children: [_jsx(TextField, { fieldProps: form.getFieldProps("code"), label: isRecovery
40
40
  ? (labels?.recoveryCode ?? "Recovery code")
41
41
  : (labels?.code ?? "Authentication code"), autoComplete: isRecovery ? "off" : "one-time-code", required: true, error: form.errors.code }), _jsx(SubmitButton, { isSubmitting: form.isSubmitting, label: labels?.submit ?? "Verify", submittingLabel: labels?.submitting ?? "Verifying…" }), allowRecoveryCode ? (_jsx("button", { type: "button", onClick: toggleMethod, "data-oauth2-secondary": "", "data-oauth2-toggle-recovery": "", className: classNames.secondary, children: isRecovery
42
42
  ? (labels?.useAuthenticator ?? "Use your authenticator app")
@@ -85,7 +85,7 @@
85
85
  */
86
86
  export { useAuthForm } from "./use-auth-form.ts";
87
87
  export type { AuthFieldProps, AuthFormState, AuthFormStatus, UseAuthFormOptions, } from "./use-auth-form.ts";
88
- export type { AuthFormClassNames, AuthFormErrors, AuthFormResult, AuthFormValues, AuthSubmitHandler, BaseAuthFormProps, BaseClassNames, SocialProvider, SocialProvidersProps, } from "./types.ts";
88
+ export type { AuthFormClassNames, AuthFormErrors, AuthFormResult, AuthFormValues, AuthSubmitHandler, BaseAuthFormProps, BaseClassNames, PasskeyOption, SocialProvider, SocialProvidersProps, } from "./types.ts";
89
89
  export { AuthFormClassNamesProvider, useAuthFormClassNames, } from "./class-names.tsx";
90
90
  export type { AuthFormClassNamesProviderProps } from "./class-names.tsx";
91
91
  export { SignInForm } from "./sign-in-form.tsx";