Skip to content

Pipeline Design 15

Seth Ford edited this page Feb 12, 2026 · 1 revision

The pipeline artifacts directory is protected. Let me output the ADR directly.


Design: Add dashboard authentication (GitHub OAuth)

Context

Shipwright's Fleet Command dashboard (dashboard/server.ts, ~3500 lines) already implements a functional GitHub OAuth flow including OAuth config (lines 33-41), session management via in-memory Map<string, Session> + file-backed persistence (lines 173-254), auth mode detection for OAuth/PAT/None (lines 356-367), a styled login page (lines 387-510), route-level auth gating (lines 2188-2202), and WebSocket auth rejection (line 2194). The frontend already has user-menu HTML in dashboard/public/index.html (lines 83-91), CSS in styles.css (lines 165-234), and JS functions fetchUser() + setupUserMenu() in app.js (lines 567-615).

The implementation is structurally complete but has six gaps that prevent production readiness:

  1. No CSRF protection/auth/github (line 1922) omits the OAuth state parameter entirely, violating RFC 6749 Section 10.12.
  2. No OAuth error handlinghandleAuthCallback() (line 1939) only checks for code, returning a bare 400 when GitHub sends error=access_denied (user denies consent).
  3. No developer auto-registration — Neither handleAuthCallback() nor handlePatLogin() writes to developerRegistry, so dashboard users are invisible in the team presence system.
  4. No Secure cookie flagsessionCookie() (line 214) sets HttpOnly; SameSite=Lax but never Secure, allowing cookie leakage over HTTP when the dashboard runs behind TLS termination.
  5. Frontend auth never initializesfetchUser() and setupUserMenu() exist in app.js but are never called from the startup path. The /api/me endpoint (line 2468) already works correctly.
  6. Heartbeat identity gap/api/connect/heartbeat (line 3764) authenticates via invite tokens for CLI clients but ignores fleet_session cookies, preventing browser-based identity enrichment.

Constraints: Bun runtime, vanilla JS frontend, no external dependencies, in-memory session Map with atomic file persistence, three auth modes coexist (OAuth / PAT / None), Bash 3.2 compatibility for test scripts.

Decision

Harden the existing OAuth implementation in-place. No new dependencies, frameworks, or session stores. Six targeted modifications:

1. CSRF State Parameter

Add const oauthStates = new Map<string, number>() near the existing session store. In handleAuthGitHub(): generate crypto.randomUUID() state, store with 10-min TTL (Date.now() + 600_000), include as &state= in the GitHub authorize URL. In handleAuthCallback(): validate state exists and hasn't expired, delete after use. Reject mismatches with redirect to /login?error=invalid_state. Purge expired states in the existing setInterval health-check loop.

States are not file-persisted — they're ephemeral (10-min TTL). Server restart simply requires re-initiating the OAuth flow.

2. OAuth Error Handling

In handleAuthCallback(), check url.searchParams.get("error") before checking for code. If present, redirect to /login?error=<encoded error_description>. The login page already accepts an error parameter (line 388 — loginPageHTML(error?: string)).

3. Developer Auto-Registration

After createSession() in both handleAuthCallback() and handlePatLogin(), call:

developerRegistry.set(`${username}@dashboard`, {
  developer_id: username,
  machine_name: "dashboard",
  hostname: "dashboard",
  platform: "web",
  last_heartbeat: Date.now(),
  daemon_running: false, daemon_pid: null,
  active_jobs: [], queued: [], events_since: 0,
});
saveDeveloperRegistry();

Key format <user>@dashboard avoids collision with CLI entries (<user>@<machine-name>). Re-login is idempotent (overwrites same key).

4. Secure Cookie Flag

Add const SECURE_COOKIES = process.env.DASHBOARD_SECURE === "true" near auth config. Modify sessionCookie() and clearSessionCookie() to append ; Secure when true. Explicit opt-in via environment variable — no auto-detection heuristics.

5. Frontend Auth Initialization

Add fetchUser(); setupUserMenu(); to the existing initialization block in app.js (near connectWebSocket() call). No new endpoints or HTML/CSS needed — everything already exists.

6. Heartbeat Identity Enrichment

In the /api/connect/heartbeat handler (line 3764), after invite-token validation, check for fleet_session cookie via getSessionFromCookie(). If present and valid, use session.githubUser as fallback developer_id when not specified in the request body. CLI heartbeats (no cookies) continue unchanged.

Data Flow: OAuth Happy Path

GET /auth/github
  → generate state, store in oauthStates
  → 302 to github.com/login/oauth/authorize?client_id=...&state=<uuid>&scope=read:org+repo

