@ultimat3/auth 29.4.0 → 30.0.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 (67) hide show
  1. package/CLAUDE.md +46 -58
  2. package/README.md +162 -9
  3. package/package.json +20 -5
  4. package/src/adapter.ts +88 -2
  5. package/src/api-key-resolver.ts +3 -3
  6. package/src/auth.ts +29 -7
  7. package/src/authorization-server-clients.ts +169 -0
  8. package/src/authorization-server-config.ts +239 -0
  9. package/src/authorization-server-errors.ts +23 -0
  10. package/src/authorization-server-postgres-sql.ts +107 -0
  11. package/src/authorization-server-postgres.ts +222 -0
  12. package/src/authorization-server-register.ts +98 -0
  13. package/src/authorization-server-request.ts +126 -0
  14. package/src/authorization-server-routes.ts +181 -0
  15. package/src/authorization-server-schema.ts +82 -0
  16. package/src/authorization-server-store.ts +189 -0
  17. package/src/authorization-server-tokens.ts +118 -0
  18. package/src/authorization-server-types.ts +108 -0
  19. package/src/authorization-server-wire.ts +135 -0
  20. package/src/authorization-server.ts +178 -0
  21. package/src/base64url.ts +40 -0
  22. package/src/builtin-adapter-violation.ts +12 -9
  23. package/src/builtin-adapter.ts +31 -5
  24. package/src/cbor-encode.ts +52 -0
  25. package/src/cbor.ts +129 -0
  26. package/src/client-scope.ts +2 -5
  27. package/src/client.ts +16 -0
  28. package/src/cose-key.ts +129 -0
  29. package/src/errors.ts +51 -0
  30. package/src/grant-authorization-code.ts +113 -0
  31. package/src/grant-client-credentials.ts +55 -0
  32. package/src/grant-device.ts +188 -0
  33. package/src/index.ts +108 -3
  34. package/src/jwks.ts +81 -12
  35. package/src/jwt-bearer-resolver.ts +80 -0
  36. package/src/limiter-install.ts +31 -2
  37. package/src/memory-adapter.ts +18 -0
  38. package/src/mfa-challenge.ts +13 -7
  39. package/src/mfa-replay-postgres.ts +71 -0
  40. package/src/mfa.ts +24 -13
  41. package/src/oauth-bearer-resolver.ts +44 -0
  42. package/src/oauth-cookie.ts +2 -1
  43. package/src/oauth-discovery.ts +17 -6
  44. package/src/oauth-exchange.ts +23 -12
  45. package/src/oauth-profile.ts +20 -9
  46. package/src/oauth-route.ts +5 -5
  47. package/src/passkey-assertion.ts +60 -0
  48. package/src/passkey-challenge.ts +59 -0
  49. package/src/passkey-client.ts +156 -0
  50. package/src/passkey-config.ts +92 -0
  51. package/src/passkey-errors.ts +89 -0
  52. package/src/passkey-evidence.ts +74 -0
  53. package/src/passkey-login.ts +127 -0
  54. package/src/passkey-memory.ts +77 -0
  55. package/src/passkey-postgres.ts +155 -0
  56. package/src/passkey-step-up.ts +135 -0
  57. package/src/passkey-verify.ts +180 -0
  58. package/src/passkey-wire.ts +61 -0
  59. package/src/soft-authenticator.ts +208 -0
  60. package/src/subject-target.ts +73 -0
  61. package/src/tables.ts +46 -0
  62. package/src/token-endpoints.ts +61 -0
  63. package/src/tokens.ts +2 -25
  64. package/src/verification-purposes.ts +61 -0
  65. package/src/verify.ts +86 -12
  66. package/src/webauthn-data.ts +111 -0
  67. package/src/workload.ts +67 -15
package/src/mfa.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  // and single-use, so a database dump is not a permanent MFA bypass; consuming one is the
5
5
  // adapter's (`consumeRecoveryCode`), because single-use has to be one atomic step.
6
6
 
7
- import { finiteCount, timingSafeEqual } from '@ultimat3/core';
7
+ import { finiteCount, hmac, timingSafeEqual } from '@ultimat3/core';
8
8
  import type { Auth } from './auth';
9
9
  import { mfaSecretInvalid } from './errors';
10
10
  import { assertFiniteAuthCount } from './policy-numbers';
