@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/adapter.ts CHANGED
@@ -14,9 +14,23 @@ export interface AuthUser {
14
14
  readonly roles: readonly string[];
15
15
  /** Direct grants that bypass roles. Rare; used by break-glass accounts. */
16
16
  readonly permissions: readonly string[];
17
+ /**
18
+ * Capability strings this human may hold, landing on `Actor.scopes` — the field `hasScope()`
19
+ * reads and `permissions` is not. Without it a user actor's scopes were the hardcoded `[]`, so
20
+ * a scope-gated human surface (`tenancy:cross`, and every support tool built on one) was
21
+ * unreachable by any human and could only be reached by minting a `serviceActor`, which throws
22
+ * the operator's identity away. Empty for almost every account.
23
+ */
24
+ readonly scopes: readonly string[];
17
25
  /** Base32 TOTP secret, or `null` when MFA is not enrolled. */
18
26
  readonly mfaSecret: string | null;
19
27
  readonly recoveryCodeHashes: readonly string[];
28
+ /**
29
+ * The identifier the IdP knows this person by — SCIM's `externalId`, OIDC's `sub`. Stable
30
+ * across a rename and across an address change, which is what a provisioning PUT/PATCH lands
31
+ * on and what an email address cannot be. `null` for an account that arrived by password.
32
+ */
33
+ readonly externalId: string | null;
20
34
  readonly disabledAt: Date | null;
21
35
  readonly createdAt: Date;
22
36
  }
@@ -27,6 +41,8 @@ export interface CreateUserInput {
27
41
  readonly passwordHash: string | null;
28
42
  readonly orgId: string | null;
29
43
  readonly roles: readonly string[];
44
+ readonly scopes?: readonly string[] | undefined;
45
+ readonly externalId?: string | null | undefined;
30
46
  readonly createdAt: Date;
31
47
  }
32
48
 
@@ -37,6 +53,16 @@ export interface UserPatch {
37
53
  readonly recoveryCodeHashes?: readonly string[] | undefined;
38
54
  readonly disabledAt?: Date | null | undefined;
39
55
  readonly roles?: readonly string[] | undefined;
56
+ readonly permissions?: readonly string[] | undefined;
57
+ readonly scopes?: readonly string[] | undefined;
58
+ readonly orgId?: string | null | undefined;
59
+ readonly externalId?: string | null | undefined;
60
+ }
61
+
62
+ /** What `listUsersByOrg` filters on. Absent means every member of the org. */
63
+ export interface UserQuery {
64
+ readonly role?: string | undefined;
65
+ readonly includeDisabled?: boolean | undefined;
40
66
  }
41
67
 
