Skip to content

Commit abb8d81

Browse files
authored
feat(remoteagent): apply per-request auth to A2A via Config.Auth (#1150)
* feat(remoteagent): apply per-request auth to A2A via Config.Auth Wire the auth package into the A2A remote agent. When A2AConfig.Auth is set, NewA2A registers an a2aclient.AuthInterceptor backed by the provider and scopes it to the ADK session id (attached before each send), so the credential is attached to requests whose agent card declares a matching security requirement. An unexported credentialsService adapts auth.CredentialProvider to a2aclient.CredentialsService, returning the raw token/key the interceptor places per scheme. The adapter lives in remoteagent (not auth/) so the core auth package stays free of the a2a-go dependency. Auth combined with a custom ClientProvider is a configuration error, since the interceptor cannot be injected into a caller-built client. * fix(remoteagent): apply review feedback to A2A auth Polish the per-request A2A auth wiring in response to review: - Document that Auth resolution is fail-open: the a2a interceptor swallows provider errors (logs and continues), so a failed resolution sends the request unauthenticated rather than failing the call. - Error on an OAuth2 credential with an empty AccessToken (and no TokenSource) instead of emitting an empty "Bearer " header, matching auth.Credential.Apply. - Tests: use t.Context(); cover the empty-OAuth2 error path and the AgentCardProvider (per-invocation card) path in the header-attach test. - Minor: separate the Auth struct field with a blank line. * test(remoteagent): broaden A2A auth coverage The initial auth tests only proved a bearer token reaches the server on the streaming path. Add end-to-end coverage for the paths a real caller hits: - apiKey scheme: the raw key lands in the card-named header (X-Api-Key), not in Authorization and without a "Bearer " prefix. - credential is usable, not just present: an enforcing server accepts the right token and rejects a wrong one (surfaced as an error event). - per-session scoping: a session-aware provider reading a2aclient.SessionIDFrom resolves a distinct credential per ADK session. - non-streaming (SendMessage) path, alongside the existing streaming one. Factor the repeated server/card setup into serveRecordingA2A, newSecureCard, and bearerCard helpers to keep the new cases readable. * fix(remoteagent): authenticate the A2A task cleanup CancelTask Auth scoped the session id only onto the message send, so the CancelTask the run loop issues from its deferred cleanup (when Run exits before a terminal event, leaving a non-terminal task) went out unauthenticated. Against a remote agent whose card requires auth the cancel is rejected, leaking the remote task — exactly the secured agents where cleanup matters most. Compute the session-scoped context once, before the deferred cleanup, and pass it to cleanupRemoteTask so both the send and the CancelTask (and any custom RemoteTaskCleanupCallback) carry the resolved credential. Add a regression test that fails (empty Authorization on CancelTask) without the fix. * fix(remoteagent): adapt A2A auth to interface-based auth.Credential After the auth core package (#1143) merged to main, auth.Credential is an interface (BearerCredential/APIKeyCredential/OAuth2Credential implementing Apply), not the earlier struct tagged-union. Rewrite credentialValue to type-switch on the concrete credential types and update the tests. Surfaced when rebasing this branch onto main. * fix(remoteagent): validate a2a credential values and wrap resolve error - Wrap the provider resolution error with %w so attribution survives when the a2a interceptor logs it. - Reject an empty API-key value and an empty OAuth2 access token (a misbehaving source) rather than transmitting an empty secret. - Note the scheme-unaware consequence in the adapter doc: a card that declares an unexpected scheme places the secret per that scheme. - Broaden credentialValue coverage (nil, unsupported kind, empty api-key/oauth2) and assert the exact Auth+ClientProvider config error. * test(remoteagent): trim narrating auth test comments Cut test comments that restated the code/assertions (one block was duplicated); keep only the intent/why (fail-open, cleanup also authenticated, credential usable-not-just-present). * fix(remoteagent): scope, place and bound the A2A credential correctly Review of the per-request A2A auth wiring surfaced several ways the credential could reach the wrong place, or fail to reach the right one with no signal at all. - Scope the credential to the whole calling identity, not the bare session id. A session id is caller-supplied, so two users each holding one named "default" resolved to the same credential. The key is now app name, user id, session id and the remote agent's name, each percent-encoded and joined with "/". The agent name is in there because a bearer token is scoped to an audience too: one provider shared between two remote agents would otherwise cross their tokens. - Refuse a security scheme that cannot carry the resolved credential, returning ErrCredentialNotFound so the interceptor tries the next one. It picks among the schemes in one requirement object in Go map order and never checks them against the credential, so a bearer token landed in an API-key header on a random subset of requests. The check also reads the fields the interceptor ignores: a query- or cookie-located API key would go out as a header the remote never reads, and a card asking for Basic would receive Bearer. - Refuse a redirect that leaves the card's host or downgrades its scheme, checked against the original request and the previous hop. The credential is attached before the first hop and Go replays headers on every hop, stripping Authorization only when the host changes and never stripping a card-named API-key header. - Bound the OAuth2 mint. TokenSource.Token takes no context and the sources behind auth.ADC and auth.ServiceAccount post through a client with no timeout, so a hung endpoint held the run loop past every budget around it. - Keep the cleanup context an agent.InvocationContext. Detaching it stripped the type a provider is told it can recover, so a provider that did failed there and the cancel went out unauthenticated — against exactly the secured agents where cleanup matters. - Authenticate the CancelTask the adka2a server issues for an abandoned child task. It reuses the subagent's auth-wired client with no scope on the context, so a secured remote rejects the cancel and the task leaks. - Turn a typed-nil provider into a constructor error, accept pointer credential forms, keep an oauth2.RetrieveError's response body out of the message the interceptor logs at ERROR, and warn once per agent where fail-open would otherwise be silent. - Leave the context untouched when Auth is unset, and export CredentialScope for the one case Auth cannot serve: a custom ClientProvider wiring its own interceptor. Document on the field what the card dictates, what cannot be sent, and how this differs from the field of the same name on mcptoolset.Config. * fix(remoteagent): signal every unauthenticated a2a send, and bound the mint A card declaring a scheme and one empty requirement object has a requirement list of length 1, so the "no requirement" warning did not fire, and the a2a interceptor's inner loop over that object's scheme names had nothing to iterate, so it never asked for a credential. The request left unauthenticated with nothing logged anywhere, and `security: [{}]` is how OpenAPI spells "authentication optional", so a real card can carry it. The check is now that no requirement object names a scheme at all, which is the condition under which the interceptor never calls the adapter. The OAuth2 mint is single-flighted per credential scope. Token() takes no context and cannot be interrupted, so bounding the caller's wait releases the caller and leaves the mint running: without the single-flight every request arriving while a token endpoint hangs started another one and parked another goroutine. The mint goroutine also recovers, since a panic there is fatal rather than something the runner can turn into an error. reattachInvocation now runs only when Auth is set. It keyed on the context already being an agent.InvocationContext, which it is on the unset path too, so an opted-out caller was getting a wrapper in RemoteTaskCleanupCallback where the field's own doc promises a plain context.WithoutCancel. isTypedNil no longer rejects a provider on a nil named map, slice or channel: a value receiver on one is callable, so rejecting it turned a working provider into a constructor error. The doc named three kinds where the code covered six. Tests for five guards that no test could fail on: the OwnsAuthScope derivation, the exported CredentialScope driven by distinct literals, schemeAccepts with no card on the context, the no-requirement warning, and the executor's card re-attachment — that last one through a credentials service that reads the card, as production's does, rather than the in-memory store that resolves on scope and scheme name alone. Doc corrections: ClientProvider described a credential scope it can never receive, since Auth and ClientProvider are mutually exclusive, and redactTokenError claimed the named-error-code branch prints only the code when it also prints error_description and error_uri. * fix(remoteagent): bound the a2a token mint and close the redaction bypass The single-flight added in the previous commit had no attempt deadline, so a token endpoint that hung once wedged that credential scope for the life of the process: Token() cannot be interrupted, the map entry was removed only by the goroutine that could never finish, and every later request for that identity joined it and waited a fresh full mintTimeout. auth/gcp's provider had already reached the other design for the same reason, and this adopts it — the bound belongs to the attempt, a joiner waits its remainder, and a caller arriving after it has passed retires the attempt and mints again. That also caps what a card can cost: a card naming N bearer-capable schemes produces N resolutions, which used to be N full timeouts and is now one. Two smaller faults on the same path. When the mint landed exactly as the caller's deadline fired, the select picked between them at random and threw away about half the tokens that had in fact arrived. And the delete has to be conditional now that an overdue attempt can be retired, or the abandoned goroutine evicts its successor's live entry on the way out. redactTokenError decided from the inner error's fields, which the token source behind auth.ServiceAccount with an Audience defeats: cloud.google.com/go/auth returns an *Error that prints the response body itself, wrapping an *oauth2.RetrieveError whose ErrorCode the adapter has already parsed out of that body. errors.As found the inner one, saw a named code, declined, and the wrapper printed the body into a log line at ERROR. It now tests what the message actually says, so it redacts whatever wrapper is in front. CardNamesNoScheme guarded on len(SecuritySchemes) == 0 where the interceptor guards on nil. A card whose JSON says "securitySchemes": {} decodes to an empty but non-nil map, so the interceptor does ask, and one unauthenticated send was reported twice. It moves to internal/ so the adka2a cancel path can use it too: that path was the last silent unauthenticated send, since the remote agent's own warning does not run in a process that only inherited the abandoned task. The mismatch warning now dedupes per credential type rather than outright. A session-aware provider resolves a different credential per user, and one sync.Once reported the first user's broken configuration and swallowed every later one. With Auth set, a card naming a non-loopback http interface warns once per agent. validateCardInterfaceOrigins already enforces https-or-loopback, but only on a fetched card, so a static, file-sourced or caller-supplied one reached the send path unchecked and sent the credential in cleartext silently. This warns rather than refuses: pointing Auth at a plaintext internal host is a decision a caller can legitimately make, and not noticing is not. The Auth doc now says the scope is only as trustworthy as the identity behind it. An adka2a server with no authenticator synthesizes the user id from the context id the calling peer chose, so a provider caching per scope would hand one peer the credential resolved for another. Tests for the guards none of the above would have been caught by, and for five the previous round left unpinned: the redirect cap asserted against the constant under test, the client timeout, the executor's card cache-hit branch, the scope-ownership rule, and internal/agent/remoteagent, which had no test file at all. The cross-origin redirect test varied the port rather than the host, so a hostname-only case joins it. 32 mutations run, all red. * fix(remoteagent): build the redacted token error, never edit the logged one Two judges split on the previous commit's redaction. Searching the wrapper's message for the response body closes the case that prompted it, but only for a wrapper that prints the body verbatim — one that quotes or re-encodes it slips past, and a body short enough to occur in the message for an unrelated reason redacts when nothing leaked. Rather than adjudicate that, the construct is gone: a response that carried a body always gets a message built here from the status and the error code and uri, and nothing is copied from whatever the wrapper wrote. error_description goes with the body. It is unbounded free text parsed out of that same body, and the endpoint this redaction exists for puts the client's own signed assertion in it. The previous commit carried it onto the redacted message, and its own fixture made that visible without failing: the assertion appeared in both the body and the description, and the test asserted only on the whole JSON string. The full error stays reachable through the chain, so only what gets logged is rebuilt. The no-scheme warning added to the adka2a cancel path had no test — both existing fixtures use a card that names a scheme, so the branch was unreachable. It has one now, in both directions. TestMintGroupJoinerWaitsTheAttemptsRemainder timed a 225ms sleep against a 200ms bound, and overshooting flipped it into the retire-and-re-mint path and hard-failed rather than missing softly. It now runs on a 2s budget, checks it actually joined the attempt before measuring, and says so instead of failing when the machine was too loaded to time it. 36 mutations, all red. * fix(remoteagent): make the ClientProvider remediation true for the cleanup cancel NewA2A's error told a caller combining a custom ClientProvider with their own interceptor that their client "sees the ADK invocation context on every call". Since the cleanup re-wrap was gated on Auth, that is false for one call: with Auth unset the cleanup CancelTask gets a plain detached context, which is what RemoteTaskCleanupCallback's doc and the opted-out contract promise. A caller following the message got no scope on that call, so the cancel went out unauthenticated and the remote task kept running — the failure the cleanup auth exists to prevent. The code stays and the message changes: the context the provider receives is the invocation context, so the scope is computed there and the client attaches it to every call. The ClientProvider field doc and the cleanup comment say the same. The remediation test now follows the message literally and drives the run through to the CancelTask, which it previously never reached. TestMintGroupSeparatesScopes could not fail on the merge it is named for. Its two mints ran one after the other, so the second found no entry whatever the map was keyed on, and keying it on the app segment alone left the whole suite green. The mints now overlap, on real scopes that share their app, session and agent segments, and keying on the app, or dropping the user or the agent segment, each turns it red. The isTypedNil doc said a nil map, slice or channel with a value receiver "reads the nil fine". That holds for a map only: indexing a nil slice panics and receiving from a nil channel blocks. The code is unchanged, since no check on the kind can tell those cases apart. Correction to 9a8442a: its message says the attempt deadline cuts a card naming N bearer-capable schemes from N full mint timeouts to one. It does not. The interceptor asks for each scheme only after the previous one returned, so each request arrives after the attempt's deadline, retires it and waits a full budget of its own — three sequential requests against a hung source take three timeouts, measured. What the deadline does fix is the permanent wedge. * feat(remoteagent): apply the A2A credential the way adk-python does A2AConfig.Auth used a2a-go's AuthInterceptor, which lets the agent card decide where the credential goes and sends the request unauthenticated when it cannot be resolved. adk-python's RemoteA2aAgent does neither: its configured auth scheme writes the header and the card's security section is never read, and a credential it cannot resolve stops the invocation rather than going out without one. Both were confirmed by running adk-python 2.10.0 (identical to main at 044a1ec3 for this code) against a recording transport. A card asking for an API key in X-Card-Key got Authorization: Bearer and no X-Card-Key, and a user with no credential got an adk_request_credential event and zero requests. The credential is now applied by an http.RoundTripper installed on the A2A client, through the credential's own Apply, the same way mcptoolset.Config.Auth applies it through auth.Transport. So every credential type works, Basic and auth.WithHeaders included, a card that declares no security still gets the credential, and a credential that cannot be resolved or applied fails the call. That removes the machinery the card-driven design needed: scheme matching, the card handed through the context, and the no-scheme and mismatch warnings, whose only job was to report sends that can no longer happen. Also matching adk-python: the credential is resolved once per invocation and reused, and the agent card fetch done by NewAgentCardProvider carries it, with the source's scheme checked before the fetch goes out. Resolving once removes the per-scheme N×timeout cost a hung token endpoint used to impose. The card fetch keeps a2a-go's 30s resolver timeout rather than the three-minute RPC one. Interactive consent is not supported yet. A ConsentRequiredError fails the call like any other error, where adk-python pauses the invocation to ask the user. Every blocking credential step, an OAuth2 mint or a credential's own Apply, runs bounded and single-flighted per scope, so a WithHeaders-wrapped OAuth2 credential cannot hold a request past every deadline, and its token endpoint's response body is redacted like a bare one's. The cleartext warning is now kept per interface rather than once per agent. The adka2a cancel path drops its card cache, and tool/mcptoolset no longer needs the paragraph explaining how its Auth field differed from this one. * fix(remoteagent): make the ClientProvider advice hold on the adka2a cancel NewA2A's error and the ClientProvider doc told a caller that the context their provider receives is the ADK invocation context. That holds only when the agent runs. An adka2a server hosting it calls the same provider to cancel an abandoned child task, with its own request context, which is not an agent.InvocationContext and carries no scope, because OwnsAuthScope is false for a custom provider. A provider written to the message returned an error there, so the child task kept running, or panicked out of Executor.Cleanup on an unchecked assertion. Both texts now name the second caller and say to check the assertion, and a test pins what that path hands the provider. The mintCall comment credited auth/gcp with retiring a hung attempt. auth/gcp does the opposite and says why: retiring would park a new goroutine every initTimeout. The two share only the attempt's deadline. The comment now states the trade this code makes — one parked goroutine per scope per mintTimeout while an endpoint hangs, against a scope locked out for good, since nothing promises Token() returns — instead of a reason that is not true. A test now fails if the transport stops keying a mint or an Apply on the request's own scope, with two identities overlapping. Before it, keying the mint on "" left the suite green. Three comments that still described the old card-driven interceptor or an impossible unscoped request are corrected. * test(remoteagent): pin five changed lines no test could fail on A mutator that picks every changed condition and comparison, rather than letting the author choose, found seven mutants of this change that survived the whole suite. Each was either a missing test or code with no job left. - The CancelTask re-wrap in cleanupRemoteTask had no job left. The auth transport recovers the invocation from the context's values whatever type the context has, so the call now takes the plain timeout context, as it did before this change. The re-wrap RemoteTaskCleanupCallback's doc promises stays. - WithICDelta with a delta that carries no context, or with no delta, was never called, so rewriting its && as || left the suite green and would have dereferenced a nil context. - RoundTrip's early returns close the request body, and nothing checked it. - The redirect table had no row where only one half of "http to https" held, and none mixing an explicit default port with an implicit one. - The cleartext warning test ran two invocations, which cannot tell "warn the first time" from "warn every time but the first": both log once. It runs three. The mutator now kills all 108 of its mutants of this change. * fix(remoteagent): correct comments that misdescribe the code, pin the single-flight A pass over every comment and error message this change adds, each checked against the code it describes, found statements that did not hold: - The mintCall comment gave auth/gcp a reason for keeping a hung attempt that auth/gcp's own ErrClientUnavailable doc rules out. Both face a step that may never return and pick opposite costs: auth/gcp a permanent lockout, this group a parked goroutine per scope per mintTimeout. - reattachInvocation's doc and a test doc still said the cleanup re-wrap exists for the credential provider. It exists for RemoteTaskCleanupCallback. - The Auth doc and the transport's doc said a credential always applies itself, as it does under mcptoolset. A bare OAuth2 credential is minted here instead. mintGroup's doc said at most one step per scope runs at a time, which an attempt retired past its deadline makes untrue. - A redirect "that leaves the card's scheme" is not refused: an upgrade to https on the same host is allowed. The doc now says what the code does. - redactTokenError called error_uri a short enumerable field. It is free text parsed out of the same body as error_description, so it is dropped too, and the claim that the endpoint puts a signed assertion in error_description, never verified, is gone. - Test comments overstated what their test catches, or credited it alone with catching something others also catch. The ClientProvider doc now says what a provider can rely on during the adka2a cancel: CredentialScope cannot be rebuilt there, and only an authenticated caller is available, through a2asrv.CallContextFrom. The Auth doc names agent.IdentityFromContext, which is what the auth.CredentialProvider contract tells a provider to use, and a test pins it on the card fetch and the send. Tests now fail if the transport stops sharing one mint or one Apply between two requests for the same scope, if RoundTrip leaves the body open when the credential fails to apply, and if WithICDelta mishandles a delta that replaces only the branch.
1 parent b531451 commit abb8d81

8 files changed

Lines changed: 4206 additions & 11 deletions

File tree

‎agent/remoteagent/v2/a2a_agent.go‎

Lines changed: 183 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,11 @@ import (
2121
"fmt"
2222
"iter"
2323
"net"
24+
"net/http"
2425
"net/url"
2526
"os"
2627
"strings"
28+
"sync"
2729
"time"
2830

2931
"github.com/a2aproject/a2a-go/v2/a2a"
@@ -32,6 +34,7 @@ import (
3234
"github.com/a2aproject/a2a-go/v2/log"
3335

3436
"google.golang.org/adk/v2/agent"
37+
"google.golang.org/adk/v2/auth"
3538
agentinternal "google.golang.org/adk/v2/internal/agent"
3639
iremoteagent "google.golang.org/adk/v2/internal/agent/remoteagent"
3740
"google.golang.org/adk/v2/server/adka2a/v2"
@@ -214,7 +217,17 @@ func NewAgentCardProvider(source string, opts ...agentcard.ResolveOption) AgentC
214217
return nil, err
215218
}
216219
if !isFile {
217-
card, err := agentcard.DefaultResolver.Resolve(ctx, source, opts...)
220+
resolver := agentcard.DefaultResolver
221+
if client := iremoteagent.CardFetchClientFrom(ctx); client != nil {
222+
// The fetch carries the credential, so the scheme is checked
223+
// before it goes out rather than with the card's interfaces
224+
// afterwards. adk-python orders the two checks the same way.
225+
if err := requireSecureCardSource(source); err != nil {
226+
return nil, err
227+
}
228+
resolver = agentcard.NewResolver(client)
229+
}
230+
card, err := resolver.Resolve(ctx, source, opts...)
218231
if err != nil {
219232
return nil, fmt.Errorf("failed to fetch an agent card: %w", err)
220233
}
@@ -237,6 +250,20 @@ func NewAgentCardProvider(source string, opts ...agentcard.ResolveOption) AgentC
237250
}
238251
}
239252

253+
// requireSecureCardSource refuses to send a credential to a card source that is
254+
// neither https nor a loopback host, the same rule validateCardInterfaceOrigins
255+
// applies to the interfaces the card names.
256+
func requireSecureCardSource(source string) error {
257+
u, err := url.Parse(source)
258+
if err != nil {
259+
return fmt.Errorf("%w: invalid agent card source URL %q: %w", ErrUntrustedCardInterface, source, err)
260+
}
261+
if u.Scheme != "https" && !isLoopbackHost(u.Hostname()) {
262+
return fmt.Errorf("%w: agent card source %q must use https, or http on a loopback host, when A2AConfig.Auth is set", ErrUntrustedCardInterface, source)
263+
}
264+
return nil
265+
}
266+
240267
// A2AConfig is used to describe and configure a remote agent.
241268
type A2AConfig struct {
242269
Name string
@@ -309,13 +336,101 @@ type A2AConfig struct {
309336
GenAIPartConverter adka2a.GenAIPartConverter
310337

311338
// ClientProvider can be used to provide a custom implementation of A2A message sending.
339+
//
340+
// It cannot be combined with Auth, so nothing this package attaches for
341+
// auth reaches it: neither the context it receives nor the one its client
342+
// receives per call carries a credential scope. A provider doing its own
343+
// auth works out the identity itself, and CredentialScope builds the key
344+
// this package would have used.
345+
//
346+
// The provider is called from two places, and only one of them has an
347+
// invocation. When this agent runs, the provider receives the ADK
348+
// invocation context, so it can compute CredentialScope(ctx.Session(),
349+
// name) there and have its client attach the scope to every call, the
350+
// cleanup CancelTask included — that call's own context is a plain
351+
// detached one, so the scope has to be captured when the client is built.
352+
// When an adka2a server hosting this agent cancels an abandoned child task,
353+
// it calls the provider with its own request context, which is not an
354+
// agent.InvocationContext and carries no scope. Check the type assertion
355+
// rather than making it unconditionally. CredentialScope cannot be rebuilt
356+
// there: nothing on that context carries the session id. What a provider
357+
// can rely on is the authenticated caller, through
358+
// a2asrv.CallContextFrom(ctx), and only when the server runs an
359+
// authenticator. Without one, nothing on that call identifies the caller.
312360
ClientProvider A2AClientProvider
361+
362+
// Auth, when set, resolves an end-user credential and applies it to every
363+
// A2A call this agent makes: the message send, the CancelTask the run loop
364+
// issues when it exits before a terminal event, and the agent card fetch
365+
// when the card comes from NewAgentCardProvider with a URL. A card fetched
366+
// by a provider of your own is not covered. It cannot be combined with a
367+
// custom ClientProvider; set one or the other.
368+
//
369+
// The credential decides where it goes, through its own Apply, as it does
370+
// for the field of the same name on mcptoolset.Config — a bare
371+
// auth.OAuth2Credential excepted, which is minted here, see below. The agent
372+
// card's security section is not consulted, so every auth.Credential works
373+
// and a card that declares no security still gets the credential. This
374+
// matches adk-python's RemoteA2aAgent, whose configured auth scheme decides
375+
// the header. A bare auth.OAuth2Credential's token is sent as a bearer
376+
// token, and one of any other type is rejected rather than sent
377+
// mislabeled. Wrapped in another credential, such as auth.WithHeaders, it
378+
// writes itself, token type included.
379+
//
380+
// A credential that cannot be resolved or applied fails the call instead of
381+
// letting it go out unauthenticated. adk-python fails closed as well, by
382+
// pausing the invocation to ask the user for consent. That round-trip is
383+
// not supported here yet: an *auth.ConsentRequiredError fails the call like
384+
// any other error, so use static tokens, API keys, or 2-legged and
385+
// service-account sources. A failure during cleanup means the CancelTask is
386+
// not sent, which leaves the remote task running.
387+
//
388+
// Enable Auth only for remote agents whose card comes from a trusted
389+
// source, since the card decides where the request goes. An
390+
// attacker-influenced card could point the credential at an endpoint it
391+
// controls, and a card naming an http:// interface sends it in cleartext,
392+
// which is logged once per interface. A redirect that leaves the card's
393+
// host, or downgrades its scheme, is refused, because the credential would
394+
// follow it, and a card fetch that carries the credential must use https
395+
// or a loopback host.
396+
//
397+
// The provider is called once per invocation and its credential reused for
398+
// every call that invocation makes, as adk-python caches it. It is called
399+
// concurrently across concurrent invocations of the same agent. Its scope
400+
// identifies both the caller and the callee, so a provider that caches per
401+
// scope neither crosses users nor sends one remote agent's token to
402+
// another: a2aclient.SessionIDFrom(ctx) yields the app name, user id,
403+
// session id and this agent's Name, each percent-encoded and joined with
404+
// "/". The acting user is also available the way the auth.CredentialProvider
405+
// contract describes, through agent.IdentityFromContext, and ctx is the
406+
// agent.InvocationContext, which a provider may type-assert. Both hold on
407+
// every call made for an invocation. Neither holds on the cancel an adka2a
408+
// server issues for an abandoned child task, where only the scope is
409+
// present.
410+
//
411+
// The scope is only as trustworthy as the identity behind it. Behind an
412+
// adka2a server the identity comes from the A2A call context, and with no
413+
// authenticator configured the server synthesizes it from the context id
414+
// the calling peer chose — so the scope is peer-chosen too, and a provider
415+
// caching per scope would hand one peer the credential resolved for
416+
// another. Set Auth on a remote agent reached that way only when the server
417+
// authenticates its callers.
418+
//
419+
// The credential a provider returns should depend on nothing finer than
420+
// that scope. Concurrent OAuth2 mints and Apply calls for one scope are
421+
// collapsed into one, so a credential built per invocation could find its
422+
// result answered by the one a sibling invocation resolved.
423+
Auth auth.CredentialProvider
424+
313425
// MessageSendConfig is attached to a2a.SendMessageRequest sent on every agent invocation.
314426
MessageSendConfig *a2a.SendMessageConfig
315427

316428
// RemoteTaskCleanupCallback is called if Run exited before a terminal event was received from the remote A2A server.
317429
// If Run exited due to an error including context cancellation it will be passed as cause.
318430
// The context passed to this callback is the original context, but with Err() removed by context.WithoutCancel.
431+
// With Auth set it is additionally wrapped so it is still an
432+
// agent.InvocationContext and still carries the credential scope, which is
433+
// what lets a cancel this callback issues be authenticated.
319434
// If no callback is provided the default behavior is to make a cancel RPC request with 5 second timeout.
320435
RemoteTaskCleanupCallback A2ARemoteTaskCleanupCallback
321436
}
@@ -326,15 +441,34 @@ func NewA2A(cfg A2AConfig) (agent.Agent, error) {
326441
if cfg.AgentCard == nil && cfg.AgentCardProvider == nil {
327442
return nil, fmt.Errorf("either AgentCard or AgentCardProvider must be provided")
328443
}
444+
if isTypedNil(cfg.Auth) {
445+
return nil, fmt.Errorf("A2AConfig.Auth holds a nil %T; leave the field unset instead", cfg.Auth)
446+
}
447+
if cfg.Auth != nil && cfg.ClientProvider != nil {
448+
return nil, fmt.Errorf("A2AConfig.Auth cannot be combined with a custom ClientProvider; wire the credential into your ClientProvider instead. When this agent runs, the context the provider receives is the ADK invocation context: compute remoteagent.CredentialScope(ctx.Session(), name) there and have the client it returns attach it with a2aclient.AttachSessionID on every call, the cleanup CancelTask included. An adka2a server cancelling an abandoned child task calls the provider with its own request context instead, which is not an agent.InvocationContext, so check the assertion. See A2AConfig.ClientProvider")
449+
}
450+
var authClient, cardClient *http.Client
329451
if cfg.ClientProvider == nil {
330-
cfg.ClientProvider = NewA2AClientProvider(a2aclient.NewFactory())
452+
var opts []a2aclient.FactoryOption
453+
if cfg.Auth != nil {
454+
authClient = authHTTPClient(cfg.Auth)
455+
cardClient = cardFetchHTTPClient(authClient)
456+
opts = append(opts,
457+
a2aclient.WithJSONRPCTransport(authClient),
458+
a2aclient.WithRESTTransport(authClient),
459+
)
460+
}
461+
cfg.ClientProvider = NewA2AClientProvider(a2aclient.NewFactory(opts...))
331462
}
332463

333464
remoteAgent := &a2aAgent{
465+
cardClient: cardClient,
334466
serverConfig: &iremoteagent.A2AServerConfig{
335467
AgentCard: cfg.AgentCard,
336468
AgentCardProvider: cfg.AgentCardProvider,
337469
ClientProvider: cfg.ClientProvider,
470+
OwnsAuthScope: cfg.Auth != nil,
471+
CardFetchClient: cardClient,
338472
},
339473
}
340474
agent, err := agent.New(agent.Config{
@@ -363,17 +497,41 @@ func NewA2A(cfg A2AConfig) (agent.Agent, error) {
363497

364498
type a2aAgent struct {
365499
serverConfig *iremoteagent.A2AServerConfig
500+
// cardClient applies A2AConfig.Auth to a card fetch. Nil when Auth is unset.
501+
cardClient *http.Client
502+
// warnedCleartext holds the http:// interfaces already reported, so each
503+
// is reported once per agent rather than once per invocation, and a card
504+
// that later names a different one is reported again.
505+
warnedCleartext sync.Map
366506
}
367507

368508
func (a *a2aAgent) run(ctx agent.InvocationContext, cfg A2AConfig) iter.Seq2[*session.Event, error] {
369509
return func(yield func(*session.Event, error) bool) {
370-
card, err := iremoteagent.ResolveAgentCard(ctx, a.serverConfig)
510+
// Scope every outgoing call of this invocation to the ADK session so
511+
// the auth transport can resolve a credential for it: the card fetch,
512+
// the message send below, and the cleanup CancelTask the deferred
513+
// cleanup issues. It is built before the card is resolved because the
514+
// card fetch is authenticated too, as adk-python does.
515+
sendCtx := authSendContext(ctx, cfg, a.cardClient)
516+
card, err := iremoteagent.ResolveAgentCard(sendCtx, a.serverConfig)
371517
if err != nil {
372518
yield(toErrorEvent(ctx, fmt.Errorf("agent card resolution failed: %w", err)), nil)
373519
return
374520
}
521+
if cfg.Auth != nil {
522+
// Only a fetched card is checked at resolution time, so a static,
523+
// file-sourced or caller-provided one reaches here unvalidated.
524+
// Once per interface — the card is usually static, and the operator
525+
// needs the fact, not a copy of it per request.
526+
for _, iface := range cardSendsInClear(card) {
527+
if _, seen := a.warnedCleartext.LoadOrStore(iface, struct{}{}); !seen {
528+
log.Warn(ctx, "a2a auth: the agent card names a non-loopback http interface, so the credential will be sent in cleartext",
529+
"agent", cfg.Name, "interface", iface)
530+
}
531+
}
532+
}
375533

376-
sender, err := cfg.ClientProvider(ctx, card)
534+
sender, err := cfg.ClientProvider(sendCtx, card)
377535
if err != nil {
378536
yield(toErrorEvent(ctx, fmt.Errorf("sender creation failed: %w", err)), nil)
379537
return
@@ -420,7 +578,7 @@ func (a *a2aAgent) run(ctx agent.InvocationContext, cfg A2AConfig) iter.Seq2[*se
420578
if err == nil && ctx.Err() != nil {
421579
err = context.Cause(ctx)
422580
}
423-
cleanupRemoteTask(ctx, cfg, card, sender, lastEvent, err)
581+
cleanupRemoteTask(sendCtx, cfg, card, sender, lastEvent, err)
424582
}()
425583

426584
processEvent := func(a2aEvent a2a.Event, a2aErr error) bool {
@@ -482,19 +640,22 @@ func (a *a2aAgent) run(ctx agent.InvocationContext, cfg A2AConfig) iter.Seq2[*se
482640
}
483641

484642
if ctx.RunConfig().StreamingMode == agent.StreamingModeNone {
485-
a2aEvent, a2aErr := sender.SendMessage(ctx, req)
643+
a2aEvent, a2aErr := sender.SendMessage(sendCtx, req)
486644
processEvent(a2aEvent, a2aErr)
487645
return
488646
}
489647

490-
for a2aEvent, a2aErr := range sender.SendStreamingMessage(ctx, req) {
648+
for a2aEvent, a2aErr := range sender.SendStreamingMessage(sendCtx, req) {
491649
if !processEvent(a2aEvent, a2aErr) {
492650
return
493651
}
494652
}
495653
}
496654
}
497655

656+
// cleanupTimeout bounds the cleanup CancelTask, credential resolution included.
657+
const cleanupTimeout = 5 * time.Second
658+
498659
func cleanupRemoteTask(ctx context.Context, cfg A2AConfig, card *a2a.AgentCard, client A2AClient, lastEvent a2a.Event, cause error) {
499660
if lastEvent == nil {
500661
return
@@ -517,7 +678,20 @@ func cleanupRemoteTask(ctx context.Context, cfg A2AConfig, card *a2a.AgentCard,
517678
return
518679
}
519680

520-
ctx = context.WithoutCancel(ctx)
681+
// WithoutCancel returns its own type, which is no longer an
682+
// agent.InvocationContext. With Auth set, re-wrap it for
683+
// RemoteTaskCleanupCallback, whose doc promises that callback the ADK
684+
// context and the scope. The CancelTask below needs no such help: the auth
685+
// transport recovers the invocation from the context's values whatever
686+
// type the context has. A caller who never opted in keeps the plain
687+
// context.WithoutCancel, custom ClientProvider included, which is why
688+
// NewA2A's error tells that provider to capture its scope when it builds
689+
// the client.
690+
detached := context.WithoutCancel(ctx)
691+
if cfg.Auth != nil {
692+
detached = reattachInvocation(ctx, detached)
693+
}
694+
ctx = detached
521695

522696
if cfg.RemoteTaskCleanupCallback != nil {
523697
cfg.RemoteTaskCleanupCallback(ctx, card, client, lastEvent.TaskInfo(), cause)
@@ -527,7 +701,7 @@ func cleanupRemoteTask(ctx context.Context, cfg A2AConfig, card *a2a.AgentCard,
527701
if state == a2a.TaskStateInputRequired && cause == nil {
528702
return
529703
}
530-
cancelCtx, cancelTimeout := context.WithTimeout(ctx, 5*time.Second)
704+
cancelCtx, cancelTimeout := context.WithTimeout(ctx, cleanupTimeout)
531705
defer cancelTimeout()
532706
_, err := client.CancelTask(cancelCtx, &a2a.CancelTaskRequest{ID: taskID})
533707
if err != nil {

0 commit comments

Comments
 (0)