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
JavaScript
"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;
}