From 8054a500e8512ef82ea762cb1759b1b9258b35a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20Kupczy=C5=84ski?= Date: Thu, 15 Oct 2026 12:38:12 +0200 Subject: [PATCH] fix(auth): verify JWT subject matches stored API token tenant Co-authored-by: Cursor --- pkg/auth/token/token.go | 4 + pkg/auth/token/token_authdisabled_test.go | 101 ++++++++++++++++-- .../token/token_embedded_authdisabled_test.go | 14 ++- 3 files changed, 110 insertions(+), 9 deletions(-) diff --git a/pkg/auth/token/token.go b/pkg/auth/token/token.go index dfa6f52640..7c559ab462 100644 --- a/pkg/auth/token/token.go +++ b/pkg/auth/token/token.go @@ -193,6 +193,10 @@ func (j *jwtManagerImpl) ValidateTenantToken(ctx context.Context, token string) return uuid.Nil, uuid.Nil, fmt.Errorf("failed to parse subject claim: %v", err) } + if dbToken.TenantId == nil || *dbToken.TenantId != parsedSubject { + return uuid.Nil, uuid.Nil, fmt.Errorf("subject claim does not match token tenant_id") + } + return parsedSubject, dbToken.ID, nil } diff --git a/pkg/auth/token/token_authdisabled_test.go b/pkg/auth/token/token_authdisabled_test.go index 1c58429dff..dcf4a780c2 100644 --- a/pkg/auth/token/token_authdisabled_test.go +++ b/pkg/auth/token/token_authdisabled_test.go @@ -10,6 +10,7 @@ import ( "time" "github.com/google/uuid" + "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -23,14 +24,32 @@ import ( "github.com/hatchet-dev/hatchet/pkg/repository/sqlcv1" ) -type stubAPITokenRepository struct{} +type stubAPITokenRepository struct { + tokens map[uuid.UUID]*sqlcv1.APIToken +} func (s *stubAPITokenRepository) CreateAPIToken(ctx context.Context, opts *v1.CreateAPITokenOpts) (*sqlcv1.APIToken, error) { - return &sqlcv1.APIToken{ID: opts.ID, ExpiresAt: pgtype.Timestamp{Time: opts.ExpiresAt, Valid: true}}, nil + tok := &sqlcv1.APIToken{ + ID: opts.ID, + TenantId: opts.TenantId, + ExpiresAt: pgtype.Timestamp{Time: opts.ExpiresAt, Valid: true}, + } + + if s.tokens == nil { + s.tokens = make(map[uuid.UUID]*sqlcv1.APIToken) + } + + s.tokens[opts.ID] = tok + + return tok, nil } func (s *stubAPITokenRepository) GetAPITokenById(ctx context.Context, id uuid.UUID) (*sqlcv1.APIToken, error) { - return &sqlcv1.APIToken{ID: id, Revoked: false, ExpiresAt: pgtype.Timestamp{Time: time.Now().Add(24 * time.Hour), Valid: true}}, nil + if tok, ok := s.tokens[id]; ok { + return tok, nil + } + + return nil, pgx.ErrNoRows } func (s *stubAPITokenRepository) ListAPITokensByTenant(ctx context.Context, tenantId uuid.UUID) ([]*sqlcv1.APIToken, error) { @@ -50,11 +69,7 @@ func (s *stubAPITokenRepository) DeleteAPIToken(ctx context.Context, tenantId, i // and vice versa. The embedded token is only accepted via the separate authdisabled manager, which // is only wired up (in the gRPC middleware) behind the compile-time authmode.IsDisabled constant. func TestAuthDisabledKeysetIsolation(t *testing.T) { - master, mainPriv, mainPub, _, err := encryption.GenerateLocalKeys() - require.NoError(t, err) - - mainEnc, err := encryption.NewLocalEncryption(master, mainPriv, mainPub) - require.NoError(t, err) + mainEnc := newLocalEncryption(t) adPriv, adPub := generateInsecureJWTKeyset(t) adEnc, err := encryption.NewInsecureJWTEncryption(adPriv, adPub) @@ -89,6 +104,76 @@ func TestAuthDisabledKeysetIsolation(t *testing.T) { assert.Error(t, err, "the authdisabled manager must reject a main-keyset token") } +func TestRejectsTokenWhenSubjectDoesNotMatchStoredTenant(t *testing.T) { + ctx := context.Background() + opts := &token.TokenOpts{Issuer: "hatchet", Audience: "hatchet", ServerURL: "http://localhost:8080"} + + enc := newLocalEncryption(t) + mgr, err := token.NewJWTManager(enc, &stubAPITokenRepository{}, opts) + require.NoError(t, err) + + tenantA := uuid.New() + tenantB := uuid.New() + + tokenA, err := mgr.GenerateTenantToken(ctx, tenantA, "tenant-a", false, nil) + require.NoError(t, err) + + gotTenant, _, err := mgr.ValidateTenantToken(ctx, tokenA.Token) + require.NoError(t, err) + assert.Equal(t, tenantA, gotTenant) + + resignedWithTenantB := resignTokenWithSubject(t, enc, opts, tokenA, tenantB) + + _, _, err = mgr.ValidateTenantToken(ctx, resignedWithTenantB) + assert.Error(t, err) +} + +func newLocalEncryption(t *testing.T) encryption.EncryptionService { + t.Helper() + + master, priv, pub, _, err := encryption.GenerateLocalKeys() + require.NoError(t, err) + + enc, err := encryption.NewLocalEncryption(master, priv, pub) + require.NoError(t, err) + + return enc +} + +func resignTokenWithSubject( + t *testing.T, + enc encryption.EncryptionService, + opts *token.TokenOpts, + tok *token.Token, + subject uuid.UUID, +) string { + t.Helper() + + signer, err := jwt.NewSigner(enc.GetPrivateJWTHandle()) + require.NoError(t, err) + + issuedAt := time.Now().Add(-time.Minute) + subjectStr := subject.String() + + rawJWT, err := jwt.NewRawJWT(&jwt.RawJWTOptions{ + IssuedAt: &issuedAt, + Audience: &opts.Audience, + Subject: &subjectStr, + ExpiresAt: &tok.ExpiresAt, + Issuer: &opts.Issuer, + CustomClaims: map[string]interface{}{ + "token_id": tok.TokenId.String(), + "server_url": opts.ServerURL, + }, + }) + require.NoError(t, err) + + signed, err := signer.SignAndEncode(rawJWT) + require.NoError(t, err) + + return signed +} + func generateInsecureJWTKeyset(t *testing.T) (private, public []byte) { t.Helper() diff --git a/pkg/auth/token/token_embedded_authdisabled_test.go b/pkg/auth/token/token_embedded_authdisabled_test.go index 76501deb77..f689f37dc3 100644 --- a/pkg/auth/token/token_embedded_authdisabled_test.go +++ b/pkg/auth/token/token_embedded_authdisabled_test.go @@ -5,13 +5,16 @@ package token_test import ( "context" "testing" + "time" + "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/hatchet-dev/hatchet/pkg/auth/token" "github.com/hatchet-dev/hatchet/pkg/authmode" "github.com/hatchet-dev/hatchet/pkg/encryption" + v1 "github.com/hatchet-dev/hatchet/pkg/repository" ) // TestEmbeddedTokenValidates checks that the committed embedded token validates against a JWT @@ -20,7 +23,16 @@ func TestEmbeddedTokenValidates(t *testing.T) { enc, err := encryption.NewInsecureJWTEncryption(authmode.EmbeddedPrivateKeyset(), authmode.EmbeddedPublicKeyset()) require.NoError(t, err) - mgr, err := token.NewJWTManager(enc, &stubAPITokenRepository{}, &token.TokenOpts{ + embeddedTenantID := uuid.MustParse(authmode.EmbeddedTokenTenantID) + repo := &stubAPITokenRepository{} + _, err = repo.CreateAPIToken(context.Background(), &v1.CreateAPITokenOpts{ + ID: uuid.MustParse(authmode.EmbeddedTokenID), + TenantId: &embeddedTenantID, + ExpiresAt: time.Now().Add(24 * time.Hour), + }) + require.NoError(t, err) + + mgr, err := token.NewJWTManager(enc, repo, &token.TokenOpts{ Issuer: authmode.EmbeddedTokenIssuer, Audience: authmode.EmbeddedTokenAudience, ServerURL: authmode.EmbeddedTokenServerURL,