Skip to content

Authentication strategies

One route, several strategies Browser to Your app: GET /me, Cookie: session. Your app: "session" ✓. Your app to Browser: 200 octocat. Script to Your app: GET /me, Bearer eyJ… Your app: "session" ✗ → "jwt" ✓. Your app to Script: 200 octocat. auth.authenticate("session", "jwt"): the first that finds the user. Browser Your app Script GET /me, Cookie: session "session" ✓ 200 octocat GET /me, Bearer eyJ… "session" ✗ → "jwt" ✓ 200 octocat auth.authenticate("session", "jwt"): the first that finds the user
What it's for: one route that browsers reach with their session and scripts with a token, each strategy tried in order.

Each strategy has an example of its own, a small app to run and try:

  • JWTStrategy: scripts log in with a password and send a JWT, which they can revoke.
  • OAuth2Strategy: people log in with their GitHub account.
  • OIDCStrategy: people log in with an OpenID Connect provider (Keycloak, Google…), its ID token verified.
  • SessionStrategy: a password, then a session that remembers who logged in.
  • HTTPBearerStrategy: personal access tokens, refused as soon as they're deleted.
  • SAMLStrategy: single sign-on with a SAML identity provider.
  • UnkeyStrategy: API keys made at sign-up, with permissions, credits and a rate limit that Unkey checks.
  • Your own strategy: API keys in X-API-Key.

This one puts three strategies together. People log in with their GitHub account; their scripts, with tokens they create. OAuth2Strategy logs people in with GitHub, SessionStrategy remembers them in a server-side session afterwards, and JWTStrategy reads the tokens of their scripts. /me takes the last two: auth.authenticate("session", "jwt") tries them in order.

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.main:app

Set JWT_SECRET and CACHE_SIGNING_KEY too (secrets.token_urlsafe(32) each), the same in every process. Without them each start makes up throwaway ones, and the sessions and tokens stop working when the app restarts. 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

Without a session or a token, nobody:

$ curl -i http://localhost:8000/me
HTTP/1.1 401 Unauthorized
www-authenticate: Bearer

{"detail":"Not authenticated"}

Open http://localhost:8000/login/github in the browser and log in with GitHub, as in the OAuth2 example: the app starts a session and takes you to /me. For a GitHub user called octocat:

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

In the same browser, open http://localhost:8000/docs and try POST /tokens: it makes a token for your scripts, valid for an hour:

{"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJnaXRodWI6NTgzMjMxIi...","token_type":"bearer","expires_in":3600}

A script uses it instead of the session:

$ TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJnaXRodWI6NTgzMjMxIi...
$ curl http://localhost:8000/me -H "Authorization: Bearer $TOKEN"
{"id":"github:583231","login":"octocat","name":"The Octocat"}

But it can't make tokens: /tokens only takes the session, so a leaked token can't make more. With only the session strategy tried, the 401 has no challenge:

$ curl -i -X POST http://localhost:8000/tokens -H "Authorization: Bearer $TOKEN"
HTTP/1.1 401 Unauthorized

{"detail":"Not authenticated"}

The script can revoke its token, which is refused from then on, until it would have expired:

$ curl -i -X DELETE http://localhost:8000/tokens/current -H "Authorization: Bearer $TOKEN"
HTTP/1.1 204 No Content

$ curl -i http://localhost:8000/me -H "Authorization: Bearer $TOKEN"
HTTP/1.1 401 Unauthorized
www-authenticate: Bearer

{"detail":"Not authenticated"}

What to look at

  • One dependency per way in: GitHubUser, SessionUser, TokenUser and AnyUser say which strategies each route accepts. The callback only takes GitHub's answer, /tokens only a session, /me a session or a token.
  • In order: AnyUser tries the session first, then the token. A strategy that finds nothing of its own in the request (no cookie, no Authorization) passes to the next one; when none finds a user, the 401 carries the challenges of those tried.
  • The app owns its users: find_or_create_user turns GitHub's profile into a user of the app, created the first time. Sessions and tokens carry 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. Requests with a token aren't checked: only the app's pages (or a script) can add an Authorization header.

The code

examples/auth_strategies/main.py
"""People log in with GitHub; their scripts, with tokens they create: authentication strategies.

    uvicorn examples.auth_strategies.main:app

Three strategies, each a way to tell who makes a request: `OAuth2Strategy` logs people in with
their GitHub account, `SessionStrategy` remembers them in a server-side session afterwards, and
`JWTStrategy` reads the tokens they create for their scripts. `/me` takes any of the last two:
`auth.authenticate("session", "jwt")` tries them in order.

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 JWT_SECRET and CACHE_SIGNING_KEY too (secrets.token_urlsafe(32) each), 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 fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from pydantic import BaseModel, Field, SecretStr
from pydantic_settings import BaseSettings, SettingsConfigDict

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

TOKEN_LIFETIME = 3600  # seconds


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")
    jwt_secret: SecretStr = Field(default_factory=throwaway_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)


blocklist = TokenBlocklist()  # in FastAPICache's backend
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))
api_tokens = auth.use(
    JWTStrategy(
        settings.jwt_secret,
        issuer=settings.base_url,
        blocklist=blocklist,  # revoked tokens are refused
        verify=lambda claims: load_user(claims["sub"]),
    )
)

# Who makes the request, by each way the routes accept.
GitHubUser = Annotated[User, Depends(auth.authenticate("github"))]
SessionUser = Annotated[User, Depends(auth.authenticate("session"))]
TokenUser = Annotated[User, Depends(auth.authenticate("jwt"))]
AnyUser = Annotated[User, Depends(auth.authenticate("session", "jwt"))]


@asynccontextmanager
async def lifespan(_: FastAPI) -> AsyncIterator[None]:
    # Redis or Valkey in production, so that every process shares sessions and revocations.
    FastAPICache.init(InMemoryBackend(), prefix="auth", 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 login() -> 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.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
async def logout(request: Request, user: SessionUser) -> None:
    del user  # only to refuse requests without a session
    sessions.logout(request)


@app.get("/me")
async def me(user: AnyUser) -> User:
    """With the session (a browser) or a token (a script)."""
    return user


class Token(BaseModel):
    access_token: str
    token_type: str = "bearer"  # noqa: S105  # the type of token, not one
    expires_in: int = TOKEN_LIFETIME


@app.post("/tokens", status_code=status.HTTP_201_CREATED)
async def create_token(user: SessionUser) -> Token:
    """A token for the user's scripts. Only with the session: a token can't make tokens."""
    return Token(access_token=api_tokens.issue(user.id, expires_in=TOKEN_LIFETIME))


@app.delete("/tokens/current", status_code=status.HTTP_204_NO_CONTENT)
async def revoke_token(
    user: TokenUser,
    credentials: Annotated[HTTPAuthorizationCredentials, Depends(HTTPBearer())],
) -> None:
    """Refuse the request's token from now on, until it would have expired anyway."""
    del user  # checked by the JWT strategy: valid and not revoked
    claims = await api_tokens.decode(credentials.credentials)
    await blocklist.revoke(claims["jti"], expires_at=claims["exp"])


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