@snokam/auth 3.6.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 +6 -0
- package/package.json +1 -1
- package/src/hooks.ts +21 -9
- package/src/impersonation/context.tsx +1 -9
- 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,12 @@
|
|
|
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
|
+
|
|
6
12
|
# [3.6.0](https://github.com/snokam/components/compare/@snokam/auth@3.5.0...@snokam/auth@3.6.0) (2026-10-06)
|
|
7
13
|
|
|
8
14
|
### Features
|
package/package.json
CHANGED
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;
|
|
@@ -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
|
}
|
|
@@ -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
|
-
}
|