@multiplatform.one/keycloak 6.7.0 → 7.0.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 (79) hide show
  1. package/dist/cjs/index.cjs +129 -55
  2. package/dist/cjs/index.native.cjs +239 -130
  3. package/dist/esm/index.js +131 -57
  4. package/dist/esm/index.native.js +239 -130
  5. package/package.json +15 -10
  6. package/src/betterAuth/expoAuthFlow.native.spec.ts +307 -0
  7. package/src/betterAuth/expoAuthFlow.native.ts +189 -55
  8. package/src/frappeToken.native.spec.ts +104 -0
  9. package/src/frappeToken.native.ts +46 -2
  10. package/src/frappeToken.spec.ts +141 -0
  11. package/src/frappeToken.ts +106 -14
  12. package/src/index.ts +1 -1
  13. package/src/keycloak/index.ts +24 -7
  14. package/src/keycloak/index.webext.ts +16 -0
  15. package/src/oauth/authorizationUrl.spec.ts +83 -0
  16. package/src/oauth/authorizationUrl.ts +74 -0
  17. package/src/oauth/callback.spec.ts +72 -0
  18. package/src/oauth/callback.ts +46 -0
  19. package/src/oauth/callbackPage.spec.ts +61 -0
  20. package/src/oauth/callbackPage.ts +57 -0
  21. package/src/oauth/endpoints.spec.ts +193 -0
  22. package/src/oauth/endpoints.ts +135 -0
  23. package/src/oauth/errors.ts +18 -0
  24. package/src/oauth/form.spec.ts +35 -0
  25. package/src/oauth/form.ts +30 -0
  26. package/src/oauth/idToken.spec.ts +191 -0
  27. package/src/oauth/idToken.ts +153 -0
  28. package/src/oauth/index.ts +10 -0
  29. package/src/oauth/jwt.ts +90 -0
  30. package/src/oauth/loopbackFlow.spec.ts +226 -0
  31. package/src/oauth/loopbackFlow.ts +114 -0
  32. package/src/oauth/pkce.spec.ts +60 -0
  33. package/src/oauth/pkce.ts +80 -0
  34. package/src/oauth/tokenExchange.spec.ts +331 -0
  35. package/src/oauth/tokenExchange.ts +232 -0
  36. package/src/one.ts +30 -3
  37. package/src/provider/AfterAuth.tsx +7 -0
  38. package/src/provider/authProvider/index.native.tsx +37 -4
  39. package/src/session/index.ts +2 -2
  40. package/src/state.ts +5 -2
  41. package/types/betterAuth/expoAuthFlow.native.d.ts +50 -3
  42. package/types/betterAuth/expoAuthFlow.native.d.ts.map +1 -1
  43. package/types/frappeToken.d.ts +14 -1
  44. package/types/frappeToken.d.ts.map +1 -1
  45. package/types/frappeToken.native.d.ts +16 -2
  46. package/types/frappeToken.native.d.ts.map +1 -1
  47. package/types/index.d.ts +1 -1
  48. package/types/index.d.ts.map +1 -1
  49. package/types/keycloak/index.d.ts.map +1 -1
  50. package/types/keycloak/index.webext.d.ts.map +1 -1
  51. package/types/oauth/authorizationUrl.d.ts +32 -0
  52. package/types/oauth/authorizationUrl.d.ts.map +1 -0
  53. package/types/oauth/callback.d.ts +26 -0
  54. package/types/oauth/callback.d.ts.map +1 -0
  55. package/types/oauth/callbackPage.d.ts +34 -0
  56. package/types/oauth/callbackPage.d.ts.map +1 -0
  57. package/types/oauth/endpoints.d.ts +68 -0
  58. package/types/oauth/endpoints.d.ts.map +1 -0
  59. package/types/oauth/errors.d.ts +17 -0
  60. package/types/oauth/errors.d.ts.map +1 -0
  61. package/types/oauth/form.d.ts +16 -0
  62. package/types/oauth/form.d.ts.map +1 -0
  63. package/types/oauth/idToken.d.ts +83 -0
  64. package/types/oauth/idToken.d.ts.map +1 -0
  65. package/types/oauth/index.d.ts +11 -0
  66. package/types/oauth/index.d.ts.map +1 -0
  67. package/types/oauth/jwt.d.ts +31 -0
  68. package/types/oauth/jwt.d.ts.map +1 -0
  69. package/types/oauth/loopbackFlow.d.ts +64 -0
  70. package/types/oauth/loopbackFlow.d.ts.map +1 -0
  71. package/types/oauth/pkce.d.ts +49 -0
  72. package/types/oauth/pkce.d.ts.map +1 -0
  73. package/types/oauth/tokenExchange.d.ts +89 -0
  74. package/types/oauth/tokenExchange.d.ts.map +1 -0
  75. package/types/one.d.ts +7 -0
  76. package/types/one.d.ts.map +1 -1
  77. package/types/provider/AfterAuth.d.ts.map +1 -1
  78. package/types/provider/authProvider/index.native.d.ts.map +1 -1
  79. package/types/state.d.ts.map +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@multiplatform.one/keycloak",
