Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
6.6 KiB
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.
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:
"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
# 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:
def provision_oauth_user(username: str, full_name: str, avatar: str) -> User:
- If the username does not exist in the live
usersdict, creates aUserwith nopassword_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_nameandavatarfrom 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
- Checks
oauth.is_enabled(config)— returns 404 if not. - Calls
oauth.make_state(). - Constructs
redirect_urias{request.url.origin()}/login/oauth/gitea/callbackusing aiohttp'srequest.url.origin(). - Redirects the browser to
oauth.authorization_url(config, state, redirect_uri).
GET /login/oauth/gitea/callback
- Reads
codeandstatequery params; returns 400 if either is missing. - Calls
oauth.validate_state(state)— redirects to/loginwith error if invalid (CSRF or replay protection). - Reconstructs the same
redirect_urias the redirect handler (required by OAuth2 spec for token exchange). - Calls
await oauth.exchange_code(config, code, redirect_uri)to get the access token. - Calls
await oauth.fetch_user(config, token)to get the Gitea user profile. - Calls
users_mod.provision_oauth_user(login, full_name, avatar_url). - Calls
users_mod.create_session(username)to get a session token. - Sets
hbd_sessioncookie (same flags as password login: httponly, Lax, 24h TTL). - Redirects to
/. - Any
OAuthErrorre-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
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_statehappy 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_clientfixture; 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).