184 lines
6.6 KiB
Go
184 lines
6.6 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 Token Exchange (RFC 8693) for Enterprise Managed Authorization.
|
|
// See https://datatracker.ietf.org/doc/html/rfc8693
|
|
|
|
package oauthex
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"golang.org/x/oauth2"
|
|
)
|
|
|
|
// Token type identifiers defined by RFC 8693 and SEP-990.
|
|
const (
|
|
// TokenTypeIDToken is the URN for OpenID Connect ID Tokens.
|
|
TokenTypeIDToken = "urn:ietf:params:oauth:token-type:id_token"
|
|
|
|
// TokenTypeSAML2 is the URN for SAML 2.0 assertions.
|
|
TokenTypeSAML2 = "urn:ietf:params:oauth:token-type:saml2"
|
|
|
|
// TokenTypeIDJAG is the URN for Identity Assertion JWT Authorization Grants.
|
|
// This is the token type returned by IdP during token exchange for SEP-990.
|
|
TokenTypeIDJAG = "urn:ietf:params:oauth:token-type:id-jag"
|
|
|
|
// GrantTypeTokenExchange is the grant type for RFC 8693 token exchange.
|
|
GrantTypeTokenExchange = "urn:ietf:params:oauth:grant-type:token-exchange"
|
|
)
|
|
|
|
// TokenExchangeRequest represents a Token Exchange request per RFC 8693.
|
|
// This is used for Enterprise Managed Authorization (SEP-990) where an MCP Client
|
|
// exchanges an ID Token from an enterprise IdP for an ID-JAG that can be used
|
|
// to obtain an access token from an MCP Server's authorization server.
|
|
type TokenExchangeRequest struct {
|
|
// RequestedTokenType indicates the type of security token being requested.
|
|
// For SEP-990, this MUST be TokenTypeIDJAG.
|
|
RequestedTokenType string
|
|
|
|
// Audience is the logical name of the target service where the client
|
|
// intends to use the requested token. For SEP-990, this MUST be the
|
|
// Issuer URL of the MCP Server's authorization server.
|
|
Audience string
|
|
|
|
// Resource is the physical location or identifier of the target resource.
|
|
// For SEP-990, this MUST be the RFC9728 Resource Identifier of the MCP Server.
|
|
Resource string
|
|
|
|
// Scope is a list of space-separated scopes for the requested token.
|
|
// This is OPTIONAL per RFC 8693 but commonly used in SEP-990.
|
|
Scope []string
|
|
|
|
// SubjectToken is the security token that represents the identity of the
|
|
// party on behalf of whom the request is being made. For SEP-990, this is
|
|
// typically an OpenID Connect ID Token.
|
|
SubjectToken string
|
|
|
|
// SubjectTokenType is the type of the security token in SubjectToken.
|
|
// For SEP-990 with OIDC, this MUST be TokenTypeIDToken.
|
|
SubjectTokenType string
|
|
}
|
|
|
|
// ExchangeToken performs a token exchange request per RFC 8693 for Enterprise
|
|
// Managed Authorization (SEP-990). It exchanges an identity assertion (typically
|
|
// an ID Token) for an Identity Assertion JWT Authorization Grant (ID-JAG) that
|
|
// can be used to obtain an access token from an MCP Server.
|
|
//
|
|
// The tokenEndpoint parameter should be the IdP's token endpoint (typically
|
|
// obtained from the IdP's authorization server metadata).
|
|
//
|
|
// Returns an oauth2.Token where:
|
|
// - Extra("issued_token_type") contains the type of the issued token (e.g., TokenTypeIDJAG)
|
|
// - AccessToken contains the ID-JAG JWT (despite the name, this is not an OAuth access token)
|
|
// - TokenType is typically "N_A" for SEP-990
|
|
// - Extra("scope") may contain the scope if different from the request
|
|
// - Expiry is when the token expires
|
|
func ExchangeToken(
|
|
ctx context.Context,
|
|
tokenEndpoint string,
|
|
req *TokenExchangeRequest,
|
|
clientCreds *ClientCredentials,
|
|
httpClient *http.Client,
|
|
) (*oauth2.Token, error) {
|
|
if tokenEndpoint == "" {
|
|
return nil, fmt.Errorf("token endpoint is required")
|
|
}
|
|
if req == nil {
|
|
return nil, fmt.Errorf("token exchange request is required")
|
|
}
|
|
if clientCreds == nil {
|
|
return nil, fmt.Errorf("client credentials are required")
|
|
}
|
|
if err := clientCreds.Validate(); err != nil {
|
|
return nil, fmt.Errorf("invalid client credentials: %w", err)
|
|
}
|
|
|
|
// Validate required fields per SEP-990 Section 4.
|
|
if req.RequestedTokenType == "" {
|
|
return nil, fmt.Errorf("requested_token_type is required")
|
|
}
|
|
if req.Audience == "" {
|
|
return nil, fmt.Errorf("audience is required")
|
|
}
|
|
if req.Resource == "" {
|
|
return nil, fmt.Errorf("resource is required")
|
|
}
|
|
if req.SubjectToken == "" {
|
|
return nil, fmt.Errorf("subject_token is required")
|
|
}
|
|
if req.SubjectTokenType == "" {
|
|
return nil, fmt.Errorf("subject_token_type is required")
|
|
}
|
|
|
|
// Validate URL schemes to prevent XSS attacks (see #526).
|
|
if err := checkURLScheme(tokenEndpoint); err != nil {
|
|
return nil, fmt.Errorf("invalid token endpoint: %w", err)
|
|
}
|
|
if err := checkURLScheme(req.Audience); err != nil {
|
|
return nil, fmt.Errorf("invalid audience: %w", err)
|
|
}
|
|
if err := checkURLScheme(req.Resource); err != nil {
|
|
return nil, fmt.Errorf("invalid resource: %w", err)
|
|
}
|
|
|
|
// Per RFC 6749 Section 3.2, parameters sent without a value (like the empty
|
|
// "code" parameter) MUST be treated as if they were omitted from the request.
|
|
// The oauth2 library's Exchange method sends an empty code, but compliant
|
|
// servers should ignore it.
|
|
cfg := &oauth2.Config{
|
|
ClientID: clientCreds.ClientID,
|
|
Endpoint: oauth2.Endpoint{
|
|
TokenURL: tokenEndpoint,
|
|
AuthStyle: oauth2.AuthStyleInParams,
|
|
},
|
|
}
|
|
// Set ClientSecret if ClientSecretAuth is configured.
|
|
if clientCreds.ClientSecretAuth != nil {
|
|
cfg.ClientSecret = clientCreds.ClientSecretAuth.ClientSecret
|
|
}
|
|
|
|
// Use custom HTTP client if provided.
|
|
if httpClient == nil {
|
|
httpClient = http.DefaultClient
|
|
}
|
|
ctxWithClient := context.WithValue(ctx, oauth2.HTTPClient, httpClient)
|
|
|
|
// Build token exchange parameters per RFC 8693.
|
|
opts := []oauth2.AuthCodeOption{
|
|
oauth2.SetAuthURLParam("grant_type", GrantTypeTokenExchange),
|
|
oauth2.SetAuthURLParam("requested_token_type", req.RequestedTokenType),
|
|
oauth2.SetAuthURLParam("audience", req.Audience),
|
|
oauth2.SetAuthURLParam("resource", req.Resource),
|
|
oauth2.SetAuthURLParam("subject_token", req.SubjectToken),
|
|
oauth2.SetAuthURLParam("subject_token_type", req.SubjectTokenType),
|
|
}
|
|
if len(req.Scope) > 0 {
|
|
opts = append(opts, oauth2.SetAuthURLParam("scope", strings.Join(req.Scope, " ")))
|
|
}
|
|
|
|
// Exchange with token exchange grant type.
|
|
// SetAuthURLParam overrides the default grant_type and adds all required parameters.
|
|
token, err := cfg.Exchange(
|
|
ctxWithClient,
|
|
"", // empty code - per RFC 6749 Section 3.2, empty params should be ignored
|
|
opts...,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("token exchange request failed: %w", err)
|
|
}
|
|
|
|
// Validate that issued_token_type is present in the response.
|
|
// The oauth2 library stores additional response fields in Extra.
|
|
issuedTokenType, _ := token.Extra("issued_token_type").(string)
|
|
if issuedTokenType == "" {
|
|
return nil, fmt.Errorf("response missing required field: issued_token_type")
|
|
}
|
|
|
|
return token, nil
|
|
}
|