@ultimat3/auth 1.2.0 → 3.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.
package/src/rate-limit.ts CHANGED
@@ -4,68 +4,198 @@
4
4
  // bypassable. `loginFailed()` lives here so the throttle and the message can never drift apart.
5
5
 
6
6
  import type { Clock } from '@ultimat3/core';
7
- import { AuthError, accountLocked } from './errors';
7
+ import { normaliseEmail } from './email';
8
+ import {
9
+ AuthError,
10
+ accountLocked,
11
+ authLimiterNotShared,
12
+ authLimiterPolicyMismatch,
13
+ } from './errors';
14
+
15
+ /**
16
+ * Where a limiter's counters live. A limiter says which it provides; `AuthRateLimitPolicy` says
17
+ * which the deployment requires, and the two are checked against each other once, at `defineAuth`.
18
+ */
19
+ export type AuthLimiterScope = 'process' | 'shared';
20
+
21
+ /**
22
+ * Hard bound on tracked keys. A key is one identity — `account:<email>` or `ip:<addr>` — so the
23
+ * natural cardinality is lower than `@ultimat3/http`'s per-route buckets, and so is the cap.
24
+ */
25
+ export const DEFAULT_MAX_AUTH_LIMIT_KEYS = 10_000;
8
26
 
9
27
  export interface AuthRateLimitPolicy {
10
28
  /** Failures inside `windowMs` before the key is locked. */
11
29
  readonly maxAttempts: number;
12
30
  readonly windowMs: number;
13
31
  readonly lockoutMs: number;
32
+ /**
33
+ * The third bucket: failures inside `windowMs` against one TENANT, across every member and
34
+ * every source address. Per-IP buckets each grant their own quota and per-account buckets
35
+ * protect one person, so a misconfigured integration spraying one org's logins from 400
36
+ * addresses is capped by neither — it saturates the shared limiter and every other tenant's
37
+ * logins slow down behind it.
38
+ *
39
+ * A separate number and not `maxAttempts`, because a whole tenant sharing five attempts is a
40
+ * denial of service against that tenant. Defaults to `maxAttempts * ORG_ATTEMPT_FACTOR`.
41
+ */
42
+ readonly orgMaxAttempts?: number | undefined;
43
+ /** Bound on the in-memory table. Defaults to `DEFAULT_MAX_AUTH_LIMIT_KEYS`. */
44
+ readonly maxKeys?: number | undefined;
45
+ /**
46
+ * What this deployment requires of the limiter. `'shared'` says `maxAttempts` is the whole
47
+ * fleet's allowance, and a per-process limiter then refuses to build — because N replicas each
48
+ * counting their own failures let an account survive `maxAttempts × N` guesses, and a lockout
49
+ * one replica established is invisible to the rest.
50
+ */
51
+ readonly scope: AuthLimiterScope;
14
52
  }
15
53
 
54
+ /** One tenant is worth this many individuals' allowances before it is throttled as a tenant. */
55
+ export const ORG_ATTEMPT_FACTOR = 20;
56
+
16
57
  export const DEFAULT_AUTH_RATE_LIMIT: AuthRateLimitPolicy = Object.freeze({
17
58
  maxAttempts: 5,
18
59
  windowMs: 15 * 60 * 1000,
19
60
  lockoutMs: 15 * 60 * 1000,
61
+ orgMaxAttempts: 5 * ORG_ATTEMPT_FACTOR,
62
+ maxKeys: DEFAULT_MAX_AUTH_LIMIT_KEYS,
63
+ // One process is the only thing this package can promise without being told.
64
+ scope: 'process',
20
65
  });
21
66
 