3
- "version": "6.7.0",
3
+ "version": "7.0.0",
4
4
  "description": "keycloak client for multiplatform.one ecosystem",
5
5
  "keywords": [
6
6
  "auth",
@@ -45,6 +45,11 @@
45
45
  "types": "./types/one.d.ts",
46
46
  "import": "./src/one.ts",
47
47
  "require": "./src/one.ts"
48
+ },
49
+ "./oauth": {
50
+ "types": "./types/oauth/index.d.ts",
51
+ "import": "./src/oauth/index.ts",
52
+ "require": "./src/oauth/index.ts"
48
53
  }
49
54
  },
50
55
  "publishConfig": {
@@ -54,11 +59,11 @@
54
59
  "@better-auth/expo": "^1.6.9",
55
60
  "better-auth": "^1.6.9",
56
61
  "jwt-decode": "^4.0.0",
57
- "@multiplatform.one/keycloak-js": "6.7.0",
58
- "@multiplatform.one/store": "6.7.0"
62
+ "@multiplatform.one/keycloak-js": "7.0.0",
63
+ "@multiplatform.one/store": "7.0.0"
59
64
  },
60
65
  "devDependencies": {
61
- "@tamagui/build": "2.0.0-rc.41",
66
+ "@tamagui/build": "2.7.6",
62
67
  "@testing-library/jest-dom": "^6.9.1",
63
68
  "@testing-library/react": "^16.3.2",
64
69
  "@types/lodash.camelcase": "^4.3.9",
@@ -70,15 +75,15 @@
70
75
  "react": "19.2.5",
71
76
  "typescript": "~5.9.3",
72
77
  "vitest": "^4.1.5",
73
- "@multiplatform.one/config": "6.7.0",
74
- "@multiplatform.one/platform": "6.7.0",
75
- "@multiplatform.one/logger": "6.7.0",
76
- "@multiplatform.one/test-utils": "6.7.0"
78
+ "@multiplatform.one/config": "7.0.0",
79
+ "@multiplatform.one/platform": "7.0.0",
80
+ "@multiplatform.one/logger": "7.0.0",
81
+ "@multiplatform.one/test-utils": "7.0.0"
77
82
  },
78
83
  "peerDependencies": {
79
84
  "react": "^19.1.0",
80
- "@multiplatform.one/logger": "^6.7.0",
81
- "@multiplatform.one/platform": "^6.7.0"
85
+ "@multiplatform.one/logger": "^7.0.0",
86
+ "@multiplatform.one/platform": "^7.0.0"
82
87
  },
