Authentication

a2a-rust ships first-party helpers for both sides of authentication:

  • Server — reject unauthenticated requests with an interceptor that guards every transport (JSON-RPC, REST, gRPC, WebSocket), because they all populate the same CallContext headers.
  • Client — acquire and attach bearer tokens, including a full OAuth 2.0 client-credentials flow with caching and refresh.

All of it is built on the crate's existing ring + hyper stack — no OAuth-ecosystem dependencies (see ADR 0010).

Server: guarding an agent

API key / static bearer token

For a fixed set of accepted credentials, ApiKeyAuthInterceptor and BearerTokenAuthInterceptor compare in constant time and need no feature flag:

fn card() -> a2a_protocol_types::agent_card::AgentCard {
    use a2a_protocol_types::agent_card::{AgentCapabilities, AgentCard, AgentInterface, AgentSkill};
    AgentCard::new("my-agent", "1.0.0", AgentInterface::jsonrpc("http://localhost:3000"))
        .with_description("An example agent")
        .with_skill(AgentSkill::new("echo", "Echo", "Echoes text").with_tags(["echo"]))
        .with_capabilities(AgentCapabilities::none().with_streaming(true))
}
use a2a_protocol_sdk::prelude::agent_executor;
struct MyAgent;
agent_executor!(MyAgent, |_ctx, _queue| async { Ok(()) });
fn main() -> Result<(), Box<dyn std::error::Error>> {
let my_executor = MyAgent;
use a2a_protocol_server::{BearerTokenAuthInterceptor, RequestHandlerBuilder};
use a2a_protocol_types::security::{
    HttpAuthSecurityScheme, SecurityRequirement, SecurityScheme, StringList,
};
use std::collections::HashMap;

// Spec §13.3: the public card declares the scheme the interceptor checks.
let card = card()
    .with_security_schemes(HashMap::from([(
        "bearer".to_owned(),
        SecurityScheme::Http(HttpAuthSecurityScheme {
            scheme: "Bearer".to_owned(),
            description: None,
            bearer_format: None,
        }),
    )]))
    .with_security_requirements(vec![SecurityRequirement {
        schemes: HashMap::from([("bearer".to_owned(), StringList { list: vec![] })]),
    }]);

let handler = RequestHandlerBuilder::new(my_executor)
    .with_agent_card(card)
    .with_interceptor(BearerTokenAuthInterceptor::new([
        "service-token-1",
        "service-token-2",
    ]))
    .build()?;
Ok(())
}

ApiKeyAuthInterceptor::new([...]) reads x-api-key by default; change the header with .with_header("X-Company-Key").

new authenticates without naming the caller, so the rate limiter keys every valid caller by its address (or, behind trusted proxies, the forwarded client address), not by who it is. To give each credential an identity (CallContext::caller_identity), build the interceptor with BearerTokenAuthInterceptor::with_labelled_tokens([(token, "caller-a"), …]) or ApiKeyAuthInterceptor::with_labelled_keys(…); the label, not the secret, is what reaches rate limiting, logs and metrics. The identity is the scheme and the label, bearer:caller-a or api-key:caller-a, and a JWT's is jwt:<issuer>:<sub> with the issuer percent-encoded (% as %25, : as %3A; a token with no iss has an empty issuer), so a token from https://idp.example for alice is jwt:https%3A//idp.example:alice. Build every one with a2a_protocol_server::auth::caller_id (bearer, api_key, jwt, mtls) rather than by hand, for an approver allowlist or anything else that compares against one. A bare name such as alice in an approver allowlist matches nobody.

An identity also scopes tasks (spec §13.1): with an authenticating interceptor, each identified caller gets a partition of its own, and another caller's task is TaskNotFound. An unlabelled bearer token or API key is the exception: it authenticates but names nobody, so it keeps the tenant's shared records. A JWT without a sub claim does not: it is refused with PermissionDenied (403), because the tenant's shared records are never handed to an anonymous token. Make the issuer put a sub in the token, or call share_tasks_across_callers().

Isolation needs a task store and a push-config store that partition by tenant. Left unset they default to the tenant-aware in-memory stores, whose capacity (with_task_store_config, 10,000 tasks by default) then applies per caller. A store you supply must be a TenantAware… store (or a custom one whose isolates_tenants() is true); otherwise build() refuses it. To let identified callers share, call RequestHandlerBuilder::share_tasks_across_callers(), which keeps one set of records per tenant; see Multi-tenancy.

Rotating and revoking (spec §13.4: credentials SHOULD be rotated periodically, and agents SHOULD have a way to revoke them). Both interceptors hand out a CredentialSet from credentials(), a handle you keep after giving the interceptor to the builder. What you add or revoke applies from the next request on, without a restart:

fn main() -> Result<(), Box<dyn std::error::Error>> {
use a2a_protocol_server::BearerTokenAuthInterceptor;

let auth = BearerTokenAuthInterceptor::with_labelled_tokens([("token-2026-09", "billing")]);
let tokens = auth.credentials(); // keep this; pass `auth` to `with_interceptor`

// Rotation: accept the new token, move the caller over, retire the old one.
tokens.add_labelled("token-2026-10", "billing");
assert!(tokens.revoke("token-2026-09"));
// Revocation of a leaked token is the same call; its next request gets 401.
Ok(())
}

A JWT is revoked by its exp, or by removing its signing key from the JWKS. A remote set is cached for an hour by default (JwtAuthInterceptor::with_jwks_ttl) and refetched when the cache expires or a token names a kid it does not hold, so tokens signed by a removed key verify until then; a static Jwks changes only by building a new interceptor. JwtAuthInterceptor keeps no list of revoked token ids.

JWT (HS256 / RS256 / ES256)

Enable the auth-jwt feature. JwtAuthInterceptor verifies the token's signature, then its claims: exp must be present and in the future (allow_missing_exp() lifts that), and nbf is honoured when present, both with 60 s of clock-skew leeway (with_leeway). iss and aud are checked only when you set them (with_issuer, with_audience); a validator with neither accepts any correctly signed, unexpired token, so set both. Keys come from a static Jwks, a shared HS256 secret, or a remote JWKS endpoint.

a2a-protocol-server = { version = "0.15", features = ["auth-jwt", "tls-rustls"] }

tls-rustls is needed to fetch JWKS or OIDC discovery documents over https://; without it the fetch is plaintext-only. Static keys and HS256 need only auth-jwt.

Validate tokens from an OIDC issuer (discovers the issuer's jwks_uri at construction, fetches and caches the set on the first request, and refetches it on key rotation):

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
use a2a_protocol_server::auth::jwt::{JwtAuthInterceptor, JwtValidator};

let validator = JwtValidator::new()
    .with_issuer("https://login.example.com")
    .with_audience("my-a2a-agent");

let interceptor =
    JwtAuthInterceptor::from_oidc_issuer("https://login.example.com", validator).await?;
Ok(())
}

A JWKS URL you already know — JwtAuthInterceptor::from_jwks_url takes the validator and the URL; from_jwks_url_with_tls_config (tls-rustls) serves an identity provider behind a private CA. Nothing is fetched at construction; the first request fetches the set, and with_jwks_ttl changes how long it is cached (one hour by default). A token naming an unknown kid forces one refetch. A fetch is bounded to 30 s and 256 KiB; if it fails, or the set holds no usable key, the call is refused with a server error (not 401), because no verdict about the token could be reached. Only RSA keys and P-256 EC keys are loaded, and an RS256 key must be 2048 to 8192 bits; entries with "use": "enc" or another key type are skipped.

Static keys — supply a Jwks directly (from a JWKS JSON document or key by key), or a shared secret for HS256:

fn main() -> Result<(), Box<dyn std::error::Error>> {
let jwks_bytes: &[u8] = br#"{"keys": []}"#;
use a2a_protocol_server::auth::jwt::{Jwks, JwtAuthInterceptor, JwtValidator};

// RS256/ES256 from a JWKS document:
let jwks = Jwks::from_json(jwks_bytes)?;
let interceptor = JwtAuthInterceptor::new(
    JwtValidator::new().with_issuer("https://issuer").with_audience("agent"),
    jwks,
);

// HS256 shared secret (no JWKS). At least 32 bytes (RFC 7518 §3.2);
// `try_with_hs256_secret` refuses a shorter one here, at startup.
let secret = b"0123456789abcdef0123456789abcdef".to_vec(); // load yours from a secret store
let interceptor = JwtAuthInterceptor::new(
    JwtValidator::new().try_with_hs256_secret(secret)?,
    Jwks::new(),
);
Ok(())
}

Mutual TLS: the client certificate names the caller

With TLS on the listener and a rustls configuration whose client verifier trusts your CAs (rustls::server::WebPkiClientVerifier, passed to Server::with_tls or WebSocketDispatcher::with_tls; for gRPC, ServerTlsConfig::client_ca_root), the certificate a client presents reaches the interceptors as CallContext::peer_certificate, the DER of its leaf. MutualTlsAuthInterceptor (tls-rustls or grpc-tls) names the caller from it and refuses a call without one (401, WWW-Authenticate: MutualTLS; gRPC UNAUTHENTICATED):

fn card() -> a2a_protocol_types::agent_card::AgentCard {
    use a2a_protocol_types::agent_card::{AgentCard, AgentInterface, AgentSkill};
    AgentCard::new("my-agent", "1.0.0", AgentInterface::jsonrpc("http://localhost:3000"))
        .with_description("An example agent")
        .with_skill(AgentSkill::new("echo", "Echo", "Echoes text").with_tags(["echo"]))
}
use a2a_protocol_server::auth::MutualTlsAuthInterceptor;
use a2a_protocol_types::security::{
    MutualTlsSecurityScheme, SecurityRequirement, SecurityScheme, StringList,
};
use std::collections::HashMap;
use a2a_protocol_sdk::prelude::*;
struct MyAgent;
agent_executor!(MyAgent, |_ctx, _queue| async { Ok(()) });
fn main() -> Result<(), Box<dyn std::error::Error>> {

// `mtls:sha256:<fingerprint>`: exact, needs no parser, changes on renewal.
let by_fingerprint = MutualTlsAuthInterceptor::new();

// `mtls:<name>` from your own mapping; `None` refuses the certificate. To
// name callers by subject, parse `der` with the X.509 crate you trust.
let by_name = MutualTlsAuthInterceptor::with_names(|der| {
    (der.len() > 0).then(|| "billing-agent".to_owned())
});
let _ = by_fingerprint;

// Spec §13.3: the public card declares the scheme the interceptor checks.
let card = card()
    .with_security_schemes(HashMap::from([(
        "mtls".to_owned(),
        SecurityScheme::MutualTls(MutualTlsSecurityScheme { description: None }),
    )]))
    .with_security_requirements(vec![SecurityRequirement {
        schemes: HashMap::from([("mtls".to_owned(), StringList { list: vec![] })]),
    }]);
let handler = RequestHandlerBuilder::new(MyAgent)
    .with_agent_card(card)
    .with_interceptor(by_name)
    .build()?;
let _ = handler;
Ok(())
}

The handshake verifies the certificate; the interceptor only names it. A verifier that also admits clients without one (allow_unauthenticated(), client_auth_optional(true)) leaves the refusal to the interceptor, so the same listener can serve the agent card to anyone. Behind a proxy that terminates TLS, the listener sees the proxy's handshake, not the client's, and there is no client certificate to read.

Security properties

  • Only HS256/RS256/ES256 are accepted. alg: none and any unlisted algorithm are rejected. HS256 is only ever verified against a configured secret — never a JWKS public key — so the RS256→HS256 algorithm-confusion downgrade is structurally impossible.
  • HS256 secrets are at least 32 bytes. RFC 7518 §3.2 requires an HS256 key at least as long as the hash output. try_with_hs256_secret returns an error for a shorter one; with_hs256_secret fails closed instead — it logs an error and rejects every HS256 token — so a short secret can never be the thing standing between a guesser and a valid token.
  • Rejections are generic. A missing, malformed, expired, or wrong token all produce the same error, so the response can't be used as an oracle.
  • Constant-time comparison for API keys and static bearer tokens.

The card and the interceptors must agree

RequestHandlerBuilder::build compares the authentication the agent card declares with the interceptors that enforce it (since 0.15):

  • A card whose securityRequirements (or a skill's) ask for a credential is refused when no registered interceptor authenticates, because every request would be served without it (spec §3.3.2, §7.4). An empty requirement {} makes authentication optional, as in OpenAPI, and asks for nothing. When a gateway, mesh or sidecar in front of the process checks the credential, say so with authenticated_upstream().
  • §13.3 requires requests to be authenticated "using one of the schemes declared in the public AgentCard.securitySchemes". So, since 0.15.0, an authenticating interceptor behind a public card that declares no scheme is refused, custom ones included: a client reading the card could not know what to present. A scheme the extended card declares must be declared, the same, in the public card too.
  • An authenticating interceptor that checks none of the schemes the public card declares is refused: a client following the card could never pass it. BearerTokenAuthInterceptor and JwtAuthInterceptor check the HTTP bearer scheme and OAuth 2.0 or OpenID Connect schemes (whose access tokens are bearer tokens); ApiKeyAuthInterceptor checks an apiKeySecurityScheme in the header it reads, compared without regard to case; MutualTlsAuthInterceptor checks a mutual-TLS scheme (mtlsSecurityScheme) and no other. A custom interceptor answers through ServerInterceptor::checks_scheme; one that does not (the default None) is trusted to check one of the declared schemes, since only its author knows what it checks.

Throttling guessed credentials

Spec §13.4 asks agents to rate-limit against brute force. RateLimitInterceptor cannot do it: registered after the authenticator, it never sees a refused credential; registered before, it cannot tell one unidentified caller from another. AuthFailureThrottle counts, per peer, the calls an authenticator refused as unauthenticated, and once a peer reaches max_failures in a window it refuses that peer's calls as rate-limited until the window ends, the right credential included, so a guesser cannot tell a hit from a miss. Register it before the authenticator:

fn card() -> a2a_protocol_types::agent_card::AgentCard {
    use a2a_protocol_types::agent_card::{AgentCard, AgentInterface, AgentSkill};
    AgentCard::new("my-agent", "1.0.0", AgentInterface::jsonrpc("http://localhost:3000"))
        .with_description("An example agent")
        .with_skill(AgentSkill::new("echo", "Echo", "Echoes text").with_tags(["echo"]))
        .with_security_schemes(std::collections::HashMap::from([(
            "bearer".to_owned(),
            a2a_protocol_types::security::SecurityScheme::Http(
                a2a_protocol_types::security::HttpAuthSecurityScheme {
                    scheme: "Bearer".to_owned(),
                    description: None,
                    bearer_format: None,
                },
            ),
        )]))
}
use a2a_protocol_sdk::prelude::agent_executor;
struct MyAgent;
agent_executor!(MyAgent, |_ctx, _queue| async { Ok(()) });
fn main() -> Result<(), Box<dyn std::error::Error>> {
use a2a_protocol_server::{
    AuthFailureThrottle, AuthFailureThrottleConfig, BearerTokenAuthInterceptor,
    RequestHandlerBuilder,
};

let throttle = AuthFailureThrottle::new(
    AuthFailureThrottleConfig::default()
        .with_max_failures(10) // refused credentials per peer per window
        .with_window_secs(300),
)?;
let handler = RequestHandlerBuilder::new(MyAgent)
    .with_agent_card(card())
    .with_interceptor(throttle)
    .with_interceptor(BearerTokenAuthInterceptor::new(["service-token-1"]))
    .build()?;
Ok(())
}

A throttled call answers 429 with Retry-After on HTTP+JSON, 200 with a -32603 body on JSON-RPC, and RESOURCE_EXHAUSTED on gRPC; every binding carries a RetryInfo detail giving the seconds until the window ends.

A peer is the IP address of the connection (CallContext::peer_addr), which every binding records when the SDK serves it. The axum router has it only when served with into_make_service_with_connect_info::<SocketAddr>(). Behind reverse proxies, set with_trusted_proxy_hops to the number of proxies so the client address they append to x-forwarded-for is counted instead. A call with no known address is not throttled.

What to weigh:

  • Everyone behind one address (a NAT, an untrusted proxy) shares a count, so a peer that keeps failing locks the others there out for the window.
  • A failure is counted when its call ends, so calls already in flight when a peer reaches the limit are still checked.
  • At most max_peers peers are tracked (10,000 by default). When the table is full, peers whose window has passed are dropped, and a new peer that still does not fit is not counted.

Error mapping (important)

A refused credential answers with each binding's own status (since 0.14.0; ADR 0014):

BindingNo usable credentialCredential not permitted
HTTP+JSON401 + WWW-Authenticate403
JSON-RPC over HTTP401 + WWW-Authenticate, body -32600403, body -32600
gRPCUNAUTHENTICATEDPERMISSION_DENIED
WebSocketbody -32600 only (see below)body -32600 only

JSON-RPC batches answer 200, since one response answers many calls; each refused entry's body is -32600.

The built-in interceptors send Bearer realm="a2a" (bearer and JWT) or ApiKey header="x-api-key" as the challenge, and never say why a credential was refused. A client that refreshes on 401 — this SDK's BearerAuthInterceptor does — recovers from a revoked or rotated token on its next call. A custom interceptor gets the same statuses by returning A2aError::unauthenticated(message, challenge) or A2aError::permission_denied(message).

WebSocket runs interceptors per message after the connection is upgraded, so there is no HTTP status to send; authenticate at your gateway or reverse proxy if WebSocket clients need a 401.

Through 0.13 every refusal answered 400 / INVALID_ARGUMENT; see Behaviour Changes in the changelog.

Authorization: scopes and per-task checks

Spec §13.1 requires an authorization check on every operation, and §3.3.2 a 403 when the authenticated caller lacks a permission, naming the missing scope where that leaks nothing. Two pieces help.

A scope every call needs. JwtValidator::require_scope refuses a token that verifies but does not grant the scope in its scope claim (a space-separated string, or an array). The answer is 403 with the RFC 6750 challenge Bearer error="insufficient_scope", scope="tasks:write", naming only the scopes the token lacks. A token that fails any other check gets 401 before its scopes are looked at.

fn main() -> Result<(), Box<dyn std::error::Error>> {
use a2a_protocol_server::auth::jwt::{Jwks, JwtAuthInterceptor, JwtValidator};

let validator = JwtValidator::new()
    .with_issuer("https://issuer")
    .with_audience("agent")
    .require_scope("a2a:invoke");
let interceptor = JwtAuthInterceptor::new(validator, Jwks::new());
Ok(())
}

A decision per operation or per task. An interceptor sees the method (CallContext::method), who the caller is (caller_identity, set by the authenticating interceptor before it) and the task the request names (task_id: the id of GetTask, CancelTask and SubscribeToTask, the taskId of a push-config call or of a message continuing a task). To refuse, return A2aError::permission_denied (403), or A2aError::insufficient_scope(message, scopes) (403 with the challenge naming the scopes) when a scope would let the caller in:

#![allow(unused)]
fn main() {
use std::future::Future;
use std::pin::Pin;

use a2a_protocol_server::{CallContext, ServerInterceptor};
use a2a_protocol_types::error::{A2aError, A2aResult};

/// Only callers on the task's allow-list may touch it.
struct TaskAcl;

impl TaskAcl {
    fn may(&self, caller: Option<&str>, task: &str) -> bool {
        // Look the pair up in your own records; refuse what is not listed.
        caller.is_some() && !task.is_empty()
    }
}

impl ServerInterceptor for TaskAcl {
    fn before<'a>(
        &'a self,
        ctx: &'a CallContext,
    ) -> Pin<Box<dyn Future<Output = A2aResult<()>> + Send + 'a>> {
        Box::pin(async move {
            match ctx.task_id() {
                Some(task) if !self.may(ctx.caller_identity(), task) => {
                    Err(A2aError::permission_denied("not permitted"))
                }
                _ => Ok(()),
            }
        })
    }

    fn after<'a>(
        &'a self,
        _ctx: &'a CallContext,
    ) -> Pin<Box<dyn Future<Output = A2aResult<()>> + Send + 'a>> {
        Box::pin(async { Ok(()) })
    }
}
}

Register it after the authenticating interceptor, so the identity is set when it runs. task_id is what the caller sent, before the handler looks the task up, so decide from your own records and not from whether the task exists: an allow-list that refuses every id it does not list answers 403 for an unknown task and a forbidden one alike, and reveals nothing (spec §3.3.2). With caller isolation on (the default once callers are identified) another caller's task is already TaskNotFound.

Client: acquiring and attaching tokens

A token you already have

fn main() -> Result<(), Box<dyn std::error::Error>> {
use std::sync::Arc;
use a2a_protocol_client::{BearerAuthInterceptor, ClientBuilder, StaticTokenProvider};

let provider = Arc::new(StaticTokenProvider::new("my-long-lived-token"));
let client = ClientBuilder::new("https://agent.example.com")
    .with_interceptor(BearerAuthInterceptor::new(provider))
    .build()?;
Ok(())
}

BearerAuthInterceptor asks its provider for a token before every request, so a provider that refreshes keeps a long-lived client authenticated across token rotations. When the agent answers 401 (surfaced as ClientError::UnexpectedStatus { status: 401 } over JSON-RPC, REST and gRPC, and for a WebSocket handshake refused with 401; a refusal inside an open WebSocket connection is a -32600 body and does not trigger this), the interceptor calls TokenProvider::invalidate with the token it sent. The call that got the 401 still fails, but the next one fetches a new token instead of resending the refused one until it expires. A custom TokenProvider gets this only if it overrides invalidate; the default does nothing.

Plaintext. Spec §13.4 says credentials SHOULD travel only over encrypted connections. A client whose endpoint is http:// or ws:// on a host other than loopback logs a warning, once, the first time a call carries a credential: an Authorization, Proxy-Authorization or Cookie header, or the header or query parameter an AuthInterceptor::api_key writes. ClientBuilder::require_https_credentials() refuses such a call instead, before sending it, with ClientError::InsecureTransport naming the header and the endpoint's origin. http://localhost and other loopback addresses are allowed either way.

Declared schemes. Spec §3.1.11 says the client MUST authenticate with one of the schemes the agent card declares. A client built with ClientBuilder::from_card checks each call, after its interceptors run, that it meets at least one of the card's securityRequirements: Authorization with the declared HTTP scheme (compared without case; Bearer for OAuth 2.0 and OpenID Connect), or the header, query parameter or cookie an API-key scheme names, whichever interceptor wrote it. A mutual-TLS scheme counts as met, since the certificate is not in the request. By default a mismatch is sent and logged once per client; .with_credential_preflight(CredentialPreflight::Refuse) refuses it before sending, with ClientError::InvalidAuthScheme naming the card's schemes and the request's header, cookie and parameter names, never their values, and CredentialPreflight::Off turns the check off. A client built for a bare endpoint has no card and checks nothing.

OAuth 2.0 client credentials

OAuth2ClientCredentials runs the RFC 6749 §4.4 grant, caches the token, refreshes it shortly before expiry, and collapses concurrent refreshes into a single request:

fn main() -> Result<(), Box<dyn std::error::Error>> {
use std::sync::Arc;
use a2a_protocol_client::{BearerAuthInterceptor, ClientBuilder, OAuth2ClientCredentials};

let provider = Arc::new(
    OAuth2ClientCredentials::new(
        "https://auth.example.com/oauth/token",
        "my-client-id",
        "my-client-secret",
    )
    .with_scopes(["tasks:read", "tasks:write"]),
);

let client = ClientBuilder::new("https://agent.example.com")
    .with_interceptor(BearerAuthInterceptor::new(provider))
    .build()?;
Ok(())
}

From the agent card — an agent that advertises an OAuth2 client-credentials scheme carries its token endpoint:

#![allow(unused)]
fn main() {
use a2a_protocol_sdk::prelude::*;
fn f(card: AgentCard) -> Result<(), Box<dyn std::error::Error>> {
let provider = OAuth2ClientCredentials::from_agent_card(
    &card, "my-oauth-scheme", "client-id", "client-secret",
)?;
Ok(())
}
}

From an OIDC issuer — discovers the token_endpoint:

use a2a_protocol_sdk::prelude::*;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let provider = OAuth2ClientCredentials::from_oidc_issuer(
    "https://login.example.com", "client-id", "client-secret",
).await?;
Ok(())
}

When the token endpoint fails, every caller waiting on that refresh gets the same error from the one request: a dead endpoint costs them one with_request_timeout together, not one each in turn. The failure is then remembered for with_failure_backoff (default 1 s; Duration::ZERO turns it off), so a tight loop makes at most one attempt per backoff. A caller cancelled mid-refresh does not strand the others; the next one starts a new attempt.

The client secret is never logged, never echoed in an error, and redacted from Debug. Basic client authentication is the default; switch to form-body credentials with .with_auth_style(TokenEndpointAuthStyle::Post).

Delegating for a principal: RFC 8693 token exchange

An agent serving Alice that delegates to another agent should send neither its own credential (it names only the agent) nor Alice's (the next agent was never meant to accept it). It exchanges the token it was called with at the identity provider for one issued for the next agent: sub is still Alice, and act names the agent acting for her (ADR 0021).

#![allow(unused)]
fn main() {
use a2a_protocol_client::token_provider::{ExchangeRequest, TokenExchange};
async fn run(alices_token: String) -> Result<(), Box<dyn std::error::Error>> {
let idp = TokenExchange::new("https://idp.example/oauth/token", "planner-agent", "secret");
let delegated = idp
    .exchange(
        &ExchangeRequest::new(alices_token)
            .with_audience("https://worker.example")
            .with_scopes(["tasks:read"]),
    )
    .await?;
let _ = delegated;
Ok(())
}
}

Send the call with delegated.access_token. The exchanged token belongs to one principal, so give it to that call alone, for example on a client built with StaticTokenProvider, not to a client every principal shares. On the receiving agent, JwtAuthInterceptor makes Alice the caller identity and lists the acting agents in CallContext::actors, nearest first. With audit, every call record names both. The identity provider decides what may be delegated, to whom, and with which scopes.

Interactive flows

Authorization-code (browser redirect) and device-code grants are interactive and out of scope for agent-to-agent auth. Implement the TokenProvider trait with your own flow and pass it to BearerAuthInterceptor.