@podosoft/podokit 1.4.2 → 1.5.0
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/dist/deploy-compose-profile.js +2 -2
- package/dist/deploy-compose-render.js +1 -0
- package/dist/deploy-profile.js +2 -2
- package/dist/deploy-render.js +2 -0
- package/dist/deploy-schema.d.ts +7 -0
- package/dist/deploy-schema.js +40 -0
- package/dist/lockfile.js +3 -1
- package/dist/provider.d.ts +1 -0
- package/dist/provider.js +5 -2
- package/dist/templates/fullstack/AGENTS.md +18 -5
- package/dist/templates/fullstack/apps/api/package.json +1 -1
- package/dist/templates/fullstack/apps/api/src/app.extensions.ts +7 -4
- package/dist/templates/fullstack/apps/api/src/app.ts +6 -3
- package/dist/templates/fullstack/apps/api/src/config/env.validation.ts +28 -0
- package/dist/templates/fullstack/apps/api/src/config/providers.ts +4 -2
- package/dist/templates/fullstack/apps/api/src/config/server-options.spec.ts +17 -6
- package/dist/templates/fullstack/apps/api/src/config/server-options.ts +5 -8
- package/dist/templates/fullstack/apps/api/src/core/client-address.spec.ts +82 -0
- package/dist/templates/fullstack/apps/api/src/core/client-address.ts +68 -0
- package/dist/templates/fullstack/apps/api/src/core/services.ts +30 -0
- package/dist/templates/fullstack/apps/web/Dockerfile +4 -0
- package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.test.ts +291 -36
- package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.ts +196 -41
- package/dist/templates/fullstack/apps/web/src/lib/server/body-limits.ts +5 -2
- package/dist/templates/fullstack/apps/web/src/lib/server/proxy-timeouts.ts +22 -0
- package/dist/templates/fullstack/dot-env.docker.example +2 -0
- package/dist/templates/fullstack/dot-env.example +17 -1
- package/dist/templates/fullstack/infra/k3s/configmap.yaml +3 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/account-sessions/account-sessions.routes.spec.ts +152 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/account-sessions/account-sessions.routes.ts +112 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.revoker.ts +24 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.service.spec.ts +18 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.service.ts +36 -13
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin.module.ts +13 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/account-sessions.test.ts +33 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/account-sessions.ts +34 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-page.svelte +62 -38
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/table-toolbar.svelte +3 -1
- package/dist/templates/modules/admin-dashboard/files/tests/api/account-sessions.api.spec.ts +55 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/users-admin.api.spec.ts +26 -0
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/devices.ts +25 -0
- package/dist/templates/modules/admin-dashboard/files/tests/seed.setup.ts +3 -10
- package/dist/templates/modules/admin-dashboard/files/tests/ui/account-sessions.ui.spec.ts +46 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/account.ui.spec.ts +7 -4
- package/dist/templates/modules/admin-dashboard/files/tests/ui/table-toolbar.ui.spec.ts +25 -0
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.ts +2 -1
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.spec.ts +16 -0
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.ts +2 -1
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.module.ts +4 -1
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.spec.ts +54 -6
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.ts +26 -4
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +8 -3
- package/dist/templates/modules/auth/files/apps/api/src/auth/revoke-other-sessions.spec.ts +56 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/revoke-other-sessions.ts +45 -0
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.spec.ts +2 -59
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.ts +0 -65
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.spec.ts +82 -21
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.ts +54 -15
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.spec.ts +43 -5
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts +10 -2
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.routes.spec.ts +61 -3
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.routes.ts +48 -15
- package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +20 -9
- package/dist/templates/modules/rate-limit/module.manifest.json +6 -9
- package/dist/templates/todo/AGENTS.md +41 -5
- package/package.json +2 -2
|
@@ -59,3 +59,28 @@ test("users toolbar can pick the search field", async ({ page }) => {
|
|
|
59
59
|
await page.getByRole("option", { name: "Name", exact: true }).click();
|
|
60
60
|
await expect(fieldSelect).toContainText("Name");
|
|
61
61
|
});
|
|
62
|
+
|
|
63
|
+
test("users toolbar keeps the search box usable at phone width", async ({ page }) => {
|
|
64
|
+
await page.setViewportSize({ width: 390, height: 844 });
|
|
65
|
+
await ready(page, "/admin/users");
|
|
66
|
+
const input = await page.locator("#toolbar-search").boundingBox();
|
|
67
|
+
const field = await page.locator('button[data-slot="select-trigger"]').last().boundingBox();
|
|
68
|
+
const button = await page.getByRole("button", { name: "Search", exact: true }).boundingBox();
|
|
69
|
+
if (!input || !field || !button) throw new Error("expected the toolbar to render");
|
|
70
|
+
// The input wraps below the field select instead of shrinking beside it.
|
|
71
|
+
expect(input.width).toBeGreaterThanOrEqual(192);
|
|
72
|
+
expect(input.y).toBeGreaterThan(field.y + field.height);
|
|
73
|
+
expect(Math.abs(input.y + input.height / 2 - (button.y + button.height / 2))).toBeLessThan(1);
|
|
74
|
+
expect(await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth))
|
|
75
|
+
.toBe(0);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("users toolbar keeps the desktop search box on one row", async ({ page }) => {
|
|
79
|
+
await page.setViewportSize({ width: 1280, height: 800 });
|
|
80
|
+
await ready(page, "/admin/users");
|
|
81
|
+
const input = await page.locator("#toolbar-search").boundingBox();
|
|
82
|
+
const field = await page.locator('button[data-slot="select-trigger"]').last().boundingBox();
|
|
83
|
+
if (!input || !field) throw new Error("expected the toolbar to render");
|
|
84
|
+
expect(input.width).toBe(320);
|
|
85
|
+
expect(Math.abs(input.y + input.height / 2 - (field.y + field.height / 2))).toBeLessThan(1);
|
|
86
|
+
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createAuthMiddleware } from "better-auth/api";
|
|
2
|
+
import { clientAddressOf } from "../core/client-address";
|
|
2
3
|
import { recordAudit } from "./audit-events";
|
|
3
4
|
import { auditEnabled } from "./audit-enabled";
|
|
4
5
|
|
|
@@ -71,6 +72,6 @@ export const auditAfterHook = createAuthMiddleware(async (ctx) => {
|
|
|
71
72
|
actorEmail: actor?.email ?? null,
|
|
72
73
|
targetId,
|
|
73
74
|
targetLabel,
|
|
74
|
-
ip: ctx.headers
|
|
75
|
+
ip: clientAddressOf(ctx.headers) ?? null,
|
|
75
76
|
});
|
|
76
77
|
});
|
|
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, mock, test } from "bun:test";
|
|
|
2
2
|
import { SQL } from "bun";
|
|
3
3
|
import type { QueryRunner } from "typeorm";
|
|
4
4
|
import type { AuthSession } from "../auth/auth.service";
|
|
5
|
+
import { CLIENT_ADDRESS_HEADER } from "../core/client-address";
|
|
5
6
|
import { InitAuditLogs1720300000000 } from "../migrations/1720300000000-InitAuditLogs";
|
|
6
7
|
import { recordAudit, recordAuditLater } from "./audit-events";
|
|
7
8
|
import { AppException } from "@podosoft/podokit-contracts";
|
|
@@ -176,6 +177,21 @@ describe("AuditService", () => {
|
|
|
176
177
|
}
|
|
177
178
|
});
|
|
178
179
|
|
|
180
|
+
test("records the address the API resolved, not the caller's forwarded header", async () => {
|
|
181
|
+
const capture = sqlCapture();
|
|
182
|
+
const service = new AuditService(capture.sql, { dialect: "postgres", enabled: on });
|
|
183
|
+
const session: AuthSession = { user: { id: "user-1" }, session: {} };
|
|
184
|
+
const headers = {
|
|
185
|
+
"x-forwarded-for": "198.51.100.66, 198.51.100.67",
|
|
186
|
+
[CLIENT_ADDRESS_HEADER]: "203.0.113.7",
|
|
187
|
+
};
|
|
188
|
+
await service.recordRequest("todo.create", new Request("http://localhost/todos", { headers }), session);
|
|
189
|
+
await service.recordRequest("todo.delete", new Request("http://localhost/todos", {
|
|
190
|
+
headers: { "x-forwarded-for": "198.51.100.66" },
|
|
191
|
+
}), session);
|
|
192
|
+
expect(capture.batches.map((batch) => batch[0]?.ip)).toEqual(["203.0.113.7", null]);
|
|
193
|
+
});
|
|
194
|
+
|
|
179
195
|
test("a failing toggle read never breaks the caller", async () => {
|
|
180
196
|
const sql = await sqliteDatabase();
|
|
181
197
|
const service = new AuditService(sql, {
|
|
@@ -2,6 +2,7 @@ import type { SQL } from "bun";
|
|
|
2
2
|
import { AppException } from "@podosoft/podokit-contracts";
|
|
3
3
|
import type { DatabaseProviderName } from "@podosoft/podokit-runtime";
|
|
4
4
|
import type { AuthSession } from "../auth/auth.service";
|
|
5
|
+
import { clientAddressOf } from "../core/client-address";
|
|
5
6
|
import { setAuditRecorder, type AuditEntry } from "./audit-events";
|
|
6
7
|
|
|
7
8
|
export interface AuditLogView extends AuditEntry {
|
|
@@ -412,7 +413,7 @@ export class AuditService {
|
|
|
412
413
|
targetId: target.id ?? null,
|
|
413
414
|
targetLabel: target.label ?? null,
|
|
414
415
|
metadata: target.metadata ?? null,
|
|
415
|
-
ip: request.headers
|
|
416
|
+
ip: clientAddressOf(request.headers) ?? null,
|
|
416
417
|
};
|
|
417
418
|
if (options.defer) this.recordLater(entry);
|
|
418
419
|
else await this.record(entry);
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
OPENAPI,
|
|
9
9
|
REQUEST_GUARDS,
|
|
10
10
|
REQUEST_IDENTITY,
|
|
11
|
+
RESOLVED_SESSIONS,
|
|
11
12
|
type AppPlugin,
|
|
12
13
|
type PodokitModule,
|
|
13
14
|
type ServiceKey,
|
|
@@ -158,7 +159,9 @@ export const authModule: PodokitModule = {
|
|
|
158
159
|
setMailConfigStore(authConfigStore);
|
|
159
160
|
const accessPolicy = services.resolve(ACCESS_POLICY);
|
|
160
161
|
const settings = new SettingsService(database.sql, authConfigStore, publishConfigChanged);
|
|
161
|
-
const authService = new AuthService(settings, accessPolicy
|
|
162
|
+
const authService = new AuthService(settings, accessPolicy, {
|
|
163
|
+
resolvedSessions: services.resolve(RESOLVED_SESSIONS),
|
|
164
|
+
});
|
|
162
165
|
services.register(SETTINGS, settings);
|
|
163
166
|
services.register(AUTH_CONFIG, new AuthConfigService(database.sql, database.provider));
|
|
164
167
|
services.register(AUTH, authService, () => authService.close());
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, mock, test } from "bun:test";
|
|
2
|
-
import { AccessPolicy, type RequestIdentity } from "../core/services";
|
|
2
|
+
import { AccessPolicy, ResolvedSessions, type RequestIdentity, type ResolvedSession } from "../core/services";
|
|
3
3
|
import type { SettingsService } from "../settings/settings.service";
|
|
4
4
|
import {
|
|
5
5
|
AuthService,
|
|
@@ -21,7 +21,7 @@ function session(
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
function identityFor(resolver: SessionResolver): RequestIdentity {
|
|
24
|
-
return createRequestIdentity(new AuthService(settings(false), new AccessPolicy(), resolver));
|
|
24
|
+
return createRequestIdentity(new AuthService(settings(false), new AccessPolicy(), { resolveSession: resolver }));
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
describe("AuthService", () => {
|
|
@@ -33,12 +33,12 @@ describe("AuthService", () => {
|
|
|
33
33
|
test("keeps explicitly public routes unauthenticated", async () => {
|
|
34
34
|
const policy = new AccessPolicy();
|
|
35
35
|
policy.register("GET", "/public", "public");
|
|
36
|
-
const service = new AuthService(settings(false), policy, async () => null);
|
|
36
|
+
const service = new AuthService(settings(false), policy, { resolveSession: async () => null });
|
|
37
37
|
await expect(service.guard(new Request("http://localhost/public"))).resolves.toBeUndefined();
|
|
38
38
|
});
|
|
39
39
|
|
|
40
40
|
test("requires a session by default", async () => {
|
|
41
|
-
const service = new AuthService(settings(false), new AccessPolicy(), async () => null);
|
|
41
|
+
const service = new AuthService(settings(false), new AccessPolicy(), { resolveSession: async () => null });
|
|
42
42
|
const result = service.guard(new Request("http://localhost/private"));
|
|
43
43
|
await expect(result).rejects.toMatchObject({ code: "AUTH_REQUIRED", statusCode: 401 });
|
|
44
44
|
});
|
|
@@ -47,7 +47,7 @@ describe("AuthService", () => {
|
|
|
47
47
|
const service = new AuthService(
|
|
48
48
|
settings(true),
|
|
49
49
|
new AccessPolicy(),
|
|
50
|
-
async () => session({ twoFactorEnabled: false }),
|
|
50
|
+
{ resolveSession: async () => session({ twoFactorEnabled: false }) },
|
|
51
51
|
);
|
|
52
52
|
const result = service.guard(new Request("http://localhost/private"));
|
|
53
53
|
await expect(result).rejects.toMatchObject({ code: "TWO_FACTOR_REQUIRED", statusCode: 403 });
|
|
@@ -57,13 +57,61 @@ describe("AuthService", () => {
|
|
|
57
57
|
const service = new AuthService(
|
|
58
58
|
settings(false),
|
|
59
59
|
new AccessPolicy(),
|
|
60
|
-
async () => session({ role: "user" }),
|
|
60
|
+
{ resolveSession: async () => session({ role: "user" }) },
|
|
61
61
|
);
|
|
62
62
|
await expect(service.requireAdmin(new Request("http://localhost/account/settings")))
|
|
63
63
|
.rejects.toMatchObject({ code: "ADMIN_REQUIRED", statusCode: 403 });
|
|
64
64
|
});
|
|
65
65
|
});
|
|
66
66
|
|
|
67
|
+
describe("AuthService resolved sessions", () => {
|
|
68
|
+
const expiresAt = new Date("2030-01-02T03:04:05.000Z");
|
|
69
|
+
|
|
70
|
+
function reporting(resolver: SessionResolver): { service: AuthService; reported: ResolvedSession[] } {
|
|
71
|
+
const resolvedSessions = new ResolvedSessions();
|
|
72
|
+
const reported: ResolvedSession[] = [];
|
|
73
|
+
resolvedSessions.listen((resolved) => reported.push(resolved));
|
|
74
|
+
return {
|
|
75
|
+
service: new AuthService(settings(false), new AccessPolicy(), { resolveSession: resolver, resolvedSessions }),
|
|
76
|
+
reported,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
test("reports each resolved session once per request, before callers continue", async () => {
|
|
81
|
+
const resolver = mock(async () => session({}, { token: "session-token", expiresAt }));
|
|
82
|
+
const { service, reported } = reporting(resolver);
|
|
83
|
+
const request = new Request("http://localhost/private");
|
|
84
|
+
|
|
85
|
+
await service.requireSession(request);
|
|
86
|
+
expect(reported).toEqual([{ token: "session-token", userId: "user-1", expiresAt }]);
|
|
87
|
+
await service.session(request);
|
|
88
|
+
expect(resolver).toHaveBeenCalledTimes(1);
|
|
89
|
+
expect(reported).toHaveLength(1);
|
|
90
|
+
|
|
91
|
+
// A serialized expiry is accepted too.
|
|
92
|
+
const serialized = reporting(async () => session({}, { token: "t2", expiresAt: expiresAt.toISOString() }));
|
|
93
|
+
await serialized.service.session(new Request("http://localhost/private"));
|
|
94
|
+
expect(serialized.reported).toEqual([{ token: "t2", userId: "user-1", expiresAt }]);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("reports nothing without a session, a token, or a valid expiry, and keeps lookup errors", async () => {
|
|
98
|
+
for (const resolver of [
|
|
99
|
+
async () => null,
|
|
100
|
+
async () => session({}, { expiresAt }),
|
|
101
|
+
async () => session({}, { token: "session-token", expiresAt: "soon" }),
|
|
102
|
+
]) {
|
|
103
|
+
const { service, reported } = reporting(resolver);
|
|
104
|
+
await service.session(new Request("http://localhost/private"));
|
|
105
|
+
expect(reported).toEqual([]);
|
|
106
|
+
}
|
|
107
|
+
const failing = reporting(async () => {
|
|
108
|
+
throw new Error("database down");
|
|
109
|
+
});
|
|
110
|
+
await expect(failing.service.session(new Request("http://localhost/private"))).rejects.toThrow("database down");
|
|
111
|
+
expect(failing.reported).toEqual([]);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
67
115
|
describe("createRequestIdentity", () => {
|
|
68
116
|
const request = (): Request => new Request("http://localhost/events");
|
|
69
117
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { AppException } from "@podosoft/podokit-contracts";
|
|
2
|
-
import type { AccessPolicy, RequestIdentity } from "../core/services";
|
|
2
|
+
import type { AccessPolicy, RequestIdentity, ResolvedSessions } from "../core/services";
|
|
3
3
|
import type { SettingsService } from "../settings/settings.service";
|
|
4
4
|
import { getAuth, refreshForRequest } from "./auth-provider";
|
|
5
5
|
import { rethrowAuthError } from "./database-outage";
|
|
@@ -37,19 +37,34 @@ function activeOrganizationOf(session: AuthSession): string | undefined {
|
|
|
37
37
|
return typeof value === "string" && value !== "" ? value : undefined;
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
export interface AuthServiceOptions {
|
|
41
|
+
/** Looks a request's session up; Better Auth's getSession by default. */
|
|
42
|
+
resolveSession?: SessionResolver;
|
|
43
|
+
/** Receives every session this service resolves, such as for the rate limiter. */
|
|
44
|
+
resolvedSessions?: ResolvedSessions;
|
|
45
|
+
}
|
|
46
|
+
|
|
40
47
|
export class AuthService {
|
|
41
48
|
private readonly sessions = new WeakMap<Request, Promise<AuthSession | null>>();
|
|
49
|
+
private readonly resolveSession: SessionResolver;
|
|
50
|
+
private readonly resolvedSessions: ResolvedSessions | undefined;
|
|
42
51
|
|
|
43
52
|
constructor(
|
|
44
53
|
private readonly settings: SettingsService,
|
|
45
54
|
private readonly accessPolicy: AccessPolicy,
|
|
46
|
-
|
|
47
|
-
) {
|
|
55
|
+
options: AuthServiceOptions = {},
|
|
56
|
+
) {
|
|
57
|
+
this.resolveSession = options.resolveSession ?? defaultSessionResolver;
|
|
58
|
+
this.resolvedSessions = options.resolvedSessions;
|
|
59
|
+
}
|
|
48
60
|
|
|
49
61
|
session(request: Request): Promise<AuthSession | null> {
|
|
50
62
|
const cached = this.sessions.get(request);
|
|
51
63
|
if (cached) return cached;
|
|
52
|
-
const result = this.resolveSession(request.headers)
|
|
64
|
+
const result = this.resolveSession(request.headers).then((session) => {
|
|
65
|
+
if (session) this.report(session);
|
|
66
|
+
return session;
|
|
67
|
+
});
|
|
53
68
|
this.sessions.set(request, result);
|
|
54
69
|
return result;
|
|
55
70
|
}
|
|
@@ -84,6 +99,13 @@ export class AuthService {
|
|
|
84
99
|
async close(): Promise<void> {
|
|
85
100
|
await closeAuthDatabase();
|
|
86
101
|
}
|
|
102
|
+
|
|
103
|
+
private report(session: AuthSession): void {
|
|
104
|
+
const { token, expiresAt } = session.session;
|
|
105
|
+
const expires = expiresAt instanceof Date ? expiresAt : new Date(String(expiresAt));
|
|
106
|
+
if (!this.resolvedSessions || typeof token !== "string" || Number.isNaN(expires.getTime())) return;
|
|
107
|
+
this.resolvedSessions.report({ token, userId: session.user.id, expiresAt: expires });
|
|
108
|
+
}
|
|
87
109
|
}
|
|
88
110
|
|
|
89
111
|
/** Answers identity questions for other modules from the one session lookup memoized per request. */
|
|
@@ -15,6 +15,8 @@ import { SIGNUP_APPROVAL_REQUIRED } from "@podosoft/podokit-contracts";
|
|
|
15
15
|
import { runUserDeletedHandlers } from "./user-delete-handlers";
|
|
16
16
|
import { authBaseUrl, authSecret } from "./auth-environment";
|
|
17
17
|
import { isMaintenanceCommand } from "./maintenance";
|
|
18
|
+
import { CLIENT_ADDRESS_HEADER } from "../core/client-address";
|
|
19
|
+
import { revokeEveryOtherSession } from "./revoke-other-sessions";
|
|
18
20
|
// podokit:begin:auth-imports
|
|
19
21
|
// podokit:end:auth-imports
|
|
20
22
|
|
|
@@ -55,6 +57,8 @@ export function buildAuth(config: AuthConfig) {
|
|
|
55
57
|
// First, so a disabled feature answers 404 before another plugin's onRequest
|
|
56
58
|
// (such as the OIDC provider's discovery metadata) can answer.
|
|
57
59
|
featureGatePlugin(gateSettings),
|
|
60
|
+
// "Sign out other sessions" revokes every other session, not only the first 100.
|
|
61
|
+
revokeEveryOtherSession(),
|
|
58
62
|
twoFactor(),
|
|
59
63
|
magicLink({
|
|
60
64
|
sendMagicLink: async ({ email, url }: { email: string; url: string; token: string }) => {
|
|
@@ -235,9 +239,10 @@ export function buildAuth(config: AuthConfig) {
|
|
|
235
239
|
baseURL: authBaseUrl(),
|
|
236
240
|
advanced: {
|
|
237
241
|
ipAddress: {
|
|
238
|
-
// The API
|
|
239
|
-
//
|
|
240
|
-
|
|
242
|
+
// The address the API resolved behind its trusted proxies, which it sets
|
|
243
|
+
// on every request, so sessions and Better Auth's own rate limits use the
|
|
244
|
+
// same client address as the rest of the API.
|
|
245
|
+
ipAddressHeaders: [CLIENT_ADDRESS_HEADER],
|
|
241
246
|
},
|
|
242
247
|
...(isMaintenanceCommand() ? { database: { validateSchema: false } } : {}),
|
|
243
248
|
},
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { betterAuth, type BetterAuthPlugin } from "better-auth";
|
|
3
|
+
import { memoryAdapter } from "better-auth/adapters/memory";
|
|
4
|
+
import { revokeEveryOtherSession } from "./revoke-other-sessions";
|
|
5
|
+
|
|
6
|
+
type Row = Record<string, unknown>;
|
|
7
|
+
|
|
8
|
+
// A user signed in once more than Better Auth's default findMany limit of 100
|
|
9
|
+
// sessions per list allows, plus the 50 extra the endpoint used to leave behind.
|
|
10
|
+
const EXTRA_SESSIONS = 150;
|
|
11
|
+
|
|
12
|
+
async function signedInWithManySessions(plugins: BetterAuthPlugin[]): Promise<{
|
|
13
|
+
revokeOthers: () => Promise<unknown>;
|
|
14
|
+
sessions: () => Row[];
|
|
15
|
+
currentToken: string;
|
|
16
|
+
}> {
|
|
17
|
+
const db: Record<string, Row[]> = { user: [], session: [], account: [], verification: [] };
|
|
18
|
+
const auth = betterAuth({
|
|
19
|
+
database: memoryAdapter(db),
|
|
20
|
+
secret: "revoke-other-sessions-test-secret-0123456789",
|
|
21
|
+
baseURL: "http://localhost:5002",
|
|
22
|
+
emailAndPassword: { enabled: true },
|
|
23
|
+
plugins,
|
|
24
|
+
});
|
|
25
|
+
const response = await auth.api.signUpEmail({
|
|
26
|
+
body: { email: "many@example.com", password: "Podokit3e-Str0ng!pw", name: "Many" },
|
|
27
|
+
asResponse: true,
|
|
28
|
+
});
|
|
29
|
+
const cookie = response.headers.getSetCookie().map((value) => value.split(";")[0]).join("; ");
|
|
30
|
+
const context = await auth.$context;
|
|
31
|
+
const userId = String(db.user[0]?.id);
|
|
32
|
+
for (let index = 0; index < EXTRA_SESSIONS; index += 1) {
|
|
33
|
+
await context.internalAdapter.createSession(userId);
|
|
34
|
+
}
|
|
35
|
+
const currentToken = String((await auth.api.getSession({ headers: new Headers({ cookie }) }))?.session.token);
|
|
36
|
+
return {
|
|
37
|
+
revokeOthers: () => auth.api.revokeOtherSessions({ headers: new Headers({ cookie }) }),
|
|
38
|
+
sessions: () => db.session ?? [],
|
|
39
|
+
currentToken,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe("revokeEveryOtherSession", () => {
|
|
44
|
+
test("Better Auth alone revokes only the first 100 other sessions", async () => {
|
|
45
|
+
const user = await signedInWithManySessions([]);
|
|
46
|
+
expect(user.sessions()).toHaveLength(EXTRA_SESSIONS + 1);
|
|
47
|
+
await user.revokeOthers();
|
|
48
|
+
expect(user.sessions().length).toBeGreaterThan(1);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("revokes every other session and keeps the current one", async () => {
|
|
52
|
+
const user = await signedInWithManySessions([revokeEveryOtherSession()]);
|
|
53
|
+
await user.revokeOthers();
|
|
54
|
+
expect(user.sessions().map((session) => session.token)).toEqual([user.currentToken]);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { BetterAuthPlugin } from "better-auth";
|
|
2
|
+
import { createAuthMiddleware } from "better-auth/api";
|
|
3
|
+
|
|
4
|
+
/** Sessions read and revoked per pass. */
|
|
5
|
+
const BATCH_SIZE = 500;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Makes `/revoke-other-sessions` revoke every other session of the user.
|
|
9
|
+
* Better Auth's endpoint lists the sessions through the adapter's default
|
|
10
|
+
* findMany limit of 100 and revokes only those, so a user with more sessions
|
|
11
|
+
* kept the rest after "sign out other sessions". This hook runs after the
|
|
12
|
+
* endpoint and revokes the remainder in batches through the internal adapter,
|
|
13
|
+
* which also clears their secondary-storage entries when one is configured.
|
|
14
|
+
*/
|
|
15
|
+
export function revokeEveryOtherSession(): BetterAuthPlugin {
|
|
16
|
+
return {
|
|
17
|
+
id: "podokit-revoke-every-other-session",
|
|
18
|
+
hooks: {
|
|
19
|
+
after: [
|
|
20
|
+
{
|
|
21
|
+
matcher: (context) => context.path === "/revoke-other-sessions",
|
|
22
|
+
handler: createAuthMiddleware(async (ctx) => {
|
|
23
|
+
const current = ctx.context.session;
|
|
24
|
+
if (!current) return;
|
|
25
|
+
let first: string | undefined;
|
|
26
|
+
for (;;) {
|
|
27
|
+
const others = await ctx.context.adapter.findMany<{ token: string }>({
|
|
28
|
+
model: "session",
|
|
29
|
+
where: [
|
|
30
|
+
{ field: "userId", value: current.user.id },
|
|
31
|
+
{ field: "token", value: current.session.token, operator: "ne" },
|
|
32
|
+
],
|
|
33
|
+
limit: BATCH_SIZE,
|
|
34
|
+
});
|
|
35
|
+
// Stop when nothing is left, or when a pass removed nothing.
|
|
36
|
+
if (others.length === 0 || others[0]?.token === first) return;
|
|
37
|
+
first = others[0]?.token;
|
|
38
|
+
await ctx.context.internalAdapter.deleteSessions(others.map((session) => session.token));
|
|
39
|
+
}
|
|
40
|
+
}),
|
|
41
|
+
},
|
|
42
|
+
],
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.spec.ts
CHANGED
|
@@ -1,12 +1,8 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import {
|
|
3
|
-
clientAddressFromProxy,
|
|
4
|
-
rateLimitConfig,
|
|
5
|
-
sessionSigningSecret,
|
|
6
|
-
} from "./rate-limit.config";
|
|
2
|
+
import { rateLimitConfig, sessionSigningSecret } from "./rate-limit.config";
|
|
7
3
|
|
|
8
4
|
describe("rate limit configuration", () => {
|
|
9
|
-
test("validates route limits
|
|
5
|
+
test("validates route limits", () => {
|
|
10
6
|
expect(
|
|
11
7
|
rateLimitConfig({
|
|
12
8
|
RATE_LIMIT_KEY_PREFIX: "podokit:tenant-a:rate-limit",
|
|
@@ -15,8 +11,6 @@ describe("rate limit configuration", () => {
|
|
|
15
11
|
RATE_LIMIT_AUTH_TTL: "45",
|
|
16
12
|
RATE_LIMIT_AUTH_MAX: "15",
|
|
17
13
|
RATE_LIMIT_RUNTIME_MAX: "900",
|
|
18
|
-
RATE_LIMIT_TRUSTED_PROXY_HOPS: "2",
|
|
19
|
-
RATE_LIMIT_PROXY_HEADER: "X-Real-IP",
|
|
20
14
|
RATE_LIMIT_STORAGE_TIMEOUT_MS: "750",
|
|
21
15
|
RATE_LIMIT_UNAVAILABLE_RETRY_AFTER: "3",
|
|
22
16
|
}),
|
|
@@ -27,69 +21,18 @@ describe("rate limit configuration", () => {
|
|
|
27
21
|
authTtlSeconds: 45,
|
|
28
22
|
authLimit: 15,
|
|
29
23
|
runtimeLimit: 900,
|
|
30
|
-
trustedProxyHops: 2,
|
|
31
|
-
proxyHeader: "x-real-ip",
|
|
32
24
|
storageTimeoutMs: 750,
|
|
33
25
|
unavailableRetryAfterSeconds: 3,
|
|
34
26
|
});
|
|
35
27
|
expect(() => rateLimitConfig({ RATE_LIMIT_MAX: "0" })).toThrow(
|
|
36
28
|
"RATE_LIMIT_MAX must be a positive integer",
|
|
37
29
|
);
|
|
38
|
-
expect(() =>
|
|
39
|
-
rateLimitConfig({ RATE_LIMIT_TRUSTED_PROXY_HOPS: "-1" }),
|
|
40
|
-
).toThrow("must be a non-negative integer");
|
|
41
|
-
expect(() =>
|
|
42
|
-
rateLimitConfig({ RATE_LIMIT_PROXY_HEADER: "invalid header" }),
|
|
43
|
-
).toThrow("must be a valid HTTP header name");
|
|
44
30
|
expect(() => rateLimitConfig({ RATE_LIMIT_KEY_PREFIX: "invalid prefix!" })).toThrow(
|
|
45
31
|
"RATE_LIMIT_KEY_PREFIX must be 1-128 characters",
|
|
46
32
|
);
|
|
47
33
|
expect(rateLimitConfig({}).keyPrefix).toBe("podokit:rate-limit");
|
|
48
34
|
});
|
|
49
35
|
|
|
50
|
-
test("selects only the address outside the configured trusted proxy depth", () => {
|
|
51
|
-
const headers = {
|
|
52
|
-
"x-forwarded-for": "203.0.113.7, 198.51.100.8",
|
|
53
|
-
};
|
|
54
|
-
expect(
|
|
55
|
-
clientAddressFromProxy(headers, "10.0.0.9", {
|
|
56
|
-
proxyHeader: "x-forwarded-for",
|
|
57
|
-
trustedProxyHops: 2,
|
|
58
|
-
}),
|
|
59
|
-
).toBe("203.0.113.7");
|
|
60
|
-
expect(
|
|
61
|
-
clientAddressFromProxy(headers, "10.0.0.9", {
|
|
62
|
-
proxyHeader: "x-forwarded-for",
|
|
63
|
-
trustedProxyHops: 1,
|
|
64
|
-
}),
|
|
65
|
-
).toBe("198.51.100.8");
|
|
66
|
-
expect(
|
|
67
|
-
clientAddressFromProxy(headers, "10.0.0.9", {
|
|
68
|
-
proxyHeader: "x-forwarded-for",
|
|
69
|
-
trustedProxyHops: 0,
|
|
70
|
-
}),
|
|
71
|
-
).toBe("10.0.0.9");
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
test("ignores malformed or incomplete forwarded chains", () => {
|
|
75
|
-
expect(
|
|
76
|
-
clientAddressFromProxy(
|
|
77
|
-
{ "x-forwarded-for": "attacker-controlled" },
|
|
78
|
-
"::ffff:10.0.0.9",
|
|
79
|
-
{
|
|
80
|
-
proxyHeader: "x-forwarded-for",
|
|
81
|
-
trustedProxyHops: 1,
|
|
82
|
-
},
|
|
83
|
-
),
|
|
84
|
-
).toBe("10.0.0.9");
|
|
85
|
-
expect(
|
|
86
|
-
clientAddressFromProxy({}, "10.0.0.9", {
|
|
87
|
-
proxyHeader: "x-forwarded-for",
|
|
88
|
-
trustedProxyHops: 1,
|
|
89
|
-
}),
|
|
90
|
-
).toBe("10.0.0.9");
|
|
91
|
-
});
|
|
92
|
-
|
|
93
36
|
test("reads the session signing secret the way the auth module does", () => {
|
|
94
37
|
expect(
|
|
95
38
|
sessionSigningSecret({ BETTER_AUTH_SECRET: " session-secret-with-at-least-32-chars " }),
|
package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.ts
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import { isIP } from "node:net";
|
|
2
|
-
|
|
3
1
|
export interface RateLimitConfig {
|
|
4
2
|
keyPrefix: string;
|
|
5
3
|
ttlSeconds: number;
|
|
@@ -7,8 +5,6 @@ export interface RateLimitConfig {
|
|
|
7
5
|
authTtlSeconds: number;
|
|
8
6
|
authLimit: number;
|
|
9
7
|
runtimeLimit: number;
|
|
10
|
-
trustedProxyHops: number;
|
|
11
|
-
proxyHeader: string;
|
|
12
8
|
storageTimeoutMs: number;
|
|
13
9
|
unavailableRetryAfterSeconds: number;
|
|
14
10
|
}
|
|
@@ -32,23 +28,6 @@ function positiveInteger(name: string, value: string | undefined, fallback: numb
|
|
|
32
28
|
return parsed;
|
|
33
29
|
}
|
|
34
30
|
|
|
35
|
-
function nonNegativeInteger(name: string, value: string | undefined, fallback: number): number {
|
|
36
|
-
if (value === undefined || value === "") return fallback;
|
|
37
|
-
const parsed = Number(value);
|
|
38
|
-
if (!Number.isInteger(parsed) || parsed < 0) {
|
|
39
|
-
throw new Error(`${name} must be a non-negative integer`);
|
|
40
|
-
}
|
|
41
|
-
return parsed;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
function headerName(value: string | undefined): string {
|
|
45
|
-
const normalized = (value || "x-forwarded-for").trim().toLowerCase();
|
|
46
|
-
if (!/^[a-z0-9!#$%&'*+.^_`|~-]+$/.test(normalized)) {
|
|
47
|
-
throw new Error("RATE_LIMIT_PROXY_HEADER must be a valid HTTP header name");
|
|
48
|
-
}
|
|
49
|
-
return normalized;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
31
|
export function rateLimitConfig(env: NodeJS.ProcessEnv = process.env): RateLimitConfig {
|
|
53
32
|
return {
|
|
54
33
|
keyPrefix: keyPrefix(env.RATE_LIMIT_KEY_PREFIX),
|
|
@@ -65,12 +44,6 @@ export function rateLimitConfig(env: NodeJS.ProcessEnv = process.env): RateLimit
|
|
|
65
44
|
env.RATE_LIMIT_RUNTIME_MAX,
|
|
66
45
|
1000,
|
|
67
46
|
),
|
|
68
|
-
trustedProxyHops: nonNegativeInteger(
|
|
69
|
-
"RATE_LIMIT_TRUSTED_PROXY_HOPS",
|
|
70
|
-
env.RATE_LIMIT_TRUSTED_PROXY_HOPS,
|
|
71
|
-
0,
|
|
72
|
-
),
|
|
73
|
-
proxyHeader: headerName(env.RATE_LIMIT_PROXY_HEADER),
|
|
74
47
|
storageTimeoutMs: positiveInteger(
|
|
75
48
|
"RATE_LIMIT_STORAGE_TIMEOUT_MS",
|
|
76
49
|
env.RATE_LIMIT_STORAGE_TIMEOUT_MS,
|
|
@@ -90,41 +63,3 @@ export function rateLimitConfig(env: NodeJS.ProcessEnv = process.env): RateLimit
|
|
|
90
63
|
export function sessionSigningSecret(env: NodeJS.ProcessEnv = process.env): string | undefined {
|
|
91
64
|
return env.BETTER_AUTH_SECRET?.trim() || undefined;
|
|
92
65
|
}
|
|
93
|
-
|
|
94
|
-
function normalizeIp(value: string | undefined): string | undefined {
|
|
95
|
-
if (!value) return undefined;
|
|
96
|
-
const trimmed = value.trim();
|
|
97
|
-
const normalized = trimmed.startsWith("::ffff:")
|
|
98
|
-
? trimmed.slice("::ffff:".length)
|
|
99
|
-
: trimmed;
|
|
100
|
-
return isIP(normalized) === 0 ? undefined : normalized;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
function headerValue(headers: unknown, name: string): string | undefined {
|
|
104
|
-
if (headers instanceof Headers) return headers.get(name) ?? undefined;
|
|
105
|
-
if (!headers || typeof headers !== "object") return undefined;
|
|
106
|
-
const value = (headers as Record<string, unknown>)[name];
|
|
107
|
-
if (typeof value === "string") return value;
|
|
108
|
-
if (!Array.isArray(value)) return undefined;
|
|
109
|
-
const strings = value.filter((item): item is string => typeof item === "string");
|
|
110
|
-
return strings.length > 0 ? strings.join(",") : undefined;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
export function clientAddressFromProxy(
|
|
114
|
-
headers: unknown,
|
|
115
|
-
remoteAddress: string | undefined,
|
|
116
|
-
config: Pick<RateLimitConfig, "proxyHeader" | "trustedProxyHops">,
|
|
117
|
-
): string {
|
|
118
|
-
const direct = normalizeIp(remoteAddress) ?? "unknown";
|
|
119
|
-
if (config.trustedProxyHops === 0) return direct;
|
|
120
|
-
|
|
121
|
-
const forwarded = headerValue(headers, config.proxyHeader)
|
|
122
|
-
?.split(",")
|
|
123
|
-
.map((value) => value.trim())
|
|
124
|
-
.filter(Boolean) ?? [];
|
|
125
|
-
const chain = [...forwarded, direct];
|
|
126
|
-
if (chain.length <= config.trustedProxyHops) return direct;
|
|
127
|
-
|
|
128
|
-
const selected = chain[chain.length - config.trustedProxyHops - 1];
|
|
129
|
-
return normalizeIp(selected) ?? direct;
|
|
130
|
-
}
|