UNPKG

kitcn

Version:

kitcn - React Query integration and CLI tools for Convex

334 lines (331 loc) 11.9 kB
'use client'; import { C as readAuthSessionFallbackData, D as CRPCClientError, O as defaultIsUnauthorized, S as clearAuthSessionFallback, T as writeAuthSessionFallbackData, d as isSessionSyncGraceActive, g as useAuthValue, h as useAuthStore, n as AuthProvider, o as FetchAccessTokenContext, t as AUTH_SESSION_SYNC_GRACE_MS, u as decodeJwtExp, w as readAuthSessionFallbackToken } from "../../auth-store-CwGbvP_s.js"; import { ConvexProviderWithAuth, useConvexAuth } from "convex/react"; import { useCallback, useEffect, useMemo, useRef } from "react"; import { jsx } from "react/jsx-runtime"; //#region src/auth/internal/convex-client.ts const convexClient = () => { return { id: "convex", $InferServerPlugin: {} }; }; //#endregion //#region src/auth-client/convex-auth-provider.tsx const defaultMutationHandler = () => { throw new CRPCClientError({ code: "UNAUTHORIZED", functionName: "mutation" }); }; const hasActiveSessionData = (session) => { if (!session || typeof session !== "object") return false; return Boolean(session.session); }; const wait = (ms) => new Promise((resolve) => { setTimeout(resolve, ms); }); const readAuthResultData = (result) => { if (!result || typeof result !== "object") return; return result.data; }; const getSessionFromPersistedToken = async (authClient, token) => { await wait(250); for (let attempt = 0; attempt < 10; attempt += 1) { const data = readAuthResultData(authClient.$fetch ? await authClient.$fetch("/get-session", { credentials: "omit", headers: { Authorization: `Bearer ${token}` } }) : await authClient.getSession?.({ fetchOptions: { credentials: "omit", headers: { Authorization: `Bearer ${token}` } } })); if (data) return data; if (attempt < 9) await wait(100); } return null; }; const syncSessionAtom = (authClient, sessionData) => { const sessionAtom = authClient.$store?.atoms?.session; if (typeof sessionAtom?.get !== "function" || typeof sessionAtom.set !== "function") return; const current = sessionAtom.get(); sessionAtom.set({ data: sessionData, error: null, isPending: false, isRefetching: false, refetch: current?.refetch ?? (async () => {}) }); }; const clearSessionAtom = (authClient) => { const sessionAtom = authClient.$store?.atoms?.session; if (typeof sessionAtom?.get !== "function" || typeof sessionAtom.set !== "function") return; const current = sessionAtom.get(); sessionAtom.set({ data: null, error: null, isPending: false, isRefetching: false, refetch: current?.refetch ?? (async () => {}) }); }; /** * Unified auth provider for Convex + Better Auth. * Handles token sync, HMR persistence, and auth callbacks. * * Structure: AuthProvider wraps ConvexAuthProviderInner so that * useAuthStore() is available when creating fetchAccessToken. */ function ConvexAuthProvider({ children, client, authClient, initialToken, onMutationUnauthorized, onQueryUnauthorized, isUnauthorized }) { useOTTHandler(authClient); return /* @__PURE__ */ jsx(AuthProvider, { initialValues: useMemo(() => ({ expiresAt: initialToken ? decodeJwtExp(initialToken) : null, token: initialToken ?? null }), [initialToken]), isUnauthorized: isUnauthorized ?? defaultIsUnauthorized, onMutationUnauthorized: onMutationUnauthorized ?? defaultMutationHandler, onQueryUnauthorized: onQueryUnauthorized ?? (() => {}), children: /* @__PURE__ */ jsx(ConvexAuthProviderInner, { authClient, client, children }) }); } /** * Inner provider that has access to AuthStore via useAuthStore(). * Creates fetchAccessToken and passes it through context (no race condition). */ function ConvexAuthProviderInner({ children, client, authClient }) { const authStore = useAuthStore(); const { data: session, isPending } = authClient.useSession(); const sessionRef = useRef(session); const isPendingRef = useRef(isPending); const pendingTokenRef = useRef(null); sessionRef.current = session; isPendingRef.current = isPending; const getCachedJwt = useCallback((minTimeRemainingMs = 0) => { const cachedToken = authStore.get("token"); if (!cachedToken) return null; const expiresAt = decodeJwtExp(cachedToken); if (expiresAt === null || expiresAt <= Date.now() + minTimeRemainingMs) return null; return cachedToken; }, [authStore]); useEffect(() => { if (hasActiveSessionData(session)) { authStore.set("sessionSyncGraceUntil", null); return; } if (!isPending && !isSessionSyncGraceActive(authStore.get("sessionSyncGraceUntil"))) { authStore.set("token", null); authStore.set("expiresAt", null); authStore.set("isAuthenticated", false); authStore.set("sessionSyncGraceUntil", null); } }, [ session, isPending, authStore ]); useEffect(() => { if (hasActiveSessionData(session) || isPending || authStore.get("token")) return; const persistedToken = readAuthSessionFallbackToken(); const persistedSessionData = readAuthSessionFallbackData(); if (!persistedToken || typeof authClient.getSession !== "function" && typeof authClient.$fetch !== "function") return; let cancelled = false; authStore.set("token", persistedToken); authStore.set("expiresAt", decodeJwtExp(persistedToken)); authStore.set("sessionSyncGraceUntil", Date.now() + AUTH_SESSION_SYNC_GRACE_MS); if (persistedSessionData) syncSessionAtom(authClient, persistedSessionData); getSessionFromPersistedToken(authClient, persistedToken).then((result) => { if (cancelled) return; if (result) { syncSessionAtom(authClient, result); writeAuthSessionFallbackData(result); return; } clearAuthSessionFallback(); clearSessionAtom(authClient); authStore.set("token", null); authStore.set("expiresAt", null); authStore.set("sessionSyncGraceUntil", null); }).catch(() => { if (cancelled) return; clearAuthSessionFallback(); clearSessionAtom(authClient); authStore.set("token", null); authStore.set("expiresAt", null); authStore.set("sessionSyncGraceUntil", null); }); return () => { cancelled = true; }; }, [ session, isPending, authStore, authClient ]); const fetchAccessToken = useCallback(async ({ forceRefreshToken = false } = {}) => { const fetchFreshToken = () => { if (pendingTokenRef.current) return pendingTokenRef.current; const cachedToken = authStore.get("token"); const fetchOptions = { throw: false }; if (cachedToken && decodeJwtExp(cachedToken) === null) { fetchOptions.credentials = "omit"; fetchOptions.headers = { Authorization: `Bearer ${cachedToken}` }; } pendingTokenRef.current = authClient.convex.token({ fetchOptions }).then((result) => { const jwt = result.data?.token || null; if (jwt) { const exp = decodeJwtExp(jwt); authStore.set("token", jwt); authStore.set("expiresAt", exp); authStore.set("sessionSyncGraceUntil", null); return jwt; } const cachedJwt = getCachedJwt(); if (cachedJwt) { authStore.set("expiresAt", decodeJwtExp(cachedJwt)); authStore.set("sessionSyncGraceUntil", null); return cachedJwt; } authStore.set("token", null); authStore.set("expiresAt", null); authStore.set("sessionSyncGraceUntil", null); return null; }).catch((error) => { const cachedJwt = getCachedJwt(); if (cachedJwt) { authStore.set("expiresAt", decodeJwtExp(cachedJwt)); authStore.set("sessionSyncGraceUntil", null); return cachedJwt; } authStore.set("token", null); authStore.set("expiresAt", null); authStore.set("sessionSyncGraceUntil", null); console.error("[fetchAccessToken] error", error); return null; }).finally(() => { pendingTokenRef.current = null; }); return pendingTokenRef.current; }; const fetchFreshTokenForced = async () => { const cachedJwt = getCachedJwt(); if (pendingTokenRef.current) { const token = await pendingTokenRef.current; if (token && (!cachedJwt || token !== cachedJwt)) return token; } return fetchFreshToken(); }; const currentSession = sessionRef.current; const currentIsPending = isPendingRef.current; const hasSession = hasActiveSessionData(currentSession); const hasSessionSyncGrace = isSessionSyncGraceActive(authStore.get("sessionSyncGraceUntil")); if (!hasSession) { if (currentIsPending || hasSessionSyncGrace) { const cachedJwt = getCachedJwt(); if (!forceRefreshToken) { if (cachedJwt) return cachedJwt; return fetchFreshToken(); } const freshToken = await fetchFreshTokenForced(); if (!freshToken && cachedJwt) { authStore.set("token", cachedJwt); authStore.set("expiresAt", decodeJwtExp(cachedJwt)); return cachedJwt; } return freshToken; } authStore.set("token", null); authStore.set("expiresAt", null); authStore.set("sessionSyncGraceUntil", null); return null; } const cachedToken = authStore.get("token"); const expiresAt = authStore.get("expiresAt"); const timeRemaining = expiresAt ? expiresAt - Date.now() : 0; if (!forceRefreshToken && cachedToken && expiresAt && timeRemaining >= 6e4) return cachedToken; if (!forceRefreshToken && pendingTokenRef.current) return pendingTokenRef.current; if (forceRefreshToken) return fetchFreshTokenForced(); return fetchFreshToken(); }, [ authStore, authClient, getCachedJwt ]); const useAuth = useCallback(function useConvexAuthHook() { const token = authStore.get("token"); const hasSession = hasActiveSessionData(sessionRef.current); const sessionMissing = !hasSession && !isPendingRef.current; return { isLoading: isPendingRef.current && !token, isAuthenticated: sessionMissing ? false : hasSession || token !== null, fetchAccessToken }; }, [fetchAccessToken, authStore]); return /* @__PURE__ */ jsx(FetchAccessTokenContext.Provider, { value: fetchAccessToken, children: /* @__PURE__ */ jsx(ConvexProviderWithAuth, { client, useAuth, children: /* @__PURE__ */ jsx(AuthStateSync, { children }) }) }); } /** * Syncs auth state from useConvexAuth() to the auth store. * MUST be inside ConvexProviderWithAuth to access useConvexAuth(). * * Defensive isLoading computation handles SSR hydration race: * 1. SSR sets token from cookie * 2. Client hydrates * 3. Better Auth's useSession() briefly returns null before loading cookie * 4. Convex sets isConvexAuthenticated = false (no auth to wait for) * 5. Without defensive check, we'd sync { isLoading: false, isAuthenticated: false } * 6. Queries would throw UNAUTHORIZED before token is validated */ function AuthStateSync({ children }) { const { isLoading: convexIsLoading, isAuthenticated } = useConvexAuth(); const authStore = useAuthStore(); const token = useAuthValue("token"); useEffect(() => { const isLoading = convexIsLoading || !!token && !isAuthenticated; authStore.set("isLoading", isLoading); authStore.set("isAuthenticated", isAuthenticated); }, [ convexIsLoading, isAuthenticated, token, authStore ]); return children; } /** * Handles cross-domain one-time token (OTT) verification. */ function useOTTHandler(authClient) { useEffect(() => { (async () => { if (typeof window === "undefined" || !window.location?.href) return; const url = new URL(window.location.href); const token = url.searchParams.get("ott"); if (token) { const authClientWithCrossDomain = authClient; url.searchParams.delete("ott"); window.history.replaceState({}, "", url); const session = (await authClientWithCrossDomain.crossDomain.oneTimeToken.verify({ token })).data?.session; if (session && typeof authClient.getSession === "function") { await authClient.getSession({ fetchOptions: { credentials: "omit", headers: { Authorization: `Bearer ${session.token}` } } }); authClientWithCrossDomain.updateSession(); } } })(); }, [authClient]); } //#endregion export { ConvexAuthProvider, convexClient };