Files
heartbeat/docs/superpowers/specs/2026-05-08-gitea-oauth2-design.md
T
2026-05-08 13:11:50 -04:00

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 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.
  5. Calls await oauth.fetch_user(config, token) to get the Gitea user profile.
  6. Calls users_mod.provision_oauth_user(login, full_name, avatar_url).
  7. Calls users_mod.create_session(username) to get a session token.
  8. Sets hbd_session cookie (same flags as password login: httponly, Lax, 24h TTL).
  9. Redirects to /.
  10. 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

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