@snokam/auth 6.0.4 → 6.0.5

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 (41) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/package.json +6 -7
  3. package/src/app-router/index.tsx +1 -0
  4. package/src/app-router/server.ts +5 -0
  5. package/src/app-sign-in/platform.ts +1 -1
  6. package/src/components/NotProvisioned/AccessHelp.tsx +1 -1
  7. package/src/components/NotProvisioned/index.tsx +2 -2
  8. package/src/cors-origin.test.ts +41 -0
  9. package/src/cors-origin.ts +34 -0
  10. package/src/cors.ts +1 -30
  11. package/src/hooks.ts +18 -36
  12. package/src/hydration/useIsHydrating.ts +12 -0
  13. package/src/impersonation/carried.test.ts +62 -0
  14. package/src/impersonation/carried.ts +21 -0
  15. package/src/impersonation/carry.ts +82 -0
  16. package/src/impersonation/context.tsx +17 -19
  17. package/src/impersonation/cookies.ts +14 -30
  18. package/src/impersonation/handlers.ts +2 -6
  19. package/src/impersonation/jar.test.ts +33 -0
  20. package/src/impersonation/jar.ts +44 -0
  21. package/src/impersonation/pass-cookie.ts +8 -2
  22. package/src/impersonation/pass.test.ts +19 -0
  23. package/src/impersonation/renewal.ts +13 -20
  24. package/src/impersonation/request.ts +1 -1
  25. package/src/impersonation/routes.ts +3 -0
  26. package/src/impersonation/schedule.test.ts +95 -0
  27. package/src/impersonation/schedule.ts +56 -0
  28. package/src/options/employee-profile.ts +3 -6
  29. package/src/password/api.ts +1 -1
  30. package/src/scopes/context.tsx +19 -31
  31. package/src/scopes/state.test.ts +51 -0
  32. package/src/scopes/state.ts +44 -0
  33. package/src/session/cookies.test.ts +27 -0
  34. package/src/session/cookies.ts +10 -1
  35. package/src/session/index.ts +5 -1
  36. package/src/session/useSession.ts +2 -10
  37. package/src/sso/exchange.test.ts +58 -0
  38. package/src/sso/exchange.ts +23 -9
  39. package/src/theme/employee-theme-sync.tsx +3 -3
  40. package/src/token/reuse.test.ts +28 -0
  41. package/src/token/reuse.ts +22 -0
@@ -0,0 +1,33 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { parseCookieHeader, readActiveCookie } from "./jar.ts";
4
+
5
+ const active = (tokenId: string, expiresOn: number) =>
6
+ `imp_active=${encodeURIComponent(
7
+ JSON.stringify({
8
+ tokenId,
9
+ expiresOn,
10
+ targetEmail: "ola@test",
11
+ finance: false,
12
+ })
13
+ )}`;
14
+
15
+ describe("reading the view-as cookie in the browser", () => {
16
+ const future = Math.floor(Date.now() / 1000) + 600;
17
+
18
+ it("picks the same imp_active as the server when two cookie scopes both send one", () => {
19
+ const cookie = `${active("other-scope", future - 100)}; ${active("own", future)}`;
20
+
21
+ assert.equal(readActiveCookie(cookie)?.tokenId, "own");
22
+ assert.equal(
23
+ JSON.parse(parseCookieHeader(cookie).imp_active).tokenId,
24
+ "own"
25
+ );
26
+ });
27
+
28
+ it("ignores an expired or unreadable cookie", () => {
29
+ assert.equal(readActiveCookie(active("old", 1)), null);
30
+ assert.equal(readActiveCookie("imp_active=%E0%A4%A"), null);
31
+ assert.equal(readActiveCookie(""), null);
32
+ });
33
+ });
@@ -0,0 +1,44 @@
1
+ import type { ImpersonationActive } from "./cookies";
2
+
3
+ export const IMP_SESSION_COOKIE = "imp_session";
4
+ export const IMP_ACTIVE_COOKIE = "imp_active";
5
+ export const IMP_PASS_COOKIE = "imp_pass";
6
+
7
+ export function parseCookieHeader(
8
+ header: string | null
9
+ ): Record<string, string> {
10
+ const jar: Record<string, string> = {};
11
+ if (!header) return jar;
12
+ for (const part of header.split(";")) {
13
+ const index = part.indexOf("=");
14
+ if (index < 0) continue;
15
+ const name = part.slice(0, index).trim();
16
+ if (name) jar[name] = decodeURIComponent(part.slice(index + 1).trim());
17
+ }
18
+ return jar;
19
+ }
20
+
21
+ export function parseActiveCookie(
22
+ json: string | undefined
23
+ ): ImpersonationActive | null {
24
+ if (!json) return null;
25
+ try {
26
+ const parsed = JSON.parse(json) as ImpersonationActive;
27
+ if (parsed.expiresOn * 1000 <= Date.now()) return null;
28
+ return parsed;
29
+ } catch {
30
+ return null;
31
+ }
32
+ }
33
+
34
+ export function readActiveCookie(
35
+ cookieString: string
36
+ ): ImpersonationActive | null {
37
+ try {
38
+ return parseActiveCookie(
39
+ parseCookieHeader(cookieString)[IMP_ACTIVE_COOKIE]
40
+ );
41
+ } catch {
42
+ return null;
43
+ }
44
+ }
@@ -1,5 +1,6 @@
1
1
  import { IMP_PASS_COOKIE, parseCookieHeader, serialize } from "./cookies";
