@snokam/auth 6.0.3 → 6.0.5

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.
Files changed (41) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/package.json +5 -6
  3. package/src/app-router/index.tsx +1 -0
  4. package/src/app-router/server.ts +5 -0
  5. package/src/app-sign-in/platform.ts +1 -1
  6. package/src/components/NotProvisioned/AccessHelp.tsx +1 -1
  7. package/src/components/NotProvisioned/index.tsx +2 -2
  8. package/src/cors-origin.test.ts +41 -0
  9. package/src/cors-origin.ts +34 -0
  10. package/src/cors.ts +1 -30
  11. package/src/hooks.ts +18 -36
  12. package/src/hydration/useIsHydrating.ts +12 -0
  13. package/src/impersonation/carried.test.ts +62 -0
  14. package/src/impersonation/carried.ts +21 -0
  15. package/src/impersonation/carry.ts +82 -0
  16. package/src/impersonation/context.tsx +17 -19
  17. package/src/impersonation/cookies.ts +14 -30
  18. package/src/impersonation/handlers.ts +2 -6
  19. package/src/impersonation/jar.test.ts +33 -0
  20. package/src/impersonation/jar.ts +44 -0
  21. package/src/impersonation/pass-cookie.ts +8 -2
  22. package/src/impersonation/pass.test.ts +19 -0
  23. package/src/impersonation/renewal.ts +13 -20
  24. package/src/impersonation/request.ts +1 -1
  25. package/src/impersonation/routes.ts +3 -0
  26. package/src/impersonation/schedule.test.ts +95 -0
  27. package/src/impersonation/schedule.ts +56 -0
  28. package/src/options/employee-profile.ts +3 -6
  29. package/src/password/api.ts +1 -1
  30. package/src/scopes/context.tsx +19 -31
  31. package/src/scopes/state.test.ts +51 -0
  32. package/src/scopes/state.ts +44 -0
  33. package/src/session/cookies.test.ts +27 -0
  34. package/src/session/cookies.ts +10 -1
  35. package/src/session/index.ts +5 -1
  36. package/src/session/useSession.ts +2 -10
  37. package/src/sso/exchange.test.ts +58 -0
  38. package/src/sso/exchange.ts +23 -9
  39. package/src/theme/employee-theme-sync.tsx +3 -3
  40. package/src/token/reuse.test.ts +28 -0
  41. package/src/token/reuse.ts +22 -0
@@ -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
+ });
@@ -3,12 +3,17 @@ import {
3
3
  Configuration,
4
4
  PlatformClient,
5
5
  type TokenExchangeResult,
6
- } from "@snokam/platform-client";
6
+ } from "@snokam/api-client/platform";
7
7
 
8
8
  const EXPIRY_SKEW_SECONDS = 60;
9
9
  const MAX_ENTRIES = 500;
10
10
 
11
- const cache = new Map<string, TokenExchangeResult>();
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: TokenExchangeResult, nowSeconds: number): boolean {
28
+ function isUsable(token: ExchangedToken, nowSeconds: number): boolean {
24
29
  return token.expiresOn - EXPIRY_SKEW_SECONDS > nowSeconds;
25
30
  }
26
31
 
27
- function remember(key: string, token: TokenExchangeResult): void {
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<TokenExchangeResult | null> {
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 = await client.ssoTokenExchange({
55
- ssoExchangeRequest: { token: providerToken },
56
- });
57
- if (!result.accessToken) return null;
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/employees-client";
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?.NEXT_PUBLIC_EMPLOYEES_API_BASE_URL;
24
+ const basePath = config ? apiBaseUrl(config) : undefined;
25
25
  const client = useMemo(
26
26
  () =>
27
27
  new EmployeesClient(
@@ -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
+ }