@snokam/auth 6.0.3 → 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 +8 -0
  2. package/package.json +5 -6
  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
package/CHANGELOG.md CHANGED
@@ -3,6 +3,14 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ ## [6.0.5](https://github.com/snokam/components/compare/@snokam/auth@6.0.4...@snokam/auth@6.0.5) (2026-11-05)
7
+
8
+ **Note:** Version bump only for package @snokam/auth
9
+
10
+ ## [6.0.4](https://github.com/snokam/components/compare/@snokam/auth@6.0.3...@snokam/auth@6.0.4) (2026-11-04)
11
+
12
+ **Note:** Version bump only for package @snokam/auth
13
+
6
14
  ## [6.0.3](https://github.com/snokam/components/compare/@snokam/auth@6.0.2...@snokam/auth@6.0.3) (2026-11-02)
7
15
 
8
16
  **Note:** Version bump only for package @snokam/auth
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@snokam/auth",
3
3
  "description": "Snøkam auth components",
4
- "version": "6.0.3",
4
+ "version": "6.0.5",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
7
7
  "files": [
@@ -23,15 +23,14 @@
23
23
  "@emotion/styled": "^11.14.1",
24
24
  "@mui/icons-material": "^9.2.0",
25
25
  "@mui/material": "^9.2.0",
26
- "@snokam/employees-client": "^1.3.0",
27
26
  "next-auth": "4.24.15",
28
27
  "path-to-regexp": "^8.4.2",
28
+ "@snokam/api-client": "1.0.0",
29
29
  "@snokam/identity": "1.1.0",
30
- "@snokam/platform-client": "5.3.2",
30
+ "@snokam/page-content": "6.9.5",
31
31
  "@snokam/react-query": "1.1.0",
32
- "@snokam/sanity-page-content": "6.9.3",
33
- "@snokam/tenant": "4.5.0",
34
- "@snokam/ui": "7.2.0"
32
+ "@snokam/tenant": "4.5.2",
33
+ "@snokam/ui": "7.2.2"
35
34
  },
36
35
  "peerDependencies": {
37
36
  "next": "16.3.8",
@@ -12,6 +12,7 @@ export { Unauthenticated } from "../components/Unauthenticated";
12
12
  export { Authenticated } from "../components/Authenticated";
13
13
  export { useAuth } from "../hooks";
14
14
  export { useSession } from "../session/useSession";
15
+ export { useIsHydrating } from "../hydration/useIsHydrating";
15
16
  export { retryUnlessMissing } from "../employee-query/retry";
16
17
  export { EmployeeThemeSync } from "../theme/employee-theme-sync";
17
18
  export { ScopesProvider, useScopes, useHasScope } from "../scopes";
@@ -14,6 +14,7 @@ import {
14
14
  appUrl,
15
15
  backendUrl,
16
16
  managedBearer,
17
+ resolveApiBaseUrl,
17
18
  } from "@snokam/tenant/server";
18
19
 
19
20
  export { getServerToken } from "./token";
@@ -27,6 +28,10 @@ export function serverTenantToken(): Promise<string | undefined> {
27
28
  return scope ? managedBearer(scope) : Promise.resolve(undefined);
28
29
  }
29
30
 
31
+ export function resolveServerApiBaseUrl(): Promise<string> {
32
+ return resolveApiBaseUrl();
33
+ }
34
+
30
35
  export function resolveServerApiBase(service: string): Promise<string> {
31
36
  return service ? backendUrl(service) : Promise.resolve("");
32
37
  }
@@ -4,7 +4,7 @@ import {
4
4
  ResponseError,
5
5
  type AuthorizationPreview,
6
6
  type IssuedAuthorizationCode,
7
- } from "@snokam/platform-client";
7
+ } from "@snokam/api-client/platform";
8
8
  import { platformClientFor } from "../impersonation/request";
9
9
  import type { AuthorizationParams } from "./consent";
10
10
 
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
 
3
- import { usePageContent } from "@snokam/sanity-page-content";
3
+ import { usePageContent } from "@snokam/page-content";
4
4
  import { PersonOffOutlined } from "@snokam/ui/icons";
5
5
  import { SystemPage, SystemPageAction } from "@snokam/ui/system-page";
6
6
  import { useAuth } from "../../hooks";
@@ -2,11 +2,11 @@
2
2
 
3
3
  import { useQuery } from "@snokam/react-query";
4
4
  import { PageLoader } from "@snokam/ui";
