Skip to content

Provider abstraction, OAuth flow, state management, PKCE, testing

Package: sillo-oauth v0.1.1 Repository: https://github.com/sillohq/oauth Source root: oauth/sillo_oauth/ Tests: oauth/tests/


sillo-oauth is a router-free, middleware-free OAuth 2.0 / OpenID Connect login library for Sillo. It exposes two public functions (authorize_url and exchange) and a handful of frozen dataclasses. There is no router, no middleware, and no storage layer. The caller decides how to redirect the user, where to store the session cookie, and what to do with the profile.

"Plain functions, no router, no middleware."

The package depends on:

DependencyConstraintPurpose
sillo-framework>=0.0.2a1sillo.helpers.crypto.sign_value / unsign_value for HMAC state signing
httpx>=0.23.3,<0.29.0Async HTTP client for token exchange and profile fetch

oauth/sillo_oauth/
├── __init__.py # Public API re-exports, __version__ = "0.1.1"
├── errors.py # OAuthError hierarchy (7 error classes)
├── models.py # AuthorizeURL, OAuthTokens, OAuthProfile frozen dataclasses
├── providers.py # OAuthProvider base + Google/Github/Discord/Microsoft
├── state.py # State minting, verification, PKCE derivation
└── flow.py # authorize_url(), exchange(), complete(), refresh, fetch_profile
graph TD
    A[flow.py] -->|imports| B[state.py]
    A -->|imports| C[models.py]
    A -->|imports| D[providers.py]
    A -->|imports| E[errors.py]
    D -->|imports| C
    B -->|imports| E
    F[__init__.py] -->|re-exports all| A
    F -->|re-exports all| B
    F -->|re-exports all| C
    F -->|re-exports all| D
    F -->|re-exports all| E

File paths (absolute):

ModulePath
__init__/Users/admin/sillo.build/oauth/sillo_oauth/__init__.py
errors/Users/admin/sillo.build/oauth/sillo_oauth/errors.py
models/Users/admin/sillo.build/oauth/sillo_oauth/models.py
providers/Users/admin/sillo.build/oauth/sillo_oauth/providers.py
state/Users/admin/sillo.build/oauth/sillo_oauth/state.py
flow/Users/admin/sillo.build/oauth/sillo_oauth/flow.py

The __init__.py re-exports 25 symbols via __all__:

__all__ = [
"__version__",
"OAuthError", "OAuthDenied", "ProviderRejected", "StateMismatch",
"StateExpired", "TokenExchangeFailed", "ProfileFetchFailed",
"ProviderMisconfigured",
"AuthorizeURL", "OAuthTokens", "OAuthProfile",
"OAuthProvider", "GoogleOAuthProvider", "GithubOAuthProvider",
"DiscordOAuthProvider", "MicrosoftOAuthProvider",
"authorize_url", "exchange", "complete", "exchange_code",
"refresh_tokens", "fetch_profile",
"issue_state", "verify_state", "derive_verifier", "pkce_challenge",
]

All three data models are frozen dataclasses. Once constructed, they are immutable. This is a deliberate contract: the library never mutates a returned model, and consumers can rely on the same object being safe to store, pass across tasks, or compare.

Source: /Users/admin/sillo.build/oauth/sillo_oauth/models.py

@dataclass(frozen=True)
class AuthorizeURL:
url: str
state: str
cookie_name: str
cookie_value: str
max_age: int

Returned by authorize_url(). The caller uses the fields to build the HTTP redirect and the Set-Cookie header.

FieldTypePurpose
urlstrThe full authorization endpoint URL with query parameters
statestrThe random state value (43 chars, base64url)
cookie_namestrNamespaced cookie name, e.g. oauth_state_google
cookie_valuestrThe signed payload to set as the cookie value
max_ageintCookie TTL in seconds (default 600 = 10 minutes)

The cookie_kwargs(**overrides) method returns a dict of safe defaults for Response.set_cookie:

