Skip to content

OAuth 2.0 / OIDC login

The OAuth helpers implement the pieces of an authorization-code login flow that are annoying to get right (discovery, CSRF state, PKCE, code exchange) while you keep the routes. Unlike the auth sources, they don't validate an incoming request; they obtain a credential through the browser redirect flow, so they live in their own fastapi_multiauth.oauth namespace and require the oauth extra:

uv add "fastapi-multiauth[oauth]"

Every helper is documented in the API reference, and Recipes carries a copy-paste login + callback pair.

The full flow with PKCE, CSRF state, and the built-in open-redirect guard:

from fastapi_multiauth.oauth import (
    OAuthError,
    oauth_build_authorization_redirect,
    oauth_decode_state,
    oauth_exchange_code,
    oauth_fetch_userinfo,
    oauth_generate_pkce_pair,
    oauth_generate_state_token,
    oauth_resolve_provider_urls,
)


@app.get("/oauth/login")
async def oauth_login(request: Request, next: str = "/"):
    endpoints = await oauth_resolve_provider_urls(settings.OIDC_DISCOVERY_URL)
    state_token = oauth_generate_state_token()
    code_verifier, code_challenge = oauth_generate_pkce_pair()
    # Store both server-side (session or Redis), never in an unsigned cookie.
    request.session["oauth_state"] = state_token
    request.session["oauth_code_verifier"] = code_verifier
    return oauth_build_authorization_redirect(
        endpoints.authorization_endpoint,
        client_id=settings.OIDC_CLIENT_ID,
        scopes="openid email profile",
        redirect_uri=settings.OIDC_REDIRECT_URI,
        destination=next,
        state_token=state_token,
        code_challenge=code_challenge,
    )


@app.get("/oauth/callback")
async def oauth_callback(request: Request, code: str, state: str | None = None):
    endpoints = await oauth_resolve_provider_urls(settings.OIDC_DISCOVERY_URL)
    expected = request.session.pop("oauth_state", "")  # single-use
    code_verifier = request.session.pop("oauth_code_verifier", None)
    # Open-redirect guard: by default only relative paths are accepted.
    destination = oauth_decode_state(state, expected_state_token=expected, fallback="/")
    try:
        token = await oauth_exchange_code(
            token_url=endpoints.token_endpoint,
            code=code,
            client_id=settings.OIDC_CLIENT_ID,
            client_secret=settings.OIDC_CLIENT_SECRET,
            redirect_uri=settings.OIDC_REDIRECT_URI,
            required_scopes="openid email profile",
            code_verifier=code_verifier,
        )
        userinfo = await oauth_fetch_userinfo(
            userinfo_url=endpoints.userinfo_endpoint,
            access_token=token["access_token"],
        )
    except OAuthError:
        return RedirectResponse("/login?error=oauth")
    ...  # create your session from userinfo

oauth_exchange_code returns the full token response: access_token plus whatever the provider granted (refresh_token, id_token, scope, …), so refresh and ID-token flows stay possible. oauth_resolve_provider_urls returns an OIDCEndpoints named tuple that also carries jwks_uri, end_session_endpoint, and issuer when the provider advertises them. Providers that require HTTP Basic authentication on the token endpoint are supported via oauth_exchange_code(..., token_endpoint_auth_method="client_secret_basic").

By default each call opens a fresh HTTPS connection. oauth_exchange_code and oauth_fetch_userinfo accept an optional client=: pass a long-lived httpx.AsyncClient (e.g. created in your app's lifespan) to reuse its connection pool across the two callback calls and avoid the extra TLS handshake. The client is borrowed, never closed, and its own configuration (timeouts included) governs the requests.

Error handling

All flow errors derive from OAuthError, so a single except covers the callback; catch the specific classes when you need to react differently:

  • OAuthDiscoveryError: the discovery document could not be fetched or is invalid (transport failure, malformed JSON, missing endpoints, issuer mismatch, non-HTTPS endpoints);
  • OAuthExchangeError: the code exchange failed (provider rejection, timeout, malformed response, wrong token_type, missing required scopes);
  • OAuthUserinfoError: the userinfo endpoint failed or returned garbage.

ValueError is reserved for caller mistakes: passing a non-HTTPS URL to any helper.

PKCE

oauth_generate_pkce_pair() returns a (code_verifier, code_challenge) pair (RFC 7636, S256 only). The challenge travels to the provider in the authorization redirect; the verifier stays server-side and is replayed at the token exchange. PKCE is optional (some providers do not support it) but recommended for every client, including confidential ones: OAuth 2.1 and RFC 9700 require it.

Open-redirect guard

oauth_decode_state(..., allowed_hosts=...) validates the destination embedded in state before you redirect to it:

  • allowed_hosts=() (default): relative paths only, secure by default;
  • allowed_hosts=("app.example.com",): relative paths plus absolute http(s) URLs on the listed hosts;
  • allowed_hosts=None: explicitly disables the check; you must validate the returned URL yourself.

Scheme-relative URLs (//evil.com), backslash tricks, and non-http(s) schemes (javascript:) always fall back.

Discovery URL is config-only

Never derive discovery_url from request input: it would open SSRF and cache-poisoning vectors. All OAuth endpoints must be HTTPS (loopback hosts are exempt for local development). Discovery results are cached for one hour, so provider-side endpoint changes are picked up without a restart.

Why httpx-oauth internally?

The authorization-code exchange (including PKCE) is delegated to httpx-oauth rather than re-implemented: less hand-written security-critical transport to maintain. What httpx-oauth does not provide stays in this library: async cached discovery, the CSRF + destination state encoding, the granted-scopes check (RFC 6749 §3.3), and the full userinfo payload (httpx-oauth's get_id_email() only returns id and email). All failures surface as OAuthError subclasses, so the dependency never leaks into your code.