@remit/auth-service 0.0.11 → 0.0.12

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.11",
3
+ "version": "0.0.12",
4
4
  "description": "better-auth identity service for the self-host stack",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
package/src/auth.ts CHANGED
@@ -41,6 +41,17 @@ const buildAuthAdapter = async (config: AuthConfig) => {
41
41
  });
42
42
  };
43
43
 
44
+ /**
45
+ * The header carrying the client address, set by the edge proxy from the
46
+ * connection it terminates (deploy/vps/caddy/routes.caddy). It is deliberately
47
+ * not `X-Forwarded-For`: that header arrives with an appended hop per proxy,
48
+ * which resolves to no single client and collapses rate limiting onto one
49
+ * shared bucket for everybody. Nothing else in the chain writes this name, and
50
+ * the edge replaces any value a client sends, so it is single-valued and
51
+ * trustworthy at the point better-auth reads it.
52
+ */
53
+ export const CLIENT_IP_HEADER = "x-remit-client-ip";
54
+
44
55
  const intEnv = (name: string, fallback: number): number => {
45
56
  const raw = process.env[name];
46
57
  if (!raw) return fallback;
@@ -77,6 +88,12 @@ export const createAuth = async (
77
88
  autoSignIn: true,
78
89
  disableSignUp: !config.selfSignUpEnabled,
79
90
  },
91
+ // Listing one header, and not better-auth's `x-forwarded-for` default,
92
+ // is what keeps the resolved address out of a caller's reach: an
93
+ // `X-Forwarded-For` a client sends is never read at all.
94
+ advanced: {
95
+ ipAddress: { ipAddressHeaders: [CLIENT_IP_HEADER] },
96
+ },
80
97
  rateLimit: {
81
98
  enabled: true,
82
99
  window: intEnv("BETTER_AUTH_RATE_LIMIT_WINDOW", 60),
package/src/index.ts CHANGED
@@ -1,5 +1,10 @@
1
1
  export { toNodeHandler } from "better-auth/node";
2
- export { type Auth, type AuthConfig, createAuth } from "./auth.js";
2
+ export {
3
+ type Auth,
4
+ type AuthConfig,
5
+ CLIENT_IP_HEADER,
6
+ createAuth,
7
+ } from "./auth.js";
3
8
  export {
4
9
  AUTH_BASE_PATH,
5
10
  AUTH_JWKS_PATH,
@@ -0,0 +1,106 @@
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
+ const SIGN_IN_MAX = 2;
10
+ const MIGRATIONS = resolve(
11
+ import.meta.dirname,
12
+ "../../../deploy/vps/migrations-sqlite/auth",
13
+ );
14
+
15
+ const createDatabase = async (dir: string, name: string): Promise<string> => {
16
+ const path = join(dir, `${name}.db`);
17
+ const { default: Database } = await import("better-sqlite3");
18
+ const { drizzle } = await import("drizzle-orm/better-sqlite3");
19
+ const { migrate } = await import("drizzle-orm/better-sqlite3/migrator");
20
+ const sqlite = new Database(path);
21
+ migrate(drizzle(sqlite), { migrationsFolder: MIGRATIONS });
22
+ sqlite.close();
23
+ return path;
24
+ };
25
+
26
+ const signIn = (auth: Auth, headers: Record<string, string>) =>
27
+ auth.handler(
28
+ new Request("http://localhost:3000/api/auth/sign-in/email", {
29
+ method: "POST",
30
+ headers: { "content-type": "application/json", ...headers },
31
+ body: JSON.stringify({
32
+ email: "nobody@example.com",
33
+ password: "a-sufficiently-long-password",
34
+ }),
35
+ }),
36
+ );
37
+
38
+ /**
39
+ * Spend one client's sign-in budget, returning the attempt number that was
40
+ * turned away. `null` means the budget never ran out.
41
+ */
42
+ const exhaust = async (
43
+ auth: Auth,
44
+ headers: Record<string, string>,
45
+ ): Promise<number | null> => {
46
+ for (let attempt = 0; attempt <= SIGN_IN_MAX; attempt++) {
47
+ const response = await signIn(auth, headers);
48
+ if (response.status === 429) return attempt;
49
+ }
50
+ return null;
51
+ };
52
+
53
+ describe("sign-in rate limiting is per client", () => {
54
+ const previousMax = process.env.BETTER_AUTH_RATE_LIMIT_SIGN_IN_MAX;
55
+ let dir: string;
56
+
57
+ before(() => {
58
+ process.env.BETTER_AUTH_RATE_LIMIT_SIGN_IN_MAX = String(SIGN_IN_MAX);
59
+ dir = mkdtempSync(join(tmpdir(), "remit-rate-limit-"));
60
+ });
61
+
62
+ after(() => {
63
+ if (previousMax === undefined) {
64
+ delete process.env.BETTER_AUTH_RATE_LIMIT_SIGN_IN_MAX;
65
+ } else {
66
+ process.env.BETTER_AUTH_RATE_LIMIT_SIGN_IN_MAX = previousMax;
67
+ }
68
+ rmSync(dir, { recursive: true, force: true });
69
+ });
70
+
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
+ });
78
+
79
+ it("locks out only the client that spent the budget", async () => {
80
+ const auth = await build("per-client");
81
+
82
+ assert.equal(
83
+ await exhaust(auth, { [CLIENT_IP_HEADER]: "203.0.113.10" }),
84
+ SIGN_IN_MAX,
85
+ );
86
+
87
+ const other = await signIn(auth, { [CLIENT_IP_HEADER]: "198.51.100.20" });
88
+ assert.equal(other.status, 401);
89
+ });
90
+
91
+ it("ignores a forwarded address the client supplies itself", async () => {
92
+ const auth = await build("spoofed");
93
+ const client = { [CLIENT_IP_HEADER]: "203.0.113.30" };
94
+
95
+ assert.equal(
96
+ await exhaust(auth, { ...client, "x-forwarded-for": "203.0.113.31" }),
97
+ SIGN_IN_MAX,
98
+ );
99
+
100
+ const spoofed = await signIn(auth, {
101
+ ...client,
102
+ "x-forwarded-for": "198.51.100.40",
103
+ });
104
+ assert.equal(spoofed.status, 429);
105
+ });
106
+ });