5
- import { loadAppContent } from "@snokam/sanity-page-content/app-router/actions";
5
+ import { loadAppContent } from "@snokam/page-content/app-router/actions";
6
6
  import {
7
7
  AppContentProvider,
8
8
  PageContentProvider,
9
- } from "@snokam/sanity-page-content/app-router/providers";
9
+ } from "@snokam/page-content/app-router/providers";
10
10
  import { AccessHelp } from "./AccessHelp";
11
11
  import { usePickerRedirect } from "./usePickerRedirect";
12
12
 
@@ -0,0 +1,41 @@
1
+ import assert from "node:assert/strict";
2
+ import { afterEach, beforeEach, describe, it } from "node:test";
3
+ import { isAllowedOrigin } from "./cors-origin.ts";
4
+
5
+ const ZONE = "platform.example";
6
+
7
+ describe("CORS origin trust", () => {
8
+ beforeEach(() => {
9
+ process.env.NEXT_PUBLIC_PLATFORM_DOMAIN = ZONE;
10
+ });
11
+
12
+ afterEach(() => {
13
+ delete process.env.NEXT_PUBLIC_PLATFORM_DOMAIN;
14
+ });
15
+
16
+ it("trusts the configured platform zone and its subdomains", () => {
17
+ assert.equal(isAllowedOrigin(`https://${ZONE}`), true);
18
+ assert.equal(isAllowedOrigin(`https://acme.${ZONE}`), true);
19
+ });
20
+
21
+ it("does not trust hosts that only end with the zone name", () => {
22
+ assert.equal(isAllowedOrigin(`https://evil${ZONE}`), false);
23
+ assert.equal(isAllowedOrigin(`https://${ZONE}.evil.example`), false);
24
+ });
25
+
26
+ it("trusts listed extra origins and local development", () => {
27
+ assert.equal(
28
+ isAllowedOrigin("https://partner.example", ["https://partner.example"]),
29
+ true
30
+ );
31
+ assert.equal(isAllowedOrigin("http://localhost:3000"), true);
32
+ });
33
+
34
+ it("fails clearly when the platform zone is not configured", () => {
35
+ delete process.env.NEXT_PUBLIC_PLATFORM_DOMAIN;
36
+ assert.throws(
37
+ () => isAllowedOrigin("https://acme.elsewhere.example"),
38
+ /NEXT_PUBLIC_PLATFORM_DOMAIN/
39
+ );
40
+ });
41
+ });
@@ -0,0 +1,34 @@
1
+ const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1"]);
2
+
3
+ function platformZone(): string {
4
+ const zone = process.env.NEXT_PUBLIC_PLATFORM_DOMAIN;
5
+ if (!zone) {
6
+ throw new Error(
7
+ "NEXT_PUBLIC_PLATFORM_DOMAIN is not configured, so CORS cannot tell platform origins apart"
8
+ );
9
+ }
10
+ return zone.toLowerCase();
11
+ }
12
+
13
+ function isWithin(hostname: string, domain: string): boolean {
14
+ return hostname === domain || hostname.endsWith(`.${domain}`);
15
+ }
16
+
17
+ export function isAllowedOrigin(
18
+ origin: string | null,
19
+ additionalOrigins: string[] = []
20
+ ): boolean {
21
+ if (!origin) return false;
22
+ if (additionalOrigins.includes(origin)) return true;
23
+
24
+ let hostname: string;
25
+ try {
26
+ hostname = new URL(origin).hostname;
27
+ } catch {
28
+ return false;
29
+ }
30
+
31
+ if (LOCAL_HOSTS.has(hostname)) return true;
32
+ if (isWithin(hostname, "snokam.no")) return true;
33
+ return isWithin(hostname, platformZone());
34
+ }
package/src/cors.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { NextResponse } from "next/server";
2
2
  import { Auth } from "./types";
3
+ import { isAllowedOrigin } from "./cors-origin";
3
4
 
