Created
May 5, 2026 18:24
-
-
Save andrewjroberts/4b9ecec6f0754da588a5a11d88554594 to your computer and use it in GitHub Desktop.
MSAL + React Router Route Modules Auth Architecture
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # MSAL + React Router Route Modules Auth Architecture | |
| ## 1. Decision | |
| Use **`@azure/msal-browser` as the actual auth engine** and wrap it in a small app-owned auth facade/store. | |
| Do **not** make `@azure/msal-react` the center of the architecture. It is useful for component-only convenience APIs, but your app needs both React hooks and point-in-time `.getState()` semantics from `clientLoader`s, which are outside React component context. MSAL React is built on React context, requires `MsalProvider`, and exposes hooks/templates to components under that provider. ([Microsoft Learn][1]) | |
| Recommended shape: | |
| ```txt | |
| @azure/msal-browser | |
| ↓ | |
| app auth facade/store | |
| ↓ | |
| React hooks + clientLoaders + services + authorizedFetch | |
| ``` | |
| Optional later: | |
| ```txt | |
| @azure/msal-react | |
| ``` | |
| Only add it if you specifically want `MsalProvider`, `useMsal`, `useIsAuthenticated`, `AuthenticatedTemplate`, or `MsalAuthenticationTemplate` in components. MSAL React is a wrapper around MSAL Browser, and Microsoft’s migration docs show `PublicClientApplication` from `@azure/msal-browser` being passed into `MsalProvider`. ([Microsoft Learn][2]) | |
| --- | |
| ## 2. Why this architecture fits React Router route modules | |
| React Router route modules own framework features like data loading, actions, revalidation, and error boundaries. ([React Router][3]) A `clientLoader` runs outside React component render, so it cannot directly use React context hooks like `useMsal()` or `useIsAuthenticated()`. | |
| Your auth layer therefore needs two access modes: | |
| ```txt | |
| Reactive component access: | |
| useAuth() | |
| useIsAuthenticated() | |
| Point-in-time non-React access: | |
| auth.getState() | |
| auth.getAccessToken() | |
| auth.start() | |
| ``` | |
| This matches your current auth-store pattern. | |
| --- | |
| ## 3. Library responsibility split | |
| ### `@azure/msal-browser` | |
| This is the real SPA auth library. | |
| Use it for: | |
| ```ts | |
| new PublicClientApplication(config) | |
| await msal.initialize() | |
| await msal.handleRedirectPromise() | |
| msal.loginRedirect(...) | |
| msal.loginPopup(...) | |
| msal.acquireTokenSilent(...) | |
| msal.acquireTokenRedirect(...) | |
| msal.acquireTokenPopup(...) | |
| msal.logoutRedirect(...) | |
| msal.logoutPopup(...) | |
| msal.getAllAccounts() | |
| msal.getActiveAccount() | |
| msal.setActiveAccount(...) | |
| msal.addEventCallback(...) | |
| msal.removeEventCallback(...) | |
| ``` | |
| MSAL Browser requires initialization before calling APIs like login, token acquisition, or redirect handling. Microsoft documents that `initialize()` is async and must resolve before other MSAL APIs are invoked. ([Microsoft Learn][4]) | |
| ### `@azure/msal-react` | |
| This is a React adapter around the browser instance. | |
| Use it only if you want: | |
| ```tsx | |
| <MsalProvider instance={pca}> | |
| useMsal() | |
| useIsAuthenticated() | |
| useAccount() | |
| useMsalAuthentication() | |
| <AuthenticatedTemplate /> | |
| <UnauthenticatedTemplate /> | |
| <MsalAuthenticationTemplate /> | |
| ``` | |
| But for this app, it should not be the source of truth because loaders/services need auth access outside React context. | |
| --- | |
| ## 4. Recommended facade API | |
| Create one app-owned auth module. | |
| ```ts | |
| export const auth = { | |
| start, | |
| getState, | |
| subscribe, | |
| login, | |
| logout, | |
| getAccessToken, | |
| setActiveAccount, | |
| }; | |
| ``` | |
| State shape: | |
| ```ts | |
| export type AuthSnapshot = { | |
| initialized: boolean; | |
| inProgress: InteractionStatus; | |
| accounts: AccountInfo[]; | |
| activeAccount: AccountInfo | null; | |
| isAuthenticated: boolean; | |
| lastAuthEventAt: number | null; | |
| lastUserActivityAt: number | null; | |
| idle: boolean; | |
| interactionRequired: boolean; | |
| error: Error | null; | |
| }; | |
| ``` | |
| React hooks should be derived from this same store: | |
| ```ts | |
| export function useAuth(): AuthSnapshot { | |
| return useSyncExternalStore(auth.subscribe, auth.getState, auth.getState); | |
| } | |
| export function useIsAuthenticated(): boolean { | |
| return useAuth().isAuthenticated; | |
| } | |
| ``` | |
| --- | |
| ## 5. What `auth.start()` means | |
| `auth.start()` is **not an MSAL API**. It is your wrapper’s idempotent bootstrap method. | |
| It should mean: | |
| ```txt | |
| Make the auth system safe to query. | |
| ``` | |
| Specifically: | |
| ```txt | |
| initialize MSAL once | |
| register MSAL event callbacks once | |
| optionally process redirect response | |
| restore/select active account from cache | |
| publish the initial auth snapshot | |
| ``` | |
| Implementation pattern: | |
| ```ts | |
| let startPromise: Promise<void> | undefined; | |
| export function start() { | |
| if (!startPromise) { | |
| startPromise = startInternal(); | |
| } | |
| return startPromise; | |
| } | |
| ``` | |
| This makes it safe to call from every protected `clientLoader`. Multiple loaders may call `auth.start()`, but only one real startup sequence runs. | |
| ```ts | |
| export async function clientLoader({ request }: Route.ClientLoaderArgs) { | |
| await requireAuth(request); | |
| return mosaicService().getClientSummary("123"); | |
| } | |
| ``` | |
| --- | |
| ## 6. Redirect processing | |
| When using redirect login, the flow is: | |
| ```txt | |
| 1. User calls loginRedirect() | |
| 2. Browser leaves the app | |
| 3. Microsoft login happens | |
| 4. Browser returns to your configured redirectUri | |
| 5. MSAL must process the redirect response | |
| 6. MSAL caches account/token artifacts | |
| 7. App restores active account and continues | |
| ``` | |
| The processing step is: | |
| ```ts | |
| const result = await msal.handleRedirectPromise(); | |
| ``` | |
| `handleRedirectPromise()` returns an authentication result when a redirect response is detected; otherwise it returns `null`. ([azuread.github.io][5]) Microsoft also documents that redirect APIs require `handleRedirectPromise()` to run after returning from redirect so the token response is handled and temporary cache entries are cleaned up. ([Microsoft Learn][6]) | |
| There are two acceptable patterns. | |
| ### Pattern A: global/per-loader bootstrap | |
| `auth.start()` calls `handleRedirectPromise()`. | |
| ```ts | |
| async function startInternal() { | |
| await msal.initialize(); | |
| const result = await msal.handleRedirectPromise(); | |
| if (result?.account) { | |
| msal.setActiveAccount(result.account); | |
| } else { | |
| restoreActiveAccountFromCache(); | |
| } | |
| registerEventsOnce(); | |
| publishAuthState(); | |
| } | |
| ``` | |
| Then every protected loader can safely do: | |
| ```ts | |
| await requireAuth(request); | |
| ``` | |
| This is simple and robust. The downside is mostly architectural coupling: auth startup errors become global-ish, and every protected route waits on startup. It is usually not a performance concern. | |
| ### Pattern B: dedicated callback route | |
| `auth.start()` only initializes and restores cache. `/auth/callback` owns redirect processing. | |
| ```ts | |
| auth.start() | |
| → initialize MSAL | |
| → restore cached account | |
| → publish state | |
| auth.completeRedirect() | |
| → initialize MSAL | |
| → handleRedirectPromise() | |
| → set active account | |
| → publish state | |
| → navigate to returnTo | |
| ``` | |
| This is cleaner if you want strict route ownership. | |
| For your route-module app, either is acceptable. The simplest practical recommendation is: | |
| ```txt | |
| Call requireAuth() from protected clientLoaders. | |
| requireAuth() calls auth.start(). | |
| auth.start() is idempotent and shared-promise backed. | |
| ``` | |
| --- | |
| ## 7. `requireAuth()` responsibility | |
| `requireAuth()` should answer one question: | |
| ```txt | |
| Does the app currently have an active signed-in account? | |
| ``` | |
| It should not generally acquire API tokens. | |
| ```ts | |
| export async function requireAuth(request: Request) { | |
| await auth.start(); | |
| const state = auth.getState(); | |
| if (!state.activeAccount) { | |
| const url = new URL(request.url); | |
| const returnTo = `${url.pathname}${url.search}`; | |
| throw redirect(`/login?returnTo=${encodeURIComponent(returnTo)}`); | |
| } | |
| return state.activeAccount; | |
| } | |
| ``` | |
| Why not acquire tokens here? Because access tokens are resource/scope-specific. | |
| ```txt | |
| Mosaic token? | |
| Graph token? | |
| CRM token? | |
| Read scope? | |
| Write scope? | |
| Different authority? | |
| Different account? | |
| ``` | |
| A generic route guard should not know those details. | |
| --- | |
| ## 8. `acquireTokenSilent()` responsibility | |
| Use `acquireTokenSilent()` **right before calling a protected resource**. | |
| Microsoft’s guidance is explicit: every time the app needs an access token, call `acquireTokenSilent`; if it fails, use an interactive API. MSAL will look for a valid token in cache and try to refresh it with the cached refresh token if needed. ([Microsoft Learn][7]) MSAL also documents that it caches tokens and manages token lifetimes/refreshing through `acquireTokenSilent()` for a given account. ([Microsoft Learn][8]) | |
| App flow: | |
| ```txt | |
| clientLoader | |
| → requireAuth() | |
| → check active account | |
| → mosaicService().getSomething() | |
| → authorizedFetch() | |
| → auth.getAccessToken() | |
| → msal.acquireTokenSilent() | |
| ``` | |
| Implementation: | |
| ```ts | |
| export async function getAccessToken(scopes: string[]): Promise<string> { | |
| await auth.start(); | |
| const { activeAccount } = auth.getState(); | |
| if (!activeAccount) { | |
| throw new LoginRequiredError(); | |
| } | |
| try { | |
| const result = await msal.acquireTokenSilent({ | |
| account: activeAccount, | |
| scopes, | |
| }); | |
| return result.accessToken; | |
| } catch (error) { | |
| if (error instanceof InteractionRequiredAuthError) { | |
| throw new ReauthenticationRequiredError(); | |
| } | |
| throw error; | |
| } | |
| } | |
| ``` | |
| --- | |
| ## 9. Silent refresh vs login route | |
| The login route is **not** where silent refresh normally happens. | |
| Silent refresh happens here: | |
| ```ts | |
| msal.acquireTokenSilent(...) | |
| ``` | |
| That should usually be inside: | |
| ```txt | |
| authorizedFetch() | |
| auth.getAccessToken() | |
| service request middleware | |
| ``` | |
| If silent refresh succeeds: | |
| ```txt | |
| user enters protected route | |
| service calls acquireTokenSilent | |
| MSAL refreshes/returns token silently | |
| API request succeeds | |
| user never sees /login | |
| ``` | |
| If silent refresh fails: | |
| ```txt | |
| acquireTokenSilent throws InteractionRequiredAuthError | |
| app routes user to /login or starts acquireTokenRedirect() | |
| user performs required interaction | |
| app returns to original route | |
| ``` | |
| MSAL token lifetime docs note that silent acquisition is best-effort; there is never a guarantee that a token can be acquired silently. ([Microsoft Learn][9]) | |
| --- | |
| ## 10. Login route responsibility | |
| `/login` is the **interactive auth boundary**. | |
| It decides whether to: | |
| ```txt | |
| show a sign-in button | |
| immediately call loginRedirect() | |
| call acquireTokenRedirect() for reauth/consent | |
| show “session expired” | |
| preserve returnTo | |
| ``` | |
| Example: | |
| ```tsx | |
| export default function Login() { | |
| async function continueAuth() { | |
| const state = auth.getState(); | |
| if (state.activeAccount) { | |
| await msal.acquireTokenRedirect({ | |
| account: state.activeAccount, | |
| scopes: ["api://mosaic/client.read"], | |
| }); | |
| } else { | |
| await msal.loginRedirect({ | |
| scopes: ["openid", "profile", "api://mosaic/client.read"], | |
| }); | |
| } | |
| } | |
| return <button onClick={continueAuth}>Continue</button>; | |
| } | |
| ``` | |
| Use `loginRedirect()` for first login. Use `acquireTokenRedirect()` when the user has an account but the resource token requires interaction. | |
| --- | |
| ## 11. Service creation pattern | |
| This is fine: | |
| ```ts | |
| export const mosaicService = () => | |
| new MosaicService({ | |
| baseUrl: "/api/mosaic", | |
| fetch: authorizedFetch, | |
| }); | |
| ``` | |
| Creating a lightweight service wrapper per `clientLoader` is not a meaningful performance issue unless the constructor does heavy work. | |
| The important rule: | |
| ```txt | |
| Do not capture a bearer token when constructing the service. | |
| ``` | |
| Avoid this: | |
| ```ts | |
| const token = auth.getState().accessToken; | |
| export const mosaicService = () => | |
| new MosaicService({ | |
| token, | |
| }); | |
| ``` | |
| Prefer this: | |
| ```ts | |
| export const mosaicService = () => | |
| new MosaicService({ | |
| baseUrl: "/api/mosaic", | |
| getAccessToken: () => auth.getAccessToken(["api://mosaic/client.read"]), | |
| }); | |
| ``` | |
| Or better, inject a centralized fetcher: | |
| ```ts | |
| export const mosaicService = () => | |
| new MosaicService({ | |
| baseUrl: "/api/mosaic", | |
| fetch: authorizedFetch, | |
| }); | |
| ``` | |
| --- | |
| ## 12. Request middleware / `authorizedFetch` | |
| Yes, add a central request boundary that attaches tokens before sending. | |
| ```ts | |
| export async function authorizedFetch( | |
| input: RequestInfo | URL, | |
| init: RequestInit = {}, | |
| ) { | |
| await auth.start(); | |
| const token = await auth.getAccessToken(["api://mosaic/client.read"]); | |
| const response = await fetch(input, { | |
| ...init, | |
| headers: { | |
| ...init.headers, | |
| Authorization: `Bearer ${token}`, | |
| }, | |
| }); | |
| if (response.status === 401) { | |
| throw new ReauthenticationRequiredError(); | |
| } | |
| if (response.status === 403) { | |
| throw new ForbiddenError(); | |
| } | |
| return response; | |
| } | |
| ``` | |
| This middleware should: | |
| ```txt | |
| get fresh-enough token per request | |
| attach Authorization header | |
| handle 401/403 centrally | |
| surface InteractionRequiredAuthError/ReauthenticationRequiredError | |
| ``` | |
| It should **not** try to be the real security boundary. The backend must validate the bearer token on every request. | |
| --- | |
| ## 13. Route guard vs request middleware | |
| Use both, but for different jobs. | |
| ```txt | |
| requireAuth() | |
| route-level UX guard | |
| checks active account | |
| redirects unauthenticated users to /login | |
| authorizedFetch() | |
| API-boundary token middleware | |
| gets access token for the specific API scope | |
| attaches Authorization header | |
| handles token rejection | |
| ``` | |
| Recommended loader: | |
| ```ts | |
| export async function clientLoader({ request }: Route.ClientLoaderArgs) { | |
| await requireAuth(request); | |
| try { | |
| return await mosaicService().getClientSummary("123"); | |
| } catch (error) { | |
| if (isInteractionRequired(error)) { | |
| const url = new URL(request.url); | |
| const returnTo = `${url.pathname}${url.search}`; | |
| throw redirect(`/login?returnTo=${encodeURIComponent(returnTo)}`); | |
| } | |
| throw error; | |
| } | |
| } | |
| ``` | |
| --- | |
| ## 14. Optional resource-specific guard | |
| Generic `requireAuth()` should not acquire tokens. | |
| But a resource-specific guard is reasonable: | |
| ```ts | |
| export async function requireMosaicAccess(request: Request) { | |
| await requireAuth(request); | |
| try { | |
| await auth.getAccessToken(["api://mosaic/client.read"]); | |
| } catch (error) { | |
| if (isInteractionRequired(error)) { | |
| const url = new URL(request.url); | |
| const returnTo = `${url.pathname}${url.search}`; | |
| throw redirect(`/login?returnTo=${encodeURIComponent(returnTo)}`); | |
| } | |
| throw error; | |
| } | |
| } | |
| ``` | |
| Use this when a route is useless unless Mosaic access is available. | |
| --- | |
| ## 15. Activity monitoring | |
| MSAL provides **auth event monitoring**, not full app/user activity monitoring. | |
| Use MSAL events for auth state changes: | |
| ```ts | |
| msal.addEventCallback((message) => { | |
| // LOGIN_SUCCESS | |
| // ACQUIRE_TOKEN_SUCCESS | |
| // ACQUIRE_TOKEN_FAILURE | |
| // LOGOUT_SUCCESS | |
| // ACTIVE_ACCOUNT_CHANGED | |
| }); | |
| ``` | |
| MSAL Browser exposes `addEventCallback` for emitted auth events. ([Microsoft Learn][10]) | |
| Your app should own user activity policy: | |
| ```txt | |
| last mouse/keyboard/touch activity | |
| tab visibility | |
| idle timeout | |
| soft lock | |
| hard logout | |
| pause polling | |
| refresh-on-focus | |
| ``` | |
| Example: | |
| ```ts | |
| const ACTIVITY_EVENTS = [ | |
| "pointerdown", | |
| "keydown", | |
| "touchstart", | |
| "scroll", | |
| "focus", | |
| ] as const; | |
| export function startActivityMonitoring() { | |
| function markActivity() { | |
| auth.patch({ | |
| lastUserActivityAt: Date.now(), | |
| idle: false, | |
| }); | |
| } | |
| for (const eventName of ACTIVITY_EVENTS) { | |
| window.addEventListener(eventName, markActivity, { | |
| passive: true, | |
| capture: true, | |
| }); | |
| } | |
| document.addEventListener("visibilitychange", () => { | |
| if (document.visibilityState === "visible") { | |
| markActivity(); | |
| // Optional: warm important scopes on resume. | |
| void auth.warmAccessToken(["api://mosaic/client.read"]); | |
| } | |
| }); | |
| } | |
| ``` | |
| --- | |
| ## 16. Logout behavior | |
| Use MSAL logout APIs for actual logout. | |
| ```ts | |
| await msal.logoutRedirect({ | |
| account: auth.getState().activeAccount ?? undefined, | |
| }); | |
| ``` | |
| `logoutRedirect()` clears MSAL’s local token cache and redirects to the server sign-out page. Microsoft warns that skipping server sign-out leaves the server session active, meaning the user may be signed back in without credentials. ([Microsoft Learn][11]) | |
| App-level idle behavior can choose between: | |
| ```txt | |
| soft idle: | |
| mark idle, hide sensitive UI, pause polling | |
| hard idle: | |
| clear app state, require “Continue” | |
| security logout: | |
| call logoutRedirect() | |
| ``` | |
| --- | |
| ## 17. Recommended end-to-end flow | |
| ```txt | |
| User navigates to /clients/123 | |
| ↓ | |
| clientLoader runs | |
| ↓ | |
| requireAuth(request) | |
| ↓ | |
| auth.start() | |
| - initialize MSAL if needed | |
| - handle redirect response if using global bootstrap | |
| - restore active account | |
| - publish auth state | |
| ↓ | |
| No active account? | |
| → redirect /login?returnTo=/clients/123 | |
| ↓ | |
| Active account exists | |
| ↓ | |
| mosaicService().getClientSummary("123") | |
| ↓ | |
| authorizedFetch() | |
| ↓ | |
| auth.getAccessToken(["api://mosaic/client.read"]) | |
| ↓ | |
| msal.acquireTokenSilent(...) | |
| ↓ | |
| Silent success? | |
| → send request with bearer token | |
| ↓ | |
| Interaction required? | |
| → redirect /login?returnTo=/clients/123 | |
| ↓ | |
| Backend validates token | |
| ↓ | |
| Route renders data | |
| ``` | |
| --- | |
| ## 18. Recommended file layout | |
| ```txt | |
| app/ | |
| auth/ | |
| msal.client.ts | |
| auth-store.client.ts | |
| require-auth.client.ts | |
| authorized-fetch.client.ts | |
| errors.ts | |
| services/ | |
| mosaic-service.ts | |
| mosaic-service-factory.client.ts | |
| routes/ | |
| login.tsx | |
| auth.callback.tsx | |
| clients.$clientId.tsx | |
| ``` | |
| --- | |
| ## 19. Core implementation sketch | |
| ### `msal.client.ts` | |
| ```ts | |
| import { | |
| PublicClientApplication, | |
| BrowserCacheLocation, | |
| } from "@azure/msal-browser"; | |
| export const msal = new PublicClientApplication({ | |
| auth: { | |
| clientId: import.meta.env.VITE_MSAL_CLIENT_ID, | |
| authority: `https://login.microsoftonline.com/${import.meta.env.VITE_MSAL_TENANT_ID}`, | |
| redirectUri: `${window.location.origin}/auth/callback`, | |
| postLogoutRedirectUri: window.location.origin, | |
| }, | |
| cache: { | |
| cacheLocation: BrowserCacheLocation.SessionStorage, | |
| }, | |
| }); | |
| ``` | |
| ### `auth-store.client.ts` | |
| ```ts | |
| import { useSyncExternalStore } from "react"; | |
| import { | |
| AccountInfo, | |
| EventMessageUtils, | |
| EventType, | |
| InteractionRequiredAuthError, | |
| InteractionStatus, | |
| } from "@azure/msal-browser"; | |
| import { msal } from "./msal.client"; | |
| export type AuthSnapshot = { | |
| initialized: boolean; | |
| inProgress: InteractionStatus; | |
| accounts: AccountInfo[]; | |
| activeAccount: AccountInfo | null; | |
| isAuthenticated: boolean; | |
| error: Error | null; | |
| }; | |
| let snapshot: AuthSnapshot = { | |
| initialized: false, | |
| inProgress: InteractionStatus.Startup, | |
| accounts: [], | |
| activeAccount: null, | |
| isAuthenticated: false, | |
| error: null, | |
| }; | |
| let startPromise: Promise<void> | undefined; | |
| let eventsRegistered = false; | |
| const listeners = new Set<() => void>(); | |
| function publish(patch: Partial<AuthSnapshot> = {}) { | |
| const accounts = msal.getAllAccounts(); | |
| const activeAccount = msal.getActiveAccount() ?? chooseAccount(accounts); | |
| snapshot = { | |
| ...snapshot, | |
| ...patch, | |
| accounts, | |
| activeAccount, | |
| isAuthenticated: Boolean(activeAccount), | |
| }; | |
| listeners.forEach((listener) => listener()); | |
| } | |
| function chooseAccount(accounts: AccountInfo[]) { | |
| if (accounts.length === 1) { | |
| msal.setActiveAccount(accounts[0]); | |
| return accounts[0]; | |
| } | |
| return null; | |
| } | |
| function registerEventsOnce() { | |
| if (eventsRegistered) return; | |
| eventsRegistered = true; | |
| msal.addEventCallback((message) => { | |
| const nextStatus = EventMessageUtils.getInteractionStatusFromEvent( | |
| message, | |
| snapshot.inProgress, | |
| ); | |
| if ( | |
| message.eventType === EventType.LOGIN_SUCCESS || | |
| message.eventType === EventType.ACQUIRE_TOKEN_SUCCESS | |
| ) { | |
| const result = message.payload as { account?: AccountInfo }; | |
| if (result.account) { | |
| msal.setActiveAccount(result.account); | |
| } | |
| } | |
| if (message.eventType === EventType.LOGOUT_SUCCESS) { | |
| msal.setActiveAccount(null); | |
| } | |
| publish({ | |
| inProgress: nextStatus ?? snapshot.inProgress, | |
| }); | |
| }); | |
| } | |
| async function startInternal() { | |
| await msal.initialize(); | |
| registerEventsOnce(); | |
| const result = await msal.handleRedirectPromise(); | |
| if (result?.account) { | |
| msal.setActiveAccount(result.account); | |
| } else { | |
| chooseAccount(msal.getAllAccounts()); | |
| } | |
| publish({ | |
| initialized: true, | |
| inProgress: InteractionStatus.None, | |
| error: null, | |
| }); | |
| } | |
| export const auth = { | |
| async start() { | |
| if (!startPromise) { | |
| startPromise = startInternal().catch((error) => { | |
| publish({ | |
| initialized: true, | |
| inProgress: InteractionStatus.None, | |
| error, | |
| }); | |
| throw error; | |
| }); | |
| } | |
| return startPromise; | |
| }, | |
| getState() { | |
| return snapshot; | |
| }, | |
| subscribe(listener: () => void) { | |
| listeners.add(listener); | |
| return () => listeners.delete(listener); | |
| }, | |
| async login() { | |
| await this.start(); | |
| await msal.loginRedirect({ | |
| scopes: ["openid", "profile"], | |
| }); | |
| }, | |
| async logout() { | |
| await this.start(); | |
| await msal.logoutRedirect({ | |
| account: this.getState().activeAccount ?? undefined, | |
| }); | |
| }, | |
| async getAccessToken(scopes: string[]) { | |
| await this.start(); | |
| const account = this.getState().activeAccount; | |
| if (!account) { | |
| throw new LoginRequiredError(); | |
| } | |
| try { | |
| const result = await msal.acquireTokenSilent({ | |
| account, | |
| scopes, | |
| }); | |
| return result.accessToken; | |
| } catch (error) { | |
| if (error instanceof InteractionRequiredAuthError) { | |
| throw new ReauthenticationRequiredError(); | |
| } | |
| throw error; | |
| } | |
| }, | |
| }; | |
| export function useAuth() { | |
| return useSyncExternalStore(auth.subscribe, auth.getState, auth.getState); | |
| } | |
| export function useIsAuthenticated() { | |
| return useAuth().isAuthenticated; | |
| } | |
| ``` | |
| ### `require-auth.client.ts` | |
| ```ts | |
| import { redirect } from "react-router"; | |
| import { auth } from "./auth-store.client"; | |
| function getReturnTo(request: Request) { | |
| const url = new URL(request.url); | |
| return `${url.pathname}${url.search}`; | |
| } | |
| export async function requireAuth(request: Request) { | |
| await auth.start(); | |
| const { activeAccount } = auth.getState(); | |
| if (!activeAccount) { | |
| throw redirect(`/login?returnTo=${encodeURIComponent(getReturnTo(request))}`); | |
| } | |
| return activeAccount; | |
| } | |
| ``` | |
| ### `authorized-fetch.client.ts` | |
| ```ts | |
| import { auth } from "./auth-store.client"; | |
| export async function authorizedFetch( | |
| input: RequestInfo | URL, | |
| init: RequestInit = {}, | |
| ) { | |
| const token = await auth.getAccessToken(["api://mosaic/client.read"]); | |
| const response = await fetch(input, { | |
| ...init, | |
| headers: { | |
| ...init.headers, | |
| Authorization: `Bearer ${token}`, | |
| }, | |
| }); | |
| if (response.status === 401) { | |
| throw new ReauthenticationRequiredError(); | |
| } | |
| if (response.status === 403) { | |
| throw new ForbiddenError(); | |
| } | |
| return response; | |
| } | |
| ``` | |
| ### `mosaic-service-factory.client.ts` | |
| ```ts | |
| import { authorizedFetch } from "../auth/authorized-fetch.client"; | |
| import { MosaicService } from "./mosaic-service"; | |
| export const mosaicService = () => | |
| new MosaicService({ | |
| baseUrl: "/api/mosaic", | |
| fetch: authorizedFetch, | |
| }); | |
| ``` | |
| ### Route module | |
| ```ts | |
| import type { Route } from "./+types/clients.$clientId"; | |
| import { redirect } from "react-router"; | |
| import { requireAuth } from "../auth/require-auth.client"; | |
| import { mosaicService } from "../services/mosaic-service-factory.client"; | |
| import { isInteractionRequired } from "../auth/errors"; | |
| export async function clientLoader({ | |
| request, | |
| params, | |
| }: Route.ClientLoaderArgs) { | |
| await requireAuth(request); | |
| try { | |
| return await mosaicService().getClientSummary(params.clientId); | |
| } catch (error) { | |
| if (isInteractionRequired(error)) { | |
| const url = new URL(request.url); | |
| const returnTo = `${url.pathname}${url.search}`; | |
| throw redirect(`/login?returnTo=${encodeURIComponent(returnTo)}`); | |
| } | |
| throw error; | |
| } | |
| } | |
| ``` | |
| --- | |
| ## 20. Final rules of thumb | |
| ```txt | |
| Use @azure/msal-browser as the source of truth. | |
| Only use @azure/msal-react if you want component convenience APIs. | |
| Expose your own auth facade with: | |
| start() | |
| getState() | |
| subscribe() | |
| getAccessToken() | |
| login() | |
| logout() | |
| Call requireAuth() from protected clientLoaders. | |
| Make auth.start() idempotent with a shared promise. | |
| Do not store access tokens in app state. | |
| Do not capture bearer tokens in service constructors. | |
| Acquire tokens at the API boundary with acquireTokenSilent(). | |
| Redirect to /login only when: | |
| no active account exists, or | |
| acquireTokenSilent() fails with interaction required. | |
| Use authorizedFetch/service middleware to attach Authorization headers. | |
| Let the backend validate bearer tokens. | |
| Use MSAL events for auth state changes. | |
| Use your own activity monitor for idle/session UX. | |
| ``` | |
| [1]: https://learn.microsoft.com/en-us/entra/msal/javascript/react/getting-started "Get started with MSAL React - Microsoft Authentication Library for JavaScript | Microsoft Learn" | |
| [2]: https://learn.microsoft.com/en-us/entra/msal/javascript/react/migration-guide "Migration Guide from MSAL v1 to MSAL React and MSAL Browser - Microsoft Authentication Library for JavaScript | Microsoft Learn" | |
| [3]: https://reactrouter.com/start/framework/route-module "Route Module | React Router" | |
| [4]: https://learn.microsoft.com/en-us/entra/msal/javascript/browser/initialization "Initialize MSAL Browser - Microsoft Authentication Library for JavaScript | Microsoft Learn" | |
| [5]: https://azuread.github.io/microsoft-authentication-library-for-js/ref/classes/_azure_msal_browser.PublicClientApplication.html?utm_source=chatgpt.com "PublicClientApplication | Documentation" | |
| [6]: https://learn.microsoft.com/en-us/entra/msal/javascript/browser/errors?utm_source=chatgpt.com "Common errors in MSAL JS" | |
| [7]: https://learn.microsoft.com/en-us/entra/msal/javascript/browser/acquire-token "Acquiring and using an access token - Microsoft Authentication Library for JavaScript | Microsoft Learn" | |
| [8]: https://learn.microsoft.com/en-us/entra/msal/javascript/browser/caching "Caching in MSAL.js - Microsoft Authentication Library for JavaScript | Microsoft Learn" | |
| [9]: https://learn.microsoft.com/en-us/entra/msal/javascript/browser/token-lifetimes "Manage token lifetimes - Microsoft Authentication Library for JavaScript | Microsoft Learn" | |
| [10]: https://learn.microsoft.com/en-us/entra/msal/javascript/browser/events?utm_source=chatgpt.com "Events in MSAL Browser" | |
| [11]: https://learn.microsoft.com/en-us/entra/msal/javascript/browser/logout "Sign out users - Microsoft Authentication Library for JavaScript | Microsoft Learn" |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment