@ultimat3/auth 23.0.0 → 24.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.
@@ -5,9 +5,24 @@
5
5
  // account linking with it. The three built-ins register through the same call an app uses, so the
6
6
  // opening does not create a second path.
7
7
 
8
+ import { renderCauseValue, renderFixLiteral } from '@ultimat3/core';
9
+ import { AuthError } from './errors';
8
10
  import type { OAuthProvider } from './oauth';
9
11
  import { BUILTIN_OAUTH_PROVIDERS } from './oauth-builtins';
10
- import { oauthProviderDuplicate, oauthProviderUnknown } from './oauth-errors';
12
+ import { oauthProviderUnknown } from './oauth-errors';
13
+
14
+ /**
15
+ * Two `registerOAuthProvider` calls claiming one id. A silent replacement would let whichever
16
+ * module imported second decide where every login for that id goes — including which `issuers`
17
+ * an id token may claim — so the second registration refuses at boot instead.
18
+ */
19
+ export const oauthProviderDuplicate = (provider: string): AuthError =>
20
+ new AuthError({
21
+ code: 'X_OAUTH_PROVIDER_DUPLICATE',
22
+ cause: `an oauth provider is already registered as ${renderCauseValue(provider)}, so the second registration would silently replace the first`,
23
+ fix: `give one of them a different id, or delete the duplicate registerOAuthProvider({ id: ${renderFixLiteral(provider, '<id>')} }) call`,
24
+ meta: { provider },
25
+ });
11
26
 
12
27
  const registry = new Map<string, OAuthProvider>();
13
28
 
@@ -10,16 +10,20 @@
10
10
 
11
11
  import {
12
12
  type Clock,
13
+ EnvMissingError,
13
14
  isUltimateError,
14
15
  logger,
16
+ renderCauseValue,
17
+ renderFixShellArg,
15
18
  renderThrowable,
16
19
  type UltimateError,
17
20
  } from '@ultimat3/core';
18
21
  import type { Auth, LoginResult } from './auth';
22
+ import { AuthError } from './errors';
19
23
  import { beginOAuth, type OAuthProviderId } from './oauth';
20
24
  import { BUILTIN_OAUTH_PROVIDER_IDS } from './oauth-builtins';
21
25
  import { clearHandshakeCookie, handshakeCookie, readHandshakeCookie } from './oauth-cookie';
22
- import { oauthDenied, oauthExchangeFailed, oauthProviderUnknown } from './oauth-errors';
26
+ import { oauthExchangeFailed, oauthProviderUnknown, restartAt } from './oauth-errors';
23
27
  import type { OAuthClientCredentials, OAuthFetch } from './oauth-exchange';
24
28
  import { oauthCredentials } from './oauth-exchange';
25
29
  import { completeOAuthLogin, type ResolveOAuthGrants } from './oauth-login';
@@ -30,6 +34,30 @@ import {
30
34
  } from './oauth-paths';
31
35
  import { hasOAuthProvider } from './oauth-registry';
32
36
 
37
+ /**
38
+ * The provider came back with `error=` and no code — almost always the user pressing Cancel.
39
+ * A separate code from `X_OAUTH_EXCHANGE_FAILED` on purpose: nothing was exchanged, nothing is
40
+ * misconfigured, and folding the single commonest non-success outcome of a login into the code
41
+ * that means "the client secret is wrong" makes both unreadable in a log and pages the wrong person.
42
+ */
43
+ export const oauthDenied = (
44
+ provider: string,
45
+ reason: string,
46
+ description: string | null,
47
+ ): AuthError =>
48
+ new AuthError({
49
+ code: 'X_OAUTH_DENIED',
50
+ // `reason` and `description` are query parameters off the callback URL — whatever the browser
51
+ // was redirected with, newlines and quotes included. `renderCauseValue` renders them as JSON
52
+ // string literals, so a forged `error_description` cannot forge a second log line or break the
53
+ // sentence around it. Both are already `string` by type: this is escaping, not throw-safety.
54
+ cause: `${provider} declined the authorization: ${renderCauseValue(reason)}${
55
+ description === null ? '' : ` (${renderCauseValue(description)})`
56
+ }`,
57
+ fix: `${restartAt(provider)} and approve the ${provider} consent screen`,
58
+ meta: { provider, reason },
59
+ });
60
+
33
61
  /**
34
62
  * What a router needs to mount one of these. Structural, like `RequestLike` in `session.ts`:
35
63
  * `@ultimat3/http` binds to this shape, this package never binds to `@ultimat3/http`.
@@ -52,9 +80,9 @@ export interface OAuthLoginRoutes {
52
80
 
53
81
  export interface OAuthLoginOptions {
54
82
  /**
55
- * The origin the provider redirects back to. Defaults to `APP_URL`, then to the request's own
56
- * origin — which is the `Host` header, so it is preferred last: a forged one only ever produces
57
- * a `redirect_uri` the provider refuses, but naming the canonical origin costs nothing.
83
+ * The origin the provider redirects back to. Defaults to `APP_URL`; with neither, the start leg
84
+ * refuses (`X_ENV_MISSING`). Never the request's own origin: that is the `Host` header, which
85
+ * the caller writes.
58
86
  */
