@remit/auth-service 0.0.11 → 0.0.13
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 +2 -2
- package/src/auth.ts +17 -0
- package/src/index.ts +6 -1
- package/src/rate-limit.test.ts +106 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/auth-service",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.13",
|
|
4
4
|
"description": "better-auth identity service for the self-host stack",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
},
|
|
22
22
|
"scripts": {
|
|
23
23
|
"test:typecheck": "tsgo --noEmit -p tsconfig.json",
|
|
24
|
-
"test:run": "node --import tsx --experimental-test-coverage --test-coverage-include='src/**' --test-coverage-exclude='src/**/*.test.ts' --test-coverage-exclude='src/**/*.test.tsx' --test-coverage-lines=94 --test 'src/**/*.test.ts'",
|
|
24
|
+
"test:run": "node $NODE_TEST_FLAGS --import tsx --experimental-test-coverage --test-coverage-include='src/**' --test-coverage-exclude='src/**/*.test.ts' --test-coverage-exclude='src/**/*.test.tsx' --test-coverage-lines=94 --test 'src/**/*.test.ts'",
|
|
25
25
|
"test": "npm run test:typecheck && npm run test:run",
|
|
26
26
|
"fix": "biome check --write src"
|
|
27
27
|
},
|
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 {
|
|
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
|
+
});
|