Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 83 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,10 @@ uv run python examples/agent_service.py
uv run python examples/hosted_platform.py
```

The first example composes an Agent, Service, every built-in credential profile, protected resource,
and the framework-neutral ASGI adapter. The second demonstrates hosted identity Platform discovery,
Service DID resolution, provisioning, DID publication, delegated signing, and identity listing. See
The first example composes an Agent, Service, JWT authentication, every built-in credential profile,
a protected resource, and the framework-neutral ASGI adapter. The second demonstrates Platform
discovery, Service DID resolution, provisioning, DID publication, verified delegated signing,
identity listing, and suspension that prevents further signing. See
[`examples/README.md`](./examples/README.md) for the integration boundaries and production
replacements.

Expand All @@ -63,6 +64,50 @@ Concrete Grant Types and extensions can define additional Grant and Revoke reque
those members through `GrantOptions.parameters` or `RevokeOptions.parameters`; the Agent retains
control of the standard Grant Type and Revoke selector fields.

## Protected HTTP requests

After explicitly enrolling an Agent with a Service, use `fetch_protected_resource()` to request a
protected URL. It returns the SDK's transport-independent `HttpResponse`:

```python
from agent_enrollment_protocol.agent import (
FetchProtectedResourceOptions,
fetch_protected_resource,
)
from agent_enrollment_protocol.core import HttpRequest

response = await fetch_protected_resource(
FetchProtectedResourceOptions(
agent=agent,
request=HttpRequest(method="GET", url="https://service.example/private"),
carrier="AEP-Authorization",
)
)
```

The helper uses advertised OpenAPI security when it can resolve a complete supported requirement;
otherwise it waits for a valid `401` AEP challenge. It reuses stored credentials or signs a
resource-bound assertion, and can request a supported Grant for an already-enrolled identity. It
does not enroll automatically. Set `grant_type` to select a credential type, or `credential_id` to
select one stored credential. A missing `credential_id` match fails instead of requesting a
replacement Grant.

Requests use replayable byte bodies. Redirects are handled manually: each same-origin JWT retry
gets a fresh assertion bound to its target, and an origin change drops caller headers and requires
a new anonymous AEP challenge. The defaults are five redirects and thirty seconds for the entire
operation, including discovery and authentication. Task cancellation propagates to the active
operation. Responses obey the Agent's `maximum_response_bytes` bound.

Put payment credentials and custom secret headers in `additional_authentication_headers`, not
`request.headers`. They are sent only on authenticated requests to the original origin. Select
`carrier="AEP-Authorization"` when payment authentication occupies `Authorization`. Additional
headers cannot override the selected AEP credential.

An optional `transport` implements `AsyncHttpTransport`; it must return redirects without following
them. The helper closes its default `HttpxTransport` after the operation but does not close a
caller-provided transport. Existing `ServiceSession.authentication_headers()` remains available
for applications that manage their own HTTP requests.

## Service ASGI integration

`agent_enrollment_protocol.adapters` provides a framework-neutral ASGI integration with no
Expand Down Expand Up @@ -97,6 +142,10 @@ if principal is None:
raise RuntimeError("The route requires AEP authentication")
```

The application then applies its own account permissions and scope checks. Authentication alone
does not authorize every operation. The complete Service construction, credential issuers and
example DID verifier are in [`examples/agent_service.py`](./examples/agent_service.py).

Use a separate unprotected application branch for public resources. For local development,
`allow_insecure_loopback=True` permits an HTTP `localhost` or loopback resource origin; production
origins require HTTPS.
Expand Down Expand Up @@ -140,6 +189,15 @@ opaque Platform context; returning updated context starts the next signing stage
idempotency key. Without a resolver, pending signing raises `PlatformSignPendingError` for the
application to continue explicitly.

Grant recovers an existing identity through `find_identity_by_service_did` when the identity
provider implements `ExistingIdentityProvider` and the Agent's local identity store is empty.
Recovery does not provision or enroll an identity. The Agent checks Service Status before
requesting credentials when the Service advertises Status.

The default HTTP transport requires TLS 1.3 or later and verifies certificates using the system
trust configuration. Applications supplying an HTTP client or transport control its TLS settings
and must enforce the same protocol minimum. The SDK does not change a supplied client's settings.

## Hosted identity Platform

`agent_enrollment_protocol.platform` implements discovery, Service-scoped Agent identity
Expand Down Expand Up @@ -182,6 +240,28 @@ advertised by `DiscoveryOptions`. Authenticate private Platform routes before co
`RequestContext`; the Platform also invokes the supplied authorizer for every private operation.
Enable hosted verification only with a replay store.

For hosted verification, the authorizer decides whether the authenticated caller may verify the
supplied Agent assertion. A trusted Service can call with its own credentials; it does not need
the identity owner's credentials. Authorize that relationship explicitly rather than permitting
every authenticated caller to verify every identity. Signing and identity management retain their
owner checks.

## Application-owned storage

Default memory stores retain state only for the lifetime of one process. To retain an Agent's
identity and session credentials across restarts, supply `AgentOptions.identity_store` and
`credential_store`. A Service supplies durable enrollment, credential, replay and idempotency
stores; a Platform supplies identity, key, idempotency and, when using hosted verification, replay
storage. Inspect and discovery caches can remain transient.

Custom stores implement the asynchronous interfaces exported by their role module. In particular,
Service `EnrollmentStore.find_or_create()` must atomically reuse or create one enrollment for an
Agent DID. Service `IdempotencyStore.execute()` and Platform `PlatformIdempotencyStore.execute()`
must atomically run or replay an operation and reject conflicting reuse of the key. Retain command
results for at least one hour. Replay-store `consume()` must atomically refuse a second use of an
assertion identifier. Independent reads followed by writes do not provide these guarantees when
requests run concurrently or the application has multiple instances.

## Development

Install the locked development environment and run the complete merge gate:
Expand Down
38 changes: 31 additions & 7 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ keys in memory.

| Example | Demonstrates |
| --- | --- |
| [`agent_service.py`](./agent_service.py) | Agent inspection, enrollment, API-key, Basic, and OAuth Bearer grants, authenticated Service access, credential revocation, and rejected reuse through the ASGI adapter |
| [`hosted_platform.py`](./hosted_platform.py) | Hosted identity Platform discovery, Service DID resolution, provisioning, DID document publication, delegated signing, identity listing, and lifecycle management |
| [`agent_service.py`](./agent_service.py) | Inspect, Enroll, Status, a JWT-authenticated request, API-key/Basic/OAuth Bearer grants, authenticated resource access, and rejection after credential revocation through the ASGI adapter |
| [`hosted_platform.py`](./hosted_platform.py) | Platform discovery, Service DID resolution, identity provisioning, DID publication, delegated signing verified with the published public key, listing, suspension, and refused signing after suspension |

Run them from the repository root after installing the development environment:

Expand All @@ -17,15 +17,39 @@ uv run python examples/agent_service.py
uv run python examples/hosted_platform.py
```

Both examples resolve `did:web` documents through HTTP clients using HTTPS URLs. Their in-memory
transports make the examples deterministic without bypassing DID resolution. Production
integrations use a normal HTTP client with their cache and network policy. Replace all memory stores
with durable implementations before deployment.
No external account, API token, running server, or payment is required. Both examples use HTTPS URLs,
but their HTTP transports run in process: `MockTransport` serves the example DID documents and
`ASGITransport` calls the Service application. They exercise DID lookup, key selection and real
signature verification, not a network TLS handshake.

The Agent and Service example runs every built-in credential profile. Each profile supplies an issuer
callback and the same `ServiceCredentialStore` to its corresponding `stored_*_grant_type()` factory.
The example authenticates with each issued credential, revokes it, and confirms that reuse is rejected.
The example first uses `fetch_protected_resource()` with the dedicated `AEP-Authorization` carrier.
Before any session credentials exist, the Agent uses the advertised JWT method. The credential loop
then uses the lower-level `authentication_headers()` API and an explicitly selected Grant Type.
Each issuer grants only the example's `account:read` scope rather than trusting arbitrary requested
scopes. Each credential is revoked by its identifier, and reuse must return `401`.

The Platform example deliberately keeps its private key in process. A production Platform supplies a
`KeyStore` backed by its key-management system and maps the Platform methods to authenticated HTTP
routes.

Successful runs print one result per step and exit with status zero. Unexpected enrollment, resource,
listing, signature, lifecycle, or revocation outcomes raise an exception and fail the command.

## Adapting the examples to an application

- Publish the Agent DID document where its DID resolves, and connect the Service application to
your HTTP server. Replace the two in-process transports with real network transports.
- The example DID resolvers trust a fixed local URL map. A production resolver needs HTTPS with
TLS 1.3 or later, certificate verification, bounded response sizes and timeouts, and a safe
redirect policy. Reuse validated DID metadata according to your application's caching policy.
- Keep Agent signing keys and Service-issued credentials in protected storage. The sample's
generated key and all memory stores disappear when the process exits. Persist enrollment,
credential, replay and idempotency state where multiple requests or instances can share it.
- Decide which Agents may enroll, which scopes they may receive, and which application operations
they may perform. Successful AEP authentication establishes a principal, not application permission.
- For a Platform, replace `AllowAuthenticatedCaller` with your permission checks and construct
`RequestContext.principal` from validated route authentication, never from caller-supplied JSON.
Use the SDK's default unique identifier generators instead of the sample's fixed identifiers.
Keep lifecycle management on privileged Platform routes, not in the Agent workflow.
46 changes: 43 additions & 3 deletions examples/agent_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,13 @@
AssertionSigner,
AuthenticationOptions,
EnrollOptions,
FetchProtectedResourceOptions,
GrantOptions,
HttpxTransport,
IdentityRequest,
RevokeOptions,
ServiceIdentity,
fetch_protected_resource,
)
from agent_enrollment_protocol.core import (
AEP_AUTHENTICATION_METHOD_JWT,
Expand All @@ -42,12 +44,14 @@
AEP_GRANT_TYPE_OAUTH_BEARER,
AEP_IDENTITY_METHOD_DID_WEB,
AepAssertionError,
AgentStatus,
ApiKeyGrantResponse,
BasicGrantResponse,
ClaimValues,
ClientAssertionClaims,
GrantRequest,
GrantTypeConfig,
HttpRequest,
InspectClaims,
OAuthBearerGrantResponse,
SigningAlgorithm,
Expand Down Expand Up @@ -191,6 +195,8 @@ async def main() -> None:
key = Ed25519PrivateKey.generate()
credential_store = MemoryServiceCredentialStore()

# The demo grants read-only account access. Requested scopes are not permission to issue
# arbitrary privileges; a real issuer applies its own account and authorization policy.
async def issue_api_key(request: GrantRequest, context: GrantContext) -> ApiKeyGrantResponse:
return ApiKeyGrantResponse(
api_key=secrets.token_urlsafe(24),
Expand All @@ -199,7 +205,7 @@ async def issue_api_key(request: GrantRequest, context: GrantContext) -> ApiKeyG
.isoformat(timespec="seconds")
.replace("+00:00", "Z"),
header="X-Agent-Key",
scopes=request.requested_scopes,
scopes=("account:read",),
)

async def issue_basic(request: GrantRequest, context: GrantContext) -> BasicGrantResponse:
Expand All @@ -209,7 +215,7 @@ async def issue_basic(request: GrantRequest, context: GrantContext) -> BasicGran
.isoformat(timespec="seconds")
.replace("+00:00", "Z"),
password=secrets.token_urlsafe(24),
scopes=request.requested_scopes,
scopes=("account:read",),
username="example-agent",
)

Expand All @@ -220,7 +226,7 @@ async def issue_oauth(request: GrantRequest, context: GrantContext) -> OAuthBear
expires_at=(context.current_time + timedelta(hours=1))
.isoformat(timespec="seconds")
.replace("+00:00", "Z"),
scopes=request.requested_scopes,
scopes=("account:read",),
token_type="Bearer",
)

Expand Down Expand Up @@ -305,8 +311,37 @@ def resolve_agent_did(request: httpx.Request) -> httpx.Response:
)
)
)
if enrollment.body.status is not AgentStatus.ACTIVE:
raise RuntimeError("The example enrollment did not become active")
print("Enroll:", enrollment.body.status.value)

status = await session.status()
if status.body.status is not AgentStatus.ACTIVE:
raise RuntimeError("The Service did not report an active Agent")
print("Status:", status.body.status.value)

# With no stored session credential, the advertised JWT method signs this resource
# request. The helper follows the challenge but does not enroll for the caller.
authenticated = await fetch_protected_resource(
FetchProtectedResourceOptions(
agent=agent,
request=HttpRequest(method="GET", url=RESOURCE),
carrier="AEP-Authorization",
transport=transport,
)
)
if authenticated.status != 200:
raise RuntimeError("The JWT-authenticated resource request failed")
jwt_resource = json.loads(authenticated.body)
if (
jwt_resource["agent_did"] != AGENT_DID
or jwt_resource["authentication_method"] != AEP_AUTHENTICATION_METHOD_JWT
):
raise RuntimeError(
"The resource did not authenticate the expected Agent and method"
)
print("Resource:", jwt_resource["message"], "(aep-jwt)")

for grant_type in (
AEP_GRANT_TYPE_API_KEY,
AEP_GRANT_TYPE_BASIC,
Expand All @@ -329,6 +364,11 @@ def resolve_agent_did(request: httpx.Request) -> httpx.Response:
response = await client.get(RESOURCE, headers=headers)
response.raise_for_status()
resource: dict[str, Any] = response.json()
if (
resource["agent_did"] != AGENT_DID
or resource["authentication_method"] != grant_type
):
raise RuntimeError("The resource did not use the selected credential")
print(
"Resource:",
resource["message"],
Expand Down
Loading
Loading