3.9 KiB
3.9 KiB
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 1–2 are reserved seeds, so the first human is id 3) POST /auth/login: verify bcrypt; on successscs.RenewToken(session fixation) then store user id; emitauth.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 (
sessionstable), 30-day lifetime with idle-timeout 7 days, cookieHttpOnly,SameSite=Lax,Securewhen BASE_URL is https
OAuth (GitHub + Gitea)
- Standard authorization-code flow;
stateparameter 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_hashNULL) + oauth_accounts row, log in
- Existing
- A linked user may later set a password via account settings (password change with no current password required only when
password_hashIS 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 intoken_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 1–2), 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