@podosoft/podokit 1.4.1 → 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 +3 -1
- package/dist/deploy-profile.js +2 -2
- package/dist/deploy-render.js +16 -8
- package/dist/deploy-schema.d.ts +7 -0
- package/dist/deploy-schema.js +40 -0
- package/dist/lockfile.d.ts +8 -1
- package/dist/lockfile.js +23 -4
- package/dist/provider.d.ts +1 -0
- package/dist/provider.js +5 -2
- package/dist/templates/fullstack/AGENTS.md +27 -2
- package/dist/templates/fullstack/README.md +1 -1
- package/dist/templates/fullstack/apps/api/package.json +1 -1
- package/dist/templates/fullstack/apps/api/scripts/run-migrations.mjs +4 -0
- package/dist/templates/fullstack/apps/api/src/app.extensions.ts +7 -2
- 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/api/src/database/database.spec.ts +66 -0
- package/dist/templates/fullstack/apps/api/src/database/database.ts +34 -11
- package/dist/templates/fullstack/apps/api/src/database/run-migrations.spec.ts +54 -6
- package/dist/templates/fullstack/apps/web/Dockerfile +5 -1
- package/dist/templates/fullstack/apps/web/src/lib/server/api.test.ts +116 -0
- package/dist/templates/fullstack/apps/web/src/lib/server/api.ts +34 -4
- package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.test.ts +436 -6
- package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.ts +352 -38
- 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/apps/web/src/routes/healthz/+server.ts +13 -0
- package/dist/templates/fullstack/dot-env.docker.example +6 -0
- package/dist/templates/fullstack/dot-env.example +23 -0
- package/dist/templates/fullstack/infra/k3s/configmap.yaml +3 -0
- package/dist/templates/fullstack/infra/k3s/web-deployment.yaml +6 -3
- package/dist/templates/fullstack/tests/api/health.api.spec.ts +8 -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/hooks.server.test.ts +24 -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/apps/web/src/lib/server/runtime-context.test.ts +6 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/runtime-context.ts +5 -0
- 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/auth-config.api.spec.ts +167 -154
- package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +94 -57
- package/dist/templates/modules/admin-dashboard/files/tests/api/exclusive.api.spec.ts +75 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +49 -39
- 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/helpers/exclusive.ts +80 -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 +54 -47
- package/dist/templates/modules/admin-dashboard/files/tests/ui/appearance.ui.spec.ts +15 -11
- package/dist/templates/modules/admin-dashboard/files/tests/ui/auth.ui.spec.ts +35 -31
- package/dist/templates/modules/admin-dashboard/files/tests/ui/general-effects.ui.spec.ts +90 -76
- package/dist/templates/modules/admin-dashboard/files/tests/ui/i18n.ui.spec.ts +19 -15
- package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +17 -3
- package/dist/templates/modules/admin-dashboard/files/tests/ui/request-budget.ui.spec.ts +15 -6
- package/dist/templates/modules/admin-dashboard/files/tests/ui/settings-dialogs.ui.spec.ts +71 -64
- package/dist/templates/modules/admin-dashboard/files/tests/ui/settings.ui.spec.ts +89 -79
- package/dist/templates/modules/admin-dashboard/files/tests/ui/table-toolbar.ui.spec.ts +25 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/two-factor.ui.spec.ts +54 -50
- 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 +19 -8
- package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.spec.ts +83 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.ts +51 -7
- 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/dist/templates/todo/README.md +1 -1
- package/package.json +2 -2
|
@@ -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. */
|
|
@@ -3,7 +3,7 @@ import { APIError } from "better-auth/api";
|
|
|
3
3
|
import { twoFactor, haveIBeenPwned, magicLink, emailOTP, username, multiSession, phoneNumber, organization, jwt, bearer, openAPI } from "better-auth/plugins";
|
|
4
4
|
import { actionEmail, sendMail } from "../mail/mailer";
|
|
5
5
|
import { sendSms } from "../sms/sms";
|
|
6
|
-
import { assertUserCreationAllowed, createFeatureGate, readSignupOpen } from "./feature-gate";
|
|
6
|
+
import { assertUserCreationAllowed, createFeatureGate, featureGatePlugin, readSignupOpen, type FeatureGateSettings } from "./feature-gate";
|
|
7
7
|
import { featureFlags, isSignupOpen } from "./auth-config-store";
|
|
8
8
|
import { orgAc, orgRoles } from "./org-permissions";
|
|
9
9
|
import { authDatabase } from "./db";
|
|
@@ -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
|
|
|
@@ -48,7 +50,15 @@ function buildSocial(config: AuthConfig): NonNullable<BetterAuthOptions["socialP
|
|
|
48
50
|
// server-enforced toggles) apply without a restart. Everything below the injection
|
|
49
51
|
// markers is contributed by other PodoKit modules (admin-dashboard, audit-log).
|
|
50
52
|
export function buildAuth(config: AuthConfig) {
|
|
53
|
+
// The feature gate reads the process-wide settings snapshot, so a rebuild does
|
|
54
|
+
// not start a new cache.
|
|
55
|
+
const gateSettings: FeatureGateSettings = { flags: featureFlags, signupOpen: isSignupOpen };
|
|
51
56
|
const plugins: BetterAuthPlugin[] = [
|
|
57
|
+
// First, so a disabled feature answers 404 before another plugin's onRequest
|
|
58
|
+
// (such as the OIDC provider's discovery metadata) can answer.
|
|
59
|
+
featureGatePlugin(gateSettings),
|
|
60
|
+
// "Sign out other sessions" revokes every other session, not only the first 100.
|
|
61
|
+
revokeEveryOtherSession(),
|
|
52
62
|
twoFactor(),
|
|
53
63
|
magicLink({
|
|
54
64
|
sendMagicLink: async ({ email, url }: { email: string; url: string; token: string }) => {
|
|
@@ -179,11 +189,11 @@ export function buildAuth(config: AuthConfig) {
|
|
|
179
189
|
};
|
|
180
190
|
|
|
181
191
|
// Request-time hooks. The feature gate turns the admin Settings toggles into a
|
|
182
|
-
// real server boundary (disabled features 404)
|
|
183
|
-
//
|
|
184
|
-
//
|
|
192
|
+
// real server boundary (disabled features 404) for endpoint calls, including
|
|
193
|
+
// in-process auth.api calls the plugin above never sees. Other PodoKit modules
|
|
194
|
+
// add their own hooks below the marker.
|
|
185
195
|
const hooks: NonNullable<BetterAuthOptions["hooks"]> = {
|
|
186
|
-
before: createFeatureGate(
|
|
196
|
+
before: createFeatureGate(gateSettings),
|
|
187
197
|
};
|
|
188
198
|
// podokit:begin:auth-hooks
|
|
189
199
|
// podokit:end:auth-hooks
|
|
@@ -229,9 +239,10 @@ export function buildAuth(config: AuthConfig) {
|
|
|
229
239
|
baseURL: authBaseUrl(),
|
|
230
240
|
advanced: {
|
|
231
241
|
ipAddress: {
|
|
232
|
-
// The API
|
|
233
|
-
//
|
|
234
|
-
|
|
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],
|
|
235
246
|
},
|
|
236
247
|
...(isMaintenanceCommand() ? { database: { validateSchema: false } } : {}),
|
|
237
248
|
},
|
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { oauthProvider } from "@better-auth/oauth-provider";
|
|
3
|
+
import { betterAuth } from "better-auth";
|
|
4
|
+
import { memoryAdapter } from "better-auth/adapters/memory";
|
|
5
|
+
import { jwt } from "better-auth/plugins";
|
|
2
6
|
import { FLAG_DEFAULTS, flagsFrom } from "../settings/flag-defaults";
|
|
3
7
|
import {
|
|
8
|
+
authRequestPath,
|
|
4
9
|
enforceFeatureGate,
|
|
10
|
+
featureForPath,
|
|
11
|
+
featureGatePlugin,
|
|
5
12
|
PUBLIC_SIGNUP_DISABLED,
|
|
6
13
|
signupOpenFrom,
|
|
7
14
|
type FeatureFlags,
|
|
@@ -56,3 +63,79 @@ describe("feature gate", () => {
|
|
|
56
63
|
expect(signupOpenFrom(new Map())).toBe(true);
|
|
57
64
|
});
|
|
58
65
|
});
|
|
66
|
+
|
|
67
|
+
describe("OIDC provider gate", () => {
|
|
68
|
+
test("covers the provider's endpoints but not the jwt plugin's", () => {
|
|
69
|
+
for (const path of [
|
|
70
|
+
"/oauth2/authorize",
|
|
71
|
+
"/oauth2/token",
|
|
72
|
+
"/oauth2/userinfo",
|
|
73
|
+
"/oauth2/register",
|
|
74
|
+
"/oauth2/consent",
|
|
75
|
+
"/oauth2/create-client",
|
|
76
|
+
"/oauth2/end-session",
|
|
77
|
+
"/admin/oauth2/create-client",
|
|
78
|
+
"/.well-known/openid-configuration",
|
|
79
|
+
"/.well-known/oauth-authorization-server",
|
|
80
|
+
"/.well-known/oauth-authorization-server/api/auth",
|
|
81
|
+
]) {
|
|
82
|
+
expect(featureForPath(path)).toBe("oidcProvider");
|
|
83
|
+
}
|
|
84
|
+
for (const path of ["/jwks", "/token", "/get-session", "/sign-in/email"]) {
|
|
85
|
+
expect(featureForPath(path)).toBeUndefined();
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("resolves request paths against the auth base URL", () => {
|
|
90
|
+
const base = "http://localhost:5002/api/auth";
|
|
91
|
+
expect(authRequestPath("http://localhost:5002/api/auth/oauth2/authorize?client_id=x", base))
|
|
92
|
+
.toBe("/oauth2/authorize");
|
|
93
|
+
expect(authRequestPath("http://localhost:5002/api/auth/.well-known/openid-configuration", `${base}/`))
|
|
94
|
+
.toBe("/.well-known/openid-configuration");
|
|
95
|
+
expect(authRequestPath("http://localhost:5002/.well-known/oauth-authorization-server/api/auth", base))
|
|
96
|
+
.toBe("/.well-known/oauth-authorization-server/api/auth");
|
|
97
|
+
expect(authRequestPath("http://localhost:5002/api/authority/oauth2/token", base))
|
|
98
|
+
.toBe("/api/authority/oauth2/token");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("answers the provider's discovery with 404 while it is disabled and serves it once enabled", async () => {
|
|
102
|
+
let enabled = false;
|
|
103
|
+
const auth = betterAuth({
|
|
104
|
+
database: memoryAdapter({ jwks: [] }),
|
|
105
|
+
baseURL: "http://localhost:5002",
|
|
106
|
+
secret: "feature-gate-spec-secret-0123456789abcdef",
|
|
107
|
+
logger: { disabled: true },
|
|
108
|
+
plugins: [
|
|
109
|
+
featureGatePlugin({
|
|
110
|
+
flags: async () => ({ ...FLAG_DEFAULTS, oidcProvider: enabled }),
|
|
111
|
+
signupOpen: async () => true,
|
|
112
|
+
}),
|
|
113
|
+
jwt(),
|
|
114
|
+
// Serves the discovery metadata from its own onRequest hook.
|
|
115
|
+
oauthProvider({ loginPage: "/login", consentPage: "/oauth2/consent" }),
|
|
116
|
+
],
|
|
117
|
+
});
|
|
118
|
+
const request = (path: string): Promise<Response> =>
|
|
119
|
+
auth.handler(new Request(`http://localhost:5002${path}`));
|
|
120
|
+
const discovery = [
|
|
121
|
+
"/api/auth/.well-known/openid-configuration",
|
|
122
|
+
"/api/auth/.well-known/oauth-authorization-server",
|
|
123
|
+
"/.well-known/oauth-authorization-server/api/auth",
|
|
124
|
+
];
|
|
125
|
+
|
|
126
|
+
for (const path of discovery) {
|
|
127
|
+
const response = await request(path);
|
|
128
|
+
expect(response.status).toBe(404);
|
|
129
|
+
expect(await response.json()).toEqual({ message: "This feature is disabled." });
|
|
130
|
+
}
|
|
131
|
+
expect((await request("/api/auth/oauth2/authorize?client_id=probe")).status).toBe(404);
|
|
132
|
+
expect((await request("/api/auth/jwks")).status).toBe(200);
|
|
133
|
+
|
|
134
|
+
enabled = true;
|
|
135
|
+
for (const path of discovery) {
|
|
136
|
+
const response = await request(path);
|
|
137
|
+
expect(response.status).toBe(200);
|
|
138
|
+
expect((await response.json()).issuer).toBe("http://localhost:5002/api/auth");
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
});
|
|
@@ -1,10 +1,13 @@
|
|
|
1
|
+
import type { BetterAuthPlugin } from "better-auth";
|
|
1
2
|
import { APIError, createAuthMiddleware } from "better-auth/api";
|
|
2
3
|
import { FLAG_DEFAULTS, type FeatureFlag } from "../settings/flag-defaults";
|
|
3
4
|
|
|
4
5
|
export const PUBLIC_SIGNUP_DISABLED = "PUBLIC_SIGNUP_DISABLED" as const;
|
|
5
6
|
|
|
6
7
|
/** Auth-relative path prefixes owned by each admin-toggleable feature. A path is
|
|
7
|
-
* gated when it
|
|
8
|
+
* gated when it starts with one of its feature's prefixes. The OIDC provider's
|
|
9
|
+
* JWKS (`/jwks`) and session JWT (`/token`) belong to the always-on `jwt`
|
|
10
|
+
* plugin, so they stay available while the provider is off. */
|
|
8
11
|
const FEATURE_PATHS: Array<{ flag: FeatureFlag; prefixes: string[] }> = [
|
|
9
12
|
{ flag: "twoFactor", prefixes: ["/two-factor/"] },
|
|
10
13
|
{ flag: "magicLink", prefixes: ["/sign-in/magic-link", "/magic-link/"] },
|
|
@@ -15,9 +18,37 @@ const FEATURE_PATHS: Array<{ flag: FeatureFlag; prefixes: string[] }> = [
|
|
|
15
18
|
{ flag: "apiKey", prefixes: ["/api-key/"] },
|
|
16
19
|
{ flag: "passkey", prefixes: ["/passkey/", "/sign-in/passkey"] },
|
|
17
20
|
{ flag: "organization", prefixes: ["/organization"] },
|
|
18
|
-
{
|
|
21
|
+
{
|
|
22
|
+
flag: "oidcProvider",
|
|
23
|
+
prefixes: [
|
|
24
|
+
"/oauth2/",
|
|
25
|
+
"/admin/oauth2/",
|
|
26
|
+
"/.well-known/openid-configuration",
|
|
27
|
+
// Also matches the RFC 8414 form outside the base path,
|
|
28
|
+
// /.well-known/oauth-authorization-server/api/auth.
|
|
29
|
+
"/.well-known/oauth-authorization-server",
|
|
30
|
+
"/.well-known/oauth-protected-resource",
|
|
31
|
+
],
|
|
32
|
+
},
|
|
19
33
|
];
|
|
20
34
|
|
|
35
|
+
/** The admin-toggleable feature that owns an auth-relative path, if any. */
|
|
36
|
+
export function featureForPath(path: string): FeatureFlag | undefined {
|
|
37
|
+
return FEATURE_PATHS.find((f) => f.prefixes.some((p) => path.startsWith(p)))?.flag;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A request's path relative to Better Auth's base URL. A path outside the base
|
|
41
|
+
* path is returned whole. */
|
|
42
|
+
export function authRequestPath(requestUrl: string, baseURL: string): string {
|
|
43
|
+
const pathname = new URL(requestUrl).pathname;
|
|
44
|
+
const basePath = new URL(baseURL).pathname.replace(/\/+$/, "");
|
|
45
|
+
return basePath && pathname.startsWith(`${basePath}/`) ? pathname.slice(basePath.length) : pathname;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function featureDisabled(): APIError {
|
|
49
|
+
return new APIError("NOT_FOUND", { message: "This feature is disabled." });
|
|
50
|
+
}
|
|
51
|
+
|
|
21
52
|
export type FeatureFlags = Record<FeatureFlag, boolean>;
|
|
22
53
|
|
|
23
54
|
/** Live settings the gate reads per request, normally the shared settings snapshot. */
|
|
@@ -75,12 +106,9 @@ export async function enforceFeatureGate(path: string, settings: FeatureGateSett
|
|
|
75
106
|
assertUserCreationAllowed(await readSignupOpen(settings.signupOpen), path);
|
|
76
107
|
return;
|
|
77
108
|
}
|
|
78
|
-
const feature =
|
|
79
|
-
f.prefixes.some((p) => path === p || path.startsWith(p)),
|
|
80
|
-
);
|
|
109
|
+
const feature = featureForPath(path);
|
|
81
110
|
if (!feature) return;
|
|
82
|
-
|
|
83
|
-
if (!enabled) throw new APIError("NOT_FOUND", { message: "This feature is disabled." });
|
|
111
|
+
if (!(await readFeatureFlags(settings.flags))[feature]) throw featureDisabled();
|
|
84
112
|
}
|
|
85
113
|
|
|
86
114
|
/** Better Auth middleware over {@link enforceFeatureGate}. The settings come from
|
|
@@ -91,3 +119,19 @@ export function createFeatureGate(settings: FeatureGateSettings): ReturnType<typ
|
|
|
91
119
|
await enforceFeatureGate(ctx.path, settings);
|
|
92
120
|
});
|
|
93
121
|
}
|
|
122
|
+
|
|
123
|
+
/** Rejects HTTP requests to a disabled feature before any plugin's `onRequest`
|
|
124
|
+
* runs. `hooks.before` alone is not enough: the OAuth provider answers its
|
|
125
|
+
* discovery metadata from `onRequest`, which Better Auth runs before endpoint
|
|
126
|
+
* hooks. Register this plugin ahead of every other plugin. */
|
|
127
|
+
export function featureGatePlugin(settings: FeatureGateSettings): BetterAuthPlugin {
|
|
128
|
+
return {
|
|
129
|
+
id: "podokit-feature-gate",
|
|
130
|
+
onRequest: async (request, ctx) => {
|
|
131
|
+
const feature = featureForPath(authRequestPath(request.url, ctx.baseURL));
|
|
132
|
+
if (!feature || (await readFeatureFlags(settings.flags))[feature]) return undefined;
|
|
133
|
+
const error = featureDisabled();
|
|
134
|
+
return { response: Response.json(error.body, { status: error.statusCode }) };
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
}
|
|
@@ -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 " }),
|