@udibo/oauth2 0.42.0 → 0.44.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 +1 -0
- package/dist/identity/errors.d.ts +2 -2
- package/dist/identity/errors.js +2 -0
- package/dist/identity/events.d.ts +44 -2
- package/dist/identity/events.js +2 -1
- package/dist/identity/passkeys/mod.d.ts +55 -0
- package/dist/identity/passkeys/mod.js +55 -0
- package/dist/identity/passkeys/service.d.ts +469 -0
- package/dist/identity/passkeys/service.js +558 -0
- package/dist/identity/service.d.ts +22 -5
- package/dist/identity/service.js +27 -8
- package/dist/react/components/internal.d.ts +5 -1
- package/dist/react/components/internal.js +9 -0
- package/dist/react/components/mfa-challenge-form.d.ts +9 -3
- package/dist/react/components/mfa-challenge-form.js +3 -3
- package/dist/react/components/mod.d.ts +1 -1
- package/dist/react/components/sign-in-form.d.ts +11 -3
- package/dist/react/components/sign-in-form.js +4 -4
- package/dist/react/components/types.d.ts +12 -0
- package/dist/testing/contract/mod.d.ts +5 -3
- package/dist/testing/contract/mod.js +5 -3
- package/dist/testing/contract/passkey-store.d.ts +66 -0
- package/dist/testing/contract/passkey-store.js +196 -0
- package/dist/testing/mod.d.ts +5 -0
- package/dist/testing/mod.js +5 -0
- package/dist/testing/passkey-authenticator.d.ts +86 -0
- package/dist/testing/passkey-authenticator.js +199 -0
- 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
|
+
}
|
|
@@ -45,7 +45,14 @@ export interface IdentityUserStore<User extends IdentityUser> {
|
|
|
45
45
|
create(profile: Record<string, unknown>, credential: PasswordCredential): Promise<User>;
|
|
46
46
|
/** Find a user by a sign-in identifier (email/username/phone). */
|
|
47
47
|
findByIdentifier(identifier: string): Promise<User | undefined>;
|
|
48
|
-
/**
|
|
48
|
+
/**
|
|
49
|
+
* Find a user by email (password-reset / sign-in-link requests).
|
|
50
|
+
*
|
|
51
|
+
* Also called when a mailed reset, sign-in or account-unlock link is
|
|
52
|
+
* redeemed: the link works only while this still resolves the address it
|
|
53
|
+
* was sent to to the same user. Normalize as at write time; a lookup that
|
|
54
|
+
* case-folds lets a link requested with different casing still redeem.
|
|
55
|
+
*/
|
|
49
56
|
findByEmail(email: string): Promise<User | undefined>;
|
|
50
57
|
/**
|
|
51
58
|
* The user's stored password credential, or `undefined`.
|
|
@@ -482,7 +489,10 @@ export declare class IdentityService<User extends IdentityUser> {
|
|
|
482
489
|
* was configured) revoke **all** of the user's sessions via
|
|
483
490
|
* `revokeAllByUser`. Clears any failed-attempt lockout — the emailed token
|
|
484
491
|
* proves the same account ownership the unlock flow does. Returns
|
|
485
|
-
* `{ userId }`, or `null` for an invalid/expired/used token
|
|
492
|
+
* `{ userId }`, or `null` for an invalid/expired/used token — including one
|
|
493
|
+
* whose address no longer finds this user through
|
|
494
|
+
* {@link IdentityUserStore.findByEmail}, because the account's email moved
|
|
495
|
+
* after the link was mailed. That refusal leaves the token unspent.
|
|
486
496
|
*
|
|
487
497
|
* A reset is the user's "lock everyone else out" action, so the outstanding
|
|
488
498
|
* passwordless credentials for the same account are voided too: sign-in links
|
|
@@ -558,7 +568,10 @@ export declare class IdentityService<User extends IdentityUser> {
|
|
|
558
568
|
* Mint + deliver a single-use account-unlock link after a lockout. Call it
|
|
559
569
|
* with the locked account's email (typically from your `lockout` event
|
|
560
570
|
* handler — the service only knows opaque user ids). Requires `tokens` and
|
|
561
|
-
* a `delivery.sendAccountUnlock` hook to actually send.
|
|
571
|
+
* a `delivery.sendAccountUnlock` hook to actually send. `email` must be an
|
|
572
|
+
* address {@link IdentityUserStore.findByEmail} resolves to `userId`:
|
|
573
|
+
* {@link IdentityService.unlockAccount} refuses the link once it does not.
|
|
574
|
+
* With a `rateLimiter`
|
|
562
575
|
* (or a tighter `rateLimiters.accountUnlock`), requests throttle per user and
|
|
563
576
|
* throw {@link IdentityError} `rate_limited` when exceeded.
|
|
564
577
|
*/
|
|
@@ -568,7 +581,9 @@ export declare class IdentityService<User extends IdentityUser> {
|
|
|
568
581
|
}): Promise<void>;
|
|
569
582
|
/**
|
|
570
583
|
* Consume an unlock token and clear the account's lockout state (failures
|
|
571
|
-
* and lock). The self-service unlock path: the emailed link lands here.
|
|
584
|
+
* and lock). The self-service unlock path: the emailed link lands here. A
|
|
585
|
+
* link whose address no longer finds this user through
|
|
586
|
+
* {@link IdentityUserStore.findByEmail} reports `invalid` and stays unspent.
|
|
572
587
|
*/
|
|
573
588
|
unlockAccount(token: string): Promise<UnlockAccountResult>;
|
|
574
589
|
/**
|
|
@@ -598,7 +613,9 @@ export declare class IdentityService<User extends IdentityUser> {
|
|
|
598
613
|
* stays your app's job. So does re-resolving the user: a user deleted or
|
|
599
614
|
* disabled since the link was minted must be treated as `invalid` by your
|
|
600
615
|
* user store, not signed in. `expired` and `invalid` are distinguished so
|
|
601
|
-
* the UI can offer a fresh link.
|
|
616
|
+
* the UI can offer a fresh link. A link whose address no longer finds this
|
|
617
|
+
* user through {@link IdentityUserStore.findByEmail} — the account's email
|
|
618
|
+
* moved after it was mailed — reports `invalid` and stays unspent.
|
|
602
619
|
*/
|
|
603
620
|
consumeSignInLink(token: string): Promise<ConsumeSignInLinkResult>;
|
|
604
621
|
/**
|