@remit/auth-service 0.0.16 → 0.1.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/auth-service",
3
- "version": "0.0.16",
3
+ "version": "0.1.1",
4
4
  "description": "better-auth identity service for the self-host stack",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -21,6 +21,10 @@
21
21
  "./schema": {
22
22
  "types": "./src/schema/auth-schema-sqlite.ts",
23
23
  "default": "./src/schema/auth-schema-sqlite.ts"
24
+ },
25
+ "./people": {
26
+ "types": "./src/people.ts",
27
+ "default": "./src/people.ts"
24
28
  }
25
29
  },
26
30
  "scripts": {
@@ -38,7 +42,11 @@
38
42
  "better-sqlite3": "^12.11.1",
39
43
  "drizzle-orm": "^0.45.2",
40
44
  "jose": "^6.2.3",
41
- "@types/better-sqlite3": "^7.6.13"
45
+ "@types/better-sqlite3": "^7.6.13",
46
+ "@remit/data-ports": "*",
47
+ "@remit/drizzle-sqlite-schema": "*",
48
+ "@remit/api-openapi-types": "*",
49
+ "@remit/domain-enums": "*"
42
50
  },
43
51
  "license": "MIT",
44
52
  "publishConfig": {
package/src/auth.ts CHANGED
@@ -1,7 +1,16 @@
1
+ import { logger } from "@remit/logger";
1
2
  import type { Auth as BetterAuthInstance } from "better-auth";
2
- import { betterAuth } from "better-auth";
3
+ import { betterAuth, type GenericEndpointContext } from "better-auth";
3
4
  import { drizzleAdapter } from "better-auth/adapters/drizzle";
5
+ import { APIError, createAuthMiddleware, isAPIError } from "better-auth/api";
4
6
  import { jwt } from "better-auth/plugins";
7
+ import {
8
+ deleteSessions,
9
+ type IdentityStore,
10
+ isUserDisabled,
11
+ markSignedIn,
12
+ sessionUserOf,
13
+ } from "./identity-store.js";
5
14
  import * as authSchemaSqlite from "./schema/auth-schema-sqlite.js";
6
15
 
7
16
  export interface AuthConfig {
@@ -20,6 +29,7 @@ export interface AuthConfig {
20
29
  * `disableSignUp`; accounts are then provisioned out-of-band.
21
30
  */
22
31
  selfSignUpEnabled: boolean;
32
+ signInEnabled: boolean;
23
33
  }
24
34
 
25
35
  // The drizzle adapter better-auth runs against. better-sqlite3 and its drizzle
@@ -52,6 +62,92 @@ const buildAuthAdapter = async (config: AuthConfig) => {
52
62
  */
53
63
  export const CLIENT_IP_HEADER = "x-remit-client-ip";
54
64
 
65
+ export const SIGN_IN_DISABLED = {
66
+ code: "SIGN_IN_DISABLED",
67
+ message: "Sign-in is turned off on this server.",
68
+ } as const;
69
+
70
+ export const ACCOUNT_DISABLED = {
71
+ code: "ACCOUNT_DISABLED",
72
+ message: "This account is disabled.",
73
+ } as const;
74
+
75
+ const RESET_TOKEN_TTL_SECONDS = 24 * 60 * 60;
76
+
77
+ const MIN_PASSWORD_LENGTH = 12;
78
+
79
+ const MAX_PASSWORD_LENGTH = 128;
80
+
81
+ const UNKNOWN = "unknown";
82
+
83
+ const clientIpOf = (ctx: GenericEndpointContext | null): string =>
84
+ ctx?.request?.headers.get(CLIENT_IP_HEADER) ??
85
+ ctx?.headers?.get(CLIENT_IP_HEADER) ??
86
+ UNKNOWN;
87
+
88
+ const SESSION_PATHS: ReadonlySet<string> = new Set(["/token", "/get-session"]);
89
+
90
+ /**
91
+ * The kill switch, then the disabled check on the two paths that carry a
92
+ * session forward. A sign-in that commits its session just after a disable
93
+ * would otherwise keep minting tokens; here that user's sessions go and the
94
+ * mint is refused.
95
+ */
96
+ const gate = (store: IdentityStore, signInEnabled: boolean) =>
97
+ createAuthMiddleware(async (ctx) => {
98
+ if (!signInEnabled && ctx.path !== "/jwks") {
99
+ throw new APIError("FORBIDDEN", { ...SIGN_IN_DISABLED });
100
+ }
101
+ if (!SESSION_PATHS.has(ctx.path)) return;
102
+ const sessionToken = await ctx.getSignedCookie(
103
+ ctx.context.authCookies.sessionToken.name,
104
+ ctx.context.secret,
105
+ );
106
+ if (!sessionToken) return;
107
+ const user = sessionUserOf(store, sessionToken);
108
+ if (!user?.disabled) return;
109
+ store.immediate(() => {
110
+ deleteSessions(store, user.userId);
111
+ store.audit({
112
+ actor: user.userId,
113
+ target: user.userId,
114
+ action: "SignInRefusedDisabled",
115
+ outcome: "RefusedDisabled",
116
+ clientIp: clientIpOf(ctx),
117
+ });
118
+ });
119
+ throw new APIError("FORBIDDEN", { ...ACCOUNT_DISABLED });
120
+ });
121
+
122
+ /**
123
+ * Every redeem of a reset link leaves a log line carrying its Origin, and a
124
+ * refused one an audit record. A refused token names no user.
125
+ */
126
+ const resetAudit = (store: IdentityStore) =>
127
+ createAuthMiddleware(async (ctx) => {
128
+ if (ctx.path !== "/reset-password") return;
129
+ const refused = isAPIError(ctx.context.returned);
130
+ // biome-ignore lint/plugin/no-logger-info: a reset redeem is an audit-grade signal
131
+ logger.info(
132
+ {
133
+ origin: ctx.request?.headers.get("origin") ?? null,
134
+ clientIp: clientIpOf(ctx),
135
+ accepted: !refused,
136
+ },
137
+ "Password reset redeem",
138
+ );
139
+ if (!refused) return;
140
+ store.immediate(() =>
141
+ store.audit({
142
+ actor: UNKNOWN,
143
+ target: UNKNOWN,
144
+ action: "PasswordResetRefused",
145
+ outcome: "RefusedToken",
146
+ clientIp: clientIpOf(ctx),
147
+ }),
148
+ );
149
+ });
150
+
55
151
  const intEnv = (name: string, fallback: number): number => {
56
152
  const raw = process.env[name];
57
153
  if (!raw) return fallback;
@@ -75,6 +171,7 @@ const intEnv = (name: string, fallback: number): number => {
75
171
  // the public type stable across any consumer's dependency graph.
76
172
  export const createAuth = async (
77
173
  config: AuthConfig,
174
+ store: IdentityStore,
78
175
  ): Promise<BetterAuthInstance> => {
79
176
  const database = await buildAuthAdapter(config);
80
177
 
@@ -87,6 +184,47 @@ export const createAuth = async (
87
184
  enabled: true,
88
185
  autoSignIn: true,
89
186
  disableSignUp: !config.selfSignUpEnabled,
187
+ minPasswordLength: MIN_PASSWORD_LENGTH,
188
+ maxPasswordLength: MAX_PASSWORD_LENGTH,
189
+ resetPasswordTokenExpiresIn: RESET_TOKEN_TTL_SECONDS,
190
+ revokeSessionsOnPasswordReset: true,
191
+ onPasswordReset: async ({ user }, request) => {
192
+ store.immediate(() =>
193
+ store.audit({
194
+ actor: user.id,
195
+ target: user.id,
196
+ action: "PasswordResetRedeemed",
197
+ outcome: "Succeeded",
198
+ clientIp: request?.headers.get(CLIENT_IP_HEADER) ?? UNKNOWN,
199
+ }),
200
+ );
201
+ },
202
+ },
203
+ hooks: {
204
+ before: gate(store, config.signInEnabled),
205
+ after: resetAudit(store),
206
+ },
207
+ databaseHooks: {
208
+ session: {
209
+ create: {
210
+ before: async (session, ctx) => {
211
+ if (!isUserDisabled(store.db, session.userId)) return;
212
+ store.immediate(() =>
213
+ store.audit({
214
+ actor: session.userId,
215
+ target: session.userId,
216
+ action: "SignInRefusedDisabled",
217
+ outcome: "RefusedDisabled",
218
+ clientIp: clientIpOf(ctx),
219
+ }),
220
+ );
221
+ throw new APIError("FORBIDDEN", { ...ACCOUNT_DISABLED });
222
+ },
223
+ after: async (session) => {
224
+ markSignedIn(store, session.userId);
225
+ },
226
+ },
227
+ },
90
228
  },
91
229
  // Listing one header, and not better-auth's `x-forwarded-for` default,
92
230
  // is what keeps the resolved address out of a caller's reach: an
@@ -118,10 +256,27 @@ export const createAuth = async (
118
256
  },
119
257
  },
120
258
  },
121
- user: { modelName: "auth_user" },
259
+ user: {
260
+ modelName: "auth_user",
261
+ additionalFields: {
262
+ disabled: {
263
+ type: "boolean",
264
+ required: false,
265
+ defaultValue: false,
266
+ input: false,
267
+ returned: false,
268
+ },
269
+ },
270
+ },
122
271
  session: { modelName: "auth_session" },
123
272
  account: { modelName: "auth_account" },
124
- verification: { modelName: "auth_verification" },
273
+ verification: {
274
+ modelName: "auth_verification",
275
+ storeIdentifier: {
276
+ default: "plain",
277
+ overrides: { "reset-password": "hashed" },
278
+ },
279
+ },
125
280
  plugins: [
126
281
  jwt({
127
282
  schema: { jwks: { modelName: "auth_jwks" } },
package/src/config.ts CHANGED
@@ -11,6 +11,11 @@ const required = (name: string, value: string | undefined): string => {
11
11
  return value;
12
12
  };
13
13
 
14
+ const isSwitchOn = (raw: string | undefined): boolean => {
15
+ const value = (raw ?? "").trim().toLowerCase();
16
+ return value !== "false" && value !== "0" && value !== "no";
17
+ };
18
+
14
19
  /**
15
20
  * Whether self-service signup is open on the better-auth tier. Mirrors the
16
21
  * Cognito user pool's `selfSignUpEnabled` prop: enabled means users can
@@ -23,10 +28,16 @@ const required = (name: string, value: string | undefined): string => {
23
28
  */
24
29
  export const resolveSelfSignUpEnabled = (
25
30
  raw: string | undefined = process.env.SELF_SIGN_UP_ENABLED,
26
- ): boolean => {
27
- const value = (raw ?? "").trim().toLowerCase();
28
- return value !== "false" && value !== "0" && value !== "no";
29
- };
31
+ ): boolean => isSwitchOn(raw);
32
+
33
+ /**
34
+ * The self-host kill switch. `SIGN_IN_ENABLED` reads like
35
+ * `SELF_SIGN_UP_ENABLED`: unset or any other value is on, and an explicit
36
+ * `false`/`0`/`no` turns every sign-in, token mint and session read off.
37
+ */
38
+ export const resolveSignInEnabled = (
39
+ raw: string | undefined = process.env.SIGN_IN_ENABLED,
40
+ ): boolean => isSwitchOn(raw);
30
41
 
31
42
  export interface DataConnectionConfig {
32
43
  connectionString: string;
@@ -59,6 +70,7 @@ export const resolveAuthConfig = (): AuthConfig => {
59
70
  .map((origin) => origin.trim())
60
71
  .filter((origin) => origin.length > 0),
61
72
  selfSignUpEnabled: resolveSelfSignUpEnabled(),
73
+ signInEnabled: resolveSignInEnabled(),
62
74
  };
63
75
  };
64
76
 
@@ -0,0 +1,286 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { logger } from "@remit/logger";
3
+ import { and, desc, eq, inArray, isNull, lt } from "drizzle-orm";
4
+ import type {
5
+ BetterSQLite3Database,
6
+ drizzle,
7
+ } from "drizzle-orm/better-sqlite3";
8
+ import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
9
+ import {
10
+ auth_session,
11
+ auth_user,
12
+ identity_audit_event,
13
+ identity_step_up,
14
+ } from "./schema/auth-schema-sqlite.js";
15
+
16
+ export type AuditAction =
17
+ | "KillSwitchOn"
18
+ | "KillSwitchOff"
19
+ | "StepUpPassed"
20
+ | "StepUpFailed"
21
+ | "SessionsRevoked"
22
+ | "PasswordResetIssued"
23
+ | "PasswordResetRedeemed"
24
+ | "PasswordResetRefused"
25
+ | "UserDisabled"
26
+ | "UserEnabled"
27
+ | "SignInRefusedDisabled"
28
+ | "PeopleRequestRefused";
29
+
30
+ export type AuditOutcome =
31
+ | "Succeeded"
32
+ | "RefusedSelf"
33
+ | "RefusedCallerDisabled"
34
+ | "RefusedSignInOff"
35
+ | "RefusedForeignOrigin"
36
+ | "RefusedStepUp"
37
+ | "RefusedToken"
38
+ | "RefusedDisabled";
39
+
40
+ export interface AuditRecord {
41
+ actor: string;
42
+ target: string;
43
+ action: AuditAction;
44
+ outcome: AuditOutcome;
45
+ clientIp: string;
46
+ }
47
+
48
+ export const HOST_CLIENT_IP = "host";
49
+
50
+ const AUDIT_RETENTION_MS = 90 * 24 * 60 * 60 * 1000;
51
+
52
+ // The MCP connector's tables share the app database. auth-service cannot depend
53
+ // on mcp-server, so the four columns a revocation writes are declared here.
54
+ const mcp_oauth_consent = sqliteTable("mcp_oauth_consent", {
55
+ userId: text("user_id"),
56
+ });
57
+ const mcp_oauth_access_token = sqliteTable("mcp_oauth_access_token", {
58
+ userId: text("user_id"),
59
+ revoked: integer("revoked", { mode: "timestamp_ms" }),
60
+ });
61
+ const mcp_oauth_refresh_token = sqliteTable("mcp_oauth_refresh_token", {
62
+ userId: text("user_id"),
63
+ revoked: integer("revoked", { mode: "timestamp_ms" }),
64
+ });
65
+ const mcp_session = sqliteTable("mcp_session", {
66
+ userId: text("user_id"),
67
+ });
68
+
69
+ export type IdentityDb = ReturnType<typeof drizzle>;
70
+
71
+ type SqliteDb = BetterSQLite3Database<Record<string, unknown>>;
72
+
73
+ export interface IdentityStore {
74
+ db: IdentityDb;
75
+ immediate<T>(work: () => T): T;
76
+ audit(record: AuditRecord): string;
77
+ relabel(auditEventId: string, record: AuditRecord): void;
78
+ close(): void;
79
+ }
80
+
81
+ const logAudit = (record: AuditRecord): void => {
82
+ // biome-ignore lint/plugin/no-logger-info: an identity audit record is an audit-grade signal
83
+ logger.info({ audit: record }, "Identity audit event");
84
+ };
85
+
86
+ const insertAudit = (db: SqliteDb, record: AuditRecord): string => {
87
+ const now = Date.now();
88
+ const auditEventId = randomUUID();
89
+ db.delete(identity_audit_event)
90
+ .where(
91
+ lt(identity_audit_event.occurredAt, new Date(now - AUDIT_RETENTION_MS)),
92
+ )
93
+ .run();
94
+ db.insert(identity_audit_event)
95
+ .values({ auditEventId, occurredAt: new Date(now), ...record })
96
+ .run();
97
+ return auditEventId;
98
+ };
99
+
100
+ export const writeAudit = (db: SqliteDb, record: AuditRecord): void => {
101
+ insertAudit(db, record);
102
+ logAudit(record);
103
+ };
104
+
105
+ export const identityStore = (db: IdentityDb): IdentityStore => {
106
+ let pending: AuditRecord[] | null = null;
107
+ const sqlite = db.$client;
108
+
109
+ const log = (record: AuditRecord): void => {
110
+ if (pending) pending.push(record);
111
+ else logAudit(record);
112
+ };
113
+
114
+ const audit = (record: AuditRecord): string => {
115
+ const auditEventId = insertAudit(db, record);
116
+ log(record);
117
+ return auditEventId;
118
+ };
119
+
120
+ const relabel = (auditEventId: string, record: AuditRecord): void => {
121
+ db.update(identity_audit_event)
122
+ .set({ action: record.action, outcome: record.outcome })
123
+ .where(eq(identity_audit_event.auditEventId, auditEventId))
124
+ .run();
125
+ log(record);
126
+ };
127
+
128
+ const immediate = <T>(work: () => T): T => {
129
+ pending = [];
130
+ const written = pending;
131
+ const result = (() => {
132
+ try {
133
+ return sqlite.transaction(work).immediate();
134
+ } finally {
135
+ pending = null;
136
+ }
137
+ })();
138
+ for (const record of written) logAudit(record);
139
+ return result;
140
+ };
141
+
142
+ return { db, immediate, audit, relabel, close: () => sqlite.close() };
143
+ };
144
+
145
+ export const openIdentityStore = async (
146
+ filename: string,
147
+ ): Promise<IdentityStore> => {
148
+ const { default: Database } = await import("better-sqlite3");
149
+ const { drizzle } = await import("drizzle-orm/better-sqlite3");
150
+ const sqlite = new Database(filename);
151
+ sqlite.pragma("journal_mode = WAL");
152
+ sqlite.pragma("busy_timeout = 5000");
153
+ sqlite.pragma("synchronous = NORMAL");
154
+ sqlite.pragma("foreign_keys = ON");
155
+ return identityStore(drizzle(sqlite));
156
+ };
157
+
158
+ export interface UserRow {
159
+ userId: string;
160
+ email: string;
161
+ disabled: boolean;
162
+ createdAt: Date;
163
+ lastSignInAt: Date;
164
+ }
165
+
166
+ export const findUser = (
167
+ store: IdentityStore,
168
+ userId: string,
169
+ ): UserRow | undefined =>
170
+ store.db
171
+ .select({
172
+ userId: auth_user.id,
173
+ email: auth_user.email,
174
+ disabled: auth_user.disabled,
175
+ createdAt: auth_user.createdAt,
176
+ lastSignInAt: auth_user.lastSignInAt,
177
+ })
178
+ .from(auth_user)
179
+ .where(eq(auth_user.id, userId))
180
+ .get();
181
+
182
+ export const isUserDisabled = (db: SqliteDb, userId: string): boolean =>
183
+ db
184
+ .select({ disabled: auth_user.disabled })
185
+ .from(auth_user)
186
+ .where(eq(auth_user.id, userId))
187
+ .get()?.disabled === true;
188
+
189
+ export const sessionUserOf = (
190
+ store: IdentityStore,
191
+ sessionToken: string,
192
+ ): { userId: string; disabled: boolean } | undefined =>
193
+ store.db
194
+ .select({ userId: auth_user.id, disabled: auth_user.disabled })
195
+ .from(auth_session)
196
+ .innerJoin(auth_user, eq(auth_user.id, auth_session.userId))
197
+ .where(eq(auth_session.token, sessionToken))
198
+ .get();
199
+
200
+ export const markSignedIn = (store: IdentityStore, userId: string): void => {
201
+ store.db
202
+ .update(auth_user)
203
+ .set({ lastSignInAt: new Date() })
204
+ .where(eq(auth_user.id, userId))
205
+ .run();
206
+ };
207
+
208
+ export const deleteSessions = (store: IdentityStore, userId: string): void => {
209
+ store.db.delete(auth_session).where(eq(auth_session.userId, userId)).run();
210
+ };
211
+
212
+ export const revokeUser = (store: IdentityStore, userId: string): void => {
213
+ const { db } = store;
214
+ const revoked = new Date();
215
+ deleteSessions(store, userId);
216
+ db.delete(mcp_oauth_consent)
217
+ .where(eq(mcp_oauth_consent.userId, userId))
218
+ .run();
219
+ db.update(mcp_oauth_access_token)
220
+ .set({ revoked })
221
+ .where(
222
+ and(
223
+ eq(mcp_oauth_access_token.userId, userId),
224
+ isNull(mcp_oauth_access_token.revoked),
225
+ ),
226
+ )
227
+ .run();
228
+ db.update(mcp_oauth_refresh_token)
229
+ .set({ revoked })
230
+ .where(
231
+ and(
232
+ eq(mcp_oauth_refresh_token.userId, userId),
233
+ isNull(mcp_oauth_refresh_token.revoked),
234
+ ),
235
+ )
236
+ .run();
237
+ db.delete(mcp_session).where(eq(mcp_session.userId, userId)).run();
238
+ db.delete(identity_step_up)
239
+ .where(eq(identity_step_up.callerUserId, userId))
240
+ .run();
241
+ };
242
+
243
+ const revokeEveryone = (store: IdentityStore): void => {
244
+ const { db } = store;
245
+ const revoked = new Date();
246
+ db.delete(auth_session).run();
247
+ db.delete(mcp_oauth_consent).run();
248
+ db.update(mcp_oauth_access_token)
249
+ .set({ revoked })
250
+ .where(isNull(mcp_oauth_access_token.revoked))
251
+ .run();
252
+ db.update(mcp_oauth_refresh_token)
253
+ .set({ revoked })
254
+ .where(isNull(mcp_oauth_refresh_token.revoked))
255
+ .run();
256
+ db.delete(mcp_session).run();
257
+ db.delete(identity_step_up).run();
258
+ };
259
+
260
+ export const applySignInSwitch = (
261
+ store: IdentityStore,
262
+ signInEnabled: boolean,
263
+ ): void => {
264
+ store.immediate(() => {
265
+ if (!signInEnabled) revokeEveryone(store);
266
+ const latest = store.db
267
+ .select({ action: identity_audit_event.action })
268
+ .from(identity_audit_event)
269
+ .where(
270
+ inArray(identity_audit_event.action, ["KillSwitchOn", "KillSwitchOff"]),
271
+ )
272
+ .orderBy(desc(identity_audit_event.occurredAt))
273
+ .limit(1)
274
+ .get();
275
+ const state: AuditAction = signInEnabled ? "KillSwitchOff" : "KillSwitchOn";
276
+ const changed = latest ? latest.action !== state : !signInEnabled;
277
+ if (!changed) return;
278
+ store.audit({
279
+ actor: "env",
280
+ target: "all",
281
+ action: state,
282
+ outcome: "Succeeded",
283
+ clientIp: HOST_CLIENT_IP,
284
+ });
285
+ });
286
+ };
package/src/index.ts CHANGED
@@ -1,9 +1,11 @@
1
1
  export { toNodeHandler } from "better-auth/node";
2
2
  export {
3
+ ACCOUNT_DISABLED,
3
4
  type Auth,
4
5
  type AuthConfig,
5
6
  CLIENT_IP_HEADER,
6
7
  createAuth,
8
+ SIGN_IN_DISABLED,
7
9
  } from "./auth.js";
8
10
  export {
9
11
  AUTH_BASE_PATH,
@@ -13,9 +15,24 @@ export {
13
15
  resolveAuthConfig,
14
16
  resolveDataConnectionConfig,
15
17
  resolveSelfSignUpEnabled,
18
+ resolveSignInEnabled,
16
19
  resolveVerifierConfig,
17
20
  type VerifierConfig,
18
21
  } from "./config.js";
22
+ export {
23
+ type AuditRecord,
24
+ HOST_CLIENT_IP,
25
+ type IdentityStore,
26
+ isUserDisabled,
27
+ openIdentityStore,
28
+ writeAudit,
29
+ } from "./identity-store.js";
30
+ export {
31
+ createPeopleDirectory,
32
+ type PeopleAdmin,
33
+ type PeopleConfig,
34
+ type PeopleDirectory,
35
+ } from "./people.js";
19
36
  export {
20
37
  type AuthFetchHandler,
21
38
  withRateLimitLogging,