@snokam/auth 3.7.15 → 3.9.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 +20 -0
- package/package.json +6 -6
- package/src/app-router/index.tsx +1 -1
- package/src/app-sign-in/handlers.ts +19 -12
- package/src/config.ts +0 -1
- package/src/handoff/business.ts +26 -0
- package/src/handoff/handlers.ts +47 -6
- package/src/handoff/seal.test.ts +25 -0
- package/src/handoff/seal.ts +14 -0
- package/src/impersonation/claims.ts +2 -0
- package/src/impersonation/personas.ts +24 -16
- package/src/impersonation/required.test.ts +44 -8
- package/src/impersonation/required.ts +27 -5
- package/src/route-handler/token.ts +9 -13
- package/src/types.ts +0 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,26 @@
|
|
|
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.9.0](https://github.com/snokam/components/compare/@snokam/auth@3.7.14...@snokam/auth@3.9.0) (2026-10-14)
|
|
7
|
+
|
|
8
|
+
### Bug Fixes
|
|
9
|
+
|
|
10
|
+
- **auth:** verify a password once when TOTP enrolment is pending, and say when sign-in is slow ([#2893](https://github.com/snokam/components/issues/2893)) ([d3e5c79](https://github.com/snokam/components/commit/d3e5c79c4c22db49389cddd07e1331dc6d25c381))
|
|
11
|
+
|
|
12
|
+
### Features
|
|
13
|
+
|
|
14
|
+
- persona mode per business, prs claim, Vis som gate, handoff sealing and CI persona (test identity P1) ([#2894](https://github.com/snokam/components/issues/2894)) ([8ab98a8](https://github.com/snokam/components/commit/8ab98a846ed6a5f962eadca6723c923a55395ce1)), closes [#2890](https://github.com/snokam/components/issues/2890)
|
|
15
|
+
|
|
16
|
+
# [3.8.0](https://github.com/snokam/components/compare/@snokam/auth@3.7.14...@snokam/auth@3.8.0) (2026-10-14)
|
|
17
|
+
|
|
18
|
+
### Bug Fixes
|
|
19
|
+
|
|
20
|
+
- **auth:** verify a password once when TOTP enrolment is pending, and say when sign-in is slow ([#2893](https://github.com/snokam/components/issues/2893)) ([d3e5c79](https://github.com/snokam/components/commit/d3e5c79c4c22db49389cddd07e1331dc6d25c381))
|
|
21
|
+
|
|
22
|
+
### Features
|
|
23
|
+
|
|
24
|
+
- persona mode per business, prs claim, Vis som gate, handoff sealing and CI persona (test identity P1) ([#2894](https://github.com/snokam/components/issues/2894)) ([8ab98a8](https://github.com/snokam/components/commit/8ab98a846ed6a5f962eadca6723c923a55395ce1)), closes [#2890](https://github.com/snokam/components/issues/2890)
|
|
25
|
+
|
|
6
26
|
## [3.7.15](https://github.com/snokam/components/compare/@snokam/auth@3.7.14...@snokam/auth@3.7.15) (2026-10-14)
|
|
7
27
|
|
|
8
28
|
### Bug Fixes
|
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.9.0",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"types": "src/index.ts",
|
|
7
7
|
"files": [
|
|
@@ -26,12 +26,12 @@
|
|
|
26
26
|
"@snokam/employees-client": "^1.3.0",
|
|
27
27
|
"next-auth": "4.24.15",
|
|
28
28
|
"path-to-regexp": "^8.4.2",
|
|
29
|
-
"@snokam/platform-client": "3.8.0",
|
|
30
|
-
"@snokam/react-query": "1.1.0",
|
|
31
|
-
"@snokam/sanity-page-content": "4.11.1",
|
|
32
29
|
"@snokam/identity": "1.1.0",
|
|
33
|
-
"@snokam/
|
|
34
|
-
"@snokam/
|
|
30
|
+
"@snokam/platform-client": "3.10.0",
|
|
31
|
+
"@snokam/react-query": "1.1.0",
|
|
32
|
+
"@snokam/sanity-page-content": "4.13.0",
|
|
33
|
+
"@snokam/tenant": "2.1.19",
|
|
34
|
+
"@snokam/ui": "4.2.0"
|
|
35
35
|
},
|
|
36
36
|
"peerDependencies": {
|
|
37
37
|
"next": "16.3.8",
|
package/src/app-router/index.tsx
CHANGED
|
@@ -33,7 +33,7 @@ export {
|
|
|
33
33
|
useImpersonation,
|
|
34
34
|
} from "../impersonation/context";
|
|
35
35
|
export type { ImpersonationActive } from "../impersonation/cookies";
|
|
36
|
-
export {
|
|
36
|
+
export { FOREIGN_BUSINESS_ERROR } from "../impersonation/required";
|
|
37
37
|
export { isAllowedRedirect } from "../impersonation/redirects";
|
|
38
38
|
export { signIn, SessionProvider } from "next-auth/react";
|
|
39
39
|
export {
|
|
@@ -9,8 +9,9 @@ import { resolveAccessToken } from "../route-handler/token";
|
|
|
9
9
|
import { readImpersonationSession } from "../impersonation/cookies";
|
|
10
10
|
import { tokenClaims } from "../impersonation/claims";
|
|
11
11
|
import {
|
|
12
|
+
chooseToken,
|
|
12
13
|
impersonationPickerUrl,
|
|
13
|
-
|
|
14
|
+
PERSONA_REQUIRED,
|
|
14
15
|
} from "../impersonation/required";
|
|
15
16
|
import type { Auth } from "../types";
|
|
16
17
|
import {
|
|
@@ -57,6 +58,8 @@ interface Signer {
|
|
|
57
58
|
accessToken: string;
|
|
58
59
|
}
|
|
59
60
|
|
|
61
|
+
type SignerResult = Signer | typeof PERSONA_REQUIRED | null;
|
|
62
|
+
|
|
60
63
|
function sameEmail(a: string | undefined, b: string): boolean {
|
|
61
64
|
return !!a && a.toLowerCase() === b.toLowerCase();
|
|
62
65
|
}
|
|
@@ -84,11 +87,12 @@ async function signerFor(
|
|
|
84
87
|
authConfig: Auth.Config,
|
|
85
88
|
request: Request,
|
|
86
89
|
email: string
|
|
87
|
-
): Promise<
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
90
|
+
): Promise<SignerResult> {
|
|
91
|
+
const persona = personaSigner(request, email);
|
|
92
|
+
if (persona) return persona;
|
|
93
|
+
|
|
94
|
+
const own = await ownSigner(authConfig, request, email);
|
|
95
|
+
return chooseToken(null, own, own ? tokenClaims(own.accessToken) : {});
|
|
92
96
|
}
|
|
93
97
|
|
|
94
98
|
function pickerRedirect(request: Request): Response {
|
|
@@ -162,11 +166,8 @@ export function createAppSignInAuthorizeHandler(authConfig: Auth.Config) {
|
|
|
162
166
|
if (!signedIn) return loginRedirect(request, params.resource);
|
|
163
167
|
|
|
164
168
|
const signer = await signerFor(authConfig, request, signedIn);
|
|
165
|
-
if (
|
|
166
|
-
|
|
167
|
-
? pickerRedirect(request)
|
|
168
|
-
: loginRedirect(request, params.resource);
|
|
169
|
-
}
|
|
169
|
+
if (signer === PERSONA_REQUIRED) return pickerRedirect(request);
|
|
170
|
+
if (!signer) return loginRedirect(request, params.resource);
|
|
170
171
|
|
|
171
172
|
const preview = await previewAuthorization(params, signer.accessToken);
|
|
172
173
|
if ("error" in preview) {
|
|
@@ -218,7 +219,13 @@ export function createAppSignInApproveHandler(authConfig: Auth.Config) {
|
|
|
218
219
|
if (!signedIn) return invalidRequest();
|
|
219
220
|
|
|
220
221
|
const signer = await signerFor(authConfig, request, signedIn);
|
|
221
|
-
if (
|
|
222
|
+
if (
|
|
223
|
+
!signer ||
|
|
224
|
+
signer === PERSONA_REQUIRED ||
|
|
225
|
+
signer.email !== consent.email
|
|
226
|
+
) {
|
|
227
|
+
return invalidRequest();
|
|
228
|
+
}
|
|
222
229
|
|
|
223
230
|
if (form.get("decision") !== "approve") {
|
|
224
231
|
return clientRedirect(request, consent, { error: ACCESS_DENIED });
|
package/src/config.ts
CHANGED
|
@@ -5,5 +5,4 @@ export const getAuthConfig = (): Auth.Config => ({
|
|
|
5
5
|
tenantId: process.env.NEXT_PUBLIC_AZURE_AD_TENANT_ID ?? "",
|
|
6
6
|
clientSecret: process.env.AZURE_AD_SECRET ?? "",
|
|
7
7
|
nextAuthSecret: process.env.NEXTAUTH_SECRET ?? "",
|
|
8
|
-
environment: process.env.NEXT_PUBLIC_ENVIRONMENT,
|
|
9
8
|
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { isLocalHost, resolveTenantByHostStrict } from "@snokam/tenant";
|
|
2
|
+
import { getManagedBearer } from "@snokam/identity";
|
|
3
|
+
import { type HostOwner, PLATFORM_HOST, UNKNOWN_HOST } from "./seal";
|
|
4
|
+
|
|
5
|
+
function hostOf(url: string | undefined): string | null {
|
|
6
|
+
return url && URL.canParse(url) ? new URL(url).hostname.toLowerCase() : null;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function isPlatformHost(host: string): boolean {
|
|
10
|
+
return [
|
|
11
|
+
process.env.NEXT_PUBLIC_PLATFORM_BASE_URL,
|
|
12
|
+
process.env.NEXT_PUBLIC_CENTRAL_AUTH_URL,
|
|
13
|
+
].some((url) => hostOf(url) === host);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function hostOwner(url: string): Promise<HostOwner> {
|
|
17
|
+
const host = new URL(url).hostname.toLowerCase();
|
|
18
|
+
if (isLocalHost(host) || isPlatformHost(host)) return PLATFORM_HOST;
|
|
19
|
+
|
|
20
|
+
const tenant = await resolveTenantByHostStrict({
|
|
21
|
+
host,
|
|
22
|
+
platformApiBase: process.env.NEXT_PUBLIC_PLATFORM_API_BASE_URL ?? "",
|
|
23
|
+
authToken: await getManagedBearer(),
|
|
24
|
+
});
|
|
25
|
+
return tenant?.businessId ? { businessId: tenant.businessId } : UNKNOWN_HOST;
|
|
26
|
+
}
|
package/src/handoff/handlers.ts
CHANGED
|
@@ -7,7 +7,13 @@ import {
|
|
|
7
7
|
} from "../session";
|
|
8
8
|
import { sealHandoff } from "./ticket";
|
|
9
9
|
import { isAllowedReturnTo } from "./return-to";
|
|
10
|
+
import { hostOwner } from "./business";
|
|
11
|
+
import { type HostOwner, maySealFor, PLATFORM_HOST } from "./seal";
|
|
10
12
|
import { revokePasswordToken } from "../password/api";
|
|
13
|
+
import { resolveAccessToken } from "../route-handler/token";
|
|
14
|
+
import { tokenClaims } from "../impersonation/claims";
|
|
15
|
+
import { impersonationPickerUrl } from "../impersonation/required";
|
|
16
|
+
import type { Auth } from "../types";
|
|
11
17
|
|
|
12
18
|
interface HandoffConfig {
|
|
13
19
|
nextAuthSecret: string;
|
|
@@ -75,6 +81,41 @@ function completeRedirect(
|
|
|
75
81
|
return Response.redirect(complete.toString(), 302);
|
|
76
82
|
}
|
|
77
83
|
|
|
84
|
+
async function callerBusiness(
|
|
85
|
+
authConfig: Auth.Config,
|
|
86
|
+
request: Request
|
|
87
|
+
): Promise<string | undefined | Response> {
|
|
88
|
+
const caller = await resolveAccessToken(authConfig, request);
|
|
89
|
+
return "error" in caller ? caller.error : tokenClaims(caller.accessToken).biz;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function handOff(
|
|
93
|
+
request: Request,
|
|
94
|
+
authConfig: Auth.Config,
|
|
95
|
+
token: HandoffToken,
|
|
96
|
+
returnTo: string
|
|
97
|
+
): Promise<Response> {
|
|
98
|
+
let owner: HostOwner;
|
|
99
|
+
try {
|
|
100
|
+
owner = await hostOwner(returnTo);
|
|
101
|
+
} catch {
|
|
102
|
+
return new Response("Tenant lookup unavailable", { status: 503 });
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (owner !== PLATFORM_HOST) {
|
|
106
|
+
const business = await callerBusiness(authConfig, request);
|
|
107
|
+
if (business instanceof Response) return business;
|
|
108
|
+
if (!maySealFor(owner, business)) {
|
|
109
|
+
return Response.redirect(
|
|
110
|
+
impersonationPickerUrl(requestOrigin(request), returnTo),
|
|
111
|
+
302
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return completeRedirect(token, returnTo, authConfig.nextAuthSecret);
|
|
117
|
+
}
|
|
118
|
+
|
|
78
119
|
function expireCookie(name: string, domain?: string, secure?: boolean): string {
|
|
79
120
|
return [
|
|
80
121
|
`${name}=`,
|
|
@@ -109,7 +150,7 @@ export function createHandoffLogoutHandler(config?: HandoffConfig) {
|
|
|
109
150
|
};
|
|
110
151
|
}
|
|
111
152
|
|
|
112
|
-
export function createHandoffStartHandler(
|
|
153
|
+
export function createHandoffStartHandler(authConfig: Auth.Config) {
|
|
113
154
|
return async function GET(request: Request) {
|
|
114
155
|
const returnTo = await validReturnTo(request);
|
|
115
156
|
if (!returnTo) {
|
|
@@ -117,9 +158,9 @@ export function createHandoffStartHandler(config: HandoffConfig) {
|
|
|
117
158
|
}
|
|
118
159
|
|
|
119
160
|
const prompt = new URL(request.url).searchParams.get("prompt");
|
|
120
|
-
const token = await centralToken(request,
|
|
161
|
+
const token = await centralToken(request, authConfig.nextAuthSecret);
|
|
121
162
|
if (token?.refreshToken && !prompt) {
|
|
122
|
-
return
|
|
163
|
+
return handOff(request, authConfig, token, returnTo);
|
|
123
164
|
}
|
|
124
165
|
|
|
125
166
|
const origin = requestOrigin(request);
|
|
@@ -130,20 +171,20 @@ export function createHandoffStartHandler(config: HandoffConfig) {
|
|
|
130
171
|
};
|
|
131
172
|
}
|
|
132
173
|
|
|
133
|
-
export function createHandoffFinishHandler(
|
|
174
|
+
export function createHandoffFinishHandler(authConfig: Auth.Config) {
|
|
134
175
|
return async function GET(request: Request) {
|
|
135
176
|
const returnTo = await validReturnTo(request);
|
|
136
177
|
if (!returnTo) {
|
|
137
178
|
return new Response("Invalid returnTo", { status: 400 });
|
|
138
179
|
}
|
|
139
180
|
|
|
140
|
-
const token = await centralToken(request,
|
|
181
|
+
const token = await centralToken(request, authConfig.nextAuthSecret);
|
|
141
182
|
if (!token?.refreshToken) {
|
|
142
183
|
const origin = requestOrigin(request);
|
|
143
184
|
const start = `${origin}/handoff/start?returnTo=${encodeURIComponent(returnTo)}`;
|
|
144
185
|
return Response.redirect(start, 302);
|
|
145
186
|
}
|
|
146
187
|
|
|
147
|
-
return
|
|
188
|
+
return handOff(request, authConfig, token, returnTo);
|
|
148
189
|
};
|
|
149
190
|
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { maySealFor, PLATFORM_HOST, UNKNOWN_HOST } from "./seal.ts";
|
|
4
|
+
|
|
5
|
+
describe("handoff sealing", () => {
|
|
6
|
+
it("seals for hosts of the caller's own business", () => {
|
|
7
|
+
assert.equal(maySealFor({ businessId: "biz-own" }, "biz-own"), true);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("seals for the platform's own hosts and localhost", () => {
|
|
11
|
+
assert.equal(maySealFor(PLATFORM_HOST, "biz-own"), true);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("refuses hosts of another business, admins included", () => {
|
|
15
|
+
assert.equal(maySealFor({ businessId: "biz-other" }, "biz-own"), false);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("refuses business hosts when the caller has no business", () => {
|
|
19
|
+
assert.equal(maySealFor({ businessId: "biz-other" }, undefined), false);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("refuses a host that resolves to no business", () => {
|
|
23
|
+
assert.equal(maySealFor(UNKNOWN_HOST, "biz-own"), false);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export const PLATFORM_HOST = "platform";
|
|
2
|
+
export const UNKNOWN_HOST = "unknown";
|
|
3
|
+
|
|
4
|
+
export type HostOwner =
|
|
5
|
+
typeof PLATFORM_HOST | typeof UNKNOWN_HOST | { businessId: string };
|
|
6
|
+
|
|
7
|
+
export function maySealFor(
|
|
8
|
+
owner: HostOwner,
|
|
9
|
+
callerBusiness: string | undefined
|
|
10
|
+
): boolean {
|
|
11
|
+
if (owner === PLATFORM_HOST) return true;
|
|
12
|
+
if (owner === UNKNOWN_HOST) return false;
|
|
13
|
+
return owner.businessId === callerBusiness;
|
|
14
|
+
}
|
|
@@ -3,7 +3,9 @@ import { Auth } from "../types";
|
|
|
3
3
|
import { resolveAccessToken } from "../route-handler/token";
|
|
4
4
|
import { tokenClaims } from "./claims";
|
|
5
5
|
import { platformCall, platformClient, platformNotConfigured } from "./request";
|
|
6
|
-
import {
|
|
6
|
+
import { FOREIGN_BUSINESS_ERROR, personaRequired } from "./required";
|
|
7
|
+
import { hostOwner } from "../handoff/business";
|
|
8
|
+
import { maySealFor } from "../handoff/seal";
|
|
7
9
|
|
|
8
10
|
function persona(employee: ProtectedEmployee) {
|
|
9
11
|
return {
|
|
@@ -22,34 +24,40 @@ function persona(employee: ProtectedEmployee) {
|
|
|
22
24
|
};
|
|
23
25
|
}
|
|
24
26
|
|
|
27
|
+
async function isForeignReturn(
|
|
28
|
+
request: Request,
|
|
29
|
+
callerBusiness: string | undefined
|
|
30
|
+
): Promise<boolean> {
|
|
31
|
+
const returnTo = new URL(request.url).searchParams.get("returnTo");
|
|
32
|
+
if (!returnTo || !URL.canParse(returnTo)) return false;
|
|
33
|
+
return !maySealFor(await hostOwner(returnTo), callerBusiness);
|
|
34
|
+
}
|
|
35
|
+
|
|
25
36
|
export function createImpersonationPersonasHandler(authConfig: Auth.Config) {
|
|
26
37
|
return async function GET(request: Request) {
|
|
27
|
-
if (!impersonationRequired(authConfig.environment)) {
|
|
28
|
-
return new Response("Not found", { status: 404 });
|
|
29
|
-
}
|
|
30
|
-
|
|
31
38
|
const caller = await resolveAccessToken(authConfig, request);
|
|
32
39
|
if ("error" in caller) return caller.error;
|
|
33
40
|
|
|
41
|
+
const claims = tokenClaims(caller.accessToken);
|
|
42
|
+
if (await isForeignReturn(request, claims.biz)) {
|
|
43
|
+
return Response.json({ error: FOREIGN_BUSINESS_ERROR }, { status: 403 });
|
|
44
|
+
}
|
|
45
|
+
if (!personaRequired(claims)) {
|
|
46
|
+
return new Response("Not found", { status: 404 });
|
|
47
|
+
}
|
|
48
|
+
|
|
34
49
|
const client = platformClient(caller.accessToken);
|
|
35
50
|
if (!client) return platformNotConfigured();
|
|
36
51
|
|
|
37
|
-
const
|
|
38
|
-
|
|
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 })),
|
|
52
|
+
const [personas, apps] = await Promise.all([
|
|
53
|
+
platformCall(client.listTestPersonas()),
|
|
46
54
|
platformCall(client.getMeApps()),
|
|
47
55
|
]);
|
|
48
|
-
if ("error" in
|
|
56
|
+
if ("error" in personas) return personas.error;
|
|
49
57
|
if ("error" in apps) return apps.error;
|
|
50
58
|
|
|
51
59
|
return Response.json({
|
|
52
|
-
personas:
|
|
60
|
+
personas: personas.value.map(persona),
|
|
53
61
|
apps: apps.value,
|
|
54
62
|
});
|
|
55
63
|
};
|
|
@@ -1,22 +1,58 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { describe, it } from "node:test";
|
|
3
3
|
import {
|
|
4
|
+
chooseToken,
|
|
4
5
|
impersonationPickerUrl,
|
|
5
|
-
impersonationRequired,
|
|
6
6
|
isSignInPage,
|
|
7
|
+
PERSONA_REQUIRED,
|
|
8
|
+
personaRequired,
|
|
7
9
|
renewDelayMs,
|
|
8
10
|
} from "./required.ts";
|
|
9
11
|
|
|
10
|
-
describe("
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
describe("token choice for the token route and MCP sign-in", () => {
|
|
13
|
+
const persona = { email: "persona@test.snokam.no", accessToken: "imp" };
|
|
14
|
+
const snokamSso = { email: "dev@snokam.no", accessToken: "own" };
|
|
15
|
+
const customerOwner = { email: "owner@acme.no", accessToken: "own" };
|
|
16
|
+
|
|
17
|
+
it("sends a persona-mode user without a persona to the picker", () => {
|
|
18
|
+
assert.equal(chooseToken(null, snokamSso, { prs: "1" }), PERSONA_REQUIRED);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("signs as the chosen persona once a Vis som session is active", () => {
|
|
22
|
+
assert.equal(chooseToken(persona, snokamSso, { prs: "1" }), persona);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("signs a customer as themselves", () => {
|
|
26
|
+
assert.equal(chooseToken(null, customerOwner, {}), customerOwner);
|
|
15
27
|
});
|
|
16
28
|
|
|
17
|
-
it("is
|
|
18
|
-
assert.equal(
|
|
29
|
+
it("has nothing to sign with when there is no session", () => {
|
|
30
|
+
assert.equal(chooseToken(null, null, {}), null);
|
|
19
31
|
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe("persona required", () => {
|
|
35
|
+
const cases = [
|
|
36
|
+
{ prs: false, imp: false, act: false, required: false },
|
|
37
|
+
{ prs: false, imp: true, act: true, required: false },
|
|
38
|
+
{ prs: false, imp: false, act: true, required: false },
|
|
39
|
+
{ prs: false, imp: true, act: false, required: false },
|
|
40
|
+
{ prs: true, imp: false, act: false, required: true },
|
|
41
|
+
{ prs: true, imp: true, act: true, required: false },
|
|
42
|
+
{ prs: true, imp: false, act: true, required: false },
|
|
43
|
+
{ prs: true, imp: true, act: false, required: false },
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
for (const { prs, imp, act, required } of cases) {
|
|
47
|
+
it(`is ${required} for prs=${prs} imp=${imp} act=${act}`, () => {
|
|
48
|
+
const claims = {
|
|
49
|
+
...(prs ? { prs: "1" } : {}),
|
|
50
|
+
...(imp ? { imp: "true" } : {}),
|
|
51
|
+
...(act ? { act: "dev@snokam.no" } : {}),
|
|
52
|
+
};
|
|
53
|
+
assert.equal(personaRequired(claims), required);
|
|
54
|
+
});
|
|
55
|
+
}
|
|
20
56
|
|
|
21
57
|
it("points the picker back at the page the person asked for", () => {
|
|
22
58
|
const url = new URL(
|
|
@@ -1,15 +1,37 @@
|
|
|
1
|
+
import type { TokenClaims } from "./claims";
|
|
2
|
+
|
|
1
3
|
export const IMPERSONATION_REQUIRED_ERROR = "impersonation_required";
|
|
2
4
|
|
|
3
|
-
const
|
|
5
|
+
export const FOREIGN_BUSINESS_ERROR = "foreign_business";
|
|
4
6
|
|
|
5
7
|
export const IMPERSONATION_PICKER_PATH = "/velg-bruker";
|
|
6
8
|
|
|
7
9
|
const RENEW_MARGIN_SECONDS = 120;
|
|
8
10
|
|
|
9
|
-
export
|
|
10
|
-
|
|
11
|
-
): boolean {
|
|
12
|
-
return
|
|
11
|
+
export const PERSONA_REQUIRED = "persona_required";
|
|
12
|
+
|
|
13
|
+
export function personaRequired(ownClaims: TokenClaims): boolean {
|
|
14
|
+
return !!ownClaims.prs && !ownClaims.imp && !ownClaims.act;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function chooseToken<T>(
|
|
18
|
+
viewAs: T | null,
|
|
19
|
+
own: T | null,
|
|
20
|
+
ownClaims: TokenClaims
|
|
21
|
+
): T | typeof PERSONA_REQUIRED | null {
|
|
22
|
+
if (viewAs) return viewAs;
|
|
23
|
+
if (!own) return null;
|
|
24
|
+
return personaRequired(ownClaims) ? PERSONA_REQUIRED : own;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function impersonationRequiredResponse(): Response {
|
|
28
|
+
return Response.json(
|
|
29
|
+
{
|
|
30
|
+
error: IMPERSONATION_REQUIRED_ERROR,
|
|
31
|
+
platformUrl: process.env.NEXT_PUBLIC_PLATFORM_BASE_URL,
|
|
32
|
+
},
|
|
33
|
+
{ status: 403 }
|
|
34
|
+
);
|
|
13
35
|
}
|
|
14
36
|
|
|
15
37
|
export function isSignInPage(pathname: string): boolean {
|
|
@@ -8,9 +8,11 @@ import { getAuthUrl, getSessionCookieSettings } from "../session";
|
|
|
8
8
|
import { exchangePasswordToken } from "../password/api";
|
|
9
9
|
import { readImpersonationSession } from "../impersonation/cookies";
|
|
10
10
|
import { exchangeSsoToken } from "../sso/exchange";
|
|
11
|
+
import { tokenClaims } from "../impersonation/claims";
|
|
11
12
|
import {
|
|
12
|
-
|
|
13
|
-
|
|
13
|
+
chooseToken,
|
|
14
|
+
impersonationRequiredResponse,
|
|
15
|
+
PERSONA_REQUIRED,
|
|
14
16
|
} from "../impersonation/required";
|
|
15
17
|
|
|
16
18
|
export type TokenResult =
|
|
@@ -114,20 +116,14 @@ export function createTokenHandler(authConfig: Auth.Config) {
|
|
|
114
116
|
});
|
|
115
117
|
}
|
|
116
118
|
|
|
117
|
-
if (impersonationRequired(authConfig.environment)) {
|
|
118
|
-
return Response.json(
|
|
119
|
-
{
|
|
120
|
-
error: IMPERSONATION_REQUIRED_ERROR,
|
|
121
|
-
platformUrl: process.env.NEXT_PUBLIC_PLATFORM_BASE_URL,
|
|
122
|
-
},
|
|
123
|
-
{ status: 403 }
|
|
124
|
-
);
|
|
125
|
-
}
|
|
126
|
-
|
|
127
119
|
const result = await resolveAccessToken(authConfig, request);
|
|
128
120
|
if ("error" in result) {
|
|
129
121
|
return result.error;
|
|
130
122
|
}
|
|
131
|
-
|
|
123
|
+
const chosen = chooseToken(null, result, tokenClaims(result.accessToken));
|
|
124
|
+
if (chosen === PERSONA_REQUIRED) {
|
|
125
|
+
return impersonationRequiredResponse();
|
|
126
|
+
}
|
|
127
|
+
return Response.json(chosen);
|
|
132
128
|
};
|
|
133
129
|
}
|