@multiplatform.one/keycloak 7.34.4 → 7.36.0

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 (40) hide show
  1. package/dist/cjs/index.cjs +120 -70
  2. package/dist/cjs/index.native.cjs +283 -148
  3. package/dist/esm/index.js +78 -28
  4. package/dist/esm/index.native.js +270 -135
  5. package/package.json +9 -9
  6. package/src/betterAuth/expoAuthFlow.deadline.spec.ts +230 -0
  7. package/src/betterAuth/expoAuthFlow.native.ts +67 -27
  8. package/src/keycloak/base.ts +3 -2
  9. package/src/keycloak/context.ts +2 -2
  10. package/src/keycloak/index.ts +26 -13
  11. package/src/keycloak/login.error.spec.tsx +85 -0
  12. package/src/provider/KeycloakProvider.loading.spec.tsx +37 -0
  13. package/src/provider/KeycloakProvider.tsx +16 -9
  14. package/src/provider/authProvider/index.native.tsx +170 -108
  15. package/src/provider/authProvider/nonblocking.native.spec.tsx +303 -0
  16. package/src/session/authError.ts +30 -0
  17. package/src/session/index.native.ts +6 -1
  18. package/src/session/index.ts +11 -4
  19. package/types/betterAuth/expoAuthFlow.native.d.ts +3 -1
  20. package/types/betterAuth/expoAuthFlow.native.d.ts.map +1 -1
  21. package/types/keycloak/base.d.ts +1 -1
  22. package/types/keycloak/base.d.ts.map +1 -1
  23. package/types/keycloak/context.d.ts +2 -2
  24. package/types/keycloak/context.d.ts.map +1 -1
  25. package/types/keycloak/index.d.ts +1 -1
  26. package/types/keycloak/index.d.ts.map +1 -1
  27. package/types/keycloak/index.native.d.ts +1 -1
  28. package/types/keycloak/index.native.d.ts.map +1 -1
  29. package/types/keycloak/index.webext.d.ts +1 -1
  30. package/types/keycloak/index.webext.d.ts.map +1 -1
  31. package/types/provider/KeycloakProvider.d.ts +3 -2
  32. package/types/provider/KeycloakProvider.d.ts.map +1 -1
  33. package/types/provider/authProvider/index.native.d.ts +1 -1
  34. package/types/provider/authProvider/index.native.d.ts.map +1 -1
  35. package/types/session/authError.d.ts +7 -0
  36. package/types/session/authError.d.ts.map +1 -0
  37. package/types/session/index.d.ts +2 -0
  38. package/types/session/index.d.ts.map +1 -1
  39. package/types/session/index.native.d.ts +1 -0
  40. package/types/session/index.native.d.ts.map +1 -1
@@ -14,143 +14,205 @@
14
14
  import { useEffect, useMemo, useState } from "react";
15
15
  import { AppState, type AppStateStatus } from "react-native";
16
16
  import * as Linking from "expo-linking";
17
- import { Loading } from "../../Loading";
18
17
  import {
19
18
  createExpoAuthFlow,
20
19
  rewriteLoopbackHostForNative,
21
20
  } from "../../betterAuth/expoAuthFlow.native";
22
21
  import { clearKeycloakBearerToken } from "../../frappeToken.native";
23
- import { KeycloakConfigContext } from "../../keycloak/config";
24
- import { Keycloak } from "../../keycloak/index";
22
+ import { KeycloakConfigContext, type KeycloakConfig } from "../../keycloak/config";
23
+ import { Keycloak } from "../../keycloak/index.native";
25
24
  import type { KeycloakLoginOptions, KeycloakLogoutOptions } from "../../keycloak/base";
26
25
  import { KeycloakContext } from "../../keycloak/context";
27
26
  import { setNativeSession } from "../../session/index.native";
27
+ import { setAuthError } from "../../session/authError";
28
+ import type { Session } from "../../session/index";
28
29
  import { AfterAuth } from "../AfterAuth";
29
30
  import type { AuthProviderProps } from "./shared";
30
31
 
31
- export function AuthProvider({
32
- children,
33
- disabled,
34
- keycloakConfig,
35
- loadingComponent,
36
- }: AuthProviderProps) {
37
- const [keycloak, setKeycloak] = useState<Keycloak>();
38
- const [isLoading, setIsLoading] = useState(true);
32
+ function createController(
33
+ config: KeycloakConfig,
34
+ disabled: boolean | undefined,
35
+ notify: () => void,
36
+ ) {
37
+ const flow = disabled
38
+ ? null
39
+ : createExpoAuthFlow(
40
+ rewriteLoopbackHostForNative(config.betterAuthBaseUrl || "http://localhost:8000"),
41
+ config.expoScheme,
42
+ );
43
+ let active = true;
44
+ let sequence = 0;
45
+ let interactive = false;
46
+ let session: Session | null = null;
47
+ const current = (request: number) => active && request === sequence;
48
+ const controller = {
49
+ keycloak: makeKeycloak(null),
50
+ loading: !disabled,
51
+ start() {
52
+ active = true;
53
+ if (!flow) {
54
+ publish(null);
55
+ } else if (!interactive) {
56
+ void refresh().catch(() => undefined);
57
+ }
58
+ },
59
+ stop() {
60
+ active = false;
61
+ sequence++;
62
+ interactive = false;
63
+ },
64
+ refresh,
65
+ };
66
+
67
+ function makeKeycloak(next: Session | null) {
68
+ const keycloak = new Keycloak(config, undefined, undefined, undefined, login, logout);
69
+ if (next?.user) {
70
+ keycloak.authenticated = true;
71
+ keycloak.subject = next.user.id;
72
+ keycloak.email = next.user.email ?? undefined;
73
+ keycloak.username = next.user.name ?? undefined;
74
+ }
75
+ return keycloak;
76
+ }
77
+
78
+ function publish(next: Session | null, error?: Error) {
79
+ session = next;
80
+ controller.keycloak = makeKeycloak(next);
81
+ controller.loading = false;
82
+ setAuthError(error);
83
+ setNativeSession(next);
84
+ notify();
85
+ }
86
+
87
+ function begin() {
88
+ const request = ++sequence;
89
+ controller.loading = true;
90
+ setAuthError();
91
+ setNativeSession(session, "loading");
92
+ notify();
93
+ return request;
94
+ }
39
95
 
40
- // Resolve the Better Auth server base URL from keycloak config.
41
- // On native, the keycloakConfig.url is the Keycloak server — the Better Auth
42
- // server is the app backend (CreateApp wires BETTER_AUTH_BASE_URL/BASE_URL).
43
- // Loopback hosts are rewritten for the device (127.0.0.1 / 10.0.2.2): see
44
- // rewriteLoopbackHostForNative.
45
- const serverBaseUrl = useMemo(() => {
46
- return rewriteLoopbackHostForNative(
47
- keycloakConfig.betterAuthBaseUrl || "http://localhost:8000",
96
+ async function probe(request: number) {
97
+ const next = await flow!.getSession();
98
+ if (!current(request)) return;
99
+ publish(
100
+ next?.user
101
+ ? {
102
+ user: {
103
+ id: next.user.id,
104
+ name: next.user.name ?? null,
105
+ email: next.user.email ?? null,
106
+ },
107
+ }
108
+ : null,
48
109
  );
49
- }, [keycloakConfig]);
110
+ }
50
111
 
51
- const scheme = useMemo(() => keycloakConfig.expoScheme, [keycloakConfig]);
112
+ function fail(request: number, cause: unknown): never {
113
+ const error = cause instanceof Error ? cause : new Error(String(cause));
114
+ if (current(request)) publish(null, error);
115
+ throw error;
116
+ }
52
117
 
53
- const flow = useMemo(
54
- () => (disabled ? null : createExpoAuthFlow(serverBaseUrl, scheme)),
55
- [disabled, serverBaseUrl, scheme],
56
- );
118
+ async function refresh() {
119
+ if (!active || !flow || interactive) return;
120
+ const request = begin();
121
+ try {
122
+ await probe(request);
123
+ } catch (error) {
124
+ fail(request, error);
125
+ }
126
+ }
57
127
 
58
- useEffect(() => {
59
- if (disabled || !flow) {
60
- setIsLoading(false);
61
- return;
128
+ async function login(options: KeycloakLoginOptions) {
129
+ if (!active || !flow) return undefined;
130
+ interactive = true;
131
+ const request = begin();
132
+ try {
133
+ await flow.signIn("keycloak", options.redirectUri || Linking.createURL("/"));
134
+ if (!current(request)) return undefined;
135
+ clearKeycloakBearerToken();
136
+ await probe(request);
137
+ } catch (error) {
138
+ fail(request, error);
139
+ } finally {
140
+ if (current(request)) interactive = false;
62
141
  }
63
- let alive = true;
142
+ return undefined;
143
+ }
64
144
 
65
- // Probe the session, build a Keycloak instance reflecting it, and push the
66
- // result into the native session store (useSession's backing).
67
- const refresh = async () => {
68
- let session: Awaited<ReturnType<typeof flow.getSession>> = null;
69
- try {
70
- session = await flow.getSession();
71
- } catch (err) {
72
- console.error("[keycloak] native session check failed:", err);
145
+ async function logout(options: KeycloakLogoutOptions) {
146
+ if (!active || !flow) return undefined;
147
+ interactive = true;
148
+ const request = ++sequence;
149
+ clearKeycloakBearerToken();
150
+ publish(null);
151
+ try {
152
+ const redirectUri = options.redirectUri?.includes("://")
153
+ ? options.redirectUri
154
+ : Linking.createURL("/");
155
+ await flow.signOut(redirectUri);
156
+ } catch (error) {
157
+ fail(request, error);
158
+ } finally {
159
+ if (current(request)) {
160
+ clearKeycloakBearerToken();
161
+ interactive = false;
73
162
  }
74
- const _keycloak = new Keycloak(
75
- keycloakConfig,
76
- undefined,
77
- undefined,
78
- undefined,
79
- async (options: KeycloakLoginOptions) => {
80
- // Default the OAuth return URL to the app's own runtime URL: Expo Go
81
- // can only receive exp:// redirects (Linking.createURL derives
82
- // exp://<host>/--/ there; the bare app scheme in dev builds).
83
- await flow.signIn("keycloak", options.redirectUri || Linking.createURL("/"));
84
- // Drop any signed-out cooldown/stale token so the first Frappe
85
- // request after sign-in fetches a fresh Bearer token.
86
- clearKeycloakBearerToken();
87
- await refresh();
88
- return undefined;
89
- },
90
- async (options: KeycloakLogoutOptions) => {
91
- // Single sign-out: ends the Better Auth session AND rides the
92
- // Keycloak end-session endpoint through the system browser (see
93
- // expoAuthFlow.signOut). Only custom-scheme URLs make sense as the
94
- // post-logout return on native; web-style paths fall back to the
95
- // app's own runtime URL.
96
- const redirectUri =
97
- options.redirectUri && options.redirectUri.includes("://")
98
- ? options.redirectUri
99
- : Linking.createURL("/");
100
- await flow.signOut(redirectUri);
101
- clearKeycloakBearerToken();
102
- await refresh();
103
- return undefined;
104
- },
105
- );
106
- if (session?.user) {
107
- _keycloak.authenticated = true;
108
- _keycloak.email = session.user.email || undefined;
109
- _keycloak.username = session.user.name || undefined;
110
- _keycloak.subject = session.user.id;
111
- }
112
- if (!alive) return;
113
- setNativeSession(
114
- session?.user
115
- ? {
116
- user: {
117
- id: session.user.id,
118
- name: session.user.name ?? null,
119
- email: session.user.email ?? null,
120
- },
121
- }
122
- : null,
123
- );
124
- setKeycloak(_keycloak);
125
- };
163
+ }
164
+ return undefined;
165
+ }
126
166
 
127
- refresh().finally(() => {
128
- if (alive) setIsLoading(false);
129
- });
167
+ return controller;
168
+ }
130
169
 
131
- // Re-probe when the app returns to the foreground: covers a completed
132
- // ASWebAuthenticationSession whose Linking event arrived after dismiss,
133
- // and a user who abandoned the end-session page (local jar already
134
- // empty; this just refreshes the header).
135
- const onAppState = (next: AppStateStatus) => {
136
- if (next === "active") refresh().catch(() => undefined);
137
- };
138
- const appSub = AppState.addEventListener("change", onAppState);
170
+ export function AuthProvider({
171
+ children,
172
+ disabled,
173
+ keycloakConfig,
174
+ loadingComponent: LoadingComponent,
175
+ }: AuthProviderProps) {
176
+ const [, setRevision] = useState(0);
177
+ const { url, realm, clientId, publicClientId, betterAuthBaseUrl, expoScheme } = keycloakConfig;
178
+ const scopes = JSON.stringify(keycloakConfig.scopes);
179
+ const controller = useMemo(
180
+ () =>
181
+ createController(
182
+ {
183
+ url,
184
+ realm,
185
+ clientId,
186
+ publicClientId,
187
+ betterAuthBaseUrl,
188
+ expoScheme,
189
+ scopes: scopes ? JSON.parse(scopes) : undefined,
190
+ },
191
+ disabled,
192
+ () => setRevision((revision) => revision + 1),
193
+ ),
194
+ [url, realm, clientId, publicClientId, betterAuthBaseUrl, expoScheme, scopes, disabled],
195
+ );
139
196
 
197
+ useEffect(() => {
198
+ if (disabled) return;
199
+ controller.start();
200
+ const appSub = AppState.addEventListener("change", (next: AppStateStatus) => {
201
+ if (next === "active") void controller.refresh().catch(() => undefined);
202
+ });
140
203
  return () => {
141
- alive = false;
204
+ controller.stop();
142
205
  appSub.remove();
143
206
  };
144
- }, [disabled, flow, keycloakConfig]);
207
+ }, [controller, disabled]);
145
208
 
146
209
  if (disabled) return <>{children}</>;
147
- if (isLoading || !keycloak) {
148
- return <Loading loadingComponent={loadingComponent} />;
149
- }
210
+
150
211
  return (
151
- <KeycloakConfigContext.Provider value={keycloakConfig}>
152
- <KeycloakContext.Provider value={keycloak}>
212
+ <KeycloakConfigContext.Provider value={controller.keycloak.config}>
213
+ <KeycloakContext.Provider value={controller.keycloak}>
153
214
  <AfterAuth>{children}</AfterAuth>
215
+ {controller.loading && LoadingComponent ? <LoadingComponent /> : null}
154
216
  </KeycloakContext.Provider>
155
217
  </KeycloakConfigContext.Provider>
156
218
  );
@@ -0,0 +1,303 @@
1
+ // @vitest-environment happy-dom
2
+ import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
3
+ import { useContext, useEffect, useState, type PropsWithChildren } from "react";
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+ import { AppState } from "react-native";
6
+ import { setAuthError } from "../../session/authError";
7
+ import { AuthProvider } from "./index.native";
8
+ import { KeycloakContext } from "../../keycloak/context";
9
+ import { setNativeSession, useSession } from "../../session/index.native";
10
+ import type { KeycloakConfig } from "../../keycloak/config";
11
+
12
+ const { appState, clearBearer, jar, afterAuth } = vi.hoisted(() => ({
13
+ appState: { current: undefined as undefined | ((state: string) => void), remove: vi.fn() },
14
+ clearBearer: vi.fn(),
15
+ jar: new Map<string, string>(),
16
+ afterAuth: vi.fn(),
17
+ }));
18
+ vi.mock("@multiplatform.one/platform", () => ({
19
+ isBrowser: false,
20
+ isServer: false,
21
+ isStorybook: false,
22
+ }));
23
+ vi.mock("expo-linking", () => ({
24
+ createURL: () => "test://home",
25
+ addEventListener: () => ({ remove() {} }),
26
+ }));
27
+ vi.mock("expo-secure-store", () => ({
28
+ getItem: (key: string) => jar.get(key),
29
+ setItem: (key: string, value: string) => jar.set(key, value),
30
+ }));
31
+ vi.mock("expo-web-browser", () => ({
32
+ openAuthSessionAsync: async () => ({ type: "success", url: "test://home?cookie=session=new" }),
33
+ dismissAuthSession: vi.fn(),
34
+ }));
35
+ vi.mock("../../frappeToken.native", () => ({ clearKeycloakBearerToken: clearBearer }));
36
+ vi.mock("../../hooks/index", () => ({ useAuthConfig: () => ({}) }));
37
+ vi.mock("../../betterAuth/client", () => ({ getAuthClient: vi.fn() }));
38
+ vi.mock("../../Loading", () => ({
39
+ Loading: ({ loadingComponent: Slot }: { loadingComponent?: import("react").ComponentType }) =>
40
+ Slot ? <Slot /> : null,
41
+ }));
42
+ vi.mock("../AfterAuth", () => ({
43
+ AfterAuth: ({ children }: PropsWithChildren) => {
44
+ afterAuth();
45
+ return children;
46
+ },
47
+ }));
48
+
49
+ const config: KeycloakConfig = {
50
+ url: "https://idp.test/auth",
51
+ realm: "test",
52
+ clientId: "app",
53
+ betterAuthBaseUrl: "https://app.test",
54
+ };
55
+ const mounted = vi.fn();
56
+ let currentKeycloak: import("../../keycloak/base").BaseKeycloak | undefined | null;
57
+ let sessionState: ReturnType<typeof useSession>;
58
+ function Child() {
59
+ currentKeycloak = useContext(KeycloakContext);
60
+ sessionState = useSession();
61
+ const [count, setCount] = useState(0);
62
+ useEffect(() => {
63
+ mounted();
64
+ }, []);
65
+ return (
66
+ <>
67
+ <button onClick={() => setCount(count + 1)}>child {count}</button>
68
+ <output>{sessionState.status}</output>
69
+ <span>{sessionState.error?.message}</span>
70
+ </>
71
+ );
72
+ }
73
+ function Slot() {
74
+ return <div>auth slot</div>;
75
+ }
76
+ const json = (data: unknown) => new Response(JSON.stringify(data));
77
+ const user = { user: { id: "fresh", name: "New User" } };
78
+ function deferred<T>() {
79
+ let resolve!: (value: T) => void;
80
+ const promise = new Promise<T>((done) => {
81
+ resolve = done;
82
+ });
83
+ return { promise, resolve };
84
+ }
85
+ function mount(cfg = config) {
86
+ return render(
87
+ <AuthProvider keycloakConfig={cfg} loadingComponent={Slot}>
88
+ <Child />
89
+ </AuthProvider>,
90
+ );
91
+ }
92
+
93
+ beforeEach(() => {
94
+ vi.useFakeTimers();
95
+ vi.spyOn(AppState, "addEventListener").mockImplementation((_event, callback) => {
96
+ appState.current = callback as (state: string) => void;
97
+ return { remove: appState.remove };
98
+ });
99
+ setAuthError();
100
+ currentKeycloak = undefined;
101
+ mounted.mockClear();
102
+ afterAuth.mockClear();
103
+ clearBearer.mockClear();
104
+ appState.remove.mockClear();
105
+ jar.clear();
106
+ setNativeSession(null, "loading");
107
+ });
108
+ afterEach(() => {
109
+ cleanup();
110
+ vi.restoreAllMocks();
111
+ vi.useRealTimers();
112
+ vi.unstubAllGlobals();
113
+ });
114
+
115
+ describe("nonblocking native auth", () => {
116
+ it("bypasses auth effects and context when disabled", () => {
117
+ const fetchMock = vi.fn();
118
+ vi.stubGlobal("fetch", fetchMock);
119
+ render(
120
+ <AuthProvider disabled keycloakConfig={config} loadingComponent={Slot}>
121
+ <Child />
122
+ </AuthProvider>,
123
+ );
124
+ expect(screen.queryByText("child 0")).not.toBeNull();
125
+ expect(screen.queryByText("auth slot")).toBeNull();
126
+ expect(currentKeycloak).toBeUndefined();
127
+ expect(afterAuth).not.toHaveBeenCalled();
128
+ expect(AppState.addEventListener).not.toHaveBeenCalled();
129
+ expect(fetchMock).not.toHaveBeenCalled();
130
+ });
131
+
132
+ it("renders a usable login and preserves child state through timeout and a successful retry", async () => {
133
+ const fetchMock = vi
134
+ .fn()
135
+ .mockImplementationOnce(() => new Promise(() => {}))
136
+ .mockResolvedValue(json(user));
137
+ vi.stubGlobal("fetch", fetchMock);
138
+ mount();
139
+ expect(screen.queryByText("child 0")).not.toBeNull();
140
+ expect(screen.queryByText("auth slot")).not.toBeNull();
141
+ expect(currentKeycloak?.login).toBeTypeOf("function");
142
+ expect(currentKeycloak?.authenticated).toBe(false);
143
+ fireEvent.click(screen.getByText("child 0"));
144
+ await act(() => vi.advanceTimersByTimeAsync(10_000));
145
+ expect(sessionState.status).toBe("unauthenticated");
146
+ expect(sessionState.error?.name).toBe("AuthTimeoutError");
147
+ expect(screen.queryByText("auth slot")).toBeNull();
148
+ expect(screen.queryByText("child 1")).not.toBeNull();
149
+ expect(appState.current).toBeTypeOf("function");
150
+ await act(async () => {
151
+ appState.current?.("active");
152
+ await vi.advanceTimersByTimeAsync(0);
153
+ });
154
+ expect(sessionState.status).toBe("authenticated");
155
+ expect(sessionState.error).toBeUndefined();
156
+ expect(currentKeycloak?.subject).toBe("fresh");
157
+ expect(mounted).toHaveBeenCalledOnce();
158
+ });
159
+
160
+ it("renders children while loading without a loading slot", () => {
161
+ vi.stubGlobal(
162
+ "fetch",
163
+ vi.fn(() => new Promise(() => {})),
164
+ );
165
+ render(
166
+ <AuthProvider keycloakConfig={config}>
167
+ <Child />
168
+ </AuthProvider>,
169
+ );
170
+ expect(screen.queryByText("child 0")).not.toBeNull();
171
+ expect(sessionState.status).toBe("loading");
172
+ });
173
+
174
+ it("login during the initial probe wins over its late signed-out response", async () => {
175
+ const probe = deferred<Response>();
176
+ vi.stubGlobal(
177
+ "fetch",
178
+ vi
179
+ .fn()
180
+ .mockReturnValueOnce(probe.promise)
181
+ .mockResolvedValueOnce(json({ url: "https://idp.test/login" }))
182
+ .mockResolvedValueOnce(json(user)),
183
+ );
184
+ mount();
185
+ expect(currentKeycloak).toBeDefined();
186
+ await act(async () => {
187
+ await currentKeycloak!.login();
188
+ });
189
+ expect(sessionState.status).toBe("authenticated");
190
+ expect(currentKeycloak?.authenticated).toBe(true);
191
+ await act(async () => {
192
+ probe.resolve(json(null));
193
+ });
194
+ expect(sessionState.status).toBe("authenticated");
195
+ expect(currentKeycloak?.authenticated).toBe(true);
196
+ expect(mounted).toHaveBeenCalledOnce();
197
+ });
198
+
199
+ it("rejects native sign-in, publishes the same error, and clears it on retry", async () => {
200
+ const failure = new Error("login offline");
201
+ vi.stubGlobal(
202
+ "fetch",
203
+ vi
204
+ .fn()
205
+ .mockResolvedValueOnce(json(null))
206
+ .mockRejectedValueOnce(failure)
207
+ .mockResolvedValueOnce(json({ url: "https://idp.test/login" }))
208
+ .mockResolvedValueOnce(json(user)),
209
+ );
210
+ await act(async () => {
211
+ mount();
212
+ await vi.advanceTimersByTimeAsync(0);
213
+ });
214
+ await act(async () => {
215
+ await expect(currentKeycloak!.login()).rejects.toBe(failure);
216
+ });
217
+ expect(sessionState.error).toBe(failure);
218
+ await act(async () => {
219
+ await currentKeycloak!.login();
220
+ });
221
+ expect(sessionState.error).toBeUndefined();
222
+ expect(currentKeycloak?.authenticated).toBe(true);
223
+ });
224
+
225
+ it("clears session and bearer state when remote logout fails and ignores a stale probe", async () => {
226
+ const stale = deferred<Response>();
227
+ const failure = new Error("logout offline");
228
+ vi.stubGlobal(
229
+ "fetch",
230
+ vi
231
+ .fn()
232
+ .mockResolvedValueOnce(json(user))
233
+ .mockReturnValueOnce(stale.promise)
234
+ .mockRejectedValue(failure),
235
+ );
236
+ await act(async () => {
237
+ mount();
238
+ await vi.advanceTimersByTimeAsync(0);
239
+ });
240
+ expect(appState.current).toBeTypeOf("function");
241
+ await act(async () => {
242
+ appState.current?.("active");
243
+ await vi.advanceTimersByTimeAsync(0);
244
+ });
245
+ await act(async () => {
246
+ await expect(currentKeycloak!.logout()).rejects.toBe(failure);
247
+ });
248
+ expect(sessionState.status).toBe("unauthenticated");
249
+ expect(sessionState.error).toBe(failure);
250
+ expect(currentKeycloak?.authenticated).toBe(false);
251
+ expect(clearBearer).toHaveBeenCalled();
252
+ await act(async () => {
253
+ stale.resolve(json(user));
254
+ });
255
+ expect(sessionState.status).toBe("unauthenticated");
256
+ expect(sessionState.error).toBe(failure);
257
+ });
258
+
259
+ it("ignores old configuration and unmounted probes without restarting identical config", async () => {
260
+ const old = deferred<Response>();
261
+ const last = deferred<Response>();
262
+ const fetchMock = vi
263
+ .fn()
264
+ .mockReturnValueOnce(old.promise)
265
+ .mockResolvedValueOnce(json(user))
266
+ .mockReturnValueOnce(last.promise);
267
+ vi.stubGlobal("fetch", fetchMock);
268
+ const view = mount();
269
+ view.rerender(
270
+ <AuthProvider keycloakConfig={{ ...config }} loadingComponent={Slot}>
271
+ <Child />
272
+ </AuthProvider>,
273
+ );
274
+ expect(fetchMock).toHaveBeenCalledOnce();
275
+ await act(async () => {
276
+ view.rerender(
277
+ <AuthProvider
278
+ keycloakConfig={{ ...config, betterAuthBaseUrl: "https://new.test" }}
279
+ loadingComponent={Slot}
280
+ >
281
+ <Child />
282
+ </AuthProvider>,
283
+ );
284
+ });
285
+ expect(sessionState.session?.user?.id).toBe("fresh");
286
+ await act(async () => {
287
+ old.resolve(json({ user: { id: "old" } }));
288
+ });
289
+ expect(sessionState.session?.user?.id).toBe("fresh");
290
+ expect(appState.current).toBeTypeOf("function");
291
+ await act(async () => {
292
+ appState.current?.("active");
293
+ await vi.advanceTimersByTimeAsync(0);
294
+ });
295
+ view.unmount();
296
+ await act(async () => {
297
+ last.resolve(json({ user: { id: "unmounted" } }));
298
+ });
299
+ render(<Child />);
300
+ expect(sessionState.session?.user?.id).not.toBe("unmounted");
301
+ expect(appState.remove).toHaveBeenCalled();
302
+ });
303
+ });
@@ -0,0 +1,30 @@
1
+ import { useSyncExternalStore } from "react";
2
+
3
+ let error: Error | undefined;
4
+ const listeners = new Set<() => void>();
5
+
6
+ export class AuthTimeoutError extends Error {
7
+ constructor(timeoutMs = 10_000) {
8
+ super(`Authentication request timed out after ${timeoutMs}ms`);
9
+ this.name = "AuthTimeoutError";
10
+ }
11
+ }
12
+
13
+ export function setAuthError(next?: Error) {
14
+ if (error === next) return;
15
+ error = next;
16
+ for (const listener of listeners) listener();
17
+ }
18
+
19
+ export function getAuthError() {
20
+ return error;
21
+ }
22
+
23
+ function subscribe(listener: () => void) {
24
+ listeners.add(listener);
25
+ return () => listeners.delete(listener);
26
+ }
27
+
28
+ export function useAuthError() {
29
+ return useSyncExternalStore(subscribe, getAuthError, getAuthError);
30
+ }
@@ -7,6 +7,9 @@
7
7
  */
8
8
 
9
9
  import { useSyncExternalStore } from "react";
10
+ import { useAuthError } from "./authError";
11
+
12
+ export * from "./authError";
10
13
  import type { Session, SessionContextValue, SessionStatus } from "./index";
11
14
 
12
15
  interface NativeSessionState {
@@ -38,9 +41,11 @@ export async function getSession() {
38
41
  export function useSession<R extends boolean = false>(
39
42
  _options?: Record<string, unknown>,
40
43
  ): SessionContextValue<R> {
41
- return useSyncExternalStore(
44
+ const session = useSyncExternalStore(
42
45
  subscribe,
43
46
  () => state,
44
47
  () => state,
45
48
  );
49
+ const error = useAuthError();
50
+ return { ...session, error };
46
51
  }