Created
May 7, 2026 16:57
-
-
Save andrewjroberts/b1a9138bd10a7b636a94e671dfa971f4 to your computer and use it in GitHub Desktop.
MSAL shape
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
| No — I **would** do that. That’s cleaner than stuffing everything under `ui-config.auth.microsoftIdentity`. | |
| I’d shape it like this: | |
| ```ts | |
| export type UiConfig = { | |
| app: { | |
| uiVersion: string; | |
| serviceVersion: string; | |
| environment: "local" | "dev" | "qa" | "prod"; | |
| }; | |
| auth: { | |
| microsoftIdentity: { | |
| clientId: string; // SPA app registration | |
| tenantId: string; | |
| authority: string; // https://login.microsoftonline.com/{tenantId} | |
| redirectUri: string; | |
| postLogoutRedirectUri?: string; | |
| apiScopes: string[]; // ["api://<api-client-id>/access_as_user"] | |
| }; | |
| }; | |
| }; | |
| ``` | |
| That is probably the best shape. | |
| ```txt | |
| app | |
| → metadata about this deployed UI/service pair | |
| auth | |
| → authentication provider config | |
| auth.microsoftIdentity | |
| → specific Microsoft/Entra/MSAL-compatible config | |
| ``` | |
| I would avoid: | |
| ```ts | |
| uiConfig: { | |
| uiVersion, | |
| backendVersion, | |
| environment, | |
| auth: ... | |
| } | |
| ``` | |
| because it mixes deployment metadata and auth config at the same level. | |
| ## Naming tweak | |
| I’d probably use: | |
| ```ts | |
| app: { | |
| uiVersion: string; | |
| apiVersion: string; | |
| environment: string; | |
| } | |
| ``` | |
| or: | |
| ```ts | |
| app: { | |
| uiVersion: string; | |
| serviceVersion: string; | |
| environment: string; | |
| } | |
| ``` | |
| `serviceVersion` is fine if your org calls the Spring backend “the service.” | |
| `backendVersion` is more obvious. | |
| `apiVersion` can be confused with REST API versioning like `/v1`. | |
| My favorite: | |
| ```ts | |
| app: { | |
| uiVersion: string; | |
| backendVersion: string; | |
| environment: string; | |
| } | |
| ``` | |
| Most explicit. | |
| ## Example JSON | |
| ```json | |
| { | |
| "app": { | |
| "uiVersion": "1.14.3", | |
| "backendVersion": "2.8.0", | |
| "environment": "dev" | |
| }, | |
| "auth": { | |
| "microsoftIdentity": { | |
| "clientId": "spa-client-id", | |
| "tenantId": "tenant-id", | |
| "authority": "https://login.microsoftonline.com/tenant-id", | |
| "redirectUri": "https://my-app.dev.company.com/auth/callback", | |
| "postLogoutRedirectUri": "https://my-app.dev.company.com", | |
| "apiScopes": ["api://spring-api-client-id/access_as_user"] | |
| } | |
| } | |
| } | |
| ``` | |
| ## For Zustand, I’d wire it like this | |
| Use the config shape above, then initialize the auth store once. | |
| ```ts | |
| import { | |
| BrowserCacheLocation, | |
| InteractionRequiredAuthError, | |
| PublicClientApplication, | |
| type AccountInfo, | |
| type AuthenticationResult, | |
| } from "@azure/msal-browser"; | |
| import { create } from "zustand"; | |
| type MicrosoftIdentityConfig = { | |
| clientId: string; | |
| tenantId: string; | |
| authority: string; | |
| redirectUri: string; | |
| postLogoutRedirectUri?: string; | |
| apiScopes: string[]; | |
| }; | |
| type AuthStatus = | |
| | "uninitialized" | |
| | "starting" | |
| | "signed-out" | |
| | "signed-in" | |
| | "redirecting" | |
| | "error"; | |
| type AuthState = { | |
| status: AuthStatus; | |
| msal: PublicClientApplication | null; | |
| account: AccountInfo | null; | |
| config: MicrosoftIdentityConfig | null; | |
| error: unknown; | |
| configure: (config: MicrosoftIdentityConfig) => void; | |
| start: () => Promise<void>; | |
| login: () => Promise<void>; | |
| logout: () => Promise<void>; | |
| getApiToken: () => Promise<string>; | |
| }; | |
| export const useAuthStore = create<AuthState>((set, get) => ({ | |
| status: "uninitialized", | |
| msal: null, | |
| account: null, | |
| config: null, | |
| error: null, | |
| configure: (config) => { | |
| const msal = new PublicClientApplication({ | |
| auth: { | |
| clientId: config.clientId, | |
| authority: config.authority, | |
| redirectUri: config.redirectUri, | |
| postLogoutRedirectUri: config.postLogoutRedirectUri, | |
| }, | |
| cache: { | |
| cacheLocation: BrowserCacheLocation.SessionStorage, | |
| storeAuthStateInCookie: false, | |
| }, | |
| }); | |
| set({ | |
| config, | |
| msal, | |
| status: "uninitialized", | |
| account: null, | |
| error: null, | |
| }); | |
| }, | |
| start: async () => { | |
| const { msal } = get(); | |
| if (!msal) { | |
| throw new Error("Auth store has not been configured."); | |
| } | |
| set({ status: "starting", error: null }); | |
| try { | |
| await msal.initialize(); | |
| const redirectResult = await msal.handleRedirectPromise(); | |
| if (redirectResult?.account) { | |
| msal.setActiveAccount(redirectResult.account); | |
| } | |
| const account = | |
| msal.getActiveAccount() ?? msal.getAllAccounts()[0] ?? null; | |
| if (account) { | |
| msal.setActiveAccount(account); | |
| } | |
| set({ | |
| account, | |
| status: account ? "signed-in" : "signed-out", | |
| }); | |
| } catch (error) { | |
| set({ error, status: "error" }); | |
| throw error; | |
| } | |
| }, | |
| login: async () => { | |
| const { msal } = get(); | |
| if (!msal) { | |
| throw new Error("Auth store has not been configured."); | |
| } | |
| set({ status: "redirecting", error: null }); | |
| await msal.loginRedirect({ | |
| scopes: ["openid", "profile"], | |
| }); | |
| }, | |
| logout: async () => { | |
| const { msal, config, account } = get(); | |
| if (!msal) { | |
| throw new Error("Auth store has not been configured."); | |
| } | |
| set({ status: "redirecting", error: null }); | |
| await msal.logoutRedirect({ | |
| account: account ?? undefined, | |
| postLogoutRedirectUri: config?.postLogoutRedirectUri, | |
| }); | |
| }, | |
| getApiToken: async () => { | |
| const { msal, config } = get(); | |
| if (!msal || !config) { | |
| throw new Error("Auth store has not been configured."); | |
| } | |
| const account = | |
| msal.getActiveAccount() ?? msal.getAllAccounts()[0] ?? null; | |
| if (!account) { | |
| set({ status: "redirecting" }); | |
| await msal.loginRedirect({ | |
| scopes: ["openid", "profile"], | |
| }); | |
| throw new Error("Login redirect started."); | |
| } | |
| try { | |
| const result: AuthenticationResult = await msal.acquireTokenSilent({ | |
| account, | |
| scopes: config.apiScopes, | |
| }); | |
| return result.accessToken; | |
| } catch (error) { | |
| if (error instanceof InteractionRequiredAuthError) { | |
| set({ status: "redirecting" }); | |
| await msal.acquireTokenRedirect({ | |
| account, | |
| scopes: config.apiScopes, | |
| }); | |
| throw new Error("Token redirect started."); | |
| } | |
| set({ error, status: "error" }); | |
| throw error; | |
| } | |
| }, | |
| })); | |
| ``` | |
| Then bootstrap: | |
| ```ts | |
| const uiConfig = await fetch("/api/ui-config").then((res) => res.json()); | |
| useAuthStore.getState().configure(uiConfig.auth.microsoftIdentity); | |
| await useAuthStore.getState().start(); | |
| ``` | |
| And your HTTP client can use it without React hooks: | |
| ```ts | |
| export async function apiFetch(input: RequestInfo | URL, init?: RequestInit) { | |
| const token = await useAuthStore.getState().getApiToken(); | |
| const headers = new Headers(init?.headers); | |
| headers.set("Authorization", `Bearer ${token}`); | |
| return fetch(input, { | |
| ...init, | |
| headers, | |
| }); | |
| } | |
| ``` | |
| So yes, I’d do: | |
| ```ts | |
| { | |
| app: { uiVersion, backendVersion, environment }, | |
| auth: { microsoftIdentity: { ... } } | |
| } | |
| ``` | |
| That is a good, senior-looking config shape. | |
| Yes, it is appropriate to store `account` in the Zustand store — **as app-facing state**. | |
| But I would treat MSAL as the **source of truth**, and Zustand as the **React/app convenience layer**. | |
| ```txt id="hxsp01" | |
| MSAL cache / active account | |
| → source of truth | |
| Zustand account field | |
| → snapshot for UI and route logic | |
| ``` | |
| Microsoft’s MSAL browser docs expose `getActiveAccount()` / `setActiveAccount()` specifically to track which account should be used for token requests; if no account is passed to token APIs, MSAL uses the active account. ([Microsoft Learn][1]) | |
| ## Why store `account` at all? | |
| Because the UI needs to react to auth state: | |
| ```tsx id="xht6c7" | |
| const account = useAuthStore((s) => s.account); | |
| return account ? <UserMenu name={account.name} /> : <SignInButton />; | |
| ``` | |
| Without `account` in Zustand, your components either call MSAL directly or need `@azure/msal-react` hooks everywhere. For your preference — service/router-loader oriented architecture — Zustand is a cleaner app boundary. | |
| ## But don’t over-trust the stored account | |
| This is the important part. | |
| `account` in Zustand is **not security**. It is client-side state. | |
| Use it for: | |
| ```txt id="cvy8uk" | |
| display name | |
| signed-in / signed-out UI | |
| choosing account for acquireTokenSilent | |
| lightweight route gating | |
| ``` | |
| Do **not** use it as proof to the backend that the user is allowed to do anything. The backend should validate the access token on every protected API call. | |
| ## Field-by-field rationale | |
| ```ts id="07uq1w" | |
| type AuthState = { | |
| status: AuthStatus; | |
| msal: PublicClientApplication | null; | |
| account: AccountInfo | null; | |
| config: MicrosoftIdentityConfig | null; | |
| error: unknown; | |
| configure: (config: MicrosoftIdentityConfig) => void; | |
| start: () => Promise<void>; | |
| login: () => Promise<void>; | |
| logout: () => Promise<void>; | |
| getApiToken: () => Promise<string>; | |
| }; | |
| ``` | |
| ### `status` | |
| Keep this. It prevents vague booleans like: | |
| ```ts id="sf9drf" | |
| isLoading | |
| isAuthenticated | |
| isRedirecting | |
| hasError | |
| ``` | |
| from drifting out of sync. | |
| I’d use something like: | |
| ```ts id="e0tm3c" | |
| type AuthStatus = | |
| | "uninitialized" | |
| | "starting" | |
| | "signed-out" | |
| | "signed-in" | |
| | "redirecting" | |
| | "error"; | |
| ``` | |
| This is especially helpful because MSAL redirect auth has weird intermediate states. You need to know whether the app is still processing a redirect response versus genuinely signed out. | |
| ### `msal` | |
| This is acceptable, but I’d be deliberate. | |
| Pros: | |
| ```txt id="m43oki" | |
| One place owns the PublicClientApplication | |
| loaders/services can access it through store actions | |
| no duplicate MSAL instances | |
| ``` | |
| Con: | |
| ```txt id="esytk6" | |
| Zustand state now contains a non-serializable class instance | |
| ``` | |
| That is fine as long as you **do not persist the whole auth store**. If you use Zustand `persist`, only persist explicit safe fields, not `msal`, `account`, tokens, or errors. | |
| A slightly cleaner version is to keep `msal` in module scope and only expose state in Zustand, but I do not think storing it is a fatal smell. For your app, I’d tolerate it. | |
| ### `account` | |
| Yes, store it. | |
| But always sync it from MSAL during `start()`: | |
| ```ts id="pwklpg" | |
| const redirectResult = await msal.handleRedirectPromise(); | |
| if (redirectResult?.account) { | |
| msal.setActiveAccount(redirectResult.account); | |
| } | |
| const account = | |
| msal.getActiveAccount() ?? msal.getAllAccounts()[0] ?? null; | |
| set({ account, status: account ? "signed-in" : "signed-out" }); | |
| ``` | |
| MSAL docs call out that apps need to determine which account to use and then set it as active; `getAllAccounts()` returns cached accounts, while the active-account APIs identify the account used for token requests. ([Microsoft Learn][1]) | |
| ### `config` | |
| Store it if the backend serves `/api/ui-config`. | |
| This makes the auth store self-contained: | |
| ```ts id="v2vham" | |
| configure(uiConfig.auth.microsoftIdentity) | |
| start() | |
| getApiToken() | |
| ``` | |
| Downside: config is static after bootstrap, so it could also live in module scope. But storing it is reasonable because `getApiToken()` needs `apiScopes`. | |
| ### `error` | |
| Good, but I’d type it a little more intentionally: | |
| ```ts id="z248tw" | |
| error: Error | unknown | null; | |
| ``` | |
| or normalize it: | |
| ```ts id="5txu6z" | |
| error: { | |
| message: string; | |
| cause?: unknown; | |
| } | null; | |
| ``` | |
| Raw `unknown` is okay internally, but annoying in UI. | |
| ### `configure` | |
| This is a good method when config comes from Spring. | |
| It creates the MSAL instance once: | |
| ```ts id="52wrqv" | |
| configure(config) | |
| ``` | |
| I would not create MSAL inside random components or loaders. One configured singleton is the right move. | |
| ### `start` | |
| This is crucial. | |
| `start()` should mean: | |
| ```txt id="jmau6b" | |
| initialize MSAL | |
| process redirect response | |
| restore active account | |
| set app auth state | |
| ``` | |
| This should happen once before the app renders protected routes/loaders. MSAL redirect flows require `handleRedirectPromise()` to complete before starting another interactive request. ([GitHub][2]) | |
| ### `login` | |
| This should only start interactive login: | |
| ```ts id="s509ds" | |
| loginRedirect({ scopes: ["openid", "profile"] }) | |
| ``` | |
| Do not make `login()` also fetch your API token. Keep login and token acquisition separate. | |
| ### `logout` | |
| Fine. | |
| I’d make sure it clears both MSAL and Zustand state eventually. Since `logoutRedirect()` navigates away, your local `set` after the call may not run, so the real cleanup usually happens on next `start()`. | |
| ### `getApiToken` | |
| This is the core service method. | |
| It should: | |
| ```txt id="ct0d6u" | |
| find active account | |
| if none, redirect to login | |
| try acquireTokenSilent for apiScopes | |
| if interaction required, acquireTokenRedirect | |
| return access token | |
| ``` | |
| MSAL’s SPA docs recommend trying `acquireTokenSilent` first; MSAL checks its cache for a valid access token and tries to refresh silently before requiring interaction. ([Microsoft Learn][3]) | |
| ## The one improvement I’d make | |
| I’d split the type into **state** and **actions** for readability: | |
| ```ts id="k74z23" | |
| type AuthStatus = | |
| | "uninitialized" | |
| | "starting" | |
| | "signed-out" | |
| | "signed-in" | |
| | "redirecting" | |
| | "error"; | |
| type AuthSnapshot = { | |
| status: AuthStatus; | |
| account: AccountInfo | null; | |
| config: MicrosoftIdentityConfig | null; | |
| error: unknown | null; | |
| }; | |
| type AuthActions = { | |
| configure: (config: MicrosoftIdentityConfig) => void; | |
| start: () => Promise<void>; | |
| login: () => Promise<void>; | |
| logout: () => Promise<void>; | |
| getApiToken: () => Promise<string>; | |
| }; | |
| type AuthState = AuthSnapshot & AuthActions; | |
| ``` | |
| Then I’d either keep `msal` private: | |
| ```ts id="117as7" | |
| let msal: PublicClientApplication | null = null; | |
| ``` | |
| or keep it in the store but name it as internal: | |
| ```ts id="j53hil" | |
| _msal: PublicClientApplication | null; | |
| ``` | |
| My preferred version: | |
| ```ts id="5ajut9" | |
| let msal: PublicClientApplication | null = null; | |
| export const useAuthStore = create<AuthState>((set, get) => ({ | |
| status: "uninitialized", | |
| account: null, | |
| config: null, | |
| error: null, | |
| configure: (config) => { | |
| msal = new PublicClientApplication({ | |
| auth: { | |
| clientId: config.clientId, | |
| authority: config.authority, | |
| redirectUri: config.redirectUri, | |
| postLogoutRedirectUri: config.postLogoutRedirectUri, | |
| }, | |
| }); | |
| set({ | |
| config, | |
| status: "uninitialized", | |
| account: null, | |
| error: null, | |
| }); | |
| }, | |
| // ... | |
| })); | |
| ``` | |
| That way Zustand contains serializable-ish app state, while MSAL stays as an implementation detail. | |
| ## My actual recommendation | |
| For your setup: | |
| ```txt id="4epsmo" | |
| Store account? | |
| → Yes. | |
| Store access token? | |
| → No. | |
| Store MSAL instance? | |
| → Acceptable, but I prefer module-private. | |
| Store config? | |
| → Yes, especially because Spring serves it. | |
| Use Zustand actions from loaders/services? | |
| → Yes. useAuthStore.getState().getApiToken() is clean. | |
| Backend still validates every request? | |
| → Absolutely. | |
| ``` | |
| The cleanest final shape is: | |
| ```ts id="q1nc1u" | |
| type AuthState = { | |
| status: AuthStatus; | |
| account: AccountInfo | null; | |
| config: MicrosoftIdentityConfig | null; | |
| error: unknown | null; | |
| configure: (config: MicrosoftIdentityConfig) => void; | |
| start: () => Promise<void>; | |
| login: () => Promise<void>; | |
| logout: () => Promise<void>; | |
| getApiToken: () => Promise<string>; | |
| }; | |
| ``` | |
| And keep this outside the store: | |
| ```ts id="j5lt9e" | |
| let msal: PublicClientApplication | null = null; | |
| ``` | |
| That gives you the ergonomics of Zustand without turning the store into a dumping ground for auth-library internals. | |
| [1]: https://learn.microsoft.com/en-us/entra/msal/javascript/browser/accounts?utm_source=chatgpt.com "Accounts in MSAL Browser" | |
| [2]: https://github.com/AzureAD/microsoft-authentication-library-for-js/blob/dev/lib/msal-browser/docs/acquire-token.md?plain=1&utm_source=chatgpt.com "microsoft-authentication-library-for-js/lib/msal-browser/docs ..." | |
| [3]: https://learn.microsoft.com/en-us/entra/identity-platform/scenario-spa-acquire-token?utm_source=chatgpt.com "Single-page application: Acquire a token to call an API" |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment