OctoWiki

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 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.loginauthApi.loginPOST /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-level isRefreshing flag + refreshSubscribers queue → POST /client/auth/refreshreplays 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:

  1. EnablePOST /2fa/setup/begin returns {qr_code (base64 PNG), manual_key} → user enters a 6-digit code → POST /2fa/setup/complete {code} returns {enabled, backup_codes}.
  2. Backup codes — shown once as monospace chips with Copy + Download-as-.txt; Regenerate (POST /2fa/backup-codes/regenerate) invalidates old ones.
  3. Disable — password-confirm → POST /2fa/disable {password}.
  4. 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/beginstartRegistration(options)POST .../register/finish {credential, name}.
  • Login (public): POST /client/auth/passkey/login/beginstartAuthenticationPOST .../login/finish → backend sets cookies, page calls refreshAuth(). Discoverable/resident credentials (no email prompt).
  • Manage: GET/PUT/DELETE /client/api/passkeys[/:id] (list/rename/delete); the card infers an icon from passkey.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.
  • LogoutPOST /client/auth/logout (backend clears cookies), then resets in-memory state in a finally (ignores errors). No idle/inactivity timer — expiry is handled reactively by the 401→refresh interceptor.

Endpoint map (all prefixed /client)

ActionMethod + path
Login / logout / refreshPOST /auth/login · /auth/logout · /auth/refresh
SessionGET /api/me
Forgot / resetPOST /auth/forgot-password · /auth/reset-password
2FA login verifyPOST /auth/2fa/verify
2FA manageGET /api/2fa/status · POST /api/2fa/setup/begin·/setup/complete·/disable·/backup-codes/regenerate
Change passwordPOST /api/auth/change-password
Passkey loginPOST /auth/passkey/login/begin · /finish
Passkey managePOST /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
1No 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.
2Login/forgot/reset use raw useState, no schema validation — unlike ChangePassword (rhf + yup). Password policy is length-8-only client-side.
3Doc-vs-code drift in ARCHITECTURE.md (/auth/me vs /api/me, username vs email).
4Dead /logged-out route referenced in public-path lists but never created.
5Silent auth-check failures/me outage looks identical to "logged out" and bounces to login with no error surfaced.
6Hard 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

On this page