go-sdk/oauthex/token_exchange_test.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

237 lines
6.8 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.
package oauthex
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
// TestExchangeToken tests the basic token exchange flow.
func TestExchangeToken(t *testing.T) {
// Create a test IdP server that implements token exchange
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Verify request method and content type
if r.Method != http.MethodPost {
t.Errorf("expected POST request, got %s", r.Method)
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
contentType := r.Header.Get("Content-Type")
if contentType != "application/x-www-form-urlencoded" {
t.Errorf("expected application/x-www-form-urlencoded, got %s", contentType)
http.Error(w, "invalid content type", http.StatusBadRequest)
return
}
// Parse form data
if err := r.ParseForm(); err != nil {
http.Error(w, "failed to parse form", http.StatusBadRequest)
return
}
// Verify required parameters per SEP-990 Section 4
grantType := r.FormValue("grant_type")
if grantType != GrantTypeTokenExchange {
t.Errorf("expected grant_type %s, got %s", GrantTypeTokenExchange, grantType)
writeErrorResponse(w, "invalid_grant", "invalid grant_type")
return
}
requestedTokenType := r.FormValue("requested_token_type")
if requestedTokenType != TokenTypeIDJAG {
t.Errorf("expected requested_token_type %s, got %s", TokenTypeIDJAG, requestedTokenType)
writeErrorResponse(w, "invalid_request", "invalid requested_token_type")
return
}
audience := r.FormValue("audience")
if audience == "" {
t.Error("audience is required")
writeErrorResponse(w, "invalid_request", "missing audience")
return
}
resource := r.FormValue("resource")
if resource == "" {
t.Error("resource is required")
writeErrorResponse(w, "invalid_request", "missing resource")
return
}
subjectToken := r.FormValue("subject_token")
if subjectToken == "" {
t.Error("subject_token is required")
writeErrorResponse(w, "invalid_request", "missing subject_token")
return
}
subjectTokenType := r.FormValue("subject_token_type")
if subjectTokenType != TokenTypeIDToken {
t.Errorf("expected subject_token_type %s, got %s", TokenTypeIDToken, subjectTokenType)
writeErrorResponse(w, "invalid_request", "invalid subject_token_type")
return
}
// Verify client authentication
clientID := r.FormValue("client_id")
clientSecret := r.FormValue("client_secret")
if clientID == "" || clientSecret == "" {
t.Error("client authentication required")
writeErrorResponse(w, "invalid_client", "client authentication failed")
return
}
if clientID != "test-client-id" || clientSecret != "test-client-secret" {
t.Error("invalid client credentials")
writeErrorResponse(w, "invalid_client", "invalid credentials")
return
}
// Return successful token exchange response per SEP-990 Section 4.2
resp := map[string]any{
"issued_token_type": TokenTypeIDJAG,
"access_token": "fake-id-jag-token",
"token_type": "N_A",
"scope": r.FormValue("scope"),
"expires_in": 300,
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Pragma", "no-cache")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(resp)
}))
defer server.Close()
// Test successful token exchange
t.Run("successful exchange", func(t *testing.T) {
req := &TokenExchangeRequest{
RequestedTokenType: TokenTypeIDJAG,
Audience: "https://auth.mcpserver.example/",
Resource: "https://mcp.mcpserver.example/",
Scope: []string{"read", "write"},
SubjectToken: "fake-id-token",
SubjectTokenType: TokenTypeIDToken,
}
token, err := ExchangeToken(
context.Background(),
server.URL,
req,
&ClientCredentials{
ClientID: "test-client-id",
ClientSecretAuth: &ClientSecretAuth{
ClientSecret: "test-client-secret",
},
},
server.Client(),
)
if err != nil {
t.Fatalf("ExchangeToken failed: %v", err)
}
issuedTokenType, ok := token.Extra("issued_token_type").(string)
if !ok || issuedTokenType != TokenTypeIDJAG {
t.Errorf("expected issued_token_type %s, got %s", TokenTypeIDJAG, issuedTokenType)
}
if token.AccessToken != "fake-id-jag-token" {
t.Errorf("expected access_token 'fake-id-jag-token', got %s", token.AccessToken)
}
if token.TokenType != "N_A" {
t.Errorf("expected token_type 'N_A', got %s", token.TokenType)
}
scope, ok := token.Extra("scope").(string)
if !ok || scope != "read write" {
t.Errorf("expected scope 'read write', got %s", scope)
}
// expires_in should be available in Extra
expiresIn, ok := token.Extra("expires_in").(float64)
if !ok || int(expiresIn) != 300 {
t.Errorf("expected expires_in 300, got %v", expiresIn)
}
})
// Test missing required fields
t.Run("missing audience", func(t *testing.T) {
req := &TokenExchangeRequest{
RequestedTokenType: TokenTypeIDJAG,
Resource: "https://mcp.mcpserver.example/",
SubjectToken: "fake-id-token",
SubjectTokenType: TokenTypeIDToken,
}
_, err := ExchangeToken(
context.Background(),
server.URL,
req,
&ClientCredentials{
ClientID: "test-client-id",
ClientSecretAuth: &ClientSecretAuth{
ClientSecret: "test-client-secret",
},
},
server.Client(),
)
if err == nil {
t.Error("expected error for missing audience, got nil")
}
})
// Test invalid URL schemes
t.Run("invalid audience URL scheme", func(t *testing.T) {
req := &TokenExchangeRequest{
RequestedTokenType: TokenTypeIDJAG,
Audience: "javascript:alert(1)",
Resource: "https://mcp.mcpserver.example/",
SubjectToken: "fake-id-token",
SubjectTokenType: TokenTypeIDToken,
}
_, err := ExchangeToken(
context.Background(),
server.URL,
req,
&ClientCredentials{
ClientID: "test-client-id",
ClientSecretAuth: &ClientSecretAuth{
ClientSecret: "test-client-secret",
},
},
server.Client(),
)
if err == nil {
t.Error("expected error for invalid audience URL scheme, got nil")
}
})
}
// writeErrorResponse writes an OAuth 2.0 error response per RFC 6749 Section 5.2.
func writeErrorResponse(w http.ResponseWriter, errorCode, errorDescription string) {
errResp := struct {
Error string `json:"error"`
ErrorDescription string `json:"error_description,omitempty"`
}{
Error: errorCode,
ErrorDescription: errorDescription,
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(errResp)
}