@snokam/auth 3.6.0 → 3.7.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 +10 -0
- package/package.json +1 -1
- package/src/app-router/index.tsx +1 -0
- package/src/app-sign-in/handlers.ts +2 -2
- package/src/app-sign-in/platform.ts +3 -15
- package/src/hooks.ts +21 -9
- package/src/impersonation/context.tsx +46 -78
- package/src/impersonation/cookies.ts +0 -4
- package/src/impersonation/handlers.ts +52 -88
- package/src/impersonation/redirects.test.ts +47 -0
- package/src/impersonation/redirects.ts +31 -0
- package/src/impersonation/request.ts +8 -5
- package/src/impersonation/required.test.ts +4 -51
- package/src/impersonation/required.ts +0 -29
- package/src/middleware.tsx +13 -2
- package/src/route-handler/token.ts +4 -1
- package/src/impersonation/RequiredImpersonationGate.tsx +0 -51
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,16 @@
|
|
|
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.7.1](https://github.com/snokam/components/compare/@snokam/auth@3.7.0...@snokam/auth@3.7.1) (2026-10-06)
|
|
7
|
+
|
|
8
|
+
**Note:** Version bump only for package @snokam/auth
|
|
9
|
+
|
|
10
|
+
# [3.7.0](https://github.com/snokam/components/compare/@snokam/auth@3.6.0...@snokam/auth@3.7.0) (2026-10-06)
|
|
11
|
+
|
|
12
|
+
### Features
|
|
13
|
+
|
|
14
|
+
- **test:** every test page needs a login; pick a persona only when a page needs one ([#2705](https://github.com/snokam/components/issues/2705)) ([e086fb6](https://github.com/snokam/components/commit/e086fb63511e0f3f4f2002d0189e089c8cda6d30))
|
|
15
|
+
|
|
6
16
|
# [3.6.0](https://github.com/snokam/components/compare/@snokam/auth@3.5.0...@snokam/auth@3.6.0) (2026-10-06)
|
|
7
17
|
|
|
8
18
|
### Features
|
package/package.json
CHANGED
package/src/app-router/index.tsx
CHANGED
|
@@ -34,6 +34,7 @@ export {
|
|
|
34
34
|
} from "../impersonation/context";
|
|
35
35
|
export type { ImpersonationActive } from "../impersonation/cookies";
|
|
36
36
|
export { impersonationRequired } from "../impersonation/required";
|
|
37
|
+
export { isAllowedRedirect } from "../impersonation/redirects";
|
|
37
38
|
export { signIn, SessionProvider } from "next-auth/react";
|
|
38
39
|
export {
|
|
39
40
|
Login,
|
|
@@ -79,13 +79,13 @@ async function ownSigner(
|
|
|
79
79
|
return "error" in access ? null : { email, accessToken: access.accessToken };
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
-
function signerFor(
|
|
82
|
+
async function signerFor(
|
|
83
83
|
authConfig: Auth.Config,
|
|
84
84
|
request: Request,
|
|
85
85
|
email: string
|
|
86
86
|
): Promise<Signer | null> {
|
|
87
87
|
if (impersonationRequired(authConfig.environment)) {
|
|
88
|
-
return
|
|
88
|
+
return personaSigner(request, email);
|
|
89
89
|
}
|
|
90
90
|
return ownSigner(authConfig, request, email);
|
|
91
91
|
}
|
|
@@ -1,36 +1,24 @@
|
|
|
1
1
|
import { getManagedBearer } from "@snokam/identity";
|
|
2
2
|
import {
|
|
3
|
-
Configuration,
|
|
4
3
|
PlatformClient,
|
|
5
4
|
ResponseError,
|
|
6
5
|
type AuthorizationPreview,
|
|
7
6
|
type IssuedAuthorizationCode,
|
|
8
7
|
} from "@snokam/platform-client";
|
|
8
|
+
import { platformClientFor } from "../impersonation/request";
|
|
9
9
|
import type { AuthorizationParams } from "./consent";
|
|
10
10
|
|
|
11
11
|
type Outcome<T> = { value: T } | { error: string };
|
|
12
12
|
|
|
13
13
|
const UNAVAILABLE = "temporarily_unavailable";
|
|
14
14
|
|
|
15
|
-
function apiBase(): string {
|
|
16
|
-
return (process.env.NEXT_PUBLIC_PLATFORM_API_BASE_URL ?? "").replace(
|
|
17
|
-
/\/+$/,
|
|
18
|
-
""
|
|
19
|
-
);
|
|
20
|
-
}
|
|
21
|
-
|
|
22
15
|
async function asLoginSite<T>(
|
|
23
16
|
call: (client: PlatformClient) => Promise<T>
|
|
24
17
|
): Promise<Outcome<T>> {
|
|
25
18
|
const loginSite = await getManagedBearer();
|
|
26
|
-
|
|
19
|
+
const client = loginSite ? platformClientFor(loginSite) : null;
|
|
20
|
+
if (!client) return { error: UNAVAILABLE };
|
|
27
21
|
|
|
28
|
-
const client = new PlatformClient(
|
|
29
|
-
new Configuration({
|
|
30
|
-
basePath: apiBase(),
|
|
31
|
-
accessToken: async () => loginSite,
|
|
32
|
-
})
|
|
33
|
-
);
|
|
34
22
|
try {
|
|
35
23
|
return { value: await call(client) };
|
|
36
24
|
} catch (error) {
|
package/src/hooks.ts
CHANGED
|
@@ -3,11 +3,25 @@ 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 {
|
|
6
|
+
import {
|
|
7
|
+
IMPERSONATION_REQUIRED_ERROR,
|
|
8
|
+
impersonationPickerUrl,
|
|
9
|
+
} from "./impersonation/required";
|
|
7
10
|
|
|
8
11
|
class ConsentRequiredError extends Error {}
|
|
9
12
|
|
|
10
|
-
class ImpersonationRequiredError extends Error {
|
|
13
|
+
class ImpersonationRequiredError extends Error {
|
|
14
|
+
constructor(readonly platformUrl: string | undefined) {
|
|
15
|
+
super(IMPERSONATION_REQUIRED_ERROR);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function sendToPicker(error: ImpersonationRequiredError): void {
|
|
20
|
+
if (!error.platformUrl) return;
|
|
21
|
+
window.location.assign(
|
|
22
|
+
impersonationPickerUrl(error.platformUrl, window.location.href)
|
|
23
|
+
);
|
|
24
|
+
}
|
|
11
25
|
|
|
12
26
|
const REFRESH_MARGIN_SECONDS = 60;
|
|
13
27
|
|
|
@@ -20,11 +34,6 @@ interface CachedToken {
|
|
|
20
34
|
let cachedToken: CachedToken | null = null;
|
|
21
35
|
let inFlight: { impKey: string; promise: Promise<string> } | null = null;
|
|
22
36
|
|
|
23
|
-
/**
|
|
24
|
-
* Identifies which principal the next token belongs to: the active impersonation
|
|
25
|
-
* tokenId, or "" for the admin's own token. Changing this (Vis som start/exit)
|
|
26
|
-
* invalidates the cache so the swap in the token route is never served stale.
|
|
27
|
-
*/
|
|
28
37
|
function readImpersonationKey(): string {
|
|
29
38
|
if (typeof document === "undefined") return "";
|
|
30
39
|
const match = document.cookie
|
|
@@ -66,7 +75,7 @@ async function fetchToken(impKey: string): Promise<string> {
|
|
|
66
75
|
throw new ConsentRequiredError();
|
|
67
76
|
}
|
|
68
77
|
if (body?.error === IMPERSONATION_REQUIRED_ERROR) {
|
|
69
|
-
throw new ImpersonationRequiredError();
|
|
78
|
+
throw new ImpersonationRequiredError(body.platformUrl);
|
|
70
79
|
}
|
|
71
80
|
}
|
|
72
81
|
|
|
@@ -134,7 +143,10 @@ export function useAuth() {
|
|
|
134
143
|
session,
|
|
135
144
|
getToken: () =>
|
|
136
145
|
getFreshToken().catch((error) => {
|
|
137
|
-
if (error instanceof ImpersonationRequiredError)
|
|
146
|
+
if (error instanceof ImpersonationRequiredError) {
|
|
147
|
+
sendToPicker(error);
|
|
148
|
+
throw error;
|
|
149
|
+
}
|
|
138
150
|
console.error(error);
|
|
139
151
|
const onAuthPage = window.location.pathname.startsWith("/auth/");
|
|
140
152
|
if (onAuthPage) return;
|
|
@@ -12,22 +12,25 @@ import {
|
|
|
12
12
|
import { IMP_ACTIVE_COOKIE, type ImpersonationActive } from "./cookies";
|
|
13
13
|
import { clearAuthTokenCache } from "../hooks";
|
|
14
14
|
import { useSessionRenewal } from "./renewal";
|
|
15
|
-
import {
|
|
15
|
+
import { isAllowedRedirect, platformBaseDomain } from "./redirects";
|
|
16
|
+
|
|
17
|
+
export interface StartImpersonationInput {
|
|
18
|
+
targetEmail: string;
|
|
19
|
+
targetName?: string;
|
|
20
|
+
targetImage?: string;
|
|
21
|
+
reason: string;
|
|
22
|
+
redirectTo?: string;
|
|
23
|
+
returnTo?: string;
|
|
24
|
+
landingUrl?: string;
|
|
25
|
+
}
|
|
16
26
|
|
|
17
27
|
interface ImpersonationContextValue {
|
|
18
28
|
active: ImpersonationActive | null;
|
|
19
29
|
starting: boolean;
|
|
20
30
|
exiting: boolean;
|
|
21
|
-
start: (
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
targetImage?: string;
|
|
25
|
-
reason: string;
|
|
26
|
-
redirectTo?: string;
|
|
27
|
-
returnTo?: string;
|
|
28
|
-
landingUrl?: string;
|
|
29
|
-
handOffOnly?: boolean;
|
|
30
|
-
}) => Promise<{ ok: boolean; error?: string }>;
|
|
31
|
+
start: (
|
|
32
|
+
input: StartImpersonationInput
|
|
33
|
+
) => Promise<{ ok: boolean; error?: string }>;
|
|
31
34
|
exit: () => Promise<void>;
|
|
32
35
|
}
|
|
33
36
|
|
|
@@ -35,27 +38,12 @@ const ImpersonationContext = createContext<ImpersonationContextValue | null>(
|
|
|
35
38
|
null
|
|
36
39
|
);
|
|
37
40
|
|
|
38
|
-
/** Registrable base domain of the platform (last two labels of the API host). */
|
|
39
|
-
function platformBaseDomain(): string {
|
|
40
|
-
const api = process.env.NEXT_PUBLIC_PLATFORM_API_BASE_URL;
|
|
41
|
-
if (!api) {
|
|
42
|
-
throw new Error("NEXT_PUBLIC_PLATFORM_API_BASE_URL is not configured");
|
|
43
|
-
}
|
|
44
|
-
return new URL(api).hostname.split(".").slice(-2).join(".");
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
/** Guards the exit redirect against open-redirect: same-origin, or https on the platform base domain. */
|
|
48
41
|
function isSafeReturnTo(returnTo: string): boolean {
|
|
49
42
|
if (typeof window === "undefined") return false;
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
if (url.protocol !== "https:") return false;
|
|
55
|
-
return url.hostname === base || url.hostname.endsWith(`.${base}`);
|
|
56
|
-
} catch {
|
|
57
|
-
return false;
|
|
58
|
-
}
|
|
43
|
+
return isAllowedRedirect(returnTo, {
|
|
44
|
+
origin: window.location.origin,
|
|
45
|
+
platformDomain: platformBaseDomain(),
|
|
46
|
+
});
|
|
59
47
|
}
|
|
60
48
|
|
|
61
49
|
function readActiveCookie(): ImpersonationActive | null {
|
|
@@ -77,7 +65,6 @@ function readActiveCookie(): ImpersonationActive | null {
|
|
|
77
65
|
|
|
78
66
|
export function ImpersonationProvider({ children }: { children: ReactNode }) {
|
|
79
67
|
const [active, setActive] = useState<ImpersonationActive | null>(null);
|
|
80
|
-
const [cookieRead, setCookieRead] = useState(false);
|
|
81
68
|
const [starting, setStarting] = useState(false);
|
|
82
69
|
const [exiting, setExiting] = useState(false);
|
|
83
70
|
|
|
@@ -85,52 +72,39 @@ export function ImpersonationProvider({ children }: { children: ReactNode }) {
|
|
|
85
72
|
|
|
86
73
|
useEffect(() => {
|
|
87
74
|
rereadCookie();
|
|
88
|
-
setCookieRead(true);
|
|
89
75
|
}, [rereadCookie]);
|
|
90
76
|
|
|
91
77
|
useSessionRenewal(active, rereadCookie);
|
|
92
78
|
|
|
93
|
-
const start = useCallback(
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
const res = await fetch("/api/impersonation/start", {
|
|
108
|
-
method: "POST",
|
|
109
|
-
headers: { "Content-Type": "application/json" },
|
|
110
|
-
credentials: "include",
|
|
111
|
-
body: JSON.stringify(input),
|
|
112
|
-
});
|
|
113
|
-
if (!res.ok) {
|
|
114
|
-
return {
|
|
115
|
-
ok: false,
|
|
116
|
-
error: (await res.text()) || `HTTP ${res.status}`,
|
|
117
|
-
};
|
|
118
|
-
}
|
|
119
|
-
clearAuthTokenCache();
|
|
120
|
-
const data = (await res.json().catch(() => ({}))) as {
|
|
121
|
-
adoptUrl?: string;
|
|
79
|
+
const start = useCallback(async (input: StartImpersonationInput) => {
|
|
80
|
+
setStarting(true);
|
|
81
|
+
try {
|
|
82
|
+
const { redirectTo, landingUrl } = input;
|
|
83
|
+
const res = await fetch("/api/impersonation/start", {
|
|
84
|
+
method: "POST",
|
|
85
|
+
headers: { "Content-Type": "application/json" },
|
|
86
|
+
credentials: "include",
|
|
87
|
+
body: JSON.stringify(input),
|
|
88
|
+
});
|
|
89
|
+
if (!res.ok) {
|
|
90
|
+
return {
|
|
91
|
+
ok: false,
|
|
92
|
+
error: (await res.text()) || `HTTP ${res.status}`,
|
|
122
93
|
};
|
|
123
|
-
if (data.adoptUrl) window.location.assign(data.adoptUrl);
|
|
124
|
-
else if (landingUrl) window.location.assign(landingUrl);
|
|
125
|
-
else if (redirectTo) window.location.assign(redirectTo);
|
|
126
|
-
else window.location.reload();
|
|
127
|
-
return { ok: true };
|
|
128
|
-
} finally {
|
|
129
|
-
setStarting(false);
|
|
130
94
|
}
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
95
|
+
clearAuthTokenCache();
|
|
96
|
+
const data = (await res.json().catch(() => ({}))) as {
|
|
97
|
+
adoptUrl?: string;
|
|
98
|
+
};
|
|
99
|
+
if (data.adoptUrl) window.location.assign(data.adoptUrl);
|
|
100
|
+
else if (landingUrl) window.location.assign(landingUrl);
|
|
101
|
+
else if (redirectTo) window.location.assign(redirectTo);
|
|
102
|
+
else window.location.reload();
|
|
103
|
+
return { ok: true };
|
|
104
|
+
} finally {
|
|
105
|
+
setStarting(false);
|
|
106
|
+
}
|
|
107
|
+
}, []);
|
|
134
108
|
|
|
135
109
|
const exit = useCallback(async () => {
|
|
136
110
|
setExiting(true);
|
|
@@ -159,12 +133,7 @@ export function ImpersonationProvider({ children }: { children: ReactNode }) {
|
|
|
159
133
|
|
|
160
134
|
return (
|
|
161
135
|
<ImpersonationContext.Provider value={value}>
|
|
162
|
-
|
|
163
|
-
sessionActive={!!active}
|
|
164
|
-
cookieRead={cookieRead}
|
|
165
|
-
>
|
|
166
|
-
{children}
|
|
167
|
-
</RequiredImpersonationGate>
|
|
136
|
+
{children}
|
|
168
137
|
</ImpersonationContext.Provider>
|
|
169
138
|
);
|
|
170
139
|
}
|
|
@@ -172,7 +141,6 @@ export function ImpersonationProvider({ children }: { children: ReactNode }) {
|
|
|
172
141
|
export function useImpersonation(): ImpersonationContextValue {
|
|
173
142
|
const context = useContext(ImpersonationContext);
|
|
174
143
|
if (!context) {
|
|
175
|
-
// Safe no-op default so the shared banner can mount in apps that haven't wired the provider yet.
|
|
176
144
|
return {
|
|
177
145
|
active: null,
|
|
178
146
|
starting: false,
|
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
import { resolveCookieDomain } from "../session";
|
|
2
2
|
|
|
3
|
-
/** HttpOnly — carries the impersonation bearer; only the token handler ever reads it. */
|
|
4
3
|
export const IMP_SESSION_COOKIE = "imp_session";
|
|
5
|
-
/** Readable — non-sensitive session facts that drive the banner + provider (no token). */
|
|
6
4
|
export const IMP_ACTIVE_COOKIE = "imp_active";
|
|
7
5
|
|
|
8
6
|
export interface ImpersonationSession {
|
|
@@ -89,7 +87,6 @@ function serialize(
|
|
|
89
87
|
return parts.join("; ");
|
|
90
88
|
}
|
|
91
89
|
|
|
92
|
-
/** Set-Cookie values that open a session (server-side, on mint success). */
|
|
93
90
|
export function buildImpersonationCookies(
|
|
94
91
|
minted: MintedToken,
|
|
95
92
|
host: string | null
|
|
@@ -123,7 +120,6 @@ export function buildImpersonationCookies(
|
|
|
123
120
|
];
|
|
124
121
|
}
|
|
125
122
|
|
|
126
|
-
/** Set-Cookie values that clear the session (exit or after posting the end record). */
|
|
127
123
|
export function clearImpersonationCookies(host: string | null): string[] {
|
|
128
124
|
return [
|
|
129
125
|
serialize(IMP_SESSION_COOKIE, "", { host, httpOnly: true, maxAge: 0 }),
|
|
@@ -13,35 +13,35 @@ import {
|
|
|
13
13
|
platformNotConfigured,
|
|
14
14
|
requestHost,
|
|
15
15
|
} from "./request";
|
|
16
|
+
import { isAllowedRedirect, platformBaseDomain } from "./redirects";
|
|
17
|
+
|
|
18
|
+
interface StartBody {
|
|
19
|
+
targetEmail?: string;
|
|
20
|
+
targetName?: string;
|
|
21
|
+
targetImage?: string;
|
|
22
|
+
reason?: string;
|
|
23
|
+
redirectTo?: string;
|
|
24
|
+
returnTo?: string;
|
|
25
|
+
landingUrl?: string;
|
|
26
|
+
}
|
|
16
27
|
|
|
17
28
|
function registrable(host: string): string {
|
|
18
29
|
return host.split(":")[0].toLowerCase().split(".").slice(-2).join(".");
|
|
19
30
|
}
|
|
20
31
|
|
|
21
|
-
function
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
}
|
|
26
|
-
return
|
|
32
|
+
function resolveLanding(returnTo: string, origin: string): string {
|
|
33
|
+
const allowed = isAllowedRedirect(returnTo, {
|
|
34
|
+
origin,
|
|
35
|
+
platformDomain: platformBaseDomain(),
|
|
36
|
+
});
|
|
37
|
+
return allowed
|
|
38
|
+
? new URL(returnTo, origin).toString()
|
|
39
|
+
: new URL("/", origin).toString();
|
|
27
40
|
}
|
|
28
41
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
const base = platformBaseDomain();
|
|
33
|
-
try {
|
|
34
|
-
const url = new URL(returnTo, origin);
|
|
35
|
-
if (url.origin === origin) return url.toString();
|
|
36
|
-
if (
|
|
37
|
-
url.protocol === "https:" &&
|
|
38
|
-
(url.hostname === base || url.hostname.endsWith(`.${base}`))
|
|
39
|
-
)
|
|
40
|
-
return url.toString();
|
|
41
|
-
} catch {
|
|
42
|
-
// Fall through to the safe default.
|
|
43
|
-
}
|
|
44
|
-
return new URL("/", origin).toString();
|
|
42
|
+
function withCookies(response: Response, cookies: string[]): Response {
|
|
43
|
+
for (const cookie of cookies) response.headers.append("Set-Cookie", cookie);
|
|
44
|
+
return response;
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
function cookieScope(host: string): string {
|
|
@@ -62,11 +62,6 @@ function handsOffToTarget(request: Request, target: string): boolean {
|
|
|
62
62
|
}
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
/**
|
|
66
|
-
* Starts a view-as-employee session: authenticates as the admin, calls the platform mint endpoint
|
|
67
|
-
* (which enforces the platform.impersonate gate + required reason + audit server-side), and, on
|
|
68
|
-
* success, sets the impersonation cookies so subsequent product calls ride the minted token.
|
|
69
|
-
*/
|
|
70
65
|
export function createImpersonationStartHandler(authConfig: Auth.Config) {
|
|
71
66
|
return async function POST(request: Request) {
|
|
72
67
|
const caller = await resolveAccessToken(authConfig, request);
|
|
@@ -75,16 +70,7 @@ export function createImpersonationStartHandler(authConfig: Auth.Config) {
|
|
|
75
70
|
const client = platformClient(caller.accessToken);
|
|
76
71
|
if (!client) return platformNotConfigured();
|
|
77
72
|
|
|
78
|
-
let body:
|
|
79
|
-
targetEmail?: string;
|
|
80
|
-
targetName?: string;
|
|
81
|
-
targetImage?: string;
|
|
82
|
-
reason?: string;
|
|
83
|
-
redirectTo?: string;
|
|
84
|
-
returnTo?: string;
|
|
85
|
-
landingUrl?: string;
|
|
86
|
-
handOffOnly?: boolean;
|
|
87
|
-
};
|
|
73
|
+
let body: StartBody;
|
|
88
74
|
try {
|
|
89
75
|
body = await request.json();
|
|
90
76
|
} catch {
|
|
@@ -118,46 +104,33 @@ export function createImpersonationStartHandler(authConfig: Auth.Config) {
|
|
|
118
104
|
tokenId: result.tokenId,
|
|
119
105
|
expiresOn: result.expiresOn,
|
|
120
106
|
};
|
|
121
|
-
|
|
122
|
-
// Custom-domain tenant: hand the session to the tenant domain's adopt route (login's pattern).
|
|
123
|
-
if (body.redirectTo && handsOffToTarget(request, body.redirectTo)) {
|
|
124
|
-
const target = new URL(body.redirectTo);
|
|
125
|
-
const ticket = sealImpersonationTicket(
|
|
126
|
-
{ ...result, business: target.host, returnTo: body.returnTo },
|
|
127
|
-
authConfig.nextAuthSecret
|
|
128
|
-
);
|
|
129
|
-
const adopt = new URL("/api/impersonation/adopt", target.origin);
|
|
130
|
-
adopt.searchParams.set("t", ticket);
|
|
131
|
-
adopt.searchParams.set(
|
|
132
|
-
"returnTo",
|
|
133
|
-
body.landingUrl ?? target.pathname + target.search
|
|
134
|
-
);
|
|
135
|
-
const crossDomain = Response.json({
|
|
136
|
-
...summary,
|
|
137
|
-
adoptUrl: adopt.toString(),
|
|
138
|
-
});
|
|
139
|
-
if (body.handOffOnly) return crossDomain;
|
|
140
|
-
for (const cookie of buildImpersonationCookies(
|
|
141
|
-
{ ...result, returnTo: body.returnTo },
|
|
142
|
-
requestHost(request)
|
|
143
|
-
)) {
|
|
144
|
-
crossDomain.headers.append("Set-Cookie", cookie);
|
|
145
|
-
}
|
|
146
|
-
return crossDomain;
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
const out = Response.json(summary);
|
|
150
|
-
for (const cookie of buildImpersonationCookies(
|
|
107
|
+
const cookies = buildImpersonationCookies(
|
|
151
108
|
{ ...result, returnTo: body.returnTo },
|
|
152
109
|
requestHost(request)
|
|
153
|
-
)
|
|
154
|
-
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
if (!body.redirectTo || !handsOffToTarget(request, body.redirectTo)) {
|
|
113
|
+
return withCookies(Response.json(summary), cookies);
|
|
155
114
|
}
|
|
156
|
-
|
|
115
|
+
|
|
116
|
+
const target = new URL(body.redirectTo);
|
|
117
|
+
const ticket = sealImpersonationTicket(
|
|
118
|
+
{ ...result, business: target.host, returnTo: body.returnTo },
|
|
119
|
+
authConfig.nextAuthSecret
|
|
120
|
+
);
|
|
121
|
+
const adopt = new URL("/api/impersonation/adopt", target.origin);
|
|
122
|
+
adopt.searchParams.set("t", ticket);
|
|
123
|
+
adopt.searchParams.set(
|
|
124
|
+
"returnTo",
|
|
125
|
+
body.landingUrl ?? target.pathname + target.search
|
|
126
|
+
);
|
|
127
|
+
return withCookies(
|
|
128
|
+
Response.json({ ...summary, adoptUrl: adopt.toString() }),
|
|
129
|
+
cookies
|
|
130
|
+
);
|
|
157
131
|
};
|
|
158
132
|
}
|
|
159
133
|
|
|
160
|
-
// Runs on the tenant domain: opens the sealed ticket and sets the impersonation cookie here.
|
|
161
134
|
export function createImpersonationAdoptHandler(authConfig: Auth.Config) {
|
|
162
135
|
return async function GET(request: Request) {
|
|
163
136
|
const url = new URL(request.url);
|
|
@@ -185,21 +158,13 @@ export function createImpersonationAdoptHandler(authConfig: Auth.Config) {
|
|
|
185
158
|
url.searchParams.get("returnTo") || "/",
|
|
186
159
|
origin
|
|
187
160
|
);
|
|
188
|
-
|
|
189
|
-
status: 302,
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
for (const cookie of buildImpersonationCookies(claims, host)) {
|
|
193
|
-
out.headers.append("Set-Cookie", cookie);
|
|
194
|
-
}
|
|
195
|
-
return out;
|
|
161
|
+
return withCookies(
|
|
162
|
+
new Response(null, { status: 302, headers: { Location: dest } }),
|
|
163
|
+
buildImpersonationCookies(claims, host)
|
|
164
|
+
);
|
|
196
165
|
};
|
|
197
166
|
}
|
|
198
167
|
|
|
199
|
-
/**
|
|
200
|
-
* Ends a session: writes the append-only session-end audit record (best effort) and clears the
|
|
201
|
-
* cookies so the admin's own token is restored. Token expiry auto-ends too.
|
|
202
|
-
*/
|
|
203
168
|
export function createImpersonationEndHandler(authConfig: Auth.Config) {
|
|
204
169
|
return async function POST(request: Request) {
|
|
205
170
|
const session = readImpersonationSession(request);
|
|
@@ -213,10 +178,9 @@ export function createImpersonationEndHandler(authConfig: Auth.Config) {
|
|
|
213
178
|
.catch(() => undefined);
|
|
214
179
|
}
|
|
215
180
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
return out;
|
|
181
|
+
return withCookies(
|
|
182
|
+
Response.json({ ok: true }),
|
|
183
|
+
clearImpersonationCookies(requestHost(request))
|
|
184
|
+
);
|
|
221
185
|
};
|
|
222
186
|
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { isAllowedRedirect } from "./redirects.ts";
|
|
4
|
+
|
|
5
|
+
const rules = {
|
|
6
|
+
origin: "https://www.test.snosky.no",
|
|
7
|
+
platformDomain: "snosky.no",
|
|
8
|
+
extraOrigins: ["https://minside.test.snokam.no"],
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
describe("redirect allow-list", () => {
|
|
12
|
+
it("allows the same origin, relative or absolute", () => {
|
|
13
|
+
assert.equal(isAllowedRedirect("/manage", rules), true);
|
|
14
|
+
assert.equal(
|
|
15
|
+
isAllowedRedirect("https://www.test.snosky.no/x", rules),
|
|
16
|
+
true
|
|
17
|
+
);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("allows https on the platform domain and its subdomains", () => {
|
|
21
|
+
assert.equal(isAllowedRedirect("https://snosky.no/", rules), true);
|
|
22
|
+
assert.equal(
|
|
23
|
+
isAllowedRedirect("https://snokam.test.snosky.no/minside", rules),
|
|
24
|
+
true
|
|
25
|
+
);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("allows the extra origins it is given", () => {
|
|
29
|
+
assert.equal(
|
|
30
|
+
isAllowedRedirect("https://minside.test.snokam.no/okonomi", rules),
|
|
31
|
+
true
|
|
32
|
+
);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("refuses everything else", () => {
|
|
36
|
+
assert.equal(isAllowedRedirect("https://evil.example/", rules), false);
|
|
37
|
+
assert.equal(
|
|
38
|
+
isAllowedRedirect("http://snokam.test.snosky.no/", rules),
|
|
39
|
+
false
|
|
40
|
+
);
|
|
41
|
+
assert.equal(
|
|
42
|
+
isAllowedRedirect("https://snosky.no.evil.example/", rules),
|
|
43
|
+
false
|
|
44
|
+
);
|
|
45
|
+
assert.equal(isAllowedRedirect("javascript:alert(1)", rules), false);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export function platformBaseDomain(): string {
|
|
2
|
+
const api = process.env.NEXT_PUBLIC_PLATFORM_API_BASE_URL;
|
|
3
|
+
if (!api) {
|
|
4
|
+
throw new Error("NEXT_PUBLIC_PLATFORM_API_BASE_URL is not configured");
|
|
5
|
+
}
|
|
6
|
+
return new URL(api).hostname.split(".").slice(-2).join(".");
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface RedirectRules {
|
|
10
|
+
origin: string;
|
|
11
|
+
platformDomain: string;
|
|
12
|
+
extraOrigins?: string[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function isAllowedRedirect(
|
|
16
|
+
target: string,
|
|
17
|
+
{ origin, platformDomain, extraOrigins = [] }: RedirectRules
|
|
18
|
+
): boolean {
|
|
19
|
+
try {
|
|
20
|
+
const url = new URL(target, origin);
|
|
21
|
+
if (url.origin === origin) return true;
|
|
22
|
+
if (url.protocol !== "https:") return false;
|
|
23
|
+
return (
|
|
24
|
+
url.hostname === platformDomain ||
|
|
25
|
+
url.hostname.endsWith(`.${platformDomain}`) ||
|
|
26
|
+
extraOrigins.includes(url.origin)
|
|
27
|
+
);
|
|
28
|
+
} catch {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -10,17 +10,20 @@ export function platformBase(): string | null {
|
|
|
10
10
|
);
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
export function
|
|
13
|
+
export function platformClientFor(
|
|
14
|
+
authorization: string
|
|
15
|
+
): PlatformClient | null {
|
|
14
16
|
const basePath = platformBase();
|
|
15
17
|
if (!basePath) return null;
|
|
16
18
|
return new PlatformClient(
|
|
17
|
-
new Configuration({
|
|
18
|
-
basePath,
|
|
19
|
-
accessToken: async () => `Bearer ${accessToken}`,
|
|
20
|
-
})
|
|
19
|
+
new Configuration({ basePath, accessToken: async () => authorization })
|
|
21
20
|
);
|
|
22
21
|
}
|
|
23
22
|
|
|
23
|
+
export function platformClient(accessToken: string): PlatformClient | null {
|
|
24
|
+
return platformClientFor(`Bearer ${accessToken}`);
|
|
25
|
+
}
|
|
26
|
+
|
|
24
27
|
export function platformNotConfigured(): Response {
|
|
25
28
|
return new Response("NEXT_PUBLIC_PLATFORM_API_BASE_URL is not configured", {
|
|
26
29
|
status: 500,
|
|
@@ -3,20 +3,10 @@ import { describe, it } from "node:test";
|
|
|
3
3
|
import {
|
|
4
4
|
impersonationPickerUrl,
|
|
5
5
|
impersonationRequired,
|
|
6
|
-
isExemptRoute,
|
|
7
6
|
renewDelayMs,
|
|
8
|
-
sendsToPicker,
|
|
9
|
-
withholdsApp,
|
|
10
7
|
} from "./required.ts";
|
|
11
8
|
|
|
12
|
-
|
|
13
|
-
authenticated: true,
|
|
14
|
-
sessionActive: false,
|
|
15
|
-
cookieRead: true,
|
|
16
|
-
exemptRoute: false,
|
|
17
|
-
};
|
|
18
|
-
|
|
19
|
-
describe("impersonation required gate", () => {
|
|
9
|
+
describe("impersonation required", () => {
|
|
20
10
|
it("is off in production and everywhere that is not the test environment", () => {
|
|
21
11
|
for (const environment of ["p", "test", "production", "", undefined]) {
|
|
22
12
|
assert.equal(impersonationRequired(environment), false);
|
|
@@ -27,58 +17,21 @@ describe("impersonation required gate", () => {
|
|
|
27
17
|
assert.equal(impersonationRequired("t"), true);
|
|
28
18
|
});
|
|
29
19
|
|
|
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
20
|
it("points the picker back at the page the person asked for", () => {
|
|
61
21
|
const url = new URL(
|
|
62
22
|
impersonationPickerUrl(
|
|
63
23
|
"https://www.test.snosky.no",
|
|
64
|
-
"https://
|
|
24
|
+
"https://minside.test.snokam.no/okonomi?x=1"
|
|
65
25
|
)
|
|
66
26
|
);
|
|
27
|
+
assert.equal(url.origin, "https://www.test.snosky.no");
|
|
67
28
|
assert.equal(url.pathname, "/velg-bruker");
|
|
68
29
|
assert.equal(
|
|
69
30
|
url.searchParams.get("returnTo"),
|
|
70
|
-
"https://
|
|
31
|
+
"https://minside.test.snokam.no/okonomi?x=1"
|
|
71
32
|
);
|
|
72
33
|
});
|
|
73
34
|
|
|
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
35
|
it("renews two minutes before the token expires, and at once when already late", () => {
|
|
83
36
|
assert.equal(renewDelayMs(1_000, 0), 880_000);
|
|
84
37
|
assert.equal(renewDelayMs(1_000, 999_000), 0);
|
|
@@ -12,35 +12,6 @@ export function impersonationRequired(
|
|
|
12
12
|
return environment === TEST_ENVIRONMENT;
|
|
13
13
|
}
|
|
14
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
15
|
export function impersonationPickerUrl(
|
|
45
16
|
platformBaseUrl: string,
|
|
46
17
|
returnTo: string
|
package/src/middleware.tsx
CHANGED
|
@@ -6,6 +6,15 @@ import { Auth } from "./types";
|
|
|
6
6
|
import { getSessionCookieSettings, getAuthUrl } from "./session";
|
|
7
7
|
import { addCorsHeaders } from "./cors";
|
|
8
8
|
|
|
9
|
+
const TEST_ENVIRONMENT = "t";
|
|
10
|
+
|
|
11
|
+
const LOGIN_FLOW_PATHS = [
|
|
12
|
+
"/auth/",
|
|
13
|
+
"/handoff/",
|
|
14
|
+
"/api/password/",
|
|
15
|
+
"/api/impersonation/adopt",
|
|
16
|
+
];
|
|
17
|
+
|
|
9
18
|
export function buildAuthMiddleware({
|
|
10
19
|
matcher = [],
|
|
11
20
|
callbacks = {},
|
|
@@ -13,7 +22,9 @@ export function buildAuthMiddleware({
|
|
|
13
22
|
cors = {},
|
|
14
23
|
...rest
|
|
15
24
|
}: Auth.Middleware.Props = {}): Auth.Middleware.Builder {
|
|
16
|
-
const requireLogin =
|
|
25
|
+
const requireLogin =
|
|
26
|
+
process.env.IS_PR === "true" ||
|
|
27
|
+
process.env.ENVIRONMENT === TEST_ENVIRONMENT;
|
|
17
28
|
const { name } = getSessionCookieSettings(getAuthUrl());
|
|
18
29
|
|
|
19
30
|
const shouldProtectPath = (pathname: string): boolean => {
|
|
@@ -25,7 +36,7 @@ export function buildAuthMiddleware({
|
|
|
25
36
|
return false;
|
|
26
37
|
}
|
|
27
38
|
|
|
28
|
-
if (pathname.startsWith(
|
|
39
|
+
if (LOGIN_FLOW_PATHS.some((path) => pathname.startsWith(path))) {
|
|
29
40
|
return false;
|
|
30
41
|
}
|
|
31
42
|
|
|
@@ -116,7 +116,10 @@ export function createTokenHandler(authConfig: Auth.Config) {
|
|
|
116
116
|
|
|
117
117
|
if (impersonationRequired(authConfig.environment)) {
|
|
118
118
|
return Response.json(
|
|
119
|
-
{
|
|
119
|
+
{
|
|
120
|
+
error: IMPERSONATION_REQUIRED_ERROR,
|
|
121
|
+
platformUrl: process.env.NEXT_PUBLIC_PLATFORM_BASE_URL,
|
|
122
|
+
},
|
|
120
123
|
{ status: 403 }
|
|
121
124
|
);
|
|
122
125
|
}
|
|
@@ -1,51 +0,0 @@
|
|
|
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
|
-
}
|