AzureAD / microsoft-authentication-library-for-js

Microsoft Authentication Library (MSAL) for JS
http://aka.ms/aadv2
MIT License
3.61k stars 2.64k forks source link

Login not working with loginredirect and not working with all browsers/os with loginPopup #7279

Open Mohammed-Mounir opened 2 weeks ago

Mohammed-Mounir commented 2 weeks ago

Core Library

MSAL.js (@azure/msal-browser)

Core Library Version

2.32.1

Wrapper Library

MSAL React (@azure/msal-react)

Wrapper Library Version

1.5.1

Public or Confidential Client?

Public

Description

Hello,

Currently, we are using loginPopup and acquireTokenPopup for authentication, on browsers like Edge and Chrome on Mac, and also on Android Chrome it's not working,

BrowserAuthError: block_nested_popups: Request was blocked inside a popup because MSAL detected it was running in a popup.

So I wanted to use loginRedirect instead of loginPopup, but it's not working at all, it's just refreshing/redirecting to the same/current Login page.

I followed the docs and searched all over the internet without any result...

P.S I have old versions of both msal-react and msal-browser, but I also tried latest version for both and getting the same result.

thanks

Error Message

BrowserAuthError: block_nested_popups: Request was blocked inside a popup because MSAL detected it was running in a popup.

MSAL Logs

No response

Network Trace (Preferrably Fiddler)

MSAL Configuration

let current_origin = window.location.origin;
export const msalConfig = {
  auth: {
    clientId: 'clientId',
    authority:
      'authority'
    redirectUri:
      current_origin +
      (current_origin.includes('localhost') ? '/app' : '/'),
  },
};

/**
 * Scopes you add here will be prompted for user consent during sign-in.
 * By default, MSAL.js will add OIDC scopes (openid, profile, email) to any login request.
 * For more information about OIDC scopes, visit:
 * https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-permissions-and-consent#openid-connect-scopes
 */
export const loginRequest = {
  scopes: ['User.Read'],
};

Relevant Code Snippets

const handleForm = async () => {
    try {
      const auth = await instance.loginPopup(loginRequest);
      const accessTokenObj = { 'MS-Access-Token': auth.accessToken };
      const response = await axiosPublic.post('/login/', accessTokenObj);
      ....
    } catch (error) {
      if (isAxiosError(error)) {
        const response = error.response;
        setErrors({ message: response?.data?.detail });
      } else {
        console.error('An unexpected error occurred:', error);
      }
    }
  };

  useEffect(() => {
    const getUserToken = () => {
      const accessTokenRequest = {
        scopes: ['user.read'],
        account: accounts[0],
      };
      instance
        .acquireTokenSilent(accessTokenRequest)
        .then((res) => validateJWT())
        .catch((error) => {
          if (error instanceof InteractionRequiredAuthError) {
            instance
              .acquireTokenPopup(accessTokenRequest)
              .then((res) => validateJWT())
              .catch((error) => console.error({ error }));
          }
          console.error(error);
        });
    };

    getUserToken();
  }, [accounts, instance, validateJWT]);

Reproduction Steps

Using loginPopup on Mac (Edge or Chrome) or Android (Chrome)

Expected Behavior

After login to Microsoft account, should be logged in.

Identity Provider

Entra ID (formerly Azure AD) / MSA

Browsers Affected (Select all that apply)

Chrome, Edge

Regression

No response

Source

External (Customer)

tnorling commented 2 weeks ago

This behavior is happening because you're invoking loginPopup/Redirect on your redirectUri page, resulting in loops (for redirect) or popups attempting to open more popups. For popups the guidance is to use a blank page as your redirectUri, for redirects the guidance is to ensure handleRedirectPromise has completed before you invoke any other API.

Mohammed-Mounir commented 2 weeks ago

Hi Thomas, This project has been using the loginPopup method for about two years without any issues. although redirectUri is set to /app However, a few days ago, it was the first user to use this project on macOS, where he encountered a problem logging in with Edge and Chrome. They were only able to log in successfully using Safari.

After searching the internet, I found some users have reported issues with loginPopup, so I attempted to switch to loginRedirect. However, as I said before this caused the login page to refresh/redirect back same Login Page.

Anyway, I found the issue as being caused by a RequireAuth component used to wrap protected routes:

function RequireAuth({ children }) { const { userToken } = useAuth(); return userToken ? children : <Navigate to="/auth/login" replace /> }

