go-sdk/auth/extauth/enterprise_handler.go
Maciej Kisiel 638d404e0d
all: stabilize client OAuth support (#861)
Also fix lint errors that were hidden because of the build tag.
2026-03-31 12:36:31 +02:00

254 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.
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
}