UNPKG

next-bungie-auth

Version:

Next Bungie Auth is an open source Next.js library that provides a configurable solution for authenticating your users with Bungie.net

403 lines (402 loc) 14.7 kB
"use client"; import { jsx as _jsx } from "react/jsx-runtime"; import React from "react"; import { useRouter } from "next/navigation"; // BEGIN STATE CONTEXTS const AuthContext = React.createContext(undefined); const AuthorizedAuthContext = React.createContext(undefined); // END STATE CONTEXTS // BEGIN EXPORTED HOOKS /** * Custom hook that returns the Next Bungie Auth Session. * @returns The Bungie session context. * @throws If used outside of a BungieSessionProvider. */ export const useBungieSession = () => { const ctx = React.useContext(AuthContext); if (ctx === undefined) { throw new Error("useBungieSession must be used within a BungieSessionProvider"); } return ctx; }; /** * Custom hook that returns the Next Bungie Auth Session. */ export const useAuthorizedBungieSession = () => { const ctx = React.useContext(AuthorizedAuthContext); if (ctx === undefined) { throw new TypeError("useAuthorizedBungieSession must be used within a BungieSessionSuspender"); } return ctx; }; // END EXPORTED HOOKS // BEGIN CONTEXT PROVIDERS /** * BungieSessionProvider is a React component that provides Bungie session management functionality. * It manages the session state, handles session refresh, and provides methods for deauthorization. * * NOTE: This this component must be wrapped within a client component if any functional * (non-serializable) arguments are passed in as props. * * @component * @example * ```tsx * <BungieSessionProvider * initialSession={serverSession} * onError={(err, type) => console.error(err, type)} * > * <App /> * </BungieSessionProvider> * ``` */ export const BungieSessionProvider = ({ children, initialSession, sessionPath = "/api/auth/session", deauthorizePath = "/api/auth/deauthorize", refreshPath = "/api/auth/refresh", enableAutomaticRefresh = true, refreshInBackground = true, fetchOverride: customFetch = fetch, timeBeforeRefresh = 30000, refreshRateLimit = 15000, onError, }) => { const [isOnline, setIsOnline] = React.useState(true); const [isVisible, setIsVisible] = React.useState(true); const isUpdatingSession = React.useRef(false); const isDeauthorizing = React.useRef(false); const [lastSuccessfulRefresh, setLastSuccessfulRefresh] = React.useState(0); const [session, setSession] = React.useState(() => { if (initialSession === undefined) { return { status: "pending", isPending: true, isFetching: true, isError: false, data: null, error: undefined, }; } else { return deriveStateFromServer({ prevSession: null, session: initialSession, }); } }); const fetchAndUpdateSession = React.useCallback((refresh = false) => { if (isUpdatingSession.current) { return; } isUpdatingSession.current = true; setSession((prev) => deriveLoadingState({ previous: prev })); customFetch(refresh ? refreshPath : sessionPath, { method: refresh ? "POST" : "GET", }) .then(async (res) => { try { if (res.status === 404) { throw new Error(`${refresh ? "Refresh" : "Session"} route not found`, { cause: res, }); } if (!res.headers.get("content-type")?.includes("application/json")) { throw new Error("Invalid response content type", { cause: { contentType: res.headers.get("content-type"), }, }); } // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const session = await res.json(); if (typeof session !== "object" || !("status" in session) || !("data" in session)) { throw new Error("Invalid response body", { cause: session, }); } if (refresh) { setLastSuccessfulRefresh(Date.now()); } setSession((prev) => deriveStateFromServer({ prevSession: prev, session: session, })); } catch (err) { // Handle server errors if (res.status >= 500) { setSession((prev) => deriveErrorState({ previous: prev, error: "server", })); onError?.(err, "server"); } else { // Re-throw errors that are not server errors throw err; } } }) .catch((err) => { // Handle network and client side errors const errType = isNetworkError(err) ? "network" : "client"; setSession((prev) => deriveErrorState({ previous: prev, error: errType, })); onError?.(err, errType); }) .finally(() => { isUpdatingSession.current = false; }); }, [isUpdatingSession, customFetch, sessionPath, onError]); const deauthorize = React.useCallback(() => { if (isDeauthorizing.current) { return; } isDeauthorizing.current = true; setSession((prev) => deriveLoadingState({ previous: prev, })); customFetch(deauthorizePath, { method: "POST", }) .then(() => { setSession({ status: "unauthorized", isPending: false, isFetching: false, isError: false, data: null, error: undefined, }); }) .catch((err) => { const errType = isNetworkError(err) ? "network" : "client"; onError?.(err, errType); setSession((prev) => deriveErrorState({ previous: prev, error: errType, })); }) .finally(() => { isDeauthorizing.current = false; }); }, [deauthorizePath, customFetch, onError, fetchAndUpdateSession]); /** * Calculates the time until the next session refresh. * Returns false to indicate that the session should not be refreshed. */ const calculateMsToNextRefresh = React.useCallback((session) => { const minWaitForRefresh = lastSuccessfulRefresh ? Math.max(0, refreshRateLimit - Math.max(0, Date.now() - lastSuccessfulRefresh)) : 0; switch (session.status) { case "stale": return minWaitForRefresh; case "authorized": return Math.max(session.isError ? 60_000 : minWaitForRefresh, new Date(session.data.accessTokenExpiresAt).getTime() - timeBeforeRefresh - Date.now()); case "unavailable": return 5 * 60_000; case "pending": case "unauthorized": return false; } }, [timeBeforeRefresh, lastSuccessfulRefresh]); React.useEffect(() => { if (session.status === "pending") { // Fetch the session if it is pending, the fetch will be skipped if the session is already being fetched fetchAndUpdateSession(false); return; } if (enableAutomaticRefresh && isOnline && (isVisible || refreshInBackground)) { const timeoutTime = calculateMsToNextRefresh(session); if (timeoutTime !== false) { const timeout = setTimeout(() => fetchAndUpdateSession(true), timeoutTime); return () => clearTimeout(timeout); } } }, [ session, fetchAndUpdateSession, enableAutomaticRefresh, calculateMsToNextRefresh, isOnline, isVisible, refreshInBackground, ]); // Attach event listeners for visibility and online status React.useEffect(() => { const handleVisibilityChange = () => { setIsVisible(document.visibilityState === "visible"); }; const handleOnlineChange = () => { setIsOnline(navigator.onLine); if (navigator.onLine) { fetchAndUpdateSession(true); } }; window.addEventListener("visibilitychange", handleVisibilityChange); window.addEventListener("online", handleOnlineChange); window.addEventListener("offline", handleOnlineChange); return () => { window.removeEventListener("visibilitychange", handleVisibilityChange); window.removeEventListener("online", handleOnlineChange); window.removeEventListener("offline", handleOnlineChange); }; }, [fetchAndUpdateSession]); // These methods are memoized to prevent unnecessary re-renders but also act as wrappers // asto not expose them to the consumer const refresh = React.useCallback(() => fetchAndUpdateSession(true), [fetchAndUpdateSession]); const kill = React.useCallback(() => deauthorize(), [deauthorize]); return (_jsx(AuthContext.Provider, { value: { ...session, refresh, kill, }, children: children })); }; /** * Within this component, the children will only be rendered even when * the session is authorized. * * This allows you to use the `useAuthorizedBungieSession` hook which guarantees * the session is authorized in your components. * * If a onUnauthorized is provided and the session is unauthorized, the function * will be called. Commonly used for redirects sign-in pages. * * If onUnavailable is provided and the session is unavailable, the function * will be called. Commonly used for redirects to a maintenance page or to display * a message to the user. */ export const BungieSessionSuspender = ({ onUnauthorized, onUnavailable, fallback, children, }) => { const router = useRouter(); const session = React.useContext(AuthContext); const onUnauthorizedRef = React.useRef(onUnauthorized); // Store the callback in a ref to avoid effects when it changes const onUnavailableRef = React.useRef(onUnavailable); if (session === undefined) { throw new TypeError("BungieSessionSuspender must be a child of a BungieSessionProvider"); } React.useEffect(() => { onUnauthorizedRef.current = onUnauthorized; }, [onUnauthorized]); React.useEffect(() => { onUnavailableRef.current = onUnavailable; }, [onUnavailable]); React.useEffect(() => { if (session.status === "unauthorized" && onUnauthorizedRef.current) { onUnauthorizedRef.current(session); } }, [router, session]); if (session.status !== "authorized") { return fallback(session); } return (_jsx(AuthorizedAuthContext.Provider, { value: session, children: children })); }; // END CONTEXT PROVIDERS // BEGIN STATE DERIVATION FUNCTIONS function deriveErrorState({ previous, error, }) { return { status: previous.isPending || previous.status === "unavailable" ? "unauthorized" : previous.status, isPending: false, isFetching: false, isError: true, data: previous.data, error, }; } function deriveLoadingState({ previous, }) { if (previous.status === "authorized" && previous.isError) { return { status: "unavailable", isPending: false, isFetching: false, isError: true, data: previous.data, error: previous.error, }; } switch (previous.status) { case "pending": return { status: "pending", isPending: true, isFetching: true, isError: false, data: null, error: undefined, }; case "stale": return { status: previous.status, isPending: true, isFetching: true, isError: previous.isError, data: previous.data, error: previous.error, }; case "authorized": case "unauthorized": case "unavailable": return { status: previous.status, isPending: false, isFetching: true, isError: previous.isError, data: previous.data, error: previous.error, }; } } function deriveStateFromServer({ prevSession, session, }) { switch (session.status) { case "authorized": return { status: "authorized", isPending: false, isFetching: false, isError: false, data: session.data, error: undefined, }; case "stale": return { status: "stale", isPending: true, isFetching: false, isError: false, data: session.data, error: undefined, }; case "unauthorized": case "expired": return { status: "unauthorized", isPending: false, isFetching: false, isError: false, data: session.data, error: undefined, }; case "error": return { status: !prevSession || prevSession.isPending ? "unauthorized" : prevSession.status, isPending: false, isFetching: false, isError: true, data: !prevSession?.data || prevSession.isPending ? null : prevSession.data, error: "server", }; case "disabled": return { status: "unavailable", isPending: false, isFetching: false, isError: true, data: session.data, error: "bungie-api-offline", }; } } function isNetworkError(err) { return err instanceof TypeError && !navigator.onLine; }