@@ -135,9 +135,7 @@ export function totpCode(secret: string, step: number, digits: number = TOTP_DIG
135
135
  // code every other unreadable secret answers too. There is no code an unreadable secret is
136
136
  // entitled to, so this returns none.
137
137
  if (key.length === 0) throw mfaSecretInvalid('totpCode');
138
- const mac = Uint8Array.from(
139
- new Bun.CryptoHasher('sha1', key).update(counterBytes(step)).digest(),
140
- );
138
+ const mac = hmac('sha1', key, counterBytes(step));
141
139
  const offset = (mac[mac.length - 1] ?? 0) & 0x0f;
142
140
  const binary =
143
141
  (((mac[offset] ?? 0) & 0x7f) << 24) |
@@ -149,7 +147,7 @@ export function totpCode(secret: string, step: number, digits: number = TOTP_DIG
149
147
 
150
148
  export interface TotpVerification {
151
149
  readonly ok: boolean;
152
- /** The step the code belonged to — feed it to `TotpReplayGuard.remember()`. */
150
+ /** The step the code belonged to — what `TotpReplayGuard.claim()` is asked to spend. */
153
151
  readonly step: number | null;
154
152
  }
155
153
 
@@ -191,14 +189,23 @@ export function verifyTotp(input: VerifyTotpInput): TotpVerification {
191
189
  return { ok: false, step: null };
192
190
  }
193
191
 
192
+ /**
193
+ * One atomic question: spend this step. A check and a write are two round trips across a network,
194
+ * and two replicas can both pass the check between them, so the guard has no separate read.
195
+ */
194
196
  export interface TotpReplayGuard {
195
- isUsed(subject: string, step: number): boolean;
196
- remember(subject: string, step: number, at: Date): void;
197
+ /**
198
+ * `true` the first time a subject spends a step, `false` for a replay. `at` is the caller's clock
199
+ * reading. A store that cannot answer REJECTS: a down database is not a wrong code.
200
+ */
201
+ claim(subject: string, step: number, at: Date): Promise<boolean>;
197
202
  }
198
203
 
199
- /** What `memoryTotpReplayGuard` returns: the interface, plus the bound it keeps, observable. */
204
+ /** What `memoryTotpReplayGuard` returns: the interface, plus the table it keeps, observable. */
200
205
  export interface MemoryTotpReplayGuard extends TotpReplayGuard {
201
206
  readonly size: number;
207
+ /** For a test reading the eviction order. Nothing on the sign-in path asks this: `claim` does. */
208
+ isUsed(subject: string, step: number): boolean;
202
209
  }
203
210
 
204
211
  /**
@@ -233,9 +240,9 @@ const newestStep = (steps: ReadonlySet<number>): number => {
233
240
  };
234
241
 
235
242
  /**
236
- * In-memory by default because a single web process is the common case; a multi-process
237
- * deployment passes a Redis-backed guard with the same two methods. Steps older than the
238
- * drift window are dropped — nothing outside it can be replayed anyway.
243
+ * One process' table: the default when no host installed a guard, so a code is single-use per
244
+ * replica. A framework boot installs `postgresTotpReplayGuard` instead (`configureTotpReplay`).
245
+ * Steps older than the drift window are dropped — nothing outside it can be replayed anyway.
239
246
  *
240
247
  * Bounded, because the subject map only ever grew: pruning happened inside one subject's `Set`
241
248
  * and never revisited a subject who stopped signing in, so the table carried one permanent entry
@@ -270,7 +277,7 @@ export function memoryTotpReplayGuard(
270
277
  if (steps.size === 0) used.delete(subject);
271
278
  }
272
279
  if (used.size <= cap) return;
273
- // Map iteration is insertion order and `remember` re-files the subject it touches, so this
280
+ // Map iteration is insertion order and `claim` re-files the subject it touches, so this
274
281
  // sort — stable by specification — breaks a tie on the newest step by least recently seen.
275
282
  // Both keys point the same way: the subject who just proved a code is the last one out.
276
283
  const furthest = [...used.entries()].sort((a, b) => newestStep(a[1]) - newestStep(b[1]));
@@ -285,7 +292,10 @@ export function memoryTotpReplayGuard(
285
292
  return used.size;
286
293
  },
287
294
  isUsed: (subject, step) => used.get(subject)?.has(step) === true,
288
- remember: (subject, step, at) => {
295
+ // `async` with no `await` in the body: it runs to its `return` in one turn, so two concurrent
296
+ // completions carrying one code cannot both find the step unspent.
297
+ claim: async (subject, step, at) => {
298
+ if (used.get(subject)?.has(step) === true) return false;
289
299
  const now = totpStep(at);
290
300
  const floor = now - drift;
291
301
  const steps = used.get(subject) ?? new Set<number>();
@@ -296,6 +306,7 @@ export function memoryTotpReplayGuard(
296
306
  used.delete(subject);
297
307
  used.set(subject, steps);
298
308
  if (used.size > cap || now - lastSweepStep >= SWEEP_EVERY_STEPS) sweep(now, floor);
309
+ return true;
299
310
  },
300
311
  };
301
312
  }
@@ -0,0 +1,44 @@
1
+ // Single responsibility: an authorization server's access token → who is calling, in the shape a
2
+ // bearer mount and an MCP endpoint both take as `resolveToken`. The `Actor` is built PER CALL by
3
+ // the app's `actor`, never stored with the grant: a member removed from the org loses access on
4
+ // the next request with no sweep. `resource`, when given, refuses a token bound to anything else.
5
+
6
+ import type { Actor } from '@ultimat3/core';
7
+ import type { ResolvedGrant } from './authorization-server-types';
8
+
9
+ /** Structurally `@ultimat3/http`'s `BearerCaller` and `@ultimat3/mcp`'s `ResolvedToken`. */
10
+ export interface OAuthBearerCaller {
11
+ readonly actor: Actor;
12
+ /** What the person (or machine) granted — the mount's scope map is cut by these. */
13
+ readonly scopes: ReadonlySet<string>;
14
+ }
15
+
16
+ export interface OAuthBearerResolverOptions {
17
+ /**
18
+ * Required: what a subject is NOW. `null` refuses the token — a removed member, a disabled
19
+ * account, a client the app no longer trusts. `grant.subject` is the client id itself for a
20
+ * `client_credentials` token.
21
+ */
22
+ readonly actor: (grant: ResolvedGrant) => Promise<Actor | null> | Actor | null;
23
+ /** RFC 8707: accept only tokens bound to this resource. Unbound tokens are refused too. */
24
+ readonly resource?: string | undefined;
25
+ }
26
+
27
+ /** What it needs of `authorizationServer()` — its `resolve`, and nothing else. */
28
+ export interface OAuthTokenSource {
29
+ resolve(token: string): Promise<ResolvedGrant | null>;
30
+ }
31
+
32
+ export function oauthBearerResolver(
33
+ server: OAuthTokenSource,
34
+ options: OAuthBearerResolverOptions,
35
+ ): (token: string) => Promise<OAuthBearerCaller | null> {
36
+ return async (token) => {
37
+ const grant = await server.resolve(token);
38
+ if (grant === null) return null;
39
+ if (options.resource !== undefined && grant.resource !== options.resource) return null;
40
+ const actor = await options.actor(grant);
41
+ if (actor === null) return null;
42
+ return { actor, scopes: grant.scopes };
43
+ };
44
+ }
@@ -8,6 +8,7 @@
8
8
  import type { Clock } from '@ultimat3/core';
9
9
  import {
10
10
  EnvMissingError,
11
+ hmac,
11
12
  readCookie,
12
13
  serializeSetCookie,
13
14
  systemClock,
@@ -227,7 +228,7 @@ export function readHandshakeCookie(
227
228
 
228
229
  /** Keyed SHA-256. Untruncated, unlike a cursor's: a cookie has room and this authorises a login. */
229
230
  function sign(body: string, secret: string): string {
230
- return new Bun.CryptoHasher('sha256', secret).update(body).digest('hex');
231
+ return hmac('sha256', secret, body, 'hex');
231
232
  }
232
233
 
233
234
  function parseBody(body: string, provider: OAuthProviderId): unknown {
@@ -9,6 +9,7 @@ import {
9
9
  renderFixShellArg,
10
10
  renderThrowable,
11
11
  } from '@ultimat3/core';
12
+ import { outboundResponse } from '@ultimat3/core/outbound';
12
13
  import { isRecord } from './json';
13
14
  import type { OAuthProvider, TokenEndpointAuth } from './oauth';
14
15
  import { oauthExchangeFailed } from './oauth-errors';
@@ -71,7 +72,9 @@ export async function discoverOAuthProvider(
71
72
  // `renderFixLiteral` is the wrong tool for a command, because its double quotes leave `$(…)`
72
73
  // live.
73
74
  const target = renderFixShellArg(url, '<issuer>/.well-known/openid-configuration');
74
- const doFetch: OAuthFetch = input.fetch ?? ((target, init) => globalThis.fetch(target, init));
75
+ const doFetch: OAuthFetch =
76
+ input.fetch ??
77
+ ((target, init) => globalThis.fetch(target, { ...init, signal: init.signal ?? null }));
75
78
  // Screened OUTSIDE the try below, deliberately: `AbortSignal.timeout(NaN)` throws, and the
76
79
  // catch around this fetch renders every throw as a provider failure — so a typo in the app's
77
80
  // own config read as the identity provider being unreachable.
@@ -84,11 +87,19 @@ export async function discoverOAuthProvider(
84
87
 
85
88
  let response: Response;
86
89
  try {
87
- response = await doFetch(url, {
88
- method: 'GET',
89
- headers: { Accept: 'application/json' },
90
- signal: AbortSignal.timeout(timeoutMs),
91
- });
90
+ // Through core's `outbound()`: `timeoutMs` bounds the whole call, the ambient request's signal
91
+ // ends it, a GET's 5xx is retried inside that bound (a POST never is), a 3xx is never followed.
92
+ response = await outboundResponse(
93
+ {
94
+ url: url,
95
+ vendor: input.id,
96
+ dial: 'named',
97
+ deadlineMs: timeoutMs,
98
+ method: 'GET',
99
+ headers: { Accept: 'application/json' },
100
+ },
101
+ { fetch: doFetch },
102
+ );
92
103
  } catch (error) {
93
104
  throw oauthExchangeFailed({
94
105
  provider: input.id,
@@ -12,6 +12,7 @@ import {
12
12
  renderThrowable,
13
13
  systemClock,
14
14
  } from '@ultimat3/core';
15
+ import { outboundResponse } from '@ultimat3/core/outbound';
15
16
  import { type IdTokenClaims, verifyIdToken } from './id-token';
16
17
  import { isRecord } from './json';
17
18
  import { type IdTokenKeys, providerJwks } from './jwks';
@@ -185,7 +186,9 @@ async function postForm(
185
186
  options: OAuthExchangeOptions,
186
187
  authorization?: string,
187
188
  ): Promise<Record<string, unknown>> {
188
- const doFetch: OAuthFetch = options.fetch ?? ((input, init) => globalThis.fetch(input, init));
189
+ const doFetch: OAuthFetch =
190
+ options.fetch ??
191
+ ((input, init) => globalThis.fetch(input, { ...init, signal: init.signal ?? null }));
189
192
  // Screened OUTSIDE the try below, deliberately: `AbortSignal.timeout(NaN)` throws, and the
190
193
  // catch around this fetch renders every throw as a provider failure — so a typo in the app's
191
194
  // own config read as the identity provider being unreachable.
@@ -199,18 +202,26 @@ async function postForm(
199
202
 
200
203
  let response: Response;
201
204
  try {
202
- response = await doFetch(url, {
203
- method: 'POST',
204
- headers: {
205
- // GitHub answers `application/x-www-form-urlencoded` unless asked for JSON.
206
- Accept: 'application/json',
207
- 'Content-Type': 'application/x-www-form-urlencoded',
208
- 'User-Agent': OAUTH_USER_AGENT,
209
- ...(authorization === undefined ? {} : { Authorization: authorization }),
205
+ // Through core's `outbound()`: `timeoutMs` bounds the whole call, the ambient request's signal
206
+ // ends it, a GET's 5xx is retried inside that bound (a POST never is), a 3xx is never followed.
207
+ response = await outboundResponse(
208
+ {
209
+ url: url,
210
+ vendor: provider,
211
+ dial: 'named',
212
+ deadlineMs: timeoutMs,
213
+ method: 'POST',
214
+ headers: {
215
+ // GitHub answers `application/x-www-form-urlencoded` unless asked for JSON.
216
+ Accept: 'application/json',
217
+ 'Content-Type': 'application/x-www-form-urlencoded',
218
+ 'User-Agent': OAUTH_USER_AGENT,
219
+ ...(authorization === undefined ? {} : { Authorization: authorization }),
220
+ },
221
+ body: body.toString(),
210
222
  },
211
- body: body.toString(),
212
- signal: AbortSignal.timeout(timeoutMs),
213
- });
223
+ { fetch: doFetch },
224
+ );
214
225
  } catch (error) {
215
226
  // The rendered value goes to the LOG, never into the `detail:`.
216
227
  //
@@ -4,6 +4,7 @@
4
4
  // decides whether this login may attach itself to an existing account by address.
5
5
 
6
6
  import { logger, renderFixShellArg, renderThrowable } from '@ultimat3/core';
7
+ import { outboundResponse } from '@ultimat3/core/outbound';
7
8
  import { idTokenEmailVerified, isVerifiedFlag } from './id-token';
8
9
  import { isRecord } from './json';
9
10
  import type { OAuthProviderId } from './oauth';
@@ -53,7 +54,9 @@ async function getJson(
53
54
  accessToken: string,
54
55
  options: OAuthProfileOptions,
55
56
  ): Promise<{ ok: true; body: unknown } | { ok: false; status: number; detail: string }> {
56
- const doFetch: OAuthFetch = options.fetch ?? ((input, init) => globalThis.fetch(input, init));
57
+ const doFetch: OAuthFetch =
58
+ options.fetch ??
59
+ ((input, init) => globalThis.fetch(input, { ...init, signal: init.signal ?? null }));
57
60
  // Screened OUTSIDE the try below, deliberately: `AbortSignal.timeout(NaN)` throws, and the
58
61
  // catch around this fetch renders every throw as a provider failure — so a typo in the app's
59
62
  // own config read as the identity provider being unreachable.
@@ -65,15 +68,23 @@ async function getJson(
65
68
  );
66
69
  let response: Response;
67
70
  try {
68
- response = await doFetch(url, {
69
- method: 'GET',
70
- headers: {
71
- Accept: 'application/json',
72
- Authorization: `Bearer ${accessToken}`,
73
- 'User-Agent': OAUTH_USER_AGENT,
71
+ // Through core's `outbound()`: `timeoutMs` bounds the whole call, the ambient request's signal
72
+ // ends it, a GET's 5xx is retried inside that bound (a POST never is), a 3xx is never followed.
73
+ response = await outboundResponse(
74
+ {
75
+ url: url,
76
+ vendor: provider,
77
+ dial: 'named',
78
+ deadlineMs: timeoutMs,
79
+ method: 'GET',
80
+ headers: {
81
+ Accept: 'application/json',
82
+ Authorization: `Bearer ${accessToken}`,
83
+ 'User-Agent': OAUTH_USER_AGENT,
84
+ },
74
85
  },
75
- signal: AbortSignal.timeout(timeoutMs),
76
- });
86
+ { fetch: doFetch },
87
+ );
77
88
  } catch (error) {
78
89
  // `renderThrowable`, never `error.message` behind an `instanceof`: `fetch` is INJECTED here,
79
90
  // so the rejection is whatever a driver or a proxy threw — and `instanceof` runs the value's
@@ -376,10 +376,10 @@ async function callbackHandler(
376
376
  const clear = hasOAuthProvider(segment) ? [clearHandshakeCookie(segment)] : [];
377
377
  try {
378
378
  const provider = assertEnabled(auth, segment);
379
- // Asked here purely so a provider with no credentials answers 404 on THIS leg too: the
380
- // exchange below resolves them again from the same two sources. One oracle, closed on both
381
- // halves — a 404 on start and a 500 on callback is the same enumeration one request later.
382
- credentialsFor(provider, options);
379
+ // A provider with no credentials answers 404 on THIS leg too: one oracle, closed on both halves
380
+ // — a 404 on start and a 500 on callback is the same enumeration one request later. And the
381
+ // answer is what the exchange uses, or `options.env` would answer the start leg alone.
382
+ const credentials = credentialsFor(provider, options);
383
383
  const url = new URL(request.url);
384
384
  assertNoProviderError(url, provider);
385
385
  const result: LoginResult = await completeOAuthLogin(auth, {
@@ -388,7 +388,7 @@ async function callbackHandler(
388
388
  state: url.searchParams.get('state') ?? '',
389
389
  code: url.searchParams.get('code') ?? '',
390
390
  },
391
- ...(options.credentials === undefined ? {} : { credentials: options.credentials }),
391
+ credentials,
392
392
  ...(options.fetch === undefined ? {} : { fetch: options.fetch }),
393
393
  ...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }),
394
394
  ...(options.resolveGrants === undefined ? {} : { resolveGrants: options.resolveGrants }),
@@ -0,0 +1,60 @@
1
+ // Single responsibility: accepting an assertion against a STORED credential — owner, signature,
2
+ // user handle, and the signature counter's non-regression, advanced compare-and-set. Shared by
3
+ // sign-in and step-up, so the two can never disagree about what a valid assertion is.
4
+
5
+ import { logger } from '@ultimat3/core';
6
+ import type { PasskeyCredential } from './adapter';
7
+ import type { Auth } from './auth';
8
+ import type { PasskeyRelyingParty } from './passkey-config';
9
+ import { refusePasskey } from './passkey-errors';
10
+ import { type CeremonyEnvelope, type VerifiedAssertion, verifyAssertion } from './passkey-verify';
11
+
12
+ export interface AcceptedAssertion {
13
+ readonly credential: PasskeyCredential;
14
+ readonly assertion: VerifiedAssertion;
15
+ }
16
+
17
+ /**
18
+ * `expectedUserId` is the account the ceremony was issued to (step-up), or `null` when the
19
+ * credential names its own (usernameless sign-in) — then the returned `userHandle`, when the
20
+ * authenticator sent one, must agree with the stored owner.
21
+ */
22
+ export async function acceptAssertion(
23
+ auth: Auth,
24
+ policy: PasskeyRelyingParty,
25
+ ceremony: 'login' | 'step-up',
26
+ envelope: CeremonyEnvelope,
27
+ expectedUserId: string | null,
28
+ ): Promise<AcceptedAssertion> {
29
+ const credential = await auth.adapter.findPasskey(envelope.credentialId);
30
+ if (credential === null) throw refusePasskey(ceremony, 'credential-unknown');
31
+ if (expectedUserId !== null && credential.userId !== expectedUserId) {
32
+ throw refusePasskey(ceremony, 'credential-owner');
33
+ }
34
+ const verified = await verifyAssertion(policy, envelope, credential.publicKey);
35
+ if (!verified.ok) throw refusePasskey(ceremony, verified.check);
36
+ const assertion = verified.value;
37
+ if (assertion.userHandle !== null && assertion.userHandle !== credential.userId) {
38
+ throw refusePasskey(ceremony, 'user-handle');
39
+ }
40
+ // §7.2 step 21: a counter that did not move forward means two devices hold this key. Both
41
+ // zero is an authenticator that keeps no counter (most synced passkeys), which is allowed.
42
+ const counted = assertion.signCount !== 0 || credential.signCount !== 0;
43
+ if (counted && assertion.signCount <= credential.signCount) {
44
+ logger.warn('auth.passkey.counter_regressed', {
45
+ credentialId: credential.id,
46
+ stored: credential.signCount,
47
+ presented: assertion.signCount,
48
+ });
49
+ throw refusePasskey(ceremony, 'counter');
50
+ }
51
+ const advanced = await auth.adapter.advancePasskeyCounter(
52
+ credential.id,
53
+ credential.signCount,
54
+ assertion.signCount,
55
+ auth.clock.now(),
56
+ );
57
+ // Lost the compare-and-set: another assertion on this counter value landed first.
58
+ if (!advanced) throw refusePasskey(ceremony, 'counter-race');
59
+ return { credential, assertion };
60
+ }
@@ -0,0 +1,59 @@
1
+ // Single responsibility: a ceremony challenge's life — minted from the CSPRNG (or, for a step-up,
2
+ // derived from a nonce and the server's digest), stored as its sha256 with a TTL through the
3
+ // adapter, and redeemed ONCE by the hash `clientDataJSON` echoes. Never a process-local map: a
4
+ // fleet answers the options on one replica and the response on another.
5
+
6
+ import type { PasskeyCeremony, PasskeyChallengeRecord } from './adapter';
7
+ import type { Auth } from './auth';
8
+ import { base64Url } from './base64url';
9
+ import { passkeyChallengeExpired } from './passkey-errors';
10
+ import { randomBytes, sha256Hex } from './tokens';
11
+
12
+ export interface IssuedChallenge {
13
+ /** The base64url challenge to put in the options. */
14
+ readonly challenge: string;
15
+ }
16
+
17
+ export interface ChallengeBinding {
18
+ readonly userId: string | null;
19
+ readonly digest?: string | undefined;
20
+ readonly nonce?: string | undefined;
21
+ /** A step-up's derived challenge; omitted, 32 random bytes are drawn. */
22
+ readonly challenge?: Uint8Array | undefined;
23
+ readonly ttlMs: number;
24
+ }
25
+
26
+ export async function issuePasskeyChallenge(
27
+ auth: Auth,
28
+ ceremony: PasskeyCeremony,
29
+ binding: ChallengeBinding,
30
+ ): Promise<IssuedChallenge> {
31
+ const challenge = base64Url(binding.challenge ?? randomBytes(32));
32
+ const now = auth.clock.now();
33
+ await auth.adapter.putPasskeyChallenge({
34
+ id: sha256Hex(challenge),
35
+ ceremony,
36
+ userId: binding.userId,
37
+ digest: binding.digest ?? null,
38
+ nonce: binding.nonce ?? null,
39
+ expiresAt: new Date(now.getTime() + binding.ttlMs),
40
+ createdAt: now,
41
+ });
42
+ return { challenge };
43
+ }
44
+
45
+ /**
46
+ * Consumed first, judged second: an expired row is gone either way, and a replay of a redeemed
47
+ * one finds nothing. Unknown, spent and late are ONE refusal — which it was is not the caller's.
48
+ */
49
+ export async function redeemPasskeyChallenge(
50
+ auth: Auth,
51
+ ceremony: PasskeyCeremony,
52
+ challenge: string,
53
+ ): Promise<PasskeyChallengeRecord> {
54
+ const row = await auth.adapter.takePasskeyChallenge(sha256Hex(challenge), ceremony);
55
+ if (row === null || row.expiresAt.getTime() <= auth.clock.now().getTime()) {
56
+ throw passkeyChallengeExpired(ceremony);
57
+ }
58
+ return row;
59
+ }
@@ -0,0 +1,156 @@
1
+ // Single responsibility: the BROWSER half of a passkey ceremony — the server's JSON options into
2
+ // `navigator.credentials.create/get`, and the credential back out as JSON for the app's action.
3
+ // Imports only `base64url.ts` and types, so an island offering "Sign in with a passkey" pays for
4
+ // these lines and nothing of the server package (`@ultimat3/auth/client`, `client-bundle.test.ts`).
5
+ //
6
+ // const options = await client.passkeyLoginOptions({});
7
+ // const outcome = await getPasskey(options);
8
+ // if (outcome.status === 'ok') await client.passkeySignIn({ response: outcome.response });
9
+
10
+ import { base64Url, canonicalBase64UrlBytes } from './base64url';
11
+ import type {
12
+ PasskeyAssertionResponse,
13
+ PasskeyCreationOptions,
14
+ PasskeyCredentialDescriptor,
15
+ PasskeyRegistrationResponse,
16
+ PasskeyRequestOptions,
17
+ } from './passkey-wire';
18
+
19
+ /**
20
+ * Every branch is an outcome to render, never an exception: `cancelled` is the person's choice
21
+ * (or the timeout), `unsupported` the browser's, `failed` anything else, named by the DOM error.
22
+ */
23
+ export type PasskeyOutcome<T> =
24
+ | { readonly status: 'ok'; readonly response: T }
25
+ | { readonly status: 'cancelled' }
26
+ | { readonly status: 'unsupported' }
27
+ | { readonly status: 'failed'; readonly error: string };
28
+
29
+ /** The browser surface this touches — injectable, so a test runs a ceremony without a browser. */
30
+ export interface PasskeyClientHost {
31
+ create(options: CredentialCreationOptions): Promise<Credential | null>;
32
+ get(options: CredentialRequestOptions): Promise<Credential | null>;
33
+ }
34
+
35
+ const browserHost = (): PasskeyClientHost | null => {
36
+ const container = globalThis.navigator?.credentials;
37
+ return container === undefined || globalThis.PublicKeyCredential === undefined ? null : container;
38
+ };
39
+
40
+ /** Whether this browser can run a ceremony at all — for hiding the button, not for security. */
41
+ export const passkeysSupported = (): boolean => browserHost() !== null;
42
+
43
+ const bytes = (field: string): Uint8Array<ArrayBuffer> =>
44
+ canonicalBase64UrlBytes(field) ?? new Uint8Array();
45
+
46
+ const TRANSPORTS: ReadonlySet<string> = new Set([
47
+ 'usb',
48
+ 'nfc',
49
+ 'ble',
50
+ 'smart-card',
51
+ 'hybrid',
52
+ 'internal',
53
+ ]);
54
+ const isTransport = (value: string): value is AuthenticatorTransport => TRANSPORTS.has(value);
55
+
56
+ const descriptor = (row: PasskeyCredentialDescriptor): PublicKeyCredentialDescriptor => ({
57
+ type: 'public-key',
58
+ id: bytes(row.id),
59
+ transports: row.transports.filter(isTransport),
60
+ });
61
+
62
+ const text = (buffer: ArrayBuffer | null | undefined): string =>
63
+ buffer === null || buffer === undefined ? '' : base64Url(new Uint8Array(buffer));
64
+
65
+ /** Structural, not `instanceof`: a test host (and a polyfill) has no `PublicKeyCredential` class. */
66
+ const isPublicKey = (credential: Credential | null): credential is PublicKeyCredential =>
67
+ credential !== null &&
68
+ credential.type === 'public-key' &&
69
+ 'rawId' in credential &&
70
+ credential.rawId instanceof ArrayBuffer &&
71
+ 'response' in credential;
72
+
73
+ const outcomeOf = (error: unknown): PasskeyOutcome<never> => {
74
+ const name =
75
+ typeof error === 'object' && error !== null && 'name' in error ? String(error.name) : 'Error';
76
+ // `NotAllowedError` is what every browser answers for "the person closed the sheet" and for the
77
+ // timeout alike, deliberately indistinguishable (§13.4.7); `AbortError` is the page's own abort.
78
+ if (name === 'NotAllowedError' || name === 'AbortError') return { status: 'cancelled' };
79
+ return { status: 'failed', error: name };
80
+ };
81
+
82
+ /** Registration: `passkeyLogin(auth).registerOptions` in, `register`'s `response` out. */
83
+ export async function newPasskey(
84
+ options: PasskeyCreationOptions,
85
+ host: PasskeyClientHost | null = browserHost(),
86
+ ): Promise<PasskeyOutcome<PasskeyRegistrationResponse>> {
87
+ if (host === null) return { status: 'unsupported' };
88
+ try {
89
+ const credential = await host.create({
90
+ publicKey: {
91
+ challenge: bytes(options.challenge),
92
+ rp: options.rp,
93
+ user: { ...options.user, id: bytes(options.user.id) },
94
+ pubKeyCredParams: [...options.pubKeyCredParams],
95
+ timeout: options.timeout,
96
+ attestation: options.attestation,
97
+ authenticatorSelection: options.authenticatorSelection,
98
+ excludeCredentials: options.excludeCredentials.map(descriptor),
99
+ },
100
+ });
101
+ if (!isPublicKey(credential)) return { status: 'failed', error: 'NoCredential' };
102
+ const response = credential.response as AuthenticatorAttestationResponse;
103
+ return {
104
+ status: 'ok',
105
+ response: {
106
+ id: credential.id,
107
+ rawId: text(credential.rawId),
108
+ type: 'public-key',
109
+ response: {
110
+ clientDataJSON: text(response.clientDataJSON),
111
+ attestationObject: text(response.attestationObject),
112
+ transports: typeof response.getTransports === 'function' ? response.getTransports() : [],
113
+ },
114
+ },
115
+ };
116
+ } catch (error) {
117
+ return outcomeOf(error);
118
+ }
119
+ }
120
+
121
+ /** Sign-in and step-up: `loginOptions` / `stepUp.options` in, `login` / `verify`'s `response` out. */
122
+ export async function getPasskey(
123
+ options: PasskeyRequestOptions,
124
+ host: PasskeyClientHost | null = browserHost(),
125
+ ): Promise<PasskeyOutcome<PasskeyAssertionResponse>> {
126
+ if (host === null) return { status: 'unsupported' };
127
+ try {
128
+ const credential = await host.get({
129
+ publicKey: {
130
+ challenge: bytes(options.challenge),
131
+ rpId: options.rpId,
132
+ timeout: options.timeout,
133
+ userVerification: options.userVerification,
134
+ allowCredentials: options.allowCredentials.map(descriptor),
135
+ },
136
+ });
137
+ if (!isPublicKey(credential)) return { status: 'failed', error: 'NoCredential' };
138
+ const response = credential.response as AuthenticatorAssertionResponse;
139
+ return {
140
+ status: 'ok',
141
+ response: {
142
+ id: credential.id,
143
+ rawId: text(credential.rawId),
144
+ type: 'public-key',
145
+ response: {
146
+ clientDataJSON: text(response.clientDataJSON),
147
+ authenticatorData: text(response.authenticatorData),
148
+ signature: text(response.signature),
149
+ userHandle: response.userHandle === null ? null : text(response.userHandle),
150
+ },
151
+ },
152
+ };
153
+ } catch (error) {
154
+ return outcomeOf(error);
155
+ }
156
+ }