@snokam/auth 3.6.0 → 3.7.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.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,16 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ ## [3.7.1](https://github.com/snokam/components/compare/@snokam/auth@3.7.0...@snokam/auth@3.7.1) (2026-10-06)
7
+
8
+ **Note:** Version bump only for package @snokam/auth
9
+
10
+ # [3.7.0](https://github.com/snokam/components/compare/@snokam/auth@3.6.0...@snokam/auth@3.7.0) (2026-10-06)
11
+
12
+ ### Features
13
+
14
+ - **test:** every test page needs a login; pick a persona only when a page needs one ([#2705](https://github.com/snokam/components/issues/2705)) ([e086fb6](https://github.com/snokam/components/commit/e086fb63511e0f3f4f2002d0189e089c8cda6d30))
15
+
6
16
  # [3.6.0](https://github.com/snokam/components/compare/@snokam/auth@3.5.0...@snokam/auth@3.6.0) (2026-10-06)
7
17
 
8
18
  ### 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.6.0",
4
+ "version": "3.7.1",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
7
7
  "files": [
@@ -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,
@@ -79,13 +79,13 @@ async function ownSigner(
79
79
  return "error" in access ? null : { email, accessToken: access.accessToken };
80
80
  }
81
81
 
82
- function signerFor(
82
+ async function signerFor(
83
83
  authConfig: Auth.Config,
84
84
  request: Request,
85
85
  email: string
86
86
  ): Promise<Signer | null> {
87
87
  if (impersonationRequired(authConfig.environment)) {
88
- return Promise.resolve(personaSigner(request, email));
88
+ return personaSigner(request, email);
89
89
  }
90
90
  return ownSigner(authConfig, request, email);
91
91
  }
@@ -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
@@ -3,11 +3,25 @@ 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
+ import {
7
+ IMPERSONATION_REQUIRED_ERROR,
8
+ impersonationPickerUrl,
9
+ } from "./impersonation/required";
7
10
 
8
11
  class ConsentRequiredError extends Error {}
9
12
 
10
- class ImpersonationRequiredError extends Error {}
13
+ class ImpersonationRequiredError extends Error {
14
+ constructor(readonly platformUrl: string | undefined) {
15
+ super(IMPERSONATION_REQUIRED_ERROR);
16
+ }
17
+ }
18
+
19
+ function sendToPicker(error: ImpersonationRequiredError): void {
20
+ if (!error.platformUrl) return;
21
+ window.location.assign(
22
+ impersonationPickerUrl(error.platformUrl, window.location.href)
23
+ );
24
+ }
11
25
 
12
26
  const REFRESH_MARGIN_SECONDS = 60;
13
27
 
@@ -20,11 +34,6 @@ interface CachedToken {
20
34
  let cachedToken: CachedToken | null = null;
21
35
  let inFlight: { impKey: string; promise: Promise<string> } | null = null;
22
36
 
23
- /**
24
- * Identifies which principal the next token belongs to: the active impersonation
25
- * tokenId, or "" for the admin's own token. Changing this (Vis som start/exit)
26
- * invalidates the cache so the swap in the token route is never served stale.
27
- */
28
37
  function readImpersonationKey(): string {
29
38
  if (typeof document === "undefined") return "";
30
39
  const match = document.cookie
@@ -66,7 +75,7 @@ async function fetchToken(impKey: string): Promise<string> {
66
75
  throw new ConsentRequiredError();
67
76
  }
68
77
  if (body?.error === IMPERSONATION_REQUIRED_ERROR) {
69
- throw new ImpersonationRequiredError();
78
+ throw new ImpersonationRequiredError(body.platformUrl);
70
79
  }
71
80
  }
72
81
 
@@ -134,7 +143,10 @@ export function useAuth() {
134
143
  session,
135
144
  getToken: () =>
136
145
  getFreshToken().catch((error) => {
137
- if (error instanceof ImpersonationRequiredError) throw error;
146
+ if (error instanceof ImpersonationRequiredError) {
147
+ sendToPicker(error);
148
+ throw error;
149
+ }
138
150
  console.error(error);
139
151
  const onAuthPage = window.location.pathname.startsWith("/auth/");
140
152
  if (onAuthPage) return;
@@ -12,22 +12,25 @@ 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 { RequiredImpersonationGate } from "./RequiredImpersonationGate";
15
+ import { isAllowedRedirect, platformBaseDomain } from "./redirects";
16
+
17
+ export interface StartImpersonationInput {
18
+ targetEmail: string;
19
+ targetName?: string;
20
+ targetImage?: string;
21
+ reason: string;
22
+ redirectTo?: string;
23
+ returnTo?: string;
24
+ landingUrl?: string;
25
+ }
16
26
 
17
27
  interface ImpersonationContextValue {
18
28
  active: ImpersonationActive | null;
19
29
  starting: boolean;
20
30
  exiting: boolean;
21
- start: (input: {
22
- targetEmail: string;
23
- targetName?: string;
24
- targetImage?: string;
25
- reason: string;
26
- redirectTo?: string;
27
- returnTo?: string;
28
- landingUrl?: string;
29
- handOffOnly?: boolean;
30
- }) => Promise<{ ok: boolean; error?: string }>;
31
+ start: (
32
+ input: StartImpersonationInput
33
+ ) => Promise<{ ok: boolean; error?: string }>;
31
34
  exit: () => Promise<void>;
32
35
  }
33
36
 
@@ -35,27 +38,12 @@ const ImpersonationContext = createContext<ImpersonationContextValue | null>(
35
38
  null
36
39
  );
37
40
 
38
- /** Registrable base domain of the platform (last two labels of the API host). */
39
- function platformBaseDomain(): string {
40
- const api = process.env.NEXT_PUBLIC_PLATFORM_API_BASE_URL;
41
- if (!api) {
42
- throw new Error("NEXT_PUBLIC_PLATFORM_API_BASE_URL is not configured");
43
- }
44
- return new URL(api).hostname.split(".").slice(-2).join(".");
45
- }
46
-
47
- /** Guards the exit redirect against open-redirect: same-origin, or https on the platform base domain. */
48
41
  function isSafeReturnTo(returnTo: string): boolean {
49
42
  if (typeof window === "undefined") return false;
50
- const base = platformBaseDomain();
51
- try {
52
- const url = new URL(returnTo, window.location.origin);
53
- if (url.origin === window.location.origin) return true;
54
- if (url.protocol !== "https:") return false;
55
- return url.hostname === base || url.hostname.endsWith(`.${base}`);
56
- } catch {
57
- return false;
58
- }
43
+ return isAllowedRedirect(returnTo, {
44
+ origin: window.location.origin,
45
+ platformDomain: platformBaseDomain(),
46
+ });
59
47
  }
60
48
 
61
49
  function readActiveCookie(): ImpersonationActive | null {
@@ -77,7 +65,6 @@ function readActiveCookie(): ImpersonationActive | null {
77
65
 
78
66
  export function ImpersonationProvider({ children }: { children: ReactNode }) {
79
67
  const [active, setActive] = useState<ImpersonationActive | null>(null);
80
- const [cookieRead, setCookieRead] = useState(false);
81
68
  const [starting, setStarting] = useState(false);
82
69
  const [exiting, setExiting] = useState(false);
83
70
 
@@ -85,52 +72,39 @@ export function ImpersonationProvider({ children }: { children: ReactNode }) {
85
72
 
86
73
  useEffect(() => {
87
74
  rereadCookie();
88
- setCookieRead(true);
89
75
  }, [rereadCookie]);
90
76
 
91
77
  useSessionRenewal(active, rereadCookie);
92
78
 
93
- const start = useCallback(
94
- async (input: {
95
- targetEmail: string;
96
- targetName?: string;
97
- targetImage?: string;
98
- reason: string;
99
- redirectTo?: string;
100
- returnTo?: string;
101
- landingUrl?: string;
102
- handOffOnly?: boolean;
103
- }) => {
104
- setStarting(true);
105
- try {
106
- const { redirectTo, landingUrl } = input;
107
- const res = await fetch("/api/impersonation/start", {
108
- method: "POST",
109
- headers: { "Content-Type": "application/json" },
110
- credentials: "include",
111
- body: JSON.stringify(input),
112
- });
113
- if (!res.ok) {
114
- return {
115
- ok: false,
116
- error: (await res.text()) || `HTTP ${res.status}`,
117
- };
118
- }
119
- clearAuthTokenCache();
120
- const data = (await res.json().catch(() => ({}))) as {
121
- adoptUrl?: string;
79
+ const start = useCallback(async (input: StartImpersonationInput) => {
80
+ setStarting(true);
81
+ try {
82
+ const { redirectTo, landingUrl } = input;
83
+ const res = await fetch("/api/impersonation/start", {
84
+ method: "POST",
85
+ headers: { "Content-Type": "application/json" },
86
+ credentials: "include",
87
+ body: JSON.stringify(input),
88
+ });
89
+ if (!res.ok) {
90
+ return {
91
+ ok: false,
92
+ error: (await res.text()) || `HTTP ${res.status}`,
122
93
  };
123
- if (data.adoptUrl) window.location.assign(data.adoptUrl);
124
- else if (landingUrl) window.location.assign(landingUrl);
125
- else if (redirectTo) window.location.assign(redirectTo);
126
- else window.location.reload();
127
- return { ok: true };
128
- } finally {
129
- setStarting(false);
130
94
  }
131
- },
132
- []
133
- );
95
+ clearAuthTokenCache();
96
+ const data = (await res.json().catch(() => ({}))) as {
97
+ adoptUrl?: string;
98
+ };
99
+ if (data.adoptUrl) window.location.assign(data.adoptUrl);
100
+ else if (landingUrl) window.location.assign(landingUrl);
101
+ else if (redirectTo) window.location.assign(redirectTo);
102
+ else window.location.reload();
103
+ return { ok: true };
104
+ } finally {
105
+ setStarting(false);
106
+ }
107
+ }, []);
134
108
 
135
109
  const exit = useCallback(async () => {
136
110
  setExiting(true);
@@ -159,12 +133,7 @@ export function ImpersonationProvider({ children }: { children: ReactNode }) {
159
133
 
160
134
  return (
161
135
  <ImpersonationContext.Provider value={value}>
162
- <RequiredImpersonationGate
163
- sessionActive={!!active}
164
- cookieRead={cookieRead}
165
- >
166
- {children}
167
- </RequiredImpersonationGate>
136
+ {children}
168
137
  </ImpersonationContext.Provider>
169
138
  );
170
139
  }
@@ -172,7 +141,6 @@ export function ImpersonationProvider({ children }: { children: ReactNode }) {
172
141
  export function useImpersonation(): ImpersonationContextValue {
173
142
  const context = useContext(ImpersonationContext);
174
143
  if (!context) {
175
- // Safe no-op default so the shared banner can mount in apps that haven't wired the provider yet.
176
144
  return {
177
145
  active: null,
178
146
  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,35 @@ 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
+ });
37
+ return allowed
38
+ ? new URL(returnTo, origin).toString()
39
+ : new URL("/", origin).toString();
27
40
  }
28
41
 
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();
42
+ function withCookies(response: Response, cookies: string[]): Response {
43
+ for (const cookie of cookies) response.headers.append("Set-Cookie", cookie);
44
+ return response;
45
45
  }
46
46
 
47
47
  function cookieScope(host: string): string {
@@ -62,11 +62,6 @@ function handsOffToTarget(request: Request, target: string): boolean {
62
62
  }
63
63
  }
64
64
 
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
65
  export function createImpersonationStartHandler(authConfig: Auth.Config) {
71
66
  return async function POST(request: Request) {
72
67
  const caller = await resolveAccessToken(authConfig, request);
@@ -75,16 +70,7 @@ export function createImpersonationStartHandler(authConfig: Auth.Config) {
75
70
  const client = platformClient(caller.accessToken);
76
71
  if (!client) return platformNotConfigured();
77
72
 
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
- };
73
+ let body: StartBody;
88
74
  try {
89
75
  body = await request.json();
90
76
  } catch {
@@ -118,46 +104,33 @@ export function createImpersonationStartHandler(authConfig: Auth.Config) {
118
104
  tokenId: result.tokenId,
119
105
  expiresOn: result.expiresOn,
120
106
  };
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(
107
+ const cookies = buildImpersonationCookies(
151
108
  { ...result, returnTo: body.returnTo },
152
109
  requestHost(request)
153
- )) {
154
- out.headers.append("Set-Cookie", cookie);
110
+ );
111
+
112
+ if (!body.redirectTo || !handsOffToTarget(request, body.redirectTo)) {
113
+ return withCookies(Response.json(summary), cookies);
155
114
  }
156
- return out;
115
+
116
+ const target = new URL(body.redirectTo);
117
+ const ticket = sealImpersonationTicket(
118
+ { ...result, business: target.host, returnTo: body.returnTo },
119
+ authConfig.nextAuthSecret
120
+ );
121
+ const adopt = new URL("/api/impersonation/adopt", target.origin);
122
+ adopt.searchParams.set("t", ticket);
123
+ adopt.searchParams.set(
124
+ "returnTo",
125
+ body.landingUrl ?? target.pathname + target.search
126
+ );
127
+ return withCookies(
128
+ Response.json({ ...summary, adoptUrl: adopt.toString() }),
129
+ cookies
130
+ );
157
131
  };
158
132
  }
159
133
 
160
- // Runs on the tenant domain: opens the sealed ticket and sets the impersonation cookie here.
161
134
  export function createImpersonationAdoptHandler(authConfig: Auth.Config) {
162
135
  return async function GET(request: Request) {
163
136
  const url = new URL(request.url);
@@ -185,21 +158,13 @@ export function createImpersonationAdoptHandler(authConfig: Auth.Config) {
185
158
  url.searchParams.get("returnTo") || "/",
186
159
  origin
187
160
  );
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;
161
+ return withCookies(
162
+ new Response(null, { status: 302, headers: { Location: dest } }),
163
+ buildImpersonationCookies(claims, host)
164
+ );
196
165
  };
197
166
  }
198
167
 
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
168
  export function createImpersonationEndHandler(authConfig: Auth.Config) {
204
169
  return async function POST(request: Request) {
205
170
  const session = readImpersonationSession(request);
@@ -213,10 +178,9 @@ export function createImpersonationEndHandler(authConfig: Auth.Config) {
213
178
  .catch(() => undefined);
214
179
  }
215
180
 
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;
181
+ return withCookies(
182
+ Response.json({ ok: true }),
183
+ clearImpersonationCookies(requestHost(request))
184
+ );
221
185
  };
222
186
  }
@@ -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,31 @@
1
+ export function platformBaseDomain(): string {
2
+ const api = process.env.NEXT_PUBLIC_PLATFORM_API_BASE_URL;
3
+ if (!api) {
4
+ throw new Error("NEXT_PUBLIC_PLATFORM_API_BASE_URL is not configured");
5
+ }
6
+ return new URL(api).hostname.split(".").slice(-2).join(".");
7
+ }
8
+
9
+ interface RedirectRules {
10
+ origin: string;
11
+ platformDomain: string;
12
+ extraOrigins?: string[];
13
+ }
14
+
15
+ export function isAllowedRedirect(
16
+ target: string,
17
+ { origin, platformDomain, extraOrigins = [] }: RedirectRules
18
+ ): boolean {
19
+ try {
20
+ const url = new URL(target, origin);
21
+ if (url.origin === origin) return true;
22
+ if (url.protocol !== "https:") return false;
23
+ return (
24
+ url.hostname === platformDomain ||
25
+ url.hostname.endsWith(`.${platformDomain}`) ||
26
+ extraOrigins.includes(url.origin)
27
+ );
28
+ } catch {
29
+ return false;
30
+ }
31
+ }
@@ -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,20 +3,10 @@ import { describe, it } from "node:test";
3
3
  import {
4
4
  impersonationPickerUrl,
5
5
  impersonationRequired,
6
- isExemptRoute,
7
6
  renewDelayMs,
8
- sendsToPicker,
9
- withholdsApp,
10
7
  } from "./required.ts";
11
8
 
12
- const signedInAlone = {
13
- authenticated: true,
14
- sessionActive: false,
15
- cookieRead: true,
16
- exemptRoute: false,
17
- };
18
-
19
- describe("impersonation required gate", () => {
9
+ describe("impersonation required", () => {
20
10
  it("is off in production and everywhere that is not the test environment", () => {
21
11
  for (const environment of ["p", "test", "production", "", undefined]) {
22
12
  assert.equal(impersonationRequired(environment), false);
@@ -27,58 +17,21 @@ describe("impersonation required gate", () => {
27
17
  assert.equal(impersonationRequired("t"), true);
28
18
  });
29
19
 
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
20
  it("points the picker back at the page the person asked for", () => {
61
21
  const url = new URL(
62
22
  impersonationPickerUrl(
63
23
  "https://www.test.snosky.no",
64
- "https://snokam.test.snosky.no/minside/okonomi?x=1"
24
+ "https://minside.test.snokam.no/okonomi?x=1"
65
25
  )
66
26
  );
27
+ assert.equal(url.origin, "https://www.test.snosky.no");
67
28
  assert.equal(url.pathname, "/velg-bruker");
68
29
  assert.equal(
69
30
  url.searchParams.get("returnTo"),
70
- "https://snokam.test.snosky.no/minside/okonomi?x=1"
31
+ "https://minside.test.snokam.no/okonomi?x=1"
71
32
  );
72
33
  });
73
34
 
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
35
  it("renews two minutes before the token expires, and at once when already late", () => {
83
36
  assert.equal(renewDelayMs(1_000, 0), 880_000);
84
37
  assert.equal(renewDelayMs(1_000, 999_000), 0);
@@ -12,35 +12,6 @@ export function impersonationRequired(
12
12
  return environment === TEST_ENVIRONMENT;
13
13
  }
14
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
15
  export function impersonationPickerUrl(
45
16
  platformBaseUrl: string,
46
17
  returnTo: string
@@ -6,6 +6,15 @@ import { Auth } from "./types";
6
6
  import { getSessionCookieSettings, getAuthUrl } from "./session";
7
7
  import { addCorsHeaders } from "./cors";
8
8
 
9
+ const TEST_ENVIRONMENT = "t";
10
+
11
+ const LOGIN_FLOW_PATHS = [
12
+ "/auth/",
13
+ "/handoff/",
14
+ "/api/password/",
15
+ "/api/impersonation/adopt",
16
+ ];
17
+
9
18
  export function buildAuthMiddleware({
10
19
  matcher = [],
11
20
  callbacks = {},
@@ -13,7 +22,9 @@ export function buildAuthMiddleware({
13
22
  cors = {},
14
23
  ...rest
15
24
  }: Auth.Middleware.Props = {}): Auth.Middleware.Builder {
16
- const requireLogin = process.env.IS_PR === "true";
25
+ const requireLogin =
26
+ process.env.IS_PR === "true" ||
27
+ process.env.ENVIRONMENT === TEST_ENVIRONMENT;
17
28
  const { name } = getSessionCookieSettings(getAuthUrl());
18
29
 
19
30
  const shouldProtectPath = (pathname: string): boolean => {
@@ -25,7 +36,7 @@ export function buildAuthMiddleware({
25
36
  return false;
26
37
  }
27
38
 
28
- if (pathname.startsWith("/auth/")) {
39
+ if (LOGIN_FLOW_PATHS.some((path) => pathname.startsWith(path))) {
29
40
  return false;
30
41
  }
31
42
 
@@ -116,7 +116,10 @@ export function createTokenHandler(authConfig: Auth.Config) {
116
116
 
117
117
  if (impersonationRequired(authConfig.environment)) {
118
118
  return Response.json(
119
- { error: IMPERSONATION_REQUIRED_ERROR },
119
+ {
120
+ error: IMPERSONATION_REQUIRED_ERROR,
121
+ platformUrl: process.env.NEXT_PUBLIC_PLATFORM_BASE_URL,
122
+ },
120
123
  { status: 403 }
121
124
  );
122
125
  }
@@ -1,51 +0,0 @@
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
- }