sillo-oauth
OAuth 2.0 and OpenID Connect login for Sillo.
Two functions and a provider object. Neither function takes a response, builds one, or registers a route — so the routes, the error handling, and the decision of what a login means stay in your application.
pip install sillo-oauth
The whole API
from sillo_oauth import GoogleOAuthProvider, authorize_url, exchange, OAuthError
google = GoogleOAuthProvider(
client_id=...,
client_secret=...,
state_secret=..., # signs the state cookie; your own key
redirect_uri="https://example.com/auth/google/callback",
)
@app.get("/auth/google/redirect")
async def start(request, response):
authorize = authorize_url(google)
return response.redirect(authorize.url).set_cookie(**authorize.cookie_kwargs())
@app.get("/auth/google/callback")
async def finish(request, response):
try:
profile = await exchange(google, request)
except OAuthError as exc:
return response.redirect(f"/login?error={exc.code}")
user = await User.objects.get_or_create_from_oauth("google", profile)
login(request, user)
return response.redirect(profile.return_to or "/")
That is the entire integration. authorize_url is pure — no request, no I/O —
and returns a URL plus the state you need to store. exchange reads the
callback request and returns a verified OAuthProfile. Nothing else is
implied.
Local development over
http://needscookie_kwargs(secure=False). Otherwise the browser accepts theSecurecookie and never sends it back, and every callback fails as a state mismatch.
What it deliberately does not do
Turning a verified external identity into a logged-in user is your
application's decision, not this package's. So there is no on_success hook,
no user model, no session handling — just the four lines after exchange:
# a server-rendered app
login(request, user)
return response.redirect("/dashboard")
# an SPA or mobile client
token = create_jwt({"id": user.id}, SECRET)
return response.json({"access_token": token})
# linking a provider to the user who is already signed in
await OAuthIdentity.objects.link(request.user, "github", profile.subject)
# nothing at all — just prove the address
return response.json({"verified_email": profile.email})
The same two functions serve all of them.
Providers
GoogleOAuthProvider, GithubOAuthProvider, DiscordOAuthProvider and
MicrosoftOAuthProvider ship with endpoints, scopes and profile mapping
filled in. Every one of those is overridable per instance.
Anything else uses OAuthProvider directly:
gitlab = OAuthProvider(
name="gitlab",
client_id=...,
client_secret=...,
state_secret=...,
authorize_endpoint="https://gitlab.com/oauth/authorize",
token_endpoint="https://gitlab.com/oauth/token",
userinfo_endpoint="https://gitlab.com/api/v4/user",
scopes=["read_user"],
)
Self-hosted installations override the endpoints, and everything derived from them follows — pointing GitHub at an Enterprise host also moves the address lookup it falls back to:
github = GithubOAuthProvider(
...,
userinfo_endpoint="https://github.acme-corp.test/api/v3/user",
)
Extra headers are merged over the provider's defaults, so adding one does not mean restating the rest:
acme = OAuthProvider(..., userinfo_headers={"X-Tenant": "acme"})
To map a provider's fields yourself, pass profile_mapper or subclass and
override map_profile:
acme = OAuthProvider(
...,
profile_mapper=lambda raw: {
"subject": raw["employee_number"],
"email": raw["work_email"],
"name": raw["full_name"],
},
)
OAuthProfile
profile.provider # "google"
profile.subject # stable provider-side id — the only safe account key
profile.key # "google:112233" — unique across providers
profile.email
profile.email_verified # False also means "the provider did not say"
profile.name
profile.username
profile.avatar_url
profile.raw # the untouched userinfo payload
profile.tokens # access/refresh tokens, for calling the provider later
profile.return_to # whatever you passed to authorize_url(return_to=...)
Key accounts on subject, never on email: addresses get reassigned, and an
unverified one is an account-takeover vector.
Errors
Every failure raises an OAuthError subclass carrying a stable, URL-safe
.code, so one except is enough and codes can go straight into a redirect.
.code |
Raised when |
|---|---|
denied |
The person declined consent. Not a fault — send them back to the login page. |
provider_error |
The provider reported some other error parameter. |
state_mismatch |
The callback does not match a redirect this server issued: no cookie, no state, a forged or tampered cookie, or one minted for another provider. |
state_expired |
Genuine state, but too old. Worth a "that took too long, try again". |
exchange_failed |
The provider would not trade the code for a token. |
profile_failed |
A token was issued but no usable profile came back. |
provider_misconfigured |
Programming error — a missing secret, redirect URI, or endpoint. |
Security
- State is signed, not stored. No session store, no database, no sticky routing — the CSRF token rides in an HMAC-signed cookie carrying an expiry and the provider name, so a cookie minted for one provider cannot complete another's callback.
- PKCE verifiers are derived, never stored. The verifier is recomputed at
exchange time as
HMAC(state_secret, state). Putting it in the cookie would have placed a secret somewhere readable; keeping it server-side would have reintroduced the state store. The provider only ever sees the S256 challenge. - State is verified before anything is sent to the provider, so a forged callback cannot make your server issue a token request.
- Reserved parameters (
state,code_challenge,client_id, …) cannot be overridden throughextra_params. Supplying one raises rather than being ignored, because an application that believes it is settingstateand silently is not has a security expectation the code no longer meets. - Tokens are redacted from reprs.
logger.info("signed in %s", profile)is an ordinary line to write, and an error tracker collects tracebacks holding profiles in frames. Neither leaks a credential; attribute access is unaffected.
state_secret is unrelated to client_secret: it protects your own cookies,
not your relationship with the provider. Any high-entropy application key
works, and one can be shared across providers.
Lower-level entry points
exchange(provider, request) is a convenience over functions that take no
request at all, for callers whose callback did not arrive as a Sillo request —
a worker, a CLI, a test:
profile = await complete(provider, code=..., state=..., cookie_value=...)
tokens = await exchange_code(provider, code=..., verifier=...)
profile = await fetch_profile(provider, tokens)
tokens = await refresh_tokens(provider, refresh_token=...)
Storing state somewhere other than a cookie works the same way — hand the stored value back explicitly:
# at the redirect step
request.session["oauth_state"] = authorize.cookie_value
# at the callback — sillo's Session has get/delete, not pop
stored = request.session.get("oauth_state")
request.session.delete("oauth_state")
profile = await exchange(google, request, state_value=stored)
Development
pip install -e ".[dev]"
pytest # 240+ tests, no network
ruff check .
ruff format --check .
mypy sillo_oauth/
The suite never touches the network and never needs real credentials. Provider
responses are canned through an injected httpx transport, and an autouse
fixture breaks the real transport so a test that forgets a stub fails loudly
instead of reaching out to Google.
Licence
BSD-3-Clause.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file sillo_oauth-0.1.0.tar.gz.
File metadata
- Download URL: sillo_oauth-0.1.0.tar.gz
- Upload date:
- Size: 75.8 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via:
uv/0.12.3 {"installer":{"name":"uv","version":"0.12.3","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
22f9db1c3e211f2de654d12abd2ca653ec455d3a5acf053f84669ccd61480685
|
|
| MD5 |
ab1c5375853fefe59fd0de641920673a
|
|
| BLAKE2b-256 |
af2c30d07c2551681b19e71e6b0c85d16349e1299f9c507b75266fdb715e7663
|
File details
Details for the file sillo_oauth-0.1.0-py3-none-any.whl.
File metadata
- Download URL: sillo_oauth-0.1.0-py3-none-any.whl
- Upload date:
- Size: 30.0 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via:
uv/0.12.3 {"installer":{"name":"uv","version":"0.12.3","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
770129821135be12e324fb6c090b76af8492ecb79b516f3be987009dd7b83bfc
|
|
| MD5 |
8cb7edaf93dbeb375741e74948679e57
|
|
| BLAKE2b-256 |
0f70effa7ed48c3df361d7f8452a4b46d434f01c7e1aacf249149405bd236173
|