@ultimat3/auth 24.0.0 → 25.1.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.
@@ -117,13 +117,13 @@ const toApiKey = (row: Row): AuthApiKeyRecord => ({
117
117
  * assembled into dynamic SQL: `null` stays a meaningful value ("clear this field") and the
118
118
  * statement text remains constant, so the query plan is cached and nothing is interpolated.
119
119
  */
120
- export class BuiltinAdapter implements AuthAdapter {
120
+ export class PostgresAuthAdapter implements AuthAdapter {
121
121
  readonly name = 'builtin-postgres';
122
122
  readonly #db: DbClient;
123
123
  readonly #clock: Clock;
124
124
 
125
125
  /**
126
- * `clock` is the one `MemoryAdapter` takes, for the same stamp: hand over the clock
126
+ * `clock` is the one `MemoryAuthAdapter` takes, for the same stamp: hand over the clock
127
127
  * `defineAuth` was given, so a redemption is dated by the clock that judged its expiry.
128
128
  */
129
129
  constructor(client: DbClient = db(), clock: Clock = systemClock) {
@@ -237,7 +237,9 @@ export class BuiltinAdapter implements AuthAdapter {
237
237
  * One statement, and the two filters are conditional predicates rather than assembled SQL, for
238
238
  * the reason `updateUser`'s columns are: the statement text stays constant and nothing is
239
239
  * interpolated. Disabled members are excluded by default — an access review reads who can sign
240
- * in today — and `includeDisabled` is what an offboarding audit passes.
240
+ * in today — and `includeDisabled` is what an offboarding audit passes. Ordered `collate "C"`
241
+ * (byte order, which `MemoryAuthAdapter` restates): the database default collation is the
242
+ * cluster's locale, so the order changed with the deployment and disagreed with memory's.
241
243
  */
242
244
  async listUsersByOrg(orgId: string, query?: UserQuery): Promise<readonly AuthUser[]> {
243
245
  const role = query?.role ?? null;
@@ -246,7 +248,7 @@ export class BuiltinAdapter implements AuthAdapter {
246
248
  where org_id = ${orgId}
247
249
  and (${query?.includeDisabled === true} or disabled_at is null)
248
250
  and (${role === null} or ${role} = any (roles))
249
- order by email asc`);
251
+ order by email collate "C" asc`);
250
252
  return rows.map(toUser);
251
253
  }
252
254
 
@@ -422,3 +424,14 @@ export class BuiltinAdapter implements AuthAdapter {
422
424
  return changed > 0;
423
425
  }
424
426
  }
427
+
428
+ /**
429
+ * The one way to build the Postgres adapter — the twin of `memoryAuthAdapter()`. The class is a
430
+ * type in the barrel only (`X_FACTORY_NAME_SPELLING`), so `new` is never a second spelling.
431
+ */
432
+ export function postgresAuthAdapter(
433
+ client: DbClient = db(),
434
+ clock: Clock = systemClock,
435
+ ): PostgresAuthAdapter {
436
+ return new PostgresAuthAdapter(client, clock);
437
+ }
package/src/email.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  // Single responsibility: the ONE normalisation an email address gets before anything uses it as an
2
2
  // identity key — a user lookup, a user insert, or the lockout bucket that has to key the same way
3
3
  // those two do. It lives above the `AuthAdapter` seam because an adapter that normalises hides a
4
- // caller that forgot to: `MemoryAdapter` did and `BuiltinAdapter` did not, so "does this account
4
+ // caller that forgot to: `MemoryAuthAdapter` did and `PostgresAuthAdapter` did not, so "does this account
5
5
  // exist" had two answers, one under `x dev` and another in production.
6
6
 
7
7
  /**
package/src/errors.ts CHANGED
@@ -117,14 +117,14 @@ export class AuthError extends UltimateError {
117
117
  * `authenticate()` returns the anonymous actor for a missing cookie rather than throwing, which
118
118
  * is correct — and is exactly why the failure surfaces here, one layer later, instead of there.
119
119
  */
120
- export const unauthenticated = (surface: string): AuthError =>
120
+ export const authUnauthenticated = (surface: string): AuthError =>
121
121
  new AuthError({
122
122
  code: 'X_UNAUTHENTICATED',
123
123
  cause: `${surface} needs an actor but ctx.actor is anonymous`,
124
124
  fix: 'send the __Host-x_session cookie with this request, and resolve it once at the boundary: authenticate(auth, readSessionCookie(request, auth.sessions.policy))',
125
125
  });
126
126
 
127
- export const forbidden = (surface: string, reason: string): AuthError =>
127
+ export const authForbidden = (surface: string, reason: string): AuthError =>
128
128
  new AuthError({
129
129
  code: 'X_FORBIDDEN',
130
130
  cause: `${surface} denied: ${reason}`,
@@ -372,7 +372,7 @@ export const authWriteFailed = (operation: string, table: string): AuthError =>
372
372
  * the same failure to an operator — the row is not there and the caller must not assume it is —
373
373
  * and the cause says which of the two happened.
374
374
  *
375
- * `MemoryAdapter` enforced neither `x_users.email` nor `x_users.external_id`, while `BuiltinAdapter`
375
+ * `MemoryAuthAdapter` enforced neither `x_users.email` nor `x_users.external_id`, while `PostgresAuthAdapter`
376
376
  * leans on both: two `register()` calls at one address made TWO rows in memory, and the second was
377
377
  * unreachable forever because `findUserByEmail` returns the first. That adapter is what `x new`
378
378
  * scaffolds and what every test runs against, so the duplicate path was only exercised against the
package/src/guards.ts CHANGED
@@ -5,14 +5,14 @@
5
5
 
6
6
  import type { Actor } from '@ultimat3/core';
7
7
  import { isAnonymous, useContext } from '@ultimat3/core';
8
- import { unauthenticated } from './errors';
8
+ import { authUnauthenticated } from './errors';
9
9
 
10
10
  const DEFAULT_SURFACE = 'this request';
11
11
 
12
12
  /** Throws `X_UNAUTHENTICATED` when the ambient actor is anonymous. */
13
13
  export function requireActor(surface: string = DEFAULT_SURFACE): Actor {
14
14
  const { actor } = useContext();
15
- if (isAnonymous(actor)) throw unauthenticated(surface);
15
+ if (isAnonymous(actor)) throw authUnauthenticated(surface);
16
16
  return actor;
17
17
  }
18
18
 
package/src/id-token.ts CHANGED
@@ -6,13 +6,12 @@
6
6
  // default is what silently makes the second door as trusting as the first.
7
7
 
8
8
  import type { Clock } from '@ultimat3/core';
9
- import { renderCauseValue } from '@ultimat3/core';
9
+ import { renderCauseValue, timingSafeEqual } from '@ultimat3/core';
10
10
  import { decodeJwtSegment } from './json';
11
11
  import { type IdTokenKeys, verifyJwtSignature } from './jwks';
12
12
  import type { OAuthProvider, OAuthProviderId } from './oauth';
13
13
  import { oauthStateInvalid, oauthTokenInvalid, restartAt } from './oauth-errors';
14
14
  import { providerFor } from './oauth-registry';
15
- import { timingSafeEqual } from './tokens';
16
15
 
17
16
  /** The subset of OIDC claims this package acts on. Provider-specific extras are ignored. */
18
17
  export interface IdTokenClaims {
@@ -128,7 +127,7 @@ export interface VerifyIdTokenInput {
128
127
  * Required, and there is deliberately no default. `'token-endpoint-tls'` asserts this token was
129
128
  * read off a TLS response from the provider's own token endpoint — the OIDC Core 3.1.3.7 case,
130
129
  * and the only one where an unverified JWT is safe. Every other channel passes a key source:
131
- * `providerJwks(providerFor(id))`, or a `createJwksClient({ jwksUri })` of its own.
130
+ * `providerJwks(providerFor(id))`, or a `jwksClient({ jwksUri })` of its own.
132
131
  */
133
132
  readonly keys: IdTokenKeys;
134
133
  }
package/src/index.ts CHANGED
@@ -61,7 +61,7 @@ export {
61
61
  UserSchema,
62
62
  VerificationSchema,
63
63
  } from './auth';
64
- export { BuiltinAdapter } from './builtin-adapter';
64
+ export { type PostgresAuthAdapter, postgresAuthAdapter } from './builtin-adapter';
65
65
  /** The opaque per-principal id a per-request document hands the page's client store. */
66
66
  export type { ClientScopeOptions } from './client-scope';
67
67
  export { clientScopeOf } from './client-scope';
@@ -78,11 +78,12 @@ export {
78
78
  AuthError,
79
79
  accountLocked,
80
80
  apiKeyInvalid,
81
+ authForbidden,
81
82
  authLimiterNotShared,
82
83
  authLimiterPolicyMismatch,
84
+ authUnauthenticated,
83
85
  authUniqueViolation,
84
86
  authWriteFailed,
85
- forbidden,
86
87
  kdfOverloaded,
87
88
  mfaRequiredUnenforceable,
88
89
  mfaSecretInvalid,
@@ -90,7 +91,6 @@ export {
90
91
  passwordWeak,
91
92
  sessionExpired,
92
93
  sessionUnknown,
93
- unauthenticated,
94
94
  } from './errors';
95
95
  export { currentActor, requireActor } from './guards';
96
96
  export type { IdTokenClaims, VerifyIdTokenInput } from './id-token';
@@ -108,22 +108,22 @@ export type {
108
108
  JwtHeader,
109
109
  } from './jwks';
110
110
  export {
111
- createJwksClient,
112
111
  decodeJwtHeader,
112
+ jwksClient,
113
113
  providerJwks,
114
114
  verifyJwtSignature,
115
115
  } from './jwks';
116
116
  export type { KdfGate, KdfLimits } from './kdf-gate';
117
117
  export {
118
+ boundedKdfGate,
118
119
  configureKdfGate,
119
- createKdfGate,
120
120
  kdfGate,
121
121
  } from './kdf-gate';
122
122
  export type { AuthLimiterFactory } from './limiter-install';
123
123
  // `installedAuthLimiter` is deliberately absent: `defineAuth` is the one reader, and a second
124
124
  // caller building limiters out of band would be a second answer to where failures are counted.
125
125
  export { configureAuthLimiters, purgeAuthLimits, resetAuthLimiters } from './limiter-install';
126
- export { MemoryAdapter } from './memory-adapter';
126
+ export { type MemoryAuthAdapter, memoryAuthAdapter } from './memory-adapter';
127
127
  export type {
128
128
  EnrolTotpInput,
129
129
  MemoryTotpReplayGuard,
@@ -136,11 +136,11 @@ export type {
136
136
  export {
137
137
  base32Decode,
138
138
  base32Encode,
139
- createTotpReplayGuard,
140
139
  DEFAULT_MAX_TOTP_SUBJECTS,
141
140
  enrolTotp,
142
141
  generateRecoveryCodes,
143
142
  generateTotpSecret,
143
+ memoryTotpReplayGuard,
144
144
  recoveryCodeHash,
145
145
  TOTP_DIGITS,
146
146
  TOTP_DRIFT_STEPS,
@@ -174,7 +174,7 @@ export type {
174
174
  OAuthProviderId,
175
175
  PkcePair,
176
176
  } from './oauth';
177
- export { assertOAuthCallback, beginOAuth, createPkce } from './oauth';
177
+ export { assertOAuthCallback, beginOAuth, pkcePair } from './oauth';
178
178
  export {
179
179
  APPLE_PROVIDER,
180
180
  BUILTIN_OAUTH_PROVIDER_IDS,
@@ -212,6 +212,7 @@ export type {
212
212
  OAuthTokens,
213
213
  } from './oauth-exchange';
214
214
  export { exchangeOAuthCode, oauthCredentials } from './oauth-exchange';
215
+ export type { OAuthGrantContext } from './oauth-grant-context';
215
216
  export type {
216
217
  CompleteOAuthLoginInput,
217
218
  OAuthGrants,
@@ -284,11 +285,11 @@ export type {
284
285
  export {
285
286
  accountKey,
286
287
  assertAuthLimiterPolicy,
287
- createAuthLimiter,
288
288
  DEFAULT_AUTH_RATE_LIMIT,
289
289
  DEFAULT_MAX_AUTH_LIMIT_KEYS,
290
290
  ipKey,
291
291
  loginFailed,
292
+ memoryAuthLimiter,
292
293
  orgKey,
293
294
  orgRateLimit,
294
295
  } from './rate-limit';
@@ -323,6 +324,7 @@ export {
323
324
  idleSlideMs,
324
325
  listDevices,
325
326
  readSessionCookie,
327
+ remainingMaxAgeSeconds,
326
328
  revokeOtherSessions,
327
329
  revokeSession,
328
330
  rotateSession,
@@ -341,7 +343,6 @@ export {
341
343
  export {
342
344
  randomToken,
343
345
  sha256Hex,
344
- timingSafeEqual,
345
346
  } from './tokens';
346
347
  export type {
347
348
  ConsumeVerificationInput,
package/src/jwks.ts CHANGED
@@ -8,11 +8,11 @@
8
8
 
9
9
  import type { Clock, Scheduler } from '@ultimat3/core';
10
10
  import {
11
- createFence,
12
- createSingleFlight,
11
+ generationFence,
13
12
  isFixShellSafe,
14
13
  renderFixShellArg,
15
14
  renderThrowable,
15
+ singleFlight,
16
16
  systemClock,
17
17
  } from '@ultimat3/core';
18
18
  import { decodeJwtSegment, isRecord } from './json';
@@ -117,7 +117,7 @@ const algorithmOf = (jwk: Record<string, unknown>): JwtAlgorithm | null => {
117
117
  * become an unbounded outbound request per attempt, so the refresh is rate-limited by the same
118
118
  * TTL as the ordinary one.
119
119
  */
120
- export function createJwksClient(options: JwksClientOptions): JwksKeySource {
120
+ export function jwksClient(options: JwksClientOptions): JwksKeySource {
121
121
  const clock = options.clock ?? systemClock;
122
122
  const ttlMs = options.ttlMs ?? DEFAULT_JWKS_TTL_MS;
123
123
  assertFiniteAuthCount(
@@ -144,7 +144,7 @@ export function createJwksClient(options: JwksClientOptions): JwksKeySource {
144
144
  // already gave up on could drop a pre-rotation key set on top of the live one, and every login
145
145
  // against the new `kid` would start missing again. Read, never `guard`: a superseded refresh is
146
146
  // still the honest answer for the callers holding it, and only the shared cache is fenced.
147
- const fence = createFence('the published jwks key set');
147
+ const fence = generationFence('the published jwks key set');
148
148
  // All three refusals below put this URI in a COMMAND position, and it came out of the ISSUER's
149
149
  // own discovery document — `jwks_uri` is remote text, and `new URL()` keeps `$`, `(`, `)` and a
150
150
  // backtick in a path. `renderFixLiteral` is the wrong tool here, because its double quotes leave
@@ -235,7 +235,7 @@ export function createJwksClient(options: JwksClientOptions): JwksKeySource {
235
235
  // running, its own callers keep their promise, and the fence above stops its late answer from
236
236
  // landing in the cache. So the worst case is one duplicate JWKS fetch, never a failed
237
237
  // verification — which is what makes a deadline here a fix rather than a risk.
238
- const flight = createSingleFlight({
238
+ const flight = singleFlight({
239
239
  deadlineMs: timeoutMs * JWKS_DEADLINE_FACTOR,
240
240
  schedule: options.schedule,
241
241
  });
@@ -295,7 +295,7 @@ const clients = new Map<string, JwksKeySource>();
295
295
  *
296
296
  * Not cached by option identity, deliberately: `fetch` and `clock` are functions and objects, so
297
297
  * any canonical key over them either collides (two different proxies, one entry) or never hits.
298
- * A bespoke client is a bespoke client; `createJwksClient` is what it is, and its own cache still
298
+ * A bespoke client is a bespoke client; `jwksClient` is what it is, and its own cache still
299
299
  * works for as long as the caller holds it.
300
300
  */
301
301
  export function providerJwks(
@@ -312,7 +312,7 @@ export function providerJwks(
312
312
  const bespoke = options !== undefined && Object.keys(options).length > 0;
313
313
  const existing = bespoke ? undefined : clients.get(provider.id);
314
314
  if (existing !== undefined) return existing;
315
- const client = createJwksClient({ ...options, provider: provider.id, jwksUri: provider.jwksUri });
315
+ const client = jwksClient({ ...options, provider: provider.id, jwksUri: provider.jwksUri });
316
316
  if (!bespoke) clients.set(provider.id, client);
317
317
  return client;
318
318
  }
package/src/kdf-gate.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  // what argon2id costs is MEMORY — 19 MiB per hash at the OWASP floor. The only remaining backstop
7
7
  // was `http.maxInflight` (1000), i.e. roughly 19 GB of arenas queued on one box.
8
8
 
9
- import { createFlightGate } from '@ultimat3/core';
9
+ import { flightGate } from '@ultimat3/core';
10
10
  import { kdfOverloaded } from './errors';
11
11
  import { assertFiniteAuthCount } from './policy-numbers';
12
12
 
@@ -43,7 +43,7 @@ export interface KdfGate {
43
43
  * The declared return type stays `KdfGate` rather than core's `FlightGate`: `active` and `queued`
44
44
  * are observations no caller here has ever had, and widening a public signature is not a refactor.
45
45
  */
46
- export function createKdfGate(limits: KdfLimits = DEFAULT_KDF_LIMITS): KdfGate {
46
+ export function boundedKdfGate(limits: KdfLimits = DEFAULT_KDF_LIMITS): KdfGate {
47
47
  // Screened at the CONSTRUCTOR, because this pair WEDGES rather than fails: core asks
48
48
  // `active < maxConcurrent` and then `waiters.length >= maxQueued`, and both are false for `NaN`,
49
49
  // so every hash on the box parks in a queue with no bound and nothing to release it — login
@@ -66,12 +66,12 @@ export function createKdfGate(limits: KdfLimits = DEFAULT_KDF_LIMITS): KdfGate {
66
66
  '`waiters.length >= NaN` is false, so the queue this gate exists to bound has no bound at all',
67
67
  0,
68
68
  );
69
- return createFlightGate(limits, {
69
+ return flightGate(limits, {
70
70
  overflow: (state) => kdfOverloaded(state.active, state.queued),
71
71
  });
72
72
  }
73
73
 
74
- let gate = createKdfGate();
74
+ let gate = boundedKdfGate();
75
75
 
76
76
  /** The process-wide gate every `hashPassword`/`verifyPassword` passes through. */
77
77
  export const kdfGate = (): KdfGate => gate;
@@ -83,10 +83,10 @@ export const kdfGate = (): KdfGate => gate;
83
83
  * runs on a 512 MiB PaaS dyno and a 64 GiB node.
84
84
  */
85
85
  export function configureKdfGate(limits: KdfLimits): void {
86
- gate = createKdfGate(limits);
86
+ gate = boundedKdfGate(limits);
87
87
  }
88
88
 
89
89
  /** Back to the shipped defaults. Tests that call `configureKdfGate` must call this in cleanup. */
90
90
  export function resetKdfGate(): void {
91
- gate = createKdfGate();
91
+ gate = boundedKdfGate();
92
92
  }
@@ -52,7 +52,7 @@ export function configureAuthLimiters(next: AuthLimiterFactory): void {
52
52
  built = new Map();
53
53
  }
54
54
 
55
- /** Back to `createAuthLimiter`, the per-process default. A host that installs one calls this on stop. */
55
+ /** Back to `memoryAuthLimiter`, the per-process default. A host that installs one calls this on stop. */
56
56
  export function resetAuthLimiters(): void {
57
57
  factory = undefined;
58
58
  built = new Map();
@@ -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 } from '@ultimat3/core';
5
+ import { type Clock, systemClock, timingSafeEqual } from '@ultimat3/core';
6
6
  import type {
7
7
  AuthAccount,
8
8
  AuthAdapter,
@@ -17,11 +17,28 @@ import type {
17
17
  UserQuery,
18
18
  } from './adapter';
19
19
  import { authUniqueViolation } from './errors';
20
- import { timingSafeEqual } from './tokens';
21
20
 
22
21
  const verificationKey = (purpose: string, identifier: string): string => `${purpose}:${identifier}`;
23
22
 
24
- export class MemoryAdapter implements AuthAdapter {
23
+ /**
24
+ * `PostgresAuthAdapter`'s `order by email collate "C"`: byte order over UTF-8, which is CODE-POINT order
25
+ * — never `localeCompare` (the machine's locale) and never `<` on strings, whose UTF-16 code units
26
+ * put an astral character (a lead surrogate, 0xD8xx) ahead of U+E000–U+FFFF.
27
+ */
28
+ const byCodePoint = (a: string, b: string): number => {
29
+ const left = a[Symbol.iterator]();
30
+ const right = b[Symbol.iterator]();
31
+ for (;;) {
32
+ const x = left.next();
33
+ const y = right.next();
34
+ if (x.done === true || y.done === true)
35
+ return (x.done === true ? 0 : 1) - (y.done === true ? 0 : 1);
36
+ const delta = (x.value.codePointAt(0) ?? 0) - (y.value.codePointAt(0) ?? 0);
37
+ if (delta !== 0) return delta;
38
+ }
39
+ };
40
+
41
+ export class MemoryAuthAdapter implements AuthAdapter {
25
42
  readonly name = 'memory';
26
43
  readonly #clock: Clock;
27
44
  readonly #users = new Map<string, AuthUser>();
@@ -33,14 +50,14 @@ export class MemoryAdapter implements AuthAdapter {
33
50
  /**
34
51
  * The clock every instant this adapter stamps comes from — one argument, because a stamp is a
35
52
  * fact about WHEN a call happened and a test that cannot move it can only assert a range.
36
- * Defaults to `systemClock`, so `new MemoryAdapter()` is what it always was.
53
+ * Defaults to `systemClock`, so `memoryAuthAdapter()` is what it always was.
37
54
  */
38
55
  constructor(clock: Clock = systemClock) {
39
56
  this.#clock = clock;
40
57
  }
41
58
 
42
59
  /**
43
- * Exact match, because `BuiltinAdapter` issues `where email = $1` against a plain `text ...
60
+ * Exact match, because `PostgresAuthAdapter` issues `where email = $1` against a plain `text ...
44
61
  * unique` column and nothing folds case there. Normalising here instead made this the ONE
45
62
  * adapter that found an account Postgres would not, which is a linked account under `x dev` and
46
63
  * a duplicate one in production. `normaliseEmail` is the caller's, above the seam.
@@ -57,7 +74,7 @@ export class MemoryAdapter implements AuthAdapter {
57
74
  }
58
75
 
59
76
  /**
60
- * The two UNIQUE constraints `x_users` declares, enforced here because `BuiltinAdapter` LEANS on
77
+ * The two UNIQUE constraints `x_users` declares, enforced here because `PostgresAuthAdapter` LEANS on
61
78
  * them: `email text not null unique` and `external_id text unique` (`tables.ts`). Without them
62
79
  * this adapter — the one `x new` scaffolds and every test runs against — accepted two rows at one
63
80
  * address, and the second was unreachable forever, since `findUserByEmail` returns the first.
@@ -178,7 +195,7 @@ export class MemoryAdapter implements AuthAdapter {
178
195
  .filter((user) => user.orgId === orgId)
179
196
  .filter((user) => query?.includeDisabled === true || user.disabledAt === null)
180
197
  .filter((user) => query?.role === undefined || user.roles.includes(query.role))
181
- .sort((a, b) => a.email.localeCompare(b.email));
198
+ .sort((a, b) => byCodePoint(a.email, b.email));
182
199
  }
183
200
 
184
201
  async getSession(id: string): Promise<AuthSession | null> {
@@ -330,3 +347,11 @@ export class MemoryAdapter implements AuthAdapter {
330
347
  return true;
331
348
  }
332
349
  }
350
+
351
+ /**
352
+ * The one way to build the in-memory adapter — the twin of `postgresAuthAdapter()`. The class is a
353
+ * type in the barrel only (`X_FACTORY_NAME_SPELLING`), so `new` is never a second spelling.
354
+ */
355
+ export function memoryAuthAdapter(clock: Clock = systemClock): MemoryAuthAdapter {
356
+ return new MemoryAuthAdapter(clock);
357
+ }
package/src/mfa.ts CHANGED
@@ -4,11 +4,11 @@
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 } from '@ultimat3/core';
7
+ import { finiteCount, timingSafeEqual } from '@ultimat3/core';
8
8
  import type { Auth } from './auth';
9
9
  import { mfaSecretInvalid } from './errors';
10
10
  import { assertFiniteAuthCount } from './policy-numbers';
11
- import { randomBytes, sha256Hex, timingSafeEqual } from './tokens';
11
+ import { randomBytes, sha256Hex } from './tokens';
12
12
 
13
13
  const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
14
14
 
@@ -196,7 +196,7 @@ export interface TotpReplayGuard {
196
196
  remember(subject: string, step: number, at: Date): void;
197
197
  }
198
198
 
199
- /** What `createTotpReplayGuard` returns: the interface, plus the bound it keeps, observable. */
199
+ /** What `memoryTotpReplayGuard` returns: the interface, plus the bound it keeps, observable. */
200
200
  export interface MemoryTotpReplayGuard extends TotpReplayGuard {
201
201
  readonly size: number;
202
202
  }
@@ -245,9 +245,9 @@ const newestStep = (steps: ReadonlySet<number>): number => {
245
245
  * is *forgotten*, not evicted: `verifyTotp` only ever offers a step within ±drift of now, so that
246
246
  * entry answers exactly as a missing one and dropping it changes no decision. Only if forgetting
247
247
  * is not enough does the cap evict live state, furthest from the live window first — the shape
248
- * `createAuthLimiter` evicts by, where a live lockout is the last bucket to go.
248
+ * `memoryAuthLimiter` evicts by, where a live lockout is the last bucket to go.
249
249
  */
250
- export function createTotpReplayGuard(
250
+ export function memoryTotpReplayGuard(
251
251
  drift: number = TOTP_DRIFT_STEPS,
252
252
  maxSubjects: number = DEFAULT_MAX_TOTP_SUBJECTS,
253
253
  ): MemoryTotpReplayGuard {
@@ -6,13 +6,19 @@
6
6
  // format at one trust level.
7
7
 
8
8
  import type { Clock } from '@ultimat3/core';
9
- import { EnvMissingError, readCookie, systemClock } from '@ultimat3/core';
9
+ import {
10
+ EnvMissingError,
11
+ readCookie,
12
+ serializeSetCookie,
13
+ systemClock,
14
+ timingSafeEqual,
15
+ } from '@ultimat3/core';
10
16
  import type { OAuthHandshake, OAuthProviderId } from './oauth';
11
17
  import { oauthStateInvalid } from './oauth-errors';
12
18
  import { hasOAuthProvider } from './oauth-registry';
13
19
  import { assertFiniteAuthCount } from './policy-numbers';
14
20
  import type { RequestLike } from './session';
15
- import { base64Url, timingSafeEqual } from './tokens';
21
+ import { base64Url } from './tokens';
16
22
 
17
23
  /** `__Host-` for the same reason the session cookie carries it: no subdomain can plant one. */
18
24
  export const OAUTH_HANDSHAKE_COOKIE_PREFIX = '__Host-x_oauth';
@@ -189,7 +195,7 @@ export function handshakeCookie(
189
195
  const maxAge = Math.floor(handshakeTtlMs(options) / 1000);
190
196
  // The handshake already names its provider, so the two legs cannot disagree about the name.
191
197
  const name = options?.name ?? handshakeCookieName(handshake.provider);
192
- return `${name}=${sealHandshake(handshake, options)}; Path=/; Max-Age=${maxAge}; HttpOnly; Secure; SameSite=Lax`;
198
+ return serializeSetCookie(name, sealHandshake(handshake, options), { maxAge });
193
199
  }
194
200
 
195
201
  /**
@@ -200,7 +206,7 @@ export function handshakeCookie(
200
206
  * clear nothing, and clearing every provider's would cancel a login running in another tab.
201
207
  */
202
208
  export function clearHandshakeCookie(provider: OAuthProviderId, name?: string): string {
203
- return `${name ?? handshakeCookieName(provider)}=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Lax`;
209
+ return serializeSetCookie(name ?? handshakeCookieName(provider), '', { maxAge: 0 });
204
210
  }
205
211
 
206
212
  /** Reads the cookie set on the redirect and returns the handshake `completeOAuthLogin` takes. */
@@ -0,0 +1,106 @@
1
+ // Single responsibility: what `resolveGrants` may read besides the profile (#670). An IdP is free
2
+ // to put authorization facts — groups, entitlements, `allowed_oauth_apps` — on `/userinfo` only,
3
+ // or in an id-token claim `OAuthProfile` does not name; a seam that sees the profile alone refuses
4
+ // a correctly granted user. The token set, the whole verified payload, and a lazy userinfo read.
5
+
6
+ import { renderFixLiteral } from '@ultimat3/core';
7
+ import { decodeJwtSegment } from './json';
8
+ import type { OAuthProviderId } from './oauth';
9
+ import { oauthExchangeFailed } from './oauth-errors';
10
+ import type { OAuthTokens } from './oauth-exchange';
11
+ import { oauthStartPath } from './oauth-paths';
12
+ import {
13
+ type OAuthProfile,
14
+ type OAuthProfileOptions,
15
+ userinfoAccountId,
16
+ userinfoBody,
17
+ } from './oauth-profile';
18
+ import { providerFor } from './oauth-registry';
19
+
20
+ /** The second argument of `resolveGrants`. Read-only, and alive for one callback only. */
21
+ export interface OAuthGrantContext {
22
+ readonly provider: OAuthProviderId;
23
+ /**
24
+ * The exchange's token set, in flight. The framework persists none of it (`accountFor` writes
25
+ * `null`), so an app that keeps a token past this call has chosen to store a credential.
26
+ */
27
+ readonly tokens: OAuthTokens;
28
+ /**
29
+ * Every claim of the id token the exchange VERIFIED — custom ones (`hd`, `groups`) included,
30
+ * which `tokens.claims` drops. `null` when the provider issued no id token.
31
+ */
32
+ readonly idTokenClaims: Readonly<Record<string, unknown>> | null;
33
+ /**
34
+ * The provider's userinfo answer, every claim, read with the access token. At most one request
35
+ * per login, however often it is asked: none at all when the profile was already read off
36
+ * userinfo (GitHub), whose body is handed back; a failed read is not remembered.
37
+ * A body naming another subject is `X_OAUTH_EXCHANGE_FAILED` — the same rule `oauthProfile`
38
+ * applies, so a grant can never be read off somebody else's identity.
39
+ */
40
+ userinfo(): Promise<Readonly<Record<string, unknown>>>;
41
+ }
42
+
43
+ /** The verified token's payload, whole. Its signature, `iss`, `aud` and `exp` were checked already. */
44
+ function fullPayload(tokens: OAuthTokens): Readonly<Record<string, unknown>> | null {
45
+ if (tokens.claims === null || tokens.idToken === null) return null;
46
+ const payload = decodeJwtSegment(tokens.idToken.split('.')[1] ?? '');
47
+ return payload === null ? null : Object.freeze(payload);
48
+ }
49
+
50
+ async function readUserinfo(
51
+ profile: OAuthProfile,
52
+ tokens: OAuthTokens,
53
+ options: OAuthProfileOptions,
54
+ ): Promise<Readonly<Record<string, unknown>>> {
55
+ const { provider } = profile;
56
+ // Asked before `userinfoBody`, whose own refusal for this case speaks of a missing identity:
57
+ // here the identity is proven, and what is missing is the endpoint the app's rule reads.
58
+ if (providerFor(provider).userInfoUrl === null) {
59
+ throw oauthExchangeFailed({
60
+ provider,
61
+ stage: 'userinfo',
62
+ detail: `${provider} publishes no userinfo endpoint, so resolveGrants cannot read one`,
63
+ fix: `resolveGrants: (profile, { idTokenClaims }) => grantsFrom(idTokenClaims) # ${provider} puts its claims in the id token, never behind context.userinfo()`,
64
+ });
65
+ }
66
+ const body = await userinfoBody(provider, tokens, options);
67
+ if (userinfoAccountId(body) !== profile.providerAccountId) {
68
+ throw oauthExchangeFailed({
69
+ provider,
70
+ stage: 'userinfo',
71
+ detail: 'the userinfo subject is not the subject of the identity being signed in',
72
+ fix: `registerOAuthProvider({ id: ${renderFixLiteral(provider, '<id>')}, userInfoUrl: '<the provider's own userinfo endpoint>', … }) # a proxy that rewrites sub is refused; then restart at ${oauthStartPath(provider)}`,
73
+ });
74
+ }
75
+ return Object.freeze(body);
76
+ }
77
+
78
+ /**
79
+ * Built once per callback, after the profile is proven and before `resolveGrants` runs. `fetched`
80
+ * is the userinfo body the profile read already made, if it made one — its subject IS the profile's.
81
+ */
82
+ export function oauthGrantContext(
83
+ profile: OAuthProfile,
84
+ tokens: OAuthTokens,
85
+ options: OAuthProfileOptions,
86
+ fetched: Record<string, unknown> | null = null,
87
+ ): OAuthGrantContext {
88
+ let pending: Promise<Readonly<Record<string, unknown>>> | undefined =
89
+ fetched === null ? undefined : Promise.resolve(Object.freeze(fetched));
90
+ return Object.freeze({
91
+ provider: profile.provider,
92
+ tokens,
93
+ idTokenClaims: fullPayload(tokens),
94
+ userinfo() {
95
+ if (pending === undefined) {
96
+ const read = readUserinfo(profile, tokens, options);
97
+ pending = read;
98
+ // Forget a failure, so a seam that retries asks the provider again rather than the cache.
99
+ read.catch(() => {
100
+ if (pending === read) pending = undefined;
101
+ });
102
+ }
103
+ return pending;
104
+ },
105
+ });
106
+ }