go-sdk/auth/extauth/oidc_login.go
Maciej Kisiel 638d404e0d
all: stabilize client OAuth support (#861)
Also fix lint errors that were hidden because of the build tag.
2026-03-31 12:36:31 +02:00

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
}