Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand All @@ -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
Expand Down
165 changes: 165 additions & 0 deletions internal/oauth/challenge.go
Original file line number Diff line number Diff line change
@@ -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
}
82 changes: 82 additions & 0 deletions internal/oauth/challenge_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
115 changes: 115 additions & 0 deletions internal/oauth/clients.go
Original file line number Diff line number Diff line change
@@ -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}}
}
}
Loading
Loading