def cookie_kwargs(self, **overrides: Any) -> dict[str, Any]:
kwargs = {
"key": self.cookie_name,
"value": self.cookie_value,
"max_age": self.max_age,
"httponly": True, # nothing in the browser needs to read it
"samesite": "lax", # strict would block the provider redirect-back
"secure": True, # HTTPS expected; set secure=False for localhost
"path": "/",
}
kwargs.update(overrides)
return kwargs

Design note: samesite="lax" is chosen over "strict" because the provider redirects back from a different origin. httponly=True prevents JavaScript from reading the state cookie (XSS defense in depth).

Source: /Users/admin/sillo.build/oauth/sillo_oauth/models.py

@dataclass(frozen=True, repr=False)
class OAuthTokens:
access_token: str
token_type: str = "Bearer"
refresh_token: str | None = None
expires_in: int | None = None
scope: str | None = None
id_token: str | None = None
raw: dict[str, Any] = field(default_factory=dict)
FieldTypeDefaultPurpose
access_tokenstr(required)The OAuth access token
token_typestr"Bearer"Token type (normalized to capitalized)
refresh_tokenstr | NoneNoneRefresh token, if the provider issued one
expires_inint | NoneNoneSeconds until expiry; None means unknown
scopestr | NoneNoneGranted scopes (space-separated)
id_tokenstr | NoneNoneOpenID Connect ID token (JWT)
rawdict[str, Any]{}Complete provider response payload

Credential redaction: The custom __repr__ never prints tokens:

OAuthTokens(access_token=<redacted>, token_type='Bearer', expires_in=3600,
scope='openid email', also_holds=[refresh_token, id_token])

authorization_header(): Returns "Bearer <token>". Normalizes lowercase "bearer" from providers like GitHub that return the token type in lowercase.

Source: /Users/admin/sillo.build/oauth/sillo_oauth/models.py

@dataclass(frozen=True)
class OAuthProfile:
provider: str
subject: str
email: str | None = None
email_verified: bool = False
name: str | None = None
username: str | None = None
avatar_url: str | None = None
raw: dict[str, Any] = field(default_factory=dict)
tokens: OAuthTokens | None = None
return_to: str | None = None
FieldTypeDefaultPurpose
providerstr(required)Provider name (e.g. "google")
subjectstr(required)Stable provider-issued user ID
emailstr | NoneNoneUser’s email address
email_verifiedboolFalseWhether the provider confirmed the email. False also means “provider did not say”
namestr | NoneNoneDisplay name
usernamestr | NoneNoneUsername / handle
avatar_urlstr | NoneNoneProfile picture URL
rawdict[str, Any]{}Complete userinfo response
tokensOAuthTokens | NoneNoneThe tokens used to fetch this profile
return_tostr | NoneNoneApplication-supplied return path

key property: Returns "<provider>:<subject>". This is the only value safe to use as a unique identity key. Emails get reassigned; usernames get renamed; provider+subject do not.


Source: /Users/admin/sillo.build/oauth/sillo_oauth/errors.py (130 lines)

All errors are direct children of OAuthError. Each has a class-level code attribute: a stable, URL-safe, underscore-delimited string that callers can match on without touching the exception class:

graph TD
    O["OAuthError<br/>code: 'oauth_error'"] --> D["OAuthDenied<br/>code: 'denied'"]
    O --> PR["ProviderRejected<br/>code: 'provider_error'"]
    O --> SM["StateMismatch<br/>code: 'state_mismatch'"]
    O --> SE["StateExpired<br/>code: 'state_expired'"]
    O --> TE["TokenExchangeFailed<br/>code: 'exchange_failed'"]
    O --> PF["ProfileFetchFailed<br/>code: 'profile_failed'"]
    O --> PM["ProviderMisconfigured<br/>code: 'provider_misconfigured'"]
ClasscodeRaised When
OAuthError"oauth_error"Base class; catch-all
OAuthDenied"denied"User declined consent (access_denied / user_denied)
ProviderRejected"provider_error"Provider redirected with a non-denial error
StateMismatch"state_mismatch"Callback doesn’t match any redirect (CSRF, forged, wrong provider)
StateExpired"state_expired"State cookie is genuine but past its TTL
TokenExchangeFailed"exchange_failed"Provider refused to exchange code for token
ProfileFetchFailed"profile_failed"Token obtained but no usable profile came back
ProviderMisconfigured"provider_misconfigured"Programming error: missing secret, redirect URI, or endpoint

