@cosmicdrift/kumiko-framework 0.38.0 → 0.39.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.38.0",
3
+ "version": "0.39.0",
4
4
  "description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -181,7 +181,7 @@
181
181
  "zod": "^4.4.3"
182
182
  },
183
183
  "devDependencies": {
184
- "@cosmicdrift/kumiko-dispatcher-live": "0.37.0",
184
+ "@cosmicdrift/kumiko-dispatcher-live": "0.38.0",
185
185
  "@types/uuid": "^11.0.0",
186
186
  "bun-types": "^1.3.13",
187
187
  "pino-pretty": "^13.1.3"
@@ -177,3 +177,82 @@ describe("auth-routes cookie behaviour on /auth/switch-tenant", () => {
177
177
  expect(newAuth?.value).not.toBe(validToken); // new jwt
178
178
  });
179
179
  });
180
+
181
+ // cookieDomain — Bug-Bash-2 Wave I (Auth auf Marketing-Host): Login auf
182
+ // dem Apex muss eine Session setzen die admin.<domain> mitliest. Ohne
183
+ // Option bleibt das Cookie host-only (kein Domain-Attribut).
184
+ describe("auth-routes cookieDomain", () => {
185
+ async function login(app: Hono): Promise<Response> {
186
+ return app.request("/api/auth/login", {
187
+ method: "POST",
188
+ headers: { "Content-Type": "application/json" },
189
+ body: JSON.stringify({ email: "a@b.c", password: "pw" }),
190
+ });
191
+ }
192
+
193
+ test("ohne cookieDomain: kein Domain-Attribut auf den Cookies", async () => {
194
+ const { app } = await buildApp();
195
+ const res = await login(app);
196
+ expect(getSetCookieRaw(res, AUTH_COOKIE_NAME)).not.toMatch(/Domain=/i);
197
+ expect(getSetCookieRaw(res, CSRF_COOKIE_NAME)).not.toMatch(/Domain=/i);
198
+ });
199
+
200
+ test("login: cookieDomain setzt Domain auf BEIDEN Cookies", async () => {
201
+ const { app } = await buildApp({ cookieDomain: "example.eu" });
202
+ const res = await login(app);
203
+ expect(getSetCookieRaw(res, AUTH_COOKIE_NAME)).toMatch(/Domain=example\.eu/i);
204
+ expect(getSetCookieRaw(res, CSRF_COOKIE_NAME)).toMatch(/Domain=example\.eu/i);
205
+ });
206
+
207
+ test("switch-tenant: rotierte Cookies tragen das Domain-Attribut", async () => {
208
+ const otherTenant = TestUsers.otherTenant;
209
+ const dispatcher = createStubDispatcher({
210
+ async query(type: string, _payload: unknown, _user: SessionUser): Promise<unknown> {
211
+ if (type === "tenant:query:memberships") {
212
+ return [
213
+ {
214
+ userId: TestUsers.user.id,
215
+ tenantId: otherTenant.tenantId,
216
+ roles: otherTenant.roles,
217
+ },
218
+ ];
219
+ }
220
+ return [];
221
+ },
222
+ });
223
+ const { app, validToken } = await buildApp({ cookieDomain: "example.eu" }, dispatcher);
224
+ const res = await app.request("/api/auth/switch-tenant", {
225
+ method: "POST",
226
+ headers: {
227
+ "Content-Type": "application/json",
228
+ Authorization: `Bearer ${validToken}`,
229
+ },
230
+ body: JSON.stringify({ tenantId: otherTenant.tenantId }),
231
+ });
232
+ expect(res.status).toBe(200);
233
+ expect(getSetCookieRaw(res, AUTH_COOKIE_NAME)).toMatch(/Domain=example\.eu/i);
234
+ });
235
+
236
+ test("logout löscht beide Varianten: mit Domain UND host-only", async () => {
237
+ const { app, validToken } = await buildApp({ cookieDomain: "example.eu" });
238
+ const res = await app.request("/api/auth/logout", {
239
+ method: "POST",
240
+ headers: { Authorization: `Bearer ${validToken}` },
241
+ });
242
+ expect(res.status).toBe(200);
243
+ const raw = res.headers.getSetCookie();
244
+ const authDeletes = raw.filter(
245
+ (c) => c.startsWith(`${AUTH_COOKIE_NAME}=`) && /Max-Age=0/i.test(c),
246
+ );
247
+ const csrfDeletes = raw.filter(
248
+ (c) => c.startsWith(`${CSRF_COOKIE_NAME}=`) && /Max-Age=0/i.test(c),
249
+ );
250
+ // Host-only-Bestand (vor cookieDomain gesetzt) + aktuelle Domain-
251
+ // Variante — beide müssen invalidiert werden, sonst wirkt der Logout
252
+ // auf dem alten Cookie nicht.
253
+ expect(authDeletes.some((c) => /Domain=example\.eu/i.test(c))).toBe(true);
254
+ expect(authDeletes.some((c) => !/Domain=/i.test(c))).toBe(true);
255
+ expect(csrfDeletes.some((c) => /Domain=example\.eu/i.test(c))).toBe(true);
256
+ expect(csrfDeletes.some((c) => !/Domain=/i.test(c))).toBe(true);
257
+ });
258
+ });
@@ -40,7 +40,12 @@ function cookieSecure(): boolean {
40
40
  // state that would trip the csrf-middleware on every retry.
41
41
  function setAuthCookies(
42
42
  c: Context,
43
- opts: { token: string; csrfToken: string; sameSite: "lax" | "strict" },
43
+ opts: {
44
+ token: string;
45
+ csrfToken: string;
46
+ sameSite: "lax" | "strict";
47
+ domain?: string | undefined;
48
+ },
44
49
  ): void {
45
50
  const sameSite = opts.sameSite === "strict" ? "Strict" : "Lax";
46
51
  const common = {
@@ -48,6 +53,7 @@ function setAuthCookies(
48
53
  sameSite,
49
54
  path: "/",
50
55
  maxAge: JWT_TTL_SECONDS,
56
+ ...(opts.domain !== undefined && { domain: opts.domain }),
51
57
  } as const;
52
58
 
53
59
  setCookie(c, AUTH_COOKIE_NAME, opts.token, { ...common, httpOnly: true });
@@ -56,9 +62,17 @@ function setAuthCookies(
56
62
  setCookie(c, CSRF_COOKIE_NAME, opts.csrfToken, { ...common, httpOnly: false });
57
63
  }
58
64
 
59
- function clearAuthCookies(c: Context): void {
65
+ function clearAuthCookies(c: Context, domain?: string): void {
66
+ // Beide Varianten löschen: mit Domain (aktuelle Cookies) UND host-only
67
+ // (Bestand aus der Zeit vor cookieDomain) — sonst bleibt nach einem
68
+ // Deploy mit neu gesetztem cookieDomain der alte Cookie liegen und der
69
+ // Logout wirkt nur scheinbar.
60
70
  deleteCookie(c, AUTH_COOKIE_NAME, { path: "/" });
61
71
  deleteCookie(c, CSRF_COOKIE_NAME, { path: "/" });
72
+ if (domain !== undefined) {
73
+ deleteCookie(c, AUTH_COOKIE_NAME, { path: "/", domain });
74
+ deleteCookie(c, CSRF_COOKIE_NAME, { path: "/", domain });
75
+ }
62
76
  }
63
77
 
64
78
  // Body schema for POST /auth/login. Enforced BEFORE rate-limit so that a
@@ -237,6 +251,14 @@ export type AuthRoutesConfig = {
237
251
  // The framework always pairs the cookie with a Double-Submit CSRF token
238
252
  // (see csrf-middleware), so "lax" is defense-in-depth, not defense-alone.
239
253
  cookieSameSite?: "lax" | "strict";
254
+ // Domain attribute for both auth cookies. Unset (default) = host-only
255
+ // cookie, scoped to the exact host that served the response. Set it to
256
+ // the registrable parent domain (e.g. "example.eu") when login and app
257
+ // live on DIFFERENT subdomains (login on apex, app on admin.) — the
258
+ // browser then sends the session to every subdomain. Careful: that
259
+ // includes ALL subdomains (tenant pages, previews); widen the scope
260
+ // only when the cross-subdomain session is actually required.
261
+ cookieDomain?: string;
240
262
  };
241
263
 
242
264
  export type PasswordResetConfig = {
@@ -402,6 +424,7 @@ export function createAuthRoutes(
402
424
  // "lax" keeps email deep-links (invite, magic-link, notification click)
403
425
  // working. High-security apps can opt into "strict" — see AuthRoutesConfig.
404
426
  const cookieSameSite = config.cookieSameSite ?? "lax";
427
+ const cookieDomain = config.cookieDomain;
405
428
 
406
429
  // POST /auth/login — public endpoint (bypasses auth middleware via PUBLIC_API_PATHS).
407
430
  // The configured login handler authenticates and returns a SessionUser;
@@ -481,7 +504,7 @@ export function createAuthRoutes(
481
504
  // ignores Set-Cookie keeps working without any server-side knowledge
482
505
  // of which transport this client will use next.
483
506
  const csrfToken = generateToken();
484
- setAuthCookies(c, { token, csrfToken, sameSite: cookieSameSite });
507
+ setAuthCookies(c, { token, csrfToken, sameSite: cookieSameSite, domain: cookieDomain });
485
508
 
486
509
  return c.json({
487
510
  isSuccess: true,
@@ -588,7 +611,7 @@ export function createAuthRoutes(
588
611
 
589
612
  const token = await jwt.sign(sessionForJwt);
590
613
  const csrfToken = generateToken();
591
- setAuthCookies(c, { token, csrfToken, sameSite: cookieSameSite });
614
+ setAuthCookies(c, { token, csrfToken, sameSite: cookieSameSite, domain: cookieDomain });
592
615
 
593
616
  return c.json({
594
617
  isSuccess: true,
@@ -671,7 +694,7 @@ export function createAuthRoutes(
671
694
  }
672
695
  const token = await jwt.sign(sessionForJwt);
673
696
  const csrfToken = generateToken();
674
- setAuthCookies(c, { token, csrfToken, sameSite: cookieSameSite });
697
+ setAuthCookies(c, { token, csrfToken, sameSite: cookieSameSite, domain: cookieDomain });
675
698
  return c.json({
676
699
  isSuccess: true,
677
700
  token,
@@ -711,7 +734,7 @@ export function createAuthRoutes(
711
734
  }
712
735
  const token = await jwt.sign(sessionForJwt);
713
736
  const csrfToken = generateToken();
714
- setAuthCookies(c, { token, csrfToken, sameSite: cookieSameSite });
737
+ setAuthCookies(c, { token, csrfToken, sameSite: cookieSameSite, domain: cookieDomain });
715
738
  return c.json({
716
739
  isSuccess: true,
717
740
  token,
@@ -737,7 +760,7 @@ export function createAuthRoutes(
737
760
  }
738
761
  // Clear cookies on the cookie-transport path. Idempotent — clearing a
739
762
  // missing cookie is a no-op, so bearer-only clients aren't affected.
740
- clearAuthCookies(c);
763
+ clearAuthCookies(c, cookieDomain);
741
764
  return c.json({ isSuccess: true });
742
765
  });
743
766
 
@@ -876,7 +899,12 @@ export function createAuthRoutes(
876
899
  // the new token in the body below — their Set-Cookie is a no-op
877
900
  // because the browser never sent cookies.
878
901
  const csrfToken = generateToken();
879
- setAuthCookies(c, { token: newToken, csrfToken, sameSite: cookieSameSite });
902
+ setAuthCookies(c, {
903
+ token: newToken,
904
+ csrfToken,
905
+ sameSite: cookieSameSite,
906
+ domain: cookieDomain,
907
+ });
880
908
 
881
909
  return c.json({ token: newToken, tenantId: targetTenantId, roles: mergedRoles });
882
910
  });