@snokam/auth 5.2.2 → 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 +12 -0
- package/package.json +5 -5
- package/src/app-router/components/Login.tsx +6 -13
- package/src/app-sign-in/handlers.ts +2 -49
- package/src/components/Error/index.tsx +1 -10
- package/src/hooks.ts +0 -25
- package/src/impersonation/claims.test.ts +7 -14
- package/src/impersonation/claims.ts +0 -7
- 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 +12 -2
- package/src/impersonation/required.test.ts +2 -61
- package/src/impersonation/required.ts +0 -34
- package/src/impersonation/routes.ts +3 -0
- package/src/impersonation/ticket.ts +1 -0
- package/src/route-handler/token.ts +1 -11
- 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,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.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
|
+
|
|
12
|
+
# [5.3.0](https://github.com/snokam/components/compare/@snokam/auth@5.2.2...@snokam/auth@5.3.0) (2026-10-14)
|
|
13
|
+
|
|
14
|
+
### Features
|
|
15
|
+
|
|
16
|
+
- 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)
|
|
17
|
+
|
|
6
18
|
## [5.2.2](https://github.com/snokam/components/compare/@snokam/auth@5.2.0...@snokam/auth@5.2.2) (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.
|
|
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.2.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.
|
|
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 (
|
|
@@ -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
|
-
|
|
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 {
|
|
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;
|
|
@@ -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,13 +3,6 @@ export interface TokenClaims {
|
|
|
3
3
|
email?: string;
|
|
4
4
|
act?: string;
|
|
5
5
|
imp?: string;
|
|
6
|
-
prs?: string;
|
|
7
|
-
roles?: string | string[];
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
export function claimedRoles(claims: TokenClaims): string[] {
|
|
11
|
-
if (!claims.roles) return [];
|
|
12
|
-
return Array.isArray(claims.roles) ? claims.roles : [claims.roles];
|
|
13
6
|
}
|
|
14
7
|
|
|
15
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);
|
|
@@ -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
|
|
31
|
+
return client
|
|
32
|
+
? { client, accessToken: caller.accessToken }
|
|
33
|
+
: { error: platformNotConfigured() };
|
|
31
34
|
}
|
|
32
35
|
|
|
33
36
|
export function createPickerBusinessesHandler(authConfig: Auth.Config) {
|
|
@@ -39,7 +42,13 @@ export function createPickerBusinessesHandler(authConfig: Auth.Config) {
|
|
|
39
42
|
platformCall(caller.client.listImpersonationBusinesses()),
|
|
40
43
|
platformCall(caller.client.getMeApps()),
|
|
41
44
|
]);
|
|
42
|
-
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
|
+
}
|
|
43
52
|
if ("error" in apps) return apps.error;
|
|
44
53
|
if (businesses.value.length === 0) {
|
|
45
54
|
return new Response("Not found", { status: 404 });
|
|
@@ -49,6 +58,7 @@ export function createPickerBusinessesHandler(authConfig: Auth.Config) {
|
|
|
49
58
|
businesses: businesses.value,
|
|
50
59
|
apps: apps.value,
|
|
51
60
|
returnBusiness: await returnBusiness(request),
|
|
61
|
+
ownBusinessId: tokenClaims(caller.accessToken).biz ?? null,
|
|
52
62
|
});
|
|
53
63
|
};
|
|
54
64
|
}
|
|
@@ -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
|
|
@@ -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 });
|
|
@@ -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
|
-
|
|
124
|
-
if (chosen === PERSONA_REQUIRED) {
|
|
125
|
-
return impersonationRequiredResponse();
|
|
126
|
-
}
|
|
127
|
-
return Response.json(chosen);
|
|
117
|
+
return Response.json(result);
|
|
128
118
|
};
|
|
129
119
|
}
|
|
@@ -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