4
5
  const DEFAULT_CORS_HEADERS = [
5
6
  "X-CSRF-Token",
@@ -15,36 +16,6 @@ const DEFAULT_CORS_HEADERS = [
15
16
  "Cookie",
16
17
  ];
17
18
 
18
- export function isAllowedOrigin(
19
- origin: string | null,
20
- additionalOrigins: string[] = []
21
- ): boolean {
22
- if (!origin) return false;
23
-
24
- try {
25
- const url = new URL(origin);
26
- if (url.hostname.endsWith(".snokam.no") || url.hostname === "snokam.no") {
27
- return true;
28
- }
29
-
30
- if (url.hostname === "localhost" || url.hostname === "127.0.0.1") {
31
- return true;
32
- }
33
-
34
- if (url.hostname.endsWith(".snosky.no") || url.hostname === "snosky.no") {
35
- return true;
36
- }
37
-
38
- if (additionalOrigins.includes(origin)) {
39
- return true;
40
- }
41
-
42
- return false;
43
- } catch {
44
- return false;
45
- }
46
- }
47
-
48
19
  export function addCorsHeaders(
49
20
  response: NextResponse,
50
21
  origin: string | null,
package/src/hooks.ts CHANGED
@@ -1,20 +1,13 @@
1
1
  import { signIn, signOut } from "next-auth/react";
2
- import { useMemo } from "react";
3
- import { IMP_ACTIVE_COOKIE } from "./impersonation/cookies";
2
+ import { useCallback, useMemo } from "react";
3
+ import { readActiveCookie } from "./impersonation/cookies";
4
4
  import { useImpersonation } from "./impersonation/context";
5
5
  import { useOwnIdentity } from "./identity/own";
6
6
  import { useSession } from "./session/useSession";
7
+ import { isReusable, type CachedToken } from "./token/reuse";
7
8
 
8
9
  class ConsentRequiredError extends Error {}
9
10
 
10
- const REFRESH_MARGIN_SECONDS = 60;
11
-
12
- interface CachedToken {
13
- bearer: string;
14
- expiresOn: number;
15
- impKey: string;
16
- }
17
-
18
11
  type TokenKind = "session" | "own";
19
12
 
20
13
  const cachedTokens: Record<TokenKind, CachedToken | null> = {
@@ -28,22 +21,7 @@ const inFlightTokens: Record<
28
21
 
29
22
  function readImpersonationKey(): string {
30
23
  if (typeof document === "undefined") return "";
31
- const match = document.cookie
32
- .split(";")
33
- .map((part) => part.trim())
34
- .find((part) => part.startsWith(`${IMP_ACTIVE_COOKIE}=`));
35
- if (!match) return "";
36
- try {
37
- const value = decodeURIComponent(match.slice(IMP_ACTIVE_COOKIE.length + 1));
38
- const parsed = JSON.parse(value) as {
39
- tokenId?: string;
40
- expiresOn?: number;
41
- };
42
- if (parsed.expiresOn && parsed.expiresOn * 1000 <= Date.now()) return "";
43
- return parsed.tokenId ?? "";
44
- } catch {
45
- return "";
46
- }
24
+ return readActiveCookie(document.cookie)?.tokenId ?? "";
47
25
  }
48
26
 
49
27
  export function clearAuthTokenCache(): void {
@@ -79,7 +57,12 @@ async function fetchToken(kind: TokenKind, impKey: string): Promise<string> {
79
57
 
80
58
  sessionStorage.removeItem("auth-consent-retried");
81
59
  const bearer = `Bearer ${accessToken}`;
82
- cachedTokens[kind] = { bearer, expiresOn, impKey };
60
+ cachedTokens[kind] = {
61
+ bearer,
62
+ expiresOn,
63
+ fetchedAt: Date.now() / 1000,
64
+ impKey,
65
+ };
83
66
  return bearer;
84
67
  }
85
68
 
@@ -88,13 +71,7 @@ async function getFreshToken(kind: TokenKind): Promise<string> {
88
71
  const now = Date.now() / 1000;
89
72
  const cached = cachedTokens[kind];
90
73
 
91
- if (
92
- cached &&
93
- cached.impKey === impKey &&
94
- now < cached.expiresOn - REFRESH_MARGIN_SECONDS
95
- ) {
96
- return cached.bearer;
97
- }
74
+ if (isReusable(cached, impKey, now)) return cached.bearer;
98
75
 
99
76
  const inFlight = inFlightTokens[kind];
100
77
  if (inFlight && inFlight.impKey === impKey) {
@@ -146,13 +123,18 @@ export function useAuth() {
146
123
  };
147
124
  }, [rawSession, active]);
148
125
 
126
+ const getToken = useCallback(
127
+ () => tokenOrLogin(own ? "own" : "session"),
128
+ [own]
129
+ );
130
+
149
131
  const value = useMemo(
150
132
  () => ({
151
133
  update,
152
134
  signIn,
153
135
  signOut,
154
136
  session,
155
- getToken: () => tokenOrLogin(own ? "own" : "session"),
137
+ getToken,
156
138
  inProgress: status === "loading",
157
139
  isAuthenticated:
158
140
  status === "authenticated" ||
@@ -160,7 +142,7 @@ export function useAuth() {
160
142
  (status === "loading" && !!session),
161
143
  }),
162
144
 
163
- [update, status, session, active, own]
145
+ [update, status, session, active, getToken]
164
146
  );
165
147
 
166
148
  return value;
@@ -0,0 +1,12 @@
1
+ "use client";
2
+
3
+ import { useSyncExternalStore } from "react";
4
+
5
+ const subscribeToNothing = () => () => {};
6
+
7
+ export const useIsHydrating = () =>
8
+ useSyncExternalStore(
9
+ subscribeToNothing,
10
+ () => false,
11
+ () => true
12
+ );
@@ -0,0 +1,62 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { carriedTo } from "./carried.ts";
4
+ import { PLATFORM_HOST, UNKNOWN_HOST } from "../handoff/seal.ts";
5
+
6
+ const now = Date.UTC(2026, 10, 3, 12, 0, 0);
7
+ const expiresOn = now / 1000 + 600;
8
+
9
+ function tokenFor(biz: string) {
10
+ const payload = Buffer.from(JSON.stringify({ biz })).toString("base64url");
11
+ return `header.${payload}.signature`;
12
+ }
13
+
14
+ const session = { accessToken: tokenFor("snokam"), expiresOn, tokenId: "t1" };
15
+ const active = {
16
+ targetEmail: "bjorn@test.snokam.no",
17
+ finance: false,
18
+ tokenId: "t1",
19
+ expiresOn,
20
+ origins: ["https://salg.beta.test.snokam.no"],
21
+ };
22
+ const snokam = { businessId: "snokam" };
23
+
24
+ describe("carrying an impersonation to another host", () => {
25
+ it("carries it to a host owned by the impersonated business", () => {
26
+ assert.deepEqual(carriedTo({ session, active }, snokam, now), {
27
+ session,
28
+ active,
29
+ });
30
+ });
31
+
32
+ it("carries it to a platform host", () => {
33
+ assert.ok(carriedTo({ session, active }, PLATFORM_HOST, now));
34
+ });
35
+
36
+ it("refuses a host owned by another business", () => {
37
+ assert.equal(
38
+ carriedTo({ session, active }, { businessId: "other" }, now),
39
+ null
40
+ );
41
+ });
42
+
43
+ it("refuses a host nobody owns", () => {
44
+ assert.equal(carriedTo({ session, active }, UNKNOWN_HOST, now), null);
45
+ });
46
+
47
+ it("refuses when nothing is being impersonated", () => {
48
+ assert.equal(carriedTo({}, snokam, now), null);
49
+ assert.equal(carriedTo({ session }, snokam, now), null);
50
+ });
51
+
52
+ it("refuses an expired impersonation", () => {
53
+ assert.equal(carriedTo({ session, active }, snokam, now + 601_000), null);
54
+ });
55
+
56
+ it("refuses when the session and the visible state disagree", () => {
57
+ assert.equal(
58
+ carriedTo({ session, active: { ...active, tokenId: "t2" } }, snokam, now),
59
+ null
60
+ );
61
+ });
62
+ });
@@ -0,0 +1,21 @@
1
+ import { type HostOwner, maySealFor } from "../handoff/seal.ts";
2
+ import { tokenClaims } from "./claims.ts";
3
+ import type { ImpersonationActive, ImpersonationSession } from "./cookies";
4
+
5
+ export interface Carried {
6
+ session: ImpersonationSession;
7
+ active: ImpersonationActive;
8
+ }
9
+
10
+ export function carriedTo(
11
+ carried: Partial<Carried>,
12
+ owner: HostOwner,
13
+ now: number = Date.now()
14
+ ): Carried | null {
15
+ const { session, active } = carried;
16
+ if (!session?.accessToken || !active) return null;
17
+ if (session.tokenId !== active.tokenId) return null;
18
+ if (session.expiresOn * 1000 <= now) return null;
19
+ const business = tokenClaims(session.accessToken).biz;
20
+ return maySealFor(owner, business) ? { session, active } : null;
21
+ }
@@ -0,0 +1,82 @@
1
+ import { Auth } from "../types";
2
+ import { isAllowedReturnTo } from "../handoff/return-to";
3
+ import { hostOwner } from "../handoff/business";
4
+ import type { HostOwner } from "../handoff/seal";
5
+ import {
6
+ buildImpersonationCookies,
7
+ readImpersonationActive,
8
+ readImpersonationSession,
9
+ } from "./cookies";
10
+ import { carriedTo } from "./carried";
11
+ import { withOrigin } from "./exit";
12
+ import { handsOffToTarget } from "./handlers";
13
+ import { requestHost } from "./request";
14
+ import { sealImpersonationTicket } from "./ticket";
15
+
16
+ const ADOPT_PATH = "/api/impersonation/adopt";
17
+
18
+ async function allowedTarget(target: unknown): Promise<URL | null> {
19
+ if (typeof target !== "string" || !URL.canParse(target)) return null;
20
+ const allowed = await isAllowedReturnTo(target, {
21
+ platformApiBase: process.env.NEXT_PUBLIC_PLATFORM_API_BASE_URL ?? "",
22
+ platformBaseDomain: process.env.NEXT_PUBLIC_PLATFORM_DOMAIN ?? "",
23
+ allowLocalhost: process.env.AUTH_ALLOW_LOCALHOST === "true",
24
+ });
25
+ return allowed ? new URL(target) : null;
26
+ }
27
+
28
+ async function ownerOf(target: URL): Promise<HostOwner | null> {
29
+ try {
30
+ return await hostOwner(target.toString());
31
+ } catch {
32
+ return null;
33
+ }
34
+ }
35
+
36
+ function adoptUrl(target: URL, ticket: string): string {
37
+ const adopt = new URL(ADOPT_PATH, target.origin);
38
+ adopt.searchParams.set("t", ticket);
39
+ adopt.searchParams.set("returnTo", target.pathname + target.search);
40
+ return adopt.toString();
41
+ }
42
+
43
+ export function createImpersonationCarryHandler(authConfig: Auth.Config) {
44
+ return async function POST(request: Request) {
45
+ const body = (await request.json().catch(() => null)) as {
46
+ redirectTo?: unknown;
47
+ } | null;
48
+ const target = await allowedTarget(body?.redirectTo);
49
+ if (!target) return new Response("Invalid redirectTo", { status: 400 });
50
+ if (!handsOffToTarget(request, target.toString())) {
51
+ return new Response("Shares cookies with this host", { status: 409 });
52
+ }
53
+
54
+ const owner = await ownerOf(target);
55
+ if (!owner)
56
+ return new Response("Tenant lookup unavailable", { status: 503 });
57
+
58
+ const carried = carriedTo(
59
+ {
60
+ session: readImpersonationSession(request) ?? undefined,
61
+ active: readImpersonationActive(request) ?? undefined,
62
+ },
63
+ owner
64
+ );
65
+ if (!carried) return new Response("Nothing to carry", { status: 409 });
66
+
67
+ const { session, active } = carried;
68
+ const origins = withOrigin(active.origins, target.origin);
69
+ const ticket = sealImpersonationTicket(
70
+ { ...session, ...active, business: target.host, origins },
71
+ authConfig.nextAuthSecret
72
+ );
73
+ const response = Response.json({ adoptUrl: adoptUrl(target, ticket) });
74
+ for (const cookie of buildImpersonationCookies(
75
+ { ...session, ...active, origins },
76
+ requestHost(request)
77
+ )) {
78
+ response.headers.append("Set-Cookie", cookie);
79
+ }
80
+ return response;
81
+ };
82
+ }
@@ -9,11 +9,7 @@ import {
9
9
  useState,
10
10
  type ReactNode,
11
11
  } from "react";
12
- import {
13
- IMP_ACTIVE_COOKIE,
14
- parseActiveCookie,
15
- type ImpersonationActive,
16
- } from "./cookies";
12
+ import { readActiveCookie, type ImpersonationActive } from "./cookies";
17
13
  import { clearAuthTokenCache } from "../hooks";
18
14
  import { useSessionRenewal } from "./renewal";
19
15
  import { useOptionalConfig } from "@snokam/tenant";
@@ -56,20 +52,16 @@ function isSafeReturnTo(
56
52
  });
57
53
  }
58
54
 
59
- function readActiveCookie(): ImpersonationActive | null {
55
+ function readDocumentActiveCookie(): ImpersonationActive | null {
60
56
  if (typeof document === "undefined") return null;
61
- const match = document.cookie
62
- .split(";")
63
- .map((part) => part.trim())
64
- .find((part) => part.startsWith(`${IMP_ACTIVE_COOKIE}=`));
65
- if (!match) return null;
66
- try {
67
- return parseActiveCookie(
68
- decodeURIComponent(match.slice(IMP_ACTIVE_COOKIE.length + 1))
69
- );
70
- } catch {
71
- return null;
72
- }
57
+ return readActiveCookie(document.cookie);
58
+ }
59
+
60
+ function keepIfUnchanged(
61
+ current: ImpersonationActive | null,
62
+ next: ImpersonationActive | null
63
+ ): ImpersonationActive | null {
64
+ return JSON.stringify(current) === JSON.stringify(next) ? current : next;
73
65
  }
74
66
 
75
67
  export function ImpersonationProvider({
@@ -86,7 +78,13 @@ export function ImpersonationProvider({
86
78
  const [starting, setStarting] = useState(false);
87
79
  const [exiting, setExiting] = useState(false);
88
80
 
89
- const rereadCookie = useCallback(() => setActive(readActiveCookie()), []);
81
+ const rereadCookie = useCallback(
82
+ () =>
83
+ setActive((current) =>
84
+ keepIfUnchanged(current, readDocumentActiveCookie())
85
+ ),
86
+ []
87
+ );
90
88
 
91
89
  useEffect(() => {
92
90
  rereadCookie();
@@ -1,9 +1,20 @@
1
1
  import { resolveCookieDomain } from "../session";
2
2
  import { tokenClaims } from "./claims";
3
+ import {
4
+ IMP_ACTIVE_COOKIE,
5
+ IMP_PASS_COOKIE,
6
+ IMP_SESSION_COOKIE,
7
+ parseCookieHeader,
8
+ } from "./jar";
3
9
 
4
- export const IMP_SESSION_COOKIE = "imp_session";
5
- export const IMP_ACTIVE_COOKIE = "imp_active";
6
- export const IMP_PASS_COOKIE = "imp_pass";
10
+ export {
11
+ IMP_ACTIVE_COOKIE,
12
+ IMP_PASS_COOKIE,
13
+ IMP_SESSION_COOKIE,
14
+ parseActiveCookie,
15
+ parseCookieHeader,
16
+ readActiveCookie,
17
+ } from "./jar";
7
18
 
8
19
  export interface ImpersonationSession {
9
20
  accessToken: string;
@@ -23,19 +34,6 @@ export interface ImpersonationActive {
23
34
  origins?: string[];
24
35
  }
25
36
 
26
- export function parseActiveCookie(
27
- json: string | undefined
28
- ): ImpersonationActive | null {
29
- if (!json) return null;
30
- try {
31
- const parsed = JSON.parse(json) as ImpersonationActive;
32
- if (parsed.expiresOn * 1000 <= Date.now()) return null;
33
- return parsed;
34
- } catch {
35
- return null;
36
- }
37
- }
38
-
39
37
  interface MintedToken {
40
38
  accessToken: string;
41
39
  expiresOn: number;
@@ -48,20 +46,6 @@ interface MintedToken {
48
46
  origins?: string[];
49
47
  }
50
48
 
51
- export function parseCookieHeader(
52
- header: string | null
53
- ): Record<string, string> {
54
- const jar: Record<string, string> = {};
55
- if (!header) return jar;
56
- for (const part of header.split(";")) {
57
- const index = part.indexOf("=");
58
- if (index < 0) continue;
59
- const name = part.slice(0, index).trim();
60
- if (name) jar[name] = decodeURIComponent(part.slice(index + 1).trim());
61
- }
62
- return jar;
63
- }
64
-
65
49
  function readCookieJson<T>(request: Request, name: string): T | null {
66
50
  const raw = parseCookieHeader(request.headers.get("cookie"))[name];
67
51
  if (!raw) return null;
@@ -7,7 +7,7 @@ import {
7
7
  } from "./cookies";
8
8
  import { sealImpersonationTicket, openImpersonationTicket } from "./ticket";
9
9
  import { impersonationPassCookie } from "./pass-cookie";
10
- import { requestOrigin, resolveCookieDomain } from "../session";
10
+ import { cookieScope, requestOrigin } from "../session";
11
11
  import {
12
12
  platformCall,
13
13
  platformClient,
@@ -49,11 +49,7 @@ function withCookies(response: Response, cookies: string[]): Response {
49
49
  return response;
50
50
  }
51
51
 
52
- function cookieScope(host: string): string {
53
- return resolveCookieDomain(host) ?? host.split(":")[0].toLowerCase();
54
- }
55
-
56
- function handsOffToTarget(request: Request, target: string): boolean {
52
+ export function handsOffToTarget(request: Request, target: string): boolean {
57
53
  const here = requestHost(request);
58
54
  if (!here) return false;
59
55
  try {