<!-- Provide a brief summary of your changes --> ## Motivation and Context Currently the `dns` and `http` based auth flows share code well in the publisher tool. However there is a lot of duplication in the auth endpoint. This on it's own is not a problem but it does make enhancements to the proof record (the `v=MCPv1; ...` string) harder to keep in sync. This PR adds a `common.go` file to share logic between the two. This supports work I am prototyping for https://github.com/modelcontextprotocol/registry/issues/482. ## How Has This Been Tested? Added new test cases for the permissions. Ran them before and after the refactoring to ensure no behavior change. Ran existing UTs. Ran the integration tests. Manually exercised the flows locally. ## Breaking Changes None. ## 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) - [ ] 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. --> - [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 - [ ] I have added or updated documentation as needed ## Additional context <!-- Add any other context, implementation notes, or design decisions -->
741 lines
23 KiB
Go
741 lines
23 KiB
Go
package auth_test
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/ed25519"
|
|
"crypto/tls"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/modelcontextprotocol/registry/internal/api/handlers/v0/auth"
|
|
intauth "github.com/modelcontextprotocol/registry/internal/auth"
|
|
"github.com/modelcontextprotocol/registry/internal/config"
|
|
)
|
|
|
|
const wellKnownPath = "/.well-known/mcp-registry-auth"
|
|
|
|
func newClientForTLSServer(t *testing.T, srv *httptest.Server) *http.Client {
|
|
t.Helper()
|
|
|
|
dialAddr := srv.Listener.Addr().String()
|
|
transport := &http.Transport{
|
|
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec // testing only
|
|
DialContext: func(ctx context.Context, network, _ string) (net.Conn, error) {
|
|
d := &net.Dialer{}
|
|
return d.DialContext(ctx, network, dialAddr)
|
|
},
|
|
ForceAttemptHTTP2: false,
|
|
MaxIdleConns: 10,
|
|
IdleConnTimeout: 30 * time.Second,
|
|
TLSHandshakeTimeout: 5 * time.Second,
|
|
}
|
|
return &http.Client{Transport: transport, Timeout: 10 * time.Second}
|
|
}
|
|
|
|
const testDomain = "example.com"
|
|
|
|
// MockHTTPKeyFetcher for testing
|
|
type MockHTTPKeyFetcher struct {
|
|
keyResponses map[string]string
|
|
err error
|
|
}
|
|
|
|
func (m *MockHTTPKeyFetcher) FetchKey(_ context.Context, domain string) (string, error) {
|
|
if m.err != nil {
|
|
return "", m.err
|
|
}
|
|
return m.keyResponses[domain], nil
|
|
}
|
|
|
|
func TestHTTPAuthHandler_ExchangeToken(t *testing.T) {
|
|
cfg := &config.Config{
|
|
JWTPrivateKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
|
}
|
|
handler := auth.NewHTTPAuthHandler(cfg)
|
|
|
|
// Generate a test key pair
|
|
publicKey, privateKey, err := ed25519.GenerateKey(nil)
|
|
require.NoError(t, err)
|
|
|
|
// Create mock HTTP key fetcher
|
|
publicKeyB64 := base64.StdEncoding.EncodeToString(publicKey)
|
|
mockFetcher := &MockHTTPKeyFetcher{
|
|
keyResponses: map[string]string{
|
|
testDomain: fmt.Sprintf("v=MCPv1; k=ed25519; p=%s", publicKeyB64),
|
|
},
|
|
}
|
|
handler.SetFetcher(mockFetcher)
|
|
|
|
tests := []struct {
|
|
name string
|
|
domain string
|
|
timestamp string
|
|
signedTimestamp string
|
|
setupMock func(*MockHTTPKeyFetcher)
|
|
expectError bool
|
|
errorContains string
|
|
}{
|
|
{
|
|
name: "successful authentication",
|
|
domain: testDomain,
|
|
timestamp: time.Now().UTC().Format(time.RFC3339),
|
|
setupMock: func(_ *MockHTTPKeyFetcher) {
|
|
// Mock is already set up with valid key
|
|
},
|
|
expectError: false,
|
|
},
|
|
{
|
|
name: "invalid domain format",
|
|
domain: "invalid..domain",
|
|
timestamp: time.Now().UTC().Format(time.RFC3339),
|
|
expectError: true,
|
|
errorContains: "invalid domain format",
|
|
},
|
|
{
|
|
name: "invalid timestamp format",
|
|
domain: testDomain,
|
|
timestamp: "invalid-timestamp",
|
|
expectError: true,
|
|
errorContains: "invalid timestamp format",
|
|
},
|
|
{
|
|
name: "timestamp too old",
|
|
domain: testDomain,
|
|
timestamp: time.Now().Add(-30 * time.Second).UTC().Format(time.RFC3339),
|
|
expectError: true,
|
|
errorContains: "timestamp outside valid window",
|
|
},
|
|
{
|
|
name: "timestamp too far in the future",
|
|
domain: testDomain,
|
|
timestamp: time.Now().Add(30 * time.Second).UTC().Format(time.RFC3339),
|
|
expectError: true,
|
|
errorContains: "timestamp outside valid window",
|
|
},
|
|
{
|
|
name: "invalid signature format",
|
|
domain: testDomain,
|
|
timestamp: time.Now().UTC().Format(time.RFC3339),
|
|
signedTimestamp: "invalid-hex",
|
|
expectError: true,
|
|
errorContains: "invalid signature format",
|
|
},
|
|
{
|
|
name: "signature wrong length",
|
|
domain: testDomain,
|
|
timestamp: time.Now().UTC().Format(time.RFC3339),
|
|
signedTimestamp: "abcdef", // too short
|
|
expectError: true,
|
|
errorContains: "invalid signature length",
|
|
},
|
|
{
|
|
name: "HTTP key fetch failure",
|
|
domain: "nonexistent.com",
|
|
timestamp: time.Now().UTC().Format(time.RFC3339),
|
|
setupMock: func(m *MockHTTPKeyFetcher) {
|
|
m.err = fmt.Errorf("HTTP 404: not found")
|
|
},
|
|
expectError: true,
|
|
errorContains: "failed to fetch public key",
|
|
},
|
|
{
|
|
name: "invalid key format",
|
|
domain: "invalidkey.com",
|
|
timestamp: time.Now().UTC().Format(time.RFC3339),
|
|
setupMock: func(m *MockHTTPKeyFetcher) {
|
|
m.keyResponses["invalidkey.com"] = "invalid key format"
|
|
m.err = nil
|
|
},
|
|
expectError: true,
|
|
errorContains: "failed to parse public key",
|
|
},
|
|
{
|
|
name: "invalid base64 key",
|
|
domain: "badkey.com",
|
|
timestamp: time.Now().UTC().Format(time.RFC3339),
|
|
setupMock: func(m *MockHTTPKeyFetcher) {
|
|
m.keyResponses["badkey.com"] = "v=MCPv1; k=ed25519; p=invalid-base64!!!"
|
|
m.err = nil
|
|
},
|
|
expectError: true,
|
|
errorContains: "failed to parse public key",
|
|
},
|
|
{
|
|
name: "wrong key size",
|
|
domain: "wrongsize.com",
|
|
timestamp: time.Now().UTC().Format(time.RFC3339),
|
|
setupMock: func(m *MockHTTPKeyFetcher) {
|
|
// Generate a key that's too short
|
|
shortKey := base64.StdEncoding.EncodeToString([]byte("short"))
|
|
m.keyResponses["wrongsize.com"] = fmt.Sprintf("v=MCPv1; k=ed25519; p=%s", shortKey)
|
|
m.err = nil
|
|
},
|
|
expectError: true,
|
|
errorContains: "failed to parse public key",
|
|
},
|
|
{
|
|
name: "signature verification failure",
|
|
domain: testDomain,
|
|
timestamp: time.Now().UTC().Format(time.RFC3339),
|
|
setupMock: func(m *MockHTTPKeyFetcher) {
|
|
// Generate different key pair for signature verification failure
|
|
wrongPublicKey, _, err := ed25519.GenerateKey(nil)
|
|
require.NoError(t, err)
|
|
wrongPublicKeyB64 := base64.StdEncoding.EncodeToString(wrongPublicKey)
|
|
m.keyResponses[testDomain] = fmt.Sprintf("v=MCPv1; k=ed25519; p=%s", wrongPublicKeyB64)
|
|
m.err = nil
|
|
},
|
|
expectError: true,
|
|
errorContains: "signature verification failed",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
// Reset mock fetcher
|
|
mockFetcher.err = nil
|
|
if tt.setupMock != nil {
|
|
tt.setupMock(mockFetcher)
|
|
}
|
|
|
|
// Generate signature if not provided
|
|
signedTimestamp := tt.signedTimestamp
|
|
if signedTimestamp == "" {
|
|
// Generate a valid signature for all cases
|
|
signature := ed25519.Sign(privateKey, []byte(tt.timestamp))
|
|
signedTimestamp = hex.EncodeToString(signature)
|
|
}
|
|
|
|
// Call the handler
|
|
result, err := handler.ExchangeToken(context.Background(), tt.domain, tt.timestamp, signedTimestamp)
|
|
|
|
if tt.expectError {
|
|
assert.Error(t, err)
|
|
if tt.errorContains != "" {
|
|
assert.Contains(t, err.Error(), tt.errorContains)
|
|
}
|
|
assert.Nil(t, result)
|
|
} else {
|
|
assert.NoError(t, err)
|
|
assert.NotNil(t, result)
|
|
assert.NotEmpty(t, result.RegistryToken)
|
|
|
|
// Verify the token contains expected claims
|
|
jwtManager := intauth.NewJWTManager(cfg)
|
|
claims, err := jwtManager.ValidateToken(context.Background(), result.RegistryToken)
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t, intauth.MethodHTTP, claims.AuthMethod)
|
|
assert.Equal(t, tt.domain, claims.AuthMethodSubject)
|
|
assert.Len(t, claims.Permissions, 1) // domain permissions only
|
|
|
|
// Check permissions use reverse DNS patterns
|
|
patterns := make([]string, len(claims.Permissions))
|
|
for i, perm := range claims.Permissions {
|
|
patterns[i] = perm.ResourcePattern
|
|
}
|
|
// Convert domain to reverse DNS for expected patterns
|
|
parts := strings.Split(tt.domain, ".")
|
|
for i, j := 0, len(parts)-1; i < j; i, j = i+1, j-1 {
|
|
parts[i], parts[j] = parts[j], parts[i]
|
|
}
|
|
reverseDomain := strings.Join(parts, ".")
|
|
assert.Contains(t, patterns, fmt.Sprintf("%s/*", reverseDomain))
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDefaultHTTPKeyFetcher_FetchKey(t *testing.T) {
|
|
// This test would require a real HTTP server or more sophisticated mocking
|
|
// For now, we'll test the basic structure
|
|
fetcher := auth.NewDefaultHTTPKeyFetcher()
|
|
assert.NotNil(t, fetcher)
|
|
|
|
// Test that it returns an error for non-existent domains
|
|
// (This will fail with network error, which is expected)
|
|
_, err := fetcher.FetchKey(context.Background(), "nonexistent-test-domain-12345.com")
|
|
assert.Error(t, err)
|
|
}
|
|
|
|
func TestDefaultHTTPKeyFetcher(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
handler http.HandlerFunc
|
|
wantErrSub string
|
|
customClient *http.Client
|
|
expectOK bool
|
|
wantBody string
|
|
}{
|
|
{
|
|
name: "oversized body",
|
|
handler: func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != wellKnownPath {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Header().Set("Content-Type", "text/plain")
|
|
_, _ = w.Write(bytes.Repeat([]byte("A"), 6000))
|
|
},
|
|
wantErrSub: "too large",
|
|
},
|
|
{
|
|
name: "non-OK status",
|
|
handler: func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == wellKnownPath {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNotFound)
|
|
},
|
|
wantErrSub: "HTTP 500",
|
|
},
|
|
{
|
|
name: "connection failure",
|
|
handler: nil,
|
|
customClient: &http.Client{
|
|
Transport: &http.Transport{
|
|
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec // testing only
|
|
DialContext: func(context.Context, string, string) (net.Conn, error) {
|
|
return nil, fmt.Errorf("dial blocked")
|
|
},
|
|
},
|
|
Timeout: 5 * time.Second,
|
|
},
|
|
wantErrSub: "failed to fetch key",
|
|
},
|
|
{
|
|
name: "response body failure",
|
|
handler: func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != wellKnownPath {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/plain")
|
|
w.Header().Set("Content-Length", "100")
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte("PARTIAL"))
|
|
},
|
|
wantErrSub: "failed to read response body",
|
|
},
|
|
{
|
|
name: "success",
|
|
handler: func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != wellKnownPath {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/plain")
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte("response"))
|
|
},
|
|
expectOK: true,
|
|
wantBody: "response",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if tt.handler != nil {
|
|
srv := httptest.NewTLSServer(tt.handler)
|
|
defer srv.Close()
|
|
c := newClientForTLSServer(t, srv)
|
|
f := auth.NewDefaultHTTPKeyFetcherWithClient(c)
|
|
got, err := f.FetchKey(context.Background(), "example.com")
|
|
if tt.expectOK {
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if got != tt.wantBody {
|
|
t.Fatalf("unexpected body: got %q want %q", got, tt.wantBody)
|
|
}
|
|
return
|
|
}
|
|
if err == nil || !strings.Contains(err.Error(), tt.wantErrSub) {
|
|
t.Fatalf("got err=%v, want substring %q", err, tt.wantErrSub)
|
|
}
|
|
return
|
|
}
|
|
|
|
f := auth.NewDefaultHTTPKeyFetcherWithClient(tt.customClient)
|
|
got, err := f.FetchKey(context.Background(), "example.com")
|
|
if tt.expectOK {
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if got != tt.wantBody {
|
|
t.Fatalf("unexpected body: got %q want %q", got, tt.wantBody)
|
|
}
|
|
return
|
|
}
|
|
if err == nil || !strings.Contains(err.Error(), tt.wantErrSub) {
|
|
t.Fatalf("got err=%v, want substring %q", err, tt.wantErrSub)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
|
|
func TestHTTPAuthHandler_Permissions(t *testing.T) {
|
|
cfg := &config.Config{
|
|
JWTPrivateKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
|
}
|
|
handler := auth.NewHTTPAuthHandler(cfg)
|
|
jwtManager := intauth.NewJWTManager(cfg)
|
|
|
|
// Generate a test key pair
|
|
publicKey, privateKey, err := ed25519.GenerateKey(nil)
|
|
require.NoError(t, err)
|
|
|
|
publicKeyB64 := base64.StdEncoding.EncodeToString(publicKey)
|
|
|
|
tests := []struct {
|
|
name string
|
|
domain string
|
|
expectedPatterns []string
|
|
unexpectedPatterns []string
|
|
}{
|
|
{
|
|
name: "simple domain",
|
|
domain: testDomain,
|
|
expectedPatterns: []string{
|
|
"com.example/*", // exact domain pattern only (HTTP does not include subdomains)
|
|
},
|
|
unexpectedPatterns: []string{
|
|
"com.example.*", // HTTP should not grant subdomain permissions
|
|
"example.com/*", // should be reversed
|
|
"*.com.example", // wrong wildcard position
|
|
},
|
|
},
|
|
{
|
|
name: "subdomain",
|
|
domain: "api.example.com",
|
|
expectedPatterns: []string{
|
|
"com.example.api/*", // exact subdomain pattern only
|
|
},
|
|
unexpectedPatterns: []string{
|
|
"com.example.api.*", // HTTP should not grant subdomain permissions
|
|
"com.example/*", // parent domain should not be included
|
|
"api.example.com/*", // should be reversed
|
|
},
|
|
},
|
|
{
|
|
name: "multi-level subdomain",
|
|
domain: "v1.api.example.com",
|
|
expectedPatterns: []string{
|
|
"com.example.api.v1/*", // exact pattern only
|
|
},
|
|
unexpectedPatterns: []string{
|
|
"com.example.api.v1.*", // HTTP should not grant subdomain permissions
|
|
"com.example/*", // parent domain should not be included
|
|
"com.example.api/*", // intermediate domain should not be included
|
|
"v1.api.example.com/*", // should be reversed
|
|
},
|
|
},
|
|
{
|
|
name: "single part domain",
|
|
domain: "localhost",
|
|
expectedPatterns: []string{
|
|
"localhost/*", // exact pattern only (no reversal needed)
|
|
},
|
|
unexpectedPatterns: []string{
|
|
"localhost.*", // HTTP should not grant subdomain permissions
|
|
"*.localhost", // wrong wildcard position
|
|
},
|
|
},
|
|
{
|
|
name: "hyphenated domain",
|
|
domain: "my-app.example-site.com",
|
|
expectedPatterns: []string{
|
|
"com.example-site.my-app/*", // exact pattern only
|
|
},
|
|
unexpectedPatterns: []string{
|
|
"com.example-site.my-app.*", // HTTP should not grant subdomain permissions
|
|
"my-app.example-site.com/*", // should be reversed
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
// Set up mock fetcher
|
|
mockFetcher := &MockHTTPKeyFetcher{
|
|
keyResponses: map[string]string{
|
|
tt.domain: fmt.Sprintf("v=MCPv1; k=ed25519; p=%s", publicKeyB64),
|
|
},
|
|
}
|
|
handler.SetFetcher(mockFetcher)
|
|
|
|
// Generate signature
|
|
timestamp := time.Now().UTC().Format(time.RFC3339)
|
|
signature := ed25519.Sign(privateKey, []byte(timestamp))
|
|
signedTimestamp := hex.EncodeToString(signature)
|
|
|
|
// Exchange token
|
|
result, err := handler.ExchangeToken(context.Background(), tt.domain, timestamp, signedTimestamp)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, result)
|
|
|
|
// Validate JWT token
|
|
claims, err := jwtManager.ValidateToken(context.Background(), result.RegistryToken)
|
|
require.NoError(t, err)
|
|
|
|
// Verify claims structure
|
|
assert.Equal(t, intauth.MethodHTTP, claims.AuthMethod)
|
|
assert.Equal(t, tt.domain, claims.AuthMethodSubject)
|
|
assert.Len(t, claims.Permissions, 1) // HTTP only grants exact domain permissions
|
|
|
|
// Extract permission patterns
|
|
patterns := make([]string, len(claims.Permissions))
|
|
for i, perm := range claims.Permissions {
|
|
patterns[i] = perm.ResourcePattern
|
|
// All permissions should be for publish action
|
|
assert.Equal(t, intauth.PermissionActionPublish, perm.Action)
|
|
}
|
|
|
|
// Check expected patterns are present
|
|
for _, expectedPattern := range tt.expectedPatterns {
|
|
assert.Contains(t, patterns, expectedPattern, "Expected pattern %s not found", expectedPattern)
|
|
}
|
|
|
|
// Check unexpected patterns are not present
|
|
for _, unexpectedPattern := range tt.unexpectedPatterns {
|
|
assert.NotContains(t, patterns, unexpectedPattern, "Unexpected pattern %s found", unexpectedPattern)
|
|
}
|
|
|
|
// Verify the permission patterns work correctly with the JWT manager's HasPermission method
|
|
for _, expectedPattern := range tt.expectedPatterns {
|
|
// Find the permission with this pattern
|
|
var foundPerm *intauth.Permission
|
|
for _, perm := range claims.Permissions {
|
|
if perm.ResourcePattern == expectedPattern {
|
|
foundPerm = &perm
|
|
break
|
|
}
|
|
}
|
|
require.NotNil(t, foundPerm, "Permission with pattern %s not found", expectedPattern)
|
|
|
|
// Test resource scenarios - only exact domain should work for HTTP
|
|
if strings.HasSuffix(expectedPattern, "/*") {
|
|
// Exact domain permissions (e.g., "com.example/*")
|
|
basePattern := strings.TrimSuffix(expectedPattern, "/*")
|
|
testResource := basePattern + "/my-package"
|
|
assert.True(t, jwtManager.HasPermission(testResource, intauth.PermissionActionPublish, claims.Permissions),
|
|
"Should have permission for %s with pattern %s", testResource, expectedPattern)
|
|
|
|
// Test that subdomain resources are NOT allowed for HTTP
|
|
subdomainResource := basePattern + ".subdomain/my-package"
|
|
assert.False(t, jwtManager.HasPermission(subdomainResource, intauth.PermissionActionPublish, claims.Permissions),
|
|
"Should NOT have permission for subdomain %s with HTTP auth", subdomainResource)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestHTTPAuthHandler_PermissionValidation(t *testing.T) {
|
|
cfg := &config.Config{
|
|
JWTPrivateKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
|
}
|
|
handler := auth.NewHTTPAuthHandler(cfg)
|
|
jwtManager := intauth.NewJWTManager(cfg)
|
|
|
|
// Generate a test key pair
|
|
publicKey, privateKey, err := ed25519.GenerateKey(nil)
|
|
require.NoError(t, err)
|
|
|
|
publicKeyB64 := base64.StdEncoding.EncodeToString(publicKey)
|
|
domain := testDomain
|
|
|
|
// Set up mock fetcher
|
|
mockFetcher := &MockHTTPKeyFetcher{
|
|
keyResponses: map[string]string{
|
|
domain: fmt.Sprintf("v=MCPv1; k=ed25519; p=%s", publicKeyB64),
|
|
},
|
|
}
|
|
handler.SetFetcher(mockFetcher)
|
|
|
|
// Generate signature and exchange token
|
|
timestamp := time.Now().UTC().Format(time.RFC3339)
|
|
signature := ed25519.Sign(privateKey, []byte(timestamp))
|
|
signedTimestamp := hex.EncodeToString(signature)
|
|
|
|
result, err := handler.ExchangeToken(context.Background(), domain, timestamp, signedTimestamp)
|
|
require.NoError(t, err)
|
|
|
|
claims, err := jwtManager.ValidateToken(context.Background(), result.RegistryToken)
|
|
require.NoError(t, err)
|
|
|
|
// Test permission validation scenarios
|
|
testCases := []struct {
|
|
name string
|
|
resource string
|
|
action intauth.PermissionAction
|
|
shouldPass bool
|
|
}{
|
|
{
|
|
name: "exact domain resource with publish action",
|
|
resource: "com.example/my-package",
|
|
action: intauth.PermissionActionPublish,
|
|
shouldPass: true,
|
|
},
|
|
{
|
|
name: "subdomain resource should fail for HTTP",
|
|
resource: "com.example.api/my-package",
|
|
action: intauth.PermissionActionPublish,
|
|
shouldPass: false, // HTTP does not grant subdomain permissions
|
|
},
|
|
{
|
|
name: "deep subdomain resource should fail for HTTP",
|
|
resource: "com.example.v1.api/my-package",
|
|
action: intauth.PermissionActionPublish,
|
|
shouldPass: false, // HTTP does not grant subdomain permissions
|
|
},
|
|
{
|
|
name: "different domain should fail",
|
|
resource: "com.otherdomain/my-package",
|
|
action: intauth.PermissionActionPublish,
|
|
shouldPass: false,
|
|
},
|
|
{
|
|
name: "partial domain match should fail",
|
|
resource: "com.example-other/my-package",
|
|
action: intauth.PermissionActionPublish,
|
|
shouldPass: false,
|
|
},
|
|
{
|
|
name: "parent domain should fail",
|
|
resource: "com/my-package",
|
|
action: intauth.PermissionActionPublish,
|
|
shouldPass: false,
|
|
},
|
|
{
|
|
name: "edit action should fail (not granted)",
|
|
resource: "com.example/my-package",
|
|
action: intauth.PermissionActionEdit,
|
|
shouldPass: false,
|
|
},
|
|
{
|
|
name: "resource without package separator should fail",
|
|
resource: "com.example",
|
|
action: intauth.PermissionActionPublish,
|
|
shouldPass: false,
|
|
},
|
|
}
|
|
|
|
for _, tc := range testCases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
hasPermission := jwtManager.HasPermission(tc.resource, tc.action, claims.Permissions)
|
|
if tc.shouldPass {
|
|
assert.True(t, hasPermission, "Expected permission for resource %s with action %s", tc.resource, tc.action)
|
|
} else {
|
|
assert.False(t, hasPermission, "Expected no permission for resource %s with action %s", tc.resource, tc.action)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestHTTPvsDNS_PermissionDifferences(t *testing.T) {
|
|
cfg := &config.Config{
|
|
JWTPrivateKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
|
}
|
|
httpHandler := auth.NewHTTPAuthHandler(cfg)
|
|
dnsHandler := auth.NewDNSAuthHandler(cfg)
|
|
jwtManager := intauth.NewJWTManager(cfg)
|
|
|
|
// Generate a test key pair
|
|
publicKey, privateKey, err := ed25519.GenerateKey(nil)
|
|
require.NoError(t, err)
|
|
|
|
publicKeyB64 := base64.StdEncoding.EncodeToString(publicKey)
|
|
domain := testDomain
|
|
|
|
// Set up mocks
|
|
mockFetcher := &MockHTTPKeyFetcher{
|
|
keyResponses: map[string]string{
|
|
domain: fmt.Sprintf("v=MCPv1; k=ed25519; p=%s", publicKeyB64),
|
|
},
|
|
}
|
|
httpHandler.SetFetcher(mockFetcher)
|
|
|
|
mockResolver := &MockDNSResolver{
|
|
txtRecords: map[string][]string{
|
|
domain: {
|
|
fmt.Sprintf("v=MCPv1; k=ed25519; p=%s", publicKeyB64),
|
|
},
|
|
},
|
|
}
|
|
dnsHandler.SetResolver(mockResolver)
|
|
|
|
// Generate tokens from both handlers
|
|
timestamp := time.Now().UTC().Format(time.RFC3339)
|
|
signature := ed25519.Sign(privateKey, []byte(timestamp))
|
|
signedTimestamp := hex.EncodeToString(signature)
|
|
|
|
httpResult, err := httpHandler.ExchangeToken(context.Background(), domain, timestamp, signedTimestamp)
|
|
require.NoError(t, err)
|
|
|
|
dnsResult, err := dnsHandler.ExchangeToken(context.Background(), domain, timestamp, signedTimestamp)
|
|
require.NoError(t, err)
|
|
|
|
// Validate both tokens
|
|
httpClaims, err := jwtManager.ValidateToken(context.Background(), httpResult.RegistryToken)
|
|
require.NoError(t, err)
|
|
|
|
dnsClaims, err := jwtManager.ValidateToken(context.Background(), dnsResult.RegistryToken)
|
|
require.NoError(t, err)
|
|
|
|
// Compare permission counts
|
|
assert.Len(t, httpClaims.Permissions, 1, "HTTP should grant 1 permission (exact domain only)")
|
|
assert.Len(t, dnsClaims.Permissions, 2, "DNS should grant 2 permissions (exact domain + subdomains)")
|
|
|
|
// Test resources that should behave differently
|
|
testCases := []struct {
|
|
name string
|
|
resource string
|
|
httpAllowed bool
|
|
dnsAllowed bool
|
|
}{
|
|
{
|
|
name: "exact domain resource",
|
|
resource: "com.example/my-package",
|
|
httpAllowed: true,
|
|
dnsAllowed: true,
|
|
},
|
|
{
|
|
name: "subdomain resource",
|
|
resource: "com.example.api/my-package",
|
|
httpAllowed: false, // HTTP does not grant subdomain permissions
|
|
dnsAllowed: true, // DNS grants subdomain permissions
|
|
},
|
|
{
|
|
name: "deep subdomain resource",
|
|
resource: "com.example.v1.api/my-package",
|
|
httpAllowed: false, // HTTP does not grant subdomain permissions
|
|
dnsAllowed: true, // DNS grants subdomain permissions
|
|
},
|
|
}
|
|
|
|
for _, tc := range testCases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
httpPermission := jwtManager.HasPermission(tc.resource, intauth.PermissionActionPublish, httpClaims.Permissions)
|
|
dnsPermission := jwtManager.HasPermission(tc.resource, intauth.PermissionActionPublish, dnsClaims.Permissions)
|
|
|
|
assert.Equal(t, tc.httpAllowed, httpPermission, "HTTP permission mismatch for %s", tc.resource)
|
|
assert.Equal(t, tc.dnsAllowed, dnsPermission, "DNS permission mismatch for %s", tc.resource)
|
|
})
|
|
}
|
|
}
|
|
|