diff --git a/docs/superpowers/specs/2026-05-09-multi-oauth-providers-design.md b/docs/superpowers/specs/2026-05-09-multi-oauth-providers-design.md new file mode 100644 index 0000000..ed6b47a --- /dev/null +++ b/docs/superpowers/specs/2026-05-09-multi-oauth-providers-design.md @@ -0,0 +1,149 @@ +# 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. + +```yaml +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): + +```python +@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": "", "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