@snokam/auth 3.3.11 → 3.4.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 +8 -6
- package/src/hooks.ts +7 -0
- package/src/impersonation/RequiredImpersonationGate.tsx +55 -0
- package/src/impersonation/context.tsx +27 -15
- package/src/impersonation/cookies.ts +15 -7
- package/src/impersonation/handlers.ts +16 -26
- 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 +78 -0
- package/src/impersonation/required.ts +45 -0
- package/src/impersonation/routes.ts +3 -2
- package/src/route-handler/index.ts +1 -0
- package/src/route-handler/token.ts +19 -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
|
+
# [3.4.0](https://github.com/snokam/components/compare/@snokam/auth@3.3.12...@snokam/auth@3.4.0) (2026-10-06)
|
|
7
|
+
|
|
8
|
+
### Features
|
|
9
|
+
|
|
10
|
+
- 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))
|
|
11
|
+
|
|
12
|
+
## [3.3.12](https://github.com/snokam/components/compare/@snokam/auth@3.3.11...@snokam/auth@3.3.12) (2026-10-05)
|
|
13
|
+
|
|
14
|
+
### Bug Fixes
|
|
15
|
+
|
|
16
|
+
- **auth:** declare @mui/icons-material so @snokam/ui icons resolve from the auth package ([#2678](https://github.com/snokam/components/issues/2678)) ([f0e825a](https://github.com/snokam/components/commit/f0e825ab53922b09a094943be44e9c990902c020)), closes [#2675](https://github.com/snokam/components/issues/2675)
|
|
17
|
+
|
|
6
18
|
## [3.3.11](https://github.com/snokam/components/compare/@snokam/auth@3.3.10...@snokam/auth@3.3.11) (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.4.0",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"types": "src/index.ts",
|
|
7
7
|
"files": [
|
|
@@ -21,16 +21,17 @@
|
|
|
21
21
|
"@azure/msal-node": "^5.4.2",
|
|
22
22
|
"@emotion/react": "^11.14.0",
|
|
23
23
|
"@emotion/styled": "^11.14.1",
|
|
24
|
+
"@mui/icons-material": "^9.2.0",
|
|
24
25
|
"@mui/material": "^9.2.0",
|
|
25
26
|
"@snokam/employees-client": "^1.3.0",
|
|
26
27
|
"next-auth": "4.24.15",
|
|
27
28
|
"path-to-regexp": "^8.4.2",
|
|
28
29
|
"@snokam/identity": "1.1.0",
|
|
30
|
+
"@snokam/platform-client": "3.6.0",
|
|
29
31
|
"@snokam/react-query": "1.1.0",
|
|
30
|
-
"@snokam/
|
|
31
|
-
"@snokam/
|
|
32
|
-
"@snokam/sanity-page-content": "4.4.
|
|
33
|
-
"@snokam/ui": "4.0.0"
|
|
32
|
+
"@snokam/tenant": "2.1.13",
|
|
33
|
+
"@snokam/ui": "4.0.0",
|
|
34
|
+
"@snokam/sanity-page-content": "4.4.2"
|
|
34
35
|
},
|
|
35
36
|
"peerDependencies": {
|
|
36
37
|
"next": "16.3.3",
|
|
@@ -52,6 +53,7 @@
|
|
|
52
53
|
"./sso-tenant": "./src/sso/tenant-cookie.ts"
|
|
53
54
|
},
|
|
54
55
|
"scripts": {
|
|
55
|
-
"lint": "eslint ."
|
|
56
|
+
"lint": "eslint .",
|
|
57
|
+
"test": "node --test \"src/**/*.test.ts\""
|
|
56
58
|
}
|
|
57
59
|
}
|
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,55 @@
|
|
|
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
|
+
sendsToPicker,
|
|
12
|
+
withholdsApp,
|
|
13
|
+
} from "./required";
|
|
14
|
+
|
|
15
|
+
interface Props {
|
|
16
|
+
sessionActive: boolean;
|
|
17
|
+
cookieRead: boolean;
|
|
18
|
+
keepOwnIdentity: boolean;
|
|
19
|
+
children: ReactNode;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function RequiredImpersonationGate({
|
|
23
|
+
sessionActive,
|
|
24
|
+
cookieRead,
|
|
25
|
+
keepOwnIdentity,
|
|
26
|
+
children,
|
|
27
|
+
}: Props) {
|
|
28
|
+
const config = useOptionalConfig();
|
|
29
|
+
const { status, data: session } = useSession();
|
|
30
|
+
const pathname = usePathname();
|
|
31
|
+
const platformUrl = config?.NEXT_PUBLIC_PLATFORM_BASE_URL;
|
|
32
|
+
|
|
33
|
+
const state = {
|
|
34
|
+
required: impersonationRequired(
|
|
35
|
+
config?.NEXT_PUBLIC_ENVIRONMENT,
|
|
36
|
+
keepOwnIdentity
|
|
37
|
+
),
|
|
38
|
+
authenticated:
|
|
39
|
+
status === "authenticated" || (status === "loading" && !!session),
|
|
40
|
+
sessionActive,
|
|
41
|
+
cookieRead,
|
|
42
|
+
authRoute: pathname?.startsWith("/auth") ?? false,
|
|
43
|
+
};
|
|
44
|
+
const redirect = sendsToPicker(state);
|
|
45
|
+
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
if (!redirect || !platformUrl) return;
|
|
48
|
+
window.location.assign(
|
|
49
|
+
impersonationPickerUrl(platformUrl, window.location.href)
|
|
50
|
+
);
|
|
51
|
+
}, [redirect, platformUrl]);
|
|
52
|
+
|
|
53
|
+
if (withholdsApp(state)) return <PageLoader />;
|
|
54
|
+
return <>{children}</>;
|
|
55
|
+
}
|
|
@@ -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
|
}
|
|
@@ -72,26 +75,28 @@ function readActiveCookie(): ImpersonationActive | null {
|
|
|
72
75
|
}
|
|
73
76
|
}
|
|
74
77
|
|
|
75
|
-
|
|
78
|
+
interface ImpersonationProviderProps {
|
|
79
|
+
children: ReactNode;
|
|
80
|
+
keepOwnIdentity?: boolean;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function ImpersonationProvider({
|
|
84
|
+
children,
|
|
85
|
+
keepOwnIdentity = false,
|
|
86
|
+
}: ImpersonationProviderProps) {
|
|
76
87
|
const [active, setActive] = useState<ImpersonationActive | null>(null);
|
|
88
|
+
const [cookieRead, setCookieRead] = useState(false);
|
|
77
89
|
const [starting, setStarting] = useState(false);
|
|
78
90
|
const [exiting, setExiting] = useState(false);
|
|
79
91
|
|
|
80
|
-
|
|
81
|
-
setActive(readActiveCookie());
|
|
82
|
-
}, []);
|
|
92
|
+
const rereadCookie = useCallback(() => setActive(readActiveCookie()), []);
|
|
83
93
|
|
|
84
|
-
// Auto-exit at token expiry so a stale banner/token never lingers.
|
|
85
94
|
useEffect(() => {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
}
|
|
92
|
-
const timer = setTimeout(() => setActive(null), ms);
|
|
93
|
-
return () => clearTimeout(timer);
|
|
94
|
-
}, [active]);
|
|
95
|
+
rereadCookie();
|
|
96
|
+
setCookieRead(true);
|
|
97
|
+
}, [rereadCookie]);
|
|
98
|
+
|
|
99
|
+
useSessionRenewal(active, rereadCookie);
|
|
95
100
|
|
|
96
101
|
const start = useCallback(
|
|
97
102
|
async (input: {
|
|
@@ -102,6 +107,7 @@ export function ImpersonationProvider({ children }: { children: ReactNode }) {
|
|
|
102
107
|
redirectTo?: string;
|
|
103
108
|
returnTo?: string;
|
|
104
109
|
landingUrl?: string;
|
|
110
|
+
handOffOnly?: boolean;
|
|
105
111
|
}) => {
|
|
106
112
|
setStarting(true);
|
|
107
113
|
try {
|
|
@@ -161,7 +167,13 @@ export function ImpersonationProvider({ children }: { children: ReactNode }) {
|
|
|
161
167
|
|
|
162
168
|
return (
|
|
163
169
|
<ImpersonationContext.Provider value={value}>
|
|
164
|
-
|
|
170
|
+
<RequiredImpersonationGate
|
|
171
|
+
sessionActive={!!active}
|
|
172
|
+
cookieRead={cookieRead}
|
|
173
|
+
keepOwnIdentity={keepOwnIdentity}
|
|
174
|
+
>
|
|
175
|
+
{children}
|
|
176
|
+
</RequiredImpersonationGate>
|
|
165
177
|
</ImpersonationContext.Provider>
|
|
166
178
|
);
|
|
167
179
|
}
|
|
@@ -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,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,78 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
impersonationPickerUrl,
|
|
5
|
+
impersonationRequired,
|
|
6
|
+
renewDelayMs,
|
|
7
|
+
sendsToPicker,
|
|
8
|
+
withholdsApp,
|
|
9
|
+
} from "./required.ts";
|
|
10
|
+
|
|
11
|
+
const signedInAlone = {
|
|
12
|
+
authenticated: true,
|
|
13
|
+
sessionActive: false,
|
|
14
|
+
cookieRead: true,
|
|
15
|
+
authRoute: false,
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
describe("impersonation required gate", () => {
|
|
19
|
+
it("is off in production and everywhere that is not the test environment", () => {
|
|
20
|
+
for (const environment of ["p", "test", "production", "", undefined]) {
|
|
21
|
+
assert.equal(impersonationRequired(environment, false), false);
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("is on in test, except where the app keeps its own identity", () => {
|
|
26
|
+
assert.equal(impersonationRequired("t", false), true);
|
|
27
|
+
assert.equal(impersonationRequired("t", true), false);
|
|
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 and anonymous visitors alone", () => {
|
|
55
|
+
const base = { required: true, ...signedInAlone };
|
|
56
|
+
assert.equal(withholdsApp({ ...base, authRoute: 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, "/manage/users");
|
|
68
|
+
assert.equal(
|
|
69
|
+
url.searchParams.get("returnTo"),
|
|
70
|
+
"https://snokam.test.snosky.no/minside/okonomi?x=1"
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("renews two minutes before the token expires, and at once when already late", () => {
|
|
75
|
+
assert.equal(renewDelayMs(1_000, 0), 880_000);
|
|
76
|
+
assert.equal(renewDelayMs(1_000, 999_000), 0);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export const IMPERSONATION_REQUIRED_ERROR = "impersonation_required";
|
|
2
|
+
|
|
3
|
+
const TEST_ENVIRONMENT = "t";
|
|
4
|
+
|
|
5
|
+
const RENEW_MARGIN_SECONDS = 120;
|
|
6
|
+
|
|
7
|
+
export function impersonationRequired(
|
|
8
|
+
environment: string | undefined,
|
|
9
|
+
keepOwnIdentity: boolean
|
|
10
|
+
): boolean {
|
|
11
|
+
return !keepOwnIdentity && environment === TEST_ENVIRONMENT;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface ImpersonationGateState {
|
|
15
|
+
required: boolean;
|
|
16
|
+
authenticated: boolean;
|
|
17
|
+
sessionActive: boolean;
|
|
18
|
+
cookieRead: boolean;
|
|
19
|
+
authRoute: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function gated({ required, authenticated, authRoute }: ImpersonationGateState) {
|
|
23
|
+
return required && authenticated && !authRoute;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function withholdsApp(state: ImpersonationGateState): boolean {
|
|
27
|
+
return gated(state) && (!state.cookieRead || !state.sessionActive);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function sendsToPicker(state: ImpersonationGateState): boolean {
|
|
31
|
+
return gated(state) && state.cookieRead && !state.sessionActive;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function impersonationPickerUrl(
|
|
35
|
+
platformBaseUrl: string,
|
|
36
|
+
returnTo: string
|
|
37
|
+
): string {
|
|
38
|
+
const picker = new URL("/manage/users", platformBaseUrl);
|
|
39
|
+
picker.searchParams.set("returnTo", returnTo);
|
|
40
|
+
return picker.toString();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function renewDelayMs(expiresOn: number, nowMs: number): number {
|
|
44
|
+
return Math.max(0, (expiresOn - RENEW_MARGIN_SECONDS) * 1000 - nowMs);
|
|
45
|
+
}
|
|
@@ -4,15 +4,15 @@ import {
|
|
|
4
4
|
createImpersonationEndHandler,
|
|
5
5
|
createImpersonationAdoptHandler,
|
|
6
6
|
} from "./handlers";
|
|
7
|
+
import { createImpersonationRenewHandler } from "./renew";
|
|
7
8
|
|
|
8
9
|
type OpContext = { params: Promise<{ op: string }> };
|
|
9
10
|
|
|
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
11
|
export function createImpersonationRouteHandlers(authConfig: Auth.Config) {
|
|
13
12
|
const start = createImpersonationStartHandler(authConfig);
|
|
14
13
|
const end = createImpersonationEndHandler(authConfig);
|
|
15
14
|
const adopt = createImpersonationAdoptHandler(authConfig);
|
|
15
|
+
const renew = createImpersonationRenewHandler(authConfig);
|
|
16
16
|
|
|
17
17
|
return {
|
|
18
18
|
async GET(request: Request, ctx: OpContext) {
|
|
@@ -24,6 +24,7 @@ export function createImpersonationRouteHandlers(authConfig: Auth.Config) {
|
|
|
24
24
|
const { op } = await ctx.params;
|
|
25
25
|
if (op === "start") return start(request);
|
|
26
26
|
if (op === "end") return end(request);
|
|
27
|
+
if (op === "renew") return renew(request);
|
|
27
28
|
return new Response("Not found", { status: 404 });
|
|
28
29
|
},
|
|
29
30
|
};
|
|
@@ -38,6 +38,7 @@ export function createAuthHandler(options: AuthOptionsFor) {
|
|
|
38
38
|
|
|
39
39
|
export { getNextAuthOptions } from "../options";
|
|
40
40
|
export { createTokenHandler } from "./token";
|
|
41
|
+
export type { TokenHandlerOptions } from "./token";
|
|
41
42
|
export {
|
|
42
43
|
createImpersonationStartHandler,
|
|
43
44
|
createImpersonationEndHandler,
|
|
@@ -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 };
|
|
@@ -100,7 +104,14 @@ export async function resolveAccessToken(
|
|
|
100
104
|
}
|
|
101
105
|
}
|
|
102
106
|
|
|
103
|
-
export
|
|
107
|
+
export interface TokenHandlerOptions {
|
|
108
|
+
keepOwnIdentity?: boolean;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function createTokenHandler(
|
|
112
|
+
authConfig: Auth.Config,
|
|
113
|
+
{ keepOwnIdentity = false }: TokenHandlerOptions = {}
|
|
114
|
+
) {
|
|
104
115
|
return async function POST(request: Request) {
|
|
105
116
|
// While a view-as-employee session is active, every product API call transparently receives the
|
|
106
117
|
// short-lived impersonation token instead of the admin's own — the single swap point the design
|
|
@@ -113,6 +124,13 @@ export function createTokenHandler(authConfig: Auth.Config) {
|
|
|
113
124
|
});
|
|
114
125
|
}
|
|
115
126
|
|
|
127
|
+
if (impersonationRequired(authConfig.environment, keepOwnIdentity)) {
|
|
128
|
+
return Response.json(
|
|
129
|
+
{ error: IMPERSONATION_REQUIRED_ERROR },
|
|
130
|
+
{ status: 403 }
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
116
134
|
const result = await resolveAccessToken(authConfig, request);
|
|
117
135
|
if ("error" in result) {
|
|
118
136
|
return result.error;
|