diff --git a/docs/superpowers/specs/2026-05-08-gitea-oauth2-design.md b/docs/superpowers/specs/2026-05-08-gitea-oauth2-design.md new file mode 100644 index 0000000..0ea79be --- /dev/null +++ b/docs/superpowers/specs/2026-05-08-gitea-oauth2-design.md @@ -0,0 +1,184 @@ +# Gitea OAuth2 Authentication — Design Spec + +Date: 2026-05-08 + +## Overview + +Add Gitea as an OAuth2 login provider alongside the existing username/password +authentication. Any user on the configured Gitea instance can sign in; their +local account is auto-provisioned on first login as a regular (non-admin) user. +Password login continues to work unchanged. + +--- + +## Config + +A new optional `oauth.gitea` block in `~/.hb.yaml`. OAuth is disabled when the +block is absent or any of the three required keys is missing. + +```yaml +oauth: + gitea: + url: https://git.example.com # Gitea base URL, no trailing slash + client_id: + client_secret: +``` + +**Gitea setup:** Create an OAuth2 application in Gitea under +*Settings → Applications → OAuth2*. Set the redirect URI to +`https:///login/oauth/gitea/callback`. + +`config.py` default: + +```python +"oauth": {}, +``` + +--- + +## New module: `hbd/server/oauth.py` + +Owns all OAuth2 logic. No new dependencies — uses `aiohttp.ClientSession` +already present in the codebase. + +### CSRF state store + +```python +# state -> expires (float) +_states: dict[str, float] = {} +STATE_TTL = 600 # 10 minutes +``` + +`_states` is an in-memory dict. Entries are created on redirect and deleted on +use or expiry. A purge runs on every new state generation. + +### Public API + +| Function | Description | +|---|---| +| `is_enabled(config)` | Returns `True` when url, client_id, and client_secret are all set | +| `make_state()` | Generates a random state token, stores it with TTL, returns it | +| `validate_state(state)` | Returns `True` and removes the state if valid and unexpired | +| `authorization_url(config, state, redirect_uri)` | Builds the Gitea `/login/oauth/authorize` redirect URL with `client_id`, `redirect_uri`, `scope=user:email`, `state` | +| `exchange_code(config, code, redirect_uri)` async | POSTs to Gitea `/login/oauth/access_token` with code and redirect_uri, returns the access token string or raises `OAuthError` | +| `fetch_user(config, token)` async | GETs Gitea `/api/v1/user` with Bearer token, returns `{"login", "full_name", "avatar_url"}` or raises `OAuthError` | + +### Error handling + +`OAuthError(message)` is a module-level exception. The callback route catches it +and renders the login page with an error message — identical to an invalid +password error in UX terms. + +Network timeouts use a 10-second `aiohttp` timeout. Any non-2xx response from +Gitea raises `OAuthError`. + +--- + +## Change: `hbd/server/users.py` + +One new function added to the public API: + +```python +def provision_oauth_user(username: str, full_name: str, avatar: str) -> User: +``` + +- If the username does not exist in the live `users` dict, creates a `User` + with no `password_hash` (so password login is impossible for this account) + and inserts it. +- If the username already exists (e.g. was defined in config with a password), + updates `full_name` and `avatar` from the OAuth profile and returns the + existing user unchanged in all other respects (preserving admin flag, + notification channels, etc.). +- Logs a one-line INFO message on first provision. + +--- + +## Changes: `hbd/server/http.py` + +### Two new route handlers + +**`GET /login/oauth/gitea`** + +1. Checks `oauth.is_enabled(config)` — returns 404 if not. +2. Calls `oauth.make_state()`. +3. Constructs `redirect_uri` as `{request.url.origin()}/login/oauth/gitea/callback` using aiohttp's `request.url.origin()`. +4. Redirects the browser to `oauth.authorization_url(config, state, redirect_uri)`. + +**`GET /login/oauth/gitea/callback`** + +1. Reads `code` and `state` query params; returns 400 if either is missing. +2. Calls `oauth.validate_state(state)` — redirects to `/login` with error if + invalid (CSRF or replay protection). +3. Reconstructs the same `redirect_uri` as the redirect handler (required by OAuth2 spec for token exchange). +4. Calls `await oauth.exchange_code(config, code, redirect_uri)` to get the access token. +4. Calls `await oauth.fetch_user(config, token)` to get the Gitea user profile. +5. Calls `users_mod.provision_oauth_user(login, full_name, avatar_url)`. +6. Calls `users_mod.create_session(username)` to get a session token. +7. Sets `hbd_session` cookie (same flags as password login: httponly, Lax, + 24h TTL). +8. Redirects to `/`. +9. Any `OAuthError` re-renders the login page with a generic error message. + +### Login page change + +When `oauth.is_enabled(config)` is `True`, the existing login form gains a +separator and a "Sign in with Gitea" link button pointing to +`/login/oauth/gitea`. The password form is always rendered regardless. + +### Route registration + +```python +web.get("/login/oauth/gitea", oauth_redirect), +web.get("/login/oauth/gitea/callback", oauth_callback), +``` + +Added alongside the existing `/login` and `/logout` routes. + +--- + +## Data flow + +``` +Browser hbd Gitea + | | | + |-- GET /login ----------->| | + |<- login page (+ button) -| | + | | | + |-- GET /login/oauth/gitea>| | + |<- 302 Gitea /authorize --| | + | | | + |-- GET /login/oauth/authorize ----------------------->| + |<- 302 /login/oauth/gitea/callback?code=..&state=.. --| + | | | + |-- GET /callback -------->| | + | |-- POST /access_token ---->| + | |<- {access_token} ---------| + | |-- GET /api/v1/user ------>| + | |<- {login, name, avatar} --| + | | provision_oauth_user() | + | | create_session() | + |<- 302 / (set cookie) ----| | +``` + +--- + +## Testing + +- `test_oauth_state`: `make_state` + `validate_state` happy path; expired state + returns False; replay (double-use) returns False. +- `test_provision_oauth_user_new`: new username creates User with no password. +- `test_provision_oauth_user_existing`: existing config user updates name/avatar, + preserves admin flag and notification_channels. +- `test_oauth_callback_invalid_state`: callback with bad state redirects to login. +- Integration: mock Gitea endpoints with `aiohttp_client` fixture; full + redirect → callback → session cookie flow. + +--- + +## Out of scope + +- Restricting login to specific Gitea organisations or teams. +- Making OAuth users admin automatically. +- Multiple OAuth providers. +- Token refresh (Gitea access tokens are long-lived; the hbd session TTL governs + re-authentication).