@multiplatform.one/keycloak 6.0.3 → 6.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@multiplatform.one/keycloak",
3
- "version": "6.0.3",
3
+ "version": "6.1.0",
4
4
  "description": "keycloak client for multiplatform.one ecosystem",
5
5
  "keywords": [
6
6
  "auth",
@@ -30,7 +30,10 @@
30
30
  "exports": {
31
31
  "./package.json": "./package.json",
32
32
  ".": {
33
- "react-native": "./src/index.native.ts",
33
+ "react-native": {
34
+ "import": "./dist/esm/index.native.js",
35
+ "require": "./dist/cjs/index.native.cjs"
36
+ },
34
37
  "types": "./src/index.ts",
35
38
  "import": "./src/index.ts",
36
39
  "require": "./src/index.ts",
@@ -49,8 +52,8 @@
49
52
  "@better-auth/expo": "^1.6.9",
50
53
  "better-auth": "^1.6.9",
51
54
  "jwt-decode": "^4.0.0",
52
- "@multiplatform.one/keycloak-js": "6.0.3",
53
- "@multiplatform.one/store": "6.0.3"
55
+ "@multiplatform.one/keycloak-js": "6.1.0",
56
+ "@multiplatform.one/store": "6.1.0"
54
57
  },
55
58
  "devDependencies": {
56
59
  "@tamagui/build": "2.0.0-rc.41",
@@ -65,17 +68,18 @@
65
68
  "react": "19.2.5",
66
69
  "typescript": "~5.9.3",
67
70
  "vitest": "^4.1.5",
68
- "@multiplatform.one/config": "6.0.3",
69
- "@multiplatform.one/logger": "6.0.3",
70
- "@multiplatform.one/platform": "6.0.3",
71
- "@multiplatform.one/test-utils": "6.0.3"
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"
72
75
  },
73
76
  "peerDependencies": {
74
77
  "react": "^19.1.0",
75
- "@multiplatform.one/logger": "6.0.3",
76
- "@multiplatform.one/platform": "6.0.3"
78
+ "@multiplatform.one/logger": "^6.1.0",
79
+ "@multiplatform.one/platform": "^6.1.0"
77
80
  },
78
81
  "optionalDependencies": {
82
+ "expo-linking": "^55.0.15",
79
83
  "expo-secure-store": "^55.0.13",
80
84
  "expo-web-browser": "^55.0.14"
81
85
  },
@@ -1,6 +1,8 @@
1
1
  import { isIframe, isServer } from "@multiplatform.one/platform";
2
2
  import { type ComponentType, type PropsWithChildren, useEffect } from "react";
3
- import { Text } from "tamagui";
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
- import { Text } from "tamagui";
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 <Text>{debug ? "loading" : null}</Text>;
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,187 @@
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 * as Linking from "expo-linking";
15
+ import * as SecureStore from "expo-secure-store";
16
+ import * as WebBrowser from "expo-web-browser";
17
+
18
+ const COOKIE_KEY = "better-auth_cookie";
19
+ const SECURE_COOKIE_PREFIX = "__Secure-";
20
+
21
+ type CookieJar = Record<string, { value: string; expires: string | null }>;
22
+
23
+ function readJar(): CookieJar {
24
+ try {
25
+ return JSON.parse(SecureStore.getItem(COOKIE_KEY) || "{}");
26
+ } catch {
27
+ return {};
28
+ }
29
+ }
30
+
31
+ function writeJar(jar: CookieJar) {
32
+ SecureStore.setItem(COOKIE_KEY, JSON.stringify(jar));
33
+ }
34
+
35
+ /** Serialize the jar to a Cookie header, dropping expired entries. */
36
+ function cookieHeader(): string {
37
+ return Object.entries(readJar()).reduce((acc, [key, v]) => {
38
+ if (v.expires && new Date(v.expires) < new Date()) return acc;
39
+ return acc ? `${acc}; ${key}=${v.value}` : `${key}=${v.value}`;
40
+ }, "");
41
+ }
42
+
43
+ /** Merge a Set-Cookie header (possibly comma-joined) into the jar. */
44
+ function absorbSetCookie(header: string | null) {
45
+ if (!header) return;
46
+ const jar = readJar();
47
+ // Split on commas that start a new cookie (name=), not expires dates.
48
+ for (const part of header.split(/,(?=[^;,]+?=)/)) {
49
+ const [pair, ...attrs] = part.split(";");
50
+ const eq = pair.indexOf("=");
51
+ if (eq === -1) continue;
52
+ const name = pair.slice(0, eq).trim();
53
+ const value = pair.slice(eq + 1).trim();
54
+ let expires: string | null = null;
55
+ let dead = false;
56
+ for (const attr of attrs) {
57
+ const [k, v] = attr.split("=").map((s) => s?.trim());
58
+ const lk = (k || "").toLowerCase();
59
+ if (lk === "max-age") {
60
+ const n = Number(v);
61
+ if (n <= 0) dead = true;
62
+ else expires = new Date(Date.now() + n * 1000).toISOString();
63
+ } else if (lk === "expires" && !expires && v) {
64
+ const d = new Date(part.slice(part.toLowerCase().indexOf("expires=") + 8).split(";")[0]);
65
+ if (!Number.isNaN(d.getTime())) {
66
+ if (d.getTime() <= Date.now()) dead = true;
67
+ else expires = d.toISOString();
68
+ }
69
+ }
70
+ }
71
+ if (dead) delete jar[name];
72
+ else jar[name] = { value, expires };
73
+ }
74
+ writeJar(jar);
75
+ }
76
+
77
+ function getOAuthState(): string | null {
78
+ const jar = readJar();
79
+ for (const name of [
80
+ `${SECURE_COOKIE_PREFIX}better-auth.oauth_state`,
81
+ "better-auth.oauth_state",
82
+ ]) {
83
+ if (jar[name]?.value) return jar[name].value;
84
+ }
85
+ return null;
86
+ }
87
+
88
+ export interface ExpoAuthFlow {
89
+ signIn(provider: string, callbackURL?: string): Promise<void>;
90
+ signOut(): Promise<void>;
91
+ getSession(): Promise<{ user?: { id: string; email?: string; name?: string } } | null>;
92
+ /** Valid provider access token (server refreshes when expired). */
93
+ getAccessToken(
94
+ providerId: string,
95
+ ): Promise<{ accessToken?: string; accessTokenExpiresAt?: string } | null>;
96
+ }
97
+
98
+ // The app mounts ONE AuthProvider, which creates one flow. Expose it so
99
+ // non-React consumers (the Frappe Bearer-token getter) can reach the same
100
+ // cookie jar + base URL.
101
+ let activeFlow: ExpoAuthFlow | null = null;
102
+
103
+ export function getActiveExpoAuthFlow(): ExpoAuthFlow | null {
104
+ return activeFlow;
105
+ }
106
+
107
+ export function createExpoAuthFlow(baseURL: string, scheme?: string): ExpoAuthFlow {
108
+ const base = baseURL.replace(/\/$/, "");
109
+ const origin = () => Linking.createURL("/", scheme ? { scheme } : undefined);
110
+
111
+ async function authFetch(path: string, init?: RequestInit) {
112
+ const cookie = cookieHeader();
113
+ const res = await fetch(`${base}/api/auth${path}`, {
114
+ ...init,
115
+ credentials: "omit",
116
+ headers: {
117
+ "Content-Type": "application/json",
118
+ ...(cookie ? { cookie } : {}),
119
+ "expo-origin": origin(),
120
+ "x-skip-oauth-proxy": "true",
121
+ ...(init?.headers || {}),
122
+ },
123
+ });
124
+ absorbSetCookie(res.headers.get("set-cookie"));
125
+ return res;
126
+ }
127
+
128
+ const flow: ExpoAuthFlow = {
129
+ async signIn(provider: string, callbackURL?: string) {
130
+ const to = callbackURL || Linking.createURL("/");
131
+ const res = await authFetch("/sign-in/social", {
132
+ method: "POST",
133
+ body: JSON.stringify({ provider, callbackURL: to }),
134
+ });
135
+ if (!res.ok) {
136
+ const body = await res.text().catch(() => "");
137
+ throw new Error(`sign-in failed (${res.status}): ${body.slice(0, 200)}`);
138
+ }
139
+ const data = (await res.json()) as { url?: string; redirect?: boolean };
140
+ if (!data?.url) throw new Error("sign-in response had no authorization url");
141
+
142
+ // The server's expo plugin proxies the IdP redirect and forwards the
143
+ // session cookie back via the return URL (?cookie=...), because the
144
+ // OAuth callback's Set-Cookie lands in the auth-session browser, not us.
145
+ const params = new URLSearchParams({ authorizationURL: data.url });
146
+ const oauthState = getOAuthState();
147
+ if (oauthState) params.append("oauthState", oauthState);
148
+ const proxyURL = `${base}/api/auth/expo-authorization-proxy?${params.toString()}`;
149
+
150
+ const result = await WebBrowser.openAuthSessionAsync(proxyURL, to);
151
+ if (result.type !== "success") {
152
+ throw new Error(`sign-in ${result.type}`);
153
+ }
154
+ const cookie = new URL(result.url).searchParams.get("cookie");
155
+ if (cookie) absorbSetCookie(cookie);
156
+ },
157
+
158
+ async signOut() {
159
+ try {
160
+ await authFetch("/sign-out", { method: "POST", body: "{}" });
161
+ } finally {
162
+ writeJar({});
163
+ }
164
+ },
165
+
166
+ async getSession() {
167
+ const res = await authFetch("/get-session");
168
+ if (!res.ok) return null;
169
+ const data = await res.json().catch(() => null);
170
+ return data && Object.keys(data).length ? data : null;
171
+ },
172
+
173
+ async getAccessToken(providerId: string) {
174
+ const res = await authFetch("/get-access-token", {
175
+ method: "POST",
176
+ body: JSON.stringify({ providerId }),
177
+ });
178
+ if (!res.ok) return null;
179
+ return (await res.json().catch(() => null)) as {
180
+ accessToken?: string;
181
+ accessTokenExpiresAt?: string;
182
+ } | null;
183
+ },
184
+ };
185
+ activeFlow = flow;
186
+ return flow;
187
+ }
@@ -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,10 @@
1
+ /**
2
+ * Keycloak access token for Frappe API requests — web variant.
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.
7
+ */
8
+ export async function getKeycloakBearerToken(): Promise<string | undefined> {
9
+ return undefined;
10
+ }
package/src/index.ts CHANGED
@@ -6,3 +6,4 @@ export * from "./provider/index";
6
6
  export * from "./session/index";
7
7
  export * from "./token";
8
8
  export * from "./types";
9
+ export { getKeycloakBearerToken } from "./frappeToken";
@@ -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}
@@ -2,19 +2,24 @@
2
2
  * File: /src/provider/auth_provider/index.native.tsx
3
3
  * Project: @multiplatform.one/keycloak
4
4
  *
5
- * Native (Expo) AuthProvider using Better Auth Expo client.
6
- * Replaces the previous expo-auth-session PKCE flow.
7
- * Auth is handled by the Better Auth server; the Expo client manages
8
- * the browser redirect flow and stores the session cookie in SecureStore.
5
+ * Native (Expo) AuthProvider using the hand-rolled Better Auth flow
6
+ * (betterAuth/expoAuthFlow.native.ts): fetch + expo-web-browser + SecureStore.
7
+ * Deliberately NOT @better-auth/expo/client its import graph drags
8
+ * better-auth's cookies module -> @better-auth/core db schema -> zod into the
9
+ * Hermes bundle, and zod's module init dies under the vxrn/rolldown dev
10
+ * transform. The server side stays better-auth (the expo() plugin provides
11
+ * /expo-authorization-proxy and the cookie hand-back).
9
12
  */
10
13
 
11
14
  import { useEffect, useMemo, useState } from "react";
15
+ import * as Linking from "expo-linking";
12
16
  import { Loading } from "../../Loading";
13
- import { getExpoAuthClient } from "../../betterAuth/expoClient";
17
+ import { createExpoAuthFlow } from "../../betterAuth/expoAuthFlow.native";
14
18
  import { KeycloakConfigContext } from "../../keycloak/config";
15
19
  import { Keycloak } from "../../keycloak/index";
16
20
  import type { KeycloakLoginOptions, KeycloakLogoutOptions } from "../../keycloak/base";
17
21
  import { KeycloakContext } from "../../keycloak/context";
22
+ import { setNativeSession } from "../../session/index.native";
18
23
  import { AfterAuth } from "../AfterAuth";
19
24
  import type { AuthProviderProps } from "./shared";
20
25
 
@@ -29,78 +34,81 @@ export function AuthProvider({
29
34
 
30
35
  // Resolve the Better Auth server base URL from keycloak config.
31
36
  // On native, the keycloakConfig.url is the Keycloak server — the Better Auth
32
- // server is the app backend. We derive it from env or fall back to a sensible default.
37
+ // server is the app backend (CreateApp wires BETTER_AUTH_BASE_URL/BASE_URL).
33
38
  const serverBaseUrl = useMemo(() => {
34
- // The Better Auth server URL should come from config or env.
35
- // For now, derive from keycloakConfig — the app server is typically on the same origin.
36
- // In production, set BETTER_AUTH_BASE_URL in the app config / env.
37
- return keycloakConfig.betterAuthBaseUrl || "http://localhost:5000";
39
+ return keycloakConfig.betterAuthBaseUrl || "http://localhost:8000";
38
40
  }, [keycloakConfig]);
39
41
 
40
- const scheme = useMemo(() => keycloakConfig.expoScheme || "multiplatform-one", [keycloakConfig]);
42
+ const scheme = useMemo(() => keycloakConfig.expoScheme, [keycloakConfig]);
41
43
 
42
- const client = useMemo(
43
- () => (disabled ? null : getExpoAuthClient(serverBaseUrl, scheme)),
44
+ const flow = useMemo(
45
+ () => (disabled ? null : createExpoAuthFlow(serverBaseUrl, scheme)),
44
46
  [disabled, serverBaseUrl, scheme],
45
47
  );
46
48
 
47
- // Check session on mount
48
49
  useEffect(() => {
49
- if (disabled || !client) {
50
+ if (disabled || !flow) {
50
51
  setIsLoading(false);
51
52
  return;
52
53
  }
53
- (async () => {
54
+ let alive = true;
55
+
56
+ // Probe the session, build a Keycloak instance reflecting it, and push the
57
+ // result into the native session store (useSession's backing).
58
+ const refresh = async () => {
59
+ let session: Awaited<ReturnType<typeof flow.getSession>> = null;
54
60
  try {
55
- const session = await client.getSession();
56
- if (session.data?.user) {
57
- const _keycloak = new Keycloak(
58
- keycloakConfig,
59
- undefined, // no raw token
60
- undefined,
61
- undefined,
62
- async (options: KeycloakLoginOptions) => {
63
- await client.signIn.social({
64
- provider: "keycloak" as any,
65
- callbackURL: options.redirectUri,
66
- });
67
- },
68
- async (_options: KeycloakLogoutOptions) => {
69
- await client.signOut();
70
- },
71
- );
72
- // Set minimal user info from session
73
- _keycloak.authenticated = true;
74
- _keycloak.email = session.data.user.email || undefined;
75
- _keycloak.username = session.data.user.name || undefined;
76
- _keycloak.subject = session.data.user.id;
77
- setKeycloak(_keycloak);
78
- } else {
79
- setKeycloak(
80
- new Keycloak(
81
- keycloakConfig,
82
- undefined,
83
- undefined,
84
- undefined,
85
- async (options: KeycloakLoginOptions) => {
86
- await client.signIn.social({
87
- provider: "keycloak" as any,
88
- callbackURL: options.redirectUri,
89
- });
90
- },
91
- async (_options: KeycloakLogoutOptions) => {
92
- await client.signOut();
93
- },
94
- ),
95
- );
96
- }
97
- } catch {
98
- setKeycloak(new Keycloak(keycloakConfig));
99
- } finally {
100
- setIsLoading(false);
61
+ session = await flow.getSession();
62
+ } catch (err) {
63
+ console.error("[keycloak] native session check failed:", err);
101
64
  }
102
- })();
103
- }, [disabled, client, keycloakConfig]);
65
+ const _keycloak = new Keycloak(
66
+ keycloakConfig,
67
+ undefined,
68
+ undefined,
69
+ undefined,
70
+ async (options: KeycloakLoginOptions) => {
71
+ // Default the OAuth return URL to the app's own runtime URL: Expo Go
72
+ // can only receive exp:// redirects (Linking.createURL derives
73
+ // exp://<host>/--/ there; the bare app scheme in dev builds).
74
+ await flow.signIn("keycloak", options.redirectUri || Linking.createURL("/"));
75
+ await refresh();
76
+ return undefined;
77
+ },
78
+ async (_options: KeycloakLogoutOptions) => {
79
+ await flow.signOut();
80
+ await refresh();
81
+ return undefined;
82
+ },
83
+ );
84
+ if (session?.user) {
85
+ _keycloak.authenticated = true;
86
+ _keycloak.email = session.user.email || undefined;
87
+ _keycloak.username = session.user.name || undefined;
88
+ _keycloak.subject = session.user.id;
89
+ }
90
+ if (!alive) return;
91
+ setNativeSession(
92
+ session?.user
93
+ ? {
94
+ user: {
95
+ id: session.user.id,
96
+ name: session.user.name ?? null,
97
+ email: session.user.email ?? null,
98
+ },
99
+ }
100
+ : null,
101
+ );
102
+ setKeycloak(_keycloak);
103
+ };
104
+
105
+ refresh().finally(() => {
106
+ if (alive) setIsLoading(false);
107
+ });
108
+ return () => {
109
+ alive = false;
110
+ };
111
+ }, [disabled, flow, keycloakConfig]);
104
112
 
105
113
  if (disabled) return <>{children}</>;
106
114
  if (isLoading || !keycloak) {
@@ -2,20 +2,45 @@
2
2
  * File: /src/session/index.native.ts
3
3
  * Project: @multiplatform.one/keycloak
4
4
  *
5
- * Better Auth session hook for React Native / Expo.
6
- * Uses the Expo auth client when available, otherwise returns empty session.
5
+ * Native session hook backed by a tiny external store the AuthProvider feeds
6
+ * (no better-auth client on native -- see betterAuth/expoAuthFlow.native.ts).
7
7
  */
8
8
 
9
- import type { SessionContextValue } from "./index";
9
+ import { useSyncExternalStore } from "react";
10
+ import type { Session, SessionContextValue, SessionStatus } from "./index";
11
+
12
+ interface NativeSessionState {
13
+ session: Session | null;
14
+ status: SessionStatus;
15
+ }
16
+
17
+ let state: NativeSessionState = { session: null, status: "loading" };
18
+ const listeners = new Set<() => void>();
19
+
20
+ /** Fed by the native AuthProvider on mount / sign-in / sign-out. */
21
+ export function setNativeSession(session: Session | null, status?: SessionStatus) {
22
+ state = {
23
+ session,
24
+ status: status ?? (session?.user ? "authenticated" : "unauthenticated"),
25
+ };
26
+ for (const l of listeners) l();
27
+ }
28
+
29
+ function subscribe(listener: () => void) {
30
+ listeners.add(listener);
31
+ return () => listeners.delete(listener);
32
+ }
10
33
 
11
34
  export async function getSession() {
12
- return null;
35
+ return state.session;
13
36
  }
14
37
 
15
38
  export function useSession<R extends boolean = false>(
16
39
  _options?: Record<string, unknown>,
17
40
  ): SessionContextValue<R> {
18
- // On native, session is managed by AuthProvider (Expo auth client).
19
- // Components consuming useSession outside AuthProvider get empty state.
20
- return { session: null, status: "unauthenticated" };
41
+ return useSyncExternalStore(
42
+ subscribe,
43
+ () => state,
44
+ () => state,
45
+ );
21
46
  }