Skip to content
This repository was archived by the owner on Mar 25, 2026. It is now read-only.
Merged
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 auth/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ func TestOAuthClient_GetToken_Expired(t *testing.T) {

// Since we don't have a real OAuth provider set up for refreshing,
// the token refresh will fail, but we should still get the expired token
token, err := client.GetToken(ctx, "google", "sheets")
token, _ := client.GetToken(ctx, "google", "sheets")
// We expect an error due to failed refresh, but the token should be returned
if token != "expired-token" {
t.Errorf("Expected token 'expired-token', got %s", token)
Expand Down
6 changes: 3 additions & 3 deletions auth/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -436,13 +436,13 @@ func (t *TokenStore) Create(ctx context.Context, info oauth2.TokenInfo) error {
Scope: info.GetScope(),
Code: info.GetCode(),
CodeCreateAt: info.GetCodeCreateAt(),
CodeExpiresIn: time.Duration(info.GetCodeExpiresIn()) * time.Second,
CodeExpiresIn: info.GetCodeExpiresIn() * time.Second,
Access: info.GetAccess(),
AccessCreateAt: info.GetAccessCreateAt(),
AccessExpiresIn: time.Duration(info.GetAccessExpiresIn()) * time.Second,
AccessExpiresIn: info.GetAccessExpiresIn() * time.Second,
Refresh: info.GetRefresh(),
RefreshCreateAt: info.GetRefreshCreateAt(),
RefreshExpiresIn: time.Duration(info.GetRefreshExpiresIn()) * time.Second,
RefreshExpiresIn: info.GetRefreshExpiresIn() * time.Second,
}

return t.store.SaveOAuthToken(ctx, token)
Expand Down
2 changes: 1 addition & 1 deletion core/operations.go
Original file line number Diff line number Diff line change
Expand Up @@ -383,7 +383,7 @@ func handleInstallToolCLI(cmd *cobra.Command, args []string) error {
if err != nil {
return fmt.Errorf("failed to read from stdin: %w", err)
}
result, err = InstallToolFromManifest(context.Background(), string(data))
result, _ = InstallToolFromManifest(context.Background(), string(data))
} else if _, statErr := os.Stat(arg); statErr == nil {
// It's a file
result, err = InstallToolFromManifest(context.Background(), arg)
Expand Down
115 changes: 90 additions & 25 deletions http/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package http
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
Expand All @@ -25,7 +26,7 @@ import (
)

// setupOAuthServer creates and returns an OAuth server instance
func setupOAuthServer(cfg *config.Config, store storage.Storage) (*auth.OAuthConfig, *auth.OAuthServer) {
func setupOAuthServer(cfg *config.Config, store storage.Storage) *auth.OAuthServer {
// Create OAuth server configuration
oauthCfg := &auth.OAuthConfig{
Issuer: getOAuthIssuerURL(cfg),
Expand All @@ -39,12 +40,12 @@ func setupOAuthServer(cfg *config.Config, store storage.Storage) (*auth.OAuthCon
// Create OAuth server
oauthServer := auth.NewOAuthServer(oauthCfg, store)

return oauthCfg, oauthServer
return oauthServer
}

// SetupOAuthHandlers adds OAuth 2.1 endpoints to the HTTP server
func SetupOAuthHandlers(mux *http.ServeMux, cfg *config.Config, store storage.Storage) error {
_, oauthServer := setupOAuthServer(cfg, store)
oauthServer := setupOAuthServer(cfg, store)

// Register OAuth endpoints
mux.HandleFunc("/.well-known/oauth-authorization-server", oauthServer.HandleMetadataDiscovery)
Expand Down Expand Up @@ -109,7 +110,6 @@ func setupMCPRoutes(mux *http.ServeMux, store storage.Storage, oauthServer *auth
utils.Debug("MCP request: method=%s, id=%v", method, id)

w.Header().Set("Content-Type", "application/json")
ctx := r.Context()

// Handle MCP protocol methods
switch method {
Expand Down Expand Up @@ -193,7 +193,7 @@ func setupMCPRoutes(mux *http.ServeMux, store storage.Storage, oauthServer *auth

// Call the tool handler (this will be type-safe based on the generated handlers)
// For now, we'll handle the most common case - operations that take structured args
result, err := callToolHandler(ctx, handler, toolArgs)
result, err := callToolHandler(handler, toolArgs)
if err != nil {
utils.Warn("MCP tool execution failed: %v", err)
response := map[string]interface{}{
Expand Down Expand Up @@ -245,7 +245,7 @@ func setupMCPRoutes(mux *http.ServeMux, store storage.Storage, oauthServer *auth
}

// callToolHandler dynamically calls an MCP tool handler with the provided arguments
func callToolHandler(ctx context.Context, handler interface{}, args map[string]interface{}) (interface{}, error) {
func callToolHandler(handler interface{}, args map[string]interface{}) (interface{}, error) {
handlerValue := reflect.ValueOf(handler)
handlerType := handlerValue.Type()

Expand Down Expand Up @@ -487,7 +487,7 @@ func (a *AuthMiddleware) Middleware(next http.Handler) http.Handler {
}

// unauthorized sends an HTTP 401 Unauthorized response with OAuth challenge
func (a *AuthMiddleware) unauthorized(w http.ResponseWriter, r *http.Request, message string) {
func (a *AuthMiddleware) unauthorized(w http.ResponseWriter, _ *http.Request, message string) {
// According to MCP spec, when authorization is required but not provided,
// servers should respond with HTTP 401 Unauthorized

Expand Down Expand Up @@ -552,14 +552,19 @@ func (a *AuthMiddleware) OptionalMiddleware(next http.Handler) http.Handler {
})
}

// enforceHTTPS ensures OAuth endpoints are accessed over HTTPS (except localhost for development)
// enforceHTTPS ensures OAuth endpoints are accessed over HTTPS (except localhost and ngrok for development)
func enforceHTTPS(w http.ResponseWriter, r *http.Request) bool {
// Allow HTTP for localhost development
if r.Host == "localhost:8080" || r.Host == "127.0.0.1:8080" ||
r.Host == "localhost:3333" || r.Host == "127.0.0.1:3333" {
return true
}

// Allow HTTP for ngrok tunnels (development)
if strings.Contains(r.Host, ".ngrok-free.dev") || strings.Contains(r.Host, ".ngrok.io") {
return true
}

if r.TLS == nil {
http.Error(w, "HTTPS required for OAuth endpoints", http.StatusForbidden)
return false
Expand All @@ -583,11 +588,29 @@ type WebOAuthHandler struct {

// OAuthAuthState tracks the state of an OAuth authorization flow
type OAuthAuthState struct {
Provider string
Integration string
UserID string // From MCP client authentication
CreatedAt time.Time
ExpiresAt time.Time
Provider string
Integration string
UserID string // From MCP client authentication
CreatedAt time.Time
ExpiresAt time.Time
CodeVerifier string // PKCE code verifier
CodeChallenge string // PKCE code challenge
}

// generatePKCEChallenge generates a PKCE code verifier and challenge
func generatePKCEChallenge() (verifier, challenge string, err error) {
// Generate a random code verifier (43-128 characters)
verifierBytes := make([]byte, 32)
if _, err := rand.Read(verifierBytes); err != nil {
return "", "", err
}
verifier = base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(verifierBytes)

// Generate code challenge using SHA256
hash := sha256.Sum256([]byte(verifier))
challenge = base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(hash[:])

return verifier, challenge, nil
}

// NewWebOAuthHandler creates a new web OAuth handler
Expand Down Expand Up @@ -794,9 +817,17 @@ func (h *WebOAuthHandler) HandleOAuthAuthorize(w http.ResponseWriter, r *http.Re
rand.Read(stateBytes)
state := base64.URLEncoding.EncodeToString(stateBytes)

// Generate PKCE challenge for X OAuth 2.0 (required by X)
codeVerifier, codeChallenge, err := generatePKCEChallenge()
if err != nil {
utils.Error("Failed to generate PKCE challenge: %v", err)
http.Error(w, "Failed to generate PKCE challenge", http.StatusInternalServerError)
return
}

// Get user ID from session or create anonymous session
session, _ := GetSessionFromRequest(r)
userID := "anonymous"
var userID string
if session != nil {
userID = session.UserID
} else {
Expand All @@ -810,13 +841,15 @@ func (h *WebOAuthHandler) HandleOAuthAuthorize(w http.ResponseWriter, r *http.Re
userID = session.UserID
}

// Store auth state
// Store auth state with PKCE parameters
h.authStates[state] = &OAuthAuthState{
Provider: providerName,
Integration: integration,
UserID: userID,
CreatedAt: time.Now(),
ExpiresAt: time.Now().Add(10 * time.Minute), // 10 minute expiry
Provider: providerName,
Integration: integration,
UserID: userID,
CreatedAt: time.Now(),
ExpiresAt: time.Now().Add(10 * time.Minute), // 10 minute expiry
CodeVerifier: codeVerifier,
CodeChallenge: codeChallenge,
}

// Build authorization URL
Expand All @@ -827,12 +860,24 @@ func (h *WebOAuthHandler) HandleOAuthAuthorize(w http.ResponseWriter, r *http.Re
return
}

// Dynamically determine the redirect URI based on the request
scheme := "http"
if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" {
scheme = "https"
}
redirectURI := fmt.Sprintf("%s://%s/oauth/callback", scheme, r.Host)

query := authURL.Query()
query.Set("client_id", provider.ClientID)
query.Set("redirect_uri", h.baseURL+"/oauth/callback")
query.Set("redirect_uri", redirectURI)
query.Set("scope", strings.Join(registry.ScopesToStrings(provider.Scopes), " "))
query.Set("response_type", "code")
query.Set("state", state)

// Add PKCE parameters (required by X OAuth 2.0)
query.Set("code_challenge", codeChallenge)
query.Set("code_challenge_method", "S256")

authURL.RawQuery = query.Encode()

// Prepare template data
Expand Down Expand Up @@ -894,15 +939,35 @@ func (h *WebOAuthHandler) HandleOAuthCallback(w http.ResponseWriter, r *http.Req

// Exchange code for tokens
tokenURL := provider.TokenURL

// Dynamically determine the redirect URI based on the request (must match authorization)
scheme := "http"
if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" {
scheme = "https"
}
redirectURI := fmt.Sprintf("%s://%s/oauth/callback", scheme, r.Host)

data := url.Values{
"grant_type": {"authorization_code"},
"code": {code},
"redirect_uri": {h.baseURL + "/oauth/callback"},
"client_id": {provider.ClientID},
"client_secret": {provider.ClientSecret},
"redirect_uri": {redirectURI},
"code_verifier": {authState.CodeVerifier}, // PKCE code verifier
}

resp, err := http.PostForm(tokenURL, data)
// Create HTTP request with Basic Auth for confidential clients (X OAuth 2.0 requirement)
req, err := http.NewRequest("POST", tokenURL, strings.NewReader(data.Encode()))
if err != nil {
utils.Error("Failed to create token request: %v", err)
http.Error(w, "Failed to create token request", http.StatusInternalServerError)
return
}

// Set Basic Auth header with client credentials
req.SetBasicAuth(provider.ClientID, provider.ClientSecret)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")

client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
utils.Error("Failed to exchange code for tokens")
http.Error(w, "Failed to exchange code for tokens", http.StatusInternalServerError)
Expand Down
2 changes: 1 addition & 1 deletion http/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ func NewHandler(cfg *config.Config) (http.Handler, func(), error) {
// Setup OAuth endpoints only if OAuth is enabled
var oauthServer *auth.OAuthServer
if cfg.OAuth != nil && cfg.OAuth.Enabled {
_, oauthServer = setupOAuthServer(cfg, store)
oauthServer = setupOAuthServer(cfg, store)
if err := SetupOAuthHandlers(mux, cfg, store); err != nil {
sessionStore.Close()
baseCleanup()
Expand Down
4 changes: 1 addition & 3 deletions registry/default.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,9 +82,7 @@ func (d *DefaultRegistry) GetOAuthProvider(ctx context.Context, name string) (*R
for _, provider := range providers {
if provider.Name == name {
// Expand environment variables in the provider configuration
expandedProvider := provider
expandedProvider.ClientID = os.ExpandEnv(provider.ClientID)
expandedProvider.ClientSecret = os.ExpandEnv(provider.ClientSecret)
expandedProvider := expandOAuthProviderEnvVars(provider)
return &expandedProvider, nil
}
}
Expand Down
Loading