# OAuth login flow

The other pages *check* a credential. The OAuth helpers *obtain* one: they handle the tricky parts of a "Log in with..." button (discovery, CSRF protection, PKCE, code exchange, ID token validation), while you keep the routes.

```bash
uv add "fastapi-multiauth[oauth,jwt]"
```

```
sequenceDiagram
    participant B as Browser
    participant A as Your app
    participant P as Provider
    B->>A: GET /oauth/login
    A-->>B: redirect to the provider
    B->>P: log in
    P-->>B: redirect to /oauth/callback
    B->>A: GET /oauth/callback?code&state
    A->>P: exchange the code
    P-->>A: tokens
    A-->>B: your session cookie
```

## 1\. Redirect to the provider

```python
@app.get("/oauth/login")
async def oauth_login(request: Request, next: str = "/"):
    endpoints = await oauth_resolve_provider_urls(DISCOVERY_URL)
    verifier, challenge = oauth_generate_pkce_pair()
    flow = {
        "state": oauth_generate_state_token(),
        "nonce": oauth_generate_nonce(),
        "verifier": verifier,
    }
    request.session["oauth"] = flow
    return oauth_build_authorization_redirect(
        endpoints.authorization_endpoint,
        client_id=CLIENT_ID,
        scopes="openid email profile",
        redirect_uri=REDIRECT_URI,
        destination=next,
        state_token=flow["state"],
        code_challenge=challenge,
        nonce=flow["nonce"],
    )
```

The state, nonce and PKCE verifier are kept in the server session (Starlette's `SessionMiddleware`) until the user comes back.

## 2\. Handle the callback

```python
@app.get("/oauth/callback")
async def oauth_callback(request: Request, code: str, state: str | None = None):
    endpoints = await oauth_resolve_provider_urls(DISCOVERY_URL)
    flow = request.session.pop("oauth", {})
    try:
        destination = oauth_decode_state(
            state, expected_state_token=flow.get("state", ""), fallback="/"
        )
        token = await oauth_exchange_code(
            token_url=endpoints.token_endpoint,
            code=code,
            client_id=CLIENT_ID,
            client_secret=CLIENT_SECRET,
            redirect_uri=REDIRECT_URI,
            code_verifier=flow.get("verifier"),
        )
        if endpoints.jwks_uri is None:
            raise OAuthError("the provider does not support OpenID Connect")
        claims = await oauth_validate_id_token(
            token.get("id_token", ""),
            issuer=endpoints.issuer,
            jwks_uri=endpoints.jwks_uri,
            client_id=CLIENT_ID,
            nonce=flow.get("nonce", ""),
        )
    except OAuthError:
        return RedirectResponse("/login?error=oauth", status_code=303)

    key = (claims["iss"], claims["sub"])
    user_id = USERS.setdefault(key, str(len(USERS) + 1))
    response = RedirectResponse(destination, status_code=303)
    session.set_cookie(response, user_id)
    return response
```

- `oauth_decode_state` refuses a callback this browser did not start, and only allows relative `next` URLs by default.
- `oauth_validate_id_token` checks the ID token's signature, issuer, audience, expiry and nonce. Never trust an ID token before that.
- Every helper raises a subclass of `OAuthError`, so one `except` covers the whole callback.

The [OpenID Connect recipe](<https://fastapi-multiauth.d3vyce.fr/recipes/oidc-sign-in/index.md>) shows the full file.

> [!WARNING]
>
> **The discovery URL is configuration**
>
> Never build it from request input.