2
2
  import { isValidPass, sealPass } from "./pass";
3
+ import { cookieScope } from "../session";
3
4
 
4
5
  export function impersonationPassCookie(
5
6
  session: { tokenId: string; expiresOn: number },
@@ -7,7 +8,12 @@ export function impersonationPassCookie(
7
8
  secret: string
8
9
  ): string {
9
10
  const maxAge = Math.max(0, Math.floor(session.expiresOn - Date.now() / 1000));
10
- const pass = sealPass(host ?? "", session.tokenId, maxAge, secret);
11
+ const pass = sealPass(
12
+ host ? cookieScope(host) : "",
13
+ session.tokenId,
14
+ maxAge,
15
+ secret
16
+ );
11
17
  return serialize(IMP_PASS_COOKIE, pass, { host, httpOnly: true, maxAge });
12
18
  }
13
19
 
@@ -20,5 +26,5 @@ export function hasImpersonationPass(
20
26
  ];
21
27
  const host =
22
28
  request.headers.get("x-forwarded-host") ?? request.headers.get("host");
23
- return isValidPass(pass, host, secret);
29
+ return isValidPass(pass, host && cookieScope(host), secret);
24
30
  }
@@ -1,6 +1,7 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { describe, it } from "node:test";
3
3
  import { isValidPass, sealPass } from "./pass.ts";
4
+ import { cookieScope } from "../session/cookies.ts";
4
5
 
5
6
  const SECRET = "test-secret";
6
7
  const HOST = "businessx.test.snosky.no";
@@ -40,4 +41,22 @@ describe("the Vis som pass", () => {
40
41
  assert.equal(isValidPass(pass, null, SECRET), false);
41
42
  assert.equal(isValidPass(pass, HOST, ""), false);
42
43
  });
44
+
45
+ it("is accepted on every host that shares its cookie scope", () => {
46
+ process.env.NEXT_PUBLIC_PLATFORM_DOMAIN = "test.snosky.no";
47
+ const pass = sealPass(
48
+ cookieScope("salg.beta.test.snokam.no"),
49
+ "tok",
50
+ HOUR,
51
+ SECRET
52
+ );
53
+ assert.equal(
54
+ isValidPass(pass, cookieScope("minside.beta.test.snokam.no"), SECRET),
55
+ true
56
+ );
57
+ assert.equal(
58
+ isValidPass(pass, cookieScope("snokam.beta.test.snosky.no"), SECRET),
59
+ false
60
+ );
61
+ });
43
62
  });
@@ -1,9 +1,9 @@
1
1
  "use client";
2
2
 
3
- import { useEffect } from "react";
3
+ import { useEffect, useRef } from "react";
4
4
  import { clearAuthTokenCache } from "../hooks";
5
5
  import type { ImpersonationActive } from "./cookies";
