@snokam/auth 6.0.4 → 6.0.6

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 (49) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/package.json +6 -7
  3. package/src/app-router/components/Provider/index.tsx +7 -4
  4. package/src/app-router/index.tsx +1 -0
  5. package/src/app-router/server.ts +5 -0
  6. package/src/app-sign-in/platform.ts +1 -1
  7. package/src/components/NotProvisioned/AccessHelp.tsx +1 -1
  8. package/src/components/NotProvisioned/index.tsx +2 -2
  9. package/src/cors-origin.test.ts +41 -0
  10. package/src/cors-origin.ts +34 -0
  11. package/src/cors.ts +1 -30
  12. package/src/hooks.ts +60 -55
  13. package/src/hydration/useIsHydrating.ts +12 -0
  14. package/src/impersonation/carried.test.ts +62 -0
  15. package/src/impersonation/carried.ts +21 -0
  16. package/src/impersonation/carry.ts +82 -0
  17. package/src/impersonation/context.tsx +17 -19
  18. package/src/impersonation/cookies.ts +14 -30
  19. package/src/impersonation/handlers.ts +2 -6
  20. package/src/impersonation/jar.test.ts +33 -0
  21. package/src/impersonation/jar.ts +44 -0
  22. package/src/impersonation/pass-cookie.ts +8 -2
  23. package/src/impersonation/pass.test.ts +19 -0
  24. package/src/impersonation/renewal.ts +13 -20
  25. package/src/impersonation/request.ts +1 -1
  26. package/src/impersonation/routes.ts +3 -0
  27. package/src/impersonation/schedule.test.ts +95 -0
  28. package/src/impersonation/schedule.ts +56 -0
  29. package/src/options/employee-profile.ts +3 -6
  30. package/src/password/api.ts +1 -1
  31. package/src/scopes/context.tsx +19 -31
  32. package/src/scopes/state.test.ts +51 -0
  33. package/src/scopes/state.ts +44 -0
  34. package/src/session/confirm.test.ts +100 -0
  35. package/src/session/confirm.ts +63 -0
  36. package/src/session/confirmed.tsx +58 -0
  37. package/src/session/cookies.test.ts +27 -0
  38. package/src/session/cookies.ts +10 -1
  39. package/src/session/hold.test.ts +27 -0
  40. package/src/session/hold.ts +18 -0
  41. package/src/session/index.ts +5 -1
  42. package/src/session/useSession.ts +6 -11
  43. package/src/sso/exchange.test.ts +58 -0
  44. package/src/sso/exchange.ts +23 -9
  45. package/src/theme/employee-theme-sync.tsx +3 -3
  46. package/src/token/failure.test.ts +33 -0
  47. package/src/token/failure.ts +17 -0
  48. package/src/token/reuse.test.ts +28 -0
  49. package/src/token/reuse.ts +22 -0
