@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/index.ts CHANGED
@@ -12,6 +12,11 @@ export type {
12
12
  AuthUser,
13
13
  AuthVerification,
14
14
  CreateUserInput,
15
+ PasskeyCeremony,
16
+ PasskeyChallengeRecord,
17
+ PasskeyCredential,
18
+ PasskeyEvidence,
19
+ PasskeyStore,
15
20
  SessionPatch,
16
21
  SessionStore,
17
22
  StoredMfaSecret,
@@ -61,6 +66,47 @@ export {
61
66
  UserSchema,
62
67
  VerificationSchema,
63
68
  } from './auth';
69
+ export type { AuthorizationServer } from './authorization-server';
70
+ export { AUTH_CODE_TTL_MS, authorizationServer } from './authorization-server';
71
+ export type {
72
+ RegisterClientInput,
73
+ RegisteredClient,
74
+ } from './authorization-server-clients';
75
+ export type {
76
+ AuthorizationServerOptions,
77
+ AuthServerEvent,
78
+ AuthServerLifetimes,
79
+ } from './authorization-server-config';
80
+ export type { PostgresAuthServerStoreOptions } from './authorization-server-postgres';
81
+ export { postgresAuthServerStore } from './authorization-server-postgres';
82
+ export type { ReadRequestResult } from './authorization-server-request';
83
+ export { AUTH_SERVER_METADATA_WELL_KNOWN } from './authorization-server-routes';
84
+ export { AUTH_SERVER_TABLES, SQL_AUTH_SERVER_TABLES } from './authorization-server-schema';
85
+ export type {
86
+ AuthServerStore,
87
+ DeviceDecision,
88
+ RefreshRotation,
89
+ } from './authorization-server-store';
90
+ export { memoryAuthServerStore } from './authorization-server-store';
91
+ export type {
92
+ AuthCodeRecord,
93
+ AuthGrantRecord,
94
+ AuthorizationRequest,
95
+ AuthServerClient,
96
+ AuthServerGrant,
97
+ AuthTokenKind,
98
+ AuthTokenRecord,
99
+ ClientAuthMethod,
100
+ DeviceCodeRecord,
101
+ DeviceStatus,
102
+ ResolvedGrant,
103
+ } from './authorization-server-types';
104
+ export { DEVICE_GRANT } from './authorization-server-types';
105
+ export type {
106
+ AuthServerHttpContext,
107
+ AuthServerHttpRequest,
108
+ AuthServerRoute,
109
+ } from './authorization-server-wire';
64
110
  export { type PostgresAuthAdapter, postgresAuthAdapter } from './builtin-adapter';
65
111
  /** The opaque per-principal id a per-request document hands the page's client store. */
66
112
  export type { ClientScopeOptions } from './client-scope';
@@ -91,7 +137,9 @@ export {
91
137
  passwordWeak,
92
138
  sessionExpired,
93
139
  sessionUnknown,
140
+ signInUnconfigured,
94
141
  } from './errors';
142
+ export type { DeviceReading } from './grant-device';
95
143
  export { currentActor, requireActor } from './guards';
96
144
  export type { IdTokenClaims, VerifyIdTokenInput } from './id-token';
97
145
  export {
@@ -113,16 +161,28 @@ export {
113
161
  providerJwks,
114
162
  verifyJwtSignature,
115
163
  } from './jwks';
164
+ export type {
165
+ JwtBearerCaller,
166
+ JwtBearerCheck,
167
+ JwtBearerResolverOptions,
168
+ } from './jwt-bearer-resolver';
169
+ export { jwtBearerResolver } from './jwt-bearer-resolver';
116
170
  export type { KdfGate, KdfLimits } from './kdf-gate';
117
171
  export {
118
172
  boundedKdfGate,
119
173
  configureKdfGate,
120
174
  kdfGate,
121
175
  } from './kdf-gate';
122
- export type { AuthLimiterFactory } from './limiter-install';
176
+ export type { AuthLimiterFactory, TotpReplayFactory } from './limiter-install';
123
177
  // `installedAuthLimiter` is deliberately absent: `defineAuth` is the one reader, and a second
124
178
  // caller building limiters out of band would be a second answer to where failures are counted.
125
- export { configureAuthLimiters, purgeAuthLimits, resetAuthLimiters } from './limiter-install';
179
+ export {
180
+ configureAuthLimiters,
181
+ configureTotpReplay,
182
+ purgeAuthLimits,
183
+ resetAuthLimiters,
184
+ resetTotpReplay,
185
+ } from './limiter-install';
126
186
  export type { HandleDirectory } from './login-identity';
127
187
  export { normaliseHandle } from './login-identity';
128
188
  export { type MemoryAuthAdapter, memoryAuthAdapter } from './memory-adapter';
@@ -160,6 +220,11 @@ export {
160
220
  MFA_CHALLENGE_TTL_MS,
161
221
  mfaChallengeRequired,
162
222
  } from './mfa-challenge';
223
+ export type {
224
+ PostgresTotpReplayGuard,
225
+ PostgresTotpReplayGuardOptions,
226
+ } from './mfa-replay-postgres';
227
+ export { postgresTotpReplayGuard, SQL_TOTP_REPLAY_TABLE } from './mfa-replay-postgres';
163
228
  export type { MfaSecretStore, SealMfaSecretsReport } from './mfa-secret';
164
229
  export {
165
230
  countUnsealedMfaSecrets,
@@ -178,6 +243,12 @@ export type {
178
243
  TokenEndpointAuth,
179
244
  } from './oauth';
180
245
  export { assertOAuthCallback, beginOAuth, pkcePair } from './oauth';
246
+ export type {
247
+ OAuthBearerCaller,
248
+ OAuthBearerResolverOptions,
249
+ OAuthTokenSource,
250
+ } from './oauth-bearer-resolver';
251
+ export { oauthBearerResolver } from './oauth-bearer-resolver';
181
252
  export {
182
253
  BUILTIN_OAUTH_PROVIDER_IDS,
183
254
  BUILTIN_OAUTH_PROVIDERS,
@@ -246,6 +317,25 @@ export {
246
317
  } from './oauth-registry';
247
318
  export type { AuthRouteDescriptor, OAuthLoginOptions, OAuthLoginRoutes } from './oauth-route';
248
319
  export { OAUTH_ROUTE_STATUS, oauthDenied, oauthLogin } from './oauth-route';
320
+ // Passkeys (WebAuthn): sign-in and digest-bound step-up. The browser half is `@ultimat3/auth/client`.
321
+ export type {
322
+ PasskeyPolicy,
323
+ PasskeyPolicyInput,
324
+ PasskeyRelyingParty,
325
+ } from './passkey-config';
326
+ export { DEFAULT_PASSKEY_CHALLENGE_TTL_MS } from './passkey-config';
327
+ export { verifyPasskeyEvidence } from './passkey-evidence';
328
+ export type { PasskeyLogin } from './passkey-login';
329
+ export { passkeyLogin } from './passkey-login';
330
+ export type { PasskeyStepUp, PasskeyStepUpOptions } from './passkey-step-up';
331
+ export { passkeyStepUp } from './passkey-step-up';
332
+ export type {
333
+ PasskeyAssertionResponse,
334
+ PasskeyCreationOptions,
335
+ PasskeyCredentialDescriptor,
336
+ PasskeyRegistrationResponse,
337
+ PasskeyRequestOptions,
338
+ } from './passkey-wire';
249
339
  export type {
250
340
  PasswordParams,
251
341
  PasswordPolicy,
@@ -337,6 +427,12 @@ export {
337
427
  /** A sign-out response's headers: the session cookie expired, and `Clear-Site-Data`. */
338
428
  export type { SignOutHeadersOptions } from './sign-out';
339
429
  export { SIGN_OUT_CLEAR_SITE_DATA, signOutHeaders } from './sign-out';
430
+ export type {
431
+ AuthSubjectRef,
432
+ AuthSubjectTarget,
433
+ AuthSubjectTargetOptions,
434
+ } from './subject-target';
435
+ export { AUTH_SUBJECT_COLUMNS, authSubjectTarget } from './subject-target';
340
436
 
341
437
  export {
342
438
  AUTH_TABLE_NAMES,
@@ -346,24 +442,33 @@ export {
346
442
  randomToken,
347
443
  sha256Hex,
348
444
  } from './tokens';
445
+ export type { VerificationPurposeDef } from './verification-purposes';
446
+ export { verificationPurposeNames } from './verification-purposes';
349
447
  export type {
350
448
  ConsumeVerificationInput,
449
+ DefineVerificationPurposeInput,
351
450
  IssuedVerification,
352
451
  IssueVerificationInput,
353
452
  MailSender,
354
453
  VerificationPurpose,
454
+ VerificationPurposeHandle,
355
455
  VerificationRuntime,
356
456
  } from './verify';
357
457
  export {
358
458
  consumeVerification,
359
459
  DEFAULT_VERIFICATION_TTL_MS,
460
+ defineVerificationPurpose,
461
+ emailVerification,
360
462
  issueVerification,
463
+ passwordReset,
361
464
  VERIFICATION_PURPOSES,
362
465
  VERIFICATION_TEMPLATES,
363
466
  } from './verify';
364
467
  export type {
365
468
  VerifyWorkloadTokenInput,
469
+ WorkloadCheck,
366
470
  WorkloadClaims,
367
471
  WorkloadToken,
472
+ WorkloadTokenCheck,
368
473
  } from './workload';
369
- export { verifyWorkloadToken } from './workload';
474
+ export { checkWorkloadToken, verifyWorkloadToken } from './workload';
package/src/jwks.ts CHANGED
@@ -10,11 +10,13 @@ import type { Clock, Scheduler } from '@ultimat3/core';
10
10
  import {
11
11
  generationFence,
12
12
  isFixShellSafe,
13
+ logger,
13
14
  renderFixShellArg,
14
15
  renderThrowable,
15
16
  singleFlight,
16
17
  systemClock,
17
18
  } from '@ultimat3/core';
19
+ import { outboundResponse } from '@ultimat3/core/outbound';
18
20
  import { decodeJwtSegment, isRecord } from './json';
19
21
  import type { OAuthProvider } from './oauth';
20
22
  import { oauthExchangeFailed, oauthTokenInvalid } from './oauth-errors';
@@ -51,6 +53,16 @@ export type IdTokenKeys = 'token-endpoint-tls' | JwksKeySource;
51
53
  /** Short enough that a rotated key set is picked up on its own; a new `kid` refreshes early. */
52
54
  export const DEFAULT_JWKS_TTL_MS = 10 * 60 * 1000;
53
55
 
56
+ /**
57
+ * How long past its TTL a key already held still answers while the key set cannot be read. An IdP
58
+ * outage otherwise ends every login with the verifying key in memory. Past it, the refusal: a key
59
+ * may have been withdrawn during the outage, and nothing here can tell.
60
+ */
61
+ export const JWKS_STALE_IF_ERROR_MS = 60 * 60 * 1000;
62
+
63
+ /** No fetch this soon after a failed one, on either path: an outage costs no request per token. */
64
+ export const JWKS_FAILURE_COOLDOWN_MS = 30 * 1000;
65
+
54
66
  /** One client is one key set, so the flight has exactly one key and its name is never read. */
55
67
  const REFRESH_KEY = 'jwks';
56
68
 
@@ -110,12 +122,16 @@ const algorithmOf = (jwk: Record<string, unknown>): JwtAlgorithm | null => {
110
122
  return null;
111
123
  };
112
124
 
125
+ /** Where a key published with no `kid` is filed: no name a token could mean starts with NUL. */
126
+ const KID_LESS = '\u0000';
127
+
113
128
  /**
114
129
  * Caches by `kid` with a TTL, and refetches once when a token names a `kid` the cache has never
115
130
  * seen — which is what makes a key rotation heal itself instead of failing every login until the
116
131
  * TTL runs out. One refetch, not one per token: an unknown `kid` an attacker invents must not
117
132
  * become an unbounded outbound request per attempt, so the refresh is rate-limited by the same
118
- * TTL as the ordinary one.
133
+ * TTL as the ordinary one. A failed fetch is rated too (`JWKS_FAILURE_COOLDOWN_MS`), and leaves a
134
+ * key already held answering for `JWKS_STALE_IF_ERROR_MS`.
119
135
  */
120
136
  export function jwksClient(options: JwksClientOptions): JwksKeySource {
121
137
  const clock = options.clock ?? systemClock;
@@ -139,6 +155,9 @@ export function jwksClient(options: JwksClientOptions): JwksKeySource {
139
155
  // is reset by every fetch, ordinary ones included — so gating the early refresh on it would let
140
156
  // an attacker's own refresh authorise the next one.
141
157
  let lastMissRefreshMs = Number.NEGATIVE_INFINITY;
158
+ // The last failed fetch, kept so a caller inside the cooldown gets its refusal without a request.
159
+ let lastFailure: { readonly atMs: number; readonly error: unknown } | null = null;
160
+ let staleAnnounced = false;
142
161
  // Two refreshes can overlap once the deadline below can evict one, and BOTH end by installing
143
162
  // what they read. Without this, the later-settling of the two wins — so a refresh the client
144
163
  // already gave up on could drop a pre-rotation key set on top of the live one, and every login
@@ -165,14 +184,27 @@ export function jwksClient(options: JwksClientOptions): JwksKeySource {
165
184
 
166
185
  const fetchKeys = async (): Promise<Map<string, CryptoKey>> => {
167
186
  const issued = fence.bump();
168
- const doFetch: OAuthFetch = options.fetch ?? ((input, init) => globalThis.fetch(input, init));
187
+ const doFetch: OAuthFetch =
188
+ options.fetch ??
189
+ ((input, init) => globalThis.fetch(input, { ...init, signal: init.signal ?? null }));
169
190
  let response: Response;
170
191
  try {
171
- response = await doFetch(options.jwksUri, {
172
- method: 'GET',
173
- headers: { Accept: 'application/json' },
174
- signal: AbortSignal.timeout(timeoutMs),
175
- });
192
+ // Through core's `outbound()`: `timeoutMs` bounds the whole call, the ambient request's signal
193
+ // ends it, a GET's 5xx is retried inside that bound (a POST never is), a 3xx is never followed.
194
+ response = await outboundResponse(
195
+ {
196
+ url: options.jwksUri,
197
+ vendor: options.provider,
198
+ dial: 'named',
199
+ deadlineMs: timeoutMs,
200
+ // One attempt: this file's own fence and cooldown are its retry discipline (one fetch per
201
+ // outage), and a login waiting on a key must not wait out a backoff.
202
+ retry: { attempts: 1, base: 0, max: 0, jitter: 'none' },
203
+ method: 'GET',
204
+ headers: { Accept: 'application/json' },
205
+ },
206
+ { fetch: doFetch },
207
+ );
176
208
  } catch (error) {
177
209
  throw oauthExchangeFailed({
178
210
  provider: options.provider,
@@ -199,13 +231,16 @@ export function jwksClient(options: JwksClientOptions): JwksKeySource {
199
231
  if (!isRecord(entry)) continue;
200
232
  const alg = algorithmOf(entry);
201
233
  const kid = entry['kid'];
202
- if (alg === null || typeof kid !== 'string' || kid === '') continue;
234
+ const named = typeof kid === 'string' && kid !== '';
235
+ // RFC 7517: `kid` is optional. An absent one is filed by position, so two such keys stay two
236
+ // and `lookup` sees the ambiguity; a present one that is no name is a malformed entry.
237
+ if (alg === null || (kid !== undefined && !named)) continue;
203
238
  // A key the runtime cannot import is skipped rather than fatal: one malformed entry in a
204
239
  // published set must not take down every login the other keys would still have verified.
205
240
  const key = await crypto.subtle
206
241
  .importKey('jwk', entry as JsonWebKey, importParams(alg), false, ['verify'])
207
242
  .catch(() => null);
208
- if (key !== null) next.set(`${kid}:${alg}`, key);
243
+ if (key !== null) next.set(`${named ? kid : `${KID_LESS}${next.size}`}:${alg}`, key);
209
244
  }
210
245
  // A 200 with nothing importable in it is a failed fetch, not a rotation to zero keys. Adopted,
211
246
  // it replaced a working cache with an empty one stamped fresh for a whole TTL.
@@ -244,7 +279,9 @@ export function jwksClient(options: JwksClientOptions): JwksKeySource {
244
279
  await flight.run(REFRESH_KEY, fetchKeys);
245
280
 
246
281
  const lookup = (current: Map<string, CryptoKey>, kid: string | null, alg: JwtAlgorithm) => {
247
- if (kid !== null) return current.get(`${kid}:${alg}`) ?? null;
282
+ // A token naming a `kid` needs that key: its text is the caller's, so it never spells a filing.
283
+ if (kid !== null)
284
+ return kid.startsWith(KID_LESS) ? null : (current.get(`${kid}:${alg}`) ?? null);
248
285
  // No `kid`: unambiguous only when the set holds exactly one key for this algorithm.
249
286
  const matches = [...current.entries()].filter(([id]) => id.endsWith(`:${alg}`));
250
287
  return matches.length === 1 ? (matches[0]?.[1] ?? null) : null;
@@ -265,10 +302,42 @@ export function jwksClient(options: JwksClientOptions): JwksKeySource {
265
302
  // early refresh is rate-limited by the same TTL as the ordinary one — which is what the
266
303
  // docstring above always promised. Set BEFORE the await so concurrent callers that pass the
267
304
  // gate together still coalesce into the one shared refresh.
268
- const earlyRefresh = !known && !stale && nowMs >= lastMissRefreshMs + ttlMs;
305
+ const cooling = lastFailure !== null && nowMs < lastFailure.atMs + JWKS_FAILURE_COOLDOWN_MS;
306
+ const earlyRefresh = !known && !stale && !cooling && nowMs >= lastMissRefreshMs + ttlMs;
307
+ const missGateWas = lastMissRefreshMs;
269
308
  if (earlyRefresh) lastMissRefreshMs = nowMs;
270
- if (stale || earlyRefresh) current = await load();
309
+ let failure = cooling ? lastFailure : null;
310
+ if (!cooling && (stale || earlyRefresh)) {
311
+ try {
312
+ current = await load();
313
+ lastFailure = null;
314
+ } catch (error) {
315
+ // Every joiner of one failed fetch lands here holding the same error: one announcement.
316
+ if (lastFailure?.error !== error) staleAnnounced = false;
317
+ failure = { atMs: clock.now().getTime(), error };
318
+ lastFailure = failure;
319
+ // A fetch that read nothing proved nothing about this `kid`. The cooldown rates failures,
320
+ // so the gate is given back, or a rotated key misses for a whole TTL after the issuer heals.
321
+ if (earlyRefresh) lastMissRefreshMs = missGateWas;
322
+ }
323
+ }
271
324
  const key = lookup(current, kid, alg);
325
+ if (failure !== null) {
326
+ // Measured from the set's own age, so a flapping issuer cannot extend the window by failing
327
+ // again: a key this old is refused however recently the last fetch was tried.
328
+ const usable =
329
+ key !== null && (!stale || nowMs < fetchedAtMs + ttlMs + JWKS_STALE_IF_ERROR_MS);
330
+ if (!usable) throw failure.error;
331
+ // Once per failed fetch, not per token: an outage at login volume is one line per cooldown.
332
+ if (stale && !staleAnnounced) {
333
+ staleAnnounced = true;
334
+ logger.warn('auth.jwks.stale_served', {
335
+ provider: options.provider,
336
+ staleForMs: nowMs - (fetchedAtMs + ttlMs),
337
+ });
338
+ }
339
+ return key;
340
+ }
272
341
  if (key === null) {
273
342
  throw oauthTokenInvalid(
274
343
  options.provider,
@@ -0,0 +1,80 @@
1
+ // Single responsibility: an external issuer's bearer JWT → who is calling, in the `resolveToken`
2
+ // shape a bearer mount and an MCP endpoint take. `apiKeyResolver`'s twin for tokens: the checks are
3
+ // `verifyWorkloadToken`'s, and every refusal — a key set that cannot be read included — is `null`
4
+ // plus one log line naming the check, never a claim's value and never the token.
5
+
6
+ import { type Clock, logger, systemClock } from '@ultimat3/core';
7
+ import type { JwksKeySource } from './jwks';
8
+ import type { PolicyActor } from './policy-bridge';
9
+ import { checkWorkloadToken, type WorkloadCheck, type WorkloadToken } from './workload';
10
+
11
+ /** Structurally `ApiKeyCaller`: http's `BearerCaller` and mcp's `ResolvedToken`. */
12
+ export interface JwtBearerCaller {
13
+ readonly actor: PolicyActor;
14
+ /** The scopes the token carries (`scope` or `scp`). The mount's scope map is cut by these. */
15
+ readonly scopes: ReadonlySet<string>;
16
+ }
17
+
18
+ export interface JwtBearerResolverOptions {
19
+ /**
20
+ * A THUNK, read when a token arrives: discovery opens no network at import, and a mount is
21
+ * declared before boot. Hold the client and return it — a new one per call has an empty cache.
22
+ */
23
+ readonly keys: () => JwksKeySource | Promise<JwksKeySource>;
24
+ /** Every issuer accepted. A list, never a wildcard. */
25
+ readonly issuers: readonly string[];
26
+ /** This app's own audience. */
27
+ readonly audience: string;
28
+ /** A scope the token must carry, when the mount needs one. The name is the app's. */
29
+ readonly requiredScope?: string | undefined;
30
+ /** The app's own claim check, after the signature held (`tid`, `azp`, `groups`). */
31
+ readonly accept?: ((raw: Readonly<Record<string, unknown>>) => boolean) | undefined;
32
+ /** Builds the actor. Roles and org are the app's decision, never guessed from a claim here. */
33
+ readonly actorOf: (token: WorkloadToken) => PolicyActor;
34
+ readonly clock?: Clock | undefined;
35
+ }
36
+
37
+ /** Every check a refusal can name: the token's own, then the three this resolver adds. */
38
+ export type JwtBearerCheck = WorkloadCheck | 'key-set' | 'scope' | 'accept';
39
+
40
+ const refuse = (check: JwtBearerCheck): null => {
41
+ // The check's NAME only: a claim's value is the caller's text, and the token is a credential.
42
+ logger.warn('auth.jwt_bearer.refused', { check });
43
+ return null;
44
+ };
45
+
46
+ /**
47
+ * `resolveToken: jwtBearerResolver({ keys, issuers, audience, actorOf })` on a bearer mount or
48
+ * `defineAppMcp()`. A thrown `accept` or `actorOf` is the app's bug and propagates; a key source
49
+ * that throws or rejects — discovery down, an unknown `kid` — fails closed as `null`.
50
+ */
51
+ export function jwtBearerResolver(
52
+ options: JwtBearerResolverOptions,
53
+ ): (token: string) => Promise<JwtBearerCaller | null> {
54
+ return async (token) => {
55
+ let checked: Awaited<ReturnType<typeof checkWorkloadToken>>;
56
+ try {
57
+ checked = await checkWorkloadToken({
58
+ token,
59
+ keys: await options.keys(),
60
+ issuers: options.issuers,
61
+ audience: options.audience,
62
+ clock: options.clock ?? systemClock,
63
+ });
64
+ } catch {
65
+ // Deliberately unrendered: the throw may quote the issuer's response, and an unreadable key
66
+ // set and an unknown `kid` must read the same to whoever is probing.
67
+ return refuse('key-set');
68
+ }
69
+ if (!checked.ok) return refuse(checked.check);
70
+ const verified = checked.token;
71
+ if (
72
+ options.requiredScope !== undefined &&
73
+ !verified.claims.scopes.includes(options.requiredScope)
74
+ ) {
75
+ return refuse('scope');
76
+ }
77
+ if (options.accept !== undefined && !options.accept(verified.raw)) return refuse('accept');
78
+ return { actor: options.actorOf(verified), scopes: new Set(verified.claims.scopes) };
79
+ };
80
+ }
@@ -1,5 +1,6 @@
1
- // Single responsibility: the ONE ambient install point for where failed credential attempts are
2
- // counted, plus the purge over whatever it built.
1
+ // Single responsibility: the ONE ambient install point for the credential state a fleet has to
2
+ // share — where failed attempts are counted, with the purge over whatever it built, and where spent
3
+ // TOTP steps are kept.
3
4
  //
4
5
  // WHY a seam and not a `defineAuth` argument: `defineAuth` is the APP's call, and the app is not
5
6
  // the thing that knows which database this process opened. A host boot resolves the pool long
@@ -14,6 +15,7 @@
14
15
  // make every app that tunes its own numbers fail at boot with `X_AUTH_LIMITER_POLICY_MISMATCH`.
15
16
  // The factory is called WITH the resolved policy, so the two halves cannot disagree.
16
17
 
18
+ import type { TotpReplayGuard } from './mfa';
17
19
  import type { AuthLimiter, AuthRateLimitPolicy } from './rate-limit';
18
20
 
19
21
  /**
@@ -110,3 +112,30 @@ export async function purgeAuthLimits(): Promise<number> {
110
112
  }
111
113
  return widest === undefined ? 0 : await widest.purgeExpired();
112
114
  }
115
+
116
+ /** Build the guard an `Auth` keeps its spent TOTP steps in. Called once per `defineAuth`. */
117
+ export type TotpReplayFactory = () => TotpReplayGuard;
118
+
119
+ let replayFactory: TotpReplayFactory | undefined;
120
+
121
+ /**
122
+ * The host's install point for where spent steps are kept, for the limiter's reason: `defineAuth`
123
+ * is the app's call and the app does not know which database this process opened. Until a boot
124
+ * filled this, one code signed in once per replica. A second install replaces the first.
125
+ */
126
+ export function configureTotpReplay(next: TotpReplayFactory): void {
127
+ replayFactory = next;
128
+ }
129
+
130
+ /** Back to `memoryTotpReplayGuard`, the per-process default. A host that installs one calls this on stop. */
131
+ export function resetTotpReplay(): void {
132
+ replayFactory = undefined;
133
+ }
134
+
135
+ /**
136
+ * `defineAuth`'s reader, deliberately NOT exported from `src/index.ts` for `installedAuthLimiter`'s
137
+ * reason. `undefined` means no host installed one, and the caller falls back to memory.
138
+ */
139
+ export function installedTotpReplay(): TotpReplayGuard | undefined {
140
+ return replayFactory?.();
141
+ }
@@ -11,12 +11,14 @@ import type {
11
11
  AuthUser,
12
12
  AuthVerification,
13
13
  CreateUserInput,
14
+ PasskeyStore,
14
15
  SessionPatch,
15
16
  StoredMfaSecret,
16
17
  UserPatch,
17
18
  UserQuery,
18
19
  } from './adapter';
19
20
  import { authUniqueViolation } from './errors';
21
+ import { memoryPasskeyStore } from './passkey-memory';
20
22
 
21
23
  const verificationKey = (purpose: string, identifier: string): string => `${purpose}:${identifier}`;
22
24
 
@@ -46,6 +48,22 @@ export class MemoryAuthAdapter implements AuthAdapter {
46
48
  readonly #accounts = new Map<string, AuthAccount>();
47
49
  readonly #verifications = new Map<string, AuthVerification>();
48
50
  readonly #apiKeys = new Map<string, AuthApiKeyRecord>();
51
+ readonly #passkeys: PasskeyStore = memoryPasskeyStore();
52
+ // The passkey members are `passkey-memory.ts`'s, bound once: closures over the store, no `this`.
53
+ readonly putPasskey: PasskeyStore['putPasskey'] = this.#passkeys.putPasskey;
54
+ readonly findPasskey: PasskeyStore['findPasskey'] = this.#passkeys.findPasskey;
55
+ readonly listPasskeys: PasskeyStore['listPasskeys'] = this.#passkeys.listPasskeys;
56
+ readonly advancePasskeyCounter: PasskeyStore['advancePasskeyCounter'] =
57
+ this.#passkeys.advancePasskeyCounter;
58
+ readonly deletePasskey: PasskeyStore['deletePasskey'] = this.#passkeys.deletePasskey;
59
+ readonly putPasskeyChallenge: PasskeyStore['putPasskeyChallenge'] =
60
+ this.#passkeys.putPasskeyChallenge;
61
+ readonly takePasskeyChallenge: PasskeyStore['takePasskeyChallenge'] =
62
+ this.#passkeys.takePasskeyChallenge;
63
+ readonly putPasskeyEvidence: PasskeyStore['putPasskeyEvidence'] =
64
+ this.#passkeys.putPasskeyEvidence;
65
+ readonly listPasskeyEvidence: PasskeyStore['listPasskeyEvidence'] =
66
+ this.#passkeys.listPasskeyEvidence;
49
67
 
50
68
  /**
51
69
  * The clock every instant this adapter stamps comes from — one argument, because a stamp is a
@@ -90,8 +90,8 @@ const TOTP_SHAPE = new RegExp(`^[0-9]{${TOTP_DIGITS}}$`);
90
90
  * The guess is RESERVED before the code is looked at, against the account, the address and the
91
91
  * tenant — the buckets a password guess spends — so six digits are worth `maxAttempts` tries per
92
92
  * window and no more, and a wrong code is followed by `X_ACCOUNT_LOCKED`. A code is good once:
93
- * a TOTP step is remembered in `auth.totpReplay`, and a recovery code is consumed by the adapter
94
- * in one atomic step, so two requests carrying one code cannot both finish.
93
+ * a TOTP step is claimed in `auth.totpReplay` and a recovery code is consumed by the adapter, each
94
+ * in one atomic step, so two requests carrying one code cannot both finish, on any replica.
95
95
  */
96
96
  export async function completeMfa(
97
97
  auth: Auth,
@@ -117,12 +117,18 @@ export async function completeMfa(
117
117
  if (TOTP_SHAPE.test(presented)) {
118
118
  const now = auth.clock.now();
119
119
  const verdict = verifyTotp({ secret, code: presented, at: now });
120
- // Checked and remembered with no `await` between them: two concurrent completions carrying
121
- // one code cannot both find the step unspent.
122
- if (!verdict.ok || verdict.step === null || auth.totpReplay.isUsed(user.id, verdict.step)) {
123
- throw mfaCodeRejected();
120
+ // Only a code that verified claims its step, so a wrong guess never writes.
121
+ if (!verdict.ok || verdict.step === null) throw mfaCodeRejected();
122
+ let claimed: boolean;
123
+ try {
124
+ claimed = await auth.totpReplay.claim(user.id, verdict.step, now);
125
+ } catch (thrown) {
126
+ // A store that is down reached no verdict, so the guess is not counted as a wrong one.
127
+ await attempt.release();
128
+ throw thrown;
124
129
  }
125
- auth.totpReplay.remember(user.id, verdict.step, now);
130
+ // `=== true`: an answer that is anything else is not evidence the step was unspent.
131
+ if (claimed !== true) throw mfaCodeRejected();
126
132
  } else {
127
133
  let consumed: boolean;
128
134
  try {
@@ -0,0 +1,71 @@
1
+ // The shared replay guard: one Postgres table, so a TOTP code spent on one replica is spent on all
2
+ // of them. A claim is one `insert … on conflict do nothing`, because a read and a write are two
3
+ // round trips and two replicas fit between them; the primary key is what decides the race.
4
+ import type { PgExecutor } from '@ultimat3/core';
5
+ import { TOTP_DRIFT_STEPS, type TotpReplayGuard, totpStep } from './mfa';
6
+
7
+ /** Applied by the boot, never by an app migration: `SQL_AUTH_LIMIT_TABLES`' rule. */
8
+ export const SQL_TOTP_REPLAY_TABLE = `
9
+ create table if not exists x_totp_steps (
10
+ subject text not null,
11
+ step bigint not null,
12
+ used_at timestamptz not null,
13
+ primary key (subject, step)
14
+ );
15
+
16
+ create index if not exists x_totp_steps_step_idx on x_totp_steps (step);
17
+ `;
18
+
19
+ /** `$1` subject, `$2` step, `$3` the caller's clock. A row back is the first use; none, a replay. */
20
+ export const SQL_TOTP_CLAIM = `
21
+ insert into x_totp_steps (subject, step, used_at)
22
+ values ($1, $2::bigint, $3::timestamptz)
23
+ on conflict (subject, step) do nothing
24
+ returning 1 as claimed
25
+ `;
26
+
27
+ /** `$1` the lowest step still kept, computed from the CALLER's clock and never `now()`. */
28
+ export const SQL_TOTP_PURGE = `
29
+ with dropped as (
30
+ delete from x_totp_steps where step < $1::bigint returning 1
31
+ )
32
+ select count(*) as removed from dropped
33
+ `;
34
+
35
+ /**
36
+ * A minute past the drift window. `verifyTotp` reads its replica's own clock, so a replica running
37
+ * behind the sweep's still offers a step the sweep's clock has written off; a row kept a little
38
+ * longer costs nothing and a row taken early reopens a spent code.
39
+ */
40
+ const SWEEP_SLACK_STEPS = 2;
41
+
42
+ export interface PostgresTotpReplayGuardOptions {
43
+ readonly executor: PgExecutor;
44
+ }
45
+
46
+ export interface PostgresTotpReplayGuard extends TotpReplayGuard {
47
+ /**
48
+ * Drop every step no replica can offer again, and answer how many rows went. The table takes one
49
+ * row per second factor and does not bound itself; `@ultimat3/cli`'s hourly sweep calls this.
50
+ */
51
+ purgeExpired(nowMs: number): Promise<number>;
52
+ }
53
+
54
+ export function postgresTotpReplayGuard(
55
+ options: PostgresTotpReplayGuardOptions,
56
+ ): PostgresTotpReplayGuard {
57
+ const exec = options.executor;
58
+ return {
59
+ // A rejection from the executor passes through on purpose: `completeMfa` reads it as no verdict.
60
+ async claim(subject, step, at): Promise<boolean> {
61
+ const rows = await exec.query(SQL_TOTP_CLAIM, [subject, step, at.toISOString()]);
62
+ return rows.length > 0;
63
+ },
64
+
65
+ async purgeExpired(nowMs): Promise<number> {
66
+ const floor = totpStep(new Date(nowMs)) - TOTP_DRIFT_STEPS - SWEEP_SLACK_STEPS;
67
+ const rows = await exec.query<{ readonly removed: number | string }>(SQL_TOTP_PURGE, [floor]);
68
+ return Number(rows[0]?.removed ?? 0);
69
+ },
70
+ };
71
+ }