From e3900c3033996fc2f2bb4590bbc82628d08eefdd Mon Sep 17 00:00:00 2001 From: Miguel Martinez Date: Mon, 9 Feb 2026 12:38:33 +0100 Subject: [PATCH] feat: add custom audience support to user JWT builder Add WithAudience functional option to allow generating JWT tokens with custom audience claims. This enables separate validation paths for different token types (e.g., MCP user tokens) while maintaining backward compatibility with the default audience. Signed-off-by: Miguel Martinez --- CLAUDE.md | 2 +- app/controlplane/pkg/jwt/user/user.go | 16 ++++++++++-- app/controlplane/pkg/jwt/user/user_test.go | 29 +++++++++++++++++++++- 3 files changed, 43 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 1464243ea..8bfeef386 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -264,7 +264,7 @@ Code reviews are required for all submissions via GitHub pull requests. - when adding new inedexes, make sure to update the generated sql migraiton files and make them CREATE INDEX CONCURRENTLY and set -- atlas:txmode none at the top - after updating protos, make sure to run `buf format -w` - Please avoid sycophantic commentary like ‘You’re absolutely correct!’ or ‘Brilliant idea!’ -- For each file you modify, update the license header. If it says 2024, change it to 2024-2025. If there's no license header, create one with the current year. +- For each file you modify, update the license header to include the current year (2026). For example, if it says 2024, change it to 2024-2026. If there's no license header, create one with the current year. - if you add any new dependency to a constructor, remember to run wire ./... - when creating PR message, keep it high-level, what functionality was added, don't add info about testing, no icons, no info about how the message was generated. - app/controlplane/api/gen/frontend/google/protobuf/descriptor.ts is a special case that we don't want to upgrade, so if it upgrades, put it back to main diff --git a/app/controlplane/pkg/jwt/user/user.go b/app/controlplane/pkg/jwt/user/user.go index 5414ab432..3c6ca0e7b 100644 --- a/app/controlplane/pkg/jwt/user/user.go +++ b/app/controlplane/pkg/jwt/user/user.go @@ -1,5 +1,5 @@ // -// Copyright 2023 The Chainloop Authors. +// Copyright 2023-2026 The Chainloop Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -28,6 +28,7 @@ type Builder struct { issuer string hmacSecret string expiration time.Duration + audience string } type NewOpt func(b *Builder) @@ -50,6 +51,12 @@ func WithExpiration(d time.Duration) NewOpt { } } +func WithAudience(aud string) NewOpt { + return func(b *Builder) { + b.audience = aud + } +} + var defaultExpiration = 24 * time.Hour var SigningMethod = jwt.SigningMethodHS256 @@ -74,11 +81,16 @@ func NewBuilder(opts ...NewOpt) (*Builder, error) { } func (ra *Builder) GenerateJWT(userID string) (string, error) { + aud := Audience + if ra.audience != "" { + aud = ra.audience + } + claims := CustomClaims{ userID, jwt.RegisteredClaims{ Issuer: ra.issuer, - Audience: jwt.ClaimStrings{Audience}, + Audience: jwt.ClaimStrings{aud}, ExpiresAt: jwt.NewNumericDate(time.Now().Add(ra.expiration)), }, } diff --git a/app/controlplane/pkg/jwt/user/user_test.go b/app/controlplane/pkg/jwt/user/user_test.go index 05a3e8772..4edb8a166 100644 --- a/app/controlplane/pkg/jwt/user/user_test.go +++ b/app/controlplane/pkg/jwt/user/user_test.go @@ -1,5 +1,5 @@ // -// Copyright 2023 The Chainloop Authors. +// Copyright 2023-2026 The Chainloop Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -94,3 +94,30 @@ func TestGenerateJWT(t *testing.T) { assert.Contains(t, claims.Audience, Audience) assert.WithinDuration(t, time.Now(), claims.ExpiresAt.Time, 10*time.Second) } + +func TestGenerateJWTWithCustomAudience(t *testing.T) { + const hmacSecret = "my-secret" + const customAudience = "mcp-user-auth.chainloop" + + b, err := NewBuilder( + WithIssuer("my-issuer"), + WithKeySecret(hmacSecret), + WithExpiration(10*time.Second), + WithAudience(customAudience), + ) + require.NoError(t, err) + + token, err := b.GenerateJWT("user-id") + require.NoError(t, err) + assert.NotEmpty(t, token) + + claims := &CustomClaims{} + tokenInfo, err := jwt.ParseWithClaims(token, claims, func(_ *jwt.Token) (interface{}, error) { + return []byte(hmacSecret), nil + }, jwt.WithValidMethods([]string{SigningMethod.Alg()})) + + require.NoError(t, err) + assert.True(t, tokenInfo.Valid) + assert.Equal(t, "user-id", claims.UserID) + assert.Equal(t, jwt.ClaimStrings{customAudience}, claims.Audience) +}