package/CHANGELOG.md CHANGED
@@ -3,6 +3,16 @@
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.6](https://github.com/snokam/components/compare/@snokam/auth@6.0.5...@snokam/auth@6.0.6) (2026-11-05)
7
+
8
+ ### Bug Fixes
9
+
10
+ - **web:** post go-live fixes for texts, sessions, place search and pickers ([#3218](https://github.com/snokam/components/issues/3218)) ([0fbf0bb](https://github.com/snokam/components/commit/0fbf0bbec53b6cf946684c76fef318aa5cea7403))
11
+
12
+ ## [6.0.5](https://github.com/snokam/components/compare/@snokam/auth@6.0.4...@snokam/auth@6.0.5) (2026-11-05)
13
+
14
+ **Note:** Version bump only for package @snokam/auth
15
+
6
16
  ## [6.0.4](https://github.com/snokam/components/compare/@snokam/auth@6.0.3...@snokam/auth@6.0.4) (2026-11-04)
7
17
 
8
18
  **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.4",
4
+ "version": "6.0.6",
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",
29
- "@snokam/platform-client": "5.4.0",
28
+ "@snokam/api-client": "1.0.0",
29
+ "@snokam/identity": "1.1.0",
30
+ "@snokam/page-content": "6.9.5",
30
31
  "@snokam/react-query": "1.1.0",
31
- "@snokam/sanity-page-content": "6.9.4",
32
- "@snokam/tenant": "4.5.1",
33
- "@snokam/ui": "7.2.1",
34
- "@snokam/identity": "1.1.0"
32
+ "@snokam/tenant": "4.5.2",
33
+ "@snokam/ui": "7.2.2"
35
34
  },
36
35
  "peerDependencies": {
37
36
  "next": "16.3.8",
@@ -8,6 +8,7 @@ import { SessionProvider } from "next-auth/react";
8
8
  import { useAuth } from "../../../hooks";
9
9
  import { EmployeeThemeSync } from "../../../theme/employee-theme-sync";
10
10
  import { ServerSessionContext } from "../../../session/useSession";
11
+ import { ConfirmedSessionProvider } from "../../../session/confirmed";
11
12
 
12
13
  export const Provider = memo((props: Auth.Provider.Props) => {
13
14
  const { session, children } = props;
@@ -15,10 +16,12 @@ export const Provider = memo((props: Auth.Provider.Props) => {
15
16
  return (
16
17
  <ServerSessionContext.Provider value={session ?? null}>
17
18
  <SessionProvider session={session ?? undefined}>
18
- <EmployeeThemeSync />
19
- <Suspense fallback={<>{children}</>}>
20
- <ProviderInner {...props}>{children}</ProviderInner>
21
- </Suspense>
19
+ <ConfirmedSessionProvider>
20
+ <EmployeeThemeSync />
21
+ <Suspense fallback={<>{children}</>}>
22
+ <ProviderInner {...props}>{children}</ProviderInner>
23
+ </Suspense>
24
+ </ConfirmedSessionProvider>
22
25
  </SessionProvider>
23
26
  </ServerSessionContext.Provider>
24
27
  );
@@ -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,19 +1,21 @@
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
-
8
- class ConsentRequiredError extends Error {}
9
-
10
- const REFRESH_MARGIN_SECONDS = 60;
11
-
12
- interface CachedToken {
13
- bearer: string;
14
- expiresOn: number;
15
- impKey: string;
16
- }
7
+ import { isReusable, type CachedToken } from "./token/reuse";
8
+ import {
9
+ confirmSessionState,
10
+ fetchSessionFromServer,
11
+ withRetries,
12
+ } from "./session/confirm";
13
+ import {
14
+ ConsentRequiredError,
15
+ failureOf,
16
+ SignedOutError,
17
+ TransientTokenError,
18
+ } from "./token/failure";
17
19
 
18
20
  type TokenKind = "session" | "own";
19
21
 
@@ -28,22 +30,7 @@ const inFlightTokens: Record<
28
30
 
29
31
  function readImpersonationKey(): string {
30
32
  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
- }
33
+ return readActiveCookie(document.cookie)?.tokenId ?? "";
47
34
  }
48
35
 
49
36
  export function clearAuthTokenCache(): void {
@@ -53,25 +40,27 @@ export function clearAuthTokenCache(): void {
53
40
  inFlightTokens.own = null;
54
41
  }
55
42
 
56
- async function fetchToken(kind: TokenKind, impKey: string): Promise<string> {
57
- const res = await fetch("/api/auth/token", {
58
- method: "POST",
59
- body: JSON.stringify(kind === "own" ? { own: true } : {}),
60
- credentials: "include",
61
- headers: {
62
- "Content-Type": "application/json",
63
- },
64
- });
65
-
66
- if (res.status === 403) {
67
- const body = await res.json().catch(() => null);
68
- if (body?.error === "consent_required") {
69
- throw new ConsentRequiredError();
70
- }
43
+ async function requestToken(kind: TokenKind): Promise<Response> {
44
+ try {
45
+ return await fetch("/api/auth/token", {
46
+ method: "POST",
47
+ body: JSON.stringify(kind === "own" ? { own: true } : {}),
48
+ credentials: "include",
49
+ headers: {
50
+ "Content-Type": "application/json",
51
+ },
52
+ });
53
+ } catch {
54
+ throw new TransientTokenError("Token request did not reach the server");
71
55
  }
56
+ }
57
+
58
+ async function fetchToken(kind: TokenKind, impKey: string): Promise<string> {
59
+ const res = await requestToken(kind);
72
60
 
73
61
  if (!res.ok) {
74
- throw new Error("Token fetch failed");
62
+ const body = await res.json().catch(() => null);
63
+ throw failureOf(res.status, body);
75
64
  }
76
65
 
77
66
  const token = await res.json();
@@ -79,7 +68,12 @@ async function fetchToken(kind: TokenKind, impKey: string): Promise<string> {
79
68
 
80
69
  sessionStorage.removeItem("auth-consent-retried");
81
70
  const bearer = `Bearer ${accessToken}`;
82
- cachedTokens[kind] = { bearer, expiresOn, impKey };
71
+ cachedTokens[kind] = {
72
+ bearer,
73
+ expiresOn,
74
+ fetchedAt: Date.now() / 1000,
75
+ impKey,
76
+ };
83
77
  return bearer;
84
78
  }
85
79
 
@@ -88,20 +82,17 @@ async function getFreshToken(kind: TokenKind): Promise<string> {
88
82
  const now = Date.now() / 1000;
89
83
  const cached = cachedTokens[kind];
90
84
 
91
- if (
92
- cached &&
93
- cached.impKey === impKey &&
94
- now < cached.expiresOn - REFRESH_MARGIN_SECONDS
95
- ) {
96
- return cached.bearer;
97
- }
85
+ if (isReusable(cached, impKey, now)) return cached.bearer;
98
86
 
99
87
  const inFlight = inFlightTokens[kind];
100
88
  if (inFlight && inFlight.impKey === impKey) {
101
89
  return inFlight.promise;
102
90
  }
103
91
 
104
- const promise = fetchToken(kind, impKey);
92
+ const promise = withRetries(
93
+ () => fetchToken(kind, impKey),
94
+ (error) => error instanceof TransientTokenError
95
+ );
105
96
  inFlightTokens[kind] = { impKey, promise };
106
97
  promise
107
98
  .catch(() => undefined)
@@ -116,11 +107,20 @@ export function fetchOwnToken(): Promise<string> {
116
107
  return tokenOrLogin("own");
117
108
  }
118
109
 
110
+ async function signInAgainIfSignedOut(error: SignedOutError): Promise<void> {
111
+ const state = await confirmSessionState(fetchSessionFromServer);
112
+ if (state !== "signed-out") throw error;
113
+ const here = `${window.location.pathname}${window.location.search}`;
114
+ window.location.href = `/auth/login?callbackUrl=${encodeURIComponent(here)}`;
115
+ }
116
+
119
117
  function tokenOrLogin(kind: TokenKind): Promise<string> {
120
118
  return getFreshToken(kind).catch((error) => {
121
119
  console.error(error);
120
+ if (error instanceof TransientTokenError) throw error;
122
121
  const onAuthPage = window.location.pathname.startsWith("/auth/");
123
122
  if (onAuthPage) return;
123
+ if (error instanceof SignedOutError) return signInAgainIfSignedOut(error);
124
124
  const reason = error instanceof ConsentRequiredError ? "consent" : "true";
125
125
  window.location.href = `/auth/login?error=${reason}`;
126
126
  }) as Promise<string>;
@@ -146,13 +146,18 @@ export function useAuth() {
146
146
  };
147
147
  }, [rawSession, active]);
148
148
 
149
+ const getToken = useCallback(
150
+ () => tokenOrLogin(own ? "own" : "session"),
151
+ [own]
152
+ );
153
+
149
154
  const value = useMemo(
150
155
  () => ({
151
156
  update,
152
157
  signIn,
153
158
  signOut,
154
159
  session,
155
- getToken: () => tokenOrLogin(own ? "own" : "session"),
160
+ getToken,
156
161
  inProgress: status === "loading",
157
162
  isAuthenticated:
158
163
  status === "authenticated" ||
@@ -160,7 +165,7 @@ export function useAuth() {
160
165
  (status === "loading" && !!session),
161
166
  }),
162
167
 
163
- [update, status, session, active, own]
168
+ [update, status, session, active, getToken]
164
169
  );
165
170
 
166
171
  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
+ }