registry/internal/api/handlers/v0/auth/github_at_test.go
Radoslav Dimitrov 099a2d702e
Deduplicate server JSON structs between publisher and registry (#318)
<!-- Provide a brief summary of your changes -->

## Motivation and Context
<!-- Why is this change needed? What problem does it solve? -->

The following PR eliminates duplicate struct definitions by migrating
the publisher tool to use canonical types from pkg/model, creating a
single source of truth across the codebase. The publisher tool
previously had its own copies of structs that were duplicated, ie.
ServerJSON, Package, Repository, VersionDetail, EnvironmentVariable,
RuntimeArgument and a few others which was not optimal


**Changes in the PR:**
- Refactored the structs to single canonical types located at pkg/model
- Refactor the implementation to use/build canonical structures directly
  - Remove all duplicate definitions (5 structs eliminated)
- Publisher now uses model.ServerJSON, model.Package, model.Repository,
etc.
- Renamed some of the structs to better reflect their use case, i.e.
ServerJSON instead of ServerDetail
- Fixes an issue where the Remote property was missing from the previous
publisher copy of the Server

**Benefits:**
  - Single source of truth - Schema changes in one place
  - Type safety - Compiler catches mismatches
  - Enhanced validation - Field constraints and bson tags
  - Zero breaking changes - Same CLI interface and JSON output
  - Future-proof - New canonical fields automatically available

Supersedes: #217 and #240

## How Has This Been Tested?
<!-- Have you tested this in a real application? Which scenarios were
tested? -->

Locally

## Breaking Changes
<!-- Will users need to update their code or configurations? -->
No

## Types of changes
<!-- What types of changes does your code introduce? Put an `x` in all
the boxes that apply: -->
- [x] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update

## Checklist
<!-- Go over all the following points, and put an `x` in all the boxes
that apply. -->
- [ ] I have read the [MCP
Documentation](https://modelcontextprotocol.io)
- [ ] My code follows the repository's style guidelines
- [ ] New and existing tests pass locally
- [ ] I have added appropriate error handling
- [ ] I have added or updated documentation as needed

## Additional context
<!-- Add any other context, implementation notes, or design decisions
-->

---------

Signed-off-by: Radoslav Dimitrov <radoslav@stacklok.com>
2025-08-28 14:58:15 +01:00

626 lines
18 KiB
Go

package auth_test
import (
"context"
"crypto/ed25519"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
v0auth "github.com/modelcontextprotocol/registry/internal/api/handlers/v0/auth"
"github.com/modelcontextprotocol/registry/internal/auth"
"github.com/modelcontextprotocol/registry/internal/config"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const (
githubUserEndpoint = "/user"
githubOrgsEndpoint = "/users/testuser/orgs"
)
func TestGitHubHandler_ExchangeToken(t *testing.T) {
// Create test handler with mock config
testSeed := make([]byte, ed25519.SeedSize)
_, err := rand.Read(testSeed)
require.NoError(t, err)
cfg := &config.Config{
JWTPrivateKey: hex.EncodeToString(testSeed),
}
t.Run("successful token exchange with user only", func(t *testing.T) {
// Create mock GitHub API server
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Verify authorization header
authHeader := r.Header.Get("Authorization")
assert.Equal(t, "Bearer valid-github-token", authHeader)
switch r.URL.Path {
case githubUserEndpoint:
user := v0auth.GitHubUserOrOrg{
Login: "testuser",
ID: 12345,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(user) //nolint:errcheck
case githubOrgsEndpoint:
orgs := []v0auth.GitHubUserOrOrg{}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(orgs) //nolint:errcheck
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer mockServer.Close()
// Create handler and set mock server URL
handler := v0auth.NewGitHubHandler(cfg)
handler.SetBaseURL(mockServer.URL)
// Test token exchange
ctx := context.Background()
response, err := handler.ExchangeToken(ctx, "valid-github-token")
require.NoError(t, err)
assert.NotNil(t, response)
assert.NotEmpty(t, response.RegistryToken)
assert.Greater(t, response.ExpiresAt, 0)
// Validate the JWT token
jwtManager := auth.NewJWTManager(cfg)
claims, err := jwtManager.ValidateToken(ctx, response.RegistryToken)
require.NoError(t, err)
assert.Equal(t, auth.MethodGitHubAT, claims.AuthMethod)
assert.Equal(t, "testuser", claims.AuthMethodSubject)
assert.Len(t, claims.Permissions, 1)
assert.Equal(t, auth.PermissionActionPublish, claims.Permissions[0].Action)
assert.Equal(t, "io.github.testuser/*", claims.Permissions[0].ResourcePattern)
})
t.Run("successful token exchange with organizations", func(t *testing.T) {
// Create mock GitHub API server
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case githubUserEndpoint:
user := v0auth.GitHubUserOrOrg{
Login: "testuser",
ID: 12345,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(user) //nolint:errcheck
case githubOrgsEndpoint:
orgs := []v0auth.GitHubUserOrOrg{
{Login: "test-org-1", ID: 1},
{Login: "test-org-2", ID: 2},
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(orgs) //nolint:errcheck
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer mockServer.Close()
// Create handler and set mock server URL
handler := v0auth.NewGitHubHandler(cfg)
handler.SetBaseURL(mockServer.URL)
// Test token exchange
ctx := context.Background()
response, err := handler.ExchangeToken(ctx, "valid-github-token")
require.NoError(t, err)
assert.NotNil(t, response)
// Validate the JWT token
jwtManager := auth.NewJWTManager(cfg)
claims, err := jwtManager.ValidateToken(ctx, response.RegistryToken)
require.NoError(t, err)
assert.Equal(t, "testuser", claims.AuthMethodSubject)
assert.Len(t, claims.Permissions, 3) // User + 2 orgs
// Check permissions
expectedPatterns := []string{
"io.github.testuser/*",
"io.github.test-org-1/*",
"io.github.test-org-2/*",
}
for i, perm := range claims.Permissions {
assert.Equal(t, auth.PermissionActionPublish, perm.Action)
assert.Equal(t, expectedPatterns[i], perm.ResourcePattern)
}
})
t.Run("invalid token returns error", func(t *testing.T) {
// Create mock GitHub API server that returns 401
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte(`{"message": "Bad credentials"}`)) //nolint:errcheck
}))
defer mockServer.Close()
// Create handler and set mock server URL
handler := v0auth.NewGitHubHandler(cfg)
handler.SetBaseURL(mockServer.URL)
// Test token exchange
ctx := context.Background()
response, err := handler.ExchangeToken(ctx, "invalid-token")
require.Error(t, err)
assert.Nil(t, response)
assert.Contains(t, err.Error(), "GitHub API error")
assert.Contains(t, err.Error(), "401")
})
t.Run("GitHub API error on user fetch", func(t *testing.T) {
// Create mock GitHub API server that returns 500
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == githubUserEndpoint {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(`{"message": "Internal server error"}`)) //nolint:errcheck
}
}))
defer mockServer.Close()
// Create handler and set mock server URL
handler := v0auth.NewGitHubHandler(cfg)
handler.SetBaseURL(mockServer.URL)
// Test token exchange
ctx := context.Background()
response, err := handler.ExchangeToken(ctx, "valid-token")
require.Error(t, err)
assert.Nil(t, response)
assert.Contains(t, err.Error(), "failed to get GitHub user")
})
t.Run("GitHub API error on orgs fetch", func(t *testing.T) {
// Create mock GitHub API server
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case githubUserEndpoint:
user := v0auth.GitHubUserOrOrg{
Login: "testuser",
ID: 12345,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(user) //nolint:errcheck
case githubOrgsEndpoint:
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(`{"message": "Internal server error"}`)) //nolint:errcheck
}
}))
defer mockServer.Close()
// Create handler and set mock server URL
handler := v0auth.NewGitHubHandler(cfg)
handler.SetBaseURL(mockServer.URL)
// Test token exchange
ctx := context.Background()
response, err := handler.ExchangeToken(ctx, "valid-token")
require.Error(t, err)
assert.Nil(t, response)
assert.Contains(t, err.Error(), "failed to get GitHub organizations")
})
t.Run("invalid GitHub username returns empty permissions", func(t *testing.T) {
// Create mock GitHub API server with invalid username
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case githubUserEndpoint:
user := v0auth.GitHubUserOrOrg{
Login: "user with spaces", // Invalid name
ID: 12345,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(user) //nolint:errcheck
case "/users/user with spaces/orgs":
orgs := []v0auth.GitHubUserOrOrg{}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(orgs) //nolint:errcheck
}
}))
defer mockServer.Close()
// Create handler and set mock server URL
handler := v0auth.NewGitHubHandler(cfg)
handler.SetBaseURL(mockServer.URL)
// Test token exchange
ctx := context.Background()
response, err := handler.ExchangeToken(ctx, "valid-token")
require.NoError(t, err)
assert.NotNil(t, response)
// Validate the JWT token
jwtManager := auth.NewJWTManager(cfg)
claims, err := jwtManager.ValidateToken(ctx, response.RegistryToken)
require.NoError(t, err)
assert.Equal(t, "user with spaces", claims.AuthMethodSubject)
assert.Empty(t, claims.Permissions) // No permissions due to invalid name
})
t.Run("invalid org name is filtered out", func(t *testing.T) {
// Create mock GitHub API server with invalid org name
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case githubUserEndpoint:
user := v0auth.GitHubUserOrOrg{
Login: "testuser",
ID: 12345,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(user) //nolint:errcheck
case githubOrgsEndpoint:
orgs := []v0auth.GitHubUserOrOrg{
{Login: "valid-org", ID: 1},
{Login: "org with spaces", ID: 2}, // Invalid name
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(orgs) //nolint:errcheck
}
}))
defer mockServer.Close()
// Create handler and set mock server URL
handler := v0auth.NewGitHubHandler(cfg)
handler.SetBaseURL(mockServer.URL)
// Test token exchange
ctx := context.Background()
response, err := handler.ExchangeToken(ctx, "valid-token")
require.NoError(t, err)
assert.NotNil(t, response)
// Validate the JWT token
jwtManager := auth.NewJWTManager(cfg)
claims, err := jwtManager.ValidateToken(ctx, response.RegistryToken)
require.NoError(t, err)
assert.Equal(t, "testuser", claims.AuthMethodSubject)
assert.Empty(t, claims.Permissions) // No permissions because one org has invalid name
})
t.Run("malformed JSON response", func(t *testing.T) {
// Create mock GitHub API server that returns invalid JSON
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == githubUserEndpoint {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{invalid json`)) //nolint:errcheck
}
}))
defer mockServer.Close()
// Create handler and set mock server URL
handler := v0auth.NewGitHubHandler(cfg)
handler.SetBaseURL(mockServer.URL)
// Test token exchange
ctx := context.Background()
response, err := handler.ExchangeToken(ctx, "valid-token")
require.Error(t, err)
assert.Nil(t, response)
assert.Contains(t, err.Error(), "failed to decode")
})
}
func TestJWTTokenValidation(t *testing.T) {
testSeed := make([]byte, ed25519.SeedSize)
_, err := rand.Read(testSeed)
require.NoError(t, err)
cfg := &config.Config{
JWTPrivateKey: hex.EncodeToString(testSeed),
}
jwtManager := auth.NewJWTManager(cfg)
ctx := context.Background()
t.Run("generate and validate token", func(t *testing.T) {
// Create test claims
claims := auth.JWTClaims{
AuthMethod: auth.MethodGitHubAT,
AuthMethodSubject: "testuser",
Permissions: []auth.Permission{
{
Action: auth.PermissionActionPublish,
ResourcePattern: "io.github.testuser/*",
},
},
}
// Generate token
tokenResponse, err := jwtManager.GenerateTokenResponse(ctx, claims)
require.NoError(t, err)
assert.NotEmpty(t, tokenResponse.RegistryToken)
// Validate token
validatedClaims, err := jwtManager.ValidateToken(ctx, tokenResponse.RegistryToken)
require.NoError(t, err)
assert.Equal(t, auth.MethodGitHubAT, validatedClaims.AuthMethod)
assert.Equal(t, "testuser", validatedClaims.AuthMethodSubject)
assert.Len(t, validatedClaims.Permissions, 1)
})
t.Run("token expiration", func(t *testing.T) {
// Create claims with past expiration
pastTime := time.Now().Add(-1 * time.Hour)
claims := auth.JWTClaims{
AuthMethod: auth.MethodGitHubAT,
AuthMethodSubject: "testuser",
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(pastTime),
IssuedAt: jwt.NewNumericDate(pastTime.Add(-1 * time.Hour)),
},
}
// Generate token
tokenResponse, err := jwtManager.GenerateTokenResponse(ctx, claims)
require.NoError(t, err)
// Validate token - should fail due to expiration
_, err = jwtManager.ValidateToken(ctx, tokenResponse.RegistryToken)
require.Error(t, err)
assert.Contains(t, err.Error(), "token is expired")
})
t.Run("invalid signature", func(t *testing.T) {
// Create test claims
claims := auth.JWTClaims{
AuthMethod: auth.MethodGitHubAT,
AuthMethodSubject: "testuser",
}
// Generate token
tokenResponse, err := jwtManager.GenerateTokenResponse(ctx, claims)
require.NoError(t, err)
// Tamper with the token
tamperedToken := tokenResponse.RegistryToken + "tampered"
// Validate token - should fail due to invalid signature
_, err = jwtManager.ValidateToken(ctx, tamperedToken)
require.Error(t, err)
})
}
func TestPermissionResourceMatching(t *testing.T) {
testSeed := make([]byte, ed25519.SeedSize)
_, err := rand.Read(testSeed)
require.NoError(t, err)
cfg := &config.Config{
JWTPrivateKey: hex.EncodeToString(testSeed),
}
jwtManager := auth.NewJWTManager(cfg)
testCases := []struct {
name string
resource string
pattern string
action auth.PermissionAction
expectedMatch bool
}{
{
name: "exact match",
resource: "io.github.testuser/myrepo",
pattern: "io.github.testuser/myrepo",
action: auth.PermissionActionPublish,
expectedMatch: true,
},
{
name: "wildcard match",
resource: "io.github.testuser/myrepo",
pattern: "io.github.testuser/*",
action: auth.PermissionActionPublish,
expectedMatch: true,
},
{
name: "global wildcard",
resource: "io.github.anyuser/anyrepo",
pattern: "*",
action: auth.PermissionActionPublish,
expectedMatch: true,
},
{
name: "no match different user",
resource: "io.github.otheruser/repo",
pattern: "io.github.testuser/*",
action: auth.PermissionActionPublish,
expectedMatch: false,
},
{
name: "no match different action",
resource: "io.github.testuser/repo",
pattern: "io.github.testuser/*",
action: auth.PermissionActionEdit,
expectedMatch: false,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
permissions := []auth.Permission{
{
Action: auth.PermissionActionPublish,
ResourcePattern: tc.pattern,
},
}
hasPermission := jwtManager.HasPermission(tc.resource, tc.action, permissions)
assert.Equal(t, tc.expectedMatch, hasPermission)
})
}
}
func TestValidGitHubNames(t *testing.T) {
// Create a minimal handler to test name validation
testSeed := make([]byte, ed25519.SeedSize)
_, err := rand.Read(testSeed)
require.NoError(t, err)
cfg := &config.Config{
JWTPrivateKey: hex.EncodeToString(testSeed),
}
validNameTests := []struct {
name string
username string
orgs []v0auth.GitHubUserOrOrg
wantPerms int
}{
{
name: "valid username only",
username: "valid-user",
orgs: []v0auth.GitHubUserOrOrg{},
wantPerms: 1,
},
{
name: "valid username with numbers",
username: "user123",
orgs: []v0auth.GitHubUserOrOrg{},
wantPerms: 1,
},
{
name: "valid username with org",
username: "valid-user",
orgs: []v0auth.GitHubUserOrOrg{
{Login: "valid-org", ID: 1},
},
wantPerms: 2,
},
{
name: "invalid username with spaces",
username: "invalid user",
orgs: []v0auth.GitHubUserOrOrg{},
wantPerms: 0, // Should return nil/empty permissions
},
{
name: "invalid username with special chars",
username: "user@invalid",
orgs: []v0auth.GitHubUserOrOrg{},
wantPerms: 0,
},
{
name: "valid username with invalid org",
username: "valid-user",
orgs: []v0auth.GitHubUserOrOrg{
{Login: "invalid org", ID: 1},
},
wantPerms: 0, // Should return nil if any name is invalid
},
}
for _, tc := range validNameTests {
t.Run(tc.name, func(t *testing.T) {
// Create mock server
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/user":
user := v0auth.GitHubUserOrOrg{
Login: tc.username,
ID: 12345,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(user) //nolint:errcheck
case "/users/" + tc.username + "/orgs":
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(tc.orgs) //nolint:errcheck
}
}))
defer mockServer.Close()
// Create handler and set mock server URL
handler := v0auth.NewGitHubHandler(cfg)
handler.SetBaseURL(mockServer.URL)
// Test token exchange
ctx := context.Background()
response, err := handler.ExchangeToken(ctx, "valid-token")
require.NoError(t, err)
// Validate the JWT token and check permissions
jwtManager := auth.NewJWTManager(cfg)
claims, err := jwtManager.ValidateToken(ctx, response.RegistryToken)
require.NoError(t, err)
assert.Len(t, claims.Permissions, tc.wantPerms)
})
}
}
func TestGitHubHandler_Creation(t *testing.T) {
testSeed := make([]byte, ed25519.SeedSize)
_, err := rand.Read(testSeed)
require.NoError(t, err)
cfg := &config.Config{
JWTPrivateKey: hex.EncodeToString(testSeed),
}
handler := v0auth.NewGitHubHandler(cfg)
assert.NotNil(t, handler, "handler should not be nil")
}
func TestConcurrentTokenExchange(t *testing.T) {
// Test that the handler is thread-safe
testSeed := make([]byte, ed25519.SeedSize)
_, err := rand.Read(testSeed)
require.NoError(t, err)
cfg := &config.Config{
JWTPrivateKey: hex.EncodeToString(testSeed),
}
// Create mock GitHub API server
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/user":
user := v0auth.GitHubUserOrOrg{
Login: "testuser",
ID: 12345,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(user) //nolint:errcheck
case githubOrgsEndpoint:
orgs := []v0auth.GitHubUserOrOrg{}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(orgs) //nolint:errcheck
}
}))
defer mockServer.Close()
handler := v0auth.NewGitHubHandler(cfg)
handler.SetBaseURL(mockServer.URL)
// Run multiple concurrent exchanges
concurrency := 10
errors := make(chan error, concurrency)
for i := 0; i < concurrency; i++ {
go func() {
ctx := context.Background()
_, err := handler.ExchangeToken(ctx, fmt.Sprintf("token-%d", i))
errors <- err
}()
}
// Collect results
for i := 0; i < concurrency; i++ {
err := <-errors
assert.NoError(t, err)
}
}