@remit/auth-service 0.0.14 → 0.0.15

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.15",
4
4
  "description": "better-auth identity service for the self-host stack",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -29,6 +29,7 @@
29
29
  "tsx": "*"
30
30
  },
31
31
  "dependencies": {
32
+ "@remit/logger-lambda": "*",
32
33
  "better-auth": "^1.6.23",
33
34
  "better-sqlite3": "^12.11.1",
34
35
  "drizzle-orm": "^0.45.2",
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,192 @@
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
+ process.env.REMIT_SERVICE_NAME = "auth-service-test";
19
+
20
+ const originalWrite = process.stdout.write.bind(process.stdout);
21
+ const written: string[] = [];
22
+ let capturing = false;
23
+
24
+ // pino writes to `process.stdout` only when its `write` has been replaced, and
25
+ // to the raw fd otherwise, so the hook must be installed before the module
26
+ // under test imports the logger. Writes pass through while nothing is
27
+ // capturing, so the test runner's own output still reaches the terminal.
28
+ process.stdout.write = ((
29
+ chunk: string | Uint8Array,
30
+ ...rest: unknown[]
31
+ ): boolean => {
32
+ if (capturing && typeof chunk === "string") {
33
+ written.push(chunk);
34
+ return true;
35
+ }
36
+ return (originalWrite as (...args: unknown[]) => boolean)(chunk, ...rest);
37
+ }) as typeof process.stdout.write;
38
+
39
+ const { withRateLimitLogging } = await import("./rate-limit-log.js");
40
+
41
+ const SIGN_IN_MAX = 2;
42
+ const MIGRATIONS = resolve(
43
+ import.meta.dirname,
44
+ "../../../deploy/vps/migrations-sqlite/auth",
45
+ );
46
+
47
+ type Line = Record<string, unknown>;
48
+
49
+ // better-auth writes its own advisories through console, so the captured
50
+ // stream is not all pino JSON; only the structured lines are of interest here.
51
+ const asJsonObject = (line: string): Line | null => {
52
+ try {
53
+ const parsed: unknown = JSON.parse(line);
54
+ return parsed !== null && typeof parsed === "object"
55
+ ? (parsed as Line)
56
+ : null;
57
+ } catch {
58
+ return null;
59
+ }
60
+ };
61
+
62
+ const captured = (): Line[] =>
63
+ written
64
+ .join("")
65
+ .split("\n")
66
+ .map(asJsonObject)
67
+ .filter((line): line is Line => line !== null);
68
+
69
+ const capture = async (emit: () => Promise<void>): Promise<Line[]> => {
70
+ written.length = 0;
71
+ capturing = true;
72
+ try {
73
+ await emit();
74
+ } finally {
75
+ capturing = false;
76
+ }
77
+ return captured();
78
+ };
79
+
80
+ const createDatabase = async (dir: string, name: string): Promise<string> => {
81
+ const path = join(dir, `${name}.db`);
82
+ const { default: Database } = await import("better-sqlite3");
83
+ const { drizzle } = await import("drizzle-orm/better-sqlite3");
84
+ const { migrate } = await import("drizzle-orm/better-sqlite3/migrator");
85
+ const sqlite = new Database(path);
86
+ migrate(drizzle(sqlite), { migrationsFolder: MIGRATIONS });
87
+ sqlite.close();
88
+ return path;
89
+ };
90
+
91
+ const signIn = (
92
+ handler: (request: Request) => Promise<Response>,
93
+ headers: Record<string, string>,
94
+ ) =>
95
+ handler(
96
+ new Request("http://localhost:3000/api/auth/sign-in/email", {
97
+ method: "POST",
98
+ headers: { "content-type": "application/json", ...headers },
99
+ body: JSON.stringify({
100
+ email: "nobody@example.com",
101
+ password: "a-sufficiently-long-password",
102
+ }),
103
+ }),
104
+ );
105
+
106
+ describe("a rate-limited auth response is logged", () => {
107
+ const previousMax = process.env.BETTER_AUTH_RATE_LIMIT_SIGN_IN_MAX;
108
+ let dir: string;
109
+
110
+ before(() => {
111
+ process.env.BETTER_AUTH_RATE_LIMIT_SIGN_IN_MAX = String(SIGN_IN_MAX);
112
+ dir = mkdtempSync(join(tmpdir(), "remit-rate-limit-log-"));
113
+ });
114
+
115
+ after(() => {
116
+ if (previousMax === undefined) {
117
+ delete process.env.BETTER_AUTH_RATE_LIMIT_SIGN_IN_MAX;
118
+ } else {
119
+ process.env.BETTER_AUTH_RATE_LIMIT_SIGN_IN_MAX = previousMax;
120
+ }
121
+ rmSync(dir, { recursive: true, force: true });
122
+ });
123
+
124
+ const build = async (name: string): Promise<Auth> =>
125
+ createAuth({
126
+ connectionString: await createDatabase(dir, name),
127
+ secret: "rate-limit-log-test-secret-value-32chars",
128
+ baseURL: "http://localhost:3000",
129
+ selfSignUpEnabled: true,
130
+ });
131
+
132
+ it("names the endpoint and the address the limit is keyed on", async () => {
133
+ const handler = withRateLimitLogging(await build("logged"));
134
+ const client = { [CLIENT_IP_HEADER]: "203.0.113.50" };
135
+
136
+ let refused: Response | undefined;
137
+ const lines = await capture(async () => {
138
+ for (let attempt = 0; attempt <= SIGN_IN_MAX; attempt++) {
139
+ const response = await signIn(handler, client);
140
+ if (response.status === 429) {
141
+ refused = response;
142
+ break;
143
+ }
144
+ }
145
+ });
146
+
147
+ assert.equal(refused?.status, 429, "expected the budget to run out");
148
+
149
+ const line = lines.find(
150
+ (entry) => entry.msg === "Auth request rate limited",
151
+ );
152
+ assert.ok(line, `no rate-limit line in: ${JSON.stringify(lines)}`);
153
+ assert.equal(line.level, "warn");
154
+ assert.equal(line.endpoint, "/sign-in/email");
155
+ assert.equal(line.method, "POST");
156
+ assert.equal(line.clientIp, "203.0.113.50");
157
+ assert.equal(typeof line.retryAfterSeconds, "number");
158
+ });
159
+
160
+ it("says so when the edge resolved no address, so one bucket serves everyone", async () => {
161
+ const handler = withRateLimitLogging(await build("unresolved"));
162
+
163
+ const lines = await capture(async () => {
164
+ for (let attempt = 0; attempt <= SIGN_IN_MAX; attempt++) {
165
+ const response = await signIn(handler, {});
166
+ if (response.status === 429) break;
167
+ }
168
+ });
169
+
170
+ const line = lines.find(
171
+ (entry) => entry.msg === "Auth request rate limited",
172
+ );
173
+ assert.ok(line, `no rate-limit line in: ${JSON.stringify(lines)}`);
174
+ assert.equal(line.clientIp, "unresolved");
175
+ });
176
+
177
+ it("leaves an answered request alone", async () => {
178
+ const handler = withRateLimitLogging(await build("allowed"));
179
+
180
+ const lines = await capture(async () => {
181
+ const response = await signIn(handler, {
182
+ [CLIENT_IP_HEADER]: "198.51.100.60",
183
+ });
184
+ assert.equal(response.status, 401);
185
+ });
186
+
187
+ assert.equal(
188
+ lines.filter((entry) => entry.msg === "Auth request rate limited").length,
189
+ 0,
190
+ );
191
+ });
192
+ });
@@ -0,0 +1,56 @@
1
+ import { logger } from "@remit/logger-lambda";
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
+ };