@snokam/auth 5.2.1 → 5.3.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
+ # [5.3.0](https://github.com/snokam/components/compare/@snokam/auth@5.2.2...@snokam/auth@5.3.0) (2026-10-14)
7
+
8
+ ### Features
9
+
10
+ - Vis som in test is prod plus operator persona starts (platform setting) ([#2944](https://github.com/snokam/components/issues/2944)) ([40084cc](https://github.com/snokam/components/commit/40084ccec44e34308e8007fb7ff0f270a5078bfc)), closes [#2917](https://github.com/snokam/components/issues/2917) [#2894](https://github.com/snokam/components/issues/2894) [#2913](https://github.com/snokam/components/issues/2913)
11
+
12
+ ## [5.2.2](https://github.com/snokam/components/compare/@snokam/auth@5.2.0...@snokam/auth@5.2.2) (2026-10-14)
13
+
14
+ ### Bug Fixes
15
+
16
+ - **platform-web:** show persona photos in the Vis som picker ([#2939](https://github.com/snokam/components/issues/2939)) ([125a20b](https://github.com/snokam/components/commit/125a20bdee81b673ace57d27541507bbbe6f6fbd))
17
+
6
18
  ## [5.2.1](https://github.com/snokam/components/compare/@snokam/auth@5.2.0...@snokam/auth@5.2.1) (2026-10-14)
7
19
 
8
20
  ### Bug Fixes
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.2.1",
4
+ "version": "5.3.0",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
7
7
  "files": [
@@ -27,11 +27,11 @@
27
27
  "next-auth": "4.24.15",
28
28
  "path-to-regexp": "^8.4.2",
29
29
  "@snokam/identity": "1.1.0",
30
+ "@snokam/platform-client": "5.3.0",
30
31
  "@snokam/react-query": "1.1.0",
31
- "@snokam/sanity-page-content": "6.4.0",
32
- "@snokam/tenant": "4.2.0",
33
- "@snokam/ui": "6.0.3",
34
- "@snokam/platform-client": "5.2.0"
32
+ "@snokam/sanity-page-content": "6.5.1",
33
+ "@snokam/tenant": "4.3.0",
34
+ "@snokam/ui": "6.1.0"
35
35
  },
36
36
  "peerDependencies": {
37
37
  "next": "16.3.8",
@@ -6,13 +6,6 @@ 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
- chooseToken,
13
- impersonationPickerUrl,
14
- PERSONA_REQUIRED,
15
- } from "../impersonation/required";
16
9
  import type { Auth } from "../types";
17
10
  import {
18
11
  type AuthorizationParams,
@@ -58,23 +51,7 @@ interface Signer {
58
51
  accessToken: string;
59
52
  }
60
53
 
61
- type SignerResult = Signer | typeof PERSONA_REQUIRED | null;
62
-
63
- function sameEmail(a: string | undefined, b: string): boolean {
64
- return !!a && a.toLowerCase() === b.toLowerCase();
65
- }
66
-
67
- function personaSigner(request: Request, operator: string): Signer | null {
68
- const session = readImpersonationSession(request);
69
- if (!session || session.expiresOn * 1000 <= Date.now()) return null;
70
-
71
- const claims = tokenClaims(session.accessToken);
72
- if (!claims.email || !sameEmail(claims.act, operator)) return null;
73
-
74
- return { email: claims.email, accessToken: session.accessToken };
75
- }
76
-
77
- async function ownSigner(
54
+ async function signerFor(
78
55
  authConfig: Auth.Config,
79
56
  request: Request,
80
57
  email: string
@@ -83,25 +60,6 @@ async function ownSigner(
83
60
  return "error" in access ? null : { email, accessToken: access.accessToken };
84
61
  }
85
62
 
86
- async function signerFor(
87
- authConfig: Auth.Config,
88
- request: Request,
89
- email: string
90
- ): Promise<SignerResult> {
91
- const persona = personaSigner(request, email);
92
- if (persona) return persona;
93
-
94
- const own = await ownSigner(authConfig, request, email);
95
- return chooseToken(null, own, own ? tokenClaims(own.accessToken) : {});
96
- }
97
-
98
- function pickerRedirect(request: Request): Response {
99
- const url = new URL(request.url);
100
- const origin = requestOrigin(request);
101
- const resume = `${origin}${url.pathname}${url.search}`;
102
- return Response.redirect(impersonationPickerUrl(origin, resume), 302);
103
- }
104
-
105
63
  function loginRedirect(request: Request, resource: string, prompt?: string) {
106
64
  const url = new URL(request.url);
107
65
  const resume = new URLSearchParams(url.searchParams);
@@ -166,7 +124,6 @@ export function createAppSignInAuthorizeHandler(authConfig: Auth.Config) {
166
124
  if (!signedIn) return loginRedirect(request, params.resource);
167
125
 
168
126
  const signer = await signerFor(authConfig, request, signedIn);
169
- if (signer === PERSONA_REQUIRED) return pickerRedirect(request);
170
127
  if (!signer) return loginRedirect(request, params.resource);
171
128
 
172
129
  const preview = await previewAuthorization(params, signer.accessToken);
@@ -219,11 +176,7 @@ export function createAppSignInApproveHandler(authConfig: Auth.Config) {
219
176
  if (!signedIn) return invalidRequest();
220
177
 
221
178
  const signer = await signerFor(authConfig, request, signedIn);
222
- if (
223
- !signer ||
224
- signer === PERSONA_REQUIRED ||
225
- signer.email !== consent.email
226
- ) {
179
+ if (!signer || signer.email !== consent.email) {
227
180
  return invalidRequest();
228
181
  }
229
182
 
@@ -2,11 +2,10 @@
2
2
 
3
3
  import { ReactNode } from "react";
4
4
  import Styled from "./styled";
5
- import { Alert, Avatar } from "@snokam/ui";
5
+ import { Avatar } from "@snokam/ui";
6
6
  import { SystemPage } from "@snokam/ui/system-page";
7
7
  import { ThemeScope } from "@snokam/ui/theme";
8
8
  import { useAuth } from "../../hooks";
9
- import { useTenantEntitlements } from "@snokam/tenant";
10
9
 
11
10
  export interface ErrorPageProps {
12
11
  message?: ReactNode;
@@ -23,8 +22,6 @@ export const Error = ({
23
22
  }: ErrorPageProps) => {
24
23
  const { session, signOut } = useAuth();
25
24
 
26
- const tenant = useTenantEntitlements();
27
-
28
25
  return (
29
26
  <SystemPage>
30
27
  <ThemeScope theme="light" as={Styled.Wrapper}>
@@ -49,12 +46,6 @@ export const Error = ({
49
46
  <br />({session.user.email})
50
47
  </Styled.User>
51
48
  )}
52
- {tenant?.personaMode && (
53
- <Alert severity="warning">
54
- Du er i test! Husk at det kun er mulig å logge inn <br />
55
- med en av våre testbrukere som du finner i 1Password.
56
- </Alert>
57
- )}
58
49
  {action}
59
50
  {!hideRetryButton && (
60
51
  <Styled.RetryButton
package/src/hooks.ts CHANGED
@@ -3,27 +3,9 @@ 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 {
7
- IMPERSONATION_REQUIRED_ERROR,
8
- impersonationPickerUrl,
9
- isSignInPage,
10
- } from "./impersonation/required";
11
6
 
12
7
  class ConsentRequiredError extends Error {}
13
8
 
14
- class ImpersonationRequiredError extends Error {
15
- constructor(readonly platformUrl: string | undefined) {
16
- super(IMPERSONATION_REQUIRED_ERROR);
17
- }
18
- }
19
-
20
- function sendToPicker(error: ImpersonationRequiredError): void {
21
- if (!error.platformUrl || isSignInPage(window.location.pathname)) return;
22
- window.location.assign(
23
- impersonationPickerUrl(error.platformUrl, window.location.href)
24
- );
25
- }
26
-
27
9
  const REFRESH_MARGIN_SECONDS = 60;
28
10
 
29
11
  interface CachedToken {
@@ -75,9 +57,6 @@ async function fetchToken(impKey: string): Promise<string> {
75
57
  if (body?.error === "consent_required") {
76
58
  throw new ConsentRequiredError();
77
59
  }
78
- if (body?.error === IMPERSONATION_REQUIRED_ERROR) {
79
- throw new ImpersonationRequiredError(body.platformUrl);
80
- }
81
60
  }
82
61
 
83
62
  if (!res.ok) {
@@ -144,10 +123,6 @@ export function useAuth() {
144
123
  session,
145
124
  getToken: () =>
146
125
  getFreshToken().catch((error) => {
147
- if (error instanceof ImpersonationRequiredError) {
148
- sendToPicker(error);
149
- throw error;
150
- }
151
126
  console.error(error);
152
127
  const onAuthPage = window.location.pathname.startsWith("/auth/");
153
128
  if (onAuthPage) return;
@@ -3,7 +3,6 @@ export interface TokenClaims {
3
3
  email?: string;
4
4
  act?: string;
5
5
  imp?: string;
6
- prs?: string;
7
6
  roles?: string | string[];
8
7
  }
9
8
 
@@ -1,6 +1,7 @@
1
1
  import { Auth } from "../types";
2
2
  import { resolveAccessToken } from "../route-handler/token";
3
3
  import { platformCall, platformClient, platformNotConfigured } from "./request";
4
+ import { tokenClaims } from "./claims";
4
5
  import { hostOwner } from "../handoff/business";
5
6
 
6
7
  interface ReturnBusiness {
@@ -27,7 +28,9 @@ async function callerClient(authConfig: Auth.Config, request: Request) {
27
28
  const caller = await resolveAccessToken(authConfig, request);
28
29
  if ("error" in caller) return { error: caller.error };
29
30
  const client = platformClient(caller.accessToken);
30
- return client ? { client } : { error: platformNotConfigured() };
31
+ return client
32
+ ? { client, accessToken: caller.accessToken }
33
+ : { error: platformNotConfigured() };
31
34
  }
32
35
 
33
36
  export function createPickerBusinessesHandler(authConfig: Auth.Config) {
@@ -49,6 +52,7 @@ export function createPickerBusinessesHandler(authConfig: Auth.Config) {
49
52
  businesses: businesses.value,
50
53
  apps: apps.value,
51
54
  returnBusiness: await returnBusiness(request),
55
+ ownBusinessId: tokenClaims(caller.accessToken).biz ?? null,
52
56
  });
53
57
  };
54
58
  }
@@ -1,59 +1,8 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { describe, it } from "node:test";
3
- import {
4
- chooseToken,
5
- impersonationPickerUrl,
6
- isSignInPage,
7
- PERSONA_REQUIRED,
8
- personaRequired,
9
- renewDelayMs,
10
- } from "./required.ts";
11
-
12
- describe("token choice for the token route and MCP sign-in", () => {
13
- const persona = { email: "persona@test.snokam.no", accessToken: "imp" };
14
- const snokamSso = { email: "dev@snokam.no", accessToken: "own" };
15
- const customerOwner = { email: "owner@acme.no", accessToken: "own" };
16
-
17
- it("sends a persona-mode user without a persona to the picker", () => {
18
- assert.equal(chooseToken(null, snokamSso, { prs: "1" }), PERSONA_REQUIRED);
19
- });
20
-
21
- it("signs as the chosen persona once a Vis som session is active", () => {
22
- assert.equal(chooseToken(persona, snokamSso, { prs: "1" }), persona);
23
- });
24
-
25
- it("signs a customer as themselves", () => {
26
- assert.equal(chooseToken(null, customerOwner, {}), customerOwner);
27
- });
28
-
29
- it("has nothing to sign with when there is no session", () => {
30
- assert.equal(chooseToken(null, null, {}), null);
31
- });
32
- });
33
-
34
- describe("persona required", () => {
35
- const cases = [
36
- { prs: false, imp: false, act: false, required: false },
37
- { prs: false, imp: true, act: true, required: false },
38
- { prs: false, imp: false, act: true, required: false },
39
- { prs: false, imp: true, act: false, required: false },
40
- { prs: true, imp: false, act: false, required: true },
41
- { prs: true, imp: true, act: true, required: false },
42
- { prs: true, imp: false, act: true, required: false },
43
- { prs: true, imp: true, act: false, required: false },
44
- ];
45
-
46
- for (const { prs, imp, act, required } of cases) {
47
- it(`is ${required} for prs=${prs} imp=${imp} act=${act}`, () => {
48
- const claims = {
49
- ...(prs ? { prs: "1" } : {}),
50
- ...(imp ? { imp: "true" } : {}),
51
- ...(act ? { act: "dev@snokam.no" } : {}),
52
- };
53
- assert.equal(personaRequired(claims), required);
54
- });
55
- }
3
+ import { impersonationPickerUrl, renewDelayMs } from "./required.ts";
56
4
 
5
+ describe("Vis som picker and renewal", () => {
57
6
  it("points the picker back at the page the person asked for", () => {
58
7
  const url = new URL(
59
8
  impersonationPickerUrl(
@@ -69,14 +18,6 @@ describe("persona required", () => {
69
18
  );
70
19
  });
71
20
 
72
- it("never sends a sign-in page to the picker, at the root or under a base path", () => {
73
- assert.equal(isSignInPage("/auth/complete"), true);
74
- assert.equal(isSignInPage("/minside/auth/login"), true);
75
- assert.equal(isSignInPage("/"), false);
76
- assert.equal(isSignInPage("/okonomi"), false);
77
- assert.equal(isSignInPage("/authors"), false);
78
- });
79
-
80
21
  it("renews two minutes before the token expires, and at once when already late", () => {
81
22
  assert.equal(renewDelayMs(1_000, 0), 880_000);
82
23
  assert.equal(renewDelayMs(1_000, 999_000), 0);
@@ -1,41 +1,7 @@
1
- import type { TokenClaims } from "./claims";
2
-
3
- export const IMPERSONATION_REQUIRED_ERROR = "impersonation_required";
4
-
5
1
  export const IMPERSONATION_PICKER_PATH = "/velg-bruker";
6
2
 
7
3
  const RENEW_MARGIN_SECONDS = 120;
8
4
 
9
- export const PERSONA_REQUIRED = "persona_required";
10
-
11
- export function personaRequired(ownClaims: TokenClaims): boolean {
12
- return !!ownClaims.prs && !ownClaims.imp && !ownClaims.act;
13
- }
14
-
15
- export function chooseToken<T>(
16
- viewAs: T | null,
17
- own: T | null,
18
- ownClaims: TokenClaims
19
- ): T | typeof PERSONA_REQUIRED | null {
20
- if (viewAs) return viewAs;
21
- if (!own) return null;
22
- return personaRequired(ownClaims) ? PERSONA_REQUIRED : own;
23
- }
24
-
25
- export function impersonationRequiredResponse(): Response {
26
- return Response.json(
27
- {
28
- error: IMPERSONATION_REQUIRED_ERROR,
29
- platformUrl: process.env.NEXT_PUBLIC_PLATFORM_BASE_URL,
30
- },
31
- { status: 403 }
32
- );
33
- }
34
-
35
- export function isSignInPage(pathname: string): boolean {
36
- return /(^|\/)auth\//.test(pathname);
37
- }
38
-
39
5
  export function impersonationPickerUrl(
40
6
  platformBaseUrl: string,
41
7
  returnTo: string
@@ -8,12 +8,6 @@ import { getAuthUrl, getSessionCookieSettings } from "../session";
8
8
  import { exchangePasswordToken } from "../password/api";
9
9
  import { readImpersonationSession } from "../impersonation/cookies";
10
10
  import { exchangeSsoToken } from "../sso/exchange";
11
- import { tokenClaims } from "../impersonation/claims";
12
- import {
13
- chooseToken,
14
- impersonationRequiredResponse,
15
- PERSONA_REQUIRED,
16
- } from "../impersonation/required";
17
11
 
18
12
  export type TokenResult =
19
13
  { accessToken: string; expiresOn: number } | { error: Response };
@@ -120,10 +114,6 @@ export function createTokenHandler(authConfig: Auth.Config) {
120
114
  if ("error" in result) {
121
115
  return result.error;
122
116
  }
123
- const chosen = chooseToken(null, result, tokenClaims(result.accessToken));
124
- if (chosen === PERSONA_REQUIRED) {
125
- return impersonationRequiredResponse();
126
- }
127
- return Response.json(chosen);
117
+ return Response.json(result);
128
118
  };
129
119
  }