@multiplatform.one/keycloak 7.34.3 → 7.35.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
@@ -0,0 +1,230 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { createExpoAuthFlow } from "./expoAuthFlow.native";
3
+ import { resetAccessTokenSelection } from "./accountSelection";
4
+
5
+ const { jar, browser } = vi.hoisted(() => ({
6
+ jar: new Map<string, string>(),
7
+ browser: vi.fn(),
8
+ }));
9
+ vi.mock("expo-secure-store", () => ({
10
+ getItem: (key: string) => jar.get(key),
11
+ setItem: (key: string, value: string) => jar.set(key, value),
12
+ }));
13
+ vi.mock("expo-linking", () => ({
14
+ createURL: () => "test://home",
15
+ addEventListener: () => ({ remove() {} }),
16
+ }));
17
+ vi.mock("expo-web-browser", () => ({
18
+ openAuthSessionAsync: browser,
19
+ dismissAuthSession: vi.fn(),
20
+ }));
21
+
22
+ const base = "https://app.test/prefix";
23
+ const json = (data: unknown, status = 200) => new Response(JSON.stringify(data), { status });
24
+ const operations = {
25
+ session: (flow: ReturnType<typeof createExpoAuthFlow>) => flow.getSession(),
26
+ token: (flow: ReturnType<typeof createExpoAuthFlow>) => flow.getAccessToken("keycloak"),
27
+ login: (flow: ReturnType<typeof createExpoAuthFlow>) => flow.signIn("keycloak"),
28
+ logout: (flow: ReturnType<typeof createExpoAuthFlow>) => flow.signOut(),
29
+ };
30
+
31
+ beforeEach(() => {
32
+ vi.useFakeTimers();
33
+ jar.clear();
34
+ browser.mockReset();
35
+ resetAccessTokenSelection();
36
+ });
37
+ afterEach(() => {
38
+ vi.useRealTimers();
39
+ vi.unstubAllGlobals();
40
+ });
41
+
42
+ describe("native auth deadlines", () => {
43
+ for (const [name, operation] of Object.entries(operations)) {
44
+ for (const stage of ["fetch", "body"] as const) {
45
+ it(`bounds ${name} when ${stage} ignores abort`, async () => {
46
+ const signals: AbortSignal[] = [];
47
+ const fetchMock = vi.fn((_url: string, init: RequestInit) => {
48
+ signals.push(init.signal as AbortSignal);
49
+ if (stage === "fetch") return new Promise<Response>(() => {});
50
+ return Promise.resolve({
51
+ ok: true,
52
+ status: 200,
53
+ headers: new Headers(),
54
+ text: () => new Promise<string>(() => {}),
55
+ json: () => new Promise<unknown>(() => {}),
56
+ });
57
+ });
58
+ vi.stubGlobal("fetch", fetchMock);
59
+ const outcome = vi.fn();
60
+ void operation(createExpoAuthFlow(base, undefined, { timeoutMs: 20 })).then(
61
+ outcome,
62
+ outcome,
63
+ );
64
+ await vi.advanceTimersByTimeAsync(41);
65
+ expect(outcome).toHaveBeenCalledWith(expect.objectContaining({ name: "AuthTimeoutError" }));
66
+ expect(signals.every((signal) => signal?.aborted)).toBe(true);
67
+ expect(vi.getTimerCount()).toBe(0);
68
+ });
69
+ }
70
+ }
71
+
72
+ it("defaults to ten seconds and ignores late response cookies", async () => {
73
+ let resolve!: (value: Response) => void;
74
+ vi.stubGlobal(
75
+ "fetch",
76
+ vi.fn(
77
+ () =>
78
+ new Promise<Response>((done) => {
79
+ resolve = done;
80
+ }),
81
+ ),
82
+ );
83
+ const outcome = vi.fn();
84
+ void createExpoAuthFlow(base).getSession().then(outcome, outcome);
85
+ await vi.advanceTimersByTimeAsync(9999);
86
+ expect(outcome).not.toHaveBeenCalled();
87
+ await vi.advanceTimersByTimeAsync(1);
88
+ expect(outcome).toHaveBeenCalledWith(expect.objectContaining({ name: "AuthTimeoutError" }));
89
+ resolve(new Response("null", { headers: { "set-cookie": "late=bad" } }));
90
+ await vi.advanceTimersByTimeAsync(0);
91
+ expect(jar.size).toBe(0);
92
+ expect(vi.getTimerCount()).toBe(0);
93
+ });
94
+
95
+ it("ignores late body cookies", async () => {
96
+ let resolve!: (value: string) => void;
97
+ const body = new Promise<string>((done) => {
98
+ resolve = done;
99
+ });
100
+ vi.stubGlobal(
101
+ "fetch",
102
+ vi.fn(async () => ({
103
+ ok: true,
104
+ status: 200,
105
+ headers: new Headers({ "set-cookie": "late=bad" }),
106
+ text: () => body,
107
+ json: () => body.then(JSON.parse),
108
+ })),
109
+ );
110
+ const outcome = vi.fn();
111
+ void createExpoAuthFlow(base, undefined, { timeoutMs: 20 }).getSession().then(outcome, outcome);
112
+ await vi.advanceTimersByTimeAsync(20);
113
+ resolve("null");
114
+ await vi.advanceTimersByTimeAsync(0);
115
+ expect(outcome).toHaveBeenCalledWith(expect.objectContaining({ name: "AuthTimeoutError" }));
116
+ expect(jar.size).toBe(0);
117
+ });
118
+
119
+ it("clears timers on success and network rejection", async () => {
120
+ const failure = new Error("offline");
121
+ vi.stubGlobal(
122
+ "fetch",
123
+ vi.fn().mockResolvedValueOnce(json(null)).mockRejectedValueOnce(failure),
124
+ );
125
+ const flow = createExpoAuthFlow(base);
126
+ await expect(flow.getSession()).resolves.toBeNull();
127
+ expect(vi.getTimerCount()).toBe(0);
128
+ await expect(flow.getSession()).rejects.toBe(failure);
129
+ expect(vi.getTimerCount()).toBe(0);
130
+ });
131
+
132
+ it("bounds the negotiated token retry", async () => {
133
+ const fetchMock = vi
134
+ .fn()
135
+ .mockResolvedValueOnce(json({ code: "VALIDATION_ERROR" }, 400))
136
+ .mockImplementationOnce(() => new Promise(() => {}));
137
+ vi.stubGlobal("fetch", fetchMock);
138
+ const outcome = vi.fn();
139
+ void createExpoAuthFlow(base, undefined, { timeoutMs: 20 })
140
+ .getAccessToken("keycloak")
141
+ .then(outcome, outcome);
142
+ await vi.advanceTimersByTimeAsync(20);
143
+ expect(fetchMock).toHaveBeenCalledTimes(2);
144
+ expect(outcome).toHaveBeenCalledWith(expect.objectContaining({ name: "AuthTimeoutError" }));
145
+ expect(vi.getTimerCount()).toBe(0);
146
+ });
147
+
148
+ it("does not time out the interactive sign-in browser", async () => {
149
+ vi.stubGlobal(
150
+ "fetch",
151
+ vi.fn(async () => json({ url: "https://idp.test/login" })),
152
+ );
153
+ let finish!: (result: unknown) => void;
154
+ browser.mockImplementation(
155
+ () =>
156
+ new Promise((resolve) => {
157
+ finish = resolve;
158
+ }),
159
+ );
160
+ const outcome = vi.fn();
161
+ void createExpoAuthFlow(base, undefined, { timeoutMs: 20 })
162
+ .signIn("keycloak")
163
+ .then(outcome, outcome);
164
+ await vi.advanceTimersByTimeAsync(60_000);
165
+ expect(browser).toHaveBeenCalledOnce();
166
+ expect(outcome).not.toHaveBeenCalled();
167
+ finish({ type: "success", url: "test://home?cookie=session=ok" });
168
+ await vi.advanceTimersByTimeAsync(0);
169
+ expect(outcome).toHaveBeenCalledWith(undefined);
170
+ });
171
+ });
172
+
173
+ describe("native auth failures", () => {
174
+ it.each(["null", "", "{}"])("accepts signed-out session body %j", async (body) => {
175
+ vi.stubGlobal(
176
+ "fetch",
177
+ vi.fn(async () => new Response(body)),
178
+ );
179
+ await expect(createExpoAuthFlow(base).getSession()).resolves.toBeNull();
180
+ });
181
+ it("accepts a 401 session response", async () => {
182
+ vi.stubGlobal(
183
+ "fetch",
184
+ vi.fn(async () => new Response("unauthorized", { status: 401 })),
185
+ );
186
+ await expect(createExpoAuthFlow(base).getSession()).resolves.toBeNull();
187
+ });
188
+ it.each(["session", "token"] as const)("rejects HTTP failures for %s", async (name) => {
189
+ vi.stubGlobal(
190
+ "fetch",
191
+ vi.fn(async () => new Response("unavailable", { status: 503 })),
192
+ );
193
+ await expect(operations[name](createExpoAuthFlow(base))).rejects.toThrow(/503/);
194
+ });
195
+ it.each(["session", "token"] as const)("rejects invalid successful JSON for %s", async (name) => {
196
+ vi.stubGlobal(
197
+ "fetch",
198
+ vi.fn(async () => new Response("<html>error</html>")),
199
+ );
200
+ await expect(operations[name](createExpoAuthFlow(base))).rejects.toThrow();
201
+ });
202
+ it("preserves signed-out token responses", async () => {
203
+ vi.stubGlobal(
204
+ "fetch",
205
+ vi.fn(async () => json({ code: "UNAUTHORIZED" }, 401)),
206
+ );
207
+ await expect(createExpoAuthFlow(base).getAccessToken("keycloak")).resolves.toBeNull();
208
+ });
209
+ it("rejects when both sign-out endpoints fail and still clears local cookies", async () => {
210
+ jar.set("better-auth_cookie", '{"session":{"value":"old","expires":null}}');
211
+ vi.stubGlobal(
212
+ "fetch",
213
+ vi.fn(async () => new Response("offline", { status: 503 })),
214
+ );
215
+ await expect(createExpoAuthFlow(base).signOut()).rejects.toThrow(/503/);
216
+ expect(jar.get("better-auth_cookie")).toBe("{}");
217
+ });
218
+ it("rejects a network failure even when plain sign-out succeeds", async () => {
219
+ const failure = new Error("SSO offline");
220
+ vi.stubGlobal(
221
+ "fetch",
222
+ vi
223
+ .fn()
224
+ .mockRejectedValueOnce(failure)
225
+ .mockResolvedValueOnce(json({ success: true })),
226
+ );
227
+ await expect(createExpoAuthFlow(base).signOut()).rejects.toBe(failure);
228
+ expect(jar.get("better-auth_cookie")).toBe("{}");
229
+ });
230
+ });
@@ -17,6 +17,7 @@ import * as Linking from "expo-linking";
17
17
  import * as SecureStore from "expo-secure-store";
