@multiplatform.one/keycloak 6.1.0 → 6.4.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 (78) hide show
  1. package/README.md +58 -0
  2. package/dist/cjs/index.cjs +28 -1
  3. package/dist/cjs/index.native.cjs +36 -5
  4. package/dist/esm/index.js +28 -1
  5. package/dist/esm/index.native.js +36 -5
  6. package/package.json +18 -16
  7. package/src/betterAuth/expoAuthFlow.native.ts +56 -5
  8. package/src/frappeToken.ts +81 -4
  9. package/types/Authenticated.d.ts +9 -0
  10. package/types/Authenticated.d.ts.map +1 -0
  11. package/types/Loading.d.ts +6 -0
  12. package/types/Loading.d.ts.map +1 -0
  13. package/types/authConfig.d.ts +10 -0
  14. package/types/authConfig.d.ts.map +1 -0
  15. package/types/betterAuth/client.d.ts +798 -0
  16. package/types/betterAuth/client.d.ts.map +1 -0
  17. package/types/betterAuth/expoAuthFlow.native.d.ts +31 -0
  18. package/types/betterAuth/expoAuthFlow.native.d.ts.map +1 -0
  19. package/types/betterAuth/expoClient.d.ts +803 -0
  20. package/types/betterAuth/expoClient.d.ts.map +1 -0
  21. package/types/betterAuth/index.d.ts +3 -0
  22. package/types/betterAuth/index.d.ts.map +1 -0
  23. package/types/frappeToken.d.ts +19 -0
  24. package/types/frappeToken.d.ts.map +1 -0
  25. package/types/frappeToken.native.d.ts +15 -0
  26. package/types/frappeToken.native.d.ts.map +1 -0
  27. package/types/hooks/index.d.ts +4 -0
  28. package/types/hooks/index.d.ts.map +1 -0
  29. package/types/hooks/useAuthConfig.d.ts +2 -0
  30. package/types/hooks/useAuthConfig.d.ts.map +1 -0
  31. package/types/hooks/useTokensFromQuery/index.d.ts +2 -0
  32. package/types/hooks/useTokensFromQuery/index.d.ts.map +1 -0
  33. package/types/hooks/useTokensFromQuery/index.native.d.ts +2 -0
  34. package/types/hooks/useTokensFromQuery/index.native.d.ts.map +1 -0
  35. package/types/hooks/useTokensFromState.d.ts +2 -0
  36. package/types/hooks/useTokensFromState.d.ts.map +1 -0
  37. package/types/index.d.ts +10 -0
  38. package/types/index.d.ts.map +1 -0
  39. package/types/keycloak/base.d.ts +50 -0
  40. package/types/keycloak/base.d.ts.map +1 -0
  41. package/types/keycloak/config.d.ts +12 -0
  42. package/types/keycloak/config.d.ts.map +1 -0
  43. package/types/keycloak/context.d.ts +3 -0
  44. package/types/keycloak/context.d.ts.map +1 -0
  45. package/types/keycloak/index.d.ts +20 -0
  46. package/types/keycloak/index.d.ts.map +1 -0
  47. package/types/keycloak/index.native.d.ts +7 -0
  48. package/types/keycloak/index.native.d.ts.map +1 -0
  49. package/types/keycloak/index.webext.d.ts +19 -0
  50. package/types/keycloak/index.webext.d.ts.map +1 -0
  51. package/types/one.d.ts +28 -0
  52. package/types/one.d.ts.map +1 -0
  53. package/types/provider/AfterAuth.d.ts +6 -0
  54. package/types/provider/AfterAuth.d.ts.map +1 -0
  55. package/types/provider/KeycloakProvider.d.ts +17 -0
  56. package/types/provider/KeycloakProvider.d.ts.map +1 -0
  57. package/types/provider/authProvider/index.d.ts +13 -0
  58. package/types/provider/authProvider/index.d.ts.map +1 -0
  59. package/types/provider/authProvider/index.native.d.ts +15 -0
  60. package/types/provider/authProvider/index.native.d.ts.map +1 -0
  61. package/types/provider/authProvider/index.webext.d.ts +14 -0
  62. package/types/provider/authProvider/index.webext.d.ts.map +1 -0
  63. package/types/provider/authProvider/shared.d.ts +38 -0
  64. package/types/provider/authProvider/shared.d.ts.map +1 -0
  65. package/types/provider/index.d.ts +4 -0
  66. package/types/provider/index.d.ts.map +1 -0
  67. package/types/session/index.d.ts +29 -0
  68. package/types/session/index.d.ts.map +1 -0
  69. package/types/session/index.native.d.ts +13 -0
  70. package/types/session/index.native.d.ts.map +1 -0
  71. package/types/session/index.webext.d.ts +11 -0
  72. package/types/session/index.webext.d.ts.map +1 -0
  73. package/types/state.d.ts +18 -0
  74. package/types/state.d.ts.map +1 -0
  75. package/types/token.d.ts +129 -0
  76. package/types/token.d.ts.map +1 -0
  77. package/types/types.d.ts +19 -0
  78. package/types/types.d.ts.map +1 -0
