@snokam/auth 5.3.0 → 5.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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
+ ## [5.3.1](https://github.com/snokam/components/compare/@snokam/auth@5.3.0...@snokam/auth@5.3.1) (2026-10-14)
7
+
8
+ ### Bug Fixes
9
+
10
+ - Vis som follow-ups to [#2944](https://github.com/snokam/components/issues/2944) (policy split, banner once, exit everywhere) ([#2950](https://github.com/snokam/components/issues/2950)) ([a5a6037](https://github.com/snokam/components/commit/a5a60377afdbdf9abfd5074f7cbbdd604e4a8def)), closes [#2948](https://github.com/snokam/components/issues/2948)
11
+
6
12
  # [5.3.0](https://github.com/snokam/components/compare/@snokam/auth@5.2.2...@snokam/auth@5.3.0) (2026-10-14)
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": "5.3.0",
4
+ "version": "5.3.1",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
7
7
  "files": [
@@ -26,11 +26,11 @@
26
26
  "@snokam/employees-client": "^1.3.0",
27
27
  "next-auth": "4.24.15",
28
28
  "path-to-regexp": "^8.4.2",
29
- "@snokam/identity": "1.1.0",
30
- "@snokam/platform-client": "5.3.0",
29
+ "@snokam/platform-client": "5.3.1",
31
30
  "@snokam/react-query": "1.1.0",
32
- "@snokam/sanity-page-content": "6.5.1",
33
- "@snokam/tenant": "4.3.0",
31
+ "@snokam/sanity-page-content": "6.5.2",
32
+ "@snokam/tenant": "4.3.1",
33
+ "@snokam/identity": "1.1.0",
34
34
  "@snokam/ui": "6.1.0"
35
35
  },
36
36
  "peerDependencies": {
@@ -1,12 +1,12 @@
1
1
  "use client";
2
2
 
3
3
  import { useEffect, useState, useRef } from "react";
4
- import { useRouter, useSearchParams } from "next/navigation";
4
+ import { useSearchParams } from "next/navigation";
5
5
  import { useAuth } from "../../hooks";
6
6
  import { signIn } from "next-auth/react";
7
7
  import { PageLoader } from "@snokam/ui";
8
8
  import { useOptionalConfig } from "@snokam/tenant";
9
- import { safeCallbackTarget } from "../../session";
9
+ import { callbackDestination, safeCallbackTarget } from "../../session";
10
10
  import Error from "../../components/Error";
11
11
  import Consent from "../../components/Consent";
12
12
  import LoggedOut from "../../components/LoggedOut";
@@ -18,7 +18,6 @@ export const Login = () => {
18
18
  const [errorKind, setErrorKind] = useState<string | null>(null);
19
19
  const [loggedOut, setLoggedOut] = useState(false);
20
20
  const hasSignedIn = useRef(false);
21
- const router = useRouter();
22
21
  const searchParams = useSearchParams();
23
22
  const config = useOptionalConfig();
24
23
  const central = config?.NEXT_PUBLIC_CENTRAL_AUTH_URL;
@@ -76,21 +75,15 @@ export const Login = () => {
76
75
  }
77
76
 
78
77
  if (isAuthenticated) {
79
- router.replace(callbackUrl || "/");
78
+ window.location.replace(
79
+ callbackDestination(callbackUrl, window.location.origin)
80
+ );
80
81
  } else if (!loggedOut) {
81
82
  hasSignedIn.current = true;
82
83
  startSignIn();
83
84
  }
84
85
  // eslint-disable-next-line react-hooks/exhaustive-deps
85
- }, [
86
- inProgress,
87
- isAuthenticated,
88
- callbackUrl,
89
- router,
90
- isError,
91
- central,
92
- loggedOut,
93
- ]);
86
+ }, [inProgress, isAuthenticated, callbackUrl, isError, central, loggedOut]);
94
87
 
