@snokam/auth 6.0.4 → 6.0.6
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 +10 -0
- package/package.json +6 -7
- package/src/app-router/components/Provider/index.tsx +7 -4
- package/src/app-router/index.tsx +1 -0
- package/src/app-router/server.ts +5 -0
- package/src/app-sign-in/platform.ts +1 -1
- package/src/components/NotProvisioned/AccessHelp.tsx +1 -1
- package/src/components/NotProvisioned/index.tsx +2 -2
- package/src/cors-origin.test.ts +41 -0
- package/src/cors-origin.ts +34 -0
- package/src/cors.ts +1 -30
- package/src/hooks.ts +60 -55
- package/src/hydration/useIsHydrating.ts +12 -0
- package/src/impersonation/carried.test.ts +62 -0
- package/src/impersonation/carried.ts +21 -0
- package/src/impersonation/carry.ts +82 -0
- package/src/impersonation/context.tsx +17 -19
- package/src/impersonation/cookies.ts +14 -30
- package/src/impersonation/handlers.ts +2 -6
- package/src/impersonation/jar.test.ts +33 -0
- package/src/impersonation/jar.ts +44 -0
- package/src/impersonation/pass-cookie.ts +8 -2
- package/src/impersonation/pass.test.ts +19 -0
- package/src/impersonation/renewal.ts +13 -20
- package/src/impersonation/request.ts +1 -1
- package/src/impersonation/routes.ts +3 -0
- package/src/impersonation/schedule.test.ts +95 -0
- package/src/impersonation/schedule.ts +56 -0
- package/src/options/employee-profile.ts +3 -6
- package/src/password/api.ts +1 -1
- package/src/scopes/context.tsx +19 -31
- package/src/scopes/state.test.ts +51 -0
- package/src/scopes/state.ts +44 -0
- package/src/session/confirm.test.ts +100 -0
- package/src/session/confirm.ts +63 -0
- package/src/session/confirmed.tsx +58 -0
- package/src/session/cookies.test.ts +27 -0
- package/src/session/cookies.ts +10 -1
- package/src/session/hold.test.ts +27 -0
- package/src/session/hold.ts +18 -0
- package/src/session/index.ts +5 -1
- package/src/session/useSession.ts +6 -11
- package/src/sso/exchange.test.ts +58 -0
- package/src/sso/exchange.ts +23 -9
- package/src/theme/employee-theme-sync.tsx +3 -3
- package/src/token/failure.test.ts +33 -0
- package/src/token/failure.ts +17 -0
- package/src/token/reuse.test.ts +28 -0
- package/src/token/reuse.ts +22 -0
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export interface ScopesState {
|
|
2
|
+
scopes: string[];
|
|
3
|
+
isImpersonating: boolean;
|
|
4
|
+
isAuthoritative: boolean;
|
|
5
|
+
isLoading: boolean;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface ScopesInput {
|
|
9
|
+
impersonating: boolean;
|
|
10
|
+
tokenScopes: string[] | null;
|
|
11
|
+
normalScopes?: string[] | null;
|
|
12
|
+
ready?: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function scopesState({
|
|
16
|
+
impersonating,
|
|
17
|
+
tokenScopes,
|
|
18
|
+
normalScopes,
|
|
19
|
+
ready,
|
|
20
|
+
}: ScopesInput): ScopesState {
|
|
21
|
+
if (impersonating) {
|
|
22
|
+
return {
|
|
23
|
+
scopes: tokenScopes ?? [],
|
|
24
|
+
isImpersonating: true,
|
|
25
|
+
isAuthoritative: tokenScopes !== null,
|
|
26
|
+
isLoading: tokenScopes === null,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
return {
|
|
30
|
+
scopes: normalScopes ?? [],
|
|
31
|
+
isImpersonating: false,
|
|
32
|
+
isAuthoritative: ready ?? normalScopes != null,
|
|
33
|
+
isLoading: ready === false,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function serverRenderedScopes(ready?: boolean): ScopesState {
|
|
38
|
+
return scopesState({
|
|
39
|
+
impersonating: false,
|
|
40
|
+
tokenScopes: null,
|
|
41
|
+
normalScopes: null,
|
|
42
|
+
ready: ready === undefined ? undefined : false,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import {
|
|
4
|
+
confirmSessionState,
|
|
5
|
+
readSessionState,
|
|
6
|
+
withRetries,
|
|
7
|
+
} from "./confirm.ts";
|
|
8
|
+
|
|
9
|
+
const noWait = async () => {};
|
|
10
|
+
const json = (body: unknown, status = 200) =>
|
|
11
|
+
new Response(JSON.stringify(body), { status });
|
|
12
|
+
|
|
13
|
+
const replies = (...answers: (Response | Error)[]) => {
|
|
14
|
+
let calls = 0;
|
|
15
|
+
const fetchSession = async () => {
|
|
16
|
+
const answer = answers[Math.min(calls, answers.length - 1)];
|
|
17
|
+
calls += 1;
|
|
18
|
+
if (answer instanceof Error) throw answer;
|
|
19
|
+
return answer;
|
|
20
|
+
};
|
|
21
|
+
return { fetchSession, calls: () => calls };
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
test("a session with a user is signed in", async () => {
|
|
25
|
+
const { fetchSession } = replies(json({ user: { email: "a@b.no" } }));
|
|
26
|
+
assert.equal(await readSessionState(fetchSession), "signed-in");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("an empty session is a confirmed sign-out", async () => {
|
|
30
|
+
const { fetchSession } = replies(json({}));
|
|
31
|
+
assert.equal(await readSessionState(fetchSession), "signed-out");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("a failed request says nothing about the session", async () => {
|
|
35
|
+
assert.equal(
|
|
36
|
+
await readSessionState(replies(new TypeError("offline")).fetchSession),
|
|
37
|
+
"unknown"
|
|
38
|
+
);
|
|
39
|
+
assert.equal(
|
|
40
|
+
await readSessionState(replies(json({}, 502)).fetchSession),
|
|
41
|
+
"unknown"
|
|
42
|
+
);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("hiccups are retried until the server answers", async () => {
|
|
46
|
+
const session = replies(
|
|
47
|
+
new TypeError("offline"),
|
|
48
|
+
json({}, 503),
|
|
49
|
+
json({ user: { email: "a@b.no" } })
|
|
50
|
+
);
|
|
51
|
+
const state = await confirmSessionState(
|
|
52
|
+
session.fetchSession,
|
|
53
|
+
[1, 1, 1],
|
|
54
|
+
noWait
|
|
55
|
+
);
|
|
56
|
+
assert.equal(state, "signed-in");
|
|
57
|
+
assert.equal(session.calls(), 3);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("a server that never answers leaves the session unknown, not signed out", async () => {
|
|
61
|
+
const session = replies(new TypeError("offline"));
|
|
62
|
+
const state = await confirmSessionState(session.fetchSession, [1, 1], noWait);
|
|
63
|
+
assert.equal(state, "unknown");
|
|
64
|
+
assert.equal(session.calls(), 3);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("retries wait longer each time and stop at the first success", async () => {
|
|
68
|
+
const waits: number[] = [];
|
|
69
|
+
let attempts = 0;
|
|
70
|
+
const value = await withRetries(
|
|
71
|
+
async () => {
|
|
72
|
+
attempts += 1;
|
|
73
|
+
if (attempts < 3) throw new Error("hiccup");
|
|
74
|
+
return "token";
|
|
75
|
+
},
|
|
76
|
+
() => true,
|
|
77
|
+
[100, 200, 400],
|
|
78
|
+
async (ms) => {
|
|
79
|
+
waits.push(ms);
|
|
80
|
+
}
|
|
81
|
+
);
|
|
82
|
+
assert.equal(value, "token");
|
|
83
|
+
assert.deepEqual(waits, [100, 200]);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("errors that are not transient are not retried", async () => {
|
|
87
|
+
let attempts = 0;
|
|
88
|
+
await assert.rejects(
|
|
89
|
+
withRetries(
|
|
90
|
+
async () => {
|
|
91
|
+
attempts += 1;
|
|
92
|
+
throw new Error("signed out");
|
|
93
|
+
},
|
|
94
|
+
() => false,
|
|
95
|
+
[1, 1],
|
|
96
|
+
noWait
|
|
97
|
+
)
|
|
98
|
+
);
|
|
99
|
+
assert.equal(attempts, 1);
|
|
100
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
export type SessionState = "signed-in" | "signed-out" | "unknown";
|
|
2
|
+
|
|
3
|
+
export const RETRY_DELAYS_MS = [500, 1500, 4000, 10000];
|
|
4
|
+
|
|
5
|
+
export type Wait = (ms: number) => Promise<void>;
|
|
6
|
+
|
|
7
|
+
const sleep: Wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
8
|
+
|
|
9
|
+
export async function withRetries<T>(
|
|
10
|
+
attempt: () => Promise<T>,
|
|
11
|
+
isTransient: (error: unknown) => boolean,
|
|
12
|
+
delays: number[] = RETRY_DELAYS_MS,
|
|
13
|
+
wait: Wait = sleep
|
|
14
|
+
): Promise<T> {
|
|
15
|
+
for (const delay of delays) {
|
|
16
|
+
try {
|
|
17
|
+
return await attempt();
|
|
18
|
+
} catch (error) {
|
|
19
|
+
if (!isTransient(error)) throw error;
|
|
20
|
+
await wait(delay);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return attempt();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function readSessionState(
|
|
27
|
+
fetchSession: () => Promise<Response>
|
|
28
|
+
): Promise<SessionState> {
|
|
29
|
+
try {
|
|
30
|
+
const response = await fetchSession();
|
|
31
|
+
if (!response.ok) return "unknown";
|
|
32
|
+
const session = (await response.json()) as { user?: unknown } | null;
|
|
33
|
+
return session?.user ? "signed-in" : "signed-out";
|
|
34
|
+
} catch {
|
|
35
|
+
return "unknown";
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
class UnknownSession extends Error {}
|
|
40
|
+
|
|
41
|
+
export async function confirmSessionState(
|
|
42
|
+
fetchSession: () => Promise<Response>,
|
|
43
|
+
delays: number[] = RETRY_DELAYS_MS,
|
|
44
|
+
wait: Wait = sleep
|
|
45
|
+
): Promise<SessionState> {
|
|
46
|
+
try {
|
|
47
|
+
return await withRetries(
|
|
48
|
+
async () => {
|
|
49
|
+
const state = await readSessionState(fetchSession);
|
|
50
|
+
if (state === "unknown") throw new UnknownSession();
|
|
51
|
+
return state;
|
|
52
|
+
},
|
|
53
|
+
(error) => error instanceof UnknownSession,
|
|
54
|
+
delays,
|
|
55
|
+
wait
|
|
56
|
+
);
|
|
57
|
+
} catch {
|
|
58
|
+
return "unknown";
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export const fetchSessionFromServer = () =>
|
|
63
|
+
fetch("/api/auth/session", { credentials: "include", cache: "no-store" });
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { createContext, ReactNode, useEffect, useMemo, useState } from "react";
|
|
4
|
+
import {
|
|
5
|
+
useSession as useLiveSession,
|
|
6
|
+
type SessionContextValue,
|
|
7
|
+
} from "next-auth/react";
|
|
8
|
+
import type { Session } from "next-auth";
|
|
9
|
+
import { confirmSessionState, fetchSessionFromServer } from "./confirm";
|
|
10
|
+
import { holdSession } from "./hold";
|
|
11
|
+
|
|
12
|
+
export const ConfirmedSessionContext =
|
|
13
|
+
createContext<SessionContextValue | null>(null);
|
|
14
|
+
|
|
15
|
+
export function ConfirmedSessionProvider({
|
|
16
|
+
children,
|
|
17
|
+
}: {
|
|
18
|
+
children: ReactNode;
|
|
19
|
+
}) {
|
|
20
|
+
const live = useLiveSession();
|
|
21
|
+
const [lastSignedIn, setLastSignedIn] = useState<Session | null>(null);
|
|
22
|
+
const [confirmedSignedOut, setConfirmedSignedOut] = useState(false);
|
|
23
|
+
const { status, data, update } = live;
|
|
24
|
+
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
if (status !== "authenticated" || !data) return;
|
|
27
|
+
setLastSignedIn(data);
|
|
28
|
+
setConfirmedSignedOut(false);
|
|
29
|
+
}, [status, data]);
|
|
30
|
+
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
if (status !== "unauthenticated" || !lastSignedIn || confirmedSignedOut)
|
|
33
|
+
return;
|
|
34
|
+
let cancelled = false;
|
|
35
|
+
confirmSessionState(fetchSessionFromServer).then((state) => {
|
|
36
|
+
if (!cancelled && state === "signed-out") setConfirmedSignedOut(true);
|
|
37
|
+
});
|
|
38
|
+
return () => {
|
|
39
|
+
cancelled = true;
|
|
40
|
+
};
|
|
41
|
+
}, [status, lastSignedIn, confirmedSignedOut]);
|
|
42
|
+
|
|
43
|
+
const value = useMemo(() => {
|
|
44
|
+
const held = holdSession(
|
|
45
|
+
{ data, status },
|
|
46
|
+
lastSignedIn,
|
|
47
|
+
confirmedSignedOut
|
|
48
|
+
);
|
|
49
|
+
if (held.status === status || !held.data) return live;
|
|
50
|
+
return { update, data: held.data, status: "authenticated" as const };
|
|
51
|
+
}, [live, data, status, update, lastSignedIn, confirmedSignedOut]);
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<ConfirmedSessionContext.Provider value={value}>
|
|
55
|
+
{children}
|
|
56
|
+
</ConfirmedSessionContext.Provider>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { cookieScope, resolveCookieDomain } from "./cookies.ts";
|
|
4
|
+
|
|
5
|
+
test("local hosts keep host-only cookies", () => {
|
|
6
|
+
assert.equal(resolveCookieDomain("localhost:3020"), undefined);
|
|
7
|
+
assert.equal(resolveCookieDomain("minside.localhost:3020"), undefined);
|
|
8
|
+
assert.equal(resolveCookieDomain("127.0.0.1:3020"), undefined);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test("a business zone shares one scope across its hosts", () => {
|
|
12
|
+
process.env.NEXT_PUBLIC_PLATFORM_DOMAIN = "test.snosky.no";
|
|
13
|
+
assert.equal(cookieScope("salg.beta.test.snokam.no"), ".beta.test.snokam.no");
|
|
14
|
+
assert.equal(
|
|
15
|
+
cookieScope("minside.beta.test.snokam.no:443"),
|
|
16
|
+
".beta.test.snokam.no"
|
|
17
|
+
);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("platform-zone hosts are their own scope", () => {
|
|
21
|
+
process.env.NEXT_PUBLIC_PLATFORM_DOMAIN = "test.snosky.no";
|
|
22
|
+
assert.equal(
|
|
23
|
+
cookieScope("snokam.beta.test.snosky.no"),
|
|
24
|
+
"snokam.beta.test.snosky.no"
|
|
25
|
+
);
|
|
26
|
+
assert.equal(cookieScope("localhost:3020"), "localhost");
|
|
27
|
+
});
|
package/src/session/cookies.ts
CHANGED
|
@@ -2,7 +2,12 @@ export function resolveCookieDomain(
|
|
|
2
2
|
host: string | undefined
|
|
3
3
|
): string | undefined {
|
|
4
4
|
const bareHost = (host ?? "").split(":")[0].toLowerCase();
|
|
5
|
-
if (
|
|
5
|
+
if (
|
|
6
|
+
!bareHost ||
|
|
7
|
+
bareHost === "localhost" ||
|
|
8
|
+
bareHost.endsWith(".localhost") ||
|
|
9
|
+
/^[\d.]+$/.test(bareHost)
|
|
10
|
+
) {
|
|
6
11
|
return undefined;
|
|
7
12
|
}
|
|
8
13
|
|
|
@@ -29,6 +34,10 @@ export function resolveCookieDomain(
|
|
|
29
34
|
return firstDot > 0 ? `.${bareHost.slice(firstDot + 1)}` : undefined;
|
|
30
35
|
}
|
|
31
36
|
|
|
37
|
+
export function cookieScope(host: string): string {
|
|
38
|
+
return resolveCookieDomain(host) ?? host.split(":")[0].toLowerCase();
|
|
39
|
+
}
|
|
40
|
+
|
|
32
41
|
export function getSessionCookieSettings(nextAuthUrl: string) {
|
|
33
42
|
const cookieDomain = process.env.COOKIE_DOMAIN;
|
|
34
43
|
const cookieTag = process.env.COOKIE_TAG;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import type { Session } from "next-auth";
|
|
4
|
+
import { holdSession } from "./hold.ts";
|
|
5
|
+
|
|
6
|
+
const session = { user: { email: "a@b.no" }, expires: "2099-01-01" } as Session;
|
|
7
|
+
const signedOut = { data: null, status: "unauthenticated" as const };
|
|
8
|
+
|
|
9
|
+
test("a live session passes through", () => {
|
|
10
|
+
const live = { data: session, status: "authenticated" as const };
|
|
11
|
+
assert.equal(holdSession(live, session, false), live);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("a dropped session is held while the sign-out is unconfirmed", () => {
|
|
15
|
+
assert.deepEqual(holdSession(signedOut, session, false), {
|
|
16
|
+
data: session,
|
|
17
|
+
status: "authenticated",
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("a confirmed sign-out is passed on", () => {
|
|
22
|
+
assert.equal(holdSession(signedOut, session, true), signedOut);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("a visitor who never signed in stays signed out", () => {
|
|
26
|
+
assert.equal(holdSession(signedOut, null, false), signedOut);
|
|
27
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { Session } from "next-auth";
|
|
2
|
+
|
|
3
|
+
export type LiveStatus = "authenticated" | "unauthenticated" | "loading";
|
|
4
|
+
|
|
5
|
+
export interface HeldSession {
|
|
6
|
+
data: Session | null;
|
|
7
|
+
status: LiveStatus;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function holdSession(
|
|
11
|
+
live: HeldSession,
|
|
12
|
+
lastSignedIn: Session | null,
|
|
13
|
+
confirmedSignedOut: boolean
|
|
14
|
+
): HeldSession {
|
|
15
|
+
if (live.status !== "unauthenticated") return live;
|
|
16
|
+
if (!lastSignedIn || confirmedSignedOut) return live;
|
|
17
|
+
return { data: lastSignedIn, status: "authenticated" };
|
|
18
|
+
}
|
package/src/session/index.ts
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
-
export {
|
|
1
|
+
export {
|
|
2
|
+
cookieScope,
|
|
3
|
+
resolveCookieDomain,
|
|
4
|
+
getSessionCookieSettings,
|
|
5
|
+
} from "./cookies";
|
|
2
6
|
export { getAuthUrl, requestOrigin } from "./url";
|
|
3
7
|
export { callbackDestination, safeCallbackTarget } from "./callback-url";
|
|
@@ -1,25 +1,20 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { createContext, useContext
|
|
3
|
+
import { createContext, useContext } from "react";
|
|
4
4
|
import {
|
|
5
5
|
useSession as useLiveSession,
|
|
6
6
|
type SessionContextValue,
|
|
7
7
|
} from "next-auth/react";
|
|
8
8
|
import type { Session } from "next-auth";
|
|
9
|
+
import { useIsHydrating } from "../hydration/useIsHydrating";
|
|
10
|
+
import { ConfirmedSessionContext } from "./confirmed";
|
|
9
11
|
|
|
10
12
|
export const ServerSessionContext = createContext<Session | null>(null);
|
|
11
13
|
|
|
12
|
-
const subscribeToNothing = () => () => {};
|
|
13
|
-
|
|
14
|
-
const useIsHydrating = () =>
|
|
15
|
-
useSyncExternalStore(
|
|
16
|
-
subscribeToNothing,
|
|
17
|
-
() => false,
|
|
18
|
-
() => true
|
|
19
|
-
);
|
|
20
|
-
|
|
21
14
|
export const useSession = (): SessionContextValue => {
|
|
22
|
-
const
|
|
15
|
+
const ownLiveSession = useLiveSession();
|
|
16
|
+
const confirmedSession = useContext(ConfirmedSessionContext);
|
|
17
|
+
const liveSession = confirmedSession ?? ownLiveSession;
|
|
23
18
|
const serverSession = useContext(ServerSessionContext);
|
|
24
19
|
const hydrating = useIsHydrating();
|
|
25
20
|
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { afterEach, beforeEach, describe, it } from "node:test";
|
|
3
|
+
import { exchangeSsoToken } from "./exchange.ts";
|
|
4
|
+
|
|
5
|
+
const realFetch = globalThis.fetch;
|
|
6
|
+
const realWarn = console.warn;
|
|
7
|
+
const inAnHour = () => Math.floor(Date.now() / 1000) + 3600;
|
|
8
|
+
|
|
9
|
+
function answering(body: unknown): { calls: number } {
|
|
10
|
+
const counter = { calls: 0 };
|
|
11
|
+
globalThis.fetch = (async () => {
|
|
12
|
+
counter.calls += 1;
|
|
13
|
+
return new Response(JSON.stringify(body), {
|
|
14
|
+
status: 200,
|
|
15
|
+
headers: { "content-type": "application/json" },
|
|
16
|
+
});
|
|
17
|
+
}) as typeof fetch;
|
|
18
|
+
return counter;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe("exchangeSsoToken", () => {
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
process.env.NEXT_PUBLIC_PLATFORM_API_BASE_URL =
|
|
24
|
+
"https://snokam.test.snosky.no/api/platform";
|
|
25
|
+
console.warn = () => {};
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
afterEach(() => {
|
|
29
|
+
globalThis.fetch = realFetch;
|
|
30
|
+
console.warn = realWarn;
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("returns the platform token with its expiry and reuses it", async () => {
|
|
34
|
+
const expiresOn = inAnHour();
|
|
35
|
+
const counter = answering({ accessToken: "platform", expiresOn });
|
|
36
|
+
|
|
37
|
+
const first = await exchangeSsoToken("entra-reused");
|
|
38
|
+
const second = await exchangeSsoToken("entra-reused");
|
|
39
|
+
|
|
40
|
+
assert.deepEqual(first, { accessToken: "platform", expiresOn });
|
|
41
|
+
assert.deepEqual(second, first);
|
|
42
|
+
assert.equal(counter.calls, 1);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("refuses an answer without an expiry instead of caching it", async () => {
|
|
46
|
+
const counter = answering({ accessToken: "platform", expiresOn: null });
|
|
47
|
+
|
|
48
|
+
assert.equal(await exchangeSsoToken("entra-no-expiry"), null);
|
|
49
|
+
assert.equal(await exchangeSsoToken("entra-no-expiry"), null);
|
|
50
|
+
assert.equal(counter.calls, 2);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("refuses an answer without a token", async () => {
|
|
54
|
+
answering({ accessToken: null, expiresOn: inAnHour() });
|
|
55
|
+
|
|
56
|
+
assert.equal(await exchangeSsoToken("entra-no-token"), null);
|
|
57
|
+
});
|
|
58
|
+
});
|
package/src/sso/exchange.ts
CHANGED
|
@@ -3,12 +3,17 @@ import {
|
|
|
3
3
|
Configuration,
|
|
4
4
|
PlatformClient,
|
|
5
5
|
type TokenExchangeResult,
|
|
6
|
-
} from "@snokam/
|
|
6
|
+
} from "@snokam/api-client/platform";
|
|
7
7
|
|
|
8
8
|
const EXPIRY_SKEW_SECONDS = 60;
|
|
9
9
|
const MAX_ENTRIES = 500;
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
export interface ExchangedToken {
|
|
12
|
+
accessToken: string;
|
|
13
|
+
expiresOn: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const cache = new Map<string, ExchangedToken>();
|
|
12
17
|
|
|
13
18
|
function platformBase(): string | null {
|
|
14
19
|
return (
|
|
@@ -20,11 +25,18 @@ function keyFor(providerToken: string): string {
|
|
|
20
25
|
return createHash("sha256").update(providerToken).digest("base64url");
|
|
21
26
|
}
|
|
22
27
|
|
|
23
|
-
function isUsable(token:
|
|
28
|
+
function isUsable(token: ExchangedToken, nowSeconds: number): boolean {
|
|
24
29
|
return token.expiresOn - EXPIRY_SKEW_SECONDS > nowSeconds;
|
|
25
30
|
}
|
|
26
31
|
|
|
27
|
-
function
|
|
32
|
+
function complete(result: TokenExchangeResult): ExchangedToken | null {
|
|
33
|
+
const { accessToken, expiresOn } = result;
|
|
34
|
+
return accessToken && typeof expiresOn === "number"
|
|
35
|
+
? { accessToken, expiresOn }
|
|
36
|
+
: null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function remember(key: string, token: ExchangedToken): void {
|
|
28
40
|
const now = Math.floor(Date.now() / 1000);
|
|
29
41
|
for (const [existing, value] of cache)
|
|
30
42
|
if (!isUsable(value, now)) cache.delete(existing);
|
|
@@ -40,7 +52,7 @@ function remember(key: string, token: TokenExchangeResult): void {
|
|
|
40
52
|
|
|
41
53
|
export async function exchangeSsoToken(
|
|
42
54
|
providerToken: string
|
|
43
|
-
): Promise<
|
|
55
|
+
): Promise<ExchangedToken | null> {
|
|
44
56
|
const basePath = platformBase();
|
|
45
57
|
if (!basePath || !providerToken) return null;
|
|
46
58
|
|
|
@@ -51,10 +63,12 @@ export async function exchangeSsoToken(
|
|
|
51
63
|
|
|
52
64
|
const client = new PlatformClient(new Configuration({ basePath }));
|
|
53
65
|
try {
|
|
54
|
-
const result =
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
66
|
+
const result = complete(
|
|
67
|
+
await client.ssoTokenExchange({
|
|
68
|
+
ssoExchangeRequest: { token: providerToken },
|
|
69
|
+
})
|
|
70
|
+
);
|
|
71
|
+
if (!result) return null;
|
|
58
72
|
remember(key, result);
|
|
59
73
|
return result;
|
|
60
74
|
} catch (e) {
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { useEffect, useMemo, useRef } from "react";
|
|
4
|
-
import { Configuration, EmployeesClient } from "@snokam/
|
|
5
|
-
import { useOptionalConfig } from "@snokam/tenant";
|
|
4
|
+
import { Configuration, EmployeesClient } from "@snokam/api-client/employees";
|
|
5
|
+
import { apiBaseUrl, useOptionalConfig } from "@snokam/tenant";
|
|
6
6
|
import { useClientQuery } from "@snokam/react-query";
|
|
7
7
|
import { useAdminTheme } from "@snokam/ui/theme/admin";
|
|
8
8
|
import type { ColorSchemeMode } from "@snokam/ui/theme/admin";
|
|
@@ -21,7 +21,7 @@ export function EmployeeThemeSync() {
|
|
|
21
21
|
const config = useOptionalConfig();
|
|
22
22
|
const seeded = useRef(false);
|
|
23
23
|
|
|
24
|
-
const basePath = config
|
|
24
|
+
const basePath = config ? apiBaseUrl(config) : undefined;
|
|
25
25
|
const client = useMemo(
|
|
26
26
|
() =>
|
|
27
27
|
new EmployeesClient(
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import {
|
|
4
|
+
ConsentRequiredError,
|
|
5
|
+
failureOf,
|
|
6
|
+
SignedOutError,
|
|
7
|
+
TransientTokenError,
|
|
8
|
+
} from "./failure.ts";
|
|
9
|
+
|
|
10
|
+
test("server and gateway hiccups are transient", () => {
|
|
11
|
+
for (const status of [500, 502, 503, 504, 408, 429])
|
|
12
|
+
assert.ok(
|
|
13
|
+
failureOf(status, null) instanceof TransientTokenError,
|
|
14
|
+
`${status}`
|
|
15
|
+
);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("401 means signed out", () => {
|
|
19
|
+
assert.ok(failureOf(401, null) instanceof SignedOutError);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("a consent request is not a hiccup", () => {
|
|
23
|
+
assert.ok(
|
|
24
|
+
failureOf(403, { error: "consent_required" }) instanceof
|
|
25
|
+
ConsentRequiredError
|
|
26
|
+
);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("other refusals are neither transient nor a sign-out", () => {
|
|
30
|
+
const error = failureOf(403, null);
|
|
31
|
+
assert.ok(!(error instanceof TransientTokenError));
|
|
32
|
+
assert.ok(!(error instanceof SignedOutError));
|
|
33
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export class ConsentRequiredError extends Error {}
|
|
2
|
+
|
|
3
|
+
export class SignedOutError extends Error {}
|
|
4
|
+
|
|
5
|
+
export class TransientTokenError extends Error {}
|
|
6
|
+
|
|
7
|
+
const TRANSIENT_STATUSES = new Set([408, 425, 429]);
|
|
8
|
+
|
|
9
|
+
export function failureOf(status: number, body: unknown): Error {
|
|
10
|
+
const error = (body as { error?: unknown } | null)?.error;
|
|
11
|
+
if (status === 403 && error === "consent_required")
|
|
12
|
+
return new ConsentRequiredError();
|
|
13
|
+
if (status === 401) return new SignedOutError("Not signed in");
|
|
14
|
+
if (status >= 500 || TRANSIENT_STATUSES.has(status))
|
|
15
|
+
return new TransientTokenError(`Token request failed with ${status}`);
|
|
16
|
+
return new Error(`Token request failed with ${status}`);
|
|
17
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { isReusable, type CachedToken } from "./reuse.ts";
|
|
4
|
+
|
|
5
|
+
const token = (expiresOn: number, fetchedAt: number): CachedToken => ({
|
|
6
|
+
bearer: "Bearer x",
|
|
7
|
+
expiresOn,
|
|
8
|
+
fetchedAt,
|
|
9
|
+
impKey: "imp-1",
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
describe("cached access token reuse", () => {
|
|
13
|
+
it("reuses a token until a minute before it expires", () => {
|
|
14
|
+
assert.equal(isReusable(token(1_000, 0), "imp-1", 939), true);
|
|
15
|
+
assert.equal(isReusable(token(1_000, 0), "imp-1", 940), false);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("reuses a token handed out close to expiry instead of fetching it again on every call", () => {
|
|
19
|
+
assert.equal(isReusable(token(1_000, 970), "imp-1", 980), true);
|
|
20
|
+
assert.equal(isReusable(token(1_000, 970), "imp-1", 990), false);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("never reuses an expired token or one for another view-as session", () => {
|
|
24
|
+
assert.equal(isReusable(token(1_000, 995), "imp-1", 1_000), false);
|
|
25
|
+
assert.equal(isReusable(token(1_000, 0), "imp-2", 10), false);
|
|
26
|
+
assert.equal(isReusable(null, "imp-1", 10), false);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
const REFRESH_MARGIN_SECONDS = 60;
|
|
2
|
+
const MIN_REUSE_SECONDS = 15;
|
|
3
|
+
|
|
4
|
+
export interface CachedToken {
|
|
5
|
+
bearer: string;
|
|
6
|
+
expiresOn: number;
|
|
7
|
+
fetchedAt: number;
|
|
8
|
+
impKey: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function isReusable(
|
|
12
|
+
cached: CachedToken | null,
|
|
13
|
+
impKey: string,
|
|
14
|
+
nowSeconds: number
|
|
15
|
+
): cached is CachedToken {
|
|
16
|
+
if (!cached || cached.impKey !== impKey) return false;
|
|
17
|
+
if (nowSeconds >= cached.expiresOn) return false;
|
|
18
|
+
return (
|
|
19
|
+
nowSeconds < cached.expiresOn - REFRESH_MARGIN_SECONDS ||
|
|
20
|
+
nowSeconds < cached.fetchedAt + MIN_REUSE_SECONDS
|
|
21
|
+
);
|
|
22
|
+
}
|