42
68
  export interface UserStore {
@@ -44,6 +70,15 @@ export interface UserStore {
44
70
  findUserById(id: string): Promise<AuthUser | null>;
45
71
  createUser(input: CreateUserInput): Promise<AuthUser>;
46
72
  updateUser(id: string, patch: UserPatch): Promise<AuthUser | null>;
73
+ /**
74
+ * Optional so an adapter written against 1.2's seam still compiles; both shipped adapters
75
+ * implement it, and `directory.ts` answers `X_NOT_IMPLEMENTED` with the method name when one
76
+ * does not. The alternative was a required member, which is a breaking change to every
77
+ * third-party `AuthAdapter` for a capability most of them will never be asked for.
78
+ */
79
+ findUserByExternalId?(externalId: string): Promise<AuthUser | null>;
80
+ /** Enumeration, which nothing in the seam could do — a quarterly access review needs it. */
81
+ listUsersByOrg?(orgId: string, query?: UserQuery): Promise<readonly AuthUser[]>;
47
82
  }
48
83
 
49
84
  export interface AuthSession {
@@ -77,6 +112,20 @@ export interface SessionStore {
77
112
  /** Returns how many were killed — the "sign out everywhere else" number shown to the user. */
78
113
  deleteOtherSessions(userId: string, keepSessionId: string): Promise<number>;
79
114
  listSessions(userId: string): Promise<readonly AuthSession[]>;
115
+ /**
116
+ * Every session this user holds, including the caller's own. `deleteOtherSessions` cannot
117
+ * express it — there is no session id to keep — and a password change or a disable has to.
118
+ *
119
+ * Optional for the reason `findUserByExternalId` is: adding a required member to a shipped
120
+ * seam breaks every third-party adapter. `revocation.ts` refuses with `X_NOT_IMPLEMENTED` and
121
+ * names the method when an adapter has not got it.
122
+ */
123
+ deleteSessionsForUser?(userId: string): Promise<number>;
124
+ /**
125
+ * Everything issued before an instant. The credential-compromise sweep: rotate the secret,
126
+ * then kill everything minted under the old one, without enumerating users.
127
+ */
128
+ deleteSessionsCreatedBefore?(before: Date): Promise<number>;
80
129
  }
81
130
 
82
131
  export interface AuthAccount {
@@ -112,10 +161,18 @@ export interface VerificationStore {
112
161
  /** Upsert on `(purpose, identifier)` — issuing a new token invalidates the previous one. */
113
162
  putVerification(record: AuthVerification): Promise<void>;
114
163
  /**
115
- * Read **and consume** in one atomic step. Single-use is a storage guarantee, not a
116
- * caller convention: two concurrent redemptions must not both see an unconsumed row.
164
+ * Read **and consume** in one atomic step, and only when `tokenHash` is the live row's.
165
+ * Single-use is a storage guarantee, not a caller convention: two concurrent redemptions must
166
+ * not both see an unconsumed row. The hash belongs to that same step for the same reason — a
167
+ * store that consumes first and lets the caller compare afterwards lets an unauthenticated
168
+ * wrong guess destroy the victim's live token, which is a password-reset denial of service
169
+ * against any address an attacker can name. A non-match consumes nothing and answers `null`.
117
170
  */
118
- takeVerification(purpose: string, identifier: string): Promise<AuthVerification | null>;
171
+ takeVerification(
172
+ purpose: string,
173
+ identifier: string,
174
+ tokenHash: string,
175
+ ): Promise<AuthVerification | null>;
119
176
  }
120
177
 
121
178
  export interface AuthApiKeyRecord {
@@ -152,6 +209,14 @@ export interface AuthAdapter
152
209
  AccountStore,
153
210
  VerificationStore,
154
211
  ApiKeyStore {
155
- /** Shown by `x auth doctor --json` so the driver in use is never a guess. */
212
+ /** Reported by `Auth.adapter.name` so the driver in use is never a guess. */
156
213
  readonly name: string;
214
+ /**
215
+ * Every session held by every member of one org. It spans two tables — `x_sessions` carries no
216
+ * `org_id` and deliberately does not gain one, because org membership lives on the user and a
217
+ * denormalised copy goes stale the moment somebody moves org, which makes the 03:00 sweep miss
218
+ * exactly the sessions it was run for. So it joins through `x_users`, which is why it sits on
219
+ * the full adapter rather than on `SessionStore`.
220
+ */
221
+ deleteSessionsForOrg?(orgId: string): Promise<number>;
157
222
  }
package/src/auth.ts CHANGED
@@ -6,9 +6,10 @@
6
6
  import { type Clock, systemClock, uuid } from '@ultimat3/core';
7
7
  import { t } from '@ultimat3/schema';
8
8
  import type { AuthAdapter, AuthSession, AuthUser } from './adapter';
9
- import { mfaRequired, sessionUnknown } from './errors';
9
+ import { normaliseEmail } from './email';
10
+ import { mfaRequired, mfaRequiredUnenforceable, sessionUnknown } from './errors';
10
11
  import type { OAuthProviderId } from './oauth';
11
- import { OAUTH_PROVIDER_IDS } from './oauth';
12
+ import { oauthProviderIds } from './oauth-registry';
12
13
  import {
13
14
  checkPasswordStrength,
14
15
  DEFAULT_PASSWORD_POLICY,
@@ -21,10 +22,13 @@ import {
21
22
  type AuthLimiter,
22
23
  type AuthRateLimitPolicy,
23
24
  accountKey,
25
+ assertAuthLimiterPolicy,
24
26
  createAuthLimiter,
25
27
  DEFAULT_AUTH_RATE_LIMIT,
26
28
  ipKey,
27
29
  loginFailed,
30
+ orgKey,
31
+ orgRateLimit,
28
32
  } from './rate-limit';
29
33
  import {
30
34
  createSession,
@@ -35,6 +39,7 @@ import {
35
39
  sessionCookie,
36
40
  verifySession,
37
41
  } from './session';
42
+ import { sha256Hex, timingSafeEqual } from './tokens';
38
43
 
39
44
  // The projections safe to hand to a client or an MCP tool: no password hash, no TOTP secret,
40
45
  // no token hash. The private columns live in `adapter.ts` and never leave the server.
@@ -45,6 +50,8 @@ export const UserSchema = t.object({
45
50
  orgId: t.optional(t.uuid),
46
51
  roles: t.array(t.string),
47
52
  permissions: t.array(t.string),
53
+ scopes: t.array(t.string),
54
+ externalId: t.optional(t.string),
48
55
  mfaEnrolled: t.boolean,
49
56
  createdAt: t.date,
50
57
  });
@@ -63,7 +70,9 @@ export const SessionSchema = t.object({
63
70
  export const AccountSchema = t.object({
64
71
  id: t.uuid,
65
72
  userId: t.uuid,
66
- provider: t.enum(['github', 'google', 'apple']),
73
+ // Any registered provider id, not the three built-ins: an app that registers its own OP has
74
+ // account rows carrying that id, and an enum of three would refuse to parse its own data.
75
+ provider: t.string,
67
76
  providerAccountId: t.string,
68
77
  expiresAt: t.optional(t.date),
69
78
  createdAt: t.date,
@@ -78,10 +87,44 @@ export const VerificationSchema = t.object({
78
87
  createdAt: t.date,
79
88
  });
80
89
 
90
+ /**
91
+ * When a provider identity with no linked account is allowed to become an EXISTING local user.
92
+ *
93
+ * There are two values and there is deliberately no third. The dangerous one an app would reach
94
+ * for — "link on whatever address the provider sent" — is not spelled here at all, because a
95
+ * provider that does not verify addresses turns it into account takeover: register the victim's
96
+ * address at a sloppy provider, press the button, inherit the account. Unrepresentable beats
97
+ * explicit, the same way `PkcePair.method` is the literal `'S256'` and never `'plain'`.
98
+ *
99
+ * | value | a provider identity becomes an existing user when |
100
+ * |---|---|
101
+ * | `'verified-email'` (default) | the provider asserted the address verified AND that user had verified it too |
102
+ * | `'never'` | never — an address collision is refused and the caller signs in with their own credentials |
103
+ *
104
+ * An app that genuinely wants something else composes it: wrap `signInWithOAuth` and resolve the
105
+ * user yourself. That is the seam, and it keeps the framework from shipping the loose default.
106
+ */
107
+ export type OAuthLinkPolicy = 'verified-email' | 'never';
108
+
109
+ /** The product name an authenticator app shows when the app declared none. */
110
+ export const DEFAULT_MFA_ISSUER = 'Ultimate';
111
+
81
112
  export interface AuthMfaPolicy {
82
- /** Shown in the authenticator app. Usually the product name. */
113
+ /**
114
+ * Shown in the authenticator app. Usually the product name, and `enrolTotp(auth, …)` reads it
115
+ * from here so it is written once — a call may still name its own for the one enrolment.
116
+ */
83
117
  readonly issuer: string;
84
- readonly required: boolean;
118
+ /**
119
+ * The literal `false`, so `required: true` is a type error rather than a comment — the shape
120
+ * `OAuthProvider.usesPkce` has, and for the same reason: the value nothing enforces has to be
121
+ * unrepresentable, not discouraged. This package cannot make a second factor mandatory. Both
122
+ * credential paths branch on `user.mfaSecret` alone and `actorFromUser` degrades only a user
123
+ * who HAS a secret, so a user who never enrolled has no half-authenticated actor to enrol
124
+ * through and no enrolment route to reach — refusing them at `login()` locks them out for good.
125
+ * `defineAuth` refuses the declaration outright; the app gates its own sign-in handler.
126
+ */
127
+ readonly required: false;
85
128
  }
86
129
 
87
130
  export interface AuthConfigInput {
@@ -90,8 +133,22 @@ export interface AuthConfigInput {
90
133
  readonly session?: Partial<SessionPolicy> | undefined;
91
134
  readonly password?: Partial<PasswordPolicy> | undefined;
92
135
  readonly rateLimit?: Partial<AuthRateLimitPolicy> | undefined;
136
+ /**
137
+ * Where failed attempts are counted. Omitted means `createAuthLimiter`, which is one process'
138
+ * worth of state — correct for dev and tests, and `maxAttempts × N` for N replicas. An app that
139
+ * runs more than one declares `rateLimit.scope: 'shared'` and passes a limiter that says the
140
+ * same, or `defineAuth` refuses here rather than at 3am on the first spray.
141
+ */
142
+ readonly limiter?: AuthLimiter | undefined;
143
+ /**
144
+ * Where the per-TENANT counters live. Same rule as `limiter`, and a separate instance because
145
+ * it enforces a separate `maxAttempts` — `rateLimit.orgMaxAttempts`, which a whole org shares.
146
+ */
147
+ readonly orgLimiter?: AuthLimiter | undefined;
93
148
  readonly mfa?: Partial<AuthMfaPolicy> | undefined;
94
149
  readonly providers?: readonly OAuthProviderId[] | undefined;
150
+ /** Defaults to `'verified-email'` — both halves proven. See `OAuthLinkPolicy`. */
151
+ readonly link?: OAuthLinkPolicy | undefined;
95
152
  }
96
153
 
97
154
  export interface Auth {
@@ -101,8 +158,12 @@ export interface Auth {
101
158
  readonly password: PasswordPolicy;
102
159
  readonly rateLimit: AuthRateLimitPolicy;
103
160
  readonly limiter: AuthLimiter;
161
+ /** The tenant bucket's own policy — `rateLimit` with `orgMaxAttempts` as its `maxAttempts`. */
162
+ readonly orgRateLimit: AuthRateLimitPolicy;
163
+ readonly orgLimiter: AuthLimiter;
104
164
  readonly mfa: AuthMfaPolicy;
105
165
  readonly providers: readonly OAuthProviderId[];
166
+ readonly link: OAuthLinkPolicy;
106
167
  }
107
168
 
108
169
  export function defineAuth(config: AuthConfigInput): Auth {
@@ -110,15 +171,37 @@ export function defineAuth(config: AuthConfigInput): Auth {
110
171
  const session: SessionPolicy = { ...DEFAULT_SESSION_POLICY, ...config.session };
111
172
  const password: PasswordPolicy = { ...DEFAULT_PASSWORD_POLICY, ...config.password };
112
173
  const rateLimit: AuthRateLimitPolicy = { ...DEFAULT_AUTH_RATE_LIMIT, ...config.rateLimit };
174
+ const limiter = config.limiter ?? createAuthLimiter(clock, rateLimit);
175
+ assertAuthLimiterPolicy(rateLimit, limiter);
176
+ // The tenant bucket is a noisy-neighbour cap, not a credential-guessing allowance, so an app
177
+ // that declares `scope: 'shared'` for its LOCKOUT is not also required to ship a shared limiter
178
+ // for this one — per replica it approximates to `orgMaxAttempts × replicas`, which is a
179
+ // throughput ceiling and discloses nothing. An INJECTED org limiter is still compared, because
180
+ // then the app has made a claim about what it enforces and `Auth.orgRateLimit` reports it.
181
+ const orgLimits = orgRateLimit(rateLimit);
182
+ const orgLimiter = config.orgLimiter ?? createAuthLimiter(clock, orgLimits);
183
+ if (config.orgLimiter !== undefined) assertAuthLimiterPolicy(orgLimits, config.orgLimiter);
184
+ // Read through a widened local on purpose: the field's type is the literal `false`, so this
185
+ // branch is unreachable from TypeScript and reachable from every JS caller and every config
186
+ // parsed out of JSON — the same split `invariantColumns()` keeps its Proxy behind a compile
187
+ // error for. A declaration this package cannot enforce is refused where it is written.
188
+ const declaredMfa: { readonly required?: unknown } = config.mfa ?? {};
189
+ if (declaredMfa.required === true) throw mfaRequiredUnenforceable();
190
+ const mfa: AuthMfaPolicy = { issuer: config.mfa?.issuer ?? DEFAULT_MFA_ISSUER, required: false };
113
191
  return Object.freeze({
114
192
  adapter: config.adapter,
115
193
  clock,
116
194
  sessions: { store: config.adapter, policy: session, clock },
117
195
  password,
118
196
  rateLimit,
119
- limiter: createAuthLimiter(clock, rateLimit),
120
- mfa: { issuer: config.mfa?.issuer ?? 'Ultimate', required: config.mfa?.required ?? false },
121
- providers: config.providers ?? OAUTH_PROVIDER_IDS,
197
+ limiter,
198
+ // What the limiter in use actually enforces, not the derivation the two differ in `scope`
199
+ // exactly when the app declared 'shared' and left this limiter to the default.
200
+ orgRateLimit: orgLimiter.policy,
201
+ orgLimiter,
202
+ mfa,
203
+ providers: config.providers ?? oauthProviderIds(),
204
+ link: config.link ?? 'verified-email',
122
205
  });
123
206
  }
124
207
 
@@ -134,7 +217,7 @@ export async function register(auth: Auth, input: RegisterInput): Promise<AuthUs
134
217
  checkPasswordStrength(input.password, { policy: auth.password });
135
218
  return await auth.adapter.createUser({
136
219
  id: uuid(auth.clock),
137
- email: input.email.trim().toLowerCase(),
220
+ email: normaliseEmail(input.email),
138
221
  passwordHash: await hashPassword(input.password, auth.password.params),
139
222
  orgId: input.orgId ?? null,
140
223
  roles: input.roles ?? [],
@@ -165,10 +248,16 @@ export interface LoginResult {
165
248
  export async function login(auth: Auth, input: LoginInput): Promise<LoginResult> {
166
249
  const account = accountKey(input.email);
167
250
  const ip = input.ip ?? null;
168
- auth.limiter.assertAllowed(account);
169
- if (ip !== null) auth.limiter.assertAllowed(ipKey(ip));
251
+ await auth.limiter.assertAllowed(account);
252
+ if (ip !== null) await auth.limiter.assertAllowed(ipKey(ip));
253
+
254
+ const user = await auth.adapter.findUserByEmail(normaliseEmail(input.email));
255
+ // The tenant bucket can only be consulted once the address resolves to an org, which is still
256
+ // before the KDF runs — the expensive half of this function — so it costs one map lookup and
257
+ // caps a spray that per-IP and per-account buckets both let through.
258
+ const org = user?.orgId ?? null;
259
+ if (org !== null) await auth.orgLimiter.assertAllowed(orgKey(org));
170
260
 
171
- const user = await auth.adapter.findUserByEmail(input.email.trim().toLowerCase());
172
261
  const usable = user !== null && user.disabledAt === null;
173
262
  const verification = await verifyPassword({
174
263
  hash: usable ? user.passwordHash : null,
@@ -177,13 +266,17 @@ export async function login(auth: Auth, input: LoginInput): Promise<LoginResult>
177
266
  });
178
267
 
179
268
  if (!verification.ok || user === null) {
180
- auth.limiter.recordFailure(account);
181
- if (ip !== null) auth.limiter.recordFailure(ipKey(ip));
269
+ await auth.limiter.recordFailure(account);
270
+ if (ip !== null) await auth.limiter.recordFailure(ipKey(ip));
271
+ if (org !== null) await auth.orgLimiter.recordFailure(orgKey(org));
182
272
  throw loginFailed();
183
273
  }
184
274
 
185
- auth.limiter.recordSuccess(account);
186
- if (ip !== null) auth.limiter.recordSuccess(ipKey(ip));
275
+ await auth.limiter.recordSuccess(account);
276
+ if (ip !== null) await auth.limiter.recordSuccess(ipKey(ip));
277
+ // No `recordSuccess` on the tenant bucket, and that asymmetry is the point: one member signing
278
+ // in successfully must not clear the count a broken integration is running up beside them, or
279
+ // the tenant cap is cleared by exactly the traffic that proves the tenant is still in use.
187
280
 
188
281
  // Parameters were raised since this hash was written: upgrade it now, while we hold the
189
282
  // plaintext. This is the only moment it is possible without asking the user for anything.
@@ -193,8 +286,10 @@ export async function login(auth: Auth, input: LoginInput): Promise<LoginResult>
193
286
  });
194
287
  }
195
288
 
196
- // Password proven, second factor not. The client finishes at POST /auth/mfa/verify, which is
197
- // what mints the sessionno half-authenticated session is written here.
289
+ // Password proven, second factor not. No half-authenticated session is written here and nothing
290
+ // is persisted to correlate the two legs finishing MFA is the app's, and `X_MFA_REQUIRED`'s
291
+ // `fix:` is the instruction. The framework's own second leg is blocked on a sealed pending-MFA
292
+ // credential; the constraint is written down in `packages/auth/CLAUDE.md`.
198
293
  if (user.mfaSecret !== null) throw mfaRequired(user.id);
199
294
 
200
295
  const issued = await createSession(auth.sessions, {
@@ -226,8 +321,17 @@ export async function authenticate(auth: Auth, token: string | null): Promise<Po
226
321
  return resolveActor({ kind: 'user', user, session });
227
322
  }
228
323
 
324
+ /**
325
+ * The SECRET half is checked before the row is deleted, exactly as `verifySession` checks it. The
326
+ * id half is not a credential — it is in a device list, in a log line and in an audit row — so
327
+ * deleting on it alone made "sign this person out" an unauthenticated write for anyone who had
328
+ * ever seen one. Constant-time, and `false` for every failure, so it stays a poor oracle too.
329
+ */
229
330
  export async function logout(auth: Auth, token: string): Promise<boolean> {
230
331
  const parsed = parseSessionToken(token);
231
332
  if (parsed === null) return false;
333
+ const session = await auth.adapter.getSession(parsed.id);
334
+ if (session === null) return false;
335
+ if (!timingSafeEqual(sha256Hex(parsed.secret), session.tokenHash)) return false;
232
336
  return await auth.adapter.deleteSession(parsed.id);
233
337
  }
@@ -14,6 +14,7 @@ import type {
14
14
  CreateUserInput,
15
15
  SessionPatch,
16
16
  UserPatch,
17
+ UserQuery,
17
18
  } from './adapter';
18
19
  import { authWriteFailed } from './errors';
19
20
 
@@ -54,8 +55,10 @@ const toUser = (row: Row): AuthUser => ({
54
55
  orgId: textOrNull(row, 'org_id'),
55
56
  roles: list(row, 'roles'),
56
57
  permissions: list(row, 'permissions'),
58
+ scopes: list(row, 'scopes'),
57
59
  mfaSecret: textOrNull(row, 'mfa_secret'),
58
60
  recoveryCodeHashes: list(row, 'recovery_code_hashes'),
61
+ externalId: textOrNull(row, 'external_id'),
59
62
  disabledAt: dateOrNull(row, 'disabled_at'),
60
63
  createdAt: date(row, 'created_at'),
61
64
  });
@@ -131,9 +134,11 @@ export class BuiltinAdapter implements AuthAdapter {
131
134
 
132
135
  async createUser(input: CreateUserInput): Promise<AuthUser> {
133
136
  const row = await this.#db.one<Row>(sql`
134
- insert into x_users (id, email, password_hash, org_id, roles, created_at)
137
+ insert into x_users (id, email, password_hash, org_id, roles, scopes, external_id,
138
+ created_at)
135
139
  values (${input.id}, ${input.email}, ${input.passwordHash}, ${input.orgId},
136
- ${[...input.roles]}, ${input.createdAt})
140
+ ${[...input.roles]}, ${[...(input.scopes ?? [])]}, ${input.externalId ?? null},
141
+ ${input.createdAt})
137
142
  returning *`);
138
143
  // An empty `returning` means no row landed. A user fabricated from `{}` would travel back
139
144
  // out of `register()` as a successful registration with no identity in it.
@@ -155,12 +160,44 @@ export class BuiltinAdapter implements AuthAdapter {
155
160
  disabled_at = case when ${patch.disabledAt !== undefined}
156
161
  then ${patch.disabledAt ?? null} else disabled_at end,
157
162
  roles = case when ${patch.roles !== undefined}
158
- then ${[...(patch.roles ?? [])]} else roles end
163
+ then ${[...(patch.roles ?? [])]} else roles end,
164
+ permissions = case when ${patch.permissions !== undefined}
165
+ then ${[...(patch.permissions ?? [])]} else permissions end,
166
+ scopes = case when ${patch.scopes !== undefined}
167
+ then ${[...(patch.scopes ?? [])]} else scopes end,
168
+ org_id = case when ${patch.orgId !== undefined}
169
+ then ${patch.orgId ?? null} else org_id end,
170
+ external_id = case when ${patch.externalId !== undefined}
171
+ then ${patch.externalId ?? null} else external_id end
159
172
  where id = ${id}
160
173
  returning *`);
161
174
  return row === null ? null : toUser(row);
162
175
  }
163
176
 
177
+ async findUserByExternalId(externalId: string): Promise<AuthUser | null> {
178
+ const row = await this.#db.one<Row>(
179
+ sql`select * from x_users where external_id = ${externalId}`,
180
+ );
181
+ return row === null ? null : toUser(row);
182
+ }
183
+
184
+ /**
185
+ * One statement, and the two filters are conditional predicates rather than assembled SQL, for
186
+ * the reason `updateUser`'s columns are: the statement text stays constant and nothing is
187
+ * interpolated. Disabled members are excluded by default — an access review reads who can sign
188
+ * in today — and `includeDisabled` is what an offboarding audit passes.
189
+ */
190
+ async listUsersByOrg(orgId: string, query?: UserQuery): Promise<readonly AuthUser[]> {
191
+ const role = query?.role ?? null;
192
+ const rows = await this.#db.query<Row>(sql`
193
+ select * from x_users
194
+ where org_id = ${orgId}
195
+ and (${query?.includeDisabled === true} or disabled_at is null)
196
+ and (${role === null} or ${role} = any (roles))
197
+ order by email asc`);
198
+ return rows.map(toUser);
199
+ }
200
+
164
201
  async getSession(id: string): Promise<AuthSession | null> {
165
202
  const row = await this.#db.one<Row>(sql`select * from x_sessions where id = ${id}`);
166
203
  return row === null ? null : toSession(row);
@@ -201,6 +238,26 @@ export class BuiltinAdapter implements AuthAdapter {
201
238
  );
202
239
  }
203
240
 
241
+ async deleteSessionsForUser(userId: string): Promise<number> {
242
+ return await this.#db.execute(sql`delete from x_sessions where user_id = ${userId}`);
243
+ }
244
+
245
+ /**
246
+ * Joins through `x_users` rather than reading an `org_id` off the session. The column does not
247
+ * exist on `x_sessions` and is not being added: a copy of the membership goes stale the moment
248
+ * somebody changes org, and a stale row means the 03:00 sweep leaves live exactly the sessions
249
+ * it was run to kill.
250
+ */
251
+ async deleteSessionsForOrg(orgId: string): Promise<number> {
252
+ return await this.#db.execute(sql`
253
+ delete from x_sessions
254
+ where user_id in (select id from x_users where org_id = ${orgId})`);
255
+ }
256
+
257
+ async deleteSessionsCreatedBefore(before: Date): Promise<number> {
258
+ return await this.#db.execute(sql`delete from x_sessions where created_at < ${before}`);
259
+ }
260
+
204
261
  async listSessions(userId: string): Promise<readonly AuthSession[]> {
205
262
  const rows = await this.#db.query<Row>(
206
263
  sql`select * from x_sessions where user_id = ${userId} order by last_seen_at desc`,
@@ -244,11 +301,31 @@ export class BuiltinAdapter implements AuthAdapter {
244
301
  consumed_at = null`);
245
302
  }
246
303
 
247
- /** The `consumed_at is null` predicate is what makes redemption single-use under concurrency. */
248
- async takeVerification(purpose: string, identifier: string): Promise<AuthVerification | null> {
304
+ /**
305
+ * One conditional UPDATE, and every part of the predicate is load-bearing. `consumed_at is null`
306
+ * on the UPDATE itself is what makes redemption single-use under concurrency — inside the
307
+ * subselect alone, two racing redemptions both pick the row and both consume it. `token_hash`
308
+ * is what keeps a wrong guess from consuming anything: a consume that happens before the
309
+ * comparison turns any unauthenticated POST into a way to kill the victim's emailed link. The
310
+ * predicate compares the digest and never the token, so it leaks what a `=` on a hash leaks.
311
+ * `order by created_at desc limit 1` addresses exactly one row: `unique (purpose, identifier)`
312
+ * (`tables.ts`) already allows only one, but an unbounded `update … where` consumes every row
313
+ * that matches, so a table missing that constraint would redeem links nobody presented.
314
+ */
315
+ async takeVerification(
316
+ purpose: string,
317
+ identifier: string,
318
+ tokenHash: string,
319
+ ): Promise<AuthVerification | null> {
249
320
  const row = await this.#db.one<Row>(sql`
250
321
  update x_verifications set consumed_at = now()
251
- where purpose = ${purpose} and identifier = ${identifier} and consumed_at is null
322
+ where consumed_at is null and id = (
323
+ select id from x_verifications
324
+ where purpose = ${purpose} and identifier = ${identifier}
325
+ and consumed_at is null and token_hash = ${tokenHash}
326
+ order by created_at desc
327
+ limit 1
328
+ )
252
329
  returning *`);
253
330
  return row === null ? null : toVerification(row);
254
331
  }
@@ -0,0 +1,77 @@
1
+ // Single responsibility: reading accounts back out, safely. A quarterly access review — "who in
2
+ // this org holds `admin`, and is any of them gone?" — was unanswerable: `UserStore` could find one
3
+ // user by address or by id and nothing else, so there was no way to enumerate an org's members at
4
+ // all. The projection is `describeApiKey`'s shape and its rule: never the password hash, never the
5
+ // TOTP secret, never a recovery code hash.
6
+
7
+ import type { AuthUser, UserQuery } from './adapter';
8
+ import type { Auth } from './auth';
9
+ import { authNotImplemented } from './errors';
10
+
11
+ /** Safe to render in an admin page, return from an MCP tool, or paste into a review ticket. */
12
+ export interface AuthUserSummary {
13
+ readonly id: string;
14
+ readonly email: string;
15
+ readonly orgId: string | null;
16
+ readonly roles: readonly string[];
17
+ readonly permissions: readonly string[];
18
+ readonly scopes: readonly string[];
19
+ readonly externalId: string | null;
20
+ /** Whether a second factor is enrolled. The secret itself never leaves the server. */
21
+ readonly mfaEnrolled: boolean;
22
+ readonly emailVerifiedAt: Date | null;
23
+ readonly disabledAt: Date | null;
24
+ readonly createdAt: Date;
25
+ }
26
+
27
+ /**
28
+ * The one projection. An allow-list rather than a delete-list, because a column added to
29
+ * `AuthUser` later must not appear here by default — that is how a `mfaSecret` ends up in an
30
+ * admin JSON response one refactor after somebody was careful.
31
+ */
32
+ export function describeUser(user: AuthUser): AuthUserSummary {
33
+ return {
34
+ id: user.id,
35
+ email: user.email,
36
+ orgId: user.orgId,
37
+ roles: user.roles,
38
+ permissions: user.permissions,
39
+ scopes: user.scopes,
40
+ externalId: user.externalId,
41
+ mfaEnrolled: user.mfaSecret !== null,
42
+ emailVerifiedAt: user.emailVerifiedAt,
43
+ disabledAt: user.disabledAt,
44
+ createdAt: user.createdAt,
45
+ };
46
+ }
47
+
48
+ const unsupported = (method: string, adapterName: string) =>
49
+ authNotImplemented(
50
+ `${adapterName}.${method}()`,
51
+ `implement ${method}() on your AuthAdapter — BuiltinAdapter (Postgres) and MemoryAdapter are the two reference implementations, in packages/auth/src`,
52
+ );
53
+
54
+ /** Every member of one org, as summaries. `query.role` narrows it to one role's holders. */
55
+ export async function listOrgUsers(
56
+ auth: Auth,
57
+ orgId: string,
58
+ query?: UserQuery,
59
+ ): Promise<readonly AuthUserSummary[]> {
60
+ const list = auth.adapter.listUsersByOrg?.bind(auth.adapter);
61
+ if (list === undefined) throw unsupported('listUsersByOrg', auth.adapter.name);
62
+ return (await list(orgId, query)).map(describeUser);
63
+ }
64
+
65
+ /**
66
+ * The account a provisioning system already knows by its own id. This is what a SCIM `PUT` or
67
+ * `PATCH` resolves against — an email address cannot be, because a rename changes it and the
68
+ * subject stays the same person.
69
+ */
70
+ export async function findUserByExternalId(
71
+ auth: Auth,
72
+ externalId: string,
73
+ ): Promise<AuthUser | null> {
74
+ const find = auth.adapter.findUserByExternalId?.bind(auth.adapter);
75
+ if (find === undefined) throw unsupported('findUserByExternalId', auth.adapter.name);
76
+ return await find(externalId);
77
+ }
package/src/email.ts ADDED
@@ -0,0 +1,17 @@
1
+ // Single responsibility: the ONE normalisation an email address gets before anything uses it as an
2
+ // identity key — a user lookup, a user insert, or the lockout bucket that has to key the same way
3
+ // those two do. It lives above the `AuthAdapter` seam because an adapter that normalises hides a
4
+ // caller that forgot to: `MemoryAdapter` did and `BuiltinAdapter` did not, so "does this account
5
+ // exist" had two answers, one under `x dev` and another in production.
6
+
7
+ /**
8
+ * Trim, then lowercase. Nothing else, deliberately: stripping a `+tag` or a gmail dot would MERGE
9
+ * two addresses a person kept apart on purpose, which is an account takeover between colleagues at
10
+ * one domain rather than a convenience. RFC 5321 makes the local part case-sensitive in theory and
11
+ * no mail provider in practice treats it that way, so folding case is what stops one human holding
12
+ * two accounts at one address — and `x_users.email` is a plain `text ... unique`, whose uniqueness
13
+ * is exactly this string.
14
+ */
15
+ export function normaliseEmail(email: string): string {
16
+ return email.trim().toLowerCase();
17
+ }