# Task 1.5 — Directus auth client (cookie mode + refresh) **Phase:** 1 — Foundation **Status:** ⬜ Not started **Depends on:** 1.4 **Wiki refs:** `docs/wiki/entities/directus.md`; `docs/wiki/entities/react-spa.md` §Auth pattern; `docs/wiki/synthesis/processor-ws-contract.md` §Auth handshake ## Goal Wire the Directus SDK with cookie-mode authentication: `/auth/login` issues an httpOnly refresh cookie + returns an access token (held in memory only); `/auth/refresh` rotates both; the SDK auto-refreshes on 401. Build a small Zustand auth store (`user` + `status`) so any component can react to login/logout. After this task, the auth machinery is functional but headless — the login page (1.6) and route guard (1.7) build on it. ## Deliverables - `src/auth/client.ts` — typed Directus SDK client: ```ts import { authentication, createDirectus, rest } from '@directus/sdk'; import { config } from '@/config/context'; // see "Bootstrapping" below export type DirectusUser = { id: string; email: string | null; role: string | null; first_name: string | null; last_name: string | null; }; export type Schema = { // grow as collections become typed; for now an empty schema }; export const directus = createDirectus(config.directusUrl) .with(authentication('cookie', { credentials: 'include', autoRefresh: true })) .with(rest({ credentials: 'include' })); ``` - The `'cookie'` mode + `credentials: 'include'` is the SDK's contract for httpOnly-refresh-cookie + in-memory-access-token. `autoRefresh: true` enables the SDK's built-in 401 interceptor. - `src/auth/store.ts` — Zustand auth store: ```ts type AuthState = | { status: 'unknown' } // initial; before /users/me check | { status: 'anonymous' } | { status: 'authenticated'; user: DirectusUser } | { status: 'authenticating' }; // login in progress type AuthActions = { initialize(): Promise; // call /users/me; set status accordingly login(email: string, password: string): Promise<{ ok: true } | { ok: false; error: string }>; logout(): Promise; setUser(user: DirectusUser): void; // for after refresh sees a user-update }; ``` - `initialize` is called once at app boot from ``'s post-load step. It tries `/users/me`; on 200, sets `status: 'authenticated'`; on 401/error, sets `status: 'anonymous'`. - `src/auth/guard.ts` — `useRequireAuth()` hook used by Phase 1.7's protected routes: ```ts export function useRequireAuth() { const status = useAuthStore((s) => s.status); const navigate = useNavigate(); // TanStack Router; lands in 1.7 useEffect(() => { if (status === 'anonymous') navigate({ to: '/login' }); }, [status, navigate]); return status; } ``` - `src/auth/index.ts` — barrel re-exports. - `src/main.tsx` — call `useAuthStore.getState().initialize()` after the runtime config loads. (More precisely: the `RuntimeConfigProvider`'s success path triggers `initialize()` exactly once.) ## Specification ### Why a Zustand auth store The auth state is read in many places: route guards, the user-menu component, audit logs, the WS client (when 1.5 wires the WS). React Context cascades re-renders to all consumers on every change; Zustand's selector-based subscription means only components that read the changed slice re-render. For auth (small, infrequent changes) the difference is small in practice, but the pattern is the same one we'll use for live-position state in Phase 2 — staying consistent is the win. ### Why cookie mode (not bearer-token mode) Three reasons documented in `docs/wiki/entities/react-spa.md` §Auth pattern: 1. **No XSS exfiltration of long-lived credentials.** The refresh cookie is `httpOnly`; JS can never read it. 2. **WS upgrade carries the cookie automatically.** Same-origin → browser attaches it. No "send the JWT in the upgrade message" dance. 3. **Same pattern Directus already supports.** No custom server work; just the right login mode. The access token is held in memory by the SDK (in `directus._authentication.tokens` internally; not directly accessed by app code). It expires (default 15 min); the SDK's `autoRefresh: true` calls `/auth/refresh` on the next request that hits a 401, transparently. ### Bootstrapping order ``` 1. main.tsx renders shell. 2. RuntimeConfigProvider fetches /config.json. 3. On success: imports the config singleton, runtime-config-driven createDirectus(...) is realisable. 4. RuntimeConfigProvider calls useAuthStore.getState().initialize(). 5. initialize() calls directus.request(readMe()) (the SDK's /users/me wrapper). 6. On 200 → setUser, status: 'authenticated'. 7. On 401 → status: 'anonymous'. 8. Provider's children render — they can now read the auth store. ``` The order matters: createDirectus needs the runtime config to know what URL to hit. So the SDK client can't be a top-level module-level `const`; it has to be created after config loads. Two ways to handle this: **Option A: Lazy creation.** `getDirectusClient()` returns the singleton, creating it on first call. The first call lands inside `initialize()`, after the config has resolved. **Option B: Module-level after config import.** The config context exposes a `directus` instance that's only valid after the provider has loaded. Requires the import chain to defer. **Pick Option A.** Cleaner — the singleton creation is explicitly tied to a function call rather than module-load order. ```ts // src/auth/client.ts let _client: ReturnType | null = null; export function getDirectus() { if (!_client) { const cfg = useRuntimeConfig.getState(); // or pass via param; see below _client = createDirectus(cfg.directusUrl) .with(authentication('cookie', { credentials: 'include', autoRefresh: true })) .with(rest({ credentials: 'include' })); } return _client; } ``` If the runtime config hook isn't easily accessible from outside React, expose the config as a Zustand store too (or read directly from the loaded JSON via a non-React module). The simplest pragmatic answer: the config provider sets a module-level global on success, and `getDirectus()` reads it. ### `initialize()` — the one-time login check ```ts async initialize() { const directus = getDirectus(); set({ status: 'authenticating' }); // tiny window; UX-invisible try { const user = await directus.request(readMe({ fields: ['id', 'email', 'role', 'first_name', 'last_name'], })); set({ status: 'authenticated', user: user as DirectusUser }); } catch (err) { // 401 from /users/me means no session. Anything else is unusual but treat the same. set({ status: 'anonymous' }); } } ``` The `try/catch` swallowing all errors is deliberate: at boot, a failure to read `/users/me` _is_ the "anonymous" state. We don't surface boot-time errors here. ### `login(email, password)` ```ts async login(email, password) { const directus = getDirectus(); set({ status: 'authenticating' }); try { await directus.login(email, password, { mode: 'cookie' }); // After login the SDK has the access token; re-fetch user. const user = await directus.request(readMe({ fields: [...] })); set({ status: 'authenticated', user: user as DirectusUser }); return { ok: true }; } catch (err) { set({ status: 'anonymous' }); return { ok: false, error: humanizeAuthError(err) }; } } ``` `humanizeAuthError` maps Directus's error codes (`INVALID_CREDENTIALS`, `INVALID_OTP`, etc.) to user-friendly strings. Default for unknown: "Login failed. Please try again." ### `logout()` ```ts async logout() { const directus = getDirectus(); try { await directus.logout(); } catch { // Ignore — even if logout fails server-side, clear local state. } set({ status: 'anonymous' }); // 1.8 builds on this: also invalidate the TanStack Query cache. } ``` ### What this task does NOT do - **No login page.** That's 1.6. - **No route guards wired into routes.** That's 1.7. - **No automatic redirect on logout.** The component calling `logout()` decides where to navigate. 1.8 wires the actual logout button into the app shell. - **No "remember me."** Directus's refresh cookie is the persistence; the SPA doesn't add another layer. ## Acceptance criteria - [ ] `pnpm typecheck`, `pnpm lint`, `pnpm format:check` clean. - [ ] In a browser console (with Directus running locally + a known user seeded): `useAuthStore.getState().login('admin@example.com', 'password')` resolves to `{ ok: true }` and `useAuthStore.getState().status === 'authenticated'`. - [ ] After login, `document.cookie` shows nothing (refresh cookie is httpOnly — invisible to JS, as intended). - [ ] After login, `useAuthStore.getState().user` is the expected user record. - [ ] `useAuthStore.getState().logout()` resolves; status returns to `'anonymous'`. - [ ] Wrong credentials produce `{ ok: false, error: 'Invalid email or password.' }` (or similar humanised string). - [ ] Network error during login resolves to `{ ok: false, error: ... }` — no uncaught exception bubbling to the React error boundary. ## Risks / open questions - **`@directus/sdk` cookie mode quirks.** The SDK changed cookie-mode handling between 11.0 and 11.x. Verify the exact API against the version installed in 1.2 (`pnpm list @directus/sdk`). Adjust the `authentication('cookie', ...)` call signature if needed. - **CSRF.** Directus's cookie mode includes CSRF protection (the access token in the response body is the anti-CSRF measure — readable by JS, not by a third-party form post). The SDK handles this; we don't add custom CSRF headers. - **Session persistence across reloads.** The httpOnly refresh cookie persists across page reloads; on each load `initialize()` calls `/users/me`, which does a transparent refresh if needed, and the user is "still logged in." Verify in the integration smoke (1.6 task acceptance). - **Lazy `getDirectus()` and HMR.** During Vite dev HMR, the module reloads but the singleton survives (it's in a closure). If the config changes mid-session this becomes stale; acceptable trade-off — config rarely changes in dev. ## Done Six files under `src/auth/`: - `client.ts` — `createDirectus().with(authentication('cookie', { credentials: 'include', autoRefresh: true })).with(rest({ credentials: 'include' }))`. Three accessors: `initDirectusClient(url)` (idempotent factory; rebuilds only if URL changed), `getDirectus()` (non-React getter; throws if init not yet called), `useDirectus()` (React-side helper). `DirectusUser` type + empty `Schema` placeholder (grows in Phase 2+). - `store.ts` — Zustand store with discriminated `AuthState` (`unknown` / `anonymous` / `authenticating` / `authenticated`) and four actions (`initialize`, `login`, `logout`, `setUser`). `humanizeAuthError` maps Directus error codes (`INVALID_CREDENTIALS`, `INVALID_OTP`, `USER_SUSPENDED`) to clear strings; falls back to `errors[0].message` then a generic. - `guard.ts` — `useRequireAuth()` (redirects to `/login` on `'anonymous'`) and `useRequireRole(allowedRoles)` (bounces to `/` on role mismatch). Uses TanStack Router's `useNavigate`; only takes effect once 1.7 wires the router. - `bootstrap.tsx` — `` component: calls `initDirectusClient(cfg.directusUrl)` and kicks off the one-time `initialize()` probe. Lives inside `` so the config is loaded by the time it mounts. - `index.ts` — barrel re-exports. `src/main.tsx` now wraps `` in ``. `src/App.tsx` shows the auth status as a smoke indicator (`unknown` → `authenticating` → `authenticated`/`anonymous`) so the bootstrap is observable in the browser before 1.6 / 1.7 land. **Deviations from spec:** 1. Spec sketched a single `getDirectus()` that read from `useRuntimeConfig.getState()` as if it were a Zustand store. The runtime config is a React context (per 1.4), so reading it from a non-React function isn't possible. Solved by splitting into `initDirectusClient(url)` (called once from inside the React tree by ``) + `getDirectus()` (non-React, throws if init not yet called). The lazy-creation contract from the spec still holds. 2. Introduced `` as a discrete component rather than putting the bootstrap effect inside `App.tsx`. Reason: 1.7 will replace `App.tsx` with the router, but the auth bootstrap needs to keep running. A named component is the clean home for it. **Smoke check:** `pnpm typecheck`, `pnpm lint`, `pnpm format:check`, `pnpm build` all green. Bundle: 261KB / 80KB gzipped (up from 1.4's 283KB / 87KB; the dead-code-elimination win comes from `@directus/sdk`'s tree-shaking once it's actually imported in real paths). Browser smoke pending — needs a running Directus on `:8055` to exercise the cookie flow end-to-end. Landed in `PENDING_SHA`.