95
88
  if (loggedOut && !isAuthenticated && !isError) {
96
89
  return (
@@ -1,29 +1,22 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { describe, it } from "node:test";
3
- import { claimedRoles, tokenClaims } from "./claims.ts";
3
+ import { tokenClaims } from "./claims.ts";
4
4
 
5
5
  function token(payload: object): string {
6
6
  const encoded = Buffer.from(JSON.stringify(payload)).toString("base64url");
7
7
  return `header.${encoded}.signature`;
8
8
  }
9
9
 
10
- describe("roles carried by a view-as token", () => {
11
- it("reads the actor and a single role", () => {
10
+ describe("claims of a view-as token", () => {
11
+ it("reads the actor and the business", () => {
12
12
  const claims = tokenClaims(
13
- token({
14
- act: "morten.jansrud@test.snokam.no",
15
- roles: "platform-operator",
16
- })
13
+ token({ act: "morten.jansrud@test.snokam.no", biz: "biz-1" })
17
14
  );
18
15
  assert.equal(claims.act, "morten.jansrud@test.snokam.no");
19
- assert.deepEqual(claimedRoles(claims), ["platform-operator"]);
16
+ assert.equal(claims.biz, "biz-1");
20
17
  });
21
18
 
22
- it("reads several roles and none", () => {
23
- assert.deepEqual(claimedRoles(tokenClaims(token({ roles: ["a", "b"] }))), [
24
- "a",
25
- "b",
26
- ]);
27
- assert.deepEqual(claimedRoles(tokenClaims(token({}))), []);
19
+ it("reads nothing from a malformed token", () => {
20
+ assert.deepEqual(tokenClaims("not-a-token"), {});
28
21
  });
29
22
  });
@@ -3,12 +3,6 @@ export interface TokenClaims {
3
3
  email?: string;
4
4
  act?: string;
5
5
  imp?: string;
6
- roles?: string | string[];
7
- }
8
-
9
- export function claimedRoles(claims: TokenClaims): string[] {
10
- if (!claims.roles) return [];
11
- return Array.isArray(claims.roles) ? claims.roles : [claims.roles];
12
6
  }
13
7
 
14
8
  export function tokenClaims(accessToken: string): TokenClaims {
@@ -14,6 +14,7 @@ import { clearAuthTokenCache } from "../hooks";
14
14
  import { useSessionRenewal } from "./renewal";
15
15
  import { useOptionalConfig } from "@snokam/tenant";
16
16
  import { isAllowedRedirect, platformBaseDomain } from "./redirects";
17
+ import { exitDestination } from "./exit";
17
18
 
18
19
  export interface StartImpersonationInput {
19
20
  targetEmail: string;
@@ -121,12 +122,14 @@ export function ImpersonationProvider({ children }: { children: ReactNode }) {
121
122
  }).catch(() => undefined);
122
123
  clearAuthTokenCache();
123
124
  const returnTo = active?.returnTo;
125
+ const destination =
126
+ returnTo && isSafeReturnTo(returnTo, apiBaseUrl)
127
+ ? returnTo
128
+ : window.location.href;
124
129
  setActive(null);
125
- if (returnTo && isSafeReturnTo(returnTo, apiBaseUrl)) {
126
- window.location.assign(returnTo);
127
- } else {
128
- window.location.reload();
129
- }
130
+ window.location.assign(
131
+ exitDestination(active?.origins, window.location.origin, destination)
132
+ );
130
133
  } finally {
131
134
  setExiting(false);
132
135
  }
@@ -1,5 +1,5 @@
1
1
  import { resolveCookieDomain } from "../session";
2
- import { claimedRoles, tokenClaims } from "./claims";
2
+ import { tokenClaims } from "./claims";
3
3
 
4
4
  export const IMP_SESSION_COOKIE = "imp_session";
5
5
  export const IMP_ACTIVE_COOKIE = "imp_active";
@@ -19,7 +19,7 @@ export interface ImpersonationActive {
19
19
  expiresOn: number;
20
20
  returnTo?: string;
21
21
  actorEmail?: string;
22
- roles?: string[];
22
+ origins?: string[];
23
23
  }
24
24
 
25
25
  interface MintedToken {
@@ -31,6 +31,7 @@ interface MintedToken {
31
31
  targetImage?: string | null;
32
32
  finance: boolean;
33
33
  returnTo?: string;
34
+ origins?: string[];
34
35
  }
35
36
 
36
37
  function parseCookieHeader(header: string | null): Record<string, string> {
@@ -103,7 +104,6 @@ export function buildImpersonationCookies(
103
104
  const claims = tokenClaims(minted.accessToken);
104
105
  const active: ImpersonationActive = {
105
106
  actorEmail: claims.act,
106
- roles: claimedRoles(claims),
107
107
  targetEmail: minted.targetEmail,
108
108
  targetName: minted.targetName,
109
109
  targetImage: minted.targetImage,
@@ -111,6 +111,7 @@ export function buildImpersonationCookies(
111
111
  tokenId: minted.tokenId,
112
112
  expiresOn: minted.expiresOn,
113
113
  returnTo: minted.returnTo,
114
+ origins: minted.origins,
114
115
  };
115
116
  return [
116
117
  serialize(IMP_SESSION_COOKIE, JSON.stringify(session), {
@@ -0,0 +1,29 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { exitDestination } from "./exit.ts";
4
+
5
+ const platform = "https://www.test.snosky.no";
6
+ const tenant = "https://snokam.test.snosky.no";
7
+ const minside = `${tenant}/minside`;
8
+
9
+ describe("leaving Vis som", () => {
10
+ it("clears the other host before landing when the session spans two hosts", () => {
11
+ const url = new URL(exitDestination([platform, tenant], tenant, minside));
12
+ assert.equal(url.origin, platform);
13
+ assert.equal(url.pathname, "/api/impersonation/clear");
14
+ assert.equal(url.searchParams.get("returnTo"), minside);
15
+ });
16
+
17
+ it("clears the tenant host when leaving from the platform host", () => {
18
+ const url = new URL(
19
+ exitDestination([platform, tenant], platform, `${platform}/manage`)
20
+ );
21
+ assert.equal(url.origin, tenant);
22
+ assert.equal(url.searchParams.get("returnTo"), `${platform}/manage`);
23
+ });
24
+
25
+ it("goes straight to the destination when only this host holds the session", () => {
26
+ assert.equal(exitDestination([tenant], tenant, minside), minside);
27
+ assert.equal(exitDestination(undefined, tenant, minside), minside);
28
+ });
29
+ });
@@ -0,0 +1,13 @@
1
+ export const CLEAR_PATH = "/api/impersonation/clear";
2
+
3
+ export function exitDestination(
4
+ origins: string[] | undefined,
5
+ currentOrigin: string,
6
+ destination: string
7
+ ): string {
8
+ const other = (origins ?? []).find((origin) => origin !== currentOrigin);
9
+ if (!other) return destination;
10
+ const clear = new URL(CLEAR_PATH, other);
11
+ clear.searchParams.set("returnTo", destination);
12
+ return clear.toString();
13
+ }
@@ -108,18 +108,23 @@ export function createImpersonationStartHandler(authConfig: Auth.Config) {
108
108
  tokenId: result.tokenId,
109
109
  expiresOn: result.expiresOn,
110
110
  };
111
+ const here = requestOrigin(request);
112
+ const target =
113
+ body.redirectTo && handsOffToTarget(request, body.redirectTo)
114
+ ? new URL(body.redirectTo)
115
+ : null;
116
+ const origins = target ? [here, target.origin] : [here];
111
117
  const cookies = buildImpersonationCookies(
112
- { ...result, returnTo: body.returnTo },
118
+ { ...result, returnTo: body.returnTo, origins },
113
119
  requestHost(request)
114
120
  );
115
121
 
116
- if (!body.redirectTo || !handsOffToTarget(request, body.redirectTo)) {
122
+ if (!target) {
117
123
  return withCookies(Response.json(summary), cookies);
118
124
  }
119
125
 
120
- const target = new URL(body.redirectTo);
121
126
  const ticket = sealImpersonationTicket(
122
- { ...result, business: target.host, returnTo: body.returnTo },
127
+ { ...result, business: target.host, returnTo: body.returnTo, origins },
123
128
  authConfig.nextAuthSecret
124
129
  );
125
130
  const adopt = new URL("/api/impersonation/adopt", target.origin);
@@ -169,6 +174,20 @@ export function createImpersonationAdoptHandler(authConfig: Auth.Config) {
169
174
  };
170
175
  }
171
176
 
177
+ export function createImpersonationClearHandler() {
178
+ return async function GET(request: Request) {
179
+ const origin = requestOrigin(request);
180
+ const dest = resolveLanding(
181
+ new URL(request.url).searchParams.get("returnTo") || "/",
182
+ origin
183
+ );
184
+ return withCookies(
185
+ new Response(null, { status: 302, headers: { Location: dest } }),
186
+ clearImpersonationCookies(requestHost(request))
187
+ );
188
+ };
189
+ }
190
+
172
191
  export function createImpersonationEndHandler(authConfig: Auth.Config) {
173
192
  return async function POST(request: Request) {
174
193
  const session = readImpersonationSession(request);
@@ -42,7 +42,13 @@ export function createPickerBusinessesHandler(authConfig: Auth.Config) {
42
42
  platformCall(caller.client.listImpersonationBusinesses()),
43
43
  platformCall(caller.client.getMeApps()),
44
44
  ]);
45
- if ("error" in businesses) return businesses.error;
45
+ if ("error" in businesses) {
46
+ if (businesses.error.status !== 403) return businesses.error;
47
+ return Response.json(
48
+ { returnBusiness: await returnBusiness(request) },
49
+ { status: 403 }
50
+ );
51
+ }
46
52
  if ("error" in apps) return apps.error;
47
53
  if (businesses.value.length === 0) {
48
54
  return new Response("Not found", { status: 404 });
@@ -3,6 +3,7 @@ import {
3
3
  createImpersonationStartHandler,
4
4
  createImpersonationEndHandler,
5
5
  createImpersonationAdoptHandler,
6
+ createImpersonationClearHandler,
6
7
  } from "./handlers";
7
8
  import { createImpersonationRenewHandler } from "./renew";
8
9
  import {
@@ -16,6 +17,7 @@ export function createImpersonationRouteHandlers(authConfig: Auth.Config) {
16
17
  const start = createImpersonationStartHandler(authConfig);
17
18
  const end = createImpersonationEndHandler(authConfig);
18
19
  const adopt = createImpersonationAdoptHandler(authConfig);
20
+ const clear = createImpersonationClearHandler();
19
21
  const renew = createImpersonationRenewHandler(authConfig);
20
22
  const businesses = createPickerBusinessesHandler(authConfig);
21
23
  const people = createPickerPeopleHandler(authConfig);
@@ -24,6 +26,7 @@ export function createImpersonationRouteHandlers(authConfig: Auth.Config) {
24
26
  async GET(request: Request, ctx: OpContext) {
25
27
  const { op } = await ctx.params;
26
28
  if (op === "adopt") return adopt(request);
29
+ if (op === "clear") return clear(request);
27
30
  if (op === "businesses") return businesses(request);
28
31
  if (op === "people") return people(request);
29
32
  return new Response("Not found", { status: 404 });
@@ -9,6 +9,7 @@ export interface ImpersonationTicket {
9
9
  finance: boolean;
10
10
  business: string;
11
11
  returnTo?: string;
12
+ origins?: string[];
12
13
  }
13
14
 
14
15
  const TTL_SECONDS = 120;
@@ -0,0 +1,22 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { callbackDestination } from "./callback-url.ts";
4
+
5
+ describe("where an already signed-in login page sends you", () => {
6
+ const origin = "https://snokam.test.snosky.no";
7
+
8
+ it("keeps the basePath the callbackUrl already carries, once", () => {
9
+ assert.equal(
10
+ callbackDestination("/minside", origin),
11
+ "https://snokam.test.snosky.no/minside"
12
+ );
13
+ assert.equal(
14
+ callbackDestination("/minside/okonomi?x=1", origin),
15
+ "https://snokam.test.snosky.no/minside/okonomi?x=1"
16
+ );
17
+ });
18
+
19
+ it("falls back to the host root", () => {
20
+ assert.equal(callbackDestination(null, origin), `${origin}/`);
21
+ });
22
+ });
@@ -15,3 +15,11 @@ export function safeCallbackTarget(
15
15
  }
16
16
  return AUTH_FLOW.test(target) ? "/" : target;
17
17
  }
18
+
19
+ // A callbackUrl already carries the app's basePath; the Next router would add it again.
20
+ export function callbackDestination(
21
+ callbackUrl: string | null,
22
+ origin: string
23
+ ): string {
24
+ return new URL(callbackUrl || "/", origin).toString();
25
+ }
@@ -1,3 +1,3 @@
1
1
  export { resolveCookieDomain, getSessionCookieSettings } from "./cookies";
2
2
  export { getAuthUrl, requestOrigin } from "./url";
3
- export { safeCallbackTarget } from "./callback-url";
3
+ export { callbackDestination, safeCallbackTarget } from "./callback-url";