@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.
- package/README.md +58 -0
- package/dist/cjs/index.cjs +28 -1
- package/dist/cjs/index.native.cjs +36 -5
- package/dist/esm/index.js +28 -1
- package/dist/esm/index.native.js +36 -5
- package/package.json +18 -16
- package/src/betterAuth/expoAuthFlow.native.ts +56 -5
- package/src/frappeToken.ts +81 -4
- package/types/Authenticated.d.ts +9 -0
- package/types/Authenticated.d.ts.map +1 -0
- package/types/Loading.d.ts +6 -0
- package/types/Loading.d.ts.map +1 -0
- package/types/authConfig.d.ts +10 -0
- package/types/authConfig.d.ts.map +1 -0
- package/types/betterAuth/client.d.ts +798 -0
- package/types/betterAuth/client.d.ts.map +1 -0
- package/types/betterAuth/expoAuthFlow.native.d.ts +31 -0
- package/types/betterAuth/expoAuthFlow.native.d.ts.map +1 -0
- package/types/betterAuth/expoClient.d.ts +803 -0
- package/types/betterAuth/expoClient.d.ts.map +1 -0
- package/types/betterAuth/index.d.ts +3 -0
- package/types/betterAuth/index.d.ts.map +1 -0
- package/types/frappeToken.d.ts +19 -0
- package/types/frappeToken.d.ts.map +1 -0
- package/types/frappeToken.native.d.ts +15 -0
- package/types/frappeToken.native.d.ts.map +1 -0
- package/types/hooks/index.d.ts +4 -0
- package/types/hooks/index.d.ts.map +1 -0
- package/types/hooks/useAuthConfig.d.ts +2 -0
- package/types/hooks/useAuthConfig.d.ts.map +1 -0
- package/types/hooks/useTokensFromQuery/index.d.ts +2 -0
- package/types/hooks/useTokensFromQuery/index.d.ts.map +1 -0
- package/types/hooks/useTokensFromQuery/index.native.d.ts +2 -0
- package/types/hooks/useTokensFromQuery/index.native.d.ts.map +1 -0
- package/types/hooks/useTokensFromState.d.ts +2 -0
- package/types/hooks/useTokensFromState.d.ts.map +1 -0
- package/types/index.d.ts +10 -0
- package/types/index.d.ts.map +1 -0
- package/types/keycloak/base.d.ts +50 -0
- package/types/keycloak/base.d.ts.map +1 -0
- package/types/keycloak/config.d.ts +12 -0
- package/types/keycloak/config.d.ts.map +1 -0
- package/types/keycloak/context.d.ts +3 -0
- package/types/keycloak/context.d.ts.map +1 -0
- package/types/keycloak/index.d.ts +20 -0
- package/types/keycloak/index.d.ts.map +1 -0
- package/types/keycloak/index.native.d.ts +7 -0
- package/types/keycloak/index.native.d.ts.map +1 -0
- package/types/keycloak/index.webext.d.ts +19 -0
- package/types/keycloak/index.webext.d.ts.map +1 -0
- package/types/one.d.ts +28 -0
- package/types/one.d.ts.map +1 -0
- package/types/provider/AfterAuth.d.ts +6 -0
- package/types/provider/AfterAuth.d.ts.map +1 -0
- package/types/provider/KeycloakProvider.d.ts +17 -0
- package/types/provider/KeycloakProvider.d.ts.map +1 -0
- package/types/provider/authProvider/index.d.ts +13 -0
- package/types/provider/authProvider/index.d.ts.map +1 -0
- package/types/provider/authProvider/index.native.d.ts +15 -0
- package/types/provider/authProvider/index.native.d.ts.map +1 -0
- package/types/provider/authProvider/index.webext.d.ts +14 -0
- package/types/provider/authProvider/index.webext.d.ts.map +1 -0
- package/types/provider/authProvider/shared.d.ts +38 -0
- package/types/provider/authProvider/shared.d.ts.map +1 -0
- package/types/provider/index.d.ts +4 -0
- package/types/provider/index.d.ts.map +1 -0
- package/types/session/index.d.ts +29 -0
- package/types/session/index.d.ts.map +1 -0
- package/types/session/index.native.d.ts +13 -0
- package/types/session/index.native.d.ts.map +1 -0
- package/types/session/index.webext.d.ts +11 -0
- package/types/session/index.webext.d.ts.map +1 -0
- package/types/state.d.ts +18 -0
- package/types/state.d.ts.map +1 -0
- package/types/token.d.ts +129 -0
- package/types/token.d.ts.map +1 -0
- package/types/types.d.ts +19 -0
- 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
|
package/dist/cjs/index.cjs
CHANGED
|
@@ -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
|
|
522
|
-
|
|
523
|
-
|
|
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
|
-
|
|
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,
|
package/dist/esm/index.native.js
CHANGED
|
@@ -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
|
|
463
|
-
|
|
464
|
-
|
|
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
|
-
|
|
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.
|
|
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/
|
|
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": "
|
|
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": "./
|
|
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": "./
|
|
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/
|
|
56
|
-
"@multiplatform.one/
|
|
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.
|
|
72
|
-
"@multiplatform.one/
|
|
73
|
-
"@multiplatform.one/
|
|
74
|
-
"@multiplatform.one/logger": "6.
|
|
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/
|
|
79
|
-
"@multiplatform.one/
|
|
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
|
-
...
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
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
|
|
package/src/frappeToken.ts
CHANGED
|
@@ -1,10 +1,87 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Keycloak access token for Frappe API requests — web variant.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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 @@
|
|
|
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"}
|