# 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).