@snokam/auth 3.5.0 → 3.7.0
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 +12 -0
- package/package.json +4 -4
- package/src/app-sign-in/handlers.ts +17 -13
- package/src/app-sign-in/platform.ts +33 -22
- package/src/hooks.ts +21 -9
- package/src/impersonation/claims.ts +16 -0
- package/src/impersonation/context.tsx +1 -9
- package/src/impersonation/handlers.ts +35 -37
- package/src/impersonation/personas.ts +31 -27
- package/src/impersonation/renew.ts +30 -50
- package/src/impersonation/request.ts +38 -0
- package/src/impersonation/required.test.ts +4 -51
- package/src/impersonation/required.ts +0 -29
- package/src/middleware.tsx +13 -2
- package/src/route-handler/token.ts +4 -1
- package/src/impersonation/RequiredImpersonationGate.tsx +0 -51
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,18 @@
|
|
|
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.0](https://github.com/snokam/components/compare/@snokam/auth@3.6.0...@snokam/auth@3.7.0) (2026-10-06)
|
|
7
|
+
|
|
8
|
+
### Features
|
|
9
|
+
|
|
10
|
+
- **test:** every test page needs a login; pick a persona only when a page needs one ([#2705](https://github.com/snokam/components/issues/2705)) ([e086fb6](https://github.com/snokam/components/commit/e086fb63511e0f3f4f2002d0189e089c8cda6d30))
|
|
11
|
+
|
|
12
|
+
# [3.6.0](https://github.com/snokam/components/compare/@snokam/auth@3.5.0...@snokam/auth@3.6.0) (2026-10-06)
|
|
13
|
+
|
|
14
|
+
### Features
|
|
15
|
+
|
|
16
|
+
- **snosky:** no prices, no terms page, unlinked /status in Snøkam look, better persona flow ([#2681](https://github.com/snokam/components/issues/2681)) ([b6dd5af](https://github.com/snokam/components/commit/b6dd5af68258c31f717888372fc7258d233c9e2e))
|
|
17
|
+
|
|
6
18
|
# [3.5.0](https://github.com/snokam/components/compare/@snokam/auth@3.4.0...@snokam/auth@3.5.0) (2026-10-06)
|
|
7
19
|
|
|
8
20
|
### 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.
|
|
4
|
+
"version": "3.7.0",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"types": "src/index.ts",
|
|
7
7
|
"files": [
|
|
@@ -28,10 +28,10 @@
|
|
|
28
28
|
"path-to-regexp": "^8.4.2",
|
|
29
29
|
"@snokam/identity": "1.1.0",
|
|
30
30
|
"@snokam/platform-client": "3.6.0",
|
|
31
|
-
"@snokam/tenant": "2.1.13",
|
|
32
|
-
"@snokam/ui": "4.0.0",
|
|
33
31
|
"@snokam/react-query": "1.1.0",
|
|
34
|
-
"@snokam/sanity-page-content": "4.4.2"
|
|
32
|
+
"@snokam/sanity-page-content": "4.4.2",
|
|
33
|
+
"@snokam/tenant": "2.1.13",
|
|
34
|
+
"@snokam/ui": "4.0.0"
|
|
35
35
|
},
|
|
36
36
|
"peerDependencies": {
|
|
37
37
|
"next": "16.3.3",
|
|
@@ -6,10 +6,8 @@ import {
|
|
|
6
6
|
requestOrigin,
|
|
7
7
|
} from "../session";
|
|
8
8
|
import { resolveAccessToken } from "../route-handler/token";
|
|
9
|
-
import {
|
|
10
|
-
|
|
11
|
-
readImpersonationSession,
|
|
12
|
-
} from "../impersonation/cookies";
|
|
9
|
+
import { readImpersonationSession } from "../impersonation/cookies";
|
|
10
|
+
import { tokenClaims } from "../impersonation/claims";
|
|
13
11
|
import {
|
|
14
12
|
impersonationPickerUrl,
|
|
15
13
|
impersonationRequired,
|
|
@@ -58,13 +56,18 @@ interface Signer {
|
|
|
58
56
|
accessToken: string;
|
|
59
57
|
}
|
|
60
58
|
|
|
61
|
-
function
|
|
59
|
+
function sameEmail(a: string | undefined, b: string): boolean {
|
|
60
|
+
return !!a && a.toLowerCase() === b.toLowerCase();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function personaSigner(request: Request, operator: string): Signer | null {
|
|
62
64
|
const session = readImpersonationSession(request);
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
65
|
+
if (!session || session.expiresOn * 1000 <= Date.now()) return null;
|
|
66
|
+
|
|
67
|
+
const claims = tokenClaims(session.accessToken);
|
|
68
|
+
if (!claims.email || !sameEmail(claims.act, operator)) return null;
|
|
69
|
+
|
|
70
|
+
return { email: claims.email, accessToken: session.accessToken };
|
|
68
71
|
}
|
|
69
72
|
|
|
70
73
|
async function ownSigner(
|
|
@@ -82,7 +85,7 @@ function signerFor(
|
|
|
82
85
|
email: string
|
|
83
86
|
): Promise<Signer | null> {
|
|
84
87
|
if (impersonationRequired(authConfig.environment)) {
|
|
85
|
-
return Promise.resolve(personaSigner(request));
|
|
88
|
+
return Promise.resolve(personaSigner(request, email));
|
|
86
89
|
}
|
|
87
90
|
return ownSigner(authConfig, request, email);
|
|
88
91
|
}
|
|
@@ -175,7 +178,8 @@ export function createAppSignInAuthorizeHandler(authConfig: Auth.Config) {
|
|
|
175
178
|
const ticket = sealConsent(
|
|
176
179
|
{
|
|
177
180
|
...params,
|
|
178
|
-
|
|
181
|
+
clientName: preview.value.clientName ?? "",
|
|
182
|
+
businessName: preview.value.businessName ?? "",
|
|
179
183
|
business: new URL(params.resource).hostname,
|
|
180
184
|
state: query.get("state") ?? "",
|
|
181
185
|
email: signer.email,
|
|
@@ -227,7 +231,7 @@ export function createAppSignInApproveHandler(authConfig: Auth.Config) {
|
|
|
227
231
|
consent.state,
|
|
228
232
|
"error" in approved
|
|
229
233
|
? { error: approved.error }
|
|
230
|
-
: { code: approved.value.code }
|
|
234
|
+
: { code: approved.value.code ?? "" }
|
|
231
235
|
);
|
|
232
236
|
};
|
|
233
237
|
}
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { getManagedBearer } from "@snokam/identity";
|
|
2
|
+
import {
|
|
3
|
+
Configuration,
|
|
4
|
+
PlatformClient,
|
|
5
|
+
ResponseError,
|
|
6
|
+
type AuthorizationPreview,
|
|
7
|
+
type IssuedAuthorizationCode,
|
|
8
|
+
} from "@snokam/platform-client";
|
|
2
9
|
import type { AuthorizationParams } from "./consent";
|
|
3
10
|
|
|
4
|
-
export interface AuthorizationPreview {
|
|
5
|
-
clientName: string;
|
|
6
|
-
businessName: string;
|
|
7
|
-
}
|
|
8
|
-
|
|
9
11
|
type Outcome<T> = { value: T } | { error: string };
|
|
10
12
|
|
|
11
13
|
const UNAVAILABLE = "temporarily_unavailable";
|
|
@@ -17,36 +19,45 @@ function apiBase(): string {
|
|
|
17
19
|
);
|
|
18
20
|
}
|
|
19
21
|
|
|
20
|
-
async function
|
|
21
|
-
|
|
22
|
-
params: AuthorizationParams,
|
|
23
|
-
userToken: string
|
|
22
|
+
async function asLoginSite<T>(
|
|
23
|
+
call: (client: PlatformClient) => Promise<T>
|
|
24
24
|
): Promise<Outcome<T>> {
|
|
25
25
|
const loginSite = await getManagedBearer();
|
|
26
26
|
if (!loginSite) return { error: UNAVAILABLE };
|
|
27
27
|
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
28
|
+
const client = new PlatformClient(
|
|
29
|
+
new Configuration({
|
|
30
|
+
basePath: apiBase(),
|
|
31
|
+
accessToken: async () => loginSite,
|
|
32
|
+
})
|
|
33
|
+
);
|
|
34
|
+
try {
|
|
35
|
+
return { value: await call(client) };
|
|
36
|
+
} catch (error) {
|
|
37
|
+
if (!(error instanceof ResponseError)) throw error;
|
|
38
|
+
const body = await error.response.json().catch(() => ({}));
|
|
39
|
+
return { error: String(body.error ?? "invalid_request") };
|
|
40
|
+
}
|
|
38
41
|
}
|
|
39
42
|
|
|
40
43
|
export function previewAuthorization(
|
|
41
44
|
params: AuthorizationParams,
|
|
42
45
|
userToken: string
|
|
43
46
|
): Promise<Outcome<AuthorizationPreview>> {
|
|
44
|
-
return
|
|
47
|
+
return asLoginSite((client) =>
|
|
48
|
+
client.previewOAuthAuthorization({
|
|
49
|
+
authorizationRequest: { ...params, userToken },
|
|
50
|
+
})
|
|
51
|
+
);
|
|
45
52
|
}
|
|
46
53
|
|
|
47
54
|
export function approveAuthorization(
|
|
48
55
|
params: AuthorizationParams,
|
|
49
56
|
userToken: string
|
|
50
|
-
): Promise<Outcome<
|
|
51
|
-
return
|
|
57
|
+
): Promise<Outcome<IssuedAuthorizationCode>> {
|
|
58
|
+
return asLoginSite((client) =>
|
|
59
|
+
client.approveOAuthAuthorization({
|
|
60
|
+
authorizationRequest: { ...params, userToken },
|
|
61
|
+
})
|
|
62
|
+
);
|
|
52
63
|
}
|
package/src/hooks.ts
CHANGED
|
@@ -3,11 +3,25 @@ import { useMemo } from "react";
|
|
|
3
3
|
import { IMP_ACTIVE_COOKIE } from "./impersonation/cookies";
|
|
4
4
|
import { useImpersonation } from "./impersonation/context";
|
|
5
5
|
import { useSession } from "./session/useSession";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
IMPERSONATION_REQUIRED_ERROR,
|
|
8
|
+
impersonationPickerUrl,
|
|
9
|
+
} from "./impersonation/required";
|
|
7
10
|
|
|
8
11
|
class ConsentRequiredError extends Error {}
|
|
9
12
|
|
|
10
|
-
class ImpersonationRequiredError extends Error {
|
|
13
|
+
class ImpersonationRequiredError extends Error {
|
|
14
|
+
constructor(readonly platformUrl: string | undefined) {
|
|
15
|
+
super(IMPERSONATION_REQUIRED_ERROR);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function sendToPicker(error: ImpersonationRequiredError): void {
|
|
20
|
+
if (!error.platformUrl) return;
|
|
21
|
+
window.location.assign(
|
|
22
|
+
impersonationPickerUrl(error.platformUrl, window.location.href)
|
|
23
|
+
);
|
|
24
|
+
}
|
|
11
25
|
|
|
12
26
|
const REFRESH_MARGIN_SECONDS = 60;
|
|
13
27
|
|
|
@@ -20,11 +34,6 @@ interface CachedToken {
|
|
|
20
34
|
let cachedToken: CachedToken | null = null;
|
|
21
35
|
let inFlight: { impKey: string; promise: Promise<string> } | null = null;
|
|
22
36
|
|
|
23
|
-
/**
|
|
24
|
-
* Identifies which principal the next token belongs to: the active impersonation
|
|
25
|
-
* tokenId, or "" for the admin's own token. Changing this (Vis som start/exit)
|
|
26
|
-
* invalidates the cache so the swap in the token route is never served stale.
|
|
27
|
-
*/
|
|
28
37
|
function readImpersonationKey(): string {
|
|
29
38
|
if (typeof document === "undefined") return "";
|
|
30
39
|
const match = document.cookie
|
|
@@ -66,7 +75,7 @@ async function fetchToken(impKey: string): Promise<string> {
|
|
|
66
75
|
throw new ConsentRequiredError();
|
|
67
76
|
}
|
|
68
77
|
if (body?.error === IMPERSONATION_REQUIRED_ERROR) {
|
|
69
|
-
throw new ImpersonationRequiredError();
|
|
78
|
+
throw new ImpersonationRequiredError(body.platformUrl);
|
|
70
79
|
}
|
|
71
80
|
}
|
|
72
81
|
|
|
@@ -134,7 +143,10 @@ export function useAuth() {
|
|
|
134
143
|
session,
|
|
135
144
|
getToken: () =>
|
|
136
145
|
getFreshToken().catch((error) => {
|
|
137
|
-
if (error instanceof ImpersonationRequiredError)
|
|
146
|
+
if (error instanceof ImpersonationRequiredError) {
|
|
147
|
+
sendToPicker(error);
|
|
148
|
+
throw error;
|
|
149
|
+
}
|
|
138
150
|
console.error(error);
|
|
139
151
|
const onAuthPage = window.location.pathname.startsWith("/auth/");
|
|
140
152
|
if (onAuthPage) return;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export interface TokenClaims {
|
|
2
|
+
biz?: string;
|
|
3
|
+
email?: string;
|
|
4
|
+
act?: string;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function tokenClaims(accessToken: string): TokenClaims {
|
|
8
|
+
try {
|
|
9
|
+
const payload = accessToken.split(".")[1] ?? "";
|
|
10
|
+
return JSON.parse(
|
|
11
|
+
Buffer.from(payload, "base64url").toString("utf8")
|
|
12
|
+
) as TokenClaims;
|
|
13
|
+
} catch {
|
|
14
|
+
return {};
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -12,7 +12,6 @@ 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 { RequiredImpersonationGate } from "./RequiredImpersonationGate";
|
|
16
15
|
|
|
17
16
|
interface ImpersonationContextValue {
|
|
18
17
|
active: ImpersonationActive | null;
|
|
@@ -77,7 +76,6 @@ function readActiveCookie(): ImpersonationActive | null {
|
|
|
77
76
|
|
|
78
77
|
export function ImpersonationProvider({ children }: { children: ReactNode }) {
|
|
79
78
|
const [active, setActive] = useState<ImpersonationActive | null>(null);
|
|
80
|
-
const [cookieRead, setCookieRead] = useState(false);
|
|
81
79
|
const [starting, setStarting] = useState(false);
|
|
82
80
|
const [exiting, setExiting] = useState(false);
|
|
83
81
|
|
|
@@ -85,7 +83,6 @@ export function ImpersonationProvider({ children }: { children: ReactNode }) {
|
|
|
85
83
|
|
|
86
84
|
useEffect(() => {
|
|
87
85
|
rereadCookie();
|
|
88
|
-
setCookieRead(true);
|
|
89
86
|
}, [rereadCookie]);
|
|
90
87
|
|
|
91
88
|
useSessionRenewal(active, rereadCookie);
|
|
@@ -159,12 +156,7 @@ export function ImpersonationProvider({ children }: { children: ReactNode }) {
|
|
|
159
156
|
|
|
160
157
|
return (
|
|
161
158
|
<ImpersonationContext.Provider value={value}>
|
|
162
|
-
|
|
163
|
-
sessionActive={!!active}
|
|
164
|
-
cookieRead={cookieRead}
|
|
165
|
-
>
|
|
166
|
-
{children}
|
|
167
|
-
</RequiredImpersonationGate>
|
|
159
|
+
{children}
|
|
168
160
|
</ImpersonationContext.Provider>
|
|
169
161
|
);
|
|
170
162
|
}
|
|
@@ -7,7 +7,12 @@ import {
|
|
|
7
7
|
} from "./cookies";
|
|
8
8
|
import { sealImpersonationTicket, openImpersonationTicket } from "./ticket";
|
|
9
9
|
import { requestOrigin, resolveCookieDomain } from "../session";
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
platformCall,
|
|
12
|
+
platformClient,
|
|
13
|
+
platformNotConfigured,
|
|
14
|
+
requestHost,
|
|
15
|
+
} from "./request";
|
|
11
16
|
|
|
12
17
|
function registrable(host: string): string {
|
|
13
18
|
return host.split(":")[0].toLowerCase().split(".").slice(-2).join(".");
|
|
@@ -64,17 +69,12 @@ function handsOffToTarget(request: Request, target: string): boolean {
|
|
|
64
69
|
*/
|
|
65
70
|
export function createImpersonationStartHandler(authConfig: Auth.Config) {
|
|
66
71
|
return async function POST(request: Request) {
|
|
67
|
-
const base = platformBase();
|
|
68
|
-
if (!base) {
|
|
69
|
-
return new Response(
|
|
70
|
-
"NEXT_PUBLIC_PLATFORM_API_BASE_URL is not configured",
|
|
71
|
-
{ status: 500 }
|
|
72
|
-
);
|
|
73
|
-
}
|
|
74
|
-
|
|
75
72
|
const caller = await resolveAccessToken(authConfig, request);
|
|
76
73
|
if ("error" in caller) return caller.error;
|
|
77
74
|
|
|
75
|
+
const client = platformClient(caller.accessToken);
|
|
76
|
+
if (!client) return platformNotConfigured();
|
|
77
|
+
|
|
78
78
|
let body: {
|
|
79
79
|
targetEmail?: string;
|
|
80
80
|
targetName?: string;
|
|
@@ -91,23 +91,26 @@ export function createImpersonationStartHandler(authConfig: Auth.Config) {
|
|
|
91
91
|
return new Response("Invalid request body", { status: 400 });
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
-
const minted = await
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
94
|
+
const minted = await platformCall(
|
|
95
|
+
client.startImpersonation({
|
|
96
|
+
startImpersonationRequest: {
|
|
97
|
+
targetEmail: body.targetEmail,
|
|
98
|
+
targetName: body.targetName,
|
|
99
|
+
reason: body.reason,
|
|
100
|
+
},
|
|
101
|
+
})
|
|
102
|
+
);
|
|
103
|
+
if ("error" in minted) return minted.error;
|
|
104
|
+
|
|
105
|
+
const result = {
|
|
106
|
+
accessToken: minted.value.accessToken ?? "",
|
|
107
|
+
expiresOn: minted.value.expiresOn ?? 0,
|
|
108
|
+
tokenId: minted.value.tokenId ?? "",
|
|
109
|
+
targetEmail: minted.value.targetEmail ?? body.targetEmail ?? "",
|
|
110
|
+
targetName: minted.value.targetName,
|
|
111
|
+
targetImage: body.targetImage ?? null,
|
|
112
|
+
finance: minted.value.finance ?? false,
|
|
113
|
+
};
|
|
111
114
|
const summary = {
|
|
112
115
|
targetEmail: result.targetEmail,
|
|
113
116
|
targetName: result.targetName,
|
|
@@ -200,19 +203,14 @@ export function createImpersonationAdoptHandler(authConfig: Auth.Config) {
|
|
|
200
203
|
export function createImpersonationEndHandler(authConfig: Auth.Config) {
|
|
201
204
|
return async function POST(request: Request) {
|
|
202
205
|
const session = readImpersonationSession(request);
|
|
203
|
-
const base = platformBase();
|
|
204
206
|
|
|
205
|
-
if (session?.tokenId
|
|
207
|
+
if (session?.tokenId) {
|
|
206
208
|
const caller = await resolveAccessToken(authConfig, request);
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
headers: { Authorization: `Bearer ${caller.accessToken}` },
|
|
213
|
-
}
|
|
214
|
-
).catch(() => undefined);
|
|
215
|
-
}
|
|
209
|
+
const client =
|
|
210
|
+
"error" in caller ? null : platformClient(caller.accessToken);
|
|
211
|
+
await client
|
|
212
|
+
?.endImpersonation({ tokenId: session.tokenId })
|
|
213
|
+
.catch(() => undefined);
|
|
216
214
|
}
|
|
217
215
|
|
|
218
216
|
const out = Response.json({ ok: true });
|
|
@@ -1,18 +1,25 @@
|
|
|
1
|
+
import type { ProtectedEmployee } from "@snokam/platform-client";
|
|
1
2
|
import { Auth } from "../types";
|
|
2
3
|
import { resolveAccessToken } from "../route-handler/token";
|
|
3
|
-
import {
|
|
4
|
+
import { tokenClaims } from "./claims";
|
|
5
|
+
import { platformCall, platformClient, platformNotConfigured } from "./request";
|
|
4
6
|
import { impersonationRequired } from "./required";
|
|
5
7
|
|
|
6
|
-
function
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
8
|
+
function persona(employee: ProtectedEmployee) {
|
|
9
|
+
return {
|
|
10
|
+
name: employee.name,
|
|
11
|
+
email: employee.email,
|
|
12
|
+
position: employee.position,
|
|
13
|
+
about: employee.about,
|
|
14
|
+
active: employee.active,
|
|
15
|
+
isBot: employee.isBot,
|
|
16
|
+
image: { asset: { url: employee.image?.asset?.url } },
|
|
17
|
+
scopeGrants: employee.scopeGrants,
|
|
18
|
+
groups: (employee.groups ?? []).map((group) => ({
|
|
19
|
+
name: group.name,
|
|
20
|
+
scopeGrants: group.scopeGrants,
|
|
21
|
+
})),
|
|
22
|
+
};
|
|
16
23
|
}
|
|
17
24
|
|
|
18
25
|
export function createImpersonationPersonasHandler(authConfig: Auth.Config) {
|
|
@@ -21,32 +28,29 @@ export function createImpersonationPersonasHandler(authConfig: Auth.Config) {
|
|
|
21
28
|
return new Response("Not found", { status: 404 });
|
|
22
29
|
}
|
|
23
30
|
|
|
24
|
-
const base = platformBase();
|
|
25
|
-
if (!base) {
|
|
26
|
-
return new Response(
|
|
27
|
-
"NEXT_PUBLIC_PLATFORM_API_BASE_URL is not configured",
|
|
28
|
-
{ status: 500 }
|
|
29
|
-
);
|
|
30
|
-
}
|
|
31
|
-
|
|
32
31
|
const caller = await resolveAccessToken(authConfig, request);
|
|
33
32
|
if ("error" in caller) return caller.error;
|
|
34
33
|
|
|
35
|
-
const
|
|
34
|
+
const client = platformClient(caller.accessToken);
|
|
35
|
+
if (!client) return platformNotConfigured();
|
|
36
|
+
|
|
37
|
+
const businessId = tokenClaims(caller.accessToken).biz;
|
|
36
38
|
if (!businessId) {
|
|
37
39
|
return new Response("The signed-in account has no business", {
|
|
38
40
|
status: 409,
|
|
39
41
|
});
|
|
40
42
|
}
|
|
41
43
|
|
|
42
|
-
const employees = await
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
);
|
|
46
|
-
if (
|
|
44
|
+
const [employees, apps] = await Promise.all([
|
|
45
|
+
platformCall(client.listBusinessEmployees({ id: businessId })),
|
|
46
|
+
platformCall(client.getMeApps()),
|
|
47
|
+
]);
|
|
48
|
+
if ("error" in employees) return employees.error;
|
|
49
|
+
if ("error" in apps) return apps.error;
|
|
47
50
|
|
|
48
|
-
return
|
|
49
|
-
|
|
51
|
+
return Response.json({
|
|
52
|
+
personas: employees.value.map(persona),
|
|
53
|
+
apps: apps.value,
|
|
50
54
|
});
|
|
51
55
|
};
|
|
52
56
|
}
|
|
@@ -1,8 +1,3 @@
|
|
|
1
|
-
import {
|
|
2
|
-
Configuration,
|
|
3
|
-
PlatformClient,
|
|
4
|
-
ResponseError,
|
|
5
|
-
} from "@snokam/platform-client";
|
|
6
1
|
import { Auth } from "../types";
|
|
7
2
|
import { resolveAccessToken } from "../route-handler/token";
|
|
8
3
|
import {
|
|
@@ -10,7 +5,12 @@ import {
|
|
|
10
5
|
readImpersonationActive,
|
|
11
6
|
readImpersonationSession,
|
|
12
7
|
} from "./cookies";
|
|
13
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
platformCall,
|
|
10
|
+
platformClient,
|
|
11
|
+
platformNotConfigured,
|
|
12
|
+
requestHost,
|
|
13
|
+
} from "./request";
|
|
14
14
|
|
|
15
15
|
export function createImpersonationRenewHandler(authConfig: Auth.Config) {
|
|
16
16
|
return async function POST(request: Request) {
|
|
@@ -20,56 +20,36 @@ export function createImpersonationRenewHandler(authConfig: Auth.Config) {
|
|
|
20
20
|
return new Response("No impersonation session", { status: 404 });
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
const basePath = platformBase();
|
|
24
|
-
if (!basePath) {
|
|
25
|
-
return new Response(
|
|
26
|
-
"NEXT_PUBLIC_PLATFORM_API_BASE_URL is not configured",
|
|
27
|
-
{
|
|
28
|
-
status: 500,
|
|
29
|
-
}
|
|
30
|
-
);
|
|
31
|
-
}
|
|
32
|
-
|
|
33
23
|
const caller = await resolveAccessToken(authConfig, request);
|
|
34
24
|
if ("error" in caller) return caller.error;
|
|
35
25
|
|
|
36
|
-
const client =
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
})
|
|
26
|
+
const client = platformClient(caller.accessToken);
|
|
27
|
+
if (!client) return platformNotConfigured();
|
|
28
|
+
|
|
29
|
+
const renewal = await platformCall(
|
|
30
|
+
client.renewImpersonation({ tokenId: session.tokenId })
|
|
41
31
|
);
|
|
32
|
+
if ("error" in renewal) return renewal.error;
|
|
42
33
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
if (!renewed.accessToken || !renewed.expiresOn) {
|
|
48
|
-
return new Response("Incomplete impersonation renewal", {
|
|
49
|
-
status: 502,
|
|
50
|
-
});
|
|
51
|
-
}
|
|
34
|
+
const renewed = renewal.value;
|
|
35
|
+
if (!renewed.accessToken || !renewed.expiresOn) {
|
|
36
|
+
return new Response("Incomplete impersonation renewal", { status: 502 });
|
|
37
|
+
}
|
|
52
38
|
|
|
53
|
-
|
|
54
|
-
|
|
39
|
+
const out = Response.json({
|
|
40
|
+
tokenId: session.tokenId,
|
|
41
|
+
expiresOn: renewed.expiresOn,
|
|
42
|
+
});
|
|
43
|
+
const cookies = buildImpersonationCookies(
|
|
44
|
+
{
|
|
45
|
+
...active,
|
|
46
|
+
accessToken: renewed.accessToken,
|
|
55
47
|
expiresOn: renewed.expiresOn,
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
finance: renewed.finance ?? false,
|
|
63
|
-
},
|
|
64
|
-
requestHost(request)
|
|
65
|
-
);
|
|
66
|
-
for (const cookie of cookies) out.headers.append("Set-Cookie", cookie);
|
|
67
|
-
return out;
|
|
68
|
-
} catch (error) {
|
|
69
|
-
if (error instanceof ResponseError) {
|
|
70
|
-
return platformErrorResponse(error.response);
|
|
71
|
-
}
|
|
72
|
-
throw error;
|
|
73
|
-
}
|
|
48
|
+
finance: renewed.finance ?? false,
|
|
49
|
+
},
|
|
50
|
+
requestHost(request)
|
|
51
|
+
);
|
|
52
|
+
for (const cookie of cookies) out.headers.append("Set-Cookie", cookie);
|
|
53
|
+
return out;
|
|
74
54
|
};
|
|
75
55
|
}
|
|
@@ -1,9 +1,32 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Configuration,
|
|
3
|
+
PlatformClient,
|
|
4
|
+
ResponseError,
|
|
5
|
+
} from "@snokam/platform-client";
|
|
6
|
+
|
|
1
7
|
export function platformBase(): string | null {
|
|
2
8
|
return (
|
|
3
9
|
process.env.NEXT_PUBLIC_PLATFORM_API_BASE_URL?.replace(/\/$/, "") ?? null
|
|
4
10
|
);
|
|
5
11
|
}
|
|
6
12
|
|
|
13
|
+
export function platformClient(accessToken: string): PlatformClient | null {
|
|
14
|
+
const basePath = platformBase();
|
|
15
|
+
if (!basePath) return null;
|
|
16
|
+
return new PlatformClient(
|
|
17
|
+
new Configuration({
|
|
18
|
+
basePath,
|
|
19
|
+
accessToken: async () => `Bearer ${accessToken}`,
|
|
20
|
+
})
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function platformNotConfigured(): Response {
|
|
25
|
+
return new Response("NEXT_PUBLIC_PLATFORM_API_BASE_URL is not configured", {
|
|
26
|
+
status: 500,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
7
30
|
export function requestHost(request: Request): string | null {
|
|
8
31
|
return request.headers.get("x-forwarded-host") ?? request.headers.get("host");
|
|
9
32
|
}
|
|
@@ -24,3 +47,18 @@ export async function platformErrorResponse(
|
|
|
24
47
|
status: response.status,
|
|
25
48
|
});
|
|
26
49
|
}
|
|
50
|
+
|
|
51
|
+
export type PlatformResult<T> = { value: T } | { error: Response };
|
|
52
|
+
|
|
53
|
+
export async function platformCall<T>(
|
|
54
|
+
call: Promise<T>
|
|
55
|
+
): Promise<PlatformResult<T>> {
|
|
56
|
+
try {
|
|
57
|
+
return { value: await call };
|
|
58
|
+
} catch (error) {
|
|
59
|
+
if (error instanceof ResponseError) {
|
|
60
|
+
return { error: await platformErrorResponse(error.response) };
|
|
61
|
+
}
|
|
62
|
+
throw error;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -3,20 +3,10 @@ import { describe, it } from "node:test";
|
|
|
3
3
|
import {
|
|
4
4
|
impersonationPickerUrl,
|
|
5
5
|
impersonationRequired,
|
|
6
|
-
isExemptRoute,
|
|
7
6
|
renewDelayMs,
|
|
8
|
-
sendsToPicker,
|
|
9
|
-
withholdsApp,
|
|
10
7
|
} from "./required.ts";
|
|
11
8
|
|
|
12
|
-
|
|
13
|
-
authenticated: true,
|
|
14
|
-
sessionActive: false,
|
|
15
|
-
cookieRead: true,
|
|
16
|
-
exemptRoute: false,
|
|
17
|
-
};
|
|
18
|
-
|
|
19
|
-
describe("impersonation required gate", () => {
|
|
9
|
+
describe("impersonation required", () => {
|
|
20
10
|
it("is off in production and everywhere that is not the test environment", () => {
|
|
21
11
|
for (const environment of ["p", "test", "production", "", undefined]) {
|
|
22
12
|
assert.equal(impersonationRequired(environment), false);
|
|
@@ -27,58 +17,21 @@ describe("impersonation required gate", () => {
|
|
|
27
17
|
assert.equal(impersonationRequired("t"), true);
|
|
28
18
|
});
|
|
29
19
|
|
|
30
|
-
it("never withholds the app or redirects when not required", () => {
|
|
31
|
-
const state = { required: false, ...signedInAlone };
|
|
32
|
-
assert.equal(withholdsApp(state), false);
|
|
33
|
-
assert.equal(sendsToPicker(state), false);
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
it("sends a signed-in person without a session to the picker when required", () => {
|
|
37
|
-
const state = { required: true, ...signedInAlone };
|
|
38
|
-
assert.equal(withholdsApp(state), true);
|
|
39
|
-
assert.equal(sendsToPicker(state), true);
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
it("lets the app render once someone is being viewed as", () => {
|
|
43
|
-
const state = { required: true, ...signedInAlone, sessionActive: true };
|
|
44
|
-
assert.equal(withholdsApp(state), false);
|
|
45
|
-
assert.equal(sendsToPicker(state), false);
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
it("holds the app without redirecting until the session cookie is read", () => {
|
|
49
|
-
const state = { required: true, ...signedInAlone, cookieRead: false };
|
|
50
|
-
assert.equal(withholdsApp(state), true);
|
|
51
|
-
assert.equal(sendsToPicker(state), false);
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
it("leaves sign-in routes, the picker and anonymous visitors alone", () => {
|
|
55
|
-
const base = { required: true, ...signedInAlone };
|
|
56
|
-
assert.equal(withholdsApp({ ...base, exemptRoute: true }), false);
|
|
57
|
-
assert.equal(withholdsApp({ ...base, authenticated: false }), false);
|
|
58
|
-
});
|
|
59
|
-
|
|
60
20
|
it("points the picker back at the page the person asked for", () => {
|
|
61
21
|
const url = new URL(
|
|
62
22
|
impersonationPickerUrl(
|
|
63
23
|
"https://www.test.snosky.no",
|
|
64
|
-
"https://
|
|
24
|
+
"https://minside.test.snokam.no/okonomi?x=1"
|
|
65
25
|
)
|
|
66
26
|
);
|
|
27
|
+
assert.equal(url.origin, "https://www.test.snosky.no");
|
|
67
28
|
assert.equal(url.pathname, "/velg-bruker");
|
|
68
29
|
assert.equal(
|
|
69
30
|
url.searchParams.get("returnTo"),
|
|
70
|
-
"https://
|
|
31
|
+
"https://minside.test.snokam.no/okonomi?x=1"
|
|
71
32
|
);
|
|
72
33
|
});
|
|
73
34
|
|
|
74
|
-
it("exempts sign-in routes and the picker, and nothing else", () => {
|
|
75
|
-
assert.equal(isExemptRoute("/auth/login"), true);
|
|
76
|
-
assert.equal(isExemptRoute("/velg-bruker"), true);
|
|
77
|
-
assert.equal(isExemptRoute("/manage"), false);
|
|
78
|
-
assert.equal(isExemptRoute("/"), false);
|
|
79
|
-
assert.equal(isExemptRoute(null), false);
|
|
80
|
-
});
|
|
81
|
-
|
|
82
35
|
it("renews two minutes before the token expires, and at once when already late", () => {
|
|
83
36
|
assert.equal(renewDelayMs(1_000, 0), 880_000);
|
|
84
37
|
assert.equal(renewDelayMs(1_000, 999_000), 0);
|
|
@@ -12,35 +12,6 @@ export function impersonationRequired(
|
|
|
12
12
|
return environment === TEST_ENVIRONMENT;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
export interface ImpersonationGateState {
|
|
16
|
-
required: boolean;
|
|
17
|
-
authenticated: boolean;
|
|
18
|
-
sessionActive: boolean;
|
|
19
|
-
cookieRead: boolean;
|
|
20
|
-
exemptRoute: boolean;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
function gated({
|
|
24
|
-
required,
|
|
25
|
-
authenticated,
|
|
26
|
-
exemptRoute,
|
|
27
|
-
}: ImpersonationGateState) {
|
|
28
|
-
return required && authenticated && !exemptRoute;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
export function isExemptRoute(pathname: string | null): boolean {
|
|
32
|
-
if (!pathname) return false;
|
|
33
|
-
return pathname.startsWith("/auth") || pathname === IMPERSONATION_PICKER_PATH;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export function withholdsApp(state: ImpersonationGateState): boolean {
|
|
37
|
-
return gated(state) && (!state.cookieRead || !state.sessionActive);
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
export function sendsToPicker(state: ImpersonationGateState): boolean {
|
|
41
|
-
return gated(state) && state.cookieRead && !state.sessionActive;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
15
|
export function impersonationPickerUrl(
|
|
45
16
|
platformBaseUrl: string,
|
|
46
17
|
returnTo: string
|
package/src/middleware.tsx
CHANGED
|
@@ -6,6 +6,15 @@ import { Auth } from "./types";
|
|
|
6
6
|
import { getSessionCookieSettings, getAuthUrl } from "./session";
|
|
7
7
|
import { addCorsHeaders } from "./cors";
|
|
8
8
|
|
|
9
|
+
const TEST_ENVIRONMENT = "t";
|
|
10
|
+
|
|
11
|
+
const LOGIN_FLOW_PATHS = [
|
|
12
|
+
"/auth/",
|
|
13
|
+
"/handoff/",
|
|
14
|
+
"/api/password/",
|
|
15
|
+
"/api/impersonation/adopt",
|
|
16
|
+
];
|
|
17
|
+
|
|
9
18
|
export function buildAuthMiddleware({
|
|
10
19
|
matcher = [],
|
|
11
20
|
callbacks = {},
|
|
@@ -13,7 +22,9 @@ export function buildAuthMiddleware({
|
|
|
13
22
|
cors = {},
|
|
14
23
|
...rest
|
|
15
24
|
}: Auth.Middleware.Props = {}): Auth.Middleware.Builder {
|
|
16
|
-
const requireLogin =
|
|
25
|
+
const requireLogin =
|
|
26
|
+
process.env.IS_PR === "true" ||
|
|
27
|
+
process.env.ENVIRONMENT === TEST_ENVIRONMENT;
|
|
17
28
|
const { name } = getSessionCookieSettings(getAuthUrl());
|
|
18
29
|
|
|
19
30
|
const shouldProtectPath = (pathname: string): boolean => {
|
|
@@ -25,7 +36,7 @@ export function buildAuthMiddleware({
|
|
|
25
36
|
return false;
|
|
26
37
|
}
|
|
27
38
|
|
|
28
|
-
if (pathname.startsWith(
|
|
39
|
+
if (LOGIN_FLOW_PATHS.some((path) => pathname.startsWith(path))) {
|
|
29
40
|
return false;
|
|
30
41
|
}
|
|
31
42
|
|
|
@@ -116,7 +116,10 @@ export function createTokenHandler(authConfig: Auth.Config) {
|
|
|
116
116
|
|
|
117
117
|
if (impersonationRequired(authConfig.environment)) {
|
|
118
118
|
return Response.json(
|
|
119
|
-
{
|
|
119
|
+
{
|
|
120
|
+
error: IMPERSONATION_REQUIRED_ERROR,
|
|
121
|
+
platformUrl: process.env.NEXT_PUBLIC_PLATFORM_BASE_URL,
|
|
122
|
+
},
|
|
120
123
|
{ status: 403 }
|
|
121
124
|
);
|
|
122
125
|
}
|
|
@@ -1,51 +0,0 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
import { useEffect, type ReactNode } from "react";
|
|
4
|
-
import { usePathname } from "next/navigation";
|
|
5
|
-
import { PageLoader } from "@snokam/ui";
|
|
6
|
-
import { useOptionalConfig } from "@snokam/tenant";
|
|
7
|
-
import { useSession } from "../session/useSession";
|
|
8
|
-
import {
|
|
9
|
-
impersonationPickerUrl,
|
|
10
|
-
impersonationRequired,
|
|
11
|
-
isExemptRoute,
|
|
12
|
-
sendsToPicker,
|
|
13
|
-
withholdsApp,
|
|
14
|
-
} from "./required";
|
|
15
|
-
|
|
16
|
-
interface Props {
|
|
17
|
-
sessionActive: boolean;
|
|
18
|
-
cookieRead: boolean;
|
|
19
|
-
children: ReactNode;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export function RequiredImpersonationGate({
|
|
23
|
-
sessionActive,
|
|
24
|
-
cookieRead,
|
|
25
|
-
children,
|
|
26
|
-
}: Props) {
|
|
27
|
-
const config = useOptionalConfig();
|
|
28
|
-
const { status, data: session } = useSession();
|
|
29
|
-
const pathname = usePathname();
|
|
30
|
-
const platformUrl = config?.NEXT_PUBLIC_PLATFORM_BASE_URL;
|
|
31
|
-
|
|
32
|
-
const state = {
|
|
33
|
-
required: impersonationRequired(config?.NEXT_PUBLIC_ENVIRONMENT),
|
|
34
|
-
authenticated:
|
|
35
|
-
status === "authenticated" || (status === "loading" && !!session),
|
|
36
|
-
sessionActive,
|
|
37
|
-
cookieRead,
|
|
38
|
-
exemptRoute: isExemptRoute(pathname),
|
|
39
|
-
};
|
|
40
|
-
const redirect = sendsToPicker(state);
|
|
41
|
-
|
|
42
|
-
useEffect(() => {
|
|
43
|
-
if (!redirect || !platformUrl) return;
|
|
44
|
-
window.location.assign(
|
|
45
|
-
impersonationPickerUrl(platformUrl, window.location.href)
|
|
46
|
-
);
|
|
47
|
-
}, [redirect, platformUrl]);
|
|
48
|
-
|
|
49
|
-
if (withholdsApp(state)) return <PageLoader />;
|
|
50
|
-
return <>{children}</>;
|
|
51
|
-
}
|