Skip to main content
Pre-release

This release is a pre-release and may not be stable for production use.

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.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

sillo_oauth-1.0.0a1.tar.gz (82.5 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

sillo_oauth-1.0.0a1-py3-none-any.whl (30.0 kB view details)

Uploaded Python 3

File details

Details for the file sillo_oauth-1.0.0a1.tar.gz.

File metadata

  • Download URL: sillo_oauth-1.0.0a1.tar.gz
  • Upload date:
  • Size: 82.5 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.12.13 {"installer":{"name":"uv","version":"0.12.13","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

Hashes for sillo_oauth-1.0.0a1.tar.gz
Algorithm Hash digest
SHA256 78e3e420828ad07337ef5376ab3c2804f38f37ec1bd4632a7aa96bbefd9d7f1e
MD5 9e7ff7d6246c65971263640afd95b7b2
BLAKE2b-256 e3d5d02c9f861db79f89f103e831cfd82cc59c6f0da424e47137cdce0d1e8555

See more details on using hashes here.

File details

Details for the file sillo_oauth-1.0.0a1-py3-none-any.whl.

File metadata

  • Download URL: sillo_oauth-1.0.0a1-py3-none-any.whl
  • Upload date:
  • Size: 30.0 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.12.13 {"installer":{"name":"uv","version":"0.12.13","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

Hashes for sillo_oauth-1.0.0a1-py3-none-any.whl
Algorithm Hash digest
SHA256 05a030644605505f28ebb341c722c7b54261244d85d5f361783307dc90211ee0
MD5 d9a15e0c21c317454c1e83440f1d37c3
BLAKE2b-256 f7cafd7d131c26c91ecf455bd5a2a374aee693dfac4bb5439357949714b0072d

See more details on using hashes here.

Release history Release notifications | RSS feed

This release

1.0.0a1 This release

2 files

0.1.1

2 files

0.1.0

2 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