mirror of
https://github.com/hanzoai/traces.git
synced 2026-10-30 18:20:41 +00:00
2bd709db63
Bumps [github.com/mark3labs/mcp-go](https://github.com/mark3labs/mcp-go) from 0.37.0 to 0.43.0. - [Release notes](https://github.com/mark3labs/mcp-go/releases) - [Commits](https://github.com/mark3labs/mcp-go/compare/v0.37.0...v0.43.0) --- updated-dependencies: - dependency-name: github.com/mark3labs/mcp-go dependency-version: 0.43.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] <[email protected]> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
713 lines
22 KiB
Go
713 lines
22 KiB
Go
package transport
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// ErrNoToken is returned when no token is available in the token store
|
|
var ErrNoToken = errors.New("no token available")
|
|
|
|
// OAuthConfig holds the OAuth configuration for the client
|
|
type OAuthConfig struct {
|
|
// ClientID is the OAuth client ID
|
|
ClientID string
|
|
// ClientSecret is the OAuth client secret (for confidential clients)
|
|
ClientSecret string
|
|
// RedirectURI is the redirect URI for the OAuth flow
|
|
RedirectURI string
|
|
// Scopes is the list of OAuth scopes to request
|
|
Scopes []string
|
|
// TokenStore is the storage for OAuth tokens
|
|
TokenStore TokenStore
|
|
// AuthServerMetadataURL is the URL to the OAuth server metadata
|
|
// If empty, the client will attempt to discover it from the base URL
|
|
AuthServerMetadataURL string
|
|
// PKCEEnabled enables PKCE for the OAuth flow (recommended for public clients)
|
|
PKCEEnabled bool
|
|
// HTTPClient is an optional HTTP client to use for requests.
|
|
// If nil, a default HTTP client with a 30 second timeout will be used.
|
|
HTTPClient *http.Client
|
|
}
|
|
|
|
// TokenStore is an interface for storing and retrieving OAuth tokens.
|
|
//
|
|
// Implementations must:
|
|
// - Honor context cancellation and deadlines, returning context.Canceled
|
|
// or context.DeadlineExceeded as appropriate
|
|
// - Return ErrNoToken (or a sentinel error that wraps it) when no token
|
|
// is available, rather than conflating this with other operational errors
|
|
// - Properly propagate all other errors (database failures, I/O errors, etc.)
|
|
// - Check ctx.Done() before performing operations and return ctx.Err() if cancelled
|
|
type TokenStore interface {
|
|
// GetToken returns the current token.
|
|
// Returns ErrNoToken if no token is available.
|
|
// Returns context.Canceled or context.DeadlineExceeded if ctx is cancelled.
|
|
// Returns other errors for operational failures (I/O, database, etc.).
|
|
GetToken(ctx context.Context) (*Token, error)
|
|
|
|
// SaveToken saves a token.
|
|
// Returns context.Canceled or context.DeadlineExceeded if ctx is cancelled.
|
|
// Returns other errors for operational failures (I/O, database, etc.).
|
|
SaveToken(ctx context.Context, token *Token) error
|
|
}
|
|
|
|
// Token represents an OAuth token
|
|
type Token struct {
|
|
// AccessToken is the OAuth access token
|
|
AccessToken string `json:"access_token"`
|
|
// TokenType is the type of token (usually "Bearer")
|
|
TokenType string `json:"token_type"`
|
|
// RefreshToken is the OAuth refresh token
|
|
RefreshToken string `json:"refresh_token,omitempty"`
|
|
// ExpiresIn is the number of seconds until the token expires
|
|
ExpiresIn int64 `json:"expires_in,omitempty"`
|
|
// Scope is the scope of the token
|
|
Scope string `json:"scope,omitempty"`
|
|
// ExpiresAt is the time when the token expires
|
|
ExpiresAt time.Time `json:"expires_at,omitempty"`
|
|
}
|
|
|
|
// IsExpired returns true if the token is expired
|
|
func (t *Token) IsExpired() bool {
|
|
if t.ExpiresAt.IsZero() {
|
|
return false
|
|
}
|
|
return time.Now().After(t.ExpiresAt)
|
|
}
|
|
|
|
// MemoryTokenStore is a simple in-memory token store
|
|
type MemoryTokenStore struct {
|
|
token *Token
|
|
mu sync.RWMutex
|
|
}
|
|
|
|
// NewMemoryTokenStore creates a new in-memory token store
|
|
func NewMemoryTokenStore() *MemoryTokenStore {
|
|
return &MemoryTokenStore{}
|
|
}
|
|
|
|
// GetToken returns the current token.
|
|
// Returns ErrNoToken if no token is available.
|
|
// Returns context.Canceled or context.DeadlineExceeded if ctx is cancelled.
|
|
func (s *MemoryTokenStore) GetToken(ctx context.Context) (*Token, error) {
|
|
if err := ctx.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
if s.token == nil {
|
|
return nil, ErrNoToken
|
|
}
|
|
return s.token, nil
|
|
}
|
|
|
|
// SaveToken saves a token.
|
|
// Returns context.Canceled or context.DeadlineExceeded if ctx is cancelled.
|
|
func (s *MemoryTokenStore) SaveToken(ctx context.Context, token *Token) error {
|
|
if err := ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.token = token
|
|
return nil
|
|
}
|
|
|
|
// AuthServerMetadata represents the OAuth 2.0 Authorization Server Metadata
|
|
type AuthServerMetadata struct {
|
|
Issuer string `json:"issuer"`
|
|
AuthorizationEndpoint string `json:"authorization_endpoint"`
|
|
TokenEndpoint string `json:"token_endpoint"`
|
|
RegistrationEndpoint string `json:"registration_endpoint,omitempty"`
|
|
JwksURI string `json:"jwks_uri,omitempty"`
|
|
ScopesSupported []string `json:"scopes_supported,omitempty"`
|
|
ResponseTypesSupported []string `json:"response_types_supported"`
|
|
GrantTypesSupported []string `json:"grant_types_supported,omitempty"`
|
|
TokenEndpointAuthMethodsSupported []string `json:"token_endpoint_auth_methods_supported,omitempty"`
|
|
}
|
|
|
|
// OAuthHandler handles OAuth authentication for HTTP requests
|
|
type OAuthHandler struct {
|
|
config OAuthConfig
|
|
httpClient *http.Client
|
|
serverMetadata *AuthServerMetadata
|
|
metadataFetchErr error
|
|
metadataOnce sync.Once
|
|
baseURL string
|
|
|
|
mu sync.RWMutex // Protects expectedState
|
|
expectedState string // Expected state value for CSRF protection
|
|
}
|
|
|
|
// NewOAuthHandler creates a new OAuth handler
|
|
func NewOAuthHandler(config OAuthConfig) *OAuthHandler {
|
|
if config.TokenStore == nil {
|
|
config.TokenStore = NewMemoryTokenStore()
|
|
}
|
|
if config.HTTPClient == nil {
|
|
config.HTTPClient = &http.Client{Timeout: 30 * time.Second}
|
|
}
|
|
|
|
return &OAuthHandler{
|
|
config: config,
|
|
httpClient: config.HTTPClient,
|
|
}
|
|
}
|
|
|
|
// GetAuthorizationHeader returns the Authorization header value for a request
|
|
func (h *OAuthHandler) GetAuthorizationHeader(ctx context.Context) (string, error) {
|
|
token, err := h.getValidToken(ctx)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
// Some auth implementations are strict about token type
|
|
tokenType := token.TokenType
|
|
if tokenType == "bearer" {
|
|
tokenType = "Bearer"
|
|
}
|
|
|
|
return fmt.Sprintf("%s %s", tokenType, token.AccessToken), nil
|
|
}
|
|
|
|
// getValidToken returns a valid token, refreshing if necessary
|
|
func (h *OAuthHandler) getValidToken(ctx context.Context) (*Token, error) {
|
|
token, err := h.config.TokenStore.GetToken(ctx)
|
|
if err != nil && !errors.Is(err, ErrNoToken) {
|
|
return nil, err
|
|
}
|
|
if err == nil && !token.IsExpired() && token.AccessToken != "" {
|
|
return token, nil
|
|
}
|
|
|
|
// If we have a refresh token, try to use it
|
|
if err == nil && token.RefreshToken != "" {
|
|
newToken, err := h.refreshToken(ctx, token.RefreshToken)
|
|
if err == nil {
|
|
return newToken, nil
|
|
}
|
|
// If refresh fails, continue to authorization flow
|
|
}
|
|
|
|
// We need to get a new token through the authorization flow
|
|
return nil, ErrOAuthAuthorizationRequired
|
|
}
|
|
|
|
// refreshToken refreshes an OAuth token
|
|
func (h *OAuthHandler) refreshToken(ctx context.Context, refreshToken string) (*Token, error) {
|
|
metadata, err := h.getServerMetadata(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get server metadata: %w", err)
|
|
}
|
|
|
|
data := url.Values{}
|
|
data.Set("grant_type", "refresh_token")
|
|
data.Set("refresh_token", refreshToken)
|
|
data.Set("client_id", h.config.ClientID)
|
|
if h.config.ClientSecret != "" {
|
|
data.Set("client_secret", h.config.ClientSecret)
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(
|
|
ctx,
|
|
http.MethodPost,
|
|
metadata.TokenEndpoint,
|
|
strings.NewReader(data.Encode()),
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create refresh token request: %w", err)
|
|
}
|
|
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
req.Header.Set("Accept", "application/json")
|
|
|
|
resp, err := h.httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to send refresh token request: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
body, _ := io.ReadAll(resp.Body)
|
|
return nil, extractOAuthError(body, resp.StatusCode, "refresh token request failed")
|
|
}
|
|
|
|
var tokenResp Token
|
|
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
|
|
return nil, fmt.Errorf("failed to decode token response: %w", err)
|
|
}
|
|
|
|
// Set expiration time
|
|
if tokenResp.ExpiresIn > 0 {
|
|
tokenResp.ExpiresAt = time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second)
|
|
}
|
|
|
|
// If no new refresh token is provided, keep the old one
|
|
if tokenResp.RefreshToken == "" {
|
|
tokenResp.RefreshToken = refreshToken
|
|
}
|
|
|
|
// Save the token
|
|
if err := h.config.TokenStore.SaveToken(ctx, &tokenResp); err != nil {
|
|
return nil, fmt.Errorf("failed to save token: %w", err)
|
|
}
|
|
|
|
return &tokenResp, nil
|
|
}
|
|
|
|
// RefreshToken is a public wrapper for refreshToken
|
|
func (h *OAuthHandler) RefreshToken(ctx context.Context, refreshToken string) (*Token, error) {
|
|
return h.refreshToken(ctx, refreshToken)
|
|
}
|
|
|
|
// GetClientID returns the client ID
|
|
func (h *OAuthHandler) GetClientID() string {
|
|
return h.config.ClientID
|
|
}
|
|
|
|
// extractOAuthError attempts to parse an OAuth error response from the response body
|
|
func extractOAuthError(body []byte, statusCode int, context string) error {
|
|
// Try to parse the error as an OAuth error response
|
|
var oauthErr OAuthError
|
|
if err := json.Unmarshal(body, &oauthErr); err == nil && oauthErr.ErrorCode != "" {
|
|
return fmt.Errorf("%s: %w", context, oauthErr)
|
|
}
|
|
|
|
// If not a valid OAuth error, return the raw response
|
|
return fmt.Errorf("%s with status %d: %s", context, statusCode, body)
|
|
}
|
|
|
|
// GetClientSecret returns the client secret
|
|
func (h *OAuthHandler) GetClientSecret() string {
|
|
return h.config.ClientSecret
|
|
}
|
|
|
|
// SetBaseURL sets the base URL for the API server
|
|
func (h *OAuthHandler) SetBaseURL(baseURL string) {
|
|
h.baseURL = baseURL
|
|
}
|
|
|
|
// GetExpectedState returns the expected state value (for testing purposes)
|
|
func (h *OAuthHandler) GetExpectedState() string {
|
|
h.mu.RLock()
|
|
defer h.mu.RUnlock()
|
|
return h.expectedState
|
|
}
|
|
|
|
// SetExpectedState sets the expected state value.
|
|
//
|
|
// This can be useful if you cannot maintain an OAuthHandler
|
|
// instance throughout the authentication flow; for example, if
|
|
// the initialization and callback steps are handled in different
|
|
// requests.
|
|
//
|
|
// In such cases, this should be called with the state value generated
|
|
// during the initial authentication request (e.g. by GenerateState)
|
|
// and included in the authorization URL.
|
|
func (h *OAuthHandler) SetExpectedState(expectedState string) {
|
|
h.mu.Lock()
|
|
defer h.mu.Unlock()
|
|
h.expectedState = expectedState
|
|
}
|
|
|
|
// OAuthError represents a standard OAuth 2.0 error response
|
|
type OAuthError struct {
|
|
ErrorCode string `json:"error"`
|
|
ErrorDescription string `json:"error_description,omitempty"`
|
|
ErrorURI string `json:"error_uri,omitempty"`
|
|
}
|
|
|
|
// Error implements the error interface
|
|
func (e OAuthError) Error() string {
|
|
if e.ErrorDescription != "" {
|
|
return fmt.Sprintf("OAuth error: %s - %s", e.ErrorCode, e.ErrorDescription)
|
|
}
|
|
return fmt.Sprintf("OAuth error: %s", e.ErrorCode)
|
|
}
|
|
|
|
// OAuthProtectedResource represents the response from /.well-known/oauth-protected-resource
|
|
type OAuthProtectedResource struct {
|
|
AuthorizationServers []string `json:"authorization_servers"`
|
|
Resource string `json:"resource"`
|
|
ResourceName string `json:"resource_name,omitempty"`
|
|
}
|
|
|
|
// getServerMetadata fetches the OAuth server metadata
|
|
func (h *OAuthHandler) getServerMetadata(ctx context.Context) (*AuthServerMetadata, error) {
|
|
h.metadataOnce.Do(func() {
|
|
// If AuthServerMetadataURL is explicitly provided, use it directly
|
|
if h.config.AuthServerMetadataURL != "" {
|
|
h.fetchMetadataFromURL(ctx, h.config.AuthServerMetadataURL)
|
|
return
|
|
}
|
|
|
|
// Try to discover the authorization server via OAuth Protected Resource
|
|
// as per RFC 9728 (https://datatracker.ietf.org/doc/html/rfc9728)
|
|
baseURL, err := h.extractBaseURL()
|
|
if err != nil {
|
|
h.metadataFetchErr = fmt.Errorf("failed to extract base URL: %w", err)
|
|
return
|
|
}
|
|
|
|
// Try to fetch the OAuth Protected Resource metadata
|
|
protectedResourceURL := baseURL + "/.well-known/oauth-protected-resource"
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, protectedResourceURL, nil)
|
|
if err != nil {
|
|
h.metadataFetchErr = fmt.Errorf("failed to create protected resource request: %w", err)
|
|
return
|
|
}
|
|
|
|
req.Header.Set("Accept", "application/json")
|
|
req.Header.Set("MCP-Protocol-Version", "2025-03-26")
|
|
|
|
resp, err := h.httpClient.Do(req)
|
|
if err != nil {
|
|
h.metadataFetchErr = fmt.Errorf("failed to send protected resource request: %w", err)
|
|
return
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
// If we can't get the protected resource metadata, try OAuth Authorization Server discovery
|
|
if resp.StatusCode != http.StatusOK {
|
|
h.fetchMetadataFromURL(ctx, baseURL+"/.well-known/oauth-authorization-server")
|
|
if h.serverMetadata != nil {
|
|
return
|
|
}
|
|
// If that also fails, fall back to default endpoints
|
|
metadata, err := h.getDefaultEndpoints(baseURL)
|
|
if err != nil {
|
|
h.metadataFetchErr = fmt.Errorf("failed to get default endpoints: %w", err)
|
|
return
|
|
}
|
|
h.serverMetadata = metadata
|
|
return
|
|
}
|
|
|
|
// Parse the protected resource metadata
|
|
var protectedResource OAuthProtectedResource
|
|
if err := json.NewDecoder(resp.Body).Decode(&protectedResource); err != nil {
|
|
h.metadataFetchErr = fmt.Errorf("failed to decode protected resource response: %w", err)
|
|
return
|
|
}
|
|
|
|
// If no authorization servers are specified, fall back to default endpoints
|
|
if len(protectedResource.AuthorizationServers) == 0 {
|
|
metadata, err := h.getDefaultEndpoints(baseURL)
|
|
if err != nil {
|
|
h.metadataFetchErr = fmt.Errorf("failed to get default endpoints: %w", err)
|
|
return
|
|
}
|
|
h.serverMetadata = metadata
|
|
return
|
|
}
|
|
|
|
// Use the first authorization server
|
|
authServerURL := protectedResource.AuthorizationServers[0]
|
|
|
|
// Try OpenID Connect discovery first
|
|
h.fetchMetadataFromURL(ctx, authServerURL+"/.well-known/openid-configuration")
|
|
if h.serverMetadata != nil {
|
|
return
|
|
}
|
|
|
|
// If OpenID Connect discovery fails, try OAuth Authorization Server Metadata
|
|
h.fetchMetadataFromURL(ctx, authServerURL+"/.well-known/oauth-authorization-server")
|
|
if h.serverMetadata != nil {
|
|
return
|
|
}
|
|
|
|
// If both discovery methods fail, use default endpoints based on the authorization server URL
|
|
metadata, err := h.getDefaultEndpoints(authServerURL)
|
|
if err != nil {
|
|
h.metadataFetchErr = fmt.Errorf("failed to get default endpoints: %w", err)
|
|
return
|
|
}
|
|
h.serverMetadata = metadata
|
|
})
|
|
|
|
if h.metadataFetchErr != nil {
|
|
return nil, h.metadataFetchErr
|
|
}
|
|
|
|
return h.serverMetadata, nil
|
|
}
|
|
|
|
// fetchMetadataFromURL fetches and parses OAuth server metadata from a URL
|
|
func (h *OAuthHandler) fetchMetadataFromURL(ctx context.Context, metadataURL string) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, metadataURL, nil)
|
|
if err != nil {
|
|
h.metadataFetchErr = fmt.Errorf("failed to create metadata request: %w", err)
|
|
return
|
|
}
|
|
|
|
req.Header.Set("Accept", "application/json")
|
|
req.Header.Set("MCP-Protocol-Version", "2025-03-26")
|
|
|
|
resp, err := h.httpClient.Do(req)
|
|
if err != nil {
|
|
h.metadataFetchErr = fmt.Errorf("failed to send metadata request: %w", err)
|
|
return
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
// If metadata discovery fails, don't set any metadata
|
|
return
|
|
}
|
|
|
|
var metadata AuthServerMetadata
|
|
if err := json.NewDecoder(resp.Body).Decode(&metadata); err != nil {
|
|
h.metadataFetchErr = fmt.Errorf("failed to decode metadata response: %w", err)
|
|
return
|
|
}
|
|
|
|
h.serverMetadata = &metadata
|
|
}
|
|
|
|
// extractBaseURL extracts the base URL from the first request
|
|
func (h *OAuthHandler) extractBaseURL() (string, error) {
|
|
// If we have a base URL from a previous request, use it
|
|
if h.baseURL != "" {
|
|
return h.baseURL, nil
|
|
}
|
|
|
|
// Otherwise, we need to infer it from the redirect URI
|
|
if h.config.RedirectURI == "" {
|
|
return "", fmt.Errorf("no base URL available and no redirect URI provided")
|
|
}
|
|
|
|
// Parse the redirect URI to extract the authority
|
|
parsedURL, err := url.Parse(h.config.RedirectURI)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to parse redirect URI: %w", err)
|
|
}
|
|
|
|
// Use the scheme and host from the redirect URI
|
|
baseURL := fmt.Sprintf("%s://%s", parsedURL.Scheme, parsedURL.Host)
|
|
return baseURL, nil
|
|
}
|
|
|
|
// GetServerMetadata is a public wrapper for getServerMetadata
|
|
func (h *OAuthHandler) GetServerMetadata(ctx context.Context) (*AuthServerMetadata, error) {
|
|
return h.getServerMetadata(ctx)
|
|
}
|
|
|
|
// getDefaultEndpoints returns default OAuth endpoints based on the base URL
|
|
func (h *OAuthHandler) getDefaultEndpoints(baseURL string) (*AuthServerMetadata, error) {
|
|
// Parse the base URL to extract the authority
|
|
parsedURL, err := url.Parse(baseURL)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to parse base URL: %w", err)
|
|
}
|
|
|
|
// Discard any path component to get the authorization base URL
|
|
parsedURL.Path = ""
|
|
authBaseURL := parsedURL.String()
|
|
|
|
// Validate that the URL has a scheme and host
|
|
if parsedURL.Scheme == "" || parsedURL.Host == "" {
|
|
return nil, fmt.Errorf("invalid base URL: missing scheme or host in %q", baseURL)
|
|
}
|
|
|
|
return &AuthServerMetadata{
|
|
Issuer: authBaseURL,
|
|
AuthorizationEndpoint: authBaseURL + "/authorize",
|
|
TokenEndpoint: authBaseURL + "/token",
|
|
RegistrationEndpoint: authBaseURL + "/register",
|
|
}, nil
|
|
}
|
|
|
|
// RegisterClient performs dynamic client registration
|
|
func (h *OAuthHandler) RegisterClient(ctx context.Context, clientName string) error {
|
|
metadata, err := h.getServerMetadata(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get server metadata: %w", err)
|
|
}
|
|
|
|
if metadata.RegistrationEndpoint == "" {
|
|
return errors.New("server does not support dynamic client registration")
|
|
}
|
|
|
|
// Prepare registration request
|
|
regRequest := map[string]any{
|
|
"client_name": clientName,
|
|
"redirect_uris": []string{h.config.RedirectURI},
|
|
"token_endpoint_auth_method": "none", // For public clients
|
|
"grant_types": []string{"authorization_code", "refresh_token"},
|
|
"response_types": []string{"code"},
|
|
"scope": strings.Join(h.config.Scopes, " "),
|
|
}
|
|
|
|
// Add client_secret if this is a confidential client
|
|
if h.config.ClientSecret != "" {
|
|
regRequest["token_endpoint_auth_method"] = "client_secret_basic"
|
|
}
|
|
|
|
reqBody, err := json.Marshal(regRequest)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to marshal registration request: %w", err)
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(
|
|
ctx,
|
|
http.MethodPost,
|
|
metadata.RegistrationEndpoint,
|
|
bytes.NewReader(reqBody),
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create registration request: %w", err)
|
|
}
|
|
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Accept", "application/json")
|
|
|
|
resp, err := h.httpClient.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to send registration request: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK {
|
|
body, _ := io.ReadAll(resp.Body)
|
|
return extractOAuthError(body, resp.StatusCode, "registration request failed")
|
|
}
|
|
|
|
var regResponse struct {
|
|
ClientID string `json:"client_id"`
|
|
ClientSecret string `json:"client_secret,omitempty"`
|
|
}
|
|
|
|
if err := json.NewDecoder(resp.Body).Decode(®Response); err != nil {
|
|
return fmt.Errorf("failed to decode registration response: %w", err)
|
|
}
|
|
|
|
// Update the client configuration
|
|
h.config.ClientID = regResponse.ClientID
|
|
if regResponse.ClientSecret != "" {
|
|
h.config.ClientSecret = regResponse.ClientSecret
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// ErrInvalidState is returned when the state parameter doesn't match the expected value
|
|
var ErrInvalidState = errors.New("invalid state parameter, possible CSRF attack")
|
|
|
|
// ProcessAuthorizationResponse processes the authorization response and exchanges the code for a token
|
|
func (h *OAuthHandler) ProcessAuthorizationResponse(ctx context.Context, code, state, codeVerifier string) error {
|
|
// Validate the state parameter to prevent CSRF attacks
|
|
h.mu.Lock()
|
|
expectedState := h.expectedState
|
|
if expectedState == "" {
|
|
h.mu.Unlock()
|
|
return errors.New("no expected state found, authorization flow may not have been initiated properly")
|
|
}
|
|
|
|
if state != expectedState {
|
|
h.mu.Unlock()
|
|
return ErrInvalidState
|
|
}
|
|
|
|
// Clear the expected state after validation
|
|
h.expectedState = ""
|
|
h.mu.Unlock()
|
|
|
|
metadata, err := h.getServerMetadata(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get server metadata: %w", err)
|
|
}
|
|
|
|
data := url.Values{}
|
|
data.Set("grant_type", "authorization_code")
|
|
data.Set("code", code)
|
|
data.Set("client_id", h.config.ClientID)
|
|
data.Set("redirect_uri", h.config.RedirectURI)
|
|
|
|
if h.config.ClientSecret != "" {
|
|
data.Set("client_secret", h.config.ClientSecret)
|
|
}
|
|
|
|
if h.config.PKCEEnabled && codeVerifier != "" {
|
|
data.Set("code_verifier", codeVerifier)
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(
|
|
ctx,
|
|
http.MethodPost,
|
|
metadata.TokenEndpoint,
|
|
strings.NewReader(data.Encode()),
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create token request: %w", err)
|
|
}
|
|
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
req.Header.Set("Accept", "application/json")
|
|
|
|
resp, err := h.httpClient.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to send token request: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
body, _ := io.ReadAll(resp.Body)
|
|
return extractOAuthError(body, resp.StatusCode, "token request failed")
|
|
}
|
|
|
|
var tokenResp Token
|
|
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
|
|
return fmt.Errorf("failed to decode token response: %w", err)
|
|
}
|
|
|
|
// Set expiration time
|
|
if tokenResp.ExpiresIn > 0 {
|
|
tokenResp.ExpiresAt = time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second)
|
|
}
|
|
|
|
// Save the token
|
|
if err := h.config.TokenStore.SaveToken(ctx, &tokenResp); err != nil {
|
|
return fmt.Errorf("failed to save token: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// GetAuthorizationURL returns the URL for the authorization endpoint
|
|
func (h *OAuthHandler) GetAuthorizationURL(ctx context.Context, state, codeChallenge string) (string, error) {
|
|
metadata, err := h.getServerMetadata(ctx)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to get server metadata: %w", err)
|
|
}
|
|
|
|
// Store the state for later validation
|
|
h.SetExpectedState(state)
|
|
|
|
params := url.Values{}
|
|
params.Set("response_type", "code")
|
|
params.Set("client_id", h.config.ClientID)
|
|
params.Set("redirect_uri", h.config.RedirectURI)
|
|
params.Set("state", state)
|
|
|
|
if len(h.config.Scopes) > 0 {
|
|
params.Set("scope", strings.Join(h.config.Scopes, " "))
|
|
}
|
|
|
|
if h.config.PKCEEnabled && codeChallenge != "" {
|
|
params.Set("code_challenge", codeChallenge)
|
|
params.Set("code_challenge_method", "S256")
|
|
}
|
|
|
|
return metadata.AuthorizationEndpoint + "?" + params.Encode(), nil
|
|
}
|