Constructor signature:

class OAuthError(Exception):
code = "oauth_error"
def __init__(
self,
message: str | None = None,
*,
provider: str | None = None,
detail: str | None = None,
) -> None

If message is None, falls back to self.code. The __repr__ includes all three fields: OAuthError('denied', provider='google', detail='User declined').

Contract: Every error raised by the library is catchable as OAuthError. Callers who want fine-grained handling catch specific subclasses; callers who want a safety net catch OAuthError.


Source: /Users/admin/sillo.build/oauth/sillo_oauth/providers.py (667 lines)

class OAuthProvider:
name: str = "oauth"
authorize_endpoint: str = ""
token_endpoint: str = ""
userinfo_endpoint: str | None = None
default_scopes: tuple[str, ...] = ()
scope_separator: str = " "
use_pkce: bool = True
token_headers: Mapping[str, str] = MappingProxyType({"Accept": "application/json"})
userinfo_headers: Mapping[str, str] = MappingProxyType({"Accept": "application/json"})

Constructor (__init__):

def __init__(
self,
*,
client_id: str,
client_secret: str = "",
state_secret: str | None = None,
redirect_uri: str | None = None,
scopes: tuple[str, ...] | None = None,
name: str | None = None,
authorize_endpoint: str | None = None,
token_endpoint: str | None = None,
userinfo_endpoint: str | None = None,
use_pkce: bool | None = None,
authorize_params: dict[str, str] | None = None,
token_headers: Mapping[str, str] | None = None,
userinfo_headers: Mapping[str, str] | None = None,
profile_mapper: Callable[[dict[str, Any]], Mapping[str, Any]] | None = None,
transport: httpx.AsyncBaseTransport | None = None,
timeout: float = 10.0,
) -> None

Validation at construction time:

  1. If authorize_endpoint resolves to empty string, raises ProviderMisconfigured.
  2. If token_endpoint resolves to empty string, raises ProviderMisconfigured.
  3. token_headers and userinfo_headers are merged over class defaults using MappingProxyType to produce read-only mappings.

Immutability of headers: The class-level token_headers and userinfo_headers are MappingProxyType (read-only dict). Instance-level headers are also stored as MappingProxyType. This prevents a provider instance from accidentally modifying the class-level defaults.

__repr__: Shows name and client_id; hides both client_secret and state_secret.

MethodReturnsDescription
http_client()httpx.AsyncClientBuilds client from transport/timeout; overridable
token_request_data(code, redirect_uri, verifier)dict[str, str]Form body for token exchange; omits client_secret if empty
refresh_request_data(refresh_token)dict[str, str]Form body for refresh grant
fetch_profile(client, tokens)OAuthProfileCalls fetch_userinfo then build_profile; overridable
fetch_userinfo(client, tokens)dict[str, Any]GETs userinfo_endpoint with Authorization: Bearer header
map_profile(raw)Mapping[str, Any]Default best-guess mapping; overridable per-subclass
build_profile(raw, tokens)OAuthProfileApplies mapper, raises ProfileFetchFailed if no subject

Generic map_profile: Tries _SUBJECT_KEYS = ("sub", "id", "user_id", "uid") in order. Picks up email, name, username, avatar_url if present.

graph TD
    OP[OAuthProvider] --> G[GoogleOAuthProvider]
    OP --> GH[GithubOAuthProvider]
    OP --> D[DiscordOAuthProvider]
    OP --> M[MicrosoftOAuthProvider]
AttributeValue
name"google"
authorize_endpointhttps://accounts.google.com/o/oauth2/v2/auth
token_endpointhttps://oauth2.googleapis.com/token
userinfo_endpointhttps://openidconnect.googleapis.com/v1/userinfo
default_scopes("openid", "email", "profile")
use_pkceTrue (inherited)

Custom map_profile: maps sub, email, email_verified, name, preferred_username -> username, picture -> avatar_url.

