bento-auth-js
Version:
Authentication library for web applications of Bento-Platform
244 lines (203 loc) • 10.2 kB
text/typescript
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<string, ResourcePermissionsState> => {
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<null | MessageHandlerFunc>) => {
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<null | Worker>,
createWorker: () => Worker,
fetchUserDependentData: (() => unknown) | undefined = undefined,
) => {
const dispatch: AppDispatch = useDispatch();
const { clientId } = useBentoAuthContext();
const { refreshToken } = useAuthState();
const refreshTokenRef = useRef<string | undefined>(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<null | Window>,
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],
);
};