@snokam/auth 2.2.22 → 2.3.1

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 (80) hide show
  1. package/CHANGELOG.md +366 -1027
  2. package/package.json +36 -48
  3. package/src/app-router/components/AuthComplete.styled.tsx +17 -0
  4. package/src/app-router/components/AuthComplete.tsx +52 -0
  5. package/src/app-router/components/Login.tsx +120 -0
  6. package/src/app-router/components/Provider/index.tsx +55 -0
  7. package/src/app-router/index.tsx +56 -0
  8. package/src/app-router/server.ts +93 -0
  9. package/src/build-proxy.tsx +160 -0
  10. package/src/components/Authenticate.tsx +38 -0
  11. package/src/components/Authenticated.tsx +11 -0
  12. package/src/components/Consent/index.tsx +35 -0
  13. package/src/components/EmployeeGate/index.tsx +59 -0
  14. package/src/components/Error/index.tsx +90 -0
  15. package/src/components/Error/styled.tsx +63 -0
  16. package/src/components/InactiveEmployee/index.tsx +18 -0
  17. package/src/components/LoggedOut/index.tsx +23 -0
  18. package/src/components/NotProvisioned/index.tsx +19 -0
  19. package/src/components/Unauthenticated.tsx +11 -0
  20. package/src/config.ts +9 -0
  21. package/src/handoff/handlers.ts +146 -0
  22. package/src/handoff/return-to.ts +40 -0
  23. package/src/handoff/ticket.ts +97 -0
  24. package/src/hooks.ts +146 -0
  25. package/src/impersonation/context.tsx +185 -0
  26. package/src/impersonation/cookies.ts +124 -0
  27. package/src/impersonation/handlers.ts +234 -0
  28. package/src/impersonation/routes.ts +30 -0
  29. package/src/impersonation/ticket.ts +79 -0
  30. package/src/index.tsx +31 -0
  31. package/src/options.tsx +389 -0
  32. package/src/page-router/components/Login.tsx +50 -0
  33. package/src/page-router/components/Provider/index.tsx +45 -0
  34. package/src/page-router/index.tsx +22 -0
  35. package/src/page-router/server.ts +6 -0
  36. package/src/password/api.ts +183 -0
  37. package/src/roles/context.tsx +54 -0
  38. package/src/route-handler/token.ts +124 -0
  39. package/src/route-handler.ts +52 -0
  40. package/src/scopes/context.tsx +127 -0
  41. package/src/scopes/matcher.ts +47 -0
  42. package/src/server/auth.tsx +30 -0
  43. package/src/server/handler.tsx +68 -0
  44. package/src/server/token.tsx +10 -0
  45. package/{lib/server.d.ts → src/server.tsx} +7 -4
  46. package/src/sso/exchange.ts +64 -0
  47. package/src/sso/tenant-cookie.ts +18 -0
  48. package/src/theme/employee-theme-sync.tsx +55 -0
  49. package/src/token/claims.ts +12 -0
  50. package/src/utils/callback-url.ts +17 -0
  51. package/src/utils/consent.ts +22 -0
  52. package/src/utils/cookies.ts +75 -0
  53. package/src/utils/cors.ts +68 -0
  54. package/src/utils/params.ts +15 -0
  55. package/src/utils/types.ts +52 -0
  56. package/src/utils/url.ts +19 -0
  57. package/lib/components/Authenticate.d.ts +0 -2
  58. package/lib/components/Authenticated.d.ts +0 -6
  59. package/lib/components/Error/index.d.ts +0 -5
  60. package/lib/components/Login.d.ts +0 -2
  61. package/lib/components/Provider/index.d.ts +0 -7
  62. package/lib/components/Unauthenticated.d.ts +0 -6
  63. package/lib/hooks.d.ts +0 -10
  64. package/lib/index.d.ts +0 -14
  65. package/lib/index.js +0 -42
  66. package/lib/index.js.map +0 -1
  67. package/lib/middleware.d.ts +0 -7
  68. package/lib/middleware.js +0 -2
  69. package/lib/middleware.js.map +0 -1
  70. package/lib/options.d.ts +0 -5
  71. package/lib/options.js +0 -2
  72. package/lib/options.js.map +0 -1
  73. package/lib/server/auth.d.ts +0 -11
  74. package/lib/server/handler.d.ts +0 -14
  75. package/lib/server/token.d.ts +0 -8
  76. package/lib/server.js +0 -2
  77. package/lib/server.js.map +0 -1
  78. package/lib/utils/cookies.d.ts +0 -5
  79. package/lib/utils/params.d.ts +0 -1
  80. package/lib/utils/types.d.ts +0 -39
