@snokam/auth 6.0.5 → 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 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
+ ## [6.0.6](https://github.com/snokam/components/compare/@snokam/auth@6.0.5...@snokam/auth@6.0.6) (2026-11-05)
7
+
8
+ ### Bug Fixes
9
+
10
+ - **web:** post go-live fixes for texts, sessions, place search and pickers ([#3218](https://github.com/snokam/components/issues/3218)) ([0fbf0bb](https://github.com/snokam/components/commit/0fbf0bbec53b6cf946684c76fef318aa5cea7403))
11
+
6
12
  ## [6.0.5](https://github.com/snokam/components/compare/@snokam/auth@6.0.4...@snokam/auth@6.0.5) (2026-11-05)
7
13
 
8
14
  **Note:** Version bump only for package @snokam/auth
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@snokam/auth",
3
3
  "description": "Snøkam auth components",
4
- "version": "6.0.5",
4
+ "version": "6.0.6",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
7
7
  "files": [
@@ -8,6 +8,7 @@ import { SessionProvider } from "next-auth/react";
8
8
  import { useAuth } from "../../../hooks";
9
9
  import { EmployeeThemeSync } from "../../../theme/employee-theme-sync";
10
10
  import { ServerSessionContext } from "../../../session/useSession";
11
+ import { ConfirmedSessionProvider } from "../../../session/confirmed";
11
12
 
12
13
  export const Provider = memo((props: Auth.Provider.Props) => {
13
14
  const { session, children } = props;
@@ -15,10 +16,12 @@ export const Provider = memo((props: Auth.Provider.Props) => {
15
16
  return (
16
17
  <ServerSessionContext.Provider value={session ?? null}>
17
18
  <SessionProvider session={session ?? undefined}>
18
- <EmployeeThemeSync />
19
- <Suspense fallback={<>{children}</>}>
20
- <ProviderInner {...props}>{children}</ProviderInner>
21
- </Suspense>
19
+ <ConfirmedSessionProvider>
20
+ <EmployeeThemeSync />
21
+ <Suspense fallback={<>{children}</>}>
22
+ <ProviderInner {...props}>{children}</ProviderInner>
23
+ </Suspense>
24
+ </ConfirmedSessionProvider>
22
25
  </SessionProvider>
23
26
  </ServerSessionContext.Provider>
24
27
  );
package/src/hooks.ts CHANGED
@@ -5,8 +5,17 @@ import { useImpersonation } from "./impersonation/context";
5
5
  import { useOwnIdentity } from "./identity/own";
6
6
  import { useSession } from "./session/useSession";
7
7
  import { isReusable, type CachedToken } from "./token/reuse";
8
-
9
- class ConsentRequiredError extends Error {}
8
+ import {
9
+ confirmSessionState,
10
+ fetchSessionFromServer,
11
+ withRetries,
12
+ } from "./session/confirm";
13
+ import {
14
+ ConsentRequiredError,
15
+ failureOf,
16
+ SignedOutError,
17
+ TransientTokenError,
18
+ } from "./token/failure";
10
19
 
11
20
  type TokenKind = "session" | "own";
12
21
 
@@ -31,25 +40,27 @@ export function clearAuthTokenCache(): void {
31
40
  inFlightTokens.own = null;
32
41
  }
33
42
 
34
- async function fetchToken(kind: TokenKind, impKey: string): Promise<string> {
35
- const res = await fetch("/api/auth/token", {
36
- method: "POST",
37
- body: JSON.stringify(kind === "own" ? { own: true } : {}),
38
- credentials: "include",
39
- headers: {
40
- "Content-Type": "application/json",
41
- },
42
- });
43
-
44
- if (res.status === 403) {
45
- const body = await res.json().catch(() => null);
46
- if (body?.error === "consent_required") {
47
- throw new ConsentRequiredError();
48
- }
43
+ async function requestToken(kind: TokenKind): Promise<Response> {
44
+ try {
45
+ return await fetch("/api/auth/token", {
46
+ method: "POST",
47
+ body: JSON.stringify(kind === "own" ? { own: true } : {}),
48
+ credentials: "include",
49
+ headers: {
50
+ "Content-Type": "application/json",
51
+ },
52
+ });
53
+ } catch {
54
+ throw new TransientTokenError("Token request did not reach the server");
49
55
  }
56
+ }
57
+
58
+ async function fetchToken(kind: TokenKind, impKey: string): Promise<string> {
59
+ const res = await requestToken(kind);
50
60
 
51
61
  if (!res.ok) {
52
- throw new Error("Token fetch failed");
62
+ const body = await res.json().catch(() => null);
63
+ throw failureOf(res.status, body);
53
64
  }
54
65
 
55
66
  const token = await res.json();
@@ -78,7 +89,10 @@ async function getFreshToken(kind: TokenKind): Promise<string> {
78
89
  return inFlight.promise;
79
90
  }
80
91
 
81
- const promise = fetchToken(kind, impKey);
92
+ const promise = withRetries(
93
+ () => fetchToken(kind, impKey),
94
+ (error) => error instanceof TransientTokenError
95
+ );
82
96
  inFlightTokens[kind] = { impKey, promise };
83
97
  promise
84
98
  .catch(() => undefined)
@@ -93,11 +107,20 @@ export function fetchOwnToken(): Promise<string> {
93
107
  return tokenOrLogin("own");
94
108
  }
95
109
 
110
+ async function signInAgainIfSignedOut(error: SignedOutError): Promise<void> {
111
+ const state = await confirmSessionState(fetchSessionFromServer);
112
+ if (state !== "signed-out") throw error;
113
+ const here = `${window.location.pathname}${window.location.search}`;
114
+ window.location.href = `/auth/login?callbackUrl=${encodeURIComponent(here)}`;
115
+ }
116
+
96
117
  function tokenOrLogin(kind: TokenKind): Promise<string> {
97
118
  return getFreshToken(kind).catch((error) => {
98
119
  console.error(error);
120
+ if (error instanceof TransientTokenError) throw error;
99
121
  const onAuthPage = window.location.pathname.startsWith("/auth/");
100
122
  if (onAuthPage) return;
123
+ if (error instanceof SignedOutError) return signInAgainIfSignedOut(error);
101
124
  const reason = error instanceof ConsentRequiredError ? "consent" : "true";
102
125
  window.location.href = `/auth/login?error=${reason}`;
103
126
  }) as Promise<string>;
@@ -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 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
+ }
@@ -7,11 +7,14 @@ import {
7
7
  } from "next-auth/react";
8
8
  import type { Session } from "next-auth";
9
9
  import { useIsHydrating } from "../hydration/useIsHydrating";
10
+ import { ConfirmedSessionContext } from "./confirmed";
10
11
 
11
12
  export const ServerSessionContext = createContext<Session | null>(null);
12
13
 
13
14
  export const useSession = (): SessionContextValue => {
14
- const liveSession = useLiveSession();
15
+ const ownLiveSession = useLiveSession();
16
+ const confirmedSession = useContext(ConfirmedSessionContext);
17
+ const liveSession = confirmedSession ?? ownLiveSession;
15
18
  const serverSession = useContext(ServerSessionContext);
16
19
  const hydrating = useIsHydrating();
17
20
 
@@ -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
+ }