diff --git a/.env b/.env new file mode 100644 index 0000000..d22186d --- /dev/null +++ b/.env @@ -0,0 +1,2 @@ +# Copy this file as .env.local and replace the values with your own +VITE_MSAL_CLIENT_ID= \ No newline at end of file diff --git a/.gitignore b/.gitignore index 8a30d25..c015724 100644 --- a/.gitignore +++ b/.gitignore @@ -396,3 +396,5 @@ FodyWeavers.xsd # JetBrains Rider *.sln.iml +dist +.env.local diff --git a/README.md b/README.md index 0e1810c..2d0c2d3 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,8 @@ # Steps +Register a new app in Entra Id +https://learn.microsoft.com/en-us/entra/identity-platform/scenario-spa-app-registration + Scaffold a new React/Typescript app. https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts @@ -14,37 +17,39 @@ npm run dev Install the MSAL React package. ``` -npm install react react-dom npm install @azure/msal-react @azure/msal-browser ``` -# React + TypeScript + Vite +https://github.com/AzureAD/microsoft-authentication-library-for-js/blob/dev/samples/msal-react-samples/typescript-sample/README.md + +## Setup Entra Id App Registration + +To run the app you will need an Azure subscription so that you can create an Entra Id App Registration. You can either do that manually in the Azure portal or by using the Azure CLI and the following steps. -This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. +1. Install Azure CLI (if you haven't already): Follow the installation instructions for your operating system from the [official Azure CLI documentation](https://docs.microsoft.com/en-us/cli/azure/install-azure-cli). + +2. Sign in to Azure + +``` +az login --tenant "your-tenant-id" +``` -Currently, two official plugins are available: +3. Create the app registration and note the "appId" (client Id) and "id" (object id) that is output in the json: -- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh -- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh +``` +az ad app create --display-name "YourAppName" --sign-in-audience "AzureADMultipleOrgs" +``` -## Expanding the ESLint configuration +4. Add a SPA redirect URI, replace in the following command with the "id" from step 3: -If you are developing a production application, we recommend updating the configuration to enable type aware lint rules: +``` +az rest --method PATCH --uri 'https://graph.microsoft.com/v1.0/applications/' --headers 'Content-Type=application/json' --body {"spa":{"redirectUris":["http://localhost:5173/"]}}' +``` -- Configure the top-level `parserOptions` property like this: +5. Add Microsoft Graph API permissions, replace your-app-client-id in the following command with the "appId" from step 3: -```js -export default { - // other rules... - parserOptions: { - ecmaVersion: "latest", - sourceType: "module", - project: ["./tsconfig.json", "./tsconfig.node.json"], - tsconfigRootDir: __dirname, - }, -}; +``` +az ad app permission add --id "your-app-client-id" --api 00000003-0000-0000-c000-000000000000 --api-permissions 311a71cc-e848-46a1-bdf8-97ff7156d8e6=Scope ``` -- Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked` -- Optionally add `plugin:@typescript-eslint/stylistic-type-checked` -- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list +6. Edit authConfig.ts and replace the clientId with the "appId" from step 3. You should now be able to run the app and login. diff --git a/index.html b/index.html index ff8268f..eb5c30a 100644 --- a/index.html +++ b/index.html @@ -2,12 +2,11 @@ - + - Vite + React + TS + Tailwind + React MSAL Demo - +
diff --git a/package-lock.json b/package-lock.json index 662a07e..75583d8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,7 +35,7 @@ "prettier-plugin-tailwindcss": "^0.6.6", "tailwindcss": "^3.4.10", "typescript": "^5.5.4", - "vite": "^5.4.2" + "vite": "^5.4.6" }, "optionalDependencies": { "@rollup/rollup-darwin-arm64": "^4.21.1", @@ -584,34 +584,6 @@ "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0" } }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.21.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.21.1.tgz", - "integrity": "sha512-2thheikVEuU7ZxFXubPDOtspKn1x0yqaYQwvALVtEcvFhMifPADBrgRPyHV0TF3b+9BgvgjgagVyvA/UqPZHmg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.21.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.21.1.tgz", - "integrity": "sha512-t1lLYn4V9WgnIFHXy1d2Di/7gyzBWS8G5pQSXdZqfrdCGTwi1VasRMSS81DTYb+avDs/Zz4A6dzERki5oRYz1g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, "node_modules/@rollup/rollup-darwin-arm64": { "version": "4.21.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.21.1.tgz", @@ -625,118 +597,6 @@ "darwin" ] }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.21.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.21.1.tgz", - "integrity": "sha512-dO0BIz/+5ZdkLZrVgQrDdW7m2RkrLwYTh2YMFG9IpBtlC1x1NPNSXkfczhZieOlOLEqgXOFH3wYHB7PmBtf+Bg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.21.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.21.1.tgz", - "integrity": "sha512-sWWgdQ1fq+XKrlda8PsMCfut8caFwZBmhYeoehJ05FdI0YZXk6ZyUjWLrIgbR/VgiGycrFKMMgp7eJ69HOF2pQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.21.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.21.1.tgz", - "integrity": "sha512-9OIiSuj5EsYQlmwhmFRA0LRO0dRRjdCVZA3hnmZe1rEwRk11Jy3ECGGq3a7RrVEZ0/pCsYWx8jG3IvcrJ6RCew==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.21.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.21.1.tgz", - "integrity": "sha512-0kuAkRK4MeIUbzQYu63NrJmfoUVicajoRAL1bpwdYIYRcs57iyIV9NLcuyDyDXE2GiZCL4uhKSYAnyWpjZkWow==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.21.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.21.1.tgz", - "integrity": "sha512-/6dYC9fZtfEY0vozpc5bx1RP4VrtEOhNQGb0HwvYNwXD1BBbwQ5cKIbUVVU7G2d5WRE90NfB922elN8ASXAJEA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { - "version": "4.21.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.21.1.tgz", - "integrity": "sha512-ltUWy+sHeAh3YZ91NUsV4Xg3uBXAlscQe8ZOXRCVAKLsivGuJsrkawYPUEyCV3DYa9urgJugMLn8Z3Z/6CeyRQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.21.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.21.1.tgz", - "integrity": "sha512-BggMndzI7Tlv4/abrgLwa/dxNEMn2gC61DCLrTzw8LkpSKel4o+O+gtjbnkevZ18SKkeN3ihRGPuBxjaetWzWg==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.21.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.21.1.tgz", - "integrity": "sha512-z/9rtlGd/OMv+gb1mNSjElasMf9yXusAxnRDrBaYB+eS1shFm6/4/xDH1SAISO5729fFKUkJ88TkGPRUh8WSAA==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, "node_modules/@rollup/rollup-linux-x64-gnu": { "version": "4.21.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.21.1.tgz", @@ -750,48 +610,6 @@ "linux" ] }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.21.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.21.1.tgz", - "integrity": "sha512-CbFv/WMQsSdl+bpX6rVbzR4kAjSSBuDgCqb1l4J68UYsQNalz5wOqLGYj4ZI0thGpyX5kc+LLZ9CL+kpqDovZA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.21.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.21.1.tgz", - "integrity": "sha512-3Q3brDgA86gHXWHklrwdREKIrIbxC0ZgU8lwpj0eEKGBQH+31uPqr0P2v11pn0tSIxHvcdOWxa4j+YvLNx1i6g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.21.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.21.1.tgz", - "integrity": "sha512-tNg+jJcKR3Uwe4L0/wY3Ro0H+u3nrb04+tcq1GSYzBEmKLeOQF2emk1whxlzNqb6MMrQ2JOcQEpuuiPLyRcSIw==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, "node_modules/@rollup/rollup-win32-x64-msvc": { "version": "4.21.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.21.1.tgz", @@ -986,6 +804,18 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/node": { + "version": "22.5.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.5.0.tgz", + "integrity": "sha512-DkFrJOe+rfdHTqqMg0bSNlGlQ85hSoh2TPzZyhHsXnMtligRWpxUySiyw8FY14ITt24HVCiQPWxS3KO/QlGmWg==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "undici-types": "~6.19.2" + } + }, "node_modules/@types/prop-types": { "version": "15.7.12", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.12.tgz", @@ -2144,7 +1974,6 @@ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "dev": true, "hasInstallScript": true, - "license": "MIT", "optional": true, "os": [ "darwin" @@ -2882,11 +2711,10 @@ } }, "node_modules/picocolors": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz", - "integrity": "sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==", - "dev": true, - "license": "ISC" + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.0.tgz", + "integrity": "sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==", + "dev": true }, "node_modules/picomatch": { "version": "2.3.1", @@ -2922,9 +2750,9 @@ } }, "node_modules/postcss": { - "version": "8.4.41", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.41.tgz", - "integrity": "sha512-TesUflQ0WKZqAvg52PWL6kHgLKP6xB6heTOdoYM0Wt2UHyxNa4K25EZZMgKns3BH1RLVbZCREPpLY0rhnNoHVQ==", + "version": "8.4.47", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.47.tgz", + "integrity": "sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==", "dev": true, "funding": [ { @@ -2940,11 +2768,10 @@ "url": "https://github.com/sponsors/ai" } ], - "license": "MIT", "dependencies": { "nanoid": "^3.3.7", - "picocolors": "^1.0.1", - "source-map-js": "^1.2.0" + "picocolors": "^1.1.0", + "source-map-js": "^1.2.1" }, "engines": { "node": "^10 || ^12 || >=14" @@ -3447,11 +3274,10 @@ } }, "node_modules/source-map-js": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz", - "integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "dev": true, - "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" } @@ -3801,6 +3627,15 @@ "node": ">=14.17" } }, + "node_modules/undici-types": { + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/update-browserslist-db": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.0.tgz", @@ -3859,14 +3694,13 @@ "license": "MIT" }, "node_modules/vite": { - "version": "5.4.2", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.2.tgz", - "integrity": "sha512-dDrQTRHp5C1fTFzcSaMxjk6vdpKvT+2/mIdE07Gw2ykehT49O0z/VHS3zZ8iV/Gh8BJJKHWOe5RjaNrW5xf/GA==", + "version": "5.4.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.6.tgz", + "integrity": "sha512-IeL5f8OO5nylsgzd9tq4qD2QqI0k2CQLGrWD0rCN0EQJZpBK5vJAx0I+GDkMOXxQX/OfFHMuLIx6ddAxGX/k+Q==", "dev": true, - "license": "MIT", "dependencies": { "esbuild": "^0.21.3", - "postcss": "^8.4.41", + "postcss": "^8.4.43", "rollup": "^4.20.0" }, "bin": { diff --git a/package.json b/package.json index aa29442..bf47205 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,7 @@ "prettier-plugin-tailwindcss": "^0.6.6", "tailwindcss": "^3.4.10", "typescript": "^5.5.4", - "vite": "^5.4.2" + "vite": "^5.4.6" }, "optionalDependencies": { "@rollup/rollup-darwin-arm64": "^4.21.1", diff --git a/public/vite.svg b/public/vite.svg deleted file mode 100644 index e7b8dfb..0000000 --- a/public/vite.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx deleted file mode 100644 index 8710cd0..0000000 --- a/src/App.tsx +++ /dev/null @@ -1,9 +0,0 @@ -function App() { - return ( -
-

Hello, World!

-
- ); -} - -export default App; diff --git a/src/CustomNavigationClient.tsx b/src/CustomNavigationClient.tsx new file mode 100644 index 0000000..0ed73e6 --- /dev/null +++ b/src/CustomNavigationClient.tsx @@ -0,0 +1,25 @@ +import { NavigationClient, NavigationOptions } from '@azure/msal-browser'; +import { AnyRouter } from '@tanstack/react-router'; + +export class CustomNavigationClient extends NavigationClient { + private readonly router: AnyRouter; + + constructor(router: AnyRouter) { + super(); + this.router = router; + } + + async navigateInternal(url: string, options: NavigationOptions): Promise { + const relativePath = url.replace(window.location.origin, ''); + + console.debug('CustomNavigationClient navigating to: ', relativePath, options); + + if (options.noHistory) { + await this.router.navigate({ replace: true, to: relativePath }); + } else { + await this.router.navigate({ to: relativePath }); + } + + return false; + } +} diff --git a/src/api/useGraphUserDetails.tsx b/src/api/useGraphUserDetails.tsx new file mode 100644 index 0000000..bfd4804 --- /dev/null +++ b/src/api/useGraphUserDetails.tsx @@ -0,0 +1,34 @@ +import { useIsAuthenticated } from '@azure/msal-react'; +import { User } from '@microsoft/microsoft-graph-types'; +import { useEffect, useState } from 'react'; +import { useAccessToken } from '../infrastructure/auth/useAccessToken'; + +export function useGraphUserDetails() { + const isAuthenticated = useIsAuthenticated(); + const accessToken = useAccessToken(); + const [user, setUser] = useState(); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + if (!isAuthenticated || !accessToken) { + return; + } + + const request = new Request('https://graph.microsoft.com/v1.0/me', { + method: 'GET', + headers: new Headers({ + Authorization: 'Bearer ' + accessToken, + }), + }); + + fetch(request) + .then(async (response) => { + const user = (await response.json()) as User; + setUser(user); + }) + .catch((error) => console.error(error)) + .finally(() => setIsLoading(false)); + }, [accessToken, isAuthenticated]); + + return { user, isLoading }; +} diff --git a/src/api/useGraphUserPhoto.tsx b/src/api/useGraphUserPhoto.tsx new file mode 100644 index 0000000..dabd3a9 --- /dev/null +++ b/src/api/useGraphUserPhoto.tsx @@ -0,0 +1,35 @@ +import { useEffect, useState } from 'react'; +import { useAccessToken } from '../infrastructure/auth/useAccessToken'; + +export function useGraphUserPhoto() { + const accessToken = useAccessToken(); + const [photoBlobUrl, setPhotoBlobUrl] = useState(undefined); + + useEffect(() => { + if (!accessToken) { + return; + } + + const request = new Request('https://graph.microsoft.com/v1.0/me/photos/96x96/$value', { + method: 'GET', + headers: new Headers({ + Authorization: 'Bearer ' + accessToken, + 'Content-Type': 'image/jpg', + }), + }); + + fetch(request) + .then(async (response) => { + if (response.ok && response.status !== 404) { + const blob = await response.blob(); + const url = window.URL || window.webkitURL; + const blobUrl = url.createObjectURL(blob); + + setPhotoBlobUrl(blobUrl); + } + }) + .catch((error) => console.error(error)); + }, [accessToken]); + + return photoBlobUrl; +} diff --git a/src/assets/react.svg b/src/assets/react.svg deleted file mode 100644 index 6c87de9..0000000 --- a/src/assets/react.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/components/Button.tsx b/src/components/Button.tsx new file mode 100644 index 0000000..305f715 --- /dev/null +++ b/src/components/Button.tsx @@ -0,0 +1,21 @@ +import React from 'react'; + +export function Button({ + children, + onClick, + disabled, +}: { + readonly children: React.ReactNode; + readonly onClick: () => void; + readonly disabled?: boolean; +}) { + return ( + + ); +} diff --git a/src/components/CodeBox.tsx b/src/components/CodeBox.tsx new file mode 100644 index 0000000..fe680ab --- /dev/null +++ b/src/components/CodeBox.tsx @@ -0,0 +1,24 @@ +import { CopyButton } from './CopyButton'; + +export function CodeBox({ + code, + copyValue, + copyLabel, +}: { + readonly code: string; + readonly copyValue?: string | null; + readonly copyLabel?: string | null; +}) { + const copyText = copyValue ?? code; + + return ( +
+ {copyText && ( +
+ +
+ )} +
{code}
+
+ ); +} diff --git a/src/components/CopyButton.tsx b/src/components/CopyButton.tsx new file mode 100644 index 0000000..99b170c --- /dev/null +++ b/src/components/CopyButton.tsx @@ -0,0 +1,16 @@ +import ClipboardIcon from '@heroicons/react/24/outline/ClipboardIcon'; + +export function CopyButton({ value, label }: { readonly value: string; readonly label?: string }) { + return ( + + ); +} diff --git a/src/components/LoginButton.tsx b/src/components/LoginButton.tsx new file mode 100644 index 0000000..8a26fd5 --- /dev/null +++ b/src/components/LoginButton.tsx @@ -0,0 +1,64 @@ +import { + BrowserAuthError, + InteractionRequiredAuthError, + InteractionStatus, + RedirectRequest, + SsoSilentRequest, +} from '@azure/msal-browser'; +import { useMsal } from '@azure/msal-react'; +import { UserIcon } from '@heroicons/react/24/outline'; +import { loginRequest } from '../infrastructure/auth/authConfig'; +import { Button } from './Button'; + +export const LoginButton = () => { + const { instance, inProgress } = useMsal(); + const accounts = instance.getAllAccounts(); + const account = accounts ? accounts[0] : null; + const currentMsalOperationInProgress = inProgress; + + return ( + + ); +}; diff --git a/src/components/LogoutButton.tsx b/src/components/LogoutButton.tsx new file mode 100644 index 0000000..a7cbc87 --- /dev/null +++ b/src/components/LogoutButton.tsx @@ -0,0 +1,31 @@ +import { EndSessionRequest, InteractionStatus } from '@azure/msal-browser'; +import { useMsal } from '@azure/msal-react'; +import { loginRequest } from '../infrastructure/auth/authConfig'; +import { Button } from './Button'; + +export const LogoutButton = () => { + const { instance, inProgress } = useMsal(); + const accounts = instance.getAllAccounts(); + const account = accounts ? accounts[0] : null; + const currentMsalOperationInProgress = inProgress; + + return ( + + ); +}; diff --git a/src/components/Spinner.tsx b/src/components/Spinner.tsx new file mode 100644 index 0000000..d79b332 --- /dev/null +++ b/src/components/Spinner.tsx @@ -0,0 +1,7 @@ +export function Spinner() { + return ( +
+
+
+ ); +} diff --git a/src/components/ToggleThemeButton.tsx b/src/components/ToggleThemeButton.tsx new file mode 100644 index 0000000..f63428a --- /dev/null +++ b/src/components/ToggleThemeButton.tsx @@ -0,0 +1,38 @@ +import { MoonIcon, SunIcon } from '@heroicons/react/24/outline'; +import React, { useEffect } from 'react'; + +type ThemeMode = 'dark' | 'light'; + +export function ToggleThemeButton({ disabled }: { readonly disabled?: boolean }) { + const getInitialTheme = () => (localStorage.getItem('themeMode') ?? 'dark') as ThemeMode; + + const applyTheme = (theme: ThemeMode) => { + const htmlElement = document.querySelector('html'); + if (htmlElement) { + htmlElement.classList.toggle('dark', theme === 'dark'); + } + }; + + const [themeMode, setThemeMode] = React.useState(getInitialTheme()); + + const toggleThemeHandler = () => { + const newThemeMode: ThemeMode = themeMode === 'dark' ? 'light' : 'dark'; + localStorage.setItem('themeMode', newThemeMode); + setThemeMode(newThemeMode); + applyTheme(newThemeMode); + }; + + useEffect(() => { + applyTheme(themeMode); + }, [themeMode]); + + return ( + + ); +} diff --git a/src/components/UserMenu.tsx b/src/components/UserMenu.tsx new file mode 100644 index 0000000..cfc7aaa --- /dev/null +++ b/src/components/UserMenu.tsx @@ -0,0 +1,50 @@ +import { useAccount } from '@azure/msal-react'; +import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/react'; + +import { UserCircleIcon } from '@heroicons/react/24/outline'; +import React, { createRef, useEffect } from 'react'; +import { useGraphUserPhoto } from '../api/useGraphUserPhoto'; +import { LogoutButton } from './LogoutButton'; + +export const UserMenu = () => { + const account = useAccount(); + const userName = account?.idTokenClaims?.name ?? 'USER'; + const photoBlobUrl = useGraphUserPhoto(); + const avatarDiv = createRef(); + + useEffect(() => { + if (avatarDiv.current) { + avatarDiv.current.style.background = `url(${photoBlobUrl}) no-repeat center center / cover`; + } + }, [avatarDiv, photoBlobUrl]); + + const LogoutMenuItem = React.forwardRef(() => ); + + return ( + + + {photoBlobUrl ? ( + <> +
+ {userName} + + ) : ( + <> + + {userName} + + )} + + + + + + +
+ ); +}; diff --git a/src/features/Claims.tsx b/src/features/Claims.tsx new file mode 100644 index 0000000..0e4b5e4 --- /dev/null +++ b/src/features/Claims.tsx @@ -0,0 +1,60 @@ +import { AuthenticatedTemplate, UnauthenticatedTemplate, useAccount } from '@azure/msal-react'; +import { CodeBox } from '../components/CodeBox'; +import { useAccessToken } from '../infrastructure/auth/useAccessToken'; +import { decodeToken } from '../infrastructure/auth/utils'; +import { Page } from './Page'; + +function Claims() { + const account = useAccount(); + const accessToken = useAccessToken(); + + const decodedIdToken = decodeToken(account?.idToken); + const decodedAccessToken = decodeToken(accessToken); + + return ( + Claims} + content={ + <> +

+ Here are the claims present in the Id and Access tokens. They provided here for + information and debugging purposes. +

+ +
✋ You must be logged in to see this content.
+
+ + <> +

Id Token Claims

+

+ An ID token is used to authenticate a user. It provides information about the user, + such as their identity and profile information. +

+ {decodedIdToken && ( + + )} +

Access Token Claims

+

+ An access token is used to authorise access to protected resources or APIs. It tells + the resource server what the client application is allowed to do. +

+ {decodedAccessToken && ( + + )} + +
+ + } + /> + ); +} + +export default Claims; diff --git a/src/features/MsGraph.tsx b/src/features/MsGraph.tsx new file mode 100644 index 0000000..bd1aaae --- /dev/null +++ b/src/features/MsGraph.tsx @@ -0,0 +1,54 @@ +import { AuthenticatedTemplate, UnauthenticatedTemplate } from '@azure/msal-react'; +import { useGraphUserDetails } from '../api/useGraphUserDetails'; +import { CodeBox } from '../components/CodeBox'; +import { Spinner } from '../components/Spinner'; +import { Page } from './Page'; + +function MsGraph() { + const { user, isLoading } = useGraphUserDetails(); + + return ( + Call MS Graph} + content={ + <> +

+ Once you have an access token you can use that token to call an API. This is a simple + example of calling the{' '} + + Microsoft Graph API + {' '} + to retrieve some profile data about the user including their photo if there is one. +

+ +
✋ You must be logged in to see this content.
+
+ +

+ Note:{' '} + + The amount of fields populated below will depend on what has been populated in Entra + Id + + . +

+ {isLoading ? ( + + ) : user ? ( + <> + + + ) : ( +
User details could not be retrieved 🫤
+ )} +
+ + } + /> + ); +} + +export default MsGraph; diff --git a/src/features/Page.tsx b/src/features/Page.tsx new file mode 100644 index 0000000..e0d08e2 --- /dev/null +++ b/src/features/Page.tsx @@ -0,0 +1,14 @@ +import { ReactNode } from 'react'; + +export const Page = ({ header, content }: { header: ReactNode; content: ReactNode }) => { + return ( + <> +
+ {header} +
+
+ {content} +
+ + ); +}; diff --git a/src/features/Shell.tsx b/src/features/Shell.tsx new file mode 100644 index 0000000..f6737fc --- /dev/null +++ b/src/features/Shell.tsx @@ -0,0 +1,65 @@ +import { InteractionStatus } from '@azure/msal-browser'; +import { useIsAuthenticated, useMsal } from '@azure/msal-react'; +import { ArrowsUpDownIcon, HomeIcon, UserIcon } from '@heroicons/react/24/outline'; +import { Link, Outlet } from '@tanstack/react-router'; +import { useMemo } from 'react'; +import { LoginButton } from '../components/LoginButton'; +import { Spinner } from '../components/Spinner'; +import { ToggleThemeButton } from '../components/ToggleThemeButton'; +import { UserMenu } from '../components/UserMenu'; + +export function Shell() { + const isAuthenticated = useIsAuthenticated(); + const msal = useMsal(); + const currentMsalOperationInProgress = msal.inProgress; + + return useMemo( + () => ( + <> +
+ +
+
+ +
+ {currentMsalOperationInProgress !== InteractionStatus.None && } + + + + ), + [currentMsalOperationInProgress, isAuthenticated], + ); +} diff --git a/src/features/Welcome.tsx b/src/features/Welcome.tsx new file mode 100644 index 0000000..ecc396a --- /dev/null +++ b/src/features/Welcome.tsx @@ -0,0 +1,128 @@ +import { AuthenticatedTemplate, UnauthenticatedTemplate, useAccount } from '@azure/msal-react'; +import { CodeBracketIcon, Cog6ToothIcon } from '@heroicons/react/24/outline'; +import { Page } from './Page'; + +function Welcome() { + const account = useAccount(); + const userFirstName = account?.idTokenClaims?.name?.split(' ')[0]; + + return ( + + +

+ Welcome 👋 +

+
+ +

+ Hey, {userFirstName} + 👋 +

+
+ + } + content={ + <> +

+ Welcome to this demo site, where you'll see how to integrate a React/TypeScript + application with the Microsoft Authentication Library (MSAL) to authenticate users via + Microsoft Entra ID. In modern web development, Single Page Applications (SPAs) often + need a robust and seamless authentication mechanism. When working with Microsoft Entra + ID, there are two main strategies to consider. +

+

+ One approach is to delegate the authentication to the frontend using MSAL. Another way + would be to delegate the responsibility to your backend API (e.g. ASP.NET). There are + pros and cons to each approach and which one you choose will depend on your specific + requirements. +

+

+ This demo site is solely focused on exploring the first approach i.e. how to + authenticate via the frontend using MSAL. Here are a few of the things you might + consider when choosing one way or the other. +

+
+
+
+ +

SPA authentication (MSAL)

+
+
    +
  • + Seamless User Experience: You need a smooth user experience with fewer + redirects, making the authentication process feel more integrated and less + disruptive. Once the user is authenticated, most of the token handling (including + refreshing tokens) happens in the background. +
  • +
  • + Simplified Backend: Your application benefits from a stateless backend, + reducing the complexity of server-side session management. +
  • +
  • + Direct Token Management: You prefer the frontend to directly handle tokens, + managing authentication flows and token refresh within the SPA. +
  • +
+
+
+
+ +

API authentication

+
+
    +
  • + Centralised Security Control: You prefer centralised control over security + and session management, keeping sensitive authentication logic and token handling + on the server side. +
  • +
  • + Simplified Frontend: You aim to simplify frontend development by offloading + authentication complexity to the backend, allowing the frontend to focus solely on + user interface and user experience. +
  • +
  • + Token Security: Storing and managing tokens on the server side can reduce + the risk of token theft or misuse in the client environment, providing an + additional layer of security. +
  • +
+
+
+

What Now?

+ + + } + /> + ); +} + +export default Welcome; diff --git a/src/index.css b/src/index.css index b5c61c9..ec931a1 100644 --- a/src/index.css +++ b/src/index.css @@ -1,3 +1,93 @@ @tailwind base; @tailwind components; @tailwind utilities; + +:root { + font-family: 'Open Sans Variable', Inter, system-ui, Helvetica, Arial, sans-serif; + color-scheme: light dark; +} + +html { + padding-right: 0px !important; +} + +@layer base { + body { + @apply always-show-scrollbar custom-scrollbar flex w-full justify-center overflow-x-hidden bg-stone-600 px-4 text-lg text-blue-900 dark:bg-zinc-900 dark:text-zinc-100; + + #root { + @apply min-w-[320px] max-w-[960px]; + } + } + + a { + @apply text-blue-800 underline visited:text-blue-800 hover:text-blue-950 visited:hover:text-blue-950 dark:text-stone-200 dark:visited:text-gray-200 dark:hover:text-gray-300 dark:visited:hover:text-gray-300; + } + + nav, + footer { + a { + @apply !text-stone-200 active:!text-stone-300; + } + } + + h1 { + @apply text-4xl; + @apply font-thin; + } + + h2 { + @apply text-2xl; + @apply font-thin; + @apply my-6; + @apply leading-tight; + } + + p { + @apply mb-4; + } + + @keyframes spin { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } + } + .spinner { + border: 6px solid white; + border-top: 6px solid black; + border-radius: 50%; + width: 100%; + aspect-ratio: 1; + animation: spin 1s ease-in-out infinite; + } +} + +@layer components { + .always-show-scrollbar { + overflow-y: scroll; + scrollbar-gutter: stable; + } + + .custom-scrollbar::-webkit-scrollbar { + width: 8px; + height: 8px; + } + + .custom-scrollbar::-webkit-scrollbar-track { + background: transparent; + } + + .custom-scrollbar::-webkit-scrollbar-thumb { + background: #ccc; + border-radius: 4px; + border: 2px solid transparent; + background-clip: content-box; + } + + .custom-scrollbar::-webkit-scrollbar-thumb:hover { + background: gray; + } +} diff --git a/src/infrastructure/auth/AccessTokenProvider.tsx b/src/infrastructure/auth/AccessTokenProvider.tsx new file mode 100644 index 0000000..70138f6 --- /dev/null +++ b/src/infrastructure/auth/AccessTokenProvider.tsx @@ -0,0 +1,71 @@ +import { + BrowserAuthError, + InteractionRequiredAuthError, + InteractionStatus, + RedirectRequest, +} from '@azure/msal-browser'; +import { useMsal } from '@azure/msal-react'; +import { ReactNode, createContext, useEffect, useState } from 'react'; +import { loginRequest } from './authConfig'; + +let pageLoad = true; + +export const AccessTokenContext = createContext(null); + +export const AccessTokenProvider = ({ children }: { children: ReactNode }) => { + const { instance, inProgress } = useMsal(); + const [accessToken, setAccessToken] = useState(null); + const accounts = instance.getAllAccounts(); + const account = accounts ? accounts[0] : null; + const currentMsalOperationInProgress = inProgress; + + useEffect(() => { + if (account && currentMsalOperationInProgress === InteractionStatus.None) { + instance + // First, we'll attempt to silently acquire the token by checking the cache to see if a non-expired access token exists that we can use or refresh. + .acquireTokenSilent({ + ...loginRequest, + account, + // https://github.com/AzureAD/microsoft-authentication-library-for-js/blob/dev/lib/msal-browser/docs/token-lifetimes.md#avoiding-interactive-interruptions-in-the-middle-of-a-users-session + forceRefresh: pageLoad ? true : undefined, + }) + .then((response) => { + pageLoad = false; + console.debug('acquireTokenSilent response: ', response); + setAccessToken(response.accessToken); + }) + .catch(async (silentError) => { + console.error( + 'acquireTokenSilent silentError inProgress', + silentError, + currentMsalOperationInProgress, + ); + if ( + silentError instanceof InteractionRequiredAuthError || + silentError instanceof BrowserAuthError + ) { + // Fallback to alternate method when silent call fails. + try { + // Favouring redirect over popup since popup is blocked by default in most browsers. YMMV. + await instance.acquireTokenRedirect({ + ...loginRequest, + account, + loginHint: account?.username, + //prompt: account?.username ? "login" : "select_account", + } as RedirectRequest); + } catch (redirectError) { + // TODO handle this error + console.error('acquireTokenRedirect error', redirectError); + throw redirectError; + } + } else { + // TODO handle this error + console.error('acquireTokenSilent error', silentError); + throw silentError; + } + }); + } + }, [account, instance, currentMsalOperationInProgress]); + + return {children}; +}; diff --git a/src/infrastructure/auth/authConfig.ts b/src/infrastructure/auth/authConfig.ts new file mode 100644 index 0000000..cd99b20 --- /dev/null +++ b/src/infrastructure/auth/authConfig.ts @@ -0,0 +1,55 @@ +import { BrowserCacheLocation, Configuration, LogLevel } from '@azure/msal-browser'; + +/* + Config object to be passed to Msal on creation. + https://github.com/AzureAD/microsoft-authentication-library-for-js/blob/dev/lib/msal-browser/docs/configuration.md + */ +export const msalConfig: Configuration = { + auth: { + clientId: import.meta.env.VITE_MSAL_CLIENT_ID as string, // Set this value in the .env file. + authority: 'https://login.microsoftonline.com/common/oauth2/v2.0', + redirectUri: '/', // You must register this URI on Azure Portal/App Registration. + navigateToLoginRequestUrl: true, + protocolMode: 'AAD', // "AAD" for Entra + }, + cache: { + /* + Use LocalStorage so that token survives closing of the window/tab. + https://github.com/AzureAD/microsoft-authentication-library-for-js/blob/dev/lib/msal-browser/docs/caching.md + */ + cacheLocation: BrowserCacheLocation.LocalStorage, + }, + system: { + loggerOptions: { + logLevel: LogLevel.Warning, + piiLoggingEnabled: false, + 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; + } + }, + }, + }, +}; + +export const loginRequest = { + /* + For demo purpose, we're calling the Microsoft Graph API so we use it's default scope. + This will be different depending on the API you want to call. + */ + scopes: ['https://graph.microsoft.com/.default'], +}; diff --git a/src/infrastructure/auth/useAccessToken.tsx b/src/infrastructure/auth/useAccessToken.tsx new file mode 100644 index 0000000..be33f1d --- /dev/null +++ b/src/infrastructure/auth/useAccessToken.tsx @@ -0,0 +1,4 @@ +import { useContext } from 'react'; +import { AccessTokenContext } from './AccessTokenProvider'; + +export const useAccessToken = () => useContext(AccessTokenContext); diff --git a/src/infrastructure/auth/utils.tsx b/src/infrastructure/auth/utils.tsx new file mode 100644 index 0000000..822abde --- /dev/null +++ b/src/infrastructure/auth/utils.tsx @@ -0,0 +1,28 @@ +import { InvalidTokenError, jwtDecode } from 'jwt-decode'; + +const tokenDateToLocaleString = (tokenDate: number) => { + return new Date(tokenDate * 1000).toLocaleString(Intl.Locale.name, { + timeZoneName: 'short', + }); +}; + +export function decodeToken(token?: string | null): Record | null { + try { + if (!token) { + return null; + } + const decodedToken = jwtDecode(token) as Record; + const unixTimestampKeys = ['exp', 'iat', 'nbf', 'xms_tcdt']; + unixTimestampKeys.forEach((key) => { + if (decodedToken[key]) { + decodedToken[key] = tokenDateToLocaleString(decodedToken[key] as number); + } + }); + + return decodedToken; + } catch (error: unknown) { + console.error('Error decoding token', error); + const tokenError = error as InvalidTokenError; + return { error: tokenError.message }; + } +} diff --git a/src/main.tsx b/src/main.tsx index 966f17a..e379b05 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,10 +1,56 @@ -import React from "react"; -import ReactDOM from "react-dom/client"; -import App from "./App.tsx"; -import "./index.css"; - -ReactDOM.createRoot(document.getElementById("root")!).render( - - - -); +import { createStandardPublicClientApplication } from '@azure/msal-browser'; +import { MsalProvider } from '@azure/msal-react'; +import '@fontsource-variable/open-sans'; +import { RouterProvider, createRouter } from '@tanstack/react-router'; +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import { CustomNavigationClient } from './CustomNavigationClient.tsx'; +import './index.css'; +import { AccessTokenProvider } from './infrastructure/auth/AccessTokenProvider.tsx'; +import { msalConfig } from './infrastructure/auth/authConfig.ts'; +import { getRoutes } from './routes.tsx'; + +/* + createStandardPublicClientApplication returns an already initialized instance of PublicClientApplication. + https://github.com/AzureAD/microsoft-authentication-library-for-js/blob/dev/lib/msal-browser/docs/initialization.md +*/ +await createStandardPublicClientApplication(msalConfig) + .then((pca) => { + const router = createRouter({ + routeTree: getRoutes(), + }); + + /* + The CustomNavigationClient is a custom implementation of INavigationClient that uses our router to navigate within the app. + This is necessary because the default navigation client provided by @azure/msal-browser + uses window.location to navigate the app, which is not compatible with a single page application. + */ + const navigationClient = new CustomNavigationClient(router); + // Must set the navigation client before calling handleRedirectPromise + pca.setNavigationClient(navigationClient); + + pca.handleRedirectPromise().then((authResult) => { + if (authResult?.account) { + pca.setActiveAccount(authResult.account); + } + }); + + ReactDOM.createRoot(document.getElementById('root')!).render( + + + + + + + , + ); + }) + .catch((unknownError: unknown) => { + console.error('Error intialising application', unknownError); + ReactDOM.createRoot(document.getElementById('root')!).render( + <> +

Oops, Something went wrong 😭

+

The app could not be initialised.

+ , + ); + }); diff --git a/src/routes.tsx b/src/routes.tsx new file mode 100644 index 0000000..9bea806 --- /dev/null +++ b/src/routes.tsx @@ -0,0 +1,33 @@ +import { createRootRoute, createRoute } from '@tanstack/react-router'; +import Claims from './features/Claims.tsx'; +import MsGraph from './features/MsGraph.tsx'; +import { Shell } from './features/Shell.tsx'; +import Index from './features/Welcome.tsx'; + +const rootRoute = createRootRoute({ + component: () => { + return ; + }, +}); + +export function getRoutes() { + const routes = [ + createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => , + }), + createRoute({ + getParentRoute: () => rootRoute, + path: '/claims', + component: () => , + }), + createRoute({ + getParentRoute: () => rootRoute, + path: '/msgraph', + component: () => , + }), + ]; + + return rootRoute.addChildren(routes); +} diff --git a/tailwind.config.js b/tailwind.config.js index 614c86b..e5d946f 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -1,6 +1,7 @@ /** @type {import('tailwindcss').Config} */ export default { - content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"], + content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'], + darkMode: 'selector', theme: { extend: {}, }, diff --git a/vite.config.ts b/vite.config.ts index 861b04b..e1b1b76 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,7 +1,7 @@ -import { defineConfig } from 'vite' -import react from '@vitejs/plugin-react-swc' +import react from '@vitejs/plugin-react-swc'; +import { defineConfig } from 'vite'; // https://vitejs.dev/config/ export default defineConfig({ plugins: [react()], -}) +});