bento-auth-js
Version:
Authentication library for web applications of Bento-Platform
197 lines (168 loc) • 6.91 kB
text/typescript
import { useCallback, useEffect } from "react";
import { useDispatch } from "react-redux";
import { useNavigate, useLocation } from "react-router-dom";
import { DEFAULT_AUTH_SCOPE, useBentoAuthContext } from "./contexts";
import { useIsAuthenticated, useOpenIdConfig } from "./hooks";
import { PKCE_LS_STATE, PKCE_LS_VERIFIER, pkceChallengeFromVerifier, secureRandomString } from "./pkce";
import { tokenHandoff } from "./redux/authSlice";
import { buildUrlEncodedData, logMissingAuthContext, popLocalStorageItem } from "./utils";
import type { AppDispatch } from "./redux/store";
export const LS_SIGN_IN_POPUP = "BENTO_DID_CREATE_SIGN_IN_POPUP";
export const LS_BENTO_WAS_SIGNED_IN = "BENTO_WAS_SIGNED_IN";
export const LS_BENTO_POST_AUTH_REDIRECT = "BENTO_POST_AUTH_REDIRECT";
const DEFAULT_REDIRECT = "/overview";
export const createAuthURL = async (
authorizationEndpoint: string,
clientId: string,
authCallbackUrl: string,
scope = "openid email",
) => {
const state = secureRandomString();
const verifier = secureRandomString();
localStorage.setItem(PKCE_LS_STATE, state);
localStorage.setItem(PKCE_LS_VERIFIER, verifier);
const { pathname, search, hash } = window.location;
localStorage.setItem(LS_BENTO_POST_AUTH_REDIRECT, `${pathname}${search}${hash}`);
return (
`${authorizationEndpoint}?` +
buildUrlEncodedData({
response_type: "code",
client_id: clientId,
state,
scope,
redirect_uri: authCallbackUrl,
code_challenge: await pkceChallengeFromVerifier(verifier),
code_challenge_method: "S256",
}).toString()
);
};
export const performAuth = async (
authorizationEndpoint: string,
clientId: string,
authCallbackUrl: string,
scope = "openid email",
) => {
window.location.href = await createAuthURL(authorizationEndpoint, clientId, authCallbackUrl, scope);
};
export const usePerformAuth = () => {
const { authCallbackUrl, clientId, scope } = useBentoAuthContext();
const { data: openIdConfig } = useOpenIdConfig();
const authorizationEndpoint = openIdConfig?.["authorization_endpoint"];
return useCallback(async () => {
if (!authCallbackUrl || !clientId) {
logMissingAuthContext("authCallbackUrl", "clientId");
throw new Error("Could not create auth URL; missing authCallbackUrl or clientId");
}
if (!authorizationEndpoint) throw new Error("Could not create auth URL; missing authorization_endpoint");
window.location.href = await createAuthURL(
authorizationEndpoint,
clientId,
authCallbackUrl,
scope ?? DEFAULT_AUTH_SCOPE,
);
}, [authCallbackUrl, clientId, authorizationEndpoint, scope]);
};
export type AuthCodeCallbackFunction = (code: string, verifier: string) => Promise<void>;
const useDefaultAuthCodeCallback = (
onSuccessfulAuthentication: (() => Promise<unknown>) | (() => unknown),
): AuthCodeCallbackFunction => {
const dispatch: AppDispatch = useDispatch();
const navigate = useNavigate();
const { authCallbackUrl, clientId } = useBentoAuthContext();
return useCallback(
async (code: string, verifier: string) => {
if (!authCallbackUrl || !clientId) {
logMissingAuthContext("authCallbackUrl", "clientId");
return;
}
const lastPath = popLocalStorageItem(LS_BENTO_POST_AUTH_REDIRECT);
await dispatch(tokenHandoff({ code, verifier, clientId, authCallbackUrl }));
navigate(lastPath ?? DEFAULT_REDIRECT, { replace: true });
await onSuccessfulAuthentication();
},
[dispatch, navigate, authCallbackUrl, clientId, onSuccessfulAuthentication],
);
};
export const setLSNotSignedIn = () => {
localStorage.removeItem(LS_BENTO_WAS_SIGNED_IN);
};
export const useHandleCallback = (
callbackPath: string,
onSuccessfulAuthentication: (() => Promise<unknown>) | (() => unknown),
authCodeCallback: AuthCodeCallbackFunction | undefined = undefined,
uiErrorCallback: (message: string) => void,
) => {
const navigate = useNavigate();
const location = useLocation();
const { authCallbackUrl, clientId } = useBentoAuthContext();
const { data: oidcConfig } = useOpenIdConfig();
const isAuthenticated = useIsAuthenticated();
const defaultAuthCodeCallback = useDefaultAuthCodeCallback(onSuccessfulAuthentication);
useEffect(() => {
// Not used directly in this effect, but if we don't have it our auth callback / token handoff presumably won't
// work properly, so we terminate early.
if (!authCallbackUrl || !clientId) {
logMissingAuthContext("authCallbackUrl", "clientId");
return;
}
// Ignore non-callback URLs
if (!location.pathname.startsWith(callbackPath)) return;
// End early if we don't have OpenID config (yet)
if (!oidcConfig) return;
// If we're already authenticated, don't try to reauthenticate
if (isAuthenticated) {
navigate(DEFAULT_REDIRECT, { replace: true });
return;
}
const params = new URLSearchParams(window.location.search);
const error = params.get("error");
if (error) {
uiErrorCallback(`Error encountered during sign-in: ${error}`);
console.error(error);
setLSNotSignedIn();
return;
}
const code = params.get("code");
if (!code) {
// No code, don't do anything
setLSNotSignedIn();
return;
}
const localState = popLocalStorageItem(PKCE_LS_STATE);
if (!localState) {
console.error("no local state");
setLSNotSignedIn();
return;
}
const paramState = params.get("state");
if (localState !== paramState) {
console.error("state mismatch");
setLSNotSignedIn();
return;
}
const verifier = popLocalStorageItem(PKCE_LS_VERIFIER) ?? "";
(authCodeCallback ?? defaultAuthCodeCallback)(code, verifier).catch((err) => {
console.error(err);
setLSNotSignedIn();
});
}, [
authCallbackUrl,
authCodeCallback,
callbackPath,
clientId,
defaultAuthCodeCallback,
isAuthenticated,
location,
navigate,
oidcConfig,
uiErrorCallback,
]);
};
export const checkIsInAuthPopup = (applicationUrl: string): boolean => {
try {
const didCreateSignInPopup = localStorage.getItem(LS_SIGN_IN_POPUP);
return window.opener && window.opener.origin === applicationUrl && didCreateSignInPopup === "true";
} catch {
return false;
}
};