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:
Pranav RK 2026-03-30 19:59:30 +05:30 • committed by GitHub
commit 2d43865b97
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 2595 additions and 226 deletions

59
oauthex/client.go Normal file
View file

@ -0,0 +1,59 @@
// 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 "errors"
// ClientCredentials holds client authentication credentials for OAuth token requests.
// It supports multiple authentication methods, but only one method should be set at a time.
// Use the Validate method to ensure proper configuration.
type ClientCredentials struct {
// ClientID is the OAuth2 client identifier.
// REQUIRED for all authentication methods.
ClientID string
// ClientSecretAuth configures client authentication using a client secret.
// This is the most common authentication method for confidential clients.
// OPTIONAL. If not provided, the client is treated as a public client.
ClientSecretAuth *ClientSecretAuth
}
// ClientSecretAuth holds client secret authentication credentials.
// This authentication method supports both "client_secret_basic" and "client_secret_post"
// methods as defined in RFC 6749 Section 2.3.1.
type ClientSecretAuth struct {
// ClientSecret is the OAuth2 client secret for confidential clients.
// REQUIRED when using ClientSecretAuth.
ClientSecret string
}
// Validate checks that the ClientCredentials are properly configured.
// It ensures that:
// - ClientID is not empty.
// - At most one authentication method is configured.
// - If ClientSecretAuth is set, ClientSecret is not empty.
func (c *ClientCredentials) Validate() error {
if c.ClientID == "" {
return errors.New("ClientID is required")
}
// Count how many auth methods are configured.
authMethodCount := 0
if c.ClientSecretAuth != nil {
authMethodCount++
if c.ClientSecretAuth.ClientSecret == "" {
return errors.New("ClientSecret is required when using ClientSecretAuth")
}
}
// Allow zero auth methods (public client) or exactly one auth method.
if authMethodCount > 1 {
return errors.New("only one client authentication method can be configured")
}
return nil
}

View file

