Skip to content

Commit b3fb83f

Browse files
authored
internal/testing: fake auth server (#296)
This is a fake OAuth authentication server, for use in testing.
1 parent 845c29f commit b3fb83f

File tree

3 files changed

+154
-0
lines changed

3 files changed

+154
-0
lines changed

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ module github.com/modelcontextprotocol/go-sdk
33
go 1.23.0
44

55
require (
6+
github.com/golang-jwt/jwt/v5 v5.2.1
67
github.com/google/go-cmp v0.7.0
78
github.com/google/jsonschema-go v0.2.3
89
github.com/yosida95/uritemplate/v3 v3.0.2

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
2+
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
13
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
24
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
35
github.com/google/jsonschema-go v0.2.3-0.20250911201137-bbdc431016d2 h1:IIj7X4SH1HKy0WfPR4nNEj4dhIJWGdXM5YoBAbfpdoo=
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
package testing
2+
3+
import (
4+
"crypto/sha256"
5+
"encoding/base64"
6+
"encoding/json"
7+
"fmt"
8+
"net/http"
9+
"time"
10+
11+
"github.com/golang-jwt/jwt/v5"
12+
)
13+
14+
const (
15+
authServerPort = ":8080"
16+
issuer = "http://localhost" + authServerPort
17+
tokenExpiry = time.Hour
18+
)
19+
20+
var jwtSigningKey = []byte("fake-secret-key")
21+
22+
type authCodeInfo struct {
23+
codeChallenge string
24+
redirectURI string
25+
}
26+
27+
// // FakeAuthServer is a fake OAuth2 authorization server.
28+
// type FakeAuthServer struct {
29+
// server *http.Server
30+
// authCodes map[string]authCodeInfo
31+
// }
32+
33+
type state struct {
34+
authCodes map[string]authCodeInfo
35+
}
36+
37+
// NewFakeAuthMux constructs a ServeMux that implements an OAuth 2.1 authentication
38+
// server. It should be used with [httptest.NewTLSServer].
39+
func NewFakeAuthMux() *http.ServeMux {
40+
s := &state{authCodes: make(map[string]authCodeInfo)}
41+
mux := http.NewServeMux()
42+
mux.HandleFunc("/.well-known/oauth-authorization-server", s.handleMetadata)
43+
mux.HandleFunc("/authorize", s.handleAuthorize)
44+
mux.HandleFunc("/token", s.handleToken)
45+
return mux
46+
}
47+
48+
func (s *state) handleMetadata(w http.ResponseWriter, r *http.Request) {
49+
issuer := "https://localhost:" + r.URL.Port()
50+
metadata := map[string]any{
51+
"issuer": issuer,
52+
"authorization_endpoint": issuer + "/authorize",
53+
"token_endpoint": issuer + "/token",
54+
"jwks_uri": issuer + "/.well-known/jwks.json",
55+
"scopes_supported": []string{"openid", "profile", "email"},
56+
"response_types_supported": []string{"code"},
57+
"grant_types_supported": []string{"authorization_code"},
58+
"token_endpoint_auth_methods_supported": []string{"none"},
59+
"code_challenge_methods_supported": []string{"S256"},
60+
}
61+
w.Header().Set("Content-Type", "application/json")
62+
json.NewEncoder(w).Encode(metadata)
63+
}
64+
65+
func (s *state) handleAuthorize(w http.ResponseWriter, r *http.Request) {
66+
query := r.URL.Query()
67+
responseType := query.Get("response_type")
68+
redirectURI := query.Get("redirect_uri")
69+
codeChallenge := query.Get("code_challenge")
70+
codeChallengeMethod := query.Get("code_challenge_method")
71+
72+
if responseType != "code" {
73+
http.Error(w, "unsupported_response_type", http.StatusBadRequest)
74+
return
75+
}
76+
if redirectURI == "" {
77+
http.Error(w, "invalid_request (no redirect_uri)", http.StatusBadRequest)
78+
return
79+
}
80+
if codeChallenge == "" || codeChallengeMethod != "S256" {
81+
http.Error(w, "invalid_request (code challenge is not S256)", http.StatusBadRequest)
82+
return
83+
}
84+
if query.Get("client_id") == "" {
85+
http.Error(w, "invalid_request (missing client_id)", http.StatusBadRequest)
86+
return
87+
}
88+
89+
authCode := "fake-auth-code-" + fmt.Sprintf("%d", time.Now().UnixNano())
90+
s.authCodes[authCode] = authCodeInfo{
91+
codeChallenge: codeChallenge,
92+
redirectURI: redirectURI,
93+
}
94+
95+
redirectURL := fmt.Sprintf("%s?code=%s&state=%s", redirectURI, authCode, query.Get("state"))
96+
http.Redirect(w, r, redirectURL, http.StatusFound)
97+
}
98+
99+
func (s *state) handleToken(w http.ResponseWriter, r *http.Request) {
100+
r.ParseForm()
101+
grantType := r.Form.Get("grant_type")
102+
code := r.Form.Get("code")
103+
codeVerifier := r.Form.Get("code_verifier")
104+
// Ignore redirect_uri; it is not required in 2.1.
105+
// https://www.ietf.org/archive/id/draft-ietf-oauth-v2-1-13.html#redirect-uri-in-token-request
106+
107+
if grantType != "authorization_code" {
108+
http.Error(w, "unsupported_grant_type", http.StatusBadRequest)
109+
return
110+
}
111+
112+
authCodeInfo, ok := s.authCodes[code]
113+
if !ok {
114+
http.Error(w, "invalid_grant", http.StatusBadRequest)
115+
return
116+
}
117+
delete(s.authCodes, code)
118+
119+
// PKCE verification
120+
hasher := sha256.New()
121+
hasher.Write([]byte(codeVerifier))
122+
calculatedChallenge := base64.RawURLEncoding.EncodeToString(hasher.Sum(nil))
123+
if calculatedChallenge != authCodeInfo.codeChallenge {
124+
http.Error(w, "invalid_grant", http.StatusBadRequest)
125+
return
126+
}
127+
128+
// Issue JWT
129+
now := time.Now()
130+
claims := jwt.MapClaims{
131+
"iss": issuer,
132+
"sub": "fake-user-id",
133+
"aud": "fake-client-id",
134+
"exp": now.Add(tokenExpiry).Unix(),
135+
"iat": now.Unix(),
136+
}
137+
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
138+
accessToken, err := token.SignedString(jwtSigningKey)
139+
if err != nil {
140+
http.Error(w, "server_error", http.StatusInternalServerError)
141+
return
142+
}
143+
144+
tokenResponse := map[string]any{
145+
"access_token": accessToken,
146+
"token_type": "Bearer",
147+
"expires_in": int(tokenExpiry.Seconds()),
148+
}
149+
w.Header().Set("Content-Type", "application/json")
150+
json.NewEncoder(w).Encode(tokenResponse)
151+
}

0 commit comments

Comments
 (0)