@ultimat3/auth 30.8.0 → 31.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 (46) hide show
  1. package/CLAUDE.md +55 -109
  2. package/README.md +100 -25
  3. package/package.json +5 -4
  4. package/src/adapter-subpath.ts +34 -0
  5. package/src/api-keys.ts +14 -1
  6. package/src/auth.ts +3 -3
  7. package/src/authorization-server-clients.ts +19 -1
  8. package/src/authorization-server-config.ts +13 -4
  9. package/src/authorization-server-installed.ts +34 -0
  10. package/src/authorization-server-postgres.ts +4 -2
  11. package/src/authorization-server-store.ts +14 -4
  12. package/src/authorization-server.ts +30 -10
  13. package/src/client-scope.ts +1 -1
  14. package/src/errors.ts +9 -1
  15. package/src/grant-authorization-code.ts +21 -16
  16. package/src/grant-device.ts +2 -2
  17. package/src/identity-provider-cookie.ts +89 -0
  18. package/src/identity-provider-oidc.ts +142 -0
  19. package/src/identity-provider-route-core.ts +253 -0
  20. package/src/identity-provider-routes.ts +78 -0
  21. package/src/identity-provider-status.ts +45 -0
  22. package/src/identity-provider.ts +112 -0
  23. package/src/index.ts +34 -54
  24. package/src/jwks.ts +2 -2
  25. package/src/kdf-gate.ts +24 -14
  26. package/src/limiter-install.ts +26 -16
  27. package/src/memory-adapter.ts +5 -5
  28. package/src/mfa.ts +1 -1
  29. package/src/oauth-discovery.ts +1 -1
  30. package/src/oauth-errors.ts +26 -2
  31. package/src/oauth-exchange.ts +2 -2
  32. package/src/oauth-paths.ts +5 -2
  33. package/src/oauth-route.ts +60 -297
  34. package/src/rate-limit.ts +7 -2
  35. package/src/saml-assertion.ts +156 -0
  36. package/src/saml-c14n.ts +118 -0
  37. package/src/saml-certificate.ts +47 -0
  38. package/src/saml-dom.ts +66 -0
  39. package/src/saml-dsig.ts +165 -0
  40. package/src/saml-email-trust.ts +49 -0
  41. package/src/saml-errors.ts +96 -0
  42. package/src/saml-metadata.ts +80 -0
  43. package/src/saml-provider.ts +179 -0
  44. package/src/saml-request.ts +51 -0
  45. package/src/saml-response.ts +131 -0
  46. package/src/saml-xml.ts +328 -0
package/src/index.ts CHANGED
@@ -36,7 +36,7 @@ export type {
36
36
  VerifiedApiKey,
37
37
  } from './api-keys';
