@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 +6 -0
- package/package.json +1 -1
- package/src/app-router/components/Provider/index.tsx +7 -4
- package/src/hooks.ts +42 -19
- 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/hold.test.ts +27 -0
- package/src/session/hold.ts +18 -0
- package/src/session/useSession.ts +4 -1
- package/src/token/failure.test.ts +33 -0
- package/src/token/failure.ts +17 -0
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
|
@@ -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
|
-
<
|
|
19
|
-
|
|
20
|
-
<
|
|
21
|
-
|
|
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
|
-
|
|
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
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
+
}
|