From c497cf19b80aa1d5bedd32d105df8608e4581cca Mon Sep 17 00:00:00 2001 From: Eden Reich Date: Mon, 5 Oct 2026 01:09:31 +0200 Subject: [PATCH 1/5] feat(a2a): authenticate to agents with per-agent credentials An agent entry in agents.yaml can now carry an auth block: a static bearer token (token_env) or an OIDC client-credentials grant (oidc). The block names the environment variables that hold the secrets, so the file, the logs and infer agents list never contain them. The credentials ride on a transport inside NewClient, so they reach every request to the agent: the card fetch, SendMessage, GetTask polling, CancelTask and the liveness probe. They are only sent to the origin of the configured agent, never to a redirect target or another port on the host. An OIDC token is fetched once per grant and refreshed before it expires. A 401 or 403, a missing variable or a failed token request reaches the model as "Authentication failed for A2A agent ". Background polling ends the job on it instead of retrying forever. Closes #1514 --- cmd/agents/agents.go | 13 + cmd/agents/agents_list_test.go | 22 ++ config/agents.go | 45 ++++ config/agents_test.go | 70 +++++ docs/a2a-connections.md | 9 + docs/agents-configuration.md | 61 +++++ go.mod | 1 + go.sum | 2 + internal/protocols/a2a/README.md | 3 + internal/protocols/a2a/failure.go | 12 + internal/protocols/a2a/infrastructure/auth.go | 213 ++++++++++++++++ .../protocols/a2a/infrastructure/auth_test.go | 240 ++++++++++++++++++ .../protocols/a2a/infrastructure/client.go | 7 +- internal/protocols/a2a/query_agent.go | 2 +- internal/protocols/a2a/query_task.go | 2 +- internal/protocols/a2a/status.go | 3 + internal/protocols/a2a/submit_task.go | 23 +- .../a2a/submit_task_error_handling_test.go | 47 ++++ 18 files changed, 769 insertions(+), 6 deletions(-) create mode 100644 internal/protocols/a2a/infrastructure/auth.go create mode 100644 internal/protocols/a2a/infrastructure/auth_test.go diff --git a/cmd/agents/agents.go b/cmd/agents/agents.go index 7f452cf98..5943e9770 100644 --- a/cmd/agents/agents.go +++ b/cmd/agents/agents.go @@ -808,6 +808,10 @@ func (c *command) showAgent(cmd *cobra.Command, name string) error { fmt.Println(c.renderer.Field("Model", agent.Model)) } + if agent.Auth != nil { + fmt.Println(c.renderer.Field("Auth", describeAuth(*agent.Auth))) + } + if len(agent.Environment) > 0 { fmt.Println() fmt.Println(c.renderer.Title("Environment Variables")) @@ -822,6 +826,15 @@ func (c *command) showAgent(cmd *cobra.Command, name string) error { return nil } +// describeAuth names an agent's authentication mode and the environment +// variable its secret is read from. The secret itself is never loaded here. +func describeAuth(auth config.AgentAuth) string { + if auth.OIDC != nil { + return fmt.Sprintf("OIDC client %s at %s (secret from %s)", auth.OIDC.ClientID, auth.OIDC.IssuerURL, auth.OIDC.ClientSecretEnv) + } + return fmt.Sprintf("bearer token from %s", auth.TokenEnv) +} + func (c *command) initAgents(cmd *cobra.Command, _ []string) error { path, err := agentsConfigPath(cmd) if err != nil { diff --git a/cmd/agents/agents_list_test.go b/cmd/agents/agents_list_test.go index 3ddd476ea..4a4d1b8de 100644 --- a/cmd/agents/agents_list_test.go +++ b/cmd/agents/agents_list_test.go @@ -126,3 +126,25 @@ func TestMarkdownAgentMode(t *testing.T) { }) } } + +func TestDescribeAuth_NamesTheVariableNotTheSecret(t *testing.T) { + t.Setenv("RESEARCH_TOKEN", "s3cret-token") + t.Setenv("BILLING_CLIENT_SECRET", "s3cret-client") + + bearer := describeAuth(config.AgentAuth{TokenEnv: "RESEARCH_TOKEN"}) + oidc := describeAuth(config.AgentAuth{OIDC: &config.AgentOIDC{ + IssuerURL: "https://idp.example.com/realms/agents", + ClientID: "infer", + ClientSecretEnv: "BILLING_CLIENT_SECRET", + }}) + + if bearer != "bearer token from RESEARCH_TOKEN" { + t.Errorf("bearer description = %q", bearer) + } + if !strings.Contains(oidc, "BILLING_CLIENT_SECRET") || !strings.Contains(oidc, "infer") { + t.Errorf("oidc description = %q", oidc) + } + if strings.Contains(bearer+oidc, "s3cret") { + t.Errorf("descriptions leak a secret: %q %q", bearer, oidc) + } +} diff --git a/config/agents.go b/config/agents.go index afe4e13ae..ee067f696 100644 --- a/config/agents.go +++ b/config/agents.go @@ -1,6 +1,7 @@ package config import ( + "cmp" "net/url" "os" "path/filepath" @@ -26,6 +27,23 @@ type AgentEntry struct { Run bool `yaml:"run" mapstructure:"run"` Model string `yaml:"model,omitempty" mapstructure:"model,omitempty"` Environment map[string]string `yaml:"environment,omitempty" mapstructure:"environment,omitempty"` + Auth *AgentAuth `yaml:"auth,omitempty" mapstructure:"auth,omitempty"` +} + +// AgentAuth says how to authenticate to an agent: a static bearer token or an +// OIDC client-credentials grant. Secrets are named by environment variable and +// never stored, so agents.yaml stays safe to print and to rewrite. +type AgentAuth struct { + TokenEnv string `yaml:"token_env,omitempty" mapstructure:"token_env,omitempty"` + OIDC *AgentOIDC `yaml:"oidc,omitempty" mapstructure:"oidc,omitempty"` +} + +// AgentOIDC is an OIDC client-credentials grant against the issuer an agent trusts. +type AgentOIDC struct { + IssuerURL string `yaml:"issuer_url" mapstructure:"issuer_url"` + ClientID string `yaml:"client_id" mapstructure:"client_id"` + ClientSecretEnv string `yaml:"client_secret_env" mapstructure:"client_secret_env"` + Audience string `yaml:"audience,omitempty" mapstructure:"audience,omitempty"` } // DefaultAgentsConfig returns a default agents configuration @@ -148,6 +166,33 @@ func (c *AgentsConfig) DeleteEntry(name string) error { // ListEntries implements CollectionConfig. func (c *AgentsConfig) ListEntries() []AgentEntry { return c.Agents } +// EntryForURL returns the configured agent served from the origin of rawURL. +// The whole origin is compared, so an agent's credentials never reach another +// service on the same host. +func (c *AgentsConfig) EntryForURL(rawURL string) (AgentEntry, bool) { + origin := URLOrigin(rawURL) + if origin == "" { + return AgentEntry{}, false + } + i := slices.IndexFunc(c.Agents, func(agent AgentEntry) bool { return URLOrigin(agent.URL) == origin }) + if i < 0 { + return AgentEntry{}, false + } + return c.Agents[i], true +} + +// URLOrigin returns the scheme, host and port of rawURL in a comparable form, +// with the scheme's default port filled in. It returns "" for a URL without a host. +func URLOrigin(rawURL string) string { + u, err := url.Parse(rawURL) + if err != nil || u.Hostname() == "" { + return "" + } + scheme := strings.ToLower(u.Scheme) + port := cmp.Or(u.Port(), map[string]string{"http": "80", "https": "443"}[scheme]) + return scheme + "://" + strings.ToLower(u.Hostname()) + ":" + port +} + // GetAgentURLs returns URLs of all configured agents. func GetAgentURLs(path string) ([]string, error) { cfg, err := LoadAgents(path) diff --git a/config/agents_test.go b/config/agents_test.go index 973243c28..83483c32c 100644 --- a/config/agents_test.go +++ b/config/agents_test.go @@ -423,3 +423,73 @@ func TestIsLocalA2AAgent(t *testing.T) { }) } } + +func TestEntryForURL_MatchesTheWholeOrigin(t *testing.T) { + cfg := &config.AgentsConfig{Agents: []config.AgentEntry{ + {Name: "research", URL: "https://Research.example.com/a2a"}, + {Name: "local", URL: "http://localhost:8080"}, + }} + + tests := []struct { + name string + url string + want string + }{ + {"same origin, other path", "https://research.example.com/.well-known/agent-card.json", "research"}, + {"default port spelled out", "https://research.example.com:443", "research"}, + {"same host, other port", "https://research.example.com:8443", ""}, + {"same host, other scheme", "http://research.example.com", ""}, + {"explicit port", "http://localhost:8080/a2a", "local"}, + {"other port on localhost", "http://localhost:9090", ""}, + {"not a URL", "research", ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + agent, ok := cfg.EntryForURL(tt.url) + require.Equal(t, tt.want != "", ok) + require.Equal(t, tt.want, agent.Name) + }) + } +} + +// TestLoadAgents_AuthNamesVariablesNotSecrets: a loaded and rewritten +// agents.yaml only ever holds the variable names, never the secrets behind them. +func TestLoadAgents_AuthNamesVariablesNotSecrets(t *testing.T) { + t.Setenv("RESEARCH_TOKEN", "s3cret-token") + t.Setenv("BILLING_CLIENT_SECRET", "s3cret-client") + path := filepath.Join(t.TempDir(), "agents.yaml") + require.NoError(t, os.WriteFile(path, []byte(`agents: + - name: research + url: https://research.example.com + auth: + token_env: RESEARCH_TOKEN + - name: billing + url: https://billing.example.com + auth: + oidc: + issuer_url: https://idp.example.com/realms/agents + client_id: infer + client_secret_env: BILLING_CLIENT_SECRET + audience: billing-agent +`), 0o600)) + + cfg, err := config.LoadAgents(path) + require.NoError(t, err) + research, err := cfg.ReadEntry("research") + require.NoError(t, err) + require.Equal(t, &config.AgentAuth{TokenEnv: "RESEARCH_TOKEN"}, research.Auth) + billing, err := cfg.ReadEntry("billing") + require.NoError(t, err) + require.Equal(t, &config.AgentOIDC{ + IssuerURL: "https://idp.example.com/realms/agents", + ClientID: "infer", + ClientSecretEnv: "BILLING_CLIENT_SECRET", + Audience: "billing-agent", + }, billing.Auth.OIDC) + + require.NoError(t, cfg.DeleteEntry("research")) + rewritten, err := os.ReadFile(path) + require.NoError(t, err) + require.Contains(t, string(rewritten), "client_secret_env: BILLING_CLIENT_SECRET") + require.NotContains(t, string(rewritten), "s3cret") +} diff --git a/docs/a2a-connections.md b/docs/a2a-connections.md index 30fabcdb8..d80fff4d9 100644 --- a/docs/a2a-connections.md +++ b/docs/a2a-connections.md @@ -253,6 +253,14 @@ a2a: - Tools validate required parameters before execution - Invalid configurations result in clear error messages +### Authentication + +- An agent entry in `agents.yaml` can carry an `auth` block with a static bearer token or an OIDC + client-credentials grant. See [Authentication](agents-configuration.md#authentication). +- Secrets are read from environment variables named in the block. They are never stored in `agents.yaml` and + never shown in logs, tool results or `infer agents list`. +- Credentials are sent only to the origin of the agent they are configured for. + ### Network Security - A2A connections require proper URL validation @@ -299,6 +307,7 @@ Tools provide descriptive error messages: - "A2A connections are disabled in configuration" - "agent_url parameter is required and must be a string" - "A2A task submission failed: [specific error]" +- "Authentication failed for A2A agent \"[name]\": [reason]" - missing or rejected credentials (`401`/`403`) ## Troubleshooting diff --git a/docs/agents-configuration.md b/docs/agents-configuration.md index cdeb18247..4357712e3 100644 --- a/docs/agents-configuration.md +++ b/docs/agents-configuration.md @@ -31,6 +31,8 @@ agents: model: provider/model-name # Optional: AI model to use (e.g., deepseek/deepseek-v4-pro) environment: # Optional: Environment variables for the agent KEY: VALUE + auth: # Optional: Credentials sent to the agent (see Authentication) + token_env: AGENT_TOKEN ``` ### Field Descriptions @@ -48,6 +50,8 @@ agents: When `agent.model` is empty too, no model is passed and the agent starts on its own default. - **environment**: Key-value pairs of environment variables to pass to the agent when running locally. Supports environment variable substitution using `$VAR` or `${VAR}` syntax. +- **auth**: Credentials the CLI sends to the agent, a static bearer token or an OIDC client-credentials grant. + It names the environment variables that hold the secrets. See [Authentication](#authentication). ## CLI Commands @@ -168,6 +172,63 @@ infer agents remove code-reviewer infer agents remove project-helper --project ``` +## Authentication + +An agent that protects its `/a2a` endpoint needs credentials. Add an `auth` block to the agent's entry and the +CLI sends an `Authorization: Bearer` header on every request to that agent: the agent card fetch, task +submission, background task polling, task cancellation and the liveness probe. + +The `auth` block never holds a secret. It names the environment variable the secret is read from, so +`agents.yaml` stays safe to commit and `infer agents list` and `infer agents show` only ever print the variable name. + +### Static Bearer Token + +For an ADK agent started with `A2A_AUTH_TOKEN`: + +```yaml +agents: + - name: research + url: https://research.example.com + auth: + token_env: RESEARCH_AGENT_TOKEN +``` + +```bash +export RESEARCH_AGENT_TOKEN=... +``` + +### OIDC Client Credentials + +For an ADK agent started with `A2A_AUTH_ENABLED`, `A2A_AUTH_ISSUER_URL`, `A2A_AUTH_CLIENT_ID` and `A2A_AUTH_AUDIENCE`: + +```yaml +agents: + - name: billing + url: https://billing.example.com + auth: + oidc: + issuer_url: https://idp.example.com/realms/agents + client_id: infer + client_secret_env: BILLING_AGENT_CLIENT_SECRET + audience: billing-agent # Optional +``` + +The CLI reads the token endpoint from `/.well-known/openid-configuration`, fetches a token with the +client-credentials grant, reuses it across requests and refreshes it shortly before it expires. + +### Notes + +- Set either `token_env` or `oidc`, not both. +- Reference the variable by name (`token_env: RESEARCH_AGENT_TOKEN`). Do not write `${RESEARCH_AGENT_TOKEN}` + here - substitution would put the secret itself in the field. +- Credentials are matched by origin (scheme, host and port) of the agent's `url` and are only sent there. A + redirect to another origin or another service on the same host does not receive them. +- The credentials also apply when `INFER_A2A_AGENTS` supplies the agent list, as long as `agents.yaml` has an + entry with the same origin. +- A missing variable, a failed token request or a `401`/`403` from the agent is reported to the model as + `Authentication failed for A2A agent "": ...`. A background task whose polling is rejected ends as failed. +- There are no `infer agents add` flags for `auth` yet - edit `agents.yaml` directly. + ## Environment Variable Substitution The agents configuration supports environment variable substitution, allowing you to reference environment diff --git a/go.mod b/go.mod index 8b5b0193e..90708e021 100644 --- a/go.mod +++ b/go.mod @@ -55,6 +55,7 @@ require ( go.uber.org/zap v1.28.0 golang.org/x/crypto v0.57.0 golang.org/x/image v0.46.0 + golang.org/x/oauth2 v0.37.0 google.golang.org/protobuf v1.36.12 gopkg.in/yaml.v3 v3.0.1 modernc.org/sqlite v1.60.1 diff --git a/go.sum b/go.sum index 8e97d8c58..da01900e6 100644 --- a/go.sum +++ b/go.sum @@ -351,6 +351,8 @@ golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c= golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues= golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= +golang.org/x/oauth2 v0.37.0 h1:JUlcxA8oAtauLfiH8FX2/FkAWHAdi0QtGCGc+hofE98= +golang.org/x/oauth2 v0.37.0/go.mod h1:IxwZNxUULJmpBFf9K/9NTMSIfZZuvuTy1gGxhigP/58= golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= diff --git a/internal/protocols/a2a/README.md b/internal/protocols/a2a/README.md index 35cba447c..046503107 100644 --- a/internal/protocols/a2a/README.md +++ b/internal/protocols/a2a/README.md @@ -14,6 +14,9 @@ and the tools return normal tool results. artifacts itself when `a2a.task.artifacts_auto_download` is on. Otherwise it tells the model to fetch them with `WebFetch`. - `AgentSupervisor` starts the configured local agent containers. Agents are declared in `agents.yaml`, and the feature is gated by the `a2a` config section. +- An agent's `auth` block in `agents.yaml` gives it credentials, a bearer token or an OIDC client-credentials + grant with the secrets read from the environment. `infrastructure.NewClient` adds them to every request to + that agent's origin, and a rejection reaches the model as an authentication failure naming the agent. - `ReconcileAgents` brings a running chat in line with an edited `agents.yaml` on `/reload`. It reports the difference as `AgentChanges`. A removed agent is announced as `AgentStateRemoved` on the status stream once its goroutines have exited, and the TUI readiness drops it. diff --git a/internal/protocols/a2a/failure.go b/internal/protocols/a2a/failure.go index 797e0563b..1f8b4cd34 100644 --- a/internal/protocols/a2a/failure.go +++ b/internal/protocols/a2a/failure.go @@ -1,11 +1,23 @@ package a2a import ( + "fmt" "strings" adk "github.com/inference-gateway/adk/types" + + a2ainfra "github.com/inference-gateway/cli/internal/protocols/a2a/infrastructure" ) +// requestFailure describes a failed request to an agent for the model. An +// authentication failure is named as one, anything else keeps the action and its error. +func requestFailure(agentURL, action string, err error) string { + if message, ok := a2ainfra.AuthFailure(agentURL, err); ok { + return message + } + return fmt.Sprintf("%s: %v", action, err) +} + // textFromParts concatenates Text parts from an ADK message parts slice. func textFromParts(parts []adk.Part) string { var b strings.Builder diff --git a/internal/protocols/a2a/infrastructure/auth.go b/internal/protocols/a2a/infrastructure/auth.go new file mode 100644 index 000000000..2f68030aa --- /dev/null +++ b/internal/protocols/a2a/infrastructure/auth.go @@ -0,0 +1,213 @@ +package infrastructure + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "os" + "regexp" + "strings" + "sync" + "time" + + oauth2 "golang.org/x/oauth2" + clientcredentials "golang.org/x/oauth2/clientcredentials" + + config "github.com/inference-gateway/cli/config" +) + +// AuthError reports that a request to an agent could not be given credentials. +// Its reason names variables and status codes, never a secret or a response body. +type AuthError struct { + Agent string + Reason string +} + +func (e *AuthError) Error() string { + return fmt.Sprintf("Authentication failed for A2A agent %q: %s", e.Agent, e.Reason) +} + +// rejectedStatus matches the ADK client's error for a 401 or 403 response. +// ponytail: ADK has no typed HTTP error, so the status is read from the +// message. Switch to errors.As once ADK exposes the status code. +var rejectedStatus = regexp.MustCompile(`unexpected status code[^:]*: (401|403)\b`) + +// AuthFailure describes err for the model when it is an authentication +// failure: credentials that could not be obtained, or a 401 or 403 from the +// agent. The message names the agent and leaves the response body out. +func AuthFailure(agentURL string, err error) (string, bool) { + if err == nil { + return "", false + } + var authErr *AuthError + if errors.As(err, &authErr) { + return authErr.Error(), true + } + match := rejectedStatus.FindStringSubmatch(err.Error()) + if match == nil { + return "", false + } + rejected := &AuthError{ + Agent: agentDisplayName(agentURL), + Reason: fmt.Sprintf("the agent rejected the request with status %s, check its auth settings in agents.yaml", match[1]), + } + return rejected.Error(), true +} + +func agentDisplayName(agentURL string) string { + if agent, ok := configuredAgent(agentURL); ok { + return agent.Name + } + if u, err := url.Parse(agentURL); err == nil && u.Host != "" { + return u.Host + } + return agentURL +} + +func configuredAgent(agentURL string) (config.AgentEntry, bool) { + agents, err := config.LoadAgents(config.ResolveAgentsPath()) + if err != nil { + return config.AgentEntry{}, false + } + return agents.EntryForURL(agentURL) +} + +// authTransport adds an agent's credentials to the requests sent to its +// origin. Requests to any other origin, such as a redirect target, pass +// through untouched so a credential never leaves its agent. +type authTransport struct { + base http.RoundTripper + agent config.AgentEntry + origin string +} + +// newAuthTransport returns the transport for the agent at agentURL: one that +// authenticates when agents.yaml gives the agent credentials, the default otherwise. +func newAuthTransport(agentURL string) http.RoundTripper { + agent, ok := configuredAgent(agentURL) + if !ok || agent.Auth == nil { + return http.DefaultTransport + } + return authTransport{base: http.DefaultTransport, agent: agent, origin: config.URLOrigin(agentURL)} +} + +func (t authTransport) RoundTrip(req *http.Request) (*http.Response, error) { + if config.URLOrigin(req.URL.String()) != t.origin { + return t.base.RoundTrip(req) + } + token, err := bearerToken(t.agent) + if err != nil { + if req.Body != nil { + _ = req.Body.Close() + } + return nil, err + } + authenticated := req.Clone(req.Context()) + authenticated.Header.Set("Authorization", "Bearer "+token) + return t.base.RoundTrip(authenticated) +} + +func bearerToken(agent config.AgentEntry) (string, error) { + auth := agent.Auth + switch { + case auth.TokenEnv != "" && auth.OIDC != nil: + return "", &AuthError{Agent: agent.Name, Reason: "auth sets both token_env and oidc, keep one"} + case auth.OIDC != nil: + return oidcToken(agent.Name, *auth.OIDC) + case auth.TokenEnv != "": + return secretFromEnv(agent.Name, auth.TokenEnv) + default: + return "", &AuthError{Agent: agent.Name, Reason: "auth sets neither token_env nor oidc"} + } +} + +func secretFromEnv(agentName, variable string) (string, error) { + if secret := os.Getenv(variable); secret != "" { + return secret, nil + } + return "", &AuthError{Agent: agentName, Reason: fmt.Sprintf("environment variable %s is not set", variable)} +} + +var oidcHTTPClient = &http.Client{Timeout: 10 * time.Second} + +// oidcTokenSources keeps one token source per grant for the life of the +// process. Clients are built per request, so the cached token has to outlive them. +// ponytail: one lock also covers the first discovery of each grant. Give each +// grant its own sync.Once if agents ever wait on each other's issuers. +var oidcTokenSources = struct { + sync.Mutex + byGrant map[config.AgentOIDC]oauth2.TokenSource +}{byGrant: map[config.AgentOIDC]oauth2.TokenSource{}} + +func oidcToken(agentName string, grant config.AgentOIDC) (string, error) { + source, err := oidcTokenSource(agentName, grant) + if err != nil { + return "", err + } + token, err := source.Token() + if err != nil { + return "", &AuthError{Agent: agentName, Reason: "fetching the OIDC token: " + tokenFailureReason(err)} + } + return token.AccessToken, nil +} + +// oidcTokenSource returns the grant's token source, which fetches a token +// once and refreshes it shortly before it expires. +func oidcTokenSource(agentName string, grant config.AgentOIDC) (oauth2.TokenSource, error) { + oidcTokenSources.Lock() + defer oidcTokenSources.Unlock() + + if source, ok := oidcTokenSources.byGrant[grant]; ok { + return source, nil + } + secret, err := secretFromEnv(agentName, grant.ClientSecretEnv) + if err != nil { + return nil, err + } + tokenURL, err := discoverTokenEndpoint(grant.IssuerURL) + if err != nil { + return nil, &AuthError{Agent: agentName, Reason: "discovering the OIDC token endpoint: " + err.Error()} + } + credentials := clientcredentials.Config{ClientID: grant.ClientID, ClientSecret: secret, TokenURL: tokenURL} + if grant.Audience != "" { + credentials.EndpointParams = url.Values{"audience": {grant.Audience}} + } + source := credentials.TokenSource(context.WithValue(context.Background(), oauth2.HTTPClient, oidcHTTPClient)) + oidcTokenSources.byGrant[grant] = source + return source, nil +} + +func discoverTokenEndpoint(issuerURL string) (string, error) { + resp, err := oidcHTTPClient.Get(strings.TrimSuffix(issuerURL, "/") + "/.well-known/openid-configuration") + if err != nil { + return "", err + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("the issuer answered with status %d", resp.StatusCode) + } + var discovery struct { + TokenEndpoint string `json:"token_endpoint"` + } + if err := json.NewDecoder(resp.Body).Decode(&discovery); err != nil { + return "", fmt.Errorf("reading the discovery document: %w", err) + } + if discovery.TokenEndpoint == "" { + return "", errors.New("the discovery document has no token_endpoint") + } + return discovery.TokenEndpoint, nil +} + +// tokenFailureReason describes a failed token request without the response +// body the oauth2 error carries. +func tokenFailureReason(err error) string { + var rejected *oauth2.RetrieveError + if errors.As(err, &rejected) && rejected.Response != nil { + return strings.TrimSpace(fmt.Sprintf("the token endpoint answered with status %d %s", rejected.Response.StatusCode, rejected.ErrorCode)) + } + return err.Error() +} diff --git a/internal/protocols/a2a/infrastructure/auth_test.go b/internal/protocols/a2a/infrastructure/auth_test.go new file mode 100644 index 000000000..367fa1f58 --- /dev/null +++ b/internal/protocols/a2a/infrastructure/auth_test.go @@ -0,0 +1,240 @@ +package infrastructure + +import ( + "errors" + "fmt" + "net/http" + "net/http/httptest" + "os" + "strings" + "sync" + "sync/atomic" + "testing" + + client "github.com/inference-gateway/adk/client" + adk "github.com/inference-gateway/adk/types" +) + +const jsonRPCResult = `{"jsonrpc":"2.0","id":"1","result":{"id":"t1"}}` + +// writeAgentsYAML makes body the agents.yaml of an otherwise empty project. +func writeAgentsYAML(t *testing.T, body string) { + t.Helper() + dir := t.TempDir() + t.Chdir(dir) + t.Setenv("HOME", dir) + if err := os.MkdirAll(".infer", 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(".infer/agents.yaml", []byte(body), 0o600); err != nil { + t.Fatal(err) + } +} + +// authRecorder is an agent that remembers the Authorization header of every request. +type authRecorder struct { + mu sync.Mutex + seen []string +} + +func (r *authRecorder) ServeHTTP(w http.ResponseWriter, req *http.Request) { + r.mu.Lock() + r.seen = append(r.seen, req.Header.Get("Authorization")) + r.mu.Unlock() + w.Header().Set("Content-Type", "application/json") + if req.Method == http.MethodGet { + _, _ = w.Write([]byte(`{"name":"research"}`)) + return + } + _, _ = w.Write([]byte(jsonRPCResult)) +} + +func (r *authRecorder) headers() []string { + r.mu.Lock() + defer r.mu.Unlock() + return append([]string(nil), r.seen...) +} + +func TestNewClient_SendsBearerTokenOnEveryRequestType(t *testing.T) { + requests := map[string]func(t *testing.T, c client.A2AClient) error{ + "agent card and liveness probe": func(t *testing.T, c client.A2AClient) error { + _, err := c.GetAgentCard(t.Context()) + return err + }, + "send message": func(t *testing.T, c client.A2AClient) error { + _, err := c.SendTask(t.Context(), adk.SendMessageRequest{}) + return err + }, + "get task": func(t *testing.T, c client.A2AClient) error { + _, err := c.GetTask(t.Context(), adk.GetTaskRequest{ID: "t1"}) + return err + }, + "cancel task": func(t *testing.T, c client.A2AClient) error { + _, err := c.CancelTask(t.Context(), adk.CancelTaskRequest{ID: "t1"}) + return err + }, + } + for name, send := range requests { + t.Run(name, func(t *testing.T) { + agent := &authRecorder{} + srv := httptest.NewServer(agent) + defer srv.Close() + writeAgentsYAML(t, fmt.Sprintf("agents:\n - name: research\n url: %s\n auth:\n token_env: RESEARCH_TOKEN\n", srv.URL)) + t.Setenv("RESEARCH_TOKEN", "s3cret") + + if err := send(t, NewClient(srv.URL)); err != nil { + t.Fatalf("request failed: %v", err) + } + if got := agent.headers(); len(got) != 1 || got[0] != "Bearer s3cret" { + t.Fatalf("Authorization headers = %q, want one Bearer s3cret", got) + } + }) + } +} + +func TestNewClient_KeepsCredentialsOnTheAgentOrigin(t *testing.T) { + other := &authRecorder{} + otherSrv := httptest.NewServer(other) + defer otherSrv.Close() + redirecting := httptest.NewServer(http.RedirectHandler(otherSrv.URL+"/.well-known/agent-card.json", http.StatusFound)) + defer redirecting.Close() + writeAgentsYAML(t, fmt.Sprintf("agents:\n - name: research\n url: %s\n auth:\n token_env: RESEARCH_TOKEN\n", redirecting.URL)) + t.Setenv("RESEARCH_TOKEN", "s3cret") + + if _, err := NewClient(redirecting.URL).GetAgentCard(t.Context()); err != nil { + t.Fatalf("redirected card fetch: %v", err) + } + if _, err := NewClient(otherSrv.URL).GetAgentCard(t.Context()); err != nil { + t.Fatalf("unconfigured card fetch: %v", err) + } + for _, header := range other.headers() { + if header != "" { + t.Fatalf("another origin received Authorization %q", header) + } + } +} + +// TestNewClient_OIDCTokenIsFetchedOnceAndRefreshed: clients are built per +// request, yet they share one token until it is about to expire. +func TestNewClient_OIDCTokenIsFetchedOnceAndRefreshed(t *testing.T) { + var tokenRequests atomic.Int32 + var issuer *httptest.Server + issuer = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.URL.Path == "/.well-known/openid-configuration" { + _, _ = fmt.Fprintf(w, `{"token_endpoint":%q}`, issuer.URL+"/token") + return + } + if err := r.ParseForm(); err != nil || r.Form.Get("audience") != "research-agent" { + http.Error(w, "missing audience", http.StatusBadRequest) + return + } + n := tokenRequests.Add(1) + expiresIn := 3600 + if n == 1 { + expiresIn = 5 + } + _, _ = fmt.Fprintf(w, `{"access_token":"token-%d","token_type":"Bearer","expires_in":%d}`, n, expiresIn) + })) + defer issuer.Close() + + agent := &authRecorder{} + srv := httptest.NewServer(agent) + defer srv.Close() + writeAgentsYAML(t, fmt.Sprintf(`agents: + - name: research + url: %s + auth: + oidc: + issuer_url: %s + client_id: infer + client_secret_env: RESEARCH_CLIENT_SECRET + audience: research-agent +`, srv.URL, issuer.URL)) + t.Setenv("RESEARCH_CLIENT_SECRET", "s3cret") + + for range 4 { + if _, err := NewClient(srv.URL).GetTask(t.Context(), adk.GetTaskRequest{ID: "t1"}); err != nil { + t.Fatalf("GetTask: %v", err) + } + } + + want := []string{"Bearer token-1", "Bearer token-2", "Bearer token-2", "Bearer token-2"} + if got := agent.headers(); strings.Join(got, ",") != strings.Join(want, ",") { + t.Fatalf("Authorization headers = %q, want %q (first token expires at once, the second is reused)", got, want) + } +} + +func TestNewClient_UnsetTokenVariableIsAnAuthError(t *testing.T) { + agent := &authRecorder{} + srv := httptest.NewServer(agent) + defer srv.Close() + writeAgentsYAML(t, fmt.Sprintf("agents:\n - name: research\n url: %s\n auth:\n token_env: RESEARCH_TOKEN_UNSET\n", srv.URL)) + + _, err := NewClient(srv.URL).GetAgentCard(t.Context()) + + var authErr *AuthError + if !errors.As(err, &authErr) || !strings.Contains(authErr.Reason, "RESEARCH_TOKEN_UNSET") { + t.Fatalf("err = %v, want an AuthError naming RESEARCH_TOKEN_UNSET", err) + } + if len(agent.headers()) != 0 { + t.Fatal("a request was sent without credentials") + } +} + +// TestAuthFailure_NamesTheAgentForRejectedRequests pins the ADK error text +// AuthFailure reads the status from. +func TestAuthFailure_NamesTheAgentForRejectedRequests(t *testing.T) { + tests := []struct { + name string + status int + send func(t *testing.T, c client.A2AClient) error + wantRejected bool + }{ + {"401 on the agent card", http.StatusUnauthorized, fetchCard, true}, + {"403 on the agent card", http.StatusForbidden, fetchCard, true}, + {"401 on a task request", http.StatusUnauthorized, fetchTask, true}, + {"403 on a task request", http.StatusForbidden, fetchTask, true}, + {"500 is not an auth failure", http.StatusInternalServerError, fetchTask, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, `{"error":"invalid token body-marker"}`, tt.status) + })) + defer srv.Close() + writeAgentsYAML(t, fmt.Sprintf("agents:\n - name: research\n url: %s\n auth:\n token_env: RESEARCH_TOKEN\n", srv.URL)) + t.Setenv("RESEARCH_TOKEN", "s3cret") + + err := tt.send(t, NewClient(srv.URL)) + message, rejected := AuthFailure(srv.URL, err) + + if rejected != tt.wantRejected { + t.Fatalf("AuthFailure(%v) rejected = %v, want %v", err, rejected, tt.wantRejected) + } + if !tt.wantRejected { + return + } + for _, want := range []string{"Authentication failed", `"research"`, fmt.Sprint(tt.status)} { + if !strings.Contains(message, want) { + t.Errorf("message %q does not contain %q", message, want) + } + } + for _, leaked := range []string{"s3cret", "body-marker"} { + if strings.Contains(message, leaked) { + t.Errorf("message %q leaks %q", message, leaked) + } + } + }) + } +} + +func fetchCard(t *testing.T, c client.A2AClient) error { + _, err := c.GetAgentCard(t.Context()) + return err +} + +func fetchTask(t *testing.T, c client.A2AClient) error { + _, err := c.GetTask(t.Context(), adk.GetTaskRequest{ID: "t1"}) + return err +} diff --git a/internal/protocols/a2a/infrastructure/client.go b/internal/protocols/a2a/infrastructure/client.go index e921ba686..17b50a959 100644 --- a/internal/protocols/a2a/infrastructure/client.go +++ b/internal/protocols/a2a/infrastructure/client.go @@ -8,11 +8,12 @@ import ( ) // NewClient returns an ADK client for the agent at agentURL whose requests -// carry the session's trace context, so remote spans nest under ours. Every -// request activates the usage extension, so tasks come back with their usage. +// carry the session's trace context, so remote spans nest under ours, and the +// credentials agents.yaml gives the agent. Every request activates the usage +// extension, so tasks come back with their usage. func NewClient(agentURL string) client.A2AClient { cfg := client.DefaultConfig(agentURL) - cfg.Transport = telemetry.PropagationTransport(nil) + cfg.Transport = telemetry.PropagationTransport(newAuthTransport(agentURL)) cfg.Headers["A2A-Extensions"] = adk.UsageExtensionURI return client.NewClientWithConfig(cfg) } diff --git a/internal/protocols/a2a/query_agent.go b/internal/protocols/a2a/query_agent.go index 226373db9..6dfe237c4 100644 --- a/internal/protocols/a2a/query_agent.go +++ b/internal/protocols/a2a/query_agent.go @@ -74,7 +74,7 @@ func (t *QueryAgentTool) Execute(ctx context.Context, args map[string]any) (*age response, err := adkClient.GetAgentCard(ctx) if err != nil { logger.Error("failed to fetch agent card", "agent_url", agentURL, "error", err) - return t.errorResult(args, startTime, fmt.Sprintf("Failed to fetch agent card: %v", err)) + return t.errorResult(args, startTime, requestFailure(agentURL, "Failed to fetch agent card", err)) } return &agentdomain.ToolExecutionResult{ diff --git a/internal/protocols/a2a/query_task.go b/internal/protocols/a2a/query_task.go index c8a728bb4..c8041bdb6 100644 --- a/internal/protocols/a2a/query_task.go +++ b/internal/protocols/a2a/query_task.go @@ -95,7 +95,7 @@ func (t *QueryTaskTool) Execute(ctx context.Context, args map[string]any) (*agen taskResponse, err := adkClient.GetTask(ctx, queryParams) if err != nil { logger.Error("failed to query task", "agent_url", agentURL, "task_id", taskID, "error", err) - return t.errorResult(args, startTime, fmt.Sprintf("Failed to query task: %v", err)) + return t.errorResult(args, startTime, requestFailure(agentURL, "Failed to query task", err)) } taskBytes, err := json.Marshal(taskResponse.Result) diff --git a/internal/protocols/a2a/status.go b/internal/protocols/a2a/status.go index 776b3f261..0ea127272 100644 --- a/internal/protocols/a2a/status.go +++ b/internal/protocols/a2a/status.go @@ -65,6 +65,9 @@ func probeAgentCard(ctx context.Context, status *AgentStatus) { if _, err := a2ainfra.NewClient(status.URL).GetAgentCard(checkCtx); err != nil { status.State = a2adomain.AgentStateFailed.String() status.Error = err.Error() + if message, rejected := a2ainfra.AuthFailure(status.URL, err); rejected { + status.Error = message + } return } status.State = a2adomain.AgentStateReady.String() diff --git a/internal/protocols/a2a/submit_task.go b/internal/protocols/a2a/submit_task.go index cf7f20f0b..9dfa82afe 100644 --- a/internal/protocols/a2a/submit_task.go +++ b/internal/protocols/a2a/submit_task.go @@ -208,7 +208,7 @@ func (t *SubmitTaskTool) Execute(ctx context.Context, args map[string]any) (*age t.taskTracker.RemoveTask(existingTaskID) return t.errorResult(args, startTime, fmt.Sprintf("Previous task no longer exists (cleared from tracker): %v", err)) } - return t.errorResult(args, startTime, fmt.Sprintf("A2A task submission failed: %v", err)) + return t.errorResult(args, startTime, requestFailure(agentURL, "A2A task submission failed", err)) } var sendResponse adk.SendMessageResponse @@ -335,6 +335,9 @@ func (t *SubmitTaskTool) runA2APolling( pollAttempt, currentInterval, time.Since(state.StartedAt)) currentTask, err := t.queryTask(ctx, adkClient, taskID) + if message, rejected := a2ainfra.AuthFailure(agentURL, err); rejected { + return authFailedResult(agentURL, taskID, state.ContextID, message) + } if err != nil || currentTask == nil { currentInterval = t.handleQueryError(agentURL, taskID, strategy, currentInterval, state, ticker, err) continue @@ -356,6 +359,24 @@ func (t *SubmitTaskTool) runA2APolling( } } +// authFailedResult ends the polling of a task whose agent no longer accepts +// the credentials, since retrying would be rejected the same way forever. +func authFailedResult(agentURL, taskID, contextID, message string) agentdomain.ToolExecutionResult { + return agentdomain.ToolExecutionResult{ + ToolName: ToolSubmitTask, + Success: false, + Error: message, + Data: SubmitTaskResult{ + TaskID: taskID, + ContextID: contextID, + AgentURL: agentURL, + State: string(adk.TaskStateFailed), + Success: false, + Message: message, + }, + } +} + // trackerKey tells apart the agents a gateway fronts under one URL, so a // task paused on one tenant is never resumed through another. func trackerKey(agentURL, tenant string) string { diff --git a/internal/protocols/a2a/submit_task_error_handling_test.go b/internal/protocols/a2a/submit_task_error_handling_test.go index 568b2778c..41cca5959 100644 --- a/internal/protocols/a2a/submit_task_error_handling_test.go +++ b/internal/protocols/a2a/submit_task_error_handling_test.go @@ -13,6 +13,8 @@ import ( adk "github.com/inference-gateway/adk/types" config "github.com/inference-gateway/cli/config" + a2adomain "github.com/inference-gateway/cli/internal/protocols/a2a/domain" + scheddomain "github.com/inference-gateway/cli/internal/scheduler/domain" ) func TestSubmitTaskTool_isTaskNotFoundError(t *testing.T) { @@ -404,3 +406,48 @@ func TestSubmitTaskTool_NoExistingTask(t *testing.T) { assert.Equal(t, 1, tracker.RegisterContextCallCount()) }) } + +func authTestConfig() *config.Config { + return &config.Config{ + A2A: config.A2AConfig{ + Enabled: true, + Tools: config.A2AToolsConfig{SubmitTask: config.SubmitTaskToolConfig{Enabled: true}}, + Task: config.A2ATaskConfig{StatusPollSeconds: 1}, + }, + } +} + +func TestSubmitTaskTool_RejectedSubmissionIsAnAuthFailure(t *testing.T) { + t.Chdir(t.TempDir()) + t.Setenv("HOME", t.TempDir()) + client := &adkmocks.FakeA2AClient{} + client.SendTaskReturns(nil, errors.New(`unexpected status code: 401, body: {"error":"invalid token"}`)) + tool := NewSubmitTaskToolWithClient(authTestConfig(), nil, nil, nil, client) + + result, err := tool.Execute(t.Context(), map[string]any{ + "agent_url": "https://research.example.com", + "task_description": "summarise", + }) + + assert.NoError(t, err) + assert.False(t, result.Success) + assert.Contains(t, result.Error, `Authentication failed for A2A agent "research.example.com"`) + assert.NotContains(t, result.Error, "invalid token") +} + +// TestSubmitTaskTool_PollingStopsOnAuthFailure: a rejected credential is +// rejected on every poll, so the job fails instead of polling forever. +func TestSubmitTaskTool_PollingStopsOnAuthFailure(t *testing.T) { + t.Chdir(t.TempDir()) + t.Setenv("HOME", t.TempDir()) + client := &adkmocks.FakeA2AClient{} + client.GetTaskReturns(nil, errors.New("unexpected status code: 403, body: forbidden")) + tool := NewSubmitTaskToolWithClient(authTestConfig(), nil, nil, nil, client) + state := &a2adomain.TaskPollingState{TaskID: "t1", ContextID: "ctx1", AgentURL: "https://research.example.com"} + + result := tool.runA2APolling(t.Context(), state.AgentURL, "t1", state, func(scheddomain.JobSignal) {}, func(adk.Task) {}) + + assert.False(t, result.Success) + assert.Contains(t, result.Error, `Authentication failed for A2A agent "research.example.com"`) + assert.Equal(t, 1, client.GetTaskCallCount()) +} From 90adf9b8277c07cbab92f77f4deabf699bf99e89 Mon Sep 17 00:00:00 2001 From: Eden Reich Date: Mon, 5 Oct 2026 01:20:31 +0200 Subject: [PATCH 2/5] fix(a2a): read the OIDC token endpoint from the agent card The A2A protocol has the agent declare how to authenticate in the securitySchemes of its card, so the issuer does not belong in agents.yaml. The grant now takes its token endpoint from the card: the discovery URL of an openIdConnect scheme, or the token URL of an oauth2 client-credentials flow, with the scopes the card's security requirements list. auth.oidc keeps the client id, the secret variable and the audience. issuer_url becomes an optional pin. With it set, a card that points outside that issuer is refused, so a tampered card cannot collect the client secret. Refs #1514 --- cmd/agents/agents.go | 2 +- config/agents.go | 6 +- docs/a2a-connections.md | 2 + docs/agents-configuration.md | 17 ++- internal/protocols/a2a/README.md | 3 +- internal/protocols/a2a/infrastructure/auth.go | 117 ++++++++++++++---- .../protocols/a2a/infrastructure/auth_test.go | 116 +++++++++++++---- 7 files changed, 212 insertions(+), 51 deletions(-) diff --git a/cmd/agents/agents.go b/cmd/agents/agents.go index 5943e9770..d988a83f9 100644 --- a/cmd/agents/agents.go +++ b/cmd/agents/agents.go @@ -830,7 +830,7 @@ func (c *command) showAgent(cmd *cobra.Command, name string) error { // variable its secret is read from. The secret itself is never loaded here. func describeAuth(auth config.AgentAuth) string { if auth.OIDC != nil { - return fmt.Sprintf("OIDC client %s at %s (secret from %s)", auth.OIDC.ClientID, auth.OIDC.IssuerURL, auth.OIDC.ClientSecretEnv) + return fmt.Sprintf("OIDC client %s (secret from %s)", auth.OIDC.ClientID, auth.OIDC.ClientSecretEnv) } return fmt.Sprintf("bearer token from %s", auth.TokenEnv) } diff --git a/config/agents.go b/config/agents.go index ee067f696..7af03609b 100644 --- a/config/agents.go +++ b/config/agents.go @@ -38,12 +38,14 @@ type AgentAuth struct { OIDC *AgentOIDC `yaml:"oidc,omitempty" mapstructure:"oidc,omitempty"` } -// AgentOIDC is an OIDC client-credentials grant against the issuer an agent trusts. +// AgentOIDC is the client of a client-credentials grant. Where to get the token +// is declared by the agent card's security schemes, not here. IssuerURL is an +// optional pin: the card may then only point at that issuer. type AgentOIDC struct { - IssuerURL string `yaml:"issuer_url" mapstructure:"issuer_url"` ClientID string `yaml:"client_id" mapstructure:"client_id"` ClientSecretEnv string `yaml:"client_secret_env" mapstructure:"client_secret_env"` Audience string `yaml:"audience,omitempty" mapstructure:"audience,omitempty"` + IssuerURL string `yaml:"issuer_url,omitempty" mapstructure:"issuer_url,omitempty"` } // DefaultAgentsConfig returns a default agents configuration diff --git a/docs/a2a-connections.md b/docs/a2a-connections.md index d80fff4d9..fe3f54c9b 100644 --- a/docs/a2a-connections.md +++ b/docs/a2a-connections.md @@ -260,6 +260,8 @@ a2a: - Secrets are read from environment variables named in the block. They are never stored in `agents.yaml` and never shown in logs, tool results or `infer agents list`. - Credentials are sent only to the origin of the agent they are configured for. +- For OIDC the token endpoint is read from the `securitySchemes` of the agent card. An optional `issuer_url` + pins the issuer the card may point at. ### Network Security diff --git a/docs/agents-configuration.md b/docs/agents-configuration.md index 4357712e3..a6d8c2159 100644 --- a/docs/agents-configuration.md +++ b/docs/agents-configuration.md @@ -207,14 +207,23 @@ agents: url: https://billing.example.com auth: oidc: - issuer_url: https://idp.example.com/realms/agents client_id: infer client_secret_env: BILLING_AGENT_CLIENT_SECRET - audience: billing-agent # Optional + audience: billing-agent # Optional + issuer_url: https://idp.example.com/realms/agents # Optional pin, see below ``` -The CLI reads the token endpoint from `/.well-known/openid-configuration`, fetches a token with the -client-credentials grant, reuses it across requests and refreshes it shortly before it expires. +You do not configure where the token comes from. As the A2A protocol defines, the agent declares it in the +`securitySchemes` of its agent card, and the CLI reads it from there: + +- an `openIdConnect` scheme gives the OpenID Connect discovery URL, whose document names the token endpoint +- an `oauth2` scheme with a `clientCredentials` flow gives the token URL directly + +The scopes the card's `securityRequirements` list for that scheme are requested with the token. The CLI fetches +a token with the client-credentials grant, reuses it across requests and refreshes it shortly before it expires. + +The client secret is sent to the token endpoint the card names. Set `issuer_url` to pin it: the CLI then refuses +a card that points anywhere outside that issuer, so a tampered card cannot collect the secret. ### Notes diff --git a/internal/protocols/a2a/README.md b/internal/protocols/a2a/README.md index 046503107..a51ea738a 100644 --- a/internal/protocols/a2a/README.md +++ b/internal/protocols/a2a/README.md @@ -15,7 +15,8 @@ and the tools return normal tool results. - `AgentSupervisor` starts the configured local agent containers. Agents are declared in `agents.yaml`, and the feature is gated by the `a2a` config section. - An agent's `auth` block in `agents.yaml` gives it credentials, a bearer token or an OIDC client-credentials - grant with the secrets read from the environment. `infrastructure.NewClient` adds them to every request to + grant with the secrets read from the environment. The token endpoint of the grant comes from the security + schemes on the agent card. `infrastructure.NewClient` adds them to every request to that agent's origin, and a rejection reaches the model as an authentication failure naming the agent. - `ReconcileAgents` brings a running chat in line with an edited `agents.yaml` on `/reload`. It reports the difference as `AgentChanges`. A removed agent is announced as `AgentStateRemoved` on the status stream once its diff --git a/internal/protocols/a2a/infrastructure/auth.go b/internal/protocols/a2a/infrastructure/auth.go index 2f68030aa..4e23b1efd 100644 --- a/internal/protocols/a2a/infrastructure/auth.go +++ b/internal/protocols/a2a/infrastructure/auth.go @@ -5,10 +5,12 @@ import ( "encoding/json" "errors" "fmt" + "maps" "net/http" "net/url" "os" "regexp" + "slices" "strings" "sync" "time" @@ -16,11 +18,14 @@ import ( oauth2 "golang.org/x/oauth2" clientcredentials "golang.org/x/oauth2/clientcredentials" + client "github.com/inference-gateway/adk/client" + adk "github.com/inference-gateway/adk/types" + config "github.com/inference-gateway/cli/config" ) // AuthError reports that a request to an agent could not be given credentials. -// Its reason names variables and status codes, never a secret or a response body. +// Its reason names variables, URLs and status codes, never a secret. type AuthError struct { Agent string Reason string @@ -116,7 +121,7 @@ func bearerToken(agent config.AgentEntry) (string, error) { case auth.TokenEnv != "" && auth.OIDC != nil: return "", &AuthError{Agent: agent.Name, Reason: "auth sets both token_env and oidc, keep one"} case auth.OIDC != nil: - return oidcToken(agent.Name, *auth.OIDC) + return oidcToken(agent) case auth.TokenEnv != "": return secretFromEnv(agent.Name, auth.TokenEnv) default: @@ -133,71 +138,139 @@ func secretFromEnv(agentName, variable string) (string, error) { var oidcHTTPClient = &http.Client{Timeout: 10 * time.Second} +// oidcGrant identifies one client-credentials grant: a client at one agent. +type oidcGrant struct { + agentURL string + client config.AgentOIDC +} + // oidcTokenSources keeps one token source per grant for the life of the // process. Clients are built per request, so the cached token has to outlive them. // ponytail: one lock also covers the first discovery of each grant. Give each // grant its own sync.Once if agents ever wait on each other's issuers. var oidcTokenSources = struct { sync.Mutex - byGrant map[config.AgentOIDC]oauth2.TokenSource -}{byGrant: map[config.AgentOIDC]oauth2.TokenSource{}} + byGrant map[oidcGrant]oauth2.TokenSource +}{byGrant: map[oidcGrant]oauth2.TokenSource{}} -func oidcToken(agentName string, grant config.AgentOIDC) (string, error) { - source, err := oidcTokenSource(agentName, grant) +func oidcToken(agent config.AgentEntry) (string, error) { + source, err := oidcTokenSource(agent) if err != nil { return "", err } token, err := source.Token() if err != nil { - return "", &AuthError{Agent: agentName, Reason: "fetching the OIDC token: " + tokenFailureReason(err)} + return "", &AuthError{Agent: agent.Name, Reason: "fetching the OIDC token: " + tokenFailureReason(err)} } return token.AccessToken, nil } -// oidcTokenSource returns the grant's token source, which fetches a token -// once and refreshes it shortly before it expires. -func oidcTokenSource(agentName string, grant config.AgentOIDC) (oauth2.TokenSource, error) { +// oidcTokenSource returns the token source of the agent's grant, which fetches +// a token once and refreshes it shortly before it expires. The token endpoint +// is the one the agent card declares. +func oidcTokenSource(agent config.AgentEntry) (oauth2.TokenSource, error) { + grant := oidcGrant{agentURL: agent.URL, client: *agent.Auth.OIDC} + oidcTokenSources.Lock() defer oidcTokenSources.Unlock() if source, ok := oidcTokenSources.byGrant[grant]; ok { return source, nil } - secret, err := secretFromEnv(agentName, grant.ClientSecretEnv) + secret, err := secretFromEnv(agent.Name, grant.client.ClientSecretEnv) if err != nil { return nil, err } - tokenURL, err := discoverTokenEndpoint(grant.IssuerURL) + endpoint, err := declaredTokenEndpoint(agent.URL, grant.client.IssuerURL) if err != nil { - return nil, &AuthError{Agent: agentName, Reason: "discovering the OIDC token endpoint: " + err.Error()} + return nil, &AuthError{Agent: agent.Name, Reason: err.Error()} + } + credentials := clientcredentials.Config{ + ClientID: grant.client.ClientID, + ClientSecret: secret, + TokenURL: endpoint.url, + Scopes: endpoint.scopes, } - credentials := clientcredentials.Config{ClientID: grant.ClientID, ClientSecret: secret, TokenURL: tokenURL} - if grant.Audience != "" { - credentials.EndpointParams = url.Values{"audience": {grant.Audience}} + if grant.client.Audience != "" { + credentials.EndpointParams = url.Values{"audience": {grant.client.Audience}} } source := credentials.TokenSource(context.WithValue(context.Background(), oauth2.HTTPClient, oidcHTTPClient)) oidcTokenSources.byGrant[grant] = source return source, nil } -func discoverTokenEndpoint(issuerURL string) (string, error) { - resp, err := oidcHTTPClient.Get(strings.TrimSuffix(issuerURL, "/") + "/.well-known/openid-configuration") +// tokenEndpoint is where an agent's card says a client-credentials token is +// issued, with the scopes its security requirements ask for. +type tokenEndpoint struct { + url string + scopes []string +} + +// declaredTokenEndpoint reads the token endpoint from the security schemes of +// the agent's card: an openIdConnect scheme through its discovery document, or +// an oauth2 scheme's client-credentials flow. With a pinned issuer the card may +// only point there, so a tampered card cannot send the client secret elsewhere. +func declaredTokenEndpoint(agentURL, pinnedIssuer string) (tokenEndpoint, error) { + ctx, cancel := context.WithTimeout(context.Background(), oidcHTTPClient.Timeout) + defer cancel() + + card, err := client.NewClient(agentURL).GetAgentCard(ctx) if err != nil { - return "", err + return tokenEndpoint{}, fmt.Errorf("reading the security schemes of the agent card: %w", err) + } + for _, name := range slices.Sorted(maps.Keys(card.SecuritySchemes)) { + scheme := card.SecuritySchemes[name] + endpoint := tokenEndpoint{scopes: requiredScopes(card, name)} + switch { + case scheme.OpenIDConnectSecurityScheme != nil: + discoveryURL := scheme.OpenIDConnectSecurityScheme.OpenIDConnectURL + if err := checkPinnedIssuer(discoveryURL, pinnedIssuer); err != nil { + return tokenEndpoint{}, err + } + endpoint.url, err = discoverTokenEndpoint(discoveryURL) + return endpoint, err + case scheme.Oauth2SecurityScheme != nil && scheme.Oauth2SecurityScheme.Flows.ClientCredentials != nil: + endpoint.url = scheme.Oauth2SecurityScheme.Flows.ClientCredentials.TokenURL + return endpoint, checkPinnedIssuer(endpoint.url, pinnedIssuer) + } + } + return tokenEndpoint{}, errors.New("the agent card declares no openIdConnect or oauth2 client-credentials security scheme") +} + +func requiredScopes(card *adk.AgentCard, schemeName string) []string { + for _, requirement := range card.SecurityRequirements { + if scopes, ok := requirement.Schemes[schemeName]; ok { + return scopes.List + } + } + return nil +} + +func checkPinnedIssuer(declaredURL, pinnedIssuer string) error { + if pinnedIssuer == "" || strings.HasPrefix(declaredURL, strings.TrimSuffix(pinnedIssuer, "/")+"/") { + return nil + } + return fmt.Errorf("the agent card points at %s, outside the pinned issuer_url %s", declaredURL, pinnedIssuer) +} + +func discoverTokenEndpoint(discoveryURL string) (string, error) { + resp, err := oidcHTTPClient.Get(discoveryURL) + if err != nil { + return "", fmt.Errorf("discovering the OIDC token endpoint: %w", err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("the issuer answered with status %d", resp.StatusCode) + return "", fmt.Errorf("discovering the OIDC token endpoint: the issuer answered with status %d", resp.StatusCode) } var discovery struct { TokenEndpoint string `json:"token_endpoint"` } if err := json.NewDecoder(resp.Body).Decode(&discovery); err != nil { - return "", fmt.Errorf("reading the discovery document: %w", err) + return "", fmt.Errorf("reading the OIDC discovery document: %w", err) } if discovery.TokenEndpoint == "" { - return "", errors.New("the discovery document has no token_endpoint") + return "", errors.New("the OIDC discovery document has no token_endpoint") } return discovery.TokenEndpoint, nil } diff --git a/internal/protocols/a2a/infrastructure/auth_test.go b/internal/protocols/a2a/infrastructure/auth_test.go index 367fa1f58..c179f7413 100644 --- a/internal/protocols/a2a/infrastructure/auth_test.go +++ b/internal/protocols/a2a/infrastructure/auth_test.go @@ -1,6 +1,7 @@ package infrastructure import ( + "cmp" "errors" "fmt" "net/http" @@ -35,6 +36,7 @@ func writeAgentsYAML(t *testing.T, body string) { type authRecorder struct { mu sync.Mutex seen []string + card string } func (r *authRecorder) ServeHTTP(w http.ResponseWriter, req *http.Request) { @@ -43,7 +45,7 @@ func (r *authRecorder) ServeHTTP(w http.ResponseWriter, req *http.Request) { r.mu.Unlock() w.Header().Set("Content-Type", "application/json") if req.Method == http.MethodGet { - _, _ = w.Write([]byte(`{"name":"research"}`)) + _, _ = w.Write([]byte(cmp.Or(r.card, `{"name":"research"}`))) return } _, _ = w.Write([]byte(jsonRPCResult)) @@ -114,9 +116,10 @@ func TestNewClient_KeepsCredentialsOnTheAgentOrigin(t *testing.T) { } } -// TestNewClient_OIDCTokenIsFetchedOnceAndRefreshed: clients are built per -// request, yet they share one token until it is about to expire. -func TestNewClient_OIDCTokenIsFetchedOnceAndRefreshed(t *testing.T) { +// fakeIssuer serves an OIDC discovery document and a token endpoint. Its first +// token expires at once, later ones are long lived. +func fakeIssuer(t *testing.T) (*httptest.Server, *atomic.Int32) { + t.Helper() var tokenRequests atomic.Int32 var issuer *httptest.Server issuer = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -125,8 +128,8 @@ func TestNewClient_OIDCTokenIsFetchedOnceAndRefreshed(t *testing.T) { _, _ = fmt.Fprintf(w, `{"token_endpoint":%q}`, issuer.URL+"/token") return } - if err := r.ParseForm(); err != nil || r.Form.Get("audience") != "research-agent" { - http.Error(w, "missing audience", http.StatusBadRequest) + if err := r.ParseForm(); err != nil || r.Form.Get("audience") != "research-agent" || r.Form.Get("scope") != "tasks" { + http.Error(w, "missing audience or scope", http.StatusBadRequest) return } n := tokenRequests.Add(1) @@ -136,32 +139,103 @@ func TestNewClient_OIDCTokenIsFetchedOnceAndRefreshed(t *testing.T) { } _, _ = fmt.Fprintf(w, `{"access_token":"token-%d","token_type":"Bearer","expires_in":%d}`, n, expiresIn) })) - defer issuer.Close() + t.Cleanup(issuer.Close) + return issuer, &tokenRequests +} - agent := &authRecorder{} - srv := httptest.NewServer(agent) - defer srv.Close() - writeAgentsYAML(t, fmt.Sprintf(`agents: +const oidcAgentsYAML = `agents: - name: research url: %s auth: oidc: - issuer_url: %s client_id: infer client_secret_env: RESEARCH_CLIENT_SECRET audience: research-agent -`, srv.URL, issuer.URL)) - t.Setenv("RESEARCH_CLIENT_SECRET", "s3cret") +` - for range 4 { - if _, err := NewClient(srv.URL).GetTask(t.Context(), adk.GetTaskRequest{ID: "t1"}); err != nil { - t.Fatalf("GetTask: %v", err) - } +// TestNewClient_OIDCTokenComesFromTheCardAndIsReused: the agent card says +// where tokens are issued. Clients are built per request, yet they share one +// token until it is about to expire. +func TestNewClient_OIDCTokenComesFromTheCardAndIsReused(t *testing.T) { + tests := []struct { + name string + scheme func(issuerURL string) string + }{ + {"openIdConnect scheme", func(issuerURL string) string { + return fmt.Sprintf(`{"openIdConnectSecurityScheme":{"openIdConnectUrl":%q}}`, issuerURL+"/.well-known/openid-configuration") + }}, + {"oauth2 client-credentials flow", func(issuerURL string) string { + return fmt.Sprintf(`{"oauth2SecurityScheme":{"flows":{"clientCredentials":{"tokenUrl":%q,"scopes":{}}}}}`, issuerURL+"/token") + }}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + issuer, _ := fakeIssuer(t) + agent := &authRecorder{card: fmt.Sprintf( + `{"name":"research","securitySchemes":{"idp":%s},"securityRequirements":[{"schemes":{"idp":{"list":["tasks"]}}}]}`, + tt.scheme(issuer.URL))} + srv := httptest.NewServer(agent) + defer srv.Close() + writeAgentsYAML(t, fmt.Sprintf(oidcAgentsYAML, srv.URL)) + t.Setenv("RESEARCH_CLIENT_SECRET", "s3cret") + + for range 4 { + if _, err := NewClient(srv.URL).GetTask(t.Context(), adk.GetTaskRequest{ID: "t1"}); err != nil { + t.Fatalf("GetTask: %v", err) + } + } + + want := []string{"", "Bearer token-1", "Bearer token-2", "Bearer token-2", "Bearer token-2"} + if got := agent.headers(); strings.Join(got, ",") != strings.Join(want, ",") { + t.Fatalf("Authorization headers = %q, want %q (one card read, the first token expires at once, the second is reused)", got, want) + } + }) } +} - want := []string{"Bearer token-1", "Bearer token-2", "Bearer token-2", "Bearer token-2"} - if got := agent.headers(); strings.Join(got, ",") != strings.Join(want, ",") { - t.Fatalf("Authorization headers = %q, want %q (first token expires at once, the second is reused)", got, want) +func TestNewClient_OIDCRefusesACardItCannotTrust(t *testing.T) { + tests := []struct { + name string + card func(issuerURL string) string + pin string + wantReason string + }{ + { + name: "card declares no usable scheme", + card: func(string) string { return `{"name":"research"}` }, + wantReason: "declares no openIdConnect or oauth2 client-credentials security scheme", + }, + { + name: "card points outside the pinned issuer", + card: func(issuerURL string) string { + return fmt.Sprintf(`{"name":"research","securitySchemes":{"idp":{"openIdConnectSecurityScheme":{"openIdConnectUrl":%q}}}}`, issuerURL+"/.well-known/openid-configuration") + }, + pin: "https://idp.example.com/realms/agents", + wantReason: "outside the pinned issuer_url", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + issuer, tokenRequests := fakeIssuer(t) + srv := httptest.NewServer(&authRecorder{card: tt.card(issuer.URL)}) + defer srv.Close() + agents := fmt.Sprintf(oidcAgentsYAML, srv.URL) + if tt.pin != "" { + agents += " issuer_url: " + tt.pin + "\n" + } + writeAgentsYAML(t, agents) + t.Setenv("RESEARCH_CLIENT_SECRET", "s3cret") + + _, err := NewClient(srv.URL).GetAgentCard(t.Context()) + + var authErr *AuthError + if !errors.As(err, &authErr) || !strings.Contains(authErr.Reason, tt.wantReason) { + t.Fatalf("err = %v, want an AuthError containing %q", err, tt.wantReason) + } + if n := tokenRequests.Load(); n != 0 { + t.Fatalf("the client secret was sent to the issuer %d times", n) + } + }) } } From d5d55690a2ab6c1ba4ec9c0cc6e350ca232af30e Mon Sep 17 00:00:00 2001 From: Eden Reich Date: Mon, 5 Oct 2026 01:32:55 +0200 Subject: [PATCH 3/5] feat(a2a): fall back to issuer_url when the agent card declares no scheme An agent whose card has no securitySchemes cannot say where its tokens are issued. The mock agent is one of them today. auth.oidc.issuer_url now also serves as the fallback issuer for such a card, next to pinning the issuer a declaring card may point at. Without it the request fails with an authentication error that says to set it. Refs #1514 --- config/agents.go | 4 +-- docs/a2a-connections.md | 2 +- docs/agents-configuration.md | 13 ++++++-- internal/protocols/a2a/infrastructure/auth.go | 9 ++++-- .../protocols/a2a/infrastructure/auth_test.go | 31 +++++++++++++++---- 5 files changed, 45 insertions(+), 14 deletions(-) diff --git a/config/agents.go b/config/agents.go index 7af03609b..48f089a36 100644 --- a/config/agents.go +++ b/config/agents.go @@ -39,8 +39,8 @@ type AgentAuth struct { } // AgentOIDC is the client of a client-credentials grant. Where to get the token -// is declared by the agent card's security schemes, not here. IssuerURL is an -// optional pin: the card may then only point at that issuer. +// is declared by the agent card's security schemes. IssuerURL is optional: the +// card may then only point at that issuer, and it is used when the card declares none. type AgentOIDC struct { ClientID string `yaml:"client_id" mapstructure:"client_id"` ClientSecretEnv string `yaml:"client_secret_env" mapstructure:"client_secret_env"` diff --git a/docs/a2a-connections.md b/docs/a2a-connections.md index fe3f54c9b..0afec2a8d 100644 --- a/docs/a2a-connections.md +++ b/docs/a2a-connections.md @@ -261,7 +261,7 @@ a2a: never shown in logs, tool results or `infer agents list`. - Credentials are sent only to the origin of the agent they are configured for. - For OIDC the token endpoint is read from the `securitySchemes` of the agent card. An optional `issuer_url` - pins the issuer the card may point at. + pins the issuer the card may point at and is used when the card declares none. ### Network Security diff --git a/docs/agents-configuration.md b/docs/agents-configuration.md index a6d8c2159..36f729d97 100644 --- a/docs/agents-configuration.md +++ b/docs/agents-configuration.md @@ -210,7 +210,7 @@ agents: client_id: infer client_secret_env: BILLING_AGENT_CLIENT_SECRET audience: billing-agent # Optional - issuer_url: https://idp.example.com/realms/agents # Optional pin, see below + issuer_url: https://idp.example.com/realms/agents # Optional, see below ``` You do not configure where the token comes from. As the A2A protocol defines, the agent declares it in the @@ -222,8 +222,15 @@ You do not configure where the token comes from. As the A2A protocol defines, th The scopes the card's `securityRequirements` list for that scheme are requested with the token. The CLI fetches a token with the client-credentials grant, reuses it across requests and refreshes it shortly before it expires. -The client secret is sent to the token endpoint the card names. Set `issuer_url` to pin it: the CLI then refuses -a card that points anywhere outside that issuer, so a tampered card cannot collect the secret. +`issuer_url` is optional and does two things: + +- **Pin.** The client secret is sent to the token endpoint the card names. With `issuer_url` set, the CLI refuses + a card that points anywhere outside that issuer, so a tampered card cannot collect the secret. +- **Fallback.** An agent whose card declares no security scheme cannot say where its tokens come from. The CLI + then discovers the token endpoint from `issuer_url`. Without it, such an agent fails with an authentication error. + +The [a2a-auth example](../examples/a2a-auth/) runs one agent behind a bearer token and one behind OIDC with +Keycloak, with a mock model and no API key. ### Notes diff --git a/internal/protocols/a2a/infrastructure/auth.go b/internal/protocols/a2a/infrastructure/auth.go index 4e23b1efd..404493de5 100644 --- a/internal/protocols/a2a/infrastructure/auth.go +++ b/internal/protocols/a2a/infrastructure/auth.go @@ -209,7 +209,8 @@ type tokenEndpoint struct { // declaredTokenEndpoint reads the token endpoint from the security schemes of // the agent's card: an openIdConnect scheme through its discovery document, or // an oauth2 scheme's client-credentials flow. With a pinned issuer the card may -// only point there, so a tampered card cannot send the client secret elsewhere. +// only point there, so a tampered card cannot send the client secret elsewhere, +// and a card that declares no scheme falls back to that issuer. func declaredTokenEndpoint(agentURL, pinnedIssuer string) (tokenEndpoint, error) { ctx, cancel := context.WithTimeout(context.Background(), oidcHTTPClient.Timeout) defer cancel() @@ -234,7 +235,11 @@ func declaredTokenEndpoint(agentURL, pinnedIssuer string) (tokenEndpoint, error) return endpoint, checkPinnedIssuer(endpoint.url, pinnedIssuer) } } - return tokenEndpoint{}, errors.New("the agent card declares no openIdConnect or oauth2 client-credentials security scheme") + if pinnedIssuer == "" { + return tokenEndpoint{}, errors.New("the agent card declares no openIdConnect or oauth2 client-credentials security scheme, set auth.oidc.issuer_url") + } + fallbackURL, err := discoverTokenEndpoint(strings.TrimSuffix(pinnedIssuer, "/") + "/.well-known/openid-configuration") + return tokenEndpoint{url: fallbackURL}, err } func requiredScopes(card *adk.AgentCard, schemeName string) []string { diff --git a/internal/protocols/a2a/infrastructure/auth_test.go b/internal/protocols/a2a/infrastructure/auth_test.go index c179f7413..48645f224 100644 --- a/internal/protocols/a2a/infrastructure/auth_test.go +++ b/internal/protocols/a2a/infrastructure/auth_test.go @@ -116,9 +116,9 @@ func TestNewClient_KeepsCredentialsOnTheAgentOrigin(t *testing.T) { } } -// fakeIssuer serves an OIDC discovery document and a token endpoint. Its first -// token expires at once, later ones are long lived. -func fakeIssuer(t *testing.T) (*httptest.Server, *atomic.Int32) { +// fakeIssuer serves an OIDC discovery document and a token endpoint that +// insists on wantScope. Its first token expires at once, later ones are long lived. +func fakeIssuer(t *testing.T, wantScope string) (*httptest.Server, *atomic.Int32) { t.Helper() var tokenRequests atomic.Int32 var issuer *httptest.Server @@ -128,7 +128,7 @@ func fakeIssuer(t *testing.T) (*httptest.Server, *atomic.Int32) { _, _ = fmt.Fprintf(w, `{"token_endpoint":%q}`, issuer.URL+"/token") return } - if err := r.ParseForm(); err != nil || r.Form.Get("audience") != "research-agent" || r.Form.Get("scope") != "tasks" { + if err := r.ParseForm(); err != nil || r.Form.Get("audience") != "research-agent" || r.Form.Get("scope") != wantScope { http.Error(w, "missing audience or scope", http.StatusBadRequest) return } @@ -170,7 +170,7 @@ func TestNewClient_OIDCTokenComesFromTheCardAndIsReused(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - issuer, _ := fakeIssuer(t) + issuer, _ := fakeIssuer(t, "tasks") agent := &authRecorder{card: fmt.Sprintf( `{"name":"research","securitySchemes":{"idp":%s},"securityRequirements":[{"schemes":{"idp":{"list":["tasks"]}}}]}`, tt.scheme(issuer.URL))} @@ -193,6 +193,25 @@ func TestNewClient_OIDCTokenComesFromTheCardAndIsReused(t *testing.T) { } } +// TestNewClient_OIDCFallsBackToTheConfiguredIssuer: an agent whose card +// declares no security scheme is reached through auth.oidc.issuer_url. +func TestNewClient_OIDCFallsBackToTheConfiguredIssuer(t *testing.T) { + issuer, _ := fakeIssuer(t, "") + agent := &authRecorder{} + srv := httptest.NewServer(agent) + defer srv.Close() + writeAgentsYAML(t, fmt.Sprintf(oidcAgentsYAML, srv.URL)+" issuer_url: "+issuer.URL+"\n") + t.Setenv("RESEARCH_CLIENT_SECRET", "s3cret") + + if _, err := NewClient(srv.URL).GetTask(t.Context(), adk.GetTaskRequest{ID: "t1"}); err != nil { + t.Fatalf("GetTask: %v", err) + } + want := []string{"", "Bearer token-1"} + if got := agent.headers(); strings.Join(got, ",") != strings.Join(want, ",") { + t.Fatalf("Authorization headers = %q, want %q", got, want) + } +} + func TestNewClient_OIDCRefusesACardItCannotTrust(t *testing.T) { tests := []struct { name string @@ -216,7 +235,7 @@ func TestNewClient_OIDCRefusesACardItCannotTrust(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - issuer, tokenRequests := fakeIssuer(t) + issuer, tokenRequests := fakeIssuer(t, "tasks") srv := httptest.NewServer(&authRecorder{card: tt.card(issuer.URL)}) defer srv.Close() agents := fmt.Sprintf(oidcAgentsYAML, srv.URL) From 88256c74dcec4a6fea631ab3fea7c34b3293b70f Mon Sep 17 00:00:00 2001 From: Eden Reich Date: Mon, 5 Oct 2026 01:33:20 +0200 Subject: [PATCH 4/5] docs(examples): add a2a-auth, agents behind a bearer token and OIDC Two mock agents protect /a2a, one with a static bearer token and one with OIDC against Keycloak. infer authenticates to each with the credentials in the example's agents.yaml, with the secrets read from the environment. The model is a tokenless mock, so no provider API key is needed. The Taskfile covers an authorized delegation to each agent, a wrong token, a missing token and a wrong client secret, the last three reported as authentication failures. Closes #1512 --- docs/examples.md | 1 + examples/a2a-auth/.infer/agents.yaml | 15 +++ examples/a2a-auth/README.md | 96 ++++++++++++++++++++ examples/a2a-auth/Taskfile.yml | 93 +++++++++++++++++++ examples/a2a-auth/docker-compose.yaml | 65 +++++++++++++ examples/a2a-auth/keycloak/realm-export.json | 36 ++++++++ examples/a2a-auth/scenarios.yaml | 23 +++++ 7 files changed, 329 insertions(+) create mode 100644 examples/a2a-auth/.infer/agents.yaml create mode 100644 examples/a2a-auth/README.md create mode 100644 examples/a2a-auth/Taskfile.yml create mode 100644 examples/a2a-auth/docker-compose.yaml create mode 100644 examples/a2a-auth/keycloak/realm-export.json create mode 100644 examples/a2a-auth/scenarios.yaml diff --git a/docs/examples.md b/docs/examples.md index 2c3415ca5..775cb2cc1 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -26,6 +26,7 @@ Most examples ship a Docker Compose file: | [postgres-storage](../examples/postgres-storage/) | Persisting conversations to PostgreSQL | | [a2a-traces](../examples/a2a-traces/) | End-to-end OpenTelemetry traces between the CLI and an A2A agent | | [a2a-gateway](../examples/a2a-gateway/) | Every A2A agent behind the gateway, addressed by tenant, with a mock model and no API key | +| [a2a-auth](../examples/a2a-auth/) | A2A agents behind a bearer token and behind OIDC with Keycloak, with a mock model and no API key | There is also an [examples/kubernetes](../examples/kubernetes/) manifest set for running the gateway and an agent on a cluster. diff --git a/examples/a2a-auth/.infer/agents.yaml b/examples/a2a-auth/.infer/agents.yaml new file mode 100644 index 000000000..570adbf3a --- /dev/null +++ b/examples/a2a-auth/.infer/agents.yaml @@ -0,0 +1,15 @@ +--- +agents: + - name: token-agent + url: http://localhost:8091 + run: false + auth: + token_env: TOKEN_AGENT_TOKEN + - name: oidc-agent + url: http://localhost:8092 + run: false + auth: + oidc: + client_id: infer + client_secret_env: OIDC_AGENT_CLIENT_SECRET + issuer_url: http://localhost:8090/realms/a2a diff --git a/examples/a2a-auth/README.md b/examples/a2a-auth/README.md new file mode 100644 index 000000000..216bcf5d7 --- /dev/null +++ b/examples/a2a-auth/README.md @@ -0,0 +1,96 @@ +# A2A agents behind authentication + +Two A2A agents protect their `/a2a` endpoint, and `infer` authenticates to each with the credentials from +[`.infer/agents.yaml`](.infer/agents.yaml): + +```text +infer ──┬──► mock-agent-token (static bearer token) + │ + └──► mock-agent-oidc (OIDC) + ▲ + Keycloak ───┘ issues the token infer presents +``` + +- **token-agent** is started with `A2A_AUTH_TOKEN`. `infer` sends that token, read from `TOKEN_AGENT_TOKEN`. +- **oidc-agent** is started with `A2A_AUTH_ENABLED` and trusts a Keycloak realm. `infer` fetches a token from + Keycloak with the client-credentials grant, using the client secret from `OIDC_AGENT_CLIENT_SECRET`, reuses it + and refreshes it before it expires. + +`agents.yaml` only names the environment variables. The secrets themselves are set in the +[`Taskfile.yml`](Taskfile.yml), which stands in for your shell or secret manager. + +The model is a [tokenless](https://github.com/inference-gateway/tokenless) mock scripted in +[`scenarios.yaml`](scenarios.yaml), and both agents run the [mock agent](https://github.com/inference-gateway/mock-agent), +which uses a mock LLM. The example needs no API key. You need Docker and [Task](https://taskfile.dev). + +## Requirements + +- An `infer` release with per-agent credentials (`auth` in `agents.yaml`). Until it is released, run `task build` + in the repository root and pass the build in, for example `task all INFER=../../infer`. + +## Layout + +```text +a2a-auth/ +├── .infer/agents.yaml # the two agents and where their credentials come from +├── docker-compose.yaml # both agents and Keycloak +├── keycloak/realm-export.json # the realm, the infer client and its audience mapper +├── Taskfile.yml # one task per scenario, with the secrets in its environment +└── scenarios.yaml # what the mock model says, per prompt +``` + +## Run it + +```bash +task up +task status +task all +task down +``` + +| Task | What happens | +| --- | --- | +| `task up` | Starts the token agent on `localhost:8091`, Keycloak on `localhost:8090` and the OIDC agent on `localhost:8092` | +| `task status` | Probes both agents with their credentials | +| `task token` | The model delegates to the token agent. The task is accepted and completes | +| `task oidc` | The model delegates to the OIDC agent with a token from Keycloak. The task is accepted and completes | +| `task wrong-token` | A wrong bearer token. The agent answers `401` and the model sees an authentication failure | +| `task missing-token` | `TOKEN_AGENT_TOKEN` is empty. The failure names the variable and no request is sent | +| `task wrong-secret` | A wrong client secret. Keycloak refuses to issue a token and the model sees an authentication failure | +| `task all` | Runs the five scenarios above | +| `task chat` | Opens `infer chat` with the mock model. Try the prompts from the scenarios | +| `task down` | Stops everything | + +### What to expect + +`task wrong-token`, `task missing-token` and `task wrong-secret` print the tool result the model receives: + +```text +Authentication failed for A2A agent "token-agent": the agent rejected the request with status 401, check its auth settings in agents.yaml +Authentication failed for A2A agent "token-agent": environment variable TOKEN_AGENT_TOKEN is not set +Authentication failed for A2A agent "oidc-agent": fetching the OIDC token: the token endpoint answered with status 401 unauthorized_client +``` + +The last two take a few seconds, because the request is retried before the failure is reported. + +## Configuration notes + +- The A2A protocol has an agent declare how to authenticate in the `securitySchemes` of its agent card, and + `infer` reads the OIDC token endpoint from there. The mock agent does not declare them yet, so + `auth.oidc.issuer_url` names the issuer instead. For an agent that does declare them, `issuer_url` is optional + and pins the issuer the card may point at. +- Keycloak and the OIDC agent share one network namespace (`network_mode: service:keycloak`), so the issuer is + `http://localhost:8090/realms/a2a` for `infer` on your machine and for the agent in its container. The token's + issuer has to match on both sides. +- The realm's `infer` client has a service account and an audience mapper that adds `mock-agent` to the token, + which is the audience the OIDC agent accepts (`A2A_AUTH_AUDIENCE`). +- `INFER_A2A_TOOLS_SUBMIT_TASK_REQUIRE_APPROVAL=false` lets headless mode submit tasks, since it has no one to ask + for approval. +- The secrets in `Taskfile.yml`, `docker-compose.yaml` and the realm export are demo values. Do not reuse them. + +## See Also + +- [A2A Agents Configuration, Authentication](../../docs/agents-configuration.md#authentication) +- [A2A Connections](../../docs/a2a-connections.md) +- [a2a-gateway](../a2a-gateway/), every agent behind the gateway +- [The gateway's Keycloak example](https://github.com/inference-gateway/inference-gateway/tree/main/examples/docker-compose/auth-keycloak) diff --git a/examples/a2a-auth/Taskfile.yml b/examples/a2a-auth/Taskfile.yml new file mode 100644 index 000000000..560d0be3e --- /dev/null +++ b/examples/a2a-auth/Taskfile.yml @@ -0,0 +1,93 @@ +version: '3' + +vars: + INFER: infer + +env: + INFER_GATEWAY_MOCK: 'true' + INFER_GATEWAY_MOCK_SCENARIOS: '{{.TASKFILE_DIR}}/scenarios.yaml' + INFER_AGENT_MODEL: mock/openai/gpt-4o + INFER_A2A_ENABLED: 'true' + INFER_A2A_TOOLS_SUBMIT_TASK_REQUIRE_APPROVAL: 'false' + TOKEN_AGENT_TOKEN: demo-token + OIDC_AGENT_CLIENT_SECRET: infer-secret + +tasks: + default: + desc: List the scenarios + silent: true + cmds: + - task --list + + up: + desc: Start the token agent, Keycloak and the OIDC agent + cmds: + - docker compose up -d --wait mock-agent-token mock-agent-oidc + + down: + desc: Stop everything + cmds: + - docker compose down + + status: + desc: Probe both agents with the credentials from agents.yaml + cmds: + - '{{.INFER}} agents status --project' + + token: + desc: The model delegates to the agent behind a static bearer token + cmds: + - task: headless + vars: + PROMPT: ask the token agent to echo hello + + oidc: + desc: The model delegates to the agent behind OIDC, with a token from Keycloak + cmds: + - task: headless + vars: + PROMPT: ask the oidc agent to echo hello + + wrong-token: + desc: A wrong bearer token is rejected and reported as an authentication failure + cmds: + - task: headless + vars: + OVERRIDE: TOKEN_AGENT_TOKEN=not-the-token + PROMPT: ask the token agent to echo hello + + missing-token: + desc: An unset token variable is reported before any request is sent + cmds: + - task: headless + vars: + OVERRIDE: TOKEN_AGENT_TOKEN= + PROMPT: ask the token agent to echo hello + + wrong-secret: + desc: Keycloak refuses a wrong client secret, reported as an authentication failure + cmds: + - task: headless + vars: + OVERRIDE: OIDC_AGENT_CLIENT_SECRET=not-the-secret + PROMPT: ask the oidc agent to echo hello + + chat: + desc: Chat with the mock model + interactive: true + cmds: + - '{{.INFER}} chat' + + all: + desc: Run every headless scenario + cmds: + - task: token + - task: oidc + - task: wrong-token + - task: missing-token + - task: wrong-secret + + headless: + internal: true + cmds: + - "{{.OVERRIDE}} {{.INFER}} headless --no-save \"{{.PROMPT}}\" | grep -E '\"role\":\"(tool|user)\"'" diff --git a/examples/a2a-auth/docker-compose.yaml b/examples/a2a-auth/docker-compose.yaml new file mode 100644 index 000000000..eb9abaff0 --- /dev/null +++ b/examples/a2a-auth/docker-compose.yaml @@ -0,0 +1,65 @@ +--- +services: + mock-agent-token: + image: ghcr.io/inference-gateway/mock-agent:latest + pull_policy: always + ports: + - "8091:8080" + environment: + A2A_AGENT_URL: http://localhost:8091 + A2A_AUTH_TOKEN: demo-token + restart: unless-stopped + networks: + - a2a-auth-network + + keycloak: + image: quay.io/keycloak/keycloak:26.3.2 + command: start-dev --import-realm + environment: + KC_BOOTSTRAP_ADMIN_USERNAME: admin + KC_BOOTSTRAP_ADMIN_PASSWORD: admin + KC_HTTP_PORT: 8090 + KC_HOSTNAME: http://localhost:8090 + volumes: + - ./keycloak/realm-export.json:/opt/keycloak/data/import/realm-export.json:ro + ports: + - "8090:8090" + - "8092:8080" + restart: unless-stopped + networks: + - a2a-auth-network + + keycloak-ready: + image: curlimages/curl:latest + depends_on: + - keycloak + restart: "no" + entrypoint: + - sh + - -c + - | + until curl -sf http://keycloak:8090/realms/a2a/.well-known/openid-configuration > /dev/null; do + echo 'Waiting for the Keycloak realm...' + sleep 3 + done + networks: + - a2a-auth-network + + mock-agent-oidc: + image: ghcr.io/inference-gateway/mock-agent:latest + pull_policy: always + network_mode: service:keycloak + environment: + A2A_AGENT_URL: http://localhost:8092 + A2A_AUTH_ENABLED: true + A2A_AUTH_ISSUER_URL: http://localhost:8090/realms/a2a + A2A_AUTH_CLIENT_ID: mock-agent + A2A_AUTH_AUDIENCE: mock-agent + depends_on: + keycloak-ready: + condition: service_completed_successfully + restart: unless-stopped + +networks: + a2a-auth-network: + driver: bridge diff --git a/examples/a2a-auth/keycloak/realm-export.json b/examples/a2a-auth/keycloak/realm-export.json new file mode 100644 index 000000000..c86d13d72 --- /dev/null +++ b/examples/a2a-auth/keycloak/realm-export.json @@ -0,0 +1,36 @@ +{ + "realm": "a2a", + "enabled": true, + "sslRequired": "none", + "registrationAllowed": false, + "displayName": "A2A agents", + "accessTokenLifespan": 300, + "clients": [ + { + "clientId": "infer", + "enabled": true, + "protocol": "openid-connect", + "publicClient": false, + "standardFlowEnabled": false, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": true, + "clientAuthenticatorType": "client-secret", + "secret": "infer-secret", + "fullScopeAllowed": true, + "protocolMappers": [ + { + "name": "mock-agent-audience", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-mapper", + "config": { + "included.custom.audience": "mock-agent", + "id.token.claim": "false", + "access.token.claim": "true", + "introspection.token.claim": "true" + } + } + ] + } + ] +} diff --git a/examples/a2a-auth/scenarios.yaml b/examples/a2a-auth/scenarios.yaml new file mode 100644 index 000000000..7715fc575 --- /dev/null +++ b/examples/a2a-auth/scenarios.yaml @@ -0,0 +1,23 @@ +fallback: + content: 'Try "ask the token agent to echo hello" or "ask the oidc agent to echo hello".' +scenarios: + - name: token-agent + match: '(?i)ask the token agent to echo hello' + turns: + - tool_calls: + - name: A2A_SubmitTask + args: + agent_url: http://localhost:8091 + task_description: echo hello + - content: I asked the token agent to echo hello. + - content: The token agent answered. + - name: oidc-agent + match: '(?i)ask the oidc agent to echo hello' + turns: + - tool_calls: + - name: A2A_SubmitTask + args: + agent_url: http://localhost:8092 + task_description: echo hello + - content: I asked the oidc agent to echo hello. + - content: The oidc agent answered. From ee4e1ad037f8c7380c8e8ac6c10f9eef9777a1e9 Mon Sep 17 00:00:00 2001 From: Eden Reich Date: Mon, 5 Oct 2026 18:58:35 +0200 Subject: [PATCH 5/5] docs(examples): read the OIDC token endpoint from the agent card in a2a-auth mock-agent 0.5.0 declares its runtime auth scheme on the agent card, so the OIDC agent's card now points at the Keycloak realm. Drop issuer_url from the example's agents.yaml so it shows the card-driven path, and pin the image to 0.5.0, since an older cached latest has no scheme and needs issuer_url. --- examples/a2a-auth/.infer/agents.yaml | 1 - examples/a2a-auth/README.md | 8 ++++---- examples/a2a-auth/docker-compose.yaml | 4 ++-- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/examples/a2a-auth/.infer/agents.yaml b/examples/a2a-auth/.infer/agents.yaml index 570adbf3a..b7be210d4 100644 --- a/examples/a2a-auth/.infer/agents.yaml +++ b/examples/a2a-auth/.infer/agents.yaml @@ -12,4 +12,3 @@ agents: oidc: client_id: infer client_secret_env: OIDC_AGENT_CLIENT_SECRET - issuer_url: http://localhost:8090/realms/a2a diff --git a/examples/a2a-auth/README.md b/examples/a2a-auth/README.md index 216bcf5d7..e39c02422 100644 --- a/examples/a2a-auth/README.md +++ b/examples/a2a-auth/README.md @@ -75,10 +75,10 @@ The last two take a few seconds, because the request is retried before the failu ## Configuration notes -- The A2A protocol has an agent declare how to authenticate in the `securitySchemes` of its agent card, and - `infer` reads the OIDC token endpoint from there. The mock agent does not declare them yet, so - `auth.oidc.issuer_url` names the issuer instead. For an agent that does declare them, `issuer_url` is optional - and pins the issuer the card may point at. +- The A2A protocol has an agent declare how to authenticate in the `securitySchemes` of its agent card. The OIDC + agent declares an `openIdConnect` scheme that points at the Keycloak realm, and `infer` reads the token endpoint + from there, so `agents.yaml` only holds the client. Set `auth.oidc.issuer_url` to pin the issuer the card may + point at, or to name the issuer for an agent whose card declares no scheme. - Keycloak and the OIDC agent share one network namespace (`network_mode: service:keycloak`), so the issuer is `http://localhost:8090/realms/a2a` for `infer` on your machine and for the agent in its container. The token's issuer has to match on both sides. diff --git a/examples/a2a-auth/docker-compose.yaml b/examples/a2a-auth/docker-compose.yaml index eb9abaff0..0a4b16165 100644 --- a/examples/a2a-auth/docker-compose.yaml +++ b/examples/a2a-auth/docker-compose.yaml @@ -1,7 +1,7 @@ --- services: mock-agent-token: - image: ghcr.io/inference-gateway/mock-agent:latest + image: ghcr.io/inference-gateway/mock-agent:0.5.0 pull_policy: always ports: - "8091:8080" @@ -46,7 +46,7 @@ services: - a2a-auth-network mock-agent-oidc: - image: ghcr.io/inference-gateway/mock-agent:latest + image: ghcr.io/inference-gateway/mock-agent:0.5.0 pull_policy: always network_mode: service:keycloak environment: