Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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 (notype) → one provider, backward compat confirmedget_providers()with Gitea + GitHub + Nextcloud → correct count, types, and labelsget_providers()skips entry missingclient_idorclient_secretget_providers()skips Gitea/Nextcloud entry missingurlget_providers()skips entry with unknowntype(logs warning)build_auth_urlfor each provider type → correct authorize URLexchange_codefor GitHub →Accept: application/jsonheader presentfetch_userfor Nextcloud →ocs.datanavigation, missing avatar handled as""- Login page HTML → one button per provider; no buttons when
oauthis 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