Skip to content
Closed
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
75 changes: 72 additions & 3 deletions internal/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ These apply to every client-mode command. Each command selects the agent it talk
| `--auth <creds>` | | Shorthand for `--svc-param "Authorization=<creds>"`. |
| `--credential-helper <spec>` | | Credential helper returning the headers for an agent: `[scope=]path`. Repeatable. Not read from the local `.env`. See [Credentials](#credentials). |
| `--unix-socket <path>` | | Send every HTTP request and gRPC connection through the Unix socket at `<path>`, whatever the URL's host. Applies to the card request and the REST, JSON-RPC and gRPC transports. Not read from the local `.env`. |
| `--oauth <mode>` | | OAuth sign-in for agents that require it: `auto` (default), `cached`, or `off`. See [OAuth](#oauth). |
| `--oauth-clients <json>` | | OAuth client registration per authorization server. Repeatable. Not read from the local `.env`. |
| `--oauth-default-client-id <id>` | | OAuth client ID for authorization servers that support Client ID Metadata Documents when no registration matches; empty disables it. Not read from the local `.env`. |
| `--tenant <id>` | | Tenant identifier. Passed on every request. |
| `--timeout <dur>` | | Request timeout. Default `30s`. |
| `--verbose` | `-v` | Verbose output to stderr. |
Expand Down Expand Up @@ -315,13 +318,79 @@ credential-helper:

a2a executes the configured helper command, so `credential-helper`, `--unix-socket` and `--ca-cert` are not read from the local `.env` found by walking up from the current directory: a `.env` in a cloned repository could otherwise run a command, redirect requests or choose the certificates a2a trusts. a2a ignores them there with a warning. Set them with the flag, the environment, `~/.config/a2a-cli/config.yaml`, or a file passed with `--config`.

### `auth get` - Inspect credentials
### OAuth

When an agent requires OAuth, a2a signs you in, stores the grant and attaches it to later calls, so you don't fetch and paste tokens.

```bash
a2a send -a https://agent.example.com "hello" # prompts once, saves the grant, sends
a2a send -a https://agent.example.com "again" # reuses the grant, refreshing it when needed
a2a auth login https://agent.example.com --device # sign in ahead of time, no browser needed
a2a send -a https://agent.example.com --oauth cached "from CI" # never prompts; fails if no grant is stored
```

| Flag | Values | Description |
|---|---|---|
| `--oauth` | `auto` (default), `cached`, `off` | `auto` signs in when an agent requires it and stderr is a terminal; without a terminal it fails like `cached`. `cached` only uses stored grants and otherwise fails with `A2ACLI_ERR_CREDENTIALS_MISSING` (exit 4). `off` disables OAuth. |
| `--oauth-clients` | JSON object or array | Client registrations, one per authorization server: `{"authority":"<issuer>","client_id":"<client id or CIMD URL>","scopes":[...]}` on the command line and in `A2ACLI_OAUTH_CLIENTS`; a list of such objects in config files. Not read from the local `.env`. See [Client registrations](#client-registrations). |
| `--oauth-default-client-id` | Client ID or CIMD URL, empty to disable | Client ID used with servers that support Client ID Metadata Documents when no registration matches. Default `https://a2a-protocol.org/oauth/a2a-cli.json`. Not read from the local `.env`. |

#### Where a2a learns how to sign in

| Source | When | What a2a reads |
|---|---|---|
| Agent card | Before the first request | An `oauth2` scheme: its `oauth2MetadataUrl` ([RFC 8414](https://www.rfc-editor.org/rfc/rfc8414) metadata), or inline `authorizationCode` / `deviceCode` endpoints. An `openIdConnect` scheme: its discovery document. The first `securityRequirements` entry made of one such scheme is used, with its scopes. |
| `401` with a `Bearer` challenge | `--endpoint`, a protected card, or a card that declares no OAuth | `resource_metadata`, or the well-known location, for the Protected Resource Metadata ([RFC 9728](https://www.rfc-editor.org/rfc/rfc9728)); redirects stay on the same origin. Then the first listed authorization server whose metadata validates and that has a usable client. |

OAuth is enforced only when the agent card requires it. A card that also accepts other credentials, such as an API key, or an agent that declares nothing, gets a stored grant when one applies, and the request is sent either way.

#### Sign-in

| Flow | Used when | What you see |
|---|---|---|
| Authorization code with PKCE ([RFC 7636](https://www.rfc-editor.org/rfc/rfc7636), [RFC 8252](https://www.rfc-editor.org/rfc/rfc8252)) | Default | A URL to open; a2a waits for the redirect on `http://127.0.0.1:<port>/oauth/callback`. |
| Device authorization ([RFC 8628](https://www.rfc-editor.org/rfc/rfc8628)) | `--device`, or the server has no authorization endpoint | A URL and a code to enter on any device. |

Tokens are only sent over `https`, or `http` to a loopback host. Every request to the authorization server carries the agent URL as `resource` ([RFC 8707](https://www.rfc-editor.org/rfc/rfc8707)), so the token only works for that agent. These requests go through the same transport as agent requests, so `--unix-socket` and `--ca-cert` apply to them. Signing in has its own 5-minute limit; `--timeout` covers the agent requests, which start after it. If signing in fails or the agent still rejects the token, the command fails with `A2ACLI_ERR_AUTH_FAILED` (exit 4).

Grants are stored per issuer, client ID, agent URL and scopes in `~/.config/a2a-cli/oauth/grants.json` (mode 0600). A grant also serves agents below its URL, and one obtained with more scopes than an agent asks for is reused. a2a refreshes a token shortly before it expires, or once when the agent rejects it with `error="invalid_token"`, and retries the request once.

#### Client registrations

The authorization server only talks to clients it knows. Configure one registration per authorization server, not per agent:

```yaml
# ~/.config/a2a-cli/config.yaml
oauth-clients:
- authority: https://login.example.com
client_id: 0f1e2d3c
scopes: [a2a.invoke] # optional; used when the agent names none
```

When no registration matches and the server advertises `client_id_metadata_document_supported`, a2a uses the [Client ID Metadata Document](https://datatracker.ietf.org/doc/draft-ietf-oauth-client-id-metadata-document/) `https://a2a-protocol.org/oauth/a2a-cli.json` as its client ID, so no registration is needed. Set `--oauth-default-client-id` to use another document, or to an empty value to turn this off.

#### Precedence

`--auth` and `--svc-param Authorization=…` win over a credential helper, which wins over OAuth. OAuth leaves alone any request that already carries an `Authorization` header.

### `auth` - Manage credentials

| Command | Flags | Description |
|---|---|---|
| `auth login <agent>` | `--device`, `--scope <scope>` (repeatable) | Sign in and store the grant without sending a message. |
| `auth list` | | Stored grants: resource, issuer, client, scopes and expiry. |
| `auth get <agent>` | `--reveal` | Credential helper headers and the stored grants that apply to the agent, redacted unless `--reveal`. |
| `auth logout <agent>` | `--all` | Revoke the grants that apply to the agent at the authorization server when it supports [RFC 7009](https://www.rfc-editor.org/rfc/rfc7009), then delete them locally. Other agents on the same host keep theirs. |

```bash
a2a auth get https://agent.example.com # header names, values redacted
a2a auth get https://agent.example.com --reveal # header values
a2a auth login https://agent.example.com --scope a2a.invoke
a2a auth list
a2a auth get https://agent.example.com --reveal
a2a auth logout --all
```

A script or a sandboxed agent can sign in once with `a2a auth login` and then run with `--oauth cached`. Both have to use the same `~/.config/a2a-cli`, so a sandbox with its own home directory doesn't see a grant stored outside it.

---

## Server Mode
Expand Down
9 changes: 7 additions & 2 deletions internal/cli/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,16 @@ import (
"github.com/spf13/cobra"
)

func newAuthCmd(cfg *globalConfig) *cobra.Command {
func newAuthCmd(cfg *globalConfig, d deps) *cobra.Command {
cmd := &cobra.Command{
Use: "auth",
Short: "Manage the credentials sent to agents",
}
cmd.AddCommand(newAuthGetCmd(cfg))
cmd.AddCommand(
newAuthLoginCmd(cfg, d),
newAuthLogoutCmd(cfg),
newAuthGetCmd(cfg),
newAuthListCmd(cfg),
)
return cmd
}
134 changes: 101 additions & 33 deletions internal/cli/auth_get.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,18 +27,28 @@ import (
"github.com/a2aproject/a2a-cli/internal/flagparse"
)

type helperResult struct {
spec string
headers map[string][]string
expires string
}

func newAuthGetCmd(cfg *globalConfig) *cobra.Command {
var reveal bool
cmd := &cobra.Command{
Use: "get <agent-url>",
Short: "Print the headers the credential helper returns for an agent",
Long: `Print the headers the credential helper returns for an agent.
Short: "Print the credentials sent to an agent: credential helper headers and stored OAuth grants",
Long: `Print the credentials sent to an agent: the headers the credential helper
returns for it, and the OAuth grants stored for its host.

A credential helper follows the Bazel credential helper protocol:
https://github.com/EngFlow/credential-helper-spec. --credential-helper takes
"[scope=]path", where scope is a host such as example.com, or *.example.com
for a domain and its subdomains. Config files list {scope, command} entries
under credential-helper. The most specific helper for the agent's host runs.`,
under credential-helper. The most specific helper for the agent's host runs.

OAuth grants are obtained with a2a auth login or when an agent requires
them; see a2a auth login --help. --reveal prints header values and access tokens.`,
Example: ` a2a auth get https://agent.example.com
a2a auth get https://agent.example.com --reveal`,
Args: cobra.ExactArgs(1),
Expand All @@ -51,54 +61,112 @@ under credential-helper. The most specific helper for the agent's host runs.`,
if !isHTTPURL(target) {
return clierr.Usage(fmt.Sprintf("auth get needs an http(s) agent URL, got %q", args[0]))
}
if cfg.credentials == nil {
return fmt.Errorf("no credential helper configured")
}
h, ok := credhelper.Select(cfg.credentials.Helpers, target)
if !ok {
return fmt.Errorf("no credential helper for %s", target)
helper, err := cfg.helperHeaders(cmd.Context(), target)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(cmd.Context(), cfg.timeout)
defer cancel()
resp, err := h.Get(ctx, target)
grants, err := cfg.grantsForAgent(target)
if err != nil {
return err
}
headers := resp.Headers
if !reveal {
headers = redactHeaders(headers)
if helper == nil && len(grants) == 0 {
return fmt.Errorf("no credential helper or OAuth grant for %s", target)
}
views := make([]grantView, 0, len(grants))
for _, g := range grants {
views = append(views, newGrantView(g, reveal))
}

if cfg.IsJSON() {
return cfg.PrintJSON(map[string]any{"url": target, "helper": h.Spec, "headers": headers, "expires": resp.Expires})
out := map[string]any{"url": target}
if helper != nil {
headers := helper.headers
if !reveal {
headers = redactHeaders(headers)
}
out["helper"], out["headers"], out["expires"] = helper.spec, headers, helper.expires
}
if len(views) > 0 {
out["oauth"] = views
}
return cfg.PrintJSON(out)
}
names := make([]string, 0, len(headers))
for name := range headers {
names = append(names, name)
if helper != nil {
if err := printHelperHeaders(cfg, helper, reveal); err != nil {
return err
}
}
slices.Sort(names)
rows := make([][]string, 0, len(names))
for _, name := range names {
rows = append(rows, []string{name, strings.Join(headers[name], ", ")})
if len(views) == 0 {
return nil
}
if _, err := fmt.Fprintf(cfg.Out, "helper: %s\n", h.Spec); err != nil {
if _, err := fmt.Fprintln(cfg.Out, "oauth grants:"); err != nil {
return err
}
if resp.Expires != "" {
if _, err := fmt.Fprintf(cfg.Out, "expires: %s\n", resp.Expires); err != nil {
return err
}
}
if len(rows) == 0 {
_, err := fmt.Fprintln(cfg.Out, "no headers")
if err := printGrantTable(cfg.Printer, views); err != nil {
return err
}
return cfg.PrintTable([]string{"HEADER", "VALUE"}, rows)
if reveal {
for _, v := range views {
if _, err := fmt.Fprintf(cfg.Out, "access token for %s: %s\n", v.Resource, v.AccessToken); err != nil {
return err
}
}
}
return nil
},
}
cmd.Flags().BoolVar(&reveal, "reveal", false, "Print header values instead of redacting them")
cmd.Flags().BoolVar(&reveal, "reveal", false, "Print header values and access tokens instead of redacting them")
return cmd
}

// helperHeaders runs the credential helper for target, or returns nil when none
// is configured for its host.
func (g *globalConfig) helperHeaders(ctx context.Context, target string) (*helperResult, error) {
if g.credentials == nil {
return nil, nil
}
h, ok := credhelper.Select(g.credentials.Helpers, target)
if !ok {
return nil, nil
}
ctx, cancel := context.WithTimeout(ctx, g.timeout)
defer cancel()
resp, err := h.Get(ctx, target)
if err != nil {
return nil, err
}
return &helperResult{spec: h.Spec, headers: resp.Headers, expires: resp.Expires}, nil
}

func printHelperHeaders(cfg *globalConfig, helper *helperResult, reveal bool) error {
headers := helper.headers
if !reveal {
headers = redactHeaders(headers)
}
names := make([]string, 0, len(headers))
for name := range headers {
names = append(names, name)
}
slices.Sort(names)
rows := make([][]string, 0, len(names))
for _, name := range names {
rows = append(rows, []string{name, strings.Join(headers[name], ", ")})
}
if _, err := fmt.Fprintf(cfg.Out, "helper: %s\n", helper.spec); err != nil {
return err
}
if helper.expires != "" {
if _, err := fmt.Fprintf(cfg.Out, "expires: %s\n", helper.expires); err != nil {
return err
}
}
if len(rows) == 0 {
_, err := fmt.Fprintln(cfg.Out, "no headers")
return err
}
return cfg.PrintTable([]string{"HEADER", "VALUE"}, rows)
}

func redactHeaders(headers map[string][]string) map[string][]string {
out := make(map[string][]string, len(headers))
for name, values := range headers {
Expand Down
Loading
Loading