59
87
  readonly baseUrl?: string | undefined;
60
88
  /** Defaults to the two env vars in the provider table, read per request, never at import. */
@@ -125,34 +153,44 @@ export function oauthRouteStatus(code: string): number {
125
153
  return Object.hasOwn(OAUTH_ROUTE_STATUS, code) ? (OAUTH_ROUTE_STATUS[code] ?? 502) : 502;
126
154
  }
127
155
 
156
+ /** The one sentence every refusal carries outward. The event name is where the rest went. */
157
+ const PUBLIC_CAUSE =
158
+ 'the sign-in was refused; its cause and its fix are in this server\u2019s log under auth.oauth.refused';
159
+
160
+ /** A code this framework would have minted — the only kind spliced into a command. */
161
+ const FRAMEWORK_CODE = /^X_[A-Z0-9_]+$/;
162
+
128
163
  /**
129
- * What an anonymous caller is allowed to read. `UltimateError.toJSON()` carries `meta` and `stack`
130
- * — a developer's fields — and BOTH legs of this flow are public by definition, so serialising it
131
- * whole published a stack trace and whatever a factory put in `meta` to whoever typed the URL. Four
132
- * fields, the same four on every code: no per-code judgement about which `meta` key is safe today.
164
+ * What an anonymous caller is allowed to read: the code, its registered title and docs link, and
165
+ * one fixed sentence. Both legs are public by definition, and an authored `cause`/`fix` is written
166
+ * for the developer — it names env vars, endpoints and what a provider answered — so neither is
167
+ * served, on any code: no per-code judgement about which sentence is safe today.
133
168
  */
134
169
  const publicBody = (coded: UltimateError): Record<string, string> => ({
135
170
  code: coded.code,
136
171
  title: coded.title,
137
- cause: coded.cause,
138
- fix: coded.fix,
172
+ cause: PUBLIC_CAUSE,
173
+ fix: FRAMEWORK_CODE.test(coded.code)
174
+ ? `x errors explain ${renderFixShellArg(coded.code, '<CODE>')} --json`
175
+ : 'x errors list --json',
139
176
  docs: coded.docs,
140
177
  });
141
178
 
142
179
  /**
143
- * Failure is JSON, never a redirect to a login page carrying `?error=`. A callback is the one
144
- * request in the flow whose failure the developer has to read, and a redirect that drops the code
145
- * and the fix line is exactly how three dead `fix:` strings survived a whole release. An app that
146
- * wants a rendered page wraps these two descriptors; the framework ships the debuggable answer.
180
+ * Failure is JSON, never a redirect to a login page carrying `?error=`: the status and the code
181
+ * are the answer a client branches on. The developer's half — the cause and the fix line — is
182
+ * logged as `auth.oauth.refused`, where only an operator reads it. An app that wants a rendered
183
+ * page wraps these two descriptors.
147
184
  */
148
185
  function problem(error: unknown, extraCookies: readonly string[]): Response {
149
186
  const coded = isUltimateError(error) ? error : uncoded(error);
187
+ const status = oauthRouteStatus(coded.code);
188
+ const fields = { code: coded.code, status, cause: coded.cause, fix: coded.fix };
189
+ if (status >= 500) logger.error('auth.oauth.refused', fields);
190
+ else logger.info('auth.oauth.refused', fields);
150
191
  const headers = new Headers({ 'content-type': 'application/json; charset=utf-8' });
151
192
  for (const cookie of extraCookies) headers.append('set-cookie', cookie);
152
- return new Response(JSON.stringify(publicBody(coded)), {
153
- status: oauthRouteStatus(coded.code),
154
- headers,
155
- });
193
+ return new Response(JSON.stringify(publicBody(coded)), { status, headers });
156
194
  }
157
195
 
