kitcn
Version:
kitcn - React Query integration and CLI tools for Convex
334 lines (331 loc) • 11.9 kB
JavaScript
'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 };