83
88
  "optionalDependencies": {
84
89
  "expo-linking": "^55.0.15",
@@ -0,0 +1,307 @@
1
+ /**
2
+ * Specs for the hand-rolled native Better Auth flow (expoAuthFlow.native.ts):
3
+ * SSO sign-in (social → expo-authorization-proxy → cookie hand-back),
4
+ * SSO sign-out (fetch-then-browser-ride), the plain sign-out fallback, and
5
+ * the loopback host rewrite for simulators/emulators.
6
+ */
7
+
8
+ import { beforeEach, describe, expect, it, vi } from "vitest";
9
+
10
+ const secureStore = new Map<string, string>();
11
+
12
+ vi.mock("expo-linking", () => ({
13
+ createURL: (path: string) => `multiplatform-one://${path.replace(/^\//, "")}`,
14
+ addEventListener: vi.fn(() => ({ remove: vi.fn() })),
15
+ openURL: vi.fn(async () => {}),
16
+ }));
17
+ vi.mock("expo-secure-store", () => ({
18
+ getItem: (key: string) => secureStore.get(key) ?? null,
19
+ setItem: (key: string, value: string) => {
20
+ secureStore.set(key, value);
21
+ },
22
+ }));
23
+ const openAuthSessionAsync = vi.fn(async (..._args: unknown[]) => ({ type: "dismiss" }) as const);
24
+ const dismissAuthSession = vi.fn();
25
+ vi.mock("expo-web-browser", () => ({
26
+ openAuthSessionAsync: (...args: unknown[]) => openAuthSessionAsync(...args),
27
+ dismissAuthSession: (...args: unknown[]) => dismissAuthSession(...args),
28
+ maybeCompleteAuthSession: vi.fn(),
29
+ }));
30
+
31
+ import { Platform } from "react-native";
32
+ import {
33
+ createExpoAuthFlow,
34
+ ensureReturnUrlAuthority,
35
+ extractCookieFromReturnUrl,
36
+ normalizeForReturnMatch,
37
+ rewriteLoopbackHostForNative,
38
+ } from "./expoAuthFlow.native";
39
+
40
+ const BASE = "http://127.0.0.1:9999";
41
+ // The vitest resolver aliases react-native -> react-native-web (Platform.OS
42
+ // "web"), which rides the iOS/default branches; assert against the actual
43
+ // platform so the spec also holds under a real react-native resolution.
44
+ const DEVICE_LOOPBACK = Platform.OS === "android" ? "10.0.2.2" : "127.0.0.1";
45
+
46
+ function jsonResponse(body: unknown, init?: ResponseInit) {
47
+ return new Response(JSON.stringify(body), {
48
+ headers: { "content-type": "application/json" },
49
+ ...init,
50
+ });
51
+ }
52
+
53
+ describe("rewriteLoopbackHostForNative", () => {
54
+ it("rewrites localhost for the device", () => {
55
+ expect(rewriteLoopbackHostForNative("http://localhost:8000")).toBe(
56
+ `http://${DEVICE_LOOPBACK}:8000`,
57
+ );
58
+ expect(rewriteLoopbackHostForNative("http://127.0.0.1:8000/x")).toBe(
59
+ `http://${DEVICE_LOOPBACK}:8000/x`,
60
+ );
61
+ });
62
+
63
+ it("passes non-loopback hosts through untouched", () => {
64
+ expect(rewriteLoopbackHostForNative("http://10.1.2.3:8000")).toBe("http://10.1.2.3:8000");
65
+ expect(rewriteLoopbackHostForNative("https://bench.example.com")).toBe(
66
+ "https://bench.example.com",
67
+ );
68
+ expect(rewriteLoopbackHostForNative("not a url")).toBe("not a url");
69
+ });
70
+ });
71
+
72
+ describe("normalizeForReturnMatch", () => {
73
+ it("matches createURL's empty-authority form against iOS's delivered form", () => {
74
+ // Linking.createURL("/") yields ":///", iOS delivers "://?cookie=…".
75
+ const base = normalizeForReturnMatch("multiplatform-one:///");
76
+ expect(normalizeForReturnMatch("multiplatform-one://?cookie=abc").startsWith(base)).toBe(true);
77
+ expect(normalizeForReturnMatch("multiplatform-one:///?cookie=abc").startsWith(base)).toBe(true);
78
+ expect(normalizeForReturnMatch("other-scheme://?cookie=abc").startsWith(base)).toBe(false);
79
+ });
80
+
81
+ it("drops query and fragment before comparing", () => {
82
+ expect(normalizeForReturnMatch("exp://192.168.1.5:8081/--/?cookie=x#frag")).toBe(
83
+ "exp://192.168.1.5:8081/--",
84
+ );
85
+ });
86
+ });
87
+
88
+ describe("ensureReturnUrlAuthority", () => {
89
+ it("gives bare-scheme URLs a host segment (Keycloak UriBuilder needs one)", () => {
90
+ expect(ensureReturnUrlAuthority("multiplatform-one:///", "signed-out")).toBe(
91
+ "multiplatform-one://signed-out",
92
+ );
93
+ expect(ensureReturnUrlAuthority("multiplatform-one://", "signed-out")).toBe(
94
+ "multiplatform-one://signed-out",
95
+ );
96
+ });
97
+
98
+ it("leaves URLs with an authority untouched", () => {
99
+ expect(ensureReturnUrlAuthority("exp://192.168.1.5:8081/--/", "signed-out")).toBe(
100
+ "exp://192.168.1.5:8081/--/",
101
+ );
102
+ expect(ensureReturnUrlAuthority("multiplatform-one://home", "signed-out")).toBe(
103
+ "multiplatform-one://home",
104
+ );
105
+ });
106
+ });
107
+
108
+ describe("extractCookieFromReturnUrl", () => {
109
+ it("reads cookie from the iOS-delivered empty-authority form", () => {
110
+ expect(
111
+ extractCookieFromReturnUrl("multiplatform-one://?cookie=better-auth.session_token=tok"),
112
+ ).toBe("better-auth.session_token=tok");
113
+ expect(
114
+ extractCookieFromReturnUrl("multiplatform-one:///?cookie=better-auth.session_token=tok"),
115
+ ).toBe("better-auth.session_token=tok");
116
+ });
117
+
118
+ it("decodes and ignores fragment / extra params (Expo Go exp:// too)", () => {
119
+ expect(
120
+ extractCookieFromReturnUrl("exp://192.168.1.5:8081/--/?foo=1&cookie=name%3Dvalue%20x#frag"),
121
+ ).toBe("name=value x");
122
+ });
123
+
124
+ it("returns null when the return has no cookie", () => {
125
+ expect(extractCookieFromReturnUrl("multiplatform-one://signed-out")).toBeNull();
126
+ expect(extractCookieFromReturnUrl("multiplatform-one://?state=abc")).toBeNull();
127
+ });
128
+ });
129
+
130
+ describe("expoAuthFlow signOut (single sign-out)", () => {
131
+ beforeEach(() => {
132
+ secureStore.clear();
133
+ openAuthSessionAsync.mockClear();
134
+ // A live session cookie in the jar, as after a completed sign-in.
135
+ secureStore.set(
136
+ "better-auth_cookie",
137
+ JSON.stringify({
138
+ "better-auth.session_token": { value: "tok", expires: null },
139
+ }),
140
+ );
141
+ });
142
+
143
+ it("rides the Keycloak end-session URL through the auth browser and clears the jar", async () => {
144
+ const endSessionUrl =
145
+ "http://127.0.0.1:9999/auth/realms/myrealm/protocol/openid-connect/logout?id_token_hint=x";
146
+ const fetchMock = vi.fn(async (url: string | URL, _init?: RequestInit) => {
147
+ const href = String(url);
148
+ expect(href).toContain("/api/auth/sign-out/sso?mode=json");
149
+ expect(href).toContain(encodeURIComponent("multiplatform-one://"));
150
+ return jsonResponse({ redirect: true, url: endSessionUrl });
151
+ });
152
+ vi.stubGlobal("fetch", fetchMock);
153
+
154
+ const flow = createExpoAuthFlow(BASE);
155
+ await flow.signOut();
156
+
157
+ expect(fetchMock).toHaveBeenCalledTimes(1);
158
+ // The session cookie rode the sign-out fetch (that's what authenticates it).
159
+ const init = fetchMock.mock.calls[0][1];
160
+ expect((init?.headers as Record<string, string>).cookie).toContain(
161
+ "better-auth.session_token=tok",
162
+ );
163
+ // The browser ride hits the end-session URL, returning to the app scheme
164
+ // (with the authority segment Keycloak's redirect builder requires).
165
+ expect(openAuthSessionAsync).toHaveBeenCalledWith(
166
+ endSessionUrl,
167
+ "multiplatform-one://signed-out",
168
+ expect.anything(),
169
+ );
170
+ expect(secureStore.get("better-auth_cookie")).toBe("{}");
171
+ });
172
+
173
+ it("skips the browser ride when the server answers redirect:false", async () => {
174
+ vi.stubGlobal(
175
+ "fetch",
176
+ vi.fn(async () => jsonResponse({ redirect: false, url: "multiplatform-one://" })),
177
+ );
178
+ const flow = createExpoAuthFlow(BASE);
179
+ await flow.signOut();
180
+ expect(openAuthSessionAsync).not.toHaveBeenCalled();
181
+ expect(secureStore.get("better-auth_cookie")).toBe("{}");
182
+ });
183
+
184
+ it("falls back to plain /sign-out when the sso endpoint is missing", async () => {
185
+ const calls: string[] = [];
186
+ vi.stubGlobal(
187
+ "fetch",
188
+ vi.fn(async (url: string | URL) => {
189
+ const href = String(url);
190
+ calls.push(href);
191
+ if (href.includes("/sign-out/sso")) return new Response("nope", { status: 404 });
192
+ return jsonResponse({ success: true });
193
+ }),
194
+ );
195
+ const flow = createExpoAuthFlow(BASE);
196
+ await flow.signOut();
197
+ expect(calls.some((href) => href.endsWith("/api/auth/sign-out"))).toBe(true);
198
+ expect(openAuthSessionAsync).not.toHaveBeenCalled();
199
+ expect(secureStore.get("better-auth_cookie")).toBe("{}");
200
+ });
201
+
202
+ it("still clears the jar when the user backs out of the browser ride", async () => {
203
+ vi.stubGlobal(
204
+ "fetch",
205
+ vi.fn(async () => jsonResponse({ redirect: true, url: "http://kc/logout" })),
206
+ );
207
+ openAuthSessionAsync.mockResolvedValueOnce({ type: "cancel" } as never);
208
+ const flow = createExpoAuthFlow(BASE);
209
+ await flow.signOut();
210
+ expect(secureStore.get("better-auth_cookie")).toBe("{}");
211
+ });
212
+ });
213
+
214
+ describe("expoAuthFlow signIn (OIDC URL/state machine)", () => {
215
+ beforeEach(() => {
216
+ secureStore.clear();
217
+ openAuthSessionAsync.mockReset();
218
+ openAuthSessionAsync.mockResolvedValue({ type: "dismiss" } as never);
219
+ });
220
+
221
+ it("POSTs /sign-in/social then opens the expo-authorization-proxy in the auth session", async () => {
222
+ const authz = "http://kc/realms/myrealm/protocol/openid-connect/auth?client_id=app";
223
+ const fetchMock = vi.fn(async (url: string | URL, init?: RequestInit) => {
224
+ const href = String(url);
225
+ expect(href).toContain("/api/auth/sign-in/social");
226
+ expect(init?.method).toBe("POST");
227
+ const body = JSON.parse(String(init?.body));
228
+ expect(body.provider).toBe("keycloak");
229
+ expect(body.callbackURL).toContain("multiplatform-one://");
230
+ expect((init?.headers as Record<string, string>)["expo-origin"]).toContain(
231
+ "multiplatform-one://",
232
+ );
233
+ return jsonResponse({ url: authz, redirect: true });
234
+ });
235
+ vi.stubGlobal("fetch", fetchMock);
236
+ openAuthSessionAsync.mockResolvedValueOnce({
237
+ type: "success",
238
+ url: "multiplatform-one://?cookie=better-auth.session_token=sess",
239
+ } as never);
240
+
241
+ const flow = createExpoAuthFlow(BASE);
242
+ await flow.signIn("keycloak");
243
+
244
+ expect(openAuthSessionAsync).toHaveBeenCalledTimes(1);
245
+ const [proxyUrl, returnUrl] = openAuthSessionAsync.mock.calls[0];
246
+ expect(String(proxyUrl)).toContain("/api/auth/expo-authorization-proxy?");
247
+ expect(String(proxyUrl)).toContain(encodeURIComponent(authz));
248
+ expect(String(returnUrl).startsWith("multiplatform-one://")).toBe(true);
249
+
250
+ const sessionFetch = vi.fn(async (url: string | URL, init?: RequestInit) => {
251
+ expect(String(url)).toContain("/api/auth/get-session");
252
+ expect((init?.headers as Record<string, string>).cookie).toContain(
253
+ "better-auth.session_token=sess",
254
+ );
255
+ return jsonResponse({ user: { id: "u1", name: "Test User" } });
256
+ });
257
+ vi.stubGlobal("fetch", sessionFetch);
258
+ const session = await flow.getSession();
259
+ expect(session?.user?.name).toBe("Test User");
260
+ });
261
+
262
+ it("forwards oauth_state from the jar on the proxy URL", async () => {
263
+ secureStore.set(
264
+ "better-auth_cookie",
265
+ JSON.stringify({
266
+ "better-auth.oauth_state": { value: "state-xyz", expires: null },
267
+ }),
268
+ );
269
+ vi.stubGlobal(
270
+ "fetch",
271
+ vi.fn(async () => jsonResponse({ url: "http://kc/auth", redirect: true })),
272
+ );
273
+ openAuthSessionAsync.mockResolvedValueOnce({ type: "dismiss" } as never);
274
+ const flow = createExpoAuthFlow(BASE);
275
+ await flow.signIn("keycloak");
276
+ const proxyUrl = String(openAuthSessionAsync.mock.calls[0][0]);
277
+ expect(proxyUrl).toContain("oauthState=state-xyz");
278
+ });
279
+
280
+ it("is a no-op when the user dismisses the auth session", async () => {
281
+ vi.stubGlobal(
282
+ "fetch",
283
+ vi.fn(async () => jsonResponse({ url: "http://kc/auth", redirect: true })),
284
+ );
285
+ const flow = createExpoAuthFlow(BASE);
286
+ await flow.signIn("keycloak");
287
+ expect(JSON.parse(secureStore.get("better-auth_cookie") || "{}")).toEqual({});
288
+ });
289
+
290
+ it("throws when the server omits the authorization url", async () => {
291
+ vi.stubGlobal(
292
+ "fetch",
293
+ vi.fn(async () => jsonResponse({})),
294
+ );
295
+ const flow = createExpoAuthFlow(BASE);
296
+ await expect(flow.signIn("keycloak")).rejects.toThrow(/no authorization url/);
297
+ });
298
+
299
+ it("throws on a failed social sign-in", async () => {
300
+ vi.stubGlobal(
301
+ "fetch",
302
+ vi.fn(async () => new Response("nope", { status: 500 })),
303
+ );
304
+ const flow = createExpoAuthFlow(BASE);
305
+ await expect(flow.signIn("keycloak")).rejects.toThrow(/sign-in failed \(500\)/);
306
+ });
307
+ });
@@ -1,6 +1,7 @@
1
1
  /**
2
- * Hand-rolled Better Auth flow for native (Expo) — fetch + expo-web-browser +
3
- * SecureStore, NO better-auth client packages.
2
+ * Hand-rolled Better Auth flow for native (Expo) — fetch + expo-web-browser
3
+ * (ASWebAuthenticationSession / Chrome Custom Tabs) + SecureStore, NO
4
+ * better-auth client packages.
4
5
  *
5
6
  * Why not @better-auth/expo/client: its import graph drags better-auth's
6
7
  * cookies module -> @better-auth/core db schema -> zod into the Hermes bundle,
@@ -16,6 +17,12 @@ import * as Linking from "expo-linking";
16
17
  import * as SecureStore from "expo-secure-store";
17
18
  import * as WebBrowser from "expo-web-browser";
18
19
 
20
+ try {
21
+ WebBrowser.maybeCompleteAuthSession?.();
22
+ } catch {
23
+ /* web-only redirect completion; native is a no-op */
24
+ }
25
+
19
26
  const COOKIE_KEY = "better-auth_cookie";
20
27
  const SECURE_COOKIE_PREFIX = "__Secure-";
21
28
 
@@ -88,7 +95,14 @@ function getOAuthState(): string | null {
88
95
 
89
96
  export interface ExpoAuthFlow {
90
97
  signIn(provider: string, callbackURL?: string): Promise<void>;
91
- signOut(): Promise<void>;
98
+ /**
99
+ * Ends the Better Auth session AND (when the server exposes the
100
+ * keycloak-sso plugin) the Keycloak SSO session, riding the OIDC
101
+ * end-session endpoint through the same browser that holds the SSO
102
+ * cookie. `postLogoutRedirectUri` is where that browser hands control
103
+ * back (defaults to the app's own runtime URL).
104
+ */
105
+ signOut(postLogoutRedirectUri?: string): Promise<void>;
92
106
  getSession(): Promise<{ user?: { id: string; email?: string; name?: string } } | null>;
93
107
  /** Valid provider access token (server refreshes when expired). */
94
108
  getAccessToken(
@@ -96,6 +110,144 @@ export interface ExpoAuthFlow {
96
110
  ): Promise<{ accessToken?: string; accessTokenExpiresAt?: string } | null>;
97
111
  }
98
112
 
113
+ /**
114
+ * Rewrite a loopback host for the device the app actually runs on:
115
+ * `localhost` inside the iOS simulator can resolve to `::1`, where unrelated
116
+ * daemons may squat the port (the reason the web env pins 127.0.0.1), and
117
+ * Android emulators reach the host machine at 10.0.2.2, never loopback.
118
+ * Non-loopback hosts (LAN IPs, real domains) pass through untouched.
119
+ *
120
+ * String-based on purpose: React Native's minimal URL polyfill does not
121
+ * implement the `hostname` SETTER (reads work, mutation throws), so a
122
+ * new-URL-and-assign rewrite silently no-ops on device.
123
+ */
124
+ export function rewriteLoopbackHostForNative(configured: string): string {
125
+ const deviceHost = Platform.OS === "android" ? "10.0.2.2" : "127.0.0.1";
126
+ return configured.replace(/^(https?:\/\/)(localhost|127\.0\.0\.1)(?=[:/]|$)/i, `$1${deviceHost}`);
127
+ }
128
+
129
+ /**
130
+ * Loosen a return URL for prefix matching: drop query/fragment and collapse
131
+ * the slash run after the scheme. `Linking.createURL("/")` yields
132
+ * `multiplatform-one:///` (empty authority) but iOS delivers the redirect as
133
+ * `multiplatform-one://?cookie=…` — a strict startsWith between the two
134
+ * misses, leaving the sign-in promise pending forever (observed on the
135
+ * 2026-08-13 simulator run).
136
+ */
137
+ export function normalizeForReturnMatch(url: string): string {
138
+ return url
139
+ .split("?")[0]
140
+ .split("#")[0]
141
+ .replace(/:\/{2,}/, "://")
142
+ .replace(/\/+$/, "");
143
+ }
144
+
145
+ /**
146
+ * Give a bare-scheme URL (`multiplatform-one:///` — what Linking.createURL
147
+ * emits for custom app schemes: empty authority) a host segment. OIDC
148
+ * post-logout redirects need it: Keycloak's redirect builder (Java
149
+ * UriBuilder) throws "empty host" appending the `state` param to an
150
+ * authority-less URI, answering 500 "We are sorry" instead of returning to
151
+ * the app. URLs that already carry an authority (exp://host:port/…) pass
152
+ * through untouched.
153
+ */
154
+ export function ensureReturnUrlAuthority(url: string, segment: string): string {
155
+ return url.replace(/^([a-z0-9.+-]+:)\/{2,3}(?=\?|#|$)/i, `$1//${segment}`);
156
+ }
157
+
158
+ /**
159
+ * Pull the Better Auth session cookie out of an OAuth return URL without
160
+ * `new URL()`. React Native's URL polyfill throws or drops search on
161
+ * authority-less custom schemes (`multiplatform-one://?cookie=…` — the
162
+ * shape iOS actually delivers). Mirrors `URLSearchParams.get("cookie")`
163
+ * including decodeURIComponent.
164
+ */
165
+ export function extractCookieFromReturnUrl(returnUrl: string): string | null {
166
+ const q = returnUrl.indexOf("?");
167
+ if (q === -1) return null;
168
+ const query = returnUrl.slice(q + 1).split("#")[0];
169
+ for (const part of query.split("&")) {
170
+ const eq = part.indexOf("=");
171
+ const key = eq === -1 ? part : part.slice(0, eq);
172
+ let decodedKey: string;
173
+ try {
174
+ decodedKey = decodeURIComponent(key.replace(/\+/g, " "));
175
+ } catch {
176
+ decodedKey = key;
177
+ }
178
+ if (decodedKey !== "cookie") continue;
179
+ const raw = eq === -1 ? "" : part.slice(eq + 1);
180
+ try {
181
+ return decodeURIComponent(raw.replace(/\+/g, " "));
182
+ } catch {
183
+ return raw;
184
+ }
185
+ }
186
+ return null;
187
+ }
188
+
189
+ /**
190
+ * Open `url` in the platform OAuth-return browser (ASWebAuthenticationSession
191
+ * on iOS, Chrome Custom Tabs on Android — the Slack / Linear / Discord bar)
192
+ * and resolve with the custom-scheme return URL (or undefined when the user
193
+ * backs out).
194
+ *
195
+ * A Linking listener runs for the same window: iOS sometimes delivers the
196
+ * app-scheme return as a deep link while the auth session is still up (or
197
+ * reports dismiss a tick before the URL arrives). Whichever fires first
198
+ * wins. `preferEphemeralSession: false` so the Keycloak SSO cookie survives
199
+ * across the sign-in session and the matching sign-out end-session ride.
200
+ */
201
+ async function openAuthBrowser(
202
+ url: string,
203
+ returnUrlBase: string,
204
+ options?: { timeoutMs?: number },
205
+ ): Promise<string | undefined> {
206
+ const returnBase = normalizeForReturnMatch(returnUrlBase);
207
+ let returned: string | undefined;
208
+ const linkSub = Linking.addEventListener("url", ({ url: next }) => {
209
+ if (!next || returned) return;
210
+ if (!normalizeForReturnMatch(next).startsWith(returnBase)) return;
211
+ returned = next;
212
+ try {
213
+ WebBrowser.dismissAuthSession();
214
+ } catch {
215
+ /* session already gone */
216
+ }
217
+ });
218
+ const timer = options?.timeoutMs
219
+ ? setTimeout(() => {
220
+ if (returned) return;
221
+ returned = "";
222
+ try {
223
+ WebBrowser.dismissAuthSession();
224
+ } catch {
225
+ /* session already gone */
226
+ }
227
+ }, options.timeoutMs)
228
+ : undefined;
229
+ try {
230
+ const result = await WebBrowser.openAuthSessionAsync(url, returnUrlBase, {
231
+ preferEphemeralSession: false,
232
+ });
233
+ if (!returned && result.type === "success" && "url" in result && result.url) {
234
+ returned = result.url;
235
+ }
236
+ if (!returned) {
237
+ // Late iOS deep-link after the session reports dismiss.
238
+ await new Promise((resolve) => setTimeout(resolve, 100));
239
+ }
240
+ return returned || undefined;
241
+ } finally {
242
+ if (timer) clearTimeout(timer);
243
+ try {
244
+ linkSub.remove();
245
+ } catch {
246
+ /* already removed */
247
+ }
248
+ }
249
+ }
250
+
99
251
  // The app mounts ONE AuthProvider, which creates one flow. Expose it so
100
252
  // non-React consumers (the Frappe Bearer-token getter) can reach the same
101
253
  // cookie jar + base URL.
@@ -148,67 +300,49 @@ export function createExpoAuthFlow(baseURL: string, scheme?: string): ExpoAuthFl
148
300
  if (oauthState) params.append("oauthState", oauthState);
149
301
  const proxyURL = `${base}/api/auth/expo-authorization-proxy?${params.toString()}`;
150
302
 
151
- // Which browser opens the Keycloak login page differs by platform, because
152
- // the wallet deeplink (metamask://connect?channelId=…, a CUSTOM scheme
153
- // emitted by the classic @metamask/sdk connector) must be able to app-switch
154
- // to MetaMask from inside it:
155
- //
156
- // - ANDROID: a Chrome Custom Tab (openAuthSessionAsync). Custom Tabs DO
157
- // launch other apps via custom schemes, and the tab is hosted by + returns
158
- // to THIS app, so it stays warm across the wallet bounce and the KC page
159
- // receives the MetaMask-SDK relay response, then hands the custom-scheme
160
- // return URL (to?cookie=…) back to us.
161
- // - iOS: ASWebAuthenticationSession (what openAuthSessionAsync maps to)
162
- // REFUSES to launch other apps via custom schemes — it's an OAuth-return
163
- // surface only, so "Open MetaMask" does nothing (verified on-device:
164
- // SafariViewService never app-switches to io.metamask). Full system Safari
165
- // via Linking.openURL DOES app-switch to MetaMask (iOS shows an "Open in
166
- // MetaMask?" prompt for custom schemes). The wallet signs over the SDK
167
- // relay, KC verifies + redirects to the custom-scheme return URL, and
168
- // Safari hands that back to the app, caught by a Linking listener.
303
+ // Platform-split browser choice: see openAuthBrowser.
169
304
  const toBase = to.split("?")[0];
170
- let returnUrl: string | undefined;
171
- if (Platform.OS === "ios") {
172
- returnUrl = await new Promise<string | undefined>((resolve, reject) => {
173
- let settled = false;
174
- const linkSub = Linking.addEventListener("url", ({ url }) => {
175
- if (settled || !url || !url.startsWith(toBase)) return;
176
- settled = true;
177
- try {
178
- linkSub.remove();
179
- } catch {
180
- /* already removed */
181
- }
182
- resolve(url);
183
- });
184
- Linking.openURL(proxyURL).catch((e) => {
185
- if (settled) return;
186
- settled = true;
187
- try {
188
- linkSub.remove();
189
- } catch {
190
- /* already removed */
191
- }
192
- reject(e instanceof Error ? e : new Error(`sign-in openURL failed: ${e}`));
193
- });
194
- });
195
- } else {
196
- const result = await WebBrowser.openAuthSessionAsync(proxyURL, toBase, {
197
- preferEphemeralSession: false, // keep the SSO + siww nonce cookie across the wallet bounce
198
- });
199
- returnUrl = result.type === "success" && "url" in result ? result.url : undefined;
200
- }
305
+ const returnUrl = await openAuthBrowser(proxyURL, toBase);
201
306
  if (!returnUrl) {
202
307
  // dismissed / cancelled — treat as a no-op sign-in (user backed out)
203
308
  return;
204
309
  }
205
- const cookie = new URL(returnUrl).searchParams.get("cookie");
310
+ const cookie = extractCookieFromReturnUrl(returnUrl);
206
311
  if (cookie) absorbSetCookie(cookie);
207
312
  },
208
313
 
209
- async signOut() {
314
+ async signOut(postLogoutRedirectUri?: string) {
315
+ const to = ensureReturnUrlAuthority(
316
+ postLogoutRedirectUri || Linking.createURL("/"),
317
+ "signed-out",
318
+ );
210
319
  try {
211
- await authFetch("/sign-out", { method: "POST", body: "{}" });
320
+ // Single sign-out via the server's keycloak-sso plugin: the fetch
321
+ // (carrying the SecureStore session cookies) revokes the Better Auth
322
+ // session and answers with the Keycloak end-session URL, which we
323
+ // then ride through the system browser — the ONLY place the KC SSO
324
+ // cookie lives. Without that ride the next sign-in silently
325
+ // re-authenticates.
326
+ const res = await authFetch(
327
+ `/sign-out/sso?mode=json&callbackURL=${encodeURIComponent(to)}`,
328
+ { method: "GET" },
329
+ );
330
+ if (!res.ok) throw new Error(`sign-out/sso failed (${res.status})`);
331
+ const data = (await res.json().catch(() => null)) as {
332
+ redirect?: boolean;
333
+ url?: string;
334
+ } | null;
335
+ if (data?.redirect && data.url) {
336
+ // User backing out of the browser is fine: the local session is
337
+ // already dead; only the KC SSO cookie may survive.
338
+ await openAuthBrowser(data.url, to.split("?")[0], { timeoutMs: 120_000 }).catch(
339
+ () => undefined,
340
+ );
341
+ }
342
+ } catch {
343
+ // Server without the keycloak-sso plugin (or unreachable): fall back
344
+ // to the plain Better Auth sign-out so the local session still dies.
345
+ await authFetch("/sign-out", { method: "POST", body: "{}" }).catch(() => undefined);
212
346
  } finally {
213
347
  writeJar({});
214
348
  }