ms-identity-javascript-react-tutorial icon indicating copy to clipboard operation
ms-identity-javascript-react-tutorial copied to clipboard

MSAL React / Azure AD B2C AuthProvider sample request

Open spacecat opened this issue 3 years ago • 4 comments

Can you please provide us with a sample that demonstrates how to create an authentication provider using MSAL React & Create React App?

I managed to find the following resources:

https://medium.com/ascentic-technology/authentication-with-msal-js-2fe281098038 (good starting point but uses older MSAL libraries + uses class components. So it's a little bit outdated. https://gist.github.com/ayeshnipun/77bae1b89dc1f69c8c6d4e44d140bd33#file-auth-provider-js (Authentication provider)

I also found this one:

https://github.com/microsoftgraph/msgraph-sample-reactspa

This one kind of demonstrates what I'm after (https://github.com/microsoftgraph/msgraph-sample-reactspa/blob/main/graph-tutorial/src/AppContext.tsx) but it also adds several other concepts. It would be nice to have like a sample SPA in this repo with only login and logout using a well-architected authentication provider pattern/solution.

I think this would be a great idea for an article on Microsoft docs and in this repo.

What I would like to see in that article/sample SPA:

  • The SPA should be based on Create React App
  • The react app should integrate with Azure AD B2C
  • The sample should contain a well-structured AuthProvider react function component for the most common scenarios (log in, log out etc)
  • The UI should have protected routes and non-protected routes (I'm thinking a simple react router dashboard type of sample with a spinner/loader/splashscreen, login, logout, registration, reset password etc)
  • The AuthProvider react component should handle the authenticated / unauthenticated user
  • If authenticated you should be able to see the protected routes
  • If not authenticated you should not be able to see the protected routes
  • Show how to do login and logout with both redirect and pop-up (like in the other samples in this repo)
  • Both a .js and .tsx version of the sample

Again it would be nice to see this type of info somewhere and that you could use as a starting point.

Also, I'm curious about whether you know of any other resources where I can read more about how to build your own authentication provider using MSAL React + Azure AD B2C?

spacecat avatar Sep 30 '22 16:09 spacecat

Hi @spacecat, Thanks for reaching out. Let me look at the articles you sent, and I will get back to you with a response as soon as possible.

salman90 avatar Oct 03 '22 16:10 salman90

Hi @spacecat. Apologies for the late response. I went through the samples and the articles you sent. I needed help seeing the benefits of using an AuthProvider class using msal-react. In msal-react, you can easily use the hooks to perform the actions in the AuthProvider class without creating it. Why would you like us to use an AuthProvider class in the samples, and what are its benefits?

salman90 avatar Oct 06 '22 17:10 salman90

Hi, and thank you for the reply.

I purchased https://themeforest.net/item/metronic-responsive-admin-dashboard-template/4021469 a few weeks ago - it's a complete React application. And I've been studying it and in particular the authentication components.

Now what I want to do is to take their auth provider and replace it with MSAL React. And I'm not sure how to do that? I've asked in their support support forum if they had any suggestions and this is the response that I got:

We aren't experts in msal. From my point of you, would be better to copy AuthProvider and rewrite it in the msal way (and then use it), instead of wrapping AuthProvider.

Here's the entire thread just for completeness: https://devs.keenthemes.com/question/how-to-replace-authprovider-with-microsoft-authentication-library-for-react-msal-react

And here is the AuthProvider they're referring to:

import {
  FC,
  useState,
  useEffect,
  createContext,
  useContext,
  useRef,
  Dispatch,
  SetStateAction,
} from 'react'
import {LayoutSplashScreen} from '../../../../_metronic/layout/core'
import {AuthModel, UserModel} from './_models'
import * as authHelper from './AuthHelpers'
import {getUserByToken} from './_requests'
import {WithChildren} from '../../../../_metronic/helpers'

type AuthContextProps = {
  auth: AuthModel | undefined
  saveAuth: (auth: AuthModel | undefined) => void
  currentUser: UserModel | undefined
  setCurrentUser: Dispatch<SetStateAction<UserModel | undefined>>
  logout: () => void
}

const initAuthContextPropsState = {
  auth: authHelper.getAuth(),
  saveAuth: () => {},
  currentUser: undefined,
  setCurrentUser: () => {},
  logout: () => {},
}

const AuthContext = createContext<AuthContextProps>(initAuthContextPropsState)

const useAuth = () => {
  return useContext(AuthContext)
}

const AuthProvider: FC<WithChildren> = ({children}) => {
  const [auth, setAuth] = useState<AuthModel | undefined>(authHelper.getAuth())
  const [currentUser, setCurrentUser] = useState<UserModel | undefined>()
  const saveAuth = (auth: AuthModel | undefined) => {
    setAuth(auth)
    if (auth) {
      authHelper.setAuth(auth)
    } else {
      authHelper.removeAuth()
    }
  }

  const logout = () => {
    saveAuth(undefined)
    setCurrentUser(undefined)
  }

  return (
    <AuthContext.Provider value={{auth, saveAuth, currentUser, setCurrentUser, logout}}>
      {children}
    </AuthContext.Provider>
  )
}

const AuthInit: FC<WithChildren> = ({children}) => {
  const {auth, logout, setCurrentUser} = useAuth()
  const didRequest = useRef(false)
  const [showSplashScreen, setShowSplashScreen] = useState(true)
  // We should request user by authToken (IN OUR EXAMPLE IT'S API_TOKEN) before rendering the application
  useEffect(() => {
    const requestUser = async (apiToken: string) => {
      try {
        if (!didRequest.current) {
          const {data} = await getUserByToken(apiToken)
          if (data) {
            setCurrentUser(data)
          }
        }
      } catch (error) {
        console.error(error)
        if (!didRequest.current) {
          logout()
        }
      } finally {
        setShowSplashScreen(false)
      }

      return () => (didRequest.current = true)
    }

    if (auth && auth.api_token) {
      requestUser(auth.api_token)
    } else {
      logout()
      setShowSplashScreen(false)
    }
    // eslint-disable-next-line
  }, [])

  return showSplashScreen ? <LayoutSplashScreen /> : <>{children}</>
}

export {AuthProvider, AuthInit, useAuth}

Here's how they use their auth provider from the Login component:

/* eslint-disable jsx-a11y/anchor-is-valid */
import {useState} from 'react'
import * as Yup from 'yup'
import clsx from 'clsx'
import {Link} from 'react-router-dom'
import {useFormik} from 'formik'
import {getUserByToken, login} from '../core/_requests'
import {toAbsoluteUrl} from '../../../../_metronic/helpers'
import {useAuth} from '../core/Auth'

const loginSchema = Yup.object().shape({
  email: Yup.string()
    .email('Wrong email format')
    .min(3, 'Minimum 3 symbols')
    .max(50, 'Maximum 50 symbols')
    .required('Email is required'),
  password: Yup.string()
    .min(3, 'Minimum 3 symbols')
    .max(50, 'Maximum 50 symbols')
    .required('Password is required'),
})

const initialValues = {
  email: '[email protected]',
  password: 'demo',
}

/*
  Formik+YUP+Typescript:
  https://jaredpalmer.com/formik/docs/tutorial#getfieldprops
  https://medium.com/@maurice.de.beijer/yup-validation-and-typescript-and-formik-6c342578a20e
*/

export function Login() {
  const [loading, setLoading] = useState(false)
  const {saveAuth, setCurrentUser} = useAuth()

  const formik = useFormik({
    initialValues,
    validationSchema: loginSchema,
    onSubmit: async (values, {setStatus, setSubmitting}) => {
      setLoading(true)
      try {
        const {data: auth} = await login(values.email, values.password)
        saveAuth(auth)
        const {data: user} = await getUserByToken(auth.api_token)
        setCurrentUser(user)
      } catch (error) {
        console.error(error)
        saveAuth(undefined)
        setStatus('The login details are incorrect')
        setSubmitting(false)
        setLoading(false)
      }
    },
  })

  return (
    <form
     ...
    </form>
  )
}

Models:

export interface AuthModel {
  api_token: string
  refreshToken?: string
}

export interface UserModel {
  id: number
  username: string
  password: string | undefined
  email: string
  first_name: string
  last_name: string
  fullname?: string
  occupation?: string
  companyName?: string
  phone?: string
  roles?: Array<number>
  pic?: string
  language?: 'en' | 'de' | 'es' | 'fr' | 'ja' | 'zh' | 'ru'
  timeZone?: string
  website?: 'https://keenthemes.com'
  emailSettings?: UserEmailSettingsModel
  auth?: AuthModel
  communication?: UserCommunicationModel
  address?: UserAddressModel
  socialNetworks?: UserSocialNetworksModel
}

index.tsx

import {createRoot} from 'react-dom/client'
// Axios
import axios from 'axios'
import {Chart, registerables} from 'chart.js'
import {QueryClient, QueryClientProvider} from 'react-query'
import {ReactQueryDevtools} from 'react-query/devtools'
// Apps
import {MetronicI18nProvider} from './_metronic/i18n/Metronici18n'
/**
 * TIP: Replace this style import with rtl styles to enable rtl mode
 *
 * import './_metronic/assets/css/style.rtl.css'
 **/
import './_metronic/assets/sass/style.scss'
import './_metronic/assets/sass/plugins.scss'
import './_metronic/assets/sass/style.react.scss'
import {AppRoutes} from './app/routing/AppRoutes'
import {AuthProvider, setupAxios} from './app/modules/auth'
/**
 * Creates `axios-mock-adapter` instance for provided `axios` instance, add
 * basic Metronic mocks and returns it.
 *
 * @see https://github.com/ctimmerm/axios-mock-adapter
 */
/**
 * Inject Metronic interceptors for axios.
 *
 * @see https://github.com/axios/axios#interceptors
 */
setupAxios(axios)
Chart.register(...registerables)

const queryClient = new QueryClient()
const container = document.getElementById('root')
if (container) {
  createRoot(container).render(
    <QueryClientProvider client={queryClient}>
      <MetronicI18nProvider>
        <AuthProvider>
          <AppRoutes />
        </AuthProvider>
      </MetronicI18nProvider>
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
  )
}

AppRoutes.tsx

/**
 * High level router.
 *
 * Note: It's recommended to compose related routes in internal router
 * components (e.g: `src/app/modules/Auth/pages/AuthPage`, `src/app/BasePage`).
 */

import {FC} from 'react'
import {Routes, Route, BrowserRouter, Navigate} from 'react-router-dom'
import {PrivateRoutes} from './PrivateRoutes'
import {ErrorsPage} from '../modules/errors/ErrorsPage'
import {Logout, AuthPage, useAuth} from '../modules/auth'
import {App} from '../App'

/**
 * Base URL of the website.
 *
 * @see https://facebook.github.io/create-react-app/docs/using-the-public-folder
 */
const {PUBLIC_URL} = process.env

const AppRoutes: FC = () => {
  const {currentUser} = useAuth()
  return (
    <BrowserRouter basename={PUBLIC_URL}>
      <Routes>
        <Route element={<App />}>
          <Route path='error/*' element={<ErrorsPage />} />
          <Route path='logout' element={<Logout />} />
          {currentUser ? (
            <>
              <Route path='/*' element={<PrivateRoutes />} />
              <Route index element={<Navigate to='/dashboard' />} />
            </>
          ) : (
            <>
              <Route path='auth/*' element={<AuthPage />} />
              <Route path='*' element={<Navigate to='/auth' />} />
            </>
          )}
        </Route>
      </Routes>
    </BrowserRouter>
  )
}

export {AppRoutes}

App.tsx

import {Suspense} from 'react'
import {Outlet} from 'react-router-dom'
import {I18nProvider} from '../_metronic/i18n/i18nProvider'
import {LayoutProvider, LayoutSplashScreen} from '../_metronic/layout/core'
import {MasterInit} from '../_metronic/layout/MasterInit'
import {AuthInit} from './modules/auth'

const App = () => {
  return (
    <Suspense fallback={<LayoutSplashScreen />}>
      <I18nProvider>
        <LayoutProvider>
          <AuthInit>
            <Outlet />
            <MasterInit />
          </AuthInit>
        </LayoutProvider>
      </I18nProvider>
    </Suspense>
  )
}

export {App}

AuthPage.tsx

import {Route, Routes} from 'react-router-dom'
import {Registration} from './components/Registration'
import {ForgotPassword} from './components/ForgotPassword'
import {Login} from './components/Login'
import {AuthLayout} from './AuthLayout'

const AuthPage = () => (
  <Routes>
    <Route element={<AuthLayout />}>
      <Route path='login' element={<Login />} />
      <Route path='registration' element={<Registration />} />
      <Route path='forgot-password' element={<ForgotPassword />} />
      <Route index element={<Login />} />
    </Route>
  </Routes>
)

export {AuthPage}

And now I'm building my own auth provider using MSAL React - I'm using https://github.com/Azure-Samples/ms-identity-javascript-react-tutorial/blob/main/1-Authentication/2-sign-in-b2c/README.md as my starting point:

import { useMsal } from '@azure/msal-react';
import { createContext, useState } from 'react';
import { loginRequest } from './authConfig';

export const AuthProviderContext = createContext({
    logIn: undefined,
    logOut: undefined
});

export default function AuthProvider({ children }) {
    const auth = useAuthProviderContext();

    return (
        <AuthProviderContext.Provider value={auth}>
            {children}
        </AuthProviderContext.Provider>
    );
}

function useAuthProviderContext() {
    const { instance } = useMsal();

    const logIn = async () => {
        console.log('LOGGING IN...');
        await instance.loginRedirect(loginRequest);
    };

    const logOut = async () => {
        console.log('LOGGING OUT...');
        await instance.logoutRedirect();
    };

    return {
        logIn,
        logOut
    };
}

index.js

import React from "react";
import ReactDOM from "react-dom";
import { PublicClientApplication, EventType } from "@azure/msal-browser";
import App from "./App.jsx";
import { msalConfig } from "./authConfig";
import "bootstrap/dist/css/bootstrap.min.css";
import "./styles/index.css";

export const msalInstance = new PublicClientApplication(msalConfig);

msalInstance.addEventCallback(event => {
    if (event.eventType === EventType.LOGIN_SUCCESS) {
        console.log(event);
        msalInstance.setActiveAccount(event.payload.account);
    }
});

ReactDOM.render(
    <React.StrictMode>
        <App msalInstance={msalInstance} />
    </React.StrictMode>,
    document.getElementById("root")
);

App.jsx - And here I'm wrapping my AuthProvider with MsalProvider.

import React, { useState, useEffect } from "react";
import { MsalProvider, AuthenticatedTemplate, UnauthenticatedTemplate, useMsal } from "@azure/msal-react";
import { EventType, InteractionType } from "@azure/msal-browser";
import { msalConfig, b2cPolicies } from "./authConfig";
import { PageLayout, IdTokenClaims } from "./ui.jsx";
import Button from "react-bootstrap/Button";
import "./styles/App.css";
import AuthProvider from "./auth-provider";

const IdTokenContent = () => {
    const { accounts } = useMsal();
    const [idTokenClaims, setIdTokenClaims] = useState(null);

    function GetIdTokenClaims() {
        setIdTokenClaims(accounts[0].idTokenClaims)
    }

    return (
        <>
            <h5 className="card-title">Welcome {accounts[0].name}</h5>
            {idTokenClaims ?
                <IdTokenClaims idTokenClaims={idTokenClaims} />
                :
                <Button variant="secondary" onClick={GetIdTokenClaims}>View ID Token Claims</Button>
            }
        </>
    );
};

const MainContent = () => {
    const { instance } = useMsal();

    useEffect(() => {
        const callbackId = instance.addEventCallback((event) => {
            if (event.eventType === EventType.LOGIN_FAILURE) {
                if (event.error && event.error.errorMessage.indexOf("AADB2C90118") > -1) {
                    if (event.interactionType === InteractionType.Redirect) {
                        instance.loginRedirect(b2cPolicies.authorities.forgotPassword);
                    } else if (event.interactionType === InteractionType.Popup) {
                        instance.loginPopup(b2cPolicies.authorities.forgotPassword)
                            .catch(e => {
                                return;
                            });
                    }
                }
            }

            if (event.eventType === EventType.LOGIN_SUCCESS) {
                if (event?.payload) {
                    if (event.payload.idTokenClaims["acr"] === b2cPolicies.names.forgotPassword) {
                        window.alert("Password has been reset successfully. \nPlease sign-in with your new password");
                        return instance.logout();
                    }
                }
            }
        });

        return () => {
            if (callbackId) {
                instance.removeEventCallback(callbackId);
            }
        };
    }, []);

    return (
        <div className="App">
            <AuthenticatedTemplate>
                <IdTokenContent />
            </AuthenticatedTemplate>

            <UnauthenticatedTemplate>
                <h5 className="card-title">Please sign-in to see your profile information.</h5>
            </UnauthenticatedTemplate>
        </div>
    );
};

export default function App({ msalInstance }) {
    return (
        <MsalProvider instance={msalInstance}>
            <AuthProvider>
                <PageLayout>
                    <MainContent />
                </PageLayout>
            </AuthProvider>
        </MsalProvider>
    );
}

ui.jsx - And here I'm using my auth provider and calling authProviderContext.logOut and authProviderContext.logIn.

import React from "react";
import { AuthenticatedTemplate, UnauthenticatedTemplate, useMsal } from "@azure/msal-react";
import { Navbar, Button, Dropdown, DropdownButton } from "react-bootstrap";
import { loginRequest, b2cPolicies } from "./authConfig";
import { useContext, useState, useEffect } from 'react';
import { AuthProviderContext } from './auth-provider';

const NavigationBar = () => {
    const { instance } = useMsal();
    const authProviderContext = useContext(AuthProviderContext);
    
    const handleLogin = () => {
        instance.loginPopup(loginRequest)
        .catch((error) => console.log(error))
    }
    
    
    
    return (
        <>
            <AuthenticatedTemplate>
                <div className="ml-auto">
                    <Button variant="info" onClick={() => instance.loginPopup(b2cPolicies.authorities.editProfile)} className="ml-auto">Edit Profile</Button>
                    <DropdownButton variant="warning" className="ml-auto" drop="left" title="Sign Out">
                        <Dropdown.Item as="button" onClick={() => instance.logoutPopup({ postLogoutRedirectUri: "/", mainWindowRedirectUri: "/" })}>Sign out using Popup</Dropdown.Item>
                        <Dropdown.Item as="button" onClick={() => instance.logoutRedirect({ postLogoutRedirectUri: "/" })}>Sign out using Redirect</Dropdown.Item>
                        <Dropdown.Item as="button" onClick={authProviderContext.logOut}>LOG OUT</Dropdown.Item>
                    </DropdownButton>
                </div>
            </AuthenticatedTemplate>
            <UnauthenticatedTemplate>
                <DropdownButton variant="secondary" className="ml-auto" drop="left" title="Sign In">
                    <Dropdown.Item as="button" onClick={handleLogin}>Sign in using Popup</Dropdown.Item>
                    <Dropdown.Item as="button" onClick={() => instance.loginRedirect(loginRequest)}>Sign in using Redirect</Dropdown.Item>
                    <Dropdown.Item as="button" onClick={authProviderContext.logIn}>LOG IN</Dropdown.Item>
                </DropdownButton>
            </UnauthenticatedTemplate>
        </>
    );
};

export const PageLayout = (props) => {
    const [username, setUsername] = useState('');
    const { instance } = useMsal();

    useEffect(() => {
        const currentAccount = instance.getActiveAccount();

        if (currentAccount) {
            setUsername(currentAccount.username);
        }

    }, [instance]);

    return (
        <>
            <Navbar bg="primary" variant="dark">
                <a className="navbar-brand" href="/">Microsoft identity platform</a>
                <NavigationBar />
            </Navbar>
            <br />
            <h5><center>Welcome to the Microsoft Authentication Library For React Tutorial</center></h5>
            <br />
            {props.children}
            <br />
            <AuthenticatedTemplate>
                <footer>
                    <center>How did we do?
                        <a href="https://forms.office.com/Pages/ResponsePage.aspx?id=v4j5cvGGr0GRqy180BHbR73pcsbpbxNJuZCMKN0lURpUMlRHSkc5U1NLUkxFNEtVN0dEOTFNQkdTWiQlQCN0PWcu" target="_blank"> Share your experience!</a>
                    </center>
                </footer>
                <span>Username: {username}</span>
            </AuthenticatedTemplate>
        </>
    );
};

export const IdTokenClaims = (props) => {
    return (
        <div id="token-div">
            <p><strong>Audience: </strong> {props.idTokenClaims.aud}</p>
            <p><strong>Issuer: </strong> {props.idTokenClaims.iss}</p>
            <p><strong>OID: </strong> {props.idTokenClaims.oid}</p>
            <p><strong>UPN: </strong> {props.idTokenClaims.preferred_username}</p>
        </div>
    );
}


I would then take my auth provider and replace theirs with mine.

Why would you like us to use an AuthProvider class in the samples, and what are its benefits?

I've 15+ years of experience in .NET/Web development as a full-stack developer but I've only built very little in React. So I can't really say what the actual benefit would be other than you would have all of your auth logic (login, logout, etc) in one place and then you can call them from anywhere in your app.

And also I think this is probably how you'd typically build a React app with auth by having a separate component handling all of that functionality.

And one last reason would be so that devs not so familiar with MSAL React and authentication in a real world app could benefit from seeing how it's done in the real world. I think having those types of samples would be invaluable.

spacecat avatar Oct 07 '22 08:10 spacecat

You can drop the AuthProvider class and use msal-react out of the box, as shown in the samples, or you can create an AuthProvider class and replace it with the one they provided in the tutorial. Both approaches will work fine. Here are more resources that can help you with msal-react and b2c:

Unfortunately, we don't have any implementation for msal-react with an AuthProvider class. I will speak to my team about your feedback and see what we can do with our samples in the future.

salman90 avatar Oct 10 '22 16:10 salman90

Thank you, I think I've got a good idea now on how to move forward.

spacecat avatar Oct 16 '22 14:10 spacecat