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:
parent
05277f6e3c
commit
2d43865b97
1 changed files with 2595 additions and 226 deletions
242
auth/extauth/oidc_login.go
Normal file
242
auth/extauth/oidc_login.go
Normal 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
|
||||
}
|
||||
Loading…
Reference in a new issue