Skip to main content

sillo-oauth

Test

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:// needs cookie_kwargs(secure=False). Otherwise the browser accepts the Secure cookie 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 through extra_params. Supplying one raises rather than being ignored, because an application that believes it is setting state and 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.

Metadata

Release files for sillo-oauth 1.0.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for sillo-oauth 1.0.0
File Size Uploaded
sillo_oauth-1.0.0.tar.gz 82.5 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for sillo-oauth 1.0.0
File Interpreter ABI Platform
sillo_oauth-1.0.0-py3-none-any.whl Python 3 none any Details

Total release size: 112.5 kB

Release files / sillo_oauth-1.0.0.tar.gz

Download URL sillo_oauth-1.0.0.tar.gz
Size 82.5 kB
Tags Source
SHA-256 checksum
How to use checksums
01d7bdc24be21b7904a12d69128d678280ee0a57ac3d4985d20d45dba4d80c90
BLAKE2b-256 checksum
How to use checksums
acb04de46ddfd0c870e99de02e8d95ca9c3da2119eb24f296765ecd1eb06b29d
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.12.19 {"installer":{"name":"uv","version":"0.12.19","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}

Release files / sillo_oauth-1.0.0-py3-none-any.whl

Download URL sillo_oauth-1.0.0-py3-none-any.whl
Size 30.0 kB
Tags Python 3
SHA-256 checksum
How to use checksums
839dcc410ec3d74d4cd73e522f6d770867f6b7d234c870b11296eb03e68e1a6e
BLAKE2b-256 checksum
How to use checksums
2fcd7dc68293587dc5eed47d162ddfb7b5eb33e66aa1fda5d388d70abcec8c6a
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.12.19 {"installer":{"name":"uv","version":"0.12.19","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}

Release history Release notifications | RSS feed

This release

1.0.0 This release

2 release files

0.1.1

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page