AttributeValue
name"github"
authorize_endpointhttps://github.com/login/oauth/authorize
token_endpointhttps://github.com/login/oauth/access_token
userinfo_endpointhttps://api.github.com/user
default_scopes("read:user", "user:email")
use_pkceFalse
userinfo_headers{"Accept": "application/vnd.github+json"}

Special behaviors:

  • PKCE is off because GitHub’s OAuth app flow does not implement it.
  • emails_endpoint attribute is derived from userinfo_endpoint at construction.
  • Overrides fetch_profile: If /user returns no email, makes a second call to /user/emails to find the primary verified address. Failure is non-fatal (login succeeds even if the email scope is refused).
  • _fetch_primary_email(client, tokens) -> dict | None: Returns {"email": ..., "email_verified": True} or None.
  • Custom map_profile: maps id (as subject), login (as username), name, email, email_verified, avatar_url.

Enterprise GitHub: Set userinfo_endpoint to https://github.example.com/api/user. The emails_endpoint is derived automatically.

AttributeValue
name"discord"
authorize_endpointhttps://discord.com/oauth2/authorize
token_endpointhttps://discord.com/api/oauth2/token
userinfo_endpointhttps://discord.com/api/users/@me
default_scopes("identify", "email")

Custom map_profile: builds CDN avatar URL from id + avatar hash: https://cdn.discordapp.com/avatars/{id}/{avatar}.png. Maps global_name or username for display name. email_verified maps from Discord’s verified field.

AttributeValue
name"microsoft"
userinfo_endpointhttps://graph.microsoft.com/oidc/userinfo
default_scopes("openid", "email", "profile")

Extra constructor parameter: tenant: str = "common". Derives both authorize_endpoint and token_endpoint from: https://login.microsoftonline.com/{tenant}/oauth2/v2.0/authorize|token

Explicit authorize_endpoint or token_endpoint kwargs override tenant-derived defaults.

Custom map_profile: maps sub, email (falls back to upn), name, preferred_username, picture. email_verified is always False because Microsoft’s userinfo endpoint never states verification status.


Source: /Users/admin/sillo.build/oauth/sillo_oauth/flow.py

def authorize_url(
provider: OAuthProvider,
*,
redirect_uri: str | None = None,
scopes: tuple[str, ...] | None = None,
return_to: str | None = None,
extra_params: Mapping[str, str] | None = None,
ttl: int = 600,
secret: str | None = None,
cookie_name: str | None = None,
) -> AuthorizeURL

This is a pure function: no I/O, no request object, no response object. It does one thing: builds the URL the browser should redirect to.

  1. Resolve secret: Per-call secret wins over provider.state_secret. Raises ProviderMisconfigured if neither is set.
  2. Resolve redirect URI: Per-call wins over provider.redirect_uri. Raises ProviderMisconfigured if neither is set.
  3. Mint state: Calls issue_state(provider.name, secret, ttl=ttl, return_to=return_to). Returns (state, cookie_value).
  4. Build params dict:
    • response_type=code
    • client_id=provider.client_id
    • redirect_uri=redirect_uri
    • state=state
    • Optionally scope (joined with provider.scope_separator)
    • Optionally PKCE: code_challenge=pkce_challenge(derive_verifier(state, secret)) and code_challenge_method=S256
  5. Merge extras: Per-call extra_params merged. Provider-level authorize_params merged under that. Per-call wins on collision.
  6. Reject reserved params: If any of the 7 managed parameters appear in extras, raises ProviderMisconfigured with a message explaining which parameter and what to do instead.
  7. Construct URL: _merge_query(provider.authorize_endpoint, params). Preserves any existing query on the endpoint.
  8. Return: AuthorizeURL(url, state, cookie_name, cookie_value, max_age=ttl).

The following 7 parameter names are managed by the library and cannot be overridden via extra_params:

ParameterReason
stateManaged by issue_state
code_challengeDerived from state via derive_verifier
code_challenge_methodAlways S256 when PKCE is enabled
response_typeAlways code
client_idSet from provider.client_id
redirect_uriSet from redirect_uri
scopeSet from scopes
graph TD
    A[authorize_url called] --> B{provider.use_pkce?}
    B -->|Yes| C[derive_verifier state, secret]
    C --> D[pkce_challenge verifier]
    D --> E["Add code_challenge + code_challenge_method=S256"]
    B -->|No| F[No PKCE params added]
    E --> G[Build URL]
    F --> G

