auths-fastapi 0.1.3__py3-none-any.whl

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,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
@@ -0,0 +1,69 @@
1
+ """Typed principal and capability models.
2
+
3
+ Mirrors `auths_rp::principal` (`VerifiedPrincipal` / `Capability`): a `Principal` is
4
+ constructed only by a verifier on a successful verdict, so possessing one is proof the
5
+ holder demonstrated current key control. `authorize` returns a bool here (the FastAPI
6
+ dependency raises on failure); the capability is a typed wrapper, never a bare string —
7
+ authority is compared by value identity, not by magic-string equality at call sites.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class Capability:
17
+ """A required-or-granted capability, compared by value (never a bare magic string).
18
+
19
+ Args:
20
+ * `name`: The capability string (e.g. `deploy:prod`).
21
+
22
+ Usage:
23
+ ```python
24
+ needed = Capability("deploy:prod")
25
+ assert needed == Capability("deploy:prod")
26
+ ```
27
+ """
28
+
29
+ name: str
30
+
31
+ def __str__(self) -> str:
32
+ return self.name
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class Principal:
37
+ """A verified, scoped identity yielded by a successful presentation verdict.
38
+
39
+ There is no path to a `Principal` other than a verifier returning one from a `VALID`
40
+ verdict, so a handler that receives a `Principal` is reachable only on an authenticated
41
+ request. Capabilities come from the verified credential, never from the request.
42
+
43
+ Args:
44
+ * `issuer`: The credential issuer AID.
45
+ * `subject`: The holder AID whose current key signed the presentation.
46
+ * `caps`: The granted capabilities (immutable tuple).
47
+ * `role`: An optional informational role claim.
48
+ * `expires_at`: An optional credential expiry (RFC-3339).
49
+
50
+ Usage:
51
+ ```python
52
+ principal = Principal(issuer="did:keri:E…", subject="did:keri:E…", caps=(Capability("deploy:prod"),))
53
+ assert principal.authorize(Capability("deploy:prod"))
54
+ ```
55
+ """
56
+
57
+ issuer: str
58
+ subject: str
59
+ caps: tuple[Capability, ...]
60
+ role: str | None = None
61
+ expires_at: str | None = None
62
+
63
+ def authorize(self, capability: Capability) -> bool:
64
+ """Whether this principal carries `capability`.
65
+
66
+ Args:
67
+ * `capability`: The capability the route/tool requires.
68
+ """
69
+ return capability in self.caps
auths_fastapi/py.typed ADDED
File without changes
@@ -0,0 +1,332 @@
1
+ """The injectable verification step: wire token → verified `Principal`.
2
+
3
+ Mirrors `auths_api::rp_auth::PresentationVerifier`: the crypto check is behind a Protocol so
4
+ the production KERI path and the tests share one dependency factory. The production
5
+ `KeriPresentationVerifier` parses the wire token, consumes the single-use challenge against
6
+ the REAL store, loads the KEL/TEL inputs (app-supplied), builds the camelCase
7
+ `VerifyPresentationRequest` bundle, calls the native `auths.verify_presentation`, enforces
8
+ pinned roots, and maps the status to a `Principal` or raises `PresentationDenied`.
9
+
10
+ Tests inject a fake verifier over the same real `ChallengeStore`, so replay/audience are
11
+ genuinely exercised with no native binding installed.
12
+
13
+ The nonce and signature are never logged here or by callers.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import base64
19
+ import json
20
+ from dataclasses import dataclass, field
21
+ from datetime import datetime
22
+ from typing import Any, Callable, Protocol
23
+
24
+ from .challenge_store import ChallengeStore
25
+ from .models import Capability, Principal
26
+
27
+
28
+ class PresentationDenied(Exception):
29
+ """A presentation was rejected; `status_code` is the HTTP class to surface.
30
+
31
+ Args:
32
+ * `status_code`: 400/401/403/503 per the verdict-to-status mapping.
33
+ * `detail`: A coarse, non-sensitive reason (never the nonce or signature).
34
+ """
35
+
36
+ def __init__(self, status_code: int, detail: str) -> None:
37
+ super().__init__(detail)
38
+ self.status_code = status_code
39
+ self.detail = detail
40
+
41
+
42
+ class PresentationVerifier(Protocol):
43
+ """Turn a wire token into a verified `Principal`, consuming its challenge.
44
+
45
+ The implementation owns the single-use consume so replay protection is enforced in one
46
+ place. It raises `PresentationDenied` on any failure and returns a `Principal` only on a
47
+ fully-valid, pinned-root presentation.
48
+ """
49
+
50
+ def verify(self, wire_token: str, now: datetime) -> Principal:
51
+ """Verify `wire_token` as of `now`, consuming its single-use challenge.
52
+
53
+ Args:
54
+ * `wire_token`: The base64url(JSON) token from the `Authorization` header.
55
+ * `now`: Verification time, injected at the HTTP boundary.
56
+ """
57
+ ...
58
+
59
+
60
+ @dataclass(frozen=True)
61
+ class WirePresentation:
62
+ """The untrusted wire shape decoded from the `Auths-Presentation` token.
63
+
64
+ Externally-tagged binding: exactly one of `challenge_nonce` / `ttl_nonce` is set.
65
+
66
+ Args:
67
+ * `credential_said`: The presented credential SAID.
68
+ * `audience`: The audience the client claims to bind to.
69
+ * `signature_b64url`: The presentation signature (base64url, no pad).
70
+ * `challenge_nonce`: The interactive-mode nonce (base64url), if challenge-bound.
71
+ * `ttl_nonce`: The TTL-mode nonce (base64url), if TTL-bound.
72
+ * `ttl_not_after`: The TTL-mode expiry (RFC-3339), if TTL-bound.
73
+ """
74
+
75
+ credential_said: str
76
+ audience: str
77
+ signature_b64url: str
78
+ challenge_nonce: str | None = None
79
+ ttl_nonce: str | None = None
80
+ ttl_not_after: str | None = None
81
+
82
+ @property
83
+ def nonce(self) -> str:
84
+ """The bound nonce regardless of mode (base64url)."""
85
+ value = self.challenge_nonce if self.challenge_nonce is not None else self.ttl_nonce
86
+ if value is None:
87
+ raise ValueError("presentation has no nonce")
88
+ return value
89
+
90
+
91
+ def parse_presentation_token(token: str) -> WirePresentation:
92
+ """Decode a base64url(JSON) presentation token into a `WirePresentation`.
93
+
94
+ Raises `ValueError` on any structural problem; the caller maps that to HTTP 400/401.
95
+ The token is never logged.
96
+
97
+ Args:
98
+ * `token`: The base64url(JSON) value after the `Auths-Presentation ` scheme.
99
+ """
100
+ raw = base64.urlsafe_b64decode(_pad(token))
101
+ document = json.loads(raw)
102
+ binding = document["binding"]
103
+ if "challenge" in binding:
104
+ return WirePresentation(
105
+ credential_said=document["credential_said"],
106
+ audience=document["audience"],
107
+ signature_b64url=document["signature_b64"],
108
+ challenge_nonce=binding["challenge"]["nonce"],
109
+ )
110
+ if "ttl" in binding:
111
+ ttl = binding["ttl"]
112
+ return WirePresentation(
113
+ credential_said=document["credential_said"],
114
+ audience=document["audience"],
115
+ signature_b64url=document["signature_b64"],
116
+ ttl_nonce=ttl["nonce"],
117
+ ttl_not_after=ttl["not_after"],
118
+ )
119
+ raise ValueError("unknown presentation binding")
120
+
121
+
122
+ def parse_presentation_header(authorization: str) -> WirePresentation:
123
+ """Parse an `Authorization: Auths-Presentation <token>` header value.
124
+
125
+ The scheme is case-sensitive and separated by exactly one space. Raises `ValueError`
126
+ (mapped to HTTP 400/401) for a missing/wrong scheme or a malformed token.
127
+
128
+ Args:
129
+ * `authorization`: The raw `Authorization` header value.
130
+ """
131
+ scheme, _, token = authorization.partition(" ")
132
+ if scheme != "Auths-Presentation":
133
+ raise ValueError("wrong Authorization scheme")
134
+ if not token.strip():
135
+ raise ValueError("missing presentation token")
136
+ return parse_presentation_token(token.strip())
137
+
138
+
139
+ def _pad(b64url: str) -> str:
140
+ """Restore base64 padding stripped on the wire."""
141
+ return b64url + "=" * (-len(b64url) % 4)
142
+
143
+
144
+ def _b64url_to_standard_b64(b64url: str) -> str:
145
+ """Re-encode a base64url (no-pad) value as standard base64 (the bundle's encoding).
146
+
147
+ The wire carries nonce/signature as URL-safe base64 without padding; the native
148
+ `VerifyPresentationRequest` expects standard base64. Decode then re-encode rather than
149
+ char-substituting so an invalid payload is caught here.
150
+
151
+ Args:
152
+ * `b64url`: A URL-safe, unpadded base64 string.
153
+ """
154
+ return base64.standard_b64encode(base64.urlsafe_b64decode(_pad(b64url))).decode("ascii")
155
+
156
+
157
+ @dataclass(frozen=True)
158
+ class PresentationInputs:
159
+ """The KEL/TEL inputs the app resolves for a credential SAID.
160
+
161
+ These are the registry-resolved documents the native verifier needs; the relying party
162
+ supplies them via the `load_inputs` callback so this package stays free of storage/Git.
163
+ Each KEL/TEL entry is a parsed JSON object (the native bundle embeds them inline).
164
+
165
+ Args:
166
+ * `credential`: The signed ACDC (`{"acdc": …, "signatureB64": …}`) object.
167
+ * `issuer_kel`: The issuer's key event log events.
168
+ * `subject_kel`: The subject (holder) KEL events.
169
+ * `delegator_kel`: The subject's delegator KEL events (empty if none).
170
+ * `tel`: The credential's transaction event log events.
171
+ * `receipts`: Witness receipts (empty under first-party `warn` policy).
172
+ """
173
+
174
+ credential: dict[str, object]
175
+ issuer_kel: list[dict[str, object]]
176
+ subject_kel: list[dict[str, object]]
177
+ delegator_kel: list[dict[str, object]] = field(default_factory=list)
178
+ tel: list[dict[str, object]] = field(default_factory=list)
179
+ receipts: list[dict[str, object]] = field(default_factory=list)
180
+
181
+
182
+ # The app-supplied loader: credential SAID → registry-resolved inputs (raises on not-found).
183
+ LoadInputs = Callable[[str], PresentationInputs]
184
+
185
+
186
+ # Status strings on the native `PresentationStatus` enum that this module maps to HTTP.
187
+ _STATUS_TO_HTTP = {
188
+ "VALID": 200,
189
+ "HOLDER_NOT_CURRENT_KEY": 401,
190
+ "WRONG_AUDIENCE": 401,
191
+ "NONCE_MISMATCH_OR_CONSUMED": 401,
192
+ "EXPIRED": 401,
193
+ "SUBJECT_KEL_INVALID": 401,
194
+ "CREDENTIAL_NOT_VALID": 401,
195
+ "MALFORMED_REQUEST": 400,
196
+ "INPUT_TOO_LARGE": 400,
197
+ "UNSUPPORTED_SCHEMA_VERSION": 400,
198
+ "UNKNOWN": 401,
199
+ }
200
+
201
+
202
+ class KeriPresentationVerifier:
203
+ """Production verifier: native KERI presentation authentication over an app registry.
204
+
205
+ Flow (mirrors `authenticate_presentation`): parse the wire token → consume the
206
+ single-use challenge against the real store → `load_inputs(credential_said)` →
207
+ build the camelCase `VerifyPresentationRequest` (re-encoding base64url → standard
208
+ base64) → `auths.verify_presentation(json)` → enforce pinned roots → map the status to a
209
+ `Principal` or raise `PresentationDenied`.
210
+
211
+ The expected audience is the relying party's CONFIGURED audience, never the wire header.
212
+ `pinned_roots` is a DID-only allowlist (the `.auths/roots` model): the verified issuer
213
+ (or its delegator root) must be pinned, else 401. Capabilities come from the credential,
214
+ never from the request.
215
+
216
+ Args:
217
+ * `audience`: This relying party's canonical audience (the trust source).
218
+ * `challenges`: The single-use challenge store shared with the mint route.
219
+ * `load_inputs`: Resolves a credential SAID to its KEL/TEL inputs (raises if absent).
220
+ * `pinned_roots`: The set of trusted issuer/delegator DIDs (fail-closed if empty).
221
+
222
+ Usage:
223
+ ```python
224
+ verifier = KeriPresentationVerifier(
225
+ audience="api.example.com",
226
+ challenges=store,
227
+ load_inputs=resolve_from_registry,
228
+ pinned_roots={"did:keri:Eroot"},
229
+ )
230
+ ```
231
+ """
232
+
233
+ def __init__(
234
+ self,
235
+ audience: str,
236
+ challenges: ChallengeStore,
237
+ load_inputs: LoadInputs,
238
+ pinned_roots: frozenset[str],
239
+ ) -> None:
240
+ self._audience = audience
241
+ self._challenges = challenges
242
+ self._load_inputs = load_inputs
243
+ self._pinned_roots = pinned_roots
244
+
245
+ def verify(self, wire_token: str, now: datetime) -> Principal:
246
+ try:
247
+ wire = parse_presentation_header(wire_token)
248
+ except (ValueError, KeyError, json.JSONDecodeError) as exc:
249
+ raise PresentationDenied(400, "malformed presentation") from exc
250
+
251
+ if not self._challenges.consume(wire.audience, wire.nonce, now):
252
+ raise PresentationDenied(401, "challenge replayed, expired, or unknown")
253
+
254
+ try:
255
+ inputs = self._load_inputs(wire.credential_said)
256
+ except LookupError as exc:
257
+ raise PresentationDenied(401, "credential could not be resolved") from exc
258
+
259
+ request = self._build_request(wire, inputs, now)
260
+ report = _verify_presentation_native(json.dumps(request))
261
+ return self._map_report(report)
262
+
263
+ def _build_request(
264
+ self, wire: WirePresentation, inputs: PresentationInputs, now: datetime
265
+ ) -> dict[str, object]:
266
+ """Assemble the camelCase `VerifyPresentationRequest` bundle for the native verifier.
267
+
268
+ Wire nonce/signature are base64url (no pad); the bundle wants standard base64, so they
269
+ are re-encoded here. The expected challenge is the configured store's nonce — the same
270
+ value that was just consumed — re-encoded the same way.
271
+ """
272
+ return {
273
+ "schemaVersion": 1,
274
+ "envelope": {
275
+ "credentialSaid": wire.credential_said,
276
+ "audience": wire.audience,
277
+ "binding": {
278
+ "mode": "challenge",
279
+ "nonceB64": _b64url_to_standard_b64(wire.nonce),
280
+ },
281
+ "signatureB64": _b64url_to_standard_b64(wire.signature_b64url),
282
+ },
283
+ "credential": inputs.credential,
284
+ "issuerKel": inputs.issuer_kel,
285
+ "subjectKel": inputs.subject_kel,
286
+ "delegatorKel": inputs.delegator_kel,
287
+ "tel": inputs.tel,
288
+ "receipts": inputs.receipts,
289
+ "witnessPolicy": "warn",
290
+ "audience": self._audience,
291
+ "expectedChallengeB64": _b64url_to_standard_b64(wire.nonce),
292
+ "now": now.isoformat(),
293
+ }
294
+
295
+ def _map_report(self, report: object) -> Principal:
296
+ """Map a native `PresentationReport` to a `Principal` or raise `PresentationDenied`."""
297
+ status = getattr(report, "status")
298
+ status_name = getattr(status, "name", str(status))
299
+ if status_name != "VALID":
300
+ raise PresentationDenied(
301
+ _STATUS_TO_HTTP.get(status_name, 401), "presentation rejected"
302
+ )
303
+
304
+ issuer = getattr(report, "issuer")
305
+ if issuer not in self._pinned_roots:
306
+ raise PresentationDenied(401, "issuer is not a pinned root")
307
+
308
+ caps = tuple(Capability(name) for name in (getattr(report, "caps") or []))
309
+ return Principal(
310
+ issuer=issuer,
311
+ subject=getattr(report, "subject"),
312
+ caps=caps,
313
+ role=getattr(report, "role", None),
314
+ expires_at=getattr(report, "expires_at", None),
315
+ )
316
+
317
+
318
+ def _verify_presentation_native(request_json: str) -> object:
319
+ """Call the native `auths.verify_presentation`, importing the optional binding lazily.
320
+
321
+ Importing inside the call keeps the package importable (and its fake-verifier tests
322
+ runnable) with no native binding installed.
323
+
324
+ Args:
325
+ * `request_json`: The camelCase `VerifyPresentationRequest` JSON document.
326
+ """
327
+ import auths
328
+
329
+ # The native binding is an optional extra; do not couple static checking to whatever
330
+ # build is installed (its typed surface is the binding package's own concern).
331
+ native: Any = auths
332
+ return native.verify_presentation(request_json)
@@ -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,10 @@
1
+ auths_fastapi/__init__.py,sha256=gALTbhuC_zhm83IvRjl_gjBbv_gK7ZdaVx_Moppumck,1909
2
+ auths_fastapi/challenge_route.py,sha256=7OV6OItka_eGkBCHV06A0b1TapoDoNDqvqbeAj8P-LE,2665
3
+ auths_fastapi/challenge_store.py,sha256=_eVoWEsMZQgc0NdJTbtbwWhm-is85q2iJ3viwQbhEqk,4770
4
+ auths_fastapi/dependency.py,sha256=FPdB7cS9X5oiZqW1J9BirQeubjhrKzVnjue0MVKO9F8,4208
5
+ auths_fastapi/models.py,sha256=GbhNWqzERs4u3qklTYTSZx_MzlUax0ZSugtTvSaPHgI,2226
6
+ auths_fastapi/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ auths_fastapi/verifier.py,sha256=ky1VTkdk4N-uOuARm0_DduxAF6CVSvWbTBY0ZPToVJ8,13041
8
+ auths_fastapi-0.1.3.dist-info/METADATA,sha256=As_9A6TqBv7hkIL82DZsqNrk2QEquBD27-xM8liWgFY,5546
9
+ auths_fastapi-0.1.3.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
10
+ auths_fastapi-0.1.3.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any