@snokam/auth 3.4.0 → 3.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,12 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ # [3.5.0](https://github.com/snokam/components/compare/@snokam/auth@3.4.0...@snokam/auth@3.5.0) (2026-10-06)
7
+
8
+ ### Features
9
+
10
+ - **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))
11
+
6
12
  # [3.4.0](https://github.com/snokam/components/compare/@snokam/auth@3.3.12...@snokam/auth@3.4.0) (2026-10-06)
7
13
 
8
14
  ### 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.5.0",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
7
7
  "files": [
@@ -28,9 +28,9 @@
28
28
  "path-to-regexp": "^8.4.2",
29
29
  "@snokam/identity": "1.1.0",
30
30
  "@snokam/platform-client": "3.6.0",
31
- "@snokam/react-query": "1.1.0",
32
31
  "@snokam/tenant": "2.1.13",
33
32
  "@snokam/ui": "4.0.0",
33
+ "@snokam/react-query": "1.1.0",
34
34
  "@snokam/sanity-page-content": "4.4.2"
35
35
  },
36
36
  "peerDependencies": {
@@ -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,14 @@ import {
6
6
  requestOrigin,
7
7
  } from "../session";
8
8
  import { resolveAccessToken } from "../route-handler/token";
9
+ import {
10
+ readImpersonationActive,
11
+ readImpersonationSession,
12
+ } from "../impersonation/cookies";
13
+ import {
14
+ impersonationPickerUrl,
15
+ impersonationRequired,
16
+ } from "../impersonation/required";
9
17
  import type { Auth } from "../types";
10
18
  import {
11
19
  type AuthorizationParams,
@@ -45,6 +53,47 @@ async function signedInEmail(
45
53
  return token?.refreshToken && token.email ? String(token.email) : null;
46
54
  }
47
55
 
56
+ interface Signer {
57
+ email: string;
58
+ accessToken: string;
59
+ }
60
+
61
+ function personaSigner(request: Request): Signer | null {
62
+ const session = readImpersonationSession(request);
63
+ const active = readImpersonationActive(request);
64
+ if (!session || !active || session.expiresOn * 1000 <= Date.now()) {
65
+ return null;
66
+ }
67
+ return { email: active.targetEmail, accessToken: session.accessToken };
68
+ }
69
+
70
+ async function ownSigner(
71
+ authConfig: Auth.Config,
72
+ request: Request,
73
+ email: string
74
+ ): Promise<Signer | null> {
75
+ const access = await resolveAccessToken(authConfig, request);
76
+ return "error" in access ? null : { email, accessToken: access.accessToken };
77
+ }
78
+
79
+ function signerFor(
80
+ authConfig: Auth.Config,
81
+ request: Request,
82
+ email: string
83
+ ): Promise<Signer | null> {
84
+ if (impersonationRequired(authConfig.environment)) {
85
+ return Promise.resolve(personaSigner(request));
86
+ }
87
+ return ownSigner(authConfig, request, email);
88
+ }
89
+
90
+ function pickerRedirect(request: Request): Response {
91
+ const url = new URL(request.url);
92
+ const origin = requestOrigin(request);
93
+ const resume = `${origin}${url.pathname}${url.search}`;
94
+ return Response.redirect(impersonationPickerUrl(origin, resume), 302);
95
+ }
96
+
48
97
  function loginRedirect(request: Request, resource: string, prompt?: string) {
49
98
  const url = new URL(request.url);
50
99
  const resume = new URLSearchParams(url.searchParams);
@@ -105,13 +154,17 @@ export function createAppSignInAuthorizeHandler(authConfig: Auth.Config) {
105
154
  const params = readParams(query);
106
155
  if (!params) return invalidRequest();
107
156
 
108
- const email = await signedInEmail(request, authConfig.nextAuthSecret);
109
- if (!email) return loginRedirect(request, params.resource);
157
+ const signedIn = await signedInEmail(request, authConfig.nextAuthSecret);
158
+ if (!signedIn) return loginRedirect(request, params.resource);
110
159
 
111
- const access = await resolveAccessToken(authConfig, request);
112
- if ("error" in access) return loginRedirect(request, params.resource);
160
+ const signer = await signerFor(authConfig, request, signedIn);
161
+ if (!signer) {
162
+ return impersonationRequired(authConfig.environment)
163
+ ? pickerRedirect(request)
164
+ : loginRedirect(request, params.resource);
165
+ }
113
166
 
114
- const preview = await previewAuthorization(params, access.accessToken);
167
+ const preview = await previewAuthorization(params, signer.accessToken);
115
168
  if ("error" in preview) {
116
169
  return preview.error === ACCESS_DENIED
117
170
  ? loginRedirect(request, params.resource, "login")
@@ -125,7 +178,7 @@ export function createAppSignInAuthorizeHandler(authConfig: Auth.Config) {
125
178
  ...preview.value,
126
179
  business: new URL(params.resource).hostname,
127
180
  state: query.get("state") ?? "",
128
- email,
181
+ email: signer.email,
129
182
  nonce,
130
183
  },
131
184
  authConfig.nextAuthSecret
@@ -155,8 +208,11 @@ export function createAppSignInApproveHandler(authConfig: Auth.Config) {
155
208
  return invalidRequest();
156
209
  }
157
210
 
158
- const email = await signedInEmail(request, authConfig.nextAuthSecret);
159
- if (email !== consent.email) return invalidRequest();
211
+ const signedIn = await signedInEmail(request, authConfig.nextAuthSecret);
212
+ if (!signedIn) return invalidRequest();
213
+
214
+ const signer = await signerFor(authConfig, request, signedIn);
215
+ if (!signer || signer.email !== consent.email) return invalidRequest();
160
216
 
161
217
  if (form.get("decision") !== "approve") {
162
218
  return clientRedirect(request, consent.redirectUri, consent.state, {
@@ -164,10 +220,7 @@ export function createAppSignInApproveHandler(authConfig: Auth.Config) {
164
220
  });
165
221
  }
166
222
 
167
- const access = await resolveAccessToken(authConfig, request);
168
- if ("error" in access) return invalidRequest();
169
-
170
- const approved = await approveAuthorization(consent, access.accessToken);
223
+ const approved = await approveAuthorization(consent, signer.accessToken);
171
224
  return clientRedirect(
172
225
  request,
173
226
  consent.redirectUri,
@@ -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
 
@@ -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>
@@ -0,0 +1,52 @@
1
+ import { Auth } from "../types";
2
+ import { resolveAccessToken } from "../route-handler/token";
3
+ import { platformBase, platformErrorResponse } from "./request";
4
+ import { impersonationRequired } from "./required";
5
+
6
+ function businessOf(accessToken: string): string | null {
7
+ try {
8
+ const payload = accessToken.split(".")[1] ?? "";
9
+ const claims = JSON.parse(
10
+ Buffer.from(payload, "base64url").toString("utf8")
11
+ ) as { biz?: string };
12
+ return claims.biz ?? null;
13
+ } catch {
14
+ return null;
15
+ }
16
+ }
17
+
18
+ export function createImpersonationPersonasHandler(authConfig: Auth.Config) {
19
+ return async function GET(request: Request) {
20
+ if (!impersonationRequired(authConfig.environment)) {
21
+ return new Response("Not found", { status: 404 });
22
+ }
23
+
24
+ const base = platformBase();
25
+ if (!base) {
26
+ return new Response(
27
+ "NEXT_PUBLIC_PLATFORM_API_BASE_URL is not configured",
28
+ { status: 500 }
29
+ );
30
+ }
31
+
32
+ const caller = await resolveAccessToken(authConfig, request);
33
+ if ("error" in caller) return caller.error;
34
+
35
+ const businessId = businessOf(caller.accessToken);
36
+ if (!businessId) {
37
+ return new Response("The signed-in account has no business", {
38
+ status: 409,
39
+ });
40
+ }
41
+
42
+ const employees = await fetch(
43
+ `${base}/v1.0/protected/businesses/${encodeURIComponent(businessId)}/employees`,
44
+ { headers: { Authorization: `Bearer ${caller.accessToken}` } }
45
+ );
46
+ if (!employees.ok) return platformErrorResponse(employees);
47
+
48
+ return new Response(await employees.text(), {
49
+ headers: { "Content-Type": "application/json" },
50
+ });
51
+ };
52
+ }
@@ -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 }