254 lines
8.5 KiB
Go
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
|
|
}
|