Six files under src/auth/:
- client.ts: createDirectus<Schema>().with(authentication('cookie', ...))
.with(rest(...)). Lazy singleton via initDirectusClient(url) +
getDirectus() + useDirectus() (React-side helper). DirectusUser type;
empty Schema (grows in Phase 2+).
- store.ts: Zustand store with discriminated AuthState
(unknown / anonymous / authenticating / authenticated). Actions:
initialize, login, logout, setUser. humanizeAuthError maps Directus
error codes (INVALID_CREDENTIALS / INVALID_OTP / USER_SUSPENDED) to
user-facing strings.
- guard.ts: useRequireAuth (redirect to /login on anonymous) and
useRequireRole (bounce to / on role mismatch). Uses TanStack Router's
useNavigate; effective once 1.7 wires the router.
- bootstrap.tsx: <AuthBootstrap> component runs initDirectusClient +
initialize() once after the runtime config is loaded.
- index.ts: barrel re-exports.
main.tsx wraps App in <RuntimeConfigProvider><AuthBootstrap>...
App.tsx shows the auth status as a smoke indicator before 1.6/1.7.
Deviations:
1. Split getDirectus into initDirectusClient(url) + getDirectus()
because the runtime config is a React context (per 1.4), not a
Zustand store. <AuthBootstrap> bridges from React land to the non-
React getter.
2. Added <AuthBootstrap> as a discrete component rather than putting
the bootstrap effect in App.tsx — App.tsx gets replaced by the
router in 1.7, but bootstrap needs to keep running.
13 KiB
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: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<Schema>(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: trueenables the SDK's built-in 401 interceptor.
- The
-
src/auth/store.ts— Zustand auth store:type AuthState = | { status: 'unknown' } // initial; before /users/me check | { status: 'anonymous' } | { status: 'authenticated'; user: DirectusUser } | { status: 'authenticating' }; // login in progress type AuthActions = { initialize(): Promise<void>; // call /users/me; set status accordingly login(email: string, password: string): Promise<{ ok: true } | { ok: false; error: string }>; logout(): Promise<void>; setUser(user: DirectusUser): void; // for after refresh sees a user-update };initializeis called once at app boot from<RuntimeConfigProvider>'s post-load step. It tries/users/me; on 200, setsstatus: 'authenticated'; on 401/error, setsstatus: 'anonymous'.
-
src/auth/guard.ts—useRequireAuth()hook used by Phase 1.7's protected routes: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— calluseAuthStore.getState().initialize()after the runtime config loads. (More precisely: theRuntimeConfigProvider's success path triggersinitialize()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:
- No XSS exfiltration of long-lived credentials. The refresh cookie is
httpOnly; JS can never read it. - WS upgrade carries the cookie automatically. Same-origin → browser attaches it. No "send the JWT in the upgrade message" dance.
- 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 <RuntimeConfigProvider> 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.
// src/auth/client.ts
let _client: ReturnType<typeof createClient> | null = null;
export function getDirectus() {
if (!_client) {
const cfg = useRuntimeConfig.getState(); // or pass via param; see below
_client = createDirectus<Schema>(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
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)
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()
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:checkclean.- In a browser console (with Directus running locally + a known user seeded):
useAuthStore.getState().login('admin@example.com', 'password')resolves to{ ok: true }anduseAuthStore.getState().status === 'authenticated'. - After login,
document.cookieshows nothing (refresh cookie is httpOnly — invisible to JS, as intended). - After login,
useAuthStore.getState().useris 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/sdkcookie 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 theauthentication('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<Schema>().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).DirectusUsertype + emptySchemaplaceholder (grows in Phase 2+).store.ts— Zustand store with discriminatedAuthState(unknown/anonymous/authenticating/authenticated) and four actions (initialize,login,logout,setUser).humanizeAuthErrormaps Directus error codes (INVALID_CREDENTIALS,INVALID_OTP,USER_SUSPENDED) to clear strings; falls back toerrors[0].messagethen a generic.guard.ts—useRequireAuth()(redirects to/loginon'anonymous') anduseRequireRole(allowedRoles)(bounces to/on role mismatch). Uses TanStack Router'suseNavigate; only takes effect once 1.7 wires the router.bootstrap.tsx—<AuthBootstrap>component: callsinitDirectusClient(cfg.directusUrl)and kicks off the one-timeinitialize()probe. Lives inside<RuntimeConfigProvider>so the config is loaded by the time it mounts.index.ts— barrel re-exports.
src/main.tsx now wraps <App /> in <RuntimeConfigProvider><AuthBootstrap><App /></AuthBootstrap></RuntimeConfigProvider>.
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:
-
Spec sketched a single
getDirectus()that read fromuseRuntimeConfig.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 intoinitDirectusClient(url)(called once from inside the React tree by<AuthBootstrap>) +getDirectus()(non-React, throws if init not yet called). The lazy-creation contract from the spec still holds. -
Introduced
<AuthBootstrap>as a discrete component rather than putting the bootstrap effect insideApp.tsx. Reason: 1.7 will replaceApp.tsxwith 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.