auths-fastapi 0.1.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,7 @@
1
+ __pycache__/
2
+ .pytest_cache/
3
+ .mypy_cache/
4
+ *.egg-info/
5
+ dist/
6
+ build/
7
+ .venv/
@@ -0,0 +1,134 @@
1
+ Metadata-Version: 2.4
2
+ Name: auths-fastapi
3
+ Version: 0.1.3
4
+ Summary: FastAPI dependency authenticating an Auths-Presentation request into a typed Principal
5
+ Project-URL: Homepage, https://auths.dev
6
+ Project-URL: Repository, https://github.com/auths-dev/auths
7
+ Project-URL: Documentation, https://docs.auths.dev
8
+ Project-URL: Bug Tracker, https://github.com/auths-dev/auths/issues
9
+ License: Apache-2.0
10
+ Keywords: auth,did,fastapi,identity,keri,presentation
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Framework :: FastAPI
13
+ Classifier: License :: OSI Approved :: Apache Software License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Topic :: Security :: Cryptography
16
+ Classifier: Typing :: Typed
17
+ Requires-Python: >=3.10
18
+ Requires-Dist: fastapi>=0.110
19
+ Provides-Extra: client
20
+ Requires-Dist: httpx>=0.27; extra == 'client'
21
+ Provides-Extra: native
22
+ Requires-Dist: auths>=0.1; extra == 'native'
23
+ Description-Content-Type: text/markdown
24
+
25
+ # auths-fastapi
26
+
27
+ A FastAPI dependency that authenticates an `Auths-Presentation` request and yields a typed
28
+ `Principal`, or raises `HTTPException`. It is the Python counterpart of the Axum reference
29
+ middleware in `crates/auths-api/src/rp_auth.rs`: an injectable verifier, a real single-use
30
+ challenge store, a verdict→status mapping, and a dependency that hands a handler a `Principal`
31
+ **only on success**.
32
+
33
+ ## Scope
34
+
35
+ - **First-party only.** The relying party trusts credentials issued under DIDs it has pinned
36
+ in `pinned_roots` (the `.auths/roots` model — DID-only; capabilities come from the verified
37
+ credential, never the request). There is no federation or third-party issuer discovery.
38
+ - **Challenge mode is the default.** The interactive `GET /v1/auth/challenge` → single-use
39
+ nonce → present flow is the v1 path and the one this package mints. A TTL binding is opt-in
40
+ (non-interactive, no store entry to consume); within its TTL a TTL presentation can be
41
+ replayed, so prefer the challenge binding unless you have a reason not to.
42
+ - **Single process.** The bundled `ChallengeStore` lives in one process's heap. Behind a load
43
+ balancer fronting N nodes a nonce minted on one node is unknown to another, and the
44
+ single-use guarantee holds only per node — supply a shared store backend for multi-node.
45
+
46
+ ## Security notes
47
+
48
+ - The nonce and signature are **never logged** by this package; do not log the `Authorization`
49
+ header in your own middleware either.
50
+ - 401 responses carry `WWW-Authenticate: Bearer` so generic clients treat them as a standard
51
+ auth challenge.
52
+ - The expected audience is the relying party's **configured** audience, not the wire header.
53
+
54
+ ## Install
55
+
56
+ ```bash
57
+ pip install auths-fastapi # the dependency + challenge store (fake-verifier testable)
58
+ pip install "auths-fastapi[native]" # + the `auths` binding for the production verifier
59
+ pip install "auths-fastapi[client]" # + httpx for fetch_challenge
60
+ ```
61
+
62
+ ## Usage
63
+
64
+ ```python
65
+ from fastapi import Depends, FastAPI
66
+ from auths_fastapi import (
67
+ Capability, ChallengeStore, KeriPresentationVerifier, Principal,
68
+ PresentationInputs, auths_principal, challenge_router, configure, configure_mint,
69
+ )
70
+
71
+ AUDIENCE = "api.example.com"
72
+ store = ChallengeStore(max_live=10_000)
73
+
74
+ def load_inputs(credential_said: str) -> PresentationInputs:
75
+ # Resolve the credential SAID to its KEL/TEL inputs from your registry.
76
+ ...
77
+
78
+ configure(KeriPresentationVerifier(
79
+ audience=AUDIENCE,
80
+ challenges=store,
81
+ load_inputs=load_inputs,
82
+ pinned_roots=frozenset({"did:keri:Eroot"}),
83
+ ))
84
+ configure_mint(store, AUDIENCE)
85
+
86
+ app = FastAPI()
87
+ app.include_router(challenge_router) # GET /v1/auth/challenge
88
+
89
+ @app.post("/v1/deploy")
90
+ async def deploy(principal: Principal = Depends(auths_principal(Capability("deploy:prod")))):
91
+ return {"deployed_by": principal.subject}
92
+ ```
93
+
94
+ ### Guard a whole group
95
+
96
+ ```python
97
+ from fastapi import APIRouter, Depends
98
+
99
+ admin = APIRouter(
100
+ prefix="/v1/admin",
101
+ dependencies=[Depends(auths_principal(Capability("admin:read")))],
102
+ )
103
+
104
+ @admin.get("/status") # protected by the router-level dependency; no Principal needed
105
+ async def admin_status():
106
+ return {"status": "ok"}
107
+ ```
108
+
109
+ A route that does **not** depend on `auths_principal(...)` receives no `Principal` and is
110
+ unauthenticated by construction.
111
+
112
+ ### Client flow
113
+
114
+ ```python
115
+ from auths_fastapi import fetch_challenge
116
+
117
+ issued = fetch_challenge("https://api.example.com/v1/auth/challenge")
118
+ # sign over issued.nonce, build the Auths-Presentation token, send it in Authorization.
119
+ ```
120
+
121
+ ## Status mapping
122
+
123
+ | Outcome | Status | Header |
124
+ | ------------------------------------------------------------------- | ------ | ---------------------------- |
125
+ | Verified, capability satisfied | 200 | — |
126
+ | Missing/malformed header, expired, revoked, wrong audience, replay, holder-not-current, unpinned issuer | 401 | `WWW-Authenticate: Bearer` |
127
+ | Authenticated but missing the required capability | 403 | — |
128
+ | Challenge store at capacity | 503 | — |
129
+
130
+ ## Testing without the native binding
131
+
132
+ The crypto verify step is injected behind the `PresentationVerifier` protocol. Tests supply a
133
+ fake verifier over the **real** `ChallengeStore`, so replay/audience/expiry are genuinely
134
+ exercised with no binding installed (see `tests/test_dependency.py`). Run `pytest`.
@@ -0,0 +1,110 @@
1
+ # auths-fastapi
2
+
3
+ A FastAPI dependency that authenticates an `Auths-Presentation` request and yields a typed
4
+ `Principal`, or raises `HTTPException`. It is the Python counterpart of the Axum reference
5
+ middleware in `crates/auths-api/src/rp_auth.rs`: an injectable verifier, a real single-use
6
+ challenge store, a verdict→status mapping, and a dependency that hands a handler a `Principal`
7
+ **only on success**.
8
+
9
+ ## Scope
10
+
11
+ - **First-party only.** The relying party trusts credentials issued under DIDs it has pinned
12
+ in `pinned_roots` (the `.auths/roots` model — DID-only; capabilities come from the verified
13
+ credential, never the request). There is no federation or third-party issuer discovery.
14
+ - **Challenge mode is the default.** The interactive `GET /v1/auth/challenge` → single-use
15
+ nonce → present flow is the v1 path and the one this package mints. A TTL binding is opt-in
16
+ (non-interactive, no store entry to consume); within its TTL a TTL presentation can be
17
+ replayed, so prefer the challenge binding unless you have a reason not to.
18
+ - **Single process.** The bundled `ChallengeStore` lives in one process's heap. Behind a load
19
+ balancer fronting N nodes a nonce minted on one node is unknown to another, and the
20
+ single-use guarantee holds only per node — supply a shared store backend for multi-node.
21
+
22
+ ## Security notes
23
+
24
+ - The nonce and signature are **never logged** by this package; do not log the `Authorization`
25
+ header in your own middleware either.
26
+ - 401 responses carry `WWW-Authenticate: Bearer` so generic clients treat them as a standard
27
+ auth challenge.
28
+ - The expected audience is the relying party's **configured** audience, not the wire header.
29
+
30
+ ## Install
31
+
32
+ ```bash
33
+ pip install auths-fastapi # the dependency + challenge store (fake-verifier testable)
34
+ pip install "auths-fastapi[native]" # + the `auths` binding for the production verifier
35
+ pip install "auths-fastapi[client]" # + httpx for fetch_challenge
36
+ ```
37
+
38
+ ## Usage
39
+
40
+ ```python
41
+ from fastapi import Depends, FastAPI
42
+ from auths_fastapi import (
43
+ Capability, ChallengeStore, KeriPresentationVerifier, Principal,
44
+ PresentationInputs, auths_principal, challenge_router, configure, configure_mint,
45
+ )
46
+
47
+ AUDIENCE = "api.example.com"
48
+ store = ChallengeStore(max_live=10_000)
49
+
50
+ def load_inputs(credential_said: str) -> PresentationInputs:
51
+ # Resolve the credential SAID to its KEL/TEL inputs from your registry.
52
+ ...
53
+
54
+ configure(KeriPresentationVerifier(
55
+ audience=AUDIENCE,
56
+ challenges=store,
57
+ load_inputs=load_inputs,
58
+ pinned_roots=frozenset({"did:keri:Eroot"}),
59
+ ))
60
+ configure_mint(store, AUDIENCE)
61
+
62
+ app = FastAPI()
63
+ app.include_router(challenge_router) # GET /v1/auth/challenge
64
+
65
+ @app.post("/v1/deploy")
66
+ async def deploy(principal: Principal = Depends(auths_principal(Capability("deploy:prod")))):
67
+ return {"deployed_by": principal.subject}
68
+ ```
69
+
70
+ ### Guard a whole group
71
+
72
+ ```python
73
+ from fastapi import APIRouter, Depends
74
+
75
+ admin = APIRouter(
76
+ prefix="/v1/admin",
77
+ dependencies=[Depends(auths_principal(Capability("admin:read")))],
78
+ )
79
+
80
+ @admin.get("/status") # protected by the router-level dependency; no Principal needed
81
+ async def admin_status():
82
+ return {"status": "ok"}
83
+ ```
84
+
85
+ A route that does **not** depend on `auths_principal(...)` receives no `Principal` and is
86
+ unauthenticated by construction.
87
+
88
+ ### Client flow
89
+
90
+ ```python
91
+ from auths_fastapi import fetch_challenge
92
+
93
+ issued = fetch_challenge("https://api.example.com/v1/auth/challenge")
94
+ # sign over issued.nonce, build the Auths-Presentation token, send it in Authorization.
95
+ ```
96
+
97
+ ## Status mapping
98
+
99
+ | Outcome | Status | Header |
100
+ | ------------------------------------------------------------------- | ------ | ---------------------------- |
101
+ | Verified, capability satisfied | 200 | — |
102
+ | Missing/malformed header, expired, revoked, wrong audience, replay, holder-not-current, unpinned issuer | 401 | `WWW-Authenticate: Bearer` |
103
+ | Authenticated but missing the required capability | 403 | — |
104
+ | Challenge store at capacity | 503 | — |
105
+
106
+ ## Testing without the native binding
107
+
108
+ The crypto verify step is injected behind the `PresentationVerifier` protocol. Tests supply a
109
+ fake verifier over the **real** `ChallengeStore`, so replay/audience/expiry are genuinely
110
+ exercised with no binding installed (see `tests/test_dependency.py`). Run `pytest`.
@@ -0,0 +1,59 @@
1
+ """FastAPI middleware for Auths-Presentation request authentication.
2
+
3
+ Public surface:
4
+ * `auths_principal(capability)` — the FastAPI dependency yielding a verified `Principal`.
5
+ * `configure` — install the process-wide `PresentationVerifier`.
6
+ * `Principal` / `Capability` — the typed identity + authority models.
7
+ * `ChallengeStore` / `IssuedChallenge` / `StoreFull` — the single-use challenge store.
8
+ * `PresentationVerifier` (Protocol), `KeriPresentationVerifier`, `PresentationInputs`,
9
+ `PresentationDenied` — the injectable verify seam and its production impl.
10
+ * `challenge_router` / `configure_mint` / `fetch_challenge` — the mint route + client helper.
11
+
12
+ First-party only: the relying party trusts credentials issued under DIDs it has pinned
13
+ (`pinned_roots`); there is no federation. The interactive challenge binding is the default.
14
+ """
15
+
16
+ from .challenge_route import configure_mint, fetch_challenge
17
+ from .challenge_route import router as challenge_router
18
+ from .challenge_store import (
19
+ DEFAULT_CHALLENGE_TTL,
20
+ NONCE_LEN,
21
+ ChallengeStore,
22
+ IssuedChallenge,
23
+ StoreFull,
24
+ )
25
+ from .dependency import auths_principal, configure
26
+ from .models import Capability, Principal
27
+ from .verifier import (
28
+ KeriPresentationVerifier,
29
+ LoadInputs,
30
+ PresentationDenied,
31
+ PresentationInputs,
32
+ PresentationVerifier,
33
+ WirePresentation,
34
+ parse_presentation_header,
35
+ parse_presentation_token,
36
+ )
37
+
38
+ __all__ = [
39
+ "auths_principal",
40
+ "configure",
41
+ "Principal",
42
+ "Capability",
43
+ "ChallengeStore",
44
+ "IssuedChallenge",
45
+ "StoreFull",
46
+ "NONCE_LEN",
47
+ "DEFAULT_CHALLENGE_TTL",
48
+ "PresentationVerifier",
49
+ "KeriPresentationVerifier",
50
+ "PresentationInputs",
51
+ "PresentationDenied",
52
+ "LoadInputs",
53
+ "WirePresentation",
54
+ "parse_presentation_header",
55
+ "parse_presentation_token",
56
+ "challenge_router",
57
+ "configure_mint",
58
+ "fetch_challenge",
59
+ ]
@@ -0,0 +1,82 @@
1
+ """The `/v1/auth/challenge` mint route and a client-side fetch helper.
2
+
3
+ Mirrors `auths_api::rp_auth::challenge_handler`: `GET /v1/auth/challenge` mints a fresh
4
+ single-use nonce bound to the configured audience and returns `{nonce, not_after}`; at
5
+ capacity it returns 503 rather than evicting a live nonce. `fetch_challenge` is the client
6
+ counterpart (httpx) that fetches one for signing.
7
+
8
+ The store is configured once via `configure_mint(...)`.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from datetime import datetime, timezone
14
+
15
+ from fastapi import APIRouter, HTTPException
16
+
17
+ from .challenge_store import ChallengeStore, IssuedChallenge, StoreFull
18
+
19
+
20
+ class _MintConfig:
21
+ """The once-configured store + audience the mint route uses."""
22
+
23
+ store: ChallengeStore | None = None
24
+ audience: str | None = None
25
+
26
+
27
+ def configure_mint(store: ChallengeStore, audience: str) -> None:
28
+ """Install the store + audience the `/v1/auth/challenge` route mints against.
29
+
30
+ Args:
31
+ * `store`: The single-use challenge store (shared with the verifier).
32
+ * `audience`: This relying party's canonical audience.
33
+ """
34
+ _MintConfig.store = store
35
+ _MintConfig.audience = audience
36
+
37
+
38
+ router = APIRouter()
39
+ """The mint router; include with `app.include_router(challenge_router)`."""
40
+
41
+
42
+ @router.get("/v1/auth/challenge")
43
+ async def challenge_handler() -> dict[str, str]:
44
+ """Mint a fresh CSPRNG nonce bound to this RP's audience.
45
+
46
+ Returns `{nonce, not_after}`; raises 503 when the bounded store is full and 500 if the
47
+ mint route was not configured.
48
+ """
49
+ store = _MintConfig.store
50
+ audience = _MintConfig.audience
51
+ if store is None or audience is None:
52
+ raise HTTPException(status_code=500, detail="challenge mint not configured")
53
+ try:
54
+ issued = store.issue(audience, datetime.now(timezone.utc))
55
+ except StoreFull as exc:
56
+ raise HTTPException(status_code=503, detail="challenge store full") from exc
57
+ return {"nonce": issued.nonce, "not_after": issued.not_after.isoformat()}
58
+
59
+
60
+ def fetch_challenge(url: str) -> IssuedChallenge:
61
+ """Client helper: GET a challenge from `url` and parse it into an `IssuedChallenge`.
62
+
63
+ Requires the `client` extra (httpx). Raises on a non-200 (e.g. 503 when the store is
64
+ full).
65
+
66
+ Args:
67
+ * `url`: The full `/v1/auth/challenge` URL.
68
+
69
+ Usage:
70
+ ```python
71
+ issued = fetch_challenge("https://api.example.com/v1/auth/challenge")
72
+ ```
73
+ """
74
+ import httpx
75
+
76
+ response = httpx.get(url)
77
+ response.raise_for_status()
78
+ body = response.json()
79
+ return IssuedChallenge(
80
+ nonce=body["nonce"],
81
+ not_after=datetime.fromisoformat(body["not_after"]),
82
+ )
@@ -0,0 +1,121 @@
1
+ """Single-use challenge store for the interactive presentation path.
2
+
3
+ Mirrors `auths_rp::challenge::InMemoryChallengeStore`: a CSPRNG nonce minted by `issue` is
4
+ bound to an audience and consumed exactly once by `consume` (remove-on-read), giving genuine
5
+ single-use replay protection with no global seen-cache. The store is bounded (`max_live`) and
6
+ TTL-pruned so a `/v1/auth/challenge` flood cannot exhaust memory; at capacity `issue` raises
7
+ `StoreFull` rather than evicting a live nonce. `consume` runs after the caller's cheap
8
+ structural checks, so a third party cannot burn a legitimate client's nonce.
9
+
10
+ The clock is injected (`now: datetime`) — there is no hidden wall-clock read; the HTTP
11
+ boundary samples the clock and passes it down, matching the Rust clock-injection rule.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import base64
17
+ import secrets
18
+ import threading
19
+ from dataclasses import dataclass
20
+ from datetime import datetime, timedelta
21
+
22
+ NONCE_LEN = 32
23
+ """The fixed nonce width, in bytes (matches `auths_rp::NONCE_LEN`)."""
24
+
25
+ DEFAULT_CHALLENGE_TTL = timedelta(seconds=120)
26
+ """The default TTL ceiling for a minted challenge (matches `DEFAULT_CHALLENGE_TTL_SECS`)."""
27
+
28
+
29
+ class StoreFull(Exception):
30
+ """The challenge store is at capacity — retry shortly (maps to HTTP 503)."""
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class IssuedChallenge:
35
+ """A freshly minted challenge handed to the client.
36
+
37
+ Args:
38
+ * `nonce`: The base64url (no-pad) single-use nonce the client signs over.
39
+ * `not_after`: The instant after which the challenge is no longer live.
40
+ """
41
+
42
+ nonce: str
43
+ not_after: datetime
44
+
45
+
46
+ def _encode_nonce(raw: bytes) -> str:
47
+ """Encode raw nonce bytes as base64url without padding (the wire form)."""
48
+ return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
49
+
50
+
51
+ class ChallengeStore:
52
+ """A bounded, TTL-pruned, single-use challenge store.
53
+
54
+ Single-process only (an in-heap map). A multi-node deployment behind a load balancer
55
+ must supply a shared backend so a nonce minted on one node is consumable on another and
56
+ the remove-on-read guarantee holds across nodes; see the Rust load-balancer caveat.
57
+
58
+ Args:
59
+ * `max_live`: The maximum number of live challenges before `issue` raises `StoreFull`.
60
+ * `ttl`: The lifetime of each minted challenge (defaults to 120s).
61
+
62
+ Usage:
63
+ ```python
64
+ store = ChallengeStore(max_live=10_000)
65
+ issued = store.issue("api.example.com", now)
66
+ assert store.consume("api.example.com", issued.nonce, now)
67
+ ```
68
+ """
69
+
70
+ def __init__(self, max_live: int, ttl: timedelta = DEFAULT_CHALLENGE_TTL) -> None:
71
+ self._max_live = max_live
72
+ self._ttl = ttl
73
+ self._lock = threading.Lock()
74
+ self._live: dict[tuple[str, str], datetime] = {}
75
+
76
+ def issue(self, audience: str, now: datetime) -> IssuedChallenge:
77
+ """Mint a fresh single-use challenge bound to `audience`.
78
+
79
+ Prunes expired entries first; at capacity raises `StoreFull` rather than evicting a
80
+ live nonce.
81
+
82
+ Args:
83
+ * `audience`: The relying party the presentation must bind to.
84
+ * `now`: The current time, injected at the boundary.
85
+ """
86
+ nonce = _encode_nonce(secrets.token_bytes(NONCE_LEN))
87
+ not_after = now + self._ttl
88
+ with self._lock:
89
+ self._prune(now)
90
+ if len(self._live) >= self._max_live:
91
+ raise StoreFull
92
+ self._live[(audience, nonce)] = not_after
93
+ return IssuedChallenge(nonce=nonce, not_after=not_after)
94
+
95
+ def consume(self, audience: str, nonce: str, now: datetime) -> bool:
96
+ """Consume a challenge once (remove-on-read), returning True exactly once.
97
+
98
+ A second consume of the same nonce, an expired one, a wrong-audience one, or an
99
+ unknown one all return False — the single-use replay protection. A wrong audience
100
+ does not burn the nonce, so a third party cannot consume a legitimate client's.
101
+
102
+ Args:
103
+ * `audience`: The audience the client claims to bind to.
104
+ * `nonce`: The base64url nonce the client presented.
105
+ * `now`: The current time, injected at the boundary.
106
+ """
107
+ key = (audience, nonce)
108
+ with self._lock:
109
+ not_after = self._live.pop(key, None)
110
+ return not_after is not None and not_after > now
111
+
112
+ def live_count(self) -> int:
113
+ """The number of currently-stored challenges (diagnostics / tests)."""
114
+ with self._lock:
115
+ return len(self._live)
116
+
117
+ def _prune(self, now: datetime) -> None:
118
+ """Drop every entry whose expiry has passed (caller holds the lock)."""
119
+ expired = [key for key, not_after in self._live.items() if not_after <= now]
120
+ for key in expired:
121
+ del self._live[key]
@@ -0,0 +1,110 @@
1
+ """The FastAPI dependency: `Auths-Presentation` header → typed `Principal` or `HTTPException`.
2
+
3
+ Mirrors `auths_api::rp_auth`: a dependency factory `auths_principal(capability)` reads the
4
+ `Authorization` header, calls the injected `PresentationVerifier` (which consumes the
5
+ single-use challenge), enforces the capability, and RETURNS a `Principal` only on success. A
6
+ route that does not depend on it never receives a `Principal`, and the same dependency can
7
+ guard a whole `APIRouter` group via `dependencies=[Depends(...)]`.
8
+
9
+ Status mapping: malformed/missing/expired/revoked/wrong-audience/holder-not-current → 401
10
+ (with a `WWW-Authenticate: Bearer` header); missing capability → 403; store full → 503. The
11
+ nonce and signature are never logged.
12
+
13
+ The verifier is configured once via `configure(...)` (closed over by the factory) so handlers
14
+ stay declarative; `auths_principal` reads it from a module-level holder.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from datetime import datetime, timezone
20
+ from typing import Awaitable, Callable
21
+
22
+ from fastapi import HTTPException, Request
23
+
24
+ from .models import Capability, Principal
25
+ from .verifier import PresentationDenied, PresentationVerifier
26
+
27
+ # 401 responses MUST advertise the accepted scheme. We use `Bearer` (not the custom
28
+ # `Auths-Presentation`) so generic HTTP clients/proxies treat it as a standard challenge,
29
+ # matching the reference middleware's WWW-Authenticate contract.
30
+ _WWW_AUTHENTICATE = {"WWW-Authenticate": "Bearer"}
31
+
32
+
33
+ class _Config:
34
+ """The once-configured verifier the factory closes over (set via `configure`)."""
35
+
36
+ verifier: PresentationVerifier | None = None
37
+
38
+
39
+ def configure(verifier: PresentationVerifier) -> None:
40
+ """Install the process-wide presentation verifier the dependency uses.
41
+
42
+ Call once at app startup with either the production `KeriPresentationVerifier` or a fake
43
+ (tests). All `auths_principal(...)` dependencies resolve against this verifier.
44
+
45
+ Args:
46
+ * `verifier`: The verifier (production or test fake) implementing `PresentationVerifier`.
47
+
48
+ Usage:
49
+ ```python
50
+ configure(KeriPresentationVerifier(audience, store, load_inputs, pinned_roots))
51
+ ```
52
+ """
53
+ _Config.verifier = verifier
54
+
55
+
56
+ def _now() -> datetime:
57
+ """Sample the wall clock at the HTTP boundary (the presentation-layer clock read)."""
58
+ return datetime.now(timezone.utc)
59
+
60
+
61
+ def _authenticate(request: Request, required: Capability | None) -> Principal:
62
+ """Run the header → verifier → capability pipeline, raising `HTTPException` on failure."""
63
+ verifier = _Config.verifier
64
+ if verifier is None:
65
+ raise HTTPException(status_code=500, detail="auths verifier not configured")
66
+
67
+ header = request.headers.get("Authorization")
68
+ if header is None:
69
+ raise HTTPException(
70
+ status_code=401, detail="authentication required", headers=_WWW_AUTHENTICATE
71
+ )
72
+
73
+ try:
74
+ principal = verifier.verify(header, _now())
75
+ except PresentationDenied as denied:
76
+ headers = _WWW_AUTHENTICATE if denied.status_code == 401 else None
77
+ raise HTTPException(
78
+ status_code=denied.status_code, detail=denied.detail, headers=headers
79
+ ) from denied
80
+
81
+ if required is not None and not principal.authorize(required):
82
+ raise HTTPException(status_code=403, detail="insufficient capability")
83
+
84
+ return principal
85
+
86
+
87
+ def auths_principal(
88
+ capability: Capability | None = None,
89
+ ) -> Callable[[Request], Awaitable[Principal]]:
90
+ """Build a FastAPI dependency yielding a verified `Principal` (or raising `HTTPException`).
91
+
92
+ Use at the route level to receive the `Principal`, or at the router level
93
+ (`APIRouter(dependencies=[Depends(auths_principal(cap))])`) to guard a whole group without
94
+ binding the principal into each handler.
95
+
96
+ Args:
97
+ * `capability`: The capability the guarded route(s) require; omit to authenticate only.
98
+
99
+ Usage:
100
+ ```python
101
+ @app.post("/v1/deploy")
102
+ async def deploy(principal: Principal = Depends(auths_principal(Capability("deploy:prod")))):
103
+ ...
104
+ ```
105
+ """
106
+
107
+ async def dependency(request: Request) -> Principal:
108
+ return _authenticate(request, capability)
109
+
110
+ return dependency