@snokam/auth 6.0.3 → 6.0.5
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 +8 -0
- package/package.json +5 -6
- package/src/app-router/index.tsx +1 -0
- package/src/app-router/server.ts +5 -0
- package/src/app-sign-in/platform.ts +1 -1
- package/src/components/NotProvisioned/AccessHelp.tsx +1 -1
- package/src/components/NotProvisioned/index.tsx +2 -2
- package/src/cors-origin.test.ts +41 -0
- package/src/cors-origin.ts +34 -0
- package/src/cors.ts +1 -30
- package/src/hooks.ts +18 -36
- package/src/hydration/useIsHydrating.ts +12 -0
- package/src/impersonation/carried.test.ts +62 -0
- package/src/impersonation/carried.ts +21 -0
- package/src/impersonation/carry.ts +82 -0
- package/src/impersonation/context.tsx +17 -19
- package/src/impersonation/cookies.ts +14 -30
- package/src/impersonation/handlers.ts +2 -6
- package/src/impersonation/jar.test.ts +33 -0
- package/src/impersonation/jar.ts +44 -0
- package/src/impersonation/pass-cookie.ts +8 -2
- package/src/impersonation/pass.test.ts +19 -0
- package/src/impersonation/renewal.ts +13 -20
- package/src/impersonation/request.ts +1 -1
- package/src/impersonation/routes.ts +3 -0
- package/src/impersonation/schedule.test.ts +95 -0
- package/src/impersonation/schedule.ts +56 -0
- package/src/options/employee-profile.ts +3 -6
- package/src/password/api.ts +1 -1
- package/src/scopes/context.tsx +19 -31
- package/src/scopes/state.test.ts +51 -0
- package/src/scopes/state.ts +44 -0
- package/src/session/cookies.test.ts +27 -0
- package/src/session/cookies.ts +10 -1
- package/src/session/index.ts +5 -1
- package/src/session/useSession.ts +2 -10
- package/src/sso/exchange.test.ts +58 -0
- package/src/sso/exchange.ts +23 -9
- package/src/theme/employee-theme-sync.tsx +3 -3
- package/src/token/reuse.test.ts +28 -0
- package/src/token/reuse.ts +22 -0
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,14 @@
|
|
|
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
|
+
## [6.0.5](https://github.com/snokam/components/compare/@snokam/auth@6.0.4...@snokam/auth@6.0.5) (2026-11-05)
|
|
7
|
+
|
|
8
|
+
**Note:** Version bump only for package @snokam/auth
|
|
9
|
+
|
|
10
|
+
## [6.0.4](https://github.com/snokam/components/compare/@snokam/auth@6.0.3...@snokam/auth@6.0.4) (2026-11-04)
|
|
11
|
+
|
|
12
|
+
**Note:** Version bump only for package @snokam/auth
|
|
13
|
+
|
|
6
14
|
## [6.0.3](https://github.com/snokam/components/compare/@snokam/auth@6.0.2...@snokam/auth@6.0.3) (2026-11-02)
|
|
7
15
|
|
|
8
16
|
**Note:** Version bump only for package @snokam/auth
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@snokam/auth",
|
|
3
3
|
"description": "Snøkam auth components",
|
|
4
|
-
"version": "6.0.
|
|
4
|
+
"version": "6.0.5",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"types": "src/index.ts",
|
|
7
7
|
"files": [
|
|
@@ -23,15 +23,14 @@
|
|
|
23
23
|
"@emotion/styled": "^11.14.1",
|
|
24
24
|
"@mui/icons-material": "^9.2.0",
|
|
25
25
|
"@mui/material": "^9.2.0",
|
|
26
|
-
"@snokam/employees-client": "^1.3.0",
|
|
27
26
|
"next-auth": "4.24.15",
|
|
28
27
|
"path-to-regexp": "^8.4.2",
|
|
28
|
+
"@snokam/api-client": "1.0.0",
|
|
29
29
|
"@snokam/identity": "1.1.0",
|
|
30
|
-
"@snokam/
|
|
30
|
+
"@snokam/page-content": "6.9.5",
|
|
31
31
|
"@snokam/react-query": "1.1.0",
|
|
32
|
-
"@snokam/
|
|
33
|
-
"@snokam/
|
|
34
|
-
"@snokam/ui": "7.2.0"
|
|
32
|
+
"@snokam/tenant": "4.5.2",
|
|
33
|
+
"@snokam/ui": "7.2.2"
|
|
35
34
|
},
|
|
36
35
|
"peerDependencies": {
|
|
37
36
|
"next": "16.3.8",
|
package/src/app-router/index.tsx
CHANGED
|
@@ -12,6 +12,7 @@ export { Unauthenticated } from "../components/Unauthenticated";
|
|
|
12
12
|
export { Authenticated } from "../components/Authenticated";
|
|
13
13
|
export { useAuth } from "../hooks";
|
|
14
14
|
export { useSession } from "../session/useSession";
|
|
15
|
+
export { useIsHydrating } from "../hydration/useIsHydrating";
|
|
15
16
|
export { retryUnlessMissing } from "../employee-query/retry";
|
|
16
17
|
export { EmployeeThemeSync } from "../theme/employee-theme-sync";
|
|
17
18
|
export { ScopesProvider, useScopes, useHasScope } from "../scopes";
|
package/src/app-router/server.ts
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
appUrl,
|
|
15
15
|
backendUrl,
|
|
16
16
|
managedBearer,
|
|
17
|
+
resolveApiBaseUrl,
|
|
17
18
|
} from "@snokam/tenant/server";
|
|
18
19
|
|
|
19
20
|
export { getServerToken } from "./token";
|
|
@@ -27,6 +28,10 @@ export function serverTenantToken(): Promise<string | undefined> {
|
|
|
27
28
|
return scope ? managedBearer(scope) : Promise.resolve(undefined);
|
|
28
29
|
}
|
|
29
30
|
|
|
31
|
+
export function resolveServerApiBaseUrl(): Promise<string> {
|
|
32
|
+
return resolveApiBaseUrl();
|
|
33
|
+
}
|
|
34
|
+
|
|
30
35
|
export function resolveServerApiBase(service: string): Promise<string> {
|
|
31
36
|
return service ? backendUrl(service) : Promise.resolve("");
|
|
32
37
|
}
|
|
@@ -4,7 +4,7 @@ import {
|
|
|
4
4
|
ResponseError,
|
|
5
5
|
type AuthorizationPreview,
|
|
6
6
|
type IssuedAuthorizationCode,
|
|
7
|
-
} from "@snokam/
|
|
7
|
+
} from "@snokam/api-client/platform";
|
|
8
8
|
import { platformClientFor } from "../impersonation/request";
|
|
9
9
|
import type { AuthorizationParams } from "./consent";
|
|
10
10
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { usePageContent } from "@snokam/
|
|
3
|
+
import { usePageContent } from "@snokam/page-content";
|
|
4
4
|
import { PersonOffOutlined } from "@snokam/ui/icons";
|
|
5
5
|
import { SystemPage, SystemPageAction } from "@snokam/ui/system-page";
|
|
6
6
|
import { useAuth } from "../../hooks";
|
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
import { useQuery } from "@snokam/react-query";
|
|
4
4
|
import { PageLoader } from "@snokam/ui";
|
|
5
|
-
import { loadAppContent } from "@snokam/
|
|
5
|
+
import { loadAppContent } from "@snokam/page-content/app-router/actions";
|
|
6
6
|
import {
|
|
7
7
|
AppContentProvider,
|
|
8
8
|
PageContentProvider,
|
|
9
|
-
} from "@snokam/
|
|
9
|
+
} from "@snokam/page-content/app-router/providers";
|
|
10
10
|
import { AccessHelp } from "./AccessHelp";
|
|
11
11
|
import { usePickerRedirect } from "./usePickerRedirect";
|
|
12
12
|
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { afterEach, beforeEach, describe, it } from "node:test";
|
|
3
|
+
import { isAllowedOrigin } from "./cors-origin.ts";
|
|
4
|
+
|
|
5
|
+
const ZONE = "platform.example";
|
|
6
|
+
|
|
7
|
+
describe("CORS origin trust", () => {
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
process.env.NEXT_PUBLIC_PLATFORM_DOMAIN = ZONE;
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
delete process.env.NEXT_PUBLIC_PLATFORM_DOMAIN;
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("trusts the configured platform zone and its subdomains", () => {
|
|
17
|
+
assert.equal(isAllowedOrigin(`https://${ZONE}`), true);
|
|
18
|
+
assert.equal(isAllowedOrigin(`https://acme.${ZONE}`), true);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("does not trust hosts that only end with the zone name", () => {
|
|
22
|
+
assert.equal(isAllowedOrigin(`https://evil${ZONE}`), false);
|
|
23
|
+
assert.equal(isAllowedOrigin(`https://${ZONE}.evil.example`), false);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("trusts listed extra origins and local development", () => {
|
|
27
|
+
assert.equal(
|
|
28
|
+
isAllowedOrigin("https://partner.example", ["https://partner.example"]),
|
|
29
|
+
true
|
|
30
|
+
);
|
|
31
|
+
assert.equal(isAllowedOrigin("http://localhost:3000"), true);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("fails clearly when the platform zone is not configured", () => {
|
|
35
|
+
delete process.env.NEXT_PUBLIC_PLATFORM_DOMAIN;
|
|
36
|
+
assert.throws(
|
|
37
|
+
() => isAllowedOrigin("https://acme.elsewhere.example"),
|
|
38
|
+
/NEXT_PUBLIC_PLATFORM_DOMAIN/
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1"]);
|
|
2
|
+
|
|
3
|
+
function platformZone(): string {
|
|
4
|
+
const zone = process.env.NEXT_PUBLIC_PLATFORM_DOMAIN;
|
|
5
|
+
if (!zone) {
|
|
6
|
+
throw new Error(
|
|
7
|
+
"NEXT_PUBLIC_PLATFORM_DOMAIN is not configured, so CORS cannot tell platform origins apart"
|
|
8
|
+
);
|
|
9
|
+
}
|
|
10
|
+
return zone.toLowerCase();
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function isWithin(hostname: string, domain: string): boolean {
|
|
14
|
+
return hostname === domain || hostname.endsWith(`.${domain}`);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function isAllowedOrigin(
|
|
18
|
+
origin: string | null,
|
|
19
|
+
additionalOrigins: string[] = []
|
|
20
|
+
): boolean {
|
|
21
|
+
if (!origin) return false;
|
|
22
|
+
if (additionalOrigins.includes(origin)) return true;
|
|
23
|
+
|
|
24
|
+
let hostname: string;
|
|
25
|
+
try {
|
|
26
|
+
hostname = new URL(origin).hostname;
|
|
27
|
+
} catch {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (LOCAL_HOSTS.has(hostname)) return true;
|
|
32
|
+
if (isWithin(hostname, "snokam.no")) return true;
|
|
33
|
+
return isWithin(hostname, platformZone());
|
|
34
|
+
}
|
package/src/cors.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { NextResponse } from "next/server";
|
|
2
2
|
import { Auth } from "./types";
|
|
3
|
+
import { isAllowedOrigin } from "./cors-origin";
|
|
3
4
|
|
|
4
5
|
const DEFAULT_CORS_HEADERS = [
|
|
5
6
|
"X-CSRF-Token",
|
|
@@ -15,36 +16,6 @@ const DEFAULT_CORS_HEADERS = [
|
|
|
15
16
|
"Cookie",
|
|
16
17
|
];
|
|
17
18
|
|
|
18
|
-
export function isAllowedOrigin(
|
|
19
|
-
origin: string | null,
|
|
20
|
-
additionalOrigins: string[] = []
|
|
21
|
-
): boolean {
|
|
22
|
-
if (!origin) return false;
|
|
23
|
-
|
|
24
|
-
try {
|
|
25
|
-
const url = new URL(origin);
|
|
26
|
-
if (url.hostname.endsWith(".snokam.no") || url.hostname === "snokam.no") {
|
|
27
|
-
return true;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
if (url.hostname === "localhost" || url.hostname === "127.0.0.1") {
|
|
31
|
-
return true;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
if (url.hostname.endsWith(".snosky.no") || url.hostname === "snosky.no") {
|
|
35
|
-
return true;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
if (additionalOrigins.includes(origin)) {
|
|
39
|
-
return true;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
return false;
|
|
43
|
-
} catch {
|
|
44
|
-
return false;
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
|
|
48
19
|
export function addCorsHeaders(
|
|
49
20
|
response: NextResponse,
|
|
50
21
|
origin: string | null,
|
package/src/hooks.ts
CHANGED
|
@@ -1,20 +1,13 @@
|
|
|
1
1
|
import { signIn, signOut } from "next-auth/react";
|
|
2
|
-
import { useMemo } from "react";
|
|
3
|
-
import {
|
|
2
|
+
import { useCallback, useMemo } from "react";
|
|
3
|
+
import { readActiveCookie } from "./impersonation/cookies";
|
|
4
4
|
import { useImpersonation } from "./impersonation/context";
|
|
5
5
|
import { useOwnIdentity } from "./identity/own";
|
|
6
6
|
import { useSession } from "./session/useSession";
|
|
7
|
+
import { isReusable, type CachedToken } from "./token/reuse";
|
|
7
8
|
|
|
8
9
|
class ConsentRequiredError extends Error {}
|
|
9
10
|
|
|
10
|
-
const REFRESH_MARGIN_SECONDS = 60;
|
|
11
|
-
|
|
12
|
-
interface CachedToken {
|
|
13
|
-
bearer: string;
|
|
14
|
-
expiresOn: number;
|
|
15
|
-
impKey: string;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
11
|
type TokenKind = "session" | "own";
|
|
19
12
|
|
|
20
13
|
const cachedTokens: Record<TokenKind, CachedToken | null> = {
|
|
@@ -28,22 +21,7 @@ const inFlightTokens: Record<
|
|
|
28
21
|
|
|
29
22
|
function readImpersonationKey(): string {
|
|
30
23
|
if (typeof document === "undefined") return "";
|
|
31
|
-
|
|
32
|
-
.split(";")
|
|
33
|
-
.map((part) => part.trim())
|
|
34
|
-
.find((part) => part.startsWith(`${IMP_ACTIVE_COOKIE}=`));
|
|
35
|
-
if (!match) return "";
|
|
36
|
-
try {
|
|
37
|
-
const value = decodeURIComponent(match.slice(IMP_ACTIVE_COOKIE.length + 1));
|
|
38
|
-
const parsed = JSON.parse(value) as {
|
|
39
|
-
tokenId?: string;
|
|
40
|
-
expiresOn?: number;
|
|
41
|
-
};
|
|
42
|
-
if (parsed.expiresOn && parsed.expiresOn * 1000 <= Date.now()) return "";
|
|
43
|
-
return parsed.tokenId ?? "";
|
|
44
|
-
} catch {
|
|
45
|
-
return "";
|
|
46
|
-
}
|
|
24
|
+
return readActiveCookie(document.cookie)?.tokenId ?? "";
|
|
47
25
|
}
|
|
48
26
|
|
|
49
27
|
export function clearAuthTokenCache(): void {
|
|
@@ -79,7 +57,12 @@ async function fetchToken(kind: TokenKind, impKey: string): Promise<string> {
|
|
|
79
57
|
|
|
80
58
|
sessionStorage.removeItem("auth-consent-retried");
|
|
81
59
|
const bearer = `Bearer ${accessToken}`;
|
|
82
|
-
cachedTokens[kind] = {
|
|
60
|
+
cachedTokens[kind] = {
|
|
61
|
+
bearer,
|
|
62
|
+
expiresOn,
|
|
63
|
+
fetchedAt: Date.now() / 1000,
|
|
64
|
+
impKey,
|
|
65
|
+
};
|
|
83
66
|
return bearer;
|
|
84
67
|
}
|
|
85
68
|
|
|
@@ -88,13 +71,7 @@ async function getFreshToken(kind: TokenKind): Promise<string> {
|
|
|
88
71
|
const now = Date.now() / 1000;
|
|
89
72
|
const cached = cachedTokens[kind];
|
|
90
73
|
|
|
91
|
-
if (
|
|
92
|
-
cached &&
|
|
93
|
-
cached.impKey === impKey &&
|
|
94
|
-
now < cached.expiresOn - REFRESH_MARGIN_SECONDS
|
|
95
|
-
) {
|
|
96
|
-
return cached.bearer;
|
|
97
|
-
}
|
|
74
|
+
if (isReusable(cached, impKey, now)) return cached.bearer;
|
|
98
75
|
|
|
99
76
|
const inFlight = inFlightTokens[kind];
|
|
100
77
|
if (inFlight && inFlight.impKey === impKey) {
|
|
@@ -146,13 +123,18 @@ export function useAuth() {
|
|
|
146
123
|
};
|
|
147
124
|
}, [rawSession, active]);
|
|
148
125
|
|
|
126
|
+
const getToken = useCallback(
|
|
127
|
+
() => tokenOrLogin(own ? "own" : "session"),
|
|
128
|
+
[own]
|
|
129
|
+
);
|
|
130
|
+
|
|
149
131
|
const value = useMemo(
|
|
150
132
|
() => ({
|
|
151
133
|
update,
|
|
152
134
|
signIn,
|
|
153
135
|
signOut,
|
|
154
136
|
session,
|
|
155
|
-
getToken
|
|
137
|
+
getToken,
|
|
156
138
|
inProgress: status === "loading",
|
|
157
139
|
isAuthenticated:
|
|
158
140
|
status === "authenticated" ||
|
|
@@ -160,7 +142,7 @@ export function useAuth() {
|
|
|
160
142
|
(status === "loading" && !!session),
|
|
161
143
|
}),
|
|
162
144
|
|
|
163
|
-
[update, status, session, active,
|
|
145
|
+
[update, status, session, active, getToken]
|
|
164
146
|
);
|
|
165
147
|
|
|
166
148
|
return value;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { carriedTo } from "./carried.ts";
|
|
4
|
+
import { PLATFORM_HOST, UNKNOWN_HOST } from "../handoff/seal.ts";
|
|
5
|
+
|
|
6
|
+
const now = Date.UTC(2026, 10, 3, 12, 0, 0);
|
|
7
|
+
const expiresOn = now / 1000 + 600;
|
|
8
|
+
|
|
9
|
+
function tokenFor(biz: string) {
|
|
10
|
+
const payload = Buffer.from(JSON.stringify({ biz })).toString("base64url");
|
|
11
|
+
return `header.${payload}.signature`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const session = { accessToken: tokenFor("snokam"), expiresOn, tokenId: "t1" };
|
|
15
|
+
const active = {
|
|
16
|
+
targetEmail: "bjorn@test.snokam.no",
|
|
17
|
+
finance: false,
|
|
18
|
+
tokenId: "t1",
|
|
19
|
+
expiresOn,
|
|
20
|
+
origins: ["https://salg.beta.test.snokam.no"],
|
|
21
|
+
};
|
|
22
|
+
const snokam = { businessId: "snokam" };
|
|
23
|
+
|
|
24
|
+
describe("carrying an impersonation to another host", () => {
|
|
25
|
+
it("carries it to a host owned by the impersonated business", () => {
|
|
26
|
+
assert.deepEqual(carriedTo({ session, active }, snokam, now), {
|
|
27
|
+
session,
|
|
28
|
+
active,
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("carries it to a platform host", () => {
|
|
33
|
+
assert.ok(carriedTo({ session, active }, PLATFORM_HOST, now));
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("refuses a host owned by another business", () => {
|
|
37
|
+
assert.equal(
|
|
38
|
+
carriedTo({ session, active }, { businessId: "other" }, now),
|
|
39
|
+
null
|
|
40
|
+
);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("refuses a host nobody owns", () => {
|
|
44
|
+
assert.equal(carriedTo({ session, active }, UNKNOWN_HOST, now), null);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("refuses when nothing is being impersonated", () => {
|
|
48
|
+
assert.equal(carriedTo({}, snokam, now), null);
|
|
49
|
+
assert.equal(carriedTo({ session }, snokam, now), null);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("refuses an expired impersonation", () => {
|
|
53
|
+
assert.equal(carriedTo({ session, active }, snokam, now + 601_000), null);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("refuses when the session and the visible state disagree", () => {
|
|
57
|
+
assert.equal(
|
|
58
|
+
carriedTo({ session, active: { ...active, tokenId: "t2" } }, snokam, now),
|
|
59
|
+
null
|
|
60
|
+
);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { type HostOwner, maySealFor } from "../handoff/seal.ts";
|
|
2
|
+
import { tokenClaims } from "./claims.ts";
|
|
3
|
+
import type { ImpersonationActive, ImpersonationSession } from "./cookies";
|
|
4
|
+
|
|
5
|
+
export interface Carried {
|
|
6
|
+
session: ImpersonationSession;
|
|
7
|
+
active: ImpersonationActive;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function carriedTo(
|
|
11
|
+
carried: Partial<Carried>,
|
|
12
|
+
owner: HostOwner,
|
|
13
|
+
now: number = Date.now()
|
|
14
|
+
): Carried | null {
|
|
15
|
+
const { session, active } = carried;
|
|
16
|
+
if (!session?.accessToken || !active) return null;
|
|
17
|
+
if (session.tokenId !== active.tokenId) return null;
|
|
18
|
+
if (session.expiresOn * 1000 <= now) return null;
|
|
19
|
+
const business = tokenClaims(session.accessToken).biz;
|
|
20
|
+
return maySealFor(owner, business) ? { session, active } : null;
|
|
21
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { Auth } from "../types";
|
|
2
|
+
import { isAllowedReturnTo } from "../handoff/return-to";
|
|
3
|
+
import { hostOwner } from "../handoff/business";
|
|
4
|
+
import type { HostOwner } from "../handoff/seal";
|
|
5
|
+
import {
|
|
6
|
+
buildImpersonationCookies,
|
|
7
|
+
readImpersonationActive,
|
|
8
|
+
readImpersonationSession,
|
|
9
|
+
} from "./cookies";
|
|
10
|
+
import { carriedTo } from "./carried";
|
|
11
|
+
import { withOrigin } from "./exit";
|
|
12
|
+
import { handsOffToTarget } from "./handlers";
|
|
13
|
+
import { requestHost } from "./request";
|
|
14
|
+
import { sealImpersonationTicket } from "./ticket";
|
|
15
|
+
|
|
16
|
+
const ADOPT_PATH = "/api/impersonation/adopt";
|
|
17
|
+
|
|
18
|
+
async function allowedTarget(target: unknown): Promise<URL | null> {
|
|
19
|
+
if (typeof target !== "string" || !URL.canParse(target)) return null;
|
|
20
|
+
const allowed = await isAllowedReturnTo(target, {
|
|
21
|
+
platformApiBase: process.env.NEXT_PUBLIC_PLATFORM_API_BASE_URL ?? "",
|
|
22
|
+
platformBaseDomain: process.env.NEXT_PUBLIC_PLATFORM_DOMAIN ?? "",
|
|
23
|
+
allowLocalhost: process.env.AUTH_ALLOW_LOCALHOST === "true",
|
|
24
|
+
});
|
|
25
|
+
return allowed ? new URL(target) : null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function ownerOf(target: URL): Promise<HostOwner | null> {
|
|
29
|
+
try {
|
|
30
|
+
return await hostOwner(target.toString());
|
|
31
|
+
} catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function adoptUrl(target: URL, ticket: string): string {
|
|
37
|
+
const adopt = new URL(ADOPT_PATH, target.origin);
|
|
38
|
+
adopt.searchParams.set("t", ticket);
|
|
39
|
+
adopt.searchParams.set("returnTo", target.pathname + target.search);
|
|
40
|
+
return adopt.toString();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function createImpersonationCarryHandler(authConfig: Auth.Config) {
|
|
44
|
+
return async function POST(request: Request) {
|
|
45
|
+
const body = (await request.json().catch(() => null)) as {
|
|
46
|
+
redirectTo?: unknown;
|
|
47
|
+
} | null;
|
|
48
|
+
const target = await allowedTarget(body?.redirectTo);
|
|
49
|
+
if (!target) return new Response("Invalid redirectTo", { status: 400 });
|
|
50
|
+
if (!handsOffToTarget(request, target.toString())) {
|
|
51
|
+
return new Response("Shares cookies with this host", { status: 409 });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const owner = await ownerOf(target);
|
|
55
|
+
if (!owner)
|
|
56
|
+
return new Response("Tenant lookup unavailable", { status: 503 });
|
|
57
|
+
|
|
58
|
+
const carried = carriedTo(
|
|
59
|
+
{
|
|
60
|
+
session: readImpersonationSession(request) ?? undefined,
|
|
61
|
+
active: readImpersonationActive(request) ?? undefined,
|
|
62
|
+
},
|
|
63
|
+
owner
|
|
64
|
+
);
|
|
65
|
+
if (!carried) return new Response("Nothing to carry", { status: 409 });
|
|
66
|
+
|
|
67
|
+
const { session, active } = carried;
|
|
68
|
+
const origins = withOrigin(active.origins, target.origin);
|
|
69
|
+
const ticket = sealImpersonationTicket(
|
|
70
|
+
{ ...session, ...active, business: target.host, origins },
|
|
71
|
+
authConfig.nextAuthSecret
|
|
72
|
+
);
|
|
73
|
+
const response = Response.json({ adoptUrl: adoptUrl(target, ticket) });
|
|
74
|
+
for (const cookie of buildImpersonationCookies(
|
|
75
|
+
{ ...session, ...active, origins },
|
|
76
|
+
requestHost(request)
|
|
77
|
+
)) {
|
|
78
|
+
response.headers.append("Set-Cookie", cookie);
|
|
79
|
+
}
|
|
80
|
+
return response;
|
|
81
|
+
};
|
|
82
|
+
}
|
|
@@ -9,11 +9,7 @@ import {
|
|
|
9
9
|
useState,
|
|
10
10
|
type ReactNode,
|
|
11
11
|
} from "react";
|
|
12
|
-
import {
|
|
13
|
-
IMP_ACTIVE_COOKIE,
|
|
14
|
-
parseActiveCookie,
|
|
15
|
-
type ImpersonationActive,
|
|
16
|
-
} from "./cookies";
|
|
12
|
+
import { readActiveCookie, type ImpersonationActive } from "./cookies";
|
|
17
13
|
import { clearAuthTokenCache } from "../hooks";
|
|
18
14
|
import { useSessionRenewal } from "./renewal";
|
|
19
15
|
import { useOptionalConfig } from "@snokam/tenant";
|
|
@@ -56,20 +52,16 @@ function isSafeReturnTo(
|
|
|
56
52
|
});
|
|
57
53
|
}
|
|
58
54
|
|
|
59
|
-
function
|
|
55
|
+
function readDocumentActiveCookie(): ImpersonationActive | null {
|
|
60
56
|
if (typeof document === "undefined") return null;
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
);
|
|
70
|
-
} catch {
|
|
71
|
-
return null;
|
|
72
|
-
}
|
|
57
|
+
return readActiveCookie(document.cookie);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function keepIfUnchanged(
|
|
61
|
+
current: ImpersonationActive | null,
|
|
62
|
+
next: ImpersonationActive | null
|
|
63
|
+
): ImpersonationActive | null {
|
|
64
|
+
return JSON.stringify(current) === JSON.stringify(next) ? current : next;
|
|
73
65
|
}
|
|
74
66
|
|
|
75
67
|
export function ImpersonationProvider({
|
|
@@ -86,7 +78,13 @@ export function ImpersonationProvider({
|
|
|
86
78
|
const [starting, setStarting] = useState(false);
|
|
87
79
|
const [exiting, setExiting] = useState(false);
|
|
88
80
|
|
|
89
|
-
const rereadCookie = useCallback(
|
|
81
|
+
const rereadCookie = useCallback(
|
|
82
|
+
() =>
|
|
83
|
+
setActive((current) =>
|
|
84
|
+
keepIfUnchanged(current, readDocumentActiveCookie())
|
|
85
|
+
),
|
|
86
|
+
[]
|
|
87
|
+
);
|
|
90
88
|
|
|
91
89
|
useEffect(() => {
|
|
92
90
|
rereadCookie();
|
|
@@ -1,9 +1,20 @@
|
|
|
1
1
|
import { resolveCookieDomain } from "../session";
|
|
2
2
|
import { tokenClaims } from "./claims";
|
|
3
|
+
import {
|
|
4
|
+
IMP_ACTIVE_COOKIE,
|
|
5
|
+
IMP_PASS_COOKIE,
|
|
6
|
+
IMP_SESSION_COOKIE,
|
|
7
|
+
parseCookieHeader,
|
|
8
|
+
} from "./jar";
|
|
3
9
|
|
|
4
|
-
export
|
|
5
|
-
|
|
6
|
-
|
|
10
|
+
export {
|
|
11
|
+
IMP_ACTIVE_COOKIE,
|
|
12
|
+
IMP_PASS_COOKIE,
|
|
13
|
+
IMP_SESSION_COOKIE,
|
|
14
|
+
parseActiveCookie,
|
|
15
|
+
parseCookieHeader,
|
|
16
|
+
readActiveCookie,
|
|
17
|
+
} from "./jar";
|
|
7
18
|
|
|
8
19
|
export interface ImpersonationSession {
|
|
9
20
|
accessToken: string;
|
|
@@ -23,19 +34,6 @@ export interface ImpersonationActive {
|
|
|
23
34
|
origins?: string[];
|
|
24
35
|
}
|
|
25
36
|
|
|
26
|
-
export function parseActiveCookie(
|
|
27
|
-
json: string | undefined
|
|
28
|
-
): ImpersonationActive | null {
|
|
29
|
-
if (!json) return null;
|
|
30
|
-
try {
|
|
31
|
-
const parsed = JSON.parse(json) as ImpersonationActive;
|
|
32
|
-
if (parsed.expiresOn * 1000 <= Date.now()) return null;
|
|
33
|
-
return parsed;
|
|
34
|
-
} catch {
|
|
35
|
-
return null;
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
|
|
39
37
|
interface MintedToken {
|
|
40
38
|
accessToken: string;
|
|
41
39
|
expiresOn: number;
|
|
@@ -48,20 +46,6 @@ interface MintedToken {
|
|
|
48
46
|
origins?: string[];
|
|
49
47
|
}
|
|
50
48
|
|
|
51
|
-
export function parseCookieHeader(
|
|
52
|
-
header: string | null
|
|
53
|
-
): Record<string, string> {
|
|
54
|
-
const jar: Record<string, string> = {};
|
|
55
|
-
if (!header) return jar;
|
|
56
|
-
for (const part of header.split(";")) {
|
|
57
|
-
const index = part.indexOf("=");
|
|
58
|
-
if (index < 0) continue;
|
|
59
|
-
const name = part.slice(0, index).trim();
|
|
60
|
-
if (name) jar[name] = decodeURIComponent(part.slice(index + 1).trim());
|
|
61
|
-
}
|
|
62
|
-
return jar;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
49
|
function readCookieJson<T>(request: Request, name: string): T | null {
|
|
66
50
|
const raw = parseCookieHeader(request.headers.get("cookie"))[name];
|
|
67
51
|
if (!raw) return null;
|
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
} from "./cookies";
|
|
8
8
|
import { sealImpersonationTicket, openImpersonationTicket } from "./ticket";
|
|
9
9
|
import { impersonationPassCookie } from "./pass-cookie";
|
|
10
|
-
import {
|
|
10
|
+
import { cookieScope, requestOrigin } from "../session";
|
|
11
11
|
import {
|
|
12
12
|
platformCall,
|
|
13
13
|
platformClient,
|
|
@@ -49,11 +49,7 @@ function withCookies(response: Response, cookies: string[]): Response {
|
|
|
49
49
|
return response;
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
function
|
|
53
|
-
return resolveCookieDomain(host) ?? host.split(":")[0].toLowerCase();
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
function handsOffToTarget(request: Request, target: string): boolean {
|
|
52
|
+
export function handsOffToTarget(request: Request, target: string): boolean {
|
|
57
53
|
const here = requestHost(request);
|
|
58
54
|
if (!here) return false;
|
|
59
55
|
try {
|