@remit/auth-service 0.0.16 → 0.1.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/people.ts ADDED
@@ -0,0 +1,541 @@
1
+ import { createHash, randomBytes, randomUUID } from "node:crypto";
2
+ import type {
3
+ Person,
4
+ PersonStatus,
5
+ StepUpAction,
6
+ } from "@remit/api-openapi-types";
7
+ import { deriveAccountConfigId } from "@remit/data-ports/id";
8
+ import { calendarFeedTokens } from "@remit/drizzle-sqlite-schema";
9
+ import { and, asc, count, eq, gt, lte, sql } from "drizzle-orm";
10
+ import type { Auth } from "./auth.js";
11
+ import {
12
+ applySignInSwitch,
13
+ findUser,
14
+ HOST_CLIENT_IP,
15
+ type IdentityStore,
16
+ revokeUser,
17
+ type UserRow,
18
+ } from "./identity-store.js";
19
+ import {
20
+ auth_account,
21
+ auth_session,
22
+ auth_user,
23
+ auth_verification,
24
+ identity_audit_event,
25
+ identity_step_up,
26
+ } from "./schema/auth-schema-sqlite.js";
27
+
28
+ export type { Person, PersonStatus, StepUpAction };
29
+
30
+ export interface StepUpGrant {
31
+ stepUpToken: string;
32
+ expiresAt: number;
33
+ }
34
+
35
+ export interface PasswordReset {
36
+ person: Person;
37
+ resetUrl: string;
38
+ expiresAt: number;
39
+ }
40
+
41
+ export type PeopleRefusal =
42
+ | "caller_disabled"
43
+ | "not_found"
44
+ | "step_up_required"
45
+ | "wrong_password"
46
+ | "step_up_locked"
47
+ | "cannot_disable_self";
48
+
49
+ export interface Refused {
50
+ ok: false;
51
+ refusal: PeopleRefusal;
52
+ }
53
+
54
+ export type PeopleOutcome<T> = { ok: true; value: T } | Refused;
55
+
56
+ export interface PeopleRequest {
57
+ callerId: string;
58
+ clientIp: string;
59
+ }
60
+
61
+ export interface TargetedRequest extends PeopleRequest {
62
+ targetId: string;
63
+ stepUpToken: string;
64
+ }
65
+
66
+ export interface StepUpRequest extends PeopleRequest {
67
+ targetId: string;
68
+ action: StepUpAction;
69
+ password: string;
70
+ }
71
+
72
+ export type GuardRefusal = "RefusedSignInOff" | "RefusedForeignOrigin";
73
+
74
+ export interface PeopleDirectory {
75
+ listPeople(request: PeopleRequest): Promise<PeopleOutcome<Person[]>>;
76
+ createStepUp(request: StepUpRequest): Promise<PeopleOutcome<StepUpGrant>>;
77
+ signOutPerson(request: TargetedRequest): Promise<PeopleOutcome<Person>>;
78
+ updatePerson(
79
+ request: TargetedRequest & { status: PersonStatus },
80
+ ): Promise<PeopleOutcome<Person>>;
81
+ createPasswordReset(
82
+ request: TargetedRequest,
83
+ ): Promise<PeopleOutcome<PasswordReset>>;
84
+ recordRefusal(
85
+ request: PeopleRequest & { targetId: string; outcome: GuardRefusal },
86
+ ): void;
87
+ }
88
+
89
+ export interface PeopleAdmin extends PeopleDirectory {
90
+ enableByEmail(email: string): Promise<PeopleOutcome<Person>>;
91
+ resetByEmail(email: string): Promise<PeopleOutcome<PasswordReset>>;
92
+ applySignInSwitch(signInEnabled: boolean): void;
93
+ close(): void;
94
+ }
95
+
96
+ export interface PeopleConfig {
97
+ auth: Auth;
98
+ store: IdentityStore;
99
+ baseURL: string;
100
+ }
101
+
102
+ const STEP_UP_TTL_MS = 5 * 60 * 1000;
103
+ const STEP_UP_WINDOW_MS = 15 * 60 * 1000;
104
+ const STEP_UP_MAX_FAILURES = 5;
105
+ const RESET_TTL_MS = 24 * 60 * 60 * 1000;
106
+ const CREDENTIAL_PROVIDER = "credential";
107
+ const SHELL_ACTOR = "shell";
108
+
109
+ const refused = (refusal: PeopleRefusal): Refused => ({ ok: false, refusal });
110
+
111
+ const ok = <T>(value: T): { ok: true; value: T } => ({ ok: true, value });
112
+
113
+ const newSecret = (): string => randomBytes(32).toString("base64url");
114
+
115
+ const digestOf = (value: string): string =>
116
+ createHash("sha256").update(value).digest("base64url");
117
+
118
+ /**
119
+ * The identifier better-auth looks a reset row up by under
120
+ * `verification.storeIdentifier: { overrides: { "reset-password": "hashed" } }`:
121
+ * the SHA-256 of `reset-password:<token>`, base64url without padding.
122
+ */
123
+ export const resetIdentifierOf = (token: string): string =>
124
+ digestOf(`reset-password:${token}`);
125
+
126
+ const activeSessionCounts = (store: IdentityStore): Map<string, number> =>
127
+ new Map(
128
+ store.db
129
+ .select({ userId: auth_session.userId, sessions: count() })
130
+ .from(auth_session)
131
+ .where(gt(auth_session.expiresAt, new Date()))
132
+ .groupBy(auth_session.userId)
133
+ .all()
134
+ .map((row) => [row.userId, row.sessions]),
135
+ );
136
+
137
+ const toPerson = (
138
+ user: UserRow,
139
+ callerId: string,
140
+ sessions: Map<string, number>,
141
+ ): Person => ({
142
+ userId: user.userId,
143
+ email: user.email,
144
+ createdAt: user.createdAt.getTime(),
145
+ lastSignInAt: user.lastSignInAt.getTime(),
146
+ sessionCount: sessions.get(user.userId) ?? 0,
147
+ status: user.disabled ? "Disabled" : "Active",
148
+ isCaller: user.userId === callerId,
149
+ });
150
+
151
+ const personOf = (
152
+ store: IdentityStore,
153
+ userId: string,
154
+ callerId: string,
155
+ ): Person => {
156
+ const user = findUser(store, userId);
157
+ if (!user)
158
+ throw new Error(`user ${userId} vanished inside its own transaction`);
159
+ return toPerson(user, callerId, activeSessionCounts(store));
160
+ };
161
+
162
+ const userByEmail = (
163
+ store: IdentityStore,
164
+ email: string,
165
+ ): UserRow | undefined => {
166
+ const row = store.db
167
+ .select({ userId: auth_user.id })
168
+ .from(auth_user)
169
+ .where(eq(sql`lower(${auth_user.email})`, email.trim().toLowerCase()))
170
+ .get();
171
+ return row && findUser(store, row.userId);
172
+ };
173
+
174
+ /**
175
+ * Whether the caller may act at all, read inside the write transaction so two
176
+ * users disabling each other are serialized and the second finds itself
177
+ * disabled. A refusal is audited in the same transaction, which then commits.
178
+ */
179
+ const callerRefusal = (
180
+ store: IdentityStore,
181
+ request: PeopleRequest,
182
+ target: string,
183
+ ): Refused | undefined => {
184
+ const caller = findUser(store, request.callerId);
185
+ if (caller && !caller.disabled) return undefined;
186
+ store.audit({
187
+ actor: request.callerId,
188
+ target,
189
+ action: "PeopleRequestRefused",
190
+ outcome: "RefusedCallerDisabled",
191
+ clientIp: request.clientIp,
192
+ });
193
+ return refused("caller_disabled");
194
+ };
195
+
196
+ const stepUpRefusal = (
197
+ store: IdentityStore,
198
+ request: TargetedRequest,
199
+ action: StepUpAction,
200
+ ): Refused | undefined => {
201
+ const spent = store.db
202
+ .delete(identity_step_up)
203
+ .where(
204
+ and(
205
+ eq(identity_step_up.digest, digestOf(request.stepUpToken)),
206
+ eq(identity_step_up.callerUserId, request.callerId),
207
+ eq(identity_step_up.action, action),
208
+ eq(identity_step_up.targetUserId, request.targetId),
209
+ gt(identity_step_up.expiresAt, new Date()),
210
+ ),
211
+ )
212
+ .run();
213
+ if (spent.changes === 1) return undefined;
214
+ store.audit({
215
+ actor: request.callerId,
216
+ target: request.targetId,
217
+ action: "PeopleRequestRefused",
218
+ outcome: "RefusedStepUp",
219
+ clientIp: request.clientIp,
220
+ });
221
+ return refused("step_up_required");
222
+ };
223
+
224
+ const issueReset = (
225
+ store: IdentityStore,
226
+ userId: string,
227
+ scrambledHash: string,
228
+ ): { token: string; expiresAt: Date } => {
229
+ const { db } = store;
230
+ const now = new Date();
231
+ const token = newSecret();
232
+ const expiresAt = new Date(now.getTime() + RESET_TTL_MS);
233
+ db.delete(auth_verification).where(eq(auth_verification.value, userId)).run();
234
+ db.insert(auth_verification)
235
+ .values({
236
+ id: randomUUID(),
237
+ identifier: resetIdentifierOf(token),
238
+ value: userId,
239
+ expiresAt,
240
+ createdAt: now,
241
+ updatedAt: now,
242
+ })
243
+ .run();
244
+ db.update(auth_account)
245
+ .set({ password: scrambledHash, updatedAt: now })
246
+ .where(
247
+ and(
248
+ eq(auth_account.userId, userId),
249
+ eq(auth_account.providerId, CREDENTIAL_PROVIDER),
250
+ ),
251
+ )
252
+ .run();
253
+ revokeUser(store, userId);
254
+ return { token, expiresAt };
255
+ };
256
+
257
+ const disable = (store: IdentityStore, userId: string): void => {
258
+ store.db
259
+ .update(auth_user)
260
+ .set({ disabled: true })
261
+ .where(eq(auth_user.id, userId))
262
+ .run();
263
+ revokeUser(store, userId);
264
+ store.db
265
+ .delete(calendarFeedTokens)
266
+ .where(
267
+ eq(calendarFeedTokens.accountConfigId, deriveAccountConfigId(userId)),
268
+ )
269
+ .run();
270
+ };
271
+
272
+ const enable = (store: IdentityStore, userId: string): void => {
273
+ store.db
274
+ .update(auth_user)
275
+ .set({ disabled: false })
276
+ .where(eq(auth_user.id, userId))
277
+ .run();
278
+ };
279
+
280
+ export const createPeopleDirectory = async (
281
+ config: PeopleConfig,
282
+ ): Promise<PeopleAdmin> => {
283
+ const { store } = config;
284
+ const { password: passwords } = await config.auth.$context;
285
+ const resetUrlOf = (token: string): string =>
286
+ `${config.baseURL}/reset-password/${token}`;
287
+ const scrambledHash = (): Promise<string> => passwords.hash(newSecret());
288
+
289
+ const credentialHash = (userId: string): string | null | undefined =>
290
+ store.db
291
+ .select({ hash: auth_account.password })
292
+ .from(auth_account)
293
+ .where(
294
+ and(
295
+ eq(auth_account.userId, userId),
296
+ eq(auth_account.providerId, CREDENTIAL_PROVIDER),
297
+ ),
298
+ )
299
+ .get()?.hash;
300
+
301
+ const passwordMatches = async (
302
+ userId: string,
303
+ password: string,
304
+ ): Promise<boolean> => {
305
+ const hash = credentialHash(userId);
306
+ if (!hash) return false;
307
+ return passwords.verify({ hash, password });
308
+ };
309
+
310
+ const reserveAttempt = (request: StepUpRequest): PeopleOutcome<string> =>
311
+ store.immediate(() => {
312
+ const callerRefused = callerRefusal(store, request, request.targetId);
313
+ if (callerRefused) return callerRefused;
314
+ if (!findUser(store, request.targetId)) return refused("not_found");
315
+ const failures =
316
+ store.db
317
+ .select({ failures: count() })
318
+ .from(identity_audit_event)
319
+ .where(
320
+ and(
321
+ eq(identity_audit_event.actor, request.callerId),
322
+ eq(identity_audit_event.action, "StepUpFailed"),
323
+ gt(
324
+ identity_audit_event.occurredAt,
325
+ new Date(Date.now() - STEP_UP_WINDOW_MS),
326
+ ),
327
+ ),
328
+ )
329
+ .get()?.failures ?? 0;
330
+ if (failures >= STEP_UP_MAX_FAILURES) return refused("step_up_locked");
331
+ return ok(
332
+ store.audit({
333
+ actor: request.callerId,
334
+ target: request.targetId,
335
+ action: "StepUpFailed",
336
+ outcome: "RefusedStepUp",
337
+ clientIp: request.clientIp,
338
+ }),
339
+ );
340
+ });
341
+
342
+ const grantStepUp = (
343
+ request: StepUpRequest,
344
+ auditEventId: string,
345
+ ): PeopleOutcome<StepUpGrant> =>
346
+ store.immediate(() => {
347
+ const callerRefused = callerRefusal(store, request, request.targetId);
348
+ if (callerRefused) return callerRefused;
349
+ const now = new Date();
350
+ const stepUpToken = newSecret();
351
+ const expiresAt = new Date(now.getTime() + STEP_UP_TTL_MS);
352
+ store.relabel(auditEventId, {
353
+ actor: request.callerId,
354
+ target: request.targetId,
355
+ action: "StepUpPassed",
356
+ outcome: "Succeeded",
357
+ clientIp: request.clientIp,
358
+ });
359
+ store.db
360
+ .delete(identity_step_up)
361
+ .where(lte(identity_step_up.expiresAt, now))
362
+ .run();
363
+ store.db
364
+ .insert(identity_step_up)
365
+ .values({
366
+ digest: digestOf(stepUpToken),
367
+ callerUserId: request.callerId,
368
+ action: request.action,
369
+ targetUserId: request.targetId,
370
+ expiresAt,
371
+ })
372
+ .run();
373
+ return ok({ stepUpToken, expiresAt: expiresAt.getTime() });
374
+ });
375
+
376
+ const directory: PeopleAdmin = {
377
+ listPeople: async (request) =>
378
+ store.immediate(() => {
379
+ const callerRefused = callerRefusal(store, request, "all");
380
+ if (callerRefused) return callerRefused;
381
+ const sessions = activeSessionCounts(store);
382
+ const users = store.db
383
+ .select({
384
+ userId: auth_user.id,
385
+ email: auth_user.email,
386
+ disabled: auth_user.disabled,
387
+ createdAt: auth_user.createdAt,
388
+ lastSignInAt: auth_user.lastSignInAt,
389
+ })
390
+ .from(auth_user)
391
+ .orderBy(asc(auth_user.email))
392
+ .all();
393
+ return ok(
394
+ users.map((user) => toPerson(user, request.callerId, sessions)),
395
+ );
396
+ }),
397
+
398
+ createStepUp: async (request) => {
399
+ const attempt = reserveAttempt(request);
400
+ if (!attempt.ok) return attempt;
401
+ if (!(await passwordMatches(request.callerId, request.password))) {
402
+ return refused("wrong_password");
403
+ }
404
+ return grantStepUp(request, attempt.value);
405
+ },
406
+
407
+ signOutPerson: async (request) =>
408
+ store.immediate(() => {
409
+ const refusal =
410
+ callerRefusal(store, request, request.targetId) ??
411
+ stepUpRefusal(store, request, "SignOut");
412
+ if (refusal) return refusal;
413
+ if (!findUser(store, request.targetId)) return refused("not_found");
414
+ revokeUser(store, request.targetId);
415
+ store.audit({
416
+ actor: request.callerId,
417
+ target: request.targetId,
418
+ action: "SessionsRevoked",
419
+ outcome: "Succeeded",
420
+ clientIp: request.clientIp,
421
+ });
422
+ return ok(personOf(store, request.targetId, request.callerId));
423
+ }),
424
+
425
+ updatePerson: async (request) =>
426
+ store.immediate(() => {
427
+ const disabling = request.status === "Disabled";
428
+ const callerRefused = callerRefusal(store, request, request.targetId);
429
+ if (callerRefused) return callerRefused;
430
+ if (disabling && request.targetId === request.callerId) {
431
+ store.audit({
432
+ actor: request.callerId,
433
+ target: request.targetId,
434
+ action: "UserDisabled",
435
+ outcome: "RefusedSelf",
436
+ clientIp: request.clientIp,
437
+ });
438
+ return refused("cannot_disable_self");
439
+ }
440
+ const stepUpRefused = stepUpRefusal(
441
+ store,
442
+ request,
443
+ disabling ? "Disable" : "Enable",
444
+ );
445
+ if (stepUpRefused) return stepUpRefused;
446
+ if (!findUser(store, request.targetId)) return refused("not_found");
447
+ if (disabling) disable(store, request.targetId);
448
+ else enable(store, request.targetId);
449
+ store.audit({
450
+ actor: request.callerId,
451
+ target: request.targetId,
452
+ action: disabling ? "UserDisabled" : "UserEnabled",
453
+ outcome: "Succeeded",
454
+ clientIp: request.clientIp,
455
+ });
456
+ return ok(personOf(store, request.targetId, request.callerId));
457
+ }),
458
+
459
+ createPasswordReset: async (request) => {
460
+ const scrambled = await scrambledHash();
461
+ const issued = store.immediate(
462
+ (): PeopleOutcome<{ token: string; expiresAt: Date }> => {
463
+ const refusal =
464
+ callerRefusal(store, request, request.targetId) ??
465
+ stepUpRefusal(store, request, "ResetPassword");
466
+ if (refusal) return refusal;
467
+ if (!findUser(store, request.targetId)) return refused("not_found");
468
+ const reset = issueReset(store, request.targetId, scrambled);
469
+ store.audit({
470
+ actor: request.callerId,
471
+ target: request.targetId,
472
+ action: "PasswordResetIssued",
473
+ outcome: "Succeeded",
474
+ clientIp: request.clientIp,
475
+ });
476
+ return ok(reset);
477
+ },
478
+ );
479
+ if (!issued.ok) return issued;
480
+ return ok({
481
+ person: personOf(store, request.targetId, request.callerId),
482
+ resetUrl: resetUrlOf(issued.value.token),
483
+ expiresAt: issued.value.expiresAt.getTime(),
484
+ });
485
+ },
486
+
487
+ recordRefusal: (request) => {
488
+ store.immediate(() =>
489
+ store.audit({
490
+ actor: request.callerId,
491
+ target: request.targetId,
492
+ action: "PeopleRequestRefused",
493
+ outcome: request.outcome,
494
+ clientIp: request.clientIp,
495
+ }),
496
+ );
497
+ },
498
+
499
+ enableByEmail: async (email) =>
500
+ store.immediate(() => {
501
+ const user = userByEmail(store, email);
502
+ if (!user) return refused("not_found");
503
+ enable(store, user.userId);
504
+ store.audit({
505
+ actor: SHELL_ACTOR,
506
+ target: user.userId,
507
+ action: "UserEnabled",
508
+ outcome: "Succeeded",
509
+ clientIp: HOST_CLIENT_IP,
510
+ });
511
+ return ok(personOf(store, user.userId, SHELL_ACTOR));
512
+ }),
513
+
514
+ resetByEmail: async (email) => {
515
+ const scrambled = await scrambledHash();
516
+ return store.immediate(() => {
517
+ const user = userByEmail(store, email);
518
+ if (!user) return refused("not_found");
519
+ const reset = issueReset(store, user.userId, scrambled);
520
+ store.audit({
521
+ actor: SHELL_ACTOR,
522
+ target: user.userId,
523
+ action: "PasswordResetIssued",
524
+ outcome: "Succeeded",
525
+ clientIp: HOST_CLIENT_IP,
526
+ });
527
+ return ok({
528
+ person: personOf(store, user.userId, SHELL_ACTOR),
529
+ resetUrl: resetUrlOf(reset.token),
530
+ expiresAt: reset.expiresAt.getTime(),
531
+ });
532
+ });
533
+ },
534
+
535
+ applySignInSwitch: (signInEnabled) =>
536
+ applySignInSwitch(store, signInEnabled),
537
+
538
+ close: () => store.close(),
539
+ };
540
+ return directory;
541
+ };
@@ -5,6 +5,7 @@ import { join, resolve } from "node:path";
5
5
  import { after, before, describe, it } from "node:test";
