In my previous post, we created our own custom authentication provider which exposed the members of the Microsoft Authentication Library (MSAL) to handle authentication for the PCF control. Implementing our own is great, but for reusability I wondered if there was an existing library we could utilise instead. During the search for this, I came across an npm package called React AAD MSAL – a React wrapper for Azure AD using MSAL. You can learn more about the package here. We will explore how we can incorporate this to call our custom API secured by Azure AD from a PCF control.
Here is what we are going to cover in this post:
- Set up an API as an Azure Service App secured by Azure AD
- Set up a PCF control with React AAD MSAL
- Consume the API from the PCF control
Create a Simple API and Publish it to Azure Service App
First, we need to create a simple API for the PCF control to consume. Open up Visual Studio and create a new ASP.NET Core web application project. Specify the project name and the location, select .NET Core + ASP.NET Core 3.1 with API as the base template:
Once the VS project is created, delete the sample controller and sample model class and replace it with our own controller, SecureController.cs
:
using Microsoft.AspNetCore.Mvc; using System; using System.Collections.Generic; namespace Sandbox.SecureApi.Controllers { [ApiController] [Produces("application/json")] [Route("api/[controller]")] public class SecureController : ControllerBase { [HttpGet] public List<SecureRecord> GetSecureValues() { var result = new List<SecureRecord>(); for (var i = 0; i < 50; i++) { var rand = new Random().Next(i, 20000); result.Add(new SecureRecord { Id = Guid.NewGuid(), Name = $"Secure record {i}", Value = string.Format("{0:C}", rand), Modified = DateTime.Now.AddMinutes(rand * -1) }); } return result; } } public class SecureRecord { public Guid Id { get; set; } public string Name { get; set; } public string Value { get; set; } public DateTime Modified { get; set; } } }
Build the VS project and make sure that the /api/secure
route returns a list of SecureRecord objects as JSON. It should produce a payload like this:
[
{
"id": "297ceeee-7e46-4c85-90d6-952437076b3a",
"name": "Secure record 0",
"value": "$14,735.00",
"modified": "2020-06-16T00:45:04.356226+10:00"
},
{
"id": "d3a28c85-7282-4714-9656-eadbc8b45760",
"name": "Secure record 1",
"value": "$3,741.00",
"modified": "2020-06-23T15:59:04.3594948+10:00"
},
...
]
To publish to the Azure App Service, right-click on the VS project and select “Publish…”. When it prompts you to pick a publish target, choose “Create New” and click on the “Create Profile” button. Fill in the details of the new App Service for your Azure tenant and click on the “Create” button. It will take a couple of minutes to provision the App Service. Once completed, a publishing profile will be created in the VS Project. Click on the “Publish” button to publish the API to the newly provisioned App Service.
Configuring Authentication and Authorisation for the Custom API
To secure the API, open Azure Portal (https://portal.azure.com). From the search box in the top navigation, search for the App Service we just published by name and select it once found. From the App Service resource page, under Settings > Authentication / Authorization, select the following options:
Click on the Azure Active Directory from the authentication providers and configure the following to create a new AD app registration called PCF Secure API
:
Make sure to click “Save” to save the configuration. To test, open up a new incognito browser session, navigate to the route on the API (https://<your-api>.azurewebsites.net/api/secure). It will redirect to the Microsoft Login page (https://login.microsoftonline.com) and prompt for a user sign-in. Once authenticated, it will show the SecureRecords JSON payload.
Before we start configuring the AD app registration, it is important to configure the Cross-Origin Resource Sharing (CORS) for the API:
Configuring the AD App Registration
Search for App Registrations from the Azure Portal search, navigate to the App Registrations page and search for the PCF Secure API
app registration we created earlier:
Copy and store the Client ID and Tenant ID to use later when configuring the PCF control:
Under Manage > Authentication, configure the redirect URI and tokens:
Under Manage > Expose an API, copy and store the scope to use later when configuring the PCF control:
Setting up the PCF Control
First, create your PCF control project using the PCF CLI command pac pcf init
. We need to install the following npm packages:
npm install react react-dom @fluentui/react msal react-aad-msal
Here is the folder structure for the project:
components/
Login.tsx
SecureList.tsx
interfaces/
IConfig.ts
ISecureRecord.ts
App.tsx
AppContext.tsx
ControlManifest.Input.xml
index.ts
MsalAuthProvider.ts
Configuring the Control for Authentication
To allow easy configuration, several settings required by the React AAD MSAL have been exposed as properties on the control’s manifest file.
In index.ts
, we instantiate a MsalAuthProvider class and pass it down as props to App component:
private _container: HTMLDivElement; private _props: IAppProps; constructor() {} public init(context: ComponentFramework.Context<IInputs>, notifyOutputChanged: () => void, state: ComponentFramework.Dictionary, container: HTMLDivElement) { this._container = container; this._props = { componentContext: context, msalAuthProvider: createMsalAuthProvider({ appId: context.parameters.azureADAppId.raw!, appRedirectUrl: context.parameters.azureADAppRedirectUrl.raw!, appAuthority: context.parameters.azureADAppAuthority.raw!, appScopes: (context.parameters.azureADAppScopes.raw || "").split(";"), cacheLocation: context.parameters.cacheLocation.raw || "sessionStorage" } as IConfig), forceLogin: context.parameters.forceLogin.raw == "true" }; } public updateView(context: ComponentFramework.Context<IInputs>): void { this._props.componentContext = context; ReactDOM.render( React.createElement(App, this._props), this._container ); }
In MsalAuthProvider.ts
:
import { MsalAuthProvider, LoginType } from "react-aad-msal"; import { LogLevel, Logger } from "msal"; import { IConfig } from "./interfaces/IConfig"; import "regenerator-runtime"; export const createMsalAuthProvider = (config: IConfig): MsalAuthProvider => { return new MsalAuthProvider({ auth: { authority: config.appAuthority, clientId: config.appId, postLogoutRedirectUri: window.location.href, redirectUri: config.appRedirectUrl, validateAuthority: true, navigateToLoginRequestUrl: false }, system: { logger: new Logger((logLevel, message, containsPii) => { console.log("[MSAL]", message); }, { level: LogLevel.Verbose, piiLoggingEnabled: false }) }, cache: { cacheLocation: config.cacheLocation, storeAuthStateInCookie: false } }, { scopes: config.appScopes }, { loginType: LoginType.Popup, tokenRefreshUri: window.location.origin } ) };
Under the hood, the MsalAuthProvider is creating an instance of a UserAgentApplication from MSAL. We explicitly set the following configuration options:
- authority – the Azure directory to request the token from. In our case, this will be set to, https://login.microsoft.com/<Tenant ID>
- clientId – the Client ID of the app registration
- postLogoutRedirectUri – the URI to redirect the user after logout. We want to return the user to the current form, so this will be set to current location, window.location.href
- redirectUri – the URI where the authentication response can be sent and received. In our case, this is the URL of the power apps environment, https://<your-powerapps-environment>.crm6.dynamics.com. It is important to note that in the earlier section, this was set as the redirect URL for the Azure app registration
- cacheLocation – set the browser storage location to either localStorage or sessionStorage
- scopes – scopes that define the application’s access for an authenticated user. In our case, this will be set to, https://<your-api>.azurewebsites.net/user_impersonate
Using the AzureAD Component
The AzureAD component, implemented by the React AAD MSAL library, provides an easy way to integrate authentication into the PCF control. It takes care of all the complexity of checking the current authentication state and makes it performant by using the cache. It will first check whether the ID token has expired and if expired, it will attempt to renew silently. The users will be only prompted if the tokens cannot be refreshed without user interaction. The same process applies for access tokens.
In the App.tsx
, we use the function inside the AzureAD component to control what to render when the user is authenticated or when it errors:
import * as React from "react"; import AzureAD, { MsalAuthProvider, IAzureADFunctionProps } from "react-aad-msal"; import { MessageBar, MessageBarType } from "@fluentui/react/lib/MessageBar"; import { IInputs } from "./generated/ManifestTypes"; import { AppProvider } from "./AppContext"; import { Login } from "./components/Login"; import { SecureList } from "./components/SecureList"; export interface IAppProps { componentContext: ComponentFramework.Context<IInputs>; msalAuthProvider: MsalAuthProvider; forceLogin: boolean; } export const App: React.FC<IAppProps> = (props: IAppProps) => { const [forceLogin, setForceLogin] = React.useState(props.forceLogin); return ( <AppProvider {...props}> <AzureAD provider={props.msalAuthProvider} forceLogin={forceLogin}> {({ error, accountInfo }: IAzureADFunctionProps) => { if (error && forceLogin == true) { setForceLogin(false); } return (<React.Fragment> {error && ( <MessageBar messageBarType={MessageBarType.error}> {error.errorMessage} </MessageBar> )} <Login></Login> {accountInfo ? <SecureList></SecureList> : <div>Please sign in to see secure content</div> } </React.Fragment>) }} </AzureAD> </AppProvider> ); }
Note that the AzureAD component can be used multiple times within the context of your control. Whenever it is used, it requires the provider
prop to be set. Since the MsalAuthProvider is meant to be a singleton, by wrapping the entire control within the React Context, we can make sure we use the same provider instantiated in the index.ts
in the child components.
The forceLogin
prop on the AzureAD, indicates whether the user should be prompted for login automatically when they are not authenticated. This is useful when you don’t want the user to manually log in using the sign-in button.
Little side warning, when this prop is set to true, I've experienced a strange looping behaviour if the user fails to log in. To get around this issue, I've got a local state to manage the forceLogin
prop and set it to false if there is an error. Please let me know if you find a better way or spot where I've might have gone wrong!
Login Component
In the Login.tsx
, we use the AzureAD component’s function again to render controls depending on the user authentication status:
import * as React from "react"; import { ActionButton, Stack } from "office-ui-fabric-react"; import { AzureAD, IAzureADFunctionProps, AuthenticationState } from "react-aad-msal"; import { useAppContext } from "../AppContext"; export const Login: React.FC = () => { const { msalAuthProvider } = useAppContext(); return ( <div> <AzureAD provider={msalAuthProvider}> {({ login, logout, authenticationState, accountInfo }: IAzureADFunctionProps) => { const isInProgress = authenticationState === AuthenticationState.InProgress; const isAuthenticated = authenticationState === AuthenticationState.Authenticated; const isUnauthenticated = authenticationState === AuthenticationState.Unauthenticated; return ( <Stack verticalAlign={"center"} horizontalAlign={"end"} horizontal tokens={{ childrenGap: 4 }}> {isAuthenticated && ( <ActionButton iconProps={{ iconName: "Signout" }} onClick={logout}> {accountInfo?.account?.name} </ActionButton> )} {(isUnauthenticated || isInProgress) && ( <ActionButton iconProps={{ iconName: "Signin" }} onClick={login} disabled={isInProgress}> Sign in </ActionButton> )} </Stack> ) }} </AzureAD> </div> ); }
It’s quite intuitive what the code is doing, it checks for the authentication status and depending on whether the user is authenticated or not, it will show the “Sign in” or the “Logout” button.
Secure List Component
This component brings everything together by calling the API and displaying the secure records in the details list. It uses the useEffect
React hook to observe any changes in the authentication status. When the user is authenticated, it will make a call to retrieve an access token using the getAccessToken
method on the MsalAuthProvider. Once the token is successfully acquired, the component calls the /api/secure
route on our API with the bearer token in the authorization header:
In SecureList.tsx
:
import * as React from "react"; import { useState } from "react"; import { AuthenticationState } from "react-aad-msal"; import { IColumn, SelectionMode } from "@fluentui/react/lib/DetailsList"; import { ShimmeredDetailsList } from "@fluentui/react/lib/ShimmeredDetailsList"; import { useAppContext } from "../AppContext"; import { ISecureRecord } from "../interfaces/ISecureRecord"; export const SecureList: React.FC = () => { const { componentContext, msalAuthProvider } = useAppContext(); const [columns] = useState<IColumn[]>(getColumns()); const [items, setItems] = useState<ISecureRecord[]>([]); const [isDataLoaded, setIsDataLoaded] = React.useState(false); const [accessToken, setAccessToken] = useState<string>(); const apiUrl = componentContext.parameters.apiUrl.raw!; React.useEffect(() => { const isAuthenticated = msalAuthProvider.authenticationState === AuthenticationState.Authenticated; if (isAuthenticated) { msalAuthProvider.getAccessToken().then((accessTokenResponse) => { setAccessToken(accessTokenResponse.accessToken); }); } }, [msalAuthProvider, msalAuthProvider.authenticationState]); React.useEffect(() => { if (accessToken) { getSecureValues(apiUrl, accessToken).then((values) => { setItems(values); setIsDataLoaded(true); }, (err) => { console.log(err); }) } }, [accessToken]); return ( <React.Fragment> <ShimmeredDetailsList setKey="items" items={items} columns={columns} selectionMode={SelectionMode.none} enableShimmer={!isDataLoaded} /> </React.Fragment> ); } const getSecureValues = async (apiUrl: string, accessToken: string): Promise<ISecureRecord[]> => { const response = await fetch(apiUrl + "/api/Secure", { method: 'GET', headers: { 'Authorization': 'Bearer ' + accessToken, 'Content-Type': 'application/json' }, }); return response ? await response.json() : []; } const getColumns = () => { const defaultProps = { isResizable: true, isPadded: true, isRowHeader: true, minWidth: 200 }; const columns: IColumn[] = [ { key: "column0", name: "Name", fieldName: "name", data: "string", ...defaultProps }, { key: "column1", name: "Value", fieldName: "value", data: "string", ...defaultProps } ]; return columns; }
Configuring the PCF Control
As our final step, configure the PCF control using the values copied earlier whilst setting up the Azure app registration:
And that’s it! Here is a demo of the finished PCF control where the forceLogin
is set to true:
Hope you enjoyed the post, let me know your thoughts in the comments below or any of my contact channels 😎
One Comment
Danyil
How about adding a couple of PowerComponents to the same entity form? How will they use MSAL authentication? When 2 or more components try to authenticate at the same time, they get they stuck in MSAL authentication loop. 🙁
🙂 Wouldn’t it be great if you could create a short post with an example of how you can add 2 or more components (like simple TextField with input and output) to an entity form and set the correct way to share the MSAL authentication process between them?
I was thinking something like that:
const { componentContext, msalAuthProvider, apiUrl, onChange } = useAppContext();
const orderErrorMsg: string = “Look’s like you don’t have access to the order”;
const [accessToken, setAccessToken] = useState();
const [iconProps, setIconProp] = useState({ iconName: ‘none’ });
const [textFieldValue, setTextFieldValue] = useState(componentContext.parameters.bound.raw);
const [textFieldErrorMsg, setTextFieldErrorMsg] = useState();
const handleChange = (event: any) => {
//validate order_number
if (event.target.value && ( (!Number(event.target.value)) || (event.target.value as string).length > 8) ) {
return;
}
setTextFieldValue(event.target.value);
onChange(event.target.value);
callApi(event.target.value as string);
};
useEffect(() => {
callApi(textFieldValue as string);
}, [accessToken]);
const callApi = (orderNumber: string) => {
if(accessToken) {
getOrderValue(apiUrl, orderNumber, accessToken, userId).then((values) => {
checkAndSetOrderStatusIcon(values);
}, (err) => {
console.log(err);
setTextFieldErrorMsg(orderErrorMsg);
})
} else {
setTextFieldErrorMsg(“”);
}
}
const checkAndSetOrderStatusIcon = (order: IOrder) => {
if(order && order.orderStatus!) {
switch (order.orderStatus) {
case ‘S’:
setIconProp({ iconName: ‘EntitlementRedemption’ });
console.log(‘Signed order.’);
break;
case ‘P’:
setIconProp({ iconName: ‘ChangeEntitlements’ });
console.log(‘Order in progress.’);
break;
default:
setIconProp({ iconName: ‘none’ });
}
}
}
return (
);
const getOrderValue = async (apiUrl: string, orderNumber: string, accessToken: string, userId: string): Promise => {
const response = await fetch(apiUrl + “api/order/” + orderNumber + “/userid/” + userId, {
method: ‘GET’,
headers: {
‘Authorization’: ‘Bearer ‘ + accessToken,
‘Content-Type’ : ‘application/json’
},
})
if(response.status === 200){
return response.json()
} else {
console.log(response.body);
return Promise.reject(response);
}
return Promise.reject();
}