@ultimat3/auth 1.1.0 → 2.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/oauth.ts CHANGED
@@ -1,9 +1,11 @@
1
1
  // Single responsibility: the OAuth2/OIDC handshake. PKCE is mandatory rather than
2
2
  // provider-dependent — an authorization code with no proof-of-possession is stealable from a
3
3
  // redirect, and "this provider does not need it" is how that becomes a real incident. Provider
4
- // configs are pure data: importing this file performs no network I/O and reads no env.
4
+ // configs are pure data and live in `oauth-registry.ts`: importing this file performs no network
5
+ // I/O and reads no env.
5
6
 
6
7
  import { oauthStateInvalid } from './errors';
8
+ import { providerFor } from './oauth-registry';
7
9
  import { base64Url, randomToken, sha256Bytes, timingSafeEqual } from './tokens';
8
10
 
9
11
  export interface OAuthProvider {
@@ -18,67 +20,37 @@ export interface OAuthProvider {
18
20
  * token. A list rather than one string because Google has issued both forms for years.
19
21
  */
20
22
  readonly issuers: readonly string[];
23
+ /**
24
+ * Where this provider publishes the keys its id tokens are signed with. `null` for a provider
25
+ * that issues no id token (GitHub). A provider with a key set can have a token from it verified
26
+ * on a channel that is not the token endpoint — IdP-initiated login, `form_post`, back-channel
27
+ * logout — which is what `jwks.ts` and `verifyIdToken({ keys })` exist for.
28
+ */
29
+ readonly jwksUri: string | null;
21
30
  readonly scopes: readonly string[];
22
- readonly usesPkce: boolean;
31
+ /**
32
+ * The literal `true`, not `boolean`. A provider config saying `usesPkce: false` was always
33
+ * invalid — an authorization code with no proof-of-possession is stealable from a redirect —
34
+ * and a comment saying so is not a build error. This is, and it deletes every downstream
35
+ * `if (provider.usesPkce)` branch along with the state it could ever have been false in.
36
+ *
37
+ * It stays the literal now that `registerOAuthProvider` is open to any app: the mechanism this
38
+ * package exists to own has to survive the opening, so an app cannot register a PKCE-less IdP.
39
+ */
40
+ readonly usesPkce: true;
23
41
  /** OIDC providers echo `nonce` in the id token; it binds the token to this browser. */
24
42
  readonly usesNonce: boolean;
25
43
  readonly clientIdEnv: string;
26
44
  readonly clientSecretEnv: string;
27
45
  }
28
46
 
29
- export const OAUTH_PROVIDERS = {
30
- github: {
31
- id: 'github',
32
- authorizeUrl: 'https://github.com/login/oauth/authorize',
33
- tokenUrl: 'https://github.com/login/oauth/access_token',
34
- userInfoUrl: 'https://api.github.com/user',
35
- // GitHub omits a private address from the profile; the identity is still incomplete
36
- // without it, so the flow asks for the verified list rather than guessing.
37
- userEmailsUrl: 'https://api.github.com/user/emails',
38
- issuers: [],
39
- scopes: ['read:user', 'user:email'],
40
- usesPkce: true,
41
- usesNonce: false,
42
- clientIdEnv: 'GITHUB_CLIENT_ID',
43
- clientSecretEnv: 'GITHUB_CLIENT_SECRET',
44
- },
45
- google: {
46
- id: 'google',
47
- authorizeUrl: 'https://accounts.google.com/o/oauth2/v2/auth',
48
- tokenUrl: 'https://oauth2.googleapis.com/token',
49
- // Reached only when a narrowed `scopes` leaves the id token without an email claim.
50
- userInfoUrl: 'https://openidconnect.googleapis.com/v1/userinfo',
51
- userEmailsUrl: null,
52
- issuers: ['https://accounts.google.com', 'accounts.google.com'],
53
- scopes: ['openid', 'email', 'profile'],
54
- usesPkce: true,
55
- usesNonce: true,
56
- clientIdEnv: 'GOOGLE_CLIENT_ID',
57
- clientSecretEnv: 'GOOGLE_CLIENT_SECRET',
58
- },
59
- apple: {
60
- id: 'apple',
61
- authorizeUrl: 'https://appleid.apple.com/auth/authorize',
62
- tokenUrl: 'https://appleid.apple.com/auth/token',
63
- // Apple returns claims in the id token only; there is no userinfo endpoint to call.
64
- userInfoUrl: null,
65
- userEmailsUrl: null,
66
- issuers: ['https://appleid.apple.com'],
67
- scopes: ['name', 'email'],
68
- usesPkce: true,
69
- usesNonce: true,
70
- clientIdEnv: 'APPLE_CLIENT_ID',
71
- // Apple alone does not accept a static secret: `APPLE_CLIENT_SECRET` must hold the ES256
72
- // client-secret JWT signed with the .p8 key, which Apple expires every six months.
73
- clientSecretEnv: 'APPLE_CLIENT_SECRET',
74
- },
75
- } as const satisfies Readonly<Record<string, OAuthProvider>>;
76
-
77
- export type OAuthProviderId = keyof typeof OAUTH_PROVIDERS;
78
-
79
- export const OAUTH_PROVIDER_IDS: readonly OAuthProviderId[] = Object.freeze(
80
- Object.keys(OAUTH_PROVIDERS) as OAuthProviderId[],
81
- );
47
+ /**
48
+ * Any registered provider's id — `string`, not a closed union, since 1.3.0. The union of three
49
+ * consumer IdPs made an enterprise OP unrepresentable at the type level, which is a constraint no
50
+ * configuration can escape. `providerFor(id)` is the runtime check that replaces it, and it
51
+ * throws the same `X_OAUTH_PROVIDER_UNKNOWN` the route already answered with.
52
+ */
53
+ export type OAuthProviderId = string;
82
54
 
83
55
  export interface PkcePair {
84
56
  readonly verifier: string;
@@ -115,7 +87,7 @@ export interface BeginOAuthInput {
115
87
  }
116
88
 
117
89
  export function beginOAuth(input: BeginOAuthInput): OAuthHandshake {
118
- const provider = OAUTH_PROVIDERS[input.provider];
90
+ const provider = providerFor(input.provider);
119
91
  const pkce = createPkce();
120
92
  const state = randomToken(16);
121
93
  const nonce = randomToken(16);
@@ -155,11 +127,12 @@ export interface OAuthCallback {
155
127
  * tells an attacker which half to keep guessing at.
156
128
  */
157
129
  export function assertOAuthCallback(handshake: OAuthHandshake, callback: OAuthCallback): void {
158
- const provider = OAUTH_PROVIDERS[handshake.provider];
130
+ const provider = providerFor(handshake.provider);
159
131
  if (!timingSafeEqual(handshake.state, callback.state)) {
160
132
  throw oauthStateInvalid(provider.id, 'state did not match the stored handshake');
161
133
  }
162
- if (provider.usesPkce && handshake.verifier.length < 43) {
134
+ // Unconditional: `usesPkce` is the literal `true`, so there is no provider to exempt.
135
+ if (handshake.verifier.length < 43) {
163
136
  throw oauthStateInvalid(provider.id, 'no PKCE verifier was stored for this handshake');
164
137
  }
165
138
  if (callback.nonce !== undefined && !timingSafeEqual(handshake.nonce, callback.nonce)) {
package/src/password.ts CHANGED
@@ -4,6 +4,7 @@
4
4
  // otherwise response time answers "is this email registered?" for free.
5
5
 
6
6
  import { passwordWeak } from './errors';
7
+ import { kdfGate } from './kdf-gate';
7
8
 
8
9
  export interface PasswordParams {
9
10
  readonly algorithm: 'argon2id';
@@ -57,15 +58,24 @@ const COMMON_PASSWORDS: ReadonlySet<string> = new Set([
57
58
  'correcthorse',
58
59
  ]);
59
60
 
61
+ /**
62
+ * Through `kdfGate()`, like every other KDF call here: 19 MiB of arena per hash and no per-source
63
+ * limiter that an IPv6 /64 cannot walk around means the ONLY thing bounding argon2 memory on this
64
+ * box is this gate. Past its queue it refuses with `X_OVERLOADED`, the same shed http's `admit`
65
+ * stage performs — a refusal that costs one comparison, not one arena.
66
+ */
60
67
  export async function hashPassword(
61
68
  password: string,
62
69
  params: PasswordParams = DEFAULT_PASSWORD_PARAMS,
63
70
  ): Promise<string> {
64
- return await Bun.password.hash(password, {
65
- algorithm: params.algorithm,
66
- memoryCost: params.memoryCost,
67
- timeCost: params.timeCost,
68
- });
71
+ return await kdfGate().run(
72
+ async () =>
73
+ await Bun.password.hash(password, {
74
+ algorithm: params.algorithm,
75
+ memoryCost: params.memoryCost,
76
+ timeCost: params.timeCost,
77
+ }),
78
+ );
69
79
  }
70
80
 
71
81
  /** Reads the parameters back out of a PHC string. `null` means "not a hash we recognise". */
@@ -103,13 +113,15 @@ export interface VerifyPasswordInput {
103
113
  */
104
114
  export async function verifyPassword(input: VerifyPasswordInput): Promise<PasswordVerification> {
105
115
  const params = input.params ?? DEFAULT_PASSWORD_PARAMS;
106
- if (input.hash === null) {
116
+ // Read into a local so the closure below narrows without a cast.
117
+ const hash = input.hash;
118
+ if (hash === null) {
107
119
  await hashPassword(input.password, params);
108
120
  return FAILED;
109
121
  }
110
- const ok = await Bun.password.verify(input.password, input.hash);
122
+ const ok = await kdfGate().run(async () => await Bun.password.verify(input.password, hash));
111
123
  if (!ok) return FAILED;
112
- return { ok: true, needsRehash: needsRehash(input.hash, params) };
124
+ return { ok: true, needsRehash: needsRehash(hash, params) };
113
125
  }
114
126
 
115
127
  export interface StrengthOptions {
@@ -38,12 +38,18 @@ const withPermissions = (actor: Actor, permissions: readonly string[]): PolicyAc
38
38
  });
39
39
 
40
40
  /**
41
- * A human. Roles come from the row and are expanded to permissions by policy; scopes stay
42
- * empty because a browser session is not scope-limited — the role set is the limit.
41
+ * A human. Roles come from the row and are expanded to permissions by policy; scopes come from
42
+ * the row too, and they are almost always empty.
43
+ *
44
+ * `scopes: []` used to be hardcoded here, which made a scope a thing no human could ever hold —
45
+ * so `hasScope(actor, 'tenancy:cross')`, whose own reasons name "an admin surface listing every
46
+ * org" and "support tooling", could only ever be satisfied by minting a `serviceActor` inside the
47
+ * handler. That discards the operator's identity and makes the sweep unattributable, which is the
48
+ * exact property the scope's required reason string exists to preserve.
43
49
  *
44
50
  * A session that has not satisfied an enrolled second factor resolves to an actor with no
45
- * roles and no permissions rather than an error, so a half-authenticated request can still
46
- * reach the "finish MFA" route and nothing else. Login throws `X_MFA_REQUIRED` separately.
51
+ * roles, no permissions and no scopes rather than an error, so a half-authenticated request can
52
+ * still reach the "finish MFA" route and nothing else. Login throws `X_MFA_REQUIRED` separately.
47
53
  */
48
54
  export function actorFromUser(user: AuthUser, session: AuthSession): PolicyActor {
49
55
  const mfaPending = user.mfaSecret !== null && !session.mfaSatisfied;
@@ -52,7 +58,7 @@ export function actorFromUser(user: AuthUser, session: AuthSession): PolicyActor
52
58
  id: user.id,
53
59
  orgId: user.orgId ?? undefined,
54
60
  roles: mfaPending ? [] : user.roles,
55
- scopes: [],
61
+ scopes: mfaPending ? [] : user.scopes,
56
62
  }),
57
63
  mfaPending ? [] : user.permissions,
58
64
  );
@@ -0,0 +1,74 @@
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.
7
+
8
+ import type { AuthUser, UserPatch } from './adapter';
9
+ import type { Auth } from './auth';
10
+ import { authWriteFailed } from './errors';
11
+ import { type IssuedSession, rotateSession, sessionCookie } from './session';
12
+
13
+ /**
14
+ * The fields whose change invalidates whatever the current cookie was issued under. `roles`,
15
+ * `permissions` and `scopes` are what an actor is built from; `passwordHash` is the credential
16
+ * itself; `orgId` moves every tenant-scoped read the session can perform.
17
+ */
18
+ const PRIVILEGE_FIELDS = ['roles', 'permissions', 'scopes', 'orgId', 'passwordHash'] as const;
19
+
20
+ export interface UpdatePrivilegesResult {
21
+ readonly user: AuthUser;
22
+ /**
23
+ * The replacement session, present only when a current session was passed AND a privilege field
24
+ * actually changed. Set `cookie` on the response — the old id is already deleted, so a caller
25
+ * that drops this signs the user out rather than leaving a stale-privilege cookie live.
26
+ */
27
+ readonly session?: IssuedSession | undefined;
28
+ readonly cookie?: string | undefined;
29
+ /** Which of `PRIVILEGE_FIELDS` the patch actually named. Empty means nothing rotated. */
30
+ readonly changed: readonly string[];
31
+ }
32
+
33
+ const changedFields = (patch: UserPatch): readonly string[] =>
34
+ PRIVILEGE_FIELDS.filter((field) => patch[field] !== undefined);
35
+
36
+ /**
37
+ * Apply the patch, then mint a new session id for the caller's own session when the patch touched
38
+ * privilege. Rotation is not about propagation — `authenticate` re-reads the user row on every
39
+ * request, so a revoked role takes effect on the very next one with no token-expiry lag, which is
40
+ * a better property than any claims-in-a-JWT design. It is about fixation: whoever planted or
41
+ * lifted the old cookie before the grant must not inherit the grant with it.
42
+ *
43
+ * `session` is optional because the common caller is an admin changing somebody ELSE's roles, and
44
+ * there is no cookie of theirs to rotate. That case wants `revokeUserSessions()` instead, and the
45
+ * two are deliberately separate calls — silently killing an operator's own session mid-request is
46
+ * not something a role edit should decide on its own.
47
+ */
48
+ export async function updatePrivileges(
49
+ auth: Auth,
50
+ userId: string,
51
+ patch: UserPatch,
52
+ session?: IssuedSession['session'] | undefined,
53
+ ): Promise<UpdatePrivilegesResult> {
54
+ const user = await auth.adapter.updateUser(userId, patch);
55
+ if (user === null) throw authWriteFailed('updateUser', 'x_users');
56
+
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 };
65
+ }
66
+
67
+ const issued = await rotateSession(auth.sessions, session);
68
+ return {
69
+ user,
70
+ changed,
71
+ session: issued,
72
+ cookie: sessionCookie(issued.token, auth.sessions.policy),
73
+ };
74
+ }
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
+ }