diff --git a/cmd/agents/agents.go b/cmd/agents/agents.go index 7f452cf98..d988a83f9 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 (secret from %s)", auth.OIDC.ClientID, 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..48f089a36 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,25 @@ 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 the client of a client-credentials grant. Where to get the token +// 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"` + Audience string `yaml:"audience,omitempty" mapstructure:"audience,omitempty"` + IssuerURL string `yaml:"issuer_url,omitempty" mapstructure:"issuer_url,omitempty"` } // DefaultAgentsConfig returns a default agents configuration @@ -148,6 +168,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..0afec2a8d 100644 --- a/docs/a2a-connections.md +++ b/docs/a2a-connections.md @@ -253,6 +253,16 @@ 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. +- 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 and is used when the card declares none. + ### Network Security - A2A connections require proper URL validation @@ -299,6 +309,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..36f729d97 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,79 @@ 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: + client_id: infer + client_secret_env: BILLING_AGENT_CLIENT_SECRET + audience: billing-agent # Optional + 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 +`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. + +`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 + +- 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/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..b7be210d4 --- /dev/null +++ b/examples/a2a-auth/.infer/agents.yaml @@ -0,0 +1,14 @@ +--- +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 diff --git a/examples/a2a-auth/README.md b/examples/a2a-auth/README.md new file mode 100644 index 000000000..e39c02422 --- /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. 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. +- 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..0a4b16165 --- /dev/null +++ b/examples/a2a-auth/docker-compose.yaml @@ -0,0 +1,65 @@ +--- +services: + mock-agent-token: + image: ghcr.io/inference-gateway/mock-agent:0.5.0 + 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:0.5.0 + 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. 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..a51ea738a 100644 --- a/internal/protocols/a2a/README.md +++ b/internal/protocols/a2a/README.md @@ -14,6 +14,10 @@ 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. 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 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..404493de5 --- /dev/null +++ b/internal/protocols/a2a/infrastructure/auth.go @@ -0,0 +1,291 @@ +package infrastructure + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "maps" + "net/http" + "net/url" + "os" + "regexp" + "slices" + "strings" + "sync" + "time" + + 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, URLs and status codes, never a secret. +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) + 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} + +// 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[oidcGrant]oauth2.TokenSource +}{byGrant: map[oidcGrant]oauth2.TokenSource{}} + +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: agent.Name, Reason: "fetching the OIDC token: " + tokenFailureReason(err)} + } + return token.AccessToken, nil +} + +// 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(agent.Name, grant.client.ClientSecretEnv) + if err != nil { + return nil, err + } + endpoint, err := declaredTokenEndpoint(agent.URL, grant.client.IssuerURL) + if err != nil { + return nil, &AuthError{Agent: agent.Name, Reason: err.Error()} + } + credentials := clientcredentials.Config{ + ClientID: grant.client.ClientID, + ClientSecret: secret, + TokenURL: endpoint.url, + Scopes: endpoint.scopes, + } + 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 +} + +// 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, +// 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() + + card, err := client.NewClient(agentURL).GetAgentCard(ctx) + if err != nil { + 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) + } + } + 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 { + 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("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 OIDC discovery document: %w", err) + } + if discovery.TokenEndpoint == "" { + return "", errors.New("the OIDC 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..48645f224 --- /dev/null +++ b/internal/protocols/a2a/infrastructure/auth_test.go @@ -0,0 +1,333 @@ +package infrastructure + +import ( + "cmp" + "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 + card 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(cmp.Or(r.card, `{"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) + } + } +} + +// 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 + 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" || r.Form.Get("scope") != wantScope { + http.Error(w, "missing audience or scope", 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) + })) + t.Cleanup(issuer.Close) + return issuer, &tokenRequests +} + +const oidcAgentsYAML = `agents: + - name: research + url: %s + auth: + oidc: + client_id: infer + client_secret_env: RESEARCH_CLIENT_SECRET + audience: research-agent +` + +// 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, "tasks") + 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) + } + }) + } +} + +// 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 + 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, "tasks") + 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) + } + }) + } +} + +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()) +}