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.
- auths_fastapi/__init__.py +59 -0
- auths_fastapi/challenge_route.py +82 -0
- auths_fastapi/challenge_store.py +121 -0
- auths_fastapi/dependency.py +110 -0
- auths_fastapi/models.py +69 -0
- auths_fastapi/py.typed +0 -0
- auths_fastapi/verifier.py +332 -0
- auths_fastapi-0.1.3.dist-info/METADATA +134 -0
- auths_fastapi-0.1.3.dist-info/RECORD +10 -0
- auths_fastapi-0.1.3.dist-info/WHEEL +4 -0
|
@@ -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
|
auths_fastapi/models.py
ADDED
|
@@ -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,,
|