@snokam/auth 5.3.0 → 5.3.2
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-router/components/Login.tsx +6 -13
- package/src/impersonation/claims.test.ts +7 -14
- package/src/impersonation/claims.ts +0 -6
- package/src/impersonation/context.tsx +13 -5
- package/src/impersonation/cookies.ts +4 -3
- package/src/impersonation/exit.test.ts +106 -0
- package/src/impersonation/exit.ts +36 -0
- package/src/impersonation/handlers.ts +28 -5
- package/src/impersonation/picker.ts +7 -1
- package/src/impersonation/routes.ts +3 -0
- package/src/impersonation/ticket.ts +1 -0
- package/src/session/callback-url.test.ts +22 -0
- package/src/session/callback-url.ts +8 -0
- package/src/session/index.ts +1 -1
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
|
+
## [5.3.2](https://github.com/snokam/components/compare/@snokam/auth@5.3.1...@snokam/auth@5.3.2) (2026-10-14)
|
|
7
|
+
|
|
8
|
+
### Bug Fixes
|
|
9
|
+
|
|
10
|
+
- **auth:** allow-list and chain every Vis som session host on exit ([#2951](https://github.com/snokam/components/issues/2951)) ([ee10692](https://github.com/snokam/components/commit/ee1069225349380b3a7d7087ed6007a3a4fff28e))
|
|
11
|
+
|
|
12
|
+
## [5.3.1](https://github.com/snokam/components/compare/@snokam/auth@5.3.0...@snokam/auth@5.3.1) (2026-10-14)
|
|
13
|
+
|
|
14
|
+
### Bug Fixes
|
|
15
|
+
|
|
16
|
+
- Vis som follow-ups to [#2944](https://github.com/snokam/components/issues/2944) (policy split, banner once, exit everywhere) ([#2950](https://github.com/snokam/components/issues/2950)) ([a5a6037](https://github.com/snokam/components/commit/a5a60377afdbdf9abfd5074f7cbbdd604e4a8def)), closes [#2948](https://github.com/snokam/components/issues/2948)
|
|
17
|
+
|
|
6
18
|
# [5.3.0](https://github.com/snokam/components/compare/@snokam/auth@5.2.2...@snokam/auth@5.3.0) (2026-10-14)
|
|
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": "5.3.
|
|
4
|
+
"version": "5.3.2",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"types": "src/index.ts",
|
|
7
7
|
"files": [
|
|
@@ -27,10 +27,10 @@
|
|
|
27
27
|
"next-auth": "4.24.15",
|
|
28
28
|
"path-to-regexp": "^8.4.2",
|
|
29
29
|
"@snokam/identity": "1.1.0",
|
|
30
|
-
"@snokam/platform-client": "5.3.
|
|
30
|
+
"@snokam/platform-client": "5.3.1",
|
|
31
31
|
"@snokam/react-query": "1.1.0",
|
|
32
|
-
"@snokam/sanity-page-content": "6.5.
|
|
33
|
-
"@snokam/tenant": "4.3.
|
|
32
|
+
"@snokam/sanity-page-content": "6.5.2",
|
|
33
|
+
"@snokam/tenant": "4.3.1",
|
|
34
34
|
"@snokam/ui": "6.1.0"
|
|
35
35
|
},
|
|
36
36
|
"peerDependencies": {
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { useEffect, useState, useRef } from "react";
|
|
4
|
-
import {
|
|
4
|
+
import { useSearchParams } from "next/navigation";
|
|
5
5
|
import { useAuth } from "../../hooks";
|
|
6
6
|
import { signIn } from "next-auth/react";
|
|
7
7
|
import { PageLoader } from "@snokam/ui";
|
|
8
8
|
import { useOptionalConfig } from "@snokam/tenant";
|
|
9
|
-
import { safeCallbackTarget } from "../../session";
|
|
9
|
+
import { callbackDestination, safeCallbackTarget } from "../../session";
|
|
10
10
|
import Error from "../../components/Error";
|
|
11
11
|
import Consent from "../../components/Consent";
|
|
12
12
|
import LoggedOut from "../../components/LoggedOut";
|
|
@@ -18,7 +18,6 @@ export const Login = () => {
|
|
|
18
18
|
const [errorKind, setErrorKind] = useState<string | null>(null);
|
|
19
19
|
const [loggedOut, setLoggedOut] = useState(false);
|
|
20
20
|
const hasSignedIn = useRef(false);
|
|
21
|
-
const router = useRouter();
|
|
22
21
|
const searchParams = useSearchParams();
|
|
23
22
|
const config = useOptionalConfig();
|
|
24
23
|
const central = config?.NEXT_PUBLIC_CENTRAL_AUTH_URL;
|
|
@@ -76,21 +75,15 @@ export const Login = () => {
|
|
|
76
75
|
}
|
|
77
76
|
|
|
78
77
|
if (isAuthenticated) {
|
|
79
|
-
|
|
78
|
+
window.location.replace(
|
|
79
|
+
callbackDestination(callbackUrl, window.location.origin)
|
|
80
|
+
);
|
|
80
81
|
} else if (!loggedOut) {
|
|
81
82
|
hasSignedIn.current = true;
|
|
82
83
|
startSignIn();
|
|
83
84
|
}
|
|
84
85
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
85
|
-
}, [
|
|
86
|
-
inProgress,
|
|
87
|
-
isAuthenticated,
|
|
88
|
-
callbackUrl,
|
|
89
|
-
router,
|
|
90
|
-
isError,
|
|
91
|
-
central,
|
|
92
|
-
loggedOut,
|
|
93
|
-
]);
|
|
86
|
+
}, [inProgress, isAuthenticated, callbackUrl, isError, central, loggedOut]);
|
|
94
87
|
|
|
95
88
|
if (loggedOut && !isAuthenticated && !isError) {
|
|
96
89
|
return (
|
|
@@ -1,29 +1,22 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { describe, it } from "node:test";
|
|
3
|
-
import {
|
|
3
|
+
import { tokenClaims } from "./claims.ts";
|
|
4
4
|
|
|
5
5
|
function token(payload: object): string {
|
|
6
6
|
const encoded = Buffer.from(JSON.stringify(payload)).toString("base64url");
|
|
7
7
|
return `header.${encoded}.signature`;
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
-
describe("
|
|
11
|
-
it("reads the actor and
|
|
10
|
+
describe("claims of a view-as token", () => {
|
|
11
|
+
it("reads the actor and the business", () => {
|
|
12
12
|
const claims = tokenClaims(
|
|
13
|
-
token({
|
|
14
|
-
act: "morten.jansrud@test.snokam.no",
|
|
15
|
-
roles: "platform-operator",
|
|
16
|
-
})
|
|
13
|
+
token({ act: "morten.jansrud@test.snokam.no", biz: "biz-1" })
|
|
17
14
|
);
|
|
18
15
|
assert.equal(claims.act, "morten.jansrud@test.snokam.no");
|
|
19
|
-
assert.
|
|
16
|
+
assert.equal(claims.biz, "biz-1");
|
|
20
17
|
});
|
|
21
18
|
|
|
22
|
-
it("reads
|
|
23
|
-
assert.deepEqual(
|
|
24
|
-
"a",
|
|
25
|
-
"b",
|
|
26
|
-
]);
|
|
27
|
-
assert.deepEqual(claimedRoles(tokenClaims(token({}))), []);
|
|
19
|
+
it("reads nothing from a malformed token", () => {
|
|
20
|
+
assert.deepEqual(tokenClaims("not-a-token"), {});
|
|
28
21
|
});
|
|
29
22
|
});
|
|
@@ -3,12 +3,6 @@ export interface TokenClaims {
|
|
|
3
3
|
email?: string;
|
|
4
4
|
act?: string;
|
|
5
5
|
imp?: string;
|
|
6
|
-
roles?: string | string[];
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
export function claimedRoles(claims: TokenClaims): string[] {
|
|
10
|
-
if (!claims.roles) return [];
|
|
11
|
-
return Array.isArray(claims.roles) ? claims.roles : [claims.roles];
|
|
12
6
|
}
|
|
13
7
|
|
|
14
8
|
export function tokenClaims(accessToken: string): TokenClaims {
|
|
@@ -14,6 +14,7 @@ import { clearAuthTokenCache } from "../hooks";
|
|
|
14
14
|
import { useSessionRenewal } from "./renewal";
|
|
15
15
|
import { useOptionalConfig } from "@snokam/tenant";
|
|
16
16
|
import { isAllowedRedirect, platformBaseDomain } from "./redirects";
|
|
17
|
+
import { exitDestination } from "./exit";
|
|
17
18
|
|
|
18
19
|
export interface StartImpersonationInput {
|
|
19
20
|
targetEmail: string;
|
|
@@ -121,12 +122,19 @@ export function ImpersonationProvider({ children }: { children: ReactNode }) {
|
|
|
121
122
|
}).catch(() => undefined);
|
|
122
123
|
clearAuthTokenCache();
|
|
123
124
|
const returnTo = active?.returnTo;
|
|
125
|
+
const destination =
|
|
126
|
+
returnTo && isSafeReturnTo(returnTo, apiBaseUrl)
|
|
127
|
+
? returnTo
|
|
128
|
+
: window.location.href;
|
|
124
129
|
setActive(null);
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
+
window.location.assign(
|
|
131
|
+
exitDestination(
|
|
132
|
+
active?.origins,
|
|
133
|
+
window.location.origin,
|
|
134
|
+
destination,
|
|
135
|
+
(origin) => isSafeReturnTo(origin, apiBaseUrl)
|
|
136
|
+
)
|
|
137
|
+
);
|
|
130
138
|
} finally {
|
|
131
139
|
setExiting(false);
|
|
132
140
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { resolveCookieDomain } from "../session";
|
|
2
|
-
import {
|
|
2
|
+
import { tokenClaims } from "./claims";
|
|
3
3
|
|
|
4
4
|
export const IMP_SESSION_COOKIE = "imp_session";
|
|
5
5
|
export const IMP_ACTIVE_COOKIE = "imp_active";
|
|
@@ -19,7 +19,7 @@ export interface ImpersonationActive {
|
|
|
19
19
|
expiresOn: number;
|
|
20
20
|
returnTo?: string;
|
|
21
21
|
actorEmail?: string;
|
|
22
|
-
|
|
22
|
+
origins?: string[];
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
interface MintedToken {
|
|
@@ -31,6 +31,7 @@ interface MintedToken {
|
|
|
31
31
|
targetImage?: string | null;
|
|
32
32
|
finance: boolean;
|
|
33
33
|
returnTo?: string;
|
|
34
|
+
origins?: string[];
|
|
34
35
|
}
|
|
35
36
|
|
|
36
37
|
function parseCookieHeader(header: string | null): Record<string, string> {
|
|
@@ -103,7 +104,6 @@ export function buildImpersonationCookies(
|
|
|
103
104
|
const claims = tokenClaims(minted.accessToken);
|
|
104
105
|
const active: ImpersonationActive = {
|
|
105
106
|
actorEmail: claims.act,
|
|
106
|
-
roles: claimedRoles(claims),
|
|
107
107
|
targetEmail: minted.targetEmail,
|
|
108
108
|
targetName: minted.targetName,
|
|
109
109
|
targetImage: minted.targetImage,
|
|
@@ -111,6 +111,7 @@ export function buildImpersonationCookies(
|
|
|
111
111
|
tokenId: minted.tokenId,
|
|
112
112
|
expiresOn: minted.expiresOn,
|
|
113
113
|
returnTo: minted.returnTo,
|
|
114
|
+
origins: minted.origins,
|
|
114
115
|
};
|
|
115
116
|
return [
|
|
116
117
|
serialize(IMP_SESSION_COOKIE, JSON.stringify(session), {
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { exitDestination, withOrigin } from "./exit.ts";
|
|
4
|
+
import { isAllowedRedirect } from "./redirects.ts";
|
|
5
|
+
|
|
6
|
+
const platform = "https://www.test.snosky.no";
|
|
7
|
+
const tenant = "https://snokam.test.snosky.no";
|
|
8
|
+
const other = "https://andre.test.snosky.no";
|
|
9
|
+
const minside = `${tenant}/minside`;
|
|
10
|
+
|
|
11
|
+
function allowedFrom(origin: string) {
|
|
12
|
+
return (target: string) =>
|
|
13
|
+
isAllowedRedirect(target, { origin, platformDomain: "snosky.no" });
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function leave(origins: string[] | undefined, from: string, to: string) {
|
|
17
|
+
return exitDestination(origins, from, to, allowedFrom(from));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function hops(url: string): string[] {
|
|
21
|
+
const chain = [];
|
|
22
|
+
let next: string | null = url;
|
|
23
|
+
while (next) {
|
|
24
|
+
const parsed: URL = new URL(next);
|
|
25
|
+
chain.push(parsed.origin);
|
|
26
|
+
next = parsed.searchParams.get("returnTo");
|
|
27
|
+
}
|
|
28
|
+
return chain;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe("leaving Vis som", () => {
|
|
32
|
+
it("clears the other host before landing when the session spans two hosts", () => {
|
|
33
|
+
const url = new URL(leave([platform, tenant], tenant, minside));
|
|
34
|
+
assert.equal(url.origin, platform);
|
|
35
|
+
assert.equal(url.pathname, "/api/impersonation/clear");
|
|
36
|
+
assert.equal(url.searchParams.get("returnTo"), minside);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("clears the tenant host when leaving from the platform host", () => {
|
|
40
|
+
const url = new URL(
|
|
41
|
+
leave([platform, tenant], platform, `${platform}/manage`)
|
|
42
|
+
);
|
|
43
|
+
assert.equal(url.origin, tenant);
|
|
44
|
+
assert.equal(url.searchParams.get("returnTo"), `${platform}/manage`);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("goes straight to the destination when only this host holds the session", () => {
|
|
48
|
+
assert.equal(leave([tenant], tenant, minside), minside);
|
|
49
|
+
assert.equal(leave(undefined, tenant, minside), minside);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("chains through every other host that adopted the session", () => {
|
|
53
|
+
assert.deepEqual(hops(leave([platform, tenant, other], tenant, minside)), [
|
|
54
|
+
platform,
|
|
55
|
+
other,
|
|
56
|
+
tenant,
|
|
57
|
+
]);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("visits each host once even when it is listed twice", () => {
|
|
61
|
+
assert.deepEqual(
|
|
62
|
+
hops(leave([platform, platform, `${platform}/`], tenant, minside)),
|
|
63
|
+
[platform, tenant]
|
|
64
|
+
);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe("a tampered session-host list", () => {
|
|
69
|
+
const foreign = [
|
|
70
|
+
"https://evil.example",
|
|
71
|
+
"https://snosky.no.evil.example",
|
|
72
|
+
"http://plain.test.snosky.no",
|
|
73
|
+
"javascript:alert(1)",
|
|
74
|
+
"not a url",
|
|
75
|
+
];
|
|
76
|
+
|
|
77
|
+
it("never redirects to a host outside the allow-list", () => {
|
|
78
|
+
const url = leave([...foreign, tenant], tenant, minside);
|
|
79
|
+
assert.equal(url, minside);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("keeps the allowed hosts and drops the foreign ones", () => {
|
|
83
|
+
assert.deepEqual(
|
|
84
|
+
hops(leave([platform, ...foreign, other], tenant, minside)),
|
|
85
|
+
[platform, other, tenant]
|
|
86
|
+
);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe("recording the hosts a session lives on", () => {
|
|
91
|
+
it("appends a host that adopts the session", () => {
|
|
92
|
+
assert.deepEqual(withOrigin([platform, tenant], other), [
|
|
93
|
+
platform,
|
|
94
|
+
tenant,
|
|
95
|
+
other,
|
|
96
|
+
]);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("leaves the list alone when the host is already on it", () => {
|
|
100
|
+
assert.deepEqual(withOrigin([platform, tenant], tenant), [
|
|
101
|
+
platform,
|
|
102
|
+
tenant,
|
|
103
|
+
]);
|
|
104
|
+
assert.deepEqual(withOrigin(undefined, tenant), [tenant]);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export const CLEAR_PATH = "/api/impersonation/clear";
|
|
2
|
+
|
|
3
|
+
export function withOrigin(
|
|
4
|
+
origins: string[] | undefined,
|
|
5
|
+
origin: string
|
|
6
|
+
): string[] {
|
|
7
|
+
const known = origins ?? [];
|
|
8
|
+
return known.includes(origin) ? known : [...known, origin];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function otherSessionHosts(
|
|
12
|
+
origins: string[] | undefined,
|
|
13
|
+
currentOrigin: string,
|
|
14
|
+
isAllowed: (origin: string) => boolean
|
|
15
|
+
): string[] {
|
|
16
|
+
const hosts = (origins ?? [])
|
|
17
|
+
.filter(isAllowed)
|
|
18
|
+
.map((origin) => new URL(origin, currentOrigin).origin);
|
|
19
|
+
return [...new Set(hosts)].filter((host) => host !== currentOrigin);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function exitDestination(
|
|
23
|
+
origins: string[] | undefined,
|
|
24
|
+
currentOrigin: string,
|
|
25
|
+
destination: string,
|
|
26
|
+
isAllowed: (origin: string) => boolean
|
|
27
|
+
): string {
|
|
28
|
+
return otherSessionHosts(origins, currentOrigin, isAllowed).reduceRight(
|
|
29
|
+
(next, host) => {
|
|
30
|
+
const clear = new URL(CLEAR_PATH, host);
|
|
31
|
+
clear.searchParams.set("returnTo", next);
|
|
32
|
+
return clear.toString();
|
|
33
|
+
},
|
|
34
|
+
destination
|
|
35
|
+
);
|
|
36
|
+
}
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
requestHost,
|
|
15
15
|
} from "./request";
|
|
16
16
|
import { isAllowedRedirect, platformBaseDomain } from "./redirects";
|
|
17
|
+
import { withOrigin } from "./exit";
|
|
17
18
|
|
|
18
19
|
interface StartBody {
|
|
19
20
|
targetEmail?: string;
|
|
@@ -108,18 +109,23 @@ export function createImpersonationStartHandler(authConfig: Auth.Config) {
|
|
|
108
109
|
tokenId: result.tokenId,
|
|
109
110
|
expiresOn: result.expiresOn,
|
|
110
111
|
};
|
|
112
|
+
const here = requestOrigin(request);
|
|
113
|
+
const target =
|
|
114
|
+
body.redirectTo && handsOffToTarget(request, body.redirectTo)
|
|
115
|
+
? new URL(body.redirectTo)
|
|
116
|
+
: null;
|
|
117
|
+
const origins = target ? [here, target.origin] : [here];
|
|
111
118
|
const cookies = buildImpersonationCookies(
|
|
112
|
-
{ ...result, returnTo: body.returnTo },
|
|
119
|
+
{ ...result, returnTo: body.returnTo, origins },
|
|
113
120
|
requestHost(request)
|
|
114
121
|
);
|
|
115
122
|
|
|
116
|
-
if (!
|
|
123
|
+
if (!target) {
|
|
117
124
|
return withCookies(Response.json(summary), cookies);
|
|
118
125
|
}
|
|
119
126
|
|
|
120
|
-
const target = new URL(body.redirectTo);
|
|
121
127
|
const ticket = sealImpersonationTicket(
|
|
122
|
-
{ ...result, business: target.host, returnTo: body.returnTo },
|
|
128
|
+
{ ...result, business: target.host, returnTo: body.returnTo, origins },
|
|
123
129
|
authConfig.nextAuthSecret
|
|
124
130
|
);
|
|
125
131
|
const adopt = new URL("/api/impersonation/adopt", target.origin);
|
|
@@ -164,7 +170,24 @@ export function createImpersonationAdoptHandler(authConfig: Auth.Config) {
|
|
|
164
170
|
);
|
|
165
171
|
return withCookies(
|
|
166
172
|
new Response(null, { status: 302, headers: { Location: dest } }),
|
|
167
|
-
buildImpersonationCookies(
|
|
173
|
+
buildImpersonationCookies(
|
|
174
|
+
{ ...claims, origins: withOrigin(claims.origins, origin) },
|
|
175
|
+
host
|
|
176
|
+
)
|
|
177
|
+
);
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function createImpersonationClearHandler() {
|
|
182
|
+
return async function GET(request: Request) {
|
|
183
|
+
const origin = requestOrigin(request);
|
|
184
|
+
const dest = resolveLanding(
|
|
185
|
+
new URL(request.url).searchParams.get("returnTo") || "/",
|
|
186
|
+
origin
|
|
187
|
+
);
|
|
188
|
+
return withCookies(
|
|
189
|
+
new Response(null, { status: 302, headers: { Location: dest } }),
|
|
190
|
+
clearImpersonationCookies(requestHost(request))
|
|
168
191
|
);
|
|
169
192
|
};
|
|
170
193
|
}
|
|
@@ -42,7 +42,13 @@ export function createPickerBusinessesHandler(authConfig: Auth.Config) {
|
|
|
42
42
|
platformCall(caller.client.listImpersonationBusinesses()),
|
|
43
43
|
platformCall(caller.client.getMeApps()),
|
|
44
44
|
]);
|
|
45
|
-
if ("error" in businesses)
|
|
45
|
+
if ("error" in businesses) {
|
|
46
|
+
if (businesses.error.status !== 403) return businesses.error;
|
|
47
|
+
return Response.json(
|
|
48
|
+
{ returnBusiness: await returnBusiness(request) },
|
|
49
|
+
{ status: 403 }
|
|
50
|
+
);
|
|
51
|
+
}
|
|
46
52
|
if ("error" in apps) return apps.error;
|
|
47
53
|
if (businesses.value.length === 0) {
|
|
48
54
|
return new Response("Not found", { status: 404 });
|
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
createImpersonationStartHandler,
|
|
4
4
|
createImpersonationEndHandler,
|
|
5
5
|
createImpersonationAdoptHandler,
|
|
6
|
+
createImpersonationClearHandler,
|
|
6
7
|
} from "./handlers";
|
|
7
8
|
import { createImpersonationRenewHandler } from "./renew";
|
|
8
9
|
import {
|
|
@@ -16,6 +17,7 @@ export function createImpersonationRouteHandlers(authConfig: Auth.Config) {
|
|
|
16
17
|
const start = createImpersonationStartHandler(authConfig);
|
|
17
18
|
const end = createImpersonationEndHandler(authConfig);
|
|
18
19
|
const adopt = createImpersonationAdoptHandler(authConfig);
|
|
20
|
+
const clear = createImpersonationClearHandler();
|
|
19
21
|
const renew = createImpersonationRenewHandler(authConfig);
|
|
20
22
|
const businesses = createPickerBusinessesHandler(authConfig);
|
|
21
23
|
const people = createPickerPeopleHandler(authConfig);
|
|
@@ -24,6 +26,7 @@ export function createImpersonationRouteHandlers(authConfig: Auth.Config) {
|
|
|
24
26
|
async GET(request: Request, ctx: OpContext) {
|
|
25
27
|
const { op } = await ctx.params;
|
|
26
28
|
if (op === "adopt") return adopt(request);
|
|
29
|
+
if (op === "clear") return clear(request);
|
|
27
30
|
if (op === "businesses") return businesses(request);
|
|
28
31
|
if (op === "people") return people(request);
|
|
29
32
|
return new Response("Not found", { status: 404 });
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { callbackDestination } from "./callback-url.ts";
|
|
4
|
+
|
|
5
|
+
describe("where an already signed-in login page sends you", () => {
|
|
6
|
+
const origin = "https://snokam.test.snosky.no";
|
|
7
|
+
|
|
8
|
+
it("keeps the basePath the callbackUrl already carries, once", () => {
|
|
9
|
+
assert.equal(
|
|
10
|
+
callbackDestination("/minside", origin),
|
|
11
|
+
"https://snokam.test.snosky.no/minside"
|
|
12
|
+
);
|
|
13
|
+
assert.equal(
|
|
14
|
+
callbackDestination("/minside/okonomi?x=1", origin),
|
|
15
|
+
"https://snokam.test.snosky.no/minside/okonomi?x=1"
|
|
16
|
+
);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("falls back to the host root", () => {
|
|
20
|
+
assert.equal(callbackDestination(null, origin), `${origin}/`);
|
|
21
|
+
});
|
|
22
|
+
});
|
|
@@ -15,3 +15,11 @@ export function safeCallbackTarget(
|
|
|
15
15
|
}
|
|
16
16
|
return AUTH_FLOW.test(target) ? "/" : target;
|
|
17
17
|
}
|
|
18
|
+
|
|
19
|
+
// A callbackUrl already carries the app's basePath; the Next router would add it again.
|
|
20
|
+
export function callbackDestination(
|
|
21
|
+
callbackUrl: string | null,
|
|
22
|
+
origin: string
|
|
23
|
+
): string {
|
|
24
|
+
return new URL(callbackUrl || "/", origin).toString();
|
|
25
|
+
}
|
package/src/session/index.ts
CHANGED