OAuth2Strategy¶
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:
GitHubUseris the only dependency with thegithubstrategy, 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/githubset, 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_userturns 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;
CSRFMiddlewarerefuses what other sites make them send, such as a form that posts to/logout.
The code¶
"""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)