|
| 1 | +# Account Associations (OAuth backends) |
| 2 | + |
| 3 | +Account associations let a PyPI user link their account |
| 4 | +to a third-party identity provider (GitHub, GitLab) for identity verification. |
| 5 | +The OAuth token is used only to confirm the external identity at link time; |
| 6 | +it is **not** stored. |
| 7 | +What gets stored is the provider name, the provider's stable numeric user ID, |
| 8 | +and the username. |
| 9 | + |
| 10 | +This is distinct from Trusted Publishing (OIDC, in `warehouse/oidc/`), |
| 11 | +which authenticates CI/CD systems for uploads. |
| 12 | +Account associations authenticate a human linking their own profile. |
| 13 | + |
| 14 | +## Architecture |
| 15 | + |
| 16 | +Each provider is an implementation of `IOAuthProviderService` (`warehouse/accounts/oauth.py`). |
| 17 | +The concrete clients are: |
| 18 | + |
| 19 | +| Provider | Real client | Null client (dev/test) | Flow | |
| 20 | +| -------- | ----------- | ---------------------- | ---- | |
| 21 | +| GitHub | `GitHubAppClient` | `NullGitHubOAuthClient` | GitHub App OAuth | |
| 22 | +| GitLab | `GitLabOAuthClient` | `NullGitLabOAuthClient` | OAuth 2.0, `read_user` scope | |
| 23 | + |
| 24 | +Services are registered per-provider in `warehouse/accounts/__init__.py` |
| 25 | +via `register_service_factory(..., IOAuthProviderService, name="<provider>")`. |
| 26 | +The views live in `warehouse/manage/views/account_associations.py` |
| 27 | +and the `connect`/`callback` routes in `warehouse/routes.py` |
| 28 | +(`/manage/account/associations/<provider>/{connect,callback}`). |
| 29 | + |
| 30 | +The provider's numeric `id` is the stable identifier for linking - |
| 31 | +usernames can change, IDs cannot. |
| 32 | + |
| 33 | +## Configuration |
| 34 | + |
| 35 | +Backends are selected by environment variable, parsed in `warehouse/config.py` |
| 36 | +with `maybe_set_compound` into `<provider>.oauth.backend` settings: |
| 37 | + |
| 38 | +| Env var | Setting | Required? | |
| 39 | +| ------- | ------- | --------- | |
| 40 | +| `GITHUB_OAUTH_BACKEND` | `github.oauth.backend` | **Required** - registered unconditionally | |
| 41 | +| `GITLAB_OAUTH_BACKEND` | `gitlab.oauth.backend` | Optional - registered only when set; the views return 404 when unconfigured | |
| 42 | + |
| 43 | +The value uses a compound format - the client class path |
| 44 | +followed by space-separated `key=value` kwargs: |
| 45 | + |
| 46 | +```bash |
| 47 | +GITLAB_OAUTH_BACKEND=warehouse.accounts.oauth.GitLabOAuthClient client_id=<app-id> client_secret=<app-secret> |
| 48 | +``` |
| 49 | + |
| 50 | +The real clients read `<provider>.oauth.client_id` and |
| 51 | +`<provider>.oauth.client_secret` from settings in `create_service`. |
| 52 | + |
| 53 | +### Local development |
| 54 | + |
| 55 | +`dev/environment` already wires both providers to their Null clients, |
| 56 | +so the account-associations UI works locally without registering real OAuth apps: |
| 57 | + |
| 58 | +```bash |
| 59 | +GITHUB_OAUTH_BACKEND=warehouse.accounts.oauth.NullGitHubOAuthClient |
| 60 | +GITLAB_OAUTH_BACKEND=warehouse.accounts.oauth.NullGitLabOAuthClient |
| 61 | +``` |
| 62 | + |
| 63 | +The Null clients simulate the OAuth round-trip and must never be used in |
| 64 | +production. |
| 65 | + |
| 66 | +## Gotcha: missing `client_id`/`client_secret` |
| 67 | + |
| 68 | +Setting a backend to the real client class **without** the `client_id` and |
| 69 | +`client_secret` kwargs is a footgun that passes startup and fails later: |
| 70 | + |
| 71 | +```bash |
| 72 | +# Broken - no kwargs |
| 73 | +GITLAB_OAUTH_BACKEND=warehouse.accounts.oauth.GitLabOAuthClient |
| 74 | +``` |
| 75 | + |
| 76 | +`includeme` only checks that the backend setting is present, so the app boots fine. |
| 77 | +But `create_service` reads `settings["gitlab.oauth.client_id"]`, |
| 78 | +so the first time a user clicks "Connect", the request 500s with `KeyError: 'gitlab.oauth.client_id'`. |
| 79 | +Always pass both kwargs with the real client. |
| 80 | +This applies to every provider. |
| 81 | + |
| 82 | +## Adding a new provider |
| 83 | + |
| 84 | +Follow the `GitLabOAuthClient` pattern: |
| 85 | + |
| 86 | +1. Add the client class (and a `Null*Client`) in |
| 87 | + `warehouse/accounts/oauth.py`, implementing `IOAuthProviderService`. |
| 88 | +2. Parse the backend env var in `warehouse/config.py` with `maybe_set_compound`. |
| 89 | +3. Register the service factory in `warehouse/accounts/__init__.py` |
| 90 | + (gate it on the setting being present if the provider is optional). |
| 91 | +4. Add `connect`/`callback` routes in `warehouse/routes.py` and views in |
| 92 | + `warehouse/manage/views/account_associations.py`. |
| 93 | +5. Update the account-associations UI in `warehouse/templates/manage/account.html`. |
| 94 | +6. Add tests in `tests/unit/accounts/test_oauth.py` and test config in `tests/conftest.py`. |
0 commit comments