diff --git a/README.md b/README.md index a1ef1e1..10c579b 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,10 @@ The Service module includes stored credential profiles for each built-in Grant T Each factory accepts application-owned credential issuance and storage implementations. The included memory store is intended for examples and local development. +Concrete Grant Types and extensions can define additional Grant and Revoke request members. Pass +those members through `GrantOptions.parameters` or `RevokeOptions.parameters`; the Agent retains +control of the standard Grant Type and Revoke selector fields. + ## Service ASGI integration `agent_enrollment_protocol.adapters` provides a framework-neutral ASGI integration with no diff --git a/src/agent_enrollment_protocol/agent/client.py b/src/agent_enrollment_protocol/agent/client.py index a18e422..3eb1314 100644 --- a/src/agent_enrollment_protocol/agent/client.py +++ b/src/agent_enrollment_protocol/agent/client.py @@ -16,6 +16,7 @@ AEP_MEDIA_TYPE, AEP_PROBLEM_MEDIA_TYPE, AEP_WELL_KNOWN_PATH, + AepAssertionError, AepValidationError, AgentStatus, ApiKeyGrantResponse, @@ -38,6 +39,7 @@ SigningAlgorithm, StatusResponse, command_path_from_inspect, + decode_jwt_unverified, did_web_document_url, media_type_essence, missing_required_claim_names, @@ -113,7 +115,8 @@ def __init__(self, options: AgentOptions) -> None: raise ValueError("AEP Agent request timeout must be positive and finite") self._allow_insecure_loopback = options.allow_insecure_loopback self._assertion_lifetime = int(lifetime) - self._clock = options.clock + self._clock = _validated_clock(options.clock) + self._clock() owned_transports: list[AsyncHttpTransport] = [] command_transport: AsyncHttpTransport if options.command_transport is None: @@ -124,7 +127,7 @@ def __init__(self, options: AgentOptions) -> None: else: command_transport = options.command_transport self._command_transport = command_transport - self._credential_store = options.credential_store or MemoryCredentialStore(options.clock) + self._credential_store = options.credential_store or MemoryCredentialStore(self._clock) self._identity_provider = options.identity_provider self._identity_store = options.identity_store or MemoryIdentityStore() self._idempotency_keys = options.idempotency_keys or RandomIdempotencyKeyProvider() @@ -379,7 +382,8 @@ async def grant(self, options: GrantOptions | None = None) -> CommandResult[Gran key = await self._idempotency_key( inspection, Command.GRANT.value, options.idempotency_key, grant_type=grant_type ) - request_data: dict[str, object] = {"grant_type": grant_type} + request_data: dict[str, object] = dict(options.parameters) + request_data["grant_type"] = grant_type if options.requested_scopes: request_data["requested_scopes"] = options.requested_scopes request = GrantRequest.model_validate(request_data) @@ -395,6 +399,16 @@ async def grant(self, options: GrantOptions | None = None) -> CommandResult[Gran ) raw = result.body credential = _parse_credential(grant_type, raw) + if isinstance(credential, ApiKeyGrantResponse): + config = (inspection.document.commands.grant_types_config or {}).get(grant_type) + configured_headers = None if config is None else config.to_wire().get("header_names") + if configured_headers is not None: + if not isinstance(configured_headers, list) or any( + not isinstance(value, str) for value in configured_headers + ): + raise ValueError("AEP API-key header_names configuration is invalid") + if credential.header.lower() not in {value.lower() for value in configured_headers}: + raise ValueError("AEP API-key Grant response header was not advertised") grant_result = GrantResult(credential=credential, grant_type=grant_type, raw=raw) if credential is not None: record = CredentialRecord( @@ -411,9 +425,10 @@ async def grant(self, options: GrantOptions | None = None) -> CommandResult[Gran async def revoke(self, options: RevokeOptions) -> CommandResult[RevokeResponse]: if options.all_grant_types: - request_data: dict[str, object] = {"all_grant_types": "true"} + request_data: dict[str, object] = dict(options.parameters) + request_data["all_grant_types"] = "true" else: - request_data = {} + request_data = dict(options.parameters) if options.grant_type is not None: request_data["grant_type"] = options.grant_type if options.credential_id is not None: @@ -457,7 +472,7 @@ async def revoke(self, options: RevokeOptions) -> CommandResult[RevokeResponse]: ) if matches: await self._agent._credential_store.delete_credential( - record.service_did, record.credential_id + inspection.document.service.did, record.credential_id ) return result @@ -656,6 +671,9 @@ async def _sign_assertion( assertion = await signer(claims, tuple(SigningAlgorithm(value) for value in algorithms)) if not assertion: raise ValueError("AEP assertion signer returned an empty assertion") + _validate_signed_assertion( + assertion, claims, algorithms, self._agent._allow_insecure_loopback + ) return assertion async def _find_credential( @@ -829,6 +847,43 @@ def _validate_credential_record(record: CredentialRecord, service_did: str, now: raise ValueError("stored AEP credential metadata does not match its payload") +def _validated_clock(clock: Clock) -> Clock: + def current_time() -> datetime: + value = clock() + if value.utcoffset() is None: + raise ValueError("AEP Agent clock must return a time with a UTC offset") + return value + + return current_time + + +def _validate_signed_assertion( + assertion: str, + claims: ClientAssertionClaims, + algorithms: tuple[str, ...], + allow_insecure_loopback: bool, +) -> None: + try: + header, payload = decode_jwt_unverified(assertion) + parsed_claims = ClientAssertionClaims.model_validate_json( + json.dumps(payload), + context={"allow_insecure_loopback": allow_insecure_loopback}, + ) + except (AepAssertionError, ValueError) as error: + raise ValueError("AEP assertion signer returned an invalid assertion") from error + key_id = header.get("kid") + if ( + header.get("typ") != "JWT" + or header.get("alg") not in algorithms + or not isinstance(key_id, str) + or key_id.partition("#")[0] != claims.iss + or parsed_claims != claims + ): + raise ValueError( + "AEP assertion signer returned an assertion that does not match the request" + ) + + def _inspection_from_cache( entry: InspectCacheEntry, inspect_url: str, service_url: str ) -> Inspection: diff --git a/src/agent_enrollment_protocol/agent/types.py b/src/agent_enrollment_protocol/agent/types.py index 6baa167..6668ccb 100644 --- a/src/agent_enrollment_protocol/agent/types.py +++ b/src/agent_enrollment_protocol/agent/types.py @@ -1,6 +1,7 @@ from __future__ import annotations from collections.abc import Awaitable, Callable, Mapping, Sequence +from copy import deepcopy from dataclasses import dataclass, field from datetime import datetime from types import MappingProxyType @@ -74,7 +75,7 @@ class CredentialRecord: expires_at: datetime grant_type: str issued_at: datetime - payload: bytes + payload: bytes = field(repr=False) service_did: str service_url: str @@ -145,19 +146,21 @@ class EnrollOptions: class GrantOptions: grant_type: str | None = None idempotency_key: str | None = None + parameters: Mapping[str, object] = field(default_factory=lambda: MappingProxyType({})) preferred_grant_types: tuple[str, ...] = () requested_scopes: tuple[str, ...] = () def __post_init__(self) -> None: object.__setattr__(self, "preferred_grant_types", tuple(self.preferred_grant_types)) + object.__setattr__(self, "parameters", _copy_parameters(self.parameters)) object.__setattr__(self, "requested_scopes", tuple(self.requested_scopes)) @dataclass(frozen=True, slots=True) class GrantResult: - credential: BuiltInCredential | None + credential: BuiltInCredential | None = field(repr=False) grant_type: str - raw: bytes + raw: bytes = field(repr=False) @dataclass(frozen=True, slots=True) @@ -166,6 +169,10 @@ class RevokeOptions: credential_id: str | None = None grant_type: str | None = None idempotency_key: str | None = None + parameters: Mapping[str, object] = field(default_factory=lambda: MappingProxyType({})) + + def __post_init__(self) -> None: + object.__setattr__(self, "parameters", _copy_parameters(self.parameters)) @dataclass(frozen=True, slots=True) @@ -203,3 +210,9 @@ def __init__(self, missing: Sequence[str]) -> None: "AEP Agent cannot satisfy the Service's required Claim Names: " + ", ".join(values) ) self.missing = values + + +def _copy_parameters(parameters: Mapping[str, object]) -> Mapping[str, object]: + if any(not isinstance(name, str) or not name for name in parameters): + raise ValueError("AEP extension parameter names must be non-empty strings") + return MappingProxyType(deepcopy(dict(parameters))) diff --git a/src/agent_enrollment_protocol/core/assertions.py b/src/agent_enrollment_protocol/core/assertions.py index 81230b7..d64db88 100644 --- a/src/agent_enrollment_protocol/core/assertions.py +++ b/src/agent_enrollment_protocol/core/assertions.py @@ -1,8 +1,9 @@ from __future__ import annotations import json +import re import time -from base64 import urlsafe_b64decode +from base64 import b64decode from dataclasses import dataclass from typing import Any @@ -11,6 +12,8 @@ from .errors import AepAssertionError from .models import AssertionOperation, ClientAssertionClaims, SigningAlgorithm +_BASE64URL_PATTERN = re.compile(r"\A[A-Za-z0-9_-]+\Z") + @dataclass(frozen=True, slots=True) class VerifyClientAssertionOptions: @@ -103,7 +106,7 @@ def verify_client_assertion( def decode_jwt_unverified(assertion: str) -> tuple[dict[str, Any], dict[str, Any]]: parts = assertion.split(".") - if len(parts) != 3: + if len(parts) != 3 or any(_BASE64URL_PATTERN.fullmatch(part) is None for part in parts): raise AepAssertionError("Invalid JWT.") try: header = _decode_part(parts[0]) @@ -122,8 +125,17 @@ def _require_key_binding(key_id: str, claims: ClientAssertionClaims) -> None: def _decode_part(value: str) -> dict[str, Any]: - decoded = urlsafe_b64decode(value + "=" * (-len(value) % 4)).decode() - parsed = json.loads(decoded) + decoded = b64decode(value + "=" * (-len(value) % 4), altchars=b"-_", validate=True).decode() + + def pairs(items: list[tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, item in items: + if key in result: + raise ValueError(f"duplicate object member: {key}") + result[key] = item + return result + + parsed = json.loads(decoded, object_pairs_hook=pairs) if not isinstance(parsed, dict): raise ValueError("JWT part must be an object") return parsed diff --git a/src/agent_enrollment_protocol/core/did_web.py b/src/agent_enrollment_protocol/core/did_web.py index 1562942..693ec50 100644 --- a/src/agent_enrollment_protocol/core/did_web.py +++ b/src/agent_enrollment_protocol/core/did_web.py @@ -1,38 +1,26 @@ from __future__ import annotations +import re from collections.abc import Mapping +from copy import deepcopy from typing import Any from urllib.parse import unquote, urlsplit +_INVALID_PERCENT_ENCODING = re.compile(r"%(?![0-9A-Fa-f]{2})") + def did_web_document_url(did: str, *, allow_insecure_loopback: bool = False) -> str: - prefix = "did:web:" - if not did.startswith(prefix): - raise ValueError(f"Unsupported DID method: {did}") - parts = did[len(prefix) :].split(":") - if not parts[0]: - raise ValueError(f"Invalid did:web identifier: {did}") - host = unquote(parts[0]) - path = ( - "/.well-known/did.json" - if len(parts) == 1 - else f"/{'/'.join(unquote(part) for part in parts[1:])}/did.json" - ) + host, path_parts = _did_web_parts(did) + path = "/.well-known/did.json" if not path_parts else f"/{'/'.join(path_parts)}/did.json" scheme = "http" if allow_insecure_loopback and _is_loopback(host) else "https" - url = f"{scheme}://{host}{path}" - parsed = urlsplit(url) - try: - _ = parsed.port - except ValueError as error: - raise ValueError(f"Invalid did:web identifier: {did}") from error - if not parsed.hostname or parsed.username or parsed.password or parsed.query or parsed.fragment: - raise ValueError(f"Invalid did:web identifier: {did}") - return url + return f"{scheme}://{host}{path}" def select_did_web_public_jwk( document: Mapping[str, Any], *, did: str, key_id: str ) -> dict[str, Any]: + if document.get("id") != did: + raise ValueError("AEP did:web document ID does not identify the assertion issuer") key_did = key_id.partition("#")[0] if key_did != did: raise ValueError("AEP did:web key ID does not identify the assertion issuer") @@ -44,10 +32,46 @@ def select_did_web_public_jwk( continue jwk = method.get("publicKeyJwk") if isinstance(jwk, dict): - return dict(jwk) + return deepcopy(jwk) raise ValueError(f"No public JWK found for {key_id}") def _is_loopback(host: str) -> bool: hostname = urlsplit(f"//{host}").hostname return hostname in {"localhost", "127.0.0.1", "::1"} + + +def _did_web_parts(did: str) -> tuple[str, tuple[str, ...]]: + prefix = "did:web:" + if not did.startswith(prefix): + raise ValueError(f"Unsupported DID method: {did}") + encoded_parts = did[len(prefix) :].split(":") + if ( + not did.isascii() + or not encoded_parts[0] + or any(_INVALID_PERCENT_ENCODING.search(part) for part in encoded_parts) + ): + raise ValueError(f"Invalid did:web identifier: {did}") + host = unquote(encoded_parts[0]) + if not host.isascii(): + raise ValueError(f"Invalid did:web identifier: {did}") + parsed = urlsplit(f"//{host}") + try: + _ = parsed.port + except ValueError as error: + raise ValueError(f"Invalid did:web identifier: {did}") from error + if ( + not parsed.hostname + or parsed.username + or parsed.password + or parsed.path + or parsed.query + or parsed.fragment + ): + raise ValueError(f"Invalid did:web identifier: {did}") + path_parts = tuple(encoded_parts[1:]) + for part in path_parts: + decoded = unquote(part) + if not part or decoded in {".", ".."} or any(value in decoded for value in "/\\?#"): + raise ValueError(f"Invalid did:web identifier: {did}") + return host, path_parts diff --git a/src/agent_enrollment_protocol/core/http.py b/src/agent_enrollment_protocol/core/http.py index 1763a91..77b6250 100644 --- a/src/agent_enrollment_protocol/core/http.py +++ b/src/agent_enrollment_protocol/core/http.py @@ -1,5 +1,6 @@ from __future__ import annotations +import re from collections.abc import Mapping from dataclasses import dataclass, field from types import MappingProxyType @@ -14,6 +15,13 @@ ProtectedResourceAuthorization, ) +_TOKEN = r"[!#$%&'*+.^_`|~0-9A-Za-z-]+" +_QUOTED_STRING = r'"(?:[\t !#-\[\]-~\x80-\xff]|\\[\t !-~\x80-\xff])*"' +_MEDIA_TYPE_PATTERN = re.compile( + rf"\A[ \t]*(?P{_TOKEN})/(?P{_TOKEN})[ \t]*" + rf"(?:;[ \t]*{_TOKEN}[ \t]*=[ \t]*(?:{_TOKEN}|{_QUOTED_STRING})[ \t]*)*\Z" +) + @dataclass(frozen=True, slots=True) class HttpRequest: @@ -37,7 +45,10 @@ def __post_init__(self) -> None: def media_type_essence(value: str) -> str: - return value.partition(";")[0].strip().lower() + match = _MEDIA_TYPE_PATTERN.fullmatch(value) + if match is None: + return "" + return f"{match.group('type')}/{match.group('subtype')}".lower() def normalize_endpoint_base(endpoint_base: str = DEFAULT_HTTP_ENDPOINT_BASE) -> str: diff --git a/src/agent_enrollment_protocol/core/inspect.py b/src/agent_enrollment_protocol/core/inspect.py index 5d28c32..6d0502b 100644 --- a/src/agent_enrollment_protocol/core/inspect.py +++ b/src/agent_enrollment_protocol/core/inspect.py @@ -1,8 +1,9 @@ from __future__ import annotations -from urllib.parse import SplitResult, unquote, urlsplit +from urllib.parse import SplitResult, urlsplit from .constants import AEP_VERSION +from .did_web import _did_web_parts from .models import VERSION_PATTERN, InspectDocument @@ -28,18 +29,12 @@ def require_service_origin_binding( def did_web_origin(did: str, *, allow_insecure_loopback: bool = False) -> str: - prefix = "did:web:" - if not did.startswith(prefix): - raise ValueError("AEP Service identity must use did:web") - encoded_host = did[len(prefix) :].partition(":")[0] - if not encoded_host: - raise ValueError("Invalid did:web Service identity") - host = unquote(encoded_host) + try: + host, _ = _did_web_parts(did) + except ValueError as error: + raise ValueError("Invalid did:web Service identity") from error scheme = "http" if allow_insecure_loopback and _loopback_host(host) else "https" - parsed = urlsplit(f"{scheme}://{host}") - if not parsed.hostname or parsed.username or parsed.password: - raise ValueError("Invalid did:web Service identity") - return _origin(parsed) + return _origin(urlsplit(f"{scheme}://{host}")) def same_origin(first: str, second: str) -> bool: diff --git a/src/agent_enrollment_protocol/core/models.py b/src/agent_enrollment_protocol/core/models.py index 9de18f2..77aad42 100644 --- a/src/agent_enrollment_protocol/core/models.py +++ b/src/agent_enrollment_protocol/core/models.py @@ -34,6 +34,7 @@ r"^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}" r"(?:\.[0-9]+)?(?:Z|[+-][0-9]{2}:[0-9]{2})$" ) +RFC3339_FULL_DATE_PATTERN = re.compile(r"^[0-9]{4}-[0-9]{2}-[0-9]{2}$") URI_SCHEME_PATTERN = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]*$") @@ -509,13 +510,13 @@ def normalize_scopes(self) -> Self: class OAuthBearerGrantResponse(CredentialResponse): - access_token: str = Field(min_length=1) + access_token: str = Field(min_length=1, repr=False) token_format: Literal["opaque", "jwt"] | None = None token_type: Literal["Bearer"] class ApiKeyGrantResponse(CredentialResponse): - api_key: str = Field(min_length=1) + api_key: str = Field(min_length=1, repr=False) header: str = Field(min_length=1) @model_validator(mode="after") @@ -531,7 +532,7 @@ def validate_api_key(self) -> Self: class BasicGrantResponse(CredentialResponse): - password: str = Field(min_length=1) + password: str = Field(min_length=1, repr=False) realm: str | None = Field(default=None, min_length=1) username: str = Field(min_length=1) @@ -839,6 +840,8 @@ def parse_rfc3339(value: str) -> datetime: def parse_full_date(value: str) -> date: + if RFC3339_FULL_DATE_PATTERN.fullmatch(value) is None: + raise ValueError("invalid RFC 3339 full-date") return date.fromisoformat(value) diff --git a/src/agent_enrollment_protocol/core/openapi.py b/src/agent_enrollment_protocol/core/openapi.py index 1917886..e77754c 100644 --- a/src/agent_enrollment_protocol/core/openapi.py +++ b/src/agent_enrollment_protocol/core/openapi.py @@ -1,10 +1,13 @@ from __future__ import annotations +import re from dataclasses import dataclass from urllib.parse import urljoin, urlsplit from .models import OpenApiTrailingSlash +_PATH_EXPRESSION = re.compile(r"\{[^{}]+\}") + @dataclass(frozen=True, slots=True) class OpenApiPathMatch: @@ -19,7 +22,7 @@ def match_openapi_path( path: str, trailing_slash: OpenApiTrailingSlash, ) -> OpenApiPathMatch: - if not method or not path or "?" in path: + if not method or not path or "?" in path or "#" in path: raise ValueError("Invalid OpenAPI operation target") request_segments = _segments(path, trailing_slash) matches: list[tuple[tuple[int, ...], str]] = [] @@ -29,10 +32,10 @@ def match_openapi_path( continue score: list[int] = [] for expected, actual in zip(template_segments, request_segments, strict=True): - variable = expected.startswith("{") and expected.endswith("}") and len(expected) > 2 - if not variable and expected != actual: + matched, templated = _match_segment(expected, actual) + if not matched: break - score.append(0 if variable else 1) + score.append(0 if templated else 1) else: matches.append((tuple(score), template)) if not matches: @@ -61,6 +64,8 @@ def resolve_openapi_url( ): raise ValueError("Invalid final AEP Inspect URL") resolved = urlsplit(urljoin(final_inspect_url, reference)) + if inspect.scheme == "https" and resolved.scheme != "https": + raise ValueError("Invalid AEP OpenAPI URL") allowed = resolved.scheme == "https" or ( allow_insecure_loopback and resolved.scheme == "http" @@ -84,3 +89,20 @@ def _segments(path: str, mode: OpenApiTrailingSlash) -> tuple[str, ...]: else path ) return tuple(normalized.removeprefix("/").split("/")) + + +def _match_segment(template: str, value: str) -> tuple[bool, bool]: + remainder = _PATH_EXPRESSION.sub("", template) + if "{" in remainder or "}" in remainder: + return False, False + expressions = tuple(_PATH_EXPRESSION.finditer(template)) + if not expressions: + return template == value, False + position = 0 + pattern: list[str] = [] + for expression in expressions: + pattern.append(re.escape(template[position : expression.start()])) + pattern.append(r"[^/?#]+") + position = expression.end() + pattern.append(re.escape(template[position:])) + return re.fullmatch("".join(pattern), value) is not None, True diff --git a/src/agent_enrollment_protocol/service/service.py b/src/agent_enrollment_protocol/service/service.py index e67f972..85b9534 100644 --- a/src/agent_enrollment_protocol/service/service.py +++ b/src/agent_enrollment_protocol/service/service.py @@ -15,6 +15,8 @@ from agent_enrollment_protocol.core import ( AEP_AUTHENTICATION_METHOD_JWT, + AEP_GRANT_TYPE_BASIC, + AEP_GRANT_TYPE_OAUTH_BEARER, AEP_PROBLEM_MEDIA_TYPE, AEP_VERSION, AgentStatus, @@ -22,6 +24,7 @@ AuthorizationCarrier, AuthorizationScheme, Bindings, + ClaimValues, ClientAssertionClaims, Command, Commands, @@ -32,6 +35,7 @@ GrantRequest, HttpConfiguration, Identity, + InspectClaims, InspectDocument, ProblemDetails, ProtectedResourceAuthorization, @@ -199,6 +203,9 @@ async def enroll(self, body: bytes, options: CommandOptions) -> ServiceResult[En return _problem("invalid_request", "Invalid request", 400) async def execute() -> ServiceResult[EnrollResponse]: + existing = await self._enrollment_store.find(claims.sub) + if existing is not None: + return _enrollment_result(existing, claims.sub) required = self._document.claims.required if self._document.claims else () missing = missing_required_claim_names(required or (), request.claims) if missing: @@ -220,7 +227,7 @@ async def create() -> EnrollmentRecord: ) return EnrollmentRecord( agent_did=claims.sub, - claims=request.claims, + claims=_accepted_claims(request.claims, self._document.claims), created_at=now, enrollment_id=identifier, owner_action_required=decision.owner_action_required, @@ -232,7 +239,7 @@ async def create() -> EnrollmentRecord: ) record, _ = await self._enrollment_store.find_or_create(claims.sub, create) - return _enrollment_result(record) + return _enrollment_result(record, claims.sub) return await self._idempotent( claims.sub, @@ -252,7 +259,7 @@ async def status(self, options: CommandOptions) -> ServiceResult[StatusResponse] record = await self._enrollment_store.find(claims.sub) if record is None: return _problem("not_recognized", "Not recognized", 401) - _validate_enrollment_record(record) + _validate_enrollment_record(record, claims.sub) return ServiceResult( status=200, body=StatusResponse.model_validate( @@ -275,7 +282,7 @@ async def execute() -> ServiceResult[dict[str, Any]]: record = await self._enrollment_store.find(claims.sub) if record is None: return _problem("not_recognized", "Not recognized", 401) - _validate_enrollment_record(record) + _validate_enrollment_record(record, claims.sub) definition = self._grant_types.get(request.grant_type) if definition is None: return _problem("unsupported_grant_type", "Unsupported grant type", 400) @@ -317,7 +324,7 @@ async def execute() -> ServiceResult[RevokeResponse]: record = await self._enrollment_store.find(claims.sub) if record is None: return _problem("not_recognized", "Not recognized", 401) - _validate_enrollment_record(record) + _validate_enrollment_record(record, claims.sub) if request.grant_type is not None: definition = self._grant_types.get(request.grant_type) if definition is None: @@ -379,6 +386,14 @@ async def authenticate_protected_resource( authentication_method=AEP_AUTHENTICATION_METHOD_JWT, ), ) + if presentation is not None: + method = ( + AEP_GRANT_TYPE_BASIC + if presentation.scheme is AuthorizationScheme.BASIC + else AEP_GRANT_TYPE_OAUTH_BEARER + ) + if method not in self._authentication_methods: + return self._protected_problem("unsupported_authentication_method", resource) authentication_input = CredentialAuthenticationInput( current_time=self._now(), headers=headers, @@ -412,7 +427,7 @@ async def _active(self, agent_did: str) -> bool: record = await self._enrollment_store.find(agent_did) if record is None: return False - _validate_enrollment_record(record) + _validate_enrollment_record(record, agent_did) return record.status is AgentStatus.ACTIVE async def _authenticate_assertion( @@ -622,25 +637,11 @@ def _problem( ) -def _enrollment_result(record: EnrollmentRecord) -> ServiceResult[EnrollResponse]: - _validate_enrollment_record(record) - if record.status in {AgentStatus.ACTIVE, AgentStatus.PENDING, AgentStatus.REJECTED}: - return ServiceResult( - status=200, - body=EnrollResponse.model_validate(_lifecycle_data(record)), - ) - code = { - AgentStatus.SUSPENDED: "identity_suspended", - AgentStatus.TERMINATED: "identity_terminated", - AgentStatus.UNAVAILABLE: "identity_unavailable", - }.get(record.status, "enrollment_failed") - return _problem( - code, - _title(code), - 403 if code.startswith("identity_") else 400, - owner_action_required=record.owner_action_required, - requirements_pending=record.requirements_pending, - verification_pending=record.verification_pending, +def _enrollment_result(record: EnrollmentRecord, agent_did: str) -> ServiceResult[EnrollResponse]: + _validate_enrollment_record(record, agent_did) + return ServiceResult( + status=200, + body=EnrollResponse.model_validate(_lifecycle_data(record)), ) @@ -842,8 +843,24 @@ def _lifecycle_data(record: EnrollmentRecord) -> dict[str, Any]: return data -def _validate_enrollment_record(record: EnrollmentRecord) -> None: +def _accepted_claims( + values: ClaimValues | None, advertised: InspectClaims | None +) -> ClaimValues | None: + if values is None: + return None + names = ( + (*(advertised.required or ()), *(advertised.preferred or ()), *(advertised.optional or ())) + if advertised is not None + else () + ) + accepted = {name: value for name, value in values.to_wire().items() if name in names} + return ClaimValues.model_validate(accepted) + + +def _validate_enrollment_record(record: EnrollmentRecord, agent_did: str) -> None: replace(record) + if record.agent_did != agent_did: + raise ValueError("AEP enrollment store returned a mismatched Agent DID") def _rfc3339(value: datetime) -> str: diff --git a/tests/test_agent.py b/tests/test_agent.py index 14178fb..b52a731 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import base64 import json from collections.abc import Callable from datetime import UTC, datetime, timedelta @@ -81,11 +82,26 @@ async def sign( assert identity.agent_did == claims.iss assert algorithms == (SigningAlgorithm.EDDSA,) self.claims.append(claims) - return "signed.assertion.value" + return signed_assertion(claims) return sign +def signed_assertion( + claims: ClientAssertionClaims, + *, + algorithm: str = "EdDSA", + key_id: str | None = None, + payload: dict[str, object] | None = None, +) -> str: + def encode(value: object) -> str: + data = json.dumps(value, separators=(",", ":")).encode() + return base64.urlsafe_b64encode(data).rstrip(b"=").decode() + + header = {"alg": algorithm, "kid": key_id or claims.iss, "typ": "JWT"} + return f"{encode(header)}.{encode(claims.to_wire() if payload is None else payload)}.c2ln" + + class FixedKeys: def __init__(self, value: str = "operation-key") -> None: self.value = value @@ -274,7 +290,7 @@ async def test_authentication_uses_assertion_and_honors_selection() -> None: client_assertion_only=True, ) ) - assert headers == {"AEP-Authorization": "AEP signed.assertion.value"} + assert headers["AEP-Authorization"].startswith("AEP ") assert provider.claims[-1].resource == "https://api.example.com/resource" with pytest.raises(ValueError, match="cannot accompany"): await service.authentication_headers( diff --git a/tests/test_agent_boundaries.py b/tests/test_agent_boundaries.py index fa9aed2..b4204ad 100644 --- a/tests/test_agent_boundaries.py +++ b/tests/test_agent_boundaries.py @@ -16,6 +16,7 @@ CredentialRecord, EnrollOptions, GrantOptions, + GrantResult, HttpxTransport, InspectCacheEntry, MemoryCredentialStore, @@ -34,6 +35,7 @@ ) from agent_enrollment_protocol.agent.types import IdentityRequest from agent_enrollment_protocol.core import ( + ApiKeyGrantResponse, AuthorizationCarrier, ClaimValues, ClientAssertionClaims, @@ -43,7 +45,14 @@ SigningAlgorithm, ) -from .test_agent import FakeIdentityProvider, FixedKeys, QueueTransport, configured_agent, response +from .test_agent import ( + FakeIdentityProvider, + FixedKeys, + QueueTransport, + configured_agent, + response, + signed_assertion, +) from .test_core_models import inspect_document @@ -375,6 +384,117 @@ async def test_grant_selection_custom_and_inactive_boundaries() -> None: assert result.body.grant_type == "api-key" +@pytest.mark.asyncio +async def test_grant_and_revoke_forward_extension_parameters() -> None: + transport = QueueTransport( + response({"status": "active"}), + response({"custom": "credential"}), + response({}), + ) + document = document_with( + authentication={"methods": ["future"]}, + commands={ + "supported": ["grant", "inspect", "revoke", "status"], + "grant_types": ["future"], + }, + ) + agent, _ = configured_agent(QueueTransport(response(document.to_wire())), transport) + service = agent.service("api.example.com") + await service.identity() + parameters = {"label": "workload"} + await service.grant(GrantOptions(grant_type="future", parameters=parameters)) + parameters["label"] = "changed" + await service.revoke(RevokeOptions(grant_type="future", parameters={"reason": "rotation"})) + grant_body = transport.requests[1].body + revoke_body = transport.requests[2].body + assert grant_body is not None + assert revoke_body is not None + assert json.loads(grant_body) == { + "grant_type": "future", + "label": "workload", + } + assert json.loads(revoke_body) == { + "grant_type": "future", + "reason": "rotation", + } + + +@pytest.mark.asyncio +async def test_grant_rejects_unadvertised_api_key_header() -> None: + document = document_with( + commands={ + "supported": ["grant", "inspect", "status"], + "grant_types": ["api-key"], + "grant_types_config": {"api-key": {"header_names": ["x-service-key"]}}, + } + ) + agent, _ = configured_agent( + QueueTransport(response(document.to_wire())), + QueueTransport( + response({"status": "active"}), + response( + { + "api_key": "secret", + "credential_id": "one", + "expires_at": "2026-01-01T01:00:00Z", + "header": "X-Other-Key", + } + ), + ), + ) + await agent.service("api.example.com").identity() + with pytest.raises(ValueError, match="was not advertised"): + await agent.service("api.example.com").grant(GrantOptions(grant_type="api-key")) + accepted, _ = configured_agent( + QueueTransport(response(document.to_wire())), + QueueTransport( + response({"status": "active"}), + response( + { + "api_key": "secret", + "credential_id": "two", + "expires_at": "2026-01-01T01:00:00Z", + "header": "X-Service-Key", + } + ), + ), + ) + await accepted.service("api.example.com").identity() + result = await accepted.service("api.example.com").grant(GrantOptions(grant_type="api-key")) + assert result.body.credential is not None + + +@pytest.mark.asyncio +@pytest.mark.parametrize("header_names", ["x-service-key", [1]]) +async def test_grant_rejects_invalid_api_key_header_configuration( + header_names: object, +) -> None: + document = document_with( + commands={ + "supported": ["grant", "inspect", "status"], + "grant_types": ["api-key"], + "grant_types_config": {"api-key": {"header_names": header_names}}, + } + ) + agent, _ = configured_agent( + QueueTransport(response(document.to_wire())), + QueueTransport( + response({"status": "active"}), + response( + { + "api_key": "secret", + "credential_id": "one", + "expires_at": "2026-01-01T01:00:00Z", + "header": "X-Service-Key", + } + ), + ), + ) + await agent.service("api.example.com").identity() + with pytest.raises(ValueError, match="configuration is invalid"): + await agent.service("api.example.com").grant(GrantOptions(grant_type="api-key")) + + @pytest.mark.asyncio async def test_revoke_and_forget_boundaries() -> None: agent, _ = configured_agent( @@ -685,6 +805,64 @@ async def signer_for(self, identity: ServiceIdentity) -> AssertionSigner: AuthenticationOptions("https://api.example.com/resource", client_assertion_only=True) ) + class AlteredAssertionProvider(FakeIdentityProvider): + async def signer_for(self, identity: ServiceIdentity) -> AssertionSigner: + async def sign( + claims: ClientAssertionClaims, algorithms: tuple[SigningAlgorithm, ...] + ) -> str: + del algorithms + payload = claims.to_wire() + payload["jti"] = "altered" + return signed_assertion(claims, payload=payload) + + return sign + + altered = Agent( + AgentOptions( + identity_provider=AlteredAssertionProvider(), + inspect_transport=QueueTransport(response(inspect_document().to_wire())), + ) + ) + with pytest.raises(ValueError, match="does not match"): + await altered.service("api.example.com").status() + + class MalformedAssertionProvider(FakeIdentityProvider): + async def signer_for(self, identity: ServiceIdentity) -> AssertionSigner: + async def sign( + claims: ClientAssertionClaims, algorithms: tuple[SigningAlgorithm, ...] + ) -> str: + return "not-a-jwt" + + return sign + + malformed = Agent( + AgentOptions( + identity_provider=MalformedAssertionProvider(), + inspect_transport=QueueTransport(response(inspect_document().to_wire())), + ) + ) + with pytest.raises(ValueError, match="invalid assertion"): + await malformed.service("api.example.com").status() + + class WrongAlgorithmAssertionProvider(FakeIdentityProvider): + async def signer_for(self, identity: ServiceIdentity) -> AssertionSigner: + async def sign( + claims: ClientAssertionClaims, algorithms: tuple[SigningAlgorithm, ...] + ) -> str: + del algorithms + return signed_assertion(claims, algorithm="ES256") + + return sign + + wrong_algorithm = Agent( + AgentOptions( + identity_provider=WrongAlgorithmAssertionProvider(), + inspect_transport=QueueTransport(response(inspect_document().to_wire())), + ) + ) + with pytest.raises(ValueError, match="does not match"): + await wrong_algorithm.service("api.example.com").status() + unsupported = document_with( commands={"supported": ["inspect", "status"], "grant_types": ["api-key"]} ) @@ -723,6 +901,87 @@ def test_identity_rejects_untyped_signing_algorithms() -> None: "did:web:api.example.com", "https://api.example.com/", ) + with pytest.raises(ValueError, match="Agent clock"): + Agent(AgentOptions(identity_provider=FakeIdentityProvider(), clock=datetime.now)) + with pytest.raises(ValueError, match="parameter names"): + GrantOptions(parameters={"": True}) + with pytest.raises(ValueError, match="parameter names"): + RevokeOptions(parameters=cast(dict[str, object], {1: True})) + + +@pytest.mark.asyncio +async def test_revoke_uses_authoritative_service_did_for_store_deletion() -> None: + now = datetime(2026, 1, 1, tzinfo=UTC) + payload = json.dumps( + { + "api_key": "secret", + "credential_id": "foreign", + "expires_at": "2026-01-01T01:00:00Z", + "header": "X-Key", + } + ).encode() + + class ForeignListStore: + def __init__(self) -> None: + self.deleted: list[tuple[str, str]] = [] + + async def delete_credential(self, service_did: str, credential_id: str) -> None: + self.deleted.append((service_did, credential_id)) + + async def find_credential( + self, service_did: str, credential_id: str + ) -> CredentialRecord | None: + return None + + async def list_credentials(self, service_did: str) -> tuple[CredentialRecord, ...]: + return ( + CredentialRecord( + "foreign", + now + timedelta(hours=1), + "api-key", + now, + payload, + "did:web:foreign.example", + "https://foreign.example/", + ), + ) + + async def save_credential(self, credential: CredentialRecord) -> None: + pass + + store = ForeignListStore() + agent, _ = configured_agent( + QueueTransport(response(inspect_document().to_wire())), + QueueTransport(response({})), + credential_store=cast(MemoryCredentialStore, store), + ) + await agent.service("api.example.com").revoke(RevokeOptions(all_grant_types=True)) + assert store.deleted == [("did:web:api.example.com", "foreign")] + + +def test_credential_results_hide_secret_material_from_repr() -> None: + now = datetime(2026, 1, 1, tzinfo=UTC) + record = CredentialRecord( + "one", + now + timedelta(hours=1), + "api-key", + now, + b'{"api_key":"record-secret-value"}', + "did:web:api.example.com", + "https://api.example.com/", + ) + result = GrantResult( + ApiKeyGrantResponse( + api_key="grant-secret-value", + credential_id="one", + expires_at="2026-01-01T01:00:00Z", + header="X-Key", + ), + "api-key", + b'{"api_key":"grant-secret-value"}', + ) + assert "record-secret-value" not in repr(record) + assert "grant-secret-value" not in repr(result) @pytest.mark.asyncio diff --git a/tests/test_core_assertions.py b/tests/test_core_assertions.py index ef24617..f55709f 100644 --- a/tests/test_core_assertions.py +++ b/tests/test_core_assertions.py @@ -203,3 +203,8 @@ def test_unverified_decoder_rejects_non_jwt_and_non_object_parts() -> None: decode_jwt_unverified(f"{array}.{object_part}.signature") with pytest.raises(AepAssertionError): decode_jwt_unverified("invalid.invalid.signature") + with pytest.raises(AepAssertionError): + decode_jwt_unverified(f"{object_part}!!!.{object_part}.signature") + duplicate = urlsafe_b64encode(b'{"value":1,"value":2}').decode().rstrip("=") + with pytest.raises(AepAssertionError): + decode_jwt_unverified(f"{duplicate}.{object_part}.signature") diff --git a/tests/test_core_http.py b/tests/test_core_http.py index a59b9e2..94ad3a3 100644 --- a/tests/test_core_http.py +++ b/tests/test_core_http.py @@ -41,6 +41,14 @@ def test_http_values_paths_and_transport_models() -> None: assert media_type_essence("Application/AEP+JSON; charset=utf-8") == "application/aep+json" + assert media_type_essence('application/aep+json; profile="one;two"') == ("application/aep+json") + for malformed in ( + "application/aep+json;", + "application/aep+json; garbage", + "application/aep+json; charset=", + "application/aep+json\r\n", + ): + assert media_type_essence(malformed) == "" assert normalize_endpoint_base() == "/aep/" assert normalize_endpoint_base("/custom") == "/custom/" assert normalize_endpoint_base("/custom/") == "/custom/" @@ -155,32 +163,50 @@ def test_did_web_document_and_public_key_selection() -> None: ) assert did_web_document_url("did:web:example.com") == "https://example.com/.well-known/did.json" document = { + "id": did, "verificationMethod": [ {"id": "other", "publicKeyJwk": {}}, - {"id": key_id, "publicKeyJwk": {"kty": "OKP", "x": "key"}}, - ] + { + "id": key_id, + "publicKeyJwk": {"key_ops": ["verify"], "kty": "OKP", "x": "key"}, + }, + ], } key = select_did_web_public_jwk(document, did=did, key_id=key_id) - assert key == {"kty": "OKP", "x": "key"} + assert key == {"key_ops": ["verify"], "kty": "OKP", "x": "key"} key["x"] = "changed" + cast(list[str], key["key_ops"]).append("sign") methods = cast(list[dict[str, Any]], document["verificationMethod"]) assert methods[1]["publicKeyJwk"]["x"] == "key" + assert methods[1]["publicKeyJwk"]["key_ops"] == ["verify"] for invalid_did in ( "did:key:one", "did:web:", "did:web:user@example.com", "did:web:example.com%3Fquery", "did:web:example.com%3Ainvalid", + "did:web:example.com:%2Fadmin", + "did:web:example.com:%2E%2E:secret", + "did:web:example.com:", + "did:web:examplé.com", + "did:web:%C3%A9xample.com", ): with pytest.raises(ValueError): did_web_document_url(invalid_did) with pytest.raises(ValueError, match="issuer"): select_did_web_public_jwk(document, did=did, key_id="did:web:other#key") with pytest.raises(ValueError, match="No public JWK"): - select_did_web_public_jwk({}, did=did, key_id=key_id) + select_did_web_public_jwk({"id": did}, did=did, key_id=key_id) + with pytest.raises(ValueError, match="document ID"): + select_did_web_public_jwk( + {**document, "id": "did:web:other.example"}, did=did, key_id=key_id + ) with pytest.raises(ValueError, match="No public JWK"): select_did_web_public_jwk( - {"verificationMethod": [{"id": key_id, "publicKeyMultibase": "z123"}]}, + { + "id": did, + "verificationMethod": [{"id": key_id, "publicKeyMultibase": "z123"}], + }, did=did, key_id=key_id, ) @@ -191,14 +217,12 @@ def test_openapi_url_and_path_helpers() -> None: resolve_openapi_url("https://service.example/.well-known/aep", "/openapi.json") == "https://service.example/openapi.json" ) - assert ( + with pytest.raises(ValueError): resolve_openapi_url( "https://127.0.0.1/.well-known/aep", "http://127.0.0.1/openapi.json", allow_insecure_loopback=True, ) - == "http://127.0.0.1/openapi.json" - ) assert ( resolve_openapi_url( "http://127.0.0.1/.well-known/aep", @@ -222,6 +246,13 @@ def test_openapi_url_and_path_helpers() -> None: trailing_slash=OpenApiTrailingSlash.EQUIVALENT, ) assert equivalent.template == "/items/{id}" + partial = match_openapi_path( + ("/items/{id}.json",), + method="get", + path="/items/one.json", + trailing_slash=OpenApiTrailingSlash.STRICT, + ) + assert partial.template == "/items/{id}.json" for inspect_url, reference in ( ("http://service.example/.well-known/aep", "/openapi.json"), ("https://user@service.example/.well-known/aep", "/openapi.json"), @@ -252,6 +283,13 @@ def test_openapi_url_and_path_helpers() -> None: path="/one/two", trailing_slash=OpenApiTrailingSlash.STRICT, ) + with pytest.raises(ValueError, match="not documented"): + match_openapi_path( + ("/items/{id",), + method="GET", + path="/items/one", + trailing_slash=OpenApiTrailingSlash.STRICT, + ) with pytest.raises(ValueError, match="Ambiguous"): match_openapi_path( ("/items/{id}", "/items/{name}"), diff --git a/tests/test_core_models.py b/tests/test_core_models.py index 3b8b094..2c6b739 100644 --- a/tests/test_core_models.py +++ b/tests/test_core_models.py @@ -286,6 +286,8 @@ def test_claim_models_accept_registered_and_additive_values() -> None: {"contact.email": "owner.example.com"}, {"contact.mobile": "(415) 555-0100"}, {"person.birthdate": "2025-02-30"}, + {"person.birthdate": "20250101"}, + {"person.birthdate": "2025-W01-1"}, {"person.first_name": ""}, ], ) @@ -417,6 +419,30 @@ def test_assertion_problem_credential_and_metadata_models() -> None: ).realm == "example" ) + assert "access-secret-value" not in repr( + OAuthBearerGrantResponse( + access_token="access-secret-value", + credential_id="credential-1", + expires_at=expires, + token_type="Bearer", + ) + ) + assert "api-secret-value" not in repr( + ApiKeyGrantResponse( + api_key="api-secret-value", + credential_id="credential-2", + expires_at=expires, + header="X-API-Key", + ) + ) + assert "basic-secret-value" not in repr( + BasicGrantResponse( + credential_id="credential-3", + expires_at=expires, + password="basic-secret-value", + username="user", + ) + ) assert IdempotencyMetadata( idempotency_key="request-1", first_body_hash=f"sha256:{'0' * 64}", diff --git a/tests/test_service.py b/tests/test_service.py index 0ce457d..47dc1c3 100644 --- a/tests/test_service.py +++ b/tests/test_service.py @@ -147,13 +147,17 @@ def _options(operation: AssertionOperation, jti: str, *, key: str | None = None) @pytest.mark.asyncio async def test_enroll_status_and_replay_protection() -> None: + store = MemoryEnrollmentStore() service, verifier = _service( - claims=InspectClaims(required=("contact.email",), preferred=("person.first_name",)) + claims=InspectClaims(required=("contact.email",), preferred=("person.first_name",)), + enrollment_store=store, ) body = ( EnrollRequest( agent_did=AGENT_DID, - claims=ClaimValues.model_validate({"contact.email": "agent@example.com"}), + claims=ClaimValues.model_validate( + {"contact.email": "agent@example.com", "unadvertised": "ignored"} + ), idempotency_key="enroll-1", ) .model_dump_json(by_alias=True, exclude_none=True) @@ -166,6 +170,9 @@ async def test_enroll_status_and_replay_protection() -> None: assert enrolled.status == 200 assert enrolled.body is not None and enrolled.body.status is AgentStatus.ACTIVE assert verifier.contexts[0].operation is AssertionOperation.ENROLL + record = await store.find(AGENT_DID) + assert record is not None and record.claims is not None + assert record.claims.to_wire() == {"contact.email": "agent@example.com"} replay = await service.enroll(body, _options(AssertionOperation.ENROLL, "one", key="enroll-1")) assert replay.status == 401 diff --git a/tests/test_service_boundaries.py b/tests/test_service_boundaries.py index 26fcdba..92531d4 100644 --- a/tests/test_service_boundaries.py +++ b/tests/test_service_boundaries.py @@ -12,6 +12,7 @@ AgentStatus, AssertionOperation, ClientAssertionClaims, + InspectClaims, OpenApiPathMatching, OpenApiReference, OpenApiTrailingSlash, @@ -174,10 +175,10 @@ async def test_status_and_lifecycle_boundaries() -> None: absent = await empty.status(_options(AssertionOperation.STATUS, "absent")) assert absent.status == 401 - for status, code in ( - (AgentStatus.SUSPENDED, "identity_suspended"), - (AgentStatus.TERMINATED, "identity_terminated"), - (AgentStatus.UNAVAILABLE, "identity_unavailable"), + for status in ( + AgentStatus.SUSPENDED, + AgentStatus.TERMINATED, + AgentStatus.UNAVAILABLE, ): store = MemoryEnrollmentStore() await store.save(replace(_record(), status=status)) @@ -186,7 +187,19 @@ async def test_status_and_lifecycle_boundaries() -> None: b'{"agent_did":"did:web:agent.example"}', _options(AssertionOperation.ENROLL, f"enroll-{status}", key=f"key-{status}"), ) - assert result.problem is not None and result.problem.code == code + assert result.status == 200 + assert result.body is not None and result.body.status is status + + store = MemoryEnrollmentStore() + await store.save(_record()) + changed_requirements, _ = _service( + claims=InspectClaims(required=("contact.email",)), enrollment_store=store + ) + existing = await changed_requirements.enroll( + b'{"agent_did":"did:web:agent.example"}', + _options(AssertionOperation.ENROLL, "existing", key="existing"), + ) + assert existing.status == 200 with pytest.raises(ValueError, match="UTC offsets"): replace(_record(), since=NOW.replace(tzinfo=None)) @@ -345,6 +358,16 @@ async def test_assertion_and_protected_resource_boundaries() -> None: assert unsupported.response.problem is not None assert unsupported.response.problem.code == "unsupported_authentication_method" + for authorization in ("Basic dXNlcjpwYXNz", "Bearer credential"): + unsupported = await no_jwt.authenticate_protected_resource( + ProtectedResourceRequest( + headers={"Authorization": authorization}, method="GET", url=resource + ) + ) + assert unsupported.response is not None + assert unsupported.response.problem is not None + assert unsupported.response.problem.code == "unsupported_authentication_method" + unknown = await service.authenticate_protected_resource( ProtectedResourceRequest( headers={ @@ -427,6 +450,15 @@ async def test_credential_authentication_rejects_invalid_principals() -> None: @pytest.mark.asyncio async def test_custom_boundaries_are_validated() -> None: + class MismatchedEnrollmentStore(MemoryEnrollmentStore): + async def find(self, agent_did: str) -> EnrollmentRecord | None: + del agent_did + return replace(_record(), agent_did="did:web:other.example") + + mismatched, _ = _service(enrollment_store=MismatchedEnrollmentStore()) + with pytest.raises(ValueError, match="mismatched Agent DID"): + await mismatched.status(_options(AssertionOperation.STATUS, "mismatched-store")) + class EmptyIdempotencyStore: async def execute( self,