AAuth: HTTP Message Signature verification (RFC 9421 + draft-hardt-httpbis-signature-key)#2276
AAuth: HTTP Message Signature verification (RFC 9421 + draft-hardt-httpbis-signature-key)#2276christian-posta wants to merge 6 commits into
Conversation
|
@copilot review |
1644445 to
df0b8ae
Compare
|
Force-pushed with |
7f5bec8 to
f0265ec
Compare
|
PR is now ready for review. @copilot review |
There was a problem hiding this comment.
Pull request overview
This PR adds inbound AAuth (HTTP Message Signature) verification as a first-class traffic policy in agentgateway, including RFC 9421 signature verification, Signature-Key scheme support (hwk, jwks_uri, jwt), JWT-based key binding/validation, CEL exposure (aauth.*), and examples + schema wiring.
Changes:
- Introduces new workspace crates
http-message-sig(RFC 9421/9530 + Signature-Key draft) andaauth(aa-agent+jwt / aa-auth+jwt validation). - Adds
AAuthtraffic policy with config/schema + integration into gateway/route policy pipelines, request snapshot/CEL, and telemetry. - Adds examples and cross-implementation test vectors.
Reviewed changes
Copilot reviewed 46 out of 47 changed files in this pull request and generated 10 comments.
Show a summary per file
| File | Description |
|---|---|
| schema/config.md | Documents new aauth policy fields across config locations. |
| schema/config.json | Adds JSON schema definitions for LocalAAuthConfig, AAuthMode, AAuthRequiredScheme, etc. |
| examples/aauth/README.md | End-to-end walkthrough for running AAuth verification with the Go reference client. |
| examples/aauth/config.yaml | Production-shaped example config enabling aauth + authorization rule. |
| examples/aauth/config.dev.yaml | Dev walkthrough config (loopback HTTP issuers allowed). |
| crates/http-message-sig/tests/test_vectors.rs | Cross-impl tests against JSON vectors + regressions. |
| crates/http-message-sig/tests/aauth-test-vectors.json | Reference vectors consumed by Rust tests. |
| crates/http-message-sig/src/signing/verifier.rs | Signature verification implementation (RFC 9421 profile + Signature-Key). |
| crates/http-message-sig/src/signing/signer.rs | Request signing helper to generate Signature headers for tests/interop. |
| crates/http-message-sig/src/signing/signature_base.rs | Signature base construction (including raw Signature-Input preservation). |
| crates/http-message-sig/src/signing/mod.rs | Exposes signing modules and re-exports core APIs. |
| crates/http-message-sig/src/lib.rs | New crate root exports. |
| crates/http-message-sig/src/keys/mod.rs | Key module exports. |
| crates/http-message-sig/src/keys/jwk.rs | Minimal JWK representation + Ed25519 extraction + canonical JSON. |
| crates/http-message-sig/src/keys/jwk_thumbprint.rs | RFC 7638 thumbprint calculation. |
| crates/http-message-sig/src/keys/ed25519.rs | Ed25519 key encode/decode + sign/verify helpers. |
| crates/http-message-sig/src/headers/signature.rs | Signature header parse/build. |
| crates/http-message-sig/src/headers/signature_key.rs | Signature-Key parse/build (semicolon + legacy parenthesized). |
| crates/http-message-sig/src/headers/signature_input.rs | Signature-Input parse/build (preserves raw value for param ordering). |
| crates/http-message-sig/src/headers/mod.rs | Header module exports. |
| crates/http-message-sig/src/errors.rs | Error type for the crate. |
| crates/http-message-sig/src/encoding/mod.rs | Encoding module exports. |
| crates/http-message-sig/src/encoding/base64.rs | Base64/base64url helpers. |
| crates/http-message-sig/src/digest/mod.rs | Digest module exports. |
| crates/http-message-sig/src/digest/content_digest.rs | RFC 9530 Content-Digest helpers. |
| crates/http-message-sig/Cargo.toml | New crate manifest. |
| crates/agentgateway/src/types/local.rs | Adds aauth to local policy types + policy splitting. |
| crates/agentgateway/src/types/agent.rs | Adds TrafficPolicy::AAuth variant. |
| crates/agentgateway/src/types/agent_xds.rs | Adds aauth traffic policy kind name. |
| crates/agentgateway/src/telemetry/metrics.rs | Adds OutboundCallSubtype::AAuth for JWKS fetch telemetry. |
| crates/agentgateway/src/telemetry/log.rs | Logs aauth.scheme and aauth.agent from request snapshot. |
| crates/agentgateway/src/store/binds.rs | Adds aauth to RoutePolicies/GatewayPolicies + inheritance merging. |
| crates/agentgateway/src/proxy/httpproxy.rs | Applies AAuth policy before JWT/OIDC to populate claims early. |
| crates/agentgateway/src/http/mod.rs | Registers new http::aauth module. |
| crates/agentgateway/src/http/aauth.rs | Implements the AAuth policy (verification, JWKS cache, CEL claims). |
| crates/agentgateway/src/http/aauth_tests.rs | Integration/unit tests for policy behavior and cache/validation helpers. |
| crates/agentgateway/src/cel/types.rs | Exposes aauth claims in CEL executor and request snapshots. |
| crates/agentgateway/Cargo.toml | Adds dependencies on new workspace crates. |
| crates/aauth/src/tokens/validation.rs | Shared JWT decode/validate utilities + issuer URL admission rules. |
| crates/aauth/src/tokens/mod.rs | Exports token validation APIs. |
| crates/aauth/src/tokens/auth_token.rs | Validates aa-auth+jwt including aud, agent, act.sub, cnf.jwk. |
| crates/aauth/src/tokens/agent_token.rs | Validates aa-agent+jwt including required dwk and cnf.jwk. |
| crates/aauth/src/lib.rs | New crate root exports. |
| crates/aauth/src/errors.rs | Error type for token validation crate. |
| crates/aauth/Cargo.toml | New crate manifest. |
| Cargo.toml | Adds new crates to workspace + workspace dependencies. |
| Cargo.lock | Locks new dependencies (ed25519-dalek, etc.) and new crates. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| impl Debug for JwksCache { | ||
| fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { | ||
| write!(f, "JwksCache(<runtime state>)") | ||
| } | ||
| } |
| #[path = "aauth_tests.rs"] | ||
| mod tests; | ||
|
|
||
| const TRACE_POLICY_KIND: &str = "aauth"; |
| let required_components = ["@method", "@authority", "@path", "signature-key"]; | ||
| for req in required_components { | ||
| if !sig_input.components.iter().any(|c| c == req) { | ||
| return Err(Error::InvalidSignature(format!( | ||
| "missing required component: {}", | ||
| req | ||
| ))); | ||
| } | ||
| } | ||
| if get_header(headers, "content-digest").is_some() | ||
| && !sig_input.components.iter().any(|c| c == "content-digest") | ||
| { | ||
| return Err(Error::InvalidSignature( | ||
| "content-digest header present but not covered".to_string(), | ||
| )); | ||
| } | ||
| if get_header(headers, "authorization").is_some() | ||
| && !sig_input.components.iter().any(|c| c == "authorization") | ||
| { | ||
| return Err(Error::InvalidSignature( | ||
| "authorization header present but not covered".to_string(), | ||
| )); | ||
| } |
| |`binds[].listeners[].routes[].policies.apiKey.location.expression.expression`|string|CEL expression that returns the credential string. This location can extract credentials but cannot insert them.| | ||
| |`binds[].listeners[].routes[].policies.aauth`|object|Authenticate incoming requests using AAuth (HTTP Message Signing).| | ||
| |`binds[].listeners[].routes[].policies.aauth.mode`|enum|Controls whether requests must carry a valid AAuth signature.<br>Possible values: `strict`, `optional`, `permissive`.| | ||
| |`binds[].listeners[].routes[].policies.aauth.requiredScheme`|enum|Minimum acceptable signature-key scheme.<br><br>The schemes are ordered by strength: `hwk` < `jwks` < `jwt`. A request signed with a<br>stronger scheme always satisfies a weaker requirement.<br>Possible values: `hwk`, `jwks`, `jwt`.| |
| |`binds[].listeners[].policies.jwtAuth.jwtValidationOptions.requiredClaims`|[]string|Claims that must be present in the token before validation.<br>Only "exp", "nbf", "aud", "iss", "sub" are enforced; others<br>(including "iat" and "jti") are ignored.<br>Defaults to ["exp"]. Use an empty list to require no claims.| | ||
| |`binds[].listeners[].policies.aauth`|object|Authenticate incoming requests using AAuth (HTTP Message Signing).| | ||
| |`binds[].listeners[].policies.aauth.mode`|enum|Controls whether requests must carry a valid AAuth signature.<br>Possible values: `strict`, `optional`, `permissive`.| | ||
| |`binds[].listeners[].policies.aauth.requiredScheme`|enum|Minimum acceptable signature-key scheme.<br><br>The schemes are ordered by strength: `hwk` < `jwks` < `jwt`. A request signed with a<br>stronger scheme always satisfies a weaker requirement.<br>Possible values: `hwk`, `jwks`, `jwt`.| |
| |`policies[].policy.apiKey.location.expression.expression`|string|CEL expression that returns the credential string. This location can extract credentials but cannot insert them.| | ||
| |`policies[].policy.aauth`|object|Authenticate incoming requests using AAuth (HTTP Message Signing).| | ||
| |`policies[].policy.aauth.mode`|enum|Controls whether requests must carry a valid AAuth signature.<br>Possible values: `strict`, `optional`, `permissive`.| | ||
| |`policies[].policy.aauth.requiredScheme`|enum|Minimum acceptable signature-key scheme.<br><br>The schemes are ordered by strength: `hwk` < `jwks` < `jwt`. A request signed with a<br>stronger scheme always satisfies a weaker requirement.<br>Possible values: `hwk`, `jwks`, `jwt`.| |
| |`routeGroups[].routes[].policies.apiKey.location.expression.expression`|string|CEL expression that returns the credential string. This location can extract credentials but cannot insert them.| | ||
| |`routeGroups[].routes[].policies.aauth`|object|Authenticate incoming requests using AAuth (HTTP Message Signing).| | ||
| |`routeGroups[].routes[].policies.aauth.mode`|enum|Controls whether requests must carry a valid AAuth signature.<br>Possible values: `strict`, `optional`, `permissive`.| | ||
| |`routeGroups[].routes[].policies.aauth.requiredScheme`|enum|Minimum acceptable signature-key scheme.<br><br>The schemes are ordered by strength: `hwk` < `jwks` < `jwt`. A request signed with a<br>stronger scheme always satisfies a weaker requirement.<br>Possible values: `hwk`, `jwks`, `jwt`.| |
| |`llm.policies.jwtAuth.jwtValidationOptions.requiredClaims`|[]string|Claims that must be present in the token before validation.<br>Only "exp", "nbf", "aud", "iss", "sub" are enforced; others<br>(including "iat" and "jti") are ignored.<br>Defaults to ["exp"]. Use an empty list to require no claims.| | ||
| |`llm.policies.aauth`|object|Authenticate incoming requests using AAuth (HTTP Message Signing).| | ||
| |`llm.policies.aauth.mode`|enum|Controls whether requests must carry a valid AAuth signature.<br>Possible values: `strict`, `optional`, `permissive`.| | ||
| |`llm.policies.aauth.requiredScheme`|enum|Minimum acceptable signature-key scheme.<br><br>The schemes are ordered by strength: `hwk` < `jwks` < `jwt`. A request signed with a<br>stronger scheme always satisfies a weaker requirement.<br>Possible values: `hwk`, `jwks`, `jwt`.| |
| |`mcp.policies.apiKey.location.expression.expression`|string|CEL expression that returns the credential string. This location can extract credentials but cannot insert them.| | ||
| |`mcp.policies.aauth`|object|Authenticate incoming requests using AAuth (HTTP Message Signing).| | ||
| |`mcp.policies.aauth.mode`|enum|Controls whether requests must carry a valid AAuth signature.<br>Possible values: `strict`, `optional`, `permissive`.| | ||
| |`mcp.policies.aauth.requiredScheme`|enum|Minimum acceptable signature-key scheme.<br><br>The schemes are ordered by strength: `hwk` < `jwks` < `jwt`. A request signed with a<br>stronger scheme always satisfies a weaker requirement.<br>Possible values: `hwk`, `jwks`, `jwt`.| |
| "default": "strict" | ||
| }, | ||
| "requiredScheme": { | ||
| "description": "Minimum acceptable signature-key scheme.\n\nThe schemes are ordered by strength: `hwk` < `jwks` < `jwt`. A request signed with a\nstronger scheme always satisfies a weaker requirement.", |
Adds the http-message-sig crate, a self-contained implementation of RFC 9421 (HTTP Message Signatures), RFC 9530 (Content-Digest), and draft-hardt-httpbis-signature-key (the Signature-Key header with hwk, jwks_uri, and jwt schemes). Ed25519 only for the signing side; the JWK type accepts OKP, RSA, and EC for use by higher layers that consume the JWKS of an arbitrary issuer. Key design points: - The verifier rebuilds the @signature-params line byte-for-byte from the raw Signature-Input header via build_signature_base_raw. RFC 9421 §2.5 requires the signature base be reproduced exactly; reconstructing from parsed fields silently reorders parameters and breaks Ed25519 verification against signers that emit them in a different order than ours. - The signer's @authority includes the URL port when present, matching the verifier's authority construction so sign+verify agree on https://example.com:8443/... - Unsupported signature-key schemes are rejected before key resolution to avoid masking the underlying rejection with a key-fetch error. - created is required per RFC 9421 §4.1; absence is rejected rather than silently defaulting to 0. Tested against cross-implementation vectors (lifted from the Go reference at https://github.com/christian-posta/extauth-aauth-resource) plus 39 unit tests covering signing/verification round-trips, parser edge cases, and content-digest computation. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Signed-off-by: Christian Posta <christian.posta@gmail.com>
Adds the aauth crate built on top of http-message-sig. Validates aa-agent+jwt and aa-auth+jwt tokens per draft-hardt-oauth-aauth- protocol: extracts and verifies the JWT, checks required claims, and returns the embedded cnf.jwk for HTTP signature verification. Key behaviors: - The issuer URL check (is_acceptable_jwt_issuer_url) enforces host-only HTTPS for production. The dev-only http branch is loopback-restricted (localhost, 127.0.0.0/8, ::1) so the dev flag cannot be exploited to point at an external HTTP host. - get_scopes() returns None when the scope claim is empty or whitespace-only, so the 'must have at least one of sub or scope' guard in validate_auth_token is not bypassed by Some(vec![]). - validate_agent_token strictly requires aud when the caller passes Some(expected_audience) (mirroring auth-token semantics); the gateway passes None for agent tokens because they are identity assertions, not resource-scoped grants. - jti is captured when present but not required — agentgateway does not maintain a replay cache, and reference implementations omit it. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Signed-off-by: Christian Posta <christian.posta@gmail.com>
Adds the AAuth policy as a route- and gateway-level traffic policy. Policy behavior: - Three signature-key schemes accepted: hwk (pseudonymous, key inline), jwks_uri (identified, key discovered via well-known doc + JWKS), and jwt (authorized, key bound to an aa-agent+jwt or aa-auth+jwt via RFC 7800 cnf.jwk). - Modes Strict / Optional / Permissive control how missing or invalid signatures are handled. - requiredScheme enforces a minimum scheme strength (hwk < jwks_uri < jwt); stronger schemes always satisfy weaker requirements. Insufficient-scheme rejections include an aauth challenge response header. - Verified claims (scheme, agent, agent_delegate, user, scope, thumbprint, jwt_claims) are injected into request extensions and exposed to CEL as aauth.* for downstream authorization rules. JWKS cache: - Keyed by (issuer_id, dwk) so a single issuer can legitimately publish multiple discovery documents (e.g. aauth-agent.json and aauth-issuer.json) without one's keys aliasing the other's. - Single-flight via per-key tokio::sync::Mutex<()> so N concurrent misses on the same issuer fan out to one network fetch, not N. - Lazy eviction on stale-read upgrades to a write lock and removes expired entries so the map doesn't grow unbounded across rotating issuers. Network-egress safety: - metadata.jwks_uri from the well-known doc is validated to be HTTPS before fetching; a compromised CDN or issuer can otherwise inject http://attacker/jwks and downgrade transport for signing keys. - Under allowInsecureHttpIssuer, plaintext JWKS fetches are accepted only when the host is a loopback address. Tests: 20 policy-level + 11 cross-impl test vectors. End-to-end verified against the Go reference at github.com/christian-posta/extauth-aauth- resource (sign-request CLI for hwk, agent-client for jwks_uri and aa-agent+jwt, tamper-rejection cases, mode behaviors). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Signed-off-by: Christian Posta <christian.posta@gmail.com>
Adds an example config under examples/aauth/ pairing with the Go reference's agent-client for end-to-end smoke tests, and the auto-regenerated schema/config.json and schema/config.md output covering the new policy fields. The Mode and RequiredScheme enums are renamed to AAuthMode and AAuthRequiredScheme in the generated schema (via schemars(rename)) so the JSON Schema $defs use descriptive names instead of schemars' auto-deduplicated Mode2 suffix. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Signed-off-by: Christian Posta <christian.posta@gmail.com>
Updates examples/aauth/README.md with a step-by-step demo against the agent-client binary from the Go reference repo at github.com/christian-posta/extauth-aauth-resource. The walkthrough exercises the strongest scheme (aa-agent+jwt), which is the path most likely to surface gateway bugs because it involves: - JWT signature verification against the issuer's JWKS - cnf.jwk extraction (RFC 7800 proof-of-possession) - HTTP message-signature verification with the cnf-bound key Also adds examples/aauth/config.dev.yaml — a paired loopback-only walkthrough config (allowInsecureHttpIssuer: true, no identity pin) so users can clone, run, and verify without first writing their own config. The production-shaped config.yaml is kept unchanged. Verified end-to-end on the rebased branch: - unsigned -> 401 missing signature headers - aa-agent+jwt -> 200 + echo body - tampered JWT -> 401 invalid_auth_token - config.yaml -> validates - config.dev.yaml -> validates (via cargo test -p agentgateway --test validate_examples) Also fixes the README's incorrect attribution of the reference repo as 'Python' — it's Go. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Signed-off-by: Christian Posta <christian.posta@gmail.com>
605342d to
400393f
Compare
Signed-off-by: Christian Posta <christian.posta@gmail.com>
6676820 to
8b30b3c
Compare
|
Think it's ready for review @howardjohn @npolshakova |
Summary
Adds AAuth (HTTP Message Signing) verification to agentgateway as a route- and gateway-level
traffic policy.
This is the verifier half of the
AAuth protocol draft —
inbound requests carrying RFC 9421 HTTP Message Signatures are verified against keys conveyed
via the Signature-Key draft.
Three schemes are supported:
hwkjwks_uri{iss}/.well-known/{dwk}→jwks_uri→ JWKSjwtcnf.jwkVerified claims (scheme, agent, agent_delegate, user, scope, thumbprint, jwt_claims) land in
request extensions and are exposed to CEL as
aauth.*for downstream authorization.Layout
crates/http-message-sig/— new crate implementing RFC 9421 + RFC 9530 + the signature-keydraft. Ed25519-only signing; JWK type accepts OKP/RSA/EC for use by higher layers.
crates/aauth/— new crate validating aa-agent+jwt and aa-auth+jwt tokens; built onhttp-message-sig.
crates/agentgateway/src/http/aauth.rs(+ tests) — the policy module that ties everythingtogether; implements
RequestPolicyTrait.TrafficPolicy::AAuthvariant,RoutePolicies/GatewayPoliciesfields,OutboundCallSubtype::AAuthfor JWKS-fetch telemetry, AAuthClaims plumbed into the CELExecutor/RequestSnapshot,
aauth.scheme/aauth.agentlog fields.examples/aauth/— minimal config + README that pairs with the Go reference'sagent-clientfor end-to-end smoke tests.
Notable design choices
(id, dwk)so a single issuer publishing bothaauth-agent.jsonandaauth-issuer.jsondoesn't alias the two key sets.tokio::sync::Mutex<()>) so N concurrentcold requests fan out to one network fetch.
metadata.jwks_uriis HTTPS-only; underallowInsecureHttpIssuer, plaintext isloopback-only (
localhost,127.0.0.0/8,::1). A compromised metadata document canotherwise inject
http://attacker/jwksand downgrade transport for the actual signing keys.createdis required per RFC 9421 §4.1 — the parser rejects its absence rather thansilently defaulting to 0, which would render the freshness check meaningless on misconfigured
deployments.
@authorityincludes port on both sign and verify sohttps://example.com:8443/...round-trips correctly.
scheme=jwksis rejected; only the currentjwks_uriis accepted.Testing
http-message-sig+ 11 cross-impl test vectors (lifted from the Goreference's vectors file).
aauth.agentgateway::http::aauth::testscovering modes, schemeordering, tamper rejection, deserialization, cache behaviors, jwks_uri validation.
https://github.com/christian-posta/extauth-aauth-resource:
sign-requestCLI for hwk,agent-clientfor jwks_uri and aa-agent+jwt, tamper cases, mode behaviors.Limitations / follow-ups
Content-Digestbody re-verification is not performed inside the gateway. The signeddigest header is verified (signature covers it), but the request body itself is not
re-hashed. Backends that care about body integrity should re-verify, or sit behind a
bufferpolicy. Worth a separate PR to integrate with the existing buffer infrastructure.apply_innerclones the entireHeaderMapinto aHashMap<String, String>because the verifier API takes the latter. Real cost butchanging
verify_signature's signature ripples through the crate's public API; deferred.http_message_sig::keys::jwk::JWK(custom) andjsonwebtoken::jwk::Jwk(used byhttp/jwt.rs). Consolidation is a larger reshape anddeserves its own PR.
Test plan
cargo test -p http-message-sig -p aauth(60 tests)cargo test -p agentgateway --lib http::aauth(20 tests)cargo clippy --all-targets -- -D warnings(clean)make generate-schema check-clean-repo(clean)🤖 Generated with Claude Code