import { MutableRefObject, useCallback, useEffect, useMemo, useRef } from "react"; import { useDispatch, useSelector } from "react-redux"; import { useBentoAuthContext } from "./contexts"; import { Resource, makeResourceKey } from "./resources"; import { AuthSliceState, fetchResourcesPermissions, refreshTokens, tokenHandoff } from "./redux/authSlice"; import { LS_SIGN_IN_POPUP, createAuthURL } from "./performAuth"; import { fetchOpenIdConfigurationIfNecessary, type OIDCSliceState } from "./redux/openIdConfigSlice"; import { getIsAuthenticated, logMissingAuthContext, makeAuthorizationHeader } from "./utils"; import type { AppDispatch, RootState } from "./redux/store"; const AUTH_RESULT_TYPE = "authResult"; type MessageHandlerFunc = (e: MessageEvent) => void; export const useAuthState = (): AuthSliceState => useSelector((state: RootState) => state.auth); export const useIsAuthenticated = (): boolean => { const { idTokenContents } = useAuthState(); return getIsAuthenticated(idTokenContents); }; export const useAccessToken = () => useAuthState().accessToken; export const useAuthorizationHeader = () => { const accessToken = useAccessToken(); return useMemo(() => makeAuthorizationHeader(accessToken), [accessToken]); }; export const useIsAutoAuthenticating = () => useAuthState().isAutoAuthenticating; type BaseResourcePermissionsState = { isFetching: boolean; hasAttempted: boolean; error: string; }; type ResourcePermissionsState = BaseResourcePermissionsState & { permissions: string[] }; type ResourceHasPermissionState = BaseResourcePermissionsState & { hasPermission: boolean }; export const useResourcesPermissions = ( resources: Resource[], authzUrl: string | undefined, ): Record => { const dispatch: AppDispatch = useDispatch(); const keys = useMemo(() => resources.map((resource) => makeResourceKey(resource)), [resources]); const { resourcePermissions, isHandingOffCodeForToken } = useAuthState(); useEffect(() => { const anyFetching = keys.some((key) => !!resourcePermissions[key]?.isFetching); const allHavePermissions = keys.every((key) => !!resourcePermissions[key]?.permissions?.length); const allAttempted = keys.every((key) => !!resourcePermissions[key]?.hasAttempted); // If the sign-in process is ongoing (code-token exchange), or any permissions are currently fetching, or // all requested permissions have already been tried/returned, we don't need to dispatch the fetch action: if (!authzUrl || isHandingOffCodeForToken || anyFetching || allHavePermissions || allAttempted) return; dispatch(fetchResourcesPermissions({ resources, authzUrl })); }, [dispatch, keys, resources, resourcePermissions, authzUrl, isHandingOffCodeForToken]); // Construct an object with resource keys yielding an object containing the permissions on the object return useMemo( () => Object.fromEntries( keys.map((key) => { const { permissions, isFetching, hasAttempted, error } = resourcePermissions[key] ?? {}; return [ key, { permissions: permissions ?? [], isFetching: isFetching ?? false, hasAttempted: hasAttempted ?? false, error: error ?? "", }, ]; }), ), [keys, resourcePermissions], ); }; export const useResourcePermissions = (resource: Resource, authzUrl: string | undefined): ResourcePermissionsState => { const key = makeResourceKey(resource); const resourcesPermissions = useResourcesPermissions([resource], authzUrl); return resourcesPermissions[key]; }; export const useHasResourcePermission = ( resource: Resource, authzUrl: string | undefined, permission: string, ): ResourceHasPermissionState => { const { permissions, ...props } = useResourcePermissions(resource, authzUrl) ?? {}; return { ...props, hasPermission: permissions.includes(permission) }; }; export const useOpenIdConfig = (): OIDCSliceState => { const dispatch: AppDispatch = useDispatch(); const { openIdConfigUrl } = useBentoAuthContext(); useEffect(() => { if (!openIdConfigUrl) { logMissingAuthContext("openIdConfigUrl"); return; } dispatch(fetchOpenIdConfigurationIfNecessary(openIdConfigUrl)); }, [dispatch, openIdConfigUrl]); return useSelector((state: RootState) => state.openIdConfiguration); }; export const useSignInPopupTokenHandoff = (windowMessageHandler: MutableRefObject) => { const dispatch: AppDispatch = useDispatch(); const { applicationUrl, authCallbackUrl, clientId } = useBentoAuthContext(); useEffect(() => { if (!applicationUrl || !authCallbackUrl || !clientId) { logMissingAuthContext("applicationUrl", "authCallbackUrl", "clientId"); } else { windowMessageHandler.current = (e: MessageEvent) => { if (e.origin !== applicationUrl) return; if (e.data?.type !== AUTH_RESULT_TYPE) return; const { code, verifier } = e.data ?? {}; if (!code || !verifier) return; localStorage.removeItem(LS_SIGN_IN_POPUP); dispatch(tokenHandoff({ code, verifier, clientId, authCallbackUrl })); }; window.addEventListener("message", windowMessageHandler.current); } // Listener cleanup return () => { if (windowMessageHandler.current) { window.removeEventListener("message", windowMessageHandler.current); } }; }, [dispatch, applicationUrl, authCallbackUrl, clientId, windowMessageHandler]); }; export const useSessionWorkerTokenRefresh = ( sessionWorkerRef: MutableRefObject, createWorker: () => Worker, fetchUserDependentData: (() => unknown) | undefined = undefined, ) => { const dispatch: AppDispatch = useDispatch(); const { clientId } = useBentoAuthContext(); const { refreshToken } = useAuthState(); const refreshTokenRef = useRef(refreshToken); useEffect(() => { // A bit hacky: we use a ref to get the refreshToken into the worker event listener without triggering a // dependency change for the useEffect below. refreshTokenRef.current = refreshToken; }, [refreshToken]); useEffect(() => { if (!clientId) { logMissingAuthContext("clientId"); } else { if (!sessionWorkerRef.current) { const sw = createWorker(); sw.addEventListener("message", () => { // It would be nice to check if we have a refresh token here without refs, but doing so would mean // unbinding and re-binding the listener every time the effect is re-executed. Instead, we can use a // ref to access the token without triggering a hook dependency change. // While the action itself also handles the no refresh token case, it pollutes the Redux and console // logs and so it's nicer to re-check here. if (refreshTokenRef.current) dispatch(refreshTokens(clientId)); if (fetchUserDependentData) fetchUserDependentData(); }); sessionWorkerRef.current = sw; } } return () => { if (sessionWorkerRef.current) { sessionWorkerRef.current.terminate(); sessionWorkerRef.current = null; } }; }, [dispatch, createWorker, fetchUserDependentData, clientId, sessionWorkerRef]); }; export const useOpenSignInWindowCallback = ( signInWindow: MutableRefObject, windowFeatures = "scrollbars=no, toolbar=no, menubar=no, width=800, height=600", ) => { const { clientId, authCallbackUrl } = useBentoAuthContext(); const { data: openIdConfig } = useOpenIdConfig(); return useCallback(() => { if (!clientId || !authCallbackUrl) { logMissingAuthContext("clientId", "authCallbackUrl"); return; } if (signInWindow.current && !signInWindow.current.closed) { signInWindow.current.focus(); return; } if (!openIdConfig || !window.top) return; const popupTop = window.top.outerHeight / 2 + window.top.screenY - 350; const popupLeft = window.top.outerWidth / 2 + window.top.screenX - 400; (async () => { localStorage.setItem(LS_SIGN_IN_POPUP, "true"); signInWindow.current = window.open( await createAuthURL(openIdConfig["authorization_endpoint"], clientId, authCallbackUrl), "Bento Sign In", `${windowFeatures}, top=${popupTop}, left=${popupLeft}`, ); })(); }, [openIdConfig, clientId, authCallbackUrl, windowFeatures, signInWindow]); }; export const usePopupOpenerAuthCallback = () => { const { applicationUrl } = useBentoAuthContext(); return useCallback( async (code: string, verifier: string) => { if (!applicationUrl) { logMissingAuthContext("applicationUrl"); return; } if (!window.opener) return; // We're inside a popup window for authentication // Send the code and verifier to the main thread/page for authentication // IMPORTANT SECURITY: provide BENTO_URL as the target origin: window.opener.postMessage({ type: "authResult", code, verifier }, applicationUrl); // We're inside a popup window which has successfully re-authenticated the user, meaning we need to // close ourselves to return focus to the original window. window.close(); }, [applicationUrl], ); };