Pansy is multi-user: users own gardens and share them with others. This issue implements local (email + password) authentication and the session layer that OIDC (#5) also reuses. Auth is OIDC-first in deployment, but local auth is the fallback and the simplest path to a working login for development.
Scope
internal/store/users.go, internal/store/sessions.go: queries for the existing users/sessions tables from migration 0001.
internal/service/auth.go:
Register — argon2id hashing (golang.org/x/crypto/argon2, id variant, ~64 MiB / 1 iteration / 4 threads, random 16-byte salt; encode params+salt+hash in the stored string). First registered user gets is_admin=true. PANSY_REGISTRATION=closed blocks signup except when no users exist (bootstrap).
Login — verify with constant-time comparison; identical error for unknown email vs wrong password.
Sessions — random 32-byte token; store sha256(token); 30-day sliding expiry (touch on use); logout deletes the row.
internal/api: POST /api/v1/auth/register, POST /auth/login, POST /auth/logout, GET /auth/me. Session cookie: HttpOnly, SameSite=Lax, Secure when PANSY_BASE_URL is https, path /.
Auth middleware: resolves the cookie to a user, injects actor user ID into the request context; /api/v1 routes except healthz/auth require it.
GET /api/v1/auth/providers: {local: bool, oidc: bool, oidc_label: string} from config — the login page (#6) renders from this. oidc stays false until #5.
Expired-session cleanup (on access or a periodic sweep — keep it simple).
curl flow: register → login → cookie → GET /auth/me returns the user; logout invalidates; restart of the server keeps valid sessions (DB-backed).
Second registered user has is_admin=false; PANSY_REGISTRATION=closed returns 403 for signup when users exist but allows the very first user.
Wrong-password and unknown-email responses are indistinguishable.
go test ./internal/service/... covers register/login/session expiry.
Part of epic #20 · Design: [DESIGN.md](https://gitea.stevedudenhoeffer.com/steve/pansy/src/branch/main/DESIGN.md)
## Context
Pansy is multi-user: users own gardens and share them with others. This issue implements local (email + password) authentication and the session layer that OIDC (#5) also reuses. Auth is OIDC-first in deployment, but local auth is the fallback and the simplest path to a working login for development.
## Scope
- [ ] `internal/store/users.go`, `internal/store/sessions.go`: queries for the existing `users`/`sessions` tables from migration 0001.
- [ ] `internal/service/auth.go`:
- `Register` — argon2id hashing (`golang.org/x/crypto/argon2`, id variant, ~64 MiB / 1 iteration / 4 threads, random 16-byte salt; encode params+salt+hash in the stored string). First registered user gets `is_admin=true`. `PANSY_REGISTRATION=closed` blocks signup **except when no users exist** (bootstrap).
- `Login` — verify with constant-time comparison; identical error for unknown email vs wrong password.
- Sessions — random 32-byte token; store `sha256(token)`; 30-day sliding expiry (touch on use); logout deletes the row.
- [ ] `internal/api`: `POST /api/v1/auth/register`, `POST /auth/login`, `POST /auth/logout`, `GET /auth/me`. Session cookie: HttpOnly, SameSite=Lax, Secure when `PANSY_BASE_URL` is https, path `/`.
- [ ] Auth middleware: resolves the cookie to a user, injects actor user ID into the request context; `/api/v1` routes except healthz/auth require it.
- [ ] `GET /api/v1/auth/providers`: `{local: bool, oidc: bool, oidc_label: string}` from config — the login page (#6) renders from this. `oidc` stays false until #5.
- [ ] Expired-session cleanup (on access or a periodic sweep — keep it simple).
## Out of scope
- OIDC (#5). Login UI (#6). Password reset/change flows, email verification, rate limiting (post-v1).
## Key files & patterns
- Constant-time compare habit: `subtle.ConstantTimeCompare` as in `/Users/steve/Projects/mort/cmd/mort/agentkit_api.go`.
- Service-layer convention (all methods take `(ctx, actor, args)`; handlers stay thin): `DESIGN.md` § Backend layout.
## Dependencies
Blocked by #1.
## Acceptance criteria
- curl flow: register → login → cookie → `GET /auth/me` returns the user; logout invalidates; restart of the server keeps valid sessions (DB-backed).
- Second registered user has `is_admin=false`; `PANSY_REGISTRATION=closed` returns 403 for signup when users exist but allows the very first user.
- Wrong-password and unknown-email responses are indistinguishable.
- `go test ./internal/service/...` covers register/login/session expiry.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Part of epic #20 · Design: DESIGN.md
Context
Pansy is multi-user: users own gardens and share them with others. This issue implements local (email + password) authentication and the session layer that OIDC (#5) also reuses. Auth is OIDC-first in deployment, but local auth is the fallback and the simplest path to a working login for development.
Scope
internal/store/users.go,internal/store/sessions.go: queries for the existingusers/sessionstables from migration 0001.internal/service/auth.go:Register— argon2id hashing (golang.org/x/crypto/argon2, id variant, ~64 MiB / 1 iteration / 4 threads, random 16-byte salt; encode params+salt+hash in the stored string). First registered user getsis_admin=true.PANSY_REGISTRATION=closedblocks signup except when no users exist (bootstrap).Login— verify with constant-time comparison; identical error for unknown email vs wrong password.sha256(token); 30-day sliding expiry (touch on use); logout deletes the row.internal/api:POST /api/v1/auth/register,POST /auth/login,POST /auth/logout,GET /auth/me. Session cookie: HttpOnly, SameSite=Lax, Secure whenPANSY_BASE_URLis https, path/./api/v1routes except healthz/auth require it.GET /api/v1/auth/providers:{local: bool, oidc: bool, oidc_label: string}from config — the login page (#6) renders from this.oidcstays false until #5.Out of scope
Key files & patterns
subtle.ConstantTimeCompareas in/Users/steve/Projects/mort/cmd/mort/agentkit_api.go.(ctx, actor, args); handlers stay thin):DESIGN.md§ Backend layout.Dependencies
Blocked by #1.
Acceptance criteria
GET /auth/mereturns the user; logout invalidates; restart of the server keeps valid sessions (DB-backed).is_admin=false;PANSY_REGISTRATION=closedreturns 403 for signup when users exist but allows the very first user.go test ./internal/service/...covers register/login/session expiry.