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
3 changes: 3 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,15 @@ require (
github.com/joho/godotenv v1.5.1
github.com/spf13/cobra v1.10.2
github.com/spf13/pflag v1.0.10
github.com/zalando/go-keyring v0.2.8
golang.org/x/sys v0.49.0
google.golang.org/grpc v1.84.0
gopkg.in/yaml.v3 v3.0.1
)

require (
github.com/danieljoos/wincred v1.2.3 // indirect
github.com/godbus/dbus/v5 v5.2.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
golang.org/x/mod v0.42.0 // indirect
Expand Down
14 changes: 14 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@ github.com/a2aproject/a2a-go v0.3.15/go.mod h1:I7Cm+a1oL+UT6zMoP+roaRE5vdfUa1iQG
github.com/a2aproject/a2a-go/v2 v2.6.0 h1:QflB0gHxOP0o5quvA/h9EbYgfOsmRsSmFkeBYtKdwwk=
github.com/a2aproject/a2a-go/v2 v2.6.0/go.mod h1:rvFIb0sBJl2qmJRYU2fQxI6hhIQksmGGMEhT/uRwWww=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ=
github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
Expand All @@ -13,12 +19,20 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs=
github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
Expand Down
6 changes: 4 additions & 2 deletions internal/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ These apply to every client-mode command. Each command selects the agent it talk
| `--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`. |
| `--oauth-store <store>` | | Where OAuth grants are kept: `file` (default) or `keychain`. 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 @@ -334,6 +335,7 @@ a2a send -a https://agent.example.com --oauth cached "from CI" # never prompt
| `--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`. |
| `--oauth-store` | `file` (default), `keychain` | `file` keeps grants, tokens included, in `~/.config/a2a-cli/oauth/grants.json` (mode 0600). `keychain` keeps the tokens in the macOS Keychain, Windows Credential Manager or Linux Secret Service, one entry per token, and the rest of each grant in `keychain-grants.json`; the two stores keep separate grants. Without a keychain, or when a token is too large for it, saving a grant fails rather than writing the tokens to disk. Not read from the local `.env`. |

#### Where a2a learns how to sign in

Expand All @@ -353,7 +355,7 @@ OAuth is enforced only when the agent card requires it. A card that also accepts

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.
Grants are stored per issuer, client ID, agent URL and scopes in `~/.config/a2a-cli/oauth/grants.json` (mode 0600), or in the OS keychain with an index in `keychain-grants.json` under `--oauth-store keychain`. 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

Expand Down Expand Up @@ -389,7 +391,7 @@ 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.
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` and `--oauth-store`, so a sandbox with its own home directory doesn't see a grant stored outside it.

---

Expand Down
4 changes: 2 additions & 2 deletions internal/cli/auth_login.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,8 @@ a2a learns where to sign in from the agent card's oauth2 or openIdConnect
security scheme, or from the Bearer challenge of a 401 response and the
agent's protected resource metadata. It prints a URL to open and waits for the
redirect to 127.0.0.1; with --device it prints a URL and a code to enter on any
device. The grant is stored in ~/.config/a2a-cli/oauth/grants.json and
refreshed when it expires.
device. The grant is stored in ~/.config/a2a-cli/oauth/grants.json (or the OS
keychain with --oauth-store keychain) and refreshed when it expires.

Other commands sign in the same way when an agent requires it (--oauth auto,
the default) if a terminal is attached. --oauth cached only uses stored grants,
Expand Down
13 changes: 12 additions & 1 deletion internal/cli/oauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,18 @@ func (g *globalConfig) newOAuthStore(d deps) (oauth.Store, error) {
return nil, err
}
}
return &oauth.FileStore{Dir: dir}, nil
switch g.oauthStore {
case "", "file":
return &oauth.FileStore{Dir: dir}, nil
case "keychain":
kr := d.keyring
if kr == nil {
kr = oauth.SystemKeyring{}
}
return &oauth.KeyringStore{Index: &oauth.FileStore{Dir: dir, Name: oauth.KeyringIndexName}, Keyring: kr}, nil
default:
return nil, clierr.Usage(fmt.Sprintf("invalid --oauth-store %q (want file or keychain)", g.oauthStore))
}
}

// wrapSignIn makes every command marked with [agentCall] sign in when the agent
Expand Down
96 changes: 96 additions & 0 deletions internal/cli/oauth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import (
"path/filepath"
"slices"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
Expand Down Expand Up @@ -462,6 +463,101 @@ func TestAuthUsageErrors(t *testing.T) {
}
}

type memKeyring struct {
mu sync.Mutex
secrets map[string]string
}

func (k *memKeyring) Get(service, user string) (string, error) {
k.mu.Lock()
defer k.mu.Unlock()
if s, ok := k.secrets[service+"/"+user]; ok {
return s, nil
}
return "", oauth.ErrSecretNotFound
}

func (k *memKeyring) Set(service, user, secret string) error {
k.mu.Lock()
defer k.mu.Unlock()
if k.secrets == nil {
k.secrets = map[string]string{}
}
k.secrets[service+"/"+user] = secret
return nil
}

func (k *memKeyring) Delete(service, user string) error {
k.mu.Lock()
defer k.mu.Unlock()
delete(k.secrets, service+"/"+user)
return nil
}

func TestOAuthKeychainStore(t *testing.T) {
t.Parallel()
e := newOAuthEnv(t, oauthtest.ServerOptions{}, oauthtest.AgentOptions{Card: oauthtest.CardMetadataURL})
kr := &memKeyring{}
e.deps.keyring = kr
args := []string{"send", "-a", e.agent.URL, e.clients(), "--oauth-store", "keychain", oauthTestMessage}
e.mustRun(t, args...)

index, err := os.ReadFile(filepath.Join(e.deps.oauthDir, oauth.KeyringIndexName))
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(index), "access_token") || strings.Contains(string(index), "refresh_token") {
t.Fatalf("%s contains tokens: %s", oauth.KeyringIndexName, index)
}
if _, err := os.Stat(filepath.Join(e.deps.oauthDir, "grants.json")); !os.IsNotExist(err) {
t.Fatalf("grants.json exists with --oauth-store keychain (stat error %v)", err)
}
if len(kr.secrets) != 2 {
t.Fatalf("keychain entries = %d, want one per token", len(kr.secrets))
}
if _, stderr := e.mustRun(t, args...); strings.Contains(stderr, "To authorize") {
t.Fatalf("second send stderr = %q, want the grant read from the keychain", stderr)
}
e.mustRun(t, "auth", "logout", "--all", "--oauth-store", "keychain")
if len(kr.secrets) != 0 {
t.Fatalf("keychain entries after logout = %d, want 0", len(kr.secrets))
}
}

func TestOAuthKeychainGrantWithoutTokens(t *testing.T) {
t.Parallel()
tests := []struct {
name string
mutate func(*memKeyring)
store []string
}{
{name: "file store", store: nil},
{name: "keychain entries removed", mutate: func(k *memKeyring) { k.secrets = nil }, store: []string{"--oauth-store", "keychain"}},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
e := newOAuthEnv(t, oauthtest.ServerOptions{}, oauthtest.AgentOptions{Card: oauthtest.CardMetadataURL})
kr := &memKeyring{}
e.deps.keyring = kr
e.mustRun(t, "auth", "login", e.agent.URL, e.clients(), "--oauth-store", "keychain")
if tc.mutate != nil {
tc.mutate(kr)
}
sent := len(e.agent.AuthorizationHeaders())
e.deps.authInteractive = func() bool { return false }
args := append([]string{"send", "-a", e.agent.URL, e.clients(), oauthTestMessage}, tc.store...)
_, _, err := e.run(t, args...)
if got := errCode(err); got != clierr.CodeCredentialsMissing {
t.Fatalf("a2a send error = %v (code %s), want %s", err, got, clierr.CodeCredentialsMissing)
}
if got := e.agent.AuthorizationHeaders()[sent:]; len(got) != 0 {
t.Fatalf("the agent received %q, want no request without a usable token", got)
}
})
}
}

var cmpIgnoreExpiry = cmp.FilterPath(func(p cmp.Path) bool { return p.Last().String() == ".ExpiresAt" }, cmp.Ignore())

// apiKeyOrOAuthAgent serves a card that accepts an API key or OAuth and counts
Expand Down
6 changes: 5 additions & 1 deletion internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ type deps struct {
authTimeout time.Duration
// authSleep, if set, replaces the wait between device flow polls.
authSleep func(context.Context, time.Duration) error
// keyring replaces the OS keychain used by --oauth-store keychain.
keyring oauth.Keyring
}

func (d *deps) setDefaults() {
Expand Down Expand Up @@ -104,6 +106,7 @@ type globalConfig struct {
oauthMode string
oauthClients oauth.ClientsFlag
oauthDefaultClientID string
oauthStore string
oauth *oauth.Manager

// insecureWarning prints insecureCredentialWarning at most once.
Expand Down Expand Up @@ -248,8 +251,9 @@ func newRootCmd(cfg *globalConfig, deps deps) (*cobra.Command, error) {
pf.StringVar(&cfg.oauthMode, "oauth", oauthAuto, "OAuth sign-in for agents that require it: auto (sign in when needed), cached (use stored grants only), or off (see a2a auth login --help)")
pf.Var(&cfg.oauthClients, "oauth-clients", `OAuth client registration as JSON: {"authority":"<issuer>","client_id":"<id or CIMD URL>","scopes":[...]} (repeatable; a JSON array is also accepted)`)
pf.StringVar(&cfg.oauthDefaultClientID, "oauth-default-client-id", oauth.DefaultClientID, "OAuth client ID used with authorization servers that support Client ID Metadata Documents when no oauth-clients entry matches; empty disables it")
pf.StringVar(&cfg.oauthStore, "oauth-store", "file", "Where OAuth grants are stored: file (~/.config/a2a-cli/oauth/grants.json) or keychain (tokens in the OS keychain)")
pf.StringVar(&cfg.configPath, "config", "", "Load configuration from an explicit file (.yaml, .json, or .env) in place of the local .env")
for _, name := range []string{"unix-socket", "credential-helper", "ca-cert", "oauth-clients", "oauth-default-client-id"} {
for _, name := range []string{"unix-socket", "credential-helper", "ca-cert", "oauth-clients", "oauth-default-client-id", "oauth-store"} {
if err := pf.SetAnnotation(name, clicfg.AnnotationNotFromDiscoveredFile, []string{"true"}); err != nil {
return nil, err
}
Expand Down
Loading
Loading