240 lines
7.5 KiB
Go
240 lines
7.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.
|
|
|
|
// 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
|
|
|
|
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("at least one scope is required (must include 'openid')")
|
|
}
|
|
|
|
if !slices.Contains(config.Scopes, "openid") {
|
|
return nil, nil, fmt.Errorf("the 'openid' scope is required 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
|
|
}
|