Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 2 additions & 34 deletions auth/authorization_code.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"crypto/rand"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"slices"
Expand Down Expand Up @@ -198,6 +199,7 @@ func isNonRootHTTPSURL(u string) bool {
// On success, [AuthorizationCodeHandler.TokenSource] will return a token source with the fetched token.
func (h *AuthorizationCodeHandler) Authorize(ctx context.Context, req *http.Request, resp *http.Response) error {
defer resp.Body.Close()
defer io.Copy(io.Discard, resp.Body)

wwwChallenges, err := oauthex.ParseWWWAuthenticate(resp.Header[http.CanonicalHeaderKey("WWW-Authenticate")])
if err != nil {
Expand Down Expand Up @@ -395,40 +397,6 @@ func (h *AuthorizationCodeHandler) getAuthServerMetadata(ctx context.Context, pr
return asm, nil
}

// authorizationServerMetadataURLs returns a list of URLs to try when looking for
// authorization server metadata as mandated by the MCP specification:
// https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#authorization-server-metadata-discovery.
func authorizationServerMetadataURLs(issuerURL string) []string {
var urls []string

baseURL, err := url.Parse(issuerURL)
if err != nil {
return nil
}

if baseURL.Path == "" {
// "OAuth 2.0 Authorization Server Metadata".
baseURL.Path = "/.well-known/oauth-authorization-server"
urls = append(urls, baseURL.String())
// "OpenID Connect Discovery 1.0".
baseURL.Path = "/.well-known/openid-configuration"
urls = append(urls, baseURL.String())
return urls
}

originalPath := baseURL.Path
// "OAuth 2.0 Authorization Server Metadata with path insertion".
baseURL.Path = "/.well-known/oauth-authorization-server/" + strings.TrimLeft(originalPath, "/")
urls = append(urls, baseURL.String())
// "OpenID Connect Discovery 1.0 with path insertion".
baseURL.Path = "/.well-known/openid-configuration/" + strings.TrimLeft(originalPath, "/")
urls = append(urls, baseURL.String())
// "OpenID Connect Discovery 1.0 with path appending".
baseURL.Path = "/" + strings.Trim(originalPath, "/") + "/.well-known/openid-configuration"
urls = append(urls, baseURL.String())
return urls
}

type registrationType int

const (
Expand Down
7 changes: 7 additions & 0 deletions auth/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,10 @@
// The function is responsible for closing the response body.
Authorize(context.Context, *http.Request, *http.Response) error
}

// OAuthHandlerBase is an embeddable type that satisfies the private method
// requirement of [OAuthHandler]. Extension packages should embed this type
// in their handler structs to implement OAuthHandler.
type OAuthHandlerBase struct{}

func (OAuthHandlerBase) isOAuthHandler() {}

Check failure on line 49 in auth/client.go

View workflow job for this annotation

GitHub Actions / lint

func OAuthHandlerBase.isOAuthHandler is unused (U1000)
243 changes: 243 additions & 0 deletions auth/extauth/enterprise_handler.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
// 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"

// IDTokenResult contains the ID token obtained from OIDC login.
type IDTokenResult struct {
// Token is the OpenID Connect ID Token (JWT).
Token string
}

// 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.
type IDTokenFetcher func(ctx context.Context) (*IDTokenResult, 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

// IdPClientID is the MCP Client's ID registered at the IdP.
// OPTIONAL. Required if the IdP requires client authentication for token exchange.
IdPClientID string

// IdPClientSecret is the MCP Client's secret registered at the IdP.
// OPTIONAL. Required if the IdP requires client authentication for token exchange.
IdPClientSecret string
Comment on lines +47 to +53
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should these be represented as newly introduced 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

// MCPClientID is the MCP Client's ID registered at the MCP Server.
// OPTIONAL. Required if the MCP Server requires client authentication.
MCPClientID string

// MCPClientSecret is the MCP Client's secret registered at the MCP Server.
// OPTIONAL. Required if the MCP Server requires client authentication.
MCPClientSecret string
Comment on lines +67 to +73
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should these be represented as newly introduced 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 {
auth.OAuthHandlerBase
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.MCPAuthServerURL == "" {
return nil, errors.New("MCPAuthServerURL is required")
}
if config.MCPResourceURI == "" {
return nil, errors.New("MCPResourceURI is required")
}
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)
idTokenResult, err := h.config.IDTokenFetcher(ctx)
if err != nil {
return fmt.Errorf("failed to obtain ID token: %w", err)
}

// 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)
}

// 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: idTokenResult.Token,
SubjectTokenType: oauthex.TokenTypeIDToken,
}

tokenExchangeResp, err := oauthex.ExchangeToken(
ctx,
idpMeta.TokenEndpoint,
tokenExchangeReq,
&oauthex.ClientCredentials{
ClientID: h.config.IdPClientID,
ClientSecret: h.config.IdPClientSecret,
},
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)
}

// Step 5: JWT Bearer Grant (ID-JAG → Access Token)
accessToken, err := exchangeJWTBearer(
ctx,
mcpMeta.TokenEndpoint,
tokenExchangeResp.AccessToken,
&oauthex.ClientCredentials{
ClientID: h.config.MCPClientID,
ClientSecret: h.config.MCPClientSecret,
},
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,
ClientSecret: clientCreds.ClientSecret,
Endpoint: oauth2.Endpoint{
TokenURL: tokenEndpoint,
AuthStyle: oauth2.AuthStyleInParams,
},
}

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
}
Loading
Loading