@remit/auth-service 0.0.14 → 0.0.16

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.14",
3
+ "version": "0.0.16",
4
4
  "description": "better-auth identity service for the self-host stack",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -17,6 +17,10 @@
17
17
  "./verifier": {
18
18
  "types": "./src/verifier.ts",
19
19
  "default": "./src/verifier.ts"
20
+ },
21
+ "./schema": {
22
+ "types": "./src/schema/auth-schema-sqlite.ts",
23
+ "default": "./src/schema/auth-schema-sqlite.ts"
20
24
  }
21
25
  },
22
26
  "scripts": {
@@ -29,7 +33,8 @@
29
33
  "tsx": "*"
30
34
  },
31
35
  "dependencies": {
32
- "better-auth": "^1.6.23",
36
+ "@remit/logger": "*",
37
+ "better-auth": "^1.7.12",
33
38
  "better-sqlite3": "^12.11.1",
34
39
  "drizzle-orm": "^0.45.2",
35
40
  "jose": "^6.2.3",
package/src/auth.ts CHANGED
@@ -6,9 +6,9 @@ import * as authSchemaSqlite from "./schema/auth-schema-sqlite.js";
6
6
 
7
7
  export interface AuthConfig {
8
8
  /**
9
- * Path to the shared database file (RFC 036 D3). The identity tables live
10
- * beside the app tables and are driven through the drizzle adapter's
11
- * `sqlite` provider.
9
+ * Path to the app database file (#1420). The identity tables live beside
10
+ * the app tables and are driven through the drizzle adapter's `sqlite`
11
+ * provider.
12
12
  */
13
13
  connectionString: string;
14
14
  secret: string;
package/src/config.ts CHANGED
@@ -34,11 +34,14 @@ export interface DataConnectionConfig {
34
34
 
35
35
  /**
36
36
  * Resolve where identity data lives, from the same env vars the rest of the
37
- * self-host stack reads (RFC 036 D5). The identity tables share the app
38
- * database file, so the locator is `SQLITE_DB_PATH`.
37
+ * self-host stack reads (RFC 036 D5). The identity tables live in the app
38
+ * database, so the locator is `SQLITE_APP_DB_PATH`.
39
39
  */
40
40
  export const resolveDataConnectionConfig = (): DataConnectionConfig => ({
41
- connectionString: required("SQLITE_DB_PATH", process.env.SQLITE_DB_PATH),
41
+ connectionString: required(
42
+ "SQLITE_APP_DB_PATH",
43
+ process.env.SQLITE_APP_DB_PATH,
44
+ ),
42
45
  });
43
46
 
44
47
  /**
package/src/index.ts CHANGED
@@ -16,6 +16,10 @@ export {
16
16
  resolveVerifierConfig,
17
17
  type VerifierConfig,
18
18
  } from "./config.js";
19
+ export {
20
+ type AuthFetchHandler,
21
+ withRateLimitLogging,
22
+ } from "./rate-limit-log.js";
19
23
  export {
20
24
  createJwtVerifier,
21
25
  extractBearerToken,
@@ -0,0 +1,190 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtempSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join, resolve } from "node:path";
5
+ import { after, before, describe, it } from "node:test";
6
+ import type { Auth } from "./auth.js";
7
+ import { CLIENT_IP_HEADER, createAuth } from "./auth.js";
8
+
9
+ /**
10
+ * #441: better-auth answers a rate-limited request from inside its own router,
11
+ * so the stack log stayed completely silent while an instance was throttled.
12
+ * This drives the real handler until it really answers 429 and reads the line
13
+ * off the real logger — a stub handler would prove nothing about the seam the
14
+ * server actually mounts.
15
+ */
16
+
17
+ process.env.LOG_LEVEL = "info";
18
+
19
+ const originalWrite = process.stdout.write.bind(process.stdout);
20
+ const written: string[] = [];
21
+ let capturing = false;
22
+
23
+ // The logger writes its lines through `process.stdout`. Writes pass through
24
+ // while nothing is capturing, so the test runner's own output still reaches
25
+ // the terminal.
26
+ process.stdout.write = ((
27
+ chunk: string | Uint8Array,
28
+ ...rest: unknown[]
29
+ ): boolean => {
30
+ if (capturing && typeof chunk === "string") {
31
+ written.push(chunk);
32
+ return true;
33
+ }
34
+ return (originalWrite as (...args: unknown[]) => boolean)(chunk, ...rest);
35
+ }) as typeof process.stdout.write;
36
+
37
+ const { withRateLimitLogging } = await import("./rate-limit-log.js");
38
+
39
+ const SIGN_IN_MAX = 2;
40
+ const MIGRATIONS = resolve(
41
+ import.meta.dirname,
42
+ "../../../deploy/vps/migrations-sqlite/app",
43
+ );
44
+
45
+ type Line = Record<string, unknown>;
46
+
47
+ // better-auth writes its own advisories through console, so the captured
48
+ // stream is not all JSON; only the structured lines are of interest here.
49
+ const asJsonObject = (line: string): Line | null => {
50
+ try {
51
+ const parsed: unknown = JSON.parse(line);
52
+ return parsed !== null && typeof parsed === "object"
53
+ ? (parsed as Line)
54
+ : null;
55
+ } catch {
56
+ return null;
57
+ }
58
+ };
59
+
60
+ const captured = (): Line[] =>
61
+ written
62
+ .join("")
63
+ .split("\n")
64
+ .map(asJsonObject)
65
+ .filter((line): line is Line => line !== null);
66
+
67
+ const capture = async (emit: () => Promise<void>): Promise<Line[]> => {
68
+ written.length = 0;
69
+ capturing = true;
70
+ try {
71
+ await emit();
72
+ } finally {
73
+ capturing = false;
74
+ }
75
+ return captured();
76
+ };
77
+
78
+ const createDatabase = async (dir: string, name: string): Promise<string> => {
79
+ const path = join(dir, `${name}.db`);
80
+ const { default: Database } = await import("better-sqlite3");
81
+ const { drizzle } = await import("drizzle-orm/better-sqlite3");
82
+ const { migrate } = await import("drizzle-orm/better-sqlite3/migrator");
83
+ const sqlite = new Database(path);
84
+ migrate(drizzle(sqlite), { migrationsFolder: MIGRATIONS });
85
+ sqlite.close();
86
+ return path;
87
+ };
88
+
89
+ const signIn = (
90
+ handler: (request: Request) => Promise<Response>,
91
+ headers: Record<string, string>,
92
+ ) =>
93
+ handler(
94
+ new Request("http://localhost:3000/api/auth/sign-in/email", {
95
+ method: "POST",
96
+ headers: { "content-type": "application/json", ...headers },
97
+ body: JSON.stringify({
98
+ email: "nobody@example.com",
99
+ password: "a-sufficiently-long-password",
100
+ }),
101
+ }),
102
+ );
103
+
104
+ describe("a rate-limited auth response is logged", () => {
105
+ const previousMax = process.env.BETTER_AUTH_RATE_LIMIT_SIGN_IN_MAX;
106
+ let dir: string;
107
+
108
+ before(() => {
109
+ process.env.BETTER_AUTH_RATE_LIMIT_SIGN_IN_MAX = String(SIGN_IN_MAX);
110
+ dir = mkdtempSync(join(tmpdir(), "remit-rate-limit-log-"));
111
+ });
112
+
113
+ after(() => {
114
+ if (previousMax === undefined) {
115
+ delete process.env.BETTER_AUTH_RATE_LIMIT_SIGN_IN_MAX;
116
+ } else {
117
+ process.env.BETTER_AUTH_RATE_LIMIT_SIGN_IN_MAX = previousMax;
118
+ }
119
+ rmSync(dir, { recursive: true, force: true });
120
+ });
121
+
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
+ });
129
+
130
+ it("names the endpoint and the address the limit is keyed on", async () => {
131
+ const handler = withRateLimitLogging(await build("logged"));
132
+ const client = { [CLIENT_IP_HEADER]: "203.0.113.50" };
133
+
134
+ let refused: Response | undefined;
135
+ const lines = await capture(async () => {
136
+ for (let attempt = 0; attempt <= SIGN_IN_MAX; attempt++) {
137
+ const response = await signIn(handler, client);
138
+ if (response.status === 429) {
139
+ refused = response;
140
+ break;
141
+ }
142
+ }
143
+ });
144
+
145
+ assert.equal(refused?.status, 429, "expected the budget to run out");
146
+
147
+ const line = lines.find(
148
+ (entry) => entry.msg === "Auth request rate limited",
149
+ );
150
+ assert.ok(line, `no rate-limit line in: ${JSON.stringify(lines)}`);
151
+ assert.equal(line.level, "warn");
152
+ assert.equal(line.endpoint, "/sign-in/email");
153
+ assert.equal(line.method, "POST");
154
+ assert.equal(line.clientIp, "203.0.113.50");
155
+ assert.equal(typeof line.retryAfterSeconds, "number");
156
+ });
157
+
158
+ it("says so when the edge resolved no address, so one bucket serves everyone", async () => {
159
+ const handler = withRateLimitLogging(await build("unresolved"));
160
+
161
+ const lines = await capture(async () => {
162
+ for (let attempt = 0; attempt <= SIGN_IN_MAX; attempt++) {
163
+ const response = await signIn(handler, {});
164
+ if (response.status === 429) break;
165
+ }
166
+ });
167
+
168
+ const line = lines.find(
169
+ (entry) => entry.msg === "Auth request rate limited",
170
+ );
171
+ assert.ok(line, `no rate-limit line in: ${JSON.stringify(lines)}`);
172
+ assert.equal(line.clientIp, "unresolved");
173
+ });
174
+
175
+ it("leaves an answered request alone", async () => {
176
+ const handler = withRateLimitLogging(await build("allowed"));
177
+
178
+ const lines = await capture(async () => {
179
+ const response = await signIn(handler, {
180
+ [CLIENT_IP_HEADER]: "198.51.100.60",
181
+ });
182
+ assert.equal(response.status, 401);
183
+ });
184
+
185
+ assert.equal(
186
+ lines.filter((entry) => entry.msg === "Auth request rate limited").length,
187
+ 0,
188
+ );
189
+ });
190
+ });
@@ -0,0 +1,56 @@
1
+ import { logger } from "@remit/logger";
2
+ import type { Auth } from "./auth.js";
3
+ import { CLIENT_IP_HEADER } from "./auth.js";
4
+ import { AUTH_BASE_PATH } from "./config.js";
5
+
6
+ export type AuthFetchHandler = Auth["handler"];
7
+
8
+ const TOO_MANY_REQUESTS = 429;
9
+
10
+ /** better-auth's own name for the seconds it wants the caller to wait. */
11
+ const RETRY_AFTER_HEADER = "x-retry-after";
12
+
13
+ const endpointOf = (url: string): string => {
14
+ const { pathname } = new URL(url);
15
+ if (!pathname.startsWith(AUTH_BASE_PATH)) return pathname;
16
+ return pathname.slice(AUTH_BASE_PATH.length) || "/";
17
+ };
18
+
19
+ const retryAfterSeconds = (response: Response): number | undefined => {
20
+ const raw = response.headers.get(RETRY_AFTER_HEADER);
21
+ if (raw === null) return undefined;
22
+ const seconds = Number(raw);
23
+ return Number.isFinite(seconds) ? seconds : undefined;
24
+ };
25
+
26
+ /**
27
+ * Wrap the better-auth fetch handler so a rate-limited answer leaves a line.
28
+ *
29
+ * better-auth enforces its limits inside the router it owns and answers 429
30
+ * before any route, hook or middleware of ours runs, so the response on its way
31
+ * out is the only place the event can be observed at all. Without this a
32
+ * throttled instance is silent on the operator's side while its users read the
33
+ * failure as having been signed out.
34
+ *
35
+ * `clientIp` is the bucket the limit is keyed on, not incidental context: the
36
+ * limits are per address, so it is what says whether one caller is looping or a
37
+ * whole office behind one NAT is sharing a budget. An absent header means the
38
+ * edge did not set it and better-auth has collapsed every caller onto one
39
+ * bucket, which is itself the thing to see.
40
+ */
41
+ export const withRateLimitLogging =
42
+ (auth: { handler: AuthFetchHandler }): AuthFetchHandler =>
43
+ async (request: Request): Promise<Response> => {
44
+ const response = await auth.handler(request);
45
+ if (response.status !== TOO_MANY_REQUESTS) return response;
46
+ logger.warn(
47
+ {
48
+ endpoint: endpointOf(request.url),
49
+ method: request.method,
50
+ clientIp: request.headers.get(CLIENT_IP_HEADER) ?? "unresolved",
51
+ retryAfterSeconds: retryAfterSeconds(response),
52
+ },
53
+ "Auth request rate limited",
54
+ );
55
+ return response;
56
+ };
@@ -9,7 +9,7 @@ import { CLIENT_IP_HEADER, createAuth } from "./auth.js";
9
9
  const SIGN_IN_MAX = 2;
10
10
  const MIGRATIONS = resolve(
11
11
  import.meta.dirname,
12
- "../../../deploy/vps/migrations-sqlite/auth",
12
+ "../../../deploy/vps/migrations-sqlite/app",
13
13
  );
14
14
 
15
15
  const createDatabase = async (dir: string, name: string): Promise<string> => {
@@ -1,6 +1,6 @@
1
1
  // The five better-auth identity tables, in the shape better-auth's drizzle
2
2
  // adapter emits with `provider: "sqlite"`. Used to generate the committed
3
- // sqlite migrations (deploy/vps/migrations-sqlite/auth).
3
+ // sqlite migrations (deploy/vps/migrations-sqlite/app).
4
4
  //
5
5
  // Hand-maintained, deliberately. The `@better-auth/cli generate` command no
6
6
  // longer emits this shape: it writes `mode: "timestamp_ms"` where these columns
@@ -104,4 +104,6 @@ export const auth_jwks = sqliteTable("auth_jwks", {
104
104
  privateKey: text("private_key").notNull(),
105
105
  createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
106
106
  expiresAt: integer("expires_at", { mode: "timestamp" }),
107
+ alg: text("alg"),
108
+ crv: text("crv"),
107
109
  });
@@ -35,26 +35,6 @@ const baseConfig = {
35
35
  };
36
36
 
37
37
  describe("createAuth self-signup gate", () => {
38
- it("flips better-auth disableSignUp from the selfSignUpEnabled flag", async () => {
39
- const open = await createAuth({ ...baseConfig, selfSignUpEnabled: true });
40
- const closed = await createAuth({
41
- ...baseConfig,
42
- selfSignUpEnabled: false,
43
- });
44
- assert.equal(open.options.emailAndPassword?.disableSignUp, false);
45
- assert.equal(closed.options.emailAndPassword?.disableSignUp, true);
46
- });
47
-
48
- it("drives the sqlite drizzle provider", async () => {
49
- const auth = await createAuth({
50
- connectionString: ":memory:",
51
- secret: baseConfig.secret,
52
- baseURL: baseConfig.baseURL,
53
- selfSignUpEnabled: true,
54
- });
55
- assert.equal(auth.options.emailAndPassword?.disableSignUp, false);
56
- });
57
-
58
38
  it("rejects a signup request server-side when disabled, before any DB access", async () => {
59
39
  const auth = await createAuth({ ...baseConfig, selfSignUpEnabled: false });
60
40
  const request = new Request(
@@ -1,16 +0,0 @@
1
- // The instance-owner singleton, used to generate the committed SQLite
2
- // migrations (deploy/vps/migrations-sqlite/meta).
3
- import { sql } from "drizzle-orm";
4
- import { check, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
5
-
6
- export const instance_owner = sqliteTable(
7
- "instance_owner",
8
- {
9
- id: integer("id").primaryKey().default(1),
10
- userId: text("user_id").notNull(),
11
- claimedAt: integer("claimed_at", { mode: "timestamp" })
12
- .$defaultFn(() => /* @__PURE__ */ new Date())
13
- .notNull(),
14
- },
15
- (table) => [check("instance_owner_singleton", sql`${table.id} = 1`)],
16
- );