Enterprise managed authorization (#770)
`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>
This commit is contained in:
parent
05277f6e3c
commit
2d43865b97
1 changed files with 2595 additions and 226 deletions
505
auth/extauth/oidc_login_test.go
Normal file
505
auth/extauth/oidc_login_test.go
Normal file
|
|
@ -0,0 +1,505 @@
|
|||
// 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 extauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/modelcontextprotocol/go-sdk/auth"
|
||||
"github.com/modelcontextprotocol/go-sdk/oauthex"
|
||||
)
|
||||
|
||||
// TestInitiateOIDCLogin tests the OIDC authorization request generation.
|
||||
func TestInitiateOIDCLogin(t *testing.T) {
|
||||
// Create mock IdP server
|
||||
idpServer := createMockOIDCServer(t)
|
||||
defer idpServer.Close()
|
||||
config := &OIDCLoginConfig{
|
||||
IssuerURL: idpServer.URL,
|
||||
Credentials: &oauthex.ClientCredentials{
|
||||
ClientID: "test-client",
|
||||
},
|
||||
RedirectURL: "http://localhost:8080/callback",
|
||||
Scopes: []string{"openid", "profile", "email"},
|
||||
HTTPClient: idpServer.Client(),
|
||||
}
|
||||
t.Run("successful initiation", func(t *testing.T) {
|
||||
authReq, _, err := initiateOIDCLogin(context.Background(), config)
|
||||
if err != nil {
|
||||
t.Fatalf("initiateOIDCLogin failed: %v", err)
|
||||
}
|
||||
// Validate authURL
|
||||
if authReq.authURL == "" {
|
||||
t.Error("authURL is empty")
|
||||
}
|
||||
// Parse and validate URL parameters
|
||||
u, err := url.Parse(authReq.authURL)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse authURL: %v", err)
|
||||
}
|
||||
q := u.Query()
|
||||
if q.Get("response_type") != "code" {
|
||||
t.Errorf("expected response_type 'code', got '%s'", q.Get("response_type"))
|
||||
}
|
||||
if q.Get("client_id") != "test-client" {
|
||||
t.Errorf("expected client_id 'test-client', got '%s'", q.Get("client_id"))
|
||||
}
|
||||
if q.Get("redirect_uri") != "http://localhost:8080/callback" {
|
||||
t.Errorf("expected redirect_uri 'http://localhost:8080/callback', got '%s'", q.Get("redirect_uri"))
|
||||
}
|
||||
if q.Get("scope") != "openid profile email" {
|
||||
t.Errorf("expected scope 'openid profile email', got '%s'", q.Get("scope"))
|
||||
}
|
||||
if q.Get("code_challenge_method") != "S256" {
|
||||
t.Errorf("expected code_challenge_method 'S256', got '%s'", q.Get("code_challenge_method"))
|
||||
}
|
||||
// Validate state is generated
|
||||
if authReq.state == "" {
|
||||
t.Error("state is empty")
|
||||
}
|
||||
if q.Get("state") != authReq.state {
|
||||
t.Errorf("state in URL doesn't match returned state")
|
||||
}
|
||||
// Validate PKCE parameters
|
||||
if authReq.codeVerifier == "" {
|
||||
t.Error("codeVerifier is empty")
|
||||
}
|
||||
if q.Get("code_challenge") == "" {
|
||||
t.Error("code_challenge is empty")
|
||||
}
|
||||
})
|
||||
t.Run("with login_hint", func(t *testing.T) {
|
||||
configWithHint := *config
|
||||
configWithHint.LoginHint = "user@example.com"
|
||||
authReq, _, err := initiateOIDCLogin(context.Background(), &configWithHint)
|
||||
if err != nil {
|
||||
t.Fatalf("initiateOIDCLogin failed: %v", err)
|
||||
}
|
||||
u, err := url.Parse(authReq.authURL)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse authURL: %v", err)
|
||||
}
|
||||
q := u.Query()
|
||||
if q.Get("login_hint") != "user@example.com" {
|
||||
t.Errorf("expected login_hint 'user@example.com', got '%s'", q.Get("login_hint"))
|
||||
}
|
||||
})
|
||||
t.Run("without login_hint", func(t *testing.T) {
|
||||
authReq, _, err := initiateOIDCLogin(context.Background(), config)
|
||||
if err != nil {
|
||||
t.Fatalf("initiateOIDCLogin failed: %v", err)
|
||||
}
|
||||
u, err := url.Parse(authReq.authURL)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse authURL: %v", err)
|
||||
}
|
||||
q := u.Query()
|
||||
if q.Has("login_hint") {
|
||||
t.Errorf("expected no login_hint parameter, but got '%s'", q.Get("login_hint"))
|
||||
}
|
||||
})
|
||||
t.Run("nil config", func(t *testing.T) {
|
||||
_, _, err := initiateOIDCLogin(context.Background(), nil)
|
||||
if err == nil {
|
||||
t.Error("expected error for nil config, got nil")
|
||||
}
|
||||
})
|
||||
t.Run("missing openid scope", func(t *testing.T) {
|
||||
badConfig := *config
|
||||
badConfig.Scopes = []string{"profile", "email"} // Missing "openid"
|
||||
_, _, err := initiateOIDCLogin(context.Background(), &badConfig)
|
||||
if err == nil {
|
||||
t.Error("expected error for missing openid scope, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "openid") {
|
||||
t.Errorf("expected error about missing 'openid', got: %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("missing required fields", func(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(*OIDCLoginConfig)
|
||||
expectErr string
|
||||
}{
|
||||
{
|
||||
name: "missing IssuerURL",
|
||||
mutate: func(c *OIDCLoginConfig) { c.IssuerURL = "" },
|
||||
expectErr: "IssuerURL is required",
|
||||
},
|
||||
{
|
||||
name: "missing ClientID",
|
||||
mutate: func(c *OIDCLoginConfig) { c.Credentials.ClientID = "" },
|
||||
expectErr: "ClientID is required",
|
||||
},
|
||||
{
|
||||
name: "missing RedirectURL",
|
||||
mutate: func(c *OIDCLoginConfig) {
|
||||
c.RedirectURL = ""
|
||||
// Ensure ClientID is present to test RedirectURL validation
|
||||
c.Credentials = &oauthex.ClientCredentials{ClientID: "test"}
|
||||
},
|
||||
expectErr: "RedirectURL is required",
|
||||
},
|
||||
{
|
||||
name: "missing Scopes",
|
||||
mutate: func(c *OIDCLoginConfig) {
|
||||
c.Scopes = nil
|
||||
// Ensure required fields are present to test Scopes validation
|
||||
c.Credentials = &oauthex.ClientCredentials{ClientID: "test"}
|
||||
c.RedirectURL = "http://localhost:8080/callback"
|
||||
},
|
||||
expectErr: "Scopes is required",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
badConfig := *config
|
||||
tt.mutate(&badConfig)
|
||||
_, _, err := initiateOIDCLogin(context.Background(), &badConfig)
|
||||
if err == nil {
|
||||
t.Error("expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.expectErr) {
|
||||
t.Errorf("expected error containing '%s', got: %v", tt.expectErr, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestCompleteOIDCLogin tests the authorization code exchange.
|
||||
func TestCompleteOIDCLogin(t *testing.T) {
|
||||
// Create mock IdP server
|
||||
idpServer := createMockOIDCServerWithToken(t)
|
||||
defer idpServer.Close()
|
||||
config := &OIDCLoginConfig{
|
||||
IssuerURL: idpServer.URL,
|
||||
Credentials: &oauthex.ClientCredentials{
|
||||
ClientID: "test-client",
|
||||
ClientSecretAuth: &oauthex.ClientSecretAuth{
|
||||
ClientSecret: "test-secret",
|
||||
},
|
||||
},
|
||||
RedirectURL: "http://localhost:8080/callback",
|
||||
Scopes: []string{"openid", "profile", "email"},
|
||||
HTTPClient: idpServer.Client(),
|
||||
}
|
||||
t.Run("successful code exchange", func(t *testing.T) {
|
||||
// First initiate to get oauth2Config
|
||||
_, oauth2Config, err := initiateOIDCLogin(context.Background(), config)
|
||||
if err != nil {
|
||||
t.Fatalf("initiateOIDCLogin failed: %v", err)
|
||||
}
|
||||
|
||||
token, err := completeOIDCLogin(
|
||||
context.Background(),
|
||||
config,
|
||||
oauth2Config,
|
||||
"test-auth-code",
|
||||
"test-code-verifier",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("completeOIDCLogin failed: %v", err)
|
||||
}
|
||||
// Validate tokens
|
||||
idToken, ok := token.Extra("id_token").(string)
|
||||
if !ok || idToken == "" {
|
||||
t.Error("id_token is missing or empty")
|
||||
}
|
||||
if token.AccessToken == "" {
|
||||
t.Error("AccessToken is empty")
|
||||
}
|
||||
if token.TokenType != "Bearer" {
|
||||
t.Errorf("expected TokenType 'Bearer', got '%s'", token.TokenType)
|
||||
}
|
||||
if token.Expiry.IsZero() {
|
||||
t.Error("Expiry is zero")
|
||||
}
|
||||
})
|
||||
t.Run("missing parameters", func(t *testing.T) {
|
||||
_, oauth2Config, _ := initiateOIDCLogin(context.Background(), config)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
authCode string
|
||||
codeVerifier string
|
||||
expectErr string
|
||||
}{
|
||||
{
|
||||
name: "missing authCode",
|
||||
authCode: "",
|
||||
codeVerifier: "test-verifier",
|
||||
expectErr: "authCode is required",
|
||||
},
|
||||
{
|
||||
name: "missing codeVerifier",
|
||||
authCode: "test-code",
|
||||
codeVerifier: "",
|
||||
expectErr: "codeVerifier is required",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := completeOIDCLogin(
|
||||
context.Background(),
|
||||
config,
|
||||
oauth2Config,
|
||||
tt.authCode,
|
||||
tt.codeVerifier,
|
||||
)
|
||||
if err == nil {
|
||||
t.Error("expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.expectErr) {
|
||||
t.Errorf("expected error containing '%s', got: %v", tt.expectErr, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestOIDCLoginE2E tests the complete OIDC login flow end-to-end.
|
||||
func TestOIDCLoginE2E(t *testing.T) {
|
||||
// Create mock IdP server
|
||||
idpServer := createMockOIDCServerWithToken(t)
|
||||
defer idpServer.Close()
|
||||
config := &OIDCLoginConfig{
|
||||
IssuerURL: idpServer.URL,
|
||||
Credentials: &oauthex.ClientCredentials{
|
||||
ClientID: "test-client",
|
||||
ClientSecretAuth: &oauthex.ClientSecretAuth{
|
||||
ClientSecret: "test-secret",
|
||||
},
|
||||
},
|
||||
RedirectURL: "http://localhost:8080/callback",
|
||||
Scopes: []string{"openid", "profile", "email"},
|
||||
HTTPClient: idpServer.Client(),
|
||||
}
|
||||
// Step 1: Initiate login
|
||||
authReq, oauth2Config, err := initiateOIDCLogin(context.Background(), config)
|
||||
if err != nil {
|
||||
t.Fatalf("initiateOIDCLogin failed: %v", err)
|
||||
}
|
||||
// Step 2: Simulate user authentication and redirect
|
||||
// (In real flow, user would visit authReq.authURL and IdP would redirect back)
|
||||
// Here we just use a mock authorization code
|
||||
mockAuthCode := "mock-authorization-code"
|
||||
// Step 3: Complete login with authorization code
|
||||
token, err := completeOIDCLogin(
|
||||
context.Background(),
|
||||
config,
|
||||
oauth2Config,
|
||||
mockAuthCode,
|
||||
authReq.codeVerifier,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("completeOIDCLogin failed: %v", err)
|
||||
}
|
||||
// Validate we got an ID token
|
||||
idToken, ok := token.Extra("id_token").(string)
|
||||
if !ok || idToken == "" {
|
||||
t.Error("Expected ID token, got empty or missing")
|
||||
}
|
||||
// Validate ID token is a JWT (has 3 parts)
|
||||
parts := strings.Split(idToken, ".")
|
||||
if len(parts) != 3 {
|
||||
t.Errorf("Expected JWT with 3 parts, got %d parts", len(parts))
|
||||
}
|
||||
}
|
||||
|
||||
// createMockOIDCServer creates a mock OIDC server for testing initiateOIDCLogin.
|
||||
func createMockOIDCServer(t *testing.T) *httptest.Server {
|
||||
var serverURL string
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Handle OIDC discovery
|
||||
if r.URL.Path == "/.well-known/openid-configuration" {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"issuer": serverURL,
|
||||
"authorization_endpoint": serverURL + "/authorize",
|
||||
"token_endpoint": serverURL + "/token",
|
||||
"jwks_uri": serverURL + "/.well-known/jwks.json",
|
||||
"response_types_supported": []string{"code"},
|
||||
"code_challenge_methods_supported": []string{"S256"},
|
||||
"grant_types_supported": []string{"authorization_code"},
|
||||
})
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}))
|
||||
serverURL = server.URL
|
||||
return server
|
||||
}
|
||||
|
||||
// createMockOIDCServerWithToken creates a mock OIDC server that also handles token exchange.
|
||||
func createMockOIDCServerWithToken(t *testing.T) *httptest.Server {
|
||||
var serverURL string
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Handle OIDC discovery
|
||||
if r.URL.Path == "/.well-known/openid-configuration" {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"issuer": serverURL,
|
||||
"authorization_endpoint": serverURL + "/authorize",
|
||||
"token_endpoint": serverURL + "/token",
|
||||
"jwks_uri": serverURL + "/.well-known/jwks.json",
|
||||
"response_types_supported": []string{"code"},
|
||||
"code_challenge_methods_supported": []string{"S256"},
|
||||
"grant_types_supported": []string{"authorization_code"},
|
||||
})
|
||||
return
|
||||
}
|
||||
// Handle token endpoint
|
||||
if r.URL.Path == "/token" {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "failed to parse form", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// Validate grant type
|
||||
if r.FormValue("grant_type") != "authorization_code" {
|
||||
http.Error(w, "invalid grant_type", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// Create mock ID token (JWT)
|
||||
now := time.Now().Unix()
|
||||
idToken := fmt.Sprintf("eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.%s.mock-signature",
|
||||
base64EncodeClaims(map[string]interface{}{
|
||||
"iss": serverURL,
|
||||
"sub": "test-user",
|
||||
"aud": "test-client",
|
||||
"exp": now + 3600,
|
||||
"iat": now,
|
||||
"email": "test@example.com",
|
||||
}))
|
||||
// Return token response
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"access_token": "mock-access-token",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600,
|
||||
"refresh_token": "mock-refresh-token",
|
||||
"id_token": idToken,
|
||||
})
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}))
|
||||
serverURL = server.URL
|
||||
return server
|
||||
}
|
||||
|
||||
// base64EncodeClaims encodes JWT claims for testing.
|
||||
func base64EncodeClaims(claims map[string]interface{}) string {
|
||||
claimsJSON, _ := json.Marshal(claims)
|
||||
return base64.RawURLEncoding.EncodeToString(claimsJSON)
|
||||
}
|
||||
|
||||
// TestPerformOIDCLogin tests the combined OIDC login flow with callback.
|
||||
func TestPerformOIDCLogin(t *testing.T) {
|
||||
// Create mock IdP server
|
||||
idpServer := createMockOIDCServerWithToken(t)
|
||||
defer idpServer.Close()
|
||||
config := &OIDCLoginConfig{
|
||||
IssuerURL: idpServer.URL,
|
||||
Credentials: &oauthex.ClientCredentials{
|
||||
ClientID: "test-client",
|
||||
ClientSecretAuth: &oauthex.ClientSecretAuth{
|
||||
ClientSecret: "test-secret",
|
||||
},
|
||||
},
|
||||
RedirectURL: "http://localhost:8080/callback",
|
||||
Scopes: []string{"openid", "profile", "email"},
|
||||
HTTPClient: idpServer.Client(),
|
||||
}
|
||||
|
||||
t.Run("successful flow", func(t *testing.T) {
|
||||
token, err := PerformOIDCLogin(context.Background(), config,
|
||||
func(ctx context.Context, args *auth.AuthorizationArgs) (*auth.AuthorizationResult, error) {
|
||||
// Validate authURL has required parameters
|
||||
u, err := url.Parse(args.URL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid authURL: %w", err)
|
||||
}
|
||||
q := u.Query()
|
||||
if q.Get("response_type") != "code" {
|
||||
return nil, fmt.Errorf("missing response_type")
|
||||
}
|
||||
if q.Get("state") == "" {
|
||||
return nil, fmt.Errorf("missing state")
|
||||
}
|
||||
|
||||
// Simulate successful user authentication
|
||||
return &auth.AuthorizationResult{
|
||||
Code: "mock-auth-code",
|
||||
State: q.Get("state"), // Return the expected state from URL
|
||||
}, nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("PerformOIDCLogin failed: %v", err)
|
||||
}
|
||||
|
||||
idToken, ok := token.Extra("id_token").(string)
|
||||
if !ok || idToken == "" {
|
||||
t.Error("id_token is missing or empty")
|
||||
}
|
||||
if token.AccessToken == "" {
|
||||
t.Error("AccessToken is empty")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("state mismatch", func(t *testing.T) {
|
||||
_, err := PerformOIDCLogin(context.Background(), config,
|
||||
func(ctx context.Context, args *auth.AuthorizationArgs) (*auth.AuthorizationResult, error) {
|
||||
// Return wrong state to simulate CSRF attack
|
||||
return &auth.AuthorizationResult{
|
||||
Code: "mock-auth-code",
|
||||
State: "wrong-state",
|
||||
}, nil
|
||||
})
|
||||
|
||||
if err == nil {
|
||||
t.Error("expected error for state mismatch, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "state mismatch") {
|
||||
t.Errorf("expected state mismatch error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("fetcher error", func(t *testing.T) {
|
||||
_, err := PerformOIDCLogin(context.Background(), config,
|
||||
func(ctx context.Context, args *auth.AuthorizationArgs) (*auth.AuthorizationResult, error) {
|
||||
return nil, fmt.Errorf("user cancelled")
|
||||
})
|
||||
|
||||
if err == nil {
|
||||
t.Error("expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "user cancelled") {
|
||||
t.Errorf("expected 'user cancelled' error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nil fetcher", func(t *testing.T) {
|
||||
_, err := PerformOIDCLogin(context.Background(), config, nil)
|
||||
if err == nil {
|
||||
t.Error("expected error for nil fetcher, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "authCodeFetcher is required") {
|
||||
t.Errorf("expected 'authCodeFetcher is required' error, got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
Loading…
Reference in a new issue