feat: web UI server auth foundation (/api/* login, logout, me) #14

Merged
bvandeusen merged 262 commits from dev into main 2026-04-20 22:51:46 -04:00
bvandeusen commented 2026-04-20 22:41:04 -04:00 (Migrated from git.fabledsword.com)

Summary

  • Adds the /api/* native JSON surface used by the web UI and future Flutter client, separate from the existing /rest/* Subsonic compatibility layer.
  • Session auth: server-minted opaque tokens (base64 url-safe, 32 random bytes) with a sha256 hash stored server-side. Clients present the token as a minstrel_session cookie (HttpOnly, SameSite=Strict, Max-Age 30d) or Authorization: Bearer … header — native clients can use either.
  • Endpoints: POST /api/auth/login, POST /api/auth/logout, GET /api/me. RequireUser middleware protects logout + me; login stays public.
  • All errors use a consistent {"error":{"code","message"}} envelope.

Scope stops at auth + /api/me — library read endpoints and the SvelteKit frontend ship in follow-up PRs.

What's in the branch

12 commits, 21 files touched (see git log origin/main..HEAD for the full list):

  • Migration 0004_sessions.up.sql + sqlc queries (InsertSession, GetSessionByTokenHash, TouchSessionLastSeen, DeleteSession, DeleteSessionByTokenHash)
  • internal/auth/session.goMintSessionToken, HashSessionToken, VerifyPassword, RequireUser middleware
  • internal/api/ package — api.Mount, handlers for login/logout/me, error envelope, types
  • internal/server/server.go — root router now calls api.Mount alongside existing /api/admin and /rest/* routes
  • Last commit fixes a whitespace-bearer divergence caught in final review (logout now matches middleware trimming)

Test plan

  • Unit / integration tests (go test ./internal/api/... ./internal/auth/...)
  • End-to-end smoke on live docker compose stack:
    • POST /api/auth/login → 200 + Set-Cookie (HttpOnly, SameSite=Strict, Max-Age=2592000) + bearer token in body
    • GET /api/me via cookie → 200
    • GET /api/me via bearer → 200
    • GET /api/me unauthenticated → 401
    • Login with wrong password → 401 {"error":{"code":"invalid_credentials"…}}
    • POST /api/auth/logout → 204, Set-Cookie Max-Age=0
    • Reusing token after logout (cookie or bearer) → 401 (server-side row deleted)
    • Bearer with trailing whitespace on logout → 204 AND session server-side row deleted (regression guard)
  • Watch CI pipeline on the PR before merge
  • After merge: git pull --ff-only origin main locally

Known non-blocking follow-ups (tracked for later PRs)

  • RequireUser issues 3 sequential DB calls (session lookup + user lookup + touch) — consolidate via JOIN once traffic matters
  • RequireUser silently swallows orphan-session cleanup errors — add slog.Warn
  • Secure cookie flag derives from r.TLS != nil, which is wrong behind a TLS-terminating reverse proxy — honor X-Forwarded-Proto or add a MINSTREL_COOKIE_SECURE config
  • Login has a user-enumeration timing oracle (bcrypt only runs when the user exists) — run a dummy bcrypt on the not-found path
  • No rate limiting on /api/auth/login
  • me_test.go has a _ = dbq.User{} preserve-line that can be removed by dropping the unused dbq import
  • Full-suite parallel test DB isolation: internal/api tests TRUNCATE users, which can collide with internal/auth bootstrap tests under -p >1. Run per-package or with -p 1 until we assign each package its own schema/DB.

Out of scope (per plan)

  • OIDC / OAuth — still deferred to late-v1 per project scope
  • Library read endpoints (/api/albums, /api/artists, etc.) — next plan
  • Web frontend (SvelteKit) — next plan after library endpoints

🤖 Generated with Claude Code

## Summary - Adds the `/api/*` native JSON surface used by the web UI and future Flutter client, separate from the existing `/rest/*` Subsonic compatibility layer. - Session auth: server-minted opaque tokens (base64 url-safe, 32 random bytes) with a sha256 hash stored server-side. Clients present the token as a `minstrel_session` cookie (HttpOnly, SameSite=Strict, Max-Age 30d) or `Authorization: Bearer …` header — native clients can use either. - Endpoints: `POST /api/auth/login`, `POST /api/auth/logout`, `GET /api/me`. `RequireUser` middleware protects logout + me; login stays public. - All errors use a consistent `{"error":{"code","message"}}` envelope. Scope stops at auth + `/api/me` — library read endpoints and the SvelteKit frontend ship in follow-up PRs. ## What's in the branch 12 commits, 21 files touched (see `git log origin/main..HEAD` for the full list): - Migration `0004_sessions.up.sql` + sqlc queries (`InsertSession`, `GetSessionByTokenHash`, `TouchSessionLastSeen`, `DeleteSession`, `DeleteSessionByTokenHash`) - `internal/auth/session.go` — `MintSessionToken`, `HashSessionToken`, `VerifyPassword`, `RequireUser` middleware - `internal/api/` package — `api.Mount`, handlers for login/logout/me, error envelope, types - `internal/server/server.go` — root router now calls `api.Mount` alongside existing `/api/admin` and `/rest/*` routes - Last commit fixes a whitespace-bearer divergence caught in final review (logout now matches middleware trimming) ## Test plan - [x] Unit / integration tests (`go test ./internal/api/... ./internal/auth/...`) - [x] End-to-end smoke on live docker compose stack: - [x] `POST /api/auth/login` → 200 + Set-Cookie (HttpOnly, SameSite=Strict, Max-Age=2592000) + bearer token in body - [x] `GET /api/me` via cookie → 200 - [x] `GET /api/me` via bearer → 200 - [x] `GET /api/me` unauthenticated → 401 - [x] Login with wrong password → 401 `{"error":{"code":"invalid_credentials"…}}` - [x] `POST /api/auth/logout` → 204, Set-Cookie Max-Age=0 - [x] Reusing token after logout (cookie or bearer) → 401 (server-side row deleted) - [x] Bearer with trailing whitespace on logout → 204 AND session server-side row deleted (regression guard) - [ ] Watch CI pipeline on the PR before merge - [ ] After merge: `git pull --ff-only origin main` locally ## Known non-blocking follow-ups (tracked for later PRs) - `RequireUser` issues 3 sequential DB calls (session lookup + user lookup + touch) — consolidate via JOIN once traffic matters - `RequireUser` silently swallows orphan-session cleanup errors — add `slog.Warn` - `Secure` cookie flag derives from `r.TLS != nil`, which is wrong behind a TLS-terminating reverse proxy — honor `X-Forwarded-Proto` or add a `MINSTREL_COOKIE_SECURE` config - Login has a user-enumeration timing oracle (bcrypt only runs when the user exists) — run a dummy bcrypt on the not-found path - No rate limiting on `/api/auth/login` - `me_test.go` has a `_ = dbq.User{}` preserve-line that can be removed by dropping the unused `dbq` import - Full-suite parallel test DB isolation: `internal/api` tests TRUNCATE `users`, which can collide with `internal/auth` bootstrap tests under `-p >1`. Run per-package or with `-p 1` until we assign each package its own schema/DB. ## Out of scope (per plan) - OIDC / OAuth — still deferred to late-v1 per project scope - Library read endpoints (`/api/albums`, `/api/artists`, etc.) — next plan - Web frontend (SvelteKit) — next plan after library endpoints 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign in to join this conversation.
No Reviewers
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: bvandeusen/minstrel#14