Skip to content

SAMLStrategy

SAMLStrategy Browser to Your app: GET /saml/login. Your app to Browser: 302 → IdP, SAML request. Browser to Identity provider: log in at the IdP, once for every app. Identity provider to Browser: a signed assertion, in a form. Browser to Your app: POST /saml/acs, assertion. Your app: ✓ signature, audience, request, once. Your app to Browser: session cookie, 303 /me. One login at the identity provider opens every app. Browser Your app Identity provider GET /saml/login 302 → IdP, SAML request log in at the IdP, once for every app a signed assertion, in a form POST /saml/acs, assertion ✓ signature, audience, request, once session cookie, 303 /me One login at the identity provider opens every app
What it's for: single sign-on with the company's identity provider: one login there opens every app.

Single sign-on with a SAML identity provider (Okta, Entra ID, ADFS, Keycloak…), then a session. The strategy: SAMLStrategy.

Run it

The app needs an identity provider (an IdP). To try it without one of your own, Mock SAML takes any e-mail at example.com and any password. From a clone of the repository:

make deps                 # .venv, with every extra
source .venv/bin/activate
export IDP_ENTITY_ID=https://saml.example.com/entityid
export IDP_SSO_URL=https://mocksaml.com/api/saml/sso
export IDP_CERTIFICATE="$(curl -s https://mocksaml.com/api/saml/metadata | python -c 'import re, sys, textwrap; c = re.search(r"X509Certificate>([^<]+)<", sys.stdin.read())[1].strip(); print("-----BEGIN CERTIFICATE-----", *textwrap.wrap(c, 64), "-----END CERTIFICATE-----", sep="\n")')"
uvicorn examples.auth_strategies.saml_login:app

Mock SAML is for trying, never for an app

Its signing key is public, so anyone can sign its answers. With your IdP, set its entity id, single sign-on URL and signing certificate, and register the app there with the entity id http://localhost:8000/saml/metadata and the ACS http://localhost:8000/saml/acs (or give it /saml/metadata).

Set CACHE_SIGNING_KEY too (secrets.token_urlsafe(32)), the same in every process.

Try it

/saml/login sends the browser to the IdP, with a SAML request. The request's id waits in a cookie, for the answer to carry:

$ curl -i http://localhost:8000/saml/login
HTTP/1.1 302 Found
location: https://mocksaml.com/api/saml/sso?SAMLRequest=fdFLT8MwDADgv1...
set-cookie: __Host-alcazaba-saml-saml=_b67fcee98db3916463789be0824bd27c7db1ad3a; Max-Age=600; Path=/; HttpOnly; Secure; SameSite=None
cache-control: no-store

Open http://localhost:8000/saml/login in the browser, and log in at Mock SAML as jackson@example.com with any password. Mock SAML posts its signed answer back to /saml/acs; the strategy checks it, and the app starts a session and takes you to /me:

{"id":"saml:jackson@example.com","email":"jackson@example.com"}

The IdP reads the app's metadata at http://localhost:8000/saml/metadata: its entity id and its ACS.

What to look at

  • Checked on what's signed: the strategy reads only the part that the IdP's signature covers, so an answer with a signed part and another one added (signature wrapping) gets nowhere. The issuer, the audience (this app), the recipient (the ACS), the request it answers and its validity are checked there.
  • Once: each assertion is kept in the cache's backend until it expires, so a copy posted again is refused, by every process.
  • No defaults for the IdP: without IDP_ENTITY_ID, IDP_SSO_URL and IDP_CERTIFICATE the app doesn't start: it never trusts an IdP it wasn't told about.
  • CSRF: the IdP posts the answer from its own site, so CSRFMiddleware lets /saml/acs through, and the strategy checks what CSRF would: that the answer is for the login this browser started.

The code

examples/auth_strategies/saml_login.py
"""Single sign-on with a SAML identity provider (Okta, Entra ID, ADFS, Keycloak…), then a session.

    uvicorn examples.auth_strategies.saml_login:app

`/saml/login` sends the browser to the identity provider (the IdP) with a SAML request. The user
logs in there, and the IdP posts its signed answer back to `/saml/acs`, where `SAMLStrategy`
checks the signature with the IdP's certificate, and that the answer is for this app, for this
login and still valid. The app keeps its own user in a session (`SessionStrategy`), and gives
the IdP its metadata at `/saml/metadata`.

Set the IdP's IDP_ENTITY_ID, IDP_SSO_URL and IDP_CERTIFICATE (its signing certificate, in PEM),
and CACHE_SIGNING_KEY (secrets.token_urlsafe(32)), the same in every process, in the environment
or a .env file. At the IdP, register the app with the entity id
http://localhost:8000/saml/metadata and the ACS http://localhost:8000/saml/acs.
"""

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

import uvicorn
from fastapi import Depends, FastAPI, Request, Response, 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.saml import SAMLProfile, SAMLStrategy
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. The IdP's has no defaults:
    an app must never trust an IdP it wasn't told about."""

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

    base_url: str = "http://localhost:8000"
    idp_entity_id: str
    idp_sso_url: str
    idp_certificate: str
    cache_signing_key: SecretStr = Field(default_factory=throwaway_secret)


settings = Settings()  # type: ignore[call-arg]  # the IdP's settings come from the environment


class User(BaseModel):
    id: str
    email: str


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

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


db = Database()


def find_or_create_user(profile: SAMLProfile) -> User | None:
    """The IdP says who logged in: our user for that NameID (an e-mail here), created the first
    time."""
    user_id = f"saml:{profile.name_id}"
    if user_id not in db.users:
        db.users[user_id] = User(id=user_id, email=profile.name_id)
    return db.users[user_id]


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


auth = FastAPIAuth()
saml = auth.use(
    SAMLStrategy(
        entity_id=f"{settings.base_url}/saml/metadata",  # this app, at the IdP
        acs_url=f"{settings.base_url}/saml/acs",
        idp_entity_id=settings.idp_entity_id,
        idp_sso_url=settings.idp_sso_url,
        idp_certificate=settings.idp_certificate,
        verify=find_or_create_user,
    )
)
sessions = auth.use(SessionStrategy(load_user))
SAMLUser = Annotated[User, Depends(auth.authenticate("saml"))]
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, and refuses an
    # assertion that another one already accepted.
    FastAPICache.init(InMemoryBackend(), prefix="sso", signing_key=settings.cache_signing_key)
    yield


app = FastAPI(lifespan=lifespan)
app.add_middleware(ServerSessionMiddleware)
# The IdP posts to the ACS from its own site: SAMLStrategy checks what CSRF would.
app.add_middleware(CSRFMiddleware, exempt_paths=["/saml/acs"])


@app.get("/saml/login")
async def log_in() -> RedirectResponse:
    """To the IdP's login page, which posts the user back to the ACS."""
    return saml.redirect()


@app.post("/saml/acs")
async def acs(request: Request, user: SAMLUser) -> RedirectResponse:
    """The IdP posts its answer here: the strategy checks it; 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("/saml/metadata")
async def metadata() -> Response:
    """This app's metadata, for the IdP."""
    return Response(saml.metadata(), media_type="application/samlmetadata+xml")


@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("saml_login:app", reload=True)