@snokam/auth 3.7.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 +4 -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/impersonation/context.tsx +45 -69
- 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/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,10 @@
|
|
|
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
|
+
|
|
6
10
|
# [3.7.0](https://github.com/snokam/components/compare/@snokam/auth@3.6.0...@snokam/auth@3.7.0) (2026-10-06)
|
|
7
11
|
|
|
8
12
|
### 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) {
|
|
@@ -12,21 +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 { 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
|
+
}
|
|
15
26
|
|
|
16
27
|
interface ImpersonationContextValue {
|
|
17
28
|
active: ImpersonationActive | null;
|
|
18
29
|
starting: boolean;
|
|
19
30
|
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 }>;
|
|
31
|
+
start: (
|
|
32
|
+
input: StartImpersonationInput
|
|
33
|
+
) => Promise<{ ok: boolean; error?: string }>;
|
|
30
34
|
exit: () => Promise<void>;
|
|
31
35
|
}
|
|
32
36
|
|
|
@@ -34,27 +38,12 @@ const ImpersonationContext = createContext<ImpersonationContextValue | null>(
|
|
|
34
38
|
null
|
|
35
39
|
);
|
|
36
40
|
|
|
37
|
-
/** Registrable base domain of the platform (last two labels of the API host). */
|
|
38
|
-
function platformBaseDomain(): string {
|
|
39
|
-
const api = process.env.NEXT_PUBLIC_PLATFORM_API_BASE_URL;
|
|
40
|
-
if (!api) {
|
|
41
|
-
throw new Error("NEXT_PUBLIC_PLATFORM_API_BASE_URL is not configured");
|
|
42
|
-
}
|
|
43
|
-
return new URL(api).hostname.split(".").slice(-2).join(".");
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/** Guards the exit redirect against open-redirect: same-origin, or https on the platform base domain. */
|
|
47
41
|
function isSafeReturnTo(returnTo: string): boolean {
|
|
48
42
|
if (typeof window === "undefined") return false;
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
if (url.protocol !== "https:") return false;
|
|
54
|
-
return url.hostname === base || url.hostname.endsWith(`.${base}`);
|
|
55
|
-
} catch {
|
|
56
|
-
return false;
|
|
57
|
-
}
|
|
43
|
+
return isAllowedRedirect(returnTo, {
|
|
44
|
+
origin: window.location.origin,
|
|
45
|
+
platformDomain: platformBaseDomain(),
|
|
46
|
+
});
|
|
58
47
|
}
|
|
59
48
|
|
|
60
49
|
function readActiveCookie(): ImpersonationActive | null {
|
|
@@ -87,47 +76,35 @@ export function ImpersonationProvider({ children }: { children: ReactNode }) {
|
|
|
87
76
|
|
|
88
77
|
useSessionRenewal(active, rereadCookie);
|
|
89
78
|
|
|
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;
|
|
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}`,
|
|
119
93
|
};
|
|
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
94
|
}
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
+
}, []);
|
|
131
108
|
|
|
132
109
|
const exit = useCallback(async () => {
|
|
133
110
|
setExiting(true);
|
|
@@ -164,7 +141,6 @@ export function ImpersonationProvider({ children }: { children: ReactNode }) {
|
|
|
164
141
|
export function useImpersonation(): ImpersonationContextValue {
|
|
165
142
|
const context = useContext(ImpersonationContext);
|
|
166
143
|
if (!context) {
|
|
167
|
-
// Safe no-op default so the shared banner can mount in apps that haven't wired the provider yet.
|
|
168
144
|
return {
|
|
169
145
|
active: null,
|
|
170
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,
|