Account Associations (OAuth backends)¶
Account associations let a PyPI user link their account to a third-party identity provider (GitHub, GitLab) for identity verification. The OAuth token is used only to confirm the external identity at link time; it is not stored. What gets stored is the provider name, the provider's stable numeric user ID, and the username.
This is distinct from Trusted Publishing (OIDC, in warehouse/oidc/),
which authenticates CI/CD systems for uploads.
Account associations authenticate a human linking their own profile.
Architecture¶
Each provider is an implementation of IOAuthProviderService (warehouse/accounts/oauth.py).
The concrete clients are:
| Provider | Real client | Null client (dev/test) | Flow |
|---|---|---|---|
| GitHub | GitHubAppClient |
NullGitHubOAuthClient |
GitHub App OAuth |
| GitLab | GitLabOAuthClient |
NullGitLabOAuthClient |
OAuth 2.0, read_user scope |
Services are registered per-provider in warehouse/accounts/__init__.py
via register_service_factory(..., IOAuthProviderService, name="<provider>").
The views live in warehouse/manage/views/account_associations.py
and the connect/callback routes in warehouse/routes.py
(/manage/account/associations/<provider>/{connect,callback}).
The provider's numeric id is the stable identifier for linking -
usernames can change, IDs cannot.
Configuration¶
Backends are selected by environment variable, parsed in warehouse/config.py
with maybe_set_compound into <provider>.oauth.backend settings:
| Env var | Setting | Required? |
|---|---|---|
GITHUB_OAUTH_BACKEND |
github.oauth.backend |
Required - registered unconditionally |
GITLAB_OAUTH_BACKEND |
gitlab.oauth.backend |
Optional - registered only when set; the views return 404 when unconfigured |
The value uses a compound format - the client class path
followed by space-separated key=value kwargs:
GITLAB_OAUTH_BACKEND=warehouse.accounts.oauth.GitLabOAuthClient client_id=<app-id> client_secret=<app-secret>
The real clients read <provider>.oauth.client_id and
<provider>.oauth.client_secret from settings in create_service.
Local development¶
dev/environment already wires both providers to their Null clients,
so the account-associations UI works locally without registering real OAuth apps:
GITHUB_OAUTH_BACKEND=warehouse.accounts.oauth.NullGitHubOAuthClient
GITLAB_OAUTH_BACKEND=warehouse.accounts.oauth.NullGitLabOAuthClient
The Null clients simulate the OAuth round-trip and must never be used in production.
Gotcha: missing client_id/client_secret¶
Setting a backend to the real client class without the client_id and
client_secret kwargs is a footgun that passes startup and fails later:
includeme only checks that the backend setting is present, so the app boots fine.
But create_service reads settings["gitlab.oauth.client_id"],
so the first time a user clicks "Connect", the request 500s with KeyError: 'gitlab.oauth.client_id'.
Always pass both kwargs with the real client.
This applies to every provider.
Adding a new provider¶
Follow the GitLabOAuthClient pattern:
- Add the client class (and a
Null*Client) inwarehouse/accounts/oauth.py, implementingIOAuthProviderService. - Parse the backend env var in
warehouse/config.pywithmaybe_set_compound. - Register the service factory in
warehouse/accounts/__init__.py(gate it on the setting being present if the provider is optional). - Add
connect/callbackroutes inwarehouse/routes.pyand views inwarehouse/manage/views/account_associations.py. - Update the account-associations UI in
warehouse/templates/manage/account.html. - Add tests in
tests/unit/accounts/test_oauth.pyand test config intests/conftest.py.