18
18
  import * as WebBrowser from "expo-web-browser";
19
19
  import { requestAccessToken } from "./accountSelection";
20
+ import { AuthTimeoutError } from "../session/authError";
20
21
 
21
22
  try {
22
23
  WebBrowser.maybeCompleteAuthSession?.();
@@ -264,25 +265,55 @@ export function getActiveExpoAuthFlow(): ExpoAuthFlow | null {
264
265
  return activeFlow;
265
266
  }
266
267
 
267
- export function createExpoAuthFlow(baseURL: string, scheme?: string): ExpoAuthFlow {
268
+ export function createExpoAuthFlow(
269
+ baseURL: string,
270
+ scheme?: string,
271
+ options?: { timeoutMs?: number },
272
+ ): ExpoAuthFlow {
268
273
  const base = baseURL.replace(/\/$/, "");
269
274
  const origin = () => Linking.createURL("/", scheme ? { scheme } : undefined);
270
275
 
271
276
  async function authFetch(path: string, init?: RequestInit) {
272
- const cookie = cookieHeader();
273
- const res = await fetch(`${base}/api/auth${path}`, {
274
- ...init,
275
- credentials: "omit",
276
- headers: {
277
- "Content-Type": "application/json",
278
- ...(cookie ? { cookie } : {}),
279
- "expo-origin": origin(),
280
- "x-skip-oauth-proxy": "true",
281
- ...init?.headers,
282
- },
277
+ const controller = new AbortController();
278
+ const timeoutMs = options?.timeoutMs ?? 10_000;
279
+ let timer: ReturnType<typeof setTimeout> | undefined;
280
+ const deadline = new Promise<never>((_resolve, reject) => {
281
+ timer = setTimeout(() => {
282
+ reject(new AuthTimeoutError(timeoutMs));
283
+ controller.abort();
284
+ }, timeoutMs);
283
285
  });
284
- absorbSetCookie(res.headers.get("set-cookie"));
285
- return res;
286
+ try {
287
+ const buffered = await Promise.race([
288
+ (async () => {
289
+ const cookie = cookieHeader();
290
+ const response = await fetch(`${base}/api/auth${path}`, {
291
+ ...init,
292
+ signal: controller.signal,
293
+ credentials: "omit",
294
+ headers: {
295
+ "Content-Type": "application/json",
296
+ ...(cookie ? { cookie } : {}),
297
+ "expo-origin": origin(),
298
+ "x-skip-oauth-proxy": "true",
299
+ ...init?.headers,
300
+ },
301
+ });
302
+ const text = await response.text();
303
+ return { response, text };
304
+ })(),
305
+ deadline,
306
+ ]);
307
+ absorbSetCookie(buffered.response.headers.get("set-cookie"));
308
+ return {
309
+ status: buffered.response.status,
310
+ ok: buffered.response.ok,
311
+ text: buffered.text,
312
+ json: () => (buffered.text.trim() ? JSON.parse(buffered.text) : null),
313
+ };
314
+ } finally {
315
+ clearTimeout(timer);
316
+ }
286
317
  }
287
318
 
288
319
  const flow: ExpoAuthFlow = {
@@ -293,10 +324,10 @@ export function createExpoAuthFlow(baseURL: string, scheme?: string): ExpoAuthFl
293
324
  body: JSON.stringify({ provider, callbackURL: to }),
294
325
  });
295
326
  if (!res.ok) {
296
- const body = await res.text().catch(() => "");
327
+ const body = res.text;
297
328
  throw new Error(`sign-in failed (${res.status}): ${body.slice(0, 200)}`);
298
329
  }
299
- const data = (await res.json()) as { url?: string; redirect?: boolean };
330
+ const data = res.json() as { url?: string; redirect?: boolean };
300
331
  if (!data?.url) throw new Error("sign-in response had no authorization url");
301
332
 
302
333
  // The server's expo plugin proxies the IdP redirect and forwards the
@@ -323,6 +354,7 @@ export function createExpoAuthFlow(baseURL: string, scheme?: string): ExpoAuthFl
323
354
  postLogoutRedirectUri || Linking.createURL("/"),
324
355
  "signed-out",
325
356
  );
357
+ let missingSso = false;
326
358
  try {
327
359
  // Single sign-out via the server's keycloak-sso plugin: the fetch
328
360
  // (carrying the SecureStore session cookies) revokes the Better Auth
@@ -334,22 +366,21 @@ export function createExpoAuthFlow(baseURL: string, scheme?: string): ExpoAuthFl
334
366
  `/sign-out/sso?mode=json&callbackURL=${encodeURIComponent(to)}`,
335
367
  { method: "GET" },
336
368
  );
369
+ missingSso = res.status === 404 || res.status === 405;
337
370
  if (!res.ok) throw new Error(`sign-out/sso failed (${res.status})`);
338
- const data = (await res.json().catch(() => null)) as {
371
+ const data = res.json() as {
339
372
  redirect?: boolean;
340
373
  url?: string;
341
374
  } | null;
342
375
  if (data?.redirect && data.url) {
343
376
  // User backing out of the browser is fine: the local session is
344
377
  // already dead; only the KC SSO cookie may survive.
345
- await openAuthBrowser(data.url, to.split("?")[0], { timeoutMs: 120_000 }).catch(
346
- () => undefined,
347
- );
378
+ await openAuthBrowser(data.url, to.split("?")[0], { timeoutMs: 120_000 });
348
379
  }
349
- } catch {
350
- // Server without the keycloak-sso plugin (or unreachable): fall back
351
- // to the plain Better Auth sign-out so the local session still dies.
352
- await authFetch("/sign-out", { method: "POST", body: "{}" }).catch(() => undefined);
380
+ } catch (error) {
381
+ const fallback = await authFetch("/sign-out", { method: "POST", body: "{}" });
382
+ if (!fallback.ok) throw new Error(`sign-out failed (${fallback.status})`);
383
+ if (!missingSso) throw error;
353
384
  } finally {
354
385
  writeJar({});
355
386
  }
@@ -357,8 +388,9 @@ export function createExpoAuthFlow(baseURL: string, scheme?: string): ExpoAuthFl
357
388
 
358
389
  async getSession() {
359
390
  const res = await authFetch("/get-session");
360
- if (!res.ok) return null;
361
- const data = await res.json().catch(() => null);
391
+ if (res.status === 401) return null;
392
+ if (!res.ok) throw new Error(`get-session failed (${res.status})`);
393
+ const data = res.json();
362
394
  return data && Object.keys(data).length ? data : null;
363
395
  },
364
396
 
@@ -369,7 +401,15 @@ export function createExpoAuthFlow(baseURL: string, scheme?: string): ExpoAuthFl
369
401
  method: "POST",
370
402
  body: JSON.stringify(body),
371
403
  });
372
- payload = await response.json().catch(() => null);
404
+ if (response.status >= 500) {
405
+ throw new Error(`get-access-token failed (${response.status})`);
406
+ }
407
+ try {
408
+ payload = response.json();
409
+ } catch (error) {
410
+ if (response.ok) throw error;
411
+ payload = null;
412
+ }
373
413
  return { status: response.status, body: payload as { code?: string } | null };
374
414
  });
