`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>
256 lines
8.5 KiB
Go
256 lines
8.5 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.
|
|
|
|
// Package extauth provides OAuth handler implementations for MCP authorization extensions.
|
|
// This package implements Enterprise Managed Authorization as defined in SEP-990.
|
|
|
|
//go:build mcp_go_client_oauth
|
|
|
|
package extauth
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
|
|
"github.com/modelcontextprotocol/go-sdk/auth"
|
|
"github.com/modelcontextprotocol/go-sdk/oauthex"
|
|
"golang.org/x/oauth2"
|
|
)
|
|
|
|
// grantTypeJWTBearer is the grant type for RFC 7523 JWT Bearer authorization grant.
|
|
const grantTypeJWTBearer = "urn:ietf:params:oauth:grant-type:jwt-bearer"
|
|
|
|
// IDTokenFetcher is called to obtain an ID Token from the enterprise IdP.
|
|
// This is typically done via OIDC login flow where the user authenticates
|
|
// with their enterprise identity provider.
|
|
//
|
|
// Returns an oauth2.Token where Extra("id_token") contains the OpenID Connect ID Token (JWT).
|
|
type IDTokenFetcher func(ctx context.Context) (*oauth2.Token, error)
|
|
|
|
// EnterpriseHandlerConfig is the configuration for [EnterpriseHandler].
|
|
type EnterpriseHandlerConfig struct {
|
|
// IdP configuration (where the user authenticates)
|
|
|
|
// IdPIssuerURL is the enterprise IdP's issuer URL (e.g., "https://acme.okta.com").
|
|
// Used for OIDC discovery to find the token endpoint.
|
|
// REQUIRED.
|
|
IdPIssuerURL string
|
|
|
|
// IdPCredentials contains the MCP Client's credentials registered at the IdP.
|
|
// REQUIRED. These credentials are used for token exchange at the IdP.
|
|
// The ClientID is always required. ClientSecretAuth is optional and only needed
|
|
// if the IdP requires client authentication (confidential clients).
|
|
IdPCredentials *oauthex.ClientCredentials
|
|
|
|
// MCP Server configuration (the resource being accessed)
|
|
|
|
// MCPAuthServerURL is the MCP Server's authorization server issuer URL.
|
|
// Used as the audience for token exchange and for metadata discovery.
|
|
// REQUIRED.
|
|
MCPAuthServerURL string
|
|
|
|
// MCPResourceURI is the MCP Server's resource identifier (RFC 9728).
|
|
// Used as the resource parameter in token exchange.
|
|
// REQUIRED.
|
|
MCPResourceURI string
|
|
|
|
// MCPCredentials contains the MCP Client's credentials registered at the MCP Server.
|
|
// REQUIRED. These credentials are used for JWT Bearer grant at the MCP Server.
|
|
// The ClientID is always required. ClientSecretAuth is optional and only needed
|
|
// if the MCP Server requires client authentication.
|
|
MCPCredentials *oauthex.ClientCredentials
|
|
|
|
// MCPScopes is the list of scopes to request at the MCP Server.
|
|
// OPTIONAL.
|
|
MCPScopes []string
|
|
|
|
// IDTokenFetcher is called to obtain an ID Token when authorization is needed.
|
|
// The implementation should handle the OIDC login flow (e.g., browser redirect,
|
|
// callback handling) and return the ID token.
|
|
// REQUIRED.
|
|
IDTokenFetcher IDTokenFetcher
|
|
|
|
// HTTPClient is an optional HTTP client for customization.
|
|
// If nil, http.DefaultClient is used.
|
|
// OPTIONAL.
|
|
HTTPClient *http.Client
|
|
}
|
|
|
|
// EnterpriseHandler is an implementation of [auth.OAuthHandler] that uses
|
|
// Enterprise Managed Authorization (SEP-990) to obtain access tokens.
|
|
//
|
|
// The flow consists of:
|
|
// 1. OIDC Login: User authenticates with enterprise IdP → ID Token
|
|
// 2. Token Exchange (RFC 8693): ID Token → ID-JAG at IdP
|
|
// 3. JWT Bearer Grant (RFC 7523): ID-JAG → Access Token at MCP Server
|
|
type EnterpriseHandler struct {
|
|
config *EnterpriseHandlerConfig
|
|
|
|
// tokenSource is the token source obtained after authorization.
|
|
tokenSource oauth2.TokenSource
|
|
}
|
|
|
|
// Compile-time check that EnterpriseHandler implements auth.OAuthHandler.
|
|
var _ auth.OAuthHandler = (*EnterpriseHandler)(nil)
|
|
|
|
// NewEnterpriseHandler creates a new EnterpriseHandler.
|
|
// It performs validation of the configuration and returns an error if invalid.
|
|
func NewEnterpriseHandler(config *EnterpriseHandlerConfig) (*EnterpriseHandler, error) {
|
|
if config == nil {
|
|
return nil, errors.New("config must be provided")
|
|
}
|
|
if config.IdPIssuerURL == "" {
|
|
return nil, errors.New("IdPIssuerURL is required")
|
|
}
|
|
if config.IdPCredentials == nil {
|
|
return nil, errors.New("IdPCredentials is required")
|
|
}
|
|
if err := config.IdPCredentials.Validate(); err != nil {
|
|
return nil, fmt.Errorf("invalid IdPCredentials: %w", err)
|
|
}
|
|
if config.MCPAuthServerURL == "" {
|
|
return nil, errors.New("MCPAuthServerURL is required")
|
|
}
|
|
if config.MCPResourceURI == "" {
|
|
return nil, errors.New("MCPResourceURI is required")
|
|
}
|
|
if config.MCPCredentials == nil {
|
|
return nil, errors.New("MCPCredentials is required")
|
|
}
|
|
if err := config.MCPCredentials.Validate(); err != nil {
|
|
return nil, fmt.Errorf("invalid MCPCredentials: %w", err)
|
|
}
|
|
if config.IDTokenFetcher == nil {
|
|
return nil, errors.New("IDTokenFetcher is required")
|
|
}
|
|
return &EnterpriseHandler{config: config}, nil
|
|
}
|
|
|
|
// TokenSource returns the token source for outgoing requests.
|
|
// Returns nil if authorization has not been performed yet.
|
|
func (h *EnterpriseHandler) TokenSource(ctx context.Context) (oauth2.TokenSource, error) {
|
|
return h.tokenSource, nil
|
|
}
|
|
|
|
// Authorize performs the Enterprise Managed Authorization flow.
|
|
// It is called when a request fails with 401 or 403.
|
|
func (h *EnterpriseHandler) Authorize(ctx context.Context, req *http.Request, resp *http.Response) error {
|
|
defer resp.Body.Close()
|
|
defer io.Copy(io.Discard, resp.Body)
|
|
|
|
httpClient := h.config.HTTPClient
|
|
if httpClient == nil {
|
|
httpClient = http.DefaultClient
|
|
}
|
|
|
|
// Step 1: Get ID Token via the configured fetcher (e.g., OIDC login)
|
|
oidcToken, err := h.config.IDTokenFetcher(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to obtain ID token: %w", err)
|
|
}
|
|
|
|
// Extract ID token from the oauth2.Token
|
|
idToken, ok := oidcToken.Extra("id_token").(string)
|
|
if !ok || idToken == "" {
|
|
return fmt.Errorf("id_token not found in OIDC token response")
|
|
}
|
|
|
|
// Step 2: Discover IdP token endpoint via OIDC discovery
|
|
idpMeta, err := auth.GetAuthServerMetadata(ctx, h.config.IdPIssuerURL, httpClient)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to discover IdP metadata: %w", err)
|
|
}
|
|
if idpMeta == nil {
|
|
return fmt.Errorf("no authorization server metadata found for IdP: %s", h.config.IdPIssuerURL)
|
|
}
|
|
|
|
// Step 3: Token Exchange (ID Token → ID-JAG)
|
|
tokenExchangeReq := &oauthex.TokenExchangeRequest{
|
|
RequestedTokenType: oauthex.TokenTypeIDJAG,
|
|
Audience: h.config.MCPAuthServerURL,
|
|
Resource: h.config.MCPResourceURI,
|
|
Scope: h.config.MCPScopes,
|
|
SubjectToken: idToken,
|
|
SubjectTokenType: oauthex.TokenTypeIDToken,
|
|
}
|
|
|
|
idJAGToken, err := oauthex.ExchangeToken(
|
|
ctx,
|
|
idpMeta.TokenEndpoint,
|
|
tokenExchangeReq,
|
|
h.config.IdPCredentials,
|
|
httpClient,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("token exchange failed: %w", err)
|
|
}
|
|
|
|
// Step 4: Discover MCP Server token endpoint
|
|
mcpMeta, err := auth.GetAuthServerMetadata(ctx, h.config.MCPAuthServerURL, httpClient)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to discover MCP auth server metadata: %w", err)
|
|
}
|
|
if mcpMeta == nil {
|
|
return fmt.Errorf("no authorization server metadata found for MCP server: %s", h.config.MCPAuthServerURL)
|
|
}
|
|
|
|
// Step 5: JWT Bearer Grant (ID-JAG → Access Token)
|
|
// The ID-JAG is in the AccessToken field of the token (despite the name)
|
|
accessToken, err := exchangeJWTBearer(
|
|
ctx,
|
|
mcpMeta.TokenEndpoint,
|
|
idJAGToken.AccessToken,
|
|
h.config.MCPCredentials,
|
|
httpClient,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("JWT bearer grant failed: %w", err)
|
|
}
|
|
|
|
// Store the token source for subsequent requests
|
|
h.tokenSource = oauth2.StaticTokenSource(accessToken)
|
|
return nil
|
|
}
|
|
|
|
// exchangeJWTBearer exchanges an Identity Assertion JWT Authorization Grant (ID-JAG)
|
|
// for an access token using JWT Bearer Grant per RFC 7523.
|
|
func exchangeJWTBearer(
|
|
ctx context.Context,
|
|
tokenEndpoint string,
|
|
assertion string,
|
|
clientCreds *oauthex.ClientCredentials,
|
|
httpClient *http.Client,
|
|
) (*oauth2.Token, error) {
|
|
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
|
|
}
|
|
|
|
if httpClient == nil {
|
|
httpClient = http.DefaultClient
|
|
}
|
|
ctxWithClient := context.WithValue(ctx, oauth2.HTTPClient, httpClient)
|
|
|
|
token, err := cfg.Exchange(
|
|
ctxWithClient,
|
|
"",
|
|
oauth2.SetAuthURLParam("grant_type", grantTypeJWTBearer),
|
|
oauth2.SetAuthURLParam("assertion", assertion),
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("JWT bearer grant request failed: %w", err)
|
|
}
|
|
|
|
return token, nil
|
|
}
|