7. exchange / complete - The Callback Step

Section titled “7. exchange / complete - The Callback Step”
async def exchange(
provider: OAuthProvider,
request: Request,
*,
secret: str | None = None,
cookie_name: str | None = None,
state_value: str | None = None,
redirect_uri: str | None = None,
) -> OAuthProfile

Reads code, state, error, error_description from request.query_params. Reads stored state from cookie (via cookie_name) or the state_value parameter. Delegates to complete().

async def complete(
provider: OAuthProvider,
*,
code: str | None,
state: str | None,
cookie_value: str | None,
error: str | None = None,
error_description: str | None = None,
secret: str | None = None,
redirect_uri: str | None = None,
) -> OAuthProfile

The request-free form of exchange. For callers who authenticated the callback another way (e.g., from a background job or a different framework).

graph TD
    A[Callback arrives] --> B{Provider reported error?}
    B -->|"access_denied / user_denied"| C[Raise OAuthDenied]
    B -->|"Other error"| D[Raise ProviderRejected]
    B -->|No error| E{State verification}
    E -->|"Missing cookie/state"| F[Raise StateMismatch]
    E -->|"Signature invalid"| F
    E -->|"Provider mismatch"| F
    E -->|"Expired"| G[Raise StateExpired]
    E -->|"Valid"| H{PKCE?}
    H -->|Yes| I[derive_verifier]
    H -->|No| J[verifier=None]
    I --> K[POST token endpoint]
    J --> K
    K --> L{Token response OK?}
    L -->|No| M[Raise TokenExchangeFailed]
    L -->|Yes| N[Fetch profile]
    N --> O{Profile OK?}
    O -->|No| P[Raise ProfileFetchFailed]
    O -->|Yes| Q[Return OAuthProfile with return_to]

Why this order matters:

  1. Provider error first: If the provider already told us the user denied, there is no point verifying state or making HTTP requests.
  2. State verification second: Before talking to the provider, confirm the callback is genuine. This prevents an attacker from using the library as a proxy to exchange stolen codes.
  3. Only then talk to the provider: Token exchange and profile fetch happen only after both checks pass.
async def exchange_code(
provider: OAuthProvider,
*,
code: str,
redirect_uri: str | None = None,
verifier: str | None = None,
) -> OAuthTokens

Skips state verification entirely. For callers who authenticated the callback another way (e.g., server-to-server, or a custom state mechanism).


Source: /Users/admin/sillo.build/oauth/sillo_oauth/state.py (233 lines)

ConstantValuePurpose
_PKCE_INFO"sillo-oauth/pkce/v1:"Domain separator for HMAC-based verifier derivation
_STATE_BYTES32Entropy in state values (32 bytes = 43 chars base64url)
@dataclass(frozen=True)
class StatePayload:
state: str # random value echoed by provider
provider: str # provider name the redirect was for
expires_at: float # unix timestamp
return_to: str | None = None # application-supplied opaque value
def issue_state(
provider: str,
secret: str,
*,
ttl: int = 600,
return_to: str | None = None,
now: float | None = None,
) -> tuple[str, str]
  1. Generate 32 random bytes via secrets.token_bytes(32).
  2. Base64url-encode without padding -> 43-char state string.
  3. Build JSON payload: {"s": state, "p": provider, "e": issued+ttl, "r": return_to}.
  4. Sign with sillo.helpers.crypto.sign_value(payload, secret).
  5. Return (state, cookie_value).

The now parameter is injectable for testing (no sleeps needed).

def verify_state(
cookie_value: str | None,
state_param: str | None,
provider: str,
secret: str,
*,
now: float | None = None,
) -> StatePayload

Fixed order of checks:

StepCheckFailure Mode
1Both values presentStateMismatch
2Signature valid (via unsign_value)StateMismatch
3Payload is a dictStateMismatch
4state matches state_param (constant-time hmac.compare_digest)StateMismatch
5provider matchesStateMismatch
6expires_at is numericStateMismatch
7Not expiredStateExpired

