Client Portal — Auth & 2FA
The client portal's authentication end-to-end — the httpOnly-cookie session model, the login + TOTP-2FA + passkey flows with a sequence diagram, the axios 401→refresh single-flight, client-side route guards, forgot/reset password, logout, the full endpoint map, and the findings (no CSRF token, no schema validation on login, doc-vs-code drift).
How the client portal logs users in and keeps them in. The model (per ARCHITECTURE.md) is a static-ish SPA with httpOnly-cookie auth against the Go backend — no token ever touches JS; the backend sets/clears cookies and axios rides them via withCredentials. For the backend side of this surface see Auth & Access → client portal. For the settings UI that manages 2FA/passkeys/API-keys see Client Portal → Settings.
ARCHITECTURE.md drifts from the code — trust the code
The design doc says the session endpoint is GET /auth/me and the login body is {username, password}. The shipped code calls GET /client/api/me and posts {email, password, remember_me}. Where they disagree, the code wins.
The cookie model
The backend sets octopus_access + octopus_refresh, HttpOnly; Secure; SameSite=Lax (config/cookie.go:10-11). The access cookie is scoped Path=/client and the refresh cookie Path=/client/auth (set in setAuthCookies, http/handler/client_auth.go:467). Note the cookie names have no _token suffix and the lifetimes drift from the constants: CookieAccessExpiry = 15*time.Minute (config/cookie.go:17) is dead code — the access cookie's Expires is taken from the JWT, whose access TTL is 1 hour (utils/jwt.go:35); the refresh cookie is 7 days (utils/jwt.go:36). The login/2FA/passkey JSON responses carry only user info — no token. Client auth state lives in a React Context (memory only) — {user, isAuthenticated, isLoading, error} — with no localStorage/sessionStorage of the session (the only persisted keys are the passkey-nudge dismissal flags).
Login + 2FA + passkey
Login page ((auth)/login/page.tsx) — raw useState + native <form>, no react-hook-form/zod (validation is if (!email || !password)). On submit useAuth.login → authApi.login → POST /client/auth/login {email, password, remember_me}. If the response has requires_2fa, AuthProvider.login returns {requires_2fa:true} without setting user state, and the page swaps to the TOTP step. On success: snackbar + router.push('/').
AuthProvider bootstrap — on mount checkAuth() calls authApi.getMe() → GET /client/api/me (hydrating user incl. two_factor_enabled and enabled_features), but skips it on public paths. Any error silently resets to unauthenticated with error:null — so a backend/network outage on /me is indistinguishable from "not logged in" and just bounces to login.
The axios client
The 401 → refresh → replay path:
services/api/client.ts — one instance, baseURL .../client, withCredentials:true. No request interceptor, no CSRF/XSRF handling (relies entirely on SameSite=Lax). The response interceptor:
- On a
401(not already retried, not the refresh endpoint, not a public page): single-flights a refresh via a module-levelisRefreshingflag +refreshSubscribersqueue →POST /client/auth/refresh→ replays the original request. - Refresh failure → snackbar "Session expired. Please login again." → hard
window.location.href = '/login'(full reload, wipes UI state), returning a never-resolving promise to suppress error flicker.
Route protection
Route groups (auth) (public) vs (main) (gated); no middleware.ts. AuthGuard reads {isAuthenticated, isLoading} and, once !isLoading && !isAuthenticated, router.replace('/login') — rendering <PageLoader/> until resolved so protected content never flashes. Protection is thus dual: AuthGuard (React redirect) + the axios interceptor's hard redirect on 401. Note /logged-out is referenced in publicPaths but no such page exists — dead config; logout goes to /login.
TOTP 2FA
UI: settings/security/TwoFactorSection.tsx; service twoFactorApi under /client/api/2fa/*. A state machine idle → qr → verify → backup_codes:
- Enable —
POST /2fa/setup/beginreturns{qr_code (base64 PNG), manual_key}→ user enters a 6-digit code →POST /2fa/setup/complete {code}returns{enabled, backup_codes}. - Backup codes — shown once as monospace chips with Copy + Download-as-
.txt; Regenerate (POST /2fa/backup-codes/regenerate) invalidates old ones. - Disable — password-confirm →
POST /2fa/disable {password}. - Login challenge — driven from the login page (not this component):
POST /client/auth/2fa/verify {code, is_backup_code}; a checkbox toggles "use a backup code" (XXXX-XXXX) vs 6-digit TOTP.
Passkeys (WebAuthn)
Hook usePasskey.ts (@simplewebauthn/browser); service passkeyApi. Gated on browserSupportsWebAuthn().
- Register (protected):
POST /client/api/passkeys/register/begin→startRegistration(options)→POST .../register/finish {credential, name}. - Login (public):
POST /client/auth/passkey/login/begin→startAuthentication→POST .../login/finish→ backend sets cookies, page callsrefreshAuth(). Discoverable/resident credentials (no email prompt). - Manage:
GET/PUT/DELETE /client/api/passkeys[/:id](list/rename/delete); the card infers an icon frompasskey.transports. PasskeySetupNudge— after login, on the dashboard, prompts users with zero passkeys (browser-supported, not dismissed within a 30-day localStorage cooldown, 2s delay). "Set Up" →/settings?tab=security.
Forgot / reset & logout
- Forgot — email-regex only →
POST /client/auth/forgot-password {email}→ always shows "Check your mailbox" (no account enumeration, enforced backend-side) + a 30s resend countdown. - Reset — reads
?token=, min length 8 + confirm-match →POST /client/auth/reset-password {token, new_password}→ redirect to/login. - Logout —
POST /client/auth/logout(backend clears cookies), then resets in-memory state in afinally(ignores errors). No idle/inactivity timer — expiry is handled reactively by the 401→refresh interceptor.
Endpoint map (all prefixed /client)
| Action | Method + path |
|---|---|
| Login / logout / refresh | POST /auth/login · /auth/logout · /auth/refresh |
| Session | GET /api/me |
| Forgot / reset | POST /auth/forgot-password · /auth/reset-password |
| 2FA login verify | POST /auth/2fa/verify |
| 2FA manage | GET /api/2fa/status · POST /api/2fa/setup/begin·/setup/complete·/disable·/backup-codes/regenerate |
| Change password | POST /api/auth/change-password |
| Passkey login | POST /auth/passkey/login/begin · /finish |
| Passkey manage | POST /api/passkeys/register/begin·/finish · GET /api/passkeys · PUT/DELETE /api/passkeys/:id |
Note the namespace split: unauthenticated actions under /client/auth/*, authenticated under /client/api/* — except change-password, which is oddly /client/api/auth/change-password.
Findings
| # | Finding |
|---|---|
| 1 | No CSRF token — cookie auth + withCredentials with zero XSRF handling; rests entirely on backend SameSite=Lax. (Upside: tokens are httpOnly, so no XSS token theft.) Confirm the backend enforces origin/CSRF. |
| 2 | Login/forgot/reset use raw useState, no schema validation — unlike ChangePassword (rhf + yup). Password policy is length-8-only client-side. |
| 3 | Doc-vs-code drift in ARCHITECTURE.md (/auth/me vs /api/me, username vs email). |
| 4 | Dead /logged-out route referenced in public-path lists but never created. |
| 5 | Silent auth-check failures — /me outage looks identical to "logged out" and bounces to login with no error surfaced. |
| 6 | Hard window.location redirects on session expiry discard unsaved UI state (intentional, but worth knowing). |
Key files
- Pages:
src/app/(auth)/{login,forgot-password,reset-password}/page.tsx - State:
src/providers/AuthProvider.tsx,src/hooks/{useAuth,usePasskey}.ts,src/components/guards/AuthGuard.tsx,src/components/common/PasskeySetupNudge.tsx - 2FA/passkey UI:
src/components/sections/settings/security/* - Transport:
src/services/api/client.ts
Client Portal — Architecture
The client-facing Next.js portal — App Router structure, the eleven-deep provider stack, the hand-written axios data layer (and the orphaned SWR template leftovers), MUI 7 + Aurora theming with the Stack-row-default gotcha, i18n/OTel, and the build/deploy reality (standalone Node under systemd + nginx — NOT Cloudflare Pages, contradicting the in-repo ARCHITECTURE.md).
Client Portal — Settings
The client portal's six settings tabs — Account Info (read-only), Security (2FA/passkeys), API Keys (username/password credentials shown once), Webhooks (per-event, HTTPS-enforced, HMAC signing + rotate/test), IP Whitelist (empty = allow-all, no client-side CIDR validation), and Integrations (G2A self-service OAuth issuing; Shopify is NOT implemented — flag + dead upsell only).