d322e26d91
When the server starts without a GITHUB_PERSONAL_ACCESS_TOKEN, it now starts in 'unauthenticated mode' with only an auth_login tool available. The auth_login tool: - Initiates the OAuth device flow with GitHub - Uses MCP URL elicitation to show the verification URL and user code - Polls for completion while showing progress notifications - Upon success, dynamically registers all configured GitHub tools This enables a much simpler setup experience - users no longer need to pre-configure a PAT. They can simply start the server and authenticate interactively when prompted. Key changes: - New AuthManager in pkg/github/auth.go handles device flow state - New auth_login tool in pkg/github/auth_tools.go - NewUnauthenticatedMCPServer in internal/ghmcp/server.go for token-less startup - CLI flags --oauth-client-id and --oauth-client-secret for enterprise scenarios - Support for github.com, GHES, and GHEC hosts The token is held in memory for the session duration - no persistent storage, which is ideal for Docker --rm workflows. Closes #132
280 lines
8.1 KiB
Go
280 lines
8.1 KiB
Go
package github
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func TestNewOAuthHostFromAPIHost(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
apiHost string
|
|
expectedHostname string
|
|
expectedDevice string
|
|
expectedToken string
|
|
}{
|
|
{
|
|
name: "github.com (empty host)",
|
|
apiHost: "",
|
|
expectedHostname: "github.com",
|
|
expectedDevice: "https://github.com/login/device/code",
|
|
expectedToken: "https://github.com/login/oauth/access_token",
|
|
},
|
|
{
|
|
name: "github.com (explicit)",
|
|
apiHost: "github.com",
|
|
expectedHostname: "github.com",
|
|
expectedDevice: "https://github.com/login/device/code",
|
|
expectedToken: "https://github.com/login/oauth/access_token",
|
|
},
|
|
{
|
|
name: "GHES without scheme",
|
|
apiHost: "github.enterprise.com",
|
|
expectedHostname: "github.enterprise.com",
|
|
expectedDevice: "https://github.enterprise.com/login/device/code",
|
|
expectedToken: "https://github.enterprise.com/login/oauth/access_token",
|
|
},
|
|
{
|
|
name: "GHES with https scheme",
|
|
apiHost: "https://github.enterprise.com",
|
|
expectedHostname: "github.enterprise.com",
|
|
expectedDevice: "https://github.enterprise.com/login/device/code",
|
|
expectedToken: "https://github.enterprise.com/login/oauth/access_token",
|
|
},
|
|
{
|
|
name: "GHEC tenant",
|
|
apiHost: "company.ghe.com",
|
|
expectedHostname: "company.ghe.com",
|
|
expectedDevice: "https://company.ghe.com/login/device/code",
|
|
expectedToken: "https://company.ghe.com/login/oauth/access_token",
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
host := NewOAuthHostFromAPIHost(tc.apiHost)
|
|
assert.Equal(t, tc.expectedHostname, host.Hostname)
|
|
assert.Equal(t, tc.expectedDevice, host.DeviceCodeURL)
|
|
assert.Equal(t, tc.expectedToken, host.TokenURL)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAuthManager_StateTransitions(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
host := NewOAuthHostFromAPIHost("")
|
|
authMgr := NewAuthManager(host, "test-client-id", "", nil)
|
|
|
|
// Initial state should be unauthenticated
|
|
assert.Equal(t, AuthStateUnauthenticated, authMgr.State())
|
|
assert.False(t, authMgr.IsAuthenticated())
|
|
assert.Empty(t, authMgr.Token())
|
|
|
|
// Cannot call Reset when not pending
|
|
authMgr.Reset()
|
|
assert.Equal(t, AuthStateUnauthenticated, authMgr.State())
|
|
}
|
|
|
|
func TestAuthManager_StartDeviceFlow(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Create a mock OAuth server
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/login/device/code" {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
resp := map[string]interface{}{
|
|
"device_code": "test-device-code",
|
|
"user_code": "ABCD-1234",
|
|
"verification_uri": "https://github.com/login/device",
|
|
"expires_in": 900,
|
|
"interval": 5,
|
|
}
|
|
_ = json.NewEncoder(w).Encode(resp)
|
|
return
|
|
}
|
|
http.NotFound(w, r)
|
|
}))
|
|
defer server.Close()
|
|
|
|
host := OAuthHost{
|
|
Hostname: "test.example.com",
|
|
DeviceCodeURL: server.URL + "/login/device/code",
|
|
TokenURL: server.URL + "/login/oauth/access_token",
|
|
}
|
|
|
|
authMgr := NewAuthManager(host, "test-client-id", "", nil)
|
|
|
|
// Start the device flow
|
|
deviceResp, err := authMgr.StartDeviceFlow(context.Background())
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "test-device-code", deviceResp.DeviceCode)
|
|
assert.Equal(t, "ABCD-1234", deviceResp.UserCode)
|
|
assert.Equal(t, "https://github.com/login/device", deviceResp.VerificationURI)
|
|
|
|
// State should now be pending
|
|
assert.Equal(t, AuthStatePending, authMgr.State())
|
|
}
|
|
|
|
func TestAuthManager_CompleteDeviceFlow_Success(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Track poll attempts
|
|
pollCount := 0
|
|
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/login/device/code" {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
resp := map[string]interface{}{
|
|
"device_code": "test-device-code",
|
|
"user_code": "ABCD-1234",
|
|
"verification_uri": "https://github.com/login/device",
|
|
"expires_in": 900,
|
|
"interval": 1, // Short interval for test
|
|
}
|
|
_ = json.NewEncoder(w).Encode(resp)
|
|
return
|
|
}
|
|
if r.URL.Path == "/login/oauth/access_token" {
|
|
pollCount++
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if pollCount < 2 {
|
|
// First poll returns pending
|
|
resp := map[string]interface{}{
|
|
"error": "authorization_pending",
|
|
}
|
|
_ = json.NewEncoder(w).Encode(resp)
|
|
} else {
|
|
// Second poll returns token
|
|
resp := map[string]interface{}{
|
|
"access_token": "gho_test_token_12345",
|
|
"token_type": "bearer",
|
|
"scope": "repo,read:org",
|
|
}
|
|
_ = json.NewEncoder(w).Encode(resp)
|
|
}
|
|
return
|
|
}
|
|
http.NotFound(w, r)
|
|
}))
|
|
defer server.Close()
|
|
|
|
host := OAuthHost{
|
|
Hostname: "test.example.com",
|
|
DeviceCodeURL: server.URL + "/login/device/code",
|
|
TokenURL: server.URL + "/login/oauth/access_token",
|
|
}
|
|
|
|
authMgr := NewAuthManager(host, "test-client-id", "", nil)
|
|
|
|
// Start the device flow
|
|
_, err := authMgr.StartDeviceFlow(context.Background())
|
|
require.NoError(t, err)
|
|
|
|
// Complete the flow
|
|
err = authMgr.CompleteDeviceFlow(context.Background())
|
|
require.NoError(t, err)
|
|
|
|
// Should now be authenticated
|
|
assert.Equal(t, AuthStateAuthenticated, authMgr.State())
|
|
assert.True(t, authMgr.IsAuthenticated())
|
|
assert.Equal(t, "gho_test_token_12345", authMgr.Token())
|
|
}
|
|
|
|
func TestAuthManager_CompleteDeviceFlow_AccessDenied(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/login/device/code" {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
resp := map[string]interface{}{
|
|
"device_code": "test-device-code",
|
|
"user_code": "ABCD-1234",
|
|
"verification_uri": "https://github.com/login/device",
|
|
"expires_in": 900,
|
|
"interval": 1,
|
|
}
|
|
_ = json.NewEncoder(w).Encode(resp)
|
|
return
|
|
}
|
|
if r.URL.Path == "/login/oauth/access_token" {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
resp := map[string]interface{}{
|
|
"error": "access_denied",
|
|
"error_description": "The user has denied your request.",
|
|
}
|
|
_ = json.NewEncoder(w).Encode(resp)
|
|
return
|
|
}
|
|
http.NotFound(w, r)
|
|
}))
|
|
defer server.Close()
|
|
|
|
host := OAuthHost{
|
|
Hostname: "test.example.com",
|
|
DeviceCodeURL: server.URL + "/login/device/code",
|
|
TokenURL: server.URL + "/login/oauth/access_token",
|
|
}
|
|
|
|
authMgr := NewAuthManager(host, "test-client-id", "", nil)
|
|
|
|
// Start the device flow
|
|
_, err := authMgr.StartDeviceFlow(context.Background())
|
|
require.NoError(t, err)
|
|
|
|
// Complete the flow - should fail with access denied
|
|
err = authMgr.CompleteDeviceFlow(context.Background())
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "denied")
|
|
|
|
// Should be back to unauthenticated
|
|
assert.Equal(t, AuthStateUnauthenticated, authMgr.State())
|
|
}
|
|
|
|
func TestAuthManager_Reset(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/login/device/code" {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
resp := map[string]interface{}{
|
|
"device_code": "test-device-code",
|
|
"user_code": "ABCD-1234",
|
|
"verification_uri": "https://github.com/login/device",
|
|
"expires_in": 900,
|
|
"interval": 1,
|
|
}
|
|
_ = json.NewEncoder(w).Encode(resp)
|
|
return
|
|
}
|
|
http.NotFound(w, r)
|
|
}))
|
|
defer server.Close()
|
|
|
|
host := OAuthHost{
|
|
Hostname: "test.example.com",
|
|
DeviceCodeURL: server.URL + "/login/device/code",
|
|
TokenURL: server.URL + "/login/oauth/access_token",
|
|
}
|
|
|
|
authMgr := NewAuthManager(host, "test-client-id", "", nil)
|
|
|
|
// Start the device flow
|
|
_, err := authMgr.StartDeviceFlow(context.Background())
|
|
require.NoError(t, err)
|
|
assert.Equal(t, AuthStatePending, authMgr.State())
|
|
|
|
// Reset should clear the pending state
|
|
authMgr.Reset()
|
|
assert.Equal(t, AuthStateUnauthenticated, authMgr.State())
|
|
}
|