`auth`, `oauthex`: implement Enterprise Managed Authorization (SEP-990) This PR implements Enterprise Managed Authorization (SEP-990) for the Go MCP SDK, enabling MCP Clients and Servers to leverage enterprise Identity Providers for seamless authorization without requiring users to authenticate separately to each MCP Server. Overview Enterprise Managed Authorization follows the Identity Assertion Authorization Grant specification (draft-ietf-oauth-identity-assertion-authz-grant), implementing a three-step flow: 1. **Single Sign-On (SSO)**: User authenticates to the MCP Client via enterprise IdP (Okta, Auth0, Azure AD, etc.) 2. **Token Exchange (RFC 8693)**: Client exchanges ID Token for Identity Assertion JWT Authorization Grant (ID-JAG) at the IdP 3. **JWT Bearer Grant (RFC 7523)**: Client exchanges ID-JAG for Access Token at the MCP Server This enables: - **For end users**: Single sign-on across MCP Clients and Servers—no manual connection/authorization per server - **For enterprise admins**: Centralized visibility and control over which MCP Servers can be used within the organization - **For MCP clients**: Automatic token acquisition without user interaction for each server Closes: [#628](https://github.com/modelcontextprotocol/go-sdk/issues/628) --------- Co-authored-by: Maciej Kisiel <mkisiel@google.com>
239 lines
6.8 KiB
Go
239 lines
6.8 KiB
Go
// Copyright 2026 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.
|
|
|
|
//go:build mcp_go_client_oauth
|
|
|
|
package oauthex
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
)
|
|
|
|
// TestExchangeToken tests the basic token exchange flow.
|
|
func TestExchangeToken(t *testing.T) {
|
|
// Create a test IdP server that implements token exchange
|
|
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
// Verify request method and content type
|
|
if r.Method != http.MethodPost {
|
|
t.Errorf("expected POST request, got %s", r.Method)
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
contentType := r.Header.Get("Content-Type")
|
|
if contentType != "application/x-www-form-urlencoded" {
|
|
t.Errorf("expected application/x-www-form-urlencoded, got %s", contentType)
|
|
http.Error(w, "invalid content type", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Parse form data
|
|
if err := r.ParseForm(); err != nil {
|
|
http.Error(w, "failed to parse form", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Verify required parameters per SEP-990 Section 4
|
|
grantType := r.FormValue("grant_type")
|
|
if grantType != GrantTypeTokenExchange {
|
|
t.Errorf("expected grant_type %s, got %s", GrantTypeTokenExchange, grantType)
|
|
writeErrorResponse(w, "invalid_grant", "invalid grant_type")
|
|
return
|
|
}
|
|
|
|
requestedTokenType := r.FormValue("requested_token_type")
|
|
if requestedTokenType != TokenTypeIDJAG {
|
|
t.Errorf("expected requested_token_type %s, got %s", TokenTypeIDJAG, requestedTokenType)
|
|
writeErrorResponse(w, "invalid_request", "invalid requested_token_type")
|
|
return
|
|
}
|
|
|
|
audience := r.FormValue("audience")
|
|
if audience == "" {
|
|
t.Error("audience is required")
|
|
writeErrorResponse(w, "invalid_request", "missing audience")
|
|
return
|
|
}
|
|
|
|
resource := r.FormValue("resource")
|
|
if resource == "" {
|
|
t.Error("resource is required")
|
|
writeErrorResponse(w, "invalid_request", "missing resource")
|
|
return
|
|
}
|
|
|
|
subjectToken := r.FormValue("subject_token")
|
|
if subjectToken == "" {
|
|
t.Error("subject_token is required")
|
|
writeErrorResponse(w, "invalid_request", "missing subject_token")
|
|
return
|
|
}
|
|
|
|
subjectTokenType := r.FormValue("subject_token_type")
|
|
if subjectTokenType != TokenTypeIDToken {
|
|
t.Errorf("expected subject_token_type %s, got %s", TokenTypeIDToken, subjectTokenType)
|
|
writeErrorResponse(w, "invalid_request", "invalid subject_token_type")
|
|
return
|
|
}
|
|
|
|
// Verify client authentication
|
|
clientID := r.FormValue("client_id")
|
|
clientSecret := r.FormValue("client_secret")
|
|
if clientID == "" || clientSecret == "" {
|
|
t.Error("client authentication required")
|
|
writeErrorResponse(w, "invalid_client", "client authentication failed")
|
|
return
|
|
}
|
|
|
|
if clientID != "test-client-id" || clientSecret != "test-client-secret" {
|
|
t.Error("invalid client credentials")
|
|
writeErrorResponse(w, "invalid_client", "invalid credentials")
|
|
return
|
|
}
|
|
|
|
// Return successful token exchange response per SEP-990 Section 4.2
|
|
resp := map[string]interface{}{
|
|
"issued_token_type": TokenTypeIDJAG,
|
|
"access_token": "fake-id-jag-token",
|
|
"token_type": "N_A",
|
|
"scope": r.FormValue("scope"),
|
|
"expires_in": 300,
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
w.Header().Set("Pragma", "no-cache")
|
|
w.WriteHeader(http.StatusOK)
|
|
json.NewEncoder(w).Encode(resp)
|
|
}))
|
|
defer server.Close()
|
|
|
|
// Test successful token exchange
|
|
t.Run("successful exchange", func(t *testing.T) {
|
|
req := &TokenExchangeRequest{
|
|
RequestedTokenType: TokenTypeIDJAG,
|
|
Audience: "https://auth.mcpserver.example/",
|
|
Resource: "https://mcp.mcpserver.example/",
|
|
Scope: []string{"read", "write"},
|
|
SubjectToken: "fake-id-token",
|
|
SubjectTokenType: TokenTypeIDToken,
|
|
}
|
|
|
|
token, err := ExchangeToken(
|
|
context.Background(),
|
|
server.URL,
|
|
req,
|
|
&ClientCredentials{
|
|
ClientID: "test-client-id",
|
|
ClientSecretAuth: &ClientSecretAuth{
|
|
ClientSecret: "test-client-secret",
|
|
},
|
|
},
|
|
server.Client(),
|
|
)
|
|
|
|
if err != nil {
|
|
t.Fatalf("ExchangeToken failed: %v", err)
|
|
}
|
|
|
|
issuedTokenType, ok := token.Extra("issued_token_type").(string)
|
|
if !ok || issuedTokenType != TokenTypeIDJAG {
|
|
t.Errorf("expected issued_token_type %s, got %s", TokenTypeIDJAG, issuedTokenType)
|
|
}
|
|
|
|
if token.AccessToken != "fake-id-jag-token" {
|
|
t.Errorf("expected access_token 'fake-id-jag-token', got %s", token.AccessToken)
|
|
}
|
|
|
|
if token.TokenType != "N_A" {
|
|
t.Errorf("expected token_type 'N_A', got %s", token.TokenType)
|
|
}
|
|
|
|
scope, ok := token.Extra("scope").(string)
|
|
if !ok || scope != "read write" {
|
|
t.Errorf("expected scope 'read write', got %s", scope)
|
|
}
|
|
|
|
// expires_in should be available in Extra
|
|
expiresIn, ok := token.Extra("expires_in").(float64)
|
|
if !ok || int(expiresIn) != 300 {
|
|
t.Errorf("expected expires_in 300, got %v", expiresIn)
|
|
}
|
|
})
|
|
|
|
// Test missing required fields
|
|
t.Run("missing audience", func(t *testing.T) {
|
|
req := &TokenExchangeRequest{
|
|
RequestedTokenType: TokenTypeIDJAG,
|
|
Resource: "https://mcp.mcpserver.example/",
|
|
SubjectToken: "fake-id-token",
|
|
SubjectTokenType: TokenTypeIDToken,
|
|
}
|
|
|
|
_, err := ExchangeToken(
|
|
context.Background(),
|
|
server.URL,
|
|
req,
|
|
&ClientCredentials{
|
|
ClientID: "test-client-id",
|
|
ClientSecretAuth: &ClientSecretAuth{
|
|
ClientSecret: "test-client-secret",
|
|
},
|
|
},
|
|
server.Client(),
|
|
)
|
|
|
|
if err == nil {
|
|
t.Error("expected error for missing audience, got nil")
|
|
}
|
|
})
|
|
|
|
// Test invalid URL schemes
|
|
t.Run("invalid audience URL scheme", func(t *testing.T) {
|
|
req := &TokenExchangeRequest{
|
|
RequestedTokenType: TokenTypeIDJAG,
|
|
Audience: "javascript:alert(1)",
|
|
Resource: "https://mcp.mcpserver.example/",
|
|
SubjectToken: "fake-id-token",
|
|
SubjectTokenType: TokenTypeIDToken,
|
|
}
|
|
|
|
_, err := ExchangeToken(
|
|
context.Background(),
|
|
server.URL,
|
|
req,
|
|
&ClientCredentials{
|
|
ClientID: "test-client-id",
|
|
ClientSecretAuth: &ClientSecretAuth{
|
|
ClientSecret: "test-client-secret",
|
|
},
|
|
},
|
|
server.Client(),
|
|
)
|
|
|
|
if err == nil {
|
|
t.Error("expected error for invalid audience URL scheme, got nil")
|
|
}
|
|
})
|
|
}
|
|
|
|
// writeErrorResponse writes an OAuth 2.0 error response per RFC 6749 Section 5.2.
|
|
func writeErrorResponse(w http.ResponseWriter, errorCode, errorDescription string) {
|
|
errResp := struct {
|
|
Error string `json:"error"`
|
|
ErrorDescription string `json:"error_description,omitempty"`
|
|
}{
|
|
Error: errorCode,
|
|
ErrorDescription: errorDescription,
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
json.NewEncoder(w).Encode(errResp)
|
|
}
|