322 lines
11 KiB
Go
322 lines
11 KiB
Go
package middleware
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
ghcontext "github.com/github/github-mcp-server/pkg/context"
|
|
"github.com/github/github-mcp-server/pkg/http/headers"
|
|
"github.com/github/github-mcp-server/pkg/http/oauth"
|
|
"github.com/github/github-mcp-server/pkg/utils"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func TestExtractUserToken(t *testing.T) {
|
|
oauthCfg := &oauth.Config{
|
|
BaseURL: "https://example.com",
|
|
AuthorizationServer: "https://github.com/login/oauth",
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
authHeader string
|
|
expectedStatusCode int
|
|
expectedTokenType utils.TokenType
|
|
expectedToken string
|
|
expectTokenInfo bool
|
|
expectWWWAuth bool
|
|
}{
|
|
// Missing authorization header
|
|
{
|
|
name: "missing Authorization header returns 401 with WWW-Authenticate",
|
|
authHeader: "",
|
|
expectedStatusCode: http.StatusUnauthorized,
|
|
expectTokenInfo: false,
|
|
expectWWWAuth: true,
|
|
},
|
|
// Personal Access Token (classic) - ghp_ prefix
|
|
{
|
|
name: "personal access token (classic) with Bearer prefix",
|
|
authHeader: "Bearer ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
|
expectedStatusCode: http.StatusOK,
|
|
expectedTokenType: utils.TokenTypePersonalAccessToken,
|
|
expectedToken: "ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
|
expectTokenInfo: true,
|
|
},
|
|
{
|
|
name: "personal access token (classic) with bearer lowercase",
|
|
authHeader: "bearer ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
|
expectedStatusCode: http.StatusOK,
|
|
expectedTokenType: utils.TokenTypePersonalAccessToken,
|
|
expectedToken: "ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
|
expectTokenInfo: true,
|
|
},
|
|
{
|
|
name: "personal access token (classic) without Bearer prefix",
|
|
authHeader: "ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
|
expectedStatusCode: http.StatusOK,
|
|
expectedTokenType: utils.TokenTypePersonalAccessToken,
|
|
expectedToken: "ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
|
expectTokenInfo: true,
|
|
},
|
|
// Fine-grained Personal Access Token - github_pat_ prefix
|
|
{
|
|
name: "fine-grained personal access token with Bearer prefix",
|
|
authHeader: "Bearer github_pat_xxxxxxxxxxxxxxxxxxxxxxx",
|
|
expectedStatusCode: http.StatusOK,
|
|
expectedTokenType: utils.TokenTypeFineGrainedPersonalAccessToken,
|
|
expectedToken: "github_pat_xxxxxxxxxxxxxxxxxxxxxxx",
|
|
expectTokenInfo: true,
|
|
},
|
|
{
|
|
name: "fine-grained personal access token without Bearer prefix",
|
|
authHeader: "github_pat_xxxxxxxxxxxxxxxxxxxxxxx",
|
|
expectedStatusCode: http.StatusOK,
|
|
expectedTokenType: utils.TokenTypeFineGrainedPersonalAccessToken,
|
|
expectedToken: "github_pat_xxxxxxxxxxxxxxxxxxxxxxx",
|
|
expectTokenInfo: true,
|
|
},
|
|
// OAuth Access Token - gho_ prefix
|
|
{
|
|
name: "OAuth access token with Bearer prefix",
|
|
authHeader: "Bearer gho_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
|
expectedStatusCode: http.StatusOK,
|
|
expectedTokenType: utils.TokenTypeOAuthAccessToken,
|
|
expectedToken: "gho_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
|
expectTokenInfo: true,
|
|
},
|
|
{
|
|
name: "OAuth access token without Bearer prefix",
|
|
authHeader: "gho_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
|
expectedStatusCode: http.StatusOK,
|
|
expectedTokenType: utils.TokenTypeOAuthAccessToken,
|
|
expectedToken: "gho_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
|
expectTokenInfo: true,
|
|
},
|
|
// User-to-Server GitHub App Token - ghu_ prefix
|
|
{
|
|
name: "user-to-server GitHub App token with Bearer prefix",
|
|
authHeader: "Bearer ghu_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
|
expectedStatusCode: http.StatusOK,
|
|
expectedTokenType: utils.TokenTypeUserToServerGitHubAppToken,
|
|
expectedToken: "ghu_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
|
expectTokenInfo: true,
|
|
},
|
|
{
|
|
name: "user-to-server GitHub App token without Bearer prefix",
|
|
authHeader: "ghu_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
|
expectedStatusCode: http.StatusOK,
|
|
expectedTokenType: utils.TokenTypeUserToServerGitHubAppToken,
|
|
expectedToken: "ghu_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
|
expectTokenInfo: true,
|
|
},
|
|
// Server-to-Server GitHub App Token (installation token) - ghs_ prefix
|
|
{
|
|
name: "server-to-server GitHub App token with Bearer prefix",
|
|
authHeader: "Bearer ghs_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
|
expectedStatusCode: http.StatusOK,
|
|
expectedTokenType: utils.TokenTypeServerToServerGitHubAppToken,
|
|
expectedToken: "ghs_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
|
expectTokenInfo: true,
|
|
},
|
|
{
|
|
name: "server-to-server GitHub App token without Bearer prefix",
|
|
authHeader: "ghs_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
|
expectedStatusCode: http.StatusOK,
|
|
expectedTokenType: utils.TokenTypeServerToServerGitHubAppToken,
|
|
expectedToken: "ghs_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
|
expectTokenInfo: true,
|
|
},
|
|
// Old-style Personal Access Token (40 hex characters, pre-2021)
|
|
{
|
|
name: "old-style personal access token (40 hex chars) with Bearer prefix",
|
|
authHeader: "Bearer 0123456789abcdef0123456789abcdef01234567",
|
|
expectedStatusCode: http.StatusOK,
|
|
expectedTokenType: utils.TokenTypePersonalAccessToken,
|
|
expectedToken: "0123456789abcdef0123456789abcdef01234567",
|
|
expectTokenInfo: true,
|
|
},
|
|
{
|
|
name: "old-style personal access token (40 hex chars) without Bearer prefix",
|
|
authHeader: "0123456789abcdef0123456789abcdef01234567",
|
|
expectedStatusCode: http.StatusOK,
|
|
expectedTokenType: utils.TokenTypePersonalAccessToken,
|
|
expectedToken: "0123456789abcdef0123456789abcdef01234567",
|
|
expectTokenInfo: true,
|
|
},
|
|
// Error cases
|
|
{
|
|
name: "unsupported GitHub-Bearer header returns 400",
|
|
authHeader: "GitHub-Bearer some_encrypted_token",
|
|
expectedStatusCode: http.StatusBadRequest,
|
|
expectTokenInfo: false,
|
|
},
|
|
{
|
|
name: "invalid token format returns 400",
|
|
authHeader: "Bearer invalid_token_format",
|
|
expectedStatusCode: http.StatusBadRequest,
|
|
expectTokenInfo: false,
|
|
},
|
|
{
|
|
name: "unrecognized prefix returns 400",
|
|
authHeader: "Bearer xyz_notavalidprefix",
|
|
expectedStatusCode: http.StatusBadRequest,
|
|
expectTokenInfo: false,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
var capturedTokenInfo *ghcontext.TokenInfo
|
|
var tokenInfoCaptured bool
|
|
|
|
nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
capturedTokenInfo, tokenInfoCaptured = ghcontext.GetTokenInfo(r.Context())
|
|
w.WriteHeader(http.StatusOK)
|
|
})
|
|
|
|
middleware := ExtractUserToken(oauthCfg)
|
|
handler := middleware(nextHandler)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
|
if tt.authHeader != "" {
|
|
req.Header.Set(headers.AuthorizationHeader, tt.authHeader)
|
|
}
|
|
rr := httptest.NewRecorder()
|
|
|
|
handler.ServeHTTP(rr, req)
|
|
|
|
assert.Equal(t, tt.expectedStatusCode, rr.Code)
|
|
|
|
if tt.expectWWWAuth {
|
|
wwwAuth := rr.Header().Get("WWW-Authenticate")
|
|
assert.NotEmpty(t, wwwAuth, "expected WWW-Authenticate header")
|
|
assert.Contains(t, wwwAuth, "Bearer resource_metadata=")
|
|
}
|
|
|
|
if tt.expectTokenInfo {
|
|
require.True(t, tokenInfoCaptured, "expected TokenInfo to be present in context")
|
|
require.NotNil(t, capturedTokenInfo)
|
|
assert.Equal(t, tt.expectedTokenType, capturedTokenInfo.TokenType)
|
|
assert.Equal(t, tt.expectedToken, capturedTokenInfo.Token)
|
|
} else {
|
|
assert.False(t, tokenInfoCaptured, "expected no TokenInfo in context")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestExtractUserToken_NilOAuthConfig(t *testing.T) {
|
|
var capturedTokenInfo *ghcontext.TokenInfo
|
|
var tokenInfoCaptured bool
|
|
|
|
nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
capturedTokenInfo, tokenInfoCaptured = ghcontext.GetTokenInfo(r.Context())
|
|
w.WriteHeader(http.StatusOK)
|
|
})
|
|
|
|
middleware := ExtractUserToken(nil)
|
|
handler := middleware(nextHandler)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
|
req.Header.Set(headers.AuthorizationHeader, "Bearer ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx")
|
|
rr := httptest.NewRecorder()
|
|
|
|
handler.ServeHTTP(rr, req)
|
|
|
|
assert.Equal(t, http.StatusOK, rr.Code)
|
|
require.True(t, tokenInfoCaptured)
|
|
require.NotNil(t, capturedTokenInfo)
|
|
assert.Equal(t, utils.TokenTypePersonalAccessToken, capturedTokenInfo.TokenType)
|
|
}
|
|
|
|
func TestExtractUserToken_MissingAuthHeader_WWWAuthenticateFormat(t *testing.T) {
|
|
oauthCfg := &oauth.Config{
|
|
BaseURL: "https://api.example.com",
|
|
AuthorizationServer: "https://github.com/login/oauth",
|
|
ResourcePath: "/mcp",
|
|
}
|
|
|
|
nextHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
})
|
|
|
|
middleware := ExtractUserToken(oauthCfg)
|
|
handler := middleware(nextHandler)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
|
// No Authorization header
|
|
rr := httptest.NewRecorder()
|
|
|
|
handler.ServeHTTP(rr, req)
|
|
|
|
assert.Equal(t, http.StatusUnauthorized, rr.Code)
|
|
wwwAuth := rr.Header().Get("WWW-Authenticate")
|
|
assert.NotEmpty(t, wwwAuth)
|
|
assert.Contains(t, wwwAuth, "Bearer")
|
|
assert.Contains(t, wwwAuth, "resource_metadata=")
|
|
assert.Contains(t, wwwAuth, "/.well-known/oauth-protected-resource")
|
|
}
|
|
|
|
func TestSendAuthChallenge(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
oauthCfg *oauth.Config
|
|
requestPath string
|
|
expectedContains []string
|
|
}{
|
|
{
|
|
name: "with base URL configured",
|
|
oauthCfg: &oauth.Config{
|
|
BaseURL: "https://mcp.example.com",
|
|
},
|
|
requestPath: "/api/test",
|
|
expectedContains: []string{
|
|
"Bearer",
|
|
"resource_metadata=",
|
|
"https://mcp.example.com/.well-known/oauth-protected-resource",
|
|
},
|
|
},
|
|
{
|
|
name: "with nil config uses request host",
|
|
oauthCfg: nil,
|
|
requestPath: "/api/test",
|
|
expectedContains: []string{
|
|
"Bearer",
|
|
"resource_metadata=",
|
|
"/.well-known/oauth-protected-resource",
|
|
},
|
|
},
|
|
{
|
|
name: "with resource path configured",
|
|
oauthCfg: &oauth.Config{
|
|
BaseURL: "https://mcp.example.com",
|
|
ResourcePath: "/mcp",
|
|
},
|
|
requestPath: "/api/test",
|
|
expectedContains: []string{
|
|
"Bearer",
|
|
"resource_metadata=",
|
|
"/mcp",
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
rr := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, tt.requestPath, nil)
|
|
|
|
sendAuthChallenge(rr, req, tt.oauthCfg)
|
|
|
|
assert.Equal(t, http.StatusUnauthorized, rr.Code)
|
|
wwwAuth := rr.Header().Get("WWW-Authenticate")
|
|
for _, expected := range tt.expectedContains {
|
|
assert.Contains(t, wwwAuth, expected)
|
|
}
|
|
})
|
|
}
|
|
}
|