Proteggi un'API FastAPI
Questa guida rapida protegge un'API FastAPI con token di accesso KeenAuth. Aggiungi la dipendenza RequireAuth dell'SDK Python, richiedi una scope su una route e testi l'API offline con il kit di test dell'SDK.
L'API ha tre endpoint:
| Endpoint | Richiede |
|---|---|
GET /api/public |
nulla |
GET /api/private |
un token di accesso valido per la tua API |
GET /api/messages |
un token di accesso valido con la scope messages:read |
Prima di iniziare
- Un ambiente KeenAuth. Il suo issuer ha il formato
https://your-environment.eu.keenauth.app. - Un'API registrata nell'ambiente (Crea un'API). Il suo
identifier, per esempiohttps://api.example.com, è l'audience dei token che la tua API accetta. - La scope
messages:readsu quell'API, concessa all'applicazione che la chiama. - Python 3.10 o successivo.
1. Installa l'SDK
pip install "keenauth-python[fastapi]" uvicorn
L'SDK non è ancora pubblicato. Finché non lo sarà, l'esempio lo installa dal codice sorgente dell'SDK. L'esempio fissa ogni pacchetto a una versione esatta in requirements.txt.
2. Configura
L'API legge la configurazione solo dalle variabili d'ambiente:
| Variabile | Obbligatoria | Significato |
|---|---|---|
KEENAUTH_ISSUER |
sì | L'issuer del tuo ambiente |
KEENAUTH_AUDIENCE |
sì | L'identificatore della tua API |
PUBLIC_BASE_URL |
sì | L'URL che i client chiamano (vedi Dietro un proxy) |
KEENAUTH_WEBHOOK_SECRET |
consigliata | Il segreto di firma idp_whsec_ dell'endpoint webhook che consegna token.revoked (due, separati da virgola, durante la rotazione). Senza di essa l'API non viene mai a sapere dei token revocati |
3. Proteggi le tue route
create_app crea il protector e le route. Depends(RequireAuth(protector)) richiede un token valido e restituisce i suoi claim verificati; scopes=[...] richiede inoltre delle scope. install(app) rende gli errori come risposte application/problem+json, con un header WWW-Authenticate per ogni challenge.
def create_app(
verifier: AccessTokenVerifier,
*,
public_base_url: str,
revocation: Optional[RevocationChecker] = None,
webhook_secrets: Sequence[str] = (),
) -> FastAPI:
"""Builds the API. The verifier is a parameter so tests can pass the offline test kit's verifier. With
webhook_secrets (the idp_whsec_ secrets of your webhook endpoint) the token.revoked webhook is served and feeds
`revocation`, which must then be a MemoryRevocationCache."""
protector = Protector(
verifier,
public_base_url=public_base_url,
revocation=revocation,
# The error never contains the token or claim values; never log the Authorization or DPoP header.
on_error=lambda err: log.warning("rejected: %s", err),
)
app = FastAPI()
install(app) # problem+json responses and one WWW-Authenticate header per challenge
@app.get("/api/public")
def public() -> dict[str, Any]:
# No token needed.
return {"message": "Hello from a public endpoint."}
@app.get("/api/private")
def private(claims: AccessClaims = Depends(RequireAuth(protector))) -> dict[str, Any]:
# Any valid access token for this API.
return {"message": "Hello from a private endpoint.", "sub": claims.subject}
@app.get("/api/messages")
def messages(claims: AccessClaims = Depends(RequireAuth(protector, scopes=["messages:read"]))) -> dict[str, Any]:
# A valid access token that also carries the messages:read scope.
return {"messages": ["Hello from a scoped endpoint."]}
# token.revoked deliveries from KeenAuth.
if webhook_secrets:
if not isinstance(revocation, MemoryRevocationCache):
raise ValueError("the token.revoked webhook needs a MemoryRevocationCache to feed")
app.include_router(webhook_router(RevocationWebhook(webhook_secrets, revocation)))
else:
log.warning("KEENAUTH_WEBHOOK_SECRET is not set: token.revoked events are not received, so revoked tokens stay valid until they expire")
return app
app_from_env costruisce il verifier reale dalla configurazione. Il verifier recupera le chiavi di firma dal tuo ambiente la prima volta che vede un token. La dipendenza è sincrona, quindi FastAPI la esegue nel suo thread pool.
def app_from_env() -> FastAPI:
"""The production app: KEENAUTH_ISSUER, KEENAUTH_AUDIENCE and PUBLIC_BASE_URL are required."""
issuer = os.environ.get("KEENAUTH_ISSUER", "")
audience = os.environ.get("KEENAUTH_AUDIENCE", "")
# The URL clients call: DPoP proofs are checked against it, never against the Host header.
public_base_url = os.environ.get("PUBLIC_BASE_URL", "")
if not issuer or not audience or not public_base_url:
raise SystemExit("KEENAUTH_ISSUER, KEENAUTH_AUDIENCE and PUBLIC_BASE_URL are required")
# The verifier refuses an issuer under the reserved .invalid domain, so a test token can never pass here.
verifier = Verifier(VerifierConfig(issuer=issuer, audience=audience))
# Fed by the token.revoked webhook.
revocation = MemoryRevocationCache()
# The signing secret (idp_whsec_...) of the webhook endpoint that delivers token.revoked; comma separated while rotating.
webhook_secrets = [x.strip() for x in os.environ.get("KEENAUTH_WEBHOOK_SECRET", "").split(",") if x.strip()]
return create_app(verifier, public_base_url=public_base_url, revocation=revocation, webhook_secrets=webhook_secrets)
4. Avvia e chiama l'API
export KEENAUTH_ISSUER=https://your-environment.eu.keenauth.app
export KEENAUTH_AUDIENCE=https://api.example.com
export PUBLIC_BASE_URL=https://api.example.com
uvicorn main:app_from_env --factory --port 8080
Per una prima chiamata, ottieni un token con il grant client credentials di un'applicazione machine-to-machine che ha la scope messages:read sulla tua API:
ACCESS_TOKEN=$(curl -s -u "$CLIENT_ID:$CLIENT_SECRET" \
-d grant_type=client_credentials \
-d resource=https://api.example.com \
-d scope=messages:read \
https://your-environment.eu.keenauth.app/oauth2/token | jq -r .access_token)
curl -i http://localhost:8080/api/messages -H "Authorization: Bearer $ACCESS_TOKEN"
5. Testa senza rete
keenauth.testing emette token firmati con una chiave di test pubblica, e testing.verifier accetta solo quei token. I test passano quel verifier a create_app e chiamano l'app con il test client di Starlette, quindi non richiedono rete né un account KeenAuth. Vedi Kit di test offline.
@pytest.fixture()
def api() -> Api:
cache = MemoryRevocationCache()
app = create_app(
testing.verifier(audience=AUDIENCE),
public_base_url=BASE_URL,
revocation=cache,
webhook_secrets=[testing.webhook_secret()],
)
return TestClient(app, base_url=BASE_URL), cache
def test_scope(api: Api) -> None:
client, _ = api
no_scope = testing.mint_access_token(AUDIENCE)
assert_problem(client.get("/api/messages", headers=bearer(no_scope)), 403, "insufficient_scope", 'scope="messages:read"')
with_scope = testing.mint_access_token(AUDIENCE, claims={"scope": "profile messages:read"})
assert client.get("/api/messages", headers=bearer(with_scope)).status_code == 200
pip install --no-deps -r requirements-dev.txt
python -m pytest -q
I test dell'esempio verificano che un token valido venga accettato e che un token mancante, scaduto o con audience errata, una scope mancante, un token nella query dell'URL e un token revocato vengano rifiutati con gli stati indicati sotto. Inviano anche un token vincolato a DPoP con una prova per PUBLIC_BASE_URL, e verificano che app_from_env rifiuti l'issuer di test. I test inviano inoltre consegne token.revoked firmate: una valida revoca il token, una falsificata, alterata o obsoleta riceve 400 e non revoca nulla, e una ripetuta produce un solo effetto.
Risposte
| Richiesta | Stato | code nel corpo |
|---|---|---|
Nessun header Authorization |
401 |
missing_token |
| Token scaduto, token per un'altra API, firma non valida, token revocato | 401 |
invalid_token |
| Token senza la scope richiesta | 403 |
insufficient_scope |
Token nella query dell'URL (?access_token=) |
400 |
invalid_request |
Il corpo è application/problem+json e l'header WWW-Authenticate contiene la challenge standard. La tabella completa è in Proteggere la tua API con il middleware.
Dietro un proxy
Se i client usano DPoP, il middleware verifica che ogni prova sia stata creata per l'URL chiamato dal client. Imposta PUBLIC_BASE_URL sull'URL esterno che i client chiamano, per esempio https://api.example.com, anche dietro un proxy o un load balancer. È obbligatoria e deve usare https (http solo per localhost, 127.0.0.1 o [::1]); l'app rifiuta di avviarsi senza di essa. Il middleware non legge mai gli header X-Forwarded-* da solo. Uvicorn, però, applica X-Forwarded-Proto all'URL della richiesta quando la connessione proviene da un indirizzo in --forwarded-allow-ips (per impostazione predefinita 127.0.0.1,::1); con PUBLIC_BASE_URL impostato, l'URL verificato non dipende da questo.
Token revocati
Un token di accesso resta valido fino alla scadenza, quindi KeenAuth comunica alla tua API quando i token vengono revocati. L'esempio passa al protector una cache di revoca in memoria e serve un webhook token.revoked su POST /webhooks/keenauth (webhook.py) che la alimenta.
- Nella dashboard, crea un endpoint webhook con l'URL
https://<your host>/webhooks/keenauthche si iscrive atoken.revoked, ed esporta il suo segreto di firma comeKEENAUTH_WEBHOOK_SECRET. - Il gestore verifica la firma con l'SDK sul corpo grezzo e risponde
400quando non è valida o quando il timestamp differisce di più di cinque minuti. Mappa il selettore dell'evento (access_token,session,user,client,org,org_user,client_user) a un marcatore di revoca e risponde204; il middleware rifiuta quindi ogni token corrispondente emesso prima dinot_before. - KeenAuth consegna almeno una volta, quindi il gestore applica ogni
webhook-iduna sola volta. Se il marcatore non può essere salvato, risponde503e KeenAuth consegna di nuovo l'evento. Ignora gli altri tipi di evento e i selettori che nessun token di accesso può corrispondere (refresh_family,api).
Il gestore registra l'id dell'evento, il tipo di selettore e il motivo, mai il corpo, una firma o il segreto. Ogni processo worker ha una propria cache; con più worker o istanze, usa invece un archivio condiviso. Vedi Cache di revoca.
Prossimi passi
- Proteggere la tua API con il middleware: Flask, requisiti di step-up, DPoP e tutte le opzioni.
- Verificare i token: il verifier sotto il middleware.
- Kit di test offline.