Skip to content

OAuth2Strategy

OAuth2Strategy Browser to Your app: GET /login/github. Your app to Browser: 302 → GitHub, state, PKCE. Browser to GitHub: log in at GitHub, allow the app. GitHub to Browser: 302 → callback ?code=…&state=… Browser to Your app: GET /auth/github/callback. Your app to GitHub: code + verifier → profile. Your app to Browser: session cookie, 303 /me. Logged in with GitHub: your app never sees the password. Browser Your app GitHub GET /login/github 302 → GitHub, state, PKCE log in at GitHub, allow the app 302 → callback ?code=…&state=… GET /auth/github/callback code + verifier → profile session cookie, 303 /me Logged in with GitHub: your app never sees the password
What it's for: people log in with an account they already have (GitHub, Google…), and your app never sees their password.

People log in with their GitHub account: the authorization code flow with PKCE, then a session that remembers them. The strategy: OAuth2Strategy.

Run it

Create a GitHub OAuth app (GitHub's Settings → Developer settings → OAuth Apps) whose callback URL is http://localhost:8000/auth/github/callback. Then, from a clone of the repository:

make deps                 # .venv, with every extra
source .venv/bin/activate
export GITHUB_CLIENT_ID=... GITHUB_CLIENT_SECRET=...
uvicorn examples.auth_strategies.oauth2_login:app

Set CACHE_SIGNING_KEY too (secrets.token_urlsafe(32)), the same in every process. Open the app at http://localhost:8000, not 127.0.0.1: GitHub only sends people back to the callback URL it knows.

Try it

/login/github sends the browser to GitHub, with a random state and a PKCE challenge. The state and the PKCE verifier wait in a cookie, for the callback to check:

$ curl -i http://localhost:8000/login/github
HTTP/1.1 302 Found
location: https://github.com/login/oauth/authorize?response_type=code&client_id=...&redirect_uri=http%3A%2F%2Flocalhost%3A8000%2Fauth%2Fgithub%2Fcallback&state=jXJcV-_O0q0_v9K2MfyEfeMjEah55SvgW9sSoO3rjmY&code_challenge=c19OME8_wXKNeiwWUU70d6uUfTS2mdbbKUgEioMybbI&code_challenge_method=S256&scope=read%3Auser
set-cookie: __Host-alcazaba-oauth2-github=jXJcV-_O0q0_v9K2MfyEfeMjEah55SvgW9sSoO3rjmY.iDVwXX46...; Max-Age=600; Path=/; HttpOnly; Secure; SameSite=Lax
cache-control: no-store

Open http://localhost:8000/login/github in the browser. GitHub asks whether to let the app read your profile, then sends you back to the callback: the strategy checks the state, exchanges the code for a token and reads your profile, and the app starts a session and takes you to /me. For a GitHub user called octocat:

{"id":"github:583231","login":"octocat","name":"The Octocat"}

What to look at

  • Only on the callback: GitHubUser is the only dependency with the github strategy, and only the callback takes it. Elsewhere the strategy finds nobody, and the session tells who's there.
  • A login nobody can force on you: the callback is refused without the cookie that /login/github set, or with another state, so a link to the callback with someone else's code doesn't log you in as them.
  • The app owns its users: find_or_create_user turns GitHub's profile into a user of the app, created the first time. The session carries the app's user id, never GitHub's token.
  • CSRF: the session is a cookie, which browsers send by themselves; CSRFMiddleware refuses what other sites make them send, such as a form that posts to /logout.

The code

examples/auth_strategies/oauth2_login.py
"""Log in with GitHub: the authorization code flow with PKCE, then a session.

    uvicorn examples.auth_strategies.oauth2_login:app

`/login/github` sends the browser to GitHub, which asks the user to let the app read their
profile and sends them back to the callback. There `OAuth2Strategy` checks the state, exchanges
the code for a token, reads the profile and calls `find_or_create_user`; the app keeps its own
user in a session (`SessionStrategy`), so GitHub isn't asked again on every request.

Create a GitHub OAuth app (Settings → Developer settings → OAuth Apps) whose callback URL is
http://localhost:8000/auth/github/callback, and set GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET.
Set CACHE_SIGNING_KEY too (secrets.token_urlsafe(32)), the same in every process, in the
environment or a .env file. Open the app at http://localhost:8000: GitHub only sends people back
to the callback URL it knows.
"""

import secrets
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from typing import Annotated, Any

import uvicorn
from fastapi import Depends, FastAPI, Request, status
from fastapi.responses import RedirectResponse
from pydantic import BaseModel, Field, SecretStr
from pydantic_settings import BaseSettings, SettingsConfigDict

from fastapi_alcazaba import FastAPIAuth, FastAPICache
from fastapi_alcazaba.auth import SessionStrategy
from fastapi_alcazaba.auth.strategies.oauth2 import OAuth2Strategy
from fastapi_alcazaba.backends.inmemory import InMemoryBackend
from fastapi_alcazaba.security import CSRFMiddleware, ServerSessionMiddleware


def throwaway_secret() -> SecretStr:
    """A random secret, for trying the example: it changes at every start and differs between
    processes."""
    return SecretStr(secrets.token_urlsafe(32))


class Settings(BaseSettings):
    """The configuration, from environment variables or a .env file."""

    model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")

    base_url: str = "http://localhost:8000"
    github_client_id: str = "set GITHUB_CLIENT_ID"
    github_client_secret: SecretStr = SecretStr("set GITHUB_CLIENT_SECRET")
    cache_signing_key: SecretStr = Field(default_factory=throwaway_secret)


settings = Settings()


class User(BaseModel):
    id: str
    login: str
    name: str | None = None


class Database:
    """Stands in for the database."""

    def __init__(self) -> None:
        self.users: dict[str, User] = {}


db = Database()


def find_or_create_user(tokens: dict[str, Any], profile: dict[str, Any] | None) -> User | None:
    """GitHub says who logged in: our user for that account, created the first time."""
    del tokens  # GitHub's access token: keep it to call GitHub on the user's behalf
    if profile is None:
        return None  # refuses the login
    user_id = f"github:{profile['id']}"
    if user_id not in db.users:
        db.users[user_id] = User(id=user_id, login=profile["login"], name=profile.get("name"))
    return db.users[user_id]


def load_user(user_id: str) -> User | None:
    return db.users.get(user_id)


auth = FastAPIAuth()
github = auth.use(
    OAuth2Strategy(
        name="github",
        authorization_url="https://github.com/login/oauth/authorize",
        token_url="https://github.com/login/oauth/access_token",  # noqa: S106  # a URL
        userinfo_url="https://api.github.com/user",
        client_id=settings.github_client_id,
        client_secret=settings.github_client_secret,
        redirect_uri=f"{settings.base_url}/auth/github/callback",
        scopes=["read:user"],
        verify=find_or_create_user,
    )
)
sessions = auth.use(SessionStrategy(load_user))
GitHubUser = Annotated[User, Depends(auth.authenticate("github"))]
CurrentUser = Annotated[User, Depends(auth.authenticate("session"))]


@asynccontextmanager
async def lifespan(_: FastAPI) -> AsyncIterator[None]:
    # Redis or Valkey in production, so that every process shares the sessions.
    FastAPICache.init(InMemoryBackend(), prefix="github", signing_key=settings.cache_signing_key)
    yield


app = FastAPI(lifespan=lifespan)
app.add_middleware(ServerSessionMiddleware)
app.add_middleware(CSRFMiddleware)  # session cookies: refuse what other sites make browsers send


@app.get("/login/github")
async def log_in() -> RedirectResponse:
    """To GitHub's login page, which sends the user back to the callback."""
    return github.redirect()


@app.get("/auth/github/callback")
async def github_callback(request: Request, user: GitHubUser) -> RedirectResponse:
    """GitHub sends the user back here: the strategy checks the state, exchanges the code and
    reads the profile; the session remembers the user from now on."""
    sessions.login(request, user.id)
    return RedirectResponse("/me", status_code=status.HTTP_303_SEE_OTHER)


@app.get("/me")
async def me(user: CurrentUser) -> User:
    return user


@app.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
async def log_out(request: Request, user: CurrentUser) -> None:
    del user  # only to refuse requests without a session
    sessions.logout(request)


if __name__ == "__main__":
    uvicorn.run("oauth2_login:app", reload=True)