@multiplatform.one/keycloak 6.0.4 → 6.3.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 +46 -6
- package/dist/cjs/index.native.cjs +748 -0
- package/dist/esm/index.js +45 -5
- package/dist/esm/index.native.js +715 -0
- package/package.json +23 -17
- package/src/Authenticated.tsx +3 -1
- package/src/Loading.tsx +10 -2
- package/src/betterAuth/expoAuthFlow.native.ts +238 -0
- package/src/frappeToken.native.ts +45 -0
- package/src/frappeToken.ts +59 -0
- package/src/index.ts +1 -0
- package/src/provider/KeycloakProvider.tsx +11 -0
- package/src/provider/authProvider/index.native.tsx +72 -64
- package/src/session/index.native.ts +32 -7
- 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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@multiplatform.one/keycloak",
|
|
3
|
-
"version": "6.0
|
|
3
|
+
"version": "6.3.0",
|
|
4
4
|
"description": "keycloak client for multiplatform.one ecosystem",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"auth",
|
|
@@ -14,30 +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
|
-
"react-native":
|
|
34
|
-
|
|
34
|
+
"react-native": {
|
|
35
|
+
"types": "./types/index.d.ts",
|
|
36
|
+
"import": "./dist/esm/index.native.js",
|
|
37
|
+
"require": "./dist/cjs/index.native.cjs"
|
|
38
|
+
},
|
|
39
|
+
"types": "./types/index.d.ts",
|
|
35
40
|
"import": "./src/index.ts",
|
|
36
41
|
"require": "./src/index.ts",
|
|
37
42
|
"default": "./src/index.ts"
|
|
38
43
|
},
|
|
39
44
|
"./one": {
|
|
40
|
-
"types": "./
|
|
45
|
+
"types": "./types/one.d.ts",
|
|
41
46
|
"import": "./src/one.ts",
|
|
42
47
|
"require": "./src/one.ts"
|
|
43
48
|
}
|
|
@@ -49,8 +54,8 @@
|
|
|
49
54
|
"@better-auth/expo": "^1.6.9",
|
|
50
55
|
"better-auth": "^1.6.9",
|
|
51
56
|
"jwt-decode": "^4.0.0",
|
|
52
|
-
"@multiplatform.one/keycloak-js": "6.0
|
|
53
|
-
"@multiplatform.one/store": "6.0
|
|
57
|
+
"@multiplatform.one/keycloak-js": "6.3.0",
|
|
58
|
+
"@multiplatform.one/store": "6.3.0"
|
|
54
59
|
},
|
|
55
60
|
"devDependencies": {
|
|
56
61
|
"@tamagui/build": "2.0.0-rc.41",
|
|
@@ -65,17 +70,18 @@
|
|
|
65
70
|
"react": "19.2.5",
|
|
66
71
|
"typescript": "~5.9.3",
|
|
67
72
|
"vitest": "^4.1.5",
|
|
68
|
-
"@multiplatform.one/
|
|
69
|
-
"@multiplatform.one/
|
|
70
|
-
"@multiplatform.one/
|
|
71
|
-
"@multiplatform.one/
|
|
73
|
+
"@multiplatform.one/platform": "6.3.0",
|
|
74
|
+
"@multiplatform.one/test-utils": "6.3.0",
|
|
75
|
+
"@multiplatform.one/config": "6.3.0",
|
|
76
|
+
"@multiplatform.one/logger": "6.3.0"
|
|
72
77
|
},
|
|
73
78
|
"peerDependencies": {
|
|
74
79
|
"react": "^19.1.0",
|
|
75
|
-
"@multiplatform.one/
|
|
76
|
-
"@multiplatform.one/
|
|
80
|
+
"@multiplatform.one/platform": "^6.3.0",
|
|
81
|
+
"@multiplatform.one/logger": "^6.3.0"
|
|
77
82
|
},
|
|
78
83
|
"optionalDependencies": {
|
|
84
|
+
"expo-linking": "^55.0.15",
|
|
79
85
|
"expo-secure-store": "^55.0.13",
|
|
80
86
|
"expo-web-browser": "^55.0.14"
|
|
81
87
|
},
|
|
@@ -85,7 +91,7 @@
|
|
|
85
91
|
"module:jsx": "dist/jsx",
|
|
86
92
|
"scripts": {
|
|
87
93
|
"typecheck": "tsc --noEmit",
|
|
88
|
-
"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",
|
|
89
95
|
"test": "vitest run --coverage --coverage.provider=v8 --coverage.reporter=text --coverage.reporter=lcov --coverage.reporter=html"
|
|
90
96
|
}
|
|
91
97
|
}
|
package/src/Authenticated.tsx
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { isIframe, isServer } from "@multiplatform.one/platform";
|
|
2
2
|
import { type ComponentType, type PropsWithChildren, useEffect } from "react";
|
|
3
|
-
|
|
3
|
+
// react-native Text, not tamagui -- this can render above the app's
|
|
4
|
+
// TamaguiProvider (see Loading.tsx).
|
|
5
|
+
import { Text } from "react-native";
|
|
4
6
|
import { useAuthConfig, useTokensFromQuery } from "./hooks";
|
|
5
7
|
import { useKeycloak } from "./keycloak/index";
|
|
6
8
|
|
package/src/Loading.tsx
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import type { ComponentType } from "react";
|
|
2
|
-
|
|
2
|
+
// react-native primitives, NOT tamagui: the Keycloak provider mounts ABOVE the
|
|
3
|
+
// app's ThemeProvider/TamaguiProvider in createApp's composition, so tamagui
|
|
4
|
+
// components here throw "Can't find Tamagui configuration" on native.
|
|
5
|
+
import { ActivityIndicator, Text, View } from "react-native";
|
|
3
6
|
import { useAuthConfig } from "./hooks";
|
|
4
7
|
|
|
5
8
|
export interface LoadingProps {
|
|
@@ -10,7 +13,12 @@ export function Loading({ loadingComponent }: LoadingProps) {
|
|
|
10
13
|
const { debug } = useAuthConfig();
|
|
11
14
|
const LoadingComponent = loadingComponent;
|
|
12
15
|
if (typeof LoadingComponent === "undefined") {
|
|
13
|
-
return
|
|
16
|
+
return (
|
|
17
|
+
<View style={{ flex: 1, alignItems: "center", justifyContent: "center" }}>
|
|
18
|
+
<ActivityIndicator />
|
|
19
|
+
{debug ? <Text>loading</Text> : null}
|
|
20
|
+
</View>
|
|
21
|
+
);
|
|
14
22
|
}
|
|
15
23
|
return <LoadingComponent />;
|
|
16
24
|
}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hand-rolled Better Auth flow for native (Expo) — fetch + expo-web-browser +
|
|
3
|
+
* SecureStore, NO better-auth client packages.
|
|
4
|
+
*
|
|
5
|
+
* Why not @better-auth/expo/client: its import graph drags better-auth's
|
|
6
|
+
* cookies module -> @better-auth/core db schema -> zod into the Hermes bundle,
|
|
7
|
+
* and zod's module init dies under the vxrn/rolldown dev transform
|
|
8
|
+
* ("fn is not a function"). The wire protocol underneath is tiny (mirrors
|
|
9
|
+
* @better-auth/expo/dist/client.js): a cookie jar in SecureStore, an
|
|
10
|
+
* expo-origin header, the server's /expo-authorization-proxy to hand the
|
|
11
|
+
* session cookie back through the auth-session return URL.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { Platform } from "react-native";
|
|
15
|
+
import * as Linking from "expo-linking";
|
|
16
|
+
import * as SecureStore from "expo-secure-store";
|
|
17
|
+
import * as WebBrowser from "expo-web-browser";
|
|
18
|
+
|
|
19
|
+
const COOKIE_KEY = "better-auth_cookie";
|
|
20
|
+
const SECURE_COOKIE_PREFIX = "__Secure-";
|
|
21
|
+
|
|
22
|
+
type CookieJar = Record<string, { value: string; expires: string | null }>;
|
|
23
|
+
|
|
24
|
+
function readJar(): CookieJar {
|
|
25
|
+
try {
|
|
26
|
+
return JSON.parse(SecureStore.getItem(COOKIE_KEY) || "{}");
|
|
27
|
+
} catch {
|
|
28
|
+
return {};
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function writeJar(jar: CookieJar) {
|
|
33
|
+
SecureStore.setItem(COOKIE_KEY, JSON.stringify(jar));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Serialize the jar to a Cookie header, dropping expired entries. */
|
|
37
|
+
function cookieHeader(): string {
|
|
38
|
+
return Object.entries(readJar()).reduce((acc, [key, v]) => {
|
|
39
|
+
if (v.expires && new Date(v.expires) < new Date()) return acc;
|
|
40
|
+
return acc ? `${acc}; ${key}=${v.value}` : `${key}=${v.value}`;
|
|
41
|
+
}, "");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Merge a Set-Cookie header (possibly comma-joined) into the jar. */
|
|
45
|
+
function absorbSetCookie(header: string | null) {
|
|
46
|
+
if (!header) return;
|
|
47
|
+
const jar = readJar();
|
|
48
|
+
// Split on commas that start a new cookie (name=), not expires dates.
|
|
49
|
+
for (const part of header.split(/,(?=[^;,]+?=)/)) {
|
|
50
|
+
const [pair, ...attrs] = part.split(";");
|
|
51
|
+
const eq = pair.indexOf("=");
|
|
52
|
+
if (eq === -1) continue;
|
|
53
|
+
const name = pair.slice(0, eq).trim();
|
|
54
|
+
const value = pair.slice(eq + 1).trim();
|
|
55
|
+
let expires: string | null = null;
|
|
56
|
+
let dead = false;
|
|
57
|
+
for (const attr of attrs) {
|
|
58
|
+
const [k, v] = attr.split("=").map((s) => s?.trim());
|
|
59
|
+
const lk = (k || "").toLowerCase();
|
|
60
|
+
if (lk === "max-age") {
|
|
61
|
+
const n = Number(v);
|
|
62
|
+
if (n <= 0) dead = true;
|
|
63
|
+
else expires = new Date(Date.now() + n * 1000).toISOString();
|
|
64
|
+
} else if (lk === "expires" && !expires && v) {
|
|
65
|
+
const d = new Date(part.slice(part.toLowerCase().indexOf("expires=") + 8).split(";")[0]);
|
|
66
|
+
if (!Number.isNaN(d.getTime())) {
|
|
67
|
+
if (d.getTime() <= Date.now()) dead = true;
|
|
68
|
+
else expires = d.toISOString();
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (dead) delete jar[name];
|
|
73
|
+
else jar[name] = { value, expires };
|
|
74
|
+
}
|
|
75
|
+
writeJar(jar);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function getOAuthState(): string | null {
|
|
79
|
+
const jar = readJar();
|
|
80
|
+
for (const name of [
|
|
81
|
+
`${SECURE_COOKIE_PREFIX}better-auth.oauth_state`,
|
|
82
|
+
"better-auth.oauth_state",
|
|
83
|
+
]) {
|
|
84
|
+
if (jar[name]?.value) return jar[name].value;
|
|
85
|
+
}
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface ExpoAuthFlow {
|
|
90
|
+
signIn(provider: string, callbackURL?: string): Promise<void>;
|
|
91
|
+
signOut(): Promise<void>;
|
|
92
|
+
getSession(): Promise<{ user?: { id: string; email?: string; name?: string } } | null>;
|
|
93
|
+
/** Valid provider access token (server refreshes when expired). */
|
|
94
|
+
getAccessToken(
|
|
95
|
+
providerId: string,
|
|
96
|
+
): Promise<{ accessToken?: string; accessTokenExpiresAt?: string } | null>;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// The app mounts ONE AuthProvider, which creates one flow. Expose it so
|
|
100
|
+
// non-React consumers (the Frappe Bearer-token getter) can reach the same
|
|
101
|
+
// cookie jar + base URL.
|
|
102
|
+
let activeFlow: ExpoAuthFlow | null = null;
|
|
103
|
+
|
|
104
|
+
export function getActiveExpoAuthFlow(): ExpoAuthFlow | null {
|
|
105
|
+
return activeFlow;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function createExpoAuthFlow(baseURL: string, scheme?: string): ExpoAuthFlow {
|
|
109
|
+
const base = baseURL.replace(/\/$/, "");
|
|
110
|
+
const origin = () => Linking.createURL("/", scheme ? { scheme } : undefined);
|
|
111
|
+
|
|
112
|
+
async function authFetch(path: string, init?: RequestInit) {
|
|
113
|
+
const cookie = cookieHeader();
|
|
114
|
+
const res = await fetch(`${base}/api/auth${path}`, {
|
|
115
|
+
...init,
|
|
116
|
+
credentials: "omit",
|
|
117
|
+
headers: {
|
|
118
|
+
"Content-Type": "application/json",
|
|
119
|
+
...(cookie ? { cookie } : {}),
|
|
120
|
+
"expo-origin": origin(),
|
|
121
|
+
"x-skip-oauth-proxy": "true",
|
|
122
|
+
...init?.headers,
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
absorbSetCookie(res.headers.get("set-cookie"));
|
|
126
|
+
return res;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const flow: ExpoAuthFlow = {
|
|
130
|
+
async signIn(provider: string, callbackURL?: string) {
|
|
131
|
+
const to = callbackURL || Linking.createURL("/");
|
|
132
|
+
const res = await authFetch("/sign-in/social", {
|
|
133
|
+
method: "POST",
|
|
134
|
+
body: JSON.stringify({ provider, callbackURL: to }),
|
|
135
|
+
});
|
|
136
|
+
if (!res.ok) {
|
|
137
|
+
const body = await res.text().catch(() => "");
|
|
138
|
+
throw new Error(`sign-in failed (${res.status}): ${body.slice(0, 200)}`);
|
|
139
|
+
}
|
|
140
|
+
const data = (await res.json()) as { url?: string; redirect?: boolean };
|
|
141
|
+
if (!data?.url) throw new Error("sign-in response had no authorization url");
|
|
142
|
+
|
|
143
|
+
// The server's expo plugin proxies the IdP redirect and forwards the
|
|
144
|
+
// session cookie back via the return URL (?cookie=...), because the
|
|
145
|
+
// OAuth callback's Set-Cookie lands in the auth-session browser, not us.
|
|
146
|
+
const params = new URLSearchParams({ authorizationURL: data.url });
|
|
147
|
+
const oauthState = getOAuthState();
|
|
148
|
+
if (oauthState) params.append("oauthState", oauthState);
|
|
149
|
+
const proxyURL = `${base}/api/auth/expo-authorization-proxy?${params.toString()}`;
|
|
150
|
+
|
|
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;
|
|
200
|
+
}
|
|
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");
|
|
206
|
+
if (cookie) absorbSetCookie(cookie);
|
|
207
|
+
},
|
|
208
|
+
|
|
209
|
+
async signOut() {
|
|
210
|
+
try {
|
|
211
|
+
await authFetch("/sign-out", { method: "POST", body: "{}" });
|
|
212
|
+
} finally {
|
|
213
|
+
writeJar({});
|
|
214
|
+
}
|
|
215
|
+
},
|
|
216
|
+
|
|
217
|
+
async getSession() {
|
|
218
|
+
const res = await authFetch("/get-session");
|
|
219
|
+
if (!res.ok) return null;
|
|
220
|
+
const data = await res.json().catch(() => null);
|
|
221
|
+
return data && Object.keys(data).length ? data : null;
|
|
222
|
+
},
|
|
223
|
+
|
|
224
|
+
async getAccessToken(providerId: string) {
|
|
225
|
+
const res = await authFetch("/get-access-token", {
|
|
226
|
+
method: "POST",
|
|
227
|
+
body: JSON.stringify({ providerId }),
|
|
228
|
+
});
|
|
229
|
+
if (!res.ok) return null;
|
|
230
|
+
return (await res.json().catch(() => null)) as {
|
|
231
|
+
accessToken?: string;
|
|
232
|
+
accessTokenExpiresAt?: string;
|
|
233
|
+
} | null;
|
|
234
|
+
},
|
|
235
|
+
};
|
|
236
|
+
activeFlow = flow;
|
|
237
|
+
return flow;
|
|
238
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keycloak access token for Frappe API requests — native variant.
|
|
3
|
+
*
|
|
4
|
+
* Native Frappe requests can't ride the better-auth cookies (they live in the
|
|
5
|
+
* SecureStore jar, not the OS cookie store), so the Frappe HttpClient sends
|
|
6
|
+
* `Authorization: Bearer <keycloak access token>` instead — the bench's
|
|
7
|
+
* frappe_keycloak auth hook checks the Bearer header first. The token comes
|
|
8
|
+
* from better-auth's /get-access-token endpoint (which refreshes server-side
|
|
9
|
+
* when expired), authenticated with the SecureStore cookie jar.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { getActiveExpoAuthFlow } from "./betterAuth/expoAuthFlow.native";
|
|
13
|
+
|
|
14
|
+
let cached: { token: string; expiresAt: number } | undefined;
|
|
15
|
+
|
|
16
|
+
/** Returns a valid Keycloak access token, or undefined when signed out. */
|
|
17
|
+
export async function getKeycloakBearerToken(): Promise<string | undefined> {
|
|
18
|
+
// 60s slack so we never hand out a token that expires mid-request.
|
|
19
|
+
if (cached && cached.expiresAt - 60_000 > Date.now()) return cached.token;
|
|
20
|
+
const flow = getActiveExpoAuthFlow();
|
|
21
|
+
if (!flow) return undefined;
|
|
22
|
+
try {
|
|
23
|
+
const res = await flow.getAccessToken("keycloak");
|
|
24
|
+
if (!res?.accessToken) {
|
|
25
|
+
cached = undefined;
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
cached = {
|
|
29
|
+
token: res.accessToken,
|
|
30
|
+
expiresAt: res.accessTokenExpiresAt
|
|
31
|
+
? new Date(res.accessTokenExpiresAt).getTime()
|
|
32
|
+
: Date.now() + 60_000,
|
|
33
|
+
};
|
|
34
|
+
return cached.token;
|
|
35
|
+
} catch {
|
|
36
|
+
// Signed out / server unreachable -> guest requests.
|
|
37
|
+
cached = undefined;
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Drop the cached token (sign-out). */
|
|
43
|
+
export function clearKeycloakBearerToken() {
|
|
44
|
+
cached = undefined;
|
|
45
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keycloak access token for Frappe API requests — web variant.
|
|
3
|
+
*
|
|
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
|
+
|
|
18
|
+
/** Returns a valid Keycloak access token, or undefined when signed out. */
|
|
19
|
+
export async function getKeycloakBearerToken(): Promise<string | undefined> {
|
|
20
|
+
if (typeof window === "undefined") return undefined;
|
|
21
|
+
// 60s slack so we never hand out a token that expires mid-request.
|
|
22
|
+
if (cached && cached.expiresAt - 60_000 > Date.now()) return cached.token;
|
|
23
|
+
try {
|
|
24
|
+
const res = await fetch(`${window.location.origin}/api/auth/get-access-token`, {
|
|
25
|
+
method: "POST",
|
|
26
|
+
credentials: "include",
|
|
27
|
+
headers: { "Content-Type": "application/json" },
|
|
28
|
+
body: JSON.stringify({ providerId: "keycloak" }),
|
|
29
|
+
});
|
|
30
|
+
if (!res.ok) {
|
|
31
|
+
cached = undefined;
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
const data = (await res.json().catch(() => null)) as {
|
|
35
|
+
accessToken?: string;
|
|
36
|
+
accessTokenExpiresAt?: string;
|
|
37
|
+
} | null;
|
|
38
|
+
if (!data?.accessToken) {
|
|
39
|
+
cached = undefined;
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
cached = {
|
|
43
|
+
token: data.accessToken,
|
|
44
|
+
expiresAt: data.accessTokenExpiresAt
|
|
45
|
+
? new Date(data.accessTokenExpiresAt).getTime()
|
|
46
|
+
: Date.now() + 60_000,
|
|
47
|
+
};
|
|
48
|
+
return cached.token;
|
|
49
|
+
} catch {
|
|
50
|
+
// Signed out / server unreachable -> guest requests.
|
|
51
|
+
cached = undefined;
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Drop the cached token (sign-out). */
|
|
57
|
+
export function clearKeycloakBearerToken() {
|
|
58
|
+
cached = undefined;
|
|
59
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -9,12 +9,21 @@ export interface KeycloakProviderProps extends PropsWithChildren, AuthConfig {
|
|
|
9
9
|
clientId?: string;
|
|
10
10
|
publicClientId?: string;
|
|
11
11
|
realm?: string;
|
|
12
|
+
/** Better Auth server base URL (drives the native sign-in flow). Without it
|
|
13
|
+
* the native provider falls back to a hardcoded localhost:5000 -- on macOS
|
|
14
|
+
* that's ControlCenter (AirPlay) answering 403 to everything, which makes
|
|
15
|
+
* the Login button a perfectly silent no-op. */
|
|
16
|
+
betterAuthBaseUrl?: string;
|
|
17
|
+
/** App scheme for the Expo auth-session redirect (native). */
|
|
18
|
+
expoScheme?: string;
|
|
12
19
|
}
|
|
13
20
|
|
|
14
21
|
export function KeycloakProvider({
|
|
15
22
|
baseUrl,
|
|
23
|
+
betterAuthBaseUrl,
|
|
16
24
|
children,
|
|
17
25
|
clientId,
|
|
26
|
+
expoScheme,
|
|
18
27
|
loginRedirectUri,
|
|
19
28
|
debug,
|
|
20
29
|
disabled,
|
|
@@ -35,6 +44,8 @@ export function KeycloakProvider({
|
|
|
35
44
|
publicClientId,
|
|
36
45
|
realm: realm || "main",
|
|
37
46
|
url: baseUrl ?? "",
|
|
47
|
+
betterAuthBaseUrl,
|
|
48
|
+
expoScheme,
|
|
38
49
|
}}
|
|
39
50
|
>
|
|
40
51
|
{children}
|