6
6
  import type { Auth } from "./auth.js";
7
7
  import { CLIENT_IP_HEADER, createAuth } from "./auth.js";
8
+ import { openIdentityStore } from "./identity-store.js";
8
9
 
9
10
  /**
10
11
  * #441: better-auth answers a rate-limited request from inside its own router,
@@ -119,13 +120,19 @@ describe("a rate-limited auth response is logged", () => {
119
120
  rmSync(dir, { recursive: true, force: true });
120
121
  });
121
122
 
122
- const build = async (name: string): Promise<Auth> =>
123
- createAuth({
124
- connectionString: await createDatabase(dir, name),
125
- secret: "rate-limit-log-test-secret-value-32chars",
126
- baseURL: "http://localhost:3000",
127
- selfSignUpEnabled: true,
128
- });
123
+ const build = async (name: string): Promise<Auth> => {
124
+ const connectionString = await createDatabase(dir, name);
125
+ return createAuth(
126
+ {
127
+ connectionString,
128
+ secret: "rate-limit-log-test-secret-value-32chars",
129
+ baseURL: "http://localhost:3000",
130
+ selfSignUpEnabled: true,
131
+ signInEnabled: true,
132
+ },
133
+ await openIdentityStore(connectionString),
134
+ );
135
+ };
129
136
 
130
137
  it("names the endpoint and the address the limit is keyed on", async () => {
131
138
  const handler = withRateLimitLogging(await build("logged"));
@@ -5,6 +5,7 @@ import { join, resolve } from "node:path";
5
5
  import { after, before, describe, it } from "node:test";
6
6
  import type { Auth } from "./auth.js";
7
7
  import { CLIENT_IP_HEADER, createAuth } from "./auth.js";
8
+ import { openIdentityStore } from "./identity-store.js";
8
9
 
9
10
  const SIGN_IN_MAX = 2;
10
11
  const MIGRATIONS = resolve(
@@ -68,13 +69,19 @@ describe("sign-in rate limiting is per client", () => {
68
69
  rmSync(dir, { recursive: true, force: true });
69
70
  });
70
71
 
71
- const build = async (name: string): Promise<Auth> =>
72
- createAuth({
73
- connectionString: await createDatabase(dir, name),
74
- secret: "rate-limit-test-secret-value-32chars-min",
75
- baseURL: "http://localhost:3000",
76
- selfSignUpEnabled: true,
77
- });
72
+ const build = async (name: string): Promise<Auth> => {
73
+ const connectionString = await createDatabase(dir, name);
74
+ return createAuth(
75
+ {
76
+ connectionString,
77
+ secret: "rate-limit-test-secret-value-32chars-min",
78
+ baseURL: "http://localhost:3000",
79
+ selfSignUpEnabled: true,
80
+ signInEnabled: true,
81
+ },
82
+ await openIdentityStore(connectionString),
83
+ );
84
+ };
78
85
 
79
86
  it("locks out only the client that spent the budget", async () => {
80
87
  const auth = await build("per-client");
@@ -10,7 +10,14 @@
10
10
  // every deployed instance and rewrite all five tables. Match better-auth's
11
11
  // runtime expectations by hand when a version bump changes them, and let
12
12
  // vps-migrations-drift.sqlite.test.ts catch the migration side.
13
- import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
13
+ import { sql } from "drizzle-orm";
14
+ import {
15
+ index,
16
+ integer,
17
+ sqliteTable,
18
+ text,
19
+ uniqueIndex,
20
+ } from "drizzle-orm/sqlite-core";
14
21
 
15
22
  export const auth_user = sqliteTable("auth_user", {
16
23
  id: text("id").primaryKey(),
@@ -27,6 +34,10 @@ export const auth_user = sqliteTable("auth_user", {
27
34
  .$defaultFn(() => /* @__PURE__ */ new Date())
