@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/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 { normaliseEmail } from './email';
9
10
  import { mfaRequired, 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,6 +87,25 @@ 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
+
81
109
  export interface AuthMfaPolicy {
82
110
  /** Shown in the authenticator app. Usually the product name. */
83
111
  readonly issuer: string;
@@ -90,8 +118,22 @@ export interface AuthConfigInput {
90
118
  readonly session?: Partial<SessionPolicy> | undefined;
91
119
  readonly password?: Partial<PasswordPolicy> | undefined;
92
120
  readonly rateLimit?: Partial<AuthRateLimitPolicy> | undefined;
121
+ /**
122
+ * Where failed attempts are counted. Omitted means `createAuthLimiter`, which is one process'
123
+ * worth of state — correct for dev and tests, and `maxAttempts × N` for N replicas. An app that
124
+ * runs more than one declares `rateLimit.scope: 'shared'` and passes a limiter that says the
125
+ * same, or `defineAuth` refuses here rather than at 3am on the first spray.
126
+ */
127
+ readonly limiter?: AuthLimiter | undefined;
128
+ /**
129
+ * Where the per-TENANT counters live. Same rule as `limiter`, and a separate instance because
130
+ * it enforces a separate `maxAttempts` — `rateLimit.orgMaxAttempts`, which a whole org shares.
131
+ */
132
+ readonly orgLimiter?: AuthLimiter | undefined;
93
133
  readonly mfa?: Partial<AuthMfaPolicy> | undefined;
94
134
  readonly providers?: readonly OAuthProviderId[] | undefined;
135
+ /** Defaults to `'verified-email'` — both halves proven. See `OAuthLinkPolicy`. */
136
+ readonly link?: OAuthLinkPolicy | undefined;
95
137
  }
96
138
 
97
139
  export interface Auth {
@@ -101,8 +143,12 @@ export interface Auth {
101
143
  readonly password: PasswordPolicy;
102
144
  readonly rateLimit: AuthRateLimitPolicy;
103
145
  readonly limiter: AuthLimiter;
146
+ /** The tenant bucket's own policy — `rateLimit` with `orgMaxAttempts` as its `maxAttempts`. */
147
+ readonly orgRateLimit: AuthRateLimitPolicy;
148
+ readonly orgLimiter: AuthLimiter;
104
149
  readonly mfa: AuthMfaPolicy;
105
150
  readonly providers: readonly OAuthProviderId[];
151
+ readonly link: OAuthLinkPolicy;
106
152
  }
107
153
 
108
154
  export function defineAuth(config: AuthConfigInput): Auth {
@@ -110,15 +156,30 @@ export function defineAuth(config: AuthConfigInput): Auth {
110
156
  const session: SessionPolicy = { ...DEFAULT_SESSION_POLICY, ...config.session };
111
157
  const password: PasswordPolicy = { ...DEFAULT_PASSWORD_POLICY, ...config.password };
112
158
  const rateLimit: AuthRateLimitPolicy = { ...DEFAULT_AUTH_RATE_LIMIT, ...config.rateLimit };
159
+ const limiter = config.limiter ?? createAuthLimiter(clock, rateLimit);
160
+ assertAuthLimiterPolicy(rateLimit, limiter);
161
+ // The tenant bucket is a noisy-neighbour cap, not a credential-guessing allowance, so an app
162
+ // that declares `scope: 'shared'` for its LOCKOUT is not also required to ship a shared limiter
163
+ // for this one — per replica it approximates to `orgMaxAttempts × replicas`, which is a
164
+ // throughput ceiling and discloses nothing. An INJECTED org limiter is still compared, because
165
+ // then the app has made a claim about what it enforces and `Auth.orgRateLimit` reports it.
166
+ const orgLimits = orgRateLimit(rateLimit);
167
+ const orgLimiter = config.orgLimiter ?? createAuthLimiter(clock, orgLimits);
168
+ if (config.orgLimiter !== undefined) assertAuthLimiterPolicy(orgLimits, config.orgLimiter);
113
169
  return Object.freeze({
114
170
  adapter: config.adapter,
115
171
  clock,
116
172
  sessions: { store: config.adapter, policy: session, clock },
117
173
  password,
118
174
  rateLimit,
119
- limiter: createAuthLimiter(clock, rateLimit),
175
+ limiter,
176
+ // What the limiter in use actually enforces, not the derivation — the two differ in `scope`
177
+ // exactly when the app declared 'shared' and left this limiter to the default.
178
+ orgRateLimit: orgLimiter.policy,
179
+ orgLimiter,
120
180
  mfa: { issuer: config.mfa?.issuer ?? 'Ultimate', required: config.mfa?.required ?? false },
121
- providers: config.providers ?? OAUTH_PROVIDER_IDS,
181
+ providers: config.providers ?? oauthProviderIds(),
182
+ link: config.link ?? 'verified-email',
122
183
  });
123
184
  }
124
185
 
@@ -134,7 +195,7 @@ export async function register(auth: Auth, input: RegisterInput): Promise<AuthUs
134
195
  checkPasswordStrength(input.password, { policy: auth.password });
135
196
  return await auth.adapter.createUser({
136
197
  id: uuid(auth.clock),
137
- email: input.email.trim().toLowerCase(),
198
+ email: normaliseEmail(input.email),
138
199
  passwordHash: await hashPassword(input.password, auth.password.params),
139
200
  orgId: input.orgId ?? null,
140
201
  roles: input.roles ?? [],
@@ -165,10 +226,16 @@ export interface LoginResult {
165
226
  export async function login(auth: Auth, input: LoginInput): Promise<LoginResult> {
166
227
  const account = accountKey(input.email);
167
228
  const ip = input.ip ?? null;
168
- auth.limiter.assertAllowed(account);
169
- if (ip !== null) auth.limiter.assertAllowed(ipKey(ip));
229
+ await auth.limiter.assertAllowed(account);
230
+ if (ip !== null) await auth.limiter.assertAllowed(ipKey(ip));
231
+
232
+ const user = await auth.adapter.findUserByEmail(normaliseEmail(input.email));
233
+ // The tenant bucket can only be consulted once the address resolves to an org, which is still
234
+ // before the KDF runs — the expensive half of this function — so it costs one map lookup and
235
+ // caps a spray that per-IP and per-account buckets both let through.
236
+ const org = user?.orgId ?? null;
237
+ if (org !== null) await auth.orgLimiter.assertAllowed(orgKey(org));
170
238
 
171
- const user = await auth.adapter.findUserByEmail(input.email.trim().toLowerCase());
172
239
  const usable = user !== null && user.disabledAt === null;
173
240
  const verification = await verifyPassword({
174
241
  hash: usable ? user.passwordHash : null,
@@ -177,13 +244,17 @@ export async function login(auth: Auth, input: LoginInput): Promise<LoginResult>
177
244
  });
178
245
 
179
246
  if (!verification.ok || user === null) {
180
- auth.limiter.recordFailure(account);
181
- if (ip !== null) auth.limiter.recordFailure(ipKey(ip));
247
+ await auth.limiter.recordFailure(account);
248
+ if (ip !== null) await auth.limiter.recordFailure(ipKey(ip));
249
+ if (org !== null) await auth.orgLimiter.recordFailure(orgKey(org));
182
250
  throw loginFailed();
183
251
  }
184
252
 
185
- auth.limiter.recordSuccess(account);
186
- if (ip !== null) auth.limiter.recordSuccess(ipKey(ip));
253
+ await auth.limiter.recordSuccess(account);
254
+ if (ip !== null) await auth.limiter.recordSuccess(ipKey(ip));
255
+ // No `recordSuccess` on the tenant bucket, and that asymmetry is the point: one member signing
256
+ // in successfully must not clear the count a broken integration is running up beside them, or
257
+ // the tenant cap is cleared by exactly the traffic that proves the tenant is still in use.
187
258
 
188
259
  // Parameters were raised since this hash was written: upgrade it now, while we hold the
189
260
  // plaintext. This is the only moment it is possible without asking the user for anything.
@@ -193,8 +264,10 @@ export async function login(auth: Auth, input: LoginInput): Promise<LoginResult>
193
264
  });
194
265
  }
195
266
 
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.
267
+ // Password proven, second factor not. No half-authenticated session is written here and nothing
268
+ // is persisted to correlate the two legs finishing MFA is the app's, and `X_MFA_REQUIRED`'s
269
+ // `fix:` is the instruction. The framework's own second leg is blocked on a sealed pending-MFA
270
+ // credential; the constraint is written down in `packages/auth/CLAUDE.md`.
198
271
  if (user.mfaSecret !== null) throw mfaRequired(user.id);
199
272
 
200
273
  const issued = await createSession(auth.sessions, {
@@ -226,8 +299,17 @@ export async function authenticate(auth: Auth, token: string | null): Promise<Po
226
299
  return resolveActor({ kind: 'user', user, session });
227
300
  }
228
301
 
302
+ /**
303
+ * The SECRET half is checked before the row is deleted, exactly as `verifySession` checks it. The
304
+ * id half is not a credential — it is in a device list, in a log line and in an audit row — so
305
+ * deleting on it alone made "sign this person out" an unauthenticated write for anyone who had
306
+ * ever seen one. Constant-time, and `false` for every failure, so it stays a poor oracle too.
307
+ */
229
308
  export async function logout(auth: Auth, token: string): Promise<boolean> {
230
309
  const parsed = parseSessionToken(token);
231
310
  if (parsed === null) return false;
311
+ const session = await auth.adapter.getSession(parsed.id);
312
+ if (session === null) return false;
313
+ if (!timingSafeEqual(sha256Hex(parsed.secret), session.tokenHash)) return false;
232
314
  return await auth.adapter.deleteSession(parsed.id);
233
315
  }
@@ -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
+ }