@ -66,6 +66,7 @@ func getJSON[T any](ctx context.Context, c *http.Client, url string, limit int64
// checkURLScheme ensures that its argument is a valid URL with a scheme
// that prevents XSS attacks.
// See #526.
// Note: a copy of this function exists in auth/extauth/oidc_login.go; keep these in sync.
func checkURLScheme(u string) error {
if u == "" {
return nil

185
oauthex/token_exchange.go Normal file
View file

@ -0,0 +1,185 @@
// 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.
// This file implements Token Exchange (RFC 8693) for Enterprise Managed Authorization.
// See https://datatracker.ietf.org/doc/html/rfc8693
//go:build mcp_go_client_oauth
package oauthex
import (
"context"
"fmt"
"net/http"
"strings"
"golang.org/x/oauth2"
)
// Token type identifiers defined by RFC 8693 and SEP-990.
const (
// TokenTypeIDToken is the URN for OpenID Connect ID Tokens.
TokenTypeIDToken = "urn:ietf:params:oauth:token-type:id_token"
// TokenTypeSAML2 is the URN for SAML 2.0 assertions.
TokenTypeSAML2 = "urn:ietf:params:oauth:token-type:saml2"
// TokenTypeIDJAG is the URN for Identity Assertion JWT Authorization Grants.
// This is the token type returned by IdP during token exchange for SEP-990.
TokenTypeIDJAG = "urn:ietf:params:oauth:token-type:id-jag"
// GrantTypeTokenExchange is the grant type for RFC 8693 token exchange.
GrantTypeTokenExchange = "urn:ietf:params:oauth:grant-type:token-exchange"
)
// TokenExchangeRequest represents a Token Exchange request per RFC 8693.
// This is used for Enterprise Managed Authorization (SEP-990) where an MCP Client
// exchanges an ID Token from an enterprise IdP for an ID-JAG that can be used
// to obtain an access token from an MCP Server's authorization server.
type TokenExchangeRequest struct {
// RequestedTokenType indicates the type of security token being requested.
// For SEP-990, this MUST be TokenTypeIDJAG.
RequestedTokenType string
// Audience is the logical name of the target service where the client
// intends to use the requested token. For SEP-990, this MUST be the
// Issuer URL of the MCP Server's authorization server.
Audience string
// Resource is the physical location or identifier of the target resource.
// For SEP-990, this MUST be the RFC9728 Resource Identifier of the MCP Server.
Resource string
// Scope is a list of space-separated scopes for the requested token.
// This is OPTIONAL per RFC 8693 but commonly used in SEP-990.
Scope []string
// SubjectToken is the security token that represents the identity of the
// party on behalf of whom the request is being made. For SEP-990, this is
// typically an OpenID Connect ID Token.
SubjectToken string
// SubjectTokenType is the type of the security token in SubjectToken.
// For SEP-990 with OIDC, this MUST be TokenTypeIDToken.
SubjectTokenType string
}
// ExchangeToken performs a token exchange request per RFC 8693 for Enterprise
// Managed Authorization (SEP-990). It exchanges an identity assertion (typically
// an ID Token) for an Identity Assertion JWT Authorization Grant (ID-JAG) that
// can be used to obtain an access token from an MCP Server.
//
// The tokenEndpoint parameter should be the IdP's token endpoint (typically
// obtained from the IdP's authorization server metadata).
//
// Returns an oauth2.Token where:
// - Extra("issued_token_type") contains the type of the issued token (e.g., TokenTypeIDJAG)
// - AccessToken contains the ID-JAG JWT (despite the name, this is not an OAuth access token)
// - TokenType is typically "N_A" for SEP-990
// - Extra("scope") may contain the scope if different from the request
// - Expiry is when the token expires
func ExchangeToken(
ctx context.Context,
tokenEndpoint string,
req *TokenExchangeRequest,
clientCreds *ClientCredentials,
httpClient *http.Client,
) (*oauth2.Token, error) {
if tokenEndpoint == "" {
return nil, fmt.Errorf("token endpoint is required")
}
if req == nil {
return nil, fmt.Errorf("token exchange request is required")
}
if clientCreds == nil {
return nil, fmt.Errorf("client credentials are required")
}
if err := clientCreds.Validate(); err != nil {
return nil, fmt.Errorf("invalid client credentials: %w", err)
}
// Validate required fields per SEP-990 Section 4.
if req.RequestedTokenType == "" {
return nil, fmt.Errorf("requested_token_type is required")
}
if req.Audience == "" {
return nil, fmt.Errorf("audience is required")
}
if req.Resource == "" {
return nil, fmt.Errorf("resource is required")
}
if req.SubjectToken == "" {
return nil, fmt.Errorf("subject_token is required")
}
if req.SubjectTokenType == "" {
return nil, fmt.Errorf("subject_token_type is required")
}
// Validate URL schemes to prevent XSS attacks (see #526).
if err := checkURLScheme(tokenEndpoint); err != nil {
return nil, fmt.Errorf("invalid token endpoint: %w", err)
}
if err := checkURLScheme(req.Audience); err != nil {
return nil, fmt.Errorf("invalid audience: %w", err)
}
if err := checkURLScheme(req.Resource); err != nil {
return nil, fmt.Errorf("invalid resource: %w", err)
}
// Per RFC 6749 Section 3.2, parameters sent without a value (like the empty
// "code" parameter) MUST be treated as if they were omitted from the request.
// The oauth2 library's Exchange method sends an empty code, but compliant
// servers should ignore it.
cfg := &oauth2.Config{
ClientID: clientCreds.ClientID,
Endpoint: oauth2.Endpoint{
TokenURL: tokenEndpoint,
AuthStyle: oauth2.AuthStyleInParams,
},
}
// Set ClientSecret if ClientSecretAuth is configured.
if clientCreds.ClientSecretAuth != nil {
cfg.ClientSecret = clientCreds.ClientSecretAuth.ClientSecret
}
// Use custom HTTP client if provided.
if httpClient == nil {
httpClient = http.DefaultClient
}
ctxWithClient := context.WithValue(ctx, oauth2.HTTPClient, httpClient)
// Build token exchange parameters per RFC 8693.
opts := []oauth2.AuthCodeOption{
oauth2.SetAuthURLParam("grant_type", GrantTypeTokenExchange),
oauth2.SetAuthURLParam("requested_token_type", req.RequestedTokenType),
oauth2.SetAuthURLParam("audience", req.Audience),
oauth2.SetAuthURLParam("resource", req.Resource),
oauth2.SetAuthURLParam("subject_token", req.SubjectToken),
oauth2.SetAuthURLParam("subject_token_type", req.SubjectTokenType),
}
if len(req.Scope) > 0 {
opts = append(opts, oauth2.SetAuthURLParam("scope", strings.Join(req.Scope, " ")))
}
// Exchange with token exchange grant type.
// SetAuthURLParam overrides the default grant_type and adds all required parameters.
token, err := cfg.Exchange(
ctxWithClient,
"", // empty code - per RFC 6749 Section 3.2, empty params should be ignored
opts...,
)
if err != nil {
return nil, fmt.Errorf("token exchange request failed: %w", err)
}
// Validate that issued_token_type is present in the response.
// The oauth2 library stores additional response fields in Extra.
issuedTokenType, _ := token.Extra("issued_token_type").(string)
if issuedTokenType == "" {
return nil, fmt.Errorf("response missing required field: issued_token_type")
}
return token, nil
}

View file

@ -0,0 +1,239 @@
// 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)
}