diff --git a/cmd/agents/agents.go b/cmd/agents/agents.go index 92bbcaaef..562a05072 100644 --- a/cmd/agents/agents.go +++ b/cmd/agents/agents.go @@ -829,13 +829,20 @@ 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. +// describeAuth names an agent's authentication mode and where its token comes +// from. No secret is loaded, and a command's arguments stay out since one may +// carry a secret. func describeAuth(auth config.AgentAuth) string { - if auth.OIDC != nil { + switch { + case auth.OIDC != nil: return fmt.Sprintf("OIDC client %s (secret from %s)", auth.OIDC.ClientID, auth.OIDC.ClientSecretEnv) + case auth.TokenFile != "": + return fmt.Sprintf("bearer token from file %s", auth.TokenFile) + case len(auth.TokenCommand) > 0: + return fmt.Sprintf("bearer token from command %s", auth.TokenCommand[0]) + default: + return fmt.Sprintf("bearer token from %s", auth.TokenEnv) } - return fmt.Sprintf("bearer token from %s", auth.TokenEnv) } func (c *command) initAgents(cmd *cobra.Command, _ []string) error { diff --git a/cmd/agents/agents_list_test.go b/cmd/agents/agents_list_test.go index 4a4d1b8de..f4040221e 100644 --- a/cmd/agents/agents_list_test.go +++ b/cmd/agents/agents_list_test.go @@ -138,13 +138,22 @@ func TestDescribeAuth_NamesTheVariableNotTheSecret(t *testing.T) { ClientSecretEnv: "BILLING_CLIENT_SECRET", }}) + file := describeAuth(config.AgentAuth{TokenFile: "/run/secrets/token"}) + command := describeAuth(config.AgentAuth{TokenCommand: []string{"gcloud", "auth", "print-identity-token", "--audiences=s3cret-audience"}}) + if bearer != "bearer token from RESEARCH_TOKEN" { t.Errorf("bearer description = %q", bearer) } + if file != "bearer token from file /run/secrets/token" { + t.Errorf("file description = %q", file) + } + if command != "bearer token from command gcloud" { + t.Errorf("command description = %q", command) + } 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) + if strings.Contains(bearer+oidc+file+command, "s3cret") { + t.Errorf("descriptions leak a secret: %q %q %q %q", bearer, oidc, file, command) } } diff --git a/config/agents.go b/config/agents.go index 48f089a36..496029eb0 100644 --- a/config/agents.go +++ b/config/agents.go @@ -30,22 +30,27 @@ type AgentEntry struct { 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. +// AgentAuth says where the bearer token for an agent comes from: an environment +// variable, a file re-read on every request, a command, or an OIDC +// client-credentials grant. Exactly one is set. No secret is ever stored here, +// 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"` + TokenEnv string `yaml:"token_env,omitempty" mapstructure:"token_env,omitempty"` + TokenFile string `yaml:"token_file,omitempty" mapstructure:"token_file,omitempty"` + TokenCommand []string `yaml:"token_command,omitempty" mapstructure:"token_command,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. +// Scopes are requested in addition to the ones the card requires. 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"` + 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"` + Scopes []string `yaml:"scopes,omitempty" mapstructure:"scopes,omitempty"` } // DefaultAgentsConfig returns a default agents configuration diff --git a/config/agents_test.go b/config/agents_test.go index 83483c32c..ec893c134 100644 --- a/config/agents_test.go +++ b/config/agents_test.go @@ -471,6 +471,15 @@ func TestLoadAgents_AuthNamesVariablesNotSecrets(t *testing.T) { client_id: infer client_secret_env: BILLING_CLIENT_SECRET audience: billing-agent + scopes: [api://billing/.default] + - name: cluster + url: https://cluster.example.com + auth: + token_file: /var/run/secrets/kubernetes.io/serviceaccount/token + - name: cloud + url: https://cloud.example.com + auth: + token_command: [gcloud, auth, print-identity-token, --audiences=https://cloud.example.com] `), 0o600)) cfg, err := config.LoadAgents(path) @@ -485,7 +494,14 @@ func TestLoadAgents_AuthNamesVariablesNotSecrets(t *testing.T) { ClientID: "infer", ClientSecretEnv: "BILLING_CLIENT_SECRET", Audience: "billing-agent", + Scopes: []string{"api://billing/.default"}, }, billing.Auth.OIDC) + cluster, err := cfg.ReadEntry("cluster") + require.NoError(t, err) + require.Equal(t, &config.AgentAuth{TokenFile: "/var/run/secrets/kubernetes.io/serviceaccount/token"}, cluster.Auth) + cloud, err := cfg.ReadEntry("cloud") + require.NoError(t, err) + require.Equal(t, []string{"gcloud", "auth", "print-identity-token", "--audiences=https://cloud.example.com"}, cloud.Auth.TokenCommand) require.NoError(t, cfg.DeleteEntry("research")) rewritten, err := os.ReadFile(path) diff --git a/docs/a2a-connections.md b/docs/a2a-connections.md index 08144a809..7a615b4fe 100644 --- a/docs/a2a-connections.md +++ b/docs/a2a-connections.md @@ -255,10 +255,10 @@ a2a: ### 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`. +- An agent entry in `agents.yaml` can carry an `auth` block: a bearer token from an environment variable, a + file or a command, or an OIDC client-credentials grant. See [Authentication](agents-configuration.md#authentication). +- The block names where the secret comes from. Secrets 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. diff --git a/docs/agents-configuration.md b/docs/agents-configuration.md index 36f729d97..354c4257a 100644 --- a/docs/agents-configuration.md +++ b/docs/agents-configuration.md @@ -50,8 +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). +- **auth**: Credentials the CLI sends to the agent: a bearer token from an environment variable, a file or a + command, or an OIDC client-credentials grant. It never holds a secret. See [Authentication](#authentication). ## CLI Commands @@ -197,6 +197,48 @@ agents: export RESEARCH_AGENT_TOKEN=... ``` +### Bearer Token from a File + +For a token that a platform writes and rotates on disk: the projected service account token of a Kubernetes pod, +a JWT-SVID written by [spiffe-helper](https://github.com/spiffe/spiffe-helper), or a token rendered by Vault Agent. +The CLI reads the file on every request, so a rotated token is picked up without a restart. + +```yaml +agents: + - name: research + url: https://research.example.com + auth: + token_file: /var/run/secrets/kubernetes.io/serviceaccount/token +``` + +Nothing is provisioned to the CLI itself: the platform attests the workload and hands it the token. Pair it with +an agent whose `A2A_AUTH_ISSUER_URL` is the platform's issuer, for example the cluster's OIDC discovery endpoint or +the SPIRE OIDC Discovery Provider. + +### Bearer Token from a Command + +For a token minted by a cloud CLI or a secrets broker. The command's stdout, trimmed, is the token. It runs without +a shell, under a 10 second timeout, and its output is reused until the token expires: the `exp` claim when it is a +JWT, otherwise five minutes. + +```yaml +agents: + - name: billing + url: https://billing.example.com + auth: + token_command: [gcloud, auth, print-identity-token, --audiences=https://billing.example.com] +``` + +| Provider | Command | +| --- | --- | +| Google Cloud | `gcloud auth print-identity-token --audiences=` (with `--impersonate-service-account` off GCP) | +| Microsoft Entra ID | `az account get-access-token --resource api:// --query accessToken -o tsv` | +| HashiCorp Vault | `vault read -field=token identity/oidc/token/` | + +`token_command` is read from `~/.infer/agents.yaml` only. A project `.infer/agents.yaml` arrives with a clone and +the agent card is fetched without approval, so a command there is refused with an authentication error. Use +`token_file` in a project file. A failed command is reported by its name and exit status, never its output. + ### OIDC Client Credentials For an ADK agent started with `A2A_AUTH_ENABLED`, `A2A_AUTH_ISSUER_URL`, `A2A_AUTH_CLIENT_ID` and `A2A_AUTH_AUDIENCE`: @@ -211,6 +253,7 @@ agents: client_secret_env: BILLING_AGENT_CLIENT_SECRET audience: billing-agent # Optional issuer_url: https://idp.example.com/realms/agents # Optional, see below + scopes: [api://billing-agent/.default] # Optional, added to the card's scopes ``` You do not configure where the token comes from. As the A2A protocol defines, the agent declares it in the @@ -219,8 +262,9 @@ You do not configure where the token comes from. As the A2A protocol defines, th - 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 scopes the card's `securityRequirements` list for that scheme are requested with the token, plus any in +`scopes`. Microsoft Entra ID needs `api:///.default` there, since the ADK card declares none. 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: @@ -230,11 +274,13 @@ a token with the client-credentials grant, reuses it across requests and refresh 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. +Keycloak, with a mock model and no API key. The [a2a-auth-gcp](../examples/a2a-auth-gcp/), +[a2a-auth-entraid](../examples/a2a-auth-entraid/) and [a2a-auth-aws](../examples/a2a-auth-aws/) examples put +an agent behind Google, Microsoft Entra ID and Amazon Cognito and authenticate with the provider's own tokens. ### Notes -- Set either `token_env` or `oidc`, not both. +- Set exactly one of `token_env`, `token_file`, `token_command` and `oidc`. - 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 diff --git a/docs/examples.md b/docs/examples.md index 09c4a55ae..10526a756 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -27,6 +27,9 @@ Most examples ship a Docker Compose file: | [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 | +| [a2a-auth-gcp](../examples/a2a-auth-gcp/) | An A2A agent behind Google Cloud identity, authenticated with a service account ID token | +| [a2a-auth-entraid](../examples/a2a-auth-entraid/) | An A2A agent behind Microsoft Entra ID, with the OIDC client-credentials grant and `az` tokens | +| [a2a-auth-aws](../examples/a2a-auth-aws/) | An A2A agent behind Amazon Cognito, authenticated with a machine-to-machine token | | [a2a-gateway-auth](../examples/a2a-gateway-auth/) | The gateway applying OIDC auth, guardrails and tracing to A2A traffic, with a mock model and no API key | There is also an [examples/kubernetes](../examples/kubernetes/) manifest set for running the gateway and an diff --git a/examples/a2a-auth-aws/.env.example b/examples/a2a-auth-aws/.env.example new file mode 100644 index 000000000..457a1fb75 --- /dev/null +++ b/examples/a2a-auth-aws/.env.example @@ -0,0 +1,5 @@ +# Amazon Cognito - README.md has the commands that produce these values +AWS_REGION= +COGNITO_USER_POOL_ID= +COGNITO_CLIENT_ID= +COGNITO_CLIENT_SECRET= diff --git a/examples/a2a-auth-aws/.gitignore b/examples/a2a-auth-aws/.gitignore new file mode 100644 index 000000000..d99c904f9 --- /dev/null +++ b/examples/a2a-auth-aws/.gitignore @@ -0,0 +1,2 @@ +.env +.token diff --git a/examples/a2a-auth-aws/README.md b/examples/a2a-auth-aws/README.md new file mode 100644 index 000000000..7f4289d4e --- /dev/null +++ b/examples/a2a-auth-aws/README.md @@ -0,0 +1,93 @@ +# A2A agent behind Amazon Cognito + +The same setup as [a2a-auth](../a2a-auth/), with a Cognito user pool as the issuer instead of Keycloak. Read that +example first: it explains how the agent verifies tokens and how `agents.yaml` carries credentials, none of which +changes here. + +```text +infer ──► mock-agent (A2A_AUTH_ISSUER_URL=https://cognito-idp..amazonaws.com/) + ▲ + ╰── .token, written by get-token.sh from the AWS CLI +``` + +Cognito issues machine-to-machine tokens to an _app client_ that holds a secret. Those tokens carry no `aud` +claim (Cognito binds audiences only for user logins), so the agent checks the `client_id` claim against +`A2A_AUTH_AUDIENCE` instead, the check AWS documents for resource servers. `infer` reads the token from a file with +`token_file`, the shape a workload gets from a sidecar or an init step. + +The model is a [tokenless](https://github.com/inference-gateway/tokenless) mock scripted in +[`scenarios.yaml`](scenarios.yaml), so the example needs no LLM key. You need Docker, [Task](https://taskfile.dev) +and the [AWS CLI](https://aws.amazon.com/cli/) with credentials that can manage Cognito. + +## Setup + +```bash +AWS_REGION=eu-west-1 +POOL_ID=$(aws cognito-idp create-user-pool --region "$AWS_REGION" \ + --pool-name infer-a2a --query UserPool.Id --output text) + +# Machine clients must be allowed at least one custom scope from a resource server. +aws cognito-idp create-resource-server --region "$AWS_REGION" --user-pool-id "$POOL_ID" \ + --identifier infer-a2a-agent --name "Infer A2A agent" \ + --scopes ScopeName=invoke,ScopeDescription="Submit tasks to the agent" + +CLIENT_ID=$(aws cognito-idp create-user-pool-client --region "$AWS_REGION" --user-pool-id "$POOL_ID" \ + --client-name infer-a2a-client --generate-secret \ + --explicit-auth-flows ALLOW_CLIENT_TOKEN_AUTH \ + --allowed-o-auth-scopes infer-a2a-agent/invoke \ + --query UserPoolClient.ClientId --output text) +CLIENT_SECRET=$(aws cognito-idp describe-user-pool-client --region "$AWS_REGION" --user-pool-id "$POOL_ID" \ + --client-id "$CLIENT_ID" --query UserPoolClient.ClientSecret --output text) + +cat > .env < .token + + status: + desc: Probe the agent with the credentials from agents.yaml + cmds: + - '{{.INFER}} agents status --project' + + delegate: + desc: The model delegates to the agent, authenticated with the provider's token + cmds: + - task: headless + vars: + PROMPT: ask the aws agent to echo hello + + wrong-token: + desc: A token that is not Cognito's is rejected and reported as an authentication failure + cmds: + - echo not-a-token > .token + - task: headless + vars: + PROMPT: ask the aws 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: delegate + - task: wrong-token + + headless: + internal: true + cmds: + - "{{.INFER}} headless --no-save \"{{.PROMPT}}\" | grep -E '\"role\":\"(tool|user)\"'" diff --git a/examples/a2a-auth-aws/docker-compose.yaml b/examples/a2a-auth-aws/docker-compose.yaml new file mode 100644 index 000000000..364fd1b3e --- /dev/null +++ b/examples/a2a-auth-aws/docker-compose.yaml @@ -0,0 +1,18 @@ +--- +# One A2A agent with an Amazon Cognito user pool as the identity provider. Cognito machine tokens +# carry no aud, so the agent checks client_id against the audience instead, see the README. +services: + mock-agent: + image: ghcr.io/inference-gateway/mock-agent:0.5.0 + pull_policy: always + env_file: + - .env + ports: + - "8093:8080" + environment: + A2A_AGENT_URL: http://localhost:8093 + A2A_AUTH_ENABLED: true + A2A_AUTH_ISSUER_URL: https://cognito-idp.${AWS_REGION}.amazonaws.com/${COGNITO_USER_POOL_ID} + A2A_AUTH_CLIENT_ID: ${COGNITO_CLIENT_ID} + A2A_AUTH_AUDIENCE: ${COGNITO_CLIENT_ID} + restart: unless-stopped diff --git a/examples/a2a-auth-aws/get-token.sh b/examples/a2a-auth-aws/get-token.sh new file mode 100755 index 000000000..07c1065b3 --- /dev/null +++ b/examples/a2a-auth-aws/get-token.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# Print a machine-to-machine access token for the app client in .env. +set -euo pipefail +set -a +. "$(dirname "$0")/.env" +set +a + +aws cognito-idp get-client-token \ + --region "${AWS_REGION}" \ + --client-id "${COGNITO_CLIENT_ID}" \ + --secret "${COGNITO_CLIENT_SECRET}" \ + --query ClientAuthenticationResult.AccessToken \ + --output text diff --git a/examples/a2a-auth-aws/scenarios.yaml b/examples/a2a-auth-aws/scenarios.yaml new file mode 100644 index 000000000..80416f0c2 --- /dev/null +++ b/examples/a2a-auth-aws/scenarios.yaml @@ -0,0 +1,13 @@ +fallback: + content: 'Try "ask the aws agent to echo hello".' +scenarios: + - name: aws-agent + match: '(?i)ask the aws agent to echo hello' + turns: + - tool_calls: + - name: A2A_SubmitTask + args: + agent_url: http://localhost:8093 + task_description: echo hello + - content: I asked the aws agent to echo hello. + - content: The aws agent answered. diff --git a/examples/a2a-auth-entraid/.env.example b/examples/a2a-auth-entraid/.env.example new file mode 100644 index 000000000..ba426b7bb --- /dev/null +++ b/examples/a2a-auth-entraid/.env.example @@ -0,0 +1,5 @@ +# Microsoft Entra ID - README.md has the commands that produce these values +AZURE_TENANT_ID= +AZURE_API_APP_ID= +AZURE_CLIENT_APP_ID= +AZURE_CLIENT_SECRET= diff --git a/examples/a2a-auth-entraid/.gitignore b/examples/a2a-auth-entraid/.gitignore new file mode 100644 index 000000000..d99c904f9 --- /dev/null +++ b/examples/a2a-auth-entraid/.gitignore @@ -0,0 +1,2 @@ +.env +.token diff --git a/examples/a2a-auth-entraid/README.md b/examples/a2a-auth-entraid/README.md new file mode 100644 index 000000000..976eb6876 --- /dev/null +++ b/examples/a2a-auth-entraid/README.md @@ -0,0 +1,92 @@ +# A2A agent behind Microsoft Entra ID + +The same setup as [a2a-auth](../a2a-auth/), with Microsoft Entra ID as the issuer instead of Keycloak. Read that +example first: it explains how the agent verifies tokens and how `agents.yaml` carries credentials, none of which +changes here. + +```text +infer ──► mock-agent (A2A_AUTH_ISSUER_URL=https://login.microsoftonline.com//v2.0) + │ + ╰──► login.microsoftonline.com client-credentials grant, scope api:///.default +``` + +Two app registrations are involved: one represents the agent (the API the tokens are issued _for_) and one the +caller that requests them. `infer` runs the client-credentials grant itself from the `oidc` block, reading the token +endpoint from the agent card and the client secret from `AZURE_CLIENT_SECRET`. Entra requires the +`api:///.default` scope on that grant and the card declares none, which is what `scopes` is for. + +The model is a [tokenless](https://github.com/inference-gateway/tokenless) mock scripted in +[`scenarios.yaml`](scenarios.yaml), so the example needs no LLM key. You need Docker, [Task](https://taskfile.dev) +and the [Azure CLI](https://learn.microsoft.com/cli/azure/) logged in to the tenant. + +## Setup + +```bash +TENANT_ID=$(az account show --query tenantId -o tsv) + +# The API. Request v2 tokens, or Entra issues v1 tokens with another issuer. +API_APP_ID=$(az ad app create --display-name infer-a2a-agent --query appId -o tsv) +az ad app update --id "$API_APP_ID" --identifier-uris "api://$API_APP_ID" \ + --set api.requestedAccessTokenVersion=2 +az ad sp create --id "$API_APP_ID" + +# The caller: a confidential client with a secret. +CLIENT_APP_ID=$(az ad app create --display-name infer-a2a-client --query appId -o tsv) +az ad sp create --id "$CLIENT_APP_ID" +CLIENT_SECRET=$(az ad app credential reset --id "$CLIENT_APP_ID" --query password -o tsv) + +cat > .env </.default`, and the secret is only ever named by its variable. +- To go secretless, replace the block in your `~/.infer/agents.yaml` with the token the Azure CLI or a managed + identity mints: + + ```yaml + auth: + token_command: [az, account, get-access-token, --resource, api://, --query, accessToken, -o, tsv] + ``` + + On Azure, with a managed identity, point `token_command` at a script that reads the IMDS token endpoint, or + write the token to a file and use `token_file`. +- **v1 tokens.** If `requestedAccessTokenVersion` stays unset, Entra issues v1 tokens with + `iss: https://sts.windows.net//`, which the agent rejects because it discovered the `/v2.0` issuer. + Decode the token at [jwt.ms](https://jwt.ms) and check the `ver` claim. +- **`AADSTS500011`** means the API has no service principal in the tenant; the `az ad sp create` step creates it. +- **Every app in the tenant can request a token for your API** unless the API's service principal has "assignment + required" turned on. Authentication proves the token came from your tenant for your API, not _which_ app asked. + +## See Also + +- [A2A Agents Configuration, Authentication](../../docs/agents-configuration.md#authentication) +- [a2a-auth](../a2a-auth/), the same agent behind a static token and behind Keycloak +- [a2a-auth-gcp](../a2a-auth-gcp/) and [a2a-auth-aws](../a2a-auth-aws/) +- [Agent2Agent authentication in Microsoft Foundry](https://learn.microsoft.com/en-us/azure/foundry/agents/concepts/agent-to-agent-authentication) diff --git a/examples/a2a-auth-entraid/Taskfile.yml b/examples/a2a-auth-entraid/Taskfile.yml new file mode 100644 index 000000000..97f65547e --- /dev/null +++ b/examples/a2a-auth-entraid/Taskfile.yml @@ -0,0 +1,73 @@ +version: '3' + +vars: + INFER: infer + +dotenv: ['.env'] + +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' + +tasks: + default: + desc: List the scenarios + silent: true + cmds: + - task --list + + up: + desc: Start the agent, trusting the tenant's v2.0 issuer + cmds: + - docker compose up -d --wait + + down: + desc: Stop the agent + cmds: + - docker compose down + + token: + desc: Print an access token for the agent's API as the signed-in az account, into .token + cmds: + - ./get-token.sh > .token + + status: + desc: Probe the agent with the credentials from agents.yaml + cmds: + - '{{.INFER}} agents status --project' + + delegate: + desc: The model delegates to the agent, authenticated with the provider's token + cmds: + - task: headless + vars: + PROMPT: ask the entra agent to echo hello + + wrong-token: + desc: A wrong client secret is refused by Entra and reported as an authentication failure + env: + AZURE_CLIENT_SECRET: not-the-secret + cmds: + - task: headless + vars: + PROMPT: ask the entra agent to echo hello + + chat: + desc: Chat with the mock model + interactive: true + cmds: + - '{{.INFER}} chat' + + all: + desc: Run every headless scenario + cmds: + - task: delegate + - task: wrong-token + + headless: + internal: true + cmds: + - "{{.INFER}} headless --no-save \"{{.PROMPT}}\" | grep -E '\"role\":\"(tool|user)\"'" diff --git a/examples/a2a-auth-entraid/docker-compose.yaml b/examples/a2a-auth-entraid/docker-compose.yaml new file mode 100644 index 000000000..0e5d7f07d --- /dev/null +++ b/examples/a2a-auth-entraid/docker-compose.yaml @@ -0,0 +1,17 @@ +--- +# One A2A agent with Microsoft Entra ID as the identity provider. The IDs come from .env, see .env.example. +services: + mock-agent: + image: ghcr.io/inference-gateway/mock-agent:0.5.0 + pull_policy: always + env_file: + - .env + ports: + - "8093:8080" + environment: + A2A_AGENT_URL: http://localhost:8093 + A2A_AUTH_ENABLED: true + A2A_AUTH_ISSUER_URL: https://login.microsoftonline.com/${AZURE_TENANT_ID}/v2.0 + A2A_AUTH_CLIENT_ID: ${AZURE_CLIENT_APP_ID} + A2A_AUTH_AUDIENCE: ${AZURE_API_APP_ID} + restart: unless-stopped diff --git a/examples/a2a-auth-entraid/get-token.sh b/examples/a2a-auth-entraid/get-token.sh new file mode 100755 index 000000000..8017c10cc --- /dev/null +++ b/examples/a2a-auth-entraid/get-token.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +# Print an access token for the agent's API registration, as the signed-in az account. +set -euo pipefail +set -a +. "$(dirname "$0")/.env" +set +a + +az account get-access-token --resource "api://${AZURE_API_APP_ID}" --query accessToken -o tsv diff --git a/examples/a2a-auth-entraid/scenarios.yaml b/examples/a2a-auth-entraid/scenarios.yaml new file mode 100644 index 000000000..f02be1fff --- /dev/null +++ b/examples/a2a-auth-entraid/scenarios.yaml @@ -0,0 +1,13 @@ +fallback: + content: 'Try "ask the entra agent to echo hello".' +scenarios: + - name: entra-agent + match: '(?i)ask the entra agent to echo hello' + turns: + - tool_calls: + - name: A2A_SubmitTask + args: + agent_url: http://localhost:8093 + task_description: echo hello + - content: I asked the entra agent to echo hello. + - content: The entra agent answered. diff --git a/examples/a2a-auth-gcp/.env.example b/examples/a2a-auth-gcp/.env.example new file mode 100644 index 000000000..e4bcde208 --- /dev/null +++ b/examples/a2a-auth-gcp/.env.example @@ -0,0 +1,4 @@ +# Google Cloud - README.md has the commands that produce these values +GCP_SERVICE_ACCOUNT= +# Any URL you choose; tokens are minted for it and the agent accepts only it +AGENT_AUDIENCE=https://gcp-agent.example.com diff --git a/examples/a2a-auth-gcp/.gitignore b/examples/a2a-auth-gcp/.gitignore new file mode 100644 index 000000000..d99c904f9 --- /dev/null +++ b/examples/a2a-auth-gcp/.gitignore @@ -0,0 +1,2 @@ +.env +.token diff --git a/examples/a2a-auth-gcp/README.md b/examples/a2a-auth-gcp/README.md new file mode 100644 index 000000000..3432c2a28 --- /dev/null +++ b/examples/a2a-auth-gcp/README.md @@ -0,0 +1,86 @@ +# A2A agent behind Google Cloud identity + +The same setup as [a2a-auth](../a2a-auth/), with Google as the issuer instead of Keycloak. Read that example +first: it explains how the agent verifies tokens and how `agents.yaml` carries credentials, none of which changes here. + +```text +infer ──► mock-agent (A2A_AUTH_ISSUER_URL=https://accounts.google.com) + ▲ + ╰── .token, written by get-token.sh from gcloud +``` + +No identity-provider setup is needed. A Google service account can mint an OIDC ID token for any audience you +name, which is how Google's own services authenticate service-to-service calls on Cloud Run. There is no +client-credentials grant and no client secret: `infer` reads the token from a file with `token_file`, the shape a +workload gets from the metadata server or a sidecar. + +The model is a [tokenless](https://github.com/inference-gateway/tokenless) mock scripted in +[`scenarios.yaml`](scenarios.yaml), so the example needs no LLM key. You need Docker, [Task](https://taskfile.dev) +and the [gcloud CLI](https://cloud.google.com/sdk/docs/install) logged in. + +## Setup + +```bash +PROJECT_ID=$(gcloud config get-value project) +SA="infer-a2a-client@${PROJECT_ID}.iam.gserviceaccount.com" + +gcloud iam service-accounts create infer-a2a-client +# Let your own account mint tokens as the service account. +gcloud iam service-accounts add-iam-policy-binding "$SA" \ + --member="user:$(gcloud config get-value account)" \ + --role=roles/iam.serviceAccountTokenCreator + +cat > .env <, --audiences=https://gcp-agent.example.com] + ``` + + On GCP itself, with the service account attached to the workload, drop `--impersonate-service-account`, or read + the metadata server's `identity?audience=` endpoint into a file. +- **The audience is whatever you say it is.** `AGENT_AUDIENCE` must be identical in `.env` (read by the agent) + and in the token request (read by `get-token.sh`). +- **User credentials cannot set an audience.** `gcloud auth print-identity-token` without impersonation returns a + token for gcloud's own client ID, which the agent rejects. +- **Any Google account can pass authentication.** `https://accounts.google.com` is one issuer for every Google + account, so a valid token only proves "some Google identity minted a token for your audience". The mock agent + has no policy layer. Put the agent behind the gateway and add a guardrail on `input.identity.email`, as the + gateway's [auth-gcp example](https://github.com/inference-gateway/inference-gateway/tree/main/examples/docker-compose/auth-gcp) + does, before exposing a real agent this way. +- ID tokens live one hour. `task token` again when `task delegate` starts failing. + +## See Also + +- [A2A Agents Configuration, Authentication](../../docs/agents-configuration.md#authentication) +- [a2a-auth](../a2a-auth/), the same agent behind a static token and behind Keycloak +- [a2a-auth-entraid](../a2a-auth-entraid/) and [a2a-auth-aws](../a2a-auth-aws/) +- [Authenticate your AI agents on Cloud Run](https://docs.cloud.google.com/run/docs/ai/authenticate-agents) diff --git a/examples/a2a-auth-gcp/Taskfile.yml b/examples/a2a-auth-gcp/Taskfile.yml new file mode 100644 index 000000000..21cd0e74f --- /dev/null +++ b/examples/a2a-auth-gcp/Taskfile.yml @@ -0,0 +1,73 @@ +version: '3' + +vars: + INFER: infer + +dotenv: ['.env'] + +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' + +tasks: + default: + desc: List the scenarios + silent: true + cmds: + - task --list + + up: + desc: Start the agent, trusting Google as the issuer + cmds: + - docker compose up -d --wait + + down: + desc: Stop the agent + cmds: + - docker compose down + + token: + desc: Mint an ID token for the agent's audience and write it to .token + cmds: + - ./get-token.sh > .token + + status: + desc: Probe the agent with the credentials from agents.yaml + cmds: + - '{{.INFER}} agents status --project' + + delegate: + desc: The model delegates to the agent, authenticated with the provider's token + cmds: + - task: headless + vars: + PROMPT: ask the gcp agent to echo hello + + wrong-token: + desc: A token that is not Google's is rejected and reported as an authentication failure + cmds: + - echo not-a-token > .token + - task: headless + vars: + PROMPT: ask the gcp 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: delegate + - task: wrong-token + + headless: + internal: true + cmds: + - "{{.INFER}} headless --no-save \"{{.PROMPT}}\" | grep -E '\"role\":\"(tool|user)\"'" diff --git a/examples/a2a-auth-gcp/docker-compose.yaml b/examples/a2a-auth-gcp/docker-compose.yaml new file mode 100644 index 000000000..ff5b9f954 --- /dev/null +++ b/examples/a2a-auth-gcp/docker-compose.yaml @@ -0,0 +1,18 @@ +--- +# One A2A agent with Google as the identity provider. Google's issuer is shared by every +# Google account, so any service account that mints a token for AGENT_AUDIENCE passes, see the README. +services: + mock-agent: + image: ghcr.io/inference-gateway/mock-agent:0.5.0 + pull_policy: always + env_file: + - .env + ports: + - "8093:8080" + environment: + A2A_AGENT_URL: http://localhost:8093 + A2A_AUTH_ENABLED: true + A2A_AUTH_ISSUER_URL: https://accounts.google.com + A2A_AUTH_CLIENT_ID: infer + A2A_AUTH_AUDIENCE: ${AGENT_AUDIENCE} + restart: unless-stopped diff --git a/examples/a2a-auth-gcp/get-token.sh b/examples/a2a-auth-gcp/get-token.sh new file mode 100755 index 000000000..5781d1658 --- /dev/null +++ b/examples/a2a-auth-gcp/get-token.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +# Print an ID token for the service account in .env, minted for the agent's audience. +set -euo pipefail +set -a +. "$(dirname "$0")/.env" +set +a + +gcloud auth print-identity-token \ + --impersonate-service-account="${GCP_SERVICE_ACCOUNT}" \ + --audiences="${AGENT_AUDIENCE}" \ + --include-email diff --git a/examples/a2a-auth-gcp/scenarios.yaml b/examples/a2a-auth-gcp/scenarios.yaml new file mode 100644 index 000000000..dd5e14c03 --- /dev/null +++ b/examples/a2a-auth-gcp/scenarios.yaml @@ -0,0 +1,13 @@ +fallback: + content: 'Try "ask the gcp agent to echo hello".' +scenarios: + - name: gcp-agent + match: '(?i)ask the gcp agent to echo hello' + turns: + - tool_calls: + - name: A2A_SubmitTask + args: + agent_url: http://localhost:8093 + task_description: echo hello + - content: I asked the gcp agent to echo hello. + - content: The gcp agent answered. diff --git a/examples/a2a-auth/README.md b/examples/a2a-auth/README.md index e39c02422..92795f16a 100644 --- a/examples/a2a-auth/README.md +++ b/examples/a2a-auth/README.md @@ -93,4 +93,6 @@ The last two take a few seconds, because the request is retried before the failu - [A2A Agents Configuration, Authentication](../../docs/agents-configuration.md#authentication) - [A2A Connections](../../docs/a2a-connections.md) - [a2a-gateway](../a2a-gateway/), every agent behind the gateway +- [a2a-auth-gcp](../a2a-auth-gcp/), [a2a-auth-entraid](../a2a-auth-entraid/) and [a2a-auth-aws](../a2a-auth-aws/), + the same agent behind a cloud identity provider - [The gateway's Keycloak example](https://github.com/inference-gateway/inference-gateway/tree/main/examples/docker-compose/auth-keycloak) diff --git a/internal/protocols/a2a/README.md b/internal/protocols/a2a/README.md index dbddfb5f2..da00ed7c5 100644 --- a/internal/protocols/a2a/README.md +++ b/internal/protocols/a2a/README.md @@ -14,10 +14,11 @@ 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. +- An agent's `auth` block in `agents.yaml` gives it credentials: a bearer token from an environment variable, + a file re-read per request or a command (userspace file only, cached until the token expires), or an OIDC + client-credentials grant. 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. - The gateway is an agent too. An agent URL on the origin of `gateway.url` gets `gateway.api_key` as its bearer token, unless `agents.yaml` gives it an `auth` block. The composition root hands the pair over with `infrastructure.UseGatewayCredential`. A guardrail refusal from the gateway, a 403 with a JSON-RPC error, diff --git a/internal/protocols/a2a/infrastructure/auth.go b/internal/protocols/a2a/infrastructure/auth.go index c84afd74b..3bd928b06 100644 --- a/internal/protocols/a2a/infrastructure/auth.go +++ b/internal/protocols/a2a/infrastructure/auth.go @@ -2,6 +2,7 @@ package infrastructure import ( "context" + "encoding/base64" "encoding/json" "errors" "fmt" @@ -9,6 +10,8 @@ import ( "net/http" "net/url" "os" + "os/exec" + "path/filepath" "slices" "strings" "sync" @@ -184,15 +187,25 @@ func (t authTransport) RoundTrip(req *http.Request) (*http.Response, error) { func bearerToken(agent config.AgentEntry) (string, error) { auth := agent.Auth + sources := 0 + for _, set := range []bool{auth.TokenEnv != "", auth.TokenFile != "", len(auth.TokenCommand) > 0, auth.OIDC != nil} { + if set { + sources++ + } + } switch { - case auth.TokenEnv != "" && auth.OIDC != nil: - return "", &AuthError{Agent: agent.Name, Reason: "auth sets both token_env and oidc, keep one"} + case sources > 1: + return "", &AuthError{Agent: agent.Name, Reason: "auth sets more than one of token_env, token_file, token_command and oidc, keep one"} case auth.OIDC != nil: return oidcToken(agent) case auth.TokenEnv != "": return secretFromEnv(agent.Name, auth.TokenEnv) + case auth.TokenFile != "": + return secretFromFile(agent.Name, auth.TokenFile) + case len(auth.TokenCommand) > 0: + return commandToken(agent) default: - return "", &AuthError{Agent: agent.Name, Reason: "auth sets neither token_env nor oidc"} + return "", &AuthError{Agent: agent.Name, Reason: "auth sets none of token_env, token_file, token_command and oidc"} } } @@ -203,12 +216,133 @@ func secretFromEnv(agentName, variable string) (string, error) { return "", &AuthError{Agent: agentName, Reason: fmt.Sprintf("environment variable %s is not set", variable)} } -var oidcHTTPClient = &http.Client{Timeout: 10 * time.Second} +// secretFromFile reads the token on every call, so a token rotated in place by +// the kubelet, spiffe-helper or a secrets agent is picked up without a restart. +func secretFromFile(agentName, path string) (string, error) { + data, err := os.ReadFile(path) + if err != nil { + return "", &AuthError{Agent: agentName, Reason: fmt.Sprintf("reading token_file %s: %v", path, err)} + } + if token := strings.TrimSpace(string(data)); token != "" { + return token, nil + } + return "", &AuthError{Agent: agentName, Reason: fmt.Sprintf("token_file %s is empty", path)} +} + +// credentialTimeout bounds one token fetch, whether from an issuer or a command. +const credentialTimeout = 10 * time.Second + +// opaqueTokenTTL is how long a command's token is reused when it is not a JWT +// that states its own expiry. +// ponytail: flat 5 minute TTL for opaque tokens, add token_ttl if a provider needs it. +const opaqueTokenTTL = 5 * time.Minute + +type commandCacheKey struct { + agentURL string + command string +} + +type cachedToken struct { + token string + expires time.Time +} + +// commandTokens keeps the last token each command produced until it expires, +// so a cloud CLI is not run on every request. +var commandTokens = struct { + sync.Mutex + byCommand map[commandCacheKey]cachedToken +}{byCommand: map[commandCacheKey]cachedToken{}} + +// commandToken runs the agent's token_command and returns its stdout. The +// command is only honoured from the userspace agents.yaml, since a project file +// arrives with a clone and the agent card fetch runs without approval. +func commandToken(agent config.AgentEntry) (string, error) { + if !agentsPathIsUserspace() { + return "", &AuthError{Agent: agent.Name, Reason: "token_command is only read from ~/.infer/agents.yaml"} + } + key := commandCacheKey{agentURL: agent.URL, command: strings.Join(agent.Auth.TokenCommand, "\x00")} + + commandTokens.Lock() + defer commandTokens.Unlock() + + if cached, ok := commandTokens.byCommand[key]; ok && time.Now().Before(cached.expires) { + return cached.token, nil + } + token, err := runTokenCommand(agent.Name, agent.Auth.TokenCommand) + if err != nil { + return "", err + } + commandTokens.byCommand[key] = cachedToken{token: token, expires: tokenExpiry(token)} + return token, nil +} + +func runTokenCommand(agentName string, argv []string) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), credentialTimeout) + defer cancel() + + cmd := exec.CommandContext(ctx, argv[0], argv[1:]...) + cmd.Stderr = nil + stdout, err := cmd.Output() + if err != nil { + return "", &AuthError{Agent: agentName, Reason: fmt.Sprintf("token_command %s failed: %s", argv[0], commandFailure(ctx, err))} + } + if token := strings.TrimSpace(string(stdout)); token != "" { + return token, nil + } + return "", &AuthError{Agent: agentName, Reason: fmt.Sprintf("token_command %s printed no token", argv[0])} +} + +// commandFailure names why a command failed without quoting its output, which +// may carry the token or another secret. +func commandFailure(ctx context.Context, err error) string { + if ctx.Err() != nil { + return fmt.Sprintf("timed out after %s", credentialTimeout) + } + var exit *exec.ExitError + if errors.As(err, &exit) { + return fmt.Sprintf("exit status %d", exit.ExitCode()) + } + return err.Error() +} + +// tokenExpiry is when a command's token stops being reused: shortly before the +// exp claim when the token is a JWT, otherwise after opaqueTokenTTL. +func tokenExpiry(token string) time.Time { + fallback := time.Now().Add(opaqueTokenTTL) + parts := strings.Split(token, ".") + if len(parts) != 3 { + return fallback + } + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return fallback + } + var claims struct { + Exp int64 `json:"exp"` + } + if json.Unmarshal(payload, &claims) != nil || claims.Exp == 0 { + return fallback + } + return time.Unix(claims.Exp, 0).Add(-30 * time.Second) +} + +func agentsPathIsUserspace() bool { + home, err := os.UserHomeDir() + if err != nil { + return false + } + userspace := filepath.Join(home, config.ConfigDirName, config.AgentsFileName) + resolved, err := filepath.Abs(config.ResolveAgentsPath()) + return err == nil && resolved == userspace +} + +var oidcHTTPClient = &http.Client{Timeout: credentialTimeout} // oidcGrant identifies one client-credentials grant: a client at one agent. type oidcGrant struct { agentURL string - client config.AgentOIDC + client string } // oidcTokenSources keeps one token source per grant for the life of the @@ -236,7 +370,8 @@ func oidcToken(agent config.AgentEntry) (string, error) { // 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} + oidc := *agent.Auth.OIDC + grant := oidcGrant{agentURL: agent.URL, client: fmt.Sprintf("%+v", oidc)} oidcTokenSources.Lock() defer oidcTokenSources.Unlock() @@ -244,22 +379,22 @@ func oidcTokenSource(agent config.AgentEntry) (oauth2.TokenSource, error) { if source, ok := oidcTokenSources.byGrant[grant]; ok { return source, nil } - secret, err := secretFromEnv(agent.Name, grant.client.ClientSecretEnv) + secret, err := secretFromEnv(agent.Name, oidc.ClientSecretEnv) if err != nil { return nil, err } - endpoint, err := declaredTokenEndpoint(agent.URL, grant.client.IssuerURL) + endpoint, err := declaredTokenEndpoint(agent.URL, oidc.IssuerURL) if err != nil { return nil, &AuthError{Agent: agent.Name, Reason: err.Error()} } credentials := clientcredentials.Config{ - ClientID: grant.client.ClientID, + ClientID: oidc.ClientID, ClientSecret: secret, TokenURL: endpoint.url, - Scopes: endpoint.scopes, + Scopes: append(endpoint.scopes, oidc.Scopes...), } - if grant.client.Audience != "" { - credentials.EndpointParams = url.Values{"audience": {grant.client.Audience}} + if oidc.Audience != "" { + credentials.EndpointParams = url.Values{"audience": {oidc.Audience}} } source := credentials.TokenSource(context.WithValue(context.Background(), oauth2.HTTPClient, oidcHTTPClient)) oidcTokenSources.byGrant[grant] = source @@ -279,7 +414,7 @@ type tokenEndpoint struct { // 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) + ctx, cancel := context.WithTimeout(context.Background(), credentialTimeout) defer cancel() card, err := client.NewClient(runningURL(agentURL)).GetAgentCard(ctx) diff --git a/internal/protocols/a2a/infrastructure/auth_test.go b/internal/protocols/a2a/infrastructure/auth_test.go index 88424be2b..9ef5c0491 100644 --- a/internal/protocols/a2a/infrastructure/auth_test.go +++ b/internal/protocols/a2a/infrastructure/auth_test.go @@ -2,15 +2,19 @@ package infrastructure import ( "cmp" + "encoding/base64" "errors" "fmt" "net/http" "net/http/httptest" "os" + "path/filepath" + "slices" "strings" "sync" "sync/atomic" "testing" + "time" client "github.com/inference-gateway/adk/client" adk "github.com/inference-gateway/adk/types" @@ -474,3 +478,207 @@ func TestRejection_ReadsTheGatewayResponses(t *testing.T) { }) } } + +// writeUserspaceAgentsYAML makes body the ~/.infer/agents.yaml of a home +// directory that holds no project, so token_command is honoured. +func writeUserspaceAgentsYAML(t *testing.T, body string) { + t.Helper() + dir := t.TempDir() + t.Chdir(dir) + t.Setenv("HOME", dir) + if err := os.MkdirAll(filepath.Join(dir, ".infer"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, ".infer", "agents.yaml"), []byte(body), 0o600); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(dir, "project"), 0o755); err != nil { + t.Fatal(err) + } + t.Chdir(filepath.Join(dir, "project")) +} + +func TestNewClient_TokenFileIsReadOnEveryRequest(t *testing.T) { + agent := &authRecorder{} + srv := httptest.NewServer(agent) + defer srv.Close() + tokenPath := filepath.Join(t.TempDir(), "token") + if err := os.WriteFile(tokenPath, []byte("first\n"), 0o600); err != nil { + t.Fatal(err) + } + writeAgentsYAML(t, fmt.Sprintf("agents:\n - name: research\n url: %s\n auth:\n token_file: %s\n", srv.URL, tokenPath)) + + c := NewClient(srv.URL) + if err := fetchCard(t, c); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(tokenPath, []byte("second"), 0o600); err != nil { + t.Fatal(err) + } + if err := fetchTask(t, c); err != nil { + t.Fatal(err) + } + + want := []string{"Bearer first", "Bearer second"} + if got := agent.headers(); !slices.Equal(got, want) { + t.Fatalf("headers = %q, want %q", got, want) + } +} + +func TestNewClient_UnreadableOrEmptyTokenFileIsAnAuthError(t *testing.T) { + empty := filepath.Join(t.TempDir(), "empty") + if err := os.WriteFile(empty, []byte(" \n"), 0o600); err != nil { + t.Fatal(err) + } + for name, path := range map[string]string{"missing": filepath.Join(t.TempDir(), "missing"), "empty": empty} { + 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_file: %s\n", srv.URL, path)) + + err := fetchCard(t, NewClient(srv.URL)) + + var authErr *AuthError + if !errors.As(err, &authErr) || !strings.Contains(authErr.Reason, path) { + t.Fatalf("err = %v, want an AuthError naming %s", err, path) + } + if len(agent.headers()) != 0 { + t.Fatal("a request was sent without credentials") + } + }) + } +} + +// countingTokenCommand returns an argv that prints token and appends one line +// to a counter file, and a function reading how often it ran. +func countingTokenCommand(t *testing.T, token string) ([]string, func() int) { + t.Helper() + counter := filepath.Join(t.TempDir(), "runs") + argv := []string{"sh", "-c", fmt.Sprintf("echo run >> %s && printf '%%s\\n' '%s'", counter, token)} + runs := func() int { + data, _ := os.ReadFile(counter) + return strings.Count(string(data), "run") + } + return argv, runs +} + +func agentsYAMLWithCommand(url string, argv []string) string { + quoted := make([]string, len(argv)) + for i, arg := range argv { + quoted[i] = fmt.Sprintf("%q", arg) + } + return fmt.Sprintf("agents:\n - name: research\n url: %s\n auth:\n token_command: [%s]\n", url, strings.Join(quoted, ", ")) +} + +func expiredJWT(t *testing.T) string { + t.Helper() + payload := base64.RawURLEncoding.EncodeToString([]byte(fmt.Sprintf(`{"exp":%d}`, time.Now().Add(-time.Minute).Unix()))) + return "eyJhbGciOiJub25lIn0." + payload + ".sig" +} + +func TestNewClient_TokenCommandIsReusedUntilItExpires(t *testing.T) { + agent := &authRecorder{} + srv := httptest.NewServer(agent) + defer srv.Close() + argv, runs := countingTokenCommand(t, "cmd-token") + writeUserspaceAgentsYAML(t, agentsYAMLWithCommand(srv.URL, argv)) + + c := NewClient(srv.URL) + for range 2 { + if err := fetchTask(t, c); err != nil { + t.Fatal(err) + } + } + + if got := agent.headers(); !slices.Equal(got, []string{"Bearer cmd-token", "Bearer cmd-token"}) { + t.Fatalf("headers = %q", got) + } + if runs() != 1 { + t.Fatalf("the command ran %d times, want once", runs()) + } +} + +func TestNewClient_ExpiredJWTFromTokenCommandIsFetchedAgain(t *testing.T) { + agent := &authRecorder{} + srv := httptest.NewServer(agent) + defer srv.Close() + argv, runs := countingTokenCommand(t, expiredJWT(t)) + writeUserspaceAgentsYAML(t, agentsYAMLWithCommand(srv.URL, argv)) + + c := NewClient(srv.URL) + for range 2 { + if err := fetchTask(t, c); err != nil { + t.Fatal(err) + } + } + + if runs() != 2 { + t.Fatalf("the command ran %d times, want twice", runs()) + } +} + +func TestNewClient_FailingTokenCommandIsAnAuthErrorWithoutItsOutput(t *testing.T) { + tests := []struct { + name string + argv []string + want string + }{ + {"non-zero exit", []string{"sh", "-c", "echo leaked-secret; exit 3"}, "exit status 3"}, + {"no output", []string{"true"}, "printed no token"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + agent := &authRecorder{} + srv := httptest.NewServer(agent) + defer srv.Close() + writeUserspaceAgentsYAML(t, agentsYAMLWithCommand(srv.URL, tt.argv)) + + err := fetchCard(t, NewClient(srv.URL)) + + var authErr *AuthError + if !errors.As(err, &authErr) || !strings.Contains(authErr.Reason, tt.want) { + t.Fatalf("err = %v, want an AuthError containing %q", err, tt.want) + } + if strings.Contains(authErr.Reason, "leaked-secret") { + t.Fatalf("the error quotes the command output: %v", err) + } + if len(agent.headers()) != 0 { + t.Fatal("a request was sent without credentials") + } + }) + } +} + +func TestNewClient_TokenCommandInAProjectFileIsRefused(t *testing.T) { + agent := &authRecorder{} + srv := httptest.NewServer(agent) + defer srv.Close() + writeAgentsYAML(t, agentsYAMLWithCommand(srv.URL, []string{"echo", "tok"})) + t.Setenv("HOME", t.TempDir()) + + err := fetchCard(t, NewClient(srv.URL)) + + var authErr *AuthError + if !errors.As(err, &authErr) || !strings.Contains(authErr.Reason, "~/.infer/agents.yaml") { + t.Fatalf("err = %v, want an AuthError naming the userspace file", err) + } + if len(agent.headers()) != 0 { + t.Fatal("a request was sent without credentials") + } +} + +func TestNewClient_SeveralCredentialSourcesAreAnAuthError(t *testing.T) { + agent := &authRecorder{} + srv := httptest.NewServer(agent) + defer srv.Close() + t.Setenv("RESEARCH_TOKEN", "s3cret") + writeAgentsYAML(t, fmt.Sprintf("agents:\n - name: research\n url: %s\n auth:\n token_env: RESEARCH_TOKEN\n token_file: /dev/null\n", srv.URL)) + + err := fetchCard(t, NewClient(srv.URL)) + + var authErr *AuthError + if !errors.As(err, &authErr) || !strings.Contains(authErr.Reason, "keep one") { + t.Fatalf("err = %v, want an AuthError asking for one source", err) + } +}