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

242
auth/extauth/oidc_login.go Normal file
View file

@ -0,0 +1,242 @@
// 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 OIDC Authorization Code flow for obtaining ID tokens
// as part of Enterprise Managed Authorization (SEP-990).
// See https://openid.net/specs/openid-connect-core-1_0.html
//go:build mcp_go_client_oauth
package extauth
import (
"context"
"crypto/rand"
"fmt"
"net/http"
"net/url"
"slices"
"strings"
"github.com/modelcontextprotocol/go-sdk/auth"
"github.com/modelcontextprotocol/go-sdk/oauthex"
"golang.org/x/oauth2"
)
// OIDCLoginConfig configures the OIDC Authorization Code flow for obtaining
// an ID Token. This is used with [PerformOIDCLogin] to authenticate users
// with an enterprise IdP before calling the Enterprise Managed Authorization flow.
type OIDCLoginConfig struct {
// IssuerURL is the IdP's issuer URL (e.g., "https://acme.okta.com").
// REQUIRED.
IssuerURL string
// Credentials contains the MCP Client's credentials registered at the IdP.
// The ClientID field is REQUIRED. The ClientSecret field is OPTIONAL
// (only required if the client is confidential, not a public client).
// REQUIRED (struct itself), but ClientSecret field can be empty.
Credentials *oauthex.ClientCredentials
// RedirectURL is the OAuth2 redirect URI registered with the IdP.
// This must match exactly what was registered with the IdP.
// REQUIRED.
RedirectURL string
// Scopes are the OAuth2/OIDC scopes to request.
// "openid" is REQUIRED for OIDC. Common values: ["openid", "profile", "email"]
// REQUIRED.
Scopes []string
// LoginHint is a hint to the IdP about the user's identity.
// Some IdPs may require this (e.g., as an email address for routing to SSO providers).
// Example: "user@example.com"
// OPTIONAL.
LoginHint string
// HTTPClient is the HTTP client for making requests.
// If nil, http.DefaultClient is used.
// OPTIONAL.
HTTPClient *http.Client
}
// PerformOIDCLogin performs the complete OIDC Authorization Code flow with PKCE
// in a single function call. This is the recommended approach for obtaining an
// ID Token for use with [EnterpriseHandler].
//
// Returns an oauth2.Token where:
// - Extra("id_token") contains the OpenID Connect ID Token (JWT)
// - AccessToken contains the OAuth2 access token (if issued by IdP)
// - RefreshToken contains the OAuth2 refresh token (if issued by IdP)
// - TokenType is the token type (typically "Bearer")
// - Expiry is when the token expires
func PerformOIDCLogin(
ctx context.Context,
config *OIDCLoginConfig,
authCodeFetcher auth.AuthorizationCodeFetcher,
) (*oauth2.Token, error) {
if authCodeFetcher == nil {
return nil, fmt.Errorf("authCodeFetcher is required")
}
authReq, oauth2Config, err := initiateOIDCLogin(ctx, config)
if err != nil {
return nil, fmt.Errorf("failed to initiate OIDC login: %w", err)
}
authResult, err := authCodeFetcher(ctx, &auth.AuthorizationArgs{URL: authReq.authURL})
if err != nil {
return nil, fmt.Errorf("failed to fetch authorization code: %w", err)
}
if authResult.State != authReq.state {
return nil, fmt.Errorf("state mismatch: expected %q, got %q", authReq.state, authResult.State)
}
tokens, err := completeOIDCLogin(ctx, config, oauth2Config, authResult.Code, authReq.codeVerifier)
if err != nil {
return nil, fmt.Errorf("failed to complete OIDC login: %w", err)
}
return tokens, nil
}
// oidcAuthorizationRequest holds internal state for OIDC authorization.
type oidcAuthorizationRequest struct {
authURL string
state string
codeVerifier string
}
// initiateOIDCLogin initiates an OIDC Authorization Code flow with PKCE.
func initiateOIDCLogin(
ctx context.Context,
config *OIDCLoginConfig,
) (*oidcAuthorizationRequest, *oauth2.Config, error) {
if config == nil {
return nil, nil, fmt.Errorf("config is required")
}
if config.IssuerURL == "" {
return nil, nil, fmt.Errorf("IssuerURL is required")
}
if config.Credentials == nil || config.Credentials.ClientID == "" {
return nil, nil, fmt.Errorf("Credentials.ClientID is required")
}
if config.RedirectURL == "" {
return nil, nil, fmt.Errorf("RedirectURL is required")
}
if len(config.Scopes) == 0 {
return nil, nil, fmt.Errorf("Scopes is required (must include 'openid')")
}
if !slices.Contains(config.Scopes, "openid") {
return nil, nil, fmt.Errorf("Scopes must include 'openid' for OIDC")
}
if err := checkURLScheme(config.IssuerURL); err != nil {
return nil, nil, fmt.Errorf("invalid IssuerURL: %w", err)
}
if err := checkURLScheme(config.RedirectURL); err != nil {
return nil, nil, fmt.Errorf("invalid RedirectURL: %w", err)
}
httpClient := config.HTTPClient
if httpClient == nil {
httpClient = http.DefaultClient
}
meta, err := auth.GetAuthServerMetadata(ctx, config.IssuerURL, httpClient)
if err != nil {
return nil, nil, fmt.Errorf("failed to discover OIDC metadata: %w", err)
}
if meta == nil {
return nil, nil, fmt.Errorf("no authorization server metadata found for OIDC issuer: %s", config.IssuerURL)
}
if meta.AuthorizationEndpoint == "" {
return nil, nil, fmt.Errorf("authorization_endpoint not found in OIDC metadata")
}
codeVerifier := oauth2.GenerateVerifier()
state := rand.Text()
oauth2Config := &oauth2.Config{
ClientID: config.Credentials.ClientID,
RedirectURL: config.RedirectURL,
Scopes: config.Scopes,
Endpoint: oauth2.Endpoint{
AuthURL: meta.AuthorizationEndpoint,
TokenURL: meta.TokenEndpoint,
},
}
// Set ClientSecret if ClientSecretAuth is configured
if config.Credentials.ClientSecretAuth != nil {
oauth2Config.ClientSecret = config.Credentials.ClientSecretAuth.ClientSecret
}
authURLOpts := []oauth2.AuthCodeOption{
oauth2.S256ChallengeOption(codeVerifier),
}
if config.LoginHint != "" {
authURLOpts = append(authURLOpts, oauth2.SetAuthURLParam("login_hint", config.LoginHint))
}
authURL := oauth2Config.AuthCodeURL(state, authURLOpts...)
return &oidcAuthorizationRequest{
authURL: authURL,
state: state,
codeVerifier: codeVerifier,
}, oauth2Config, nil
}
// completeOIDCLogin completes the OIDC Authorization Code flow by exchanging
// the authorization code for tokens.
func completeOIDCLogin(
ctx context.Context,
config *OIDCLoginConfig,
oauth2Config *oauth2.Config,
authCode string,
codeVerifier string,
) (*oauth2.Token, error) {
if authCode == "" {
return nil, fmt.Errorf("authCode is required")
}
if codeVerifier == "" {
return nil, fmt.Errorf("codeVerifier is required")
}
httpClient := config.HTTPClient
if httpClient == nil {
httpClient = http.DefaultClient
}
ctxWithClient := context.WithValue(ctx, oauth2.HTTPClient, httpClient)
oauth2Token, err := oauth2Config.Exchange(
ctxWithClient,
authCode,
oauth2.VerifierOption(codeVerifier),
)
if err != nil {
return nil, fmt.Errorf("token exchange failed: %w", err)
}
// Validate that id_token is present in the response
idToken, ok := oauth2Token.Extra("id_token").(string)
if !ok || idToken == "" {
return nil, fmt.Errorf("id_token not found in token response")
}
return oauth2Token, nil
}
// 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 oauthex/oauth2.go; keep these in sync.
func checkURLScheme(u string) error {
if u == "" {
return nil
}
uu, err := url.Parse(u)
if err != nil {
return err
}
scheme := strings.ToLower(uu.Scheme)
if scheme == "javascript" || scheme == "data" || scheme == "vbscript" {
return fmt.Errorf("URL has disallowed scheme %q", scheme)
}
return nil
}