docs: add Gitea OAuth2 design spec
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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: <gitea-app-client-id>
|
||||
client_secret: <gitea-app-client-secret>
|
||||
```
|
||||
|
||||
**Gitea setup:** Create an OAuth2 application in Gitea under
|
||||
*Settings → Applications → OAuth2*. Set the redirect URI to
|
||||
`https://<hbd-host>/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).
|
||||
Reference in New Issue
Block a user