@snokam/auth 3.4.0 → 3.6.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 +3 -3
- package/src/app-router/index.tsx +1 -0
- package/src/app-sign-in/handlers.ts +71 -14
- package/src/app-sign-in/platform.ts +33 -22
- package/src/impersonation/RequiredImpersonationGate.tsx +3 -7
- package/src/impersonation/claims.ts +16 -0
- package/src/impersonation/context.tsx +1 -10
- package/src/impersonation/handlers.ts +35 -37
- package/src/impersonation/personas.ts +56 -0
- package/src/impersonation/renew.ts +30 -50
- package/src/impersonation/request.ts +38 -0
- package/src/impersonation/required.test.ts +16 -8
- package/src/impersonation/required.ts +17 -7
- package/src/impersonation/routes.ts +3 -0
- package/src/route-handler/index.ts +0 -1
- package/src/route-handler/token.ts +2 -12
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.6.0](https://github.com/snokam/components/compare/@snokam/auth@3.5.0...@snokam/auth@3.6.0) (2026-10-06)
|
|
7
|
+
|
|
8
|
+
### Features
|
|
9
|
+
|
|
10
|
+
- **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))
|
|
11
|
+
|
|
12
|
+
# [3.5.0](https://github.com/snokam/components/compare/@snokam/auth@3.4.0...@snokam/auth@3.5.0) (2026-10-06)
|
|
13
|
+
|
|
14
|
+
### Features
|
|
15
|
+
|
|
16
|
+
- **test:** pick a test user straight after sign-in, on snosky.no and for MCP sign-in too ([#2680](https://github.com/snokam/components/issues/2680)) ([eea727a](https://github.com/snokam/components/commit/eea727ae1a607139c7c30610109e877eaa20dae9))
|
|
17
|
+
|
|
6
18
|
# [3.4.0](https://github.com/snokam/components/compare/@snokam/auth@3.3.12...@snokam/auth@3.4.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.6.0",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"types": "src/index.ts",
|
|
7
7
|
"files": [
|
|
@@ -29,9 +29,9 @@
|
|
|
29
29
|
"@snokam/identity": "1.1.0",
|
|
30
30
|
"@snokam/platform-client": "3.6.0",
|
|
31
31
|
"@snokam/react-query": "1.1.0",
|
|
32
|
+
"@snokam/sanity-page-content": "4.4.2",
|
|
32
33
|
"@snokam/tenant": "2.1.13",
|
|
33
|
-
"@snokam/ui": "4.0.0"
|
|
34
|
-
"@snokam/sanity-page-content": "4.4.2"
|
|
34
|
+
"@snokam/ui": "4.0.0"
|
|
35
35
|
},
|
|
36
36
|
"peerDependencies": {
|
|
37
37
|
"next": "16.3.3",
|
package/src/app-router/index.tsx
CHANGED
|
@@ -33,6 +33,7 @@ export {
|
|
|
33
33
|
useImpersonation,
|
|
34
34
|
} from "../impersonation/context";
|
|
35
35
|
export type { ImpersonationActive } from "../impersonation/cookies";
|
|
36
|
+
export { impersonationRequired } from "../impersonation/required";
|
|
36
37
|
export { signIn, SessionProvider } from "next-auth/react";
|
|
37
38
|
export {
|
|
38
39
|
Login,
|
|
@@ -6,6 +6,12 @@ import {
|
|
|
6
6
|
requestOrigin,
|
|
7
7
|
} from "../session";
|
|
8
8
|
import { resolveAccessToken } from "../route-handler/token";
|
|
9
|
+
import { readImpersonationSession } from "../impersonation/cookies";
|
|
10
|
+
import { tokenClaims } from "../impersonation/claims";
|
|
11
|
+
import {
|
|
12
|
+
impersonationPickerUrl,
|
|
13
|
+
impersonationRequired,
|
|
14
|
+
} from "../impersonation/required";
|
|
9
15
|
import type { Auth } from "../types";
|
|
10
16
|
import {
|
|
11
17
|
type AuthorizationParams,
|
|
@@ -45,6 +51,52 @@ async function signedInEmail(
|
|
|
45
51
|
return token?.refreshToken && token.email ? String(token.email) : null;
|
|
46
52
|
}
|
|
47
53
|
|
|
54
|
+
interface Signer {
|
|
55
|
+
email: string;
|
|
56
|
+
accessToken: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
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 {
|
|
64
|
+
const session = readImpersonationSession(request);
|
|
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 };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function ownSigner(
|
|
74
|
+
authConfig: Auth.Config,
|
|
75
|
+
request: Request,
|
|
76
|
+
email: string
|
|
77
|
+
): Promise<Signer | null> {
|
|
78
|
+
const access = await resolveAccessToken(authConfig, request);
|
|
79
|
+
return "error" in access ? null : { email, accessToken: access.accessToken };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function signerFor(
|
|
83
|
+
authConfig: Auth.Config,
|
|
84
|
+
request: Request,
|
|
85
|
+
email: string
|
|
86
|
+
): Promise<Signer | null> {
|
|
87
|
+
if (impersonationRequired(authConfig.environment)) {
|
|
88
|
+
return Promise.resolve(personaSigner(request, email));
|
|
89
|
+
}
|
|
90
|
+
return ownSigner(authConfig, request, email);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function pickerRedirect(request: Request): Response {
|
|
94
|
+
const url = new URL(request.url);
|
|
95
|
+
const origin = requestOrigin(request);
|
|
96
|
+
const resume = `${origin}${url.pathname}${url.search}`;
|
|
97
|
+
return Response.redirect(impersonationPickerUrl(origin, resume), 302);
|
|
98
|
+
}
|
|
99
|
+
|
|
48
100
|
function loginRedirect(request: Request, resource: string, prompt?: string) {
|
|
49
101
|
const url = new URL(request.url);
|
|
50
102
|
const resume = new URLSearchParams(url.searchParams);
|
|
@@ -105,13 +157,17 @@ export function createAppSignInAuthorizeHandler(authConfig: Auth.Config) {
|
|
|
105
157
|
const params = readParams(query);
|
|
106
158
|
if (!params) return invalidRequest();
|
|
107
159
|
|
|
108
|
-
const
|
|
109
|
-
if (!
|
|
160
|
+
const signedIn = await signedInEmail(request, authConfig.nextAuthSecret);
|
|
161
|
+
if (!signedIn) return loginRedirect(request, params.resource);
|
|
110
162
|
|
|
111
|
-
const
|
|
112
|
-
if (
|
|
163
|
+
const signer = await signerFor(authConfig, request, signedIn);
|
|
164
|
+
if (!signer) {
|
|
165
|
+
return impersonationRequired(authConfig.environment)
|
|
166
|
+
? pickerRedirect(request)
|
|
167
|
+
: loginRedirect(request, params.resource);
|
|
168
|
+
}
|
|
113
169
|
|
|
114
|
-
const preview = await previewAuthorization(params,
|
|
170
|
+
const preview = await previewAuthorization(params, signer.accessToken);
|
|
115
171
|
if ("error" in preview) {
|
|
116
172
|
return preview.error === ACCESS_DENIED
|
|
117
173
|
? loginRedirect(request, params.resource, "login")
|
|
@@ -122,10 +178,11 @@ export function createAppSignInAuthorizeHandler(authConfig: Auth.Config) {
|
|
|
122
178
|
const ticket = sealConsent(
|
|
123
179
|
{
|
|
124
180
|
...params,
|
|
125
|
-
|
|
181
|
+
clientName: preview.value.clientName ?? "",
|
|
182
|
+
businessName: preview.value.businessName ?? "",
|
|
126
183
|
business: new URL(params.resource).hostname,
|
|
127
184
|
state: query.get("state") ?? "",
|
|
128
|
-
email,
|
|
185
|
+
email: signer.email,
|
|
129
186
|
nonce,
|
|
130
187
|
},
|
|
131
188
|
authConfig.nextAuthSecret
|
|
@@ -155,8 +212,11 @@ export function createAppSignInApproveHandler(authConfig: Auth.Config) {
|
|
|
155
212
|
return invalidRequest();
|
|
156
213
|
}
|
|
157
214
|
|
|
158
|
-
const
|
|
159
|
-
if (
|
|
215
|
+
const signedIn = await signedInEmail(request, authConfig.nextAuthSecret);
|
|
216
|
+
if (!signedIn) return invalidRequest();
|
|
217
|
+
|
|
218
|
+
const signer = await signerFor(authConfig, request, signedIn);
|
|
219
|
+
if (!signer || signer.email !== consent.email) return invalidRequest();
|
|
160
220
|
|
|
161
221
|
if (form.get("decision") !== "approve") {
|
|
162
222
|
return clientRedirect(request, consent.redirectUri, consent.state, {
|
|
@@ -164,17 +224,14 @@ export function createAppSignInApproveHandler(authConfig: Auth.Config) {
|
|
|
164
224
|
});
|
|
165
225
|
}
|
|
166
226
|
|
|
167
|
-
const
|
|
168
|
-
if ("error" in access) return invalidRequest();
|
|
169
|
-
|
|
170
|
-
const approved = await approveAuthorization(consent, access.accessToken);
|
|
227
|
+
const approved = await approveAuthorization(consent, signer.accessToken);
|
|
171
228
|
return clientRedirect(
|
|
172
229
|
request,
|
|
173
230
|
consent.redirectUri,
|
|
174
231
|
consent.state,
|
|
175
232
|
"error" in approved
|
|
176
233
|
? { error: approved.error }
|
|
177
|
-
: { code: approved.value.code }
|
|
234
|
+
: { code: approved.value.code ?? "" }
|
|
178
235
|
);
|
|
179
236
|
};
|
|
180
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
|
}
|
|
@@ -8,6 +8,7 @@ import { useSession } from "../session/useSession";
|
|
|
8
8
|
import {
|
|
9
9
|
impersonationPickerUrl,
|
|
10
10
|
impersonationRequired,
|
|
11
|
+
isExemptRoute,
|
|
11
12
|
sendsToPicker,
|
|
12
13
|
withholdsApp,
|
|
13
14
|
} from "./required";
|
|
@@ -15,14 +16,12 @@ import {
|
|
|
15
16
|
interface Props {
|
|
16
17
|
sessionActive: boolean;
|
|
17
18
|
cookieRead: boolean;
|
|
18
|
-
keepOwnIdentity: boolean;
|
|
19
19
|
children: ReactNode;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
export function RequiredImpersonationGate({
|
|
23
23
|
sessionActive,
|
|
24
24
|
cookieRead,
|
|
25
|
-
keepOwnIdentity,
|
|
26
25
|
children,
|
|
27
26
|
}: Props) {
|
|
28
27
|
const config = useOptionalConfig();
|
|
@@ -31,15 +30,12 @@ export function RequiredImpersonationGate({
|
|
|
31
30
|
const platformUrl = config?.NEXT_PUBLIC_PLATFORM_BASE_URL;
|
|
32
31
|
|
|
33
32
|
const state = {
|
|
34
|
-
required: impersonationRequired(
|
|
35
|
-
config?.NEXT_PUBLIC_ENVIRONMENT,
|
|
36
|
-
keepOwnIdentity
|
|
37
|
-
),
|
|
33
|
+
required: impersonationRequired(config?.NEXT_PUBLIC_ENVIRONMENT),
|
|
38
34
|
authenticated:
|
|
39
35
|
status === "authenticated" || (status === "loading" && !!session),
|
|
40
36
|
sessionActive,
|
|
41
37
|
cookieRead,
|
|
42
|
-
|
|
38
|
+
exemptRoute: isExemptRoute(pathname),
|
|
43
39
|
};
|
|
44
40
|
const redirect = sendsToPicker(state);
|
|
45
41
|
|
|
@@ -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
|
+
}
|
|
@@ -75,15 +75,7 @@ function readActiveCookie(): ImpersonationActive | null {
|
|
|
75
75
|
}
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
-
|
|
79
|
-
children: ReactNode;
|
|
80
|
-
keepOwnIdentity?: boolean;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
export function ImpersonationProvider({
|
|
84
|
-
children,
|
|
85
|
-
keepOwnIdentity = false,
|
|
86
|
-
}: ImpersonationProviderProps) {
|
|
78
|
+
export function ImpersonationProvider({ children }: { children: ReactNode }) {
|
|
87
79
|
const [active, setActive] = useState<ImpersonationActive | null>(null);
|
|
88
80
|
const [cookieRead, setCookieRead] = useState(false);
|
|
89
81
|
const [starting, setStarting] = useState(false);
|
|
@@ -170,7 +162,6 @@ export function ImpersonationProvider({
|
|
|
170
162
|
<RequiredImpersonationGate
|
|
171
163
|
sessionActive={!!active}
|
|
172
164
|
cookieRead={cookieRead}
|
|
173
|
-
keepOwnIdentity={keepOwnIdentity}
|
|
174
165
|
>
|
|
175
166
|
{children}
|
|
176
167
|
</RequiredImpersonationGate>
|
|
@@ -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 });
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { ProtectedEmployee } from "@snokam/platform-client";
|
|
2
|
+
import { Auth } from "../types";
|
|
3
|
+
import { resolveAccessToken } from "../route-handler/token";
|
|
4
|
+
import { tokenClaims } from "./claims";
|
|
5
|
+
import { platformCall, platformClient, platformNotConfigured } from "./request";
|
|
6
|
+
import { impersonationRequired } from "./required";
|
|
7
|
+
|
|
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
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function createImpersonationPersonasHandler(authConfig: Auth.Config) {
|
|
26
|
+
return async function GET(request: Request) {
|
|
27
|
+
if (!impersonationRequired(authConfig.environment)) {
|
|
28
|
+
return new Response("Not found", { status: 404 });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const caller = await resolveAccessToken(authConfig, request);
|
|
32
|
+
if ("error" in caller) return caller.error;
|
|
33
|
+
|
|
34
|
+
const client = platformClient(caller.accessToken);
|
|
35
|
+
if (!client) return platformNotConfigured();
|
|
36
|
+
|
|
37
|
+
const businessId = tokenClaims(caller.accessToken).biz;
|
|
38
|
+
if (!businessId) {
|
|
39
|
+
return new Response("The signed-in account has no business", {
|
|
40
|
+
status: 409,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
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;
|
|
50
|
+
|
|
51
|
+
return Response.json({
|
|
52
|
+
personas: employees.value.map(persona),
|
|
53
|
+
apps: apps.value,
|
|
54
|
+
});
|
|
55
|
+
};
|
|
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,6 +3,7 @@ import { describe, it } from "node:test";
|
|
|
3
3
|
import {
|
|
4
4
|
impersonationPickerUrl,
|
|
5
5
|
impersonationRequired,
|
|
6
|
+
isExemptRoute,
|
|
6
7
|
renewDelayMs,
|
|
7
8
|
sendsToPicker,
|
|
8
9
|
withholdsApp,
|
|
@@ -12,19 +13,18 @@ const signedInAlone = {
|
|
|
12
13
|
authenticated: true,
|
|
13
14
|
sessionActive: false,
|
|
14
15
|
cookieRead: true,
|
|
15
|
-
|
|
16
|
+
exemptRoute: false,
|
|
16
17
|
};
|
|
17
18
|
|
|
18
19
|
describe("impersonation required gate", () => {
|
|
19
20
|
it("is off in production and everywhere that is not the test environment", () => {
|
|
20
21
|
for (const environment of ["p", "test", "production", "", undefined]) {
|
|
21
|
-
assert.equal(impersonationRequired(environment
|
|
22
|
+
assert.equal(impersonationRequired(environment), false);
|
|
22
23
|
}
|
|
23
24
|
});
|
|
24
25
|
|
|
25
|
-
it("is on in test
|
|
26
|
-
assert.equal(impersonationRequired("t"
|
|
27
|
-
assert.equal(impersonationRequired("t", true), false);
|
|
26
|
+
it("is on in test", () => {
|
|
27
|
+
assert.equal(impersonationRequired("t"), true);
|
|
28
28
|
});
|
|
29
29
|
|
|
30
30
|
it("never withholds the app or redirects when not required", () => {
|
|
@@ -51,9 +51,9 @@ describe("impersonation required gate", () => {
|
|
|
51
51
|
assert.equal(sendsToPicker(state), false);
|
|
52
52
|
});
|
|
53
53
|
|
|
54
|
-
it("leaves sign-in routes and anonymous visitors alone", () => {
|
|
54
|
+
it("leaves sign-in routes, the picker and anonymous visitors alone", () => {
|
|
55
55
|
const base = { required: true, ...signedInAlone };
|
|
56
|
-
assert.equal(withholdsApp({ ...base,
|
|
56
|
+
assert.equal(withholdsApp({ ...base, exemptRoute: true }), false);
|
|
57
57
|
assert.equal(withholdsApp({ ...base, authenticated: false }), false);
|
|
58
58
|
});
|
|
59
59
|
|
|
@@ -64,13 +64,21 @@ describe("impersonation required gate", () => {
|
|
|
64
64
|
"https://snokam.test.snosky.no/minside/okonomi?x=1"
|
|
65
65
|
)
|
|
66
66
|
);
|
|
67
|
-
assert.equal(url.pathname, "/
|
|
67
|
+
assert.equal(url.pathname, "/velg-bruker");
|
|
68
68
|
assert.equal(
|
|
69
69
|
url.searchParams.get("returnTo"),
|
|
70
70
|
"https://snokam.test.snosky.no/minside/okonomi?x=1"
|
|
71
71
|
);
|
|
72
72
|
});
|
|
73
73
|
|
|
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
|
+
|
|
74
82
|
it("renews two minutes before the token expires, and at once when already late", () => {
|
|
75
83
|
assert.equal(renewDelayMs(1_000, 0), 880_000);
|
|
76
84
|
assert.equal(renewDelayMs(1_000, 999_000), 0);
|
|
@@ -2,13 +2,14 @@ export const IMPERSONATION_REQUIRED_ERROR = "impersonation_required";
|
|
|
2
2
|
|
|
3
3
|
const TEST_ENVIRONMENT = "t";
|
|
4
4
|
|
|
5
|
+
export const IMPERSONATION_PICKER_PATH = "/velg-bruker";
|
|
6
|
+
|
|
5
7
|
const RENEW_MARGIN_SECONDS = 120;
|
|
6
8
|
|
|
7
9
|
export function impersonationRequired(
|
|
8
|
-
environment: string | undefined
|
|
9
|
-
keepOwnIdentity: boolean
|
|
10
|
+
environment: string | undefined
|
|
10
11
|
): boolean {
|
|
11
|
-
return
|
|
12
|
+
return environment === TEST_ENVIRONMENT;
|
|
12
13
|
}
|
|
13
14
|
|
|
14
15
|
export interface ImpersonationGateState {
|
|
@@ -16,11 +17,20 @@ export interface ImpersonationGateState {
|
|
|
16
17
|
authenticated: boolean;
|
|
17
18
|
sessionActive: boolean;
|
|
18
19
|
cookieRead: boolean;
|
|
19
|
-
|
|
20
|
+
exemptRoute: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function gated({
|
|
24
|
+
required,
|
|
25
|
+
authenticated,
|
|
26
|
+
exemptRoute,
|
|
27
|
+
}: ImpersonationGateState) {
|
|
28
|
+
return required && authenticated && !exemptRoute;
|
|
20
29
|
}
|
|
21
30
|
|
|
22
|
-
function
|
|
23
|
-
|
|
31
|
+
export function isExemptRoute(pathname: string | null): boolean {
|
|
32
|
+
if (!pathname) return false;
|
|
33
|
+
return pathname.startsWith("/auth") || pathname === IMPERSONATION_PICKER_PATH;
|
|
24
34
|
}
|
|
25
35
|
|
|
26
36
|
export function withholdsApp(state: ImpersonationGateState): boolean {
|
|
@@ -35,7 +45,7 @@ export function impersonationPickerUrl(
|
|
|
35
45
|
platformBaseUrl: string,
|
|
36
46
|
returnTo: string
|
|
37
47
|
): string {
|
|
38
|
-
const picker = new URL(
|
|
48
|
+
const picker = new URL(IMPERSONATION_PICKER_PATH, platformBaseUrl);
|
|
39
49
|
picker.searchParams.set("returnTo", returnTo);
|
|
40
50
|
return picker.toString();
|
|
41
51
|
}
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
createImpersonationAdoptHandler,
|
|
6
6
|
} from "./handlers";
|
|
7
7
|
import { createImpersonationRenewHandler } from "./renew";
|
|
8
|
+
import { createImpersonationPersonasHandler } from "./personas";
|
|
8
9
|
|
|
9
10
|
type OpContext = { params: Promise<{ op: string }> };
|
|
10
11
|
|
|
@@ -13,11 +14,13 @@ export function createImpersonationRouteHandlers(authConfig: Auth.Config) {
|
|
|
13
14
|
const end = createImpersonationEndHandler(authConfig);
|
|
14
15
|
const adopt = createImpersonationAdoptHandler(authConfig);
|
|
15
16
|
const renew = createImpersonationRenewHandler(authConfig);
|
|
17
|
+
const personas = createImpersonationPersonasHandler(authConfig);
|
|
16
18
|
|
|
17
19
|
return {
|
|
18
20
|
async GET(request: Request, ctx: OpContext) {
|
|
19
21
|
const { op } = await ctx.params;
|
|
20
22
|
if (op === "adopt") return adopt(request);
|
|
23
|
+
if (op === "personas") return personas(request);
|
|
21
24
|
return new Response("Not found", { status: 404 });
|
|
22
25
|
},
|
|
23
26
|
async POST(request: Request, ctx: OpContext) {
|
|
@@ -38,7 +38,6 @@ export function createAuthHandler(options: AuthOptionsFor) {
|
|
|
38
38
|
|
|
39
39
|
export { getNextAuthOptions } from "../options";
|
|
40
40
|
export { createTokenHandler } from "./token";
|
|
41
|
-
export type { TokenHandlerOptions } from "./token";
|
|
42
41
|
export {
|
|
43
42
|
createImpersonationStartHandler,
|
|
44
43
|
createImpersonationEndHandler,
|
|
@@ -104,18 +104,8 @@ export async function resolveAccessToken(
|
|
|
104
104
|
}
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
-
export
|
|
108
|
-
keepOwnIdentity?: boolean;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
export function createTokenHandler(
|
|
112
|
-
authConfig: Auth.Config,
|
|
113
|
-
{ keepOwnIdentity = false }: TokenHandlerOptions = {}
|
|
114
|
-
) {
|
|
107
|
+
export function createTokenHandler(authConfig: Auth.Config) {
|
|
115
108
|
return async function POST(request: Request) {
|
|
116
|
-
// While a view-as-employee session is active, every product API call transparently receives the
|
|
117
|
-
// short-lived impersonation token instead of the admin's own — the single swap point the design
|
|
118
|
-
// relies on. A clean exit (or expiry) discards the cookie and this falls back to the admin token.
|
|
119
109
|
const impersonation = readImpersonationSession(request);
|
|
120
110
|
if (impersonation && impersonation.expiresOn * 1000 > Date.now()) {
|
|
121
111
|
return Response.json({
|
|
@@ -124,7 +114,7 @@ export function createTokenHandler(
|
|
|
124
114
|
});
|
|
125
115
|
}
|
|
126
116
|
|
|
127
|
-
if (impersonationRequired(authConfig.environment
|
|
117
|
+
if (impersonationRequired(authConfig.environment)) {
|
|
128
118
|
return Response.json(
|
|
129
119
|
{ error: IMPERSONATION_REQUIRED_ERROR },
|
|
130
120
|
{ status: 403 }
|