@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.
Files changed (97) hide show
  1. package/dist/deploy-compose-profile.js +2 -2
  2. package/dist/deploy-compose-render.js +3 -1
  3. package/dist/deploy-profile.js +2 -2
  4. package/dist/deploy-render.js +16 -8
  5. package/dist/deploy-schema.d.ts +7 -0
  6. package/dist/deploy-schema.js +40 -0
  7. package/dist/lockfile.d.ts +8 -1
  8. package/dist/lockfile.js +23 -4
  9. package/dist/provider.d.ts +1 -0
  10. package/dist/provider.js +5 -2
  11. package/dist/templates/fullstack/AGENTS.md +27 -2
  12. package/dist/templates/fullstack/README.md +1 -1
  13. package/dist/templates/fullstack/apps/api/package.json +1 -1
  14. package/dist/templates/fullstack/apps/api/scripts/run-migrations.mjs +4 -0
  15. package/dist/templates/fullstack/apps/api/src/app.extensions.ts +7 -2
  16. package/dist/templates/fullstack/apps/api/src/app.ts +6 -3
  17. package/dist/templates/fullstack/apps/api/src/config/env.validation.ts +28 -0
  18. package/dist/templates/fullstack/apps/api/src/config/providers.ts +4 -2
  19. package/dist/templates/fullstack/apps/api/src/config/server-options.spec.ts +17 -6
  20. package/dist/templates/fullstack/apps/api/src/config/server-options.ts +5 -8
  21. package/dist/templates/fullstack/apps/api/src/core/client-address.spec.ts +82 -0
  22. package/dist/templates/fullstack/apps/api/src/core/client-address.ts +68 -0
  23. package/dist/templates/fullstack/apps/api/src/core/services.ts +30 -0
  24. package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +66 -0
  25. package/dist/templates/fullstack/apps/api/src/database/database.ts +34 -11
  26. package/dist/templates/fullstack/apps/api/src/database/run-migrations.spec.ts +54 -6
  27. package/dist/templates/fullstack/apps/web/Dockerfile +5 -1
  28. package/dist/templates/fullstack/apps/web/src/lib/server/api.test.ts +116 -0
  29. package/dist/templates/fullstack/apps/web/src/lib/server/api.ts +34 -4
  30. package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.test.ts +436 -6
  31. package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.ts +352 -38
  32. package/dist/templates/fullstack/apps/web/src/lib/server/body-limits.ts +5 -2
  33. package/dist/templates/fullstack/apps/web/src/lib/server/proxy-timeouts.ts +22 -0
  34. package/dist/templates/fullstack/apps/web/src/routes/healthz/+server.ts +13 -0
  35. package/dist/templates/fullstack/dot-env.docker.example +6 -0
  36. package/dist/templates/fullstack/dot-env.example +23 -0
  37. package/dist/templates/fullstack/infra/k3s/configmap.yaml +3 -0
  38. package/dist/templates/fullstack/infra/k3s/web-deployment.yaml +6 -3
  39. package/dist/templates/fullstack/tests/api/health.api.spec.ts +8 -0
  40. package/dist/templates/modules/admin-dashboard/files/apps/api/src/account-sessions/account-sessions.routes.spec.ts +152 -0
  41. package/dist/templates/modules/admin-dashboard/files/apps/api/src/account-sessions/account-sessions.routes.ts +112 -0
  42. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.revoker.ts +24 -0
  43. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.service.spec.ts +18 -0
  44. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.service.ts +36 -13
  45. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin.module.ts +13 -1
  46. package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.test.ts +24 -1
  47. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/account-sessions.test.ts +33 -0
  48. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/account-sessions.ts +34 -0
  49. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-page.svelte +62 -38
  50. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/table-toolbar.svelte +3 -1
  51. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/runtime-context.test.ts +6 -0
  52. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/runtime-context.ts +5 -0
  53. package/dist/templates/modules/admin-dashboard/files/tests/api/account-sessions.api.spec.ts +55 -0
  54. package/dist/templates/modules/admin-dashboard/files/tests/api/auth-config.api.spec.ts +167 -154
  55. package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +94 -57
  56. package/dist/templates/modules/admin-dashboard/files/tests/api/exclusive.api.spec.ts +75 -0
  57. package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +49 -39
  58. package/dist/templates/modules/admin-dashboard/files/tests/api/users-admin.api.spec.ts +26 -0
  59. package/dist/templates/modules/admin-dashboard/files/tests/helpers/devices.ts +25 -0
  60. package/dist/templates/modules/admin-dashboard/files/tests/helpers/exclusive.ts +80 -0
  61. package/dist/templates/modules/admin-dashboard/files/tests/seed.setup.ts +3 -10
  62. package/dist/templates/modules/admin-dashboard/files/tests/ui/account-sessions.ui.spec.ts +46 -0
  63. package/dist/templates/modules/admin-dashboard/files/tests/ui/account.ui.spec.ts +54 -47
  64. package/dist/templates/modules/admin-dashboard/files/tests/ui/appearance.ui.spec.ts +15 -11
  65. package/dist/templates/modules/admin-dashboard/files/tests/ui/auth.ui.spec.ts +35 -31
  66. package/dist/templates/modules/admin-dashboard/files/tests/ui/general-effects.ui.spec.ts +90 -76
  67. package/dist/templates/modules/admin-dashboard/files/tests/ui/i18n.ui.spec.ts +19 -15
  68. package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +17 -3
  69. package/dist/templates/modules/admin-dashboard/files/tests/ui/request-budget.ui.spec.ts +15 -6
  70. package/dist/templates/modules/admin-dashboard/files/tests/ui/settings-dialogs.ui.spec.ts +71 -64
  71. package/dist/templates/modules/admin-dashboard/files/tests/ui/settings.ui.spec.ts +89 -79
  72. package/dist/templates/modules/admin-dashboard/files/tests/ui/table-toolbar.ui.spec.ts +25 -0
  73. package/dist/templates/modules/admin-dashboard/files/tests/ui/two-factor.ui.spec.ts +54 -50
  74. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.ts +2 -1
  75. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.spec.ts +16 -0
  76. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.ts +2 -1
  77. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.module.ts +4 -1
  78. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.spec.ts +54 -6
  79. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.ts +26 -4
  80. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +19 -8
  81. package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.spec.ts +83 -0
  82. package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.ts +51 -7
  83. package/dist/templates/modules/auth/files/apps/api/src/auth/revoke-other-sessions.spec.ts +56 -0
  84. package/dist/templates/modules/auth/files/apps/api/src/auth/revoke-other-sessions.ts +45 -0
  85. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.spec.ts +2 -59
  86. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.ts +0 -65
  87. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.spec.ts +82 -21
  88. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.ts +54 -15
  89. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.spec.ts +43 -5
  90. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts +10 -2
  91. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.routes.spec.ts +61 -3
  92. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.routes.ts +48 -15
  93. package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +20 -9
  94. package/dist/templates/modules/rate-limit/module.manifest.json +6 -9
  95. package/dist/templates/todo/AGENTS.md +41 -5
  96. package/dist/templates/todo/README.md +1 -1
  97. 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
- private readonly resolveSession: SessionResolver = defaultSessionResolver,
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). It reads the process-wide
183
- // settings snapshot, so a rebuild does not start a new cache. Other PodoKit
184
- // modules add their own hooks below the marker.
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({ flags: featureFlags, signupOpen: isSignupOpen }),
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 sits behind the SvelteKit server proxy, which forwards the
233
- // resolved client IP as x-forwarded-for; trust it so sessions record an IP.
234
- ipAddressHeaders: ["x-forwarded-for"],
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 equals a prefix or starts with a prefix ending in "/". */
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
- { flag: "oidcProvider", prefixes: ["/oauth2/", "/.well-known/openid-configuration", "/.well-known/oauth-authorization-server", "/.well-known/oauth-protected-resource"] },
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 = FEATURE_PATHS.find((f) =>
79
- f.prefixes.some((p) => path === p || path.startsWith(p)),
80
- );
109
+ const feature = featureForPath(path);
81
110
  if (!feature) return;
82
- const enabled = (await readFeatureFlags(settings.flags))[feature.flag];
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
+ }
@@ -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 and proxy settings", () => {
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 " }),