Skip to content

Commit 29c1650

Browse files
authored
internal/oauthex: OAuth extensions (#125)
Add a package for the extensions to OAuth 2.0 required by MCP. This first PR adds Protected Resource Metadata.
1 parent 1250a31 commit 29c1650

File tree

3 files changed

+658
-0
lines changed

3 files changed

+658
-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: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,270 @@
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, err := splitChallenges(tt.input)
63+
if err != nil {
64+
t.Fatal(err)
65+
}
66+
if !reflect.DeepEqual(got, tt.want) {
67+
t.Errorf("splitChallenges() = %v, want %v", got, tt.want)
68+
}
69+
})
70+
}
71+
}
72+
73+
func TestSplitChallengesError(t *testing.T) {
74+
if _, err := splitChallenges(`"Bearer"`); err == nil {
75+
t.Fatal("got nil, want error")
76+
}
77+
}
78+
79+
func TestParseSingleChallenge(t *testing.T) {
80+
tests := []struct {
81+
name string
82+
input string
83+
want challenge
84+
wantErr bool
85+
}{
86+
{
87+
name: "scheme only",
88+
input: "Basic",
89+
want: challenge{
90+
Scheme: "basic",
91+
},
92+
wantErr: false,
93+
},
94+
{
95+
name: "scheme with one quoted param",
96+
input: `Bearer realm="example.com"`,
97+
want: challenge{
98+
Scheme: "bearer",
99+
Params: map[string]string{"realm": "example.com"},
100+
},
101+
wantErr: false,
102+
},
103+
{
104+
name: "scheme with one unquoted param",
105+
input: `Bearer realm=example.com`,
106+
want: challenge{
107+
Scheme: "bearer",
108+
Params: map[string]string{"realm": "example.com"},
109+
},
110+
wantErr: false,
111+
},
112+
{
113+
name: "scheme with multiple params",
114+
input: `Bearer realm="example", error="invalid_token", error_description="The token expired"`,
115+
want: challenge{
116+
Scheme: "bearer",
117+
Params: map[string]string{
118+
"realm": "example",
119+
"error": "invalid_token",
120+
"error_description": "The token expired",
121+
},
122+
},
123+
wantErr: false,
124+
},
125+
{
126+
name: "scheme with multiple unquoted params",
127+
input: `Bearer realm=example, error=invalid_token, error_description=The token expired`,
128+
want: challenge{
129+
Scheme: "bearer",
130+
Params: map[string]string{
131+
"realm": "example",
132+
"error": "invalid_token",
133+
"error_description": "The token expired",
134+
},
135+
},
136+
wantErr: false,
137+
},
138+
{
139+
name: "case-insensitive scheme and keys",
140+
input: `BEARER ReAlM="example"`,
141+
want: challenge{
142+
Scheme: "bearer",
143+
Params: map[string]string{"realm": "example"},
144+
},
145+
wantErr: false,
146+
},
147+
{
148+
name: "param with escaped quote",
149+
input: `Bearer realm="example \"foo\" bar"`,
150+
want: challenge{
151+
Scheme: "bearer",
152+
Params: map[string]string{"realm": `example "foo" bar`},
153+
},
154+
wantErr: false,
155+
},
156+
{
157+
name: "param without quotes (token)",
158+
input: "Bearer realm=example.com",
159+
want: challenge{
160+
Scheme: "bearer",
161+
Params: map[string]string{"realm": "example.com"},
162+
},
163+
wantErr: false,
164+
},
165+
{
166+
name: "malformed param - no value",
167+
input: "Bearer realm=",
168+
wantErr: true,
169+
},
170+
{
171+
name: "malformed param - unterminated quote",
172+
input: `Bearer realm="example`,
173+
wantErr: true,
174+
},
175+
{
176+
name: "malformed param - missing comma",
177+
input: `Bearer realm="a" error="b"`,
178+
wantErr: true,
179+
},
180+
{
181+
name: "malformed param - initial equal",
182+
input: `Bearer ="a"`,
183+
wantErr: true,
184+
},
185+
{
186+
name: "empty input",
187+
input: "",
188+
wantErr: true,
189+
},
190+
}
191+
192+
for _, tt := range tests {
193+
t.Run(tt.name, func(t *testing.T) {
194+
got, err := parseSingleChallenge(tt.input)
195+
if (err != nil) != tt.wantErr {
196+
t.Errorf("parseSingleChallenge() error = %v, wantErr %v", err, tt.wantErr)
197+
return
198+
}
199+
if !reflect.DeepEqual(got, tt.want) {
200+
t.Errorf("parseSingleChallenge() = %v, want %v", got, tt.want)
201+
}
202+
})
203+
}
204+
}
205+
206+
func TestGetProtectedResourceMetadata(t *testing.T) {
207+
ctx := context.Background()
208+
t.Run("FromHeader", func(t *testing.T) {
209+
h := &fakeResourceHandler{serveWWWAuthenticate: true}
210+
server := httptest.NewTLSServer(h)
211+
h.installHandlers(server.URL)
212+
client := server.Client()
213+
res, err := client.Get(server.URL + "/resource")
214+
if err != nil {
215+
t.Fatal(err)
216+
}
217+
if res.StatusCode != http.StatusUnauthorized {
218+
t.Fatal("want unauth")
219+
}
220+
prm, err := GetProtectedResourceMetadataFromHeader(ctx, res.Header, client)
221+
if err != nil {
222+
t.Fatal(err)
223+
}
224+
if prm == nil {
225+
t.Fatal("nil prm")
226+
}
227+
})
228+
t.Run("FromID", func(t *testing.T) {
229+
h := &fakeResourceHandler{serveWWWAuthenticate: false}
230+
server := httptest.NewTLSServer(h)
231+
h.installHandlers(server.URL)
232+
client := server.Client()
233+
prm, err := GetProtectedResourceMetadataFromID(ctx, server.URL, client)
234+
if err != nil {
235+
t.Fatal(err)
236+
}
237+
if prm == nil {
238+
t.Fatal("nil prm")
239+
}
240+
})
241+
}
242+
243+
type fakeResourceHandler struct {
244+
http.ServeMux
245+
serveWWWAuthenticate bool
246+
}
247+
248+
func (h *fakeResourceHandler) installHandlers(serverURL string) {
249+
path := "/.well-known/oauth-protected-resource"
250+
url := serverURL + path
251+
h.Handle("GET /resource", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
252+
if h.serveWWWAuthenticate {
253+
w.Header().Set("WWW-Authenticate", fmt.Sprintf(`Bearer resource_metadata="%s"`, url))
254+
}
255+
w.WriteHeader(http.StatusUnauthorized)
256+
}))
257+
h.Handle("GET "+path, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
258+
w.Header().Set("Content-Type", "application/json")
259+
// If there is a WWW-Authenticate header, the resource field is the value of that header.
260+
// If not, it's the server URL without the "/.well-known/..." part.
261+
resource := serverURL
262+
if h.serveWWWAuthenticate {
263+
resource = url
264+
}
265+
prm := &ProtectedResourceMetadata{Resource: resource}
266+
if err := json.NewEncoder(w).Encode(prm); err != nil {
267+
panic(err)
268+
}
269+
}))
270+
}

0 commit comments

Comments
 (0)