@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 +6 -0
- package/package.json +5 -5
- package/src/app-router/components/Login.tsx +6 -13
- package/src/impersonation/claims.test.ts +7 -14
- package/src/impersonation/claims.ts +0 -6
- package/src/impersonation/context.tsx +8 -5
- package/src/impersonation/cookies.ts +4 -3
- package/src/impersonation/exit.test.ts +29 -0
- package/src/impersonation/exit.ts +13 -0
- package/src/impersonation/handlers.ts +23 -4
- package/src/impersonation/picker.ts +7 -1
- package/src/impersonation/routes.ts +3 -0
- package/src/impersonation/ticket.ts +1 -0
- package/src/session/callback-url.test.ts +22 -0
- package/src/session/callback-url.ts +8 -0
- package/src/session/index.ts +1 -1
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.
|
|
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/
|
|
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.
|
|
33
|
-
"@snokam/tenant": "4.3.
|
|
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 {
|
|
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
|
-
|
|
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 {
|
|
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("
|
|
11
|
-
it("reads the actor and
|
|
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.
|
|
16
|
+
assert.equal(claims.biz, "biz-1");
|
|
20
17
|
});
|
|
21
18
|
|
|
22
|
-
it("reads
|
|
23
|
-
assert.deepEqual(
|
|
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
|
-
|
|
126
|
-
window.location.
|
|
127
|
-
|
|
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 {
|
|
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
|
-
|
|
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 (!
|
|
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)
|
|
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 });
|
|
@@ -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
|
+
}
|
package/src/session/index.ts
CHANGED