From bd5cdc3eaded2e262c23f10092c4d924f8e92e38 Mon Sep 17 00:00:00 2001 From: Dev Singh Date: Thu, 28 Mar 2024 18:06:29 -0500 Subject: [PATCH] add auth --- admin-api-frontend/package.json | 7 + admin-api-frontend/src/App.js | 54 +++-- admin-api-frontend/src/authConfig.js | 77 +++++++ .../src/hooks/useFetchWithMsal.jsx | 90 ++++++++ admin-api-frontend/src/index.js | 45 +++- admin-api-frontend/src/utils/claimUtils.js | 206 ++++++++++++++++++ 6 files changed, 451 insertions(+), 28 deletions(-) create mode 100644 admin-api-frontend/src/authConfig.js create mode 100644 admin-api-frontend/src/hooks/useFetchWithMsal.jsx create mode 100644 admin-api-frontend/src/utils/claimUtils.js diff --git a/admin-api-frontend/package.json b/admin-api-frontend/package.json index c03f432..1d35fae 100644 --- a/admin-api-frontend/package.json +++ b/admin-api-frontend/package.json @@ -4,14 +4,21 @@ "private": true, "homepage": "/admin-api", "dependencies": { + "@azure/msal-browser": "^3.11.1", + "@azure/msal-react": "^2.0.14", "@nextui-org/react": "^2.2.9", "@testing-library/jest-dom": "^5.17.0", "@testing-library/react": "^13.4.0", "@testing-library/user-event": "^13.5.0", + "@typescript-eslint/parser": "^7.4.0", + "bootstrap": "^5.3.3", "framer-motion": "^11.0.5", "react": "^18.2.0", + "react-bootstrap": "^2.10.2", "react-dom": "^18.2.0", + "react-router-dom": "^6.22.3", "react-scripts": "5.0.1", + "typescript": "^5.4.3", "web-vitals": "^2.1.4" }, "scripts": { diff --git a/admin-api-frontend/src/App.js b/admin-api-frontend/src/App.js index 8843870..c2fcef8 100644 --- a/admin-api-frontend/src/App.js +++ b/admin-api-frontend/src/App.js @@ -4,27 +4,47 @@ import AddRolesForm from "./components/AddRolesForm/index.tsx"; import RemoveRolesForm from "./components/RemoveRolesForm/index.tsx"; import CreateUserForm from "./components/CreateUserForm/index.tsx"; import DeleteUserForm from "./components/DeleteUserForm/index.tsx"; - +import { MsalProvider } from '@azure/msal-react'; import { NextUIProvider } from "@nextui-org/react"; +import { MsalAuthenticationTemplate } from '@azure/msal-react'; +import { InteractionType } from '@azure/msal-browser'; +import { loginRequest } from "./authConfig"; + -function App() { +const App = ({ instance }) => { + const authRequest = { + ...loginRequest, + }; + let acct = instance.getActiveAccount(); + if (!acct) { + console.error("Ah fuck!") + return null; + } + console.log(acct); return ( - -
-
-

ACM Admin API

-

Roles and permissions should be comma separated

-
- - - - - + + + +
+
+

ACM Admin API

+

Welcome {acct["name"]}!

+

Roles and permissions should be comma separated

+
+ + + + + +
+
-
-
-
+ + + ); -} +}; export default App; diff --git a/admin-api-frontend/src/authConfig.js b/admin-api-frontend/src/authConfig.js new file mode 100644 index 0000000..87ecc05 --- /dev/null +++ b/admin-api-frontend/src/authConfig.js @@ -0,0 +1,77 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { LogLevel } from "@azure/msal-browser"; + +/** + * Configuration object to be passed to MSAL instance on creation. + * For a full list of MSAL.js configuration parameters, visit: + * https://github.com/AzureAD/microsoft-authentication-library-for-js/blob/dev/lib/msal-browser/docs/configuration.md + */ +export const msalConfig = { + auth: { + clientId: '393a9b2f-620d-45d5-8b69-f8d36d3e123b', // This is the ONLY mandatory field that you need to supply. + authority: 'https://login.microsoftonline.com/c8d9148f-9a59-4db3-827d-42ea0c2b6e2e/', // Replace the placeholder with your tenant subdomain + redirectUri: '/admin-api/', // You must register this URI on Azure Portal/App Registration. Defaults to window.location.origin + postLogoutRedirectUri: '/', // Indicates the page to navigate after logout. + }, + cache: { + cacheLocation: 'localStorage', // Configures cache location. "sessionStorage" is more secure, but "localStorage" gives you SSO between tabs. + storeAuthStateInCookie: false, // Set this to "true" if you are having issues on IE11 or Edge + }, + system: { + loggerOptions: { + /** + * Below you can configure MSAL.js logs. For more information, visit: + * https://docs.microsoft.com/azure/active-directory/develop/msal-logging-js + */ + loggerCallback: (level, message, containsPii) => { + if (containsPii) { + return; + } + switch (level) { + case LogLevel.Error: + console.error(message); + return; + case LogLevel.Info: + console.info(message); + return; + case LogLevel.Verbose: + console.debug(message); + return; + case LogLevel.Warning: + console.warn(message); + return; + default: + return; + } + }, + }, + }, +}; + +/** + * Add here the endpoints and scopes when obtaining an access token for protected web APIs. For more information, see: + * https://github.com/AzureAD/microsoft-authentication-library-for-js/blob/dev/lib/msal-browser/docs/resources-and-scopes.md + */ +export const protectedResources = { + AdminAPI: { + endpoint: 'https://localhost:44351/api/todolist', + scopes: { + read: ['api://86baa161-ea6e-4c68-a75a-9ff13690f7da/AdminAPI.Write'], + write: ['api://86baa161-ea6e-4c68-a75a-9ff13690f7da/AdminAPI.Write'], + }, + }, +}; + +/** + * 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: [...protectedResources.AdminAPI.scopes.read, ...protectedResources.AdminAPI.scopes.write], +}; diff --git a/admin-api-frontend/src/hooks/useFetchWithMsal.jsx b/admin-api-frontend/src/hooks/useFetchWithMsal.jsx new file mode 100644 index 0000000..d9a9d20 --- /dev/null +++ b/admin-api-frontend/src/hooks/useFetchWithMsal.jsx @@ -0,0 +1,90 @@ +import { + useState, + useCallback, +} from 'react'; + +import { InteractionType } from '@azure/msal-browser'; +import { useMsal, useMsalAuthentication } from "@azure/msal-react"; + +/** + * Custom hook to call a web API using bearer token obtained from MSAL + * @param {PopupRequest} msalRequest + * @returns + */ +const useFetchWithMsal = (msalRequest) => { + const { instance } = useMsal(); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [data, setData] = useState(null); + + const { result, error: msalError } = useMsalAuthentication(InteractionType.Popup, { + ...msalRequest, + account: instance.getActiveAccount(), + redirectUri: '/redirect' + }); + + /** + * Execute a fetch request with the given options + * @param {string} method: GET, POST, PUT, DELETE + * @param {String} endpoint: The endpoint to call + * @param {Object} data: The data to send to the endpoint, if any + * @returns JSON response + */ + const execute = async (method, endpoint, data = null) => { + if (msalError) { + setError(msalError); + return; + } + + if (result) { + try { + let response = null; + + const headers = new Headers(); + const bearer = `Bearer ${result.accessToken}`; + headers.append("Authorization", bearer); + + if (data) headers.append('Content-Type', 'application/json'); + + let options = { + method: method, + headers: headers, + body: data ? JSON.stringify(data) : null, + }; + + setIsLoading(true); + response = (await fetch(endpoint, options)); + + if ((response.status === 200 || response.status === 201)) { + let responseData = response; + + try { + responseData = await response.json(); + } catch (error) { + console.log(error); + } finally { + setData(responseData); + setIsLoading(false); + return responseData; + } + } + + setIsLoading(false); + return response; + } catch (e) { + setError(e); + setIsLoading(false); + throw e; + } + } + }; + + return { + isLoading, + error, + data, + execute: useCallback(execute, [result, msalError]), // to avoid infinite calls when inside a `useEffect` + }; +}; + +export default useFetchWithMsal; \ No newline at end of file diff --git a/admin-api-frontend/src/index.js b/admin-api-frontend/src/index.js index d563c0f..5462c16 100644 --- a/admin-api-frontend/src/index.js +++ b/admin-api-frontend/src/index.js @@ -1,17 +1,40 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; -import './index.css'; +import { BrowserRouter } from 'react-router-dom'; +import { PublicClientApplication, EventType } from '@azure/msal-browser'; + +import { msalConfig } from './authConfig.js'; import App from './App'; -import reportWebVitals from './reportWebVitals'; + +import 'bootstrap/dist/css/bootstrap.min.css'; + +/** + * MSAL should be instantiated outside of the component tree to prevent it from being re-instantiated on re-renders. + * For more, visit: https://github.com/AzureAD/microsoft-authentication-library-for-js/blob/dev/lib/msal-react/docs/getting-started.md + */ +const msalInstance = new PublicClientApplication(msalConfig); + +// Default to using the first account if no account is active on page load +if (!msalInstance.getActiveAccount() && msalInstance.getAllAccounts().length > 0) { + // Account selection logic is app dependent. Adjust as needed for different use cases. + msalInstance.setActiveAccount(msalInstance.getAllAccounts()[0]); +} + +// Optional - This will update account state if a user signs in from another tab or window +msalInstance.enableAccountStorageEvents(); + +// Listen for sign-in event and set active account +msalInstance.addEventCallback((event) => { + if (event.eventType === EventType.LOGIN_SUCCESS && event.payload.account) { + const account = event.payload.account; + msalInstance.setActiveAccount(account); + } +}); const root = ReactDOM.createRoot(document.getElementById('root')); + root.render( - - - -); - -// If you want to start measuring performance in your app, pass a function -// to log results (for example: reportWebVitals(console.log)) -// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals -reportWebVitals(); + + + +); \ No newline at end of file diff --git a/admin-api-frontend/src/utils/claimUtils.js b/admin-api-frontend/src/utils/claimUtils.js new file mode 100644 index 0000000..8fb0a57 --- /dev/null +++ b/admin-api-frontend/src/utils/claimUtils.js @@ -0,0 +1,206 @@ +/** + * Populate claims table with appropriate description + * @param {Object} claims ID token claims + * @returns claimsObject + */ +export const createClaimsTable = (claims) => { + let claimsObj = {}; + let index = 0; + + Object.keys(claims).forEach((key) => { + if (typeof claims[key] !== 'string' && typeof claims[key] !== 'number') return; + switch (key) { + case 'aud': + populateClaim( + key, + claims[key], + "Identifies the intended recipient of the token. In ID tokens, the audience is your app's Application ID, assigned to your app in the Azure portal.", + index, + claimsObj + ); + index++; + break; + case 'iss': + populateClaim( + key, + claims[key], + 'Identifies the issuer, or authorization server that constructs and returns the token. It also identifies the Azure AD tenant for which the user was authenticated. If the token was issued by the v2.0 endpoint, the URI will end in /v2.0. The GUID that indicates that the user is a consumer user from a Microsoft account is 9188040d-6c67-4c5b-b112-36a304b66dad.', + index, + claimsObj + ); + index++; + break; + case 'iat': + populateClaim( + key, + changeDateFormat(claims[key]), + 'Issued At indicates when the authentication for this token occurred.', + index, + claimsObj + ); + index++; + break; + case 'nbf': + populateClaim( + key, + changeDateFormat(claims[key]), + 'The nbf (not before) claim identifies the time (as UNIX timestamp) before which the JWT must not be accepted for processing.', + index, + claimsObj + ); + index++; + break; + case 'exp': + populateClaim( + key, + changeDateFormat(claims[key]), + "The exp (expiration time) claim identifies the expiration time (as UNIX timestamp) on or after which the JWT must not be accepted for processing. It's important to note that in certain circumstances, a resource may reject the token before this time. For example, if a change in authentication is required or a token revocation has been detected.", + index, + claimsObj + ); + index++; + break; + case 'name': + populateClaim( + key, + claims[key], + "The name claim provides a human-readable value that identifies the subject of the token. The value isn't guaranteed to be unique, it can be changed, and it's designed to be used only for display purposes. The profile scope is required to receive this claim.", + index, + claimsObj + ); + index++; + break; + case 'preferred_username': + populateClaim( + key, + claims[key], + 'The primary username that represents the user. It could be an email address, phone number, or a generic username without a specified format. Its value is mutable and might change over time. Since it is mutable, this value must not be used to make authorization decisions. It can be used for username hints, however, and in human-readable UI as a username. The profile scope is required in order to receive this claim.', + index, + claimsObj + ); + index++; + break; + case 'nonce': + populateClaim( + key, + claims[key], + 'The nonce matches the parameter included in the original /authorize request to the IDP. If it does not match, your application should reject the token.', + index, + claimsObj + ); + index++; + break; + case 'oid': + populateClaim( + key, + claims[key], + 'The oid (user’s object id) is the only claim that should be used to uniquely identify a user in an Azure AD tenant. The token might have one or more of the following claim, that might seem like a unique identifier, but is not and should not be used as such.', + index, + claimsObj + ); + index++; + break; + case 'tid': + populateClaim( + key, + claims[key], + 'The tenant ID. You will use this claim to ensure that only users from the current Azure AD tenant can access this app.', + index, + claimsObj + ); + index++; + break; + case 'upn': + populateClaim( + key, + claims[key], + '(user principal name) – might be unique amongst the active set of users in a tenant but tend to get reassigned to new employees as employees leave the organization and others take their place or might change to reflect a personal change like marriage.', + index, + claimsObj + ); + index++; + break; + case 'email': + populateClaim( + key, + claims[key], + 'Email might be unique amongst the active set of users in a tenant but tend to get reassigned to new employees as employees leave the organization and others take their place.', + index, + claimsObj + ); + index++; + break; + case 'acct': + populateClaim( + key, + claims[key], + 'Available as an optional claim, it lets you know what the type of user (homed, guest) is. For example, for an individual’s access to their data you might not care for this claim, but you would use this along with tenant id (tid) to control access to say a company-wide dashboard to just employees (homed users) and not contractors (guest users).', + index, + claimsObj + ); + index++; + break; + case 'sid': + populateClaim(key, claims[key], 'Session ID, used for per-session user sign-out.', index, claimsObj); + index++; + break; + case 'sub': + populateClaim( + key, + claims[key], + 'The sub claim is a pairwise identifier - it is unique to a particular application ID. If a single user signs into two different apps using two different client IDs, those apps will receive two different values for the subject claim.', + index, + claimsObj + ); + index++; + break; + case 'ver': + populateClaim( + key, + claims[key], + 'Version of the token issued by the Microsoft identity platform', + index, + claimsObj + ); + index++; + break; + case 'uti': + case 'rh': + index++; + break; + case '_claim_names': + case '_claim_sources': + default: + populateClaim(key, claims[key], '', index, claimsObj); + index++; + } + }); + + return claimsObj; +}; + +/** + * Populates claim, description, and value into an claimsObject + * @param {String} claim + * @param {String} value + * @param {String} description + * @param {Number} index + * @param {Object} claimsObject + */ +const populateClaim = (claim, value, description, index, claimsObject) => { + let claimsArray = []; + claimsArray[0] = claim; + claimsArray[1] = value; + claimsArray[2] = description; + claimsObject[index] = claimsArray; +}; + +/** + * Transforms Unix timestamp to date and returns a string value of that date + * @param {String} date Unix timestamp + * @returns + */ +const changeDateFormat = (date) => { + let dateObj = new Date(date * 1000); + return `${date} - [${dateObj.toString()}]`; +};