GET /auth/callback?code=<code>&state=<uuid>
  → validate state (delete from Map)
  → POST github.com/login/oauth/access_token (exchange code)
  → GET api.github.com/user (get login + avatar)
  → GET api.github.com/repos/:owner/:repo/collaborators/:user/permission
  → if admin/write: createSession(), register developer, Set-Cookie, 302 to /
  → else: 403 accessDeniedHTML

GET / (authenticated)
  → auth gate passes, serve index.html
  → app.js: fetch /api/me → populate #user-menu
  → app.js: WebSocket /ws → authenticated push

Data Flow: Error Paths

GET /auth/callback?error=access_denied&error_description=The+user+has+denied...
  → 302 to /login?error=The+user+has+denied...

GET /auth/callback?code=<code>&state=<expired-or-missing>
  → 302 to /login?error=invalid_state

GET /auth/callback?code=<code>&state=<valid> but GitHub rejects code
  → 302 to /login?error=token_exchange_failed

Alternatives Considered

  1. JWT-based stateless sessions — Pros: No server-side store, horizontally scalable. / Cons: Can't revoke sessions without a blocklist (defeating the purpose), adds a JWT dependency, the existing Map+file store works well for a single-server dev-team dashboard.

  2. External session store (Redis/SQLite) — Pros: Survives crashes, enables multi-instance. / Cons: Adds operational dependency to a currently zero-dependency Bun server, overkill for <10 concurrent users.

  3. Middleware framework rewrite (Hono/Express) — Pros: Structured middleware pipeline, community auth plugins. / Cons: Major refactor of 3500-line server, new dependency chain, existing flat handler is well-organized and auth gating already works.

  4. Double-submit cookie CSRF — Pros: No server-side state needed. / Cons: More complex implementation, OAuth state parameter is the standard approach per RFC 6749 Section 10.12 and GitHub's own recommendation.

Implementation Plan

  • Files to create:

    • scripts/sw-dashboard-test.sh — Bash test suite (project harness conventions: mock binaries, PASS/FAIL counters, ERR trap). Tests: CSRF state lifecycle, session creation/expiry, developer auto-registration, OAuth error redirect, public vs protected route classification.
  • Files to modify:

    • dashboard/server.ts — CSRF state Map + generation/validation in handleAuthGitHub()/handleAuthCallback(), OAuth error check, developer auto-registration in both login handlers, Secure cookie flag, heartbeat identity enrichment
    • dashboard/public/app.js — Add fetchUser() and setupUserMenu() calls to initialization
    • package.json — Register sw-dashboard-test.sh in test scripts array
  • Dependencies: None. Zero new packages.

  • Risk areas:

    • OAuth state Map memory — Mitigated by 10-min TTL + periodic cleanup. Each entry is UUID + timestamp (~80 bytes). Even 10,000 pending states = <1 MB.
    • Developer registry key collision<user>@dashboard is distinct from CLI keys <user>@<machine>. Multiple browser sessions for same user overwrite idempotently.
    • No-auth mode regression — All changes gated behind isAuthEnabled(). /api/me already returns { username: "local" } in no-auth mode. fetchUser() handles this gracefully.
    • Login page error renderingloginPageHTML(error) renders the error parameter directly into HTML. The error string comes from GitHub's error_description — must HTML-escape it to prevent reflected XSS. The existing errorHtml template on line 391 renders raw — needs escaping.

Validation Criteria

  • /auth/github 302 Location includes &state= parameter (non-empty UUID)
  • /auth/callback with valid state + code creates session and redirects to /
  • /auth/callback with missing/expired state redirects to /login?error=invalid_state
  • /auth/callback?error=access_denied redirects to /login with human-readable message
  • Error messages in login page are HTML-escaped (no reflected XSS)
  • Successful OAuth login registers user in developerRegistry as <user>@dashboard
  • Successful PAT login registers user in developerRegistry as <user>@dashboard
  • sessionCookie() includes ; Secure when DASHBOARD_SECURE=true
  • sessionCookie() omits Secure when DASHBOARD_SECURE is unset
  • Frontend calls fetchUser() on page load and populates user-menu
  • /api/me returns session data for authenticated requests
  • /api/me returns { username: "local" } when auth disabled
  • WebSocket upgrade without valid session returns 401
  • CLI heartbeats without cookies still work via invite-token auth
  • Browser heartbeats with fleet_session cookie get identity-enriched
  • sw-dashboard-test.sh passes (PASS > 0, FAIL = 0)
  • All 22 existing test suites pass (npm test)
  • No new runtime dependencies

Clone this wiki locally