@cplieger/access 0.0.0 → 1.0.0-dev.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,500 @@
1
+ import type { ApiErr, ApiResult } from "@cplieger/reactive/fetch";
2
+ import type { AuthClient, PublicKeyCredentialJSON, RecoverFlags } from "./client.js";
3
+ import { authCode } from "./wire.js";
4
+ import type { CeremonyResponse, PasskeyView, SignedInResponse } from "./wire.js";
5
+
6
+ /** A DOMException's name from the authenticator call, or "" for a throw that is not a DOMException. */
7
+ export type DOMExceptionName = string;
8
+
9
+ export type RegisterOutcome =
10
+ | { readonly kind: "registered"; readonly passkey: PasskeyView }
11
+ | { readonly kind: "cancelled" }
12
+ | { readonly kind: "duplicate" }
13
+ | { readonly kind: "timeout" }
14
+ | { readonly kind: "not-discoverable" }
15
+ | { readonly kind: "failed"; readonly error: ApiErr | DOMExceptionName };
16
+
17
+ export type SignInOutcome =
18
+ | { readonly kind: "signed-in"; readonly result: SignedInResponse }
19
+ | { readonly kind: "cancelled" }
20
+ | { readonly kind: "timeout" }
21
+ | { readonly kind: "ceremony-expired" }
22
+ | { readonly kind: "unknown-credential" }
23
+ | { readonly kind: "verification-failed" }
24
+ | { readonly kind: "failed"; readonly error: ApiErr | DOMExceptionName };
25
+
26
+ /** A step-up signs nobody in: /stepup/passkey/finish answers 204, so success carries no SignedInResponse. */
27
+ export type StepUpOutcome =
28
+ { readonly kind: "stepped-up" } | Exclude<SignInOutcome, { kind: "signed-in" }>;
29
+
30
+ export type RecoverOutcome =
31
+ Exclude<RegisterOutcome, { kind: "registered" }> | { readonly kind: "recovered" };
32
+
33
+ interface Platform {
34
+ readonly isConditionalMediationAvailable?: () => Promise<boolean>;
35
+ readonly parseCreationOptionsFromJSON?: (
36
+ o: PublicKeyCredentialCreationOptionsJSON,
37
+ ) => PublicKeyCredentialCreationOptions;
38
+ readonly parseRequestOptionsFromJSON?: (
39
+ o: PublicKeyCredentialRequestOptionsJSON,
40
+ ) => PublicKeyCredentialRequestOptions;
41
+ readonly signalAllAcceptedCredentials?: (o: {
42
+ rpId: string;
43
+ userId: string;
44
+ allAcceptedCredentialIds: string[];
45
+ }) => Promise<void>;
46
+ readonly signalCurrentUserDetails?: (o: {
47
+ rpId: string;
48
+ userId: string;
49
+ name: string;
50
+ displayName: string;
51
+ }) => Promise<void>;
52
+ readonly signalUnknownCredential?: (o: { rpId: string; credentialId: string }) => Promise<void>;
53
+ }
54
+
55
+ function platform(): Platform | undefined {
56
+ return (globalThis as { readonly PublicKeyCredential?: Platform }).PublicKeyCredential;
57
+ }
58
+
59
+ function credentials(): CredentialsContainer | undefined {
60
+ return (globalThis as { readonly navigator?: { readonly credentials?: CredentialsContainer } })
61
+ .navigator?.credentials;
62
+ }
63
+
64
+ /** Whether this browser can run a ceremony, and the conditional (autofill) one. Never rejects. */
65
+ export async function passkeySupport(): Promise<{
66
+ readonly webauthn: boolean;
67
+ readonly conditional: boolean;
68
+ }> {
69
+ const pk = platform();
70
+ if (pk === undefined || credentials() === undefined) {
71
+ return { webauthn: false, conditional: false };
72
+ }
73
+ const probe = pk.isConditionalMediationAvailable;
74
+ let conditional = false;
75
+ if (typeof probe === "function") {
76
+ try {
77
+ conditional = await probe.call(pk);
78
+ } catch {
79
+ conditional = false;
80
+ }
81
+ }
82
+ return { webauthn: true, conditional };
83
+ }
84
+
85
+ function bytes(b64url: string): ArrayBuffer {
86
+ const b64 = b64url.replaceAll("-", "+").replaceAll("_", "/");
87
+ const binary = atob(b64 + "=".repeat((4 - (b64.length % 4)) % 4));
88
+ const out = new Uint8Array(binary.length);
89
+ for (let i = 0; i < binary.length; i++) {
90
+ out[i] = binary.charCodeAt(i);
91
+ }
92
+ return out.buffer;
93
+ }
94
+
95
+ function credentialDescriptors(
96
+ list: readonly PublicKeyCredentialDescriptorJSON[],
97
+ ): PublicKeyCredentialDescriptor[] {
98
+ return list.map((d) => ({
99
+ id: bytes(d.id),
100
+ type: d.type as PublicKeyCredentialType,
101
+ ...(d.transports === undefined ? {} : { transports: d.transports as AuthenticatorTransport[] }),
102
+ }));
103
+ }
104
+
105
+ /**
106
+ * The options navigator.credentials.create takes, through PublicKeyCredential's own
107
+ * parseCreationOptionsFromJSON where the browser has it, else by decoding the base64url fields.
108
+ * @internal
109
+ */
110
+ export function toCreationOptions(
111
+ json: PublicKeyCredentialCreationOptionsJSON,
112
+ ): PublicKeyCredentialCreationOptions {
113
+ const pk = platform();
114
+ const parse = pk?.parseCreationOptionsFromJSON;
115
+ if (typeof parse === "function") {
116
+ return parse.call(pk, json);
117
+ }
118
+ const { challenge, user, excludeCredentials, hints: _hints, extensions: _ext, ...rest } = json;
119
+ return {
120
+ ...rest,
121
+ challenge: bytes(challenge),
122
+ user: { ...user, id: bytes(user.id) },
123
+ ...(excludeCredentials === undefined
124
+ ? {}
125
+ : { excludeCredentials: credentialDescriptors(excludeCredentials) }),
126
+ ...(json.extensions?.credProps === undefined
127
+ ? {}
128
+ : { extensions: { credProps: json.extensions.credProps } }),
129
+ } as PublicKeyCredentialCreationOptions;
130
+ }
131
+
132
+ /**
133
+ * The options navigator.credentials.get takes, as toCreationOptions builds them.
134
+ * @internal
135
+ */
136
+ export function toRequestOptions(
137
+ json: PublicKeyCredentialRequestOptionsJSON,
138
+ ): PublicKeyCredentialRequestOptions {
139
+ const pk = platform();
140
+ const parse = pk?.parseRequestOptionsFromJSON;
141
+ if (typeof parse === "function") {
142
+ return parse.call(pk, json);
143
+ }
144
+ const { challenge, allowCredentials, hints: _hints, extensions: _ext, ...rest } = json;
145
+ return {
146
+ ...rest,
147
+ challenge: bytes(challenge),
148
+ ...(allowCredentials === undefined
149
+ ? {}
150
+ : { allowCredentials: credentialDescriptors(allowCredentials) }),
151
+ } as PublicKeyCredentialRequestOptions;
152
+ }
153
+
154
+ type Verdict =
155
+ | { readonly kind: "timeout" | "cancelled" | "duplicate" }
156
+ | { readonly kind: "failed"; readonly name: DOMExceptionName };
157
+
158
+ // AbortSignal.timeout() aborts with TimeoutError, not AbortError; AbortError is the caller
159
+ // cancelling its own (conditional) ceremony, NotAllowedError the user dismissing the prompt.
160
+ function classifyDOMException(e: unknown): Verdict {
161
+ if (!(e instanceof DOMException)) {
162
+ return { kind: "failed", name: "" };
163
+ }
164
+ switch (e.name) {
165
+ case "TimeoutError":
166
+ return { kind: "timeout" };
167
+ case "AbortError":
168
+ case "NotAllowedError":
169
+ return { kind: "cancelled" };
170
+ case "InvalidStateError":
171
+ return { kind: "duplicate" };
172
+ default:
173
+ return { kind: "failed", name: e.name };
174
+ }
175
+ }
176
+
177
+ // A 2xx begin answer of the other ceremony kind is the route's decode failure, as the client
178
+ // reports a body that is not its type.
179
+ function wrongKind(): ApiErr {
180
+ return { ok: false, status: 0, code: "decode", error: "ceremony options of the wrong kind" };
181
+ }
182
+
183
+ function creationJSON(r: CeremonyResponse): PublicKeyCredentialCreationOptionsJSON | undefined {
184
+ const pk = r.public_key.publicKey;
185
+ return "rp" in pk ? pk : undefined;
186
+ }
187
+
188
+ function requestJSON(r: CeremonyResponse): PublicKeyCredentialRequestOptionsJSON | undefined {
189
+ const pk = r.public_key.publicKey;
190
+ return "rp" in pk ? undefined : pk;
191
+ }
192
+
193
+ function base64url(buffer: ArrayBuffer): string {
194
+ let binary = "";
195
+ for (const b of new Uint8Array(buffer)) {
196
+ binary += String.fromCharCode(b);
197
+ }
198
+ return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
199
+ }
200
+
201
+ // Built from the raw buffers, not toJSON(), which the browsers taking the options fallback above
202
+ // lack. Only a requested extension output is forwarded: go-webauthn's default
203
+ // UnsolicitedOutputPolicyReject (protocol/extensions_session.go) refuses any other, and accesshttp
204
+ // requests credProps on the create leg only.
205
+ function finishBody(cred: PublicKeyCredential, credProps: boolean): PublicKeyCredentialJSON {
206
+ const attachment = (cred as { readonly authenticatorAttachment?: unknown })
207
+ .authenticatorAttachment;
208
+ const head = {
209
+ id: cred.id,
210
+ rawId: base64url(cred.rawId),
211
+ type: cred.type,
212
+ ...(typeof attachment === "string" ? { authenticatorAttachment: attachment } : {}),
213
+ };
214
+ if ("attestationObject" in cred.response) {
215
+ const r = cred.response as AuthenticatorAttestationResponse;
216
+ const rk = credProps ? rkOf(cred.getClientExtensionResults()) : undefined;
217
+ return {
218
+ ...head,
219
+ response: {
220
+ attestationObject: base64url(r.attestationObject),
221
+ clientDataJSON: base64url(r.clientDataJSON),
222
+ ...transportsOf(r),
223
+ },
224
+ clientExtensionResults: rk === undefined ? {} : { credProps: { rk } },
225
+ };
226
+ }
227
+ const r = cred.response as AuthenticatorAssertionResponse;
228
+ return {
229
+ ...head,
230
+ response: {
231
+ authenticatorData: base64url(r.authenticatorData),
232
+ clientDataJSON: base64url(r.clientDataJSON),
233
+ signature: base64url(r.signature),
234
+ ...(r.userHandle === null ? {} : { userHandle: base64url(r.userHandle) }),
235
+ },
236
+ clientExtensionResults: {},
237
+ };
238
+ }
239
+
240
+ // getTransports is absent on older engines; the body then carries no transports.
241
+ function transportsOf(r: AuthenticatorAttestationResponse): { transports?: string[] } {
242
+ const get = (r as { readonly getTransports?: unknown }).getTransports;
243
+ const list: unknown = typeof get === "function" ? get.call(r) : undefined;
244
+ return Array.isArray(list)
245
+ ? { transports: list.filter((t): t is string => typeof t === "string") }
246
+ : {};
247
+ }
248
+
249
+ function rkOf(results: AuthenticationExtensionsClientOutputs): boolean | undefined {
250
+ const rk = (results as { readonly credProps?: { readonly rk?: unknown } }).credProps?.rk;
251
+ return typeof rk === "boolean" ? rk : undefined;
252
+ }
253
+
254
+ type Created =
255
+ | {
256
+ readonly kind: "credential";
257
+ readonly ceremony: string;
258
+ readonly json: PublicKeyCredentialJSON;
259
+ }
260
+ | Exclude<RegisterOutcome, { kind: "registered" | "not-discoverable" }>;
261
+
262
+ async function create(begin: ApiResult<CeremonyResponse>): Promise<Created> {
263
+ if (!begin.ok) {
264
+ return { kind: "failed", error: begin };
265
+ }
266
+ const json = creationJSON(begin.data);
267
+ if (json === undefined) {
268
+ return { kind: "failed", error: wrongKind() };
269
+ }
270
+ try {
271
+ const cred = await credentials()?.create({ publicKey: toCreationOptions(json) });
272
+ if (cred === null || cred === undefined) {
273
+ return { kind: "cancelled" };
274
+ }
275
+ return {
276
+ kind: "credential",
277
+ ceremony: begin.data.ceremony,
278
+ json: finishBody(cred as PublicKeyCredential, json.extensions?.credProps === true),
279
+ };
280
+ } catch (e: unknown) {
281
+ const v = classifyDOMException(e);
282
+ return v.kind === "failed" ? { kind: "failed", error: v.name } : v;
283
+ }
284
+ }
285
+
286
+ type Got =
287
+ | {
288
+ readonly kind: "credential";
289
+ readonly ceremony: string;
290
+ readonly json: PublicKeyCredentialJSON;
291
+ readonly id: string;
292
+ readonly rpId: string;
293
+ }
294
+ | Extract<SignInOutcome, { kind: "cancelled" | "timeout" | "failed" }>;
295
+
296
+ async function get(
297
+ begin: ApiResult<CeremonyResponse>,
298
+ o?: { readonly mediation?: "conditional"; readonly signal?: AbortSignal },
299
+ ): Promise<Got> {
300
+ if (!begin.ok) {
301
+ return { kind: "failed", error: begin };
302
+ }
303
+ const json = requestJSON(begin.data);
304
+ if (json === undefined) {
305
+ return { kind: "failed", error: wrongKind() };
306
+ }
307
+ const request: CredentialRequestOptions = {
308
+ publicKey: toRequestOptions(json),
309
+ ...(o?.mediation === undefined ? {} : { mediation: o.mediation }),
310
+ ...(o?.signal === undefined ? {} : { signal: o.signal }),
311
+ };
312
+ try {
313
+ const cred = await credentials()?.get(request);
314
+ if (cred === null || cred === undefined) {
315
+ return { kind: "cancelled" };
316
+ }
317
+ return {
318
+ kind: "credential",
319
+ ceremony: begin.data.ceremony,
320
+ json: finishBody(cred as PublicKeyCredential, false),
321
+ id: cred.id,
322
+ rpId: json.rpId ?? location.hostname,
323
+ };
324
+ } catch (e: unknown) {
325
+ const v = classifyDOMException(e);
326
+ switch (v.kind) {
327
+ case "failed":
328
+ return { kind: "failed", error: v.name };
329
+ case "duplicate":
330
+ // Only the create leg has an exclusion list, so here the name means no duplicate.
331
+ return { kind: "failed", error: "InvalidStateError" };
332
+ default:
333
+ return { kind: v.kind };
334
+ }
335
+ }
336
+ }
337
+
338
+ async function assertionRefused(
339
+ res: ApiErr,
340
+ got: { readonly id: string; readonly rpId: string },
341
+ ): Promise<Exclude<SignInOutcome, { kind: "signed-in" }>> {
342
+ switch (authCode(res)) {
343
+ case "auth_unknown_credential": {
344
+ const pk = platform();
345
+ const signal = pk?.signalUnknownCredential;
346
+ if (typeof signal === "function") {
347
+ await bounded(() => signal.call(pk, { rpId: got.rpId, credentialId: got.id }));
348
+ }
349
+ return { kind: "unknown-credential" };
350
+ }
351
+ case "auth_ceremony_invalid":
352
+ return { kind: "ceremony-expired" };
353
+ case "auth_assertion_failed":
354
+ return { kind: "verification-failed" };
355
+ default:
356
+ return { kind: "failed", error: res };
357
+ }
358
+ }
359
+
360
+ function registrationRefused(res: ApiErr): Exclude<RegisterOutcome, { kind: "registered" }> {
361
+ switch (authCode(res)) {
362
+ case "auth_not_discoverable":
363
+ return { kind: "not-discoverable" };
364
+ case "auth_duplicate_credential":
365
+ return { kind: "duplicate" };
366
+ default:
367
+ return { kind: "failed", error: res };
368
+ }
369
+ }
370
+
371
+ /** Signs in with a passkey; mediation "conditional" runs the autofill ceremony, and signal aborts it. */
372
+ export async function signInWithPasskey(
373
+ c: AuthClient,
374
+ o?: { readonly mediation?: "conditional"; readonly signal?: AbortSignal },
375
+ ): Promise<SignInOutcome> {
376
+ const got = await get(
377
+ await c.loginPasskeyBegin(o?.mediation === undefined ? undefined : { mediation: o.mediation }),
378
+ o,
379
+ );
380
+ if (got.kind !== "credential") {
381
+ return got;
382
+ }
383
+ const res = await c.loginPasskeyFinish(got.ceremony, got.json);
384
+ return res.ok ? { kind: "signed-in", result: res.data } : assertionRefused(res, got);
385
+ }
386
+
387
+ /** Registers a passkey for the signed-in user, then sends the provider the server's signals. */
388
+ export async function registerPasskey(c: AuthClient): Promise<RegisterOutcome> {
389
+ const made = await create(await c.passkeyRegisterBegin());
390
+ if (made.kind !== "credential") {
391
+ return made;
392
+ }
393
+ const res = await c.passkeyRegisterFinish(made.ceremony, made.json);
394
+ if (!res.ok) {
395
+ return registrationRefused(res);
396
+ }
397
+ await sendSignals(c);
398
+ return { kind: "registered", passkey: res.data };
399
+ }
400
+
401
+ /** Creates the first user with a passkey; a create-leg duplicate is a failed InvalidStateError here. */
402
+ export async function setupWithPasskey(
403
+ c: AuthClient,
404
+ r: { readonly token?: string; readonly username: string },
405
+ ): Promise<SignInOutcome> {
406
+ const made = await create(await c.setupPasskeyBegin(r));
407
+ if (made.kind === "duplicate") {
408
+ return { kind: "failed", error: "InvalidStateError" };
409
+ }
410
+ if (made.kind !== "credential") {
411
+ return made;
412
+ }
413
+ const res = await c.setupPasskeyFinish(made.ceremony, made.json);
414
+ if (res.ok) {
415
+ return { kind: "signed-in", result: res.data };
416
+ }
417
+ return authCode(res) === "auth_ceremony_invalid"
418
+ ? { kind: "ceremony-expired" }
419
+ : { kind: "failed", error: res };
420
+ }
421
+
422
+ export async function stepUpWithPasskey(c: AuthClient): Promise<StepUpOutcome> {
423
+ const got = await get(await c.stepUpPasskeyBegin());
424
+ if (got.kind !== "credential") {
425
+ return got;
426
+ }
427
+ const res = await c.stepUpPasskeyFinish(got.ceremony, got.json);
428
+ return res.ok ? { kind: "stepped-up" } : assertionRefused(res, got);
429
+ }
430
+
431
+ /** Registers a passkey with the recovery session's ceremony; o says which sessions and keys survive. */
432
+ export async function recoverWithPasskey(c: AuthClient, o: RecoverFlags): Promise<RecoverOutcome> {
433
+ const made = await create(await c.recoveryPasskeyBegin());
434
+ if (made.kind !== "credential") {
435
+ return made;
436
+ }
437
+ const res = await c.recoveryPasskeyFinish(made.ceremony, made.json, o);
438
+ return res.ok ? { kind: "recovered" } : registrationRefused(res);
439
+ }
440
+
441
+ // Safari can leave a Signal API promise pending forever
442
+ // (https://bugs.webkit.org/show_bug.cgi?id=278339); a signal is a hint, so it never blocks
443
+ // the caller for longer than this.
444
+ const SIGNAL_DEADLINE_MS = 2000;
445
+
446
+ async function bounded(send: () => Promise<unknown>): Promise<void> {
447
+ let timer: ReturnType<typeof setTimeout> | undefined;
448
+ try {
449
+ const quiet = Promise.resolve()
450
+ .then(send)
451
+ .then(
452
+ () => undefined,
453
+ () => undefined,
454
+ );
455
+ const deadline = new Promise<void>((resolve) => {
456
+ timer = setTimeout(resolve, SIGNAL_DEADLINE_MS);
457
+ });
458
+ await Promise.race([quiet, deadline]);
459
+ } finally {
460
+ clearTimeout(timer);
461
+ }
462
+ }
463
+
464
+ /**
465
+ * Tells the passkey provider which credentials the server still accepts and the user's current
466
+ * name, where the browser supports each signal. Never rejects, and waits at most 2 s per signal.
467
+ */
468
+ export async function sendSignals(c: AuthClient): Promise<void> {
469
+ const pk = platform();
470
+ if (pk === undefined) {
471
+ return;
472
+ }
473
+ const all = pk.signalAllAcceptedCredentials;
474
+ const details = pk.signalCurrentUserDetails;
475
+ if (typeof all !== "function" && typeof details !== "function") {
476
+ return;
477
+ }
478
+ const res = await c.webauthnSignals();
479
+ if (!res.ok) {
480
+ return;
481
+ }
482
+ const { allAcceptedCredentials: a, currentUserDetails: d } = res.data;
483
+ // Independent, not in series: Safari can hang or reject one, which must not suppress the other.
484
+ const sent: Promise<void>[] = [];
485
+ if (typeof all === "function") {
486
+ sent.push(
487
+ bounded(() =>
488
+ all.call(pk, {
489
+ rpId: a.rpId,
490
+ userId: a.userId,
491
+ allAcceptedCredentialIds: [...a.allAcceptedCredentialIds],
492
+ }),
493
+ ),
494
+ );
495
+ }
496
+ if (typeof details === "function") {
497
+ sent.push(bounded(() => details.call(pk, { ...d })));
498
+ }
499
+ await Promise.all(sent);
500
+ }