158
196
  /**
@@ -244,10 +282,22 @@ function credentialsFor(
244
282
  }
245
283
  }
246
284
 
247
- function originFor(request: Request, options: OAuthLoginOptions): string {
285
+ /**
286
+ * The declared origin, or a refusal. The request's own origin is the `Host` header, so a
287
+ * `redirect_uri` built from it is one the caller chose.
288
+ */
289
+ function declaredOrigin(options: OAuthLoginOptions): string {
248
290
  const env = options.env ?? Bun.env;
249
- const declared = options.baseUrl ?? env['APP_URL']?.trim() ?? '';
250
- return declared === '' ? new URL(request.url).origin : declared.replace(/\/+$/, '');
291
+ const declared = (options.baseUrl ?? env['APP_URL'] ?? '').trim();
292
+ if (declared === '') {
293
+ throw new EnvMissingError({
294
+ cause:
295
+ 'APP_URL is not set and oauthLogin() was given no baseUrl, so there is no declared origin to build the provider redirect_uri from',
296
+ fix: 'export APP_URL=http://localhost:3000 # the origin this app is served from',
297
+ meta: { key: 'APP_URL' },
298
+ });
299
+ }
300
+ return declared.replace(/\/+$/, '');
251
301
  }
252
302
 
253
303
  /** The handshake's own options, assembled once so both legs seal and open it identically. */
