237 lines
6.8 KiB
Go
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)
|
|
}
|