38
38
  export {
39
- API_KEY_NAMESPACE,
39
+ API_KEY_TOUCH_MS,
40
40
  apiKeyActor,
41
41
  describeApiKey,
42
42
  directGrants,
@@ -55,19 +55,14 @@ export type {
55
55
  RegisterInput,
56
56
  } from './auth';
57
57
  export {
58
- AccountSchema,
59
58
  authenticate,
60
- DEFAULT_MFA_ISSUER,
61
59
  defineAuth,
62
60
  login,
63
61
  logout,
64
62
  register,
65
- SessionSchema,
66
- UserSchema,
67
- VerificationSchema,
68
63
  } from './auth';
69
- export type { AuthorizationServer } from './authorization-server';
70
- export { AUTH_CODE_TTL_MS, authorizationServer } from './authorization-server';
64
+ export type { AuthorizationServer, ReadRequestOptions } from './authorization-server';
65
+ export { authorizationServer } from './authorization-server';
71
66
  export type {
72
67
  RegisterClientInput,
73
68
  RegisteredClient,
@@ -80,13 +75,8 @@ export type {
80
75
  export type { PostgresAuthServerStoreOptions } from './authorization-server-postgres';
81
76
  export { postgresAuthServerStore } from './authorization-server-postgres';
82
77
  export type { ReadRequestResult } from './authorization-server-request';
83
- export { AUTH_SERVER_METADATA_WELL_KNOWN } from './authorization-server-routes';
84
78
  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';
79
+ export type { AuthServerStore } from './authorization-server-store';
90
80
  export { memoryAuthServerStore } from './authorization-server-store';
91
81
  export type {
92
82
  AuthCodeRecord,
@@ -94,11 +84,9 @@ export type {
94
84
  AuthorizationRequest,
95
85
  AuthServerClient,
96
86
  AuthServerGrant,
97
- AuthTokenKind,
98
87
  AuthTokenRecord,
99
88
  ClientAuthMethod,
100
89
  DeviceCodeRecord,
101
- DeviceStatus,
102
90
  ResolvedGrant,
103
91
  } from './authorization-server-types';
104
92
  export { DEVICE_GRANT } from './authorization-server-types';
@@ -119,21 +107,14 @@ export { normaliseEmail } from './email';
119
107
  export type { AuthErrorCode, AuthThrowCode } from './errors';
120
108
  export {
121
109
  AUTH_BORROWED_ERROR_CODES,
122
- AUTH_ERROR_CODES,
123
- AUTH_ERROR_TITLES,
124
110
  AuthError,
125
111
  accountLocked,
126
112
  apiKeyInvalid,
127
113
  authForbidden,
128
- authLimiterNotShared,
129
- authLimiterPolicyMismatch,
130
114
  authUnauthenticated,
131
115
  authUniqueViolation,
132
116
  authWriteFailed,
133
117
  kdfOverloaded,
134
- mfaRequiredUnenforceable,
135
- mfaSecretInvalid,
136
- mfaSecretUnsealed,
137
118
  passwordWeak,
138
119
  sessionExpired,
139
120
  sessionUnknown,
@@ -143,11 +124,27 @@ export type { DeviceReading } from './grant-device';
143
124
  export { currentActor, requireActor } from './guards';
144
125
  export type { IdTokenClaims, VerifyIdTokenInput } from './id-token';
145
126
  export {
146
- decodeIdToken,
147
127
  ID_TOKEN_CLOCK_SKEW_MS,
148
- idTokenEmailVerified,
149
128
  verifyIdToken,
150
129
  } from './id-token';
130
+ export type {
131
+ IdentityBeginInput,
132
+ IdentityCompleteInput,
133
+ IdentityProvider,
134
+ IdentityRedirect,
135
+ IdentitySignInOptions,
136
+ ResolveIdentityGrants,
137
+ VerifiedIdentity,
138
+ } from './identity-provider';
139
+ export { signInWithIdentity } from './identity-provider';
140
+ export type { OidcIdentityProviderConfig } from './identity-provider-oidc';
141
+ export { oidcIdentityProvider } from './identity-provider-oidc';
142
+ export type {
143
+ IdentityProviderRoutes,
144
+ IdentityProviderRoutesOptions,
145
+ } from './identity-provider-routes';
146
+ export { identityProviderRoutes } from './identity-provider-routes';
147
+ export { OAUTH_ROUTE_STATUS } from './identity-provider-status';
151
148
  export type {
152
149
  IdTokenKeys,
153
150
  JwksClientOptions,
@@ -163,26 +160,17 @@ export {
163
160
  } from './jwks';
164
161
  export type {
165
162
  JwtBearerCaller,
166
- JwtBearerCheck,
167
163
  JwtBearerResolverOptions,
168
164
  } from './jwt-bearer-resolver';
169
165
  export { jwtBearerResolver } from './jwt-bearer-resolver';
170
166
  export type { KdfGate, KdfLimits } from './kdf-gate';
171
- export {
172
- boundedKdfGate,
173
- configureKdfGate,
174
- kdfGate,
175
- } from './kdf-gate';
176
- export type { AuthLimiterFactory, TotpReplayFactory } from './limiter-install';
167
+ // `installKdfGate` / `resetKdfGate` are `@ultimat3/auth/adapter`'s: a boot widens the gate.
168
+ export { boundedKdfGate, kdfGate } from './kdf-gate';
177
169
  // `installedAuthLimiter` is deliberately absent: `defineAuth` is the one reader, and a second
178
170
  // caller building limiters out of band would be a second answer to where failures are counted.
179
- export {
180
- configureAuthLimiters,
181
- configureTotpReplay,
182
- purgeAuthLimits,
183
- resetAuthLimiters,
184
- resetTotpReplay,
185
- } from './limiter-install';
171
+ // The installers and their factory types are `@ultimat3/auth/adapter`'s, and only its: the boot
172
+ // calls them with what `apps/<app>/runtime.ts` named.
173
+ export { purgeAuthLimits } from './limiter-install';
186
174
  export type { HandleDirectory } from './login-identity';
187
175
  export { normaliseHandle } from './login-identity';
188
176
  export { type MemoryAuthAdapter, memoryAuthAdapter } from './memory-adapter';
@@ -272,10 +260,9 @@ export { discoverOAuthProvider, discoveryUrl } from './oauth-discovery';
272
260
  // from that module, below.
273
261
  export type { OAuthExchangeFailure } from './oauth-errors';
274
262
  export {
263
+ oauthDenied,
275
264
  oauthExchangeFailed,
276
265
  oauthProviderUnknown,
277
- oauthStateInvalid,
278
- oauthTokenInvalid,
279
266
  restartAt,
280
267
  } from './oauth-errors';
281
268
  export type {
@@ -295,8 +282,6 @@ export type {
295
282
  export {
296
283
  completeOAuthLogin,
297
284
  emailVerifiedNotStored,
298
- oauthAccountNotLinked,
299
- oauthLinkingDisabled,
300
285
  signInWithOAuth,
301
286
  } from './oauth-login';
302
287
  export {
@@ -310,30 +295,26 @@ export type { OAuthProfile, OAuthProfileOptions } from './oauth-profile';
310
295
  export { oauthProfile } from './oauth-profile';
311
296
  export {
312
297
  hasOAuthProvider,
313
- oauthProviderDuplicate,
314
298
  oauthProviderIds,
315
299
  providerFor,
316
300
  registerOAuthProvider,
317
301
  } from './oauth-registry';
318
302
  export type { AuthRouteDescriptor, OAuthLoginOptions, OAuthLoginRoutes } from './oauth-route';
319
- export { OAUTH_ROUTE_STATUS, oauthDenied, oauthLogin } from './oauth-route';
303
+ export { oauthLogin } from './oauth-route';
320
304
  // Passkeys (WebAuthn): sign-in and digest-bound step-up. The browser half is `@ultimat3/auth/client`.
321
305
  export type {
322
306
  PasskeyPolicy,
323
307
  PasskeyPolicyInput,
324
308
  PasskeyRelyingParty,
325
309
  } from './passkey-config';
326
- export { DEFAULT_PASSKEY_CHALLENGE_TTL_MS } from './passkey-config';
327
310
  export { verifyPasskeyEvidence } from './passkey-evidence';
328
311
  export type { PasskeyLogin } from './passkey-login';
329
312
  export { passkeyLogin } from './passkey-login';
330
313
  export type { PasskeyStepUp, PasskeyStepUpOptions } from './passkey-step-up';
331
314
  export { passkeyStepUp } from './passkey-step-up';
332
315
  export type {
333
- PasskeyAssertionResponse,
334
316
  PasskeyCreationOptions,
335
317
  PasskeyCredentialDescriptor,
336
- PasskeyRegistrationResponse,
337
318
  PasskeyRequestOptions,
338
319
  } from './passkey-wire';
339
320
  export type {
@@ -345,7 +326,6 @@ export type {
345
326
  } from './password';
346
327
  export {
347
328
  checkPasswordStrength,
348
- DEFAULT_PASSWORD_PARAMS,
349
329
  DEFAULT_PASSWORD_POLICY,
350
330
  hashPassword,
351
331
  needsRehash,
@@ -398,13 +378,16 @@ export {
398
378
  revokeSessionsCreatedBefore,
399
379
  revokeUserSessions,
400
380
  } from './revocation';
381
+ export type { SamlIdpMetadata } from './saml-metadata';
382
+ export { parseSamlMetadata } from './saml-metadata';
383
+ export type { SamlIdentityProviderConfig } from './saml-provider';
384
+ export { samlIdentityProvider } from './saml-provider';
401
385
  export type {
402
386
  CookieJar,
403
387
  CreateSessionInput,
404
388
  IssuedSession,
405
389
  RequestLike,
406
390
  SessionCookieOptions,
407
- SessionDevice,
408
391
  SessionExpiry,
409
392
  SessionPolicy,
410
393
  SessionRuntime,
@@ -414,7 +397,6 @@ export {
414
397
  createSession,
415
398
  DEFAULT_SESSION_POLICY,
416
399
  idleSlideMs,
417
- listDevices,
418
400
  readSessionCookie,
419
401
  remainingMaxAgeSeconds,
420
402
  revokeOtherSessions,
@@ -432,7 +414,7 @@ export type {
432
414
  AuthSubjectTarget,
433
415
  AuthSubjectTargetOptions,
434
416
  } from './subject-target';
435
- export { AUTH_SUBJECT_COLUMNS, authSubjectTarget } from './subject-target';
417
+ export { authSubjectTarget } from './subject-target';
436
418
 
437
419
  export {
438
420
  AUTH_TABLE_NAMES,
@@ -442,8 +424,6 @@ export {
442
424
  randomToken,
443
425
  sha256Hex,
444
426
  } from './tokens';
445
- export type { VerificationPurposeDef } from './verification-purposes';
446
- export { verificationPurposeNames } from './verification-purposes';
447
427
  export type {
448
428
  ConsumeVerificationInput,
449
429
  DefineVerificationPurposeInput,
package/src/jwks.ts CHANGED
@@ -10,11 +10,11 @@ import type { Clock, Scheduler } from '@ultimat3/core';
10
10
  import {
11
11
  generationFence,
12
12
  isFixShellSafe,
13
+ liveClock,
13
14
  logger,
14
15
  renderFixShellArg,
15
16
  renderThrowable,
16
17
  singleFlight,
17
- systemClock,
18
18
  } from '@ultimat3/core';
19
19
  import { outboundResponse } from '@ultimat3/core/outbound';
20
20
  import { decodeJwtSegment, isRecord } from './json';
@@ -134,7 +134,7 @@ const KID_LESS = '\u0000';
134
134
  * key already held answering for `JWKS_STALE_IF_ERROR_MS`.
135
135
  */
136
136
  export function jwksClient(options: JwksClientOptions): JwksKeySource {
137
- const clock = options.clock ?? systemClock;
137
+ const clock = options.clock ?? liveClock;
138
138
  const ttlMs = options.ttlMs ?? DEFAULT_JWKS_TTL_MS;
139
139
  assertFiniteAuthCount(
140
140
  'jwks.ttlMs',
package/src/kdf-gate.ts CHANGED
@@ -1,12 +1,13 @@
1
1
  // Single responsibility: bounding how many argon2 hashes this process runs at once.
2
2
  //
3
3
  // WHY it is not covered by the limiters that already exist: `rate-limit.ts`'s `ipKey(ip)` and
4
- // `@ultimat3/http`'s `auth` bucket are both keyed by SOURCE, so an attacker rotating an IPv6 /64
5
- // mints a fresh key per attempt and is never throttled by either; and both cap ATTEMPTS, while
6
- // what argon2id costs is MEMORY — 19 MiB per hash at the OWASP floor. The only remaining backstop
7
- // was `http.maxInflight` (1000), i.e. roughly 19 GB of arenas queued on one box.
4
+ // `@ultimat3/http`'s `auth` bucket are keyed by SOURCE NETWORK (an IPv6 /64 is one key), so a
5
+ // botnet of many networks still mints a fresh key per attempt; and both cap ATTEMPTS, while what
6
+ // argon2id costs is MEMORY — 19 MiB per hash at the OWASP floor. The only remaining backstop was
7
+ // `http.maxInflight` (1000), i.e. roughly 19 GB of arenas queued on one box.
8
8
 
9
9
  import { flightGate } from '@ultimat3/core';
10
+ import { adapterSlot } from '@ultimat3/core/adapter';
10
11
  import { kdfOverloaded } from './errors';
11
12
  import { assertFiniteAuthCount } from './policy-numbers';
12
13
 
@@ -71,22 +72,31 @@ export function boundedKdfGate(limits: KdfLimits = DEFAULT_KDF_LIMITS): KdfGate
71
72
  });
72
73
  }
73
74
 
74
- let gate = boundedKdfGate();
75
+ /** Core's one slot shape (`adapter-slot.ts`): installs stack, each release removes its own frame. */
76
+ const gates = /* @__PURE__ */ adapterSlot<KdfGate | undefined>(() => undefined);
77
+ /** The shipped width, built once on first use: a module import must not build a gate it may not use. */
78
+ let shipped: KdfGate | undefined;
75
79
 
76
80
  /** The process-wide gate every `hashPassword`/`verifyPassword` passes through. */
77
- export const kdfGate = (): KdfGate => gate;
81
+ export const kdfGate = (): KdfGate => {
82
+ const installed = gates.current();
83
+ if (installed !== undefined) return installed;
84
+ shipped ??= boundedKdfGate();
85
+ return shipped;
86
+ };
78
87
 
79
88
  /**
80
- * The ONE install point, the same shape as `configureCursorSigning` in `@ultimat3/core`: a box
81
- * with more memory may widen the ceiling, and a test needs a narrow one. Not a `defineAuth` key,
82
- * because this is a property of the MACHINE, not of the app's auth policy — the same app config
83
- * runs on a 512 MiB PaaS dyno and a 64 GiB node.
89
+ * The ONE install point — on `@ultimat3/auth/adapter`, never the barrel (31.0.0): a box with more
90
+ * memory may widen the ceiling, and a test needs a narrow one. Not a `defineAuth` key, because
91
+ * this is a property of the MACHINE, not of the app's auth policy — the same app config runs on a
92
+ * 512 MiB PaaS dyno and a 64 GiB node. Screened before the frame is pushed; answers the release
93
+ * that removes THIS install and no other, in any order.
84
94
  */
85
- export function configureKdfGate(limits: KdfLimits): void {
86
- gate = boundedKdfGate(limits);
95
+ export function installKdfGate(limits: KdfLimits): () => void {
96
+ return gates.install(boundedKdfGate(limits));
87
97
  }
88
98
 
89
- /** Back to the shipped defaults. Tests that call `configureKdfGate` must call this in cleanup. */
99
+ /** Back to the shipped defaults, whatever is stacked. A test seam, on `./adapter` beside the installer. */
90
100
  export function resetKdfGate(): void {
91
- gate = boundedKdfGate();
101
+ gates.reset();
92
102
  }
@@ -15,6 +15,7 @@
15
15
  // make every app that tunes its own numbers fail at boot with `X_AUTH_LIMITER_POLICY_MISMATCH`.
16
16
  // The factory is called WITH the resolved policy, so the two halves cannot disagree.
17
17
 
18
+ import { adapterSlot } from '@ultimat3/core/adapter';
18
19
  import type { TotpReplayGuard } from './mfa';
19
20
  import type { AuthLimiter, AuthRateLimitPolicy } from './rate-limit';
20
21
 
@@ -25,7 +26,8 @@ import type { AuthLimiter, AuthRateLimitPolicy } from './rate-limit';
25
26
  */
26
27
  export type AuthLimiterFactory = (policy: AuthRateLimitPolicy) => AuthLimiter;
27
28
 
28
- let factory: AuthLimiterFactory | undefined;
29
+ /** Core's one slot shape: installs stack, each release removes its own, in any order. */
30
+ const factory = /* @__PURE__ */ adapterSlot<AuthLimiterFactory | undefined>(() => undefined);
29
31
  /**
30
32
  * The limiters a purge can still reach, ONE per distinct window.
31
33
  *
@@ -42,21 +44,27 @@ let factory: AuthLimiterFactory | undefined;
42
44
  let built = new Map<number, AuthLimiter>();
43
45
 
44
46
  /**
45
- * The ONE install point, the same shape as `configureKdfGate` beside it: a host that owns the
46
- * database connection says where failed attempts are counted, and every `defineAuth` in the
47
- * process picks it up without the app declaring anything.
47
+ * The ONE install point: a host that owns the database connection says where failed attempts are
48
+ * counted, and every `defineAuth` in the process picks it up without the app declaring anything.
49
+ * On `@ultimat3/auth/adapter` only — the boot calls it with what `apps/<app>/runtime.ts` named.
48
50
  *
49
51
  * A second install replaces the first and forgets what the first built — a limiter over a pool
50
- * the previous boot has closed is not something a purge should still be sweeping through.
52
+ * the previous boot has closed is not something a purge should still be sweeping through. Answers
53
+ * the release that removes THIS install and no other, in any order, once (core's `adapterSlot`);
54
+ * the limiters built so far are forgotten with it.
51
55
  */
52
- export function configureAuthLimiters(next: AuthLimiterFactory): void {
53
- factory = next;
56
+ export function installAuthLimiters(next: AuthLimiterFactory): () => void {
57
+ const release = factory.install(next);
54
58
  built = new Map();
59
+ return () => {
60
+ release();
61
+ built = new Map();
62
+ };
55
63
  }
56
64
 
57
65
  /** Back to `memoryAuthLimiter`, the per-process default. A host that installs one calls this on stop. */
58
66
  export function resetAuthLimiters(): void {
59
- factory = undefined;
67
+ factory.reset();
60
68
  built = new Map();
61
69
  }
62
70
 
@@ -67,8 +75,9 @@ export function resetAuthLimiters(): void {
67
75
  * back to the in-memory limiter.
68
76
  */
69
77
  export function installedAuthLimiter(policy: AuthRateLimitPolicy): AuthLimiter | undefined {
70
- if (factory === undefined) return undefined;
71
- const limiter = factory(policy);
78
+ const current = factory.current();
79
+ if (current === undefined) return undefined;
80
+ const limiter = current(policy);
72
81
  if (!built.has(policy.windowMs)) built.set(policy.windowMs, limiter);
73
82
  return limiter;
74
83
  }
@@ -116,20 +125,21 @@ export async function purgeAuthLimits(): Promise<number> {
116
125
  /** Build the guard an `Auth` keeps its spent TOTP steps in. Called once per `defineAuth`. */
117
126
  export type TotpReplayFactory = () => TotpReplayGuard;
118
127
 
119
- let replayFactory: TotpReplayFactory | undefined;
128
+ const replayFactory = /* @__PURE__ */ adapterSlot<TotpReplayFactory | undefined>(() => undefined);
120
129
 
121
130
  /**
122
131
  * The host's install point for where spent steps are kept, for the limiter's reason: `defineAuth`
123
132
  * 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.
133
+ * filled this, one code signed in once per replica. A second install stacks over the first; its
134
+ * release removes THIS install and no other, in any order, once (core's `adapterSlot`).
125
135
  */
126
- export function configureTotpReplay(next: TotpReplayFactory): void {
127
- replayFactory = next;
136
+ export function installTotpReplay(next: TotpReplayFactory): () => void {
137
+ return replayFactory.install(next);
128
138
  }
129
139
 
130
140
  /** Back to `memoryTotpReplayGuard`, the per-process default. A host that installs one calls this on stop. */
131
141
  export function resetTotpReplay(): void {
132
- replayFactory = undefined;
142
+ replayFactory.reset();
133
143
  }
134
144
 
135
145
  /**
@@ -137,5 +147,5 @@ export function resetTotpReplay(): void {
137
147
  * reason. `undefined` means no host installed one, and the caller falls back to memory.
138
148
  */
139
149
  export function installedTotpReplay(): TotpReplayGuard | undefined {
140
- return replayFactory?.();
150
+ return replayFactory.current()?.();
141
151
  }
@@ -2,7 +2,7 @@
2
2
  // database exists and the one every test in this package runs against — the same interface
3
3
  // Postgres and Better Auth implement, so a flow that works here works there or the seam is wrong.
4
4
 
5
- import { type Clock, systemClock, timingSafeEqual } from '@ultimat3/core';
5
+ import { type Clock, liveClock, timingSafeEqual } from '@ultimat3/core';
6
6
  import type {
7
7
  AuthAccount,
8
8
  AuthAdapter,
@@ -68,9 +68,9 @@ export class MemoryAuthAdapter implements AuthAdapter {
68
68
  /**
69
69
  * The clock every instant this adapter stamps comes from — one argument, because a stamp is a
70
70
  * fact about WHEN a call happened and a test that cannot move it can only assert a range.
71
- * Defaults to `systemClock`, so `memoryAuthAdapter()` is what it always was.
71
+ * Defaults to the `clock` port (`liveClock`, read per call), so `installClock` reaches it.
72
72
  */
73
- constructor(clock: Clock = systemClock) {
73
+ constructor(clock: Clock = liveClock) {
74
74
  this.#clock = clock;
75
75
  }
76
76
 
@@ -98,7 +98,7 @@ export class MemoryAuthAdapter implements AuthAdapter {
98
98
  * address, and the second was unreachable forever, since `findUserByEmail` returns the first.
99
99
  *
100
100
  * Over the STORED string, exactly as Postgres compares it. No case folding: that is the
101
- * divergence `adapter-parity.test.ts`'s first case pins, and `normaliseEmail` above the seam is
101
+ * divergence `authAdapterConformance`'s first case pins, and `normaliseEmail` above the seam is
102
102
  * what makes two spellings one address.
103
103
  */
104
104
  async createUser(input: CreateUserInput): Promise<AuthUser> {
@@ -370,6 +370,6 @@ export class MemoryAuthAdapter implements AuthAdapter {
370
370
  * The one way to build the in-memory adapter — the twin of `postgresAuthAdapter()`. The class is a
371
371
  * type in the barrel only (`X_FACTORY_NAME_SPELLING`), so `new` is never a second spelling.
372
372
  */
373
- export function memoryAuthAdapter(clock: Clock = systemClock): MemoryAuthAdapter {
373
+ export function memoryAuthAdapter(clock: Clock = liveClock): MemoryAuthAdapter {
374
374
  return new MemoryAuthAdapter(clock);
375
375
  }
package/src/mfa.ts CHANGED
@@ -241,7 +241,7 @@ const newestStep = (steps: ReadonlySet<number>): number => {
241
241
 
242
242
  /**
243
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`).
244
+ * replica. A framework boot installs `postgresTotpReplayGuard` instead (`installTotpReplay`).
245
245
  * Steps older than the drift window are dropped — nothing outside it can be replayed anyway.
246
246
  *
247
247
  * Bounded, because the subject map only ever grew: pruning happened inside one subject's `Set`
@@ -30,7 +30,7 @@ export const discoveryUrl = (issuer: string): string =>
30
30
  `${bareIssuer(issuer)}/.well-known/openid-configuration`;
31
31
 
32
32
  export interface DiscoverOAuthProviderInput {
33
- /** The id the URL segment carries: `/auth/oauth/<id>`. Also names the two env vars. */
33
+ /** The id the URL segment carries: `/auth/sso/<id>`. Also names the two env vars. */
34
34
  readonly id: string;
35
35
  readonly issuer: string;
36
36
  /** Defaults to the OIDC minimum this package needs to identify somebody. */
@@ -1,6 +1,6 @@
1
1
  // Single responsibility: the OAuth refusals more than one OAuth module raises, and the one phrase
2
- // every "start over" fix is built from. A refusal with one thrower lives beside it (`oauthDenied`
3
- // in `oauth-route.ts`, the linking ones in `oauth-login.ts`); the codes, titles and the single
2
+ // every "start over" fix is built from. A refusal with one thrower lives beside it (the linking ones
3
+ // in `oauth-login.ts`); the codes, titles and the single
4
4
  // `registerErrorCodes()` call stay in `errors.ts`, so this file adds no code and registers nothing.
5
5
 
6
6
  import { renderCauseValue, renderFixLiteral } from '@ultimat3/core';
@@ -96,3 +96,27 @@ export const oauthTokenInvalid = (provider: string, reason: string, fix: string)
96
96
  fix,
97
97
  meta: { provider },
98
98
  });
99
+
100
+ /**
101
+ * The provider came back with `error=` and no code — almost always the user pressing Cancel.
102
+ * A separate code from `X_OAUTH_EXCHANGE_FAILED` on purpose: nothing was exchanged, nothing is
103
+ * misconfigured, and folding the single commonest non-success outcome of a login into the code
104
+ * that means "the client secret is wrong" makes both unreadable in a log and pages the wrong person.
105
+ */
106
+ export const oauthDenied = (
107
+ provider: string,
108
+ reason: string,
109
+ description: string | null,
110
+ ): AuthError =>
111
+ new AuthError({
112
+ code: 'X_OAUTH_DENIED',
113
+ // `reason` and `description` are query parameters off the callback URL — whatever the browser
114
+ // was redirected with, newlines and quotes included. `renderCauseValue` renders them as JSON
115
+ // string literals, so a forged `error_description` cannot forge a second log line or break the
116
+ // sentence around it. Both are already `string` by type: this is escaping, not throw-safety.
117
+ cause: `${provider} declined the authorization: ${renderCauseValue(reason)}${
118
+ description === null ? '' : ` (${renderCauseValue(description)})`
119
+ }`,
120
+ fix: `${restartAt(provider)} and approve the ${provider} consent screen`,
121
+ meta: { provider, reason },
122
+ });
@@ -6,11 +6,11 @@
6
6
  import type { Clock } from '@ultimat3/core';
7
7
  import {
8
8
  EnvMissingError,
9
+ liveClock,
9
10
  logger,
10
11
  renderCauseValue,
11
12
  renderFixShellArg,
12
13
  renderThrowable,
13
- systemClock,
14
14
  } from '@ultimat3/core';
15
15
  import { outboundResponse } from '@ultimat3/core/outbound';
16
16
  import { type IdTokenClaims, verifyIdToken } from './id-token';
@@ -326,7 +326,7 @@ export async function exchangeOAuthCode(
326
326
  ): Promise<OAuthTokens> {
327
327
  assertOAuthCallback(handshake, callback);
328
328
  const provider = providerFor(handshake.provider);
329
- const clock = options.clock ?? systemClock;
329
+ const clock = options.clock ?? liveClock;
330
330
 
331
331
  const body = new URLSearchParams({
332
332
  grant_type: 'authorization_code',
@@ -4,8 +4,11 @@
4
4
  // while no route was mounted anywhere. Deriving both the mount and the fix from here makes a fix
5
5
  // line that names a route nothing serves impossible rather than merely discouraged.
6
6
 
7
- /** Not configurable, on purpose: a movable base path is a `fix:` line that can go stale again. */
8
- export const OAUTH_BASE_PATH = '/auth/oauth';
7
+ /**
8
+ * Not configurable, on purpose: a movable base path is a `fix:` line that can go stale again.
9
+ * `/auth/sso` since 31.0.0: every `IdentityProvider` — OIDC and SAML — signs in through it.
10
+ */
11
+ export const OAUTH_BASE_PATH = '/auth/sso';
9
12
 
10
13
  /** The pattern a router mounts. `:provider` is the segment `oauthStartPath` fills in. */
11
14
  export const OAUTH_START_ROUTE_PATH = `${OAUTH_BASE_PATH}/:provider` as const;