149 lines
4.4 KiB
Go
149 lines
4.4 KiB
Go
// Copyright 2025 The Go MCP SDK Authors. All rights reserved.
|
|
// Use of this source code is governed by an MIT-style
|
|
// license that can be found in the LICENSE file.
|
|
|
|
package testing
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/golang-jwt/jwt/v5"
|
|
)
|
|
|
|
const (
|
|
authServerPort = ":8080"
|
|
issuer = "http://localhost" + authServerPort
|
|
tokenExpiry = time.Hour
|
|
)
|
|
|
|
var jwtSigningKey = []byte("fake-secret-key")
|
|
|
|
type authCodeInfo struct {
|
|
codeChallenge string
|
|
redirectURI string
|
|
}
|
|
|
|
type state struct {
|
|
authCodes map[string]authCodeInfo
|
|
}
|
|
|
|
// NewFakeAuthMux constructs a ServeMux that implements an OAuth 2.1 authentication
|
|
// server. It should be used with [httptest.NewTLSServer].
|
|
func NewFakeAuthMux() *http.ServeMux {
|
|
s := &state{authCodes: make(map[string]authCodeInfo)}
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/.well-known/oauth-authorization-server", s.handleMetadata)
|
|
mux.HandleFunc("/authorize", s.handleAuthorize)
|
|
mux.HandleFunc("/token", s.handleToken)
|
|
return mux
|
|
}
|
|
|
|
func (s *state) handleMetadata(w http.ResponseWriter, r *http.Request) {
|
|
issuer := "https://localhost:" + r.URL.Port()
|
|
metadata := map[string]any{
|
|
"issuer": issuer,
|
|
"authorization_endpoint": issuer + "/authorize",
|
|
"token_endpoint": issuer + "/token",
|
|
"jwks_uri": issuer + "/.well-known/jwks.json",
|
|
"scopes_supported": []string{"openid", "profile", "email"},
|
|
"response_types_supported": []string{"code"},
|
|
"grant_types_supported": []string{"authorization_code"},
|
|
"token_endpoint_auth_methods_supported": []string{"none"},
|
|
"code_challenge_methods_supported": []string{"S256"},
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(metadata)
|
|
}
|
|
|
|
func (s *state) handleAuthorize(w http.ResponseWriter, r *http.Request) {
|
|
query := r.URL.Query()
|
|
responseType := query.Get("response_type")
|
|
redirectURI := query.Get("redirect_uri")
|
|
codeChallenge := query.Get("code_challenge")
|
|
codeChallengeMethod := query.Get("code_challenge_method")
|
|
|
|
if responseType != "code" {
|
|
http.Error(w, "unsupported_response_type", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if redirectURI == "" {
|
|
http.Error(w, "invalid_request (no redirect_uri)", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if codeChallenge == "" || codeChallengeMethod != "S256" {
|
|
http.Error(w, "invalid_request (code challenge is not S256)", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if query.Get("client_id") == "" {
|
|
http.Error(w, "invalid_request (missing client_id)", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
authCode := "fake-auth-code-" + fmt.Sprintf("%d", time.Now().UnixNano())
|
|
s.authCodes[authCode] = authCodeInfo{
|
|
codeChallenge: codeChallenge,
|
|
redirectURI: redirectURI,
|
|
}
|
|
|
|
redirectURL := fmt.Sprintf("%s?code=%s&state=%s", redirectURI, authCode, query.Get("state"))
|
|
http.Redirect(w, r, redirectURL, http.StatusFound)
|
|
}
|
|
|
|
func (s *state) handleToken(w http.ResponseWriter, r *http.Request) {
|
|
r.ParseForm()
|
|
grantType := r.Form.Get("grant_type")
|
|
code := r.Form.Get("code")
|
|
codeVerifier := r.Form.Get("code_verifier")
|
|
// Ignore redirect_uri; it is not required in 2.1.
|
|
// https://www.ietf.org/archive/id/draft-ietf-oauth-v2-1-13.html#redirect-uri-in-token-request
|
|
|
|
if grantType != "authorization_code" {
|
|
http.Error(w, "unsupported_grant_type", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
authCodeInfo, ok := s.authCodes[code]
|
|
if !ok {
|
|
http.Error(w, "invalid_grant", http.StatusBadRequest)
|
|
return
|
|
}
|
|
delete(s.authCodes, code)
|
|
|
|
// PKCE verification
|
|
hasher := sha256.New()
|
|
hasher.Write([]byte(codeVerifier))
|
|
calculatedChallenge := base64.RawURLEncoding.EncodeToString(hasher.Sum(nil))
|
|
if calculatedChallenge != authCodeInfo.codeChallenge {
|
|
http.Error(w, "invalid_grant", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Issue JWT
|
|
now := time.Now()
|
|
claims := jwt.MapClaims{
|
|
"iss": issuer,
|
|
"sub": "fake-user-id",
|
|
"aud": "fake-client-id",
|
|
"exp": now.Add(tokenExpiry).Unix(),
|
|
"iat": now.Unix(),
|
|
}
|
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
|
accessToken, err := token.SignedString(jwtSigningKey)
|
|
if err != nil {
|
|
http.Error(w, "server_error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
tokenResponse := map[string]any{
|
|
"access_token": accessToken,
|
|
"token_type": "Bearer",
|
|
"expires_in": int(tokenExpiry.Seconds()),
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(tokenResponse)
|
|
}
|