package/README.md ADDED
@@ -0,0 +1,58 @@
1
+ # @multiplatform.one/keycloak
2
+
3
+ Keycloak authentication for the multiplatform.one ecosystem — a
4
+ [Better Auth](https://better-auth.com)–powered client with a `useKeycloak()`
5
+ hook, session hooks, route guards, and a server config factory for
6
+ [One](https://onestack.dev) apps.
7
+
8
+ ## Install
9
+
10
+ ```bash
11
+ pnpm add @multiplatform.one/keycloak
12
+ ```
13
+
14
+ Peers: `react`, `@multiplatform.one/logger`, `@multiplatform.one/platform`.
15
+
16
+ ## What it owns
17
+
18
+ - **`KeycloakProvider`** — auth context (`debug`, `disabled`, `iframeSso`,
19
+ `loginRedirectUri`, …); usually mounted for you by
20
+ `createApp({ keycloak: true })` from `@multiplatform.one/core`
21
+ - **`useKeycloak()`** — the Keycloak client (login/logout, tokens); returns
22
+ `null` when auth is disabled
23
+ - **`useSession()`**, `useAuthConfig()`, `useTokensFromQuery`,
24
+ `useTokensFromState`
25
+ - **`Authenticated`** / `withAuthenticated` — render children only when
26
+ signed in
27
+ - **Token helpers** — `isTokenExpired`, `validOrRefreshableToken`,
28
+ `getKeycloakBearerToken`, wallet-claim readers (`getWalletsFromToken`,
29
+ `getWalletAddresses`, `hasWallets`)
30
+ - **`@multiplatform.one/keycloak/one`** — `createAuth()` Better Auth server
31
+ factory (stateless JWE sessions, Keycloak OAuth, Expo support)
32
+
33
+ ## What it must not do
34
+
35
+ - No UI beyond the guard components — login screens belong to apps
36
+ - No Frappe logic besides bearer-token bridging (`getKeycloakBearerToken`)
37
+
38
+ ## Usage
39
+
40
+ ```tsx
41
+ import { Authenticated, useKeycloak } from "@multiplatform.one/keycloak";
42
+
43
+ export function AccountMenu() {
44
+ const keycloak = useKeycloak();
45
+ if (!keycloak) return null; // auth disabled
46
+ return (
47
+ <Authenticated>
48
+ <button onClick={() => keycloak.logout()}>Sign out</button>
49
+ </Authenticated>
50
+ );
51
+ }
52
+ ```
53
+
54
+ Uses the patched adapter from `@multiplatform.one/keycloak-js` under the hood.
55
+
56
+ ## License
57
+
58
+ Apache-2.0
@@ -896,6 +896,33 @@ function KeycloakProvider({
896
896
  }
897
897
 
898
898
  // src/frappeToken.ts
899
+ var cached;
899
900
  async function getKeycloakBearerToken() {
900
- return void 0;
901
+ if (typeof window === "undefined") return void 0;
902
+ if (cached && cached.expiresAt - 6e4 > Date.now()) return cached.token;
903
+ try {
904
+ const res = await fetch(`${window.location.origin}/api/auth/get-access-token`, {
905
+ method: "POST",
906
+ credentials: "include",
907
+ headers: { "Content-Type": "application/json" },
908
+ body: JSON.stringify({ providerId: "keycloak" })
909
+ });
910
+ if (!res.ok) {
911
+ cached = void 0;
912
+ return void 0;
913
+ }
914
+ const data = await res.json().catch(() => null);
915
+ if (!data?.accessToken) {
916
+ cached = void 0;
917
+ return void 0;
918
+ }
919
+ cached = {
920
+ token: data.accessToken,
921
+ expiresAt: data.accessTokenExpiresAt ? new Date(data.accessTokenExpiresAt).getTime() : Date.now() + 6e4
922
+ };
923
+ return cached.token;
924
+ } catch {
925
+ cached = void 0;
926
+ return void 0;
927
+ }
901
928
  }
@@ -416,6 +416,7 @@ function Loading({ loadingComponent }) {
416
416
  }
417
417
 
418
418
  // src/betterAuth/expoAuthFlow.native.ts
419
+ var import_react_native3 = require("react-native");
419
420
  var Linking = __toESM(require("expo-linking"));
420
421
  var SecureStore = __toESM(require("expo-secure-store"));
421
422
  var WebBrowser = __toESM(require("expo-web-browser"));
@@ -495,7 +496,7 @@ function createExpoAuthFlow(baseURL, scheme) {
495
496
  ...cookie ? { cookie } : {},
496
497
  "expo-origin": origin(),
497
498
  "x-skip-oauth-proxy": "true",
498
- ...init?.headers || {}
499
+ ...init?.headers
499
500
  }
500
501
  });
501
502
  absorbSetCookie(res.headers.get("set-cookie"));
@@ -518,11 +519,41 @@ function createExpoAuthFlow(baseURL, scheme) {
518
519
  const oauthState = getOAuthState();
519
520
  if (oauthState) params.append("oauthState", oauthState);
520
521
  const proxyURL = `${base}/api/auth/expo-authorization-proxy?${params.toString()}`;
521
- const result = await WebBrowser.openAuthSessionAsync(proxyURL, to);
522
- if (result.type !== "success") {
523
- throw new Error(`sign-in ${result.type}`);
522
+ const toBase = to.split("?")[0];
523
+ let returnUrl;
524
+ if (import_react_native3.Platform.OS === "ios") {
525
+ returnUrl = await new Promise((resolve, reject) => {
526
+ let settled = false;
527
+ const linkSub = Linking.addEventListener("url", ({ url }) => {
528
+ if (settled || !url || !url.startsWith(toBase)) return;
529
+ settled = true;
530
+ try {
531
+ linkSub.remove();
532
+ } catch {
533
+ }
534
+ resolve(url);
535
+ });
536
+ Linking.openURL(proxyURL).catch((e) => {
537
+ if (settled) return;
538
+ settled = true;
539
+ try {
540
+ linkSub.remove();
541
+ } catch {
542
+ }
543
+ reject(e instanceof Error ? e : new Error(`sign-in openURL failed: ${e}`));
544
+ });
545
+ });
546
+ } else {
547
+ const result = await WebBrowser.openAuthSessionAsync(proxyURL, toBase, {
548
+ preferEphemeralSession: false
549
+ // keep the SSO + siww nonce cookie across the wallet bounce
550
+ });
551
+ returnUrl = result.type === "success" && "url" in result ? result.url : void 0;
524
552
  }
525
- const cookie = new URL(result.url).searchParams.get("cookie");
553
+ if (!returnUrl) {
554
+ return;
555
+ }
556
+ const cookie = new URL(returnUrl).searchParams.get("cookie");
526
557
  if (cookie) absorbSetCookie(cookie);
527
558
  },
528
559
  async signOut() {
package/dist/esm/index.js CHANGED
@@ -839,8 +839,35 @@ function KeycloakProvider({
839
839
  }
840
840
 
841
841
  // src/frappeToken.ts
842
+ var cached;
842
843
  async function getKeycloakBearerToken() {
843
- return void 0;
844
+ if (typeof window === "undefined") return void 0;
845
+ if (cached && cached.expiresAt - 6e4 > Date.now()) return cached.token;
846
+ try {
847
+ const res = await fetch(`${window.location.origin}/api/auth/get-access-token`, {
848
+ method: "POST",
849
+ credentials: "include",
850
+ headers: { "Content-Type": "application/json" },
851
+ body: JSON.stringify({ providerId: "keycloak" })
852
+ });
853
+ if (!res.ok) {
854
+ cached = void 0;
855
+ return void 0;
856
+ }
857
+ const data = await res.json().catch(() => null);
858
+ if (!data?.accessToken) {
859
+ cached = void 0;
860
+ return void 0;
861
+ }
862
+ cached = {
863
+ token: data.accessToken,
864
+ expiresAt: data.accessTokenExpiresAt ? new Date(data.accessTokenExpiresAt).getTime() : Date.now() + 6e4
865
+ };
866
+ return cached.token;
867
+ } catch {
868
+ cached = void 0;
869
+ return void 0;
870
+ }
844
871
  }
845
872
  export {
846
873
  AfterAuth,
@@ -357,6 +357,7 @@ function Loading({ loadingComponent }) {
357
357
  }
358
358
 
359
359
  // src/betterAuth/expoAuthFlow.native.ts
360
+ import { Platform } from "react-native";
360
361
  import * as Linking from "expo-linking";
361
362
  import * as SecureStore from "expo-secure-store";
362
363
  import * as WebBrowser from "expo-web-browser";
@@ -436,7 +437,7 @@ function createExpoAuthFlow(baseURL, scheme) {
436
437
  ...cookie ? { cookie } : {},
437
438
  "expo-origin": origin(),
438
439
  "x-skip-oauth-proxy": "true",
439
- ...init?.headers || {}
440
+ ...init?.headers
440
441
  }
441
442
  });
442
443
  absorbSetCookie(res.headers.get("set-cookie"));
@@ -459,11 +460,41 @@ function createExpoAuthFlow(baseURL, scheme) {
459
460
  const oauthState = getOAuthState();
460
461
  if (oauthState) params.append("oauthState", oauthState);
461
462
  const proxyURL = `${base}/api/auth/expo-authorization-proxy?${params.toString()}`;
462
- const result = await WebBrowser.openAuthSessionAsync(proxyURL, to);
463
- if (result.type !== "success") {
464
- throw new Error(`sign-in ${result.type}`);
463
+ const toBase = to.split("?")[0];
464
+ let returnUrl;
465
+ if (Platform.OS === "ios") {
466
+ returnUrl = await new Promise((resolve, reject) => {
467
+ let settled = false;
468
+ const linkSub = Linking.addEventListener("url", ({ url }) => {
469
+ if (settled || !url || !url.startsWith(toBase)) return;
470
+ settled = true;
471
+ try {
472
+ linkSub.remove();
473
+ } catch {
474
+ }
475
+ resolve(url);
476
+ });
477
+ Linking.openURL(proxyURL).catch((e) => {
478
+ if (settled) return;
479
+ settled = true;
480
+ try {
481
+ linkSub.remove();
482
+ } catch {
483
+ }
484
+ reject(e instanceof Error ? e : new Error(`sign-in openURL failed: ${e}`));
485
+ });
486
+ });
487
+ } else {
488
+ const result = await WebBrowser.openAuthSessionAsync(proxyURL, toBase, {
489
+ preferEphemeralSession: false
490
+ // keep the SSO + siww nonce cookie across the wallet bounce
491
+ });
492
+ returnUrl = result.type === "success" && "url" in result ? result.url : void 0;
465
493
  }
466
- const cookie = new URL(result.url).searchParams.get("cookie");
494
+ if (!returnUrl) {
495
+ return;
496
+ }
497
+ const cookie = new URL(returnUrl).searchParams.get("cookie");
467
498
  if (cookie) absorbSetCookie(cookie);
468
499
  },
469
500
  async signOut() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@multiplatform.one/keycloak",
3
- "version": "6.1.0",
3
+ "version": "6.4.0",
4
4
  "description": "keycloak client for multiplatform.one ecosystem",
5
5
  "keywords": [
6
6
  "auth",
@@ -14,33 +14,35 @@
14
14
  "author": "BitSpur",
15
15
  "repository": {
16
16
  "type": "git",
17
- "url": "https://gitlab.com/risserlabs/multiplatform.one/keycloak"
17
+ "url": "git+https://gitlab.com/bitspur/frappe/multiplatform.one.git",
18
+ "directory": "public/keycloak"
18
19
  },
19
20
  "source": "src/index.ts",
20
21
  "files": [
21
22
  "dist",
22
- "routes",
23
23
  "src",
24
- "types"
24
+ "types",
25
+ "!types/.tsbuildinfo"
25
26
  ],
26
27
  "sideEffects": false,
27
28
  "main": "src/index.ts",
28
29
  "module": "src/index.ts",
29
- "types": "src/index.ts",
30
+ "types": "types/index.d.ts",
30
31
  "exports": {
31
32
  "./package.json": "./package.json",
32
33
  ".": {
33
34
  "react-native": {
35
+ "types": "./types/index.d.ts",
34
36
  "import": "./dist/esm/index.native.js",
35
37
  "require": "./dist/cjs/index.native.cjs"
36
38
  },
37
- "types": "./src/index.ts",
39
+ "types": "./types/index.d.ts",
38
40
  "import": "./src/index.ts",
39
41
  "require": "./src/index.ts",
40
42
  "default": "./src/index.ts"
41
43
  },
42
44
  "./one": {
43
- "types": "./src/one.ts",
45
+ "types": "./types/one.d.ts",
44
46
  "import": "./src/one.ts",
45
47
  "require": "./src/one.ts"
46
48
  }
@@ -52,8 +54,8 @@
52
54
  "@better-auth/expo": "^1.6.9",
53
55
  "better-auth": "^1.6.9",
54
56
  "jwt-decode": "^4.0.0",
55
- "@multiplatform.one/keycloak-js": "6.1.0",
56
- "@multiplatform.one/store": "6.1.0"
57
+ "@multiplatform.one/store": "6.4.0",
58
+ "@multiplatform.one/keycloak-js": "6.4.0"
57
59
  },
58
60
  "devDependencies": {
59
61
  "@tamagui/build": "2.0.0-rc.41",
@@ -68,15 +70,15 @@
68
70
  "react": "19.2.5",
69
71
  "typescript": "~5.9.3",
70
72
  "vitest": "^4.1.5",
71
- "@multiplatform.one/config": "6.1.0",
72
- "@multiplatform.one/test-utils": "6.1.0",
73
- "@multiplatform.one/platform": "6.1.0",
74
- "@multiplatform.one/logger": "6.1.0"
73
+ "@multiplatform.one/config": "6.4.0",
74
+ "@multiplatform.one/platform": "6.4.0",
75
+ "@multiplatform.one/test-utils": "6.4.0",
76
+ "@multiplatform.one/logger": "6.4.0"
75
77
  },
76
78
  "peerDependencies": {
77
79
  "react": "^19.1.0",
78
- "@multiplatform.one/logger": "^6.1.0",
79
- "@multiplatform.one/platform": "^6.1.0"
80
+ "@multiplatform.one/platform": "^6.4.0",
81
+ "@multiplatform.one/logger": "^6.4.0"
80
82
  },
81
83
  "optionalDependencies": {
82
84
  "expo-linking": "^55.0.15",
@@ -89,7 +91,7 @@
89
91
  "module:jsx": "dist/jsx",
90
92
  "scripts": {
91
93
  "typecheck": "tsc --noEmit",
92
- "build": "rm -rf dist types 2>/dev/null && node scripts/build.mjs",
94
+ "build": "rm -rf dist types 2>/dev/null && node scripts/build.mjs && tsc -p tsconfig.build.json",
93
95
  "test": "vitest run --coverage --coverage.provider=v8 --coverage.reporter=text --coverage.reporter=lcov --coverage.reporter=html"
94
96
  }
95
97
  }
@@ -11,6 +11,7 @@
11
11
  * session cookie back through the auth-session return URL.
12
12
  */
13
13
 
14
+ import { Platform } from "react-native";
14
15
  import * as Linking from "expo-linking";
15
16
  import * as SecureStore from "expo-secure-store";
16
17
  import * as WebBrowser from "expo-web-browser";
@@ -118,7 +119,7 @@ export function createExpoAuthFlow(baseURL: string, scheme?: string): ExpoAuthFl
118
119
  ...(cookie ? { cookie } : {}),
119
120
  "expo-origin": origin(),
120
121
  "x-skip-oauth-proxy": "true",
121
- ...(init?.headers || {}),
122
+ ...init?.headers,
122
123
  },
123
124
  });
124
125
  absorbSetCookie(res.headers.get("set-cookie"));
@@ -147,11 +148,61 @@ export function createExpoAuthFlow(baseURL: string, scheme?: string): ExpoAuthFl
147
148
  if (oauthState) params.append("oauthState", oauthState);
148
149
  const proxyURL = `${base}/api/auth/expo-authorization-proxy?${params.toString()}`;
149
150
 
150
- const result = await WebBrowser.openAuthSessionAsync(proxyURL, to);
151
- if (result.type !== "success") {
152
- throw new Error(`sign-in ${result.type}`);
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.
169
+ 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;
153
200
  }
154
- const cookie = new URL(result.url).searchParams.get("cookie");
201
+ if (!returnUrl) {
202
+ // dismissed / cancelled — treat as a no-op sign-in (user backed out)
203
+ return;
204
+ }
205
+ const cookie = new URL(returnUrl).searchParams.get("cookie");
155
206
  if (cookie) absorbSetCookie(cookie);
156
207
  },
157
208
 
@@ -1,10 +1,87 @@
1
1
  /**
2
2
  * Keycloak access token for Frappe API requests — web variant.
3
3
  *
4
- * On web the app is same-origin with the bench (the :8000 proxy), so the
5
- * better-auth account cookie rides along automatically and the bench's
6
- * frappe_keycloak auth hook authenticates from it. No client-side token.
4
+ * Web HTTP requests to a same-origin bench could ride the better-auth
5
+ * account cookie, but the Socket.IO realtime handshake cannot: frappe's
6
+ * socketio auth middleware only accepts a frappe `sid` cookie or an
7
+ * `Authorization` header (it never forwards arbitrary cookies to the web
8
+ * server). So the web client fetches the Keycloak access token from
9
+ * better-auth's same-origin `/get-access-token` endpoint (which refreshes
10
+ * server-side when expired, authenticated by the better-auth session
11
+ * cookie) and sends `Authorization: Bearer <token>` on every REST request
12
+ * and socket handshake — the same transport native uses. The bench's
13
+ * frappe_keycloak auth hook validates the JWT against the realm public key.
14
+ */
15
+
16
+ let cached: { token: string; expiresAt: number } | undefined;
17
+ /** In-flight fetch, shared so parallel callers don't stampede the endpoint. */
18
+ let pending: Promise<string | undefined> | undefined;
19
+ /** After a signed-out answer, skip re-asking until this time (ms epoch). */
20
+ let signedOutUntil = 0;
21
+
22
+ const SIGNED_OUT_COOLDOWN_MS = 15_000;
23
+
24
+ /**
25
+ * Returns a valid Keycloak access token, or undefined when signed out.
26
+ *
27
+ * Anonymous visitors get a single 401 per cooldown window instead of one per
28
+ * concurrent data request: parallel callers share the in-flight fetch, and a
29
+ * signed-out answer short-circuits further attempts for a few seconds. A real
30
+ * sign-in arrives via the OAuth redirect (full navigation), which resets this
31
+ * module state anyway.
7
32
  */
8
33
  export async function getKeycloakBearerToken(): Promise<string | undefined> {
9
- return undefined;
34
+ if (typeof window === "undefined") return undefined;
35
+ // 60s slack so we never hand out a token that expires mid-request.
36
+ if (cached && cached.expiresAt - 60_000 > Date.now()) return cached.token;
37
+ if (Date.now() < signedOutUntil) return undefined;
38
+ if (pending) return pending;
39
+ pending = fetchToken().finally(() => {
40
+ pending = undefined;
41
+ });
42
+ return pending;
43
+ }
44
+
45
+ async function fetchToken(): Promise<string | undefined> {
46
+ try {
47
+ const res = await fetch(`${window.location.origin}/api/auth/get-access-token`, {
48
+ method: "POST",
49
+ credentials: "include",
50
+ headers: { "Content-Type": "application/json" },
51
+ body: JSON.stringify({ providerId: "keycloak" }),
52
+ });
53
+ if (!res.ok) {
54
+ cached = undefined;
55
+ signedOutUntil = Date.now() + SIGNED_OUT_COOLDOWN_MS;
56
+ return undefined;
57
+ }
58
+ const data = (await res.json().catch(() => null)) as {
59
+ accessToken?: string;
60
+ accessTokenExpiresAt?: string;
61
+ } | null;
62
+ if (!data?.accessToken) {
63
+ cached = undefined;
64
+ signedOutUntil = Date.now() + SIGNED_OUT_COOLDOWN_MS;
65
+ return undefined;
66
+ }
67
+ cached = {
68
+ token: data.accessToken,
69
+ expiresAt: data.accessTokenExpiresAt
70
+ ? new Date(data.accessTokenExpiresAt).getTime()
71
+ : Date.now() + 60_000,
72
+ };
73
+ return cached.token;
74
+ } catch {
75
+ // Signed out / server unreachable -> guest requests.
76
+ cached = undefined;
77
+ signedOutUntil = Date.now() + SIGNED_OUT_COOLDOWN_MS;
78
+ return undefined;
79
+ }
80
+ }
81
+
82
+ /** Drop the cached token (sign-out / fresh sign-in). */
83
+ export function clearKeycloakBearerToken() {
84
+ cached = undefined;
85
+ pending = undefined;
86
+ signedOutUntil = 0;
10
87
  }
@@ -0,0 +1,9 @@
1
+ import { type ComponentType, type PropsWithChildren } from "react";
2
+ export interface AuthenticatedProps extends PropsWithChildren {
3
+ disabled?: boolean;
4
+ loadingComponent?: ComponentType;
5
+ loggedOutComponent?: ComponentType;
6
+ }
7
+ export declare function Authenticated({ children, disabled, loggedOutComponent, loadingComponent, }: AuthenticatedProps): import("react/jsx-runtime").JSX.Element;
8
+ export declare function withAuthenticated<P extends object>(Component: ComponentType<P>, options?: AuthenticatedProps): (props: P) => import("react/jsx-runtime").JSX.Element;
9
+ //# sourceMappingURL=Authenticated.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"Authenticated.d.ts","sourceRoot":"","sources":["../src/Authenticated.tsx"],"names":[],"mappings":"AACA,OAAO,EAAE,KAAK,aAAa,EAAE,KAAK,iBAAiB,EAAa,MAAM,OAAO,CAAC;AAO9E,MAAM,WAAW,kBAAmB,SAAQ,iBAAiB;IAC3D,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,gBAAgB,CAAC,EAAE,aAAa,CAAC;IACjC,kBAAkB,CAAC,EAAE,aAAa,CAAC;CACpC;AAED,wBAAgB,aAAa,CAAC,EAC5B,QAAQ,EACR,QAAQ,EACR,kBAAkB,EAClB,gBAAgB,GACjB,EAAE,kBAAkB,2CAoCpB;AAED,wBAAgB,iBAAiB,CAAC,CAAC,SAAS,MAAM,EAChD,SAAS,EAAE,aAAa,CAAC,CAAC,CAAC,EAC3B,OAAO,GAAE,kBAAuB,IAExB,OAAO,CAAC,6CAKjB"}
@@ -0,0 +1,6 @@
1
+ import type { ComponentType } from "react";
2
+ export interface LoadingProps {
3
+ loadingComponent?: ComponentType;
4
+ }
5
+ export declare function Loading({ loadingComponent }: LoadingProps): import("react/jsx-runtime").JSX.Element;
6
+ //# sourceMappingURL=Loading.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"Loading.d.ts","sourceRoot":"","sources":["../src/Loading.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,OAAO,CAAC;AAO3C,MAAM,WAAW,YAAY;IAC3B,gBAAgB,CAAC,EAAE,aAAa,CAAC;CAClC;AAED,wBAAgB,OAAO,CAAC,EAAE,gBAAgB,EAAE,EAAE,YAAY,2CAYzD"}
@@ -0,0 +1,10 @@
1
+ export declare const defaultAuthConfig: AuthConfig;
2
+ export declare const AuthConfigContext: import("react").Context<AuthConfig>;
3
+ export interface AuthConfig {
4
+ debug?: boolean;
5
+ disabled?: boolean;
6
+ iframeSso?: boolean;
7
+ loginRedirectUri?: string;
8
+ messageHandlerKeys?: string[];
9
+ }
10
+ //# sourceMappingURL=authConfig.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"authConfig.d.ts","sourceRoot":"","sources":["../src/authConfig.ts"],"names":[],"mappings":"AAEA,eAAO,MAAM,iBAAiB,EAAE,UAAe,CAAC;AAEhD,eAAO,MAAM,iBAAiB,qCAA+C,CAAC;AAE9E,MAAM,WAAW,UAAU;IACzB,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,kBAAkB,CAAC,EAAE,MAAM,EAAE,CAAC;CAC/B"}