diff --git a/internal/README.md b/internal/README.md index 85029bc..056eda5 100644 --- a/internal/README.md +++ b/internal/README.md @@ -41,6 +41,9 @@ These apply to every client-mode command. Each command selects the agent it talk | `--auth ` | | Shorthand for `--svc-param "Authorization="`. | | `--credential-helper ` | | Credential helper returning the headers for an agent: `[scope=]path`. Repeatable. Not read from the local `.env`. See [Credentials](#credentials). | | `--unix-socket ` | | Send every HTTP request and gRPC connection through the Unix socket at ``, 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 ` | | OAuth sign-in for agents that require it: `auto` (default), `cached`, or `off`. See [OAuth](#oauth). | +| `--oauth-clients ` | | OAuth client registration per authorization server. Repeatable. Not read from the local `.env`. | +| `--oauth-default-client-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 ` | | Tenant identifier. Passed on every request. | | `--timeout ` | | Request timeout. Default `30s`. | | `--verbose` | `-v` | Verbose output to stderr. | @@ -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":"","client_id":"","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:/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 ` | `--device`, `--scope ` (repeatable) | Sign in and store the grant without sending a message. | +| `auth list` | | Stored grants: resource, issuer, client, scopes and expiry. | +| `auth get ` | `--reveal` | Credential helper headers and the stored grants that apply to the agent, redacted unless `--reveal`. | +| `auth logout ` | `--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 diff --git a/internal/cli/auth.go b/internal/cli/auth.go index 19e151d..d60aabc 100644 --- a/internal/cli/auth.go +++ b/internal/cli/auth.go @@ -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 } diff --git a/internal/cli/auth_get.go b/internal/cli/auth_get.go index 5843609..1e536b2 100644 --- a/internal/cli/auth_get.go +++ b/internal/cli/auth_get.go @@ -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 ", - 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), @@ -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 { diff --git a/internal/cli/auth_list.go b/internal/cli/auth_list.go new file mode 100644 index 0000000..889f7f4 --- /dev/null +++ b/internal/cli/auth_list.go @@ -0,0 +1,120 @@ +// Copyright 2026 The A2A Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cli + +import ( + "io" + "strings" + "time" + + "github.com/spf13/cobra" + + "github.com/a2aproject/a2a-cli/internal/clierr" + "github.com/a2aproject/a2a-cli/internal/oauth" + "github.com/a2aproject/a2a-cli/internal/output" +) + +// grantView is the JSON/text view of a stored OAuth grant. +type grantView struct { + Resource string `json:"resource"` + Issuer string `json:"issuer"` + ClientID string `json:"clientId"` + ClientSource string `json:"clientSource,omitempty"` + Scopes []string `json:"scopes,omitempty"` + ExpiresAt string `json:"expiresAt,omitempty"` + Refreshable bool `json:"refreshable"` + AccessToken string `json:"accessToken,omitempty"` +} + +func newGrantView(g oauth.Grant, reveal bool) grantView { + v := grantView{ + Resource: g.Key.Resource, + Issuer: g.Key.Issuer, + ClientID: g.Key.ClientID, + ClientSource: g.ClientSource, + Scopes: g.Key.Scopes, + Refreshable: g.RefreshToken != "", + } + if !g.ExpiresAt.IsZero() { + v.ExpiresAt = g.ExpiresAt.UTC().Format(time.RFC3339) + } + if reveal { + v.AccessToken = g.AccessToken + } + return v +} + +func newAuthListCmd(cfg *globalConfig) *cobra.Command { + return &cobra.Command{ + Use: "list", + Short: "List stored OAuth grants", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + if cfg.oauth == nil { + return clierr.Usage("OAuth is disabled (--oauth off)") + } + grants, err := cfg.oauth.List() + if err != nil { + return err + } + views := make([]grantView, 0, len(grants)) + for _, g := range grants { + views = append(views, newGrantView(g, false)) + } + if cfg.IsJSON() { + return cfg.PrintJSON(views) + } + if len(views) == 0 { + _, err := io.WriteString(cfg.Out, "No stored OAuth grants.\n") + return err + } + return printGrantTable(cfg.Printer, views) + }, + } +} + +func printGrantTable(p *output.Printer, views []grantView) error { + rows := make([][]string, 0, len(views)) + for _, v := range views { + expires := v.ExpiresAt + switch { + case expires == "": + expires = "unknown" + case v.Refreshable: + expires += " (refreshable)" + } + rows = append(rows, []string{v.Resource, v.Issuer, v.ClientID, dashIfEmpty(strings.Join(v.Scopes, " ")), expires}) + } + return p.PrintTable([]string{"RESOURCE", "ISSUER", "CLIENT", "SCOPES", "EXPIRES"}, rows) +} + +// grantsForAgent returns the stored grants that apply to target: those whose +// resource is target or a parent of it, not every grant on the same host. +func (g *globalConfig) grantsForAgent(target string) ([]oauth.Grant, error) { + if g.oauth == nil { + return nil, nil + } + grants, err := g.oauth.List() + if err != nil { + return nil, err + } + var out []oauth.Grant + for _, gr := range grants { + if oauth.ResourceCovers(gr.Key.Resource, target) { + out = append(out, gr) + } + } + return out, nil +} diff --git a/internal/cli/auth_login.go b/internal/cli/auth_login.go new file mode 100644 index 0000000..e2cafc3 --- /dev/null +++ b/internal/cli/auth_login.go @@ -0,0 +1,175 @@ +// Copyright 2026 The A2A Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cli + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/url" + "strings" + + "github.com/spf13/cobra" + + "github.com/a2aproject/a2a-cli/internal/clierr" + "github.com/a2aproject/a2a-cli/internal/oauth" + "github.com/a2aproject/a2a-go/v2/a2a" + "github.com/a2aproject/a2a-go/v2/a2aclient" +) + +func newAuthLoginCmd(cfg *globalConfig, d deps) *cobra.Command { + var device bool + var scopes []string + cmd := &cobra.Command{ + Use: "login [agent-ref]", + Short: "Sign in to an agent and store the OAuth grant, without sending a message", + Long: `Sign in to an agent and store the OAuth grant, without sending a message. + +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. + +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, +so scripts and sandboxed agents can run after an a2a auth login; --oauth off +disables OAuth. No OAuth runs when --auth, --svc-param or a credential helper +supplies the Authorization header. + +--oauth-clients configures one client per authorization server: on the command +line or in A2ACLI_OAUTH_CLIENTS as JSON, {"authority":"","client_id":"","scopes":[...]}, and in config files as a list of such objects. +When none matches a server that supports Client ID Metadata Documents, a2a uses +` + oauth.DefaultClientID + `.`, + Example: ` a2a auth login https://agent.example.com + a2a auth login https://agent.example.com --device + a2a auth login https://agent.example.com --scope a2a.invoke + a2a auth login -e https://agent.example.com/a2a --transport jsonrpc`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if cfg.oauth == nil { + return clierr.Usage("OAuth is disabled (--oauth off)") + } + if len(args) == 1 { + if cfg.url != "" { + return clierr.Usage("pass the agent either as an argument or with --endpoint, not both") + } + if err := cfg.agentCard.Set(args[0]); err != nil { + return err + } + } + req, err := cfg.loginRequirement(cmd.Context()) + if err != nil { + return cfg.oauthError(err) + } + grant, err := cfg.signIn(cmd.Context(), d, req, oauth.AcquireOptions{Device: device, Scopes: scopes}) + if err != nil { + return err + } + if cfg.IsJSON() { + return cfg.PrintJSON(newGrantView(*grant, false)) + } + return nil + }, + } + cmd.Flags().BoolVar(&device, "device", false, "Use the device authorization flow: print a URL and a code to enter on any device") + cmd.Flags().StringArrayVar(&scopes, "scope", nil, "Scope to request instead of the ones the agent names (repeatable)") + return cmd +} + +var errLoginProbe = errors.New("login probe") + +// loginProbe stops the first call and records the interface it was made to. +type loginProbe struct { + a2aclient.PassthroughInterceptor + req *a2aclient.Request +} + +func (p *loginProbe) Before(ctx context.Context, req *a2aclient.Request) (context.Context, any, error) { + r := *req + p.req = &r + return ctx, nil, errLoginProbe +} + +// loginRequirement finds out where to sign in for the configured agent: from +// its card's security schemes for the interface the client would use, or from +// the Bearer challenge and Protected Resource Metadata of that interface. +func (g *globalConfig) loginRequirement(ctx context.Context) (*oauth.Requirement, error) { + ctx, cancel := context.WithTimeout(withServiceParams(ctx, g), g.timeout) + defer cancel() + + probe := &loginProbe{} + client, err := newAgentClient(ctx, g, a2aclient.WithCallInterceptors(probe)) + var ir *oauth.InteractionRequiredError + if errors.As(err, &ir) { + return ir.Requirement, nil + } + if err != nil { + return nil, fmt.Errorf("failed to create a client: %w", err) + } + defer destroyClient(g, client) + + _, err = client.GetTask(ctx, &a2a.GetTaskRequest{ID: "a2a-auth-login", Tenant: g.tenant}) + if errors.As(err, &ir) { + return ir.Requirement, nil + } + if !errors.Is(err, errLoginProbe) { + return nil, fmt.Errorf("probing the agent: %w", err) + } + if probe.req.Card != nil { + r, err := g.oauth.RequirementForCard(ctx, probe.req.Card, probe.req.BaseURL) + if err != nil || r != nil { + return r, err + } + } + return g.challengeRequirement(ctx, probe.req.BaseURL) +} + +// challengeRequirement asks an interface without credentials for its Bearer +// challenge and follows it, or the well-known locations, to the authorization server. +func (g *globalConfig) challengeRequirement(ctx context.Context, baseURL string) (*oauth.Requirement, error) { + raw := baseURL + if !strings.Contains(raw, "://") { + raw = "https://" + raw + } + target, err := url.Parse(raw) + if err != nil { + return nil, fmt.Errorf("invalid interface URL %q: %w", baseURL, err) + } + var chs []oauth.Challenge + req, err := http.NewRequestWithContext(ctx, http.MethodGet, target.String(), nil) + if err != nil { + return nil, err + } + if resp, err := g.oauth.HTTPClient.Do(req); err == nil { + if resp.StatusCode == http.StatusUnauthorized { + chs = oauth.ParseChallenges(resp.Header.Values("WWW-Authenticate")) + } + _ = resp.Body.Close() + } else { + g.logf("requesting %s without credentials failed: %v", target, err) + } + r, err := g.oauth.RequirementForChallenge(ctx, target, chs) + if err != nil { + return nil, err + } + if r == nil { + return nil, clierr.Usage(fmt.Sprintf("%s does not advertise OAuth: its card declares no oauth2 or openIdConnect scheme and it publishes no protected resource metadata", baseURL)) + } + return r, nil +} diff --git a/internal/cli/auth_logout.go b/internal/cli/auth_logout.go new file mode 100644 index 0000000..98e3981 --- /dev/null +++ b/internal/cli/auth_logout.go @@ -0,0 +1,112 @@ +// Copyright 2026 The A2A Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cli + +import ( + "context" + "errors" + "fmt" + "io" + + "github.com/spf13/cobra" + + "github.com/a2aproject/a2a-cli/internal/clierr" + "github.com/a2aproject/a2a-cli/internal/flagparse" + "github.com/a2aproject/a2a-cli/internal/oauth" +) + +type logoutView struct { + Resource string `json:"resource"` + Issuer string `json:"issuer"` + Revoked bool `json:"revoked"` + Error string `json:"revokeError,omitempty"` +} + +func newAuthLogoutCmd(cfg *globalConfig) *cobra.Command { + var all bool + cmd := &cobra.Command{ + Use: "logout [agent-url]", + Short: "Revoke and delete stored OAuth grants for an agent, or all of them", + Example: ` a2a auth logout https://agent.example.com + a2a auth logout --all`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if cfg.oauth == nil { + return clierr.Usage("OAuth is disabled (--oauth off)") + } + if all == (len(args) == 1) { + return clierr.Usage("pass either an agent URL or --all") + } + grants, err := cfg.oauth.List() + if err != nil { + return err + } + if !all { + var ref flagparse.URLOrPath + if err := ref.Set(args[0]); err != nil { + return err + } + if grants, err = cfg.grantsForAgent(ref.URL()); err != nil { + return err + } + } + + if len(grants) > 0 { + if err := cfg.initTLS(); err != nil { + return err + } + } + ctx, cancel := context.WithTimeout(cmd.Context(), cfg.timeout) + defer cancel() + views := make([]logoutView, 0, len(grants)) + for _, g := range grants { + v := logoutView{Resource: g.Key.Resource, Issuer: g.Key.Issuer} + switch err := cfg.oauth.Revoke(ctx, &g); { + case err == nil: + v.Revoked = true + case errors.Is(err, oauth.ErrRevocationUnsupported): + v.Error = err.Error() + default: + v.Error = err.Error() + cfg.logf("revoking the grant for %s failed: %v", g.Key.Resource, err) + } + if err := cfg.oauth.Delete(ctx, g.Key); err != nil { + return err + } + views = append(views, v) + } + + if cfg.IsJSON() { + return cfg.PrintJSON(map[string]any{"removed": views}) + } + if len(views) == 0 { + _, err := io.WriteString(cfg.Out, "No stored OAuth grants to remove.\n") + return err + } + for _, v := range views { + status := "revoked" + if !v.Revoked { + status = "deleted locally; not revoked: " + v.Error + } + if _, err := fmt.Fprintf(cfg.Out, "Logged out of %s (%s)\n", v.Resource, status); err != nil { + return err + } + } + return nil + }, + } + cmd.Flags().BoolVar(&all, "all", false, "Remove every stored grant") + return cmd +} diff --git a/internal/cli/card_get.go b/internal/cli/card_get.go index 307d766..81c9eda 100644 --- a/internal/cli/card_get.go +++ b/internal/cli/card_get.go @@ -62,7 +62,7 @@ func newCardGetCmd(cfg *globalConfig) *cobra.Command { } cmd.Flags().BoolVar(&extended, "extended", false, "Fetch the extended agent card") - return cmd + return agentCall(cmd) } func getExtendedAgentCard(ctx context.Context, cfg *globalConfig) (*a2a.AgentCard, error) { diff --git a/internal/cli/chat.go b/internal/cli/chat.go index 5ea372e..bf6440d 100644 --- a/internal/cli/chat.go +++ b/internal/cli/chat.go @@ -55,17 +55,27 @@ func newChatCmd(cfg *globalConfig, deps deps) *cobra.Command { } baseCtx := withServiceParams(cmd.Context(), cfg) - ctx, cancel := context.WithTimeout(baseCtx, cfg.timeout) - client, err := newAgentClient(ctx, cfg) - cancel() + var client *a2aclient.Client + err := cfg.withSignIn(baseCtx, deps, func() error { + ctx, cancel := context.WithTimeout(baseCtx, cfg.timeout) + defer cancel() + c, err := newAgentClient(ctx, cfg) + if err != nil { + return fmt.Errorf("failed to create a client: %w", err) + } + client = c + return nil + }) if err != nil { - return fmt.Errorf("failed to create a client: %w", err) + return err } defer destroyClient(cfg, client) session := chat.NewSession(chat.Config{ Send: func(ctx context.Context, msg *a2a.Message) error { - return sendChatMessage(ctx, cfg, client, &flags, deps.poller, msg) + return cfg.withSignIn(ctx, deps, func() error { + return sendChatMessage(ctx, cfg, client, &flags, deps.poller, msg) + }) }, Cancel: func(ctx context.Context, id a2a.TaskID) error { ctx, cancel := context.WithTimeout(ctx, cfg.timeout) diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 9961516..dc64323 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -1320,6 +1320,9 @@ func mustRunCMD(t *testing.T, args ...string) string { func mustNewRoot(t *testing.T, cfg *globalConfig, deps deps) *cobra.Command { t.Helper() + if deps.oauthDir == "" { + deps.oauthDir = t.TempDir() + } r, err := newRootCmd(cfg, deps) if err != nil { t.Fatalf("newRootCmd() error = %v", err) diff --git a/internal/cli/client.go b/internal/cli/client.go index c14e0fc..944a166 100644 --- a/internal/cli/client.go +++ b/internal/cli/client.go @@ -43,10 +43,15 @@ const insecureCredentialWarning = "warning: --insecure disables TLS for gRPC; a // warnInsecureCredential warns once when a credential is sent with --insecure. func warnInsecureCredential(cfg *globalConfig) { if cfg.insecureGRPC && cfg.svcParams.HasCredential() { - _, _ = fmt.Fprintln(cfg.stderr(), insecureCredentialWarning) + cfg.warnInsecure() } } +// warnInsecure prints insecureCredentialWarning, once per command. +func (g *globalConfig) warnInsecure() { + g.insecureWarning.Do(func() { _, _ = fmt.Fprintln(g.stderr(), insecureCredentialWarning) }) +} + func newAgentClient(ctx context.Context, cfg *globalConfig, extraOpts ...a2aclient.FactoryOption) (*a2aclient.Client, error) { warnInsecureCredential(cfg) if err := cfg.initTLS(); err != nil { @@ -146,7 +151,7 @@ func resolveCard(ctx context.Context, cfg *globalConfig, ref string) (*a2a.Agent } func cardResolver(cfg *globalConfig) *agentcard.Resolver { - resolver := agentcard.NewResolver(&http.Client{Transport: cfg.httpTransport}) + resolver := agentcard.NewResolver(&http.Client{Transport: cfg.agentTransport()}) resolver.CardParser = a2av0.NewAgentCardParser() return resolver } @@ -162,11 +167,17 @@ func clientFactoryOpts(cfg *globalConfig) []a2aclient.FactoryOption { } // The a2a-go default HTTP client caps every request at 3 minutes. Use one without a // total timeout so the --timeout context deadline is what bounds a request. - httpClient := &http.Client{Transport: cfg.httpTransport} + httpClient := &http.Client{Transport: cfg.agentTransport()} opts := []a2aclient.FactoryOption{a2aclient.WithDefaultsDisabled()} if cfg.credentials != nil { opts = append(opts, cfg.credentialInterceptor()) } + if cfg.oauth != nil { + opts = append(opts, a2aclient.WithCallInterceptors(cfg.oauth.Interceptor())) + if cfg.insecureGRPC { + opts = append(opts, a2aclient.WithCallInterceptors(insecureTokenWarning{cfg: cfg})) + } + } if cfg.a2aVersion == "" || cfg.a2aVersion == "1.0" { opts = append( opts, diff --git a/internal/cli/credentials.go b/internal/cli/credentials.go index 13a8fb2..a51bbbe 100644 --- a/internal/cli/credentials.go +++ b/internal/cli/credentials.go @@ -21,7 +21,6 @@ import ( "net/http" "net/url" "strings" - "sync" "github.com/a2aproject/a2a-go/v2/a2aclient" @@ -91,7 +90,6 @@ func (g *globalConfig) cardHeaders(ctx context.Context, ref string) (map[string] // credentialInterceptor adds the helper headers to every A2A call, chosen // by the host of the interface the client is connected to. func (g *globalConfig) credentialInterceptor() a2aclient.FactoryOption { - var warn sync.Once return a2aclient.WithCallInterceptors(&credhelper.Interceptor{ Source: g.credentials, OnApply: func(headers map[string][]string) { @@ -100,7 +98,7 @@ func (g *globalConfig) credentialInterceptor() a2aclient.FactoryOption { } for name := range headers { if strings.EqualFold(name, "Authorization") { - warn.Do(func() { _, _ = fmt.Fprintln(g.stderr(), insecureCredentialWarning) }) + g.warnInsecure() } } }, diff --git a/internal/cli/events.go b/internal/cli/events.go index a78e6b2..e58bf09 100644 --- a/internal/cli/events.go +++ b/internal/cli/events.go @@ -60,6 +60,7 @@ func (g *globalConfig) initEventHandlers(cmd *cobra.Command) error { } func (g *globalConfig) handleEvent(e a2a.Event) error { + g.eventsHandled++ for _, handler := range g.handlers { if err := handler(e); err != nil { return err diff --git a/internal/cli/oauth.go b/internal/cli/oauth.go new file mode 100644 index 0000000..cec9c8f --- /dev/null +++ b/internal/cli/oauth.go @@ -0,0 +1,243 @@ +// Copyright 2026 The A2A Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cli + +import ( + "context" + "errors" + "fmt" + "net/http" + "os" + "strings" + + "github.com/spf13/cobra" + + "github.com/a2aproject/a2a-cli/internal/clierr" + "github.com/a2aproject/a2a-cli/internal/oauth" + "github.com/a2aproject/a2a-go/v2/a2aclient" +) + +// OAuth modes accepted by --oauth. +const ( + oauthAuto = "auto" + oauthCached = "cached" + oauthOff = "off" +) + +// annotationAgentCall marks commands that call an agent, so a sign-in the +// agent requires can run and the command can be retried. +const annotationAgentCall = "a2a-cli/agent-call" + +// agentCall marks cmd as a command that calls an agent. +func agentCall(cmd *cobra.Command) *cobra.Command { + if cmd.Annotations == nil { + cmd.Annotations = map[string]string{} + } + cmd.Annotations[annotationAgentCall] = "true" + return cmd +} + +// initOAuth sets up the OAuth manager, unless --oauth is off. +func (g *globalConfig) initOAuth(d deps) error { + switch g.oauthMode { + case oauthAuto, oauthCached: + case oauthOff: + return nil + default: + return clierr.Usage(fmt.Sprintf("invalid --oauth %q (want auto, cached or off)", g.oauthMode)) + } + store, err := g.newOAuthStore(d) + if err != nil { + return err + } + g.oauth = &oauth.Manager{ + Clients: g.oauthClients.Registrations(), + DefaultClientID: g.oauthDefaultClientID, + Store: store, + HTTPClient: &http.Client{Transport: baseTransport{g}}, + Logf: g.logf, + } + return nil +} + +// agentTransport is the transport for requests to agents: the HTTP transport, +// wrapped to handle 401 responses when OAuth is enabled. +func (g *globalConfig) agentTransport() http.RoundTripper { + if g.oauth == nil { + return g.httpTransport + } + return g.oauth.Transport(baseTransport{g}) +} + +// baseTransport sends requests through the HTTP transport as it is when the +// request is made, so --ca-cert, which initTLS applies once a command connects, +// also covers requests to authorization servers and the OAuth retries. +type baseTransport struct{ g *globalConfig } + +func (t baseTransport) RoundTrip(req *http.Request) (*http.Response, error) { + if t.g.httpTransport == nil { + return http.DefaultTransport.RoundTrip(req) + } + return t.g.httpTransport.RoundTrip(req) +} + +func (g *globalConfig) newOAuthStore(d deps) (oauth.Store, error) { + dir := d.oauthDir + if dir == "" { + var err error + if dir, err = oauth.DefaultDir(); err != nil { + return nil, err + } + } + return &oauth.FileStore{Dir: dir}, nil +} + +// wrapSignIn makes every command marked with [agentCall] sign in when the agent +// requires it and then run once more. +func wrapSignIn(cfg *globalConfig, d deps, cmd *cobra.Command) { + if run := cmd.RunE; run != nil && cmd.Annotations[annotationAgentCall] != "" { + cmd.RunE = func(c *cobra.Command, args []string) error { + return cfg.withSignIn(c.Context(), d, func() error { return run(c, args) }) + } + } + for _, sub := range cmd.Commands() { + wrapSignIn(cfg, d, sub) + } +} + +// withSignIn runs fn. When fn fails because the agent requires an authorization +// that has to be obtained interactively, it signs in, if allowed, and runs fn +// once more. fn creates its own clients and --timeout contexts, so the retry +// isn't limited by time spent signing in. A stream that already printed events +// is not restarted. +func (g *globalConfig) withSignIn(ctx context.Context, d deps, fn func() error) error { + before := g.eventsHandled + err := fn() + var ir *oauth.InteractionRequiredError + if !errors.As(err, &ir) { + return g.oauthError(err) + } + if g.eventsHandled != before { + return clierr.CredentialsMissing( + fmt.Sprintf("authorization for %s ended during the stream", ir.Requirement.Resource), + fmt.Sprintf("run `a2a auth login %s`, then follow the task with `a2a task subscribe `", g.agentRef(ir)), + err) + } + if reason := g.signInBlocked(d); reason != "" { + return clierr.CredentialsMissing( + fmt.Sprintf("%s, and %s", ir.Error(), reason), + fmt.Sprintf("run `a2a auth login %s` (add --device on a machine without a browser)", g.agentRef(ir)), + err) + } + if _, err := g.signIn(ctx, d, ir.Requirement, oauth.AcquireOptions{}); err != nil { + return err + } + err = fn() + if errors.As(err, &ir) { + return clierr.AuthFailed(fmt.Sprintf("still not authorized after signing in: %v", err), err) + } + return g.oauthError(err) +} + +// signInBlocked explains why an interactive sign-in can't start, or returns "". +func (g *globalConfig) signInBlocked(d deps) string { + switch { + case g.oauthMode == oauthCached: + return "no usable stored grant was found (--oauth cached)" + case !d.authInteractive(): + return "signing in needs a terminal" + default: + return "" + } +} + +// signIn obtains a grant interactively. It is bounded by its own timeout, not +// --timeout, and stops on Ctrl-C. +func (g *globalConfig) signIn(ctx context.Context, d deps, req *oauth.Requirement, opts oauth.AcquireOptions) (*oauth.Grant, error) { + ctx, stop := d.notifyInterrupt(context.WithoutCancel(ctx)) + defer stop() + ctx, cancel := context.WithTimeout(ctx, d.authTimeout) + defer cancel() + + opts.Prompter = oauth.Prompter{Out: g.stderr(), Open: d.openURL} + opts.Sleep = d.authSleep + grant, err := g.oauth.Acquire(ctx, req, opts) + if err != nil { + if errors.Is(err, context.DeadlineExceeded) { + return nil, clierr.AuthFailed(fmt.Sprintf("authorization of %s timed out after %s", req.Resource, d.authTimeout), err) + } + return nil, g.oauthError(fmt.Errorf("authorizing %s at %s: %w", req.Resource, req.Endpoints.Issuer, err)) + } + if _, err := fmt.Fprintf(g.stderr(), "Authorized. Grant saved for %s.\n", grant.Key.Resource); err != nil { + return nil, err + } + return grant, nil +} + +// oauthError classifies authorization failures; other errors pass through. +func (g *globalConfig) oauthError(err error) error { + if err == nil { + return nil + } + var nc *oauth.NoClientError + if errors.As(err, &nc) { + return clierr.Usage(nc.Error()) + } + var ir *oauth.InteractionRequiredError + var fe *oauth.FailureError + var oe *oauth.Error + if errors.As(err, &ir) || errors.As(err, &fe) || errors.As(err, &oe) { + return clierr.AuthFailed(err.Error(), err) + } + return err +} + +// agentRef is the agent to suggest in `a2a auth login` hints: the card +// reference, or --endpoint with its --transport, since auth login reads a bare +// argument as a card reference. +func (g *globalConfig) agentRef(ir *oauth.InteractionRequiredError) string { + switch { + case g.agentCard.IsSet(): + return g.agentCard.String() + case g.url != "": + parts := []string{"-e", g.url} + for _, t := range g.transports { + parts = append(parts, "--transport", t) + } + return strings.Join(parts, " ") + default: + return ir.Requirement.Resource + } +} + +// stderrIsTerminal reports whether standard error is a terminal. +func stderrIsTerminal() bool { + return isCharDevice(os.Stderr) +} + +// insecureTokenWarning warns, like for other credentials, when a call carries an +// Authorization header, such as an OAuth token, while --insecure turns off TLS +// for gRPC. +type insecureTokenWarning struct { + a2aclient.PassthroughInterceptor + cfg *globalConfig +} + +func (w insecureTokenWarning) Before(ctx context.Context, req *a2aclient.Request) (context.Context, any, error) { + if len(req.ServiceParams.Get("authorization")) > 0 { + w.cfg.warnInsecure() + } + return ctx, nil, nil +} diff --git a/internal/cli/oauth_test.go b/internal/cli/oauth_test.go new file mode 100644 index 0000000..0438375 --- /dev/null +++ b/internal/cli/oauth_test.go @@ -0,0 +1,572 @@ +// Copyright 2026 The A2A Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cli + +import ( + "context" + "crypto/tls" + "crypto/x509" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "slices" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + + "github.com/a2aproject/a2a-cli/internal/clicfg" + "github.com/a2aproject/a2a-cli/internal/clierr" + "github.com/a2aproject/a2a-cli/internal/oauth" + "github.com/a2aproject/a2a-cli/internal/oauth/oauthtest" + "github.com/a2aproject/a2a-cli/internal/polling" + "github.com/a2aproject/a2a-cli/internal/testutil" + "github.com/a2aproject/a2a-go/v2/a2a" + "github.com/a2aproject/a2a-go/v2/a2asrv" +) + +const oauthTestMessage = "hello from oauth" + +type oauthEnv struct { + as *oauthtest.AuthServer + agent *oauthtest.ProtectedAgent + deps deps +} + +func newOAuthEnv(t *testing.T, server oauthtest.ServerOptions, agent oauthtest.AgentOptions) *oauthEnv { + t.Helper() + as := oauthtest.NewAuthServer(t, server) + return &oauthEnv{ + as: as, + agent: oauthtest.NewProtectedAgent(t, as, agent), + deps: deps{ + poller: polling.Stream, + cfgLoader: clicfg.LoadEmpty, + authInteractive: func() bool { return true }, + openURL: oauthtest.Browser(t), + oauthDir: t.TempDir(), + authSleep: func(context.Context, time.Duration) error { return nil }, + }, + } +} + +func (e *oauthEnv) clients() string { + return fmt.Sprintf(`--oauth-clients={"authority":%q,"client_id":"cli-test"}`, e.as.URL) +} + +func (e *oauthEnv) run(t *testing.T, args ...string) (stdout, stderr string, err error) { + t.Helper() + return runCMDCapturingStderrWithConfig(t, e.deps, args...) +} + +func (e *oauthEnv) mustRun(t *testing.T, args ...string) (stdout, stderr string) { + t.Helper() + stdout, stderr, err := e.run(t, args...) + if err != nil { + t.Fatalf("a2a %s error = %v\nstderr:\n%s", strings.Join(args, " "), err, stderr) + } + return stdout, stderr +} + +func errCode(err error) string { + if ce := clierr.Classify(err); ce != nil { + return ce.Code + } + return "" +} + +func TestOAuthSignInFromCard(t *testing.T) { + t.Parallel() + tests := []struct { + name string + agent oauthtest.AgentOptions + }{ + {name: "metadata url", agent: oauthtest.AgentOptions{Card: oauthtest.CardMetadataURL, Scopes: []string{"a2a.invoke"}}}, + {name: "inline authorization code", agent: oauthtest.AgentOptions{Card: oauthtest.CardAuthCode}}, + {name: "inline device code", agent: oauthtest.AgentOptions{Card: oauthtest.CardDeviceCode}}, + {name: "openid connect", agent: oauthtest.AgentOptions{Card: oauthtest.CardOIDC}}, + {name: "json-rpc", agent: oauthtest.AgentOptions{Card: oauthtest.CardMetadataURL, JSONRPC: true}}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + e := newOAuthEnv(t, oauthtest.ServerOptions{}, tc.agent) + args := []string{"send", "-a", e.agent.URL, e.clients(), "-o", "json", oauthTestMessage} + + out, stderr := e.mustRun(t, args...) + if !strings.Contains(stderr, "To authorize, open:") || !strings.Contains(stderr, "Authorized.") { + t.Fatalf("first send stderr = %q, want sign-in instructions", stderr) + } + if !strings.Contains(out, oauthTestMessage) { + t.Fatalf("first send output = %q, want the echoed message", out) + } + for _, h := range e.agent.AuthorizationHeaders() { + if h == "" { + t.Fatalf("the agent received a request without a token; the card requirement should be met before the first call") + } + } + + out, stderr = e.mustRun(t, args...) + if strings.Contains(stderr, "To authorize") { + t.Fatalf("second send stderr = %q, want the stored grant reused", stderr) + } + if !strings.Contains(out, oauthTestMessage) { + t.Fatalf("second send output = %q, want the echoed message", out) + } + }) + } +} + +func TestOAuthSignInFromChallenge(t *testing.T) { + t.Parallel() + tests := []struct { + name string + agent oauthtest.AgentOptions + args func(agentURL string) []string + }{ + {name: "card without security", args: func(u string) []string { return []string{"send", "-a", u} }}, + {name: "protected card", agent: oauthtest.AgentOptions{ProtectCard: true}, args: func(u string) []string { return []string{"send", "-a", u} }}, + {name: "well-known probe", agent: oauthtest.AgentOptions{OmitResourceMetadata: true}, args: func(u string) []string { return []string{"send", "-a", u} }}, + {name: "endpoint", args: func(u string) []string { return []string{"send", "-e", u, "--transport", "rest"} }}, + {name: "json-rpc endpoint", agent: oauthtest.AgentOptions{JSONRPC: true}, args: func(u string) []string { return []string{"send", "-e", u, "--transport", "jsonrpc"} }}, + {name: "streaming", args: func(u string) []string { return []string{"send", "--stream", "-a", u} }}, + {name: "card get of a protected card", agent: oauthtest.AgentOptions{ProtectCard: true}, args: func(u string) []string { return []string{"card", "get", u} }}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + e := newOAuthEnv(t, oauthtest.ServerOptions{}, tc.agent) + args := append(tc.args(e.agent.URL), e.clients(), oauthTestMessage) + if tc.args(e.agent.URL)[0] == "card" { + args = append(tc.args(e.agent.URL), e.clients()) + } + out, stderr := e.mustRun(t, args...) + if !strings.Contains(stderr, "Authorized.") { + t.Fatalf("stderr = %q, want a sign-in", stderr) + } + if !strings.Contains(out, oauthTestMessage) && !strings.Contains(out, "Protected Echo") { + t.Fatalf("output = %q, want the agent's answer", out) + } + if _, stderr = e.mustRun(t, args...); strings.Contains(stderr, "To authorize") { + t.Fatalf("second run stderr = %q, want the stored grant reused", stderr) + } + }) + } +} + +func TestOAuthCACert(t *testing.T) { + t.Parallel() + tests := []struct { + name string + card oauthtest.CardSecurity + }{ + {name: "card requirement", card: oauthtest.CardMetadataURL}, + {name: "401 challenge", card: oauthtest.CardNone}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + e := newOAuthEnv(t, oauthtest.ServerOptions{TLS: true}, oauthtest.AgentOptions{Card: tc.card, TLS: true}) + caCert := testutil.MustWriteTmpCertFile(t, e.as.Certificate(), e.agent.Certificate()) + roots := x509.NewCertPool() + roots.AddCert(e.as.Certificate()) + browser := &http.Client{Transport: &http.Transport{TLSClientConfig: &tls.Config{RootCAs: roots}}} + e.deps.openURL = func(u string) { + resp, err := browser.Get(u) + if err != nil { + t.Errorf("browser: GET %s error = %v", u, err) + return + } + _ = resp.Body.Close() + } + args := []string{"send", "-a", e.agent.URL, e.clients(), "--ca-cert", caCert, "-o", "json", oauthTestMessage} + + if out, _ := e.mustRun(t, args...); !strings.Contains(out, oauthTestMessage) { + t.Fatalf("first send output = %q, want the echoed message", out) + } + out, stderr := e.mustRun(t, args...) + if strings.Contains(stderr, "To authorize") || !strings.Contains(out, oauthTestMessage) { + t.Fatalf("second send = %q, stderr %q, want the stored grant reused", out, stderr) + } + e.mustRun(t, "auth", "logout", "--all", "--ca-cert", caCert) + if got := len(e.as.Revoked()); got != 1 { + t.Fatalf("revoked tokens = %d, want 1 revoked over TLS", got) + } + }) + } +} + +func TestOAuthSignInNotAllowed(t *testing.T) { + t.Parallel() + tests := []struct { + name string + interactive bool + endpoint bool + extra []string + }{ + {name: "cached mode", interactive: true, extra: []string{"--oauth", "cached"}}, + {name: "no terminal", interactive: false}, + {name: "endpoint", interactive: false, endpoint: true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + e := newOAuthEnv(t, oauthtest.ServerOptions{}, oauthtest.AgentOptions{Card: oauthtest.CardMetadataURL}) + e.deps.authInteractive = func() bool { return tc.interactive } + agent, loginRef := []string{"-a", e.agent.URL}, e.agent.URL + if tc.endpoint { + agent = []string{"-e", e.agent.URL, "--transport", "rest"} + loginRef = strings.Join(agent, " ") + } + args := append(append(append([]string{"send"}, agent...), e.clients(), oauthTestMessage), tc.extra...) + _, stderr, 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 := clierr.Classify(err).Exit; got != 4 { + t.Fatalf("a2a send exit code = %d, want 4", got) + } + if strings.Contains(stderr, "To authorize") || e.as.Hits("/authorize") != 0 { + t.Fatalf("a sign-in started although it is not allowed; stderr = %q", stderr) + } + + // The hint names a login command that works for how the agent was called. + hint := clierr.Classify(err).Hint + login, ok := strings.CutPrefix(hint, "run `a2a ") + login, _, found := strings.Cut(login, "`") + if !ok || !found || login != "auth login "+loginRef { + t.Fatalf("a2a send hint = %q, want it to suggest a2a auth login %s", hint, loginRef) + } + e.deps.authInteractive = func() bool { return false } + e.mustRun(t, append(strings.Fields(login), e.clients())...) + e.mustRun(t, args...) + }) + } +} + +func TestOAuthSkipped(t *testing.T) { + t.Parallel() + tests := []struct { + name string + extra []string + wantHeader string + }{ + {name: "explicit --auth", extra: []string{"--auth", "Bearer user-supplied"}, wantHeader: "Bearer user-supplied"}, + {name: "explicit --svc-param", extra: []string{"--svc-param", "Authorization=Bearer from-param"}, wantHeader: "Bearer from-param"}, + {name: "oauth off", extra: []string{"--oauth", "off"}}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + e := newOAuthEnv(t, oauthtest.ServerOptions{}, oauthtest.AgentOptions{Card: oauthtest.CardMetadataURL}) + args := append([]string{"send", "-a", e.agent.URL, e.clients(), oauthTestMessage}, tc.extra...) + _, stderr, err := e.run(t, args...) + if err == nil { + t.Fatalf("a2a send error = nil, want the agent to reject the request") + } + if code := errCode(err); code == clierr.CodeCredentialsMissing || code == clierr.CodeAuthFailed { + t.Fatalf("a2a send error code = %s, want OAuth not involved", code) + } + if strings.Contains(stderr, "To authorize") || e.as.Hits("/.well-known/oauth-authorization-server") != 0 { + t.Fatalf("OAuth discovery ran; stderr = %q", stderr) + } + if tc.wantHeader != "" && !slices.Contains(e.agent.AuthorizationHeaders(), tc.wantHeader) { + t.Fatalf("agent Authorization headers = %q, want %q", e.agent.AuthorizationHeaders(), tc.wantHeader) + } + }) + } +} + +func TestOAuthClientSelection(t *testing.T) { + t.Parallel() + t.Run("no client", func(t *testing.T) { + t.Parallel() + e := newOAuthEnv(t, oauthtest.ServerOptions{}, oauthtest.AgentOptions{}) + _, _, err := e.run(t, "send", "-a", e.agent.URL, oauthTestMessage) + if got := errCode(err); got != clierr.CodeUsage { + t.Fatalf("a2a send error = %v (code %s), want %s", err, got, clierr.CodeUsage) + } + if !strings.Contains(err.Error(), `"authority":"`+e.as.URL+`"`) { + t.Fatalf("a2a send error = %v, want a ready-to-paste oauth-clients entry", err) + } + }) + t.Run("CIMD fallback", func(t *testing.T) { + t.Parallel() + e := newOAuthEnv(t, oauthtest.ServerOptions{CIMD: true}, oauthtest.AgentOptions{Card: oauthtest.CardMetadataURL}) + e.mustRun(t, "send", "-a", e.agent.URL, oauthTestMessage) + if got := e.as.LastAuthorizeQuery().Get("client_id"); got != oauth.DefaultClientID { + t.Fatalf("client_id = %q, want %q", got, oauth.DefaultClientID) + } + }) + t.Run("CIMD fallback configured", func(t *testing.T) { + t.Parallel() + e := newOAuthEnv(t, oauthtest.ServerOptions{CIMD: true}, oauthtest.AgentOptions{Card: oauthtest.CardMetadataURL}) + const id = "https://tools.example.com/a2a-cli.json" + e.mustRun(t, "send", "-a", e.agent.URL, "--oauth-default-client-id", id, oauthTestMessage) + if got := e.as.LastAuthorizeQuery().Get("client_id"); got != id { + t.Fatalf("client_id = %q, want %q", got, id) + } + _, _, err := e.run(t, "send", "-a", e.agent.URL, "--oauth-default-client-id=", "--oauth", "cached", oauthTestMessage) + if got := errCode(err); got != clierr.CodeUsage { + t.Fatalf("a2a send with the fallback disabled error = %v (code %s), want %s", err, got, clierr.CodeUsage) + } + }) + t.Run("from a yaml config file", func(t *testing.T) { + t.Parallel() + e := newOAuthEnv(t, oauthtest.ServerOptions{}, oauthtest.AgentOptions{Card: oauthtest.CardMetadataURL}) + path := filepath.Join(t.TempDir(), "a2a.yaml") + yaml := fmt.Sprintf("oauth-clients:\n - authority: %s\n client_id: from-yaml\n scopes: [a2a.invoke]\n", e.as.URL) + if err := os.WriteFile(path, []byte(yaml), 0o600); err != nil { + t.Fatal(err) + } + e.deps.cfgLoader = clicfg.Load + e.mustRun(t, "send", "-a", e.agent.URL, "--config", path, oauthTestMessage) + if got := e.as.LastAuthorizeQuery().Get("client_id"); got != "from-yaml" { + t.Fatalf("client_id = %q, want %q", got, "from-yaml") + } + }) +} + +func TestOAuthRequests(t *testing.T) { + t.Parallel() + e := newOAuthEnv(t, oauthtest.ServerOptions{}, oauthtest.AgentOptions{Card: oauthtest.CardMetadataURL}) + e.mustRun(t, "send", "-a", e.agent.URL, e.clients(), "--svc-param", "X-Trace=1", oauthTestMessage) + + if got := e.as.LastForm("/token").Get("resource"); got != e.agent.URL { + t.Fatalf("resource sent to the token endpoint = %q, want %q", got, e.agent.URL) + } + if got := e.as.LastAuthorizeQuery().Get("resource"); got != e.agent.URL { + t.Fatalf("resource sent to the authorization endpoint = %q, want %q", got, e.agent.URL) + } + if e.as.SawAuthorizationHeader() { + t.Fatalf("the authorization server received an Authorization header") + } + + e.as.Invalidate() + _, stderr := e.mustRun(t, "send", "-a", e.agent.URL, e.clients(), oauthTestMessage) + if strings.Contains(stderr, "To authorize") { + t.Fatalf("send after the token was invalidated stderr = %q, want a silent refresh", stderr) + } +} + +func TestAuthCommands(t *testing.T) { + t.Parallel() + e := newOAuthEnv(t, oauthtest.ServerOptions{}, oauthtest.AgentOptions{Card: oauthtest.CardMetadataURL, Scopes: []string{"a2a.invoke"}}) + + out, stderr := e.mustRun(t, "auth", "login", e.agent.URL, e.clients(), "-o", "json") + if !strings.Contains(stderr, "To authorize, open:") { + t.Fatalf("auth login stderr = %q, want sign-in instructions", stderr) + } + var login grantView + if err := json.Unmarshal([]byte(out), &login); err != nil { + t.Fatalf("json.Unmarshal(auth login output %q) error = %v", out, err) + } + want := grantView{Resource: e.agent.URL, Issuer: e.as.URL, ClientID: "cli-test", ClientSource: oauth.ClientSourceConfig, Scopes: []string{"a2a.invoke"}, Refreshable: true} + if diff := cmp.Diff(want, login, cmpIgnoreExpiry); diff != "" { + t.Fatalf("auth login wrong result (-want +got) diff = %s", diff) + } + + out, _ = e.mustRun(t, "auth", "list", "-o", "json") + var listed []grantView + if err := json.Unmarshal([]byte(out), &listed); err != nil { + t.Fatalf("json.Unmarshal(auth list output %q) error = %v", out, err) + } + if diff := cmp.Diff([]grantView{want}, listed, cmpIgnoreExpiry); diff != "" { + t.Fatalf("auth list wrong result (-want +got) diff = %s", diff) + } + + out, _ = e.mustRun(t, "auth", "get", e.agent.URL, "-o", "json") + var got struct { + URL string `json:"url"` + OAuth []grantView `json:"oauth"` + } + if err := json.Unmarshal([]byte(out), &got); err != nil { + t.Fatalf("json.Unmarshal(auth get output %q) error = %v", out, err) + } + if len(got.OAuth) != 1 || got.OAuth[0].AccessToken != "" { + t.Fatalf("auth get = %s, want one grant with the token redacted", out) + } + out, _ = e.mustRun(t, "auth", "get", e.agent.URL, "--reveal", "-o", "json") + if err := json.Unmarshal([]byte(out), &got); err != nil { + t.Fatal(err) + } + if len(got.OAuth) != 1 || !e.as.Valid(got.OAuth[0].AccessToken, e.agent.URL, nil) { + t.Fatalf("auth get --reveal = %s, want the stored access token", out) + } + + out, _ = e.mustRun(t, "auth", "logout", e.agent.URL, "-o", "json") + var logout struct { + Removed []logoutView `json:"removed"` + } + if err := json.Unmarshal([]byte(out), &logout); err != nil { + t.Fatalf("json.Unmarshal(auth logout output %q) error = %v", out, err) + } + if diff := cmp.Diff([]logoutView{{Resource: e.agent.URL, Issuer: e.as.URL, Revoked: true}}, logout.Removed); diff != "" { + t.Fatalf("auth logout wrong result (-want +got) diff = %s", diff) + } + if len(e.as.Revoked()) != 1 { + t.Fatalf("revoked tokens = %v, want the refresh token revoked", e.as.Revoked()) + } + if out, _ = e.mustRun(t, "auth", "list"); !strings.Contains(out, "No stored OAuth grants") { + t.Fatalf("auth list after logout = %q, want no grants", out) + } + + _, stderr = e.mustRun(t, "auth", "login", e.agent.URL, e.clients(), "--device", "--scope", "a2a.invoke") + if !strings.Contains(stderr, "Enter code:") { + t.Fatalf("auth login --device stderr = %q, want a user code", stderr) + } + if out, _ = e.mustRun(t, "auth", "logout", "--all"); !strings.Contains(out, "Logged out of "+e.agent.URL+" (revoked)") { + t.Fatalf("auth logout --all output = %q, want the grant revoked", out) + } +} + +func TestAuthLoginFromChallenge(t *testing.T) { + t.Parallel() + e := newOAuthEnv(t, oauthtest.ServerOptions{}, oauthtest.AgentOptions{}) + e.mustRun(t, "auth", "login", "-e", e.agent.URL, "--transport", "rest", e.clients()) + e.deps.authInteractive = func() bool { return false } + e.mustRun(t, "send", "-e", e.agent.URL, "--transport", "rest", "--oauth", "cached", e.clients(), oauthTestMessage) +} + +func TestAuthUsageErrors(t *testing.T) { + t.Parallel() + e := newOAuthEnv(t, oauthtest.ServerOptions{}, oauthtest.AgentOptions{}) + tests := [][]string{ + {"auth", "logout"}, + {"auth", "logout", e.agent.URL, "--all"}, + {"auth", "list", "--oauth", "off"}, + {"send", "-a", e.agent.URL, "--oauth", "sometimes", "x"}, + {"send", "-a", e.agent.URL, "--oauth-clients", "not json", "x"}, + } + for _, args := range tests { + if _, _, err := e.run(t, args...); errCode(err) != clierr.CodeUsage { + t.Errorf("a2a %s error = %v, want a usage error", strings.Join(args, " "), err) + } + } +} + +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 +// the A2A requests that reach it. +func apiKeyOrOAuthAgent(t *testing.T) (string, *atomic.Int32) { + t.Helper() + var hits atomic.Int32 + mux := http.NewServeMux() + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + card := &a2a.AgentCard{ + Name: "apikey-or-oauth", + Version: "1.0.0", + SupportedInterfaces: []*a2a.AgentInterface{a2a.NewAgentInterface(srv.URL, a2a.TransportProtocolHTTPJSON)}, + DefaultInputModes: []string{"text"}, + DefaultOutputModes: []string{"text"}, + Skills: []a2a.AgentSkill{}, + SecuritySchemes: a2a.NamedSecuritySchemes{ + "key": a2a.APIKeySecurityScheme{Location: a2a.APIKeySecuritySchemeLocationHeader, Name: "X-API-Key"}, + "oauth": a2a.OAuth2SecurityScheme{Flows: a2a.AuthorizationCodeOAuthFlow{AuthorizationURL: "https://as.example.com/authorize", TokenURL: "https://as.example.com/token"}}, + }, + SecurityRequirements: a2a.SecurityRequirementsOptions{{"key": {}}, {"oauth": {}}}, + } + mux.Handle(a2asrv.WellKnownAgentCardPath, a2asrv.NewStaticAgentCardHandler(card)) + mux.HandleFunc("/", func(w http.ResponseWriter, _ *http.Request) { + hits.Add(1) + http.Error(w, "reached", http.StatusTeapot) + }) + return srv.URL, &hits +} + +func TestOAuthNotRequired(t *testing.T) { + t.Parallel() + t.Run("card also accepts an API key", func(t *testing.T) { + t.Parallel() + agent, hits := apiKeyOrOAuthAgent(t) + d := deps{poller: polling.Stream, cfgLoader: clicfg.LoadEmpty, authInteractive: func() bool { return false }, oauthDir: t.TempDir()} + for _, extra := range [][]string{nil, {`--oauth-clients={"authority":"https://as.example.com","client_id":"c"}`}} { + args := append([]string{"send", "-a", agent, "--svc-param", "X-API-Key=k1", "hi"}, extra...) + _, _, err := runCMDCapturingStderrWithConfig(t, d, args...) + if code := errCode(err); code == clierr.CodeUsage || code == clierr.CodeCredentialsMissing { + t.Fatalf("a2a %s error = %v (code %s), want the request sent", strings.Join(args, " "), err, code) + } + } + if got := hits.Load(); got != 2 { + t.Fatalf("requests reaching the agent = %d, want 2", got) + } + }) + t.Run("unreadable grant store", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "grants.json"), []byte("{not json"), 0o600); err != nil { + t.Fatal(err) + } + var hits atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + hits.Add(1) + http.Error(w, "reached", http.StatusTeapot) + })) + t.Cleanup(srv.Close) + d := deps{poller: polling.Stream, cfgLoader: clicfg.LoadEmpty, oauthDir: dir} + _, _, err := runCMDCapturingStderrWithConfig(t, d, "send", "-e", srv.URL, "--transport", "rest", "hi") + if err != nil && strings.Contains(err.Error(), "grants.json") || hits.Load() != 1 { + t.Fatalf("a2a send error = %v, requests = %d, want the request sent", err, hits.Load()) + } + }) +} + +func TestAuthLoginScope(t *testing.T) { + t.Parallel() + e := newOAuthEnv(t, oauthtest.ServerOptions{}, oauthtest.AgentOptions{Card: oauthtest.CardMetadataURL}) + e.mustRun(t, "auth", "login", e.agent.URL, e.clients(), "--scope", "a2a.invoke") + e.mustRun(t, "send", "-a", e.agent.URL, e.clients(), "--oauth", "cached", oauthTestMessage) +} + +func TestAuthLogoutOneAgent(t *testing.T) { + t.Parallel() + dir := t.TempDir() + store := &oauth.FileStore{Dir: dir} + ep := oauth.Endpoints{Issuer: "https://as.example.com", Authorization: "https://as.example.com/authorize", Token: "https://as.example.com/token"} + for _, r := range []string{"https://gw.example.com/agents/a", "https://gw.example.com/agents/b"} { + g := oauth.Grant{Key: oauth.NewGrantKey(ep.Issuer, "c", r, nil), AccessToken: "at", Endpoints: ep, ExpiresAt: time.Now().Add(time.Hour)} + if err := store.Put(g); err != nil { + t.Fatal(err) + } + } + d := deps{poller: polling.Stream, cfgLoader: clicfg.LoadEmpty, oauthDir: dir} + if _, _, err := runCMDCapturingStderrWithConfig(t, d, "auth", "logout", "https://gw.example.com/agents/a"); err != nil { + t.Fatal(err) + } + left, err := store.List() + if err != nil { + t.Fatal(err) + } + if len(left) != 1 || left[0].Key.Resource != "https://gw.example.com/agents/b" { + t.Fatalf("grants after logging out of /agents/a = %+v, want only /agents/b", left) + } +} + +func TestOAuthInsecureWarning(t *testing.T) { + t.Parallel() + e := newOAuthEnv(t, oauthtest.ServerOptions{}, oauthtest.AgentOptions{Card: oauthtest.CardMetadataURL}) + e.mustRun(t, "auth", "login", e.agent.URL, e.clients()) + _, stderr := e.mustRun(t, "send", "-a", e.agent.URL, e.clients(), "--insecure", oauthTestMessage) + if got := strings.Count(stderr, insecureCredentialWarning); got != 1 { + t.Fatalf("send --insecure stderr = %q, want the credential warning once", stderr) + } +} diff --git a/internal/cli/root.go b/internal/cli/root.go index 0a90bc2..fb29e56 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -33,6 +33,7 @@ import ( "github.com/a2aproject/a2a-cli/internal/commandplugin" "github.com/a2aproject/a2a-cli/internal/credhelper" "github.com/a2aproject/a2a-cli/internal/flagparse" + "github.com/a2aproject/a2a-cli/internal/oauth" "github.com/a2aproject/a2a-cli/internal/output" "github.com/a2aproject/a2a-cli/internal/polling" ) @@ -46,6 +47,17 @@ type deps struct { cfgLoader cfgLoaderFunc isTerminal func() bool notifyInterrupt func(context.Context) (context.Context, context.CancelFunc) + + // authInteractive reports whether an interactive sign-in may start. + authInteractive func() bool + // openURL, if set, is called with the URL the user has to visit to sign in. + openURL func(string) + // oauthDir is where grants are stored; defaults to ~/.config/a2a-cli/oauth. + oauthDir string + // authTimeout bounds an interactive sign-in. + authTimeout time.Duration + // authSleep, if set, replaces the wait between device flow polls. + authSleep func(context.Context, time.Duration) error } func (d *deps) setDefaults() { @@ -63,6 +75,12 @@ func (d *deps) setDefaults() { return signal.NotifyContext(ctx, os.Interrupt) } } + if d.authInteractive == nil { + d.authInteractive = stderrIsTerminal + } + if d.authTimeout == 0 { + d.authTimeout = 5 * time.Minute + } } type globalConfig struct { @@ -83,6 +101,14 @@ type globalConfig struct { credentialHelpers credhelper.Flag credentials *credhelper.Source + oauthMode string + oauthClients oauth.ClientsFlag + oauthDefaultClientID string + oauth *oauth.Manager + + // insecureWarning prints insecureCredentialWarning at most once. + insecureWarning sync.Once + pluginsEnabled bool bindings []clicfg.FlagBinding @@ -92,7 +118,8 @@ type globalConfig struct { httpTransport http.RoundTripper rootCAs *x509.CertPool - handlers []handleEventFunc + handlers []handleEventFunc + eventsHandled int *output.Printer } @@ -189,6 +216,9 @@ func newRootCmd(cfg *globalConfig, deps deps) (*cobra.Command, error) { } cfg.initHTTPTransport() cfg.initCredentials() + if err := cfg.initOAuth(deps); err != nil { + return err + } switch output.Mode(cfg.output) { case output.ModeText, output.ModeJson, output.ModeJSONL: @@ -215,8 +245,11 @@ func newRootCmd(cfg *globalConfig, deps deps) (*cobra.Command, error) { pf.StringVar(&cfg.caCert, "ca-cert", "", "Trust only the CA certificates in this PEM file for TLS connections") pf.StringVar(&cfg.unixSocket, "unix-socket", "", "Send every HTTP request and gRPC connection through the Unix socket at this path") pf.Var(&cfg.credentialHelpers, "credential-helper", "Credential helper returning the headers for an agent: [scope=]path (repeatable; see a2a auth get --help)") + 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":"","client_id":"","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.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"} { + for _, name := range []string{"unix-socket", "credential-helper", "ca-cert", "oauth-clients", "oauth-default-client-id"} { if err := pf.SetAnnotation(name, clicfg.AnnotationNotFromDiscoveredFile, []string{"true"}); err != nil { return nil, err } @@ -228,7 +261,7 @@ func newRootCmd(cfg *globalConfig, deps deps) (*cobra.Command, error) { newChatCmd(cfg, deps), newTaskCmd(cfg), newConfigCmd(cfg), - newAuthCmd(cfg), + newAuthCmd(cfg, deps), newServeCmd(cfg), newTransportCmd(cfg), newPluginCmd(cfg), @@ -247,6 +280,7 @@ func newRootCmd(cfg *globalConfig, deps deps) (*cobra.Command, error) { cmd.SetUsageTemplate(rootUsageTemplate) markUsageErrors(cmd) + wrapSignIn(cfg, deps, cmd) return cmd, nil } diff --git a/internal/cli/send.go b/internal/cli/send.go index cd0b20d..e9ca4b9 100644 --- a/internal/cli/send.go +++ b/internal/cli/send.go @@ -101,7 +101,7 @@ func newSendCmd(cfg *globalConfig, poller pollerFunc) *cobra.Command { flags.parts.Attach(f) flags.meta.Attach(f, "metadata", "Attach request metadata as a JSON object (repeatable)") - return cmd + return agentCall(cmd) } func buildRequest(cmd *cobra.Command, cfg *globalConfig, flags *sendFlags, args []string) (*a2a.SendMessageRequest, error) { diff --git a/internal/cli/task_cancel.go b/internal/cli/task_cancel.go index f1eb6ac..14343df 100644 --- a/internal/cli/task_cancel.go +++ b/internal/cli/task_cancel.go @@ -61,5 +61,5 @@ func newTaskCancelCmd(cfg *globalConfig) *cobra.Command { } meta.Attach(cmd.Flags(), "metadata", "Attach request metadata as a JSON object (repeatable)") - return cmd + return agentCall(cmd) } diff --git a/internal/cli/task_get.go b/internal/cli/task_get.go index 4f4dcc6..45efa1a 100644 --- a/internal/cli/task_get.go +++ b/internal/cli/task_get.go @@ -73,7 +73,7 @@ func newTaskGetCmd(cfg *globalConfig) *cobra.Command { f.BoolVar(&wait, "wait", false, "Poll until the task reaches a terminal or interrupted (input/auth-required) state") f.DurationVar(&pollInterval, "poll-interval", 2*time.Second, "Duration between polls while waiting; only used with --wait. Overall wait budget is --timeout.") flagparse.AttachFilePartsOutDir(f) - return cmd + return agentCall(cmd) } func getTask(ctx context.Context, cfg *globalConfig, client *a2aclient.Client, req *a2a.GetTaskRequest, wait bool, interval time.Duration) (*a2a.Task, error) { diff --git a/internal/cli/task_list.go b/internal/cli/task_list.go index a385e81..181f537 100644 --- a/internal/cli/task_list.go +++ b/internal/cli/task_list.go @@ -98,5 +98,5 @@ func newTaskListCmd(cfg *globalConfig) *cobra.Command { f.StringVar(&since, "since", "", "Only tasks with status updates after this timestamp (RFC 3339)") f.BoolVar(&withArtifacts, "with-artifacts", false, "Include artifacts in the response") - return cmd + return agentCall(cmd) } diff --git a/internal/cli/task_push_config_create.go b/internal/cli/task_push_config_create.go index 0ce7a18..8750a31 100644 --- a/internal/cli/task_push_config_create.go +++ b/internal/cli/task_push_config_create.go @@ -76,7 +76,7 @@ func newPushConfigCreateCmd(cfg *globalConfig) *cobra.Command { fl.StringVar(&f.token, "token", "", "Optional token the agent echoes back so the receiver can validate calls") fl.StringVar(&f.authScheme, "auth-scheme", "", "Optional auth scheme the agent uses when calling the webhook (e.g. Bearer)") fl.StringVar(&f.authCredentials, "auth-credentials", "", "Optional credentials the agent presents to the webhook") - return cmd + return agentCall(cmd) } func buildPushConfig(f pushConfigCreateFlags) (*a2a.PushConfig, error) { diff --git a/internal/cli/task_push_config_delete.go b/internal/cli/task_push_config_delete.go index 74061ab..f0edf06 100644 --- a/internal/cli/task_push_config_delete.go +++ b/internal/cli/task_push_config_delete.go @@ -24,7 +24,7 @@ import ( ) func newPushConfigDeleteCmd(cfg *globalConfig) *cobra.Command { - return &cobra.Command{ + return agentCall(&cobra.Command{ Use: "delete ", Short: "Delete a task's push-notification configuration", Args: cobra.ExactArgs(2), @@ -48,5 +48,5 @@ func newPushConfigDeleteCmd(cfg *globalConfig) *cobra.Command { } return cfg.PrintPushConfigDeleted(args[0], args[1]) }, - } + }) } diff --git a/internal/cli/task_push_config_get.go b/internal/cli/task_push_config_get.go index 0c5d586..31c3ea9 100644 --- a/internal/cli/task_push_config_get.go +++ b/internal/cli/task_push_config_get.go @@ -24,7 +24,7 @@ import ( ) func newPushConfigGetCmd(cfg *globalConfig) *cobra.Command { - return &cobra.Command{ + return agentCall(&cobra.Command{ Use: "get ", Short: "Get a task's push-notification configuration", Args: cobra.ExactArgs(2), @@ -52,5 +52,5 @@ func newPushConfigGetCmd(cfg *globalConfig) *cobra.Command { } return nil }, - } + }) } diff --git a/internal/cli/task_push_config_list.go b/internal/cli/task_push_config_list.go index e8f6594..aa35e18 100644 --- a/internal/cli/task_push_config_list.go +++ b/internal/cli/task_push_config_list.go @@ -63,5 +63,5 @@ func newPushConfigListCmd(cfg *globalConfig) *cobra.Command { f := cmd.Flags() f.IntVar(&limit, "limit", 0, "Page size") f.StringVar(&pageToken, "page-token", "", "Pagination token") - return cmd + return agentCall(cmd) } diff --git a/internal/cli/task_subscribe.go b/internal/cli/task_subscribe.go index 20cd541..a75cd28 100644 --- a/internal/cli/task_subscribe.go +++ b/internal/cli/task_subscribe.go @@ -60,5 +60,5 @@ func newTaskSubscribeCmd(cfg *globalConfig) *cobra.Command { f := cmd.Flags() flagparse.AttachFilePartsOutDir(f) - return cmd + return agentCall(cmd) } diff --git a/internal/clierr/clierr.go b/internal/clierr/clierr.go index c02f7d5..1e01eff 100644 --- a/internal/clierr/clierr.go +++ b/internal/clierr/clierr.go @@ -47,6 +47,13 @@ const ( CodeCardInvalid = "A2ACLI_ERR_CARD_INVALID" // CodeTimeout marks a --timeout that expired before a terminal state; exit 5. CodeTimeout = "A2ACLI_ERR_TIMEOUT" + // CodeCredentialsMissing marks an agent that requires credentials the CLI + // does not have and could not obtain without user interaction. Exit 4. It is + // not named AUTH_REQUIRED because that is a task state, which exits 0. + CodeCredentialsMissing = "A2ACLI_ERR_CREDENTIALS_MISSING" + // CodeAuthFailed marks a failed authorization: discovery, sign-in, or a + // rejected credential. Exit 4. + CodeAuthFailed = "A2ACLI_ERR_AUTH_FAILED" // CodeInternal marks an unexpected CLI-side failure, or any condition with // no better code. Exit 1. CodeInternal = "A2ACLI_ERR_INTERNAL" @@ -88,6 +95,17 @@ func Usage(msg string) *Error { return &Error{Code: CodeUsage, Message: msg, Hint: UsageHint, Exit: 2} } +// CredentialsMissing builds an error for an agent that requires credentials the +// CLI could not obtain on its own, such as when sign-in is not allowed. +func CredentialsMissing(msg, hint string, cause error) *Error { + return &Error{Code: CodeCredentialsMissing, Message: msg, Hint: hint, Exit: 4, err: cause} +} + +// AuthFailed builds an error for a failed authorization attempt. +func AuthFailed(msg string, cause error) *Error { + return &Error{Code: CodeAuthFailed, Message: msg, Exit: 4, err: cause} +} + // CardResolution builds an error for a failure to resolve an --agent-card. // // It is called only from the card-resolution path, where the reference itself @@ -130,7 +148,7 @@ func cardInvalidHint(err error) string { if errors.As(err, &status) { switch { case status.StatusCode == http.StatusUnauthorized || status.StatusCode == http.StatusForbidden: - return "pass or check credentials (--auth, --svc-param or --credential-helper)" + return "pass or check credentials (--auth, --svc-param or --credential-helper), or sign in with `a2a auth login` if the agent uses OAuth" case status.StatusCode >= http.StatusInternalServerError: return "the agent failed to serve its card, check its logs or retry" } diff --git a/internal/clierr/clierr_test.go b/internal/clierr/clierr_test.go index 641b414..88ab570 100644 --- a/internal/clierr/clierr_test.go +++ b/internal/clierr/clierr_test.go @@ -190,6 +190,27 @@ func TestUsage(t *testing.T) { } } +func TestAuthErrors(t *testing.T) { + t.Parallel() + cause := errors.New("cause") + tests := []struct { + name string + err *Error + wantCode string + }{ + {name: "credentials missing", err: CredentialsMissing("needs sign-in", "run a2a auth login", cause), wantCode: CodeCredentialsMissing}, + {name: "auth failed", err: AuthFailed("token rejected", cause), wantCode: CodeAuthFailed}, + } + for _, tc := range tests { + if tc.err.Code != tc.wantCode || tc.err.Exit != 4 { + t.Errorf("%s: code %q exit %d, want %q exit 4", tc.name, tc.err.Code, tc.err.Exit, tc.wantCode) + } + if !errors.Is(tc.err, cause) { + t.Errorf("%s: error does not wrap its cause", tc.name) + } + } +} + func TestCardResolution(t *testing.T) { t.Parallel() @@ -219,14 +240,14 @@ func TestCardResolution(t *testing.T) { err: &agentcard.ErrStatusNotOK{StatusCode: 401, Status: "401 Unauthorized"}, wantCode: CodeCardInvalid, wantExit: 4, - wantHint: "pass or check credentials (--auth, --svc-param or --credential-helper)", + wantHint: "pass or check credentials (--auth, --svc-param or --credential-helper), or sign in with `a2a auth login` if the agent uses OAuth", }, { name: "forbidden card request asks for credentials", err: &agentcard.ErrStatusNotOK{StatusCode: 403, Status: "403 Forbidden"}, wantCode: CodeCardInvalid, wantExit: 4, - wantHint: "pass or check credentials (--auth, --svc-param or --credential-helper)", + wantHint: "pass or check credentials (--auth, --svc-param or --credential-helper), or sign in with `a2a auth login` if the agent uses OAuth", }, { name: "server error on card request points at the agent", diff --git a/internal/oauth/flag.go b/internal/oauth/flag.go new file mode 100644 index 0000000..e0552a6 --- /dev/null +++ b/internal/oauth/flag.go @@ -0,0 +1,122 @@ +// Copyright 2026 The A2A Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package oauth + +import ( + "bytes" + "encoding/json" + "fmt" + "strings" +) + +// ClientsFlag is the repeatable --oauth-clients flag. On the command line and in +// environment variables each value is a JSON object or array; config files list +// {authority, client_id, scopes} objects. +type ClientsFlag struct { + regs []Registration +} + +// Registrations returns the configured registrations in order. +func (f *ClientsFlag) Registrations() []Registration { + return f.regs +} + +// String implements [pflag.Value]. +func (f *ClientsFlag) String() string { + entries := make([]string, len(f.regs)) + for i, r := range f.regs { + entries[i] = r.Authority + "=" + r.ClientID + } + return strings.Join(entries, ",") +} + +// Set implements [pflag.Value]. +func (f *ClientsFlag) Set(value string) error { + return f.Append(value) +} + +// Type implements [pflag.Value]. +func (f *ClientsFlag) Type() string { + return "json" +} + +// Append implements [pflag.SliceValue]. +func (f *ClientsFlag) Append(value string) error { + regs, err := ParseRegistrations([]string{value}) + if err != nil { + return err + } + f.regs = append(f.regs, regs...) + return nil +} + +// Replace implements [pflag.SliceValue]. +func (f *ClientsFlag) Replace(values []string) error { + regs, err := ParseRegistrations(values) + if err != nil { + return err + } + f.regs = regs + return nil +} + +// GetSlice implements [pflag.SliceValue]. +func (f *ClientsFlag) GetSlice() []string { + out := make([]string, len(f.regs)) + for i, r := range f.regs { + b, _ := json.Marshal(r) + out[i] = string(b) + } + return out +} + +// SetConfig implements clicfg.ConfigValue: a JSON string from an environment +// variable or .env, or a list of objects from a YAML or JSON config file. +func (f *ClientsFlag) SetConfig(raw any) error { + if s, ok := raw.(string); ok { + return f.Replace([]string{s}) + } + items, ok := raw.([]any) + if !ok { + return fmt.Errorf("want a list of {authority, client_id} objects, got %T", raw) + } + regs := make([]Registration, 0, len(items)) + for _, item := range items { + r, err := parseConfigRegistration(item) + if err != nil { + return err + } + regs = append(regs, r) + } + f.regs = regs + return nil +} + +func parseConfigRegistration(item any) (Registration, error) { + if _, ok := item.(map[string]any); !ok { + return Registration{}, fmt.Errorf("oauth-clients entry %v: want {authority, client_id, scopes}", item) + } + data, err := json.Marshal(item) + if err != nil { + return Registration{}, fmt.Errorf("oauth-clients entry %v: %w", item, err) + } + dec := json.NewDecoder(bytes.NewReader(data)) + dec.DisallowUnknownFields() + var r Registration + if err := dec.Decode(&r); err != nil { + return Registration{}, fmt.Errorf("oauth-clients entry %s: want {authority, client_id, scopes}: %w", data, err) + } + return r, r.validate() +} diff --git a/internal/oauth/flag_test.go b/internal/oauth/flag_test.go new file mode 100644 index 0000000..7536b0a --- /dev/null +++ b/internal/oauth/flag_test.go @@ -0,0 +1,95 @@ +// Copyright 2026 The A2A Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package oauth + +import ( + "testing" + + "github.com/google/go-cmp/cmp" +) + +func TestClientsFlagSetConfig(t *testing.T) { + t.Parallel() + tests := []struct { + name string + raw any + want []Registration + wantErr bool + }{ + { + name: "json string", + raw: `[{"authority":"https://login.example.com","client_id":"a"},{"authority":"https://auth.example.org","client_id":"b"}]`, + want: []Registration{{Authority: "https://login.example.com", ClientID: "a"}, {Authority: "https://auth.example.org", ClientID: "b"}}, + }, + { + name: "entries", + raw: []any{ + map[string]any{"authority": "https://login.example.com", "client_id": "a", "scopes": []any{"x", "y"}}, + map[string]any{"authority": "https://auth.example.org", "client_id": DefaultClientID}, + }, + want: []Registration{ + {Authority: "https://login.example.com", ClientID: "a", Scopes: []string{"x", "y"}}, + {Authority: "https://auth.example.org", ClientID: DefaultClientID}, + }, + }, + {name: "string entry", raw: []any{`{"authority":"https://login.example.com","client_id":"a"}`}, wantErr: true}, + {name: "unknown field", raw: []any{map[string]any{"authority": "https://login.example.com", "clientId": "a"}}, wantErr: true}, + {name: "no client_id", raw: []any{map[string]any{"authority": "https://login.example.com"}}, wantErr: true}, + {name: "plain http authority", raw: []any{map[string]any{"authority": "http://login.example.com", "client_id": "a"}}, wantErr: true}, + {name: "map", raw: map[string]any{"authority": "https://login.example.com", "client_id": "a"}, wantErr: true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + var f ClientsFlag + err := f.SetConfig(tc.raw) + if (err != nil) != tc.wantErr { + t.Fatalf("ClientsFlag.SetConfig(%v) error = %v, want error %v", tc.raw, err, tc.wantErr) + } + if tc.wantErr { + return + } + if diff := cmp.Diff(tc.want, f.Registrations()); diff != "" { + t.Fatalf("ClientsFlag.SetConfig(%v) wrong result (-want +got) diff = %s", tc.raw, diff) + } + }) + } +} + +func TestClientsFlagSet(t *testing.T) { + t.Parallel() + var f ClientsFlag + for _, v := range []string{ + `{"authority":"https://login.example.com","client_id":"a"}`, + `[{"authority":"https://auth.example.org","client_id":"b","scopes":["x"]}]`, + } { + if err := f.Set(v); err != nil { + t.Fatalf("ClientsFlag.Set(%q) error = %v", v, err) + } + } + if got, want := f.String(), "https://login.example.com=a,https://auth.example.org=b"; got != want { + t.Fatalf("ClientsFlag.String() = %q, want %q", got, want) + } + want := []string{ + `{"authority":"https://login.example.com","client_id":"a"}`, + `{"authority":"https://auth.example.org","client_id":"b","scopes":["x"]}`, + } + if diff := cmp.Diff(want, f.GetSlice()); diff != "" { + t.Fatalf("ClientsFlag.GetSlice() wrong result (-want +got) diff = %s", diff) + } + if err := f.Set("not json"); err == nil { + t.Fatalf("ClientsFlag.Set(%q) error = nil, want an error", "not json") + } +} diff --git a/internal/oauth/oauthtest/agent.go b/internal/oauth/oauthtest/agent.go index c472aed..74648cc 100644 --- a/internal/oauth/oauthtest/agent.go +++ b/internal/oauth/oauthtest/agent.go @@ -16,6 +16,7 @@ package oauthtest import ( "context" + "crypto/x509" "fmt" "iter" "net/http" @@ -57,6 +58,8 @@ type AgentOptions struct { Scopes []string // JSONRPC serves the JSON-RPC binding instead of REST. JSONRPC bool + // TLS serves https with the certificate returned by [ProtectedAgent.Certificate]. + TLS bool } // ProtectedAgent is an A2A echo agent that requires access tokens from an [AuthServer]. @@ -66,6 +69,7 @@ type ProtectedAgent struct { as *AuthServer opts AgentOptions + srv *httptest.Server mu sync.Mutex headers []string @@ -77,9 +81,8 @@ func NewProtectedAgent(t *testing.T, as *AuthServer, opts AgentOptions) *Protect a := &ProtectedAgent{as: as, opts: opts} handler := a2asrv.NewHandler(echoExecutor{}, a2asrv.WithCapabilityChecks(&a2a.AgentCapabilities{Streaming: true})) mux := http.NewServeMux() - srv := httptest.NewServer(mux) - t.Cleanup(srv.Close) - a.URL = srv.URL + a.srv = startServer(t, mux, opts.TLS) + a.URL = a.srv.URL protocol := a2a.TransportProtocolHTTPJSON binding := a2asrv.NewRESTHandler(handler) @@ -181,6 +184,10 @@ func (a *ProtectedAgent) AuthorizationHeaders() []string { return append([]string(nil), a.headers...) } +// Certificate returns the agent's TLS certificate, or nil without +// [AgentOptions.TLS]. +func (a *ProtectedAgent) Certificate() *x509.Certificate { return a.srv.Certificate() } + type echoExecutor struct{} func (echoExecutor) Execute(_ context.Context, execCtx *a2asrv.ExecutorContext) iter.Seq2[a2a.Event, error] { diff --git a/internal/oauth/oauthtest/server.go b/internal/oauth/oauthtest/server.go index 07b327f..3776d6b 100644 --- a/internal/oauth/oauthtest/server.go +++ b/internal/oauth/oauthtest/server.go @@ -19,6 +19,7 @@ package oauthtest import ( "crypto/rand" "crypto/sha256" + "crypto/x509" "encoding/base64" "encoding/hex" "encoding/json" @@ -53,6 +54,8 @@ type ServerOptions struct { PendingPolls int // DenyAuthorization makes the authorization and device flows fail with access_denied. DenyAuthorization bool + // TLS serves https with the certificate returned by [AuthServer.Certificate]. + TLS bool } type codeGrant struct { @@ -77,6 +80,7 @@ type AuthServer struct { URL string opts ServerOptions + srv *httptest.Server mu sync.Mutex codes map[string]codeGrant @@ -116,12 +120,28 @@ func NewAuthServer(t *testing.T, opts ServerOptions) *AuthServer { mux.HandleFunc("POST /device", s.device) mux.HandleFunc("GET /activate", s.activate) mux.HandleFunc("POST /revoke", s.revoke) - srv := httptest.NewServer(s.record(mux)) - t.Cleanup(srv.Close) - s.URL = srv.URL + s.srv = startServer(t, s.record(mux), opts.TLS) + s.URL = s.srv.URL return s } +// startServer starts an httptest server, with TLS if requested, that is stopped +// with the test. +func startServer(t *testing.T, h http.Handler, useTLS bool) *httptest.Server { + srv := httptest.NewUnstartedServer(h) + if useTLS { + srv.StartTLS() + } else { + srv.Start() + } + t.Cleanup(srv.Close) + return srv +} + +// Certificate returns the server's TLS certificate, or nil without +// [ServerOptions.TLS]. +func (s *AuthServer) Certificate() *x509.Certificate { return s.srv.Certificate() } + func (s *AuthServer) record(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { s.mu.Lock() diff --git a/internal/oauth/urls.go b/internal/oauth/urls.go index b536457..093bae2 100644 --- a/internal/oauth/urls.go +++ b/internal/oauth/urls.go @@ -66,10 +66,10 @@ func origin(raw string) string { return u.Scheme + "://" + u.Host } -// SameOrigin reports whether two URLs have the same scheme, host and port. -func SameOrigin(a, b string) bool { - oa := origin(a) - return oa != "" && oa == origin(b) +// ResourceCovers reports whether a grant for resource applies to requests to +// target: same origin, and the target path is the resource path or below it. +func ResourceCovers(resource, target string) bool { + return resourceCovers(resource, target) } // resourceCovers reports whether resource identifies target: same origin, and diff --git a/skills/a2a-cli/SKILL.md b/skills/a2a-cli/SKILL.md index f05498d..ec6cf68 100644 --- a/skills/a2a-cli/SKILL.md +++ b/skills/a2a-cli/SKILL.md @@ -141,6 +141,7 @@ Run `a2a --help` for the full, current set. The load-bearing ones: | `--task-id ` / `--context-id ` | Continue a task / group a new task under a context. | | `--save-fileparts ` | Save raw file parts from responses (artifacts and messages) as files under ``; the output still summarizes them by name/type/size. | | `--auth ""` / `--svc-param ` | Attach credentials or transport parameters (or set `A2ACLI_*` env vars). Never commit a secret. | +| `--oauth cached` | Use only stored OAuth grants; never start a sign-in. On `A2ACLI_ERR_CREDENTIALS_MISSING`, ask the user to run the command in the error's `hint` (an `a2a auth login …`; add `--device` without a browser), then do what the hint says next. Don't run `auth login` yourself: it waits for the user. | ## Configuration