@snokam/auth 5.3.1 → 5.3.3
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 +12 -0
- package/package.json +2 -2
- package/src/components/NotProvisioned/index.tsx +3 -1
- package/src/components/NotProvisioned/usePickerRedirect.ts +41 -0
- package/src/impersonation/context.tsx +6 -1
- package/src/impersonation/exit.test.ts +82 -5
- package/src/impersonation/exit.ts +29 -6
- package/src/impersonation/handlers.ts +5 -1
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.3](https://github.com/snokam/components/compare/@snokam/auth@5.3.2...@snokam/auth@5.3.3) (2026-10-14)
|
|
7
|
+
|
|
8
|
+
### Bug Fixes
|
|
9
|
+
|
|
10
|
+
- send test operators from "no access" to the Vis som picker, land on Min side ([#2953](https://github.com/snokam/components/issues/2953)) ([dcce011](https://github.com/snokam/components/commit/dcce011160cd4b0d5661c41ce372ee15b905ebf9)), closes [#2949](https://github.com/snokam/components/issues/2949)
|
|
11
|
+
|
|
12
|
+
## [5.3.2](https://github.com/snokam/components/compare/@snokam/auth@5.3.1...@snokam/auth@5.3.2) (2026-10-14)
|
|
13
|
+
|
|
14
|
+
### Bug Fixes
|
|
15
|
+
|
|
16
|
+
- **auth:** allow-list and chain every Vis som session host on exit ([#2951](https://github.com/snokam/components/issues/2951)) ([ee10692](https://github.com/snokam/components/commit/ee1069225349380b3a7d7087ed6007a3a4fff28e))
|
|
17
|
+
|
|
6
18
|
## [5.3.1](https://github.com/snokam/components/compare/@snokam/auth@5.3.0...@snokam/auth@5.3.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.3.
|
|
4
|
+
"version": "5.3.3",
|
|
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",
|
|
29
30
|
"@snokam/platform-client": "5.3.1",
|
|
30
31
|
"@snokam/react-query": "1.1.0",
|
|
31
32
|
"@snokam/sanity-page-content": "6.5.2",
|
|
32
33
|
"@snokam/tenant": "4.3.1",
|
|
33
|
-
"@snokam/identity": "1.1.0",
|
|
34
34
|
"@snokam/ui": "6.1.0"
|
|
35
35
|
},
|
|
36
36
|
"peerDependencies": {
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
PageContentProvider,
|
|
9
9
|
} from "@snokam/sanity-page-content/app-router/providers";
|
|
10
10
|
import { AccessHelp } from "./AccessHelp";
|
|
11
|
+
import { usePickerRedirect } from "./usePickerRedirect";
|
|
11
12
|
|
|
12
13
|
const ACCESS_GATE_PAGE_KEY = "access-gate";
|
|
13
14
|
|
|
@@ -20,8 +21,9 @@ export const NotProvisioned = ({ appKey }: Props) => {
|
|
|
20
21
|
queryKey: ["access-gate-content", appKey],
|
|
21
22
|
queryFn: () => loadAppContent(appKey),
|
|
22
23
|
});
|
|
24
|
+
const checkingPicker = usePickerRedirect();
|
|
23
25
|
|
|
24
|
-
if (isPending) {
|
|
26
|
+
if (isPending || checkingPicker) {
|
|
25
27
|
return <PageLoader />;
|
|
26
28
|
}
|
|
27
29
|
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
import { useOptionalConfig } from "@snokam/tenant";
|
|
5
|
+
import { impersonationPickerUrl } from "../../impersonation/required";
|
|
6
|
+
|
|
7
|
+
const PICKER_BUSINESSES_PATH = "/api/impersonation/businesses";
|
|
8
|
+
|
|
9
|
+
// The picker's businesses route answers 200 only with persona mode on and an operator's own
|
|
10
|
+
// token, so a signed-in operator who is no employee here goes to pick a test user instead.
|
|
11
|
+
export function usePickerRedirect(): boolean {
|
|
12
|
+
const platformBaseUrl = useOptionalConfig()?.NEXT_PUBLIC_PLATFORM_BASE_URL;
|
|
13
|
+
const [checking, setChecking] = useState(Boolean(platformBaseUrl));
|
|
14
|
+
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
if (!platformBaseUrl) return;
|
|
17
|
+
let cancelled = false;
|
|
18
|
+
const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? "";
|
|
19
|
+
|
|
20
|
+
fetch(`${basePath}${PICKER_BUSINESSES_PATH}`, { credentials: "include" })
|
|
21
|
+
.then((response) => {
|
|
22
|
+
if (cancelled) return;
|
|
23
|
+
if (!response.ok) {
|
|
24
|
+
setChecking(false);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
window.location.replace(
|
|
28
|
+
impersonationPickerUrl(platformBaseUrl, window.location.href)
|
|
29
|
+
);
|
|
30
|
+
})
|
|
31
|
+
.catch(() => {
|
|
32
|
+
if (!cancelled) setChecking(false);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
return () => {
|
|
36
|
+
cancelled = true;
|
|
37
|
+
};
|
|
38
|
+
}, [platformBaseUrl]);
|
|
39
|
+
|
|
40
|
+
return checking;
|
|
41
|
+
}
|
|
@@ -128,7 +128,12 @@ export function ImpersonationProvider({ children }: { children: ReactNode }) {
|
|
|
128
128
|
: window.location.href;
|
|
129
129
|
setActive(null);
|
|
130
130
|
window.location.assign(
|
|
131
|
-
exitDestination(
|
|
131
|
+
exitDestination(
|
|
132
|
+
active?.origins,
|
|
133
|
+
window.location.origin,
|
|
134
|
+
destination,
|
|
135
|
+
(origin) => isSafeReturnTo(origin, apiBaseUrl)
|
|
136
|
+
)
|
|
132
137
|
);
|
|
133
138
|
} finally {
|
|
134
139
|
setExiting(false);
|
|
@@ -1,14 +1,36 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { describe, it } from "node:test";
|
|
3
|
-
import { exitDestination } from "./exit.ts";
|
|
3
|
+
import { exitDestination, withOrigin } from "./exit.ts";
|
|
4
|
+
import { isAllowedRedirect } from "./redirects.ts";
|
|
4
5
|
|
|
5
6
|
const platform = "https://www.test.snosky.no";
|
|
6
7
|
const tenant = "https://snokam.test.snosky.no";
|
|
8
|
+
const other = "https://andre.test.snosky.no";
|
|
7
9
|
const minside = `${tenant}/minside`;
|
|
8
10
|
|
|
11
|
+
function allowedFrom(origin: string) {
|
|
12
|
+
return (target: string) =>
|
|
13
|
+
isAllowedRedirect(target, { origin, platformDomain: "snosky.no" });
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function leave(origins: string[] | undefined, from: string, to: string) {
|
|
17
|
+
return exitDestination(origins, from, to, allowedFrom(from));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function hops(url: string): string[] {
|
|
21
|
+
const chain = [];
|
|
22
|
+
let next: string | null = url;
|
|
23
|
+
while (next) {
|
|
24
|
+
const parsed: URL = new URL(next);
|
|
25
|
+
chain.push(parsed.origin);
|
|
26
|
+
next = parsed.searchParams.get("returnTo");
|
|
27
|
+
}
|
|
28
|
+
return chain;
|
|
29
|
+
}
|
|
30
|
+
|
|
9
31
|
describe("leaving Vis som", () => {
|
|
10
32
|
it("clears the other host before landing when the session spans two hosts", () => {
|
|
11
|
-
const url = new URL(
|
|
33
|
+
const url = new URL(leave([platform, tenant], tenant, minside));
|
|
12
34
|
assert.equal(url.origin, platform);
|
|
13
35
|
assert.equal(url.pathname, "/api/impersonation/clear");
|
|
14
36
|
assert.equal(url.searchParams.get("returnTo"), minside);
|
|
@@ -16,14 +38,69 @@ describe("leaving Vis som", () => {
|
|
|
16
38
|
|
|
17
39
|
it("clears the tenant host when leaving from the platform host", () => {
|
|
18
40
|
const url = new URL(
|
|
19
|
-
|
|
41
|
+
leave([platform, tenant], platform, `${platform}/manage`)
|
|
20
42
|
);
|
|
21
43
|
assert.equal(url.origin, tenant);
|
|
22
44
|
assert.equal(url.searchParams.get("returnTo"), `${platform}/manage`);
|
|
23
45
|
});
|
|
24
46
|
|
|
25
47
|
it("goes straight to the destination when only this host holds the session", () => {
|
|
26
|
-
assert.equal(
|
|
27
|
-
assert.equal(
|
|
48
|
+
assert.equal(leave([tenant], tenant, minside), minside);
|
|
49
|
+
assert.equal(leave(undefined, tenant, minside), minside);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("chains through every other host that adopted the session", () => {
|
|
53
|
+
assert.deepEqual(hops(leave([platform, tenant, other], tenant, minside)), [
|
|
54
|
+
platform,
|
|
55
|
+
other,
|
|
56
|
+
tenant,
|
|
57
|
+
]);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("visits each host once even when it is listed twice", () => {
|
|
61
|
+
assert.deepEqual(
|
|
62
|
+
hops(leave([platform, platform, `${platform}/`], tenant, minside)),
|
|
63
|
+
[platform, tenant]
|
|
64
|
+
);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe("a tampered session-host list", () => {
|
|
69
|
+
const foreign = [
|
|
70
|
+
"https://evil.example",
|
|
71
|
+
"https://snosky.no.evil.example",
|
|
72
|
+
"http://plain.test.snosky.no",
|
|
73
|
+
"javascript:alert(1)",
|
|
74
|
+
"not a url",
|
|
75
|
+
];
|
|
76
|
+
|
|
77
|
+
it("never redirects to a host outside the allow-list", () => {
|
|
78
|
+
const url = leave([...foreign, tenant], tenant, minside);
|
|
79
|
+
assert.equal(url, minside);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("keeps the allowed hosts and drops the foreign ones", () => {
|
|
83
|
+
assert.deepEqual(
|
|
84
|
+
hops(leave([platform, ...foreign, other], tenant, minside)),
|
|
85
|
+
[platform, other, tenant]
|
|
86
|
+
);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe("recording the hosts a session lives on", () => {
|
|
91
|
+
it("appends a host that adopts the session", () => {
|
|
92
|
+
assert.deepEqual(withOrigin([platform, tenant], other), [
|
|
93
|
+
platform,
|
|
94
|
+
tenant,
|
|
95
|
+
other,
|
|
96
|
+
]);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("leaves the list alone when the host is already on it", () => {
|
|
100
|
+
assert.deepEqual(withOrigin([platform, tenant], tenant), [
|
|
101
|
+
platform,
|
|
102
|
+
tenant,
|
|
103
|
+
]);
|
|
104
|
+
assert.deepEqual(withOrigin(undefined, tenant), [tenant]);
|
|
28
105
|
});
|
|
29
106
|
});
|
|
@@ -1,13 +1,36 @@
|
|
|
1
1
|
export const CLEAR_PATH = "/api/impersonation/clear";
|
|
2
2
|
|
|
3
|
+
export function withOrigin(
|
|
4
|
+
origins: string[] | undefined,
|
|
5
|
+
origin: string
|
|
6
|
+
): string[] {
|
|
7
|
+
const known = origins ?? [];
|
|
8
|
+
return known.includes(origin) ? known : [...known, origin];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function otherSessionHosts(
|
|
12
|
+
origins: string[] | undefined,
|
|
13
|
+
currentOrigin: string,
|
|
14
|
+
isAllowed: (origin: string) => boolean
|
|
15
|
+
): string[] {
|
|
16
|
+
const hosts = (origins ?? [])
|
|
17
|
+
.filter(isAllowed)
|
|
18
|
+
.map((origin) => new URL(origin, currentOrigin).origin);
|
|
19
|
+
return [...new Set(hosts)].filter((host) => host !== currentOrigin);
|
|
20
|
+
}
|
|
21
|
+
|
|
3
22
|
export function exitDestination(
|
|
4
23
|
origins: string[] | undefined,
|
|
5
24
|
currentOrigin: string,
|
|
6
|
-
destination: string
|
|
25
|
+
destination: string,
|
|
26
|
+
isAllowed: (origin: string) => boolean
|
|
7
27
|
): string {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
28
|
+
return otherSessionHosts(origins, currentOrigin, isAllowed).reduceRight(
|
|
29
|
+
(next, host) => {
|
|
30
|
+
const clear = new URL(CLEAR_PATH, host);
|
|
31
|
+
clear.searchParams.set("returnTo", next);
|
|
32
|
+
return clear.toString();
|
|
33
|
+
},
|
|
34
|
+
destination
|
|
35
|
+
);
|
|
13
36
|
}
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
requestHost,
|
|
15
15
|
} from "./request";
|
|
16
16
|
import { isAllowedRedirect, platformBaseDomain } from "./redirects";
|
|
17
|
+
import { withOrigin } from "./exit";
|
|
17
18
|
|
|
18
19
|
interface StartBody {
|
|
19
20
|
targetEmail?: string;
|
|
@@ -169,7 +170,10 @@ export function createImpersonationAdoptHandler(authConfig: Auth.Config) {
|
|
|
169
170
|
);
|
|
170
171
|
return withCookies(
|
|
171
172
|
new Response(null, { status: 302, headers: { Location: dest } }),
|
|
172
|
-
buildImpersonationCookies(
|
|
173
|
+
buildImpersonationCookies(
|
|
174
|
+
{ ...claims, origins: withOrigin(claims.origins, origin) },
|
|
175
|
+
host
|
|
176
|
+
)
|
|
173
177
|
);
|
|
174
178
|
};
|
|
175
179
|
}
|