28
35
  .$onUpdate(() => /* @__PURE__ */ new Date())
29
36
  .notNull(),
37
+ disabled: integer("disabled", { mode: "boolean" }).default(false).notNull(),
38
+ lastSignInAt: integer("last_sign_in_at", { mode: "timestamp" })
39
+ .default(sql`0`)
40
+ .notNull(),
30
41
  });
31
42
 
32
43
  export const auth_session = sqliteTable(
@@ -107,3 +118,36 @@ export const auth_jwks = sqliteTable("auth_jwks", {
107
118
  alg: text("alg"),
108
119
  crv: text("crv"),
109
120
  });
121
+
122
+ // Not better-auth tables: the self-host People operations own these
123
+ // (packages/auth-service/src/people.ts). Times are epoch milliseconds.
124
+ export const identity_audit_event = sqliteTable(
125
+ "identity_audit_event",
126
+ {
127
+ auditEventId: text("audit_event_id").primaryKey(),
128
+ occurredAt: integer("occurred_at", { mode: "timestamp_ms" }).notNull(),
129
+ actor: text("actor").notNull(),
130
+ target: text("target").notNull(),
131
+ action: text("action").notNull(),
132
+ outcome: text("outcome").notNull(),
133
+ clientIp: text("client_ip").notNull(),
134
+ },
135
+ (table) => [
136
+ index("identity_audit_event_occurred_at_idx").on(table.occurredAt),
137
+ ],
138
+ );
139
+
140
+ export const identity_step_up = sqliteTable(
141
+ "identity_step_up",
142
+ {
143
+ digest: text("digest").notNull(),
144
+ callerUserId: text("caller_user_id").notNull(),
145
+ action: text("action").notNull(),
146
+ targetUserId: text("target_user_id").notNull(),
147
+ expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(),
148
+ },
149
+ (table) => [
150
+ uniqueIndex("identity_step_up_digest_idx").on(table.digest),
151
+ index("identity_step_up_caller_idx").on(table.callerUserId),
152
+ ],
153
+ );
@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
2
2
  import { describe, it } from "node:test";
3
3
  import { createAuth } from "./auth.js";
4
4
  import { resolveSelfSignUpEnabled } from "./config.js";
5
+ import { openIdentityStore } from "./identity-store.js";
5
6
 
6
7
  describe("resolveSelfSignUpEnabled", () => {
7
8
  it("defaults to enabled when unset or blank", () => {
@@ -36,7 +37,10 @@ const baseConfig = {
36
37
 
37
38
  describe("createAuth self-signup gate", () => {
38
39
  it("rejects a signup request server-side when disabled, before any DB access", async () => {
39
- const auth = await createAuth({ ...baseConfig, selfSignUpEnabled: false });
40
+ const auth = await createAuth(
41
+ { ...baseConfig, selfSignUpEnabled: false, signInEnabled: true },
42
+ await openIdentityStore(baseConfig.connectionString),
43
+ );
40
44
  const request = new Request(
41
45
  "http://localhost:3000/api/auth/sign-up/email",
42
46
  {
package/src/verifier.ts CHANGED
@@ -10,6 +10,8 @@ export {
10
10
  AUTH_BASE_PATH,
11
11
  AUTH_JWKS_PATH,
12
12
  AUTH_TOKEN_PATH,
13
+ resolveSelfSignUpEnabled,
14
+ resolveSignInEnabled,
13
15
  resolveVerifierConfig,
14
16
  type VerifierConfig,
15
17
  } from "./config.js";