375
415
  if (res.status < 200 || res.status >= 300) return null;
@@ -156,8 +156,9 @@ export class BaseKeycloak {
156
156
  this._clear();
157
157
  if (this._keycloakClient) {
158
158
  await this._keycloakClient.login(options);
159
- } else {
160
- await this._login?.(options);
159
+ } else if (this._login) {
160
+ await this._login(options);
161
+ return;
161
162
  }
162
163
  this._sync();
163
164
  }
@@ -1,4 +1,4 @@
1
1
  import { createContext } from "react";
2
- import type { Keycloak } from ".";
2
+ import type { BaseKeycloak } from "./base";
3
3
 
4
- export const KeycloakContext = createContext<Keycloak | undefined>(undefined);
4
+ export const KeycloakContext = createContext<BaseKeycloak | undefined>(undefined);
@@ -13,6 +13,7 @@ import { getAuthClient } from "../betterAuth/client";
13
13
  import { clearKeycloakBearerToken } from "../frappeToken";
14
14
  import { useAuthConfig } from "../hooks/index";
15
15
  import type { Session } from "../session/index";
16
+ import { setAuthError } from "../session/authError";
16
17
  import { useSession } from "../session/index";
17
18
  import type { KeycloakMock } from "../types";
18
19
  import { BaseKeycloak, type KeycloakLoginOptions, type KeycloakLogoutOptions } from "./base";
