chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,321 @@
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user