@snokam/auth 3.3.12 → 3.4.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,12 @@
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.4.0](https://github.com/snokam/components/compare/@snokam/auth@3.3.12...@snokam/auth@3.4.0) (2026-10-06)
7
+
8
+ ### Features
9
+
10
+ - 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))
11
+
6
12
  ## [3.3.12](https://github.com/snokam/components/compare/@snokam/auth@3.3.11...@snokam/auth@3.3.12) (2026-10-05)
7
13
 
8
14
  ### 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.4.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
31
  "@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"
32
+ "@snokam/tenant": "2.1.13",
33
+ "@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
  }
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,55 @@
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
+ sendsToPicker,
12
+ withholdsApp,
13
+ } from "./required";
14
+
15
+ interface Props {
16
+ sessionActive: boolean;
17
+ cookieRead: boolean;
18
+ keepOwnIdentity: boolean;
19
+ children: ReactNode;
20
+ }
21
+
22
+ export function RequiredImpersonationGate({
23
+ sessionActive,
24
+ cookieRead,
25
+ keepOwnIdentity,
26
+ children,
27
+ }: Props) {
28
+ const config = useOptionalConfig();
29
+ const { status, data: session } = useSession();
30
+ const pathname = usePathname();
31
+ const platformUrl = config?.NEXT_PUBLIC_PLATFORM_BASE_URL;
32
+
33
+ const state = {
34
+ required: impersonationRequired(
35
+ config?.NEXT_PUBLIC_ENVIRONMENT,
36
+ keepOwnIdentity
37
+ ),
38
+ authenticated:
39
+ status === "authenticated" || (status === "loading" && !!session),
40
+ sessionActive,
41
+ cookieRead,
42
+ authRoute: pathname?.startsWith("/auth") ?? false,
43
+ };
44
+ const redirect = sendsToPicker(state);
45
+
46
+ useEffect(() => {
47
+ if (!redirect || !platformUrl) return;
48
+ window.location.assign(
49
+ impersonationPickerUrl(platformUrl, window.location.href)
50
+ );
51
+ }, [redirect, platformUrl]);
52
+
53
+ if (withholdsApp(state)) return <PageLoader />;
54
+ return <>{children}</>;
55
+ }
@@ -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
  }
@@ -72,26 +75,28 @@ function readActiveCookie(): ImpersonationActive | null {
72
75
  }
73
76
  }
74
77
 
75
- export function ImpersonationProvider({ children }: { children: ReactNode }) {
78
+ interface ImpersonationProviderProps {
79
+ children: ReactNode;
80
+ keepOwnIdentity?: boolean;
81
+ }
82
+
83
+ export function ImpersonationProvider({
84
+ children,
85
+ keepOwnIdentity = false,
86
+ }: ImpersonationProviderProps) {
76
87
  const [active, setActive] = useState<ImpersonationActive | null>(null);
88
+ const [cookieRead, setCookieRead] = useState(false);
77
89
  const [starting, setStarting] = useState(false);
78
90
  const [exiting, setExiting] = useState(false);
79
91
 
80
- useEffect(() => {
81
- setActive(readActiveCookie());
82
- }, []);
92
+ const rereadCookie = useCallback(() => setActive(readActiveCookie()), []);
83
93
 
84
- // Auto-exit at token expiry so a stale banner/token never lingers.
85
94
  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]);
95
+ rereadCookie();
96
+ setCookieRead(true);
97
+ }, [rereadCookie]);
98
+
99
+ useSessionRenewal(active, rereadCookie);
95
100
 
96
101
  const start = useCallback(
97
102
  async (input: {
@@ -102,6 +107,7 @@ export function ImpersonationProvider({ children }: { children: ReactNode }) {
102
107
  redirectTo?: string;
103
108
  returnTo?: string;
104
109
  landingUrl?: string;
110
+ handOffOnly?: boolean;
105
111
  }) => {
106
112
  setStarting(true);
107
113
  try {
@@ -161,7 +167,13 @@ export function ImpersonationProvider({ children }: { children: ReactNode }) {
161
167
 
162
168
  return (
163
169
  <ImpersonationContext.Provider value={value}>
164
- {children}
170
+ <RequiredImpersonationGate
171
+ sessionActive={!!active}
172
+ cookieRead={cookieRead}
173
+ keepOwnIdentity={keepOwnIdentity}
174
+ >
175
+ {children}
176
+ </RequiredImpersonationGate>
165
177
  </ImpersonationContext.Provider>
166
178
  );
167
179
  }
@@ -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,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,78 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import {
4
+ impersonationPickerUrl,
5
+ impersonationRequired,
6
+ renewDelayMs,
7
+ sendsToPicker,
8
+ withholdsApp,
9
+ } from "./required.ts";
10
+
11
+ const signedInAlone = {
12
+ authenticated: true,
13
+ sessionActive: false,
14
+ cookieRead: true,
15
+ authRoute: false,
16
+ };
17
+
18
+ describe("impersonation required gate", () => {
19
+ it("is off in production and everywhere that is not the test environment", () => {
20
+ for (const environment of ["p", "test", "production", "", undefined]) {
21
+ assert.equal(impersonationRequired(environment, false), false);
22
+ }
23
+ });
24
+
25
+ it("is on in test, except where the app keeps its own identity", () => {
26
+ assert.equal(impersonationRequired("t", false), true);
27
+ assert.equal(impersonationRequired("t", true), false);
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 and anonymous visitors alone", () => {
55
+ const base = { required: true, ...signedInAlone };
56
+ assert.equal(withholdsApp({ ...base, authRoute: 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, "/manage/users");
68
+ assert.equal(
69
+ url.searchParams.get("returnTo"),
70
+ "https://snokam.test.snosky.no/minside/okonomi?x=1"
71
+ );
72
+ });
73
+
74
+ it("renews two minutes before the token expires, and at once when already late", () => {
75
+ assert.equal(renewDelayMs(1_000, 0), 880_000);
76
+ assert.equal(renewDelayMs(1_000, 999_000), 0);
77
+ });
78
+ });
@@ -0,0 +1,45 @@
1
+ export const IMPERSONATION_REQUIRED_ERROR = "impersonation_required";
2
+
3
+ const TEST_ENVIRONMENT = "t";
4
+
5
+ const RENEW_MARGIN_SECONDS = 120;
6
+
7
+ export function impersonationRequired(
8
+ environment: string | undefined,
9
+ keepOwnIdentity: boolean
10
+ ): boolean {
11
+ return !keepOwnIdentity && environment === TEST_ENVIRONMENT;
12
+ }
13
+
14
+ export interface ImpersonationGateState {
15
+ required: boolean;
16
+ authenticated: boolean;
17
+ sessionActive: boolean;
18
+ cookieRead: boolean;
19
+ authRoute: boolean;
20
+ }
21
+
22
+ function gated({ required, authenticated, authRoute }: ImpersonationGateState) {
23
+ return required && authenticated && !authRoute;
24
+ }
25
+
26
+ export function withholdsApp(state: ImpersonationGateState): boolean {
27
+ return gated(state) && (!state.cookieRead || !state.sessionActive);
28
+ }
29
+
30
+ export function sendsToPicker(state: ImpersonationGateState): boolean {
31
+ return gated(state) && state.cookieRead && !state.sessionActive;
32
+ }
33
+
34
+ export function impersonationPickerUrl(
35
+ platformBaseUrl: string,
36
+ returnTo: string
37
+ ): string {
38
+ const picker = new URL("/manage/users", platformBaseUrl);
39
+ picker.searchParams.set("returnTo", returnTo);
40
+ return picker.toString();
41
+ }
42
+
43
+ export function renewDelayMs(expiresOn: number, nowMs: number): number {
44
+ return Math.max(0, (expiresOn - RENEW_MARGIN_SECONDS) * 1000 - nowMs);
45
+ }
@@ -4,15 +4,15 @@ import {
4
4
  createImpersonationEndHandler,
5
5
  createImpersonationAdoptHandler,
6
6
  } from "./handlers";
7
+ import { createImpersonationRenewHandler } from "./renew";
7
8
 
8
9
  type OpContext = { params: Promise<{ op: string }> };
9
10
 
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
11
  export function createImpersonationRouteHandlers(authConfig: Auth.Config) {
13
12
  const start = createImpersonationStartHandler(authConfig);
14
13
  const end = createImpersonationEndHandler(authConfig);
15
14
  const adopt = createImpersonationAdoptHandler(authConfig);
15
+ const renew = createImpersonationRenewHandler(authConfig);
16
16
 
17
17
  return {
18
18
  async GET(request: Request, ctx: OpContext) {
@@ -24,6 +24,7 @@ export function createImpersonationRouteHandlers(authConfig: Auth.Config) {
24
24
  const { op } = await ctx.params;
25
25
  if (op === "start") return start(request);
26
26
  if (op === "end") return end(request);
27
+ if (op === "renew") return renew(request);
27
28
  return new Response("Not found", { status: 404 });
28
29
  },
29
30
  };
@@ -38,6 +38,7 @@ export function createAuthHandler(options: AuthOptionsFor) {
38
38
 
39
39
  export { getNextAuthOptions } from "../options";
40
40
  export { createTokenHandler } from "./token";
41
+ export type { TokenHandlerOptions } from "./token";
41
42
  export {
42
43
  createImpersonationStartHandler,
43
44
  createImpersonationEndHandler,
@@ -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 };
@@ -100,7 +104,14 @@ export async function resolveAccessToken(
100
104
  }
101
105
  }
102
106
 
103
- export function createTokenHandler(authConfig: Auth.Config) {
107
+ export interface TokenHandlerOptions {
108
+ keepOwnIdentity?: boolean;
109
+ }
110
+
111
+ export function createTokenHandler(
112
+ authConfig: Auth.Config,
113
+ { keepOwnIdentity = false }: TokenHandlerOptions = {}
114
+ ) {
104
115
  return async function POST(request: Request) {
105
116
  // While a view-as-employee session is active, every product API call transparently receives the
106
117
  // short-lived impersonation token instead of the admin's own — the single swap point the design
@@ -113,6 +124,13 @@ export function createTokenHandler(authConfig: Auth.Config) {
113
124
  });
114
125
  }
115
126
 
127
+ if (impersonationRequired(authConfig.environment, keepOwnIdentity)) {
128
+ return Response.json(
129
+ { error: IMPERSONATION_REQUIRED_ERROR },
130
+ { status: 403 }
131
+ );
132
+ }
133
+
116
134
  const result = await resolveAccessToken(authConfig, request);
117
135
  if ("error" in result) {
118
136
  return result.error;