For some reason, the RequireAuth component was causing this issue. Once I removed the RequireAuth component, the issue was resolved.

However, this led to another minor problem: after login, the app redirected to the main page /, then to /auth/login, and back to / again. I suspected that the redirectUri set to /app was causing this issue, but I don't have access to the Azure account to change it. As a workaround, I added a new route /app in the React Router that opens the same Login Component as /auth/login. Now, both routes /auth/login and /app direct to the same Login Component, which resolved the issue.

However, I'm not satisfied with this workaround. I'll reach out to the person who has access to the Azure account to request a change to the redirectUri, and I'll follow up once it's resolved.

P.S. I'm using acquireTokenSilent to obtain an access token and authenticate with our server.

Edit: Here is the current full code after refactoring:

Router:

const Router = createBrowserRouter([
  {
    path: '/',
    element: <MainLayout />,
    children: [
      { path: 'auth/login', element: <Login /> },
      { path: 'app', element: <Login /> },
      {
        path: '',
        element: <DashboardLayout />, // Previously with wrapped with RequireAuth
        children: [
        // Protected Routes
        ],
      },
      { path: '*', element: <Navigate to="/" replace /> },
    ],
  },
]);

export default Router;

useAuth

export default function useAuth() {
  const { instance } = useMsal();
  const [userToken, setUserToken] = useState<null | string>(null);
  const [isLoading, setIsLoading] = useState(false);

  const signIn = () => instance.loginRedirect(loginRequest);

  const signOut = async () => {
    setIsLoading(true);
    try {
      const refreshToken = sessionStorage.getItem('refresh_token');
      sessionStorage.clear();
      setUserToken(null);

      if (refreshToken) {
        await axiosPublic.post('/logout/', { refresh: refreshToken });
      }

      await instance.logoutRedirect();
    } catch (error) {
      const errorMessage = isAxiosError(error) ? error.response?.data : error;
      showToastError(errorMessage);
    } finally {
      window.location.replace('/auth/login');
    }
  };

  return { signIn, signOut, userToken, setUserToken, isLoading, setIsLoading };
}

AuthProvider

export const AuthProvider = ({ children }: { children: ReactNode }) => {
  const { instance, accounts } = useMsal();
  const account = useAccount(accounts[0] || {});
  const auth = useAuth();

  useEffect(() => {
    if (account) {
      auth.setIsLoading(true);
      instance
        .acquireTokenSilent({
          ...loginRequest,
          account: account,
        })
        .then((auth: any) => {
          const accessTokenObj = { 'MS-Access-Token': auth.accessToken };
          return axiosPublic.post('/login/', accessTokenObj);
        })
        .then((res: any) => {
          auth.setUserToken(res?.data.access);
          sessionStorage.setItem('token', res?.data.access);
          sessionStorage.setItem('refresh_token', res?.data.refresh);
          sessionStorage.setItem(
            'lastRefreshToken',
            new Date().getTime().toString()
          );
        })
        .catch((error: Error) => {
          showToastError(isAxiosError(error) ? error.response?.data : error);
          auth.signOut();
        })
        .finally(() => auth.setIsLoading(false));
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [account, instance]);

  return <AuthContext.Provider value={auth}>{children}</AuthContext.Provider>;
};

export const useAuthContext = () => {
  const context = useContext(AuthContext);

  if (context === undefined)
    console.error('AuthContext was used outside AuthProvider');

  return context;
};

Login

function Login() {
  const { userToken, signIn } = useAuthContext();
  const navigate = useNavigate();

  useEffect(() => {
    if (userToken) navigate('/', { replace: true });
  }, [navigate, userToken]);

  const handleLogin = () => signIn();

  return (
    <main id={styles.main}>
      ...
    </main>
  );
}

export default Login;
Mohammed-Mounir commented 2 weeks ago

This behavior is happening because you're invoking loginPopup/Redirect on your redirectUri page, resulting in loops (for redirect) or popups attempting to open more popups. For popups the guidance is to use a blank page as your redirectUri, for redirects the guidance is to ensure handleRedirectPromise has completed before you invoke any other API.

I checked the document regarding handleRedirectPromise, it says we don't need to call it and we should call acquireTokenSilent.

https://github.com/AzureAD/microsoft-authentication-library-for-js/blob/dev/lib/msal-react/FAQ.md#how-do-i-handle-the-redirect-flow-in-a-react-app