505 lines
15 KiB
Go
505 lines
15 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.
|
|
|
|
//go:build mcp_go_client_oauth
|
|
|
|
package extauth
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/modelcontextprotocol/go-sdk/auth"
|
|
"github.com/modelcontextprotocol/go-sdk/oauthex"
|
|
)
|
|
|
|
// TestInitiateOIDCLogin tests the OIDC authorization request generation.
|
|
func TestInitiateOIDCLogin(t *testing.T) {
|
|
// Create mock IdP server
|
|
idpServer := createMockOIDCServer(t)
|
|
defer idpServer.Close()
|
|
config := &OIDCLoginConfig{
|
|
IssuerURL: idpServer.URL,
|
|
Credentials: &oauthex.ClientCredentials{
|
|
ClientID: "test-client",
|
|
},
|
|
RedirectURL: "http://localhost:8080/callback",
|
|
Scopes: []string{"openid", "profile", "email"},
|
|
HTTPClient: idpServer.Client(),
|
|
}
|
|
t.Run("successful initiation", func(t *testing.T) {
|
|
authReq, _, err := initiateOIDCLogin(context.Background(), config)
|
|
if err != nil {
|
|
t.Fatalf("initiateOIDCLogin failed: %v", err)
|
|
}
|
|
// Validate authURL
|
|
if authReq.authURL == "" {
|
|
t.Error("authURL is empty")
|
|
}
|
|
// Parse and validate URL parameters
|
|
u, err := url.Parse(authReq.authURL)
|
|
if err != nil {
|
|
t.Fatalf("Failed to parse authURL: %v", err)
|
|
}
|
|
q := u.Query()
|
|
if q.Get("response_type") != "code" {
|
|
t.Errorf("expected response_type 'code', got '%s'", q.Get("response_type"))
|
|
}
|
|
if q.Get("client_id") != "test-client" {
|
|
t.Errorf("expected client_id 'test-client', got '%s'", q.Get("client_id"))
|
|
}
|
|
if q.Get("redirect_uri") != "http://localhost:8080/callback" {
|
|
t.Errorf("expected redirect_uri 'http://localhost:8080/callback', got '%s'", q.Get("redirect_uri"))
|
|
}
|
|
if q.Get("scope") != "openid profile email" {
|
|
t.Errorf("expected scope 'openid profile email', got '%s'", q.Get("scope"))
|
|
}
|
|
if q.Get("code_challenge_method") != "S256" {
|
|
t.Errorf("expected code_challenge_method 'S256', got '%s'", q.Get("code_challenge_method"))
|
|
}
|
|
// Validate state is generated
|
|
if authReq.state == "" {
|
|
t.Error("state is empty")
|
|
}
|
|
if q.Get("state") != authReq.state {
|
|
t.Errorf("state in URL doesn't match returned state")
|
|
}
|
|
// Validate PKCE parameters
|
|
if authReq.codeVerifier == "" {
|
|
t.Error("codeVerifier is empty")
|
|
}
|
|
if q.Get("code_challenge") == "" {
|
|
t.Error("code_challenge is empty")
|
|
}
|
|
})
|
|
t.Run("with login_hint", func(t *testing.T) {
|
|
configWithHint := *config
|
|
configWithHint.LoginHint = "user@example.com"
|
|
authReq, _, err := initiateOIDCLogin(context.Background(), &configWithHint)
|
|
if err != nil {
|
|
t.Fatalf("initiateOIDCLogin failed: %v", err)
|
|
}
|
|
u, err := url.Parse(authReq.authURL)
|
|
if err != nil {
|
|
t.Fatalf("Failed to parse authURL: %v", err)
|
|
}
|
|
q := u.Query()
|
|
if q.Get("login_hint") != "user@example.com" {
|
|
t.Errorf("expected login_hint 'user@example.com', got '%s'", q.Get("login_hint"))
|
|
}
|
|
})
|
|
t.Run("without login_hint", func(t *testing.T) {
|
|
authReq, _, err := initiateOIDCLogin(context.Background(), config)
|
|
if err != nil {
|
|
t.Fatalf("initiateOIDCLogin failed: %v", err)
|
|
}
|
|
u, err := url.Parse(authReq.authURL)
|
|
if err != nil {
|
|
t.Fatalf("Failed to parse authURL: %v", err)
|
|
}
|
|
q := u.Query()
|
|
if q.Has("login_hint") {
|
|
t.Errorf("expected no login_hint parameter, but got '%s'", q.Get("login_hint"))
|
|
}
|
|
})
|
|
t.Run("nil config", func(t *testing.T) {
|
|
_, _, err := initiateOIDCLogin(context.Background(), nil)
|
|
if err == nil {
|
|
t.Error("expected error for nil config, got nil")
|
|
}
|
|
})
|
|
t.Run("missing openid scope", func(t *testing.T) {
|
|
badConfig := *config
|
|
badConfig.Scopes = []string{"profile", "email"} // Missing "openid"
|
|
_, _, err := initiateOIDCLogin(context.Background(), &badConfig)
|
|
if err == nil {
|
|
t.Error("expected error for missing openid scope, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "openid") {
|
|
t.Errorf("expected error about missing 'openid', got: %v", err)
|
|
}
|
|
})
|
|
t.Run("missing required fields", func(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
mutate func(*OIDCLoginConfig)
|
|
expectErr string
|
|
}{
|
|
{
|
|
name: "missing IssuerURL",
|
|
mutate: func(c *OIDCLoginConfig) { c.IssuerURL = "" },
|
|
expectErr: "IssuerURL is required",
|
|
},
|
|
{
|
|
name: "missing ClientID",
|
|
mutate: func(c *OIDCLoginConfig) { c.Credentials.ClientID = "" },
|
|
expectErr: "ClientID is required",
|
|
},
|
|
{
|
|
name: "missing RedirectURL",
|
|
mutate: func(c *OIDCLoginConfig) {
|
|
c.RedirectURL = ""
|
|
// Ensure ClientID is present to test RedirectURL validation
|
|
c.Credentials = &oauthex.ClientCredentials{ClientID: "test"}
|
|
},
|
|
expectErr: "RedirectURL is required",
|
|
},
|
|
{
|
|
name: "missing Scopes",
|
|
mutate: func(c *OIDCLoginConfig) {
|
|
c.Scopes = nil
|
|
// Ensure required fields are present to test Scopes validation
|
|
c.Credentials = &oauthex.ClientCredentials{ClientID: "test"}
|
|
c.RedirectURL = "http://localhost:8080/callback"
|
|
},
|
|
expectErr: "Scopes is required",
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
badConfig := *config
|
|
tt.mutate(&badConfig)
|
|
_, _, err := initiateOIDCLogin(context.Background(), &badConfig)
|
|
if err == nil {
|
|
t.Error("expected error, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), tt.expectErr) {
|
|
t.Errorf("expected error containing '%s', got: %v", tt.expectErr, err)
|
|
}
|
|
})
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestCompleteOIDCLogin tests the authorization code exchange.
|
|
func TestCompleteOIDCLogin(t *testing.T) {
|
|
// Create mock IdP server
|
|
idpServer := createMockOIDCServerWithToken(t)
|
|
defer idpServer.Close()
|
|
config := &OIDCLoginConfig{
|
|
IssuerURL: idpServer.URL,
|
|
Credentials: &oauthex.ClientCredentials{
|
|
ClientID: "test-client",
|
|
ClientSecretAuth: &oauthex.ClientSecretAuth{
|
|
ClientSecret: "test-secret",
|
|
},
|
|
},
|
|
RedirectURL: "http://localhost:8080/callback",
|
|
Scopes: []string{"openid", "profile", "email"},
|
|
HTTPClient: idpServer.Client(),
|
|
}
|
|
t.Run("successful code exchange", func(t *testing.T) {
|
|
// First initiate to get oauth2Config
|
|
_, oauth2Config, err := initiateOIDCLogin(context.Background(), config)
|
|
if err != nil {
|
|
t.Fatalf("initiateOIDCLogin failed: %v", err)
|
|
}
|
|
|
|
token, err := completeOIDCLogin(
|
|
context.Background(),
|
|
config,
|
|
oauth2Config,
|
|
"test-auth-code",
|
|
"test-code-verifier",
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("completeOIDCLogin failed: %v", err)
|
|
}
|
|
// Validate tokens
|
|
idToken, ok := token.Extra("id_token").(string)
|
|
if !ok || idToken == "" {
|
|
t.Error("id_token is missing or empty")
|
|
}
|
|
if token.AccessToken == "" {
|
|
t.Error("AccessToken is empty")
|
|
}
|
|
if token.TokenType != "Bearer" {
|
|
t.Errorf("expected TokenType 'Bearer', got '%s'", token.TokenType)
|
|
}
|
|
if token.Expiry.IsZero() {
|
|
t.Error("Expiry is zero")
|
|
}
|
|
})
|
|
t.Run("missing parameters", func(t *testing.T) {
|
|
_, oauth2Config, _ := initiateOIDCLogin(context.Background(), config)
|
|
|
|
tests := []struct {
|
|
name string
|
|
authCode string
|
|
codeVerifier string
|
|
expectErr string
|
|
}{
|
|
{
|
|
name: "missing authCode",
|
|
authCode: "",
|
|
codeVerifier: "test-verifier",
|
|
expectErr: "authCode is required",
|
|
},
|
|
{
|
|
name: "missing codeVerifier",
|
|
authCode: "test-code",
|
|
codeVerifier: "",
|
|
expectErr: "codeVerifier is required",
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
_, err := completeOIDCLogin(
|
|
context.Background(),
|
|
config,
|
|
oauth2Config,
|
|
tt.authCode,
|
|
tt.codeVerifier,
|
|
)
|
|
if err == nil {
|
|
t.Error("expected error, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), tt.expectErr) {
|
|
t.Errorf("expected error containing '%s', got: %v", tt.expectErr, err)
|
|
}
|
|
})
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestOIDCLoginE2E tests the complete OIDC login flow end-to-end.
|
|
func TestOIDCLoginE2E(t *testing.T) {
|
|
// Create mock IdP server
|
|
idpServer := createMockOIDCServerWithToken(t)
|
|
defer idpServer.Close()
|
|
config := &OIDCLoginConfig{
|
|
IssuerURL: idpServer.URL,
|
|
Credentials: &oauthex.ClientCredentials{
|
|
ClientID: "test-client",
|
|
ClientSecretAuth: &oauthex.ClientSecretAuth{
|
|
ClientSecret: "test-secret",
|
|
},
|
|
},
|
|
RedirectURL: "http://localhost:8080/callback",
|
|
Scopes: []string{"openid", "profile", "email"},
|
|
HTTPClient: idpServer.Client(),
|
|
}
|
|
// Step 1: Initiate login
|
|
authReq, oauth2Config, err := initiateOIDCLogin(context.Background(), config)
|
|
if err != nil {
|
|
t.Fatalf("initiateOIDCLogin failed: %v", err)
|
|
}
|
|
// Step 2: Simulate user authentication and redirect
|
|
// (In real flow, user would visit authReq.authURL and IdP would redirect back)
|
|
// Here we just use a mock authorization code
|
|
mockAuthCode := "mock-authorization-code"
|
|
// Step 3: Complete login with authorization code
|
|
token, err := completeOIDCLogin(
|
|
context.Background(),
|
|
config,
|
|
oauth2Config,
|
|
mockAuthCode,
|
|
authReq.codeVerifier,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("completeOIDCLogin failed: %v", err)
|
|
}
|
|
// Validate we got an ID token
|
|
idToken, ok := token.Extra("id_token").(string)
|
|
if !ok || idToken == "" {
|
|
t.Error("Expected ID token, got empty or missing")
|
|
}
|
|
// Validate ID token is a JWT (has 3 parts)
|
|
parts := strings.Split(idToken, ".")
|
|
if len(parts) != 3 {
|
|
t.Errorf("Expected JWT with 3 parts, got %d parts", len(parts))
|
|
}
|
|
}
|
|
|
|
// createMockOIDCServer creates a mock OIDC server for testing initiateOIDCLogin.
|
|
func createMockOIDCServer(t *testing.T) *httptest.Server {
|
|
var serverURL string
|
|
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
// Handle OIDC discovery
|
|
if r.URL.Path == "/.well-known/openid-configuration" {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]any{
|
|
"issuer": serverURL,
|
|
"authorization_endpoint": serverURL + "/authorize",
|
|
"token_endpoint": serverURL + "/token",
|
|
"jwks_uri": serverURL + "/.well-known/jwks.json",
|
|
"response_types_supported": []string{"code"},
|
|
"code_challenge_methods_supported": []string{"S256"},
|
|
"grant_types_supported": []string{"authorization_code"},
|
|
})
|
|
return
|
|
}
|
|
http.NotFound(w, r)
|
|
}))
|
|
serverURL = server.URL
|
|
return server
|
|
}
|
|
|
|
// createMockOIDCServerWithToken creates a mock OIDC server that also handles token exchange.
|
|
func createMockOIDCServerWithToken(t *testing.T) *httptest.Server {
|
|
var serverURL string
|
|
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
// Handle OIDC discovery
|
|
if r.URL.Path == "/.well-known/openid-configuration" {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]any{
|
|
"issuer": serverURL,
|
|
"authorization_endpoint": serverURL + "/authorize",
|
|
"token_endpoint": serverURL + "/token",
|
|
"jwks_uri": serverURL + "/.well-known/jwks.json",
|
|
"response_types_supported": []string{"code"},
|
|
"code_challenge_methods_supported": []string{"S256"},
|
|
"grant_types_supported": []string{"authorization_code"},
|
|
})
|
|
return
|
|
}
|
|
// Handle token endpoint
|
|
if r.URL.Path == "/token" {
|
|
if err := r.ParseForm(); err != nil {
|
|
http.Error(w, "failed to parse form", http.StatusBadRequest)
|
|
return
|
|
}
|
|
// Validate grant type
|
|
if r.FormValue("grant_type") != "authorization_code" {
|
|
http.Error(w, "invalid grant_type", http.StatusBadRequest)
|
|
return
|
|
}
|
|
// Create mock ID token (JWT)
|
|
now := time.Now().Unix()
|
|
idToken := fmt.Sprintf("eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.%s.mock-signature",
|
|
base64EncodeClaims(map[string]any{
|
|
"iss": serverURL,
|
|
"sub": "test-user",
|
|
"aud": "test-client",
|
|
"exp": now + 3600,
|
|
"iat": now,
|
|
"email": "test@example.com",
|
|
}))
|
|
// Return token response
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]any{
|
|
"access_token": "mock-access-token",
|
|
"token_type": "Bearer",
|
|
"expires_in": 3600,
|
|
"refresh_token": "mock-refresh-token",
|
|
"id_token": idToken,
|
|
})
|
|
return
|
|
}
|
|
http.NotFound(w, r)
|
|
}))
|
|
serverURL = server.URL
|
|
return server
|
|
}
|
|
|
|
// base64EncodeClaims encodes JWT claims for testing.
|
|
func base64EncodeClaims(claims map[string]any) string {
|
|
claimsJSON, _ := json.Marshal(claims)
|
|
return base64.RawURLEncoding.EncodeToString(claimsJSON)
|
|
}
|
|
|
|
// TestPerformOIDCLogin tests the combined OIDC login flow with callback.
|
|
func TestPerformOIDCLogin(t *testing.T) {
|
|
// Create mock IdP server
|
|
idpServer := createMockOIDCServerWithToken(t)
|
|
defer idpServer.Close()
|
|
config := &OIDCLoginConfig{
|
|
IssuerURL: idpServer.URL,
|
|
Credentials: &oauthex.ClientCredentials{
|
|
ClientID: "test-client",
|
|
ClientSecretAuth: &oauthex.ClientSecretAuth{
|
|
ClientSecret: "test-secret",
|
|
},
|
|
},
|
|
RedirectURL: "http://localhost:8080/callback",
|
|
Scopes: []string{"openid", "profile", "email"},
|
|
HTTPClient: idpServer.Client(),
|
|
}
|
|
|
|
t.Run("successful flow", func(t *testing.T) {
|
|
token, err := PerformOIDCLogin(context.Background(), config,
|
|
func(ctx context.Context, args *auth.AuthorizationArgs) (*auth.AuthorizationResult, error) {
|
|
// Validate authURL has required parameters
|
|
u, err := url.Parse(args.URL)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid authURL: %w", err)
|
|
}
|
|
q := u.Query()
|
|
if q.Get("response_type") != "code" {
|
|
return nil, fmt.Errorf("missing response_type")
|
|
}
|
|
if q.Get("state") == "" {
|
|
return nil, fmt.Errorf("missing state")
|
|
}
|
|
|
|
// Simulate successful user authentication
|
|
return &auth.AuthorizationResult{
|
|
Code: "mock-auth-code",
|
|
State: q.Get("state"), // Return the expected state from URL
|
|
}, nil
|
|
})
|
|
|
|
if err != nil {
|
|
t.Fatalf("PerformOIDCLogin failed: %v", err)
|
|
}
|
|
|
|
idToken, ok := token.Extra("id_token").(string)
|
|
if !ok || idToken == "" {
|
|
t.Error("id_token is missing or empty")
|
|
}
|
|
if token.AccessToken == "" {
|
|
t.Error("AccessToken is empty")
|
|
}
|
|
})
|
|
|
|
t.Run("state mismatch", func(t *testing.T) {
|
|
_, err := PerformOIDCLogin(context.Background(), config,
|
|
func(ctx context.Context, args *auth.AuthorizationArgs) (*auth.AuthorizationResult, error) {
|
|
// Return wrong state to simulate CSRF attack
|
|
return &auth.AuthorizationResult{
|
|
Code: "mock-auth-code",
|
|
State: "wrong-state",
|
|
}, nil
|
|
})
|
|
|
|
if err == nil {
|
|
t.Error("expected error for state mismatch, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "state mismatch") {
|
|
t.Errorf("expected state mismatch error, got: %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("fetcher error", func(t *testing.T) {
|
|
_, err := PerformOIDCLogin(context.Background(), config,
|
|
func(ctx context.Context, args *auth.AuthorizationArgs) (*auth.AuthorizationResult, error) {
|
|
return nil, fmt.Errorf("user cancelled")
|
|
})
|
|
|
|
if err == nil {
|
|
t.Error("expected error, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "user cancelled") {
|
|
t.Errorf("expected 'user cancelled' error, got: %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("nil fetcher", func(t *testing.T) {
|
|
_, err := PerformOIDCLogin(context.Background(), config, nil)
|
|
if err == nil {
|
|
t.Error("expected error for nil fetcher, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "authCodeFetcher is required") {
|
|
t.Errorf("expected 'authCodeFetcher is required' error, got: %v", err)
|
|
}
|
|
})
|
|
}
|