Files
github--github-mcp-server/pkg/github/auth_test.go
Sam Morrow d322e26d91 feat: implement OAuth device flow authentication
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
2025-12-19 01:08:52 +01:00

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