@snokam/auth 3.3.12 → 3.5.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 +12 -0
- package/package.json +7 -6
- package/src/app-router/index.tsx +1 -0
- package/src/app-sign-in/handlers.ts +65 -12
- package/src/hooks.ts +7 -0
- package/src/impersonation/RequiredImpersonationGate.tsx +51 -0
- package/src/impersonation/context.tsx +17 -14
- package/src/impersonation/cookies.ts +15 -7
- package/src/impersonation/handlers.ts +16 -26
- package/src/impersonation/personas.ts +52 -0
- package/src/impersonation/renew.ts +75 -0
- package/src/impersonation/renewal.ts +42 -0
- package/src/impersonation/request.ts +26 -0
- package/src/impersonation/required.test.ts +86 -0
- package/src/impersonation/required.ts +55 -0
- package/src/impersonation/routes.ts +6 -2
- package/src/route-handler/token.ts +11 -3
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
|
+
# [3.5.0](https://github.com/snokam/components/compare/@snokam/auth@3.4.0...@snokam/auth@3.5.0) (2026-10-06)
|
|
7
|
+
|
|
8
|
+
### Features
|
|
9
|
+
|
|
10
|
+
- **test:** pick a test user straight after sign-in, on snosky.no and for MCP sign-in too ([#2680](https://github.com/snokam/components/issues/2680)) ([eea727a](https://github.com/snokam/components/commit/eea727ae1a607139c7c30610109e877eaa20dae9))
|
|
11
|
+
|
|
12
|
+
# [3.4.0](https://github.com/snokam/components/compare/@snokam/auth@3.3.12...@snokam/auth@3.4.0) (2026-10-06)
|
|
13
|
+
|
|
14
|
+
### Features
|
|
15
|
+
|
|
16
|
+
- require Vis som in test, and renew view-as sessions while the operator is signed in ([#2676](https://github.com/snokam/components/issues/2676)) ([e9c7bc9](https://github.com/snokam/components/commit/e9c7bc986508d9664bfcc78161c10381981c9c49))
|
|
17
|
+
|
|
6
18
|
## [3.3.12](https://github.com/snokam/components/compare/@snokam/auth@3.3.11...@snokam/auth@3.3.12) (2026-10-05)
|
|
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": "3.
|
|
4
|
+
"version": "3.5.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": "3.
|
|
30
|
+
"@snokam/platform-client": "3.6.0",
|
|
31
|
+
"@snokam/tenant": "2.1.13",
|
|
32
|
+
"@snokam/ui": "4.0.0",
|
|
31
33
|
"@snokam/react-query": "1.1.0",
|
|
32
|
-
"@snokam/sanity-page-content": "4.4.
|
|
33
|
-
"@snokam/tenant": "2.1.12",
|
|
34
|
-
"@snokam/ui": "4.0.0"
|
|
34
|
+
"@snokam/sanity-page-content": "4.4.2"
|
|
35
35
|
},
|
|
36
36
|
"peerDependencies": {
|
|
37
37
|
"next": "16.3.3",
|
|
@@ -53,6 +53,7 @@
|
|
|
53
53
|
"./sso-tenant": "./src/sso/tenant-cookie.ts"
|
|
54
54
|
},
|
|
55
55
|
"scripts": {
|
|
56
|
-
"lint": "eslint ."
|
|
56
|
+
"lint": "eslint .",
|
|
57
|
+
"test": "node --test \"src/**/*.test.ts\""
|
|
57
58
|
}
|
|
58
59
|
}
|
package/src/app-router/index.tsx
CHANGED
|
@@ -33,6 +33,7 @@ export {
|
|
|
33
33
|
useImpersonation,
|
|
34
34
|
} from "../impersonation/context";
|
|
35
35
|
export type { ImpersonationActive } from "../impersonation/cookies";
|
|
36
|
+
export { impersonationRequired } from "../impersonation/required";
|
|
36
37
|
export { signIn, SessionProvider } from "next-auth/react";
|
|
37
38
|
export {
|
|
38
39
|
Login,
|
|
@@ -6,6 +6,14 @@ import {
|
|
|
6
6
|
requestOrigin,
|
|
7
7
|
} from "../session";
|
|
8
8
|
import { resolveAccessToken } from "../route-handler/token";
|
|
9
|
+
import {
|
|
10
|
+
readImpersonationActive,
|
|
11
|
+
readImpersonationSession,
|
|
12
|
+
} from "../impersonation/cookies";
|
|
13
|
+
import {
|
|
14
|
+
impersonationPickerUrl,
|
|
15
|
+
impersonationRequired,
|
|
16
|
+
} from "../impersonation/required";
|
|
9
17
|
import type { Auth } from "../types";
|
|
10
18
|
import {
|
|
11
19
|
type AuthorizationParams,
|
|
@@ -45,6 +53,47 @@ async function signedInEmail(
|
|
|
45
53
|
return token?.refreshToken && token.email ? String(token.email) : null;
|
|
46
54
|
}
|
|
47
55
|
|
|
56
|
+
interface Signer {
|
|
57
|
+
email: string;
|
|
58
|
+
accessToken: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function personaSigner(request: Request): Signer | null {
|
|
62
|
+
const session = readImpersonationSession(request);
|
|
63
|
+
const active = readImpersonationActive(request);
|
|
64
|
+
if (!session || !active || session.expiresOn * 1000 <= Date.now()) {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
return { email: active.targetEmail, accessToken: session.accessToken };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function ownSigner(
|
|
71
|
+
authConfig: Auth.Config,
|
|
72
|
+
request: Request,
|
|
73
|
+
email: string
|
|
74
|
+
): Promise<Signer | null> {
|
|
75
|
+
const access = await resolveAccessToken(authConfig, request);
|
|
76
|
+
return "error" in access ? null : { email, accessToken: access.accessToken };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function signerFor(
|
|
80
|
+
authConfig: Auth.Config,
|
|
81
|
+
request: Request,
|
|
82
|
+
email: string
|
|
83
|
+
): Promise<Signer | null> {
|
|
84
|
+
if (impersonationRequired(authConfig.environment)) {
|
|
85
|
+
return Promise.resolve(personaSigner(request));
|
|
86
|
+
}
|
|
87
|
+
return ownSigner(authConfig, request, email);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function pickerRedirect(request: Request): Response {
|
|
91
|
+
const url = new URL(request.url);
|
|
92
|
+
const origin = requestOrigin(request);
|
|
93
|
+
const resume = `${origin}${url.pathname}${url.search}`;
|
|
94
|
+
return Response.redirect(impersonationPickerUrl(origin, resume), 302);
|
|
95
|
+
}
|
|
96
|
+
|
|
48
97
|
function loginRedirect(request: Request, resource: string, prompt?: string) {
|
|
49
98
|
const url = new URL(request.url);
|
|
50
99
|
const resume = new URLSearchParams(url.searchParams);
|
|
@@ -105,13 +154,17 @@ export function createAppSignInAuthorizeHandler(authConfig: Auth.Config) {
|
|
|
105
154
|
const params = readParams(query);
|
|
106
155
|
if (!params) return invalidRequest();
|
|
107
156
|
|
|
108
|
-
const
|
|
109
|
-
if (!
|
|
157
|
+
const signedIn = await signedInEmail(request, authConfig.nextAuthSecret);
|
|
158
|
+
if (!signedIn) return loginRedirect(request, params.resource);
|
|
110
159
|
|
|
111
|
-
const
|
|
112
|
-
if (
|
|
160
|
+
const signer = await signerFor(authConfig, request, signedIn);
|
|
161
|
+
if (!signer) {
|
|
162
|
+
return impersonationRequired(authConfig.environment)
|
|
163
|
+
? pickerRedirect(request)
|
|
164
|
+
: loginRedirect(request, params.resource);
|
|
165
|
+
}
|
|
113
166
|
|
|
114
|
-
const preview = await previewAuthorization(params,
|
|
167
|
+
const preview = await previewAuthorization(params, signer.accessToken);
|
|
115
168
|
if ("error" in preview) {
|
|
116
169
|
return preview.error === ACCESS_DENIED
|
|
117
170
|
? loginRedirect(request, params.resource, "login")
|
|
@@ -125,7 +178,7 @@ export function createAppSignInAuthorizeHandler(authConfig: Auth.Config) {
|
|
|
125
178
|
...preview.value,
|
|
126
179
|
business: new URL(params.resource).hostname,
|
|
127
180
|
state: query.get("state") ?? "",
|
|
128
|
-
email,
|
|
181
|
+
email: signer.email,
|
|
129
182
|
nonce,
|
|
130
183
|
},
|
|
131
184
|
authConfig.nextAuthSecret
|
|
@@ -155,8 +208,11 @@ export function createAppSignInApproveHandler(authConfig: Auth.Config) {
|
|
|
155
208
|
return invalidRequest();
|
|
156
209
|
}
|
|
157
210
|
|
|
158
|
-
const
|
|
159
|
-
if (
|
|
211
|
+
const signedIn = await signedInEmail(request, authConfig.nextAuthSecret);
|
|
212
|
+
if (!signedIn) return invalidRequest();
|
|
213
|
+
|
|
214
|
+
const signer = await signerFor(authConfig, request, signedIn);
|
|
215
|
+
if (!signer || signer.email !== consent.email) return invalidRequest();
|
|
160
216
|
|
|
161
217
|
if (form.get("decision") !== "approve") {
|
|
162
218
|
return clientRedirect(request, consent.redirectUri, consent.state, {
|
|
@@ -164,10 +220,7 @@ export function createAppSignInApproveHandler(authConfig: Auth.Config) {
|
|
|
164
220
|
});
|
|
165
221
|
}
|
|
166
222
|
|
|
167
|
-
const
|
|
168
|
-
if ("error" in access) return invalidRequest();
|
|
169
|
-
|
|
170
|
-
const approved = await approveAuthorization(consent, access.accessToken);
|
|
223
|
+
const approved = await approveAuthorization(consent, signer.accessToken);
|
|
171
224
|
return clientRedirect(
|
|
172
225
|
request,
|
|
173
226
|
consent.redirectUri,
|
package/src/hooks.ts
CHANGED
|
@@ -3,9 +3,12 @@ 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 { IMPERSONATION_REQUIRED_ERROR } from "./impersonation/required";
|
|
6
7
|
|
|
7
8
|
class ConsentRequiredError extends Error {}
|
|
8
9
|
|
|
10
|
+
class ImpersonationRequiredError extends Error {}
|
|
11
|
+
|
|
9
12
|
const REFRESH_MARGIN_SECONDS = 60;
|
|
10
13
|
|
|
11
14
|
interface CachedToken {
|
|
@@ -62,6 +65,9 @@ async function fetchToken(impKey: string): Promise<string> {
|
|
|
62
65
|
if (body?.error === "consent_required") {
|
|
63
66
|
throw new ConsentRequiredError();
|
|
64
67
|
}
|
|
68
|
+
if (body?.error === IMPERSONATION_REQUIRED_ERROR) {
|
|
69
|
+
throw new ImpersonationRequiredError();
|
|
70
|
+
}
|
|
65
71
|
}
|
|
66
72
|
|
|
67
73
|
if (!res.ok) {
|
|
@@ -128,6 +134,7 @@ export function useAuth() {
|
|
|
128
134
|
session,
|
|
129
135
|
getToken: () =>
|
|
130
136
|
getFreshToken().catch((error) => {
|
|
137
|
+
if (error instanceof ImpersonationRequiredError) throw error;
|
|
131
138
|
console.error(error);
|
|
132
139
|
const onAuthPage = window.location.pathname.startsWith("/auth/");
|
|
133
140
|
if (onAuthPage) return;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, type ReactNode } from "react";
|
|
4
|
+
import { usePathname } from "next/navigation";
|
|
5
|
+
import { PageLoader } from "@snokam/ui";
|
|
6
|
+
import { useOptionalConfig } from "@snokam/tenant";
|
|
7
|
+
import { useSession } from "../session/useSession";
|
|
8
|
+
import {
|
|
9
|
+
impersonationPickerUrl,
|
|
10
|
+
impersonationRequired,
|
|
11
|
+
isExemptRoute,
|
|
12
|
+
sendsToPicker,
|
|
13
|
+
withholdsApp,
|
|
14
|
+
} from "./required";
|
|
15
|
+
|
|
16
|
+
interface Props {
|
|
17
|
+
sessionActive: boolean;
|
|
18
|
+
cookieRead: boolean;
|
|
19
|
+
children: ReactNode;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function RequiredImpersonationGate({
|
|
23
|
+
sessionActive,
|
|
24
|
+
cookieRead,
|
|
25
|
+
children,
|
|
26
|
+
}: Props) {
|
|
27
|
+
const config = useOptionalConfig();
|
|
28
|
+
const { status, data: session } = useSession();
|
|
29
|
+
const pathname = usePathname();
|
|
30
|
+
const platformUrl = config?.NEXT_PUBLIC_PLATFORM_BASE_URL;
|
|
31
|
+
|
|
32
|
+
const state = {
|
|
33
|
+
required: impersonationRequired(config?.NEXT_PUBLIC_ENVIRONMENT),
|
|
34
|
+
authenticated:
|
|
35
|
+
status === "authenticated" || (status === "loading" && !!session),
|
|
36
|
+
sessionActive,
|
|
37
|
+
cookieRead,
|
|
38
|
+
exemptRoute: isExemptRoute(pathname),
|
|
39
|
+
};
|
|
40
|
+
const redirect = sendsToPicker(state);
|
|
41
|
+
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
if (!redirect || !platformUrl) return;
|
|
44
|
+
window.location.assign(
|
|
45
|
+
impersonationPickerUrl(platformUrl, window.location.href)
|
|
46
|
+
);
|
|
47
|
+
}, [redirect, platformUrl]);
|
|
48
|
+
|
|
49
|
+
if (withholdsApp(state)) return <PageLoader />;
|
|
50
|
+
return <>{children}</>;
|
|
51
|
+
}
|
|
@@ -11,6 +11,8 @@ import {
|
|
|
11
11
|
} from "react";
|
|
12
12
|
import { IMP_ACTIVE_COOKIE, type ImpersonationActive } from "./cookies";
|
|
13
13
|
import { clearAuthTokenCache } from "../hooks";
|
|
14
|
+
import { useSessionRenewal } from "./renewal";
|
|
15
|
+
import { RequiredImpersonationGate } from "./RequiredImpersonationGate";
|
|
14
16
|
|
|
15
17
|
interface ImpersonationContextValue {
|
|
16
18
|
active: ImpersonationActive | null;
|
|
@@ -24,6 +26,7 @@ interface ImpersonationContextValue {
|
|
|
24
26
|
redirectTo?: string;
|
|
25
27
|
returnTo?: string;
|
|
26
28
|
landingUrl?: string;
|
|
29
|
+
handOffOnly?: boolean;
|
|
27
30
|
}) => Promise<{ ok: boolean; error?: string }>;
|
|
28
31
|
exit: () => Promise<void>;
|
|
29
32
|
}
|
|
@@ -74,24 +77,18 @@ function readActiveCookie(): ImpersonationActive | null {
|
|
|
74
77
|
|
|
75
78
|
export function ImpersonationProvider({ children }: { children: ReactNode }) {
|
|
76
79
|
const [active, setActive] = useState<ImpersonationActive | null>(null);
|
|
80
|
+
const [cookieRead, setCookieRead] = useState(false);
|
|
77
81
|
const [starting, setStarting] = useState(false);
|
|
78
82
|
const [exiting, setExiting] = useState(false);
|
|
79
83
|
|
|
80
|
-
|
|
81
|
-
setActive(readActiveCookie());
|
|
82
|
-
}, []);
|
|
84
|
+
const rereadCookie = useCallback(() => setActive(readActiveCookie()), []);
|
|
83
85
|
|
|
84
|
-
// Auto-exit at token expiry so a stale banner/token never lingers.
|
|
85
86
|
useEffect(() => {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
}
|
|
92
|
-
const timer = setTimeout(() => setActive(null), ms);
|
|
93
|
-
return () => clearTimeout(timer);
|
|
94
|
-
}, [active]);
|
|
87
|
+
rereadCookie();
|
|
88
|
+
setCookieRead(true);
|
|
89
|
+
}, [rereadCookie]);
|
|
90
|
+
|
|
91
|
+
useSessionRenewal(active, rereadCookie);
|
|
95
92
|
|
|
96
93
|
const start = useCallback(
|
|
97
94
|
async (input: {
|
|
@@ -102,6 +99,7 @@ export function ImpersonationProvider({ children }: { children: ReactNode }) {
|
|
|
102
99
|
redirectTo?: string;
|
|
103
100
|
returnTo?: string;
|
|
104
101
|
landingUrl?: string;
|
|
102
|
+
handOffOnly?: boolean;
|
|
105
103
|
}) => {
|
|
106
104
|
setStarting(true);
|
|
107
105
|
try {
|
|
@@ -161,7 +159,12 @@ export function ImpersonationProvider({ children }: { children: ReactNode }) {
|
|
|
161
159
|
|
|
162
160
|
return (
|
|
163
161
|
<ImpersonationContext.Provider value={value}>
|
|
164
|
-
|
|
162
|
+
<RequiredImpersonationGate
|
|
163
|
+
sessionActive={!!active}
|
|
164
|
+
cookieRead={cookieRead}
|
|
165
|
+
>
|
|
166
|
+
{children}
|
|
167
|
+
</RequiredImpersonationGate>
|
|
165
168
|
</ImpersonationContext.Provider>
|
|
166
169
|
);
|
|
167
170
|
}
|
|
@@ -44,20 +44,28 @@ function parseCookieHeader(header: string | null): Record<string, string> {
|
|
|
44
44
|
return jar;
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
|
|
48
|
-
request
|
|
49
|
-
): ImpersonationSession | null {
|
|
50
|
-
const raw = parseCookieHeader(request.headers.get("cookie"))[
|
|
51
|
-
IMP_SESSION_COOKIE
|
|
52
|
-
];
|
|
47
|
+
function readCookieJson<T>(request: Request, name: string): T | null {
|
|
48
|
+
const raw = parseCookieHeader(request.headers.get("cookie"))[name];
|
|
53
49
|
if (!raw) return null;
|
|
54
50
|
try {
|
|
55
|
-
return JSON.parse(raw) as
|
|
51
|
+
return JSON.parse(raw) as T;
|
|
56
52
|
} catch {
|
|
57
53
|
return null;
|
|
58
54
|
}
|
|
59
55
|
}
|
|
60
56
|
|
|
57
|
+
export function readImpersonationSession(
|
|
58
|
+
request: Request
|
|
59
|
+
): ImpersonationSession | null {
|
|
60
|
+
return readCookieJson<ImpersonationSession>(request, IMP_SESSION_COOKIE);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function readImpersonationActive(
|
|
64
|
+
request: Request
|
|
65
|
+
): ImpersonationActive | null {
|
|
66
|
+
return readCookieJson<ImpersonationActive>(request, IMP_ACTIVE_COOKIE);
|
|
67
|
+
}
|
|
68
|
+
|
|
61
69
|
function serialize(
|
|
62
70
|
name: string,
|
|
63
71
|
value: string,
|
|
@@ -6,17 +6,8 @@ import {
|
|
|
6
6
|
readImpersonationSession,
|
|
7
7
|
} from "./cookies";
|
|
8
8
|
import { sealImpersonationTicket, openImpersonationTicket } from "./ticket";
|
|
9
|
-
import { requestOrigin } from "../session";
|
|
10
|
-
|
|
11
|
-
function platformBase(): string | null {
|
|
12
|
-
return (
|
|
13
|
-
process.env.NEXT_PUBLIC_PLATFORM_API_BASE_URL?.replace(/\/$/, "") ?? null
|
|
14
|
-
);
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
function requestHost(request: Request): string | null {
|
|
18
|
-
return request.headers.get("x-forwarded-host") ?? request.headers.get("host");
|
|
19
|
-
}
|
|
9
|
+
import { requestOrigin, resolveCookieDomain } from "../session";
|
|
10
|
+
import { platformBase, platformErrorResponse, requestHost } from "./request";
|
|
20
11
|
|
|
21
12
|
function registrable(host: string): string {
|
|
22
13
|
return host.split(":")[0].toLowerCase().split(".").slice(-2).join(".");
|
|
@@ -48,12 +39,19 @@ function resolveLanding(returnTo: string, origin: string): string {
|
|
|
48
39
|
return new URL("/", origin).toString();
|
|
49
40
|
}
|
|
50
41
|
|
|
51
|
-
|
|
52
|
-
|
|
42
|
+
function cookieScope(host: string): string {
|
|
43
|
+
return resolveCookieDomain(host) ?? host.split(":")[0].toLowerCase();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function handsOffToTarget(request: Request, target: string): boolean {
|
|
53
47
|
const here = requestHost(request);
|
|
54
48
|
if (!here) return false;
|
|
55
49
|
try {
|
|
56
|
-
|
|
50
|
+
const there = new URL(target).host;
|
|
51
|
+
return (
|
|
52
|
+
registrable(there) !== registrable(here) ||
|
|
53
|
+
cookieScope(there) !== cookieScope(here)
|
|
54
|
+
);
|
|
57
55
|
} catch {
|
|
58
56
|
return false;
|
|
59
57
|
}
|
|
@@ -85,6 +83,7 @@ export function createImpersonationStartHandler(authConfig: Auth.Config) {
|
|
|
85
83
|
redirectTo?: string;
|
|
86
84
|
returnTo?: string;
|
|
87
85
|
landingUrl?: string;
|
|
86
|
+
handOffOnly?: boolean;
|
|
88
87
|
};
|
|
89
88
|
try {
|
|
90
89
|
body = await request.json();
|
|
@@ -105,17 +104,7 @@ export function createImpersonationStartHandler(authConfig: Auth.Config) {
|
|
|
105
104
|
}),
|
|
106
105
|
});
|
|
107
106
|
|
|
108
|
-
if (!minted.ok)
|
|
109
|
-
const raw = await minted.text();
|
|
110
|
-
let message = raw;
|
|
111
|
-
try {
|
|
112
|
-
const parsed = JSON.parse(raw) as { message?: unknown };
|
|
113
|
-
if (typeof parsed.message === "string") message = parsed.message;
|
|
114
|
-
} catch {
|
|
115
|
-
// Non-JSON error body — pass the raw text through unchanged.
|
|
116
|
-
}
|
|
117
|
-
return new Response(message, { status: minted.status });
|
|
118
|
-
}
|
|
107
|
+
if (!minted.ok) return platformErrorResponse(minted);
|
|
119
108
|
|
|
120
109
|
const result = await minted.json();
|
|
121
110
|
result.targetImage = body.targetImage ?? null;
|
|
@@ -128,7 +117,7 @@ export function createImpersonationStartHandler(authConfig: Auth.Config) {
|
|
|
128
117
|
};
|
|
129
118
|
|
|
130
119
|
// Custom-domain tenant: hand the session to the tenant domain's adopt route (login's pattern).
|
|
131
|
-
if (body.redirectTo &&
|
|
120
|
+
if (body.redirectTo && handsOffToTarget(request, body.redirectTo)) {
|
|
132
121
|
const target = new URL(body.redirectTo);
|
|
133
122
|
const ticket = sealImpersonationTicket(
|
|
134
123
|
{ ...result, business: target.host, returnTo: body.returnTo },
|
|
@@ -144,6 +133,7 @@ export function createImpersonationStartHandler(authConfig: Auth.Config) {
|
|
|
144
133
|
...summary,
|
|
145
134
|
adoptUrl: adopt.toString(),
|
|
146
135
|
});
|
|
136
|
+
if (body.handOffOnly) return crossDomain;
|
|
147
137
|
for (const cookie of buildImpersonationCookies(
|
|
148
138
|
{ ...result, returnTo: body.returnTo },
|
|
149
139
|
requestHost(request)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { Auth } from "../types";
|
|
2
|
+
import { resolveAccessToken } from "../route-handler/token";
|
|
3
|
+
import { platformBase, platformErrorResponse } from "./request";
|
|
4
|
+
import { impersonationRequired } from "./required";
|
|
5
|
+
|
|
6
|
+
function businessOf(accessToken: string): string | null {
|
|
7
|
+
try {
|
|
8
|
+
const payload = accessToken.split(".")[1] ?? "";
|
|
9
|
+
const claims = JSON.parse(
|
|
10
|
+
Buffer.from(payload, "base64url").toString("utf8")
|
|
11
|
+
) as { biz?: string };
|
|
12
|
+
return claims.biz ?? null;
|
|
13
|
+
} catch {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function createImpersonationPersonasHandler(authConfig: Auth.Config) {
|
|
19
|
+
return async function GET(request: Request) {
|
|
20
|
+
if (!impersonationRequired(authConfig.environment)) {
|
|
21
|
+
return new Response("Not found", { status: 404 });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const base = platformBase();
|
|
25
|
+
if (!base) {
|
|
26
|
+
return new Response(
|
|
27
|
+
"NEXT_PUBLIC_PLATFORM_API_BASE_URL is not configured",
|
|
28
|
+
{ status: 500 }
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const caller = await resolveAccessToken(authConfig, request);
|
|
33
|
+
if ("error" in caller) return caller.error;
|
|
34
|
+
|
|
35
|
+
const businessId = businessOf(caller.accessToken);
|
|
36
|
+
if (!businessId) {
|
|
37
|
+
return new Response("The signed-in account has no business", {
|
|
38
|
+
status: 409,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const employees = await fetch(
|
|
43
|
+
`${base}/v1.0/protected/businesses/${encodeURIComponent(businessId)}/employees`,
|
|
44
|
+
{ headers: { Authorization: `Bearer ${caller.accessToken}` } }
|
|
45
|
+
);
|
|
46
|
+
if (!employees.ok) return platformErrorResponse(employees);
|
|
47
|
+
|
|
48
|
+
return new Response(await employees.text(), {
|
|
49
|
+
headers: { "Content-Type": "application/json" },
|
|
50
|
+
});
|
|
51
|
+
};
|
|
52
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Configuration,
|
|
3
|
+
PlatformClient,
|
|
4
|
+
ResponseError,
|
|
5
|
+
} from "@snokam/platform-client";
|
|
6
|
+
import { Auth } from "../types";
|
|
7
|
+
import { resolveAccessToken } from "../route-handler/token";
|
|
8
|
+
import {
|
|
9
|
+
buildImpersonationCookies,
|
|
10
|
+
readImpersonationActive,
|
|
11
|
+
readImpersonationSession,
|
|
12
|
+
} from "./cookies";
|
|
13
|
+
import { platformBase, platformErrorResponse, requestHost } from "./request";
|
|
14
|
+
|
|
15
|
+
export function createImpersonationRenewHandler(authConfig: Auth.Config) {
|
|
16
|
+
return async function POST(request: Request) {
|
|
17
|
+
const session = readImpersonationSession(request);
|
|
18
|
+
const active = readImpersonationActive(request);
|
|
19
|
+
if (!session?.tokenId || !active) {
|
|
20
|
+
return new Response("No impersonation session", { status: 404 });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const basePath = platformBase();
|
|
24
|
+
if (!basePath) {
|
|
25
|
+
return new Response(
|
|
26
|
+
"NEXT_PUBLIC_PLATFORM_API_BASE_URL is not configured",
|
|
27
|
+
{
|
|
28
|
+
status: 500,
|
|
29
|
+
}
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const caller = await resolveAccessToken(authConfig, request);
|
|
34
|
+
if ("error" in caller) return caller.error;
|
|
35
|
+
|
|
36
|
+
const client = new PlatformClient(
|
|
37
|
+
new Configuration({
|
|
38
|
+
basePath,
|
|
39
|
+
accessToken: async () => caller.accessToken,
|
|
40
|
+
})
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
const renewed = await client.renewImpersonation({
|
|
45
|
+
tokenId: session.tokenId,
|
|
46
|
+
});
|
|
47
|
+
if (!renewed.accessToken || !renewed.expiresOn) {
|
|
48
|
+
return new Response("Incomplete impersonation renewal", {
|
|
49
|
+
status: 502,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const out = Response.json({
|
|
54
|
+
tokenId: session.tokenId,
|
|
55
|
+
expiresOn: renewed.expiresOn,
|
|
56
|
+
});
|
|
57
|
+
const cookies = buildImpersonationCookies(
|
|
58
|
+
{
|
|
59
|
+
...active,
|
|
60
|
+
accessToken: renewed.accessToken,
|
|
61
|
+
expiresOn: renewed.expiresOn,
|
|
62
|
+
finance: renewed.finance ?? false,
|
|
63
|
+
},
|
|
64
|
+
requestHost(request)
|
|
65
|
+
);
|
|
66
|
+
for (const cookie of cookies) out.headers.append("Set-Cookie", cookie);
|
|
67
|
+
return out;
|
|
68
|
+
} catch (error) {
|
|
69
|
+
if (error instanceof ResponseError) {
|
|
70
|
+
return platformErrorResponse(error.response);
|
|
71
|
+
}
|
|
72
|
+
throw error;
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect } from "react";
|
|
4
|
+
import { clearAuthTokenCache } from "../hooks";
|
|
5
|
+
import type { ImpersonationActive } from "./cookies";
|
|
6
|
+
import { renewDelayMs } from "./required";
|
|
7
|
+
|
|
8
|
+
async function renewSession(): Promise<boolean> {
|
|
9
|
+
const res = await fetch("/api/impersonation/renew", {
|
|
10
|
+
method: "POST",
|
|
11
|
+
credentials: "include",
|
|
12
|
+
}).catch(() => null);
|
|
13
|
+
return res?.ok ?? false;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function useSessionRenewal(
|
|
17
|
+
active: ImpersonationActive | null,
|
|
18
|
+
rereadCookie: () => void
|
|
19
|
+
) {
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
if (!active) return;
|
|
22
|
+
|
|
23
|
+
const renewal = setTimeout(
|
|
24
|
+
async () => {
|
|
25
|
+
if (!(await renewSession())) return;
|
|
26
|
+
clearAuthTokenCache();
|
|
27
|
+
rereadCookie();
|
|
28
|
+
},
|
|
29
|
+
renewDelayMs(active.expiresOn, Date.now())
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
const expiry = setTimeout(
|
|
33
|
+
rereadCookie,
|
|
34
|
+
Math.max(0, active.expiresOn * 1000 - Date.now())
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
return () => {
|
|
38
|
+
clearTimeout(renewal);
|
|
39
|
+
clearTimeout(expiry);
|
|
40
|
+
};
|
|
41
|
+
}, [active, rereadCookie]);
|
|
42
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export function platformBase(): string | null {
|
|
2
|
+
return (
|
|
3
|
+
process.env.NEXT_PUBLIC_PLATFORM_API_BASE_URL?.replace(/\/$/, "") ?? null
|
|
4
|
+
);
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function requestHost(request: Request): string | null {
|
|
8
|
+
return request.headers.get("x-forwarded-host") ?? request.headers.get("host");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function messageOf(raw: string): string {
|
|
12
|
+
try {
|
|
13
|
+
const parsed = JSON.parse(raw) as { message?: unknown };
|
|
14
|
+
return typeof parsed.message === "string" ? parsed.message : raw;
|
|
15
|
+
} catch {
|
|
16
|
+
return raw;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function platformErrorResponse(
|
|
21
|
+
response: Response
|
|
22
|
+
): Promise<Response> {
|
|
23
|
+
return new Response(messageOf(await response.text()), {
|
|
24
|
+
status: response.status,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
impersonationPickerUrl,
|
|
5
|
+
impersonationRequired,
|
|
6
|
+
isExemptRoute,
|
|
7
|
+
renewDelayMs,
|
|
8
|
+
sendsToPicker,
|
|
9
|
+
withholdsApp,
|
|
10
|
+
} from "./required.ts";
|
|
11
|
+
|
|
12
|
+
const signedInAlone = {
|
|
13
|
+
authenticated: true,
|
|
14
|
+
sessionActive: false,
|
|
15
|
+
cookieRead: true,
|
|
16
|
+
exemptRoute: false,
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
describe("impersonation required gate", () => {
|
|
20
|
+
it("is off in production and everywhere that is not the test environment", () => {
|
|
21
|
+
for (const environment of ["p", "test", "production", "", undefined]) {
|
|
22
|
+
assert.equal(impersonationRequired(environment), false);
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("is on in test", () => {
|
|
27
|
+
assert.equal(impersonationRequired("t"), true);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("never withholds the app or redirects when not required", () => {
|
|
31
|
+
const state = { required: false, ...signedInAlone };
|
|
32
|
+
assert.equal(withholdsApp(state), false);
|
|
33
|
+
assert.equal(sendsToPicker(state), false);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("sends a signed-in person without a session to the picker when required", () => {
|
|
37
|
+
const state = { required: true, ...signedInAlone };
|
|
38
|
+
assert.equal(withholdsApp(state), true);
|
|
39
|
+
assert.equal(sendsToPicker(state), true);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("lets the app render once someone is being viewed as", () => {
|
|
43
|
+
const state = { required: true, ...signedInAlone, sessionActive: true };
|
|
44
|
+
assert.equal(withholdsApp(state), false);
|
|
45
|
+
assert.equal(sendsToPicker(state), false);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("holds the app without redirecting until the session cookie is read", () => {
|
|
49
|
+
const state = { required: true, ...signedInAlone, cookieRead: false };
|
|
50
|
+
assert.equal(withholdsApp(state), true);
|
|
51
|
+
assert.equal(sendsToPicker(state), false);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("leaves sign-in routes, the picker and anonymous visitors alone", () => {
|
|
55
|
+
const base = { required: true, ...signedInAlone };
|
|
56
|
+
assert.equal(withholdsApp({ ...base, exemptRoute: true }), false);
|
|
57
|
+
assert.equal(withholdsApp({ ...base, authenticated: false }), false);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("points the picker back at the page the person asked for", () => {
|
|
61
|
+
const url = new URL(
|
|
62
|
+
impersonationPickerUrl(
|
|
63
|
+
"https://www.test.snosky.no",
|
|
64
|
+
"https://snokam.test.snosky.no/minside/okonomi?x=1"
|
|
65
|
+
)
|
|
66
|
+
);
|
|
67
|
+
assert.equal(url.pathname, "/velg-bruker");
|
|
68
|
+
assert.equal(
|
|
69
|
+
url.searchParams.get("returnTo"),
|
|
70
|
+
"https://snokam.test.snosky.no/minside/okonomi?x=1"
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("exempts sign-in routes and the picker, and nothing else", () => {
|
|
75
|
+
assert.equal(isExemptRoute("/auth/login"), true);
|
|
76
|
+
assert.equal(isExemptRoute("/velg-bruker"), true);
|
|
77
|
+
assert.equal(isExemptRoute("/manage"), false);
|
|
78
|
+
assert.equal(isExemptRoute("/"), false);
|
|
79
|
+
assert.equal(isExemptRoute(null), false);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("renews two minutes before the token expires, and at once when already late", () => {
|
|
83
|
+
assert.equal(renewDelayMs(1_000, 0), 880_000);
|
|
84
|
+
assert.equal(renewDelayMs(1_000, 999_000), 0);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
export const IMPERSONATION_REQUIRED_ERROR = "impersonation_required";
|
|
2
|
+
|
|
3
|
+
const TEST_ENVIRONMENT = "t";
|
|
4
|
+
|
|
5
|
+
export const IMPERSONATION_PICKER_PATH = "/velg-bruker";
|
|
6
|
+
|
|
7
|
+
const RENEW_MARGIN_SECONDS = 120;
|
|
8
|
+
|
|
9
|
+
export function impersonationRequired(
|
|
10
|
+
environment: string | undefined
|
|
11
|
+
): boolean {
|
|
12
|
+
return environment === TEST_ENVIRONMENT;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface ImpersonationGateState {
|
|
16
|
+
required: boolean;
|
|
17
|
+
authenticated: boolean;
|
|
18
|
+
sessionActive: boolean;
|
|
19
|
+
cookieRead: boolean;
|
|
20
|
+
exemptRoute: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function gated({
|
|
24
|
+
required,
|
|
25
|
+
authenticated,
|
|
26
|
+
exemptRoute,
|
|
27
|
+
}: ImpersonationGateState) {
|
|
28
|
+
return required && authenticated && !exemptRoute;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function isExemptRoute(pathname: string | null): boolean {
|
|
32
|
+
if (!pathname) return false;
|
|
33
|
+
return pathname.startsWith("/auth") || pathname === IMPERSONATION_PICKER_PATH;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function withholdsApp(state: ImpersonationGateState): boolean {
|
|
37
|
+
return gated(state) && (!state.cookieRead || !state.sessionActive);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function sendsToPicker(state: ImpersonationGateState): boolean {
|
|
41
|
+
return gated(state) && state.cookieRead && !state.sessionActive;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function impersonationPickerUrl(
|
|
45
|
+
platformBaseUrl: string,
|
|
46
|
+
returnTo: string
|
|
47
|
+
): string {
|
|
48
|
+
const picker = new URL(IMPERSONATION_PICKER_PATH, platformBaseUrl);
|
|
49
|
+
picker.searchParams.set("returnTo", returnTo);
|
|
50
|
+
return picker.toString();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function renewDelayMs(expiresOn: number, nowMs: number): number {
|
|
54
|
+
return Math.max(0, (expiresOn - RENEW_MARGIN_SECONDS) * 1000 - nowMs);
|
|
55
|
+
}
|
|
@@ -4,26 +4,30 @@ import {
|
|
|
4
4
|
createImpersonationEndHandler,
|
|
5
5
|
createImpersonationAdoptHandler,
|
|
6
6
|
} from "./handlers";
|
|
7
|
+
import { createImpersonationRenewHandler } from "./renew";
|
|
8
|
+
import { createImpersonationPersonasHandler } from "./personas";
|
|
7
9
|
|
|
8
10
|
type OpContext = { params: Promise<{ op: string }> };
|
|
9
11
|
|
|
10
|
-
// One catch-all mount per app: api/impersonation/[op]/route.ts re-exports { GET, POST }.
|
|
11
|
-
// POST /start + /end, GET /adopt — so every app supports view-as with a single file.
|
|
12
12
|
export function createImpersonationRouteHandlers(authConfig: Auth.Config) {
|
|
13
13
|
const start = createImpersonationStartHandler(authConfig);
|
|
14
14
|
const end = createImpersonationEndHandler(authConfig);
|
|
15
15
|
const adopt = createImpersonationAdoptHandler(authConfig);
|
|
16
|
+
const renew = createImpersonationRenewHandler(authConfig);
|
|
17
|
+
const personas = createImpersonationPersonasHandler(authConfig);
|
|
16
18
|
|
|
17
19
|
return {
|
|
18
20
|
async GET(request: Request, ctx: OpContext) {
|
|
19
21
|
const { op } = await ctx.params;
|
|
20
22
|
if (op === "adopt") return adopt(request);
|
|
23
|
+
if (op === "personas") return personas(request);
|
|
21
24
|
return new Response("Not found", { status: 404 });
|
|
22
25
|
},
|
|
23
26
|
async POST(request: Request, ctx: OpContext) {
|
|
24
27
|
const { op } = await ctx.params;
|
|
25
28
|
if (op === "start") return start(request);
|
|
26
29
|
if (op === "end") return end(request);
|
|
30
|
+
if (op === "renew") return renew(request);
|
|
27
31
|
return new Response("Not found", { status: 404 });
|
|
28
32
|
},
|
|
29
33
|
};
|
|
@@ -8,6 +8,10 @@ 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 {
|
|
12
|
+
IMPERSONATION_REQUIRED_ERROR,
|
|
13
|
+
impersonationRequired,
|
|
14
|
+
} from "../impersonation/required";
|
|
11
15
|
|
|
12
16
|
export type TokenResult =
|
|
13
17
|
{ accessToken: string; expiresOn: number } | { error: Response };
|
|
@@ -102,9 +106,6 @@ export async function resolveAccessToken(
|
|
|
102
106
|
|
|
103
107
|
export function createTokenHandler(authConfig: Auth.Config) {
|
|
104
108
|
return async function POST(request: Request) {
|
|
105
|
-
// While a view-as-employee session is active, every product API call transparently receives the
|
|
106
|
-
// short-lived impersonation token instead of the admin's own — the single swap point the design
|
|
107
|
-
// relies on. A clean exit (or expiry) discards the cookie and this falls back to the admin token.
|
|
108
109
|
const impersonation = readImpersonationSession(request);
|
|
109
110
|
if (impersonation && impersonation.expiresOn * 1000 > Date.now()) {
|
|
110
111
|
return Response.json({
|
|
@@ -113,6 +114,13 @@ export function createTokenHandler(authConfig: Auth.Config) {
|
|
|
113
114
|
});
|
|
114
115
|
}
|
|
115
116
|
|
|
117
|
+
if (impersonationRequired(authConfig.environment)) {
|
|
118
|
+
return Response.json(
|
|
119
|
+
{ error: IMPERSONATION_REQUIRED_ERROR },
|
|
120
|
+
{ status: 403 }
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
116
124
|
const result = await resolveAccessToken(authConfig, request);
|
|
117
125
|
if ("error" in result) {
|
|
118
126
|
return result.error;
|