@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.
- package/README.md +1 -0
- package/dist/errors.d.ts +5 -0
- package/dist/errors.js +9 -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/models/token.d.ts +7 -0
- 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/server/_resource-indicator.d.ts +7 -0
- package/dist/server/_resource-indicator.js +67 -0
- package/dist/server/authorization-server.js +2 -0
- package/dist/server/grants/client-credentials.d.ts +6 -0
- package/dist/server/grants/client-credentials.js +22 -3
- package/dist/server/grants/grant.d.ts +4 -2
- package/dist/server/grants/grant.js +17 -3
- package/dist/server/grants/refresh-token.d.ts +2 -0
- package/dist/server/grants/refresh-token.js +8 -1
- package/dist/server/mod.d.ts +1 -1
- package/dist/server/mod.js +1 -1
- package/dist/server/services/token.d.ts +20 -2
- package/dist/server/services/token.js +1 -1
- package/dist/server/signing-keys.d.ts +3 -1
- package/dist/server/signing-keys.js +2 -2
- 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/contract/token.d.ts +7 -0
- package/dist/testing/contract/token.js +17 -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/dist/testing/services.js +4 -0
- package/package.json +7 -1
|
@@ -0,0 +1,469 @@
|
|
|
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 type { AuthenticationResponseJSON, PublicKeyCredentialCreationOptionsJSON, PublicKeyCredentialRequestOptionsJSON, RegistrationResponseJSON } from "@simplewebauthn/server";
|
|
12
|
+
import { type IdentityEventHook } from "../events.ts";
|
|
13
|
+
import { type RateLimiterLike } from "../rate-limit.ts";
|
|
14
|
+
/**
|
|
15
|
+
* The options a browser passes to `navigator.credentials.create()`, in the
|
|
16
|
+
* W3C JSON form (`PublicKeyCredential.parseCreationOptionsFromJSON`).
|
|
17
|
+
*/
|
|
18
|
+
export type PasskeyRegistrationOptions = PublicKeyCredentialCreationOptionsJSON;
|
|
19
|
+
/**
|
|
20
|
+
* The options a browser passes to `navigator.credentials.get()`, in the W3C
|
|
21
|
+
* JSON form (`PublicKeyCredential.parseRequestOptionsFromJSON`).
|
|
22
|
+
*/
|
|
23
|
+
export type PasskeyAuthenticationOptions = PublicKeyCredentialRequestOptionsJSON;
|
|
24
|
+
/** The browser's `navigator.credentials.create()` result, as `toJSON()` serializes it. */
|
|
25
|
+
export type PasskeyRegistrationResponse = RegistrationResponseJSON;
|
|
26
|
+
/** The browser's `navigator.credentials.get()` result, as `toJSON()` serializes it. */
|
|
27
|
+
export type PasskeyAuthenticationResponse = AuthenticationResponseJSON;
|
|
28
|
+
/**
|
|
29
|
+
* Who the ceremony is for. A passkey is bound to `id` (the RP ID) forever, so
|
|
30
|
+
* pass it as explicit configuration — the registrable host your sign-in page
|
|
31
|
+
* is served on — and never derive it from a request's `Host` header.
|
|
32
|
+
*/
|
|
33
|
+
export interface PasskeyRelyingParty {
|
|
34
|
+
/** The RP ID: a host such as `login.example.com`, with no scheme or port. */
|
|
35
|
+
id: string;
|
|
36
|
+
/** The name a browser may show in its passkey prompt. */
|
|
37
|
+
name: string;
|
|
38
|
+
/**
|
|
39
|
+
* Every origin a response may come from, such as
|
|
40
|
+
* `https://login.example.com`. A response from any other origin is
|
|
41
|
+
* refused — this check is what makes a passkey phishing-resistant.
|
|
42
|
+
*/
|
|
43
|
+
origins: readonly string[];
|
|
44
|
+
}
|
|
45
|
+
/** The account a new passkey is created for. */
|
|
46
|
+
export interface PasskeyUser {
|
|
47
|
+
/**
|
|
48
|
+
* Your stable user id. It becomes the credential's user handle (UTF-8, at
|
|
49
|
+
* most 64 bytes), so a passkey signs in the same account even after the
|
|
50
|
+
* person changes their email or username. Do not use personal data here.
|
|
51
|
+
*/
|
|
52
|
+
id: string;
|
|
53
|
+
/** The account name a passkey manager shows, typically an email or username. */
|
|
54
|
+
name: string;
|
|
55
|
+
/** A friendlier name a passkey manager may show. Defaults to `name`. */
|
|
56
|
+
displayName?: string;
|
|
57
|
+
}
|
|
58
|
+
/** A stored passkey, as {@link PasskeyStore} persists it. Nothing here is secret. */
|
|
59
|
+
export interface Passkey {
|
|
60
|
+
/**
|
|
61
|
+
* The credential id, base64url. Chosen by the registering client, not by
|
|
62
|
+
* the server, so it must be unique across every row
|
|
63
|
+
* {@link PasskeyStore.findPasskey} can return.
|
|
64
|
+
*/
|
|
65
|
+
credentialId: string;
|
|
66
|
+
/** The account this passkey signs in to. */
|
|
67
|
+
userId: string;
|
|
68
|
+
/** The RP ID the passkey was created under; it can be used only there. */
|
|
69
|
+
rpId: string;
|
|
70
|
+
/** The COSE-encoded public key. */
|
|
71
|
+
publicKey: Uint8Array;
|
|
72
|
+
/** The last signature counter seen. Synced passkeys always report `0`. */
|
|
73
|
+
signCount: number;
|
|
74
|
+
/** Transport hints the browser reported (`internal`, `hybrid`, `usb`, …). */
|
|
75
|
+
transports: string[];
|
|
76
|
+
/**
|
|
77
|
+
* The authenticator model id the authenticator *claims*. Unattested, so
|
|
78
|
+
* treat it as a display hint only — never as a security decision.
|
|
79
|
+
*/
|
|
80
|
+
aaguid: string;
|
|
81
|
+
/**
|
|
82
|
+
* Whether the passkey may be synced between devices (the backup-eligible
|
|
83
|
+
* flag). `false` means it is bound to one device or security key.
|
|
84
|
+
*/
|
|
85
|
+
multiDevice: boolean;
|
|
86
|
+
/** Whether the passkey was synced or backed up when last seen. */
|
|
87
|
+
backedUp: boolean;
|
|
88
|
+
/** A label the person can recognize and change. */
|
|
89
|
+
name: string;
|
|
90
|
+
/** When it was registered. */
|
|
91
|
+
createdAt: Date;
|
|
92
|
+
/** When it last signed in, or `null` when never. */
|
|
93
|
+
lastUsedAt: Date | null;
|
|
94
|
+
}
|
|
95
|
+
/** The server-side half of one ceremony, held in a {@link PasskeyChallengeStore}. */
|
|
96
|
+
export interface PasskeyChallenge {
|
|
97
|
+
/** The base64url challenge the browser must sign. */
|
|
98
|
+
challenge: string;
|
|
99
|
+
/** Which ceremony issued it; a challenge never crosses ceremonies. */
|
|
100
|
+
ceremony: "registration" | "authentication";
|
|
101
|
+
/** The RP ID the ceremony runs under. */
|
|
102
|
+
rpId: string;
|
|
103
|
+
/**
|
|
104
|
+
* The user the ceremony is bound to: always set for registration, set for
|
|
105
|
+
* authentication only when the user was already identified (a second
|
|
106
|
+
* factor or a re-authentication).
|
|
107
|
+
*/
|
|
108
|
+
userId?: string;
|
|
109
|
+
/** Epoch milliseconds after which the challenge is refused. */
|
|
110
|
+
expiresAt: number;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* App-owned, short-lived storage for ceremony challenges. {@link take} **must
|
|
114
|
+
* be atomic** — read and delete in one step (Redis `GETDEL`, a `DELETE …
|
|
115
|
+
* RETURNING`) — because that is what makes each challenge single-use under
|
|
116
|
+
* concurrent submission. Expire entries yourself at or after `expiresAt`.
|
|
117
|
+
*/
|
|
118
|
+
export interface PasskeyChallengeStore {
|
|
119
|
+
/** Store a challenge under an unguessable ceremony id. */
|
|
120
|
+
put(ceremonyId: string, challenge: PasskeyChallenge): Promise<void>;
|
|
121
|
+
/** Atomically remove and return the challenge, or `undefined` when absent. */
|
|
122
|
+
take(ceremonyId: string): Promise<PasskeyChallenge | undefined>;
|
|
123
|
+
}
|
|
124
|
+
/** What {@link PasskeyStore.addPasskey} did. */
|
|
125
|
+
export type PasskeyAddResult = "added" | "duplicate" | "limit_reached";
|
|
126
|
+
/** A verified use of a passkey, for {@link PasskeyStore.recordUse}. */
|
|
127
|
+
export interface PasskeyUse {
|
|
128
|
+
/** The sign count read before verification — the compare-and-set guard. */
|
|
129
|
+
previousSignCount: number;
|
|
130
|
+
/** The sign count the authenticator just reported. */
|
|
131
|
+
signCount: number;
|
|
132
|
+
/** The backup state the authenticator just reported. */
|
|
133
|
+
backedUp: boolean;
|
|
134
|
+
/** When the passkey was used. */
|
|
135
|
+
usedAt: Date;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* App-owned persistence for {@link PasskeyService}. Implement it over your
|
|
139
|
+
* database; {@link MemoryPasskeyStore} ships for development and tests.
|
|
140
|
+
* Public keys are not secrets, so nothing here needs encryption.
|
|
141
|
+
*
|
|
142
|
+
* Two methods are concurrency guards and **must be atomic**:
|
|
143
|
+
* {@link addPasskey} (uniqueness of the credential id and the per-user cap in
|
|
144
|
+
* one statement or transaction) and {@link recordUse} (a conditional update on
|
|
145
|
+
* the previous sign count).
|
|
146
|
+
*/
|
|
147
|
+
export interface PasskeyStore {
|
|
148
|
+
/** The user's passkeys, oldest first; only those under `rpId` when given. */
|
|
149
|
+
listPasskeys(userId: string, rpId?: string): Promise<Passkey[]>;
|
|
150
|
+
/**
|
|
151
|
+
* The passkey with this credential id, or `undefined`. Credential ids are
|
|
152
|
+
* chosen by the registering client, so the uniqueness {@link addPasskey}
|
|
153
|
+
* enforces must span everything this lookup searches — never narrower.
|
|
154
|
+
*/
|
|
155
|
+
findPasskey(credentialId: string): Promise<Passkey | undefined>;
|
|
156
|
+
/**
|
|
157
|
+
* Insert the passkey unless its credential id already exists
|
|
158
|
+
* (`"duplicate"`) or the user already holds `limit` passkeys
|
|
159
|
+
* (`"limit_reached"`).
|
|
160
|
+
*/
|
|
161
|
+
addPasskey(passkey: Passkey, limit: number): Promise<PasskeyAddResult>;
|
|
162
|
+
/**
|
|
163
|
+
* Record a sign-in: set the sign count, backup state, and last-used time,
|
|
164
|
+
* but only while the stored sign count still equals
|
|
165
|
+
* `use.previousSignCount`. Return whether the row changed — `false` when
|
|
166
|
+
* the passkey was removed meanwhile, so a removed passkey cannot finish one
|
|
167
|
+
* last sign-in.
|
|
168
|
+
*/
|
|
169
|
+
recordUse(credentialId: string, use: PasskeyUse): Promise<boolean>;
|
|
170
|
+
/** Rename one of the user's passkeys; `false` when it is not theirs. */
|
|
171
|
+
renamePasskey(userId: string, credentialId: string, name: string): Promise<boolean>;
|
|
172
|
+
/** Delete one of the user's passkeys; `false` when it is not theirs. */
|
|
173
|
+
removePasskey(userId: string, credentialId: string): Promise<boolean>;
|
|
174
|
+
}
|
|
175
|
+
/** Options for {@link PasskeyService}. */
|
|
176
|
+
export interface PasskeyServiceOptions {
|
|
177
|
+
/** Where passkeys live. */
|
|
178
|
+
store: PasskeyStore;
|
|
179
|
+
/** Where in-flight ceremony challenges live. */
|
|
180
|
+
challenges: PasskeyChallengeStore;
|
|
181
|
+
/** The most passkeys one user may register. Defaults to `25`. */
|
|
182
|
+
maxPasskeysPerUser?: number;
|
|
183
|
+
/** How long a ceremony stays open, in milliseconds. Defaults to five minutes. */
|
|
184
|
+
challengeTtlMs?: number;
|
|
185
|
+
/**
|
|
186
|
+
* Throttles {@link PasskeyService.finishAuthentication}: per user for a
|
|
187
|
+
* ceremony bound to one, and per the caller's `rateLimitKey` for passkey
|
|
188
|
+
* sign-in (unthrottled here without one — put your own per-IP limit in
|
|
189
|
+
* front). Every finish counts, including one naming no live ceremony, so a
|
|
190
|
+
* caller cannot flood `passkey.verify.failed` events. Excess attempts emit
|
|
191
|
+
* `passkey.verify.rate_limited` and, under `protectionMode: "enforce"`,
|
|
192
|
+
* throw `IdentityError` `rate_limited`.
|
|
193
|
+
*/
|
|
194
|
+
rateLimiter?: RateLimiterLike;
|
|
195
|
+
/** `"enforce"` (default) applies the rate limit; `"log-only"` only reports it. */
|
|
196
|
+
protectionMode?: "enforce" | "log-only";
|
|
197
|
+
/** Receives `passkey.*` {@link IdentityEvent}s for audit capture. */
|
|
198
|
+
onEvent?: IdentityEventHook;
|
|
199
|
+
}
|
|
200
|
+
/** A started ceremony: send `options` to the browser and keep `ceremonyId`. */
|
|
201
|
+
export interface PasskeyCeremony<Options> {
|
|
202
|
+
/**
|
|
203
|
+
* The key the challenge is stored under. Bind it to the browser that started
|
|
204
|
+
* the ceremony — an HttpOnly cookie, a server session — and pass it back to
|
|
205
|
+
* the matching `finish*` call.
|
|
206
|
+
*/
|
|
207
|
+
ceremonyId: string;
|
|
208
|
+
/** What the browser needs for `navigator.credentials`. */
|
|
209
|
+
options: Options;
|
|
210
|
+
}
|
|
211
|
+
/** Arguments for {@link PasskeyService.startRegistration}. */
|
|
212
|
+
export interface StartPasskeyRegistrationOptions {
|
|
213
|
+
/** The relying party the passkey binds to. */
|
|
214
|
+
relyingParty: PasskeyRelyingParty;
|
|
215
|
+
/** The signed-in account the passkey is for. */
|
|
216
|
+
user: PasskeyUser;
|
|
217
|
+
}
|
|
218
|
+
/** Arguments for {@link PasskeyService.finishRegistration}. */
|
|
219
|
+
export interface FinishPasskeyRegistrationOptions {
|
|
220
|
+
/** The same relying party the ceremony started with. */
|
|
221
|
+
relyingParty: PasskeyRelyingParty;
|
|
222
|
+
/** From {@link PasskeyService.startRegistration}. */
|
|
223
|
+
ceremonyId: string;
|
|
224
|
+
/** The signed-in account; must be the one the ceremony started for. */
|
|
225
|
+
userId: string;
|
|
226
|
+
/** The browser's response. */
|
|
227
|
+
response: PasskeyRegistrationResponse;
|
|
228
|
+
/** A label for the new passkey. Defaults to `"Passkey"`. */
|
|
229
|
+
name?: string;
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Why a registration was refused:
|
|
233
|
+
*
|
|
234
|
+
* - `"expired_challenge"` — the ceremony id is unknown, spent, or expired.
|
|
235
|
+
* - `"invalid"` — the response failed verification, or belongs to another
|
|
236
|
+
* user, relying party, or ceremony.
|
|
237
|
+
* - `"duplicate"` — this credential is already registered.
|
|
238
|
+
* - `"limit_reached"` — the user already holds the maximum number of passkeys.
|
|
239
|
+
*/
|
|
240
|
+
export type PasskeyRegistrationFailureReason = "expired_challenge" | "invalid" | "duplicate" | "limit_reached";
|
|
241
|
+
/** The result of {@link PasskeyService.finishRegistration}. */
|
|
242
|
+
export type PasskeyRegistration = {
|
|
243
|
+
registered: false;
|
|
244
|
+
reason: PasskeyRegistrationFailureReason;
|
|
245
|
+
} | {
|
|
246
|
+
registered: true;
|
|
247
|
+
/** The stored passkey. */
|
|
248
|
+
passkey: Passkey;
|
|
249
|
+
/** Whether the person was verified (PIN or biometric) while creating it. */
|
|
250
|
+
userVerified: boolean;
|
|
251
|
+
};
|
|
252
|
+
/** Arguments for {@link PasskeyService.startAuthentication}. */
|
|
253
|
+
export interface StartPasskeyAuthenticationOptions {
|
|
254
|
+
/** The relying party to authenticate against. */
|
|
255
|
+
relyingParty: PasskeyRelyingParty;
|
|
256
|
+
/**
|
|
257
|
+
* The already-identified user, for a second factor or re-authentication.
|
|
258
|
+
* Their passkeys are listed in `allowCredentials`. Omit it for passkey
|
|
259
|
+
* sign-in, which lists nothing and so cannot reveal whether an account
|
|
260
|
+
* exists.
|
|
261
|
+
*/
|
|
262
|
+
userId?: string;
|
|
263
|
+
}
|
|
264
|
+
/** Arguments for {@link PasskeyService.finishAuthentication}. */
|
|
265
|
+
export interface FinishPasskeyAuthenticationOptions {
|
|
266
|
+
/** The same relying party the ceremony started with. */
|
|
267
|
+
relyingParty: PasskeyRelyingParty;
|
|
268
|
+
/** From {@link PasskeyService.startAuthentication}. */
|
|
269
|
+
ceremonyId: string;
|
|
270
|
+
/** The browser's response. */
|
|
271
|
+
response: PasskeyAuthenticationResponse;
|
|
272
|
+
/**
|
|
273
|
+
* The user this sign-in must belong to — required whenever a user is
|
|
274
|
+
* already identified (a second factor, a re-authentication). The ceremony
|
|
275
|
+
* must have been started for exactly this user; one started without a user
|
|
276
|
+
* is refused, so a person cannot answer someone else's challenge with their
|
|
277
|
+
* own passkey. Omit it only for passkey sign-in.
|
|
278
|
+
*/
|
|
279
|
+
userId?: string;
|
|
280
|
+
/**
|
|
281
|
+
* The rate-limit key for a sign-in with no identified user, such as
|
|
282
|
+
* `${tenantId}:${clientIp}`. Without it, only ceremonies bound to a user are
|
|
283
|
+
* throttled (per user); credential ids are never used as keys, since anyone
|
|
284
|
+
* who learns one could exhaust its owner's budget.
|
|
285
|
+
*/
|
|
286
|
+
rateLimitKey?: string;
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* Why an authentication was refused. Internal detail — tell the person only
|
|
290
|
+
* that the passkey did not work:
|
|
291
|
+
*
|
|
292
|
+
* - `"expired_challenge"` — the ceremony id is unknown, spent, or expired.
|
|
293
|
+
* - `"unknown_passkey"` — no stored passkey has this credential id under
|
|
294
|
+
* this relying party.
|
|
295
|
+
* - `"invalid"` — the signature, origin, RP ID, challenge, user-presence flag,
|
|
296
|
+
* or user handle did not verify, or the passkey is not the bound user's.
|
|
297
|
+
* - `"sign_count"` — the authenticator's counter went backwards: a possible
|
|
298
|
+
* cloned authenticator. Worth an alert.
|
|
299
|
+
* - `"replayed"` — a concurrent sign-in already advanced this passkey.
|
|
300
|
+
*/
|
|
301
|
+
export type PasskeyVerifyFailureReason = "expired_challenge" | "unknown_passkey" | "invalid" | "sign_count" | "replayed";
|
|
302
|
+
/** The result of {@link PasskeyService.finishAuthentication}. */
|
|
303
|
+
export type PasskeyVerification = {
|
|
304
|
+
valid: false;
|
|
305
|
+
reason: PasskeyVerifyFailureReason;
|
|
306
|
+
} | {
|
|
307
|
+
valid: true;
|
|
308
|
+
/** The account the passkey belongs to. */
|
|
309
|
+
userId: string;
|
|
310
|
+
/** The passkey, with its use recorded. */
|
|
311
|
+
passkey: Passkey;
|
|
312
|
+
/**
|
|
313
|
+
* Whether the authenticator verified the person (PIN or biometric). A
|
|
314
|
+
* user-verified passkey is a multi-factor authenticator on its own;
|
|
315
|
+
* without it, the passkey proves possession only.
|
|
316
|
+
*/
|
|
317
|
+
userVerified: boolean;
|
|
318
|
+
};
|
|
319
|
+
/**
|
|
320
|
+
* The RFC 8176 / OpenID EAP `amr` value for a passkey: proof of possession of
|
|
321
|
+
* a key. `hwk` and `swk` would claim to know where the key lives, which an
|
|
322
|
+
* unattested passkey cannot show.
|
|
323
|
+
*/
|
|
324
|
+
export declare const PASSKEY_AMR = "pop";
|
|
325
|
+
/**
|
|
326
|
+
* Orchestrates passkey registration, sign-in, and management over app-owned
|
|
327
|
+
* stores. Every passkey is discoverable, attestation is never requested, and
|
|
328
|
+
* user verification is preferred and reported rather than required, so
|
|
329
|
+
* security keys without a PIN still work as a second factor. Deciding what a
|
|
330
|
+
* verified passkey is worth — a first factor, a second factor, whether it
|
|
331
|
+
* satisfies your MFA policy — and minting the session are the app's job.
|
|
332
|
+
*
|
|
333
|
+
* @example Register, then sign in
|
|
334
|
+
* ```ts
|
|
335
|
+
* import {
|
|
336
|
+
* MemoryPasskeyChallengeStore,
|
|
337
|
+
* MemoryPasskeyStore,
|
|
338
|
+
* PasskeyService,
|
|
339
|
+
* } from "@udibo/oauth2/identity/passkeys";
|
|
340
|
+
*
|
|
341
|
+
* const passkeys = new PasskeyService({
|
|
342
|
+
* store: new MemoryPasskeyStore(),
|
|
343
|
+
* challenges: new MemoryPasskeyChallengeStore(),
|
|
344
|
+
* });
|
|
345
|
+
* const relyingParty = {
|
|
346
|
+
* id: "login.example.com",
|
|
347
|
+
* name: "Example",
|
|
348
|
+
* origins: ["https://login.example.com"],
|
|
349
|
+
* };
|
|
350
|
+
* const user = { id: "user-1", name: "ada@example.com" };
|
|
351
|
+
*
|
|
352
|
+
* const registration = await passkeys.startRegistration({ relyingParty, user });
|
|
353
|
+
* // keep registration.ceremonyId in an HttpOnly cookie; the browser runs
|
|
354
|
+
* // navigator.credentials.create() with registration.options and posts back:
|
|
355
|
+
* declare const createResponse: import("@udibo/oauth2/identity/passkeys").PasskeyRegistrationResponse;
|
|
356
|
+
* await passkeys.finishRegistration({
|
|
357
|
+
* relyingParty,
|
|
358
|
+
* ceremonyId: registration.ceremonyId,
|
|
359
|
+
* userId: user.id,
|
|
360
|
+
* response: createResponse,
|
|
361
|
+
* });
|
|
362
|
+
*
|
|
363
|
+
* const signIn = await passkeys.startAuthentication({ relyingParty });
|
|
364
|
+
* declare const getResponse: import("@udibo/oauth2/identity/passkeys").PasskeyAuthenticationResponse;
|
|
365
|
+
* const result = await passkeys.finishAuthentication({
|
|
366
|
+
* relyingParty,
|
|
367
|
+
* ceremonyId: signIn.ceremonyId,
|
|
368
|
+
* response: getResponse,
|
|
369
|
+
* });
|
|
370
|
+
* if (result.valid) {
|
|
371
|
+
* // run your MFA policy for result.userId, then start a session
|
|
372
|
+
* }
|
|
373
|
+
* ```
|
|
374
|
+
*/
|
|
375
|
+
export declare class PasskeyService {
|
|
376
|
+
#private;
|
|
377
|
+
/**
|
|
378
|
+
* Builds the service around app-owned stores.
|
|
379
|
+
*
|
|
380
|
+
* @throws {TypeError} when `maxPasskeysPerUser` or `challengeTtlMs` is not a
|
|
381
|
+
* positive integer, so a miswired deploy fails at startup.
|
|
382
|
+
*/
|
|
383
|
+
constructor(options: PasskeyServiceOptions);
|
|
384
|
+
/** The most passkeys one user may register. */
|
|
385
|
+
get maxPasskeysPerUser(): number;
|
|
386
|
+
/**
|
|
387
|
+
* Begin adding a passkey to a signed-in account. The user's existing
|
|
388
|
+
* passkeys under this relying party are excluded so an authenticator is not
|
|
389
|
+
* registered twice. Call it only after confirming it is really the account
|
|
390
|
+
* holder — a stolen session must not be able to add its own passkey.
|
|
391
|
+
*
|
|
392
|
+
* @throws {IdentityError} `passkey_limit_reached` when the user already
|
|
393
|
+
* holds the maximum number of passkeys.
|
|
394
|
+
* @throws {TypeError} when the user id is longer than 64 UTF-8 bytes or the
|
|
395
|
+
* relying party lists no origins.
|
|
396
|
+
*/
|
|
397
|
+
startRegistration(options: StartPasskeyRegistrationOptions): Promise<PasskeyCeremony<PasskeyRegistrationOptions>>;
|
|
398
|
+
/**
|
|
399
|
+
* Verify the browser's response and store the new passkey. The challenge
|
|
400
|
+
* is spent whatever the outcome. Attestation is never relied on: a
|
|
401
|
+
* statement the authenticator volunteers must still be well-formed, but the
|
|
402
|
+
* passkey is stored as unattested and no authenticator is preferred.
|
|
403
|
+
*/
|
|
404
|
+
finishRegistration(options: FinishPasskeyRegistrationOptions): Promise<PasskeyRegistration>;
|
|
405
|
+
/**
|
|
406
|
+
* Begin a passkey sign-in. Without `userId` the browser offers every
|
|
407
|
+
* passkey it holds for this relying party (the account is learned from the
|
|
408
|
+
* response); with it, only that user's passkeys are allowed.
|
|
409
|
+
*
|
|
410
|
+
* @throws {IdentityError} `passkey_not_registered` when `userId` is given
|
|
411
|
+
* and the user has no passkey under this relying party.
|
|
412
|
+
*/
|
|
413
|
+
startAuthentication(options: StartPasskeyAuthenticationOptions): Promise<PasskeyCeremony<PasskeyAuthenticationOptions>>;
|
|
414
|
+
/**
|
|
415
|
+
* Verify a sign-in response. The challenge is spent whatever the outcome;
|
|
416
|
+
* on success the passkey's sign count and last-used time are recorded and
|
|
417
|
+
* the rate-limit window resets. This never starts a session — apply your
|
|
418
|
+
* account checks and MFA policy to `userId` first.
|
|
419
|
+
*
|
|
420
|
+
* @throws {IdentityError} `rate_limited` when a `rateLimiter` is configured,
|
|
421
|
+
* `protectionMode` is `"enforce"`, and the limit is hit.
|
|
422
|
+
*/
|
|
423
|
+
finishAuthentication(options: FinishPasskeyAuthenticationOptions): Promise<PasskeyVerification>;
|
|
424
|
+
/** The user's passkeys, oldest first; only those under `rpId` when given. */
|
|
425
|
+
listPasskeys(userId: string, rpId?: string): Promise<Passkey[]>;
|
|
426
|
+
/**
|
|
427
|
+
* Rename one of the user's passkeys. The name is trimmed and capped at 64
|
|
428
|
+
* characters; an empty name becomes `"Passkey"`. Returns `false` when the
|
|
429
|
+
* passkey is not the user's.
|
|
430
|
+
*/
|
|
431
|
+
renamePasskey(userId: string, credentialId: string, name: string): Promise<boolean>;
|
|
432
|
+
/**
|
|
433
|
+
* Remove one of the user's passkeys. Returns `false` when it is not theirs.
|
|
434
|
+
* Gate the route behind a fresh confirmation, and refuse it yourself when it
|
|
435
|
+
* would leave the account with no way to sign in — only the app knows
|
|
436
|
+
* which other methods are usable.
|
|
437
|
+
*/
|
|
438
|
+
removePasskey(userId: string, credentialId: string): Promise<boolean>;
|
|
439
|
+
}
|
|
440
|
+
/**
|
|
441
|
+
* In-memory {@link PasskeyStore} for development and tests. State is lost on
|
|
442
|
+
* restart.
|
|
443
|
+
*/
|
|
444
|
+
export declare class MemoryPasskeyStore implements PasskeyStore {
|
|
445
|
+
#private;
|
|
446
|
+
/** The user's passkeys, oldest first. */
|
|
447
|
+
listPasskeys(userId: string, rpId?: string): Promise<Passkey[]>;
|
|
448
|
+
/** The passkey with this credential id, or `undefined`. */
|
|
449
|
+
findPasskey(credentialId: string): Promise<Passkey | undefined>;
|
|
450
|
+
/** Insert unless duplicate or over the per-user limit. */
|
|
451
|
+
addPasskey(passkey: Passkey, limit: number): Promise<PasskeyAddResult>;
|
|
452
|
+
/** Record a use while the sign count still matches. */
|
|
453
|
+
recordUse(credentialId: string, use: PasskeyUse): Promise<boolean>;
|
|
454
|
+
/** Rename one of the user's passkeys. */
|
|
455
|
+
renamePasskey(userId: string, credentialId: string, name: string): Promise<boolean>;
|
|
456
|
+
/** Delete one of the user's passkeys. */
|
|
457
|
+
removePasskey(userId: string, credentialId: string): Promise<boolean>;
|
|
458
|
+
}
|
|
459
|
+
/**
|
|
460
|
+
* In-memory {@link PasskeyChallengeStore} for development and tests. Expired
|
|
461
|
+
* entries are dropped as new ones arrive.
|
|
462
|
+
*/
|
|
463
|
+
export declare class MemoryPasskeyChallengeStore implements PasskeyChallengeStore {
|
|
464
|
+
#private;
|
|
465
|
+
/** Store a challenge. */
|
|
466
|
+
put(ceremonyId: string, challenge: PasskeyChallenge): Promise<void>;
|
|
467
|
+
/** Remove and return a challenge. */
|
|
468
|
+
take(ceremonyId: string): Promise<PasskeyChallenge | undefined>;
|
|
469
|
+
}
|