Files
heartbeat/docs/superpowers/specs/2026-05-09-multi-oauth-providers-design.md
T
2026-05-09 08:19:52 -04:00

5.9 KiB

Multi-Provider OAuth2 — Design Spec

Date: 2026-05-09 Status: Approved

Goal

Allow multiple OAuth2 providers to be configured simultaneously. All enabled providers appear as login buttons on the login panel. Supported provider types: Gitea, GitHub, Nextcloud. Existing single-Gitea configs continue to work without changes.


Config Format

Each entry in the oauth dict is a named provider instance. The dict key becomes the route slug.

oauth:
  work-gitea:          # /login/oauth/work-gitea
    type: gitea        # optional — defaults to "gitea" when absent (backward compat)
    url: https://git.example.com
    client_id: xxx
    client_secret: yyy
    label: "Work Gitea"   # optional display name; falls back to provider default
    logo: https://…       # optional logo URL for button
  github:
    type: github       # no url needed — fixed SaaS endpoints
    client_id: xxx
    client_secret: yyy
  nextcloud:
    type: nextcloud
    url: https://cloud.example.com
    client_id: xxx
    client_secret: yyy

Backward compatibility: The existing oauth.gitea.{url,client_id,client_secret} config (no type field) is treated as type: gitea. No migration required.

Validation: Entries missing client_id, client_secret, or url (when the provider type requires it) are skipped with a warning log. This prevents a misconfigured entry from disabling all OAuth.


Provider Registry (oauth.py)

A PROVIDER_DEFS dict holds static knowledge about each supported provider type:

gitea github nextcloud
authorize URL {url}/login/oauth/authorize https://github.com/login/oauth/authorize {url}/apps/oauth2/authorize
token URL {url}/login/oauth/access_token https://github.com/login/oauth/access_token {url}/apps/oauth2/api/v1/token
profile URL {url}/api/v1/user https://api.github.com/user {url}/ocs/v2.php/cloud/user?format=json
scope user:email read:user (empty)
username field login login nested: ocs.data.id
display name field full_name name nested: ocs.data.display-name
avatar field avatar_url avatar_url (absent — left empty)
requires url yes no yes
default label Gitea GitHub Nextcloud

Nextcloud's profile response is nested (ocs → data). The registry entry includes a profile_data_path: ["ocs", "data"] that is navigated before field extraction.


New / Changed API in oauth.py

ResolvedProvider (new dataclass)

All endpoint URLs are pre-computed strings (no more template substitution at call time):

@dataclass
class ResolvedProvider:
    name: str            # route slug (dict key)
    type: str            # "gitea" | "github" | "nextcloud"
    label: str           # display name for login button
    logo: str            # URL or ""
    authorize_url: str
    token_url: str
    profile_url: str
    scope: str
    client_id: str
    client_secret: str
    field_map: dict      # {"username": "<provider_field>", "full_name": ..., "avatar": ...}
    profile_data_path: list[str]  # e.g. ["ocs", "data"] or []

get_providers(config) → list[ResolvedProvider] (new)

Iterates config.get("oauth", {}), resolves each valid entry against PROVIDER_DEFS, skips invalid entries. Returns providers in config declaration order (determines button order on login page).

build_auth_url(provider, state, redirect_uri) (updated signature)

Takes a ResolvedProvider. Uses provider.authorize_url, provider.scope, provider.client_id.

exchange_code(provider, code, redirect_uri) (updated signature)

Takes a ResolvedProvider. Sets Accept: application/json on all token requests (required for GitHub, harmless for others).

fetch_user(provider, access_token) (updated signature)

Takes a ResolvedProvider. After fetching the profile JSON, navigates provider.profile_data_path before applying provider.field_map. Missing fields (e.g., Nextcloud avatar) are mapped to "".

is_enabled(config) (updated)

Returns True if get_providers(config) returns at least one provider.


Routes (http.py)

Replace the two hardcoded Gitea routes with generic ones:

GET /login/oauth/{name}            initiate OAuth flow
GET /login/oauth/{name}/callback   receive code, provision user, set session

Both handlers resolve {name} via get_providers(config). If the name is not found, return 404. Existing /login/oauth/gitea URLs continue to work as long as the config has a gitea key.


Login Page (http.py)

The "or" divider appears once if any providers are configured. Below it, one button per provider stacks vertically. Button appearance mirrors the current Gitea button (same CSS class, optional logo img). Button href is /login/oauth/{provider.name}.


Tests (tests/test_oauth.py)

Updated: Existing tests for build_auth_url, exchange_code, fetch_user, is_enabled ported to new ResolvedProvider-based signatures.

New:

  • get_providers() with old single-Gitea config (no type) → one provider, backward compat confirmed
  • get_providers() with Gitea + GitHub + Nextcloud → correct count, types, and labels
  • get_providers() skips entry missing client_id or client_secret
  • get_providers() skips Gitea/Nextcloud entry missing url
  • get_providers() skips entry with unknown type (logs warning)
  • build_auth_url for each provider type → correct authorize URL
  • exchange_code for GitHub → Accept: application/json header present
  • fetch_user for Nextcloud → ocs.data navigation, missing avatar handled as ""
  • Login page HTML → one button per provider; no buttons when oauth is empty

Out of Scope

  • Generic/custom provider with user-specified endpoints
  • OIDC / token introspection
  • Restricting login to specific GitHub orgs or Nextcloud groups
  • Automatic admin promotion from OAuth
  • Token refresh