Why hmac.compare_digest? Even though a forged value cannot pass the signature check (step 2), using constant-time comparison prevents timing oracles if the signing step is ever relaxed or replaced.

def derive_verifier(state: str, secret: str) -> str
verifier = base64url(HMAC-SHA256(secret, "sillo-oauth/pkce/v1:" + state))
  • Deterministic: Neither the redirect step nor the exchange step stores it.
  • 43 characters: Bottom of RFC 7636’s 43-128 range.
  • Domain separator "sillo-oauth/pkce/v1:" prevents collision with other HMAC uses of the same secret.
  • Only the application secret can produce the correct verifier. An attacker who intercepts the authorization code cannot redeem it without the secret.
def pkce_challenge(verifier: str) -> str
challenge = base64url(SHA-256(verifier.encode("ascii")))

Standard S256 challenge as defined by RFC 7636. The provider only ever sees this challenge, never the verifier.

sequenceDiagram
    participant App as Application
    participant Provider as OAuth Provider

    App->>App: derive_verifier(state, secret)
    App->>App: pkce_challenge(verifier)
    App->>Provider: redirect with code_challenge=S256(challenge)
    Provider->>App: callback with code
    App->>App: derive_verifier(state, secret) [same result]
    App->>Provider: POST /token with code_verifier=verifier
    Provider->>Provider: SHA256(verifier) == code_challenge?
    Provider->>App: access_token

async def _request_tokens(
provider: OAuthProvider,
client: httpx.AsyncClient,
*,
code: str,
redirect_uri: str,
verifier: str | None,
) -> OAuthTokens

Calls _post_token_endpoint with provider.token_request_data(...).

async def _post_token_endpoint(
provider: OAuthProvider,
client: httpx.AsyncClient,
data: dict[str, str],
) -> OAuthTokens
  1. POST form data to provider.token_endpoint.
  2. Handle transport errors (connection refused, timeout).
  3. Check for error field even in 200 responses (some providers do this).
  4. Check for non-2xx status codes.
  5. Decode response: tries JSON first, falls back to form encoding. If no content type, tries both.
  6. Check for missing/empty access_token.
  7. Parse expires_in from any shape (int, float, string, decimal string).
  8. Build OAuthTokens with raw set to the full response.
async def refresh_tokens(
provider: OAuthProvider,
*,
refresh_token: str,
scopes: tuple[str, ...] | None = None,
) -> OAuthTokens

Sends grant_type=refresh_token. If the provider omits a new refresh token from the response, the old one is carried onto the result via dataclasses.replace.

async def fetch_profile(
provider: OAuthProvider,
tokens: OAuthTokens,
) -> OAuthProfile

For callers who already have tokens (e.g., from a stored session) and want to fetch the profile without going through the OAuth flow again.


Source: /Users/admin/sillo.build/oauth/tests/ (8 test files + conftest)

# conftest.py -- autouse fixture
@pytest.fixture(autouse=True)
def no_network(monkeypatch):
async def deny(self, request):
raise AssertionError(
"A test tried to make a real network request. "
"Inject a stub transport instead."
)
monkeypatch.setattr(
httpx.AsyncHTTPTransport, "handle_async_request", deny
)

This fixture runs for every test. A test that forgets a stub transport fails loudly and immediately.

class ProviderStub:
def route(self, url, *, json=None, text=None, status=200,
content_type=None, headers=None) -> ProviderStub
def fail(self, url, exc=None) -> ProviderStub
@property
def transport(self) -> httpx.MockTransport
def request_to(self, url) -> httpx.Request
def form_to(self, url) -> dict[str, str]

Usage pattern:

stub = ProviderStub()
stub.route("https://oauth2.googleapis.com/token", json={
"access_token": "test-token", "token_type": "Bearer"
})
stub.route("https://openidconnect.googleapis.com/v1/userinfo", json={
"sub": "123", "email": "test@example.com"
})
provider = GoogleOAuthProvider(
client_id="test",
client_secret="test",
state_secret="secret",
transport=stub.transport,
)

