Skip to content

Commit aa50088

Browse files
committed
internal/oauthex: OAuth extensions
Add a package for the extensions to OAuth 2.0 required by MCP. This first PR adds Protected Resource Metadata.
1 parent 78a66a4 commit aa50088

File tree

3 files changed

+597
-0
lines changed

3 files changed

+597
-0
lines changed

internal/oauthex/oauth2.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
// Copyright 2025 The Go Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style
3+
// license that can be found in the LICENSE file.
4+
5+
// Package oauthex implements extensions to OAuth2.
6+
package oauthex

internal/oauthex/oauth2_test.go

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
// Copyright 2025 The Go Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style
3+
// license that can be found in the LICENSE file.
4+
5+
package oauthex
6+
7+
import (
8+
"context"
9+
"encoding/json"
10+
"fmt"
11+
"net/http"
12+
"net/http/httptest"
13+
"reflect"
14+
"testing"
15+
)
16+
17+
func TestSplitChallenges(t *testing.T) {
18+
tests := []struct {
19+
name string
20+
input string
21+
want []string
22+
}{
23+
{
24+
name: "single challenge no params",
25+
input: `Basic`,
26+
want: []string{`Basic`},
27+
},
28+
{
29+
name: "single challenge with params",
30+
input: `Bearer realm="example.com", error="invalid_token"`,
31+
want: []string{`Bearer realm="example.com", error="invalid_token"`},
32+
},
33+
{
34+
name: "single challenge with comma in quoted string",
35+
input: `Bearer realm="example, with comma"`,
36+
want: []string{`Bearer realm="example, with comma"`},
37+
},
38+
{
39+
name: "two challenges",
40+
input: `Basic, Bearer realm="example"`,
41+
want: []string{`Basic`, ` Bearer realm="example"`},
42+
},
43+
{
44+
name: "multiple challenges complex",
45+
input: `Newauth realm="apps", Basic, Bearer realm="example.com", error="invalid_token"`,
46+
want: []string{`Newauth realm="apps"`, ` Basic`, ` Bearer realm="example.com", error="invalid_token"`},
47+
},
48+
{
49+
name: "challenge with escaped quote",
50+
input: `Bearer realm="example \"quoted\""`,
51+
want: []string{`Bearer realm="example \"quoted\""`},
52+
},
53+
{
54+
name: "empty input",
55+
input: "",
56+
want: []string{""},
57+
},
58+
}
59+
60+
for _, tt := range tests {
61+
t.Run(tt.name, func(t *testing.T) {
62+
got := splitChallenges(tt.input)
63+
if !reflect.DeepEqual(got, tt.want) {
64+
t.Errorf("splitChallenges() = %v, want %v", got, tt.want)
65+
}
66+
})
67+
}
68+
}
69+
70+
func TestParseSingleChallenge(t *testing.T) {
71+
tests := []struct {
72+
name string
73+
input string
74+
want challenge
75+
wantErr bool
76+
}{
77+
{
78+
name: "scheme only",
79+
input: "Basic",
80+
want: challenge{
81+
Scheme: "basic",
82+
},
83+
wantErr: false,
84+
},
85+
{
86+
name: "scheme with one quoted param",
87+
input: `Bearer realm="example.com"`,
88+
want: challenge{
89+
Scheme: "bearer",
90+
Params: map[string]string{"realm": "example.com"},
91+
},
92+
wantErr: false,
93+
},
94+
{
95+
name: "scheme with multiple params",
96+
input: `Bearer realm="example", error="invalid_token", error_description="The token expired"`,
97+
want: challenge{
98+
Scheme: "bearer",
99+
Params: map[string]string{
100+
"realm": "example",
101+
"error": "invalid_token",
102+
"error_description": "The token expired",
103+
},
104+
},
105+
wantErr: false,
106+
},
107+
{
108+
name: "case-insensitive scheme and keys",
109+
input: `BEARER ReAlM="example"`,
110+
want: challenge{
111+
Scheme: "bearer",
112+
Params: map[string]string{"realm": "example"},
113+
},
114+
wantErr: false,
115+
},
116+
{
117+
name: "param with escaped quote",
118+
input: `Bearer realm="example \"foo\" bar"`,
119+
want: challenge{
120+
Scheme: "bearer",
121+
Params: map[string]string{"realm": `example "foo" bar`},
122+
},
123+
wantErr: false,
124+
},
125+
{
126+
name: "param without quotes (token)",
127+
input: "Bearer realm=example.com",
128+
want: challenge{
129+
Scheme: "bearer",
130+
Params: map[string]string{"realm": "example.com"},
131+
},
132+
wantErr: false,
133+
},
134+
{
135+
name: "malformed param - no value",
136+
input: "Bearer realm=",
137+
wantErr: true,
138+
},
139+
{
140+
name: "malformed param - unterminated quote",
141+
input: `Bearer realm="example`,
142+
wantErr: true,
143+
},
144+
{
145+
name: "malformed param - missing comma",
146+
input: `Bearer realm="a" error="b"`,
147+
wantErr: true,
148+
},
149+
{
150+
name: "empty input",
151+
input: "",
152+
wantErr: true,
153+
},
154+
}
155+
156+
for _, tt := range tests {
157+
t.Run(tt.name, func(t *testing.T) {
158+
got, err := parseSingleChallenge(tt.input)
159+
if (err != nil) != tt.wantErr {
160+
t.Errorf("parseSingleChallenge() error = %v, wantErr %v", err, tt.wantErr)
161+
return
162+
}
163+
if !reflect.DeepEqual(got, tt.want) {
164+
t.Errorf("parseSingleChallenge() = %v, want %v", got, tt.want)
165+
}
166+
})
167+
}
168+
}
169+
170+
func TestGetProtectedResourceMetadataFromRequest(t *testing.T) {
171+
h := &fakeResourceHandler{}
172+
server := httptest.NewServer(h)
173+
h.installHandlers(server.URL)
174+
client := server.Client()
175+
res, err := http.Get(server.URL + "/resource")
176+
if err != nil {
177+
t.Fatal(err)
178+
}
179+
if res.StatusCode != http.StatusUnauthorized {
180+
t.Fatal("want unauth")
181+
}
182+
prm, err := GetProtectedResourceMetadataFromHeader(context.Background(), res.Header, client)
183+
if err != nil {
184+
t.Fatal(err)
185+
}
186+
if prm == nil {
187+
t.Fatal("nil prm")
188+
}
189+
}
190+
191+
type fakeResourceHandler struct {
192+
http.ServeMux
193+
serverURL string
194+
authenticated bool
195+
}
196+
197+
func (h *fakeResourceHandler) installHandlers(serverURL string) {
198+
path := "/.well-known/oauth-protected-resource"
199+
url := serverURL + path
200+
h.Handle("GET /resource", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
201+
if h.authenticated {
202+
fmt.Fprintln(w, "resource")
203+
return
204+
}
205+
w.Header().Set("WWW-Authenticate", fmt.Sprintf(`Bearer resource_metadata="%s"`, url))
206+
w.WriteHeader(http.StatusUnauthorized)
207+
}))
208+
h.Handle("GET "+path, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
209+
w.Header().Set("Content-Type", "application/json")
210+
prm := &ProtectedResourceMetadata{Resource: url}
211+
if err := json.NewEncoder(w).Encode(prm); err != nil {
212+
panic(err)
213+
}
214+
}))
215+
}

0 commit comments

Comments
 (0)