@snokam/auth 3.7.0 → 3.7.2
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 +11 -0
- package/package.json +4 -4
- package/src/app-router/index.tsx +1 -0
- package/src/app-sign-in/consent.ts +1 -0
- package/src/app-sign-in/handlers.ts +10 -11
- package/src/app-sign-in/platform.ts +3 -15
- package/src/hooks.ts +2 -1
- package/src/impersonation/context.tsx +54 -73
- package/src/impersonation/cookies.ts +0 -4
- package/src/impersonation/handlers.ts +54 -88
- package/src/impersonation/redirects.test.ts +47 -0
- package/src/impersonation/redirects.ts +30 -0
- package/src/impersonation/request.ts +8 -5
- package/src/impersonation/required.test.ts +9 -0
- package/src/impersonation/required.ts +4 -0
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,17 @@
|
|
|
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.2](https://github.com/snokam/components/compare/@snokam/auth@3.7.0...@snokam/auth@3.7.2) (2026-10-06)
|
|
7
|
+
|
|
8
|
+
### Bug Fixes
|
|
9
|
+
|
|
10
|
+
- **auth:** ending a persona returns to where it started; no picker loop from sign-in ([#2708](https://github.com/snokam/components/issues/2708)) ([e609377](https://github.com/snokam/components/commit/e6093776f8a5458b611f622f09611145b7cbc461))
|
|
11
|
+
- **mcp:** return iss on the sign-in callback so Codex and Cursor accept it ([#2707](https://github.com/snokam/components/issues/2707)) ([b55887e](https://github.com/snokam/components/commit/b55887e62b9e4b84fbd4bed7fbd4b5e588b534a4))
|
|
12
|
+
|
|
13
|
+
## [3.7.1](https://github.com/snokam/components/compare/@snokam/auth@3.7.0...@snokam/auth@3.7.1) (2026-10-06)
|
|
14
|
+
|
|
15
|
+
**Note:** Version bump only for package @snokam/auth
|
|
16
|
+
|
|
6
17
|
# [3.7.0](https://github.com/snokam/components/compare/@snokam/auth@3.6.0...@snokam/auth@3.7.0) (2026-10-06)
|
|
7
18
|
|
|
8
19
|
### Features
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@snokam/auth",
|
|
3
3
|
"description": "Snøkam auth components",
|
|
4
|
-
"version": "3.7.
|
|
4
|
+
"version": "3.7.2",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"types": "src/index.ts",
|
|
7
7
|
"files": [
|
|
@@ -27,10 +27,10 @@
|
|
|
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.6.
|
|
30
|
+
"@snokam/platform-client": "3.6.1",
|
|
31
31
|
"@snokam/react-query": "1.1.0",
|
|
32
|
-
"@snokam/sanity-page-content": "4.
|
|
33
|
-
"@snokam/tenant": "2.1.
|
|
32
|
+
"@snokam/sanity-page-content": "4.5.0",
|
|
33
|
+
"@snokam/tenant": "2.1.14",
|
|
34
34
|
"@snokam/ui": "4.0.0"
|
|
35
35
|
},
|
|
36
36
|
"peerDependencies": {
|
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,
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
import type { Auth } from "../types";
|
|
16
16
|
import {
|
|
17
17
|
type AuthorizationParams,
|
|
18
|
+
type ConsentClaims,
|
|
18
19
|
CONSENT_TTL_SECONDS,
|
|
19
20
|
readAppSignInConsent,
|
|
20
21
|
sealConsent,
|
|
@@ -79,13 +80,13 @@ async function ownSigner(
|
|
|
79
80
|
return "error" in access ? null : { email, accessToken: access.accessToken };
|
|
80
81
|
}
|
|
81
82
|
|
|
82
|
-
function signerFor(
|
|
83
|
+
async function signerFor(
|
|
83
84
|
authConfig: Auth.Config,
|
|
84
85
|
request: Request,
|
|
85
86
|
email: string
|
|
86
87
|
): Promise<Signer | null> {
|
|
87
88
|
if (impersonationRequired(authConfig.environment)) {
|
|
88
|
-
return
|
|
89
|
+
return personaSigner(request, email);
|
|
89
90
|
}
|
|
90
91
|
return ownSigner(authConfig, request, email);
|
|
91
92
|
}
|
|
@@ -133,15 +134,15 @@ function sameOrigin(request: Request): boolean {
|
|
|
133
134
|
|
|
134
135
|
function clientRedirect(
|
|
135
136
|
request: Request,
|
|
136
|
-
|
|
137
|
-
state: string,
|
|
137
|
+
consent: ConsentClaims,
|
|
138
138
|
result: Record<string, string>
|
|
139
139
|
): Response {
|
|
140
|
-
const target = new URL(redirectUri);
|
|
140
|
+
const target = new URL(consent.redirectUri);
|
|
141
141
|
for (const [key, value] of Object.entries(result)) {
|
|
142
142
|
target.searchParams.set(key, value);
|
|
143
143
|
}
|
|
144
|
-
if (state) target.searchParams.set("state", state);
|
|
144
|
+
if (consent.state) target.searchParams.set("state", consent.state);
|
|
145
|
+
if (consent.issuer) target.searchParams.set("iss", consent.issuer);
|
|
145
146
|
return new Response(null, {
|
|
146
147
|
status: 303,
|
|
147
148
|
headers: {
|
|
@@ -180,6 +181,7 @@ export function createAppSignInAuthorizeHandler(authConfig: Auth.Config) {
|
|
|
180
181
|
...params,
|
|
181
182
|
clientName: preview.value.clientName ?? "",
|
|
182
183
|
businessName: preview.value.businessName ?? "",
|
|
184
|
+
issuer: preview.value.issuer ?? "",
|
|
183
185
|
business: new URL(params.resource).hostname,
|
|
184
186
|
state: query.get("state") ?? "",
|
|
185
187
|
email: signer.email,
|
|
@@ -219,16 +221,13 @@ export function createAppSignInApproveHandler(authConfig: Auth.Config) {
|
|
|
219
221
|
if (!signer || signer.email !== consent.email) return invalidRequest();
|
|
220
222
|
|
|
221
223
|
if (form.get("decision") !== "approve") {
|
|
222
|
-
return clientRedirect(request, consent
|
|
223
|
-
error: ACCESS_DENIED,
|
|
224
|
-
});
|
|
224
|
+
return clientRedirect(request, consent, { error: ACCESS_DENIED });
|
|
225
225
|
}
|
|
226
226
|
|
|
227
227
|
const approved = await approveAuthorization(consent, signer.accessToken);
|
|
228
228
|
return clientRedirect(
|
|
229
229
|
request,
|
|
230
|
-
consent
|
|
231
|
-
consent.state,
|
|
230
|
+
consent,
|
|
232
231
|
"error" in approved
|
|
233
232
|
? { error: approved.error }
|
|
234
233
|
: { code: approved.value.code ?? "" }
|
|
@@ -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
|
@@ -6,6 +6,7 @@ import { useSession } from "./session/useSession";
|
|
|
6
6
|
import {
|
|
7
7
|
IMPERSONATION_REQUIRED_ERROR,
|
|
8
8
|
impersonationPickerUrl,
|
|
9
|
+
isSignInPage,
|
|
9
10
|
} from "./impersonation/required";
|
|
10
11
|
|
|
11
12
|
class ConsentRequiredError extends Error {}
|
|
@@ -17,7 +18,7 @@ class ImpersonationRequiredError extends Error {
|
|
|
17
18
|
}
|
|
18
19
|
|
|
19
20
|
function sendToPicker(error: ImpersonationRequiredError): void {
|
|
20
|
-
if (!error.platformUrl) return;
|
|
21
|
+
if (!error.platformUrl || isSignInPage(window.location.pathname)) return;
|
|
21
22
|
window.location.assign(
|
|
22
23
|
impersonationPickerUrl(error.platformUrl, window.location.href)
|
|
23
24
|
);
|
|
@@ -12,21 +12,26 @@ 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 { useOptionalConfig } from "@snokam/tenant";
|
|
16
|
+
import { isAllowedRedirect, platformBaseDomain } from "./redirects";
|
|
17
|
+
|
|
18
|
+
export interface StartImpersonationInput {
|
|
19
|
+
targetEmail: string;
|
|
20
|
+
targetName?: string;
|
|
21
|
+
targetImage?: string;
|
|
22
|
+
reason: string;
|
|
23
|
+
redirectTo?: string;
|
|
24
|
+
returnTo?: string;
|
|
25
|
+
landingUrl?: string;
|
|
26
|
+
}
|
|
15
27
|
|
|
16
28
|
interface ImpersonationContextValue {
|
|
17
29
|
active: ImpersonationActive | null;
|
|
18
30
|
starting: boolean;
|
|
19
31
|
exiting: boolean;
|
|
20
|
-
start: (
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
targetImage?: string;
|
|
24
|
-
reason: string;
|
|
25
|
-
redirectTo?: string;
|
|
26
|
-
returnTo?: string;
|
|
27
|
-
landingUrl?: string;
|
|
28
|
-
handOffOnly?: boolean;
|
|
29
|
-
}) => Promise<{ ok: boolean; error?: string }>;
|
|
32
|
+
start: (
|
|
33
|
+
input: StartImpersonationInput
|
|
34
|
+
) => Promise<{ ok: boolean; error?: string }>;
|
|
30
35
|
exit: () => Promise<void>;
|
|
31
36
|
}
|
|
32
37
|
|
|
@@ -34,27 +39,15 @@ const ImpersonationContext = createContext<ImpersonationContextValue | null>(
|
|
|
34
39
|
null
|
|
35
40
|
);
|
|
36
41
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
/** Guards the exit redirect against open-redirect: same-origin, or https on the platform base domain. */
|
|
47
|
-
function isSafeReturnTo(returnTo: string): boolean {
|
|
48
|
-
if (typeof window === "undefined") return false;
|
|
49
|
-
const base = platformBaseDomain();
|
|
50
|
-
try {
|
|
51
|
-
const url = new URL(returnTo, window.location.origin);
|
|
52
|
-
if (url.origin === window.location.origin) return true;
|
|
53
|
-
if (url.protocol !== "https:") return false;
|
|
54
|
-
return url.hostname === base || url.hostname.endsWith(`.${base}`);
|
|
55
|
-
} catch {
|
|
56
|
-
return false;
|
|
57
|
-
}
|
|
42
|
+
function isSafeReturnTo(
|
|
43
|
+
returnTo: string,
|
|
44
|
+
apiBaseUrl: string | undefined
|
|
45
|
+
): boolean {
|
|
46
|
+
if (typeof window === "undefined" || !apiBaseUrl) return false;
|
|
47
|
+
return isAllowedRedirect(returnTo, {
|
|
48
|
+
origin: window.location.origin,
|
|
49
|
+
platformDomain: platformBaseDomain(apiBaseUrl),
|
|
50
|
+
});
|
|
58
51
|
}
|
|
59
52
|
|
|
60
53
|
function readActiveCookie(): ImpersonationActive | null {
|
|
@@ -75,6 +68,7 @@ function readActiveCookie(): ImpersonationActive | null {
|
|
|
75
68
|
}
|
|
76
69
|
|
|
77
70
|
export function ImpersonationProvider({ children }: { children: ReactNode }) {
|
|
71
|
+
const apiBaseUrl = useOptionalConfig()?.NEXT_PUBLIC_PLATFORM_API_BASE_URL;
|
|
78
72
|
const [active, setActive] = useState<ImpersonationActive | null>(null);
|
|
79
73
|
const [starting, setStarting] = useState(false);
|
|
80
74
|
const [exiting, setExiting] = useState(false);
|
|
@@ -87,47 +81,35 @@ export function ImpersonationProvider({ children }: { children: ReactNode }) {
|
|
|
87
81
|
|
|
88
82
|
useSessionRenewal(active, rereadCookie);
|
|
89
83
|
|
|
90
|
-
const start = useCallback(
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
const res = await fetch("/api/impersonation/start", {
|
|
105
|
-
method: "POST",
|
|
106
|
-
headers: { "Content-Type": "application/json" },
|
|
107
|
-
credentials: "include",
|
|
108
|
-
body: JSON.stringify(input),
|
|
109
|
-
});
|
|
110
|
-
if (!res.ok) {
|
|
111
|
-
return {
|
|
112
|
-
ok: false,
|
|
113
|
-
error: (await res.text()) || `HTTP ${res.status}`,
|
|
114
|
-
};
|
|
115
|
-
}
|
|
116
|
-
clearAuthTokenCache();
|
|
117
|
-
const data = (await res.json().catch(() => ({}))) as {
|
|
118
|
-
adoptUrl?: string;
|
|
84
|
+
const start = useCallback(async (input: StartImpersonationInput) => {
|
|
85
|
+
setStarting(true);
|
|
86
|
+
try {
|
|
87
|
+
const { redirectTo, landingUrl } = input;
|
|
88
|
+
const res = await fetch("/api/impersonation/start", {
|
|
89
|
+
method: "POST",
|
|
90
|
+
headers: { "Content-Type": "application/json" },
|
|
91
|
+
credentials: "include",
|
|
92
|
+
body: JSON.stringify(input),
|
|
93
|
+
});
|
|
94
|
+
if (!res.ok) {
|
|
95
|
+
return {
|
|
96
|
+
ok: false,
|
|
97
|
+
error: (await res.text()) || `HTTP ${res.status}`,
|
|
119
98
|
};
|
|
120
|
-
if (data.adoptUrl) window.location.assign(data.adoptUrl);
|
|
121
|
-
else if (landingUrl) window.location.assign(landingUrl);
|
|
122
|
-
else if (redirectTo) window.location.assign(redirectTo);
|
|
123
|
-
else window.location.reload();
|
|
124
|
-
return { ok: true };
|
|
125
|
-
} finally {
|
|
126
|
-
setStarting(false);
|
|
127
99
|
}
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
100
|
+
clearAuthTokenCache();
|
|
101
|
+
const data = (await res.json().catch(() => ({}))) as {
|
|
102
|
+
adoptUrl?: string;
|
|
103
|
+
};
|
|
104
|
+
if (data.adoptUrl) window.location.assign(data.adoptUrl);
|
|
105
|
+
else if (landingUrl) window.location.assign(landingUrl);
|
|
106
|
+
else if (redirectTo) window.location.assign(redirectTo);
|
|
107
|
+
else window.location.reload();
|
|
108
|
+
return { ok: true };
|
|
109
|
+
} finally {
|
|
110
|
+
setStarting(false);
|
|
111
|
+
}
|
|
112
|
+
}, []);
|
|
131
113
|
|
|
132
114
|
const exit = useCallback(async () => {
|
|
133
115
|
setExiting(true);
|
|
@@ -139,7 +121,7 @@ export function ImpersonationProvider({ children }: { children: ReactNode }) {
|
|
|
139
121
|
clearAuthTokenCache();
|
|
140
122
|
const returnTo = active?.returnTo;
|
|
141
123
|
setActive(null);
|
|
142
|
-
if (returnTo && isSafeReturnTo(returnTo)) {
|
|
124
|
+
if (returnTo && isSafeReturnTo(returnTo, apiBaseUrl)) {
|
|
143
125
|
window.location.assign(returnTo);
|
|
144
126
|
} else {
|
|
145
127
|
window.location.reload();
|
|
@@ -147,7 +129,7 @@ export function ImpersonationProvider({ children }: { children: ReactNode }) {
|
|
|
147
129
|
} finally {
|
|
148
130
|
setExiting(false);
|
|
149
131
|
}
|
|
150
|
-
}, [active]);
|
|
132
|
+
}, [active, apiBaseUrl]);
|
|
151
133
|
|
|
152
134
|
const value = useMemo<ImpersonationContextValue>(
|
|
153
135
|
() => ({ active, starting, exiting, start, exit }),
|
|
@@ -164,7 +146,6 @@ export function ImpersonationProvider({ children }: { children: ReactNode }) {
|
|
|
164
146
|
export function useImpersonation(): ImpersonationContextValue {
|
|
165
147
|
const context = useContext(ImpersonationContext);
|
|
166
148
|
if (!context) {
|
|
167
|
-
// Safe no-op default so the shared banner can mount in apps that haven't wired the provider yet.
|
|
168
149
|
return {
|
|
169
150
|
active: null,
|
|
170
151
|
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,37 @@ 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
|
-
|
|
32
|
+
function resolveLanding(returnTo: string, origin: string): string {
|
|
33
|
+
const allowed = isAllowedRedirect(returnTo, {
|
|
34
|
+
origin,
|
|
35
|
+
platformDomain: platformBaseDomain(
|
|
36
|
+
process.env.NEXT_PUBLIC_PLATFORM_API_BASE_URL
|
|
37
|
+
),
|
|
38
|
+
});
|
|
39
|
+
return allowed
|
|
40
|
+
? new URL(returnTo, origin).toString()
|
|
41
|
+
: new URL("/", origin).toString();
|
|
27
42
|
}
|
|
28
43
|
|
|
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();
|
|
44
|
+
function withCookies(response: Response, cookies: string[]): Response {
|
|
45
|
+
for (const cookie of cookies) response.headers.append("Set-Cookie", cookie);
|
|
46
|
+
return response;
|
|
45
47
|
}
|
|
46
48
|
|
|
47
49
|
function cookieScope(host: string): string {
|
|
@@ -62,11 +64,6 @@ function handsOffToTarget(request: Request, target: string): boolean {
|
|
|
62
64
|
}
|
|
63
65
|
}
|
|
64
66
|
|
|
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
67
|
export function createImpersonationStartHandler(authConfig: Auth.Config) {
|
|
71
68
|
return async function POST(request: Request) {
|
|
72
69
|
const caller = await resolveAccessToken(authConfig, request);
|
|
@@ -75,16 +72,7 @@ export function createImpersonationStartHandler(authConfig: Auth.Config) {
|
|
|
75
72
|
const client = platformClient(caller.accessToken);
|
|
76
73
|
if (!client) return platformNotConfigured();
|
|
77
74
|
|
|
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
|
-
};
|
|
75
|
+
let body: StartBody;
|
|
88
76
|
try {
|
|
89
77
|
body = await request.json();
|
|
90
78
|
} catch {
|
|
@@ -118,46 +106,33 @@ export function createImpersonationStartHandler(authConfig: Auth.Config) {
|
|
|
118
106
|
tokenId: result.tokenId,
|
|
119
107
|
expiresOn: result.expiresOn,
|
|
120
108
|
};
|
|
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(
|
|
109
|
+
const cookies = buildImpersonationCookies(
|
|
151
110
|
{ ...result, returnTo: body.returnTo },
|
|
152
111
|
requestHost(request)
|
|
153
|
-
)
|
|
154
|
-
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
if (!body.redirectTo || !handsOffToTarget(request, body.redirectTo)) {
|
|
115
|
+
return withCookies(Response.json(summary), cookies);
|
|
155
116
|
}
|
|
156
|
-
|
|
117
|
+
|
|
118
|
+
const target = new URL(body.redirectTo);
|
|
119
|
+
const ticket = sealImpersonationTicket(
|
|
120
|
+
{ ...result, business: target.host, returnTo: body.returnTo },
|
|
121
|
+
authConfig.nextAuthSecret
|
|
122
|
+
);
|
|
123
|
+
const adopt = new URL("/api/impersonation/adopt", target.origin);
|
|
124
|
+
adopt.searchParams.set("t", ticket);
|
|
125
|
+
adopt.searchParams.set(
|
|
126
|
+
"returnTo",
|
|
127
|
+
body.landingUrl ?? target.pathname + target.search
|
|
128
|
+
);
|
|
129
|
+
return withCookies(
|
|
130
|
+
Response.json({ ...summary, adoptUrl: adopt.toString() }),
|
|
131
|
+
cookies
|
|
132
|
+
);
|
|
157
133
|
};
|
|
158
134
|
}
|
|
159
135
|
|
|
160
|
-
// Runs on the tenant domain: opens the sealed ticket and sets the impersonation cookie here.
|
|
161
136
|
export function createImpersonationAdoptHandler(authConfig: Auth.Config) {
|
|
162
137
|
return async function GET(request: Request) {
|
|
163
138
|
const url = new URL(request.url);
|
|
@@ -185,21 +160,13 @@ export function createImpersonationAdoptHandler(authConfig: Auth.Config) {
|
|
|
185
160
|
url.searchParams.get("returnTo") || "/",
|
|
186
161
|
origin
|
|
187
162
|
);
|
|
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;
|
|
163
|
+
return withCookies(
|
|
164
|
+
new Response(null, { status: 302, headers: { Location: dest } }),
|
|
165
|
+
buildImpersonationCookies(claims, host)
|
|
166
|
+
);
|
|
196
167
|
};
|
|
197
168
|
}
|
|
198
169
|
|
|
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
170
|
export function createImpersonationEndHandler(authConfig: Auth.Config) {
|
|
204
171
|
return async function POST(request: Request) {
|
|
205
172
|
const session = readImpersonationSession(request);
|
|
@@ -213,10 +180,9 @@ export function createImpersonationEndHandler(authConfig: Auth.Config) {
|
|
|
213
180
|
.catch(() => undefined);
|
|
214
181
|
}
|
|
215
182
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
return out;
|
|
183
|
+
return withCookies(
|
|
184
|
+
Response.json({ ok: true }),
|
|
185
|
+
clearImpersonationCookies(requestHost(request))
|
|
186
|
+
);
|
|
221
187
|
};
|
|
222
188
|
}
|
|
@@ -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,30 @@
|
|
|
1
|
+
export function platformBaseDomain(apiBaseUrl: string | undefined): string {
|
|
2
|
+
if (!apiBaseUrl) {
|
|
3
|
+
throw new Error("NEXT_PUBLIC_PLATFORM_API_BASE_URL is not configured");
|
|
4
|
+
}
|
|
5
|
+
return new URL(apiBaseUrl).hostname.split(".").slice(-2).join(".");
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
interface RedirectRules {
|
|
9
|
+
origin: string;
|
|
10
|
+
platformDomain: string;
|
|
11
|
+
extraOrigins?: string[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function isAllowedRedirect(
|
|
15
|
+
target: string,
|
|
16
|
+
{ origin, platformDomain, extraOrigins = [] }: RedirectRules
|
|
17
|
+
): boolean {
|
|
18
|
+
try {
|
|
19
|
+
const url = new URL(target, origin);
|
|
20
|
+
if (url.origin === origin) return true;
|
|
21
|
+
if (url.protocol !== "https:") return false;
|
|
22
|
+
return (
|
|
23
|
+
url.hostname === platformDomain ||
|
|
24
|
+
url.hostname.endsWith(`.${platformDomain}`) ||
|
|
25
|
+
extraOrigins.includes(url.origin)
|
|
26
|
+
);
|
|
27
|
+
} catch {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -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,6 +3,7 @@ import { describe, it } from "node:test";
|
|
|
3
3
|
import {
|
|
4
4
|
impersonationPickerUrl,
|
|
5
5
|
impersonationRequired,
|
|
6
|
+
isSignInPage,
|
|
6
7
|
renewDelayMs,
|
|
7
8
|
} from "./required.ts";
|
|
8
9
|
|
|
@@ -32,6 +33,14 @@ describe("impersonation required", () => {
|
|
|
32
33
|
);
|
|
33
34
|
});
|
|
34
35
|
|
|
36
|
+
it("never sends a sign-in page to the picker, at the root or under a base path", () => {
|
|
37
|
+
assert.equal(isSignInPage("/auth/complete"), true);
|
|
38
|
+
assert.equal(isSignInPage("/minside/auth/login"), true);
|
|
39
|
+
assert.equal(isSignInPage("/"), false);
|
|
40
|
+
assert.equal(isSignInPage("/okonomi"), false);
|
|
41
|
+
assert.equal(isSignInPage("/authors"), false);
|
|
42
|
+
});
|
|
43
|
+
|
|
35
44
|
it("renews two minutes before the token expires, and at once when already late", () => {
|
|
36
45
|
assert.equal(renewDelayMs(1_000, 0), 880_000);
|
|
37
46
|
assert.equal(renewDelayMs(1_000, 999_000), 0);
|
|
@@ -12,6 +12,10 @@ export function impersonationRequired(
|
|
|
12
12
|
return environment === TEST_ENVIRONMENT;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
export function isSignInPage(pathname: string): boolean {
|
|
16
|
+
return /(^|\/)auth\//.test(pathname);
|
|
17
|
+
}
|
|
18
|
+
|
|
15
19
|
export function impersonationPickerUrl(
|
|
16
20
|
platformBaseUrl: string,
|
|
17
21
|
returnTo: string
|