Files
solopm-server/plans/05-auth.md
T

3.9 KiB
Raw Blame History

05 — Auth, Sessions, OAuth & Tokens

Two credential paths, one identity: browser SPA uses server-side sessions in HttpOnly cookies (alexedwards/scs, Postgres store); scripts/CLI use personal access tokens. Local email/password and GitHub/Gitea OAuth all resolve to the same users row via oauth_accounts.

Registration & login

  • POST /auth/register: validate email format, username [a-z0-9-_]{3,32} lowercase-unique (citext), password ≥ 10 chars; bcrypt cost 12
  • First registered user gets is_admin = true (instance bootstrap; ids 12 are reserved seeds, so the first human is id 3)
  • POST /auth/login: verify bcrypt; on success scs.RenewToken (session fixation) then store user id; emit auth.login_succeeded / auth.login_failed (failed: no user enumeration in the response — generic message)
  • Rate-limit register/login/password endpoints per IP (10/min)
  • POST /auth/logout: destroy session
  • Session config: Postgres store (sessions table), 30-day lifetime with idle-timeout 7 days, cookie HttpOnly, SameSite=Lax, Secure when BASE_URL is https

OAuth (GitHub + Gitea)

  • Standard authorization-code flow; state parameter stored in session, verified at callback (CSRF)
  • GitHub: fixed endpoints; Gitea: endpoints derived from GITEA_URL (self-hosted friendly)
  • Provider buttons hidden in the SPA when the corresponding env vars are unset (/api/v1/me-adjacent bootstrap config endpoint or injected at index render)
  • Callback logic, in order:
    • Existing oauth_accounts(provider, provider_user_id) row → log that user in
    • Else: fetch the provider's verified primary email; if a user with that email exists → link: insert oauth_accounts row for that user, log in
    • Else: create a new user (username derived from provider login, de-duplicated with numeric suffix; password_hash NULL) + oauth_accounts row, log in
  • A linked user may later set a password via account settings (password change with no current password required only when password_hash IS NULL)
  • Never auto-link on an unverified provider email (account-takeover vector) — fall through to create-new-user

Personal access tokens

  • Format solopm_<40 chars base62>; store SHA-256 hex in token_hash; show plaintext exactly once at creation
  • prefix (first 12 chars) stored for display in the token list
  • Bearer auth middleware: constant-time hash lookup; reject revoked/expired; update last_used_at (throttled to once/minute per token)
  • Tokens act as the full user (no scopes in v1 — recorded in 17-decisions.md)
  • Emit auth.token_created / auth.token_revoked

Authorization model

  • is_admin (system-wide): manage users, run cull/reindex, see admin endpoints. Admins are not implicit members of every project — they see only their own projects in normal UI (admin endpoints are separate)
  • Project owner: everything a member can, plus: edit project settings, manage members/labels/webhooks, transfer ownership, delete project
  • Project member: full read/write on the project's issues, epics, comments, wiki, attachments, links; manage own watches/favorites
  • Non-member: 404 on all project-scoped routes
  • System user (id 1) and zombie user (id 2): cannot log in (no password, no oauth rows, login explicitly rejects ids 12), never listed in pickers

User deletion edge cases

  • A user who is the sole owner of any project cannot be deleted (409 conflict, message lists the projects) — transfer ownership first
  • Deletion is a soft delete; sessions destroyed and PATs revoked immediately
  • At cull time the zombie user inherits authored records (12-retention.md); audit_log keeps the original user id (immutable)
  • Self-service account deletion follows the same rules as admin deletion