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.
- auths_fastapi-0.1.3/.gitignore +7 -0
- auths_fastapi-0.1.3/PKG-INFO +134 -0
- auths_fastapi-0.1.3/README.md +110 -0
- auths_fastapi-0.1.3/auths_fastapi/__init__.py +59 -0
- auths_fastapi-0.1.3/auths_fastapi/challenge_route.py +82 -0
- auths_fastapi-0.1.3/auths_fastapi/challenge_store.py +121 -0
- auths_fastapi-0.1.3/auths_fastapi/dependency.py +110 -0
- auths_fastapi-0.1.3/auths_fastapi/models.py +69 -0
- auths_fastapi-0.1.3/auths_fastapi/py.typed +0 -0
- auths_fastapi-0.1.3/auths_fastapi/verifier.py +332 -0
- auths_fastapi-0.1.3/examples/app.py +89 -0
- auths_fastapi-0.1.3/pyproject.toml +54 -0
- auths_fastapi-0.1.3/tests/test_dependency.py +196 -0
- auths_fastapi-0.1.3/uv.lock +658 -0
|
@@ -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
|