@snokam/auth 3.7.0 → 3.7.2

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,17 @@
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.7.2](https://github.com/snokam/components/compare/@snokam/auth@3.7.0...@snokam/auth@3.7.2) (2026-10-06)
7
+
8
+ ### Bug Fixes
9
+
10
+ - **auth:** ending a persona returns to where it started; no picker loop from sign-in ([#2708](https://github.com/snokam/components/issues/2708)) ([e609377](https://github.com/snokam/components/commit/e6093776f8a5458b611f622f09611145b7cbc461))
11
+ - **mcp:** return iss on the sign-in callback so Codex and Cursor accept it ([#2707](https://github.com/snokam/components/issues/2707)) ([b55887e](https://github.com/snokam/components/commit/b55887e62b9e4b84fbd4bed7fbd4b5e588b534a4))
12
+
13
+ ## [3.7.1](https://github.com/snokam/components/compare/@snokam/auth@3.7.0...@snokam/auth@3.7.1) (2026-10-06)
14
+
15
+ **Note:** Version bump only for package @snokam/auth
16
+
6
17
  # [3.7.0](https://github.com/snokam/components/compare/@snokam/auth@3.6.0...@snokam/auth@3.7.0) (2026-10-06)
7
18
 
8
19
  ### Features
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.7.0",
4
+ "version": "3.7.2",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
7
7
  "files": [
@@ -27,10 +27,10 @@
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.6.0",
30
+ "@snokam/platform-client": "3.6.1",
31
31
  "@snokam/react-query": "1.1.0",
32
- "@snokam/sanity-page-content": "4.4.2",
33
- "@snokam/tenant": "2.1.13",
32
+ "@snokam/sanity-page-content": "4.5.0",
33
+ "@snokam/tenant": "2.1.14",
34
34
  "@snokam/ui": "4.0.0"
35
35
  },
36
36
  "peerDependencies": {
@@ -34,6 +34,7 @@ export {
34
34
  } from "../impersonation/context";
35
35
  export type { ImpersonationActive } from "../impersonation/cookies";
36
36
  export { impersonationRequired } from "../impersonation/required";
37
+ export { isAllowedRedirect } from "../impersonation/redirects";
37
38
  export { signIn, SessionProvider } from "next-auth/react";
38
39
  export {
39
40
  Login,
@@ -13,6 +13,7 @@ export interface ConsentClaims extends AuthorizationParams {
13
13
  state: string;
14
14
  clientName: string;
15
15
  businessName: string;
16
+ issuer: string;
16
17
  email: string;
17
18
  nonce: string;
18
19
  }
@@ -15,6 +15,7 @@ import {
15
15
  import type { Auth } from "../types";
16
16
  import {
17
17
  type AuthorizationParams,
18
+ type ConsentClaims,
18
19
  CONSENT_TTL_SECONDS,
19
20
  readAppSignInConsent,
20
21
  sealConsent,
@@ -79,13 +80,13 @@ async function ownSigner(
79
80
  return "error" in access ? null : { email, accessToken: access.accessToken };
80
81
  }
81
82
 
82
- function signerFor(
83
+ async function signerFor(
83
84
  authConfig: Auth.Config,
84
85
  request: Request,
85
86
  email: string
86
87
  ): Promise<Signer | null> {
87
88
  if (impersonationRequired(authConfig.environment)) {
88
- return Promise.resolve(personaSigner(request, email));
89
+ return personaSigner(request, email);
89
90
  }
90
91
  return ownSigner(authConfig, request, email);
91
92
  }
@@ -133,15 +134,15 @@ function sameOrigin(request: Request): boolean {
133
134
 
134
135
  function clientRedirect(
135
136
  request: Request,
136
- redirectUri: string,
137
- state: string,
137
+ consent: ConsentClaims,
138
138
  result: Record<string, string>
139
139
  ): Response {
140
- const target = new URL(redirectUri);
140
+ const target = new URL(consent.redirectUri);
141
141
  for (const [key, value] of Object.entries(result)) {
142
142
  target.searchParams.set(key, value);
143
143
  }
144
- if (state) target.searchParams.set("state", state);
144
+ if (consent.state) target.searchParams.set("state", consent.state);
145
+ if (consent.issuer) target.searchParams.set("iss", consent.issuer);
145
146
  return new Response(null, {
146
147
  status: 303,
147
148
  headers: {
@@ -180,6 +181,7 @@ export function createAppSignInAuthorizeHandler(authConfig: Auth.Config) {
180
181
  ...params,
181
182
  clientName: preview.value.clientName ?? "",
182
183
  businessName: preview.value.businessName ?? "",
184
+ issuer: preview.value.issuer ?? "",
183
185
  business: new URL(params.resource).hostname,
184
186
  state: query.get("state") ?? "",
185
187
  email: signer.email,
@@ -219,16 +221,13 @@ export function createAppSignInApproveHandler(authConfig: Auth.Config) {
219
221
  if (!signer || signer.email !== consent.email) return invalidRequest();
220
222
 
221
223
  if (form.get("decision") !== "approve") {
222
- return clientRedirect(request, consent.redirectUri, consent.state, {
223
- error: ACCESS_DENIED,
224
- });
224
+ return clientRedirect(request, consent, { error: ACCESS_DENIED });
225
225
  }
226
226
 
227
227
  const approved = await approveAuthorization(consent, signer.accessToken);
228
228
  return clientRedirect(
229
229
  request,
230
- consent.redirectUri,
231
- consent.state,
230
+ consent,
232
231
  "error" in approved
233
232
  ? { error: approved.error }
234
233
  : { code: approved.value.code ?? "" }
@@ -1,36 +1,24 @@
1
1
  import { getManagedBearer } from "@snokam/identity";
2
2
  import {
3
- Configuration,
4
3
  PlatformClient,
5
4
  ResponseError,
6
5
  type AuthorizationPreview,
7
6
  type IssuedAuthorizationCode,
8
7
  } from "@snokam/platform-client";
8
+ import { platformClientFor } from "../impersonation/request";
9
9
  import type { AuthorizationParams } from "./consent";
10
10
 
11
11
  type Outcome<T> = { value: T } | { error: string };
12
12
 
13
13
  const UNAVAILABLE = "temporarily_unavailable";
14
14
 
15
- function apiBase(): string {
16
- return (process.env.NEXT_PUBLIC_PLATFORM_API_BASE_URL ?? "").replace(
17
- /\/+$/,
18
- ""
19
- );
20
- }
21
-
22
15
  async function asLoginSite<T>(
23
16
  call: (client: PlatformClient) => Promise<T>
24
17
  ): Promise<Outcome<T>> {
25
18
  const loginSite = await getManagedBearer();
26
- if (!loginSite) return { error: UNAVAILABLE };
19
+ const client = loginSite ? platformClientFor(loginSite) : null;
20
+ if (!client) return { error: UNAVAILABLE };
27
21
 
28
- const client = new PlatformClient(
29
- new Configuration({
30
- basePath: apiBase(),
31
- accessToken: async () => loginSite,
32
- })
33
- );
34
22
  try {
35
23
  return { value: await call(client) };
36
24
  } catch (error) {
package/src/hooks.ts CHANGED
@@ -6,6 +6,7 @@ import { useSession } from "./session/useSession";
6
6
  import {
7
7
  IMPERSONATION_REQUIRED_ERROR,
8
8
  impersonationPickerUrl,
9
+ isSignInPage,
9
10
  } from "./impersonation/required";
10
11
 
11
12
  class ConsentRequiredError extends Error {}
@@ -17,7 +18,7 @@ class ImpersonationRequiredError extends Error {
17
18
  }
18
19
 
19
20
  function sendToPicker(error: ImpersonationRequiredError): void {
20
- if (!error.platformUrl) return;
21
+ if (!error.platformUrl || isSignInPage(window.location.pathname)) return;
21
22
  window.location.assign(
22
23
  impersonationPickerUrl(error.platformUrl, window.location.href)
23
24
  );
@@ -12,21 +12,26 @@ import {
12
12
  import { IMP_ACTIVE_COOKIE, type ImpersonationActive } from "./cookies";
13
13
  import { clearAuthTokenCache } from "../hooks";
14
14
  import { useSessionRenewal } from "./renewal";
15
+ import { useOptionalConfig } from "@snokam/tenant";
16
+ import { isAllowedRedirect, platformBaseDomain } from "./redirects";
17
+
18
+ export interface StartImpersonationInput {
19
+ targetEmail: string;
20
+ targetName?: string;
21
+ targetImage?: string;
22
+ reason: string;
23
+ redirectTo?: string;
24
+ returnTo?: string;
25
+ landingUrl?: string;
26
+ }
15
27
 
16
28
  interface ImpersonationContextValue {
17
29
  active: ImpersonationActive | null;
18
30
  starting: boolean;
19
31
  exiting: boolean;
20
- start: (input: {
21
- targetEmail: string;
22
- targetName?: string;
23
- targetImage?: string;
24
- reason: string;
25
- redirectTo?: string;
26
- returnTo?: string;
27
- landingUrl?: string;
28
- handOffOnly?: boolean;
29
- }) => Promise<{ ok: boolean; error?: string }>;
32
+ start: (
33
+ input: StartImpersonationInput
34
+ ) => Promise<{ ok: boolean; error?: string }>;
30
35
  exit: () => Promise<void>;
31
36
  }
32
37
 
@@ -34,27 +39,15 @@ const ImpersonationContext = createContext<ImpersonationContextValue | null>(
34
39
  null
35
40
  );
36
41
 
37
- /** Registrable base domain of the platform (last two labels of the API host). */
38
- function platformBaseDomain(): string {
39
- const api = process.env.NEXT_PUBLIC_PLATFORM_API_BASE_URL;
40
- if (!api) {
41
- throw new Error("NEXT_PUBLIC_PLATFORM_API_BASE_URL is not configured");
42
- }
43
- return new URL(api).hostname.split(".").slice(-2).join(".");
44
- }
45
-
46
- /** Guards the exit redirect against open-redirect: same-origin, or https on the platform base domain. */
47
- function isSafeReturnTo(returnTo: string): boolean {
48
- if (typeof window === "undefined") return false;
49
- const base = platformBaseDomain();
50
- try {
51
- const url = new URL(returnTo, window.location.origin);
52
- if (url.origin === window.location.origin) return true;
53
- if (url.protocol !== "https:") return false;
54
- return url.hostname === base || url.hostname.endsWith(`.${base}`);
55
- } catch {
56
- return false;
57
- }
42
+ function isSafeReturnTo(
43
+ returnTo: string,
44
+ apiBaseUrl: string | undefined
45
+ ): boolean {
46
+ if (typeof window === "undefined" || !apiBaseUrl) return false;
47
+ return isAllowedRedirect(returnTo, {
48
+ origin: window.location.origin,
49
+ platformDomain: platformBaseDomain(apiBaseUrl),
50
+ });
58
51
  }
59
52
 
60
53
  function readActiveCookie(): ImpersonationActive | null {
@@ -75,6 +68,7 @@ function readActiveCookie(): ImpersonationActive | null {
75
68
  }
76
69
 
77
70
  export function ImpersonationProvider({ children }: { children: ReactNode }) {
71
+ const apiBaseUrl = useOptionalConfig()?.NEXT_PUBLIC_PLATFORM_API_BASE_URL;
78
72
  const [active, setActive] = useState<ImpersonationActive | null>(null);
79
73
  const [starting, setStarting] = useState(false);
80
74
  const [exiting, setExiting] = useState(false);
@@ -87,47 +81,35 @@ export function ImpersonationProvider({ children }: { children: ReactNode }) {
87
81
 
88
82
  useSessionRenewal(active, rereadCookie);
89
83
 
90
- const start = useCallback(
91
- async (input: {
92
- targetEmail: string;
93
- targetName?: string;
94
- targetImage?: string;
95
- reason: string;
96
- redirectTo?: string;
97
- returnTo?: string;
98
- landingUrl?: string;
99
- handOffOnly?: boolean;
100
- }) => {
101
- setStarting(true);
102
- try {
103
- const { redirectTo, landingUrl } = input;
104
- const res = await fetch("/api/impersonation/start", {
105
- method: "POST",
106
- headers: { "Content-Type": "application/json" },
107
- credentials: "include",
108
- body: JSON.stringify(input),
109
- });
110
- if (!res.ok) {
111
- return {
112
- ok: false,
113
- error: (await res.text()) || `HTTP ${res.status}`,
114
- };
115
- }
116
- clearAuthTokenCache();
117
- const data = (await res.json().catch(() => ({}))) as {
118
- adoptUrl?: string;
84
+ const start = useCallback(async (input: StartImpersonationInput) => {
85
+ setStarting(true);
86
+ try {
87
+ const { redirectTo, landingUrl } = input;
88
+ const res = await fetch("/api/impersonation/start", {
89
+ method: "POST",
90
+ headers: { "Content-Type": "application/json" },
91
+ credentials: "include",
92
+ body: JSON.stringify(input),
93
+ });
94
+ if (!res.ok) {
95
+ return {
96
+ ok: false,
97
+ error: (await res.text()) || `HTTP ${res.status}`,
119
98
  };
120
- if (data.adoptUrl) window.location.assign(data.adoptUrl);
121
- else if (landingUrl) window.location.assign(landingUrl);
122
- else if (redirectTo) window.location.assign(redirectTo);
123
- else window.location.reload();
124
- return { ok: true };
125
- } finally {
126
- setStarting(false);
127
99
  }
128
- },
129
- []
130
- );
100
+ clearAuthTokenCache();
101
+ const data = (await res.json().catch(() => ({}))) as {
102
+ adoptUrl?: string;
103
+ };
104
+ if (data.adoptUrl) window.location.assign(data.adoptUrl);
105
+ else if (landingUrl) window.location.assign(landingUrl);
106
+ else if (redirectTo) window.location.assign(redirectTo);
107
+ else window.location.reload();
108
+ return { ok: true };
109
+ } finally {
110
+ setStarting(false);
111
+ }
112
+ }, []);
131
113
 
132
114
  const exit = useCallback(async () => {
133
115
  setExiting(true);
@@ -139,7 +121,7 @@ export function ImpersonationProvider({ children }: { children: ReactNode }) {
139
121
  clearAuthTokenCache();
140
122
  const returnTo = active?.returnTo;
141
123
  setActive(null);
142
- if (returnTo && isSafeReturnTo(returnTo)) {
124
+ if (returnTo && isSafeReturnTo(returnTo, apiBaseUrl)) {
143
125
  window.location.assign(returnTo);
144
126
  } else {
145
127
  window.location.reload();
@@ -147,7 +129,7 @@ export function ImpersonationProvider({ children }: { children: ReactNode }) {
147
129
  } finally {
148
130
  setExiting(false);
149
131
  }
150
- }, [active]);
132
+ }, [active, apiBaseUrl]);
151
133
 
152
134
  const value = useMemo<ImpersonationContextValue>(
153
135
  () => ({ active, starting, exiting, start, exit }),
@@ -164,7 +146,6 @@ export function ImpersonationProvider({ children }: { children: ReactNode }) {
164
146
  export function useImpersonation(): ImpersonationContextValue {
165
147
  const context = useContext(ImpersonationContext);
166
148
  if (!context) {
167
- // Safe no-op default so the shared banner can mount in apps that haven't wired the provider yet.
168
149
  return {
169
150
  active: null,
170
151
  starting: false,
@@ -1,8 +1,6 @@
1
1
  import { resolveCookieDomain } from "../session";
2
2
 
3
- /** HttpOnly — carries the impersonation bearer; only the token handler ever reads it. */
4
3
  export const IMP_SESSION_COOKIE = "imp_session";
5
- /** Readable — non-sensitive session facts that drive the banner + provider (no token). */
6
4
  export const IMP_ACTIVE_COOKIE = "imp_active";
7
5
 
8
6
  export interface ImpersonationSession {
@@ -89,7 +87,6 @@ function serialize(
89
87
  return parts.join("; ");
90
88
  }
91
89
 
92
- /** Set-Cookie values that open a session (server-side, on mint success). */
93
90
  export function buildImpersonationCookies(
94
91
  minted: MintedToken,
95
92
  host: string | null
@@ -123,7 +120,6 @@ export function buildImpersonationCookies(
123
120
  ];
124
121
  }
125
122
 
126
- /** Set-Cookie values that clear the session (exit or after posting the end record). */
127
123
  export function clearImpersonationCookies(host: string | null): string[] {
128
124
  return [
129
125
  serialize(IMP_SESSION_COOKIE, "", { host, httpOnly: true, maxAge: 0 }),
@@ -13,35 +13,37 @@ import {
13
13
  platformNotConfigured,
14
14
  requestHost,
15
15
  } from "./request";
16
+ import { isAllowedRedirect, platformBaseDomain } from "./redirects";
17
+
18
+ interface StartBody {
19
+ targetEmail?: string;
20
+ targetName?: string;
21
+ targetImage?: string;
22
+ reason?: string;
23
+ redirectTo?: string;
24
+ returnTo?: string;
25
+ landingUrl?: string;
26
+ }
16
27
 
17
28
  function registrable(host: string): string {
18
29
  return host.split(":")[0].toLowerCase().split(".").slice(-2).join(".");
19
30
  }
20
31
 
21
- function platformBaseDomain(): string {
22
- const api = process.env.NEXT_PUBLIC_PLATFORM_API_BASE_URL;
23
- if (!api) {
24
- throw new Error("NEXT_PUBLIC_PLATFORM_API_BASE_URL is not configured");
25
- }
26
- return new URL(api).hostname.split(".").slice(-2).join(".");
32
+ function resolveLanding(returnTo: string, origin: string): string {
33
+ const allowed = isAllowedRedirect(returnTo, {
34
+ origin,
35
+ platformDomain: platformBaseDomain(
36
+ process.env.NEXT_PUBLIC_PLATFORM_API_BASE_URL
37
+ ),
38
+ });
39
+ return allowed
40
+ ? new URL(returnTo, origin).toString()
41
+ : new URL("/", origin).toString();
27
42
  }
28
43
 
29
- // The adopt sets the tenant-domain cookie, then hands back to a same-origin path OR the platform
30
- // hub (/manage on snosky.no) — anything else falls back to "/" so it can never open-redirect.
31
- function resolveLanding(returnTo: string, origin: string): string {
32
- const base = platformBaseDomain();
33
- try {
34
- const url = new URL(returnTo, origin);
35
- if (url.origin === origin) return url.toString();
36
- if (
37
- url.protocol === "https:" &&
38
- (url.hostname === base || url.hostname.endsWith(`.${base}`))
39
- )
40
- return url.toString();
41
- } catch {
42
- // Fall through to the safe default.
43
- }
44
- return new URL("/", origin).toString();
44
+ function withCookies(response: Response, cookies: string[]): Response {
45
+ for (const cookie of cookies) response.headers.append("Set-Cookie", cookie);
46
+ return response;
45
47
  }
46
48
 
47
49
  function cookieScope(host: string): string {
@@ -62,11 +64,6 @@ function handsOffToTarget(request: Request, target: string): boolean {
62
64
  }
63
65
  }
64
66
 
65
- /**
66
- * Starts a view-as-employee session: authenticates as the admin, calls the platform mint endpoint
67
- * (which enforces the platform.impersonate gate + required reason + audit server-side), and, on
68
- * success, sets the impersonation cookies so subsequent product calls ride the minted token.
69
- */
70
67
  export function createImpersonationStartHandler(authConfig: Auth.Config) {
71
68
  return async function POST(request: Request) {
72
69
  const caller = await resolveAccessToken(authConfig, request);
@@ -75,16 +72,7 @@ export function createImpersonationStartHandler(authConfig: Auth.Config) {
75
72
  const client = platformClient(caller.accessToken);
76
73
  if (!client) return platformNotConfigured();
77
74
 
78
- let body: {
79
- targetEmail?: string;
80
- targetName?: string;
81
- targetImage?: string;
82
- reason?: string;
83
- redirectTo?: string;
84
- returnTo?: string;
85
- landingUrl?: string;
86
- handOffOnly?: boolean;
87
- };
75
+ let body: StartBody;
88
76
  try {
89
77
  body = await request.json();
90
78
  } catch {
@@ -118,46 +106,33 @@ export function createImpersonationStartHandler(authConfig: Auth.Config) {
118
106
  tokenId: result.tokenId,
119
107
  expiresOn: result.expiresOn,
120
108
  };
121
-
122
- // Custom-domain tenant: hand the session to the tenant domain's adopt route (login's pattern).
123
- if (body.redirectTo && handsOffToTarget(request, body.redirectTo)) {
124
- const target = new URL(body.redirectTo);
125
- const ticket = sealImpersonationTicket(
126
- { ...result, business: target.host, returnTo: body.returnTo },
127
- authConfig.nextAuthSecret
128
- );
129
- const adopt = new URL("/api/impersonation/adopt", target.origin);
130
- adopt.searchParams.set("t", ticket);
131
- adopt.searchParams.set(
132
- "returnTo",
133
- body.landingUrl ?? target.pathname + target.search
134
- );
135
- const crossDomain = Response.json({
136
- ...summary,
137
- adoptUrl: adopt.toString(),
138
- });
139
- if (body.handOffOnly) return crossDomain;
140
- for (const cookie of buildImpersonationCookies(
141
- { ...result, returnTo: body.returnTo },
142
- requestHost(request)
143
- )) {
144
- crossDomain.headers.append("Set-Cookie", cookie);
145
- }
146
- return crossDomain;
147
- }
148
-
149
- const out = Response.json(summary);
150
- for (const cookie of buildImpersonationCookies(
109
+ const cookies = buildImpersonationCookies(
151
110
  { ...result, returnTo: body.returnTo },
152
111
  requestHost(request)
153
- )) {
154
- out.headers.append("Set-Cookie", cookie);
112
+ );
113
+
114
+ if (!body.redirectTo || !handsOffToTarget(request, body.redirectTo)) {
115
+ return withCookies(Response.json(summary), cookies);
155
116
  }
156
- return out;
117
+
118
+ const target = new URL(body.redirectTo);
119
+ const ticket = sealImpersonationTicket(
120
+ { ...result, business: target.host, returnTo: body.returnTo },
121
+ authConfig.nextAuthSecret
122
+ );
123
+ const adopt = new URL("/api/impersonation/adopt", target.origin);
124
+ adopt.searchParams.set("t", ticket);
125
+ adopt.searchParams.set(
126
+ "returnTo",
127
+ body.landingUrl ?? target.pathname + target.search
128
+ );
129
+ return withCookies(
130
+ Response.json({ ...summary, adoptUrl: adopt.toString() }),
131
+ cookies
132
+ );
157
133
  };
158
134
  }
159
135
 
160
- // Runs on the tenant domain: opens the sealed ticket and sets the impersonation cookie here.
161
136
  export function createImpersonationAdoptHandler(authConfig: Auth.Config) {
162
137
  return async function GET(request: Request) {
163
138
  const url = new URL(request.url);
@@ -185,21 +160,13 @@ export function createImpersonationAdoptHandler(authConfig: Auth.Config) {
185
160
  url.searchParams.get("returnTo") || "/",
186
161
  origin
187
162
  );
188
- const out = new Response(null, {
189
- status: 302,
190
- headers: { Location: dest },
191
- });
192
- for (const cookie of buildImpersonationCookies(claims, host)) {
193
- out.headers.append("Set-Cookie", cookie);
194
- }
195
- return out;
163
+ return withCookies(
164
+ new Response(null, { status: 302, headers: { Location: dest } }),
165
+ buildImpersonationCookies(claims, host)
166
+ );
196
167
  };
197
168
  }
198
169
 
199
- /**
200
- * Ends a session: writes the append-only session-end audit record (best effort) and clears the
201
- * cookies so the admin's own token is restored. Token expiry auto-ends too.
202
- */
203
170
  export function createImpersonationEndHandler(authConfig: Auth.Config) {
204
171
  return async function POST(request: Request) {
205
172
  const session = readImpersonationSession(request);
@@ -213,10 +180,9 @@ export function createImpersonationEndHandler(authConfig: Auth.Config) {
213
180
  .catch(() => undefined);
214
181
  }
215
182
 
216
- const out = Response.json({ ok: true });
217
- for (const cookie of clearImpersonationCookies(requestHost(request))) {
218
- out.headers.append("Set-Cookie", cookie);
219
- }
220
- return out;
183
+ return withCookies(
184
+ Response.json({ ok: true }),
185
+ clearImpersonationCookies(requestHost(request))
186
+ );
221
187
  };
222
188
  }
@@ -0,0 +1,47 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { isAllowedRedirect } from "./redirects.ts";
4
+
5
+ const rules = {
6
+ origin: "https://www.test.snosky.no",
7
+ platformDomain: "snosky.no",
8
+ extraOrigins: ["https://minside.test.snokam.no"],
9
+ };
10
+
11
+ describe("redirect allow-list", () => {
12
+ it("allows the same origin, relative or absolute", () => {
13
+ assert.equal(isAllowedRedirect("/manage", rules), true);
14
+ assert.equal(
15
+ isAllowedRedirect("https://www.test.snosky.no/x", rules),
16
+ true
17
+ );
18
+ });
19
+
20
+ it("allows https on the platform domain and its subdomains", () => {
21
+ assert.equal(isAllowedRedirect("https://snosky.no/", rules), true);
22
+ assert.equal(
23
+ isAllowedRedirect("https://snokam.test.snosky.no/minside", rules),
24
+ true
25
+ );
26
+ });
27
+
28
+ it("allows the extra origins it is given", () => {
29
+ assert.equal(
30
+ isAllowedRedirect("https://minside.test.snokam.no/okonomi", rules),
31
+ true
32
+ );
33
+ });
34
+
35
+ it("refuses everything else", () => {
36
+ assert.equal(isAllowedRedirect("https://evil.example/", rules), false);
37
+ assert.equal(
38
+ isAllowedRedirect("http://snokam.test.snosky.no/", rules),
39
+ false
40
+ );
41
+ assert.equal(
42
+ isAllowedRedirect("https://snosky.no.evil.example/", rules),
43
+ false
44
+ );
45
+ assert.equal(isAllowedRedirect("javascript:alert(1)", rules), false);
46
+ });
47
+ });
@@ -0,0 +1,30 @@
1
+ export function platformBaseDomain(apiBaseUrl: string | undefined): string {
2
+ if (!apiBaseUrl) {
3
+ throw new Error("NEXT_PUBLIC_PLATFORM_API_BASE_URL is not configured");
4
+ }
5
+ return new URL(apiBaseUrl).hostname.split(".").slice(-2).join(".");
6
+ }
7
+
8
+ interface RedirectRules {
9
+ origin: string;
10
+ platformDomain: string;
11
+ extraOrigins?: string[];
12
+ }
13
+
14
+ export function isAllowedRedirect(
15
+ target: string,
16
+ { origin, platformDomain, extraOrigins = [] }: RedirectRules
17
+ ): boolean {
18
+ try {
19
+ const url = new URL(target, origin);
20
+ if (url.origin === origin) return true;
21
+ if (url.protocol !== "https:") return false;
22
+ return (
23
+ url.hostname === platformDomain ||
24
+ url.hostname.endsWith(`.${platformDomain}`) ||
25
+ extraOrigins.includes(url.origin)
26
+ );
27
+ } catch {
28
+ return false;
29
+ }
30
+ }
@@ -10,17 +10,20 @@ export function platformBase(): string | null {
10
10
  );
11
11
  }
12
12
 
13
- export function platformClient(accessToken: string): PlatformClient | null {
13
+ export function platformClientFor(
14
+ authorization: string
15
+ ): PlatformClient | null {
14
16
  const basePath = platformBase();
15
17
  if (!basePath) return null;
16
18
  return new PlatformClient(
17
- new Configuration({
18
- basePath,
19
- accessToken: async () => `Bearer ${accessToken}`,
20
- })
19
+ new Configuration({ basePath, accessToken: async () => authorization })
21
20
  );
22
21
  }
23
22
 
23
+ export function platformClient(accessToken: string): PlatformClient | null {
24
+ return platformClientFor(`Bearer ${accessToken}`);
25
+ }
26
+
24
27
  export function platformNotConfigured(): Response {
25
28
  return new Response("NEXT_PUBLIC_PLATFORM_API_BASE_URL is not configured", {
26
29
  status: 500,
@@ -3,6 +3,7 @@ import { describe, it } from "node:test";
3
3
  import {
4
4
  impersonationPickerUrl,
5
5
  impersonationRequired,
6
+ isSignInPage,
6
7
  renewDelayMs,
7
8
  } from "./required.ts";
8
9
 
@@ -32,6 +33,14 @@ describe("impersonation required", () => {
32
33
  );
33
34
  });
34
35
 
36
+ it("never sends a sign-in page to the picker, at the root or under a base path", () => {
37
+ assert.equal(isSignInPage("/auth/complete"), true);
38
+ assert.equal(isSignInPage("/minside/auth/login"), true);
39
+ assert.equal(isSignInPage("/"), false);
40
+ assert.equal(isSignInPage("/okonomi"), false);
41
+ assert.equal(isSignInPage("/authors"), false);
42
+ });
43
+
35
44
  it("renews two minutes before the token expires, and at once when already late", () => {
36
45
  assert.equal(renewDelayMs(1_000, 0), 880_000);
37
46
  assert.equal(renewDelayMs(1_000, 999_000), 0);
@@ -12,6 +12,10 @@ export function impersonationRequired(
12
12
  return environment === TEST_ENVIRONMENT;
13
13
  }
14
14
 
15
+ export function isSignInPage(pathname: string): boolean {
16
+ return /(^|\/)auth\//.test(pathname);
17
+ }
18
+
15
19
  export function impersonationPickerUrl(
16
20
  platformBaseUrl: string,
17
21
  returnTo: string