Key design decisions:

  • Routes are matched on scheme+host+path (query parameters ignored).
  • route() returns self for chaining: stub.route(...).route(...).
  • fail() makes an endpoint raise a transport error (default: httpx.ConnectError).
  • Responses are rebuilt per call (so a route can be hit twice without stream consumption).
  • request_to() finds the last request to a URL.
  • form_to() parses the form body of the last request to a URL.

Both issue_state and verify_state accept now: float | None:

# Issue a state that expires at t=1000
state, cookie = issue_state("google", "secret", ttl=100, now=1000.0)
# Verify at t=1050 (within TTL)
payload = verify_state(cookie, state, "google", "secret", now=1050.0)
# Verify at t=1101 (expired)
with pytest.raises(StateExpired):
verify_state(cookie, state, "google", "secret", now=1101.0)
FileTestsCovers
test_state.py~45State minting, verification rejections, PKCE derivation
test_authorize_url.py~45URL building, scopes, PKCE, extras, overrides, cookie kwargs
test_complete.py~25Happy path, provider errors, state enforcement, failure propagation
test_token_exchange.py~35Request shape, response parsing, form-encoded, failures, refresh
test_profiles.py~45All 4 providers, generic mapping, custom mapper, failures
test_docs_claims.py~15Pins exact reprs, error messages, documented tables
test_docs_openapi.py~25OpenAPI document generation, security schemes
test_sillo_integration.py~25Full flow through real Sillo app

Total: ~260 tests.

test_sillo_integration.py
def make_provider(stub, cls=GoogleOAuthProvider, **overrides):
"""Build provider wired to stub with happy-path routes."""
def state_from(response):
"""Extract state from redirect Location header."""
def session_app():
"""
Creates app with session-cookie auth.
Middleware order matters: SessionMiddleware AFTER AuthenticationMiddleware
so it runs BEFORE (inside-out ordering).
"""

sequenceDiagram
    participant Browser
    participant App as Sillo App
    participant Provider as OAuth Provider

    Browser->>App: GET /auth/login
    App->>App: authorize_url(provider)
    App->>Browser: 302 Redirect + Set-Cookie (state)
    Browser->>Provider: GET /authorize?...
    Provider->>Browser: Consent screen
    Browser->>Provider: Approve
    Provider->>Browser: 302 Redirect to /auth/callback?code=...&state=...
    Browser->>App: GET /auth/callback?code=...&state=...
    App->>App: exchange(provider, request)
    App->>Provider: POST /token (code + PKCE verifier)
    Provider->>App: access_token
    App->>Provider: GET /userinfo (Bearer token)
    Provider->>App: user profile
    App->>App: Create/update session
    App->>Browser: 302 Redirect to dashboard

The callback returns the OAuth tokens and profile as JSON instead of redirecting. The frontend stores the JWT and sends it as Authorization: Bearer <token> on subsequent requests.

When the user is already logged in, the OAuth flow can link a new provider to the existing account. The return_to field on AuthorizeURL drives the final redirect back to the settings page.

Each provider gets its own state cookie: oauth_state_google, oauth_state_github. Two logins in flight don’t interfere because the state values are independent and the cookie names are namespaced.


The state cookie is httponly=True, samesite="lax", secure=True. An attacker cannot:

  • Read the state value via JavaScript (httponly).
  • Forge a callback from a different site (samesite).
  • Intercept the cookie over HTTP (secure).

The verify_state function uses hmac.compare_digest for constant-time comparison, preventing timing oracles.

The verifier is derived via HMAC from the state and the application secret. An attacker who intercepts the authorization code cannot exchange it without the application secret, because they cannot produce the correct code_verifier.

  • OAuthTokens.__repr__ redacts all credentials.
  • OAuthProfile does not leak tokens in its __repr__.
  • authorization_header() normalizes the token type.

token_headers and userinfo_headers are stored as MappingProxyType (read-only dicts). Instance-level headers cannot leak into class defaults, and class defaults cannot be mutated through an instance.

The authorize_url function never includes client_secret, state_secret, or the PKCE verifier in the URL. Only client_id, state, and code_challenge are sent to the provider’s authorize endpoint.


End of document 41-OAUTH.md