@snokam/auth 3.3.12 → 3.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.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,18 @@
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
+ # [3.5.0](https://github.com/snokam/components/compare/@snokam/auth@3.4.0...@snokam/auth@3.5.0) (2026-10-06)
7
+
8
+ ### Features
9
+
10
+ - **test:** pick a test user straight after sign-in, on snosky.no and for MCP sign-in too ([#2680](https://github.com/snokam/components/issues/2680)) ([eea727a](https://github.com/snokam/components/commit/eea727ae1a607139c7c30610109e877eaa20dae9))
11
+
12
+ # [3.4.0](https://github.com/snokam/components/compare/@snokam/auth@3.3.12...@snokam/auth@3.4.0) (2026-10-06)
13
+
14
+ ### Features
15
+
16
+ - require Vis som in test, and renew view-as sessions while the operator is signed in ([#2676](https://github.com/snokam/components/issues/2676)) ([e9c7bc9](https://github.com/snokam/components/commit/e9c7bc986508d9664bfcc78161c10381981c9c49))
17
+
6
18
  ## [3.3.12](https://github.com/snokam/components/compare/@snokam/auth@3.3.11...@snokam/auth@3.3.12) (2026-10-05)
7
19
 
8
20
  ### Bug Fixes
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@snokam/auth",
3
3
  "description": "Snøkam auth components",
4
- "version": "3.3.12",
4
+ "version": "3.5.0",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
7
7
  "files": [
@@ -27,11 +27,11 @@
27
27
  "next-auth": "4.24.15",
28
28
  "path-to-regexp": "^8.4.2",
29
29
  "@snokam/identity": "1.1.0",
30
- "@snokam/platform-client": "3.5.1",
30
+ "@snokam/platform-client": "3.6.0",
31
+ "@snokam/tenant": "2.1.13",
32
+ "@snokam/ui": "4.0.0",
31
33
  "@snokam/react-query": "1.1.0",
32
- "@snokam/sanity-page-content": "4.4.1",
33
- "@snokam/tenant": "2.1.12",
34
- "@snokam/ui": "4.0.0"
34
+ "@snokam/sanity-page-content": "4.4.2"
35
35
  },
36
36
  "peerDependencies": {
37
37
  "next": "16.3.3",
@@ -53,6 +53,7 @@
53
53
  "./sso-tenant": "./src/sso/tenant-cookie.ts"
54
54
  },
55
55
  "scripts": {
56
- "lint": "eslint ."
56
+ "lint": "eslint .",
57
+ "test": "node --test \"src/**/*.test.ts\""
57
58
  }
58
59
  }
@@ -33,6 +33,7 @@ export {
33
33
  useImpersonation,
34
34
  } from "../impersonation/context";
35
35
  export type { ImpersonationActive } from "../impersonation/cookies";
36
+ export { impersonationRequired } from "../impersonation/required";
36
37
  export { signIn, SessionProvider } from "next-auth/react";
37
38
  export {
38
39
  Login,
@@ -6,6 +6,14 @@ import {
6
6
  requestOrigin,
7
7
  } from "../session";
8
8
  import { resolveAccessToken } from "../route-handler/token";
9
+ import {
10
+ readImpersonationActive,
11
+ readImpersonationSession,
12
+ } from "../impersonation/cookies";
13
+ import {
14
+ impersonationPickerUrl,
15
+ impersonationRequired,
16
+ } from "../impersonation/required";
9
17
  import type { Auth } from "../types";
10
18
  import {
11
19
  type AuthorizationParams,
@@ -45,6 +53,47 @@ async function signedInEmail(
45
53
  return token?.refreshToken && token.email ? String(token.email) : null;
46
54
  }
47
55
 
56
+ interface Signer {
57
+ email: string;
58
+ accessToken: string;
59
+ }
60
+
61
+ function personaSigner(request: Request): Signer | null {
62
+ const session = readImpersonationSession(request);
63
+ const active = readImpersonationActive(request);
64
+ if (!session || !active || session.expiresOn * 1000 <= Date.now()) {
65
+ return null;
66
+ }
67
+ return { email: active.targetEmail, accessToken: session.accessToken };
68
+ }
69
+
70
+ async function ownSigner(
71
+ authConfig: Auth.Config,
72
+ request: Request,
73
+ email: string
74
+ ): Promise<Signer | null> {
75
+ const access = await resolveAccessToken(authConfig, request);
76
+ return "error" in access ? null : { email, accessToken: access.accessToken };
77
+ }
78
+
79
+ function signerFor(
80
+ authConfig: Auth.Config,
81
+ request: Request,
82
+ email: string
83
+ ): Promise<Signer | null> {
84
+ if (impersonationRequired(authConfig.environment)) {
85
+ return Promise.resolve(personaSigner(request));
86
+ }
87
+ return ownSigner(authConfig, request, email);
88
+ }
89
+
90
+ function pickerRedirect(request: Request): Response {
91
+ const url = new URL(request.url);
92
+ const origin = requestOrigin(request);
93
+ const resume = `${origin}${url.pathname}${url.search}`;
94
+ return Response.redirect(impersonationPickerUrl(origin, resume), 302);
95
+ }
96
+
48
97
  function loginRedirect(request: Request, resource: string, prompt?: string) {
49
98
  const url = new URL(request.url);
50
99
  const resume = new URLSearchParams(url.searchParams);
@@ -105,13 +154,17 @@ export function createAppSignInAuthorizeHandler(authConfig: Auth.Config) {
105
154
  const params = readParams(query);
106
155
  if (!params) return invalidRequest();
107
156
 
108
- const email = await signedInEmail(request, authConfig.nextAuthSecret);
109
- if (!email) return loginRedirect(request, params.resource);
157
+ const signedIn = await signedInEmail(request, authConfig.nextAuthSecret);
158
+ if (!signedIn) return loginRedirect(request, params.resource);
110
159
 
111
- const access = await resolveAccessToken(authConfig, request);
112
- if ("error" in access) return loginRedirect(request, params.resource);
160
+ const signer = await signerFor(authConfig, request, signedIn);
161
+ if (!signer) {
162
+ return impersonationRequired(authConfig.environment)
163
+ ? pickerRedirect(request)
164
+ : loginRedirect(request, params.resource);
165
+ }
113
166
 
114
- const preview = await previewAuthorization(params, access.accessToken);
167
+ const preview = await previewAuthorization(params, signer.accessToken);
115
168
  if ("error" in preview) {
116
169
  return preview.error === ACCESS_DENIED
117
170
  ? loginRedirect(request, params.resource, "login")
@@ -125,7 +178,7 @@ export function createAppSignInAuthorizeHandler(authConfig: Auth.Config) {
125
178
  ...preview.value,
126
179
  business: new URL(params.resource).hostname,
127
180
  state: query.get("state") ?? "",
128
- email,
181
+ email: signer.email,
129
182
  nonce,
130
183
  },
131
184
  authConfig.nextAuthSecret
@@ -155,8 +208,11 @@ export function createAppSignInApproveHandler(authConfig: Auth.Config) {
155
208
  return invalidRequest();
156
209
  }
157
210
 
158
- const email = await signedInEmail(request, authConfig.nextAuthSecret);
159
- if (email !== consent.email) return invalidRequest();
211
+ const signedIn = await signedInEmail(request, authConfig.nextAuthSecret);
212
+ if (!signedIn) return invalidRequest();
213
+
214
+ const signer = await signerFor(authConfig, request, signedIn);
215
+ if (!signer || signer.email !== consent.email) return invalidRequest();
160
216
 
161
217
  if (form.get("decision") !== "approve") {
162
218
  return clientRedirect(request, consent.redirectUri, consent.state, {
@@ -164,10 +220,7 @@ export function createAppSignInApproveHandler(authConfig: Auth.Config) {
164
220
  });
165
221
  }
166
222
 
167
- const access = await resolveAccessToken(authConfig, request);
168
- if ("error" in access) return invalidRequest();
169
-
170
- const approved = await approveAuthorization(consent, access.accessToken);
223
+ const approved = await approveAuthorization(consent, signer.accessToken);
171
224
  return clientRedirect(
172
225
  request,
173
226
  consent.redirectUri,
package/src/hooks.ts CHANGED
@@ -3,9 +3,12 @@ import { useMemo } from "react";
3
3
  import { IMP_ACTIVE_COOKIE } from "./impersonation/cookies";
4
4
  import { useImpersonation } from "./impersonation/context";
5
5
  import { useSession } from "./session/useSession";
6
+ import { IMPERSONATION_REQUIRED_ERROR } from "./impersonation/required";
6
7
 
7
8
  class ConsentRequiredError extends Error {}
8
9
 
10
+ class ImpersonationRequiredError extends Error {}
11
+
9
12
  const REFRESH_MARGIN_SECONDS = 60;
10
13
 
11
14
  interface CachedToken {
@@ -62,6 +65,9 @@ async function fetchToken(impKey: string): Promise<string> {
62
65
  if (body?.error === "consent_required") {
63
66
  throw new ConsentRequiredError();
64
67
  }
68
+ if (body?.error === IMPERSONATION_REQUIRED_ERROR) {
69
+ throw new ImpersonationRequiredError();
70
+ }
65
71
  }
66
72
 
67
73
  if (!res.ok) {
@@ -128,6 +134,7 @@ export function useAuth() {
128
134
  session,
129
135
  getToken: () =>
130
136
  getFreshToken().catch((error) => {
137
+ if (error instanceof ImpersonationRequiredError) throw error;
131
138
  console.error(error);
132
139
  const onAuthPage = window.location.pathname.startsWith("/auth/");
133
140
  if (onAuthPage) return;
@@ -0,0 +1,51 @@
1
+ "use client";
2
+
3
+ import { useEffect, type ReactNode } from "react";
4
+ import { usePathname } from "next/navigation";
5
+ import { PageLoader } from "@snokam/ui";
6
+ import { useOptionalConfig } from "@snokam/tenant";
7
+ import { useSession } from "../session/useSession";
8
+ import {
9
+ impersonationPickerUrl,
10
+ impersonationRequired,
11
+ isExemptRoute,
12
+ sendsToPicker,
13
+ withholdsApp,
14
+ } from "./required";
15
+
16
+ interface Props {
17
+ sessionActive: boolean;
18
+ cookieRead: boolean;
19
+ children: ReactNode;
20
+ }
21
+
22
+ export function RequiredImpersonationGate({
23
+ sessionActive,
24
+ cookieRead,
25
+ children,
26
+ }: Props) {
27
+ const config = useOptionalConfig();
28
+ const { status, data: session } = useSession();
29
+ const pathname = usePathname();
30
+ const platformUrl = config?.NEXT_PUBLIC_PLATFORM_BASE_URL;
31
+
32
+ const state = {
33
+ required: impersonationRequired(config?.NEXT_PUBLIC_ENVIRONMENT),
34
+ authenticated:
35
+ status === "authenticated" || (status === "loading" && !!session),
36
+ sessionActive,
37
+ cookieRead,
38
+ exemptRoute: isExemptRoute(pathname),
39
+ };
40
+ const redirect = sendsToPicker(state);
41
+
42
+ useEffect(() => {
43
+ if (!redirect || !platformUrl) return;
44
+ window.location.assign(
45
+ impersonationPickerUrl(platformUrl, window.location.href)
46
+ );
47
+ }, [redirect, platformUrl]);
48
+
49
+ if (withholdsApp(state)) return <PageLoader />;
50
+ return <>{children}</>;
51
+ }
@@ -11,6 +11,8 @@ import {
11
11
  } from "react";
12
12
  import { IMP_ACTIVE_COOKIE, type ImpersonationActive } from "./cookies";
13
13
  import { clearAuthTokenCache } from "../hooks";
14
+ import { useSessionRenewal } from "./renewal";
15
+ import { RequiredImpersonationGate } from "./RequiredImpersonationGate";
14
16
 
15
17
  interface ImpersonationContextValue {
16
18
  active: ImpersonationActive | null;
@@ -24,6 +26,7 @@ interface ImpersonationContextValue {
24
26
  redirectTo?: string;
25
27
  returnTo?: string;
26
28
  landingUrl?: string;
29
+ handOffOnly?: boolean;
27
30
  }) => Promise<{ ok: boolean; error?: string }>;
28
31
  exit: () => Promise<void>;
29
32
  }
@@ -74,24 +77,18 @@ function readActiveCookie(): ImpersonationActive | null {
74
77
 
75
78
  export function ImpersonationProvider({ children }: { children: ReactNode }) {
76
79
  const [active, setActive] = useState<ImpersonationActive | null>(null);
80
+ const [cookieRead, setCookieRead] = useState(false);
77
81
  const [starting, setStarting] = useState(false);
78
82
  const [exiting, setExiting] = useState(false);
79
83
 
80
- useEffect(() => {
81
- setActive(readActiveCookie());
82
- }, []);
84
+ const rereadCookie = useCallback(() => setActive(readActiveCookie()), []);
83
85
 
84
- // Auto-exit at token expiry so a stale banner/token never lingers.
85
86
  useEffect(() => {
86
- if (!active) return;
87
- const ms = active.expiresOn * 1000 - Date.now();
88
- if (ms <= 0) {
89
- setActive(null);
90
- return;
91
- }
92
- const timer = setTimeout(() => setActive(null), ms);
93
- return () => clearTimeout(timer);
94
- }, [active]);
87
+ rereadCookie();
88
+ setCookieRead(true);
89
+ }, [rereadCookie]);
90
+
91
+ useSessionRenewal(active, rereadCookie);
95
92
 
96
93
  const start = useCallback(
97
94
  async (input: {
@@ -102,6 +99,7 @@ export function ImpersonationProvider({ children }: { children: ReactNode }) {
102
99
  redirectTo?: string;
103
100
  returnTo?: string;
104
101
  landingUrl?: string;
102
+ handOffOnly?: boolean;
105
103
  }) => {
106
104
  setStarting(true);
107
105
  try {
@@ -161,7 +159,12 @@ export function ImpersonationProvider({ children }: { children: ReactNode }) {
161
159
 
162
160
  return (
163
161
  <ImpersonationContext.Provider value={value}>
164
- {children}
162
+ <RequiredImpersonationGate
163
+ sessionActive={!!active}
164
+ cookieRead={cookieRead}
165
+ >
166
+ {children}
167
+ </RequiredImpersonationGate>
165
168
  </ImpersonationContext.Provider>
166
169
  );
167
170
  }
@@ -44,20 +44,28 @@ function parseCookieHeader(header: string | null): Record<string, string> {
44
44
  return jar;
45
45
  }
46
46
 
47
- export function readImpersonationSession(
48
- request: Request
49
- ): ImpersonationSession | null {
50
- const raw = parseCookieHeader(request.headers.get("cookie"))[
51
- IMP_SESSION_COOKIE
52
- ];
47
+ function readCookieJson<T>(request: Request, name: string): T | null {
48
+ const raw = parseCookieHeader(request.headers.get("cookie"))[name];
53
49
  if (!raw) return null;
54
50
  try {
55
- return JSON.parse(raw) as ImpersonationSession;
51
+ return JSON.parse(raw) as T;
56
52
  } catch {
57
53
  return null;
58
54
  }
59
55
  }
60
56
 
57
+ export function readImpersonationSession(
58
+ request: Request
59
+ ): ImpersonationSession | null {
60
+ return readCookieJson<ImpersonationSession>(request, IMP_SESSION_COOKIE);
61
+ }
62
+
63
+ export function readImpersonationActive(
64
+ request: Request
65
+ ): ImpersonationActive | null {
66
+ return readCookieJson<ImpersonationActive>(request, IMP_ACTIVE_COOKIE);
67
+ }
68
+
61
69
  function serialize(
62
70
  name: string,
63
71
  value: string,
@@ -6,17 +6,8 @@ import {
6
6
  readImpersonationSession,
7
7
  } from "./cookies";
8
8
  import { sealImpersonationTicket, openImpersonationTicket } from "./ticket";
9
- import { requestOrigin } from "../session";
10
-
11
- function platformBase(): string | null {
12
- return (
13
- process.env.NEXT_PUBLIC_PLATFORM_API_BASE_URL?.replace(/\/$/, "") ?? null
14
- );
15
- }
16
-
17
- function requestHost(request: Request): string | null {
18
- return request.headers.get("x-forwarded-host") ?? request.headers.get("host");
19
- }
9
+ import { requestOrigin, resolveCookieDomain } from "../session";
10
+ import { platformBase, platformErrorResponse, requestHost } from "./request";
20
11
 
21
12
  function registrable(host: string): string {
22
13
  return host.split(":")[0].toLowerCase().split(".").slice(-2).join(".");
@@ -48,12 +39,19 @@ function resolveLanding(returnTo: string, origin: string): string {
48
39
  return new URL("/", origin).toString();
49
40
  }
50
41
 
51
- /** True when the redirect target lives on a different registrable domain than this request. */
52
- function isCrossDomain(request: Request, target: string): boolean {
42
+ function cookieScope(host: string): string {
43
+ return resolveCookieDomain(host) ?? host.split(":")[0].toLowerCase();
44
+ }
45
+
46
+ function handsOffToTarget(request: Request, target: string): boolean {
53
47
  const here = requestHost(request);
54
48
  if (!here) return false;
55
49
  try {
56
- return registrable(new URL(target).host) !== registrable(here);
50
+ const there = new URL(target).host;
51
+ return (
52
+ registrable(there) !== registrable(here) ||
53
+ cookieScope(there) !== cookieScope(here)
54
+ );
57
55
  } catch {
58
56
  return false;
59
57
  }
@@ -85,6 +83,7 @@ export function createImpersonationStartHandler(authConfig: Auth.Config) {
85
83
  redirectTo?: string;
86
84
  returnTo?: string;
87
85
  landingUrl?: string;
86
+ handOffOnly?: boolean;
88
87
  };
89
88
  try {
90
89
  body = await request.json();
@@ -105,17 +104,7 @@ export function createImpersonationStartHandler(authConfig: Auth.Config) {
105
104
  }),
106
105
  });
107
106
 
108
- if (!minted.ok) {
109
- const raw = await minted.text();
110
- let message = raw;
111
- try {
112
- const parsed = JSON.parse(raw) as { message?: unknown };
113
- if (typeof parsed.message === "string") message = parsed.message;
114
- } catch {
115
- // Non-JSON error body — pass the raw text through unchanged.
116
- }
117
- return new Response(message, { status: minted.status });
118
- }
107
+ if (!minted.ok) return platformErrorResponse(minted);
119
108
 
120
109
  const result = await minted.json();
121
110
  result.targetImage = body.targetImage ?? null;
@@ -128,7 +117,7 @@ export function createImpersonationStartHandler(authConfig: Auth.Config) {
128
117
  };
129
118
 
130
119
  // Custom-domain tenant: hand the session to the tenant domain's adopt route (login's pattern).
131
- if (body.redirectTo && isCrossDomain(request, body.redirectTo)) {
120
+ if (body.redirectTo && handsOffToTarget(request, body.redirectTo)) {
132
121
  const target = new URL(body.redirectTo);
133
122
  const ticket = sealImpersonationTicket(
134
123
  { ...result, business: target.host, returnTo: body.returnTo },
@@ -144,6 +133,7 @@ export function createImpersonationStartHandler(authConfig: Auth.Config) {
144
133
  ...summary,
145
134
  adoptUrl: adopt.toString(),
146
135
  });
136
+ if (body.handOffOnly) return crossDomain;
147
137
  for (const cookie of buildImpersonationCookies(
148
138
  { ...result, returnTo: body.returnTo },
149
139
  requestHost(request)
@@ -0,0 +1,52 @@
1
+ import { Auth } from "../types";
2
+ import { resolveAccessToken } from "../route-handler/token";
3
+ import { platformBase, platformErrorResponse } from "./request";
4
+ import { impersonationRequired } from "./required";
5
+
6
+ function businessOf(accessToken: string): string | null {
7
+ try {
8
+ const payload = accessToken.split(".")[1] ?? "";
9
+ const claims = JSON.parse(
10
+ Buffer.from(payload, "base64url").toString("utf8")
11
+ ) as { biz?: string };
12
+ return claims.biz ?? null;
13
+ } catch {
14
+ return null;
15
+ }
16
+ }
17
+
18
+ export function createImpersonationPersonasHandler(authConfig: Auth.Config) {
19
+ return async function GET(request: Request) {
20
+ if (!impersonationRequired(authConfig.environment)) {
21
+ return new Response("Not found", { status: 404 });
22
+ }
23
+
24
+ const base = platformBase();
25
+ if (!base) {
26
+ return new Response(
27
+ "NEXT_PUBLIC_PLATFORM_API_BASE_URL is not configured",
28
+ { status: 500 }
29
+ );
30
+ }
31
+
32
+ const caller = await resolveAccessToken(authConfig, request);
33
+ if ("error" in caller) return caller.error;
34
+
35
+ const businessId = businessOf(caller.accessToken);
36
+ if (!businessId) {
37
+ return new Response("The signed-in account has no business", {
38
+ status: 409,
39
+ });
40
+ }
41
+
42
+ const employees = await fetch(
43
+ `${base}/v1.0/protected/businesses/${encodeURIComponent(businessId)}/employees`,
44
+ { headers: { Authorization: `Bearer ${caller.accessToken}` } }
45
+ );
46
+ if (!employees.ok) return platformErrorResponse(employees);
47
+
48
+ return new Response(await employees.text(), {
49
+ headers: { "Content-Type": "application/json" },
50
+ });
51
+ };
52
+ }
@@ -0,0 +1,75 @@
1
+ import {
2
+ Configuration,
3
+ PlatformClient,
4
+ ResponseError,
5
+ } from "@snokam/platform-client";
6
+ import { Auth } from "../types";
7
+ import { resolveAccessToken } from "../route-handler/token";
8
+ import {
9
+ buildImpersonationCookies,
10
+ readImpersonationActive,
11
+ readImpersonationSession,
12
+ } from "./cookies";
13
+ import { platformBase, platformErrorResponse, requestHost } from "./request";
14
+
15
+ export function createImpersonationRenewHandler(authConfig: Auth.Config) {
16
+ return async function POST(request: Request) {
17
+ const session = readImpersonationSession(request);
18
+ const active = readImpersonationActive(request);
19
+ if (!session?.tokenId || !active) {
20
+ return new Response("No impersonation session", { status: 404 });
21
+ }
22
+
23
+ const basePath = platformBase();
24
+ if (!basePath) {
25
+ return new Response(
26
+ "NEXT_PUBLIC_PLATFORM_API_BASE_URL is not configured",
27
+ {
28
+ status: 500,
29
+ }
30
+ );
31
+ }
32
+
33
+ const caller = await resolveAccessToken(authConfig, request);
34
+ if ("error" in caller) return caller.error;
35
+
36
+ const client = new PlatformClient(
37
+ new Configuration({
38
+ basePath,
39
+ accessToken: async () => caller.accessToken,
40
+ })
41
+ );
42
+
43
+ try {
44
+ const renewed = await client.renewImpersonation({
45
+ tokenId: session.tokenId,
46
+ });
47
+ if (!renewed.accessToken || !renewed.expiresOn) {
48
+ return new Response("Incomplete impersonation renewal", {
49
+ status: 502,
50
+ });
51
+ }
52
+
53
+ const out = Response.json({
54
+ tokenId: session.tokenId,
55
+ expiresOn: renewed.expiresOn,
56
+ });
57
+ const cookies = buildImpersonationCookies(
58
+ {
59
+ ...active,
60
+ accessToken: renewed.accessToken,
61
+ expiresOn: renewed.expiresOn,
62
+ finance: renewed.finance ?? false,
63
+ },
64
+ requestHost(request)
65
+ );
66
+ for (const cookie of cookies) out.headers.append("Set-Cookie", cookie);
67
+ return out;
68
+ } catch (error) {
69
+ if (error instanceof ResponseError) {
70
+ return platformErrorResponse(error.response);
71
+ }
72
+ throw error;
73
+ }
74
+ };
75
+ }
@@ -0,0 +1,42 @@
1
+ "use client";
2
+
3
+ import { useEffect } from "react";
4
+ import { clearAuthTokenCache } from "../hooks";
5
+ import type { ImpersonationActive } from "./cookies";
6
+ import { renewDelayMs } from "./required";
7
+
8
+ async function renewSession(): Promise<boolean> {
9
+ const res = await fetch("/api/impersonation/renew", {
10
+ method: "POST",
11
+ credentials: "include",
12
+ }).catch(() => null);
13
+ return res?.ok ?? false;
14
+ }
15
+
16
+ export function useSessionRenewal(
17
+ active: ImpersonationActive | null,
18
+ rereadCookie: () => void
19
+ ) {
20
+ useEffect(() => {
21
+ if (!active) return;
22
+
23
+ const renewal = setTimeout(
24
+ async () => {
25
+ if (!(await renewSession())) return;
26
+ clearAuthTokenCache();
27
+ rereadCookie();
28
+ },
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]);
42
+ }
@@ -0,0 +1,26 @@
1
+ export function platformBase(): string | null {
2
+ return (
3
+ process.env.NEXT_PUBLIC_PLATFORM_API_BASE_URL?.replace(/\/$/, "") ?? null
4
+ );
5
+ }
6
+
7
+ export function requestHost(request: Request): string | null {
8
+ return request.headers.get("x-forwarded-host") ?? request.headers.get("host");
9
+ }
10
+
11
+ function messageOf(raw: string): string {
12
+ try {
13
+ const parsed = JSON.parse(raw) as { message?: unknown };
14
+ return typeof parsed.message === "string" ? parsed.message : raw;
15
+ } catch {
16
+ return raw;
17
+ }
18
+ }
19
+
20
+ export async function platformErrorResponse(
21
+ response: Response
22
+ ): Promise<Response> {
23
+ return new Response(messageOf(await response.text()), {
24
+ status: response.status,
25
+ });
26
+ }
@@ -0,0 +1,86 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import {
4
+ impersonationPickerUrl,
5
+ impersonationRequired,
6
+ isExemptRoute,
7
+ renewDelayMs,
8
+ sendsToPicker,
9
+ withholdsApp,
10
+ } from "./required.ts";
11
+
12
+ const signedInAlone = {
13
+ authenticated: true,
14
+ sessionActive: false,
15
+ cookieRead: true,
16
+ exemptRoute: false,
17
+ };
18
+
19
+ describe("impersonation required gate", () => {
20
+ it("is off in production and everywhere that is not the test environment", () => {
21
+ for (const environment of ["p", "test", "production", "", undefined]) {
22
+ assert.equal(impersonationRequired(environment), false);
23
+ }
24
+ });
25
+
26
+ it("is on in test", () => {
27
+ assert.equal(impersonationRequired("t"), true);
28
+ });
29
+
30
+ it("never withholds the app or redirects when not required", () => {
31
+ const state = { required: false, ...signedInAlone };
32
+ assert.equal(withholdsApp(state), false);
33
+ assert.equal(sendsToPicker(state), false);
34
+ });
35
+
36
+ it("sends a signed-in person without a session to the picker when required", () => {
37
+ const state = { required: true, ...signedInAlone };
38
+ assert.equal(withholdsApp(state), true);
39
+ assert.equal(sendsToPicker(state), true);
40
+ });
41
+
42
+ it("lets the app render once someone is being viewed as", () => {
43
+ const state = { required: true, ...signedInAlone, sessionActive: true };
44
+ assert.equal(withholdsApp(state), false);
45
+ assert.equal(sendsToPicker(state), false);
46
+ });
47
+
48
+ it("holds the app without redirecting until the session cookie is read", () => {
49
+ const state = { required: true, ...signedInAlone, cookieRead: false };
50
+ assert.equal(withholdsApp(state), true);
51
+ assert.equal(sendsToPicker(state), false);
52
+ });
53
+
54
+ it("leaves sign-in routes, the picker and anonymous visitors alone", () => {
55
+ const base = { required: true, ...signedInAlone };
56
+ assert.equal(withholdsApp({ ...base, exemptRoute: true }), false);
57
+ assert.equal(withholdsApp({ ...base, authenticated: false }), false);
58
+ });
59
+
60
+ it("points the picker back at the page the person asked for", () => {
61
+ const url = new URL(
62
+ impersonationPickerUrl(
63
+ "https://www.test.snosky.no",
64
+ "https://snokam.test.snosky.no/minside/okonomi?x=1"
65
+ )
66
+ );
67
+ assert.equal(url.pathname, "/velg-bruker");
68
+ assert.equal(
69
+ url.searchParams.get("returnTo"),
70
+ "https://snokam.test.snosky.no/minside/okonomi?x=1"
71
+ );
72
+ });
73
+
74
+ it("exempts sign-in routes and the picker, and nothing else", () => {
75
+ assert.equal(isExemptRoute("/auth/login"), true);
76
+ assert.equal(isExemptRoute("/velg-bruker"), true);
77
+ assert.equal(isExemptRoute("/manage"), false);
78
+ assert.equal(isExemptRoute("/"), false);
79
+ assert.equal(isExemptRoute(null), false);
80
+ });
81
+
82
+ it("renews two minutes before the token expires, and at once when already late", () => {
83
+ assert.equal(renewDelayMs(1_000, 0), 880_000);
84
+ assert.equal(renewDelayMs(1_000, 999_000), 0);
85
+ });
86
+ });
@@ -0,0 +1,55 @@
1
+ export const IMPERSONATION_REQUIRED_ERROR = "impersonation_required";
2
+
3
+ const TEST_ENVIRONMENT = "t";
4
+
5
+ export const IMPERSONATION_PICKER_PATH = "/velg-bruker";
6
+
7
+ const RENEW_MARGIN_SECONDS = 120;
8
+
9
+ export function impersonationRequired(
10
+ environment: string | undefined
11
+ ): boolean {
12
+ return environment === TEST_ENVIRONMENT;
13
+ }
14
+
15
+ export interface ImpersonationGateState {
16
+ required: boolean;
17
+ authenticated: boolean;
18
+ sessionActive: boolean;
19
+ cookieRead: boolean;
20
+ exemptRoute: boolean;
21
+ }
22
+
23
+ function gated({
24
+ required,
25
+ authenticated,
26
+ exemptRoute,
27
+ }: ImpersonationGateState) {
28
+ return required && authenticated && !exemptRoute;
29
+ }
30
+
31
+ export function isExemptRoute(pathname: string | null): boolean {
32
+ if (!pathname) return false;
33
+ return pathname.startsWith("/auth") || pathname === IMPERSONATION_PICKER_PATH;
34
+ }
35
+
36
+ export function withholdsApp(state: ImpersonationGateState): boolean {
37
+ return gated(state) && (!state.cookieRead || !state.sessionActive);
38
+ }
39
+
40
+ export function sendsToPicker(state: ImpersonationGateState): boolean {
41
+ return gated(state) && state.cookieRead && !state.sessionActive;
42
+ }
43
+
44
+ export function impersonationPickerUrl(
45
+ platformBaseUrl: string,
46
+ returnTo: string
47
+ ): string {
48
+ const picker = new URL(IMPERSONATION_PICKER_PATH, platformBaseUrl);
49
+ picker.searchParams.set("returnTo", returnTo);
50
+ return picker.toString();
51
+ }
52
+
53
+ export function renewDelayMs(expiresOn: number, nowMs: number): number {
54
+ return Math.max(0, (expiresOn - RENEW_MARGIN_SECONDS) * 1000 - nowMs);
55
+ }
@@ -4,26 +4,30 @@ import {
4
4
  createImpersonationEndHandler,
5
5
  createImpersonationAdoptHandler,
6
6
  } from "./handlers";
7
+ import { createImpersonationRenewHandler } from "./renew";
8
+ import { createImpersonationPersonasHandler } from "./personas";
7
9
 
8
10
  type OpContext = { params: Promise<{ op: string }> };
9
11
 
10
- // One catch-all mount per app: api/impersonation/[op]/route.ts re-exports { GET, POST }.
11
- // POST /start + /end, GET /adopt — so every app supports view-as with a single file.
12
12
  export function createImpersonationRouteHandlers(authConfig: Auth.Config) {
13
13
  const start = createImpersonationStartHandler(authConfig);
14
14
  const end = createImpersonationEndHandler(authConfig);
15
15
  const adopt = createImpersonationAdoptHandler(authConfig);
16
+ const renew = createImpersonationRenewHandler(authConfig);
17
+ const personas = createImpersonationPersonasHandler(authConfig);
16
18
 
17
19
  return {
18
20
  async GET(request: Request, ctx: OpContext) {
19
21
  const { op } = await ctx.params;
20
22
  if (op === "adopt") return adopt(request);
23
+ if (op === "personas") return personas(request);
21
24
  return new Response("Not found", { status: 404 });
22
25
  },
23
26
  async POST(request: Request, ctx: OpContext) {
24
27
  const { op } = await ctx.params;
25
28
  if (op === "start") return start(request);
26
29
  if (op === "end") return end(request);
30
+ if (op === "renew") return renew(request);
27
31
  return new Response("Not found", { status: 404 });
28
32
  },
29
33
  };
@@ -8,6 +8,10 @@ import { getAuthUrl, getSessionCookieSettings } from "../session";
8
8
  import { exchangePasswordToken } from "../password/api";
9
9
  import { readImpersonationSession } from "../impersonation/cookies";
10
10
  import { exchangeSsoToken } from "../sso/exchange";
11
+ import {
12
+ IMPERSONATION_REQUIRED_ERROR,
13
+ impersonationRequired,
14
+ } from "../impersonation/required";
11
15
 
12
16
  export type TokenResult =
13
17
  { accessToken: string; expiresOn: number } | { error: Response };
@@ -102,9 +106,6 @@ export async function resolveAccessToken(
102
106
 
103
107
  export function createTokenHandler(authConfig: Auth.Config) {
104
108
  return async function POST(request: Request) {
105
- // While a view-as-employee session is active, every product API call transparently receives the
106
- // short-lived impersonation token instead of the admin's own — the single swap point the design
107
- // relies on. A clean exit (or expiry) discards the cookie and this falls back to the admin token.
108
109
  const impersonation = readImpersonationSession(request);
109
110
  if (impersonation && impersonation.expiresOn * 1000 > Date.now()) {
110
111
  return Response.json({
@@ -113,6 +114,13 @@ export function createTokenHandler(authConfig: Auth.Config) {
113
114
  });
114
115
  }
115
116
 
117
+ if (impersonationRequired(authConfig.environment)) {
118
+ return Response.json(
119
+ { error: IMPERSONATION_REQUIRED_ERROR },
120
+ { status: 403 }
121
+ );
122
+ }
123
+
116
124
  const result = await resolveAccessToken(authConfig, request);
117
125
  if ("error" in result) {
118
126
  return result.error;