@snokam/auth 6.0.4 → 6.0.6
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 +6 -7
- package/src/app-router/components/Provider/index.tsx +7 -4
- 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 +60 -55
- 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/confirm.test.ts +100 -0
- package/src/session/confirm.ts +63 -0
- package/src/session/confirmed.tsx +58 -0
- package/src/session/cookies.test.ts +27 -0
- package/src/session/cookies.ts +10 -1
- package/src/session/hold.test.ts +27 -0
- package/src/session/hold.ts +18 -0
- package/src/session/index.ts +5 -1
- package/src/session/useSession.ts +6 -11
- 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/failure.test.ts +33 -0
- package/src/token/failure.ts +17 -0
- package/src/token/reuse.test.ts +28 -0
- package/src/token/reuse.ts +22 -0
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
|
+
## [6.0.6](https://github.com/snokam/components/compare/@snokam/auth@6.0.5...@snokam/auth@6.0.6) (2026-11-05)
|
|
7
|
+
|
|
8
|
+
### Bug Fixes
|
|
9
|
+
|
|
10
|
+
- **web:** post go-live fixes for texts, sessions, place search and pickers ([#3218](https://github.com/snokam/components/issues/3218)) ([0fbf0bb](https://github.com/snokam/components/commit/0fbf0bbec53b6cf946684c76fef318aa5cea7403))
|
|
11
|
+
|
|
12
|
+
## [6.0.5](https://github.com/snokam/components/compare/@snokam/auth@6.0.4...@snokam/auth@6.0.5) (2026-11-05)
|
|
13
|
+
|
|
14
|
+
**Note:** Version bump only for package @snokam/auth
|
|
15
|
+
|
|
6
16
|
## [6.0.4](https://github.com/snokam/components/compare/@snokam/auth@6.0.3...@snokam/auth@6.0.4) (2026-11-04)
|
|
7
17
|
|
|
8
18
|
**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.6",
|
|
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",
|
|
29
|
-
"@snokam/
|
|
28
|
+
"@snokam/api-client": "1.0.0",
|
|
29
|
+
"@snokam/identity": "1.1.0",
|
|
30
|
+
"@snokam/page-content": "6.9.5",
|
|
30
31
|
"@snokam/react-query": "1.1.0",
|
|
31
|
-
"@snokam/
|
|
32
|
-
"@snokam/
|
|
33
|
-
"@snokam/ui": "7.2.1",
|
|
34
|
-
"@snokam/identity": "1.1.0"
|
|
32
|
+
"@snokam/tenant": "4.5.2",
|
|
33
|
+
"@snokam/ui": "7.2.2"
|
|
35
34
|
},
|
|
36
35
|
"peerDependencies": {
|
|
37
36
|
"next": "16.3.8",
|
|
@@ -8,6 +8,7 @@ import { SessionProvider } from "next-auth/react";
|
|
|
8
8
|
import { useAuth } from "../../../hooks";
|
|
9
9
|
import { EmployeeThemeSync } from "../../../theme/employee-theme-sync";
|
|
10
10
|
import { ServerSessionContext } from "../../../session/useSession";
|
|
11
|
+
import { ConfirmedSessionProvider } from "../../../session/confirmed";
|
|
11
12
|
|
|
12
13
|
export const Provider = memo((props: Auth.Provider.Props) => {
|
|
13
14
|
const { session, children } = props;
|
|
@@ -15,10 +16,12 @@ export const Provider = memo((props: Auth.Provider.Props) => {
|
|
|
15
16
|
return (
|
|
16
17
|
<ServerSessionContext.Provider value={session ?? null}>
|
|
17
18
|
<SessionProvider session={session ?? undefined}>
|
|
18
|
-
<
|
|
19
|
-
|
|
20
|
-
<
|
|
21
|
-
|
|
19
|
+
<ConfirmedSessionProvider>
|
|
20
|
+
<EmployeeThemeSync />
|
|
21
|
+
<Suspense fallback={<>{children}</>}>
|
|
22
|
+
<ProviderInner {...props}>{children}</ProviderInner>
|
|
23
|
+
</Suspense>
|
|
24
|
+
</ConfirmedSessionProvider>
|
|
22
25
|
</SessionProvider>
|
|
23
26
|
</ServerSessionContext.Provider>
|
|
24
27
|
);
|
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,19 +1,21 @@
|
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
7
|
+
import { isReusable, type CachedToken } from "./token/reuse";
|
|
8
|
+
import {
|
|
9
|
+
confirmSessionState,
|
|
10
|
+
fetchSessionFromServer,
|
|
11
|
+
withRetries,
|
|
12
|
+
} from "./session/confirm";
|
|
13
|
+
import {
|
|
14
|
+
ConsentRequiredError,
|
|
15
|
+
failureOf,
|
|
16
|
+
SignedOutError,
|
|
17
|
+
TransientTokenError,
|
|
18
|
+
} from "./token/failure";
|
|
17
19
|
|
|
18
20
|
type TokenKind = "session" | "own";
|
|
19
21
|
|
|
@@ -28,22 +30,7 @@ const inFlightTokens: Record<
|
|
|
28
30
|
|
|
29
31
|
function readImpersonationKey(): string {
|
|
30
32
|
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
|
-
}
|
|
33
|
+
return readActiveCookie(document.cookie)?.tokenId ?? "";
|
|
47
34
|
}
|
|
48
35
|
|
|
49
36
|
export function clearAuthTokenCache(): void {
|
|
@@ -53,25 +40,27 @@ export function clearAuthTokenCache(): void {
|
|
|
53
40
|
inFlightTokens.own = null;
|
|
54
41
|
}
|
|
55
42
|
|
|
56
|
-
async function
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
if (body?.error === "consent_required") {
|
|
69
|
-
throw new ConsentRequiredError();
|
|
70
|
-
}
|
|
43
|
+
async function requestToken(kind: TokenKind): Promise<Response> {
|
|
44
|
+
try {
|
|
45
|
+
return await fetch("/api/auth/token", {
|
|
46
|
+
method: "POST",
|
|
47
|
+
body: JSON.stringify(kind === "own" ? { own: true } : {}),
|
|
48
|
+
credentials: "include",
|
|
49
|
+
headers: {
|
|
50
|
+
"Content-Type": "application/json",
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
} catch {
|
|
54
|
+
throw new TransientTokenError("Token request did not reach the server");
|
|
71
55
|
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function fetchToken(kind: TokenKind, impKey: string): Promise<string> {
|
|
59
|
+
const res = await requestToken(kind);
|
|
72
60
|
|
|
73
61
|
if (!res.ok) {
|
|
74
|
-
|
|
62
|
+
const body = await res.json().catch(() => null);
|
|
63
|
+
throw failureOf(res.status, body);
|
|
75
64
|
}
|
|
76
65
|
|
|
77
66
|
const token = await res.json();
|
|
@@ -79,7 +68,12 @@ async function fetchToken(kind: TokenKind, impKey: string): Promise<string> {
|
|
|
79
68
|
|
|
80
69
|
sessionStorage.removeItem("auth-consent-retried");
|
|
81
70
|
const bearer = `Bearer ${accessToken}`;
|
|
82
|
-
cachedTokens[kind] = {
|
|
71
|
+
cachedTokens[kind] = {
|
|
72
|
+
bearer,
|
|
73
|
+
expiresOn,
|
|
74
|
+
fetchedAt: Date.now() / 1000,
|
|
75
|
+
impKey,
|
|
76
|
+
};
|
|
83
77
|
return bearer;
|
|
84
78
|
}
|
|
85
79
|
|
|
@@ -88,20 +82,17 @@ async function getFreshToken(kind: TokenKind): Promise<string> {
|
|
|
88
82
|
const now = Date.now() / 1000;
|
|
89
83
|
const cached = cachedTokens[kind];
|
|
90
84
|
|
|
91
|
-
if (
|
|
92
|
-
cached &&
|
|
93
|
-
cached.impKey === impKey &&
|
|
94
|
-
now < cached.expiresOn - REFRESH_MARGIN_SECONDS
|
|
95
|
-
) {
|
|
96
|
-
return cached.bearer;
|
|
97
|
-
}
|
|
85
|
+
if (isReusable(cached, impKey, now)) return cached.bearer;
|
|
98
86
|
|
|
99
87
|
const inFlight = inFlightTokens[kind];
|
|
100
88
|
if (inFlight && inFlight.impKey === impKey) {
|
|
101
89
|
return inFlight.promise;
|
|
102
90
|
}
|
|
103
91
|
|
|
104
|
-
const promise =
|
|
92
|
+
const promise = withRetries(
|
|
93
|
+
() => fetchToken(kind, impKey),
|
|
94
|
+
(error) => error instanceof TransientTokenError
|
|
95
|
+
);
|
|
105
96
|
inFlightTokens[kind] = { impKey, promise };
|
|
106
97
|
promise
|
|
107
98
|
.catch(() => undefined)
|
|
@@ -116,11 +107,20 @@ export function fetchOwnToken(): Promise<string> {
|
|
|
116
107
|
return tokenOrLogin("own");
|
|
117
108
|
}
|
|
118
109
|
|
|
110
|
+
async function signInAgainIfSignedOut(error: SignedOutError): Promise<void> {
|
|
111
|
+
const state = await confirmSessionState(fetchSessionFromServer);
|
|
112
|
+
if (state !== "signed-out") throw error;
|
|
113
|
+
const here = `${window.location.pathname}${window.location.search}`;
|
|
114
|
+
window.location.href = `/auth/login?callbackUrl=${encodeURIComponent(here)}`;
|
|
115
|
+
}
|
|
116
|
+
|
|
119
117
|
function tokenOrLogin(kind: TokenKind): Promise<string> {
|
|
120
118
|
return getFreshToken(kind).catch((error) => {
|
|
121
119
|
console.error(error);
|
|
120
|
+
if (error instanceof TransientTokenError) throw error;
|
|
122
121
|
const onAuthPage = window.location.pathname.startsWith("/auth/");
|
|
123
122
|
if (onAuthPage) return;
|
|
123
|
+
if (error instanceof SignedOutError) return signInAgainIfSignedOut(error);
|
|
124
124
|
const reason = error instanceof ConsentRequiredError ? "consent" : "true";
|
|
125
125
|
window.location.href = `/auth/login?error=${reason}`;
|
|
126
126
|
}) as Promise<string>;
|
|
@@ -146,13 +146,18 @@ export function useAuth() {
|
|
|
146
146
|
};
|
|
147
147
|
}, [rawSession, active]);
|
|
148
148
|
|
|
149
|
+
const getToken = useCallback(
|
|
150
|
+
() => tokenOrLogin(own ? "own" : "session"),
|
|
151
|
+
[own]
|
|
152
|
+
);
|
|
153
|
+
|
|
149
154
|
const value = useMemo(
|
|
150
155
|
() => ({
|
|
151
156
|
update,
|
|
152
157
|
signIn,
|
|
153
158
|
signOut,
|
|
154
159
|
session,
|
|
155
|
-
getToken
|
|
160
|
+
getToken,
|
|
156
161
|
inProgress: status === "loading",
|
|
157
162
|
isAuthenticated:
|
|
158
163
|
status === "authenticated" ||
|
|
@@ -160,7 +165,7 @@ export function useAuth() {
|
|
|
160
165
|
(status === "loading" && !!session),
|
|
161
166
|
}),
|
|
162
167
|
|
|
163
|
-
[update, status, session, active,
|
|
168
|
+
[update, status, session, active, getToken]
|
|
164
169
|
);
|
|
165
170
|
|
|
166
171
|
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
|
+
}
|