@multiplatform.one/keycloak 7.34.4 → 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.
- package/dist/cjs/index.cjs +120 -70
- package/dist/cjs/index.native.cjs +283 -148
- package/dist/esm/index.js +78 -28
- package/dist/esm/index.native.js +270 -135
- package/package.json +9 -9
- package/src/betterAuth/expoAuthFlow.deadline.spec.ts +230 -0
- package/src/betterAuth/expoAuthFlow.native.ts +67 -27
- package/src/keycloak/base.ts +3 -2
- package/src/keycloak/context.ts +2 -2
- package/src/keycloak/index.ts +26 -13
- package/src/keycloak/login.error.spec.tsx +85 -0
- package/src/provider/KeycloakProvider.loading.spec.tsx +37 -0
- package/src/provider/KeycloakProvider.tsx +16 -9
- package/src/provider/authProvider/index.native.tsx +170 -108
- package/src/provider/authProvider/nonblocking.native.spec.tsx +303 -0
- package/src/session/authError.ts +30 -0
- package/src/session/index.native.ts +6 -1
- package/src/session/index.ts +11 -4
- package/types/betterAuth/expoAuthFlow.native.d.ts +3 -1
- package/types/betterAuth/expoAuthFlow.native.d.ts.map +1 -1
- package/types/keycloak/base.d.ts +1 -1
- package/types/keycloak/base.d.ts.map +1 -1
- package/types/keycloak/context.d.ts +2 -2
- package/types/keycloak/context.d.ts.map +1 -1
- package/types/keycloak/index.d.ts +1 -1
- package/types/keycloak/index.d.ts.map +1 -1
- package/types/keycloak/index.native.d.ts +1 -1
- package/types/keycloak/index.native.d.ts.map +1 -1
- package/types/keycloak/index.webext.d.ts +1 -1
- package/types/keycloak/index.webext.d.ts.map +1 -1
- package/types/provider/KeycloakProvider.d.ts +3 -2
- package/types/provider/KeycloakProvider.d.ts.map +1 -1
- package/types/provider/authProvider/index.native.d.ts +1 -1
- package/types/provider/authProvider/index.native.d.ts.map +1 -1
- package/types/session/authError.d.ts +7 -0
- package/types/session/authError.d.ts.map +1 -0
- package/types/session/index.d.ts +2 -0
- package/types/session/index.d.ts.map +1 -1
- package/types/session/index.native.d.ts +1 -0
- 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(
|
|
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
|
|
273
|
-
const
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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
|
-
|
|
285
|
-
|
|
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 =
|
|
327
|
+
const body = res.text;
|
|
297
328
|
throw new Error(`sign-in failed (${res.status}): ${body.slice(0, 200)}`);
|
|
298
329
|
}
|
|
299
|
-
const data =
|
|
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 =
|
|
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 })
|
|
346
|
-
() => undefined,
|
|
347
|
-
);
|
|
378
|
+
await openAuthBrowser(data.url, to.split("?")[0], { timeoutMs: 120_000 });
|
|
348
379
|
}
|
|
349
|
-
} catch {
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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 (
|
|
361
|
-
|
|
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
|
-
|
|
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;
|
package/src/keycloak/base.ts
CHANGED
|
@@ -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
|
|
159
|
+
} else if (this._login) {
|
|
160
|
+
await this._login(options);
|
|
161
|
+
return;
|
|
161
162
|
}
|
|
162
163
|
this._sync();
|
|
163
164
|
}
|
package/src/keycloak/context.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
import { createContext } from "react";
|
|
2
|
-
import type {
|
|
2
|
+
import type { BaseKeycloak } from "./base";
|
|
3
3
|
|
|
4
|
-
export const KeycloakContext = createContext<
|
|
4
|
+
export const KeycloakContext = createContext<BaseKeycloak | undefined>(undefined);
|
package/src/keycloak/index.ts
CHANGED
|
@@ -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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
})
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
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>
|