@snokam/auth 3.4.0 → 3.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,18 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ # [3.6.0](https://github.com/snokam/components/compare/@snokam/auth@3.5.0...@snokam/auth@3.6.0) (2026-10-06)
7
+
8
+ ### Features
9
+
10
+ - **snosky:** no prices, no terms page, unlinked /status in Snøkam look, better persona flow ([#2681](https://github.com/snokam/components/issues/2681)) ([b6dd5af](https://github.com/snokam/components/commit/b6dd5af68258c31f717888372fc7258d233c9e2e))
11
+
12
+ # [3.5.0](https://github.com/snokam/components/compare/@snokam/auth@3.4.0...@snokam/auth@3.5.0) (2026-10-06)
13
+
14
+ ### Features
15
+
16
+ - **test:** pick a test user straight after sign-in, on snosky.no and for MCP sign-in too ([#2680](https://github.com/snokam/components/issues/2680)) ([eea727a](https://github.com/snokam/components/commit/eea727ae1a607139c7c30610109e877eaa20dae9))
17
+
6
18
  # [3.4.0](https://github.com/snokam/components/compare/@snokam/auth@3.3.12...@snokam/auth@3.4.0) (2026-10-06)
7
19
 
8
20
  ### 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.4.0",
4
+ "version": "3.6.0",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
7
7
  "files": [
@@ -29,9 +29,9 @@
29
29
  "@snokam/identity": "1.1.0",
30
30
  "@snokam/platform-client": "3.6.0",
31
31
  "@snokam/react-query": "1.1.0",
32
+ "@snokam/sanity-page-content": "4.4.2",
32
33
  "@snokam/tenant": "2.1.13",
33
- "@snokam/ui": "4.0.0",
34
- "@snokam/sanity-page-content": "4.4.2"
34
+ "@snokam/ui": "4.0.0"
35
35
  },
36
36
  "peerDependencies": {
37
37
  "next": "16.3.3",
@@ -33,6 +33,7 @@ export {
33
33
  useImpersonation,
34
34
  } from "../impersonation/context";
35
35
  export type { ImpersonationActive } from "../impersonation/cookies";
36
+ export { impersonationRequired } from "../impersonation/required";
36
37
  export { signIn, SessionProvider } from "next-auth/react";
37
38
  export {
38
39
  Login,
@@ -6,6 +6,12 @@ import {
6
6
  requestOrigin,
7
7
  } from "../session";
8
8
  import { resolveAccessToken } from "../route-handler/token";
9
+ import { readImpersonationSession } from "../impersonation/cookies";
10
+ import { tokenClaims } from "../impersonation/claims";
11
+ import {
12
+ impersonationPickerUrl,
13
+ impersonationRequired,
14
+ } from "../impersonation/required";
9
15
  import type { Auth } from "../types";
10
16
  import {
11
17
  type AuthorizationParams,
@@ -45,6 +51,52 @@ async function signedInEmail(
45
51
  return token?.refreshToken && token.email ? String(token.email) : null;
46
52
  }
47
53
 
54
+ interface Signer {
55
+ email: string;
56
+ accessToken: string;
57
+ }
58
+
59
+ function sameEmail(a: string | undefined, b: string): boolean {
60
+ return !!a && a.toLowerCase() === b.toLowerCase();
61
+ }
62
+
63
+ function personaSigner(request: Request, operator: string): Signer | null {
64
+ const session = readImpersonationSession(request);
65
+ if (!session || session.expiresOn * 1000 <= Date.now()) return null;
66
+
67
+ const claims = tokenClaims(session.accessToken);
68
+ if (!claims.email || !sameEmail(claims.act, operator)) return null;
69
+
70
+ return { email: claims.email, accessToken: session.accessToken };
71
+ }
72
+
73
+ async function ownSigner(
74
+ authConfig: Auth.Config,
75
+ request: Request,
76
+ email: string
77
+ ): Promise<Signer | null> {
78
+ const access = await resolveAccessToken(authConfig, request);
79
+ return "error" in access ? null : { email, accessToken: access.accessToken };
80
+ }
81
+
82
+ function signerFor(
83
+ authConfig: Auth.Config,
84
+ request: Request,
85
+ email: string
86
+ ): Promise<Signer | null> {
87
+ if (impersonationRequired(authConfig.environment)) {
88
+ return Promise.resolve(personaSigner(request, email));
89
+ }
90
+ return ownSigner(authConfig, request, email);
91
+ }
92
+
93
+ function pickerRedirect(request: Request): Response {
94
+ const url = new URL(request.url);
95
+ const origin = requestOrigin(request);
96
+ const resume = `${origin}${url.pathname}${url.search}`;
97
+ return Response.redirect(impersonationPickerUrl(origin, resume), 302);
98
+ }
99
+
48
100
  function loginRedirect(request: Request, resource: string, prompt?: string) {
49
101
  const url = new URL(request.url);
50
102
  const resume = new URLSearchParams(url.searchParams);
@@ -105,13 +157,17 @@ export function createAppSignInAuthorizeHandler(authConfig: Auth.Config) {
105
157
  const params = readParams(query);
106
158
  if (!params) return invalidRequest();
107
159
 
108
- const email = await signedInEmail(request, authConfig.nextAuthSecret);
109
- if (!email) return loginRedirect(request, params.resource);
160
+ const signedIn = await signedInEmail(request, authConfig.nextAuthSecret);
161
+ if (!signedIn) return loginRedirect(request, params.resource);
110
162
 
111
- const access = await resolveAccessToken(authConfig, request);
112
- if ("error" in access) return loginRedirect(request, params.resource);
163
+ const signer = await signerFor(authConfig, request, signedIn);
164
+ if (!signer) {
165
+ return impersonationRequired(authConfig.environment)
166
+ ? pickerRedirect(request)
167
+ : loginRedirect(request, params.resource);
168
+ }
113
169
 
114
- const preview = await previewAuthorization(params, access.accessToken);
170
+ const preview = await previewAuthorization(params, signer.accessToken);
115
171
  if ("error" in preview) {
116
172
  return preview.error === ACCESS_DENIED
117
173
  ? loginRedirect(request, params.resource, "login")
@@ -122,10 +178,11 @@ export function createAppSignInAuthorizeHandler(authConfig: Auth.Config) {
122
178
  const ticket = sealConsent(
123
179
  {
124
180
  ...params,
125
- ...preview.value,
181
+ clientName: preview.value.clientName ?? "",
182
+ businessName: preview.value.businessName ?? "",
126
183
  business: new URL(params.resource).hostname,
127
184
  state: query.get("state") ?? "",
128
- email,
185
+ email: signer.email,
129
186
  nonce,
130
187
  },
131
188
  authConfig.nextAuthSecret
@@ -155,8 +212,11 @@ export function createAppSignInApproveHandler(authConfig: Auth.Config) {
155
212
  return invalidRequest();
156
213
  }
157
214
 
158
- const email = await signedInEmail(request, authConfig.nextAuthSecret);
159
- if (email !== consent.email) return invalidRequest();
215
+ const signedIn = await signedInEmail(request, authConfig.nextAuthSecret);
216
+ if (!signedIn) return invalidRequest();
217
+
218
+ const signer = await signerFor(authConfig, request, signedIn);
219
+ if (!signer || signer.email !== consent.email) return invalidRequest();
160
220
 
161
221
  if (form.get("decision") !== "approve") {
162
222
  return clientRedirect(request, consent.redirectUri, consent.state, {
@@ -164,17 +224,14 @@ export function createAppSignInApproveHandler(authConfig: Auth.Config) {
164
224
  });
165
225
  }
166
226
 
167
- const access = await resolveAccessToken(authConfig, request);
168
- if ("error" in access) return invalidRequest();
169
-
170
- const approved = await approveAuthorization(consent, access.accessToken);
227
+ const approved = await approveAuthorization(consent, signer.accessToken);
171
228
  return clientRedirect(
172
229
  request,
173
230
  consent.redirectUri,
174
231
  consent.state,
175
232
  "error" in approved
176
233
  ? { error: approved.error }
177
- : { code: approved.value.code }
234
+ : { code: approved.value.code ?? "" }
178
235
  );
179
236
  };
180
237
  }
@@ -1,11 +1,13 @@
1
1
  import { getManagedBearer } from "@snokam/identity";
2
+ import {
3
+ Configuration,
4
+ PlatformClient,
5
+ ResponseError,
6
+ type AuthorizationPreview,
7
+ type IssuedAuthorizationCode,
8
+ } from "@snokam/platform-client";
2
9
  import type { AuthorizationParams } from "./consent";
3
10
 
4
- export interface AuthorizationPreview {
5
- clientName: string;
6
- businessName: string;
7
- }
8
-
9
11
  type Outcome<T> = { value: T } | { error: string };
10
12
 
11
13
  const UNAVAILABLE = "temporarily_unavailable";
@@ -17,36 +19,45 @@ function apiBase(): string {
17
19
  );
18
20
  }
19
21
 
20
- async function post<T>(
21
- path: string,
22
- params: AuthorizationParams,
23
- userToken: string
22
+ async function asLoginSite<T>(
23
+ call: (client: PlatformClient) => Promise<T>
24
24
  ): Promise<Outcome<T>> {
25
25
  const loginSite = await getManagedBearer();
26
26
  if (!loginSite) return { error: UNAVAILABLE };
27
27
 
28
- const response = await fetch(`${apiBase()}${path}`, {
29
- method: "POST",
30
- headers: { "Content-Type": "application/json", Authorization: loginSite },
31
- body: JSON.stringify({ ...params, userToken }),
32
- cache: "no-store",
33
- });
34
- const body = await response.json().catch(() => ({}));
35
- return response.ok
36
- ? { value: body as T }
37
- : { error: String(body.error ?? "invalid_request") };
28
+ const client = new PlatformClient(
29
+ new Configuration({
30
+ basePath: apiBase(),
31
+ accessToken: async () => loginSite,
32
+ })
33
+ );
34
+ try {
35
+ return { value: await call(client) };
36
+ } catch (error) {
37
+ if (!(error instanceof ResponseError)) throw error;
38
+ const body = await error.response.json().catch(() => ({}));
39
+ return { error: String(body.error ?? "invalid_request") };
40
+ }
38
41
  }
39
42
 
40
43
  export function previewAuthorization(
41
44
  params: AuthorizationParams,
42
45
  userToken: string
43
46
  ): Promise<Outcome<AuthorizationPreview>> {
44
- return post("/v1.0/protected/oauth/authorization/preview", params, userToken);
47
+ return asLoginSite((client) =>
48
+ client.previewOAuthAuthorization({
49
+ authorizationRequest: { ...params, userToken },
50
+ })
51
+ );
45
52
  }
46
53
 
47
54
  export function approveAuthorization(
48
55
  params: AuthorizationParams,
49
56
  userToken: string
50
- ): Promise<Outcome<{ code: string }>> {
51
- return post("/v1.0/protected/oauth/authorization", params, userToken);
57
+ ): Promise<Outcome<IssuedAuthorizationCode>> {
58
+ return asLoginSite((client) =>
59
+ client.approveOAuthAuthorization({
60
+ authorizationRequest: { ...params, userToken },
61
+ })
62
+ );
52
63
  }
@@ -8,6 +8,7 @@ import { useSession } from "../session/useSession";
8
8
  import {
9
9
  impersonationPickerUrl,
10
10
  impersonationRequired,
11
+ isExemptRoute,
11
12
  sendsToPicker,
12
13
  withholdsApp,
13
14
  } from "./required";
@@ -15,14 +16,12 @@ import {
15
16
  interface Props {
16
17
  sessionActive: boolean;
17
18
  cookieRead: boolean;
18
- keepOwnIdentity: boolean;
19
19
  children: ReactNode;
20
20
  }
21
21
 
22
22
  export function RequiredImpersonationGate({
23
23
  sessionActive,
24
24
  cookieRead,
25
- keepOwnIdentity,
26
25
  children,
27
26
  }: Props) {
28
27
  const config = useOptionalConfig();
@@ -31,15 +30,12 @@ export function RequiredImpersonationGate({
31
30
  const platformUrl = config?.NEXT_PUBLIC_PLATFORM_BASE_URL;
32
31
 
33
32
  const state = {
34
- required: impersonationRequired(
35
- config?.NEXT_PUBLIC_ENVIRONMENT,
36
- keepOwnIdentity
37
- ),
33
+ required: impersonationRequired(config?.NEXT_PUBLIC_ENVIRONMENT),
38
34
  authenticated:
39
35
  status === "authenticated" || (status === "loading" && !!session),
40
36
  sessionActive,
41
37
  cookieRead,
42
- authRoute: pathname?.startsWith("/auth") ?? false,
38
+ exemptRoute: isExemptRoute(pathname),
43
39
  };
44
40
  const redirect = sendsToPicker(state);
45
41
 
@@ -0,0 +1,16 @@
1
+ export interface TokenClaims {
2
+ biz?: string;
3
+ email?: string;
4
+ act?: string;
5
+ }
6
+
7
+ export function tokenClaims(accessToken: string): TokenClaims {
8
+ try {
9
+ const payload = accessToken.split(".")[1] ?? "";
10
+ return JSON.parse(
11
+ Buffer.from(payload, "base64url").toString("utf8")
12
+ ) as TokenClaims;
13
+ } catch {
14
+ return {};
15
+ }
16
+ }
@@ -75,15 +75,7 @@ function readActiveCookie(): ImpersonationActive | null {
75
75
  }
76
76
  }
77
77
 
78
- interface ImpersonationProviderProps {
79
- children: ReactNode;
80
- keepOwnIdentity?: boolean;
81
- }
82
-
83
- export function ImpersonationProvider({
84
- children,
85
- keepOwnIdentity = false,
86
- }: ImpersonationProviderProps) {
78
+ export function ImpersonationProvider({ children }: { children: ReactNode }) {
87
79
  const [active, setActive] = useState<ImpersonationActive | null>(null);
88
80
  const [cookieRead, setCookieRead] = useState(false);
89
81
  const [starting, setStarting] = useState(false);
@@ -170,7 +162,6 @@ export function ImpersonationProvider({
170
162
  <RequiredImpersonationGate
171
163
  sessionActive={!!active}
172
164
  cookieRead={cookieRead}
173
- keepOwnIdentity={keepOwnIdentity}
174
165
  >
175
166
  {children}
176
167
  </RequiredImpersonationGate>
@@ -7,7 +7,12 @@ import {
7
7
  } from "./cookies";
8
8
  import { sealImpersonationTicket, openImpersonationTicket } from "./ticket";
9
9
  import { requestOrigin, resolveCookieDomain } from "../session";
10
- import { platformBase, platformErrorResponse, requestHost } from "./request";
10
+ import {
11
+ platformCall,
12
+ platformClient,
13
+ platformNotConfigured,
14
+ requestHost,
15
+ } from "./request";
11
16
 
12
17
  function registrable(host: string): string {
13
18
  return host.split(":")[0].toLowerCase().split(".").slice(-2).join(".");
@@ -64,17 +69,12 @@ function handsOffToTarget(request: Request, target: string): boolean {
64
69
  */
65
70
  export function createImpersonationStartHandler(authConfig: Auth.Config) {
66
71
  return async function POST(request: Request) {
67
- const base = platformBase();
68
- if (!base) {
69
- return new Response(
70
- "NEXT_PUBLIC_PLATFORM_API_BASE_URL is not configured",
71
- { status: 500 }
72
- );
73
- }
74
-
75
72
  const caller = await resolveAccessToken(authConfig, request);
76
73
  if ("error" in caller) return caller.error;
77
74
 
75
+ const client = platformClient(caller.accessToken);
76
+ if (!client) return platformNotConfigured();
77
+
78
78
  let body: {
79
79
  targetEmail?: string;
80
80
  targetName?: string;
@@ -91,23 +91,26 @@ export function createImpersonationStartHandler(authConfig: Auth.Config) {
91
91
  return new Response("Invalid request body", { status: 400 });
92
92
  }
93
93
 
94
- const minted = await fetch(`${base}/v1.0/protected/impersonate`, {
95
- method: "POST",
96
- headers: {
97
- Authorization: `Bearer ${caller.accessToken}`,
98
- "Content-Type": "application/json",
99
- },
100
- body: JSON.stringify({
101
- targetEmail: body.targetEmail,
102
- targetName: body.targetName,
103
- reason: body.reason,
104
- }),
105
- });
106
-
107
- if (!minted.ok) return platformErrorResponse(minted);
108
-
109
- const result = await minted.json();
110
- result.targetImage = body.targetImage ?? null;
94
+ const minted = await platformCall(
95
+ client.startImpersonation({
96
+ startImpersonationRequest: {
97
+ targetEmail: body.targetEmail,
98
+ targetName: body.targetName,
99
+ reason: body.reason,
100
+ },
101
+ })
102
+ );
103
+ if ("error" in minted) return minted.error;
104
+
105
+ const result = {
106
+ accessToken: minted.value.accessToken ?? "",
107
+ expiresOn: minted.value.expiresOn ?? 0,
108
+ tokenId: minted.value.tokenId ?? "",
109
+ targetEmail: minted.value.targetEmail ?? body.targetEmail ?? "",
110
+ targetName: minted.value.targetName,
111
+ targetImage: body.targetImage ?? null,
112
+ finance: minted.value.finance ?? false,
113
+ };
111
114
  const summary = {
112
115
  targetEmail: result.targetEmail,
113
116
  targetName: result.targetName,
@@ -200,19 +203,14 @@ export function createImpersonationAdoptHandler(authConfig: Auth.Config) {
200
203
  export function createImpersonationEndHandler(authConfig: Auth.Config) {
201
204
  return async function POST(request: Request) {
202
205
  const session = readImpersonationSession(request);
203
- const base = platformBase();
204
206
 
205
- if (session?.tokenId && base) {
207
+ if (session?.tokenId) {
206
208
  const caller = await resolveAccessToken(authConfig, request);
207
- if (!("error" in caller)) {
208
- await fetch(
209
- `${base}/v1.0/protected/impersonate/${encodeURIComponent(session.tokenId)}/end`,
210
- {
211
- method: "POST",
212
- headers: { Authorization: `Bearer ${caller.accessToken}` },
213
- }
214
- ).catch(() => undefined);
215
- }
209
+ const client =
210
+ "error" in caller ? null : platformClient(caller.accessToken);
211
+ await client
212
+ ?.endImpersonation({ tokenId: session.tokenId })
213
+ .catch(() => undefined);
216
214
  }
217
215
 
218
216
  const out = Response.json({ ok: true });
@@ -0,0 +1,56 @@
1
+ import type { ProtectedEmployee } from "@snokam/platform-client";
2
+ import { Auth } from "../types";
3
+ import { resolveAccessToken } from "../route-handler/token";
4
+ import { tokenClaims } from "./claims";
5
+ import { platformCall, platformClient, platformNotConfigured } from "./request";
6
+ import { impersonationRequired } from "./required";
7
+
8
+ function persona(employee: ProtectedEmployee) {
9
+ return {
10
+ name: employee.name,
11
+ email: employee.email,
12
+ position: employee.position,
13
+ about: employee.about,
14
+ active: employee.active,
15
+ isBot: employee.isBot,
16
+ image: { asset: { url: employee.image?.asset?.url } },
17
+ scopeGrants: employee.scopeGrants,
18
+ groups: (employee.groups ?? []).map((group) => ({
19
+ name: group.name,
20
+ scopeGrants: group.scopeGrants,
21
+ })),
22
+ };
23
+ }
24
+
25
+ export function createImpersonationPersonasHandler(authConfig: Auth.Config) {
26
+ return async function GET(request: Request) {
27
+ if (!impersonationRequired(authConfig.environment)) {
28
+ return new Response("Not found", { status: 404 });
29
+ }
30
+
31
+ const caller = await resolveAccessToken(authConfig, request);
32
+ if ("error" in caller) return caller.error;
33
+
34
+ const client = platformClient(caller.accessToken);
35
+ if (!client) return platformNotConfigured();
36
+
37
+ const businessId = tokenClaims(caller.accessToken).biz;
38
+ if (!businessId) {
39
+ return new Response("The signed-in account has no business", {
40
+ status: 409,
41
+ });
42
+ }
43
+
44
+ const [employees, apps] = await Promise.all([
45
+ platformCall(client.listBusinessEmployees({ id: businessId })),
46
+ platformCall(client.getMeApps()),
47
+ ]);
48
+ if ("error" in employees) return employees.error;
49
+ if ("error" in apps) return apps.error;
50
+
51
+ return Response.json({
52
+ personas: employees.value.map(persona),
53
+ apps: apps.value,
54
+ });
55
+ };
56
+ }
@@ -1,8 +1,3 @@
1
- import {
2
- Configuration,
3
- PlatformClient,
4
- ResponseError,
5
- } from "@snokam/platform-client";
6
1
  import { Auth } from "../types";
7
2
  import { resolveAccessToken } from "../route-handler/token";
8
3
  import {
@@ -10,7 +5,12 @@ import {
10
5
  readImpersonationActive,
11
6
  readImpersonationSession,
12
7
  } from "./cookies";
13
- import { platformBase, platformErrorResponse, requestHost } from "./request";
8
+ import {
9
+ platformCall,
10
+ platformClient,
11
+ platformNotConfigured,
12
+ requestHost,
13
+ } from "./request";
14
14
 
15
15
  export function createImpersonationRenewHandler(authConfig: Auth.Config) {
16
16
  return async function POST(request: Request) {
@@ -20,56 +20,36 @@ export function createImpersonationRenewHandler(authConfig: Auth.Config) {
20
20
  return new Response("No impersonation session", { status: 404 });
21
21
  }
22
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
23
  const caller = await resolveAccessToken(authConfig, request);
34
24
  if ("error" in caller) return caller.error;
35
25
 
36
- const client = new PlatformClient(
37
- new Configuration({
38
- basePath,
39
- accessToken: async () => caller.accessToken,
40
- })
26
+ const client = platformClient(caller.accessToken);
27
+ if (!client) return platformNotConfigured();
28
+
29
+ const renewal = await platformCall(
30
+ client.renewImpersonation({ tokenId: session.tokenId })
41
31
  );
32
+ if ("error" in renewal) return renewal.error;
42
33
 
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
- }
34
+ const renewed = renewal.value;
35
+ if (!renewed.accessToken || !renewed.expiresOn) {
36
+ return new Response("Incomplete impersonation renewal", { status: 502 });
37
+ }
52
38
 
53
- const out = Response.json({
54
- tokenId: session.tokenId,
39
+ const out = Response.json({
40
+ tokenId: session.tokenId,
41
+ expiresOn: renewed.expiresOn,
42
+ });
43
+ const cookies = buildImpersonationCookies(
44
+ {
45
+ ...active,
46
+ accessToken: renewed.accessToken,
55
47
  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
- }
48
+ finance: renewed.finance ?? false,
49
+ },
50
+ requestHost(request)
51
+ );
52
+ for (const cookie of cookies) out.headers.append("Set-Cookie", cookie);
53
+ return out;
74
54
  };
75
55
  }
@@ -1,9 +1,32 @@
1
+ import {
2
+ Configuration,
3
+ PlatformClient,
4
+ ResponseError,
5
+ } from "@snokam/platform-client";
6
+
1
7
  export function platformBase(): string | null {
2
8
  return (
3
9
  process.env.NEXT_PUBLIC_PLATFORM_API_BASE_URL?.replace(/\/$/, "") ?? null
4
10
  );
5
11
  }
6
12
 
13
+ export function platformClient(accessToken: string): PlatformClient | null {
14
+ const basePath = platformBase();
15
+ if (!basePath) return null;
16
+ return new PlatformClient(
17
+ new Configuration({
18
+ basePath,
19
+ accessToken: async () => `Bearer ${accessToken}`,
20
+ })
21
+ );
22
+ }
23
+
24
+ export function platformNotConfigured(): Response {
25
+ return new Response("NEXT_PUBLIC_PLATFORM_API_BASE_URL is not configured", {
26
+ status: 500,
27
+ });
28
+ }
29
+
7
30
  export function requestHost(request: Request): string | null {
8
31
  return request.headers.get("x-forwarded-host") ?? request.headers.get("host");
9
32
  }
@@ -24,3 +47,18 @@ export async function platformErrorResponse(
24
47
  status: response.status,
25
48
  });
26
49
  }
50
+
51
+ export type PlatformResult<T> = { value: T } | { error: Response };
52
+
53
+ export async function platformCall<T>(
54
+ call: Promise<T>
55
+ ): Promise<PlatformResult<T>> {
56
+ try {
57
+ return { value: await call };
58
+ } catch (error) {
59
+ if (error instanceof ResponseError) {
60
+ return { error: await platformErrorResponse(error.response) };
61
+ }
62
+ throw error;
63
+ }
64
+ }
@@ -3,6 +3,7 @@ import { describe, it } from "node:test";
3
3
  import {
4
4
  impersonationPickerUrl,
5
5
  impersonationRequired,
6
+ isExemptRoute,
6
7
  renewDelayMs,
7
8
  sendsToPicker,
8
9
  withholdsApp,
@@ -12,19 +13,18 @@ const signedInAlone = {
12
13
  authenticated: true,
13
14
  sessionActive: false,
14
15
  cookieRead: true,
15
- authRoute: false,
16
+ exemptRoute: false,
16
17
  };
17
18
 
18
19
  describe("impersonation required gate", () => {
19
20
  it("is off in production and everywhere that is not the test environment", () => {
20
21
  for (const environment of ["p", "test", "production", "", undefined]) {
21
- assert.equal(impersonationRequired(environment, false), false);
22
+ assert.equal(impersonationRequired(environment), false);
22
23
  }
23
24
  });
24
25
 
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);
26
+ it("is on in test", () => {
27
+ assert.equal(impersonationRequired("t"), true);
28
28
  });
29
29
 
30
30
  it("never withholds the app or redirects when not required", () => {
@@ -51,9 +51,9 @@ describe("impersonation required gate", () => {
51
51
  assert.equal(sendsToPicker(state), false);
52
52
  });
53
53
 
54
- it("leaves sign-in routes and anonymous visitors alone", () => {
54
+ it("leaves sign-in routes, the picker and anonymous visitors alone", () => {
55
55
  const base = { required: true, ...signedInAlone };
56
- assert.equal(withholdsApp({ ...base, authRoute: true }), false);
56
+ assert.equal(withholdsApp({ ...base, exemptRoute: true }), false);
57
57
  assert.equal(withholdsApp({ ...base, authenticated: false }), false);
58
58
  });
59
59
 
@@ -64,13 +64,21 @@ describe("impersonation required gate", () => {
64
64
  "https://snokam.test.snosky.no/minside/okonomi?x=1"
65
65
  )
66
66
  );
67
- assert.equal(url.pathname, "/manage/users");
67
+ assert.equal(url.pathname, "/velg-bruker");
68
68
  assert.equal(
69
69
  url.searchParams.get("returnTo"),
70
70
  "https://snokam.test.snosky.no/minside/okonomi?x=1"
71
71
  );
72
72
  });
73
73
 
74
+ it("exempts sign-in routes and the picker, and nothing else", () => {
75
+ assert.equal(isExemptRoute("/auth/login"), true);
76
+ assert.equal(isExemptRoute("/velg-bruker"), true);
77
+ assert.equal(isExemptRoute("/manage"), false);
78
+ assert.equal(isExemptRoute("/"), false);
79
+ assert.equal(isExemptRoute(null), false);
80
+ });
81
+
74
82
  it("renews two minutes before the token expires, and at once when already late", () => {
75
83
  assert.equal(renewDelayMs(1_000, 0), 880_000);
76
84
  assert.equal(renewDelayMs(1_000, 999_000), 0);
@@ -2,13 +2,14 @@ export const IMPERSONATION_REQUIRED_ERROR = "impersonation_required";
2
2
 
3
3
  const TEST_ENVIRONMENT = "t";
4
4
 
5
+ export const IMPERSONATION_PICKER_PATH = "/velg-bruker";
6
+
5
7
  const RENEW_MARGIN_SECONDS = 120;
6
8
 
7
9
  export function impersonationRequired(
8
- environment: string | undefined,
9
- keepOwnIdentity: boolean
10
+ environment: string | undefined
10
11
  ): boolean {
11
- return !keepOwnIdentity && environment === TEST_ENVIRONMENT;
12
+ return environment === TEST_ENVIRONMENT;
12
13
  }
13
14
 
14
15
  export interface ImpersonationGateState {
@@ -16,11 +17,20 @@ export interface ImpersonationGateState {
16
17
  authenticated: boolean;
17
18
  sessionActive: boolean;
18
19
  cookieRead: boolean;
19
- authRoute: boolean;
20
+ exemptRoute: boolean;
21
+ }
22
+
23
+ function gated({
24
+ required,
25
+ authenticated,
26
+ exemptRoute,
27
+ }: ImpersonationGateState) {
28
+ return required && authenticated && !exemptRoute;
20
29
  }
21
30
 
22
- function gated({ required, authenticated, authRoute }: ImpersonationGateState) {
23
- return required && authenticated && !authRoute;
31
+ export function isExemptRoute(pathname: string | null): boolean {
32
+ if (!pathname) return false;
33
+ return pathname.startsWith("/auth") || pathname === IMPERSONATION_PICKER_PATH;
24
34
  }
25
35
 
26
36
  export function withholdsApp(state: ImpersonationGateState): boolean {
@@ -35,7 +45,7 @@ export function impersonationPickerUrl(
35
45
  platformBaseUrl: string,
36
46
  returnTo: string
37
47
  ): string {
38
- const picker = new URL("/manage/users", platformBaseUrl);
48
+ const picker = new URL(IMPERSONATION_PICKER_PATH, platformBaseUrl);
39
49
  picker.searchParams.set("returnTo", returnTo);
40
50
  return picker.toString();
41
51
  }
@@ -5,6 +5,7 @@ import {
5
5
  createImpersonationAdoptHandler,
6
6
  } from "./handlers";
7
7
  import { createImpersonationRenewHandler } from "./renew";
8
+ import { createImpersonationPersonasHandler } from "./personas";
8
9
 
9
10
  type OpContext = { params: Promise<{ op: string }> };
10
11
 
@@ -13,11 +14,13 @@ export function createImpersonationRouteHandlers(authConfig: Auth.Config) {
13
14
  const end = createImpersonationEndHandler(authConfig);
14
15
  const adopt = createImpersonationAdoptHandler(authConfig);
15
16
  const renew = createImpersonationRenewHandler(authConfig);
17
+ const personas = createImpersonationPersonasHandler(authConfig);
16
18
 
17
19
  return {
18
20
  async GET(request: Request, ctx: OpContext) {
19
21
  const { op } = await ctx.params;
20
22
  if (op === "adopt") return adopt(request);
23
+ if (op === "personas") return personas(request);
21
24
  return new Response("Not found", { status: 404 });
22
25
  },
23
26
  async POST(request: Request, ctx: OpContext) {
@@ -38,7 +38,6 @@ 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";
42
41
  export {
43
42
  createImpersonationStartHandler,
44
43
  createImpersonationEndHandler,
@@ -104,18 +104,8 @@ export async function resolveAccessToken(
104
104
  }
105
105
  }
106
106
 
107
- export interface TokenHandlerOptions {
108
- keepOwnIdentity?: boolean;
109
- }
110
-
111
- export function createTokenHandler(
112
- authConfig: Auth.Config,
113
- { keepOwnIdentity = false }: TokenHandlerOptions = {}
114
- ) {
107
+ export function createTokenHandler(authConfig: Auth.Config) {
115
108
  return async function POST(request: Request) {
116
- // While a view-as-employee session is active, every product API call transparently receives the
117
- // short-lived impersonation token instead of the admin's own — the single swap point the design
118
- // relies on. A clean exit (or expiry) discards the cookie and this falls back to the admin token.
119
109
  const impersonation = readImpersonationSession(request);
120
110
  if (impersonation && impersonation.expiresOn * 1000 > Date.now()) {
121
111
  return Response.json({
@@ -124,7 +114,7 @@ export function createTokenHandler(
124
114
  });
125
115
  }
126
116
 
127
- if (impersonationRequired(authConfig.environment, keepOwnIdentity)) {
117
+ if (impersonationRequired(authConfig.environment)) {
128
118
  return Response.json(
129
119
  { error: IMPERSONATION_REQUIRED_ERROR },
130
120
  { status: 403 }