6
- import { renewDelayMs } from "./required";
6
+ import { scheduleRenewal, type RenewalAttempt } from "./schedule";
7
7
 
8
8
  async function renewSession(): Promise<boolean> {
9
9
  const res = await fetch("/api/impersonation/renew", {
@@ -17,26 +17,19 @@ export function useSessionRenewal(
17
17
  active: ImpersonationActive | null,
18
18
  rereadCookie: () => void
19
19
  ) {
20
- useEffect(() => {
21
- if (!active) return;
20
+ const attempts = useRef<RenewalAttempt | null>(null);
21
+ const tokenId = active?.tokenId;
22
+ const expiresOn = active?.expiresOn;
22
23
 
23
- const renewal = setTimeout(
24
- async () => {
25
- if (!(await renewSession())) return;
24
+ useEffect(() => {
25
+ if (!tokenId || !expiresOn) return;
26
+ return scheduleRenewal({ tokenId, expiresOn }, attempts, {
27
+ renew: renewSession,
28
+ renewed: () => {
26
29
  clearAuthTokenCache();
27
30
  rereadCookie();
28
31
  },
29
- renewDelayMs(active.expiresOn, Date.now())
30
- );
31
-
32
- const expiry = setTimeout(
33
- rereadCookie,
34
- Math.max(0, active.expiresOn * 1000 - Date.now())
35
- );
36
-
37
- return () => {
38
- clearTimeout(renewal);
39
- clearTimeout(expiry);
40
- };
41
- }, [active, rereadCookie]);
32
+ expired: rereadCookie,
33
+ });
34
+ }, [tokenId, expiresOn, rereadCookie]);
42
35
  }
@@ -2,7 +2,7 @@ import {
2
2
  Configuration,
3
3
  PlatformClient,
4
4
  ResponseError,
5
- } from "@snokam/platform-client";
5
+ } from "@snokam/api-client/platform";
6
6
 
7
7
  export function platformBase(): string | null {
8
8
  return (
@@ -6,6 +6,7 @@ import {
6
6
  createImpersonationClearHandler,
7
7
  } from "./handlers";
8
8
  import { createImpersonationRenewHandler } from "./renew";
9
+ import { createImpersonationCarryHandler } from "./carry";
9
10
  import {
10
11
  createPickerBusinessesHandler,
11
12
  createPickerPeopleHandler,
@@ -19,6 +20,7 @@ export function createImpersonationRouteHandlers(authConfig: Auth.Config) {
19
20
  const adopt = createImpersonationAdoptHandler(authConfig);
20
21
  const clear = createImpersonationClearHandler();
21
22
  const renew = createImpersonationRenewHandler(authConfig);
23
+ const carry = createImpersonationCarryHandler(authConfig);
22
24
  const businesses = createPickerBusinessesHandler(authConfig);
23
25
  const people = createPickerPeopleHandler(authConfig);
24
26
 
@@ -36,6 +38,7 @@ export function createImpersonationRouteHandlers(authConfig: Auth.Config) {
36
38
  if (op === "start") return start(request);
37
39
  if (op === "end") return end(request);
38
40
  if (op === "renew") return renew(request);
41
+ if (op === "carry") return carry(request);
39
42
  return new Response("Not found", { status: 404 });
40
43
  },
41
44
  };
@@ -0,0 +1,95 @@
1
+ import assert from "node:assert/strict";
2
+ import { afterEach, beforeEach, describe, it, mock } from "node:test";
3
+ import {
4
+ scheduleRenewal,
5
+ type RenewalAttempt,
6
+ type RenewalTarget,
7
+ } from "./schedule.ts";
8
+
9
+ const START_MS = 1_800_000_000_000;
10
+ const LIFETIME_SECONDS = 15 * 60;
11
+
12
+ function flush(): Promise<void> {
13
+ return new Promise((resolve) => setImmediate(resolve));
14
+ }
15
+
16
+ async function advance(totalMs: number, stepMs = 100): Promise<void> {
17
+ for (let elapsed = 0; elapsed < totalMs; elapsed += stepMs) {
18
+ mock.timers.tick(stepMs);
19
+ await flush();
20
+ }
21
+ }
22
+
23
+ function provider(renewedExpiresOn: (nowMs: number) => number | null) {
24
+ let cookie: RenewalTarget = {
25
+ tokenId: "imp-1",
26
+ expiresOn: START_MS / 1000 + LIFETIME_SECONDS,
27
+ };
28
+ const attempts: { current: RenewalAttempt | null } = { current: null };
29
+ const calls = { renew: 0, expired: 0 };
30
+ let cancel = () => {};
31
+
32
+ const mount = () => {
33
+ cancel();
34
+ cancel = scheduleRenewal({ ...cookie }, attempts, {
35
+ renew: async () => {
36
+ calls.renew += 1;
37
+ const next = renewedExpiresOn(Date.now());
38
+ if (next !== null) cookie = { ...cookie, expiresOn: next };
39
+ return true;
40
+ },
41
+ renewed: mount,
42
+ expired: () => {
43
+ calls.expired += 1;
44
+ },
45
+ });
46
+ };
47
+
48
+ mount();
49
+ return { calls, unmount: () => cancel() };
50
+ }
51
+
52
+ describe("impersonation renewal scheduling", () => {
53
+ beforeEach(() => {
54
+ mock.timers.enable({ apis: ["setTimeout", "Date"], now: START_MS });
55
+ });
56
+
57
+ afterEach(() => {
58
+ mock.timers.reset();
59
+ });
60
+
61
+ it("does not renew again when the renewed cookie never reaches this tab", async () => {
62
+ const { calls, unmount } = provider(() => null);
63
+
64
+ await advance(LIFETIME_SECONDS * 1000 - 110_000);
65
+ unmount();
66
+
67
+ assert.equal(calls.renew, 1);
68
+ });
69
+
70
+ it("renews once per token lifetime when the cookie is updated", async () => {
71
+ const { calls, unmount } = provider(
72
+ (nowMs) => nowMs / 1000 + LIFETIME_SECONDS
73
+ );
74
+
75
+ await advance(40 * 60 * 1000, 1000);
76
+ unmount();
77
+
78
+ assert.equal(calls.renew, 3);
79
+ assert.equal(calls.expired, 0);
80
+ });
81
+
82
+ it("paces renewals when a renewed token already looks stale to this clock", async () => {
83
+ const { calls, unmount } = provider((nowMs) => nowMs / 1000 + 60);
84
+
85
+ await advance(LIFETIME_SECONDS * 1000 - 120_000);
86
+ const before = calls.renew;
87
+ await advance(60_000);
88
+ unmount();
89
+
90
+ assert.ok(
91
+ calls.renew - before <= 3,
92
+ `renewed ${calls.renew - before} times in a minute`
93
+ );
94
+ });
95
+ });
@@ -0,0 +1,56 @@
1
+ import { renewDelayMs } from "./required.ts";
2
+
3
+ const MIN_RENEW_INTERVAL_MS = 30_000;
4
+
5
+ export interface RenewalTarget {
6
+ tokenId: string;
7
+ expiresOn: number;
8
+ }
9
+
10
+ export interface RenewalAttempt extends RenewalTarget {
11
+ atMs: number;
12
+ }
13
+
14
+ export interface RenewalHandlers {
15
+ renew: () => Promise<boolean>;
16
+ renewed: () => void;
17
+ expired: () => void;
18
+ }
19
+
20
+ export function nextRenewalDelayMs(
21
+ target: RenewalTarget,
22
+ lastAttempt: RenewalAttempt | null,
23
+ nowMs: number
24
+ ): number | null {
25
+ const due = renewDelayMs(target.expiresOn, nowMs);
26
+ if (!lastAttempt || lastAttempt.tokenId !== target.tokenId) return due;
27
+ if (lastAttempt.expiresOn >= target.expiresOn) return null;
28
+ return Math.max(due, lastAttempt.atMs + MIN_RENEW_INTERVAL_MS - nowMs);
29
+ }
30
+
31
+ export function scheduleRenewal(
32
+ target: RenewalTarget,
33
+ attempts: { current: RenewalAttempt | null },
34
+ handlers: RenewalHandlers,
35
+ nowMs: number = Date.now()
36
+ ): () => void {
37
+ const delay = nextRenewalDelayMs(target, attempts.current, nowMs);
38
+ const renewal =
39
+ delay === null
40
+ ? null
41
+ : setTimeout(async () => {
42
+ attempts.current = { ...target, atMs: Date.now() };
43
+ if (!(await handlers.renew())) return;
44
+ handlers.renewed();
45
+ }, delay);
46
+
47
+ const expiry = setTimeout(
48
+ handlers.expired,
49
+ Math.max(0, target.expiresOn * 1000 - nowMs)
50
+ );
51
+
52
+ return () => {
53
+ if (renewal) clearTimeout(renewal);
54
+ clearTimeout(expiry);
55
+ };
56
+ }
@@ -1,6 +1,6 @@
1
1
  import { ConfidentialClientApplication } from "@azure/msal-node";
2
- import { Configuration, EmployeesClient } from "@snokam/employees-client";
3
- import { backendUrl } from "@snokam/tenant/server";
2
+ import { Configuration, EmployeesClient } from "@snokam/api-client/employees";
3
+ import { resolveApiBaseUrl } from "@snokam/tenant/server";
4
4
  import { getSanityImageUrl } from "@snokam/ui/media/sanity";
5
5
  import { exchangePasswordToken } from "../password/api";
6
6
  import { exchangeSsoToken } from "../sso/exchange";
@@ -13,10 +13,7 @@ async function resolveEmployeesBackend(
13
13
  ? `api://${config.clientId}/user_impersonation`
14
14
  : "";
15
15
  return {
16
- base: await backendUrl("employees", {
17
- app: "employees",
18
- entraTenantIdFallback: config.tenantId,
19
- }),
16
+ base: await resolveApiBaseUrl({ entraTenantIdFallback: config.tenantId }),
20
17
  scope,
21
18
  };
22
19
  }
@@ -1,4 +1,4 @@
1
- import type { BusinessBranding } from "@snokam/platform-client";
1
+ import type { BusinessBranding } from "@snokam/api-client/platform";
2
2
  export { enrolmentFromError, enrolmentRequiredError } from "./enrolment";
3
3
 
4
4
  export interface PasswordLoginResult {
@@ -12,15 +12,17 @@ import { useAuth } from "../hooks";
12
12
  import { IMP_ACTIVE_COOKIE } from "../impersonation/cookies";
13
13
  import { scopeGranted, type ScopeAccess } from "./matcher";
14
14
  import { decodeJwtClaims } from "../token/claims";
15
+ import { useIsHydrating } from "../hydration/useIsHydrating";
16
+ import { scopesState, serverRenderedScopes, type ScopesState } from "./state";
15
17
 
16
- export interface ScopesState {
17
- scopes: string[];
18
- isImpersonating: boolean;
19
- isAuthoritative: boolean;
20
- isLoading: boolean;
18
+ export type { ScopesState } from "./state";
19
+
20
+ interface ScopesContextValue {
21
+ live: ScopesState;
22
+ serverRendered: ScopesState;
21
23
  }
22
24
 
23
- const ScopesContext = createContext<ScopesState | null>(null);
25
+ const ScopesContext = createContext<ScopesContextValue | null>(null);
24
26
 
25
27
  function isImpersonationActive(): boolean {
26
28
  if (typeof document === "undefined") return false;
@@ -81,23 +83,13 @@ export function ScopesProvider({
81
83
  };
82
84
  }, [impersonating, isAuthenticated, getToken]);
83
85
 
84
- const value = useMemo<ScopesState>(() => {
85
- if (impersonating) {
86
- return {
87
- scopes: tokenScopes ?? [],
88
- isImpersonating: true,
89
- isAuthoritative: tokenScopes !== null,
90
- isLoading: tokenScopes === null,
91
- };
92
- }
93
- const authoritative = ready ?? normalScopes != null;
94
- return {
95
- scopes: normalScopes ?? [],
96
- isImpersonating: false,
97
- isAuthoritative: authoritative,
98
- isLoading: ready === false,
99
- };
100
- }, [impersonating, tokenScopes, normalScopes, ready]);
86
+ const value = useMemo<ScopesContextValue>(
87
+ () => ({
88
+ live: scopesState({ impersonating, tokenScopes, normalScopes, ready }),
89
+ serverRendered: serverRenderedScopes(ready),
90
+ }),
91
+ [impersonating, tokenScopes, normalScopes, ready]
92
+ );
101
93
 
102
94
  return (
103
95
  <ScopesContext.Provider value={value}>{children}</ScopesContext.Provider>
@@ -105,14 +97,10 @@ export function ScopesProvider({
105
97
  }
106
98
 
107
99
  export function useScopes(): ScopesState {
108
- return (
109
- useContext(ScopesContext) ?? {
110
- scopes: [],
111
- isImpersonating: false,
112
- isAuthoritative: false,
113
- isLoading: false,
114
- }
115
- );
100
+ const value = useContext(ScopesContext);
101
+ const hydrating = useIsHydrating();
102
+ if (!value) return serverRenderedScopes();
103
+ return hydrating ? value.serverRendered : value.live;
116
104
  }
117
105
 
118
106
  export function useHasScope(
@@ -0,0 +1,51 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { scopesState, serverRenderedScopes } from "./state.ts";
4
+
5
+ test("scopes that wait for the employee render as loading on the server", () => {
6
+ assert.deepEqual(serverRenderedScopes(true), {
7
+ scopes: [],
8
+ isImpersonating: false,
9
+ isAuthoritative: false,
10
+ isLoading: true,
11
+ });
12
+ assert.deepEqual(serverRenderedScopes(false), serverRenderedScopes(true));
13
+ });
14
+
15
+ test("scopes without a ready signal render as not authoritative and not loading on the server", () => {
16
+ assert.deepEqual(serverRenderedScopes(undefined), {
17
+ scopes: [],
18
+ isImpersonating: false,
19
+ isAuthoritative: false,
20
+ isLoading: false,
21
+ });
22
+ });
23
+
24
+ test("the server never renders the impersonated or the employee's scopes", () => {
25
+ const live = scopesState({
26
+ impersonating: true,
27
+ tokenScopes: ["sales:read"],
28
+ normalScopes: ["employees:write"],
29
+ ready: true,
30
+ });
31
+
32
+ assert.deepEqual(live.scopes, ["sales:read"]);
33
+ assert.notDeepEqual(serverRenderedScopes(true), live);
34
+ });
35
+
36
+ test("employee scopes are authoritative once they are ready", () => {
37
+ assert.deepEqual(
38
+ scopesState({
39
+ impersonating: false,
40
+ tokenScopes: null,
41
+ normalScopes: ["sales:read"],
42
+ ready: true,
43
+ }),
44
+ {
45
+ scopes: ["sales:read"],
46
+ isImpersonating: false,
47
+ isAuthoritative: true,
48
+ isLoading: false,
49
+ }
50
+ );
51
+ });
@@ -0,0 +1,44 @@
1
+ export interface ScopesState {
2
+ scopes: string[];
3
+ isImpersonating: boolean;
4
+ isAuthoritative: boolean;
5
+ isLoading: boolean;
6
+ }
7
+
8
+ export interface ScopesInput {
9
+ impersonating: boolean;
10
+ tokenScopes: string[] | null;
11
+ normalScopes?: string[] | null;
12
+ ready?: boolean;
13
+ }
14
+
15
+ export function scopesState({
16
+ impersonating,
17
+ tokenScopes,
18
+ normalScopes,
19
+ ready,
20
+ }: ScopesInput): ScopesState {
21
+ if (impersonating) {
22
+ return {
23
+ scopes: tokenScopes ?? [],
24
+ isImpersonating: true,
25
+ isAuthoritative: tokenScopes !== null,
26
+ isLoading: tokenScopes === null,
27
+ };
28
+ }
29
+ return {
30
+ scopes: normalScopes ?? [],
31
+ isImpersonating: false,
32
+ isAuthoritative: ready ?? normalScopes != null,
33
+ isLoading: ready === false,
34
+ };
35
+ }
36
+
37
+ export function serverRenderedScopes(ready?: boolean): ScopesState {
38
+ return scopesState({
39
+ impersonating: false,
40
+ tokenScopes: null,
41
+ normalScopes: null,
42
+ ready: ready === undefined ? undefined : false,
43
+ });
44
+ }
@@ -0,0 +1,27 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { cookieScope, resolveCookieDomain } from "./cookies.ts";
4
+
5
+ test("local hosts keep host-only cookies", () => {
6
+ assert.equal(resolveCookieDomain("localhost:3020"), undefined);
7
+ assert.equal(resolveCookieDomain("minside.localhost:3020"), undefined);
8
+ assert.equal(resolveCookieDomain("127.0.0.1:3020"), undefined);
9
+ });
10
+
11
+ test("a business zone shares one scope across its hosts", () => {
12
+ process.env.NEXT_PUBLIC_PLATFORM_DOMAIN = "test.snosky.no";
13
+ assert.equal(cookieScope("salg.beta.test.snokam.no"), ".beta.test.snokam.no");
14
+ assert.equal(
15
+ cookieScope("minside.beta.test.snokam.no:443"),
16
+ ".beta.test.snokam.no"
17
+ );
18
+ });
19
+
20
+ test("platform-zone hosts are their own scope", () => {
21
+ process.env.NEXT_PUBLIC_PLATFORM_DOMAIN = "test.snosky.no";
22
+ assert.equal(
23
+ cookieScope("snokam.beta.test.snosky.no"),
24
+ "snokam.beta.test.snosky.no"
25
+ );
26
+ assert.equal(cookieScope("localhost:3020"), "localhost");
27
+ });
@@ -2,7 +2,12 @@ export function resolveCookieDomain(
2
2
  host: string | undefined
3
3
  ): string | undefined {
4
4
  const bareHost = (host ?? "").split(":")[0].toLowerCase();
5
- if (!bareHost || bareHost === "localhost" || /^[\d.]+$/.test(bareHost)) {
5
+ if (
6
+ !bareHost ||
7
+ bareHost === "localhost" ||
8
+ bareHost.endsWith(".localhost") ||
9
+ /^[\d.]+$/.test(bareHost)
10
+ ) {
6
11
  return undefined;
7
12
  }
8
13
 
@@ -29,6 +34,10 @@ export function resolveCookieDomain(
29
34
  return firstDot > 0 ? `.${bareHost.slice(firstDot + 1)}` : undefined;
30
35
  }
31
36
 
37
+ export function cookieScope(host: string): string {
38
+ return resolveCookieDomain(host) ?? host.split(":")[0].toLowerCase();
39
+ }
40
+
32
41
  export function getSessionCookieSettings(nextAuthUrl: string) {
33
42
  const cookieDomain = process.env.COOKIE_DOMAIN;
34
43
  const cookieTag = process.env.COOKIE_TAG;
@@ -1,3 +1,7 @@
1
- export { resolveCookieDomain, getSessionCookieSettings } from "./cookies";
1
+ export {
2
+ cookieScope,
3
+ resolveCookieDomain,
4
+ getSessionCookieSettings,
5
+ } from "./cookies";
2
6
  export { getAuthUrl, requestOrigin } from "./url";
3
7
  export { callbackDestination, safeCallbackTarget } from "./callback-url";
@@ -1,23 +1,15 @@
1
1
  "use client";
2
2
 
3
- import { createContext, useContext, useSyncExternalStore } from "react";
3
+ import { createContext, useContext } from "react";
4
4
  import {
5
5
  useSession as useLiveSession,
6
6
  type SessionContextValue,
7
7
  } from "next-auth/react";
8
8
  import type { Session } from "next-auth";
9
+ import { useIsHydrating } from "../hydration/useIsHydrating";
9
10
 
10
11
  export const ServerSessionContext = createContext<Session | null>(null);
11
12
 
12
- const subscribeToNothing = () => () => {};
13
-
14
- const useIsHydrating = () =>
15
- useSyncExternalStore(
16
- subscribeToNothing,
17
- () => false,
18
- () => true
19
- );
20
-
21
13
  export const useSession = (): SessionContextValue => {
22
14
  const liveSession = useLiveSession();
23
15
  const serverSession = useContext(ServerSessionContext);