FastAPI API を保護する
このクイックスタートでは、KeenAuth のアクセストークンで FastAPI API を保護します。Python SDK の RequireAuth 依存関係を追加し、1 つのルートにスコープを要求して、SDK のテストキットで API をオフラインでテストします。
この API には 3 つのエンドポイントがあります:
| エンドポイント | 必要条件 |
|---|---|
GET /api/public |
不要 |
GET /api/private |
API 用の有効なアクセストークン |
GET /api/messages |
messages:read スコープを持つ有効なアクセストークン |
始める前に
- KeenAuth の環境。その issuer は
https://your-environment.eu.keenauth.appのような形式です。 - 環境に登録された API(API を作成する)。その
identifier(例:https://api.example.com)が、API が受け入れるトークンの audience です。 - その API に対する
messages:readスコープ。呼び出し元のアプリケーションに付与されている必要があります。 - Python 3.10 以降。
1. SDK をインストールする
pip install "keenauth-python[fastapi]" uvicorn
SDK はまだ公開されていません。公開されるまで、この例では SDK のソースからインストールします。例では、すべてのパッケージを requirements.txt で正確なバージョンに固定しています。
2. 設定する
API は設定を環境変数からのみ読み込みます:
| 変数 | 必須 | 意味 |
|---|---|---|
KEENAUTH_ISSUER |
はい | 環境の issuer |
KEENAUTH_AUDIENCE |
はい | API の識別子 |
PUBLIC_BASE_URL |
はい | クライアントが呼び出す URL(プロキシの背後を参照) |
KEENAUTH_WEBHOOK_SECRET |
推奨 | token.revoked を配信する webhook エンドポイントの idp_whsec_ 署名シークレット(ローテーション中は 2 つをカンマ区切りで指定)。これがないと、API は取り消されたトークンを検知できません |
3. ルートを保護する
create_app はプロテクタとルートを作成します。Depends(RequireAuth(protector)) は有効なトークンを要求し、検証済みのクレームを返します。scopes=[...] はさらにスコープも要求します。install(app) は、エラーを application/problem+json レスポンスにし、チャレンジごとに 1 つの WWW-Authenticate ヘッダを付与します。
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 は設定から実際の verifier を構築します。verifier は、最初にトークンを見たときに環境から署名鍵を取得します。依存関係は同期的なので、FastAPI はそれをスレッドプールで実行します。
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. 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
最初の呼び出しのために、messages:read スコープを API に対して持つマシン間アプリケーションの client credentials grant でトークンを取得します:
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. ネットワークなしでテストする
keenauth.testing は公開テストキーで署名したトークンを発行し、testing.verifier はそのトークンだけを受け入れます。テストはその verifier を create_app に渡し、Starlette のテストクライアントでアプリを呼び出すため、ネットワークも KeenAuth アカウントも不要です。オフラインテストキットを参照してください。
@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
この例のテストは、有効なトークンが受け入れられること、そして、トークンがない・期限切れ・audience 不一致、スコープ不足、URL クエリ内のトークン、取り消されたトークンが、以下のステータスで拒否されることを確認します。また、PUBLIC_BASE_URL 向けの proof を付けた DPoP バインドのトークンを送信し、app_from_env がテスト用 issuer を拒否することも確認します。テストでは署名された token.revoked 配信も送信します。有効な配信はトークンを取り消し、偽造・改ざん・古い配信は 400 を受け取って何も取り消さず、同じ配信を繰り返した場合は効果が 1 回だけになります。
レスポンス
| リクエスト | ステータス | 本文の code |
|---|---|---|
Authorization ヘッダがない |
401 |
missing_token |
| 期限切れのトークン、別の API 向けのトークン、不正な署名、取り消されたトークン | 401 |
invalid_token |
| 必要なスコープがないトークン | 403 |
insufficient_scope |
URL クエリ内のトークン(?access_token=) |
400 |
invalid_request |
本文は application/problem+json で、WWW-Authenticate ヘッダには標準のチャレンジが入ります。完全な表は ミドルウェアで API を保護する にあります。
プロキシの背後
クライアントが DPoP を使用する場合、ミドルウェアは各 proof がクライアントの呼び出した URL 向けに作られたことを確認します。PUBLIC_BASE_URL を、クライアントが呼び出す外部 URL に設定してください(例: https://api.example.com)。プロキシやロードバランサの背後にある場合も同様です。これは必須で、https を使用しなければなりません(http はローカルの localhost、127.0.0.1、[::1] の場合のみ使用できます)。設定がないとアプリは起動しません。ミドルウェア自体は X-Forwarded-* ヘッダを一切読みません。ただし Uvicorn は、接続元が --forwarded-allow-ips 内のアドレスである場合(既定では 127.0.0.1,::1)、リクエスト URL に X-Forwarded-Proto を適用します。PUBLIC_BASE_URL を設定すると、チェックされる URL はそれに依存しなくなります。
取り消されたトークン
アクセストークンは期限切れまで有効なままなので、KeenAuth はトークンが取り消されたときに API へ通知します。この例では、インメモリの失効キャッシュをプロテクタに渡し、それにデータを供給する token.revoked webhook を POST /webhooks/keenauth(webhook.py)で提供します。
- ダッシュボードで、URL
https://<your host>/webhooks/keenauthを持ちtoken.revokedを購読する webhook エンドポイントを作成し、その署名シークレットをKEENAUTH_WEBHOOK_SECRETとしてエクスポートします。 - ハンドラは生のボディに対して SDK で署名を検証し、署名が無効な場合、またはタイムスタンプが 5 分以上ずれている場合は
400を返します。イベントのセレクタ(access_token、session、user、client、org、org_user、client_user)を失効マーカーに対応付けて204を返します。その後ミドルウェアは、not_beforeより前に発行された一致するトークンをすべて拒否します。 - KeenAuth は少なくとも 1 回配信するため、ハンドラは各
webhook-idを一度だけ適用します。マーカーを保存できない場合は503を返し、KeenAuth はイベントを再配信します。それ以外のイベントタイプや、どのアクセストークンにも一致しないセレクタ(refresh_family、api)は無視します。
ハンドラは、イベント ID、セレクタの種類、理由をログに記録しますが、ボディ、署名、シークレットは記録しません。各ワーカープロセスは独自のキャッシュを持ちます。複数のワーカーやインスタンスを使う場合は、代わりに共有ストアを使ってください。失効キャッシュを参照してください。
次の手順
- ミドルウェアで API を保護する: Flask、ステップアップ要件、DPoP、およびすべてのオプション。
- トークンを検証する: ミドルウェアの下で動作する verifier。
- オフラインテストキット。