Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

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

Select an option

Save andrewjroberts/a9db33ccd2a6d1ccbf4595f18b4b865a to your computer and use it in GitHub Desktop.
# Auth Store Documentation
This store owns the browser-side MSAL lifecycle for the app. It keeps MSAL behind a small Zustand API, coordinates redirect login flows, exposes typed auth actions, and persists only the redirect metadata needed to survive MSAL redirect navigation.
The intended public API is:
```ts
const auth = useAuthStoreActions();
await auth.start();
await auth.login();
const token = await auth.getApiToken();
await auth.logout();
```
Everything outside `actions` is state. Everything inside `createAuthOperations` is store-local implementation detail and should not be called from app code.
## Design Goals
- Keep MSAL usage encapsulated inside the store.
- Keep app code from directly reading or mutating MSAL client state.
- Make public actions read like short workflows.
- Keep redirect correlation explicit with `redirectFlow`.
- Avoid persisting identity, tokens, promises, errors, or MSAL instances in Zustand.
- Derive redirect-in-progress state from `status === "redirecting"` instead of storing a second flag.
## Store Shape
```ts
type AuthStoreState = {
status: AuthStatus;
account: AccountInfo | null;
config: AuthConfig | null;
error: Error | null;
msal: PublicClientApplication | null;
startupPromise: Promise<AuthStartResult> | null;
redirectFlow: AuthRedirectFlow | null;
};
```
### `status`
Current auth lifecycle state.
- `"unconfigured"`: no auth config has been supplied.
- `"configured"`: auth config and MSAL client exist, but startup has not completed.
- `"starting"`: startup is currently running.
- `"signed-out"`: startup completed and no account is active.
- `"signed-in"`: startup completed and an account is active.
- `"redirecting"`: the app has started an MSAL redirect operation.
- `"error"`: an auth operation failed.
### `account`
The current active MSAL account, or `null` when signed out.
### `config`
The app auth config used to create the MSAL client.
### `error`
The last non-interaction auth error stored by the auth lifecycle.
### `msal`
The runtime MSAL client. It is never persisted.
### `startupPromise`
The in-flight startup promise. This deduplicates concurrent `start()` calls.
### `redirectFlow`
Temporary metadata used to correlate an app-started redirect with the MSAL redirect result.
It survives MSAL redirect navigation, where the app unloads before Microsoft redirects back to the callback route.
Only this field is persisted to `sessionStorage`.
## Public Usage Guide
### Configure Auth
Call `configure(config)` once you have auth config from the server or environment.
```ts
const auth = useAuthStoreActions();
auth.configure({
tenantId,
authority,
clientId,
apiScope,
redirectUri,
postLogoutRedirectUri,
});
```
`configure` is idempotent for the same config. If the config changes, the store resets runtime auth state and creates a new MSAL client.
### Start Auth On App Boot
Call `start()` after `configure()`. It initializes MSAL, handles any redirect response, restores the active account, and returns an optional post-login route.
```ts
const auth = useAuthStoreActions();
const result = await auth.start();
if (result.redirectTo) {
navigate(result.redirectTo);
}
```
`start()` is safe to call multiple times. Concurrent calls share `startupPromise`.
### Sign In
Use `login()` to begin an interactive login redirect.
```ts
await auth.login({
returnTo: "/control-exceptions/123",
});
```
If an account is already active, `login()` simply marks the store signed in and does not redirect.
Use `includeApiScope` when the login redirect should also request the API scope:
```ts
await auth.login({
returnTo: "/control-exceptions",
includeApiScope: true,
});
```
### Get An API Token
Use `getApiToken()` before API calls that require the configured API scope.
```ts
try {
const token = await auth.getApiToken();
await fetch("/api/example", {
headers: {
Authorization: `Bearer ${token}`,
},
});
} catch (error) {
if (error instanceof AuthLoginRequiredError) {
await auth.login({ returnTo: window.location.pathname });
return;
}
if (error instanceof AuthInteractionRequiredError) {
await auth.continueApiTokenRedirect({
returnTo: window.location.pathname,
});
return;
}
throw error;
}
```
`getApiToken()` only attempts silent token acquisition. If MSAL requires user interaction, it throws `AuthInteractionRequiredError`.
### Continue After Interaction Is Required
Use `continueApiTokenRedirect()` when an API token cannot be acquired silently.
```ts
await auth.continueApiTokenRedirect({
returnTo: "/control-exceptions",
});
```
If no account exists, this falls back to `login({ includeApiScope: true })`.
If an account exists, this starts `acquireTokenRedirect()`.
### Logout
Use `logout()` to start the MSAL logout redirect.
```ts
await auth.logout();
```
The browser navigates to Microsoft logout and returns to `postLogoutRedirectUri`.
### Reset
Use `reset()` only when the app intentionally wants to clear local auth-store state.
```ts
auth.reset();
```
This does not call MSAL logout. It only resets Zustand state.
## Public Actions
### `configure(config)`
Creates or updates the MSAL client from `AuthConfig`.
Behavior:
- Computes a config key from tenant, authority, client id, API scope, redirect URI, and post-logout redirect URI.
- If the existing MSAL client matches the same config, only updates `config`, clears `error`, and preserves the current status unless it was `"unconfigured"` or `"error"`.
- If the config changed, resets auth state, preserves any existing `redirectFlow`, creates a new MSAL client, and sets status to `"configured"`.
Why it preserves `redirectFlow`:
- A redirect result may return after the app reloads and reconfigures auth.
- The flow metadata is needed to route the user back to the original destination.
### `start()`
Initializes MSAL and handles redirect responses.
Behavior:
- Requires the store to be configured.
- Returns immediately if already `"signed-in"` or `"signed-out"`.
- Returns immediately if already `"redirecting"`.
- Reuses `startupPromise` if startup is already running.
- Calls `msal.initialize()`.
- Calls `msal.handleRedirectPromise()`.
- Sets the active account from the redirect result or existing MSAL cache.
- Resolves the post-redirect route from `redirectFlow`.
- Clears `startupPromise` in `finally`.
Returns:
```ts
{
account: AccountInfo | null;
redirectTo: string | null;
}
```
`redirectTo` is non-null only when an MSAL redirect result was handled.
### `login(options?)`
Starts login redirect, unless an account is already active.
Behavior:
- Requires configured auth.
- Ensures startup has run.
- If an account is active, marks the store signed in and returns.
- Creates a `login` redirect flow.
- Starts `msal.loginRedirect()` with login scopes and optional API scope.
Options:
```ts
{
returnTo?: string;
includeApiScope?: boolean;
}
```
### `continueApiTokenRedirect(options?)`
Starts an interactive redirect flow to get an API access token.
Behavior:
- Requires configured auth.
- Ensures startup has run.
- If no account is active, delegates to `login({ includeApiScope: true })`.
- If an account is active, creates an `api-token` redirect flow and starts `msal.acquireTokenRedirect()`.
### `getApiToken()`
Returns an API access token using silent token acquisition.
Behavior:
- Requires configured auth.
- Ensures startup has run.
- Requires an active account.
- Calls `msal.acquireTokenSilent()` with the configured API scope.
- Updates the active account from the token result if MSAL returns one.
- Returns `result.accessToken`.
Throws:
- `AuthLoginRequiredError` when there is no active account.
- `AuthInteractionRequiredError` when MSAL requires interactive auth.
- A normalized `Error` for other failures, which is also stored in state.
### `logout()`
Starts MSAL logout redirect.
Behavior:
- Requires configured auth.
- Sets status to `"redirecting"`.
- Calls `msal.logoutRedirect()` with the current account and configured post-logout redirect URI.
### `reset()`
Resets the Zustand store to `initialState`.
This is local state cleanup only. It does not clear MSAL cache by itself and does not redirect.
## Error Types
### `AuthLoginRequiredError`
Thrown by `getApiToken()` when no account is active.
Typical caller response:
```ts
await auth.login({ returnTo: currentRoute });
```
### `AuthInteractionRequiredError`
Thrown by `getApiToken()` when MSAL reports that silent token acquisition is not enough.
Typical caller response:
```ts
await auth.continueApiTokenRedirect({ returnTo: currentRoute });
```
### `AuthRedirectStartedError`
Thrown when code tries to start a second redirect while `status === "redirecting"`.
This protects against duplicate login/token/logout redirects.
## Internal Function Reference
These functions are implementation details. They are intentionally not exported.
### Route Helpers
#### `getCurrentPathWithSearch()`
Returns the current browser path and query string.
If called outside the browser, returns `DEFAULT_SIGNED_IN_ROUTE`.
Used by `normalizeReturnTo()` when no explicit `returnTo` is provided.
#### `normalizeReturnTo(returnTo?)`
Sanitizes the route the app should navigate to after auth redirect completion.
Rules:
- Allows only same-origin URLs.
- Converts absolute same-origin URLs to path plus query.
- Rejects auth/login, auth/callback, and logged-out routes.
- Falls back to `DEFAULT_SIGNED_IN_ROUTE` for unsafe or invalid input.
This prevents open redirects and avoids returning users to auth infrastructure routes.
#### `resolvePostRedirectTarget(redirectResult, redirectFlow)`
Determines where the app should navigate after handling an MSAL redirect result.
Rules:
- If there is no redirect result, returns `null`.
- If `redirectResult.state` matches `redirectFlow.flowId`, returns `redirectFlow.returnTo`.
- If the result exists but cannot be correlated, returns `DEFAULT_SIGNED_IN_ROUTE`.
### MSAL Helpers
#### `getConfigKey(config)`
Builds a stable string key from auth config fields that affect MSAL behavior.
Used by `configure()` to decide whether the existing MSAL client can be reused.
#### `createMsalClient(config)`
Creates a `PublicClientApplication`.
Important settings:
- Uses configured client ID, authority, redirect URI, and logout redirect URI.
- Sets `navigateToLoginRequestUrl: false` because the app owns redirect routing through `redirectFlow`.
- Uses `BrowserCacheLocation.SessionStorage`.
#### `getActiveAccount(msal, redirectResult?)`
Finds and sets the active account.
Priority:
1. Account from the redirect result.
2. Existing MSAL active account.
3. First account from `msal.getAllAccounts()`.
4. `null`.
If an account is found, it is written back to MSAL with `setActiveAccount()`.
#### `getLoginScopes(config, includeApiScope?)`
Returns the scopes for login redirect.
- Default: `["openid", "profile"]`.
- With `includeApiScope`: `["openid", "profile", config.apiScope]`.
#### `createRedirectFlow(kind, returnTo?)`
Creates redirect metadata for login or API-token redirect.
Fields:
- `flowId`: random UUID passed to MSAL as `state`.
- `returnTo`: sanitized post-auth app route.
- `kind`: `"login"` or `"api-token"`.
### State Helpers
#### `normalizeAuthError(error)`
Converts unknown thrown values into an `Error`.
Preserves existing `Error` instances. Converts non-Error values to a generic auth error.
#### `signedInState(account)`
Returns the state patch for a successful signed-in result.
Sets:
- `account`
- `status: "signed-in"`
- `error: null`
- `redirectFlow: null`
#### `signedOutState()`
Returns the state patch for a successful signed-out result.
Sets:
- `account: null`
- `status: "signed-out"`
- `error: null`
- `redirectFlow: null`
#### `redirectingState(redirectFlow)`
Returns the state patch for an in-progress redirect.
Sets:
- `status: "redirecting"`
- `error: null`
- `redirectFlow`
#### `authErrorState(error, options?)`
Returns the state patch for an auth error.
Always sets:
- `status: "error"`
- `error`
Optionally clears:
- `account`
- `redirectFlow`
The helper only includes optional fields when they should be cleared, so it does not accidentally overwrite existing state with `undefined`.
#### `startingState(startupPromise)`
Returns the state patch for startup.
Sets:
- `status: "starting"`
- `error: null`
- `startupPromise`
### Auth Operations
`createAuthOperations(set, get)` creates closure-scoped operations used by public actions.
These are not public Zustand actions. They exist to keep public action workflows shorter and consistent.
#### `isRedirecting()`
Returns whether the store is currently redirecting.
Implementation:
```ts
get().status === "redirecting"
```
#### `requireConfigured()`
Returns `{ msal, config }` when auth is configured.
Throws a normal `Error` when either value is missing.
This keeps public actions from repeatedly checking `if (!msal || !config)`.
#### `failRedirect(error)`
Normalizes a redirect failure, stores it as auth error state, clears `redirectFlow`, and rethrows it.
Used by `beginRedirect()`.
#### `beginRedirect(redirectFlow, redirect)`
Shared redirect wrapper for login, API-token redirect, and logout.
Behavior:
- Throws `AuthRedirectStartedError` if already redirecting.
- Sets redirecting state.
- Runs the provided redirect function.
- On failure, delegates to `failRedirect()`.
The `redirect` callback is usually one of:
- `msal.loginRedirect(...)`
- `msal.acquireTokenRedirect(...)`
- `msal.logoutRedirect(...)`
#### `completeSignedIn(account)`
Applies `signedInState(account)`.
Used after startup, login short-circuit, and silent token acquisition.
#### `completeSignedOut()`
Applies `signedOutState()`.
Used internally by `completeStartup()` when startup finds no account.
#### `completeStartup(account, redirectTo)`
Applies signed-in or signed-out state after startup and returns the `AuthStartResult`.
If `account` exists, it signs in. Otherwise, it signs out.
#### `failStartup(error)`
Normalizes startup failure, stores it as auth error state, clears `account` and `redirectFlow`, and rethrows it.
Used by `start()`.
### Store Creator
#### `createAuthActions(set, get)`
Creates the public `actions` object.
This function wires public actions to store-local auth operations.
#### `createAuthStoreState(set, get)`
Creates the full Zustand state object:
```ts
{
...initialState,
actions: createAuthActions(set, get),
}
```
#### `useAuthStore`
The main Zustand hook.
Use this to select auth state:
```ts
const status = useAuthStore((state) => state.status);
const account = useAuthStore((state) => state.account);
const error = useAuthStore((state) => state.error);
```
#### `useAuthStoreActions()`
Convenience hook for selecting only actions:
```ts
const auth = useAuthStoreActions();
```
Prefer this in components that only need to invoke auth workflows.
## Redirect Flow Details
Redirect flows solve one problem: MSAL redirect navigation unloads the app, but the app still needs to remember where the user was trying to go.
Before redirect:
```ts
const flow = createRedirectFlow("login", "/target-page");
await msal.loginRedirect({
scopes,
state: flow.flowId,
});
```
The store persists:
```ts
{
redirectFlow: {
flowId,
returnTo: "/target-page",
kind: "login"
}
}
```
After Microsoft redirects back:
```ts
const redirectResult = await msal.handleRedirectPromise();
```
Then:
- If `redirectResult.state === redirectFlow.flowId`, the app navigates to `redirectFlow.returnTo`.
- If the result exists but does not match, the app navigates to the default signed-in route.
- If there is no redirect result, no navigation is requested.
## React Router Integration
This app uses React Router data APIs, so auth should be integrated through route `clientLoader` functions rather than a root `useEffect`.
React Router supports browser-side data loading through `clientLoader`, loader redirects through `redirect`, and reading parent route data in components through `useRouteLoaderData`.
Important rules:
- Do not call React hooks inside loaders.
- Use `useAuthStore.getState().actions` inside loaders and other non-React modules.
- Use `useAuthStore(...)` and `useAuthStoreActions()` inside components.
- Do not assume a parent loader has completed before a child loader runs. Make auth startup idempotent and await it from every loader that needs auth.
- Route loaders should enforce route-level access. Components should use hooks for display decisions and smaller entitlement gates.
### Shared Auth Loader Utilities
Create one browser-only utility module for React Router client loaders.
Example path:
```txt
~/lib/auth/auth-loader.client.ts
```
Example implementation:
```ts
import { redirect } from "react-router";
import type { AuthConfig } from "~/lib/api/service-types";
import {
AuthInteractionRequiredError,
AuthLoginRequiredError,
useAuthStore,
} from "~/lib/auth/auth-store";
const AUTH_LOGIN_ROUTE = "/auth/login";
const AUTH_API_TOKEN_ROUTE = "/auth/api-token";
let authConfigPromise: Promise<AuthConfig> | null = null;
const getReturnTo = (request: Request) => {
const url = new URL(request.url);
return `${url.pathname}${url.search}`;
};
const getLoginUrl = (request: Request, includeApiScope = false) => {
const url = new URL(AUTH_LOGIN_ROUTE, request.url);
url.searchParams.set("returnTo", getReturnTo(request));
if (includeApiScope) {
url.searchParams.set("includeApiScope", "true");
}
return `${url.pathname}${url.search}`;
};
const getApiTokenUrl = (request: Request) => {
const url = new URL(AUTH_API_TOKEN_ROUTE, request.url);
url.searchParams.set("returnTo", getReturnTo(request));
return `${url.pathname}${url.search}`;
};
const loadAuthConfig = async () => {
authConfigPromise ??= fetch("/api/auth/config").then((response) => {
if (!response.ok) {
throw new Error("Failed to load auth config.");
}
return response.json() as Promise<AuthConfig>;
});
try {
return await authConfigPromise;
} catch (error) {
authConfigPromise = null;
throw error;
}
};
export const ensureAuthStarted = async () => {
const config = await loadAuthConfig();
const auth = useAuthStore.getState().actions;
auth.configure(config);
const startResult = await auth.start();
if (startResult.redirectTo) {
throw redirect(startResult.redirectTo);
}
return useAuthStore.getState();
};
export const requireSignedIn = async (request: Request) => {
const state = await ensureAuthStarted();
if (state.status !== "signed-in" || !state.account) {
throw redirect(getLoginUrl(request));
}
return state.account;
};
export const requireApiToken = async (request: Request) => {
await requireSignedIn(request);
try {
return await useAuthStore.getState().actions.getApiToken();
} catch (error) {
if (error instanceof AuthLoginRequiredError) {
throw redirect(getLoginUrl(request, true));
}
if (error instanceof AuthInteractionRequiredError) {
throw redirect(getApiTokenUrl(request));
}
throw error;
}
};
```
Why this utility exists:
- `ensureAuthStarted()` can be called by root, layout, and leaf route client loaders.
- `start()` already deduplicates concurrent startup work through `startupPromise`.
- Client loaders do not need hooks or component effects.
- Route protection stays in the route data layer.
### Root Route Client Loader
Use the root route to start auth during hydration and handle MSAL redirect return navigation.
```tsx
import type { Route } from "./+types/root";
import { Outlet } from "react-router";
import { ensureAuthStarted } from "~/lib/auth/auth-loader.client";
import { useAuthStore } from "~/lib/auth/auth-store";
export async function clientLoader() {
await ensureAuthStarted();
return null;
}
clientLoader.hydrate = true as const;
export function HydrateFallback() {
return <AppShellLoading />;
}
export default function Root() {
const status = useAuthStore((state) => state.status);
return (
<AppFrame authStatus={status}>
<Outlet />
</AppFrame>
);
}
```
Notes:
- `clientLoader.hydrate = true` forces the client loader to run during hydration before the route renders.
- `ensureAuthStarted()` throws a React Router redirect when the store handled an MSAL redirect result and has a `redirectTo` target.
- Child loaders that need auth should still call `ensureAuthStarted()` or `requireApiToken()`.
### Login Route Client Loader
The login route centralizes interactive login redirects.
Example path:
```txt
app/routes/auth.login.tsx
```
```tsx
import type { Route } from "./+types/auth.login";
import { ensureAuthStarted } from "~/lib/auth/auth-loader.client";
import { useAuthStore } from "~/lib/auth/auth-store";
export async function clientLoader({ request }: Route.ClientLoaderArgs) {
await ensureAuthStarted();
const url = new URL(request.url);
const returnTo = url.searchParams.get("returnTo");
const includeApiScope = url.searchParams.get("includeApiScope") === "true";
await useAuthStore.getState().actions.login({
returnTo,
includeApiScope,
});
return null;
}
export function HydrateFallback() {
return <AuthRedirectingScreen />;
}
export default function LoginRoute() {
return <AuthRedirectingScreen />;
}
```
### API Token Continuation Route
Use a separate route when a user is signed in but MSAL requires interactive consent or token refresh for the API scope.
Example path:
```txt
app/routes/auth.api-token.tsx
```
```tsx
import type { Route } from "./+types/auth.api-token";
import { ensureAuthStarted } from "~/lib/auth/auth-loader.client";
import { useAuthStore } from "~/lib/auth/auth-store";
export async function clientLoader({ request }: Route.ClientLoaderArgs) {
await ensureAuthStarted();
const url = new URL(request.url);
const returnTo = url.searchParams.get("returnTo");
await useAuthStore.getState().actions.continueApiTokenRedirect({
returnTo,
});
return null;
}
export function HydrateFallback() {
return <AuthRedirectingScreen />;
}
export default function ApiTokenRoute() {
return <AuthRedirectingScreen />;
}
```
### Protected Route Client Loader
For a route that only requires a signed-in user:
```tsx
import type { Route } from "./+types/control-exceptions";
import { requireSignedIn } from "~/lib/auth/auth-loader.client";
export async function clientLoader({ request }: Route.ClientLoaderArgs) {
const account = await requireSignedIn(request);
return {
accountHomeId: account.homeAccountId,
};
}
export default function ControlExceptionsRoute({
loaderData,
}: Route.ComponentProps) {
return <ControlExceptionsPage accountHomeId={loaderData.accountHomeId} />;
}
```
For a route that also needs API data:
```tsx
import type { Route } from "./+types/control-exceptions";
import { requireApiToken } from "~/lib/auth/auth-loader.client";
export async function clientLoader({ request }: Route.ClientLoaderArgs) {
const token = await requireApiToken(request);
const response = await fetch("/api/control-exceptions", {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!response.ok) {
throw response;
}
return {
controlExceptions: await response.json(),
};
}
```
## Entitlements
Entitlements should normally be loaded by React Router client loaders, not by random component effects.
Good division of responsibility:
- Auth store owns MSAL lifecycle, accounts, redirects, and tokens.
- A protected layout client loader owns loading the current user's entitlements.
- Components use hooks to read entitlement data from route loader data.
- Route client loaders enforce hard access rules before protected data is fetched.
- Component hooks handle view-level gates such as hiding buttons, tabs, or panels.
### Protected Layout Entitlements Loader
Create a protected layout route that wraps routes needing the signed-in user's entitlements.
Example path:
```txt
app/routes/_authenticated.tsx
```
```tsx
import type { Route } from "./+types/_authenticated";
import { Outlet } from "react-router";
import { requireApiToken } from "~/lib/auth/auth-loader.client";
export type Entitlement =
| "control-exceptions:read"
| "control-exceptions:write"
| "users:read";
export type AuthenticatedRouteData = {
entitlements: Entitlement[];
};
export async function clientLoader({
request,
}: Route.ClientLoaderArgs): Promise<AuthenticatedRouteData> {
const token = await requireApiToken(request);
const response = await fetch("/api/me/entitlements", {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!response.ok) {
throw response;
}
return {
entitlements: await response.json(),
};
}
export default function AuthenticatedLayout() {
return <Outlet />;
}
```
### Entitlement Hooks
Use `useRouteLoaderData` to read the protected layout's loader data from any child component.
```ts
import { useMemo } from "react";
import { useRouteLoaderData } from "react-router";
import type {
AuthenticatedRouteData,
Entitlement,
} from "~/routes/_authenticated";
const AUTHENTICATED_ROUTE_ID = "routes/_authenticated";
export const useEntitlements = () => {
const data = useRouteLoaderData<AuthenticatedRouteData>(
AUTHENTICATED_ROUTE_ID,
);
if (!data) {
throw new Error(
"useEntitlements must be used under the authenticated route.",
);
}
return data.entitlements;
};
export const useHasEntitlement = (entitlement: Entitlement) => {
const entitlements = useEntitlements();
return entitlements.includes(entitlement);
};
export const useEntitlementSet = () => {
const entitlements = useEntitlements();
return useMemo(() => new Set(entitlements), [entitlements]);
};
```
Use the hooks inside normal React components:
```tsx
function ControlExceptionActions() {
const canWrite = useHasEntitlement("control-exceptions:write");
return (
<Toolbar>
<Button disabled={!canWrite}>Create exception</Button>
</Toolbar>
);
}
```
For multiple checks, prefer the `Set` hook:
```tsx
function ControlExceptionTabs() {
const entitlements = useEntitlementSet();
return (
<Tabs>
<Tab id="mine">Mine</Tab>
{entitlements.has("users:read") && <Tab id="all">All users</Tab>}
</Tabs>
);
}
```
### Entitlement Gate Component
Use a gate for small conditional UI regions.
```tsx
import type { ReactNode } from "react";
import type { Entitlement } from "~/routes/_authenticated";
import { useHasEntitlement } from "~/lib/auth/use-entitlements";
type EntitlementGateProps = {
entitlement: Entitlement;
children: ReactNode;
fallback?: ReactNode;
};
export function EntitlementGate({
entitlement,
children,
fallback = null,
}: EntitlementGateProps) {
const allowed = useHasEntitlement(entitlement);
return allowed ? children : fallback;
}
```
Example:
```tsx
<EntitlementGate entitlement="control-exceptions:write">
<CreateControlExceptionButton />
</EntitlementGate>
```
### Route-Level Entitlement Enforcement
Component gates are not enough for protected data. If a route requires a specific entitlement, enforce it in the route's client loader before fetching route data.
Route loaders cannot use `useEntitlements()`, because hooks only work during React render. Either fetch entitlements in the route loader or move the entitlement fetch behind a shared client-side authorization cache that both the protected layout and route loaders can use.
```tsx
import { redirect } from "react-router";
import type { Route } from "./+types/admin-users";
import { requireApiToken } from "~/lib/auth/auth-loader.client";
import type { Entitlement } from "~/routes/_authenticated";
const requiredEntitlement: Entitlement = "users:read";
export async function clientLoader({ request }: Route.ClientLoaderArgs) {
const token = await requireApiToken(request);
const entitlementsResponse = await fetch("/api/me/entitlements", {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!entitlementsResponse.ok) {
throw entitlementsResponse;
}
const entitlements = (await entitlementsResponse.json()) as Entitlement[];
if (!entitlements.includes(requiredEntitlement)) {
throw redirect("/forbidden");
}
const usersResponse = await fetch("/api/users", {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!usersResponse.ok) {
throw usersResponse;
}
return {
users: await usersResponse.json(),
};
}
```
If many routes need entitlement checks, extract a shared loader utility:
```ts
export const requireEntitlement = async (
request: Request,
entitlement: Entitlement,
) => {
const token = await requireApiToken(request);
const response = await fetch("/api/me/entitlements", {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!response.ok) {
throw response;
}
const entitlements = (await response.json()) as Entitlement[];
if (!entitlements.includes(entitlement)) {
throw redirect("/forbidden");
}
return {
token,
entitlements,
};
};
```
### When To Use The Auth Store Hook
Use `useAuthStore` for auth lifecycle state:
```tsx
const status = useAuthStore((state) => state.status);
const account = useAuthStore((state) => state.account);
const error = useAuthStore((state) => state.error);
```
Use `useAuthStoreActions()` for user-triggered auth commands:
```tsx
const auth = useAuthStoreActions();
return <Button onClick={() => auth.logout()}>Sign out</Button>;
```
Do not put entitlement checks directly in the auth store unless entitlements are truly part of auth lifecycle state. In most cases, entitlements are app authorization data and should come from route loader data or a dedicated authorization cache.
## Testing Guidance
### Unit Test Route Helpers
Good targets:
- `normalizeReturnTo()`
- `resolvePostRedirectTarget()`
- `getLoginScopes()`
- state helpers like `signedInState()` and `authErrorState()`
These are deterministic and can be tested without MSAL.
### Unit Test Actions With A Mock MSAL Client
Mock the MSAL methods used by the store:
- `initialize`
- `handleRedirectPromise`
- `getActiveAccount`
- `getAllAccounts`
- `setActiveAccount`
- `loginRedirect`
- `acquireTokenSilent`
- `acquireTokenRedirect`
- `logoutRedirect`
Recommended action tests:
- `configure()` creates MSAL and sets status to `"configured"`.
- `configure()` with same config reuses MSAL.
- `start()` handles redirect result and returns `redirectTo`.
- `start()` deduplicates concurrent calls.
- `login()` redirects only when no account exists.
- `getApiToken()` returns access token when silent acquisition succeeds.
- `getApiToken()` throws `AuthLoginRequiredError` when signed out.
- `getApiToken()` wraps interaction-required MSAL errors.
- `continueApiTokenRedirect()` delegates to login when no account exists.
- `logout()` starts redirecting and calls `logoutRedirect()`.
### Integration Test Redirect Flow
The important redirect behavior to verify:
1. App starts login with `returnTo`.
2. Store persists `redirectFlow`.
3. App reloads.
4. `start()` handles redirect result with matching `state`.
5. `start()` returns the original `returnTo`.
6. Store clears `redirectFlow`.
## Contributor Rules
- Do not add MSAL calls outside this store unless there is a clear architectural reason.
- Do not persist `account`, `msal`, `startupPromise`, `error`, or tokens.
- Do not add private implementation functions to `actions`.
- Keep public actions small and workflow-shaped.
- Add store-local operations when multiple public actions need the same lifecycle behavior.
- Preserve `state` correlation when adding new redirect flows.
- Keep `returnTo` sanitized before storing it.
- Prefer throwing typed auth errors when callers can take a specific recovery action.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment