Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 11 additions & 4 deletions cmd/agents/agents.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
13 changes: 11 additions & 2 deletions cmd/agents/agents_list_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
23 changes: 14 additions & 9 deletions config/agents.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions config/agents_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down
8 changes: 4 additions & 4 deletions docs/a2a-connections.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
58 changes: 52 additions & 6 deletions docs/agents-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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=<agent audience>` (with `--impersonate-service-account` off GCP) |
| Microsoft Entra ID | `az account get-access-token --resource api://<api app id> --query accessToken -o tsv` |
| HashiCorp Vault | `vault read -field=token identity/oidc/token/<role>` |

`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`:
Expand All @@ -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
Expand All @@ -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://<api app id>/.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:

Expand All @@ -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
Expand Down
3 changes: 3 additions & 0 deletions docs/examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions examples/a2a-auth-aws/.env.example
Original file line number Diff line number Diff line change
@@ -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=
2 changes: 2 additions & 0 deletions examples/a2a-auth-aws/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
.env
.token
93 changes: 93 additions & 0 deletions examples/a2a-auth-aws/README.md
Original file line number Diff line number Diff line change
@@ -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.<region>.amazonaws.com/<pool>)
▲
╰── .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 <<ENV
AWS_REGION=$AWS_REGION
COGNITO_USER_POOL_ID=$POOL_ID
COGNITO_CLIENT_ID=$CLIENT_ID
COGNITO_CLIENT_SECRET=$CLIENT_SECRET
ENV
```

## Run it

```bash
task up
task all
task down
```

| Task | What happens |
| --- | --- |
| `task up` | Starts the agent on `localhost:8093`, trusting the user pool's issuer for the app client ID |
| `task token` | Runs `get-token.sh` (`aws cognito-idp get-client-token`) and writes the token to `.token` |
| `task delegate` | The model delegates to the agent. The task is accepted and completes |
| `task wrong-token` | Overwrites `.token` with junk. The agent answers `401` and the model sees an authentication failure |
| `task all` | Runs the three above |
| `task chat` | Opens `infer chat` with the mock model. Try "ask the aws agent to echo hello" |
| `task down` | Stops the agent |

## Configuration notes

- [`.infer/agents.yaml`](.infer/agents.yaml) uses `token_file: .token`. A project `agents.yaml` cannot run
commands, so the token is written by `task token`. In your own `~/.infer/agents.yaml` let `infer` fetch and
refresh it:

```yaml
auth:
token_command: [/path/to/a2a-auth-aws/get-token.sh]
```

With a user pool domain configured, the `oidc` block works too: the agent card's discovery document names the
token endpoint and `client_secret_env` holds the app client secret.
- **No `aud` claim.** Decode the token and you find `client_id`, `token_use: access` and `scope`, but no `aud`.
That is expected, and why `A2A_AUTH_AUDIENCE` is the app client ID.
- **`get-client-token` needs `ALLOW_CLIENT_TOKEN_AUTH`.** An app client created for user sign-in flows cannot use
it; create a separate machine client.
- **Bedrock AgentCore** agents accept either a JWT from an OIDC issuer like this one or IAM SigV4 request
signing. `infer` sends bearer tokens only, so configure such an agent for JWT inbound auth.

## 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-entraid](../a2a-auth-entraid/)
- [Inbound authentication for Amazon Bedrock AgentCore](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/runtime-oauth.html)
Loading
Loading