@@ -35,19 +36,31 @@ export class Keycloak extends BaseKeycloak {
35
36
  }
36
37
 
37
38
  async login(options: KeycloakLoginOptions = {}) {
38
- this._clear();
39
- if (this._keycloakClient) {
40
- await this._keycloakClient.login(options);
41
- } else if (isBrowser && !isServer) {
42
- const client = getAuthClient();
43
- await client.signIn.social({
44
- provider: "keycloak" as any,
45
- callbackURL: options.redirectUri || (isWeb ? window.location.href : ""),
46
- });
47
- } else {
48
- await this._login?.(options);
39
+ setAuthError();
40
+ try {
41
+ this._clear();
42
+ if (this._keycloakClient) {
43
+ await this._keycloakClient.login(options);
44
+ this._sync();
45
+ } else if (this._login) {
46
+ await this._login(options);
47
+ } else if (isBrowser && !isServer) {
48
+ const client = getAuthClient();
49
+ const result = await client.signIn.social({
50
+ provider: "keycloak" as any,
51
+ callbackURL: options.redirectUri || (isWeb ? window.location.href : ""),
52
+ });
53
+ if (result?.error) {
54
+ throw result.error instanceof Error
55
+ ? result.error
56
+ : new Error(result.error.message || "Sign-in failed");
57
+ }
58
+ }
59
+ } catch (cause) {
60
+ const error = cause instanceof Error ? cause : new Error(String(cause));
61
+ setAuthError(error);
62
+ throw error;
49
63
  }
50
- this._sync();
51
64
  }
52
65
 
53
66
  async logout(options: KeycloakLogoutOptions = {}) {
@@ -93,9 +106,9 @@ export function useKeycloak() {
93
106
  const keycloak = useContext(KeycloakContext);
94
107
  const keycloakConfig = useContext(KeycloakConfigContext);
95
108
  const { disabled } = useAuthConfig();
109
+ const { session, status } = useSession();
96
110
  if (disabled) return null;
97
111
  if (keycloak) return keycloak;
98
- const { session, status } = useSession();
99
112
  if (isStorybook) {
100
113
  return new Keycloak(keycloakConfig, {
101
114
  email: "storybook@example.com",
@@ -0,0 +1,85 @@
1
+ // @vitest-environment happy-dom
2
+ import { act, cleanup, renderHook } from "@testing-library/react";
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4
+ import { Keycloak } from "./index";
5
+ import { useSession } from "../session";
6
+
7
+ const { social, session } = vi.hoisted(() => ({
8
+ social: vi.fn(),
9
+ session: {
10
+ data: null as null | { user: { id: string } },
11
+ isPending: false,
12
+ error: null as Error | null,
13
+ },
14
+ }));
15
+ vi.mock("@multiplatform.one/platform", () => ({ isBrowser: true, isServer: false, isWeb: true }));
16
+ vi.mock("../betterAuth/client", () => ({
17
+ getAuthClient: () => ({ signIn: { social }, useSession: () => session }),
18
+ }));
19
+ vi.mock("../hooks/index", () => ({ useAuthConfig: () => ({}) }));
20
+ vi.mock("../frappeToken", () => ({ clearKeycloakBearerToken: vi.fn() }));
21
+ const config = { url: "https://idp.test", realm: "test", clientId: "app" };
22
+ beforeEach(() => {
23
+ social.mockReset();
24
+ session.error = null;
25
+ session.isPending = false;
26
+ session.data = null;
27
+ });
28
+ afterEach(cleanup);
29
+
30
+ describe("browser reactive auth errors", () => {
31
+ it.each(["returned", "thrown"])(
32
+ "publishes and rejects the same %s login failure, clearing on retry",
33
+ async (mode) => {
34
+ const failure = new Error("login failed");
35
+ if (mode === "thrown") social.mockRejectedValueOnce(failure);
36
+ else social.mockResolvedValueOnce({ error: failure });
37
+ const { result } = renderHook(() => useSession());
38
+ const keycloak = new Keycloak(config);
39
+ let rejected: unknown;
40
+ await act(async () => {
41
+ try {
42
+ await keycloak.login();
43
+ } catch (error) {
44
+ rejected = error;
45
+ }
46
+ });
47
+ expect(rejected).toBe(failure);
48
+ expect(result.current.error).toBe(failure);
49
+ social.mockResolvedValueOnce({ data: {} });
50
+ await act(async () => {
51
+ await keycloak.login();
52
+ });
53
+ expect(result.current.error).toBeUndefined();
54
+ },
55
+ );
56
+ it("turns a returned error payload into the Error it publishes", async () => {
57
+ social.mockResolvedValueOnce({ error: { message: "denied", code: "DENIED" } });
58
+ const { result } = renderHook(() => useSession());
59
+ let rejected: unknown;
60
+ await act(async () => {
61
+ try {
62
+ await new Keycloak(config).login();
63
+ } catch (error) {
64
+ rejected = error;
65
+ }
66
+ });
67
+ expect(rejected).toBeInstanceOf(Error);
68
+ expect(result.current.error).toBe(rejected);
69
+ expect(result.current.error?.message).toBe("denied");
70
+ });
71
+ it("reports Better Auth session errors without changing hook ordering across states", () => {
72
+ session.error = new Error("session offline");
73
+ session.isPending = true;
74
+ const { result, rerender } = renderHook(() => useSession());
75
+ expect(result.current.error).toBe(session.error);
76
+ session.isPending = false;
77
+ rerender();
78
+ expect(result.current.status).toBe("unauthenticated");
79
+ expect(result.current.error).toBe(session.error);
80
+ session.error = null;
81
+ session.data = { user: { id: "signed-in" } };
82
+ rerender();
83
+ expect(result.current.status).toBe("authenticated");
84
+ });
85
+ });
@@ -0,0 +1,37 @@
1
+ // @vitest-environment happy-dom
2
+ import { cleanup, render, screen } from "@testing-library/react";
3
+ import { afterEach, expect, it, vi } from "vitest";
4
+ import { KeycloakProvider } from "./KeycloakProvider";
5
+ import type { AuthProviderProps } from "./authProvider/shared";
6
+
7
+ const { configs } = vi.hoisted(() => ({ configs: [] as AuthProviderProps["keycloakConfig"][] }));
8
+ vi.mock("./authProvider", () => ({
9
+ AuthProvider: ({ children, loadingComponent: Slot, keycloakConfig }: AuthProviderProps) => {
10
+ configs.push(keycloakConfig);
11
+ return (
12
+ <>
13
+ {children}
14
+ {Slot && <Slot />}
15
+ </>
16
+ );
17
+ },
18
+ }));
19
+ afterEach(() => {
20
+ cleanup();
21
+ configs.length = 0;
22
+ });
23
+ it("forwards the loading slot and keeps equivalent keycloak config stable", () => {
24
+ const Slot = () => <div>loading slot</div>;
25
+ const view = render(
26
+ <KeycloakProvider loadingComponent={Slot}>
27
+ <span>child</span>
28
+ </KeycloakProvider>,
29
+ );
30
+ expect(screen.queryByText("loading slot")).not.toBeNull();
31
+ view.rerender(
32
+ <KeycloakProvider loadingComponent={Slot}>
33
+ <span>updated child</span>
34
+ </KeycloakProvider>,
35
+ );
36
+ expect(configs[1]).toBe(configs[0]);
37
+ });
@@ -1,4 +1,4 @@
1
- import type { PropsWithChildren } from "react";
1
+ import type { ComponentType, PropsWithChildren } from "react";
2
2
  import { useMemo } from "react";
3
3
  import type { AuthConfig } from "../authConfig";
4
4
  import { AuthConfigContext } from "../authConfig";
@@ -6,6 +6,7 @@ import { AuthProvider } from "./authProvider";
6
6
 
7
7
  export interface KeycloakProviderProps extends PropsWithChildren, AuthConfig {
8
8
  baseUrl?: string;
9
+ loadingComponent?: ComponentType;
9
10
  clientId?: string;
10
11
  publicClientId?: string;
11
12
  realm?: string;
@@ -25,6 +26,7 @@ export function KeycloakProvider({
25
26
  clientId,
26
27
  expoScheme,
27
28
  loginRedirectUri,
29
+ loadingComponent,
28
30
  debug,
29
31
  disabled,
30
32
  iframeSso,
@@ -35,18 +37,23 @@ export function KeycloakProvider({
35
37
  () => ({ debug, disabled, iframeSso, loginRedirectUri }),
36
38
  [debug, disabled, iframeSso, loginRedirectUri],
37
39
  );
40
+ const keycloakConfig = useMemo(
41
+ () => ({
42
+ clientId: clientId || "app",
43
+ publicClientId,
44
+ realm: realm || "main",
45
+ url: baseUrl ?? "",
46
+ betterAuthBaseUrl,
47
+ expoScheme,
48
+ }),
49
+ [clientId, publicClientId, realm, baseUrl, betterAuthBaseUrl, expoScheme],
50
+ );
38
51
  return (
39
52
  <AuthConfigContext.Provider value={authConfig}>
40
53
  <AuthProvider
41
54
  disabled={disabled}
42
- keycloakConfig={{
43
- clientId: clientId || "app",
44
- publicClientId,
45
- realm: realm || "main",
46
- url: baseUrl ?? "",
47
- betterAuthBaseUrl,
48
- expoScheme,
49
- }}
55
+ keycloakConfig={keycloakConfig}
56
+ loadingComponent={loadingComponent}
50
57
  >
51
58
  {children}
52
59
  </AuthProvider>