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) + } + } +}