Skip to content

Commit ad07a27

Browse files
committed
feat: add namespace blocking functionality for JWT authentication
Implements a denylist mechanism to block specific namespaces from publishing packages. Admins with global permissions bypass the blocking logic. This addresses namespace abuse prevention requirements. Fixes #98 🏠 Remote-Dev: homespace
1 parent 1df3737 commit ad07a27

File tree

2 files changed

+132
-6
lines changed

2 files changed

+132
-6
lines changed

internal/auth/jwt.go

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -43,9 +43,10 @@ type TokenResponse struct {
4343

4444
// JWTManager handles JWT token operations
4545
type JWTManager struct {
46-
privateKey ed25519.PrivateKey
47-
publicKey ed25519.PublicKey
48-
tokenDuration time.Duration
46+
privateKey ed25519.PrivateKey
47+
publicKey ed25519.PublicKey
48+
tokenDuration time.Duration
49+
BlockedNamespaces []string
4950
}
5051

5152
func NewJWTManager(cfg *config.Config) *JWTManager {
@@ -63,15 +64,40 @@ func NewJWTManager(cfg *config.Config) *JWTManager {
6364
privateKey := ed25519.NewKeyFromSeed(seed)
6465
publicKey := privateKey.Public().(ed25519.PublicKey)
6566

67+
blockedNamespaces := []string{
68+
// Add blocked namespaces here, e.g.:
69+
// "io.github.spammer",
70+
// "com.evil-domain",
71+
}
72+
6673
return &JWTManager{
67-
privateKey: privateKey,
68-
publicKey: publicKey,
69-
tokenDuration: 5 * time.Minute, // 5-minute tokens as per requirements
74+
privateKey: privateKey,
75+
publicKey: publicKey,
76+
tokenDuration: 5 * time.Minute, // 5-minute tokens as per requirements
77+
BlockedNamespaces: blockedNamespaces,
7078
}
7179
}
7280

7381
// GenerateToken generates a new Registry JWT token
7482
func (j *JWTManager) GenerateTokenResponse(_ context.Context, claims JWTClaims) (*TokenResponse, error) {
83+
// Check whether they have global permissions (used by admins)
84+
hasGlobalPermissions := false
85+
for _, perm := range claims.Permissions {
86+
if perm.ResourcePattern == "*" {
87+
hasGlobalPermissions = true
88+
break
89+
}
90+
}
91+
92+
// Check permissions against denylist, provided they are not an admin
93+
if !hasGlobalPermissions {
94+
for _, blockedNamespace := range j.BlockedNamespaces {
95+
if j.HasPermission(blockedNamespace+"/test", PermissionActionPublish, claims.Permissions) {
96+
return nil, fmt.Errorf("your namespace is blocked. raise an issue at https://github.com/modelcontextprotocol/registry/ if you think this is a mistake")
97+
}
98+
}
99+
}
100+
75101
if claims.IssuedAt == nil {
76102
claims.IssuedAt = jwt.NewNumericDate(time.Now())
77103
}

internal/auth/jwt_test.go

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,3 +283,103 @@ func TestNewJWTManager_InvalidKeySize(t *testing.T) {
283283
auth.NewJWTManager(cfg)
284284
})
285285
}
286+
287+
func TestJWTManager_BlockedNamespaces(t *testing.T) {
288+
// Generate a proper Ed25519 seed for testing
289+
testSeed := make([]byte, ed25519.SeedSize)
290+
_, err := rand.Read(testSeed)
291+
require.NoError(t, err)
292+
293+
cfg := &config.Config{
294+
JWTPrivateKey: hex.EncodeToString(testSeed),
295+
}
296+
297+
ctx := context.Background()
298+
299+
t.Run("blocked namespace should deny token", func(t *testing.T) {
300+
jwtManager := auth.NewJWTManager(cfg)
301+
// Add a blocked namespace for testing
302+
jwtManager.BlockedNamespaces = []string{"io.github.spammer"}
303+
304+
claims := auth.JWTClaims{
305+
AuthMethod: model.AuthMethodGitHubAT,
306+
AuthMethodSubject: "spammer",
307+
Permissions: []auth.Permission{
308+
{
309+
Action: auth.PermissionActionPublish,
310+
ResourcePattern: "io.github.spammer/*",
311+
},
312+
},
313+
}
314+
315+
tokenResponse, err := jwtManager.GenerateTokenResponse(ctx, claims)
316+
assert.Error(t, err)
317+
assert.Contains(t, err.Error(), "your namespace is blocked")
318+
assert.Nil(t, tokenResponse)
319+
})
320+
321+
t.Run("non-blocked namespace should allow token", func(t *testing.T) {
322+
jwtManager := auth.NewJWTManager(cfg)
323+
jwtManager.BlockedNamespaces = []string{"io.github.spammer"}
324+
325+
claims := auth.JWTClaims{
326+
AuthMethod: model.AuthMethodGitHubAT,
327+
AuthMethodSubject: "gooduser",
328+
Permissions: []auth.Permission{
329+
{
330+
Action: auth.PermissionActionPublish,
331+
ResourcePattern: "io.github.gooduser/*",
332+
},
333+
},
334+
}
335+
336+
tokenResponse, err := jwtManager.GenerateTokenResponse(ctx, claims)
337+
require.NoError(t, err)
338+
assert.NotEmpty(t, tokenResponse.RegistryToken)
339+
})
340+
341+
t.Run("multiple permissions with one blocked should deny token", func(t *testing.T) {
342+
jwtManager := auth.NewJWTManager(cfg)
343+
jwtManager.BlockedNamespaces = []string{"io.github.badorg"}
344+
345+
claims := auth.JWTClaims{
346+
AuthMethod: model.AuthMethodGitHubAT,
347+
AuthMethodSubject: "user",
348+
Permissions: []auth.Permission{
349+
{
350+
Action: auth.PermissionActionPublish,
351+
ResourcePattern: "io.github.user/*", // allowed
352+
},
353+
{
354+
Action: auth.PermissionActionPublish,
355+
ResourcePattern: "io.github.badorg/*", // blocked
356+
},
357+
},
358+
}
359+
360+
tokenResponse, err := jwtManager.GenerateTokenResponse(ctx, claims)
361+
assert.Error(t, err)
362+
assert.Contains(t, err.Error(), "your namespace is blocked")
363+
assert.Nil(t, tokenResponse)
364+
})
365+
366+
t.Run("global admin permissions should bypass denylist", func(t *testing.T) {
367+
jwtManager := auth.NewJWTManager(cfg)
368+
jwtManager.BlockedNamespaces = []string{"io.github.spammer"}
369+
370+
claims := auth.JWTClaims{
371+
AuthMethod: model.AuthMethodNone,
372+
AuthMethodSubject: "admin",
373+
Permissions: []auth.Permission{
374+
{
375+
Action: auth.PermissionActionPublish,
376+
ResourcePattern: "*", // global permission should bypass blocking
377+
},
378+
},
379+
}
380+
381+
tokenResponse, err := jwtManager.GenerateTokenResponse(ctx, claims)
382+
require.NoError(t, err)
383+
assert.NotEmpty(t, tokenResponse.RegistryToken)
384+
})
385+
}

0 commit comments

Comments
 (0)