chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,763 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/github/github-mcp-server/pkg/http/headers"
|
||||
"github.com/github/github-mcp-server/pkg/utils"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
var (
|
||||
defaultAuthorizationServer = "https://github.com/login/oauth"
|
||||
)
|
||||
|
||||
func TestNewAuthHandler(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dotcomHost, err := utils.NewAPIHost("https://api.github.com")
|
||||
require.NoError(t, err)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg *Config
|
||||
expectedAuthServer string
|
||||
expectedResourcePath string
|
||||
}{
|
||||
{
|
||||
name: "custom authorization server",
|
||||
cfg: &Config{
|
||||
AuthorizationServer: "https://custom.example.com/oauth",
|
||||
},
|
||||
expectedAuthServer: "https://custom.example.com/oauth",
|
||||
expectedResourcePath: "",
|
||||
},
|
||||
{
|
||||
name: "custom base URL and resource path",
|
||||
cfg: &Config{
|
||||
BaseURL: "https://example.com",
|
||||
ResourcePath: "/mcp",
|
||||
},
|
||||
expectedAuthServer: "",
|
||||
expectedResourcePath: "/mcp",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
handler, err := NewAuthHandler(tc.cfg, dotcomHost)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, handler)
|
||||
|
||||
assert.Equal(t, tc.expectedAuthServer, handler.cfg.AuthorizationServer)
|
||||
assert.Equal(t, tc.expectedResourcePath, handler.cfg.ResourcePath)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetEffectiveHostAndScheme(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
setupRequest func() *http.Request
|
||||
cfg *Config
|
||||
expectedHost string
|
||||
expectedScheme string
|
||||
}{
|
||||
{
|
||||
name: "basic request without forwarding headers",
|
||||
setupRequest: func() *http.Request {
|
||||
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
||||
req.Host = "example.com"
|
||||
return req
|
||||
},
|
||||
cfg: &Config{},
|
||||
expectedHost: "example.com",
|
||||
expectedScheme: "http", // defaults to http
|
||||
},
|
||||
{
|
||||
name: "X-Forwarded-Host ignored by default",
|
||||
setupRequest: func() *http.Request {
|
||||
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
||||
req.Host = "internal.example.com"
|
||||
req.Header.Set(headers.ForwardedHostHeader, "attacker.example.com")
|
||||
req.Header.Set(headers.ForwardedProtoHeader, "https")
|
||||
return req
|
||||
},
|
||||
cfg: &Config{},
|
||||
expectedHost: "internal.example.com",
|
||||
expectedScheme: "http",
|
||||
},
|
||||
{
|
||||
name: "request with X-Forwarded-Host header",
|
||||
setupRequest: func() *http.Request {
|
||||
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
||||
req.Host = "internal.example.com"
|
||||
req.Header.Set(headers.ForwardedHostHeader, "public.example.com")
|
||||
return req
|
||||
},
|
||||
cfg: &Config{TrustProxyHeaders: true},
|
||||
expectedHost: "public.example.com",
|
||||
expectedScheme: "http",
|
||||
},
|
||||
{
|
||||
name: "request with X-Forwarded-Proto header",
|
||||
setupRequest: func() *http.Request {
|
||||
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
||||
req.Host = "example.com"
|
||||
req.Header.Set(headers.ForwardedProtoHeader, "http")
|
||||
return req
|
||||
},
|
||||
cfg: &Config{TrustProxyHeaders: true},
|
||||
expectedHost: "example.com",
|
||||
expectedScheme: "http",
|
||||
},
|
||||
{
|
||||
name: "request with both forwarding headers",
|
||||
setupRequest: func() *http.Request {
|
||||
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
||||
req.Host = "internal.example.com"
|
||||
req.Header.Set(headers.ForwardedHostHeader, "public.example.com")
|
||||
req.Header.Set(headers.ForwardedProtoHeader, "https")
|
||||
return req
|
||||
},
|
||||
cfg: &Config{TrustProxyHeaders: true},
|
||||
expectedHost: "public.example.com",
|
||||
expectedScheme: "https",
|
||||
},
|
||||
{
|
||||
name: "request with TLS",
|
||||
setupRequest: func() *http.Request {
|
||||
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
||||
req.Host = "example.com"
|
||||
req.TLS = &tls.ConnectionState{}
|
||||
return req
|
||||
},
|
||||
cfg: &Config{},
|
||||
expectedHost: "example.com",
|
||||
expectedScheme: "https",
|
||||
},
|
||||
{
|
||||
name: "X-Forwarded-Proto takes precedence over TLS",
|
||||
setupRequest: func() *http.Request {
|
||||
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
||||
req.Host = "example.com"
|
||||
req.TLS = &tls.ConnectionState{}
|
||||
req.Header.Set(headers.ForwardedProtoHeader, "http")
|
||||
return req
|
||||
},
|
||||
cfg: &Config{TrustProxyHeaders: true},
|
||||
expectedHost: "example.com",
|
||||
expectedScheme: "http",
|
||||
},
|
||||
{
|
||||
name: "scheme is lowercased",
|
||||
setupRequest: func() *http.Request {
|
||||
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
||||
req.Host = "example.com"
|
||||
req.Header.Set(headers.ForwardedProtoHeader, "HTTPS")
|
||||
return req
|
||||
},
|
||||
cfg: &Config{TrustProxyHeaders: true},
|
||||
expectedHost: "example.com",
|
||||
expectedScheme: "https",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
req := tc.setupRequest()
|
||||
host, scheme := GetEffectiveHostAndScheme(req, tc.cfg)
|
||||
|
||||
assert.Equal(t, tc.expectedHost, host)
|
||||
assert.Equal(t, tc.expectedScheme, scheme)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveResourcePath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg *Config
|
||||
setupRequest func() *http.Request
|
||||
expectedPath string
|
||||
}{
|
||||
{
|
||||
name: "no base path uses request path",
|
||||
cfg: &Config{},
|
||||
setupRequest: func() *http.Request {
|
||||
return httptest.NewRequest(http.MethodGet, "/x/repos", nil)
|
||||
},
|
||||
expectedPath: "/x/repos",
|
||||
},
|
||||
{
|
||||
name: "base path restored for root",
|
||||
cfg: &Config{
|
||||
ResourcePath: "/mcp",
|
||||
},
|
||||
setupRequest: func() *http.Request {
|
||||
return httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
},
|
||||
expectedPath: "/mcp",
|
||||
},
|
||||
{
|
||||
name: "base path restored for nested",
|
||||
cfg: &Config{
|
||||
ResourcePath: "/mcp",
|
||||
},
|
||||
setupRequest: func() *http.Request {
|
||||
return httptest.NewRequest(http.MethodGet, "/readonly", nil)
|
||||
},
|
||||
expectedPath: "/mcp/readonly",
|
||||
},
|
||||
{
|
||||
name: "base path preserved when already present",
|
||||
cfg: &Config{
|
||||
ResourcePath: "/mcp",
|
||||
},
|
||||
setupRequest: func() *http.Request {
|
||||
return httptest.NewRequest(http.MethodGet, "/mcp/readonly/", nil)
|
||||
},
|
||||
expectedPath: "/mcp/readonly/",
|
||||
},
|
||||
{
|
||||
name: "custom base path restored",
|
||||
cfg: &Config{
|
||||
ResourcePath: "/api",
|
||||
},
|
||||
setupRequest: func() *http.Request {
|
||||
return httptest.NewRequest(http.MethodGet, "/x/repos", nil)
|
||||
},
|
||||
expectedPath: "/api/x/repos",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
req := tc.setupRequest()
|
||||
path := ResolveResourcePath(req, tc.cfg)
|
||||
|
||||
assert.Equal(t, tc.expectedPath, path)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildResourceMetadataURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg *Config
|
||||
setupRequest func() *http.Request
|
||||
resourcePath string
|
||||
expectedURL string
|
||||
}{
|
||||
{
|
||||
name: "root path",
|
||||
cfg: &Config{},
|
||||
setupRequest: func() *http.Request {
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.Host = "api.example.com"
|
||||
return req
|
||||
},
|
||||
resourcePath: "/",
|
||||
expectedURL: "http://api.example.com/.well-known/oauth-protected-resource",
|
||||
},
|
||||
{
|
||||
name: "resource path preserves trailing slash",
|
||||
cfg: &Config{},
|
||||
setupRequest: func() *http.Request {
|
||||
req := httptest.NewRequest(http.MethodGet, "/mcp/", nil)
|
||||
req.Host = "api.example.com"
|
||||
return req
|
||||
},
|
||||
resourcePath: "/mcp/",
|
||||
expectedURL: "http://api.example.com/.well-known/oauth-protected-resource/mcp/",
|
||||
},
|
||||
{
|
||||
name: "with custom resource path",
|
||||
cfg: &Config{},
|
||||
setupRequest: func() *http.Request {
|
||||
req := httptest.NewRequest(http.MethodGet, "/mcp", nil)
|
||||
req.Host = "api.example.com"
|
||||
return req
|
||||
},
|
||||
resourcePath: "/mcp",
|
||||
expectedURL: "http://api.example.com/.well-known/oauth-protected-resource/mcp",
|
||||
},
|
||||
{
|
||||
name: "with base URL config",
|
||||
cfg: &Config{
|
||||
BaseURL: "https://custom.example.com",
|
||||
},
|
||||
setupRequest: func() *http.Request {
|
||||
req := httptest.NewRequest(http.MethodGet, "/mcp", nil)
|
||||
req.Host = "api.example.com"
|
||||
return req
|
||||
},
|
||||
resourcePath: "/mcp",
|
||||
expectedURL: "https://custom.example.com/.well-known/oauth-protected-resource/mcp",
|
||||
},
|
||||
{
|
||||
name: "with forwarded headers ignored by default",
|
||||
cfg: &Config{},
|
||||
setupRequest: func() *http.Request {
|
||||
req := httptest.NewRequest(http.MethodGet, "/mcp", nil)
|
||||
req.Host = "internal.example.com"
|
||||
req.Header.Set(headers.ForwardedHostHeader, "attacker.example.com")
|
||||
req.Header.Set(headers.ForwardedProtoHeader, "https")
|
||||
return req
|
||||
},
|
||||
resourcePath: "/mcp",
|
||||
expectedURL: "http://internal.example.com/.well-known/oauth-protected-resource/mcp",
|
||||
},
|
||||
{
|
||||
name: "with forwarded headers",
|
||||
cfg: &Config{TrustProxyHeaders: true},
|
||||
setupRequest: func() *http.Request {
|
||||
req := httptest.NewRequest(http.MethodGet, "/mcp", nil)
|
||||
req.Host = "internal.example.com"
|
||||
req.Header.Set(headers.ForwardedHostHeader, "public.example.com")
|
||||
req.Header.Set(headers.ForwardedProtoHeader, "https")
|
||||
return req
|
||||
},
|
||||
resourcePath: "/mcp",
|
||||
expectedURL: "https://public.example.com/.well-known/oauth-protected-resource/mcp",
|
||||
},
|
||||
{
|
||||
name: "nil config uses request host",
|
||||
cfg: nil,
|
||||
setupRequest: func() *http.Request {
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.Host = "api.example.com"
|
||||
return req
|
||||
},
|
||||
resourcePath: "",
|
||||
expectedURL: "http://api.example.com/.well-known/oauth-protected-resource",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
req := tc.setupRequest()
|
||||
url := BuildResourceMetadataURL(req, tc.cfg, tc.resourcePath)
|
||||
|
||||
assert.Equal(t, tc.expectedURL, url)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleProtectedResource(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg *Config
|
||||
path string
|
||||
host string
|
||||
method string
|
||||
expectedStatusCode int
|
||||
expectedScopes []string
|
||||
validateResponse func(t *testing.T, body map[string]any)
|
||||
}{
|
||||
{
|
||||
name: "GET request returns protected resource metadata",
|
||||
cfg: &Config{
|
||||
BaseURL: "https://api.example.com",
|
||||
},
|
||||
path: OAuthProtectedResourcePrefix,
|
||||
host: "api.example.com",
|
||||
method: http.MethodGet,
|
||||
expectedStatusCode: http.StatusOK,
|
||||
expectedScopes: SupportedScopes,
|
||||
validateResponse: func(t *testing.T, body map[string]any) {
|
||||
t.Helper()
|
||||
assert.Equal(t, "GitHub MCP Server", body["resource_name"])
|
||||
assert.Equal(t, "https://api.example.com/", body["resource"])
|
||||
|
||||
authServers, ok := body["authorization_servers"].([]any)
|
||||
require.True(t, ok)
|
||||
require.Len(t, authServers, 1)
|
||||
assert.Equal(t, defaultAuthorizationServer, authServers[0])
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "OPTIONS request for CORS preflight",
|
||||
cfg: &Config{
|
||||
BaseURL: "https://api.example.com",
|
||||
},
|
||||
path: OAuthProtectedResourcePrefix,
|
||||
host: "api.example.com",
|
||||
method: http.MethodOptions,
|
||||
expectedStatusCode: http.StatusNoContent,
|
||||
},
|
||||
{
|
||||
name: "path with /mcp suffix",
|
||||
cfg: &Config{
|
||||
BaseURL: "https://api.example.com",
|
||||
},
|
||||
path: OAuthProtectedResourcePrefix + "/mcp",
|
||||
host: "api.example.com",
|
||||
method: http.MethodGet,
|
||||
expectedStatusCode: http.StatusOK,
|
||||
validateResponse: func(t *testing.T, body map[string]any) {
|
||||
t.Helper()
|
||||
assert.Equal(t, "https://api.example.com/mcp", body["resource"])
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "path with /readonly suffix",
|
||||
cfg: &Config{
|
||||
BaseURL: "https://api.example.com",
|
||||
},
|
||||
path: OAuthProtectedResourcePrefix + "/readonly",
|
||||
host: "api.example.com",
|
||||
method: http.MethodGet,
|
||||
expectedStatusCode: http.StatusOK,
|
||||
validateResponse: func(t *testing.T, body map[string]any) {
|
||||
t.Helper()
|
||||
assert.Equal(t, "https://api.example.com/readonly", body["resource"])
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "path with trailing slash",
|
||||
cfg: &Config{
|
||||
BaseURL: "https://api.example.com",
|
||||
},
|
||||
path: OAuthProtectedResourcePrefix + "/mcp/",
|
||||
host: "api.example.com",
|
||||
method: http.MethodGet,
|
||||
expectedStatusCode: http.StatusOK,
|
||||
validateResponse: func(t *testing.T, body map[string]any) {
|
||||
t.Helper()
|
||||
assert.Equal(t, "https://api.example.com/mcp/", body["resource"])
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "custom authorization server in response",
|
||||
cfg: &Config{
|
||||
BaseURL: "https://api.example.com",
|
||||
AuthorizationServer: "https://custom.auth.example.com/oauth",
|
||||
},
|
||||
path: OAuthProtectedResourcePrefix,
|
||||
host: "api.example.com",
|
||||
method: http.MethodGet,
|
||||
expectedStatusCode: http.StatusOK,
|
||||
validateResponse: func(t *testing.T, body map[string]any) {
|
||||
t.Helper()
|
||||
authServers, ok := body["authorization_servers"].([]any)
|
||||
require.True(t, ok)
|
||||
require.Len(t, authServers, 1)
|
||||
assert.Equal(t, "https://custom.auth.example.com/oauth", authServers[0])
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dotcomHost, err := utils.NewAPIHost("https://api.github.com")
|
||||
require.NoError(t, err)
|
||||
|
||||
handler, err := NewAuthHandler(tc.cfg, dotcomHost)
|
||||
require.NoError(t, err)
|
||||
|
||||
router := chi.NewRouter()
|
||||
handler.RegisterRoutes(router)
|
||||
|
||||
req := httptest.NewRequest(tc.method, tc.path, nil)
|
||||
req.Host = tc.host
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
assert.Equal(t, tc.expectedStatusCode, rec.Code)
|
||||
|
||||
// Check CORS headers
|
||||
assert.Equal(t, "*", rec.Header().Get("Access-Control-Allow-Origin"))
|
||||
assert.Contains(t, rec.Header().Get("Access-Control-Allow-Methods"), "GET")
|
||||
assert.Contains(t, rec.Header().Get("Access-Control-Allow-Methods"), "OPTIONS")
|
||||
|
||||
if tc.method == http.MethodGet && tc.validateResponse != nil {
|
||||
assert.Equal(t, "application/json", rec.Header().Get("Content-Type"))
|
||||
|
||||
var body map[string]any
|
||||
err := json.Unmarshal(rec.Body.Bytes(), &body)
|
||||
require.NoError(t, err)
|
||||
|
||||
tc.validateResponse(t, body)
|
||||
|
||||
// Verify scopes if expected
|
||||
if tc.expectedScopes != nil {
|
||||
scopes, ok := body["scopes_supported"].([]any)
|
||||
require.True(t, ok)
|
||||
assert.Len(t, scopes, len(tc.expectedScopes))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterRoutes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dotcomHost, err := utils.NewAPIHost("https://api.github.com")
|
||||
require.NoError(t, err)
|
||||
|
||||
handler, err := NewAuthHandler(&Config{
|
||||
BaseURL: "https://api.example.com",
|
||||
}, dotcomHost)
|
||||
require.NoError(t, err)
|
||||
|
||||
router := chi.NewRouter()
|
||||
handler.RegisterRoutes(router)
|
||||
|
||||
// List of expected routes that should be registered
|
||||
expectedRoutes := []string{
|
||||
OAuthProtectedResourcePrefix,
|
||||
OAuthProtectedResourcePrefix + "/",
|
||||
OAuthProtectedResourcePrefix + "/mcp",
|
||||
OAuthProtectedResourcePrefix + "/mcp/",
|
||||
OAuthProtectedResourcePrefix + "/readonly",
|
||||
OAuthProtectedResourcePrefix + "/readonly/",
|
||||
OAuthProtectedResourcePrefix + "/mcp/readonly",
|
||||
OAuthProtectedResourcePrefix + "/mcp/readonly/",
|
||||
OAuthProtectedResourcePrefix + "/x/repos",
|
||||
OAuthProtectedResourcePrefix + "/mcp/x/repos",
|
||||
}
|
||||
|
||||
for _, route := range expectedRoutes {
|
||||
t.Run("route:"+route, func(t *testing.T) {
|
||||
// Test GET
|
||||
req := httptest.NewRequest(http.MethodGet, route, nil)
|
||||
req.Host = "api.example.com"
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
assert.Equal(t, http.StatusOK, rec.Code, "GET %s should return 200", route)
|
||||
|
||||
// Test OPTIONS (CORS preflight)
|
||||
req = httptest.NewRequest(http.MethodOptions, route, nil)
|
||||
req.Host = "api.example.com"
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
assert.Equal(t, http.StatusNoContent, rec.Code, "OPTIONS %s should return 204", route)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSupportedScopes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Verify all expected scopes are present
|
||||
expectedScopes := []string{
|
||||
"repo",
|
||||
"read:org",
|
||||
"read:user",
|
||||
"user:email",
|
||||
"read:packages",
|
||||
"write:packages",
|
||||
"read:project",
|
||||
"project",
|
||||
"gist",
|
||||
"notifications",
|
||||
"workflow",
|
||||
"codespace",
|
||||
}
|
||||
|
||||
assert.Equal(t, expectedScopes, SupportedScopes)
|
||||
}
|
||||
|
||||
func TestProtectedResourceResponseFormat(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dotcomHost, err := utils.NewAPIHost("https://api.github.com")
|
||||
require.NoError(t, err)
|
||||
|
||||
handler, err := NewAuthHandler(&Config{
|
||||
BaseURL: "https://api.example.com",
|
||||
}, dotcomHost)
|
||||
require.NoError(t, err)
|
||||
|
||||
router := chi.NewRouter()
|
||||
handler.RegisterRoutes(router)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, OAuthProtectedResourcePrefix, nil)
|
||||
req.Host = "api.example.com"
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
|
||||
var response map[string]any
|
||||
err = json.Unmarshal(rec.Body.Bytes(), &response)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify all required RFC 9728 fields are present
|
||||
assert.Contains(t, response, "resource")
|
||||
assert.Contains(t, response, "authorization_servers")
|
||||
assert.Contains(t, response, "bearer_methods_supported")
|
||||
assert.Contains(t, response, "scopes_supported")
|
||||
|
||||
// Verify resource name (optional but we include it)
|
||||
assert.Contains(t, response, "resource_name")
|
||||
assert.Equal(t, "GitHub MCP Server", response["resource_name"])
|
||||
|
||||
// Verify bearer_methods_supported contains "header"
|
||||
bearerMethods, ok := response["bearer_methods_supported"].([]any)
|
||||
require.True(t, ok)
|
||||
assert.Contains(t, bearerMethods, "header")
|
||||
|
||||
// Verify authorization_servers is an array with GitHub OAuth
|
||||
authServers, ok := response["authorization_servers"].([]any)
|
||||
require.True(t, ok)
|
||||
assert.Len(t, authServers, 1)
|
||||
assert.Equal(t, defaultAuthorizationServer, authServers[0])
|
||||
}
|
||||
|
||||
func TestOAuthProtectedResourcePrefix(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// RFC 9728 specifies this well-known path
|
||||
assert.Equal(t, "/.well-known/oauth-protected-resource", OAuthProtectedResourcePrefix)
|
||||
}
|
||||
|
||||
func TestDefaultAuthorizationServer(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.Equal(t, "https://github.com/login/oauth", defaultAuthorizationServer)
|
||||
}
|
||||
|
||||
func TestAPIHostResolver_AuthorizationServerURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
host string
|
||||
oauthConfig *Config
|
||||
expectedURL string
|
||||
expectedError bool
|
||||
expectedStatusCode int
|
||||
errorContains string
|
||||
}{
|
||||
{
|
||||
name: "valid host returns authorization server URL",
|
||||
host: "https://github.com",
|
||||
expectedURL: "https://github.com/login/oauth",
|
||||
expectedStatusCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "invalid host returns error",
|
||||
host: "://invalid-url",
|
||||
expectedURL: "",
|
||||
expectedError: true,
|
||||
errorContains: "could not parse host as URL",
|
||||
},
|
||||
{
|
||||
name: "host without scheme returns error",
|
||||
host: "github.com",
|
||||
expectedURL: "",
|
||||
expectedError: true,
|
||||
errorContains: "host must have a scheme",
|
||||
},
|
||||
{
|
||||
name: "GHEC host returns correct authorization server URL",
|
||||
host: "https://test.ghe.com",
|
||||
expectedURL: "https://test.ghe.com/login/oauth",
|
||||
expectedStatusCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "GHES host returns correct authorization server URL",
|
||||
host: "https://ghe.example.com",
|
||||
expectedURL: "https://ghe.example.com/login/oauth",
|
||||
expectedStatusCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "GHES with http scheme returns the correct authorization server URL",
|
||||
host: "http://ghe.example.com",
|
||||
expectedURL: "http://ghe.example.com/login/oauth",
|
||||
expectedStatusCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "custom authorization server in config takes precedence",
|
||||
host: "https://github.com",
|
||||
oauthConfig: &Config{
|
||||
AuthorizationServer: "https://custom.auth.example.com/oauth",
|
||||
},
|
||||
expectedURL: "https://custom.auth.example.com/oauth",
|
||||
expectedStatusCode: http.StatusOK,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
apiHost, err := utils.NewAPIHost(tc.host)
|
||||
if tc.expectedError {
|
||||
require.Error(t, err)
|
||||
if tc.errorContains != "" {
|
||||
assert.Contains(t, err.Error(), tc.errorContains)
|
||||
}
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
|
||||
config := tc.oauthConfig
|
||||
if config == nil {
|
||||
config = &Config{}
|
||||
}
|
||||
config.BaseURL = tc.host
|
||||
|
||||
handler, err := NewAuthHandler(config, apiHost)
|
||||
require.NoError(t, err)
|
||||
|
||||
router := chi.NewRouter()
|
||||
handler.RegisterRoutes(router)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, OAuthProtectedResourcePrefix, nil)
|
||||
req.Host = "api.example.com"
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
|
||||
var response map[string]any
|
||||
err = json.Unmarshal(rec.Body.Bytes(), &response)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Contains(t, response, "authorization_servers")
|
||||
if tc.expectedStatusCode != http.StatusOK {
|
||||
require.Equal(t, tc.expectedStatusCode, rec.Code)
|
||||
if tc.errorContains != "" {
|
||||
assert.Contains(t, rec.Body.String(), tc.errorContains)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
responseAuthServers, ok := response["authorization_servers"].([]any)
|
||||
require.True(t, ok)
|
||||
require.Len(t, responseAuthServers, 1)
|
||||
assert.Equal(t, tc.expectedURL, responseAuthServers[0])
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user