From 5a2535f31a92718c63b0d9bdea212391976e2a44 Mon Sep 17 00:00:00 2001 From: Mykyta Netipa Date: Wed, 4 Nov 2026 22:20:45 +0000 Subject: [PATCH] feat: add OAuth discovery and a grant store The CLI learns how to authorize from the agent card's securitySchemes and securityRequirements (oauth2 with a metadata URL or inline flows, or openIdConnect), or from a 401: every Bearer challenge is parsed and its resource_metadata, or the well-known location, leads to the Protected Resource Metadata (RFC 9728) and on to Authorization Server Metadata (RFC 8414) or OpenID Connect Discovery. Metadata must be served over https (http only on loopback), resource metadata redirects stay within the origin, and the resource and issuer named in each document are checked. A card that also accepts other credentials, such as an API key, makes the requirement optional. Client registrations come from configuration, with the a2a-cli Client ID Metadata Document as the default client for servers that support CIMD when none matches; the default can be changed or disabled. Grants are kept in ~/.config/a2a-cli/oauth/grants.json (0600) keyed by issuer, client ID, resource and sorted scopes, with an OS file lock (flock, LockFileEx) so concurrent CLI processes don't lose rotated refresh tokens. Tests run against oauthtest, an in-process authorization server (RFC 8414 and OpenID Connect metadata, authorization code with PKCE, device flow, refresh with optional rotation, revocation, optional TLS) and an A2A echo agent that requires its tokens, declares OAuth in its card in each supported form and answers 401 with a Bearer challenge, so the flows are exercised end to end without a browser, network access or a real identity provider. Refs #98 --- go.mod | 2 +- internal/oauth/challenge.go | 165 +++++++++++ internal/oauth/challenge_test.go | 82 ++++++ internal/oauth/clients.go | 115 ++++++++ internal/oauth/clients_test.go | 131 +++++++++ internal/oauth/discover.go | 152 ++++++++++ internal/oauth/discover_test.go | 329 +++++++++++++++++++++ internal/oauth/doc.go | 24 ++ internal/oauth/errors.go | 85 ++++++ internal/oauth/grant.go | 107 +++++++ internal/oauth/grant_test.go | 93 ++++++ internal/oauth/lock_other.go | 25 ++ internal/oauth/lock_unix.go | 38 +++ internal/oauth/lock_windows.go | 40 +++ internal/oauth/metadata.go | 219 ++++++++++++++ internal/oauth/oauthtest/agent.go | 210 ++++++++++++++ internal/oauth/oauthtest/server.go | 441 +++++++++++++++++++++++++++++ internal/oauth/store.go | 206 ++++++++++++++ internal/oauth/store_test.go | 174 ++++++++++++ internal/oauth/token.go | 132 +++++++++ internal/oauth/token_test.go | 134 +++++++++ internal/oauth/urls.go | 139 +++++++++ internal/oauth/urls_test.go | 116 ++++++++ 23 files changed, 3158 insertions(+), 1 deletion(-) create mode 100644 internal/oauth/challenge.go create mode 100644 internal/oauth/challenge_test.go create mode 100644 internal/oauth/clients.go create mode 100644 internal/oauth/clients_test.go create mode 100644 internal/oauth/discover.go create mode 100644 internal/oauth/discover_test.go create mode 100644 internal/oauth/doc.go create mode 100644 internal/oauth/errors.go create mode 100644 internal/oauth/grant.go create mode 100644 internal/oauth/grant_test.go create mode 100644 internal/oauth/lock_other.go create mode 100644 internal/oauth/lock_unix.go create mode 100644 internal/oauth/lock_windows.go create mode 100644 internal/oauth/metadata.go create mode 100644 internal/oauth/oauthtest/agent.go create mode 100644 internal/oauth/oauthtest/server.go create mode 100644 internal/oauth/store.go create mode 100644 internal/oauth/store_test.go create mode 100644 internal/oauth/token.go create mode 100644 internal/oauth/token_test.go create mode 100644 internal/oauth/urls.go create mode 100644 internal/oauth/urls_test.go diff --git a/go.mod b/go.mod index fb68b07..328bd62 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/joho/godotenv v1.5.1 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 + golang.org/x/sys v0.49.0 google.golang.org/grpc v1.84.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -19,7 +20,6 @@ require ( golang.org/x/mod v0.42.0 // indirect golang.org/x/net v0.61.0 // indirect golang.org/x/sync v0.24.0 // indirect - golang.org/x/sys v0.49.0 // indirect golang.org/x/text v0.43.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20261012220534-4341edf3f941 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20261012220534-4341edf3f941 // indirect diff --git a/internal/oauth/challenge.go b/internal/oauth/challenge.go new file mode 100644 index 0000000..26d708b --- /dev/null +++ b/internal/oauth/challenge.go @@ -0,0 +1,165 @@ +// 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 ( + "strings" +) + +// Challenge is one authentication challenge from a WWW-Authenticate header. +type Challenge struct { + // Scheme is the lowercase authentication scheme, e.g. "bearer". + Scheme string + // Params holds the auth-params with lowercase names. + Params map[string]string +} + +// ParseChallenges parses every challenge in the given WWW-Authenticate header +// values (RFC 9110 §11.6.1). A header may carry several comma-separated challenges. +func ParseChallenges(values []string) []Challenge { + var out []Challenge + for _, v := range values { + out = append(out, parseChallengeList(v)...) + } + return out +} + +func parseChallengeList(s string) []Challenge { + var out []Challenge + p := &challengeParser{s: s} + var cur *Challenge + for { + p.skip(" \t,") + if p.done() { + break + } + tok := p.token() + if tok == "" { + p.i++ + continue + } + p.skip(" \t") + if !p.done() && p.peek() == '=' && cur != nil { + p.i++ + p.skip(" \t") + cur.Params[strings.ToLower(tok)] = p.value() + continue + } + out = append(out, Challenge{Scheme: strings.ToLower(tok), Params: map[string]string{}}) + cur = &out[len(out)-1] + if p.token68() { + continue + } + } + return out +} + +type challengeParser struct { + s string + i int +} + +func (p *challengeParser) done() bool { return p.i >= len(p.s) } +func (p *challengeParser) peek() byte { return p.s[p.i] } + +func (p *challengeParser) skip(chars string) { + for !p.done() && strings.IndexByte(chars, p.peek()) >= 0 { + p.i++ + } +} + +func (p *challengeParser) token() string { + start := p.i + for !p.done() && isTokenChar(p.peek()) { + p.i++ + } + return p.s[start:p.i] +} + +// token68 consumes a token68 credential following a scheme (e.g. "Negotiate abc==") +// so it is not mistaken for a parameter name. It reports whether one was consumed. +func (p *challengeParser) token68() bool { + save := p.i + tok := p.token() + if tok == "" { + return false + } + end := p.i + for !p.done() && p.peek() == '=' { + p.i++ + } + p.skip(" \t") + if p.done() || p.peek() == ',' { + if end == p.i { + p.i = save + return false + } + return true + } + p.i = save + return false +} + +func (p *challengeParser) value() string { + if p.done() { + return "" + } + if p.peek() != '"' { + return p.token() + } + p.i++ + var b strings.Builder + for !p.done() { + c := p.peek() + p.i++ + switch c { + case '\\': + if !p.done() { + b.WriteByte(p.peek()) + p.i++ + } + case '"': + return b.String() + default: + b.WriteByte(c) + } + } + return b.String() +} + +func isTokenChar(c byte) bool { + if c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' { + return true + } + return strings.IndexByte("!#$%&'*+-.^_`|~/:", c) >= 0 +} + +// bearerChallenge returns the first Bearer challenge. +func bearerChallenge(chs []Challenge) (Challenge, bool) { + for _, c := range chs { + if c.Scheme == "bearer" { + return c, true + } + } + return Challenge{}, false +} + +// splitScopes splits a space-separated scope string. +func splitScopes(s string) []string { + if f := strings.Fields(s); len(f) > 0 { + return f + } + return nil +} diff --git a/internal/oauth/challenge_test.go b/internal/oauth/challenge_test.go new file mode 100644 index 0000000..d26235f --- /dev/null +++ b/internal/oauth/challenge_test.go @@ -0,0 +1,82 @@ +// 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 TestParseChallenges(t *testing.T) { + t.Parallel() + tests := []struct { + name string + values []string + want []Challenge + }{ + { + name: "bearer with quoted params", + values: []string{`Bearer resource_metadata="https://agent.example.com/.well-known/oauth-protected-resource", scope="a2a.invoke a2a.read"`}, + want: []Challenge{{Scheme: "bearer", Params: map[string]string{ + "resource_metadata": "https://agent.example.com/.well-known/oauth-protected-resource", + "scope": "a2a.invoke a2a.read", + }}}, + }, + { + name: "bare scheme", + values: []string{"Bearer"}, + want: []Challenge{{Scheme: "bearer", Params: map[string]string{}}}, + }, + { + name: "several challenges in one header", + values: []string{`Basic realm="corp", Bearer error="invalid_token", error_description="expired"`}, + want: []Challenge{ + {Scheme: "basic", Params: map[string]string{"realm": "corp"}}, + {Scheme: "bearer", Params: map[string]string{"error": "invalid_token", "error_description": "expired"}}, + }, + }, + { + name: "several headers", + values: []string{`Negotiate abc==`, `Bearer realm=agents`}, + want: []Challenge{ + {Scheme: "negotiate", Params: map[string]string{}}, + {Scheme: "bearer", Params: map[string]string{"realm": "agents"}}, + }, + }, + { + name: "escaped quotes and case-insensitive names", + values: []string{`BEARER Realm="a \"quoted\" realm"`}, + want: []Challenge{{Scheme: "bearer", Params: map[string]string{"realm": `a "quoted" realm`}}}, + }, + { + name: "unquoted url value", + values: []string{`Bearer resource_metadata=https://agent.example.com/prm`}, + want: []Challenge{{Scheme: "bearer", Params: map[string]string{"resource_metadata": "https://agent.example.com/prm"}}}, + }, + { + name: "no headers", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got := ParseChallenges(tc.values) + if diff := cmp.Diff(tc.want, got); diff != "" { + t.Fatalf("ParseChallenges(%q) wrong result (-want +got) diff = %s", tc.values, diff) + } + }) + } +} diff --git a/internal/oauth/clients.go b/internal/oauth/clients.go new file mode 100644 index 0000000..c785447 --- /dev/null +++ b/internal/oauth/clients.go @@ -0,0 +1,115 @@ +// 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 ( + "encoding/json" + "fmt" + "strings" +) + +// DefaultClientID is the Client ID Metadata Document published for a2a-cli. It is +// used with authorization servers that support CIMD when no registration matches. +const DefaultClientID = "https://a2a-protocol.org/oauth/a2a-cli.json" + +// Client sources reported by [ClientChoice]. +const ( + ClientSourceConfig = "config" + ClientSourceCIMD = "cimd-default" +) + +// Registration is a client registration from configuration. +type Registration struct { + // Authority is the issuer identifier of the authorization server. + Authority string `json:"authority"` + // ClientID is a pre-registered client ID or a Client ID Metadata Document URL. + ClientID string `json:"client_id"` + // Scopes are requested when the agent names none. + Scopes []string `json:"scopes,omitempty"` +} + +// ClientChoice is the client used with an authorization server. +type ClientChoice struct { + Registration + // Source is ClientSourceConfig or ClientSourceCIMD. + Source string +} + +// ParseRegistrations parses --oauth-clients values. Each value is a JSON object +// or a JSON array of objects. +func ParseRegistrations(values []string) ([]Registration, error) { + var regs []Registration + for _, v := range values { + v = strings.TrimSpace(v) + if v == "" { + continue + } + var batch []Registration + if strings.HasPrefix(v, "[") { + if err := json.Unmarshal([]byte(v), &batch); err != nil { + return nil, fmt.Errorf("invalid oauth-clients value %q: %w", v, err) + } + } else { + var r Registration + if err := json.Unmarshal([]byte(v), &r); err != nil { + return nil, fmt.Errorf("invalid oauth-clients value %q: want a JSON object with authority and client_id: %w", v, err) + } + batch = []Registration{r} + } + for _, r := range batch { + if err := r.validate(); err != nil { + return nil, err + } + regs = append(regs, r) + } + } + return regs, nil +} + +func (r Registration) validate() error { + if r.Authority == "" || r.ClientID == "" { + return fmt.Errorf("invalid oauth-clients entry %+v: authority and client_id are required", r) + } + if err := checkSecureURL(r.Authority); err != nil { + return fmt.Errorf("invalid oauth-clients authority: %w", err) + } + return nil +} + +// selectClient picks the registration for an authorization server, falling back +// to defaultClientID, normally [DefaultClientID], when the server supports Client +// ID Metadata Documents. An empty defaultClientID disables the fallback. +func selectClient(regs []Registration, defaultClientID string, e Endpoints) (ClientChoice, error) { + var matches []Registration + for _, r := range regs { + if sameIssuer(r.Authority, e.Issuer) { + matches = append(matches, r) + } + } + switch { + case len(matches) == 1: + return ClientChoice{Registration: matches[0], Source: ClientSourceConfig}, nil + case len(matches) > 1: + ids := make([]string, len(matches)) + for i, m := range matches { + ids[i] = m.ClientID + } + return ClientChoice{}, fmt.Errorf("several oauth-clients entries match %s (%s); keep one", e.Issuer, strings.Join(ids, ", ")) + case e.CIMDSupported && defaultClientID != "": + return ClientChoice{Registration: Registration{Authority: e.Issuer, ClientID: defaultClientID}, Source: ClientSourceCIMD}, nil + default: + return ClientChoice{}, &NoClientError{Issuers: []string{e.Issuer}} + } +} diff --git a/internal/oauth/clients_test.go b/internal/oauth/clients_test.go new file mode 100644 index 0000000..4dcc1dc --- /dev/null +++ b/internal/oauth/clients_test.go @@ -0,0 +1,131 @@ +// 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 ( + "errors" + "testing" + + "github.com/google/go-cmp/cmp" +) + +func TestParseRegistrations(t *testing.T) { + t.Parallel() + tests := []struct { + name string + values []string + want []Registration + wantErr bool + }{ + { + name: "object", + values: []string{`{"authority":"https://login.example.com","client_id":"0f1e2d3c","scopes":["a2a.invoke"]}`}, + want: []Registration{{Authority: "https://login.example.com", ClientID: "0f1e2d3c", Scopes: []string{"a2a.invoke"}}}, + }, + { + name: "array and object", + values: []string{ + `[{"authority":"https://login.example.com","client_id":"a"},{"authority":"https://auth.example.org","client_id":"https://a2a-protocol.org/oauth/a2a-cli.json"}]`, + `{"authority":"https://idp.example.net","client_id":"c"}`, + }, + want: []Registration{ + {Authority: "https://login.example.com", ClientID: "a"}, + {Authority: "https://auth.example.org", ClientID: "https://a2a-protocol.org/oauth/a2a-cli.json"}, + {Authority: "https://idp.example.net", ClientID: "c"}, + }, + }, + {name: "missing client_id", values: []string{`{"authority":"https://login.example.com"}`}, wantErr: true}, + {name: "plain http authority", values: []string{`{"authority":"http://login.example.com","client_id":"a"}`}, wantErr: true}, + {name: "not json", values: []string{`login.example.com=a`}, wantErr: true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got, err := ParseRegistrations(tc.values) + if (err != nil) != tc.wantErr { + t.Fatalf("ParseRegistrations() error = %v, want error %v", err, tc.wantErr) + } + if diff := cmp.Diff(tc.want, got); diff != "" { + t.Fatalf("ParseRegistrations() wrong result (-want +got) diff = %s", diff) + } + }) + } +} + +func TestSelectClient(t *testing.T) { + t.Parallel() + regs := []Registration{ + {Authority: "https://login.example.com/", ClientID: "configured"}, + {Authority: "https://dup.example.com", ClientID: "one"}, + {Authority: "https://dup.example.com", ClientID: "two"}, + } + tests := []struct { + name string + endpoints Endpoints + noDefault bool + want ClientChoice + wantErr bool + noClient bool + }{ + { + name: "configured registration, trailing slash ignored", + endpoints: Endpoints{Issuer: "https://login.example.com", CIMDSupported: true}, + want: ClientChoice{Registration: regs[0], Source: ClientSourceConfig}, + }, + { + name: "CIMD fallback", + endpoints: Endpoints{Issuer: "https://other.example.com", CIMDSupported: true}, + want: ClientChoice{Registration: Registration{Authority: "https://other.example.com", ClientID: DefaultClientID}, Source: ClientSourceCIMD}, + }, + { + name: "no client", + endpoints: Endpoints{Issuer: "https://other.example.com"}, + wantErr: true, + noClient: true, + }, + { + name: "CIMD fallback disabled", + endpoints: Endpoints{Issuer: "https://other.example.com", CIMDSupported: true}, + noDefault: true, + wantErr: true, + noClient: true, + }, + { + name: "ambiguous", + endpoints: Endpoints{Issuer: "https://dup.example.com"}, + wantErr: true, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + defaultID := DefaultClientID + if tc.noDefault { + defaultID = "" + } + got, err := selectClient(regs, defaultID, tc.endpoints) + if (err != nil) != tc.wantErr { + t.Fatalf("selectClient() error = %v, want error %v", err, tc.wantErr) + } + var nc *NoClientError + if errors.As(err, &nc) != tc.noClient { + t.Fatalf("selectClient() error = %v, want NoClientError %v", err, tc.noClient) + } + if diff := cmp.Diff(tc.want, got); diff != "" { + t.Fatalf("selectClient() wrong result (-want +got) diff = %s", diff) + } + }) + } +} diff --git a/internal/oauth/discover.go b/internal/oauth/discover.go new file mode 100644 index 0000000..fca5135 --- /dev/null +++ b/internal/oauth/discover.go @@ -0,0 +1,152 @@ +// 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 ( + "context" + "fmt" + "net/url" + + "github.com/a2aproject/a2a-go/v2/a2a" +) + +// Requirement describes the grant a request needs: the authorization server to +// get it from, the resource it is for and the scopes it must cover. +type Requirement struct { + Endpoints Endpoints + // Resource is the normalized agent URL, sent as the RFC 8707 resource indicator. + Resource string + // Scopes requested by the agent; empty when it named none. + Scopes []string + // PreferDevice is set when the agent card only offers the device code flow. + PreferDevice bool + // Optional is set when the agent card also accepts credentials other than + // OAuth, such as an API key, so a missing grant is not an error: the request + // goes out without one and the agent decides. + Optional bool +} + +// requirementFromCard returns the requirement declared by a card for calls to +// baseURL, or nil when the card declares no OAuth requirement the CLI can meet. +// The first securityRequirements option whose only scheme is oauth2 or +// openIdConnect is used; options needing other schemes are skipped, and make +// the requirement optional. +func (f fetcher) requirementFromCard(ctx context.Context, card *a2a.AgentCard, baseURL string) (*Requirement, error) { + for _, option := range card.SecurityRequirements { + if len(option) == 0 { + return nil, nil + } + if len(option) != 1 { + continue + } + for name, scopes := range option { + scheme, ok := card.SecuritySchemes[name] + if !ok { + continue + } + endpoints, preferDevice, ok, err := f.schemeEndpoints(ctx, scheme) + if err != nil { + return nil, fmt.Errorf("security scheme %q: %w", name, err) + } + if !ok { + continue + } + return &Requirement{ + Endpoints: endpoints, + Resource: NormalizeResource(baseURL), + Scopes: append([]string(nil), scopes...), + PreferDevice: preferDevice, + Optional: acceptsOtherCredentials(card), + }, nil + } + } + return nil, nil +} + +// acceptsOtherCredentials reports whether a card offers a securityRequirements +// option that needs no OAuth scheme, such as an API key or HTTP authentication. +func acceptsOtherCredentials(card *a2a.AgentCard) bool { + for _, option := range card.SecurityRequirements { + oauth, defined := false, true + for name := range option { + switch card.SecuritySchemes[name].(type) { + case nil: + defined = false + case a2a.OAuth2SecurityScheme, a2a.OpenIDConnectSecurityScheme: + oauth = true + } + } + if defined && !oauth { + return true + } + } + return false +} + +// schemeEndpoints resolves the endpoints of an oauth2 or openIdConnect scheme. +// It reports ok=false for schemes and flows the CLI can't use. +func (f fetcher) schemeEndpoints(ctx context.Context, scheme a2a.SecurityScheme) (Endpoints, bool, bool, error) { + switch s := scheme.(type) { + case a2a.OAuth2SecurityScheme: + if s.Oauth2MetadataURL != "" { + m, err := f.metadataAt(ctx, s.Oauth2MetadataURL) + if err != nil { + return Endpoints{}, false, false, err + } + e := m.endpoints() + _, device := s.Flows.(a2a.DeviceCodeOAuthFlow) + return e, device, true, e.validate() + } + switch flow := s.Flows.(type) { + case a2a.AuthorizationCodeOAuthFlow: + e := Endpoints{Issuer: origin(flow.TokenURL), Authorization: flow.AuthorizationURL, Token: flow.TokenURL} + return e, false, true, e.validate() + case a2a.DeviceCodeOAuthFlow: + e := Endpoints{Issuer: origin(flow.TokenURL), DeviceAuthorization: flow.DeviceAuthorizationURL, Token: flow.TokenURL} + return e, true, true, e.validate() + default: + return Endpoints{}, false, false, nil + } + case a2a.OpenIDConnectSecurityScheme: + if s.OpenIDConnectURL == "" { + return Endpoints{}, false, false, nil + } + m, err := f.metadataAt(ctx, s.OpenIDConnectURL) + if err != nil { + return Endpoints{}, false, false, err + } + e := m.endpoints() + return e, false, true, e.validate() + default: + return Endpoints{}, false, false, nil + } +} + +// challengeTarget is what a Bearer challenge tells about a 401 response. +type challengeTarget struct { + resource *ResourceMetadata + scopes []string +} + +// discoverFromChallenge follows a 401 to the resource's metadata. It returns +// errNoResourceMetadata when the resource doesn't advertise any. +func (f fetcher) discoverFromChallenge(ctx context.Context, target *url.URL, chs []Challenge) (*challengeTarget, error) { + bc, _ := bearerChallenge(chs) + prm, err := f.resourceMetadata(ctx, target, bc.Params["resource_metadata"]) + if err != nil { + return nil, err + } + return &challengeTarget{resource: prm, scopes: splitScopes(bc.Params["scope"])}, nil +} diff --git a/internal/oauth/discover_test.go b/internal/oauth/discover_test.go new file mode 100644 index 0000000..99e931f --- /dev/null +++ b/internal/oauth/discover_test.go @@ -0,0 +1,329 @@ +// 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 ( + "errors" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + + "github.com/a2aproject/a2a-cli/internal/oauth/oauthtest" + "github.com/a2aproject/a2a-go/v2/a2a" + "github.com/a2aproject/a2a-go/v2/a2aclient/agentcard" +) + +func testFetcher() fetcher { return fetcher{client: &http.Client{}} } + +// jsonServer serves body as JSON at every path. +func jsonServer(t *testing.T, body string) *httptest.Server { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(body)) + })) + t.Cleanup(srv.Close) + return srv +} + +func TestRequirementFromCard(t *testing.T) { + t.Parallel() + as := oauthtest.NewAuthServer(t, oauthtest.ServerOptions{}) + scopes := []string{"a2a.invoke"} + all := Endpoints{ + Issuer: as.URL, + Authorization: as.URL + "/authorize", + Token: as.URL + "/token", + DeviceAuthorization: as.URL + "/device", + Revocation: as.URL + "/revoke", + } + tests := []struct { + name string + card oauthtest.CardSecurity + want *Requirement + }{ + { + name: "oauth2 metadata URL", + card: oauthtest.CardMetadataURL, + want: &Requirement{Endpoints: all, Scopes: scopes}, + }, + { + name: "inline authorization code flow", + card: oauthtest.CardAuthCode, + want: &Requirement{Endpoints: Endpoints{Issuer: as.URL, Authorization: as.URL + "/authorize", Token: as.URL + "/token"}, Scopes: scopes}, + }, + { + name: "inline device code flow", + card: oauthtest.CardDeviceCode, + want: &Requirement{Endpoints: Endpoints{Issuer: as.URL, DeviceAuthorization: as.URL + "/device", Token: as.URL + "/token"}, Scopes: scopes, PreferDevice: true}, + }, + { + name: "openIdConnect", + card: oauthtest.CardOIDC, + want: &Requirement{Endpoints: all, Scopes: scopes}, + }, + { + name: "no security", + card: oauthtest.CardNone, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + agent := oauthtest.NewProtectedAgent(t, as, oauthtest.AgentOptions{Card: tc.card, Scopes: scopes}) + card, err := agentcard.DefaultResolver.Resolve(t.Context(), agent.URL) + if err != nil { + t.Fatalf("Resolve(%q) error = %v", agent.URL, err) + } + if tc.want != nil { + tc.want.Resource = NormalizeResource(agent.URL) + } + got, err := testFetcher().requirementFromCard(t.Context(), card, agent.URL) + if err != nil { + t.Fatalf("requirementFromCard() error = %v", err) + } + if diff := cmp.Diff(tc.want, got); diff != "" { + t.Fatalf("requirementFromCard() wrong result (-want +got) diff = %s", diff) + } + }) + } +} + +func TestRequirementFromCardSkipsUnusableOptions(t *testing.T) { + t.Parallel() + card := &a2a.AgentCard{ + SecuritySchemes: a2a.NamedSecuritySchemes{ + "key": a2a.APIKeySecurityScheme{Name: "X-Key", Location: "header"}, + "oauth": a2a.OAuth2SecurityScheme{Flows: a2a.AuthorizationCodeOAuthFlow{AuthorizationURL: "https://as.example.com/authorize", TokenURL: "https://as.example.com/token"}}, + }, + SecurityRequirements: a2a.SecurityRequirementsOptions{ + {"key": nil}, + {"key": nil, "oauth": nil}, + {"missing": nil}, + {"oauth": {"read"}}, + }, + } + got, err := testFetcher().requirementFromCard(t.Context(), card, "https://agent.example.com/") + if err != nil { + t.Fatalf("requirementFromCard() error = %v", err) + } + // The API key option makes the OAuth requirement optional. + want := &Requirement{ + Endpoints: Endpoints{Issuer: "https://as.example.com", Authorization: "https://as.example.com/authorize", Token: "https://as.example.com/token"}, + Resource: "https://agent.example.com", + Scopes: []string{"read"}, + Optional: true, + } + if diff := cmp.Diff(want, got); diff != "" { + t.Fatalf("requirementFromCard() wrong result (-want +got) diff = %s", diff) + } + + // An empty option means the agent can be called without credentials. + card.SecurityRequirements = append(a2a.SecurityRequirementsOptions{{}}, card.SecurityRequirements...) + if got, err := testFetcher().requirementFromCard(t.Context(), card, "https://agent.example.com"); got != nil || err != nil { + t.Fatalf("requirementFromCard() with an anonymous option = %+v, %v, want nil, nil", got, err) + } +} + +func TestRequirementFromCardRejectsBadMetadata(t *testing.T) { + t.Parallel() + srv := jsonServer(t, `{"issuer":"https://elsewhere.example.com","token_endpoint":"https://elsewhere.example.com/token"}`) + card := &a2a.AgentCard{ + SecuritySchemes: a2a.NamedSecuritySchemes{"oauth": a2a.OAuth2SecurityScheme{Oauth2MetadataURL: srv.URL + "/.well-known/oauth-authorization-server"}}, + SecurityRequirements: a2a.SecurityRequirementsOptions{{"oauth": nil}}, + } + _, err := testFetcher().requirementFromCard(t.Context(), card, "https://agent.example.com") + if err == nil || !strings.Contains(err.Error(), "does not publish metadata there") { + t.Fatalf("requirementFromCard() error = %v, want the issuer mismatch", err) + } +} + +func TestDiscoverFromChallenge(t *testing.T) { + t.Parallel() + as := oauthtest.NewAuthServer(t, oauthtest.ServerOptions{}) + for _, omit := range []bool{false, true} { + agent := oauthtest.NewProtectedAgent(t, as, oauthtest.AgentOptions{Scopes: []string{"a2a.invoke"}, OmitResourceMetadata: omit}) + resp, err := http.Get(agent.URL + "/tasks") + if err != nil { + t.Fatal(err) + } + _ = resp.Body.Close() + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("GET %s status = %d, want 401", agent.URL, resp.StatusCode) + } + target, _ := url.Parse(agent.URL + "/tasks") + got, err := testFetcher().discoverFromChallenge(t.Context(), target, ParseChallenges(resp.Header.Values("WWW-Authenticate"))) + if err != nil { + t.Fatalf("discoverFromChallenge(omit resource_metadata=%v) error = %v", omit, err) + } + want := &challengeTarget{ + resource: &ResourceMetadata{Resource: agent.URL, AuthorizationServers: []string{as.URL}, ScopesSupported: []string{"a2a.invoke"}}, + scopes: []string{"a2a.invoke"}, + } + if diff := cmp.Diff(want, got, cmp.AllowUnexported(challengeTarget{})); diff != "" { + t.Fatalf("discoverFromChallenge(omit resource_metadata=%v) wrong result (-want +got) diff = %s", omit, diff) + } + } +} + +func TestResourceMetadataErrors(t *testing.T) { + t.Parallel() + notFound := httptest.NewServer(http.NotFoundHandler()) + t.Cleanup(notFound.Close) + tests := []struct { + name string + body string + explicit bool + wantErr string + }{ + {name: "other resource", body: `{"resource":"https://other.example.com","authorization_servers":["https://as.example.com"]}`, wantErr: "describes"}, + {name: "no resource", body: `{"authorization_servers":["https://as.example.com"]}`, wantErr: "has no resource"}, + {name: "no authorization servers", body: `{"resource":"%s"}`, wantErr: "lists no authorization servers"}, + {name: "explicit URL not found", explicit: true, wantErr: "HTTP 404"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + var srv *httptest.Server + srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if tc.body == "" { + http.NotFound(w, r) + return + } + _, _ = w.Write([]byte(strings.ReplaceAll(tc.body, "%s", srv.URL))) + })) + t.Cleanup(srv.Close) + target, _ := url.Parse(srv.URL) + metadataURL := "" + if tc.explicit { + metadataURL = srv.URL + "/.well-known/oauth-protected-resource" + } + _, err := testFetcher().resourceMetadata(t.Context(), target, metadataURL) + if err == nil || !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("resourceMetadata() error = %v, want it to contain %q", err, tc.wantErr) + } + }) + } + + t.Run("none published", func(t *testing.T) { + t.Parallel() + target, _ := url.Parse(notFound.URL + "/a2a") + if _, err := testFetcher().resourceMetadata(t.Context(), target, ""); !errors.Is(err, errNoResourceMetadata) { + t.Fatalf("resourceMetadata() error = %v, want errNoResourceMetadata", err) + } + }) + + t.Run("cross-origin redirect", func(t *testing.T) { + t.Parallel() + other := jsonServer(t, `{"resource":"https://agent.example.com","authorization_servers":["https://as.example.com"]}`) + redirect := httptest.NewServer(http.RedirectHandler(other.URL+"/prm", http.StatusFound)) + t.Cleanup(redirect.Close) + target, _ := url.Parse(redirect.URL) + _, err := testFetcher().resourceMetadata(t.Context(), target, redirect.URL+"/prm") + if err == nil || !strings.Contains(err.Error(), "another origin") { + t.Fatalf("resourceMetadata() error = %v, want the cross-origin redirect refused", err) + } + }) +} + +func TestServerMetadata(t *testing.T) { + t.Parallel() + for _, oidcOnly := range []bool{false, true} { + as := oauthtest.NewAuthServer(t, oauthtest.ServerOptions{OIDCOnly: oidcOnly, CIMD: true}) + m, err := testFetcher().serverMetadata(t.Context(), as.URL) + if err != nil { + t.Fatalf("serverMetadata(OIDCOnly=%v) error = %v", oidcOnly, err) + } + want := Endpoints{ + Issuer: as.URL, + Authorization: as.URL + "/authorize", + Token: as.URL + "/token", + DeviceAuthorization: as.URL + "/device", + Revocation: as.URL + "/revoke", + CIMDSupported: true, + } + if diff := cmp.Diff(want, m.endpoints()); diff != "" { + t.Fatalf("serverMetadata(OIDCOnly=%v).endpoints() wrong result (-want +got) diff = %s", oidcOnly, diff) + } + } + + srv := jsonServer(t, `{"issuer":"https://elsewhere.example.com"}`) + if _, err := testFetcher().serverMetadata(t.Context(), srv.URL); err == nil || !strings.Contains(err.Error(), "names issuer") { + t.Fatalf("serverMetadata() error = %v, want the issuer mismatch", err) + } +} + +func TestEndpointsValidate(t *testing.T) { + t.Parallel() + tests := []struct { + name string + e Endpoints + wantErr string + }{ + {name: "authorization code", e: Endpoints{Issuer: "https://as.example.com", Authorization: "https://as.example.com/authorize", Token: "https://as.example.com/token"}}, + {name: "device only", e: Endpoints{Issuer: "https://as.example.com", DeviceAuthorization: "https://as.example.com/device", Token: "https://as.example.com/token"}}, + {name: "no issuer", e: Endpoints{Token: "https://as.example.com/token"}, wantErr: "no issuer"}, + {name: "no token endpoint", e: Endpoints{Issuer: "https://as.example.com", Authorization: "https://as.example.com/authorize"}, wantErr: "no token endpoint"}, + {name: "no user flow", e: Endpoints{Issuer: "https://as.example.com", Token: "https://as.example.com/token"}, wantErr: "neither"}, + {name: "plain http", e: Endpoints{Issuer: "http://as.example.com", Authorization: "http://as.example.com/authorize", Token: "http://as.example.com/token"}, wantErr: "https"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + err := tc.e.validate() + if tc.wantErr == "" { + if err != nil { + t.Fatalf("validate() error = %v, want nil", err) + } + return + } + if err == nil || !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("validate() error = %v, want it to contain %q", err, tc.wantErr) + } + }) + } +} + +func TestAcceptsOtherCredentials(t *testing.T) { + t.Parallel() + oauthScheme := a2a.OAuth2SecurityScheme{Flows: a2a.AuthorizationCodeOAuthFlow{AuthorizationURL: "https://as.example.com/authorize", TokenURL: "https://as.example.com/token"}} + schemes := a2a.NamedSecuritySchemes{ + "oauth": oauthScheme, + "oidc": a2a.OpenIDConnectSecurityScheme{OpenIDConnectURL: "https://as.example.com/.well-known/openid-configuration"}, + "key": a2a.APIKeySecurityScheme{Name: "X-Key", Location: "header"}, + } + tests := []struct { + name string + options a2a.SecurityRequirementsOptions + want bool + }{ + {name: "oauth only", options: a2a.SecurityRequirementsOptions{{"oauth": nil}}}, + {name: "oauth or openIdConnect", options: a2a.SecurityRequirementsOptions{{"oauth": nil}, {"oidc": nil}}}, + {name: "oauth or api key", options: a2a.SecurityRequirementsOptions{{"oauth": nil}, {"key": nil}}, want: true}, + {name: "oauth or no credentials", options: a2a.SecurityRequirementsOptions{{"oauth": nil}, {}}, want: true}, + {name: "oauth and api key together", options: a2a.SecurityRequirementsOptions{{"oauth": nil, "key": nil}}}, + {name: "undefined scheme", options: a2a.SecurityRequirementsOptions{{"oauth": nil}, {"missing": nil}}}, + } + for _, tc := range tests { + card := &a2a.AgentCard{SecuritySchemes: schemes, SecurityRequirements: tc.options} + if got := acceptsOtherCredentials(card); got != tc.want { + t.Errorf("%s: acceptsOtherCredentials() = %v, want %v", tc.name, got, tc.want) + } + } +} diff --git a/internal/oauth/doc.go b/internal/oauth/doc.go new file mode 100644 index 0000000..36153b4 --- /dev/null +++ b/internal/oauth/doc.go @@ -0,0 +1,24 @@ +// 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 obtains, stores and attaches OAuth access tokens for agents. +// +// The CLI learns how to authorize from two sources: the agent card's +// securitySchemes and securityRequirements, read before the first request, and +// Bearer challenges on a 401 response, which lead to Protected Resource Metadata +// (RFC 9728) and Authorization Server Metadata (RFC 8414). Grants come from the +// authorization code flow with PKCE and a loopback redirect (RFC 7636, RFC 8252) +// or the device authorization grant (RFC 8628), and are kept in a [Store] keyed +// by issuer, client ID, resource and scopes. +package oauth diff --git a/internal/oauth/errors.go b/internal/oauth/errors.go new file mode 100644 index 0000000..4ed90d4 --- /dev/null +++ b/internal/oauth/errors.go @@ -0,0 +1,85 @@ +// 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 ( + "fmt" + "strings" +) + +// InteractionRequiredError reports that a request needs a grant that has to be +// obtained with user interaction first. +type InteractionRequiredError struct { + // Requirement describes where and for what to obtain the grant. + Requirement *Requirement + // Reason explains why a stored grant could not be used; empty when there was none. + Reason string +} + +// Error implements the error interface. +func (e *InteractionRequiredError) Error() string { + msg := fmt.Sprintf("%s requires authorization at %s", e.Requirement.Resource, e.Requirement.Endpoints.Issuer) + if e.Reason != "" { + msg += ": " + e.Reason + } + return msg +} + +// FailureError reports a failed step of the authorization process: discovery, +// metadata validation or token refresh. +type FailureError struct { + Err error +} + +// Error implements the error interface. +func (e *FailureError) Error() string { return e.Err.Error() } + +// Unwrap returns the cause. +func (e *FailureError) Unwrap() error { return e.Err } + +func failure(format string, args ...any) error { + return &FailureError{Err: fmt.Errorf(format, args...)} +} + +// NoClientError reports that no client registration can be used with an +// authorization server. +type NoClientError struct { + // Issuers are the authorization servers that were tried. + Issuers []string +} + +// Error implements the error interface. +func (e *NoClientError) Error() string { + example := `{"authority":"","client_id":""}` + if len(e.Issuers) > 0 { + example = fmt.Sprintf(`{"authority":%q,"client_id":""}`, e.Issuers[0]) + } + return fmt.Sprintf("no client in oauth-clients matches the authorization server %s; add %s to oauth-clients", + strings.Join(e.Issuers, ", "), example) +} + +// Error is an OAuth error response from an authorization server (RFC 6749 §5.2). +type Error struct { + Code string `json:"error"` + Description string `json:"error_description,omitempty"` +} + +// Error implements the error interface. +func (e *Error) Error() string { + if e.Description != "" { + return fmt.Sprintf("%s: %s", e.Code, e.Description) + } + return e.Code +} diff --git a/internal/oauth/grant.go b/internal/oauth/grant.go new file mode 100644 index 0000000..95f0973 --- /dev/null +++ b/internal/oauth/grant.go @@ -0,0 +1,107 @@ +// 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 ( + "crypto/sha256" + "encoding/hex" + "slices" + "strings" + "time" +) + +// refreshSkew is how long before expiry a token is refreshed. +const refreshSkew = time.Minute + +// GrantKey identifies a grant in the store. +type GrantKey struct { + Issuer string `json:"issuer"` + ClientID string `json:"client_id"` + Resource string `json:"resource"` + Scopes []string `json:"scopes,omitempty"` +} + +// NewGrantKey builds a key with normalized resource and sorted, deduplicated scopes. +func NewGrantKey(issuer, clientID, resource string, scopes []string) GrantKey { + s := slices.Clone(scopes) + slices.Sort(s) + s = slices.Compact(s) + if len(s) == 0 { + s = nil + } + return GrantKey{ + Issuer: strings.TrimSuffix(issuer, "/"), + ClientID: clientID, + Resource: NormalizeResource(resource), + Scopes: s, + } +} + +// ID is a stable identifier of the key. +func (k GrantKey) ID() string { + sum := sha256.Sum256([]byte(k.Issuer + "\n" + k.ClientID + "\n" + k.Resource + "\n" + strings.Join(k.Scopes, " "))) + return hex.EncodeToString(sum[:16]) +} + +// Equal reports whether two keys identify the same grant. +func (k GrantKey) Equal(o GrantKey) bool { + return k.Issuer == o.Issuer && k.ClientID == o.ClientID && k.Resource == o.Resource && slices.Equal(k.Scopes, o.Scopes) +} + +// Grant is a stored authorization: the tokens and the endpoints to refresh and +// revoke them with. +type Grant struct { + Key GrantKey `json:"key"` + AccessToken string `json:"access_token,omitempty"` + RefreshToken string `json:"refresh_token,omitempty"` + TokenType string `json:"token_type,omitempty"` + ExpiresAt time.Time `json:"expires_at,omitzero"` + Endpoints Endpoints `json:"endpoints"` + CreatedAt time.Time `json:"created_at,omitzero"` + // ClientSource records whether the client came from configuration or the CIMD default. + ClientSource string `json:"client_source,omitempty"` +} + +// expired reports whether the access token is past its expiry. +func (g *Grant) expired(now time.Time) bool { + return !g.ExpiresAt.IsZero() && !now.Before(g.ExpiresAt) +} + +// needsRefresh reports whether the access token expires within [refreshSkew]. +func (g *Grant) needsRefresh(now time.Time) bool { + return g.AccessToken == "" || (!g.ExpiresAt.IsZero() && !now.Add(refreshSkew).Before(g.ExpiresAt)) +} + +// header is the Authorization header value for the grant. +func (g *Grant) header() string { + return "Bearer " + g.AccessToken +} + +// requirement rebuilds the requirement the grant was obtained for. +func (g *Grant) requirement() *Requirement { + return &Requirement{Endpoints: g.Endpoints, Resource: g.Key.Resource, Scopes: g.Key.Scopes} +} + +func (g *Grant) apply(t *tokenResponse, now time.Time) { + g.AccessToken = t.AccessToken + if t.RefreshToken != "" { + g.RefreshToken = t.RefreshToken + } + g.TokenType = t.TokenType + g.ExpiresAt = time.Time{} + if t.ExpiresIn > 0 { + g.ExpiresAt = now.Add(time.Duration(t.ExpiresIn) * time.Second).UTC() + } +} diff --git a/internal/oauth/grant_test.go b/internal/oauth/grant_test.go new file mode 100644 index 0000000..9e1a85a --- /dev/null +++ b/internal/oauth/grant_test.go @@ -0,0 +1,93 @@ +// 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 ( + "errors" + "io" + "testing" + "time" + + "github.com/google/go-cmp/cmp" +) + +func TestGrantExpiry(t *testing.T) { + t.Parallel() + now := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) + tests := []struct { + name string + grant Grant + wantExpired bool + wantNeedsRefresh bool + }{ + {name: "no expiry", grant: Grant{AccessToken: "at"}}, + {name: "valid", grant: Grant{AccessToken: "at", ExpiresAt: now.Add(time.Hour)}}, + {name: "within refresh skew", grant: Grant{AccessToken: "at", ExpiresAt: now.Add(refreshSkew / 2)}, wantNeedsRefresh: true}, + {name: "expired", grant: Grant{AccessToken: "at", ExpiresAt: now}, wantExpired: true, wantNeedsRefresh: true}, + {name: "no access token", grant: Grant{RefreshToken: "rt"}, wantNeedsRefresh: true}, + } + for _, tc := range tests { + if got := tc.grant.expired(now); got != tc.wantExpired { + t.Errorf("%s: expired() = %v, want %v", tc.name, got, tc.wantExpired) + } + if got := tc.grant.needsRefresh(now); got != tc.wantNeedsRefresh { + t.Errorf("%s: needsRefresh() = %v, want %v", tc.name, got, tc.wantNeedsRefresh) + } + } +} + +func TestGrantApply(t *testing.T) { + t.Parallel() + now := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) + g := Grant{ + Key: NewGrantKey("https://as.example.com", "cli", "https://agent.example.com", []string{"read"}), + AccessToken: "old", + RefreshToken: "rt", + ExpiresAt: now, + Endpoints: Endpoints{Issuer: "https://as.example.com", Token: "https://as.example.com/token"}, + } + + // A response without a refresh token keeps the current one. + g.apply(&tokenResponse{AccessToken: "new", TokenType: "Bearer", ExpiresIn: 60}, now) + if g.AccessToken != "new" || g.RefreshToken != "rt" || !g.ExpiresAt.Equal(now.Add(time.Minute)) { + t.Fatalf("apply() = %+v, want the new access token, the old refresh token and expiry in a minute", g) + } + if got := g.header(); got != "Bearer new" { + t.Fatalf("header() = %q, want %q", got, "Bearer new") + } + + // A rotated refresh token replaces it; no expires_in clears the expiry. + g.apply(&tokenResponse{AccessToken: "newer", RefreshToken: "rt2"}, now) + if g.RefreshToken != "rt2" || !g.ExpiresAt.IsZero() { + t.Fatalf("apply() = %+v, want the rotated refresh token and no expiry", g) + } + + want := &Requirement{Endpoints: g.Endpoints, Resource: "https://agent.example.com", Scopes: []string{"read"}} + if diff := cmp.Diff(want, g.requirement()); diff != "" { + t.Fatalf("requirement() wrong result (-want +got) diff = %s", diff) + } +} + +func TestFailure(t *testing.T) { + t.Parallel() + err := failure("refreshing the grant for %s: %w", "https://agent.example.com", io.ErrUnexpectedEOF) + var fe *FailureError + if !errors.As(err, &fe) || !errors.Is(err, io.ErrUnexpectedEOF) { + t.Fatalf("failure() = %#v, want a *FailureError wrapping the cause", err) + } + if want := "refreshing the grant for https://agent.example.com: unexpected EOF"; err.Error() != want { + t.Fatalf("failure().Error() = %q, want %q", err.Error(), want) + } +} diff --git a/internal/oauth/lock_other.go b/internal/oauth/lock_other.go new file mode 100644 index 0000000..74172b0 --- /dev/null +++ b/internal/oauth/lock_other.go @@ -0,0 +1,25 @@ +// 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. + +//go:build !(darwin || dragonfly || freebsd || linux || netbsd || openbsd || windows) + +package oauth + +import "os" + +// tryLockFile is a no-op where no advisory file lock is available: the store is +// then not protected against concurrent CLI processes. +func tryLockFile(*os.File) (bool, error) { return true, nil } + +func unlockFile(*os.File) error { return nil } diff --git a/internal/oauth/lock_unix.go b/internal/oauth/lock_unix.go new file mode 100644 index 0000000..0222e1c --- /dev/null +++ b/internal/oauth/lock_unix.go @@ -0,0 +1,38 @@ +// 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. + +//go:build darwin || dragonfly || freebsd || linux || netbsd || openbsd + +package oauth + +import ( + "errors" + "os" + + "golang.org/x/sys/unix" +) + +// tryLockFile takes an exclusive flock on f without waiting. It reports false +// when another open file holds the lock. +func tryLockFile(f *os.File) (bool, error) { + err := unix.Flock(int(f.Fd()), unix.LOCK_EX|unix.LOCK_NB) + if errors.Is(err, unix.EWOULDBLOCK) { + return false, nil + } + return err == nil, err +} + +func unlockFile(f *os.File) error { + return unix.Flock(int(f.Fd()), unix.LOCK_UN) +} diff --git a/internal/oauth/lock_windows.go b/internal/oauth/lock_windows.go new file mode 100644 index 0000000..833a0d6 --- /dev/null +++ b/internal/oauth/lock_windows.go @@ -0,0 +1,40 @@ +// 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. + +//go:build windows + +package oauth + +import ( + "errors" + "os" + + "golang.org/x/sys/windows" +) + +// tryLockFile locks f exclusively without waiting. It reports false when +// another open file holds the lock. +func tryLockFile(f *os.File) (bool, error) { + var ol windows.Overlapped + err := windows.LockFileEx(windows.Handle(f.Fd()), windows.LOCKFILE_EXCLUSIVE_LOCK|windows.LOCKFILE_FAIL_IMMEDIATELY, 0, 1, 0, &ol) + if errors.Is(err, windows.ERROR_LOCK_VIOLATION) { + return false, nil + } + return err == nil, err +} + +func unlockFile(f *os.File) error { + var ol windows.Overlapped + return windows.UnlockFileEx(windows.Handle(f.Fd()), 0, 1, 0, &ol) +} diff --git a/internal/oauth/metadata.go b/internal/oauth/metadata.go new file mode 100644 index 0000000..817118d --- /dev/null +++ b/internal/oauth/metadata.go @@ -0,0 +1,219 @@ +// 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 ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" +) + +const maxMetadataBytes = 1 << 20 + +// ServerMetadata is the subset of RFC 8414 Authorization Server Metadata and +// OpenID Connect Discovery the CLI uses. +type ServerMetadata struct { + Issuer string `json:"issuer"` + AuthorizationEndpoint string `json:"authorization_endpoint,omitempty"` + TokenEndpoint string `json:"token_endpoint,omitempty"` + DeviceAuthorizationEndpoint string `json:"device_authorization_endpoint,omitempty"` + RevocationEndpoint string `json:"revocation_endpoint,omitempty"` + CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported,omitempty"` + ClientIDMetadataDocumentSupported bool `json:"client_id_metadata_document_supported,omitempty"` +} + +// ResourceMetadata is the subset of RFC 9728 Protected Resource Metadata the CLI uses. +type ResourceMetadata struct { + Resource string `json:"resource"` + AuthorizationServers []string `json:"authorization_servers,omitempty"` + ScopesSupported []string `json:"scopes_supported,omitempty"` +} + +// Endpoints are the authorization server endpoints a grant is obtained, +// refreshed and revoked with. +type Endpoints struct { + Issuer string `json:"issuer"` + Authorization string `json:"authorization,omitempty"` + Token string `json:"token"` + DeviceAuthorization string `json:"device_authorization,omitempty"` + Revocation string `json:"revocation,omitempty"` + // CIMDSupported reports that the server accepts Client ID Metadata Document URLs as client IDs. + CIMDSupported bool `json:"cimd_supported,omitempty"` +} + +func (m *ServerMetadata) endpoints() Endpoints { + return Endpoints{ + Issuer: m.Issuer, + Authorization: m.AuthorizationEndpoint, + Token: m.TokenEndpoint, + DeviceAuthorization: m.DeviceAuthorizationEndpoint, + Revocation: m.RevocationEndpoint, + CIMDSupported: m.ClientIDMetadataDocumentSupported, + } +} + +func (e Endpoints) validate() error { + if e.Issuer == "" { + return errors.New("authorization server has no issuer") + } + if e.Token == "" { + return fmt.Errorf("authorization server %s has no token endpoint", e.Issuer) + } + for _, u := range []string{e.Authorization, e.Token, e.DeviceAuthorization, e.Revocation} { + if u == "" { + continue + } + if err := checkSecureURL(u); err != nil { + return fmt.Errorf("authorization server %s: %w", e.Issuer, err) + } + } + if e.Authorization == "" && e.DeviceAuthorization == "" { + return fmt.Errorf("authorization server %s has neither an authorization nor a device authorization endpoint", e.Issuer) + } + return nil +} + +// fetcher makes the CLI's own requests to metadata and authorization server +// endpoints. Its client never carries agent credentials. +type fetcher struct { + client *http.Client +} + +// getJSON fetches a JSON document over https (or loopback http). With +// sameOrigin, redirects are only followed within the URL's origin. +func (f fetcher) getJSON(ctx context.Context, rawURL string, sameOrigin bool, out any) error { + if err := checkSecureURL(rawURL); err != nil { + return err + } + client := *f.client + start := origin(rawURL) + client.CheckRedirect = func(req *http.Request, via []*http.Request) error { + if len(via) >= 5 { + return errors.New("too many redirects") + } + if sameOrigin && origin(req.URL.String()) != start { + return fmt.Errorf("redirect from %s to another origin %s refused", rawURL, req.URL) + } + return checkSecureURL(req.URL.String()) + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) + if err != nil { + return err + } + req.Header.Set("Accept", "application/json") + resp, err := client.Do(req) + if err != nil { + return err + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + return &statusError{url: rawURL, status: resp.StatusCode} + } + if err := json.NewDecoder(io.LimitReader(resp.Body, maxMetadataBytes)).Decode(out); err != nil { + return fmt.Errorf("parsing %s: %w", rawURL, err) + } + return nil +} + +type statusError struct { + url string + status int +} + +func (e *statusError) Error() string { + return fmt.Sprintf("GET %s: HTTP %d", e.url, e.status) +} + +// serverMetadata fetches and validates the metadata of an issuer, trying the RFC +// 8414 location first and OpenID Connect Discovery second. +func (f fetcher) serverMetadata(ctx context.Context, issuer string) (*ServerMetadata, error) { + urls := serverMetadataURLs(issuer) + if len(urls) == 0 { + return nil, fmt.Errorf("invalid authorization server identifier %q", issuer) + } + var errs []error + for _, u := range urls { + var m ServerMetadata + if err := f.getJSON(ctx, u, false, &m); err != nil { + errs = append(errs, err) + continue + } + if !sameIssuer(m.Issuer, issuer) { + return nil, fmt.Errorf("metadata at %s names issuer %q, want %q", u, m.Issuer, issuer) + } + return &m, nil + } + return nil, fmt.Errorf("fetching metadata of %s: %w", issuer, errors.Join(errs...)) +} + +// metadataAt fetches metadata from a URL given by an agent card and checks that +// the URL is a well-known location of the issuer it names. +func (f fetcher) metadataAt(ctx context.Context, rawURL string) (*ServerMetadata, error) { + var m ServerMetadata + if err := f.getJSON(ctx, rawURL, false, &m); err != nil { + return nil, err + } + for _, u := range serverMetadataURLs(m.Issuer) { + if strings.TrimSuffix(u, "/") == strings.TrimSuffix(rawURL, "/") { + return &m, nil + } + } + return nil, fmt.Errorf("metadata at %s names issuer %q, which does not publish metadata there", rawURL, m.Issuer) +} + +// resourceMetadata fetches the Protected Resource Metadata for a request that got +// a 401: from the challenge's resource_metadata, or else from the well-known +// locations. It returns errNoResourceMetadata when the resource publishes none. +func (f fetcher) resourceMetadata(ctx context.Context, target *url.URL, metadataURL string) (*ResourceMetadata, error) { + candidates := []string{metadataURL} + explicit := metadataURL != "" + if !explicit { + candidates = resourceMetadataURLs(target) + } + var errs []error + for _, u := range candidates { + var m ResourceMetadata + err := f.getJSON(ctx, u, true, &m) + if err != nil { + var se *statusError + if !explicit && errors.As(err, &se) && se.status == http.StatusNotFound { + continue + } + errs = append(errs, err) + continue + } + if m.Resource == "" { + return nil, fmt.Errorf("resource metadata at %s has no resource", u) + } + if !resourceCovers(m.Resource, target.String()) { + return nil, fmt.Errorf("resource metadata at %s describes %s, not %s", u, m.Resource, target) + } + if len(m.AuthorizationServers) == 0 { + return nil, fmt.Errorf("resource metadata at %s lists no authorization servers", u) + } + return &m, nil + } + if len(errs) == 0 { + return nil, errNoResourceMetadata + } + return nil, errors.Join(errs...) +} + +var errNoResourceMetadata = errors.New("no protected resource metadata") diff --git a/internal/oauth/oauthtest/agent.go b/internal/oauth/oauthtest/agent.go new file mode 100644 index 0000000..c472aed --- /dev/null +++ b/internal/oauth/oauthtest/agent.go @@ -0,0 +1,210 @@ +// 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 oauthtest + +import ( + "context" + "fmt" + "iter" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + + "github.com/a2aproject/a2a-go/v2/a2a" + "github.com/a2aproject/a2a-go/v2/a2asrv" +) + +// CardSecurity selects what the agent card declares about authorization. +type CardSecurity int + +const ( + // CardNone declares nothing; clients learn about OAuth from the 401 challenge. + CardNone CardSecurity = iota + // CardMetadataURL declares an oauth2 scheme with oauth2MetadataUrl. + CardMetadataURL + // CardAuthCode declares an oauth2 scheme with an inline authorizationCode flow. + CardAuthCode + // CardDeviceCode declares an oauth2 scheme with an inline deviceCode flow. + CardDeviceCode + // CardOIDC declares an openIdConnect scheme. + CardOIDC +) + +// AgentOptions configure a [ProtectedAgent]. +type AgentOptions struct { + // Card selects the security the agent card declares. + Card CardSecurity + // ProtectCard requires a token for the agent card too. + ProtectCard bool + // OmitResourceMetadata leaves resource_metadata out of the challenge, so + // clients have to probe the well-known location. + OmitResourceMetadata bool + // Scopes are required by the agent and named in the card and the challenge. + Scopes []string + // JSONRPC serves the JSON-RPC binding instead of REST. + JSONRPC bool +} + +// ProtectedAgent is an A2A echo agent that requires access tokens from an [AuthServer]. +type ProtectedAgent struct { + // URL is the agent's base URL, which is also its resource identifier. + URL string + + as *AuthServer + opts AgentOptions + + mu sync.Mutex + headers []string +} + +// NewProtectedAgent starts an agent protected by as that is stopped with the test. +func NewProtectedAgent(t *testing.T, as *AuthServer, opts AgentOptions) *ProtectedAgent { + t.Helper() + 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 + + protocol := a2a.TransportProtocolHTTPJSON + binding := a2asrv.NewRESTHandler(handler) + if opts.JSONRPC { + protocol = a2a.TransportProtocolJSONRPC + binding = a2asrv.NewJSONRPCHandler(handler) + } + card := a.card(protocol) + cardHandler := a2asrv.NewStaticAgentCardHandler(card) + if opts.ProtectCard { + cardHandler = a.protect(cardHandler) + } + mux.Handle(a2asrv.WellKnownAgentCardPath, cardHandler) + mux.HandleFunc("GET /.well-known/oauth-protected-resource", func(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, map[string]any{ + "resource": a.URL, + "authorization_servers": []string{as.URL}, + "scopes_supported": opts.Scopes, + }) + }) + mux.Handle("/", a.protect(binding)) + return a +} + +func (a *ProtectedAgent) card(protocol a2a.TransportProtocol) *a2a.AgentCard { + card := &a2a.AgentCard{ + Name: "Protected Echo", + Version: "1.0.0", + Capabilities: a2a.AgentCapabilities{Streaming: true}, + SupportedInterfaces: []*a2a.AgentInterface{a2a.NewAgentInterface(a.URL, protocol)}, + DefaultInputModes: []string{"text"}, + DefaultOutputModes: []string{"text"}, + Skills: []a2a.AgentSkill{}, + } + scopes := map[string]string{} + for _, s := range a.opts.Scopes { + scopes[s] = s + } + var scheme a2a.SecurityScheme + switch a.opts.Card { + case CardMetadataURL: + scheme = a2a.OAuth2SecurityScheme{ + Oauth2MetadataURL: a.as.URL + "/.well-known/oauth-authorization-server", + Flows: a2a.AuthorizationCodeOAuthFlow{AuthorizationURL: a.as.URL + "/authorize", TokenURL: a.as.URL + "/token", Scopes: scopes}, + } + case CardAuthCode: + scheme = a2a.OAuth2SecurityScheme{ + Flows: a2a.AuthorizationCodeOAuthFlow{AuthorizationURL: a.as.URL + "/authorize", TokenURL: a.as.URL + "/token", Scopes: scopes, PKCERequired: true}, + } + case CardDeviceCode: + scheme = a2a.OAuth2SecurityScheme{ + Flows: a2a.DeviceCodeOAuthFlow{DeviceAuthorizationURL: a.as.URL + "/device", TokenURL: a.as.URL + "/token", Scopes: scopes}, + } + case CardOIDC: + scheme = a2a.OpenIDConnectSecurityScheme{OpenIDConnectURL: a.as.URL + "/.well-known/openid-configuration"} + default: + return card + } + card.SecuritySchemes = a2a.NamedSecuritySchemes{"oauth": scheme} + card.SecurityRequirements = a2a.SecurityRequirementsOptions{{"oauth": a2a.SecuritySchemeScopes(a.opts.Scopes)}} + return card +} + +func (a *ProtectedAgent) protect(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + auth := r.Header.Get("Authorization") + a.mu.Lock() + a.headers = append(a.headers, auth) + a.mu.Unlock() + token, ok := strings.CutPrefix(auth, "Bearer ") + if ok && a.as.Valid(token, a.URL, a.opts.Scopes) { + next.ServeHTTP(w, r) + return + } + var params []string + if !a.opts.OmitResourceMetadata { + params = append(params, fmt.Sprintf("resource_metadata=%q", a.URL+"/.well-known/oauth-protected-resource")) + } + if len(a.opts.Scopes) > 0 { + params = append(params, fmt.Sprintf("scope=%q", strings.Join(a.opts.Scopes, " "))) + } + if auth != "" { + params = append(params, `error="invalid_token"`) + } + challenge := "Bearer" + if len(params) > 0 { + challenge += " " + strings.Join(params, ", ") + } + w.Header().Set("WWW-Authenticate", challenge) + http.Error(w, "unauthorized", http.StatusUnauthorized) + }) +} + +// AuthorizationHeaders returns the Authorization header of every request the +// agent received, empty for requests without one. +func (a *ProtectedAgent) AuthorizationHeaders() []string { + a.mu.Lock() + defer a.mu.Unlock() + return append([]string(nil), a.headers...) +} + +type echoExecutor struct{} + +func (echoExecutor) Execute(_ context.Context, execCtx *a2asrv.ExecutorContext) iter.Seq2[a2a.Event, error] { + return func(yield func(a2a.Event, error) bool) { + if execCtx.StoredTask == nil { + if !yield(a2a.NewSubmittedTask(execCtx, execCtx.Message), nil) { + return + } + } + var text []string + for _, p := range execCtx.Message.Parts { + text = append(text, p.Text()) + } + evt := a2a.NewArtifactEvent(execCtx, a2a.NewTextPart(strings.Join(text, " "))) + evt.LastChunk = true + if !yield(evt, nil) { + return + } + yield(a2a.NewStatusUpdateEvent(execCtx, a2a.TaskStateCompleted, nil), nil) + } +} + +func (echoExecutor) Cancel(_ context.Context, execCtx *a2asrv.ExecutorContext) iter.Seq2[a2a.Event, error] { + return func(yield func(a2a.Event, error) bool) { + yield(a2a.NewStatusUpdateEvent(execCtx, a2a.TaskStateCanceled, nil), nil) + } +} diff --git a/internal/oauth/oauthtest/server.go b/internal/oauth/oauthtest/server.go new file mode 100644 index 0000000..07b327f --- /dev/null +++ b/internal/oauth/oauthtest/server.go @@ -0,0 +1,441 @@ +// 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 oauthtest provides an in-process OAuth authorization server and an +// OAuth-protected A2A agent for tests. +package oauthtest + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "slices" + "strings" + "sync" + "testing" + "time" +) + +// ServerOptions configure an [AuthServer]. +type ServerOptions struct { + // RotateRefreshTokens issues a new refresh token on every refresh and + // invalidates the old one. + RotateRefreshTokens bool + // TokenLifetime is the access token lifetime. Defaults to an hour. + TokenLifetime time.Duration + // CIMD advertises client_id_metadata_document_supported. + CIMD bool + // NoAuthorizationEndpoint leaves out the authorization endpoint, so only the + // device flow is available. + NoAuthorizationEndpoint bool + // NoRevocation leaves out the revocation endpoint. + NoRevocation bool + // OIDCOnly serves metadata only at the OpenID Connect Discovery location. + OIDCOnly bool + // PendingPolls is how many device token polls answer authorization_pending + // after the user approved. + PendingPolls int + // DenyAuthorization makes the authorization and device flows fail with access_denied. + DenyAuthorization bool +} + +type codeGrant struct { + clientID, redirectURI, challenge, resource, scope string +} + +type tokenInfo struct { + clientID, resource, scope string + expires time.Time + revoked bool +} + +type deviceGrant struct { + clientID, resource, scope, userCode string + approved, denied bool + polls int +} + +// AuthServer is an in-process OAuth 2.0 authorization server. +type AuthServer struct { + // URL is the issuer identifier and base URL. + URL string + + opts ServerOptions + mu sync.Mutex + + codes map[string]codeGrant + access map[string]*tokenInfo + refresh map[string]*tokenInfo + devices map[string]*deviceGrant + + hits map[string]int + forms map[string]url.Values + authSeen bool + revoked []string + authQuery url.Values +} + +// NewAuthServer starts an authorization server that is stopped with the test. +func NewAuthServer(t *testing.T, opts ServerOptions) *AuthServer { + t.Helper() + if opts.TokenLifetime == 0 { + opts.TokenLifetime = time.Hour + } + s := &AuthServer{ + opts: opts, + codes: map[string]codeGrant{}, + access: map[string]*tokenInfo{}, + refresh: map[string]*tokenInfo{}, + devices: map[string]*deviceGrant{}, + hits: map[string]int{}, + forms: map[string]url.Values{}, + } + mux := http.NewServeMux() + if !opts.OIDCOnly { + mux.HandleFunc("GET /.well-known/oauth-authorization-server", s.metadata) + } + mux.HandleFunc("GET /.well-known/openid-configuration", s.metadata) + mux.HandleFunc("GET /authorize", s.authorize) + mux.HandleFunc("POST /token", s.token) + 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 + return s +} + +func (s *AuthServer) record(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + s.mu.Lock() + s.hits[r.URL.Path]++ + if r.Header.Get("Authorization") != "" { + s.authSeen = true + } + s.mu.Unlock() + next.ServeHTTP(w, r) + }) +} + +// Metadata returns the server's RFC 8414 metadata document. +func (s *AuthServer) Metadata() map[string]any { + m := map[string]any{ + "issuer": s.URL, + "token_endpoint": s.URL + "/token", + "device_authorization_endpoint": s.URL + "/device", + "code_challenge_methods_supported": []string{"S256"}, + "grant_types_supported": []string{"authorization_code", "refresh_token", "urn:ietf:params:oauth:grant-type:device_code"}, + } + if !s.opts.NoAuthorizationEndpoint { + m["authorization_endpoint"] = s.URL + "/authorize" + } + if !s.opts.NoRevocation { + m["revocation_endpoint"] = s.URL + "/revoke" + } + if s.opts.CIMD { + m["client_id_metadata_document_supported"] = true + } + return m +} + +func (s *AuthServer) metadata(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, s.Metadata()) +} + +func (s *AuthServer) authorize(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + s.mu.Lock() + s.authQuery = q + s.mu.Unlock() + redirect, err := url.Parse(q.Get("redirect_uri")) + if err != nil || redirect.Scheme != "http" || redirect.Hostname() != "127.0.0.1" || redirect.Path != "/oauth/callback" { + http.Error(w, "invalid redirect_uri", http.StatusBadRequest) + return + } + if q.Get("response_type") != "code" || q.Get("code_challenge_method") != "S256" || q.Get("code_challenge") == "" || q.Get("client_id") == "" { + http.Error(w, "invalid authorization request", http.StatusBadRequest) + return + } + back := redirect.Query() + back.Set("state", q.Get("state")) + if s.opts.DenyAuthorization { + back.Set("error", "access_denied") + } else { + code := random() + s.mu.Lock() + s.codes[code] = codeGrant{ + clientID: q.Get("client_id"), + redirectURI: q.Get("redirect_uri"), + challenge: q.Get("code_challenge"), + resource: q.Get("resource"), + scope: q.Get("scope"), + } + s.mu.Unlock() + back.Set("code", code) + } + redirect.RawQuery = back.Encode() + http.Redirect(w, r, redirect.String(), http.StatusFound) +} + +func (s *AuthServer) token(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + oauthError(w, "invalid_request") + return + } + s.mu.Lock() + defer s.mu.Unlock() + s.forms["/token"] = r.PostForm + switch r.PostForm.Get("grant_type") { + case "authorization_code": + c, ok := s.codes[r.PostForm.Get("code")] + delete(s.codes, r.PostForm.Get("code")) + sum := sha256.Sum256([]byte(r.PostForm.Get("code_verifier"))) + switch { + case !ok: + oauthError(w, "invalid_grant") + case c.clientID != r.PostForm.Get("client_id") || c.redirectURI != r.PostForm.Get("redirect_uri"): + oauthError(w, "invalid_grant") + case base64.RawURLEncoding.EncodeToString(sum[:]) != c.challenge: + oauthError(w, "invalid_grant") + default: + s.issue(w, c.clientID, firstNonEmpty(r.PostForm.Get("resource"), c.resource), c.scope) + } + case "refresh_token": + info, ok := s.refresh[r.PostForm.Get("refresh_token")] + if !ok || info.revoked || info.clientID != r.PostForm.Get("client_id") { + oauthError(w, "invalid_grant") + return + } + if s.opts.RotateRefreshTokens { + info.revoked = true + s.issue(w, info.clientID, info.resource, info.scope) + return + } + access := random() + s.access[access] = &tokenInfo{clientID: info.clientID, resource: info.resource, scope: info.scope, expires: time.Now().Add(s.opts.TokenLifetime)} + writeJSON(w, http.StatusOK, map[string]any{"access_token": access, "token_type": "Bearer", "expires_in": int(s.opts.TokenLifetime / time.Second)}) + case "urn:ietf:params:oauth:grant-type:device_code": + d, ok := s.devices[r.PostForm.Get("device_code")] + switch { + case !ok: + oauthError(w, "invalid_grant") + case d.denied: + oauthError(w, "access_denied") + case !d.approved || d.polls < s.opts.PendingPolls: + if d.approved { + d.polls++ + } + oauthError(w, "authorization_pending") + default: + delete(s.devices, r.PostForm.Get("device_code")) + s.issue(w, d.clientID, firstNonEmpty(r.PostForm.Get("resource"), d.resource), d.scope) + } + default: + oauthError(w, "unsupported_grant_type") + } +} + +// issue writes a token response; s.mu must be held. +func (s *AuthServer) issue(w http.ResponseWriter, clientID, resource, scope string) { + access, refresh := random(), random() + s.access[access] = &tokenInfo{clientID: clientID, resource: resource, scope: scope, expires: time.Now().Add(s.opts.TokenLifetime)} + s.refresh[refresh] = &tokenInfo{clientID: clientID, resource: resource, scope: scope} + writeJSON(w, http.StatusOK, map[string]any{ + "access_token": access, + "token_type": "Bearer", + "expires_in": int(s.opts.TokenLifetime / time.Second), + "refresh_token": refresh, + "scope": scope, + }) +} + +func (s *AuthServer) device(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil || r.PostForm.Get("client_id") == "" { + oauthError(w, "invalid_request") + return + } + dc, uc := random(), strings.ToUpper(random()[:8]) + s.mu.Lock() + s.forms["/device"] = r.PostForm + s.devices[dc] = &deviceGrant{clientID: r.PostForm.Get("client_id"), resource: r.PostForm.Get("resource"), scope: r.PostForm.Get("scope"), userCode: uc} + s.mu.Unlock() + writeJSON(w, http.StatusOK, map[string]any{ + "device_code": dc, + "user_code": uc, + "verification_uri": s.URL + "/activate", + "verification_uri_complete": s.URL + "/activate?user_code=" + uc, + "expires_in": 600, + "interval": 1, + }) +} + +// activate approves (or denies) the device grant with the given user_code. +func (s *AuthServer) activate(w http.ResponseWriter, r *http.Request) { + uc := r.URL.Query().Get("user_code") + s.mu.Lock() + defer s.mu.Unlock() + for _, d := range s.devices { + if d.userCode == uc { + d.approved = !s.opts.DenyAuthorization + d.denied = s.opts.DenyAuthorization + w.WriteHeader(http.StatusOK) + return + } + } + http.Error(w, "unknown user code", http.StatusNotFound) +} + +func (s *AuthServer) revoke(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + oauthError(w, "invalid_request") + return + } + tok := r.PostForm.Get("token") + s.mu.Lock() + defer s.mu.Unlock() + s.revoked = append(s.revoked, tok) + if info, ok := s.refresh[tok]; ok { + info.revoked = true + for _, a := range s.access { + if a.clientID == info.clientID && a.resource == info.resource { + a.revoked = true + } + } + } + if info, ok := s.access[tok]; ok { + info.revoked = true + } + w.WriteHeader(http.StatusOK) +} + +// Valid reports whether token is a live access token issued for resource and +// covering every scope in scopes. Tokens issued without a resource indicator are +// valid for any resource. +func (s *AuthServer) Valid(token, resource string, scopes []string) bool { + s.mu.Lock() + defer s.mu.Unlock() + info, ok := s.access[token] + if !ok || info.revoked || time.Now().After(info.expires) { + return false + } + if info.resource != "" && strings.TrimSuffix(info.resource, "/") != strings.TrimSuffix(resource, "/") { + return false + } + granted := strings.Fields(info.scope) + for _, sc := range scopes { + if !slices.Contains(granted, sc) { + return false + } + } + return true +} + +// Invalidate makes every issued access token invalid, as if they had been +// revoked server-side; refresh tokens keep working. +func (s *AuthServer) Invalidate() { + s.mu.Lock() + defer s.mu.Unlock() + for _, info := range s.access { + info.revoked = true + } +} + +// RevokeRefreshTokens makes every issued refresh token invalid. +func (s *AuthServer) RevokeRefreshTokens() { + s.mu.Lock() + defer s.mu.Unlock() + for _, info := range s.refresh { + info.revoked = true + } +} + +// Hits returns how many requests reached path. +func (s *AuthServer) Hits(path string) int { + s.mu.Lock() + defer s.mu.Unlock() + return s.hits[path] +} + +// SawAuthorizationHeader reports whether any request to the server carried an +// Authorization header. Agent credentials must never reach the server. +func (s *AuthServer) SawAuthorizationHeader() bool { + s.mu.Lock() + defer s.mu.Unlock() + return s.authSeen +} + +// LastForm returns the form of the last request to /token or /device. +func (s *AuthServer) LastForm(path string) url.Values { + s.mu.Lock() + defer s.mu.Unlock() + return s.forms[path] +} + +// LastAuthorizeQuery returns the query of the last authorization request. +func (s *AuthServer) LastAuthorizeQuery() url.Values { + s.mu.Lock() + defer s.mu.Unlock() + return s.authQuery +} + +// Revoked returns the tokens sent to the revocation endpoint. +func (s *AuthServer) Revoked() []string { + s.mu.Lock() + defer s.mu.Unlock() + return slices.Clone(s.revoked) +} + +// Browser returns a function that plays the user's browser: given the URL the +// CLI prints, it follows the authorization server's redirect back to the CLI's +// loopback callback, or approves a device code. +func Browser(t *testing.T) func(string) { + t.Helper() + return func(u string) { + resp, err := http.Get(u) + if err != nil { + t.Errorf("browser: GET %s error = %v", u, err) + return + } + _ = resp.Body.Close() + } +} + +func oauthError(w http.ResponseWriter, code string) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": code}) +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +func random() string { + b := make([]byte, 16) + _, _ = rand.Read(b) + return hex.EncodeToString(b) +} + +func firstNonEmpty(a, b string) string { + if a != "" { + return a + } + return b +} diff --git a/internal/oauth/store.go b/internal/oauth/store.go new file mode 100644 index 0000000..1fceb49 --- /dev/null +++ b/internal/oauth/store.go @@ -0,0 +1,206 @@ +// 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 ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "time" +) + +// Store keeps grants. Reads may happen at any time; callers hold Lock around a +// read-modify-write so concurrent CLI processes don't lose rotated tokens. +type Store interface { + // List returns every grant. + List() ([]Grant, error) + // Get returns the grant for key, or nil when there is none. + Get(key GrantKey) (*Grant, error) + // Put adds or replaces the grant with the same key. + Put(g Grant) error + // Delete removes the grant for key, if any. + Delete(key GrantKey) error + // Lock takes the store's cross-process lock and returns its release function. + Lock(ctx context.Context) (func(), error) +} + +// DefaultDir returns ~/.config/a2a-cli/oauth. +func DefaultDir() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("resolving home directory: %w", err) + } + return filepath.Join(home, ".config", "a2a-cli", "oauth"), nil +} + +const ( + grantsFileName = "grants.json" + lockFileName = "grants.lock" + storeVersion = 1 + lockRetry = 25 * time.Millisecond +) + +// FileStore keeps grants, tokens included, in Dir/grants.json, readable only by +// the user (0600, in a 0700 directory). +type FileStore struct { + Dir string +} + +var _ Store = (*FileStore)(nil) + +type grantsFile struct { + Version int `json:"version"` + Grants []Grant `json:"grants"` +} + +// List implements [Store]. +func (s *FileStore) List() ([]Grant, error) { + data, err := os.ReadFile(filepath.Join(s.Dir, grantsFileName)) + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("reading grants: %w", err) + } + var f grantsFile + if err := json.Unmarshal(data, &f); err != nil { + return nil, fmt.Errorf("parsing %s: %w", filepath.Join(s.Dir, grantsFileName), err) + } + if f.Version > storeVersion { + return nil, fmt.Errorf("%s was written by a newer a2a (version %d)", filepath.Join(s.Dir, grantsFileName), f.Version) + } + return f.Grants, nil +} + +// Get implements [Store]. +func (s *FileStore) Get(key GrantKey) (*Grant, error) { + grants, err := s.List() + if err != nil { + return nil, err + } + for _, g := range grants { + if g.Key.Equal(key) { + return &g, nil + } + } + return nil, nil +} + +// Put implements [Store]. +func (s *FileStore) Put(g Grant) error { + grants, err := s.List() + if err != nil { + return err + } + replaced := false + for i := range grants { + if grants[i].Key.Equal(g.Key) { + grants[i] = g + replaced = true + } + } + if !replaced { + grants = append(grants, g) + } + return s.write(grants) +} + +// Delete implements [Store]. +func (s *FileStore) Delete(key GrantKey) error { + grants, err := s.List() + if err != nil { + return err + } + kept := grants[:0] + for _, g := range grants { + if !g.Key.Equal(key) { + kept = append(kept, g) + } + } + return s.write(kept) +} + +func (s *FileStore) write(grants []Grant) error { + if err := os.MkdirAll(s.Dir, 0o700); err != nil { + return fmt.Errorf("creating %s: %w", s.Dir, err) + } + if grants == nil { + grants = []Grant{} + } + data, err := json.MarshalIndent(grantsFile{Version: storeVersion, Grants: grants}, "", " ") + if err != nil { + return err + } + tmp, err := os.CreateTemp(s.Dir, grantsFileName+".*.tmp") + if err != nil { + return fmt.Errorf("writing grants: %w", err) + } + tmpName := tmp.Name() + defer func() { + if _, err := os.Stat(tmpName); err == nil { + _ = os.Remove(tmpName) + } + }() + if err := tmp.Chmod(0o600); err != nil && !errors.Is(err, errors.ErrUnsupported) { + _ = tmp.Close() + return fmt.Errorf("writing grants: %w", err) + } + if _, err := tmp.Write(append(data, '\n')); err != nil { + _ = tmp.Close() + return fmt.Errorf("writing grants: %w", err) + } + if err := tmp.Close(); err != nil { + return fmt.Errorf("writing grants: %w", err) + } + if err := os.Rename(tmpName, filepath.Join(s.Dir, grantsFileName)); err != nil { + return fmt.Errorf("writing grants: %w", err) + } + return nil +} + +// Lock implements [Store] with an advisory lock on Dir/grants.lock. The OS +// releases it when the process exits, so a crashed or interrupted process +// never leaves the store locked. +func (s *FileStore) Lock(ctx context.Context) (func(), error) { + if err := os.MkdirAll(s.Dir, 0o700); err != nil { + return nil, fmt.Errorf("creating %s: %w", s.Dir, err) + } + f, err := os.OpenFile(filepath.Join(s.Dir, lockFileName), os.O_CREATE|os.O_RDWR, 0o600) + if err != nil { + return nil, fmt.Errorf("locking grants: %w", err) + } + for { + locked, err := tryLockFile(f) + if err != nil { + _ = f.Close() + return nil, fmt.Errorf("locking grants: %w", err) + } + if locked { + return func() { + _ = unlockFile(f) + _ = f.Close() + }, nil + } + select { + case <-ctx.Done(): + _ = f.Close() + return nil, fmt.Errorf("locking grants: %w", ctx.Err()) + case <-time.After(lockRetry): + } + } +} diff --git a/internal/oauth/store_test.go b/internal/oauth/store_test.go new file mode 100644 index 0000000..c6bcb20 --- /dev/null +++ b/internal/oauth/store_test.go @@ -0,0 +1,174 @@ +// 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 ( + "context" + "os" + "path/filepath" + "runtime" + "sync" + "testing" + "time" + + "github.com/google/go-cmp/cmp" +) + +func TestNewGrantKey(t *testing.T) { + t.Parallel() + got := NewGrantKey("https://login.example.com/", "c", "https://Agent.example.com/", []string{"b", "a", "b"}) + want := GrantKey{Issuer: "https://login.example.com", ClientID: "c", Resource: "https://agent.example.com", Scopes: []string{"a", "b"}} + if diff := cmp.Diff(want, got); diff != "" { + t.Fatalf("NewGrantKey() wrong result (-want +got) diff = %s", diff) + } + if got.ID() != NewGrantKey("https://login.example.com", "c", "https://agent.example.com", []string{"a", "b"}).ID() { + t.Fatalf("GrantKey.ID() differs for equal keys") + } +} + +func TestFileStore(t *testing.T) { + t.Parallel() + dir := filepath.Join(t.TempDir(), "oauth") + s := &FileStore{Dir: dir} + k1 := NewGrantKey("https://login.example.com", "c", "https://a.example.com", []string{"x"}) + k2 := NewGrantKey("https://login.example.com", "c", "https://b.example.com", nil) + expires := time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC) + + if got, err := s.List(); err != nil || got != nil { + t.Fatalf("FileStore.List() on a missing file = %v, %v, want nil, nil", got, err) + } + for _, g := range []Grant{ + {Key: k1, AccessToken: "a1", RefreshToken: "r1", ExpiresAt: expires}, + {Key: k2, AccessToken: "b1"}, + {Key: k1, AccessToken: "a2", RefreshToken: "r2", ExpiresAt: expires}, + } { + if err := s.Put(g); err != nil { + t.Fatalf("FileStore.Put() error = %v", err) + } + } + got, err := s.Get(k1) + if err != nil { + t.Fatalf("FileStore.Get() error = %v", err) + } + if diff := cmp.Diff(&Grant{Key: k1, AccessToken: "a2", RefreshToken: "r2", ExpiresAt: expires}, got); diff != "" { + t.Fatalf("FileStore.Get() wrong result (-want +got) diff = %s", diff) + } + if err := s.Delete(k1); err != nil { + t.Fatalf("FileStore.Delete() error = %v", err) + } + all, err := s.List() + if err != nil { + t.Fatalf("FileStore.List() error = %v", err) + } + if diff := cmp.Diff([]Grant{{Key: k2, AccessToken: "b1"}}, all); diff != "" { + t.Fatalf("FileStore.List() wrong result (-want +got) diff = %s", diff) + } + + if runtime.GOOS == "windows" { + return + } + for path, want := range map[string]os.FileMode{dir: 0o700, filepath.Join(dir, grantsFileName): 0o600} { + info, err := os.Stat(path) + if err != nil { + t.Fatalf("os.Stat(%q) error = %v", path, err) + } + if got := info.Mode().Perm(); got != want { + t.Fatalf("mode of %s = %v, want %v", path, got, want) + } + } +} + +func TestFileStoreLock(t *testing.T) { + t.Parallel() + s := &FileStore{Dir: t.TempDir()} + key := NewGrantKey("https://login.example.com", "c", "https://a.example.com", nil) + if err := s.Put(Grant{Key: key, AccessToken: "0"}); err != nil { + t.Fatal(err) + } + + const workers = 8 + var wg sync.WaitGroup + for range workers { + wg.Go(func() { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + unlock, err := s.Lock(ctx) + if err != nil { + t.Errorf("FileStore.Lock() error = %v", err) + return + } + defer unlock() + g, err := s.Get(key) + if err != nil { + t.Errorf("FileStore.Get() error = %v", err) + return + } + g.AccessToken += "+" + if err := s.Put(*g); err != nil { + t.Errorf("FileStore.Put() error = %v", err) + } + }) + } + wg.Wait() + g, err := s.Get(key) + if err != nil { + t.Fatal(err) + } + if want := "0++++++++"; g.AccessToken != want { + t.Fatalf("access token after %d locked updates = %q, want %q", workers, g.AccessToken, want) + } +} + +func TestFileStoreLockTimesOut(t *testing.T) { + t.Parallel() + s := &FileStore{Dir: t.TempDir()} + unlock, err := s.Lock(context.Background()) + if err != nil { + t.Fatal(err) + } + defer unlock() + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + if _, err := s.Lock(ctx); err == nil { + t.Fatalf("FileStore.Lock() while held error = nil, want timeout") + } +} + +func TestFileStoreLockIsNotStolen(t *testing.T) { + t.Parallel() + s := &FileStore{Dir: t.TempDir()} + unlock, err := s.Lock(context.Background()) + if err != nil { + t.Fatal(err) + } + // A lock held through a slow refresh stays held, however old the file is. + old := time.Now().Add(-time.Hour) + if err := os.Chtimes(filepath.Join(s.Dir, lockFileName), old, old); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + if _, err := s.Lock(ctx); err == nil { + t.Fatal("FileStore.Lock() took a held lock") + } + unlock() + + // The file left behind after release does not block the next locker. + unlock, err = s.Lock(context.Background()) + if err != nil { + t.Fatalf("FileStore.Lock() after release error = %v", err) + } + unlock() +} diff --git a/internal/oauth/token.go b/internal/oauth/token.go new file mode 100644 index 0000000..58d1304 --- /dev/null +++ b/internal/oauth/token.go @@ -0,0 +1,132 @@ +// 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" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" +) + +const ( + grantTypeAuthorizationCode = "authorization_code" + grantTypeRefreshToken = "refresh_token" + grantTypeDeviceCode = "urn:ietf:params:oauth:grant-type:device_code" +) + +type tokenResponse struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn flexInt `json:"expires_in"` + RefreshToken string `json:"refresh_token"` + Scope string `json:"scope"` +} + +// flexInt accepts a JSON number or a numeric string; some servers send expires_in as a string. +type flexInt int64 + +func (n *flexInt) UnmarshalJSON(b []byte) error { + s := strings.Trim(string(b), `"`) + if s == "" || s == "null" { + *n = 0 + return nil + } + v, err := strconv.ParseInt(s, 10, 64) + if err != nil { + return fmt.Errorf("invalid number %s", b) + } + *n = flexInt(v) + return nil +} + +// postForm posts form values to an authorization server endpoint and decodes a +// JSON response into out. OAuth error responses are returned as *Error. +func (f fetcher) postForm(ctx context.Context, endpoint string, form url.Values, out any) error { + if err := checkSecureURL(endpoint); err != nil { + return err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode())) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + resp, err := f.client.Do(req) + if err != nil { + return err + } + defer func() { _ = resp.Body.Close() }() + body, err := io.ReadAll(io.LimitReader(resp.Body, maxMetadataBytes)) + if err != nil { + return fmt.Errorf("reading response from %s: %w", endpoint, err) + } + if resp.StatusCode < 200 || resp.StatusCode > 299 { + var oe Error + if json.Unmarshal(body, &oe) == nil && oe.Code != "" { + return &oe + } + return fmt.Errorf("POST %s: HTTP %d", endpoint, resp.StatusCode) + } + if out == nil { + return nil + } + if len(bytes.TrimSpace(body)) == 0 { + return fmt.Errorf("POST %s: empty response", endpoint) + } + var oe Error + if json.Unmarshal(body, &oe) == nil && oe.Code != "" { + return &oe + } + if err := json.Unmarshal(body, out); err != nil { + return fmt.Errorf("parsing response from %s: %w", endpoint, err) + } + return nil +} + +// token calls the token endpoint and validates the response. +func (f fetcher) token(ctx context.Context, endpoint string, form url.Values) (*tokenResponse, error) { + var t tokenResponse + if err := f.postForm(ctx, endpoint, form, &t); err != nil { + return nil, err + } + if t.AccessToken == "" { + return nil, fmt.Errorf("token endpoint %s returned no access_token", endpoint) + } + if t.TokenType != "" && !strings.EqualFold(t.TokenType, "bearer") { + return nil, fmt.Errorf("token endpoint %s returned unsupported token type %q", endpoint, t.TokenType) + } + return &t, nil +} + +func tokenForm(grantType, clientID, resource string) url.Values { + form := url.Values{"grant_type": {grantType}, "client_id": {clientID}} + if resource != "" { + form.Set("resource", resource) + } + return form +} + +// isOAuthError reports whether err is an OAuth error response with the given code. +func isOAuthError(err error, code string) bool { + var oe *Error + return errors.As(err, &oe) && oe.Code == code +} diff --git a/internal/oauth/token_test.go b/internal/oauth/token_test.go new file mode 100644 index 0000000..8f28874 --- /dev/null +++ b/internal/oauth/token_test.go @@ -0,0 +1,134 @@ +// 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 ( + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" +) + +func TestToken(t *testing.T) { + t.Parallel() + requests := make(chan *http.Request, 1) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + t.Errorf("ParseForm() error = %v", err) + } + requests <- r + _, _ = w.Write([]byte(`{"access_token":"at","token_type":"Bearer","expires_in":"3600","refresh_token":"rt"}`)) + })) + t.Cleanup(srv.Close) + + form := tokenForm(grantTypeAuthorizationCode, "cli", "https://agent.example.com") + form.Set("code", "c0de") + got, err := testFetcher().token(t.Context(), srv.URL, form) + if err != nil { + t.Fatalf("token() error = %v", err) + } + want := &tokenResponse{AccessToken: "at", TokenType: "Bearer", ExpiresIn: 3600, RefreshToken: "rt"} + if diff := cmp.Diff(want, got); diff != "" { + t.Fatalf("token() wrong result (-want +got) diff = %s", diff) + } + r := <-requests + if ct := r.Header.Get("Content-Type"); ct != "application/x-www-form-urlencoded" { + t.Errorf("Content-Type = %q, want a form", ct) + } + wantForm := url.Values{ + "grant_type": {"authorization_code"}, + "client_id": {"cli"}, + "resource": {"https://agent.example.com"}, + "code": {"c0de"}, + } + if diff := cmp.Diff(wantForm, r.PostForm); diff != "" { + t.Fatalf("token request form wrong (-want +got) diff = %s", diff) + } +} + +func TestTokenErrors(t *testing.T) { + t.Parallel() + tests := []struct { + name string + status int + body string + wantErr string + wantOAuth string + }{ + {name: "oauth error", status: http.StatusBadRequest, body: `{"error":"invalid_grant","error_description":"expired"}`, wantErr: "invalid_grant: expired", wantOAuth: "invalid_grant"}, + {name: "oauth error with status 200", status: http.StatusOK, body: `{"error":"authorization_pending"}`, wantErr: "authorization_pending", wantOAuth: "authorization_pending"}, + {name: "server error", status: http.StatusInternalServerError, body: `oops`, wantErr: "HTTP 500"}, + {name: "empty response", status: http.StatusOK, wantErr: "empty response"}, + {name: "no access token", status: http.StatusOK, body: `{"token_type":"Bearer"}`, wantErr: "no access_token"}, + {name: "unsupported token type", status: http.StatusOK, body: `{"access_token":"at","token_type":"mac"}`, wantErr: `unsupported token type "mac"`}, + {name: "bad expires_in", status: http.StatusOK, body: `{"access_token":"at","expires_in":"soon"}`, wantErr: "parsing response"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(tc.status) + _, _ = w.Write([]byte(tc.body)) + })) + t.Cleanup(srv.Close) + _, err := testFetcher().token(t.Context(), srv.URL, tokenForm(grantTypeRefreshToken, "cli", "")) + if err == nil || !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("token() error = %v, want it to contain %q", err, tc.wantErr) + } + if got := isOAuthError(err, tc.wantOAuth); got != (tc.wantOAuth != "") { + t.Fatalf("isOAuthError(%v, %q) = %v", err, tc.wantOAuth, got) + } + }) + } +} + +func TestPostFormWithoutResult(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + t.Cleanup(srv.Close) + if err := testFetcher().postForm(t.Context(), srv.URL, url.Values{"token": {"t"}}, nil); err != nil { + t.Fatalf("postForm() with an empty 200 response error = %v, want nil", err) + } + if err := testFetcher().postForm(t.Context(), "http://as.example.com/revoke", nil, nil); err == nil { + t.Fatal("postForm() to a non-loopback http URL error = nil, want it refused") + } +} + +func TestTokenForm(t *testing.T) { + t.Parallel() + got := tokenForm(grantTypeDeviceCode, "cli", "") + want := url.Values{"grant_type": {"urn:ietf:params:oauth:grant-type:device_code"}, "client_id": {"cli"}} + if diff := cmp.Diff(want, got); diff != "" { + t.Fatalf("tokenForm() without a resource wrong result (-want +got) diff = %s", diff) + } +} + +func TestFlexInt(t *testing.T) { + t.Parallel() + for in, want := range map[string]flexInt{`3600`: 3600, `"3600"`: 3600, `null`: 0, `""`: 0} { + var got flexInt + if err := json.Unmarshal([]byte(in), &got); err != nil || got != want { + t.Errorf("Unmarshal(%s) = %d, %v, want %d", in, got, err, want) + } + } + var n flexInt + if err := json.Unmarshal([]byte(`"1h"`), &n); err == nil { + t.Error(`Unmarshal("1h") error = nil, want an error`) + } +} diff --git a/internal/oauth/urls.go b/internal/oauth/urls.go new file mode 100644 index 0000000..b536457 --- /dev/null +++ b/internal/oauth/urls.go @@ -0,0 +1,139 @@ +// 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 ( + "fmt" + "net" + "net/url" + "strings" +) + +const ( + wellKnownServerMetadata = "/.well-known/oauth-authorization-server" + wellKnownOpenIDConfig = "/.well-known/openid-configuration" + wellKnownResourceMetadata = "/.well-known/oauth-protected-resource" +) + +// NormalizeResource returns the canonical form of an agent URL used as an RFC +// 8707 resource indicator and grant-store key: lowercase scheme and host, no +// default port, query, fragment or trailing slash. gRPC "host:port" addresses +// get an https scheme. +func NormalizeResource(raw string) string { + if raw == "" { + return "" + } + if !strings.Contains(raw, "://") { + raw = "https://" + raw + } + u, err := url.Parse(raw) + if err != nil || u.Host == "" { + return raw + } + scheme := strings.ToLower(u.Scheme) + host := strings.ToLower(u.Hostname()) + if port := u.Port(); port != "" && !(scheme == "https" && port == "443") && !(scheme == "http" && port == "80") { + host = net.JoinHostPort(host, port) + } else if strings.Contains(host, ":") { + host = "[" + host + "]" + } + return scheme + "://" + host + strings.TrimSuffix(u.EscapedPath(), "/") +} + +// sameIssuer compares issuer identifiers, ignoring a trailing slash. +func sameIssuer(a, b string) bool { + return strings.TrimSuffix(a, "/") == strings.TrimSuffix(b, "/") +} + +// origin returns scheme://host[:port] of a normalized URL. +func origin(raw string) string { + u, err := url.Parse(NormalizeResource(raw)) + if err != nil { + return "" + } + 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 resource identifies target: same origin, and +// the target path is the resource path or below it. +func resourceCovers(resource, target string) bool { + r, t := NormalizeResource(resource), NormalizeResource(target) + if origin(r) != origin(t) { + return false + } + rp := strings.TrimPrefix(r, origin(r)) + tp := strings.TrimPrefix(t, origin(t)) + return rp == "" || tp == rp || strings.HasPrefix(tp, rp+"/") +} + +// checkSecureURL requires https, allowing http only for loopback hosts so local +// agents and tests work. +func checkSecureURL(raw string) error { + u, err := url.Parse(raw) + if err != nil { + return fmt.Errorf("invalid URL %q: %w", raw, err) + } + switch { + case u.Host == "": + return fmt.Errorf("invalid URL %q: missing host", raw) + case u.Scheme == "https": + return nil + case u.Scheme == "http" && isLoopback(u.Hostname()): + return nil + default: + return fmt.Errorf("refusing non-https URL %q", raw) + } +} + +func isLoopback(host string) bool { + if strings.EqualFold(host, "localhost") { + return true + } + ip := net.ParseIP(host) + return ip != nil && ip.IsLoopback() +} + +// serverMetadataURLs returns where an issuer's metadata may live: the RFC 8414 +// location, with the well-known segment inserted before the issuer path, and the +// OpenID Connect Discovery location, with it appended. +func serverMetadataURLs(issuer string) []string { + u, err := url.Parse(strings.TrimSuffix(issuer, "/")) + if err != nil || u.Host == "" { + return nil + } + base := u.Scheme + "://" + u.Host + path := u.EscapedPath() + return []string{ + base + wellKnownServerMetadata + path, + base + path + wellKnownOpenIDConfig, + } +} + +// resourceMetadataURLs returns the RFC 9728 well-known locations for a request +// URL: with the request path inserted, then the origin root. +func resourceMetadataURLs(target *url.URL) []string { + base := target.Scheme + "://" + target.Host + path := strings.TrimSuffix(target.EscapedPath(), "/") + if path == "" { + return []string{base + wellKnownResourceMetadata} + } + return []string{base + wellKnownResourceMetadata + path, base + wellKnownResourceMetadata} +} diff --git a/internal/oauth/urls_test.go b/internal/oauth/urls_test.go new file mode 100644 index 0000000..3abdee7 --- /dev/null +++ b/internal/oauth/urls_test.go @@ -0,0 +1,116 @@ +// 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 ( + "net/url" + "testing" + + "github.com/google/go-cmp/cmp" +) + +func TestNormalizeResource(t *testing.T) { + t.Parallel() + tests := map[string]string{ + "https://Agent.Example.com/": "https://agent.example.com", + "https://agent.example.com:443/a2a/": "https://agent.example.com/a2a", + "http://127.0.0.1:8080/a?x=1#frag": "http://127.0.0.1:8080/a", + "http://localhost:80": "http://localhost", + "agent.example.com:50051": "https://agent.example.com:50051", + "": "", + } + for in, want := range tests { + if got := NormalizeResource(in); got != want { + t.Errorf("NormalizeResource(%q) = %q, want %q", in, got, want) + } + } +} + +func TestResourceCovers(t *testing.T) { + t.Parallel() + tests := []struct { + resource, target string + want bool + }{ + {"https://agent.example.com", "https://agent.example.com/message:send", true}, + {"https://agent.example.com/a2a", "https://agent.example.com/a2a/v1/tasks", true}, + {"https://agent.example.com/a2a", "https://agent.example.com/a2a", true}, + {"https://agent.example.com/a2a", "https://agent.example.com/a2ab", false}, + {"https://agent.example.com", "https://other.example.com", false}, + {"https://agent.example.com", "http://agent.example.com", false}, + } + for _, tc := range tests { + if got := resourceCovers(tc.resource, tc.target); got != tc.want { + t.Errorf("resourceCovers(%q, %q) = %v, want %v", tc.resource, tc.target, got, tc.want) + } + } +} + +func TestCheckSecureURL(t *testing.T) { + t.Parallel() + tests := map[string]bool{ + "https://login.example.com/token": true, + "http://127.0.0.1:9000/token": true, + "http://localhost/token": true, + "http://[::1]:9000/token": true, + "http://login.example.com/token": false, + "ftp://login.example.com": false, + "/token": false, + } + for in, ok := range tests { + if err := checkSecureURL(in); (err == nil) != ok { + t.Errorf("checkSecureURL(%q) error = %v, want ok %v", in, err, ok) + } + } +} + +func TestServerMetadataURLs(t *testing.T) { + t.Parallel() + tests := map[string][]string{ + "https://login.example.com": { + "https://login.example.com/.well-known/oauth-authorization-server", + "https://login.example.com/.well-known/openid-configuration", + }, + "https://login.example.com/tenant/v2.0/": { + "https://login.example.com/.well-known/oauth-authorization-server/tenant/v2.0", + "https://login.example.com/tenant/v2.0/.well-known/openid-configuration", + }, + } + for in, want := range tests { + if diff := cmp.Diff(want, serverMetadataURLs(in)); diff != "" { + t.Errorf("serverMetadataURLs(%q) wrong result (-want +got) diff = %s", in, diff) + } + } +} + +func TestResourceMetadataURLs(t *testing.T) { + t.Parallel() + tests := map[string][]string{ + "https://agent.example.com": {"https://agent.example.com/.well-known/oauth-protected-resource"}, + "https://agent.example.com/a2a/v1/": { + "https://agent.example.com/.well-known/oauth-protected-resource/a2a/v1", + "https://agent.example.com/.well-known/oauth-protected-resource", + }, + } + for in, want := range tests { + u, err := url.Parse(in) + if err != nil { + t.Fatal(err) + } + if diff := cmp.Diff(want, resourceMetadataURLs(u)); diff != "" { + t.Errorf("resourceMetadataURLs(%q) wrong result (-want +got) diff = %s", in, diff) + } + } +}