Skip to content

Commit 7b8fa3c

Browse files
committed
refactor: isolate credential recovery HTTP routes
1 parent 94c4983 commit 7b8fa3c

3 files changed

Lines changed: 228 additions & 73 deletions

File tree

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
"""Public OIDC credential-recovery HTTP routes."""
2+
3+
from __future__ import annotations
4+
5+
from collections.abc import Mapping
6+
from dataclasses import asdict
7+
from typing import Any, Literal
8+
9+
from pydantic import BaseModel, ConfigDict, Field
10+
from starlette.requests import Request
11+
from starlette.responses import JSONResponse, Response
12+
from starlette.routing import Route
13+
14+
from agentnet.core.app import CommunicationCore
15+
from agentnet.errors import ValidationError
16+
from agentnet.identity.recovery import OIDCCredentialRecoveryCoordinator
17+
18+
19+
class RecoveryBeginBody(BaseModel):
20+
model_config = ConfigDict(extra="forbid")
21+
22+
old_harness_id: str = Field(min_length=1, max_length=256)
23+
new_harness_kind: str = Field(min_length=1, max_length=64)
24+
new_harness_name: str = Field(min_length=1, max_length=128)
25+
new_binding_assurance: Literal["os_bound", "hardware_bound"]
26+
new_public_key_pem: str = Field(min_length=128, max_length=16_384)
27+
28+
29+
class RecoveryCompleteBody(BaseModel):
30+
model_config = ConfigDict(extra="forbid")
31+
32+
recovery_transaction_id: str = Field(min_length=16, max_length=128)
33+
possession_signature: str = Field(min_length=1, max_length=2_048)
34+
independent_approvals: tuple[dict[str, Any], ...] = Field(min_length=1, max_length=5)
35+
36+
37+
def create_credential_recovery_routes(
38+
core: CommunicationCore,
39+
recovery_coordinator: OIDCCredentialRecoveryCoordinator,
40+
response_headers: Mapping[str, str],
41+
) -> list[Route]:
42+
"""Mount public recovery routes only when recovery is configured."""
43+
44+
async def begin_recovery(request: Request) -> Response:
45+
peer = "unavailable" if request.client is None else request.client.host
46+
core.quotas.consume(
47+
scope=f"public-credential-recovery:{peer}",
48+
metric="recovery_attempts",
49+
amount=1,
50+
limit=20,
51+
)
52+
body = await request.body()
53+
if len(body) > core.config.max_request_bytes:
54+
raise ValidationError("credential recovery request exceeds the configured limit")
55+
parsed = RecoveryBeginBody.model_validate_json(body)
56+
authorization = recovery_coordinator.begin_authorization(
57+
domain_id=core.config.domain_id,
58+
old_harness_id=parsed.old_harness_id,
59+
new_harness_kind=parsed.new_harness_kind,
60+
new_harness_name=parsed.new_harness_name,
61+
new_binding_assurance=parsed.new_binding_assurance,
62+
new_public_key_pem=parsed.new_public_key_pem,
63+
)
64+
return JSONResponse(
65+
asdict(authorization),
66+
status_code=201,
67+
headers=response_headers,
68+
)
69+
70+
async def complete_recovery(request: Request) -> Response:
71+
peer = "unavailable" if request.client is None else request.client.host
72+
core.quotas.consume(
73+
scope=f"public-credential-recovery:{peer}",
74+
metric="recovery_attempts",
75+
amount=1,
76+
limit=20,
77+
)
78+
body = await request.body()
79+
if len(body) > core.config.max_request_bytes:
80+
raise ValidationError("credential recovery request exceeds the configured limit")
81+
parsed = RecoveryCompleteBody.model_validate_json(body)
82+
result = recovery_coordinator.complete_recovery(
83+
transaction_id=parsed.recovery_transaction_id,
84+
possession_signature=parsed.possession_signature,
85+
approvals=parsed.independent_approvals,
86+
)
87+
return JSONResponse(
88+
result.model_dump(mode="json"),
89+
status_code=201,
90+
headers=response_headers,
91+
)
92+
93+
return [
94+
Route("/v1/credential-recovery/oidc/begin", begin_recovery, methods=["POST"]),
95+
Route("/v1/credential-recovery/complete", complete_recovery, methods=["POST"]),
96+
]
97+
98+
99+
__all__ = ["create_credential_recovery_routes"]

src/agentnet/identity_admin_http.py

Lines changed: 9 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -8,23 +8,21 @@
88
from __future__ import annotations
99

1010
from collections.abc import Awaitable, Callable
11-
from dataclasses import asdict
12-
from typing import Any, Literal
11+
from typing import Any
1312

14-
from pydantic import BaseModel, ConfigDict, Field
1513
from starlette.requests import Request
16-
from starlette.responses import JSONResponse, Response
1714
from starlette.routing import Route
1815

1916
from agentnet.approval.service import IndependentApprovalVerifier
2017
from agentnet.authorization.admin_http import create_authority_admin_routes
2118
from agentnet.authorization.evidence import IssuanceAuthority
2219
from agentnet.authorization.policy import AuthorizationRequest
2320
from agentnet.core.app import CommunicationCore
24-
from agentnet.errors import GateBlocked, ValidationError
21+
from agentnet.errors import GateBlocked
22+
from agentnet.identity.recovery import OIDCCredentialRecoveryCoordinator
23+
from agentnet.identity.recovery_http import create_credential_recovery_routes
2524
from agentnet.identity.revocation import HarnessRevocationService
2625
from agentnet.identity.revocation_http import create_harness_revocation_routes
27-
from agentnet.identity.recovery import OIDCCredentialRecoveryCoordinator
2826
from agentnet.identity.workload_http import create_workload_admin_routes
2927

3028

@@ -40,24 +38,6 @@ def _headers() -> dict[str, str]:
4038
}
4139

4240

43-
class RecoveryBeginBody(BaseModel):
44-
model_config = ConfigDict(extra="forbid")
45-
46-
old_harness_id: str = Field(min_length=1, max_length=256)
47-
new_harness_kind: str = Field(min_length=1, max_length=64)
48-
new_harness_name: str = Field(min_length=1, max_length=128)
49-
new_binding_assurance: Literal["os_bound", "hardware_bound"]
50-
new_public_key_pem: str = Field(min_length=128, max_length=16_384)
51-
52-
53-
class RecoveryCompleteBody(BaseModel):
54-
model_config = ConfigDict(extra="forbid")
55-
56-
recovery_transaction_id: str = Field(min_length=16, max_length=128)
57-
possession_signature: str = Field(min_length=1, max_length=2_048)
58-
independent_approvals: tuple[dict[str, Any], ...] = Field(min_length=1, max_length=5)
59-
60-
6141
def _decision(
6242
core: CommunicationCore,
6343
*,
@@ -101,51 +81,6 @@ def create_identity_admin_routes(
10181
task_grants=core.grants,
10282
)
10383

104-
async def begin_recovery(request: Request) -> Response:
105-
if recovery_coordinator is None:
106-
raise GateBlocked("credential_recovery", "OIDC credential recovery is not configured")
107-
peer = "unavailable" if request.client is None else request.client.host
108-
core.quotas.consume(
109-
scope=f"public-credential-recovery:{peer}",
110-
metric="recovery_attempts",
111-
amount=1,
112-
limit=20,
113-
)
114-
body = await request.body()
115-
if len(body) > core.config.max_request_bytes:
116-
raise ValidationError("credential recovery request exceeds the configured limit")
117-
parsed = RecoveryBeginBody.model_validate_json(body)
118-
authorization = recovery_coordinator.begin_authorization(
119-
domain_id=core.config.domain_id,
120-
old_harness_id=parsed.old_harness_id,
121-
new_harness_kind=parsed.new_harness_kind,
122-
new_harness_name=parsed.new_harness_name,
123-
new_binding_assurance=parsed.new_binding_assurance,
124-
new_public_key_pem=parsed.new_public_key_pem,
125-
)
126-
return JSONResponse(asdict(authorization), status_code=201, headers=_headers())
127-
128-
async def complete_recovery(request: Request) -> Response:
129-
if recovery_coordinator is None:
130-
raise GateBlocked("credential_recovery", "OIDC credential recovery is not configured")
131-
peer = "unavailable" if request.client is None else request.client.host
132-
core.quotas.consume(
133-
scope=f"public-credential-recovery:{peer}",
134-
metric="recovery_attempts",
135-
amount=1,
136-
limit=20,
137-
)
138-
body = await request.body()
139-
if len(body) > core.config.max_request_bytes:
140-
raise ValidationError("credential recovery request exceeds the configured limit")
141-
parsed = RecoveryCompleteBody.model_validate_json(body)
142-
result = recovery_coordinator.complete_recovery(
143-
transaction_id=parsed.recovery_transaction_id,
144-
possession_signature=parsed.possession_signature,
145-
approvals=parsed.independent_approvals,
146-
)
147-
return JSONResponse(result.model_dump(mode="json"), status_code=201, headers=_headers())
148-
14984
routes = create_authority_admin_routes(
15085
core,
15186
body_and_actor,
@@ -172,10 +107,11 @@ async def complete_recovery(request: Request) -> Response:
172107
)
173108
if recovery_coordinator is not None:
174109
routes.extend(
175-
[
176-
Route("/v1/credential-recovery/oidc/begin", begin_recovery, methods=["POST"]),
177-
Route("/v1/credential-recovery/complete", complete_recovery, methods=["POST"]),
178-
]
110+
create_credential_recovery_routes(
111+
core,
112+
recovery_coordinator,
113+
_headers(),
114+
)
179115
)
180116
return routes
181117

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
from __future__ import annotations
2+
3+
from dataclasses import dataclass
4+
from pathlib import Path
5+
6+
import httpx
7+
import pytest
8+
from starlette.applications import Starlette
9+
10+
from agentnet.core.app import CommunicationCore
11+
from agentnet.identity.recovery import CredentialRecoveryResult
12+
from agentnet.identity.recovery_http import create_credential_recovery_routes
13+
from agentnet.operations.config import ExtensionConfig
14+
from agentnet.security.signatures import P256KeyPair, canonical_json
15+
16+
17+
@dataclass(frozen=True)
18+
class _Authorization:
19+
transaction_id: str
20+
authorization_url: str
21+
expires_at: int
22+
23+
24+
class _RecoveryCoordinator:
25+
def __init__(self) -> None:
26+
self.begin_kwargs = None
27+
self.complete_kwargs = None
28+
29+
def begin_authorization(self, **kwargs):
30+
self.begin_kwargs = kwargs
31+
return _Authorization(
32+
transaction_id="recovery-transaction-0001",
33+
authorization_url="https://idp.example/authorize",
34+
expires_at=2_000_000_300,
35+
)
36+
37+
def complete_recovery(self, **kwargs):
38+
self.complete_kwargs = kwargs
39+
return CredentialRecoveryResult(
40+
principal_id="principal-1",
41+
revoked_harness_id="old-harness-1",
42+
harness_id="new-harness-1",
43+
credential_id="new-credential-1",
44+
approval_receipt_ids=("receipt-1",),
45+
)
46+
47+
48+
@pytest.mark.anyio
49+
async def test_recovery_http_mount_executes_begin_and_complete_contracts(
50+
store,
51+
tmp_path: Path,
52+
) -> None:
53+
core = CommunicationCore(
54+
ExtensionConfig(
55+
domain_id="corp.example",
56+
data_dir=tmp_path / "data",
57+
database_url=f"sqlite:///{tmp_path / 'core.sqlite3'}",
58+
artifact_dir=tmp_path / "artifacts",
59+
public_base_url="http://127.0.0.1",
60+
),
61+
store,
62+
)
63+
coordinator = _RecoveryCoordinator()
64+
headers = {"Cache-Control": "no-store", "Pragma": "no-cache"}
65+
app = Starlette(
66+
routes=create_credential_recovery_routes(core, coordinator, headers) # type: ignore[arg-type]
67+
)
68+
key = P256KeyPair.generate()
69+
70+
begin_body = canonical_json(
71+
{
72+
"old_harness_id": "old-harness-1",
73+
"new_harness_kind": "pi",
74+
"new_harness_name": "replacement laptop",
75+
"new_binding_assurance": "os_bound",
76+
"new_public_key_pem": key.public_pem,
77+
}
78+
)
79+
complete_body = canonical_json(
80+
{
81+
"recovery_transaction_id": "recovery-transaction-0001",
82+
"possession_signature": "proof-of-possession",
83+
"independent_approvals": [{"receipt_id": "receipt-1"}],
84+
}
85+
)
86+
87+
async with httpx.AsyncClient(
88+
transport=httpx.ASGITransport(app=app),
89+
base_url="http://127.0.0.1",
90+
) as client:
91+
begun = await client.post(
92+
"/v1/credential-recovery/oidc/begin",
93+
content=begin_body,
94+
headers={"Content-Type": "application/json"},
95+
)
96+
completed = await client.post(
97+
"/v1/credential-recovery/complete",
98+
content=complete_body,
99+
headers={"Content-Type": "application/json"},
100+
)
101+
102+
assert begun.status_code == 201
103+
assert begun.json()["transaction_id"] == "recovery-transaction-0001"
104+
assert begun.headers["cache-control"] == "no-store"
105+
assert coordinator.begin_kwargs == {
106+
"domain_id": "corp.example",
107+
"old_harness_id": "old-harness-1",
108+
"new_harness_kind": "pi",
109+
"new_harness_name": "replacement laptop",
110+
"new_binding_assurance": "os_bound",
111+
"new_public_key_pem": key.public_pem,
112+
}
113+
assert completed.status_code == 201
114+
assert completed.json()["credential_id"] == "new-credential-1"
115+
assert completed.headers["pragma"] == "no-cache"
116+
assert coordinator.complete_kwargs == {
117+
"transaction_id": "recovery-transaction-0001",
118+
"possession_signature": "proof-of-possession",
119+
"approvals": ({"receipt_id": "receipt-1"},),
120+
}

0 commit comments

Comments
 (0)