67
+ /**
68
+ * Async on every member, so a shared implementation can exist at all: a lockout that holds across
69
+ * replicas is a network round trip, and a synchronous signature has no way to wait for one. The
70
+ * in-memory limiter below answers immediately — the cost is one already-settled promise per call,
71
+ * on a path that is about to run a KDF.
72
+ */
22
73
  export interface AuthLimiter {
23
- /** Throws `X_ACCOUNT_LOCKED` if the key is inside a lockout. Call before any KDF work. */
24
- assertAllowed(key: string): void;
25
- recordFailure(key: string): void;
74
+ /**
75
+ * The limits this limiter actually enforces, including where it keeps its counters. Stated
76
+ * rather than assumed, because `defineAuth` compares it against the app's declaration: a
77
+ * limiter counting to 50 under a policy that says 5 makes `Auth.rateLimit` a number the
78
+ * operator reads and nothing enforces.
79
+ */
80
+ readonly policy: AuthRateLimitPolicy;
81
+ /** Rejects with `X_ACCOUNT_LOCKED` if the key is inside a lockout. Call before any KDF work. */
82
+ assertAllowed(key: string): Promise<void>;
83
+ recordFailure(key: string): Promise<void>;
26
84
  /** A success clears the window: a legitimate user is not punished for a typo yesterday. */
27
- recordSuccess(key: string): void;
28
- lockedUntil(key: string): Date | null;
29
- reset(): void;
85
+ recordSuccess(key: string): Promise<void>;
86
+ lockedUntil(key: string): Promise<Date | null>;
87
+ reset(): Promise<void>;
30
88
  }
31
89
 
32
- export const accountKey = (email: string): string => `account:${email.trim().toLowerCase()}`;
90
+ /** What `createAuthLimiter` returns: the interface, plus the bound it keeps, observable. */
91
+ export interface MemoryAuthLimiter extends AuthLimiter {
92
+ readonly size: number;
93
+ }
94
+
95
+ /**
96
+ * The SAME normalisation the lookup uses, from the one declaration — a bucket keyed differently
97
+ * from the row it protects hands a sprayer a fresh attempt budget per spelling of one address.
98
+ */
99
+ export const accountKey = (email: string): string => `account:${normaliseEmail(email)}`;
33
100
 
34
101
  export const ipKey = (ip: string): string => `ip:${ip}`;
35
102
 
103
+ /**
104
+ * The tenant bucket's key shape, declared here even though the general noisy-neighbour case lives
105
+ * in `@ultimat3/http` and `@ultimat3/jobs`: three limiters that spell one tenant three ways cannot
106
+ * be read together during an incident.
107
+ */
108
+ export const orgKey = (orgId: string): string => `org:${orgId}`;
109
+
110
+ /**
111
+ * The policy the tenant limiter enforces — the same window and lockout, a wider allowance. One
112
+ * derivation, read by `defineAuth` when it builds the limiter and again when it checks an
113
+ * injected one, so the two cannot disagree about what was declared.
114
+ */
115
+ export function orgRateLimit(policy: AuthRateLimitPolicy): AuthRateLimitPolicy {
116
+ return {
117
+ ...policy,
118
+ maxAttempts: policy.orgMaxAttempts ?? policy.maxAttempts * ORG_ATTEMPT_FACTOR,
119
+ };
120
+ }
121
+
36
122
  interface Bucket {
37
123
  failures: number[];
38
124
  lockedUntilMs: number;
125
+ /**
126
+ * The instant this entry becomes indistinguishable from a missing one: the window has emptied
127
+ * and any lockout has expired, so it answers exactly as a first-ever attempt does.
128
+ */
129
+ forgetAtMs: number;
39
130
  }
40
131
 
