@snokam/auth 5.3.2 → 5.3.4
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 +2 -2
- package/src/components/NotProvisioned/index.tsx +3 -1
- package/src/components/NotProvisioned/usePickerRedirect.ts +41 -0
- package/src/hooks.ts +9 -4
- package/src/impersonation/cookies.ts +6 -2
- package/src/impersonation/handlers.ts +19 -8
- package/src/impersonation/pass-cookie.ts +24 -0
- package/src/impersonation/pass.test.ts +43 -0
- package/src/impersonation/pass.ts +23 -0
- package/src/impersonation/renew.ts +17 -9
- package/src/middleware.tsx +4 -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.4](https://github.com/snokam/components/compare/@snokam/auth@5.3.3...@snokam/auth@5.3.4) (2026-10-15)
|
|
7
|
+
|
|
8
|
+
### Bug Fixes
|
|
9
|
+
|
|
10
|
+
- **auth:** a Vis som session in another business no longer loops back to the picker ([#2957](https://github.com/snokam/components/issues/2957)) ([ae37b4b](https://github.com/snokam/components/commit/ae37b4b7bfc515f357a5faee3ff565e5032d81a7)), closes [#2894](https://github.com/snokam/components/issues/2894)
|
|
11
|
+
|
|
12
|
+
## [5.3.3](https://github.com/snokam/components/compare/@snokam/auth@5.3.2...@snokam/auth@5.3.3) (2026-10-14)
|
|
13
|
+
|
|
14
|
+
### Bug Fixes
|
|
15
|
+
|
|
16
|
+
- send test operators from "no access" to the Vis som picker, land on Min side ([#2953](https://github.com/snokam/components/issues/2953)) ([dcce011](https://github.com/snokam/components/commit/dcce011160cd4b0d5661c41ce372ee15b905ebf9)), closes [#2949](https://github.com/snokam/components/issues/2949)
|
|
17
|
+
|
|
6
18
|
## [5.3.2](https://github.com/snokam/components/compare/@snokam/auth@5.3.1...@snokam/auth@5.3.2) (2026-10-14)
|
|
7
19
|
|
|
8
20
|
### 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": "5.3.
|
|
4
|
+
"version": "5.3.4",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"types": "src/index.ts",
|
|
7
7
|
"files": [
|
|
@@ -26,11 +26,11 @@
|
|
|
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/identity": "1.1.0",
|
|
30
29
|
"@snokam/platform-client": "5.3.1",
|
|
31
30
|
"@snokam/react-query": "1.1.0",
|
|
32
31
|
"@snokam/sanity-page-content": "6.5.2",
|
|
33
32
|
"@snokam/tenant": "4.3.1",
|
|
33
|
+
"@snokam/identity": "1.1.0",
|
|
34
34
|
"@snokam/ui": "6.1.0"
|
|
35
35
|
},
|
|
36
36
|
"peerDependencies": {
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
PageContentProvider,
|
|
9
9
|
} from "@snokam/sanity-page-content/app-router/providers";
|
|
10
10
|
import { AccessHelp } from "./AccessHelp";
|
|
11
|
+
import { usePickerRedirect } from "./usePickerRedirect";
|
|
11
12
|
|
|
12
13
|
const ACCESS_GATE_PAGE_KEY = "access-gate";
|
|
13
14
|
|
|
@@ -20,8 +21,9 @@ export const NotProvisioned = ({ appKey }: Props) => {
|
|
|
20
21
|
queryKey: ["access-gate-content", appKey],
|
|
21
22
|
queryFn: () => loadAppContent(appKey),
|
|
22
23
|
});
|
|
24
|
+
const checkingPicker = usePickerRedirect();
|
|
23
25
|
|
|
24
|
-
if (isPending) {
|
|
26
|
+
if (isPending || checkingPicker) {
|
|
25
27
|
return <PageLoader />;
|
|
26
28
|
}
|
|
27
29
|
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
import { useOptionalConfig } from "@snokam/tenant";
|
|
5
|
+
import { impersonationPickerUrl } from "../../impersonation/required";
|
|
6
|
+
|
|
7
|
+
const PICKER_BUSINESSES_PATH = "/api/impersonation/businesses";
|
|
8
|
+
|
|
9
|
+
// The picker's businesses route answers 200 only with persona mode on and an operator's own
|
|
10
|
+
// token, so a signed-in operator who is no employee here goes to pick a test user instead.
|
|
11
|
+
export function usePickerRedirect(): boolean {
|
|
12
|
+
const platformBaseUrl = useOptionalConfig()?.NEXT_PUBLIC_PLATFORM_BASE_URL;
|
|
13
|
+
const [checking, setChecking] = useState(Boolean(platformBaseUrl));
|
|
14
|
+
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
if (!platformBaseUrl) return;
|
|
17
|
+
let cancelled = false;
|
|
18
|
+
const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? "";
|
|
19
|
+
|
|
20
|
+
fetch(`${basePath}${PICKER_BUSINESSES_PATH}`, { credentials: "include" })
|
|
21
|
+
.then((response) => {
|
|
22
|
+
if (cancelled) return;
|
|
23
|
+
if (!response.ok) {
|
|
24
|
+
setChecking(false);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
window.location.replace(
|
|
28
|
+
impersonationPickerUrl(platformBaseUrl, window.location.href)
|
|
29
|
+
);
|
|
30
|
+
})
|
|
31
|
+
.catch(() => {
|
|
32
|
+
if (!cancelled) setChecking(false);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
return () => {
|
|
36
|
+
cancelled = true;
|
|
37
|
+
};
|
|
38
|
+
}, [platformBaseUrl]);
|
|
39
|
+
|
|
40
|
+
return checking;
|
|
41
|
+
}
|
package/src/hooks.ts
CHANGED
|
@@ -102,12 +102,15 @@ export function useAuth() {
|
|
|
102
102
|
const { data: rawSession, status, update } = useSession();
|
|
103
103
|
const { active } = useImpersonation();
|
|
104
104
|
|
|
105
|
+
// A host of another business holds only the Vis som session, never the actor's own sign-in.
|
|
105
106
|
const session = useMemo(() => {
|
|
106
|
-
if (!active
|
|
107
|
+
if (!active) return rawSession;
|
|
107
108
|
return {
|
|
108
109
|
...rawSession,
|
|
110
|
+
expires:
|
|
111
|
+
rawSession?.expires ?? new Date(active.expiresOn * 1000).toISOString(),
|
|
109
112
|
user: {
|
|
110
|
-
...rawSession
|
|
113
|
+
...rawSession?.user,
|
|
111
114
|
name: active.targetName || active.targetEmail,
|
|
112
115
|
email: active.targetEmail,
|
|
113
116
|
image: active.targetImage ?? null,
|
|
@@ -132,10 +135,12 @@ export function useAuth() {
|
|
|
132
135
|
}) as Promise<string>,
|
|
133
136
|
inProgress: status === "loading",
|
|
134
137
|
isAuthenticated:
|
|
135
|
-
status === "authenticated" ||
|
|
138
|
+
status === "authenticated" ||
|
|
139
|
+
!!active ||
|
|
140
|
+
(status === "loading" && !!session),
|
|
136
141
|
}),
|
|
137
142
|
|
|
138
|
-
[update, status, session]
|
|
143
|
+
[update, status, session, active]
|
|
139
144
|
);
|
|
140
145
|
|
|
141
146
|
return value;
|
|
@@ -3,6 +3,7 @@ import { tokenClaims } from "./claims";
|
|
|
3
3
|
|
|
4
4
|
export const IMP_SESSION_COOKIE = "imp_session";
|
|
5
5
|
export const IMP_ACTIVE_COOKIE = "imp_active";
|
|
6
|
+
export const IMP_PASS_COOKIE = "imp_pass";
|
|
6
7
|
|
|
7
8
|
export interface ImpersonationSession {
|
|
8
9
|
accessToken: string;
|
|
@@ -34,7 +35,9 @@ interface MintedToken {
|
|
|
34
35
|
origins?: string[];
|
|
35
36
|
}
|
|
36
37
|
|
|
37
|
-
function parseCookieHeader(
|
|
38
|
+
export function parseCookieHeader(
|
|
39
|
+
header: string | null
|
|
40
|
+
): Record<string, string> {
|
|
38
41
|
const jar: Record<string, string> = {};
|
|
39
42
|
if (!header) return jar;
|
|
40
43
|
for (const part of header.split(";")) {
|
|
@@ -68,7 +71,7 @@ export function readImpersonationActive(
|
|
|
68
71
|
return readCookieJson<ImpersonationActive>(request, IMP_ACTIVE_COOKIE);
|
|
69
72
|
}
|
|
70
73
|
|
|
71
|
-
function serialize(
|
|
74
|
+
export function serialize(
|
|
72
75
|
name: string,
|
|
73
76
|
value: string,
|
|
74
77
|
{
|
|
@@ -131,5 +134,6 @@ export function clearImpersonationCookies(host: string | null): string[] {
|
|
|
131
134
|
return [
|
|
132
135
|
serialize(IMP_SESSION_COOKIE, "", { host, httpOnly: true, maxAge: 0 }),
|
|
133
136
|
serialize(IMP_ACTIVE_COOKIE, "", { host, httpOnly: false, maxAge: 0 }),
|
|
137
|
+
serialize(IMP_PASS_COOKIE, "", { host, httpOnly: true, maxAge: 0 }),
|
|
134
138
|
];
|
|
135
139
|
}
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
readImpersonationSession,
|
|
7
7
|
} from "./cookies";
|
|
8
8
|
import { sealImpersonationTicket, openImpersonationTicket } from "./ticket";
|
|
9
|
+
import { impersonationPassCookie } from "./pass-cookie";
|
|
9
10
|
import { requestOrigin, resolveCookieDomain } from "../session";
|
|
10
11
|
import {
|
|
11
12
|
platformCall,
|
|
@@ -115,10 +116,17 @@ export function createImpersonationStartHandler(authConfig: Auth.Config) {
|
|
|
115
116
|
? new URL(body.redirectTo)
|
|
116
117
|
: null;
|
|
117
118
|
const origins = target ? [here, target.origin] : [here];
|
|
118
|
-
const cookies =
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
119
|
+
const cookies = [
|
|
120
|
+
...buildImpersonationCookies(
|
|
121
|
+
{ ...result, returnTo: body.returnTo, origins },
|
|
122
|
+
requestHost(request)
|
|
123
|
+
),
|
|
124
|
+
impersonationPassCookie(
|
|
125
|
+
result,
|
|
126
|
+
requestHost(request),
|
|
127
|
+
authConfig.nextAuthSecret
|
|
128
|
+
),
|
|
129
|
+
];
|
|
122
130
|
|
|
123
131
|
if (!target) {
|
|
124
132
|
return withCookies(Response.json(summary), cookies);
|
|
@@ -170,10 +178,13 @@ export function createImpersonationAdoptHandler(authConfig: Auth.Config) {
|
|
|
170
178
|
);
|
|
171
179
|
return withCookies(
|
|
172
180
|
new Response(null, { status: 302, headers: { Location: dest } }),
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
181
|
+
[
|
|
182
|
+
...buildImpersonationCookies(
|
|
183
|
+
{ ...claims, origins: withOrigin(claims.origins, origin) },
|
|
184
|
+
host
|
|
185
|
+
),
|
|
186
|
+
impersonationPassCookie(claims, host, authConfig.nextAuthSecret),
|
|
187
|
+
]
|
|
177
188
|
);
|
|
178
189
|
};
|
|
179
190
|
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { IMP_PASS_COOKIE, parseCookieHeader, serialize } from "./cookies";
|
|
2
|
+
import { isValidPass, sealPass } from "./pass";
|
|
3
|
+
|
|
4
|
+
export function impersonationPassCookie(
|
|
5
|
+
session: { tokenId: string; expiresOn: number },
|
|
6
|
+
host: string | null,
|
|
7
|
+
secret: string
|
|
8
|
+
): string {
|
|
9
|
+
const maxAge = Math.max(0, Math.floor(session.expiresOn - Date.now() / 1000));
|
|
10
|
+
const pass = sealPass(host ?? "", session.tokenId, maxAge, secret);
|
|
11
|
+
return serialize(IMP_PASS_COOKIE, pass, { host, httpOnly: true, maxAge });
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function hasImpersonationPass(
|
|
15
|
+
request: Request,
|
|
16
|
+
secret: string
|
|
17
|
+
): boolean {
|
|
18
|
+
const pass = parseCookieHeader(request.headers.get("cookie"))[
|
|
19
|
+
IMP_PASS_COOKIE
|
|
20
|
+
];
|
|
21
|
+
const host =
|
|
22
|
+
request.headers.get("x-forwarded-host") ?? request.headers.get("host");
|
|
23
|
+
return isValidPass(pass, host, secret);
|
|
24
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { isValidPass, sealPass } from "./pass.ts";
|
|
4
|
+
|
|
5
|
+
const SECRET = "test-secret";
|
|
6
|
+
const HOST = "businessx.test.snosky.no";
|
|
7
|
+
const HOUR = 3600;
|
|
8
|
+
|
|
9
|
+
describe("the Vis som pass", () => {
|
|
10
|
+
it("lets the host it was sealed for through", () => {
|
|
11
|
+
assert.equal(
|
|
12
|
+
isValidPass(sealPass(HOST, "tok", HOUR, SECRET), HOST, SECRET),
|
|
13
|
+
true
|
|
14
|
+
);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("is refused on another host", () => {
|
|
18
|
+
const pass = sealPass(HOST, "tok", HOUR, SECRET);
|
|
19
|
+
assert.equal(isValidPass(pass, "acme.test.snosky.no", SECRET), false);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("is refused when sealed with another secret, or forged", () => {
|
|
23
|
+
assert.equal(
|
|
24
|
+
isValidPass(sealPass(HOST, "tok", HOUR, "other"), HOST, SECRET),
|
|
25
|
+
false
|
|
26
|
+
);
|
|
27
|
+
assert.equal(isValidPass("a.b.c", HOST, SECRET), false);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("is refused once it has expired", () => {
|
|
31
|
+
assert.equal(
|
|
32
|
+
isValidPass(sealPass(HOST, "tok", -10, SECRET), HOST, SECRET),
|
|
33
|
+
false
|
|
34
|
+
);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("is refused without a pass, a host or a secret", () => {
|
|
38
|
+
const pass = sealPass(HOST, "tok", HOUR, SECRET);
|
|
39
|
+
assert.equal(isValidPass(undefined, HOST, SECRET), false);
|
|
40
|
+
assert.equal(isValidPass(pass, null, SECRET), false);
|
|
41
|
+
assert.equal(isValidPass(pass, HOST, ""), false);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { open, seal } from "../ticket/seal.ts";
|
|
2
|
+
|
|
3
|
+
const PURPOSE = "impersonation-pass";
|
|
4
|
+
|
|
5
|
+
// A host of another business never gets the actor's own sign-in, so this sealed, host-bound pass
|
|
6
|
+
// is what tells the auth middleware that the host holds a Vis som session.
|
|
7
|
+
export function sealPass(
|
|
8
|
+
host: string,
|
|
9
|
+
tokenId: string,
|
|
10
|
+
ttlSeconds: number,
|
|
11
|
+
secret: string
|
|
12
|
+
): string {
|
|
13
|
+
return seal({ business: host, tokenId }, secret, ttlSeconds, PURPOSE);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function isValidPass(
|
|
17
|
+
pass: string | undefined,
|
|
18
|
+
host: string | null,
|
|
19
|
+
secret: string
|
|
20
|
+
): boolean {
|
|
21
|
+
if (!pass || !host || !secret) return false;
|
|
22
|
+
return open(pass, secret, PURPOSE, { expectedBusiness: host }) !== null;
|
|
23
|
+
}
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
readImpersonationActive,
|
|
6
6
|
readImpersonationSession,
|
|
7
7
|
} from "./cookies";
|
|
8
|
+
import { impersonationPassCookie } from "./pass-cookie";
|
|
8
9
|
import {
|
|
9
10
|
platformCall,
|
|
10
11
|
platformClient,
|
|
@@ -40,15 +41,22 @@ export function createImpersonationRenewHandler(authConfig: Auth.Config) {
|
|
|
40
41
|
tokenId: session.tokenId,
|
|
41
42
|
expiresOn: renewed.expiresOn,
|
|
42
43
|
});
|
|
43
|
-
const cookies =
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
44
|
+
const cookies = [
|
|
45
|
+
...buildImpersonationCookies(
|
|
46
|
+
{
|
|
47
|
+
...active,
|
|
48
|
+
accessToken: renewed.accessToken,
|
|
49
|
+
expiresOn: renewed.expiresOn,
|
|
50
|
+
finance: renewed.finance ?? false,
|
|
51
|
+
},
|
|
52
|
+
requestHost(request)
|
|
53
|
+
),
|
|
54
|
+
impersonationPassCookie(
|
|
55
|
+
{ tokenId: session.tokenId, expiresOn: renewed.expiresOn },
|
|
56
|
+
requestHost(request),
|
|
57
|
+
authConfig.nextAuthSecret
|
|
58
|
+
),
|
|
59
|
+
];
|
|
52
60
|
for (const cookie of cookies) out.headers.append("Set-Cookie", cookie);
|
|
53
61
|
return out;
|
|
54
62
|
};
|
package/src/middleware.tsx
CHANGED
|
@@ -5,6 +5,7 @@ import { NextResponse, NextFetchEvent } from "next/server";
|
|
|
5
5
|
import { Auth } from "./types";
|
|
6
6
|
import { getSessionCookieSettings, getAuthUrl } from "./session";
|
|
7
7
|
import { addCorsHeaders } from "./cors";
|
|
8
|
+
import { hasImpersonationPass } from "./impersonation/pass-cookie";
|
|
8
9
|
|
|
9
10
|
const TEST_ENVIRONMENT = "t";
|
|
10
11
|
|
|
@@ -117,7 +118,9 @@ export function buildAuthMiddleware({
|
|
|
117
118
|
secret,
|
|
118
119
|
});
|
|
119
120
|
|
|
120
|
-
const isAuthorized =
|
|
121
|
+
const isAuthorized =
|
|
122
|
+
((await authorized({ req, token })) ?? !!token) ||
|
|
123
|
+
hasImpersonationPass(req, secret);
|
|
121
124
|
if (isAuthorized) {
|
|
122
125
|
req.nextauth = { token };
|
|
123
126
|
return;
|