package/src/hooks.ts ADDED
@@ -0,0 +1,146 @@
1
+ import { useSession, signIn, signOut } from "next-auth/react";
2
+ import { useMemo } from "react";
3
+ import { IMP_ACTIVE_COOKIE } from "./impersonation/cookies";
4
+ import { useImpersonation } from "./impersonation/context";
5
+
6
+ class ConsentRequiredError extends Error {}
7
+
8
+ const REFRESH_MARGIN_SECONDS = 60;
9
+
10
+ interface CachedToken {
11
+ bearer: string;
12
+ expiresOn: number;
13
+ impKey: string;
14
+ }
15
+
16
+ let cachedToken: CachedToken | null = null;
17
+ let inFlight: { impKey: string; promise: Promise<string> } | null = null;
18
+
19
+ /**
20
+ * Identifies which principal the next token belongs to: the active impersonation
21
+ * tokenId, or "" for the admin's own token. Changing this (Vis som start/exit)
22
+ * invalidates the cache so the swap in the token route is never served stale.
23
+ */
24
+ function readImpersonationKey(): string {
25
+ if (typeof document === "undefined") return "";
26
+ const match = document.cookie
27
+ .split(";")
28
+ .map((part) => part.trim())
29
+ .find((part) => part.startsWith(`${IMP_ACTIVE_COOKIE}=`));
30
+ if (!match) return "";
31
+ try {
32
+ const value = decodeURIComponent(match.slice(IMP_ACTIVE_COOKIE.length + 1));
33
+ const parsed = JSON.parse(value) as {
34
+ tokenId?: string;
35
+ expiresOn?: number;
36
+ };
37
+ if (parsed.expiresOn && parsed.expiresOn * 1000 <= Date.now()) return "";
38
+ return parsed.tokenId ?? "";
39
+ } catch {
40
+ return "";
41
+ }
42
+ }
43
+
44
+ export function clearAuthTokenCache(): void {
45
+ cachedToken = null;
46
+ inFlight = null;
47
+ }
48
+
49
+ async function fetchToken(impKey: string): Promise<string> {
50
+ const res = await fetch("/api/auth/token", {
51
+ method: "POST",
52
+ body: JSON.stringify({}),
53
+ credentials: "include",
54
+ headers: {
55
+ "Content-Type": "application/json",
56
+ },
57
+ });
58
+
59
+ if (res.status === 403) {
60
+ const body = await res.json().catch(() => null);
61
+ if (body?.error === "consent_required") {
62
+ throw new ConsentRequiredError();
63
+ }
64
+ }
65
+
66
+ if (!res.ok) {
67
+ throw new Error("Token fetch failed");
68
+ }
69
+
70
+ const token = await res.json();
71
+ const { accessToken, expiresOn } = token;
72
+
73
+ sessionStorage.removeItem("auth-consent-retried");
74
+ const bearer = `Bearer ${accessToken}`;
75
+ cachedToken = { bearer, expiresOn, impKey };
76
+ return bearer;
77
+ }
78
+
79
+ async function getFreshToken(): Promise<string> {
80
+ const impKey = readImpersonationKey();
81
+ const now = Date.now() / 1000;
82
+
83
+ if (
84
+ cachedToken &&
85
+ cachedToken.impKey === impKey &&
86
+ now < cachedToken.expiresOn - REFRESH_MARGIN_SECONDS
87
+ ) {
88
+ return cachedToken.bearer;
89
+ }
90
+
91
+ if (inFlight && inFlight.impKey === impKey) {
92
+ return inFlight.promise;
93
+ }
94
+
95
+ const promise = fetchToken(impKey);
96
+ inFlight = { impKey, promise };
97
+ promise
98
+ .catch(() => undefined)
99
+ .finally(() => {
100
+ if (inFlight?.promise === promise) inFlight = null;
101
+ });
102
+ return promise;
103
+ }
104
+
105
+ export function useAuth() {
106
+ const { data: rawSession, status, update } = useSession();
107
+ const { active } = useImpersonation();
108
+
109
+ const session = useMemo(() => {
110
+ if (!active || !rawSession?.user) return rawSession;
111
+ return {
112
+ ...rawSession,
113
+ user: {
114
+ ...rawSession.user,
115
+ name: active.targetName || active.targetEmail,
116
+ email: active.targetEmail,
117
+ image: active.targetImage ?? null,
118
+ },
119
+ };
120
+ }, [rawSession, active]);
121
+
122
+ const value = useMemo(
123
+ () => ({
124
+ update,
125
+ signIn,
126
+ signOut,
127
+ session,
128
+ getToken: () =>
129
+ getFreshToken().catch((error) => {
130
+ console.error(error);
131
+ const onAuthPage = window.location.pathname.startsWith("/auth/");
132
+ if (onAuthPage) return;
133
+ const reason =
134
+ error instanceof ConsentRequiredError ? "consent" : "true";
135
+ window.location.href = `/auth/login?error=${reason}`;
136
+ }) as Promise<string>,
137
+ inProgress: status === "loading",
138
+ isAuthenticated:
139
+ status === "authenticated" || (status === "loading" && !!session),
140
+ }),
141
+
142
+ [update, status, session]
143
+ );
144
+
145
+ return value;
146
+ }
@@ -0,0 +1,185 @@
1
+ "use client";
2
+
3
+ import {
4
+ createContext,
5
+ useCallback,
6
+ useContext,
7
+ useEffect,
8
+ useMemo,
9
+ useState,
10
+ type ReactNode,
11
+ } from "react";
12
+ import { IMP_ACTIVE_COOKIE, type ImpersonationActive } from "./cookies";
13
+ import { clearAuthTokenCache } from "../hooks";
14
+
15
+ interface ImpersonationContextValue {
16
+ active: ImpersonationActive | null;
17
+ starting: boolean;
18
+ exiting: boolean;
19
+ start: (input: {
20
+ targetEmail: string;
21
+ targetName?: string;
22
+ targetImage?: string;
23
+ reason: string;
24
+ redirectTo?: string;
25
+ returnTo?: string;
26
+ landingUrl?: string;
27
+ }) => Promise<{ ok: boolean; error?: string }>;
28
+ exit: () => Promise<void>;
29
+ }
30
+
31
+ const ImpersonationContext = createContext<ImpersonationContextValue | null>(
32
+ null
33
+ );
34
+
35
+ /** Registrable base domain of the platform (last two labels of the API host). */
36
+ function platformBaseDomain(): string {
37
+ const api = process.env.NEXT_PUBLIC_PLATFORM_API_BASE_URL;
38
+ if (!api) {
39
+ throw new Error("NEXT_PUBLIC_PLATFORM_API_BASE_URL is not configured");
40
+ }
41
+ return new URL(api).hostname.split(".").slice(-2).join(".");
42
+ }
43
+
44
+ /** Guards the exit redirect against open-redirect: same-origin, or https on the platform base domain. */
45
+ function isSafeReturnTo(returnTo: string): boolean {
46
+ if (typeof window === "undefined") return false;
47
+ const base = platformBaseDomain();
48
+ try {
49
+ const url = new URL(returnTo, window.location.origin);
50
+ if (url.origin === window.location.origin) return true;
51
+ if (url.protocol !== "https:") return false;
52
+ return url.hostname === base || url.hostname.endsWith(`.${base}`);
53
+ } catch {
54
+ return false;
55
+ }
56
+ }
57
+
58
+ function readActiveCookie(): ImpersonationActive | null {
59
+ if (typeof document === "undefined") return null;
60
+ const match = document.cookie
61
+ .split(";")
62
+ .map((part) => part.trim())
63
+ .find((part) => part.startsWith(`${IMP_ACTIVE_COOKIE}=`));
64
+ if (!match) return null;
65
+ try {
66
+ const value = decodeURIComponent(match.slice(IMP_ACTIVE_COOKIE.length + 1));
67
+ const parsed = JSON.parse(value) as ImpersonationActive;
68
+ if (parsed.expiresOn * 1000 <= Date.now()) return null;
69
+ return parsed;
70
+ } catch {
71
+ return null;
72
+ }
73
+ }
74
+
75
+ export function ImpersonationProvider({ children }: { children: ReactNode }) {
76
+ const [active, setActive] = useState<ImpersonationActive | null>(null);
77
+ const [starting, setStarting] = useState(false);
78
+ const [exiting, setExiting] = useState(false);
79
+
80
+ useEffect(() => {
81
+ setActive(readActiveCookie());
82
+ }, []);
83
+
84
+ // Auto-exit at token expiry so a stale banner/token never lingers.
85
+ 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
+
96
+ const start = useCallback(
97
+ async (input: {
98
+ targetEmail: string;
99
+ targetName?: string;
100
+ targetImage?: string;
101
+ reason: string;
102
+ redirectTo?: string;
103
+ returnTo?: string;
104
+ landingUrl?: string;
105
+ }) => {
106
+ setStarting(true);
107
+ try {
108
+ const { redirectTo, landingUrl } = input;
109
+ const res = await fetch("/api/impersonation/start", {
110
+ method: "POST",
111
+ headers: { "Content-Type": "application/json" },
112
+ credentials: "include",
113
+ body: JSON.stringify(input),
114
+ });
115
+ if (!res.ok) {
116
+ return {
117
+ ok: false,
118
+ error: (await res.text()) || `HTTP ${res.status}`,
119
+ };
120
+ }
121
+ clearAuthTokenCache();
122
+ const data = (await res.json().catch(() => ({}))) as {
123
+ adoptUrl?: string;
124
+ };
125
+ if (data.adoptUrl) window.location.assign(data.adoptUrl);
126
+ else if (landingUrl) window.location.assign(landingUrl);
127
+ else if (redirectTo) window.location.assign(redirectTo);
128
+ else window.location.reload();
129
+ return { ok: true };
130
+ } finally {
131
+ setStarting(false);
132
+ }
133
+ },
134
+ []
135
+ );
136
+
137
+ const exit = useCallback(async () => {
138
+ setExiting(true);
139
+ try {
140
+ await fetch("/api/impersonation/end", {
141
+ method: "POST",
142
+ credentials: "include",
143
+ }).catch(() => undefined);
144
+ clearAuthTokenCache();
145
+ const returnTo = active?.returnTo;
146
+ setActive(null);
147
+ if (returnTo && isSafeReturnTo(returnTo)) {
148
+ window.location.assign(returnTo);
149
+ } else {
150
+ window.location.reload();
151
+ }
152
+ } finally {
153
+ setExiting(false);
154
+ }
155
+ }, [active]);
156
+
157
+ const value = useMemo<ImpersonationContextValue>(
158
+ () => ({ active, starting, exiting, start, exit }),
159
+ [active, starting, exiting, start, exit]
160
+ );
161
+
162
+ return (
163
+ <ImpersonationContext.Provider value={value}>
164
+ {children}
165
+ </ImpersonationContext.Provider>
166
+ );
167
+ }
168
+
169
+ export function useImpersonation(): ImpersonationContextValue {
170
+ const context = useContext(ImpersonationContext);
171
+ if (!context) {
172
+ // Safe no-op default so the shared banner can mount in apps that haven't wired the provider yet.
173
+ return {
174
+ active: null,
175
+ starting: false,
176
+ exiting: false,
177
+ start: async () => ({
178
+ ok: false,
179
+ error: "Impersonation ikke tilgjengelig her.",
180
+ }),
181
+ exit: async () => undefined,
182
+ };
183
+ }
184
+ return context;
185
+ }
@@ -0,0 +1,124 @@
1
+ import { resolveCookieDomain } from "../utils/cookies";
2
+
3
+ /** HttpOnly — carries the impersonation bearer; only the token handler ever reads it. */
4
+ export const IMP_SESSION_COOKIE = "imp_session";
5
+ /** Readable — non-sensitive session facts that drive the banner + provider (no token). */
6
+ export const IMP_ACTIVE_COOKIE = "imp_active";
7
+
8
+ export interface ImpersonationSession {
9
+ accessToken: string;
10
+ expiresOn: number;
11
+ tokenId: string;
12
+ }
13
+
14
+ export interface ImpersonationActive {
15
+ targetEmail: string;
16
+ targetName?: string | null;
17
+ targetImage?: string | null;
18
+ finance: boolean;
19
+ tokenId: string;
20
+ expiresOn: number;
21
+ returnTo?: string;
22
+ }
23
+
24
+ interface MintedToken {
25
+ accessToken: string;
26
+ expiresOn: number;
27
+ tokenId: string;
28
+ targetEmail: string;
29
+ targetName?: string | null;
30
+ targetImage?: string | null;
31
+ finance: boolean;
32
+ returnTo?: string;
33
+ }
34
+
35
+ function parseCookieHeader(header: string | null): Record<string, string> {
36
+ const jar: Record<string, string> = {};
37
+ if (!header) return jar;
38
+ for (const part of header.split(";")) {
39
+ const index = part.indexOf("=");
40
+ if (index < 0) continue;
41
+ const name = part.slice(0, index).trim();
42
+ if (name) jar[name] = decodeURIComponent(part.slice(index + 1).trim());
43
+ }
44
+ return jar;
45
+ }
46
+
47
+ export function readImpersonationSession(
48
+ request: Request
49
+ ): ImpersonationSession | null {
50
+ const raw = parseCookieHeader(request.headers.get("cookie"))[
51
+ IMP_SESSION_COOKIE
52
+ ];
53
+ if (!raw) return null;
54
+ try {
55
+ return JSON.parse(raw) as ImpersonationSession;
56
+ } catch {
57
+ return null;
58
+ }
59
+ }
60
+
61
+ function serialize(
62
+ name: string,
63
+ value: string,
64
+ {
65
+ host,
66
+ httpOnly,
67
+ maxAge,
68
+ }: { host?: string | null; httpOnly: boolean; maxAge: number }
69
+ ): string {
70
+ const domain = resolveCookieDomain(host ?? undefined);
71
+ const secure = host ? !host.includes("localhost") : true;
72
+ const parts = [
73
+ `${name}=${encodeURIComponent(value)}`,
74
+ "Path=/",
75
+ "SameSite=Lax",
76
+ `Max-Age=${maxAge}`,
77
+ ];
78
+ if (domain) parts.push(`Domain=${domain}`);
79
+ if (httpOnly) parts.push("HttpOnly");
80
+ if (secure) parts.push("Secure");
81
+ return parts.join("; ");
82
+ }
83
+
84
+ /** Set-Cookie values that open a session (server-side, on mint success). */
85
+ export function buildImpersonationCookies(
86
+ minted: MintedToken,
87
+ host: string | null
88
+ ): string[] {
89
+ const maxAge = Math.max(0, Math.floor(minted.expiresOn - Date.now() / 1000));
90
+ const session: ImpersonationSession = {
91
+ accessToken: minted.accessToken,
92
+ expiresOn: minted.expiresOn,
93
+ tokenId: minted.tokenId,
94
+ };
95
+ const active: ImpersonationActive = {
96
+ targetEmail: minted.targetEmail,
97
+ targetName: minted.targetName,
98
+ targetImage: minted.targetImage,
99
+ finance: minted.finance,
100
+ tokenId: minted.tokenId,
101
+ expiresOn: minted.expiresOn,
102
+ returnTo: minted.returnTo,
103
+ };
104
+ return [
105
+ serialize(IMP_SESSION_COOKIE, JSON.stringify(session), {
106
+ host,
107
+ httpOnly: true,
108
+ maxAge,
109
+ }),
110
+ serialize(IMP_ACTIVE_COOKIE, JSON.stringify(active), {
111
+ host,
112
+ httpOnly: false,
113
+ maxAge,
114
+ }),
115
+ ];
116
+ }
117
+
118
+ /** Set-Cookie values that clear the session (exit or after posting the end record). */
119
+ export function clearImpersonationCookies(host: string | null): string[] {
120
+ return [
121
+ serialize(IMP_SESSION_COOKIE, "", { host, httpOnly: true, maxAge: 0 }),
122
+ serialize(IMP_ACTIVE_COOKIE, "", { host, httpOnly: false, maxAge: 0 }),
123
+ ];
124
+ }
@@ -0,0 +1,234 @@
1
+ import { Auth } from "../utils/types";
2
+ import { resolveAccessToken } from "../route-handler/token";
3
+ import {
4
+ buildImpersonationCookies,
5
+ clearImpersonationCookies,
6
+ readImpersonationSession,
7
+ } from "./cookies";
8
+ import { sealImpersonationTicket, openImpersonationTicket } from "./ticket";
9
+ import { requestOrigin } from "../utils/url";
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
+ }
20
+
21
+ function registrable(host: string): string {
22
+ return host.split(":")[0].toLowerCase().split(".").slice(-2).join(".");
23
+ }
24
+
25
+ function platformBaseDomain(): string {
26
+ const api = process.env.NEXT_PUBLIC_PLATFORM_API_BASE_URL;
27
+ if (!api) {
28
+ throw new Error("NEXT_PUBLIC_PLATFORM_API_BASE_URL is not configured");
29
+ }
30
+ return new URL(api).hostname.split(".").slice(-2).join(".");
31
+ }
32
+
33
+ // The adopt sets the tenant-domain cookie, then hands back to a same-origin path OR the platform
34
+ // hub (/manage on snosky.no) — anything else falls back to "/" so it can never open-redirect.
35
+ function resolveLanding(returnTo: string, origin: string): string {
36
+ const base = platformBaseDomain();
37
+ try {
38
+ const url = new URL(returnTo, origin);
39
+ if (url.origin === origin) return url.toString();
40
+ if (
41
+ url.protocol === "https:" &&
42
+ (url.hostname === base || url.hostname.endsWith(`.${base}`))
43
+ )
44
+ return url.toString();
45
+ } catch {
46
+ // Fall through to the safe default.
47
+ }
48
+ return new URL("/", origin).toString();
49
+ }
50
+
51
+ /** True when the redirect target lives on a different registrable domain than this request. */
52
+ function isCrossDomain(request: Request, target: string): boolean {
53
+ const here = requestHost(request);
54
+ if (!here) return false;
55
+ try {
56
+ return registrable(new URL(target).host) !== registrable(here);
57
+ } catch {
58
+ return false;
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Starts a view-as-employee session: authenticates as the admin, calls the platform mint endpoint
64
+ * (which enforces the platform.impersonate gate + required reason + audit server-side), and, on
65
+ * success, sets the impersonation cookies so subsequent product calls ride the minted token.
66
+ */
67
+ export function createImpersonationStartHandler(authConfig: Auth.Config) {
68
+ return async function POST(request: Request) {
69
+ const base = platformBase();
70
+ if (!base) {
71
+ return new Response(
72
+ "NEXT_PUBLIC_PLATFORM_API_BASE_URL is not configured",
73
+ { status: 500 }
74
+ );
75
+ }
76
+
77
+ const caller = await resolveAccessToken(authConfig, request);
78
+ if ("error" in caller) return caller.error;
79
+
80
+ let body: {
81
+ targetEmail?: string;
82
+ targetName?: string;
83
+ targetImage?: string;
84
+ reason?: string;
85
+ redirectTo?: string;
86
+ returnTo?: string;
87
+ landingUrl?: string;
88
+ };
89
+ try {
90
+ body = await request.json();
91
+ } catch {
92
+ return new Response("Invalid request body", { status: 400 });
93
+ }
94
+
95
+ const minted = await fetch(`${base}/v1.0/protected/impersonate`, {
96
+ method: "POST",
97
+ headers: {
98
+ Authorization: `Bearer ${caller.accessToken}`,
99
+ "Content-Type": "application/json",
100
+ },
101
+ body: JSON.stringify({
102
+ targetEmail: body.targetEmail,
103
+ targetName: body.targetName,
104
+ reason: body.reason,
105
+ }),
106
+ });
107
+
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
+ }
119
+
120
+ const result = await minted.json();
121
+ result.targetImage = body.targetImage ?? null;
122
+ const summary = {
123
+ targetEmail: result.targetEmail,
124
+ targetName: result.targetName,
125
+ finance: result.finance,
126
+ tokenId: result.tokenId,
127
+ expiresOn: result.expiresOn,
128
+ };
129
+
130
+ // Custom-domain tenant: hand the session to the tenant domain's adopt route (login's pattern).
131
+ if (body.redirectTo && isCrossDomain(request, body.redirectTo)) {
132
+ const target = new URL(body.redirectTo);
133
+ const ticket = sealImpersonationTicket(
134
+ { ...result, business: target.host, returnTo: body.returnTo },
135
+ authConfig.nextAuthSecret
136
+ );
137
+ const adopt = new URL("/api/impersonation/adopt", target.origin);
138
+ adopt.searchParams.set("t", ticket);
139
+ adopt.searchParams.set(
140
+ "returnTo",
141
+ body.landingUrl ?? target.pathname + target.search
142
+ );
143
+ const crossDomain = Response.json({
144
+ ...summary,
145
+ adoptUrl: adopt.toString(),
146
+ });
147
+ for (const cookie of buildImpersonationCookies(
148
+ { ...result, returnTo: body.returnTo },
149
+ requestHost(request)
150
+ )) {
151
+ crossDomain.headers.append("Set-Cookie", cookie);
152
+ }
153
+ return crossDomain;
154
+ }
155
+
156
+ const out = Response.json(summary);
157
+ for (const cookie of buildImpersonationCookies(
158
+ { ...result, returnTo: body.returnTo },
159
+ requestHost(request)
160
+ )) {
161
+ out.headers.append("Set-Cookie", cookie);
162
+ }
163
+ return out;
164
+ };
165
+ }
166
+
167
+ // Runs on the tenant domain: opens the sealed ticket and sets the impersonation cookie here.
168
+ export function createImpersonationAdoptHandler(authConfig: Auth.Config) {
169
+ return async function GET(request: Request) {
170
+ const url = new URL(request.url);
171
+ const origin = requestOrigin(request);
172
+ const ticket = url.searchParams.get("t");
173
+ const host = requestHost(request);
174
+ const claims = ticket
175
+ ? openImpersonationTicket(
176
+ ticket,
177
+ authConfig.nextAuthSecret,
178
+ host ?? undefined
179
+ )
180
+ : null;
181
+
182
+ if (!claims) {
183
+ const dest = new URL("/", origin);
184
+ dest.searchParams.set("impersonation_error", "1");
185
+ return new Response(null, {
186
+ status: 302,
187
+ headers: { Location: dest.toString() },
188
+ });
189
+ }
190
+
191
+ const dest = resolveLanding(
192
+ url.searchParams.get("returnTo") || "/",
193
+ origin
194
+ );
195
+ const out = new Response(null, {
196
+ status: 302,
197
+ headers: { Location: dest },
198
+ });
199
+ for (const cookie of buildImpersonationCookies(claims, host)) {
200
+ out.headers.append("Set-Cookie", cookie);
201
+ }
202
+ return out;
203
+ };
204
+ }
205
+
206
+ /**
207
+ * Ends a session: writes the append-only session-end audit record (best effort) and clears the
208
+ * cookies so the admin's own token is restored. Token expiry auto-ends too.
209
+ */
210
+ export function createImpersonationEndHandler(authConfig: Auth.Config) {
211
+ return async function POST(request: Request) {
212
+ const session = readImpersonationSession(request);
213
+ const base = platformBase();
214
+
215
+ if (session?.tokenId && base) {
216
+ const caller = await resolveAccessToken(authConfig, request);
217
+ if (!("error" in caller)) {
218
+ await fetch(
219
+ `${base}/v1.0/protected/impersonate/${encodeURIComponent(session.tokenId)}/end`,
220
+ {
221
+ method: "POST",
222
+ headers: { Authorization: `Bearer ${caller.accessToken}` },
223
+ }
224
+ ).catch(() => undefined);
225
+ }
226
+ }
227
+
228
+ const out = Response.json({ ok: true });
229
+ for (const cookie of clearImpersonationCookies(requestHost(request))) {
230
+ out.headers.append("Set-Cookie", cookie);
231
+ }
232
+ return out;
233
+ };
234
+ }