@@ -265,12 +315,15 @@ async function startHandler(
265
315
  request: Request,
266
316
  ): Promise<Response> {
267
317
  try {
318
+ // Before the provider is looked at: asked after, a missing origin answered 500 for a provider
319
+ // this app enabled and 404 for one it did not.
320
+ const origin = declaredOrigin(options);
268
321
  const provider = assertEnabled(auth, providerSegment(request, 'start'));
269
322
  const credentials = credentialsFor(provider, options);
270
323
  const handshake = beginOAuth({
271
324
  provider,
272
325
  clientId: credentials.clientId,
273
- redirectUri: `${originFor(request, options)}${oauthCallbackPath(provider)}`,
326
+ redirectUri: `${origin}${oauthCallbackPath(provider)}`,
274
327
  scopes: options.scopes,
275
328
  });
276
329
  return new Response(null, {
@@ -28,7 +28,12 @@ export interface ServiceIdentity {
28
28
  /** The four `ActorKind`s, as the four things that can be holding a credential. */
29
29
  export type AuthIdentity =
30
30
  | { readonly kind: 'user'; readonly user: AuthUser; readonly session: AuthSession }
31
- | { readonly kind: 'agent'; readonly apiKey: AuthApiKeyRecord }
31
+ | {
32
+ readonly kind: 'agent';
33
+ readonly apiKey: AuthApiKeyRecord;
34
+ /** What the key's owner may do — `null` for a key no user owns. See `actorFromApiKey`. */
35
+ readonly ownerGrants: readonly string[] | null;
36
+ }
32
37
  | { readonly kind: 'service'; readonly service: ServiceIdentity }
33
38
  | { readonly kind: 'anonymous' };
34
39
 
@@ -64,20 +69,58 @@ export function actorFromUser(user: AuthUser, session: AuthSession): PolicyActor
64
69
  );
65
70
  }
66
71
 
72
+ /** `*` and `<resource>:*` — the two spellings a role uses to be granted everything. */
73
+ export const isWildcardScope = (scope: string): boolean => scope === '*' || scope.endsWith(':*');
74
+
75
+ // `@ultimat3/policy`'s own reading of a grant's resource, mirrored: same tier, so not imported.
76
+ const resourceOf = (grant: string): string => grant.split(':')[0] ?? grant;
77
+
78
+ /** Whether one of the owner's grants reaches a named permission. A grant MAY be a wildcard. */
79
+ const covers = (grant: string, wanted: string): boolean =>
80
+ grant === '*' ||
81
+ grant === wanted ||
82
+ (grant.endsWith(':*') && resourceOf(grant) === resourceOf(wanted));
83
+
84
+ /**
85
+ * What a key may carry of what it names. Two cuts, and both only ever remove:
86
+ *
87
+ * - a wildcard scope is dropped. `issueApiKey` refuses to mint one, and a row written before that
88
+ * refusal (or by hand) must not keep the reach the refusal exists to deny;
89
+ * - a key a user OWNS keeps only the scopes that owner could exercise themselves. A key was
90
+ * minted with whatever list its issuer typed, so without this a member could hold a key
91
+ * reaching further than their own account — and it kept that reach after a demotion.
92
+ *
93
+ * `ownerGrants` is `null` for a key no user owns: there is nobody to be narrower than.
94
+ */
95
+ export function apiKeyScopes(
96
+ scopes: readonly string[],
97
+ ownerGrants: readonly string[] | null,
98
+ ): readonly string[] {
99
+ return scopes.filter(
100
+ (scope) =>
101
+ !isWildcardScope(scope) &&
102
+ (ownerGrants === null || ownerGrants.some((grant) => covers(grant, scope))),
103
+ );
104
+ }
105
+
67
106
  /**
68
- * An MCP/LLM caller. The actor's scopes are **exactly** the key's scopes — never the owning
69
- * user's roles, never a default set. An agent that can do more than its key says is the whole
70
- * failure mode this bridge exists to prevent.
107
+ * An MCP/LLM caller. The actor's scopes are the key's scopes cut by `apiKeyScopes` — never the
108
+ * owning user's roles, never a default set. An agent that can do more than its key says, or more
109
+ * than its owner can, is the whole failure mode this bridge exists to prevent.
71
110
  */
72
- export function actorFromApiKey(key: AuthApiKeyRecord): PolicyActor {
111
+ export function actorFromApiKey(
112
+ key: AuthApiKeyRecord,
113
+ ownerGrants: readonly string[] | null,
114
+ ): PolicyActor {
115
+ const scopes = apiKeyScopes(key.scopes, ownerGrants);
73
116
  return withPermissions(
74
117
  agentActor({
75
118
  id: key.id,
76
119
  orgId: key.orgId ?? undefined,
77
120
  roles: [],
78
- scopes: key.scopes,
121
+ scopes,
79
122
  }),
80
- key.scopes,
123
+ scopes,
81
124
  );
82
125
  }
83
126
 
@@ -100,7 +143,7 @@ export function resolveActor(identity: AuthIdentity): PolicyActor {
100
143
  case 'user':
101
144
  return actorFromUser(identity.user, identity.session);
102
145
  case 'agent':
103
- return actorFromApiKey(identity.apiKey);
146
+ return actorFromApiKey(identity.apiKey, identity.ownerGrants);
104
147
  case 'service':
105
148
  return actorFromService(identity.service);
106
149
  case 'anonymous':
package/src/privileges.ts CHANGED
@@ -1,9 +1,6 @@
1
- // Single responsibility: changing what a user may do, and rotating the credential that was issued
2
- // under the old answer. `packages/auth/CLAUDE.md` has listed "rotate the session id on any
3
- // privilege change (`rotateSession`)" as a non-negotiable since 1.0, and
4
- // `SessionPolicy.rotateOnPrivilegeChange` has defaulted `true` — while `rotateSession` had no
5
- // caller anywhere outside its own test and the flag was read by nothing. Per axiom 3 the rule did
6
- // not exist. This is the caller that makes it exist.
1
+ // Single responsibility: changing what a user may do, and ending the credentials issued under the
2
+ // old answer — the caller's session is rotated on any privilege change, and a changed password
3
+ // ends every other session the user holds.
7
4
 
8
5
  import type { AuthUser, UserPatch } from './adapter';
9
6
  import type { Auth } from './auth';
@@ -28,6 +25,8 @@ export interface UpdatePrivilegesResult {
28
25
  readonly cookie?: string | undefined;
29
26
  /** Which of `PRIVILEGE_FIELDS` the patch actually named. Empty means nothing rotated. */
30
27
  readonly changed: readonly string[];
28
+ /** Sessions a `passwordHash` change ended. Zero for every other patch. */
29
+ readonly sessionsRevoked: number;
31
30
  }
32
31
 
33
32
  const changedFields = (patch: UserPatch): readonly string[] =>
@@ -44,6 +43,12 @@ const changedFields = (patch: UserPatch): readonly string[] =>
44
43
  * there is no cookie of theirs to rotate. That case wants `revokeUserSessions()` instead, and the
45
44
  * two are deliberately separate calls — silently killing an operator's own session mid-request is
46
45
  * not something a role edit should decide on its own.
46
+ *
47
+ * A `passwordHash` change is the exception, and it decides for itself: the password is what every
48
+ * OTHER session of this user was issued under, so they all end here. The caller's own session —
49
+ * passed in, and this user's — is the one that survives (rotated, or kept when rotation is off);
50
+ * with no session of the user's own, as in a reset or an admin's change, none survives. A changed
51
+ * password that left a lifted cookie working would not have changed anything for whoever holds it.
47
52
  */
48
53
  export async function updatePrivileges(
49
54
  auth: Auth,
@@ -51,23 +56,30 @@ export async function updatePrivileges(
51
56
  patch: UserPatch,
52
57
  session?: IssuedSession['session'] | undefined,
53
58
  ): Promise<UpdatePrivilegesResult> {
59
+ const changed = changedFields(patch);
60
+ const own = session !== undefined && session.userId === userId ? session : undefined;
61
+ const credentialChanged = changed.includes('passwordHash');
54
62
  const user = await auth.adapter.updateUser(userId, patch);
55
63
  if (user === null) throw authWriteFailed('updateUser', 'x_users');
64
+ if (changed.length === 0) return { user, changed, sessionsRevoked: 0 };
56
65
 
57
- const changed = changedFields(patch);
58
- if (
59
- changed.length === 0 ||
60
- session === undefined ||
61
- session.userId !== userId ||
62
- !auth.sessions.policy.rotateOnPrivilegeChange
63
- ) {
64
- return { user, changed };
66
+ const issued =
67
+ own !== undefined && auth.sessions.policy.rotateOnPrivilegeChange
68
+ ? await rotateSession(auth.sessions, own)
69
+ : undefined;
70
+ let sessionsRevoked = 0;
71
+ if (credentialChanged) {
72
+ const keep = issued?.session.id ?? own?.id;
73
+ sessionsRevoked =
74
+ keep === undefined
75
+ ? await auth.adapter.deleteSessionsForUser(userId)
76
+ : await auth.adapter.deleteOtherSessions(userId, keep);
65
77
  }
66
-
67
- const issued = await rotateSession(auth.sessions, session);
78
+ if (issued === undefined) return { user, changed, sessionsRevoked };
68
79
  return {
69
80
  user,
70
81
  changed,
82
+ sessionsRevoked,
71
83
  session: issued,
72
84
  cookie: sessionCookie(issued.token, auth.sessions.policy),
73
85
  };
@@ -1,104 +1,104 @@
1
- // The shared credential limiter: two Postgres tables, so N replicas count one spray once and a
1
+ // The shared credential limiter: one Postgres table, so N replicas count one spray once and a
2
2
  // lockout one pod established is visible to the rest. Without it `rateLimit.scope: 'shared'` is a
3
3
  // declaration nothing can satisfy, while `x new` scaffolds `replicas: 2` — which is
4
4
  // `maxAttempts × 2` guesses per account.
5
- import type { Clock } from '@ultimat3/core';
6
- import { accountLocked } from './errors';
7
- import type { AuthLimiter, AuthRateLimitPolicy } from './rate-limit';
8
-
9
- /**
10
- * The one thing this limiter needs from the DB layer, declared structurally rather than imported.
11
- * `@ultimat3/action`'s `idempotency-postgres.ts` and `@ultimat3/http`'s rate-limit store declare
12
- * the same shape for the same reason: the connection belongs to the HOST, not to any of them, so
13
- * a limiter takes the pool the boot already opened instead of opening a second one against a URL
14
- * that was resolved once.
15
- *
16
- * **`Bun.sql` does not satisfy it** — `Bun.sql.query` is `undefined`; it is a tagged template
17
- * whose positional form is `unsafe`. `@ultimat3/db`'s `DbClient.query({ text, values })` does,
18
- * wrapped in one line, and so does a transaction handle.
19
- */
20
- export interface PgExecutor {
21
- query<R>(sql: string, params: readonly unknown[]): Promise<readonly R[]>;
22
- }
5
+ import type { Clock, PgExecutor } from '@ultimat3/core';
6
+ import { accountLocked, authWriteFailed } from './errors';
7
+ import type { AuthLimiter, AuthRateLimitPolicy, AuthReservation } from './rate-limit';
23
8
 
24
9
  /**
25
10
  * Applied by the boot, never by an app migration — the rule `SQL_IDEMPOTENCY_TABLE` follows, so
26
11
  * `x dev`, the container's `web` role and `ROLE=migrate` all install it.
27
12
  *
28
- * A row per FAILURE rather than a counter per key, because the window this package enforces is a
29
- * SLIDING one: a counter plus a window end is a fixed window, which admits `maxAttempts` at the
30
- * end of one window and `maxAttempts` again at the start of the next — twice the declared
31
- * allowance, under the same declared numbers, on the credential path.
13
+ * ONE row per key, holding every attempt still inside the window. The window this package enforces
14
+ * is a SLIDING one, so the instants are kept (a counter plus a window end is a fixed window, which
15
+ * admits `maxAttempts` at the end of one and `maxAttempts` again at the start of the next). They
16
+ * are kept IN the row because the row is what a concurrent take waits on: a count over a second
17
+ * table reads its statement's snapshot, and that snapshot predates the wait.
18
+ *
19
+ * The two `add column` lines are the upgrade of a table made before the reservation, on the rule
20
+ * `x_users` follows: additive, defaulted, idempotent, applied at every boot. The name and the
21
+ * first two columns are unchanged on purpose — a replica still running the previous release reads
22
+ * and writes this table as it always did, so a lockout holds on both sides of a rolling deploy.
32
23
  */
33
24
  export const SQL_AUTH_LIMIT_TABLES = `
34
- create table if not exists x_auth_failures (
35
- key text not null,
36
- at_ms bigint not null
37
- );
38
-
39
- create index if not exists x_auth_failures_key_idx on x_auth_failures (key, at_ms);
40
-
41
25
  create table if not exists x_auth_lockouts (
42
- key text primary key,
43
- locked_until_ms bigint not null
26
+ key text primary key,
27
+ locked_until_ms bigint not null,
28
+ attempts_ms bigint[] not null default '{}',
29
+ admitted boolean not null default true
44
30
  );
45
31
 
32
+ alter table x_auth_lockouts add column if not exists attempts_ms bigint[] not null default '{}';
33
+ alter table x_auth_lockouts add column if not exists admitted boolean not null default true;
34
+
46
35
  create index if not exists x_auth_lockouts_until_idx on x_auth_lockouts (locked_until_ms);
47
36
  `;
48
37
 
49
38
  /**
50
- * The per-key serializer, spelled from documented functions only: `md5` and a hex bit-string cast,
51
- * never `hashtext`, which is an internal with no compatibility promise.
39
+ * The attempts still inside the window, read off the row as it is AFTER its lock is taken. It is
40
+ * repeated in the statement below rather than computed once in a CTE, and the repetition is
41
+ * required: only a direct `x_auth_lockouts.<column>` reference inside `on conflict do update` sees
42
+ * what a concurrent take just committed. `$2` nowMs, `$3` windowMs.
52
43
  */
53
- export const SQL_AUTH_KEY_LOCK = "pg_advisory_xact_lock(('x' || md5($1))::bit(64)::bigint)";
44
+ const LIVE =
45
+ 'array(select at_ms from unnest(x_auth_lockouts.attempts_ms) as at_ms ' +
46
+ 'where at_ms > $2::bigint - $3::bigint)';
47
+
48
+ const LOCKED = 'x_auth_lockouts.locked_until_ms > $2::bigint';
54
49
 
55
50
  /**
56
- * `$1` key, `$2` nowMs — and a **transaction-scoped advisory lock on the key**, taken in the same
57
- * statement, before the row lands.
58
- *
59
- * `PgExecutor` accepts a transaction handle (see its doc comment), and the insert and the count
60
- * below are two statements. Without this lock two OUTER transactions recording a failure for one
61
- * account each counted only what had COMMITTED plus their own row: with `maxAttempts: 3` and one
62
- * failure already committed, both read two, neither locked, and both committed — three failures
63
- * and an open account. The lock makes the second transaction wait at the insert until the first
64
- * commits, so its count is taken against a snapshot that already holds the first's row.
51
+ * `$1` key, `$2` nowMs, `$3` windowMs, `$4` lockoutMs, `$5` maxAttempts — the reservation, in ONE
52
+ * statement: decide and count together, so N concurrent takes are admitted one at a time.
65
53
  *
66
- * Autocommit is unaffected: the lock is taken and released inside the statement's own implicit
67
- * transaction, which is one extra no-op per failure and no extra round trip. The guarantee is
68
- * READ COMMITTED, which is what `withTransaction` opens with no `isolation:` — a caller that opts
69
- * into `'repeatable read'` or `'serializable'` pins the count's snapshot at transaction start, and
70
- * no lock can make a statement see a commit its own snapshot precedes.
54
+ * A locked key is left untouched and answers `admitted = false`. Otherwise the attempt joins the
55
+ * window and, if it fills it, starts the lockout. `admitted` is persisted because `returning`
56
+ * cannot see the row as it was: the verdict has to be a column the statement wrote.
71
57
  *
72
- * `recordFailure` always locks account → ip → org (`auth.ts`), one fixed order, so two concurrent
73
- * sign-ins cannot take two of these locks in opposite orders and deadlock.
58
+ * A live lockout is never rewritten, so nothing arriving during one can shorten it. Inside a
59
+ * caller's own transaction the row lock is held to that commit, which is what serialises two outer
60
+ * transactions; `auth.ts` takes account → ip → org in that fixed order, so two sign-ins cannot
61
+ * hold two of these rows in opposite orders.
74
62
  */
75
- export const SQL_AUTH_RECORD_FAILURE = `
76
- with locked as (select ${SQL_AUTH_KEY_LOCK})
77
- insert into x_auth_failures (key, at_ms)
78
- select $1, $2::bigint from locked
63
+ export const SQL_AUTH_TAKE = `
64
+ insert into x_auth_lockouts (key, attempts_ms, locked_until_ms, admitted)
65
+ values (
66
+ $1,
67
+ array[$2::bigint],
68
+ case when 1 >= $5::bigint then $2::bigint + $4::bigint else 0 end,
69
+ true
70
+ )
71
+ on conflict (key) do update
72
+ set attempts_ms = case when ${LOCKED} then x_auth_lockouts.attempts_ms
73
+ else ${LIVE} || $2::bigint end,
74
+ locked_until_ms = case
75
+ when ${LOCKED} then x_auth_lockouts.locked_until_ms
76
+ when cardinality(${LIVE}) + 1 >= $5::bigint then $2::bigint + $4::bigint
77
+ else 0 end,
78
+ admitted = not (${LOCKED})
79
+ returning admitted, locked_until_ms
79
80
  `;
80
81
 
82
+ /** The window with the first entry equal to `$2` taken out — one reservation, never every equal one. */
83
+ const WITHOUT =
84
+ '(attempts_ms[:array_position(attempts_ms, $2::bigint) - 1] || ' +
85
+ 'attempts_ms[array_position(attempts_ms, $2::bigint) + 1:])';
86
+
81
87
  /**
82
- * `$1` key, `$2` nowMs, `$3` windowMs, `$4` lockoutMs, `$5` maxAttempts.
88
+ * `$1` key, `$2` the reservation's `atMs`, `$3` nowMs, `$4` windowMs, `$5` maxAttempts.
83
89
  *
84
- * A SECOND statement, deliberately, and not a CTE beside the insert above: every CTE in one
85
- * statement reads that statement's snapshot, so a `count(*)` sharing it cannot see the failure
86
- * being inserted beside it and the lock would fire one attempt late. Run afterwards, the count
87
- * sees this caller's own row and every other replica's that has committed — and, because the
88
- * insert holds `SQL_AUTH_KEY_LOCK`, every row a concurrent transaction on this key committed too.
89
- *
90
- * `greatest` on conflict EXTENDS a live lockout and never shortens one — a spray arriving during
91
- * a lockout must not be able to reset it to a nearer deadline.
90
+ * Gives ONE attempt back, and lifts the lockout when what is left no longer fills the window — a
91
+ * lockout the refunded attempt itself completed. A reservation that is no longer in the row (the
92
+ * key was cleared, or purged) matches nothing and changes nothing.
92
93
  */
93
- export const SQL_AUTH_LOCK = `
94
- insert into x_auth_lockouts (key, locked_until_ms)
95
- select $1, $2::bigint + $4::bigint
96
- from x_auth_failures
97
- where key = $1 and at_ms > $2::bigint - $3::bigint
98
- having count(*) >= $5::bigint
99
- on conflict (key) do update
100
- set locked_until_ms = greatest(x_auth_lockouts.locked_until_ms, excluded.locked_until_ms)
101
- returning locked_until_ms
94
+ export const SQL_AUTH_REFUND = `
95
+ update x_auth_lockouts
96
+ set attempts_ms = ${WITHOUT},
97
+ locked_until_ms = case
98
+ when (select count(*) from unnest(${WITHOUT}) as at_ms
99
+ where at_ms > $3::bigint - $4::bigint) < $5::bigint then 0
100
+ else locked_until_ms end
101
+ where key = $1 and array_position(attempts_ms, $2::bigint) is not null
102
102
  `;
103
103
 
104
104
  /** `$2` is the caller's clock: an expired lockout answers exactly as a missing one. */
@@ -106,30 +106,27 @@ export const SQL_AUTH_LOCKED_UNTIL = `
106
106
  select locked_until_ms from x_auth_lockouts where key = $1 and locked_until_ms > $2::bigint
107
107
  `;
108
108
 
109
- /** A success clears the window AND the lockout: one round trip, because both must go together. */
110
- export const SQL_AUTH_FORGET_KEY = `
111
- with cleared as (delete from x_auth_failures where key = $1 returning key)
112
- delete from x_auth_lockouts where key = $1
113
- `;
109
+ /** A success clears the window AND the lockout: they are one row, so they go together. */
110
+ export const SQL_AUTH_FORGET_KEY = 'delete from x_auth_lockouts where key = $1';
114
111
 
115
- export const SQL_AUTH_RESET = `
116
- with cleared as (delete from x_auth_failures returning key)
117
- delete from x_auth_lockouts
118
- `;
112
+ export const SQL_AUTH_RESET = 'delete from x_auth_lockouts';
119
113
 
120
114
  /**
121
- * `$1` nowMs, `$2` windowMs — the CALLER's clock, never `now()`. Every instant in these tables is
115
+ * `$1` nowMs, `$2` windowMs — the CALLER's clock, never `now()`. Every instant in this table is
122
116
  * written from the caller's clock, so a purge measuring against the SERVER's would delete rows by
123
- * the offset between the two: failures that are still inside the window, and lockouts that are
117
+ * the offset between the two: attempts that are still inside the window, and lockouts that are
124
118
  * still live. The second one hands a sprayer its account back.
125
119
  */
126
120
  export const SQL_AUTH_PURGE = `
127
- with dropped_failures as (
128
- delete from x_auth_failures where at_ms <= $1::bigint - $2::bigint returning key
129
- ), dropped_lockouts as (
130
- delete from x_auth_lockouts where locked_until_ms <= $1::bigint returning key
121
+ with dropped as (
122
+ delete from x_auth_lockouts
123
+ where locked_until_ms <= $1::bigint
124
+ and not exists (
125
+ select 1 from unnest(attempts_ms) as at_ms where at_ms > $1::bigint - $2::bigint
126
+ )
127
+ returning key
131
128
  )
132
- select (select count(*) from dropped_failures) + (select count(*) from dropped_lockouts) as removed
129
+ select count(*) as removed from dropped
133
130
  `;
134
131
 
135
132
  export interface PostgresAuthLimiterOptions {
@@ -146,9 +143,9 @@ export interface PostgresAuthLimiterOptions {
146
143
 
147
144
  export interface PostgresAuthLimiter extends AuthLimiter {
148
145
  /**
149
- * Drop every failure past the window and every expired lockout, and answer how many rows went.
150
- * Neither table bounds itself — `ipKey` mints one key per source address, so a spray from an
151
- * IPv6 /64 is a row per attempt — and Postgres forgets nothing on its own. An app runs this
146
+ * Drop every key whose window has emptied and whose lockout has expired, and answer how many
147
+ * rows went. The table does not bound itself — `ipKey` mints one key per source address, so a
148
+ * spray from an IPv6 /64 is a row per attempt — and Postgres forgets nothing on its own. An app runs this
152
149
  * from a `task`; a row this deletes answers exactly as a missing one, so it changes no decision.
153
150
  */
154
151
  purgeExpired(): Promise<number>;
@@ -159,6 +156,10 @@ interface LockRow {
159
156
  readonly locked_until_ms: number | string;
160
157
  }
161
158
 
159
+ interface TakeRow extends LockRow {
160
+ readonly admitted: boolean;
161
+ }
162
+
162
163
  /**
163
164
  * **Install it at `defineAuth`, beside the declaration it satisfies.** Two limiters, one table:
164
165
  * the keys are prefixed (`account:`, `ip:`, `org:`) and every limit travels as a parameter, so
@@ -192,22 +193,33 @@ export function postgresAuthLimiter(options: PostgresAuthLimiterOptions): Postgr
192
193
  // bound this limiter does not enforce is the thing `assertAuthLimiterPolicy` exists to catch.
193
194
  policy: { ...policy, maxKeys: undefined, scope: 'shared' },
194
195
 
195
- async assertAllowed(key): Promise<void> {
196
- const until = await lockedUntilMs(key);
197
- if (until === null) return;
198
- throw accountLocked(key, Math.ceil((until - nowMs()) / 1000));
199
- },
200
-
201
- async recordFailure(key): Promise<void> {
196
+ async reserve(key): Promise<AuthReservation> {
202
197
  const at = nowMs();
203
- await exec.query(SQL_AUTH_RECORD_FAILURE, [key, at]);
204
- await exec.query(SQL_AUTH_LOCK, [
198
+ const rows = await exec.query<TakeRow>(SQL_AUTH_TAKE, [
205
199
  key,
206
200
  at,
207
201
  policy.windowMs,
208
202
  policy.lockoutMs,
209
203
  policy.maxAttempts,
210
204
  ]);
205
+ const row = rows[0];
206
+ // No row is no verdict, and no verdict is not an admission: an executor that answered
207
+ // nothing must not be read as "allowed" on the credential path.
208
+ if (row === undefined) throw authWriteFailed('reserve', 'x_auth_lockouts');
209
+ if (row.admitted !== true) {
210
+ throw accountLocked(key, Math.ceil((Number(row.locked_until_ms) - at) / 1000));
211
+ }
212
+ return { key, atMs: at };
213
+ },
214
+
215
+ async refund(reservation): Promise<void> {
216
+ await exec.query(SQL_AUTH_REFUND, [
217
+ reservation.key,
218
+ reservation.atMs,
219
+ nowMs(),
220
+ policy.windowMs,
221
+ policy.maxAttempts,
222
+ ]);
211
223
  },
212
224
 
213
225
  async recordSuccess(key): Promise<void> {