auth: New auth flow (#273)
Fixes #264. Adds endpoints: ``` /v0/auth/github # exchange a github token for a registry token that gives you access to namespaces io.github.username and io.github.org for your orgs /v0/auth/none # get a registry token for namespace io.modelcontextprotocol.anonymous (only for testing/local dev purposes) ``` Updates `/v0/publish` to accept one of these registry tokens. ## Motivation and Context See #264 ## How Has This Been Tested? - Running locally and getting a token from `/v0/auth/none` and then publishing to `/v0/publish` - Updating the integration tests that get a token and then publish, and them passing ## Breaking Changes This is a breaking change to the auth API. This is fine for registry given it's not deployed yet. ## Types of changes <!-- What types of changes does your code introduce? Put an `x` in all the boxes that apply: --> - [ ] Bug fix (non-breaking change which fixes an issue) - [x] New feature (non-breaking change which adds functionality) - [x] Breaking change (fix or feature that would cause existing functionality to change) - [x] Documentation update ## Checklist <!-- Go over all the following points, and put an `x` in all the boxes that apply. --> - [x] I have read the [MCP Documentation](https://modelcontextprotocol.io) - [x] My code follows the repository's style guidelines - [x] New and existing tests pass locally - [x] I have added appropriate error handling - [x] I have added or updated documentation as needed ## Additional context <!-- Add any other context, implementation notes, or design decisions --> --------- Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> Co-authored-by: adam jones <domdomegg@users.noreply.github.com>
This commit is contained in:
parent
95bf1e3e30
commit
c733cedaf8
6 changed files with 1759 additions and 1537 deletions
|
|
@ -1,28 +0,0 @@
|
|||
// Package auth provides authentication mechanisms for the MCP registry
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/modelcontextprotocol/registry/internal/model"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrAuthRequired is returned when authentication is required but not provided
|
||||
ErrAuthRequired = errors.New("authentication required")
|
||||
// ErrUnsupportedAuthMethod is returned when an unsupported auth method is used
|
||||
ErrUnsupportedAuthMethod = errors.New("unsupported authentication method")
|
||||
)
|
||||
|
||||
// Service defines the authentication service interface
|
||||
type Service interface {
|
||||
// StartAuthFlow initiates an authentication flow and returns the flow information
|
||||
StartAuthFlow(ctx context.Context, method model.AuthMethod, repoRef string) (map[string]string, string, error)
|
||||
|
||||
// CheckAuthStatus checks the status of an authentication flow using a status token
|
||||
CheckAuthStatus(ctx context.Context, statusToken string) (string, error)
|
||||
|
||||
// ValidateAuth validates the authentication credentials
|
||||
ValidateAuth(ctx context.Context, auth model.Authentication) (bool, error)
|
||||
}
|
||||
|
|
@ -1,250 +0,0 @@
|
|||
// Package auth provides authentication mechanisms for the MCP registry
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrAuthFailed is returned when authentication fails
|
||||
ErrAuthFailed = errors.New("authentication failed")
|
||||
// ErrInvalidToken is returned when a token is invalid
|
||||
ErrInvalidToken = errors.New("invalid token")
|
||||
// ErrMissingScope is returned when a token doesn't have the required scope
|
||||
ErrMissingScope = errors.New("token missing required scope")
|
||||
)
|
||||
|
||||
// GitHubOAuthConfig holds the configuration for GitHub OAuth
|
||||
type GitHubOAuthConfig struct {
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
}
|
||||
|
||||
// DeviceCodeResponse represents the response from GitHub's device code endpoint
|
||||
type DeviceCodeResponse struct {
|
||||
DeviceCode string `json:"device_code"`
|
||||
UserCode string `json:"user_code"`
|
||||
VerificationURI string `json:"verification_uri"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
Interval int `json:"interval"`
|
||||
}
|
||||
|
||||
// AccessTokenResponse represents the response from GitHub's access token endpoint
|
||||
type AccessTokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
Scope string `json:"scope"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// TokenValidationResponse represents the response from GitHub's token validation endpoint
|
||||
type TokenValidationResponse struct {
|
||||
ID int `json:"id"`
|
||||
URL string `json:"url"`
|
||||
Scopes []string `json:"scopes"`
|
||||
SingleFile string `json:"single_file,omitempty"`
|
||||
Repository string `json:"repository,omitempty"`
|
||||
Fingerprint string `json:"fingerprint,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// GitHubDeviceAuth provides methods for GitHub device OAuth authentication
|
||||
type GitHubDeviceAuth struct {
|
||||
config GitHubOAuthConfig
|
||||
}
|
||||
|
||||
// NewGitHubDeviceAuth creates a new GitHub device auth instance
|
||||
func NewGitHubDeviceAuth(config GitHubOAuthConfig) *GitHubDeviceAuth {
|
||||
return &GitHubDeviceAuth{
|
||||
config: config,
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateToken validates if a GitHub token has the necessary permissions to access the required repository.
|
||||
// It verifies the token owner matches the repository owner or is a member of the owning organization.
|
||||
// It also verifies that the token was created for the same ClientID used to set up the authentication.
|
||||
// Returns true if valid, false otherwise along with an error explaining the validation failure.
|
||||
func (g *GitHubDeviceAuth) ValidateToken(ctx context.Context, token string, requiredRepo string) (bool, error) {
|
||||
// If no repo is required, we can't validate properly
|
||||
if requiredRepo == "" {
|
||||
return false, fmt.Errorf("repository reference is required for token validation")
|
||||
}
|
||||
|
||||
// First, validate that the token is associated with our ClientID
|
||||
tokenReq, err := http.NewRequestWithContext(
|
||||
ctx,
|
||||
http.MethodGet,
|
||||
"https://api.github.com/applications/"+g.config.ClientID+"/token",
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
// The applications endpoint requires basic auth with client ID and secret
|
||||
tokenReq.SetBasicAuth(g.config.ClientID, g.config.ClientSecret)
|
||||
tokenReq.Header.Set("Accept", "application/vnd.github+json")
|
||||
|
||||
// Create request body with the token
|
||||
type tokenCheck struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
}
|
||||
|
||||
checkBody, err := json.Marshal(tokenCheck{AccessToken: token})
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
// POST instead of GET for security reasons per GitHub API
|
||||
tokenURL := "https://api.github.com/applications/" + g.config.ClientID + "/token"
|
||||
tokenReq, err = http.NewRequestWithContext(ctx, http.MethodPost, tokenURL, io.NopCloser(bytes.NewReader(checkBody)))
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
tokenReq.SetBasicAuth(g.config.ClientID, g.config.ClientSecret)
|
||||
tokenReq.Header.Set("Accept", "application/vnd.github+json")
|
||||
tokenReq.Header.Set("Content-Type", "application/json")
|
||||
|
||||
client := &http.Client{}
|
||||
tokenResp, err := client.Do(tokenReq)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer tokenResp.Body.Close()
|
||||
|
||||
// Check response - 200 means token is valid and associated with our app
|
||||
// 404 means token is not associated with our app
|
||||
if tokenResp.StatusCode != http.StatusOK {
|
||||
return false, fmt.Errorf("token is not associated with this application (status: %d)", tokenResp.StatusCode)
|
||||
}
|
||||
|
||||
var tokenInfo TokenValidationResponse
|
||||
tokenRespBody, err := io.ReadAll(tokenResp.Body)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(tokenRespBody, &tokenInfo); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
// Check if there's an error in the response
|
||||
if tokenInfo.Error != "" {
|
||||
return false, fmt.Errorf("token validation error: %s", tokenInfo.Error)
|
||||
}
|
||||
|
||||
// Get the authenticated user
|
||||
userReq, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.github.com/user", nil)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
userReq.Header.Set("Accept", "application/vnd.github+json")
|
||||
userReq.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
|
||||
client = &http.Client{}
|
||||
userResp, err := client.Do(userReq)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer userResp.Body.Close()
|
||||
|
||||
if userResp.StatusCode != http.StatusOK {
|
||||
return false, fmt.Errorf("failed to get user info: status %d", userResp.StatusCode)
|
||||
}
|
||||
|
||||
var userInfo struct {
|
||||
Login string `json:"login"`
|
||||
}
|
||||
|
||||
userBody, err := io.ReadAll(userResp.Body)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(userBody, &userInfo); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
// Extract owner from the required repo
|
||||
owner, _, err := g.ExtractGitHubRepoFromName(requiredRepo)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
// Verify that the authenticated user matches the owner
|
||||
if userInfo.Login != owner {
|
||||
// Check if the user is a member of the organization
|
||||
isMember, err := g.checkOrgMembership(ctx, token, userInfo.Login, owner)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to check org membership: %s", owner)
|
||||
}
|
||||
|
||||
if !isMember {
|
||||
return false, fmt.Errorf(
|
||||
"token belongs to user %s, but repository is owned by %s and user is not a member of the organization",
|
||||
userInfo.Login, owner)
|
||||
}
|
||||
}
|
||||
|
||||
// If we've reached this point, the token has access the repo and the user matches
|
||||
// the owner or is a member of the owner org
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (g *GitHubDeviceAuth) ExtractGitHubRepoFromName(n string) (owner, repo string, err error) {
|
||||
// match io.github.<owner>/<repo>
|
||||
regexp := regexp.MustCompile(`io\.github\.([^/]+)/([^/]+)`)
|
||||
matches := regexp.FindStringSubmatch(n)
|
||||
if len(matches) != 3 {
|
||||
return "", "", fmt.Errorf("invalid GitHub repository name: %s", n)
|
||||
}
|
||||
return matches[1], matches[2], nil
|
||||
}
|
||||
|
||||
// extractGitHubRepo extracts the owner and repository name from a GitHub repository URL
|
||||
func (g *GitHubDeviceAuth) ExtractGitHubRepo(repoURL string) (owner, repo string, err error) {
|
||||
regexp := regexp.MustCompile(`github\.com/([^/]+)/([^/]+)`)
|
||||
matches := regexp.FindStringSubmatch(repoURL)
|
||||
if len(matches) != 3 {
|
||||
return "", "", fmt.Errorf("invalid GitHub repository URL: %s", repoURL)
|
||||
}
|
||||
return matches[1], matches[2], nil
|
||||
}
|
||||
|
||||
// checkOrgMembership checks if a user is a member of an organization
|
||||
func (g *GitHubDeviceAuth) checkOrgMembership(ctx context.Context, token, username, org string) (bool, error) {
|
||||
// Create request to check if user is a member of the organization
|
||||
// GitHub API endpoint: GET /orgs/{org}/members/{username}
|
||||
// true if status code is 204 No Content
|
||||
// false if status code is 404 Not Found
|
||||
|
||||
url := fmt.Sprint("https://api.github.com/orgs/", org, "/members/", username)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
req.Header.Set("Accept", "application/vnd.github+json")
|
||||
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusNoContent {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
return false, fmt.Errorf("failed to check org membership: status %d", resp.StatusCode)
|
||||
}
|
||||
149
internal/auth/jwt.go
Normal file
149
internal/auth/jwt.go
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/modelcontextprotocol/registry/internal/config"
|
||||
"github.com/modelcontextprotocol/registry/internal/model"
|
||||
)
|
||||
|
||||
// PermissionAction represents the type of action that can be performed
|
||||
type PermissionAction string
|
||||
|
||||
const (
|
||||
PermissionActionPublish PermissionAction = "publish"
|
||||
// Intended for admins taking moderation actions only, at least for now
|
||||
PermissionActionEdit PermissionAction = "edit"
|
||||
)
|
||||
|
||||
type Permission struct {
|
||||
Action PermissionAction `json:"action"` // The action type (publish or edit)
|
||||
ResourcePattern string `json:"resource"` // e.g., "io.github.username/*"
|
||||
}
|
||||
|
||||
// JWTClaims represents the claims for the Registry JWT token
|
||||
type JWTClaims struct {
|
||||
jwt.RegisteredClaims
|
||||
// Authentication method used to obtain this token
|
||||
AuthMethod model.AuthMethod `json:"auth_method"`
|
||||
AuthMethodSubject string `json:"auth_method_sub"`
|
||||
Permissions []Permission `json:"permissions"`
|
||||
}
|
||||
|
||||
type TokenResponse struct {
|
||||
RegistryToken string `json:"registry_token"`
|
||||
ExpiresAt int `json:"expires_at"`
|
||||
}
|
||||
|
||||
// JWTManager handles JWT token operations
|
||||
type JWTManager struct {
|
||||
privateKey ed25519.PrivateKey
|
||||
publicKey ed25519.PublicKey
|
||||
tokenDuration time.Duration
|
||||
}
|
||||
|
||||
func NewJWTManager(cfg *config.Config) *JWTManager {
|
||||
seed, err := hex.DecodeString(cfg.JWTPrivateKey)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("JWTPrivateKey must be a valid hex-encoded string: %v", err))
|
||||
}
|
||||
|
||||
// Require a valid Ed25519 seed (32 bytes)
|
||||
if len(seed) != ed25519.SeedSize {
|
||||
panic(fmt.Sprintf("JWTPrivateKey seed must be exactly %d bytes for Ed25519, got %d bytes", ed25519.SeedSize, len(seed)))
|
||||
}
|
||||
|
||||
// Generate the full Ed25519 key pair from the seed
|
||||
privateKey := ed25519.NewKeyFromSeed(seed)
|
||||
publicKey := privateKey.Public().(ed25519.PublicKey)
|
||||
|
||||
return &JWTManager{
|
||||
privateKey: privateKey,
|
||||
publicKey: publicKey,
|
||||
tokenDuration: 5 * time.Minute, // 5-minute tokens as per requirements
|
||||
}
|
||||
}
|
||||
|
||||
// GenerateToken generates a new Registry JWT token
|
||||
func (j *JWTManager) GenerateTokenResponse(_ context.Context, claims JWTClaims) (*TokenResponse, error) {
|
||||
if claims.IssuedAt == nil {
|
||||
claims.IssuedAt = jwt.NewNumericDate(time.Now())
|
||||
}
|
||||
if claims.ExpiresAt == nil {
|
||||
claims.ExpiresAt = jwt.NewNumericDate(time.Now().Add(j.tokenDuration))
|
||||
}
|
||||
if claims.NotBefore == nil {
|
||||
claims.NotBefore = jwt.NewNumericDate(time.Now())
|
||||
}
|
||||
if claims.Issuer == "" {
|
||||
claims.Issuer = "mcp-registry"
|
||||
}
|
||||
|
||||
// Create token with claims
|
||||
token := jwt.NewWithClaims(&jwt.SigningMethodEd25519{}, claims)
|
||||
|
||||
// Sign token with Ed25519 private key
|
||||
tokenString, err := token.SignedString(j.privateKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to sign token: %w", err)
|
||||
}
|
||||
|
||||
return &TokenResponse{
|
||||
RegistryToken: tokenString,
|
||||
ExpiresAt: int(claims.ExpiresAt.Unix()),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ValidateToken validates a Registry JWT token and returns the claims
|
||||
func (j *JWTManager) ValidateToken(_ context.Context, tokenString string) (*JWTClaims, error) {
|
||||
// Parse token
|
||||
// This also validates expiry
|
||||
token, err := jwt.ParseWithClaims(
|
||||
tokenString,
|
||||
&JWTClaims{},
|
||||
func(_ *jwt.Token) (interface{}, error) { return j.publicKey, nil },
|
||||
jwt.WithValidMethods([]string{"EdDSA"}),
|
||||
jwt.WithExpirationRequired(),
|
||||
)
|
||||
|
||||
// Validate token
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse token: %w", err)
|
||||
}
|
||||
if !token.Valid {
|
||||
return nil, fmt.Errorf("invalid token")
|
||||
}
|
||||
|
||||
// Extract claims
|
||||
claims, ok := token.Claims.(*JWTClaims)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid token claims")
|
||||
}
|
||||
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
func (j *JWTManager) HasPermission(resource string, action PermissionAction, permissions []Permission) bool {
|
||||
for _, perm := range permissions {
|
||||
if perm.Action == action && isResourceMatch(resource, perm.ResourcePattern) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isResourceMatch(resource, pattern string) bool {
|
||||
if pattern == "*" {
|
||||
return true
|
||||
}
|
||||
if strings.HasSuffix(pattern, "*") {
|
||||
return strings.HasPrefix(resource, strings.TrimSuffix(pattern, "*"))
|
||||
}
|
||||
return resource == pattern
|
||||
}
|
||||
285
internal/auth/jwt_test.go
Normal file
285
internal/auth/jwt_test.go
Normal file
|
|
@ -0,0 +1,285 @@
|
|||
package auth_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/modelcontextprotocol/registry/internal/auth"
|
||||
"github.com/modelcontextprotocol/registry/internal/config"
|
||||
"github.com/modelcontextprotocol/registry/internal/model"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestJWTManager_GenerateAndVerifyToken(t *testing.T) {
|
||||
// Generate a proper Ed25519 seed for testing
|
||||
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 verify valid token", func(t *testing.T) {
|
||||
claims := auth.JWTClaims{
|
||||
AuthMethod: model.AuthMethodGitHub,
|
||||
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)
|
||||
assert.Greater(t, tokenResponse.ExpiresAt, 0)
|
||||
|
||||
// Verify token
|
||||
verifiedClaims, err := jwtManager.ValidateToken(ctx, tokenResponse.RegistryToken)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, model.AuthMethodGitHub, verifiedClaims.AuthMethod)
|
||||
assert.Equal(t, "testuser", verifiedClaims.AuthMethodSubject)
|
||||
assert.Equal(t, "mcp-registry", verifiedClaims.Issuer)
|
||||
assert.Len(t, verifiedClaims.Permissions, 1)
|
||||
assert.Equal(t, auth.PermissionActionPublish, verifiedClaims.Permissions[0].Action)
|
||||
assert.Equal(t, "io.github.testuser/*", verifiedClaims.Permissions[0].ResourcePattern)
|
||||
})
|
||||
|
||||
t.Run("token with custom claims", func(t *testing.T) {
|
||||
issuedAt := jwt.NewNumericDate(time.Now().Add(-1 * time.Minute))
|
||||
expiresAt := jwt.NewNumericDate(time.Now().Add(10 * time.Minute))
|
||||
notBefore := jwt.NewNumericDate(time.Now().Add(-30 * time.Second))
|
||||
|
||||
claims := auth.JWTClaims{
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
IssuedAt: issuedAt,
|
||||
ExpiresAt: expiresAt,
|
||||
NotBefore: notBefore,
|
||||
Issuer: "custom-issuer",
|
||||
},
|
||||
AuthMethod: model.AuthMethodNone,
|
||||
AuthMethodSubject: "anonymous",
|
||||
Permissions: []auth.Permission{
|
||||
{
|
||||
Action: auth.PermissionActionEdit,
|
||||
ResourcePattern: "*",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Generate token
|
||||
tokenResponse, err := jwtManager.GenerateTokenResponse(ctx, claims)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify token
|
||||
verifiedClaims, err := jwtManager.ValidateToken(ctx, tokenResponse.RegistryToken)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, model.AuthMethodNone, verifiedClaims.AuthMethod)
|
||||
assert.Equal(t, "anonymous", verifiedClaims.AuthMethodSubject)
|
||||
assert.Equal(t, "custom-issuer", verifiedClaims.Issuer)
|
||||
assert.Equal(t, issuedAt.Unix(), verifiedClaims.IssuedAt.Unix())
|
||||
assert.Equal(t, expiresAt.Unix(), verifiedClaims.ExpiresAt.Unix())
|
||||
assert.Equal(t, notBefore.Unix(), verifiedClaims.NotBefore.Unix())
|
||||
})
|
||||
|
||||
t.Run("expired token should fail validation", func(t *testing.T) {
|
||||
claims := auth.JWTClaims{
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(-1 * time.Hour)), // Already expired
|
||||
},
|
||||
AuthMethod: model.AuthMethodGitHub,
|
||||
AuthMethodSubject: "testuser",
|
||||
}
|
||||
|
||||
// Generate token
|
||||
tokenResponse, err := jwtManager.GenerateTokenResponse(ctx, claims)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify token should fail
|
||||
_, err = jwtManager.ValidateToken(ctx, tokenResponse.RegistryToken)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "failed to parse token")
|
||||
})
|
||||
|
||||
t.Run("invalid token signature should fail", func(t *testing.T) {
|
||||
// Create a different seed
|
||||
differentSeed := make([]byte, ed25519.SeedSize)
|
||||
_, err := rand.Read(differentSeed)
|
||||
require.NoError(t, err)
|
||||
|
||||
differentCfg := &config.Config{
|
||||
JWTPrivateKey: hex.EncodeToString(differentSeed),
|
||||
}
|
||||
differentJWTManager := auth.NewJWTManager(differentCfg)
|
||||
|
||||
claims := auth.JWTClaims{
|
||||
AuthMethod: model.AuthMethodGitHub,
|
||||
AuthMethodSubject: "testuser",
|
||||
}
|
||||
|
||||
// Generate token with different key
|
||||
tokenResponse, err := differentJWTManager.GenerateTokenResponse(ctx, claims)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Try to verify with original key - should fail
|
||||
_, err = jwtManager.ValidateToken(ctx, tokenResponse.RegistryToken)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "failed to parse token")
|
||||
})
|
||||
|
||||
t.Run("malformed token should fail", func(t *testing.T) {
|
||||
// Try to validate a malformed token
|
||||
_, err := jwtManager.ValidateToken(ctx, "not.a.valid.token")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "failed to parse token")
|
||||
})
|
||||
|
||||
t.Run("multiple permissions", func(t *testing.T) {
|
||||
claims := auth.JWTClaims{
|
||||
AuthMethod: model.AuthMethodGitHub,
|
||||
AuthMethodSubject: "admin",
|
||||
Permissions: []auth.Permission{
|
||||
{
|
||||
Action: auth.PermissionActionPublish,
|
||||
ResourcePattern: "io.github.admin/*",
|
||||
},
|
||||
{
|
||||
Action: auth.PermissionActionEdit,
|
||||
ResourcePattern: "*",
|
||||
},
|
||||
{
|
||||
Action: auth.PermissionActionPublish,
|
||||
ResourcePattern: "io.github.org/*",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Generate token
|
||||
tokenResponse, err := jwtManager.GenerateTokenResponse(ctx, claims)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify token
|
||||
verifiedClaims, err := jwtManager.ValidateToken(ctx, tokenResponse.RegistryToken)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, verifiedClaims.Permissions, 3)
|
||||
assert.Equal(t, auth.PermissionActionPublish, verifiedClaims.Permissions[0].Action)
|
||||
assert.Equal(t, auth.PermissionActionEdit, verifiedClaims.Permissions[1].Action)
|
||||
assert.Equal(t, auth.PermissionActionPublish, verifiedClaims.Permissions[2].Action)
|
||||
})
|
||||
}
|
||||
|
||||
func TestJWTManager_HasPermission(t *testing.T) {
|
||||
// Generate a proper Ed25519 seed for testing
|
||||
testSeed := make([]byte, ed25519.SeedSize)
|
||||
_, err := rand.Read(testSeed)
|
||||
require.NoError(t, err)
|
||||
|
||||
cfg := &config.Config{
|
||||
JWTPrivateKey: hex.EncodeToString(testSeed),
|
||||
}
|
||||
|
||||
jwtManager := auth.NewJWTManager(cfg)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
resource string
|
||||
action auth.PermissionAction
|
||||
permissions []auth.Permission
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "exact match",
|
||||
resource: "io.github.testuser/server1",
|
||||
action: auth.PermissionActionPublish,
|
||||
permissions: []auth.Permission{
|
||||
{Action: auth.PermissionActionPublish, ResourcePattern: "io.github.testuser/server1"},
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "wildcard match",
|
||||
resource: "io.github.testuser/server2",
|
||||
action: auth.PermissionActionPublish,
|
||||
permissions: []auth.Permission{
|
||||
{Action: auth.PermissionActionPublish, ResourcePattern: "io.github.testuser/*"},
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "global wildcard",
|
||||
resource: "any.resource.here",
|
||||
action: auth.PermissionActionEdit,
|
||||
permissions: []auth.Permission{
|
||||
{Action: auth.PermissionActionEdit, ResourcePattern: "*"},
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "wrong action",
|
||||
resource: "io.github.testuser/server1",
|
||||
action: auth.PermissionActionEdit,
|
||||
permissions: []auth.Permission{
|
||||
{Action: auth.PermissionActionPublish, ResourcePattern: "io.github.testuser/*"},
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "no match",
|
||||
resource: "io.github.otheruser/server1",
|
||||
action: auth.PermissionActionPublish,
|
||||
permissions: []auth.Permission{
|
||||
{Action: auth.PermissionActionPublish, ResourcePattern: "io.github.testuser/*"},
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "multiple permissions with match",
|
||||
resource: "io.github.org/server1",
|
||||
action: auth.PermissionActionPublish,
|
||||
permissions: []auth.Permission{
|
||||
{Action: auth.PermissionActionPublish, ResourcePattern: "io.github.testuser/*"},
|
||||
{Action: auth.PermissionActionEdit, ResourcePattern: "*"},
|
||||
{Action: auth.PermissionActionPublish, ResourcePattern: "io.github.org/*"},
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "empty permissions",
|
||||
resource: "io.github.testuser/server1",
|
||||
action: auth.PermissionActionPublish,
|
||||
permissions: []auth.Permission{},
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := jwtManager.HasPermission(tt.resource, tt.action, tt.permissions)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewJWTManager_InvalidKeySize(t *testing.T) {
|
||||
// Test with invalid key size (should panic)
|
||||
cfg := &config.Config{
|
||||
JWTPrivateKey: "too-short-key",
|
||||
}
|
||||
|
||||
assert.Panics(t, func() {
|
||||
auth.NewJWTManager(cfg)
|
||||
})
|
||||
}
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
//go:build noauth
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/modelcontextprotocol/registry/internal/config"
|
||||
"github.com/modelcontextprotocol/registry/internal/model"
|
||||
)
|
||||
|
||||
func init() {
|
||||
fmt.Fprintln(os.Stderr, `WARNING: "noauth" build tag has disabled authentication`)
|
||||
}
|
||||
|
||||
// NewAuthService creates a new authentication service that does nothing. All its methods succeed unconditionally.
|
||||
func NewAuthService(*config.Config) Service {
|
||||
return &noAuth{}
|
||||
}
|
||||
|
||||
type noAuth struct{}
|
||||
|
||||
// StartAuthFlow always returns fake flow info and a fake status token
|
||||
func (*noAuth) StartAuthFlow(context.Context, model.AuthMethod, string) (map[string]string, string, error) {
|
||||
return map[string]string{"fake": "info"}, "fake-status-token", nil
|
||||
}
|
||||
|
||||
// CheckAuthStatus always returns a fake token
|
||||
func (*noAuth) CheckAuthStatus(context.Context, string) (string, error) {
|
||||
return "fake-token", nil
|
||||
}
|
||||
|
||||
// ValidateAuth always returns true
|
||||
func (*noAuth) ValidateAuth(context.Context, model.Authentication) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
|
|
@ -1,61 +0,0 @@
|
|||
//go:build !noauth
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/modelcontextprotocol/registry/internal/config"
|
||||
"github.com/modelcontextprotocol/registry/internal/model"
|
||||
)
|
||||
|
||||
// ServiceImpl implements the Service interface
|
||||
type ServiceImpl struct {
|
||||
config *config.Config
|
||||
githubAuth *GitHubDeviceAuth
|
||||
}
|
||||
|
||||
// NewAuthService creates a new authentication service
|
||||
//
|
||||
//nolint:ireturn // Factory function intentionally returns interface for dependency injection
|
||||
func NewAuthService(cfg *config.Config) Service {
|
||||
githubConfig := GitHubOAuthConfig{
|
||||
ClientID: cfg.GithubClientID,
|
||||
ClientSecret: cfg.GithubClientSecret,
|
||||
}
|
||||
|
||||
return &ServiceImpl{
|
||||
config: cfg,
|
||||
githubAuth: NewGitHubDeviceAuth(githubConfig),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ServiceImpl) StartAuthFlow(_ context.Context, _ model.AuthMethod,
|
||||
_ string) (map[string]string, string, error) {
|
||||
// return not implemented error
|
||||
return nil, "", fmt.Errorf("not implemented")
|
||||
}
|
||||
|
||||
func (s *ServiceImpl) CheckAuthStatus(_ context.Context, _ string) (string, error) {
|
||||
// return not implemented error
|
||||
return "", fmt.Errorf("not implemented")
|
||||
}
|
||||
|
||||
// ValidateAuth validates authentication credentials
|
||||
func (s *ServiceImpl) ValidateAuth(ctx context.Context, auth model.Authentication) (bool, error) {
|
||||
// If authentication is required but not provided
|
||||
if auth.Method == "" || auth.Method == model.AuthMethodNone {
|
||||
return false, ErrAuthRequired
|
||||
}
|
||||
|
||||
switch auth.Method {
|
||||
case model.AuthMethodGitHub:
|
||||
// Extract repo reference from the repository URL if it's not provided
|
||||
return s.githubAuth.ValidateToken(ctx, auth.Token, auth.RepoRef)
|
||||
case model.AuthMethodNone:
|
||||
return false, ErrAuthRequired
|
||||
default:
|
||||
return false, ErrUnsupportedAuthMethod
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue