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]) }) } }