132
+ /** An idle limiter still sweeps this often, so one spray's state does not sit until the next. */
133
+ const SWEEP_EVERY_MS = 60_000;
134
+
41
135
  /**
42
136
  * Sliding window over an injected `Clock` — never `Date.now()`, so a lockout test is
43
137
  * deterministic instead of a sleep. In-memory per process; a multi-process deployment passes
44
138
  * a shared implementation of the same interface.
139
+ *
140
+ * Bounded, because half the keys are attacker-chosen: `ipKey` mints one per source address, so
141
+ * a spray from an IPv6 /64 is a fresh key per attempt and an unbounded map is an OOM. Two rules
142
+ * keep it flat. An entry whose window has emptied and whose lockout has expired is *forgotten*,
143
+ * not evicted — it answers exactly as a missing one, so dropping it changes no decision. Only if
144
+ * that is not enough does the cap evict live state, and then the entries nearest to being
145
+ * forgotten anyway go first: a locked account is the last key to go, so filling the table is not
146
+ * a way to buy back attempts against one.
45
147
  */
46
148
  export function createAuthLimiter(
47
149
  clock: Clock,
48
150
  policy: AuthRateLimitPolicy = DEFAULT_AUTH_RATE_LIMIT,
49
- ): AuthLimiter {
151
+ ): MemoryAuthLimiter {
50
152
  const buckets = new Map<string, Bucket>();
153
+ const maxKeys = Math.max(1, Math.floor(policy.maxKeys ?? DEFAULT_MAX_AUTH_LIMIT_KEYS));
154
+ const evictTo = Math.max(1, Math.floor(maxKeys * 0.9));
155
+ let lastSweepMs = Number.NEGATIVE_INFINITY;
51
156
 
52
157
  const bucketFor = (key: string): Bucket => {
53
158
  const existing = buckets.get(key);
54
159
  if (existing !== undefined) return existing;
55
- const fresh: Bucket = { failures: [], lockedUntilMs: 0 };
160
+ const fresh: Bucket = { failures: [], lockedUntilMs: 0, forgetAtMs: 0 };
56
161
  buckets.set(key, fresh);
57
162
  return fresh;
58
163
  };
59
164
 
165
+ const sweep = (nowMs: number): void => {
166
+ lastSweepMs = nowMs;
167
+ for (const [key, bucket] of buckets) {
168
+ if (bucket.forgetAtMs <= nowMs) buckets.delete(key);
169
+ }
170
+ if (buckets.size <= maxKeys) return;
171
+ // Batched down to `evictTo` so this sort is paid once per 10% of the cap, not per failure.
172
+ // A live lockout outranks its deadline: two entries recorded a second apart are otherwise
173
+ // ordered by recency, which would let a spray evict the account it just locked.
174
+ const locked = (bucket: Bucket): number => (bucket.lockedUntilMs > nowMs ? 1 : 0);
175
+ const nearestForgotten = [...buckets.entries()].sort(
176
+ (a, b) => locked(a[1]) - locked(b[1]) || a[1].forgetAtMs - b[1].forgetAtMs,
177
+ );
178
+ for (const [key] of nearestForgotten) {
179
+ if (buckets.size <= evictTo) break;
180
+ buckets.delete(key);
181
+ }
182
+ };
183
+
60
184
  return {
61
- assertAllowed(key) {
185
+ // The resolved policy, not the argument: `maxKeys` is normalized above, and a limiter that
186
+ // reported an unresolved bound would be reporting something it does not enforce.
187
+ policy: { ...policy, maxKeys, scope: 'process' },
188
+ get size() {
189
+ return buckets.size;
190
+ },
191
+ async assertAllowed(key) {
62
192
  const bucket = buckets.get(key);
63
193
  if (bucket === undefined) return;
64
194
  const nowMs = clock.now().getTime();
65
195
  if (bucket.lockedUntilMs <= nowMs) return;
66
196
  throw accountLocked(key, Math.ceil((bucket.lockedUntilMs - nowMs) / 1000));
67
197
  },
68
- recordFailure(key) {
198
+ async recordFailure(key) {
69
199
  const nowMs = clock.now().getTime();
70
200
  const bucket = bucketFor(key);
71
201
  bucket.failures = bucket.failures.filter((at) => at > nowMs - policy.windowMs);
@@ -73,21 +203,54 @@ export function createAuthLimiter(
73
203
  if (bucket.failures.length >= policy.maxAttempts) {
74
204
  bucket.lockedUntilMs = nowMs + policy.lockoutMs;
75
205
  }
206
+ // The newest failure leaves the window last, so that is the earliest this entry is free —
207
+ // unless a lockout outlives it. Recorded here because this is the only growth path.
208
+ bucket.forgetAtMs = Math.max(bucket.lockedUntilMs, nowMs + policy.windowMs);
209
+ if (buckets.size > maxKeys || nowMs - lastSweepMs >= SWEEP_EVERY_MS) sweep(nowMs);
76
210
  },
77
- recordSuccess(key) {
211
+ async recordSuccess(key) {
78
212
  buckets.delete(key);
79
213
  },
80
- lockedUntil(key) {
214
+ async lockedUntil(key) {
81
215
  const bucket = buckets.get(key);
82
216
  if (bucket === undefined || bucket.lockedUntilMs <= clock.now().getTime()) return null;
83
217
  return new Date(bucket.lockedUntilMs);
84
218
  },
85
- reset() {
219
+ async reset() {
86
220
  buckets.clear();
87
221
  },
88
222
  };
89
223
  }
90
224
 
225
+ /**
226
+ * The numbers a limiter is compared on. `maxKeys` is absent deliberately: it bounds one process'
227
+ * table, so a shared limiter has no opinion on it and comparing it would refuse a correct pairing.
228
+ */
229
+ const ENFORCED_LIMITS = ['maxAttempts', 'windowMs', 'lockoutMs'] as const;
230
+
231
+ /**
232
+ * At `defineAuth`, never at the first login. Two ways the declared policy and the limiter in use
233
+ * can disagree, and both end the same way — an operator reading `Auth.rateLimit` and believing a
234
+ * number nothing enforces:
235
+ *
236
+ * - a per-node limiter under a `'shared'` declaration is a lockout worth `maxAttempts × replicas`;
237
+ * - a limiter counting to its own numbers makes every other field of the policy decorative.
238
+ *
239
+ * The declaration stays the app's — this package cannot see how many processes the image runs,
240
+ * and inferring it would be wrong the first time the app scaled — so the framework's job is to
241
+ * refuse the pairing, not to guess either half of it.
242
+ */
243
+ export function assertAuthLimiterPolicy(declared: AuthRateLimitPolicy, limiter: AuthLimiter): void {
244
+ if (declared.scope === 'shared' && limiter.policy.scope !== 'shared') {
245
+ throw authLimiterNotShared(limiter.policy.scope);
246
+ }
247
+ for (const field of ENFORCED_LIMITS) {
248
+ if (declared[field] !== limiter.policy[field]) {
249
+ throw authLimiterPolicyMismatch(field, declared[field], limiter.policy[field]);
250
+ }
251
+ }
252
+ }
253
+
91
254
  /**
92
255
  * The single generic failure. Every credential path — unknown email, wrong password, disabled
93
256
  * account, unverified address — throws exactly this object shape, so the rendered error is
@@ -0,0 +1,100 @@
1
+ // Single responsibility: taking a credential away, at the three blast radii an incident actually
2
+ // has — one person, one tenant, everything issued before an instant. `deleteOtherSessions` was the
3
+ // only one of the three the seam could express, so "kill every session in this org, now" was a
4
+ // per-user loop over an enumeration the adapter could not do, or a `TRUNCATE` that killed every
5
+ // other tenant with it. Doing nothing meant thirty days, which is the absolute TTL.
6
+
7
+ import { logger } from '@ultimat3/core';
8
+ import type { AuthUser } from './adapter';
9
+ import type { Auth } from './auth';
10
+ import { authNotImplemented, authWriteFailed } from './errors';
11
+
12
+ /**
13
+ * Every revocation is logged before it runs, with the reason the caller gave. An incident review
14
+ * asks "who killed these sessions and why" and a `delete` with no line answers neither; the reason
15
+ * is a required argument for the same rule `crossTenant()` requires one.
16
+ */
17
+ const record = (operation: string, scope: string, reason: string): void => {
18
+ logger.warn('auth.revocation', { operation, scope, reason });
19
+ };
20
+
21
+ const unsupported = (method: string, adapterName: string) =>
22
+ authNotImplemented(
23
+ `${adapterName}.${method}()`,
24
+ `implement ${method}() on your AuthAdapter — BuiltinAdapter (Postgres) and MemoryAdapter are the two reference implementations, in packages/auth/src`,
25
+ );
26
+
27
+ /** Every session this user holds, their current one included. Returns how many died. */
28
+ export async function revokeUserSessions(
29
+ auth: Auth,
30
+ userId: string,
31
+ reason: string,
32
+ ): Promise<number> {
33
+ const remove = auth.adapter.deleteSessionsForUser?.bind(auth.adapter);
34
+ if (remove === undefined) throw unsupported('deleteSessionsForUser', auth.adapter.name);
35
+ record('revokeUserSessions', userId, reason);
36
+ return await remove(userId);
37
+ }
38
+
39
+ /**
40
+ * Every session held by every member of one org. The 03:00 answer to a confirmed credential
41
+ * compromise in one tenant, and the one thing the seam could not express at all.
42
+ */
43
+ export async function revokeOrgSessions(
44
+ auth: Auth,
45
+ orgId: string,
46
+ reason: string,
47
+ ): Promise<number> {
48
+ const remove = auth.adapter.deleteSessionsForOrg?.bind(auth.adapter);
49
+ if (remove === undefined) throw unsupported('deleteSessionsForOrg', auth.adapter.name);
50
+ record('revokeOrgSessions', orgId, reason);
51
+ return await remove(orgId);
52
+ }
53
+
54
+ /**
55
+ * Everything minted before an instant. The sweep that follows a rotated `SESSION_SECRET` or a
56
+ * suspected dump: it needs no enumeration of users, so it is the one that works when the list of
57
+ * affected accounts is exactly what is not known yet.
58
+ */
59
+ export async function revokeSessionsCreatedBefore(
60
+ auth: Auth,
61
+ before: Date,
62
+ reason: string,
63
+ ): Promise<number> {
64
+ const remove = auth.adapter.deleteSessionsCreatedBefore?.bind(auth.adapter);
65
+ if (remove === undefined) throw unsupported('deleteSessionsCreatedBefore', auth.adapter.name);
66
+ record('revokeSessionsCreatedBefore', before.toISOString(), reason);
67
+ return await remove(before);
68
+ }
69
+
70
+ export interface DisabledUser {
71
+ readonly user: AuthUser;
72
+ readonly sessionsRevoked: number;
73
+ }
74
+
75
+ /**
76
+ * `disabledAt` is read by `login`, by `authenticate` and by the OAuth path — and until this
77
+ * function existed, no code path anywhere ever SET it. Offboarding was an UPDATE somebody typed.
78
+ *
79
+ * Stamping the column is not enough on its own: `authenticate` re-reads the user row on every
80
+ * request, so a disabled account stops working on its next request, but the live session row is
81
+ * still there and still slides. The sessions go in the same call, in that order — stamp first, so
82
+ * a request racing the revocation finds the row already disabled.
83
+ */
84
+ export async function disableUser(
85
+ auth: Auth,
86
+ userId: string,
87
+ reason: string,
88
+ ): Promise<DisabledUser> {
89
+ const user = await auth.adapter.updateUser(userId, { disabledAt: auth.clock.now() });
90
+ // No row came back: either there is no such user, or the adapter did not return the update.
91
+ // Both mean the account is not known to be disabled, and reporting success would be a lie.
92
+ if (user === null) throw authWriteFailed('updateUser', 'x_users');
93
+ record('disableUser', userId, reason);
94
+ return { user, sessionsRevoked: await revokeUserSessions(auth, userId, reason) };
95
+ }
96
+
97
+ /** The inverse. No sessions are restored — a re-enabled account signs in again, deliberately. */
98
+ export async function enableUser(auth: Auth, userId: string): Promise<AuthUser | null> {
99
+ return await auth.adapter.updateUser(userId, { disabledAt: null });
100
+ }
package/src/session.ts CHANGED
@@ -12,13 +12,34 @@ import { randomToken, sha256Hex, timingSafeEqual } from './tokens';
12
12
  export interface SessionPolicy {
13
13
  /** Hard ceiling from creation. Never extended. */
14
14
  readonly absoluteTtlMs: number;
15
- /** Measured from `lastSeenAt`, refreshed on every verified request. */
15
+ /** Measured from `lastSeenAt`, refreshed at most once per `idleSlideMs`. */
16
16
  readonly idleTtlMs: number;
17
+ /**
18
+ * How stale `lastSeenAt` may get before a verified request writes it forward. Absent means
19
+ * `idleTtlMs / IDLE_SLIDE_DIVISOR`, derived rather than a constant so an app that shortens
20
+ * `idleTtlMs` does not silently get a slide longer than its own idle window — which would
21
+ * pin every session to its creation time and expire it on the dot.
22
+ *
23
+ * It exists because `verifySession` used to write on EVERY authenticated request: one request
24
+ * was a SELECT, an `UPDATE … RETURNING *` and a second SELECT, before the app's own first
25
+ * query. At 20k rps that is 20k writes a second on one hot table, autovacuum falls behind, and
26
+ * the incident reads as "the database is slow" rather than "authentication is a write path".
27
+ * The trade is bounded: idle expiry is now precise to within one `idleSlideMs`.
28
+ */
29
+ readonly idleSlideMs?: number | undefined;
17
30
  readonly cookieName: string;
18
31
  /** Mint a new session id whenever roles/scopes change — closes session fixation. */
19
32
  readonly rotateOnPrivilegeChange: boolean;
20
33
  }
21
34
 
35
+ /** 20 → roughly 5% of the idle window, so the write rate falls ~20× and the drift stays small. */
36
+ export const IDLE_SLIDE_DIVISOR = 20;
37
+
38
+ /** The resolved slide, wherever it is read. One derivation, so the two readers cannot disagree. */
39
+ export function idleSlideMs(policy: SessionPolicy): number {
40
+ return Math.max(0, policy.idleSlideMs ?? Math.floor(policy.idleTtlMs / IDLE_SLIDE_DIVISOR));
41
+ }
42
+
22
43
  export const DEFAULT_SESSION_POLICY: SessionPolicy = Object.freeze({
23
44
  absoluteTtlMs: 30 * 24 * 60 * 60 * 1000,
24
45
  idleTtlMs: 7 * 24 * 60 * 60 * 1000,
@@ -134,10 +155,18 @@ export async function verifySession(
134
155
  throw sessionExpired(expiry.absoluteExpired ? 'absolute' : 'idle', session.id);
135
156
  }
136
157
 
158
+ const ip = observed?.ip ?? session.ip;
159
+ const userAgent = observed?.userAgent ?? session.userAgent;
160
+ // The window slides only when it has actually moved. A second request inside the slide issues
161
+ // no write at all — the read path stays a read — while a changed address or user agent is
162
+ // written immediately, because that is the row a device list and an incident review read.
163
+ const stale = now.getTime() - session.lastSeenAt.getTime() >= idleSlideMs(runtime.policy);
164
+ if (!stale && ip === session.ip && userAgent === session.userAgent) return session;
165
+
137
166
  const touched = await runtime.store.updateSession(session.id, {
138
- lastSeenAt: now,
139
- ip: observed?.ip ?? session.ip,
140
- userAgent: observed?.userAgent ?? session.userAgent,
167
+ lastSeenAt: stale ? now : session.lastSeenAt,
168
+ ip,
169
+ userAgent,
141
170
  });
142
171
  return touched ?? session;
143
172
  }
package/src/tables.ts CHANGED
@@ -10,11 +10,26 @@ export const X_USERS_TABLE = `create table if not exists x_users (
10
10
  org_id uuid,
11
11
  roles text[] not null default '{}',
12
12
  permissions text[] not null default '{}',
13
+ scopes text[] not null default '{}',
14
+ external_id text unique,
13
15
  mfa_secret text,
14
16
  recovery_code_hashes text[] not null default '{}',
15
17
  disabled_at timestamptz,
16
18
  created_at timestamptz not null default now()
17
- )`;
19
+ );
20
+ create index if not exists x_users_org_id_idx on x_users (org_id)`;
21
+
22
+ /**
23
+ * The two columns `x_users` gained in 1.3.0, as the statements an app already running 1.2 runs
24
+ * once. Both are additive and both have a default, so the migration is not a rewrite and takes no
25
+ * exclusive lock beyond the catalog update.
26
+ */
27
+ export const X_USERS_MIGRATION_1_3: readonly string[] = Object.freeze([
28
+ `alter table x_users add column if not exists scopes text[] not null default '{}'`,
29
+ 'alter table x_users add column if not exists external_id text',
30
+ 'create unique index if not exists x_users_external_id_key on x_users (external_id)',
31
+ 'create index if not exists x_users_org_id_idx on x_users (org_id)',
32
+ ]);
18
33
 
19
34
  // `id` is the public half of the cookie; `token_hash` is sha256 of the secret half.
20
35
  export const X_SESSIONS_TABLE = `create table if not exists x_sessions (
@@ -29,7 +44,8 @@ export const X_SESSIONS_TABLE = `create table if not exists x_sessions (
29
44
  mfa_satisfied boolean not null default false
30
45
  );
31
46
  create index if not exists x_sessions_user_id_idx on x_sessions (user_id);
32
- create index if not exists x_sessions_absolute_expires_at_idx on x_sessions (absolute_expires_at)`;
47
+ create index if not exists x_sessions_absolute_expires_at_idx on x_sessions (absolute_expires_at);
48
+ create index if not exists x_sessions_created_at_idx on x_sessions (created_at)`;
33
49
 
34
50
  export const X_ACCOUNTS_TABLE = `create table if not exists x_accounts (
35
51
  id uuid primary key,
package/src/tokens.ts CHANGED
@@ -1,7 +1,13 @@
1
1
  // Single responsibility: the secret primitives every other file in this package shares —
2
- // CSPRNG tokens, SHA-256 hashing and a comparison whose duration does not depend on where
3
- // two strings first differ. Centralised so no call site can quietly reach for `===` on a
4
- // secret, which leaks the shared prefix length one request at a time.
2
+ // CSPRNG tokens and SHA-256 hashing. Centralised so no call site can quietly reach for `===` on
3
+ // a secret, which leaks the shared prefix length one request at a time. The constant-time
4
+ // comparison itself lives in `@ultimat3/core` (`timingSafeEqual`) `@ultimat3/storage` needs the
5
+ // exact same one, and re-exporting it here keeps every existing `from '@ultimat3/auth'` import
6
+ // working.
7
+
8
+ import { timingSafeEqual } from '@ultimat3/core';
9
+
10
+ export { timingSafeEqual };
5
11
 
6
12
  const BASE64URL_UNSAFE = /[+/=]/g;
7
13
  const BASE64URL_REPLACEMENTS: Readonly<Record<string, string>> = { '+': '-', '/': '_', '=': '' };
@@ -23,6 +29,23 @@ export function randomToken(byteLength = 32): string {
23
29
  return base64Url(randomBytes(byteLength));
24
30
  }
25
31
 
32
+ /**
33
+ * The inverse of `base64Url`, for the segments of a JWT. Answers `null` rather than throwing
34
+ * because every caller is reading an attacker-supplied string — a `URIError` or an `InvalidCharacterError`
35
+ * escaping from here would turn a coded refusal into a 500, the same reason `readCookie` never throws.
36
+ */
37
+ export function base64UrlBytes(segment: string): Uint8Array<ArrayBuffer> | null {
38
+ const padded = segment
39
+ .replaceAll('-', '+')
40
+ .replaceAll('_', '/')
41
+ .padEnd(Math.ceil(segment.length / 4) * 4, '=');
42
+ try {
43
+ return Uint8Array.from(atob(padded), (char) => char.charCodeAt(0));
44
+ } catch {
45
+ return null;
46
+ }
47
+ }
48
+
26
49
  export function sha256Hex(value: string): string {
27
50
  return new Bun.CryptoHasher('sha256').update(value).digest('hex');
28
51
  }
@@ -31,20 +54,6 @@ export function sha256Bytes(value: string): Uint8Array {
31
54
  return Uint8Array.from(new Bun.CryptoHasher('sha256').update(value).digest());
32
55
  }
33
56
 
34
- /**
35
- * Length is compared first and non-constant-time on purpose: every secret this package
36
- * compares is a fixed-width hash or token, so the length carries no information, and the
37
- * XOR accumulator below is what has to be branch-free.
38
- */
39
- export function timingSafeEqual(a: string, b: string): boolean {
40
- if (a.length !== b.length) return false;
41
- let diff = 0;
42
- for (let index = 0; index < a.length; index += 1) {
43
- diff |= a.charCodeAt(index) ^ b.charCodeAt(index);
44
- }
45
- return diff === 0;
46
- }
47
-
48
57
  /** Hash-then-compare. The plaintext never has to be held next to the stored value. */
49
58
  export function matchesHash(plaintext: string, storedHash: string): boolean {
50
59
  return timingSafeEqual(sha256Hex(plaintext), storedHash);
package/src/verify.ts CHANGED
@@ -108,19 +108,26 @@ export interface ConsumeVerificationInput {
108
108
  }
109
109
 
110
110
  /**
111
- * The store consumes the row atomically, so a second redemption finds nothing even if it
112
- * races the first. The hash comparison happens after, on the row we already own.
111
+ * The hash goes INTO the consume, it is not compared after one: the store consumes the row only
112
+ * when the hash is the live row's, atomically, so a second redemption finds nothing even if it
113
+ * races the first *and* a wrong guess leaves the row live. Comparing afterwards made an
114
+ * unauthenticated POST with any token at all destroy the victim's emailed link — one request per
115
+ * address for permanent password-reset denial.
113
116
  */
114
117
  export async function consumeVerification(
115
118
  runtime: VerificationRuntime,
116
119
  input: ConsumeVerificationInput,
117
120
  ): Promise<AuthVerification> {
118
- const record = await runtime.store.takeVerification(input.purpose, input.identifier);
121
+ const tokenHash = sha256Hex(input.token);
122
+ const record = await runtime.store.takeVerification(input.purpose, input.identifier, tokenHash);
119
123
  if (record === null) throw verificationInvalid(input.purpose);
120
- if (runtime.clock.now().getTime() >= record.expiresAt.getTime()) {
124
+ // Kept for the seam, not for the blessed adapters: `VerificationStore` is implementable by an
125
+ // app, and one that ignores the third argument would otherwise redeem any token. Constant-time
126
+ // because this package never compares a secret — or a digest of one — with `===`.
127
+ if (!timingSafeEqual(tokenHash, record.tokenHash)) {
121
128
  throw verificationInvalid(input.purpose);
122
129
  }
123
- if (!timingSafeEqual(sha256Hex(input.token), record.tokenHash)) {
130
+ if (runtime.clock.now().getTime() >= record.expiresAt.getTime()) {
124
131
  throw verificationInvalid(input.purpose);
125
132
  }
126
133
  return record;