Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save andrewjroberts/b1a9138bd10a7b636a94e671dfa971f4 to your computer and use it in GitHub Desktop.

Select an option

Save andrewjroberts/b1a9138bd10a7b636a94e671dfa971f4 to your computer and use it in GitHub Desktop.
MSAL shape
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