@multiplatform.one/keycloak 6.7.0 → 7.1.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 +129 -55
- package/dist/cjs/index.native.cjs +239 -130
- package/dist/esm/index.js +131 -57
- package/dist/esm/index.native.js +239 -130
- package/package.json +15 -10
- package/src/betterAuth/expoAuthFlow.native.spec.ts +307 -0
- package/src/betterAuth/expoAuthFlow.native.ts +189 -55
- package/src/frappeToken.native.spec.ts +104 -0
- package/src/frappeToken.native.ts +46 -2
- package/src/frappeToken.spec.ts +141 -0
- package/src/frappeToken.ts +106 -14
- package/src/index.ts +1 -1
- package/src/keycloak/index.ts +24 -7
- package/src/keycloak/index.webext.ts +16 -0
- package/src/oauth/authorizationUrl.spec.ts +83 -0
- package/src/oauth/authorizationUrl.ts +74 -0
- package/src/oauth/callback.spec.ts +72 -0
- package/src/oauth/callback.ts +46 -0
- package/src/oauth/callbackPage.spec.ts +61 -0
- package/src/oauth/callbackPage.ts +57 -0
- package/src/oauth/endpoints.spec.ts +193 -0
- package/src/oauth/endpoints.ts +135 -0
- package/src/oauth/errors.ts +18 -0
- package/src/oauth/form.spec.ts +35 -0
- package/src/oauth/form.ts +30 -0
- package/src/oauth/idToken.spec.ts +191 -0
- package/src/oauth/idToken.ts +153 -0
- package/src/oauth/index.ts +10 -0
- package/src/oauth/jwt.ts +90 -0
- package/src/oauth/loopbackFlow.spec.ts +226 -0
- package/src/oauth/loopbackFlow.ts +114 -0
- package/src/oauth/pkce.spec.ts +60 -0
- package/src/oauth/pkce.ts +80 -0
- package/src/oauth/tokenExchange.spec.ts +331 -0
- package/src/oauth/tokenExchange.ts +232 -0
- package/src/one.ts +30 -3
- package/src/provider/AfterAuth.tsx +7 -0
- package/src/provider/authProvider/index.native.tsx +37 -4
- package/src/session/index.ts +2 -2
- package/src/state.ts +5 -2
- package/types/betterAuth/expoAuthFlow.native.d.ts +50 -3
- package/types/betterAuth/expoAuthFlow.native.d.ts.map +1 -1
- package/types/frappeToken.d.ts +14 -1
- package/types/frappeToken.d.ts.map +1 -1
- package/types/frappeToken.native.d.ts +16 -2
- package/types/frappeToken.native.d.ts.map +1 -1
- package/types/index.d.ts +1 -1
- package/types/index.d.ts.map +1 -1
- package/types/keycloak/index.d.ts.map +1 -1
- package/types/keycloak/index.webext.d.ts.map +1 -1
- package/types/oauth/authorizationUrl.d.ts +32 -0
- package/types/oauth/authorizationUrl.d.ts.map +1 -0
- package/types/oauth/callback.d.ts +26 -0
- package/types/oauth/callback.d.ts.map +1 -0
- package/types/oauth/callbackPage.d.ts +34 -0
- package/types/oauth/callbackPage.d.ts.map +1 -0
- package/types/oauth/endpoints.d.ts +68 -0
- package/types/oauth/endpoints.d.ts.map +1 -0
- package/types/oauth/errors.d.ts +17 -0
- package/types/oauth/errors.d.ts.map +1 -0
- package/types/oauth/form.d.ts +16 -0
- package/types/oauth/form.d.ts.map +1 -0
- package/types/oauth/idToken.d.ts +83 -0
- package/types/oauth/idToken.d.ts.map +1 -0
- package/types/oauth/index.d.ts +11 -0
- package/types/oauth/index.d.ts.map +1 -0
- package/types/oauth/jwt.d.ts +31 -0
- package/types/oauth/jwt.d.ts.map +1 -0
- package/types/oauth/loopbackFlow.d.ts +64 -0
- package/types/oauth/loopbackFlow.d.ts.map +1 -0
- package/types/oauth/pkce.d.ts +49 -0
- package/types/oauth/pkce.d.ts.map +1 -0
- package/types/oauth/tokenExchange.d.ts +89 -0
- package/types/oauth/tokenExchange.d.ts.map +1 -0
- package/types/one.d.ts +7 -0
- package/types/one.d.ts.map +1 -1
- package/types/provider/AfterAuth.d.ts.map +1 -1
- package/types/provider/authProvider/index.native.d.ts.map +1 -1
- 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": "
|
|
3
|
+
"version": "7.1.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": "
|
|
58
|
-
"@multiplatform.one/store": "
|
|
62
|
+
"@multiplatform.one/keycloak-js": "7.1.0",
|
|
63
|
+
"@multiplatform.one/store": "7.1.0"
|
|
59
64
|
},
|
|
60
65
|
"devDependencies": {
|
|
61
|
-
"@tamagui/build": "2.
|
|
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": "
|
|
74
|
-
"@multiplatform.one/platform": "
|
|
75
|
-
"@multiplatform.one/logger": "
|
|
76
|
-
"@multiplatform.one/test-utils": "
|
|
78
|
+
"@multiplatform.one/config": "7.1.0",
|
|
79
|
+
"@multiplatform.one/platform": "7.1.0",
|
|
80
|
+
"@multiplatform.one/logger": "7.1.0",
|
|
81
|
+
"@multiplatform.one/test-utils": "7.1.0"
|
|
77
82
|
},
|
|
78
83
|
"peerDependencies": {
|
|
79
84
|
"react": "^19.1.0",
|
|
80
|
-
"@multiplatform.one/logger": "^
|
|
81
|
-
"@multiplatform.one/platform": "^
|
|
85
|
+
"@multiplatform.one/logger": "^7.1.0",
|
|
86
|
+
"@multiplatform.one/platform": "^7.1.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
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
}
|