feat: add option to use x.509 certificate system-api-user tokens (#11876)

# Which Problems Are Solved

System API users currently authenticate using raw RSA public keys
configured via Path or KeyData. This approach doesn't integrate well
with Kubernetes tooling.

# How the Problems Are Solved

Allow for the `path`/`keyData` to be an X.509 certificate. 

The `NotBefore` and `NotAfter` fields of the certificate are beeing
respected when validating the JWT.

# Additional Changes

# Additional Context

- Closes #11442

---------

Co-authored-by: Livio Spring <[email protected]>
This commit is contained in:
Wim Van Laer
2026-03-23 13:32:27 +00:00
committed by GitHub
co-authored by Livio Spring
parent 85ce8c153b
commit 70adaff793
4 changed files with 321 additions and 17 deletions
@@ -54,6 +54,9 @@ SystemAPIUsers:
KeyData: <base64 encoded value of system-user-1.pub>
```
You can either specify the public key directly in this way or wrap it in an X.509 certificate for better key management
in environments like kubernetes.
You can define memberships for the user as well:
```yaml
+4 -2
View File
@@ -808,7 +808,9 @@ SystemAPIUsers:
# # Add keys for authentication of the systemAPI here:
# # you can specify any name for the user, but they will have to match the `issuer` and `sub` claim in the JWT:
# - superuser:
# Path: /path/to/superuser/ey.pem # you can provide the key either by reference with the path
# # you can provide the key either by reference with the path. The data in this file has to either be a public RSA
# # key or a public X.509 certificate
# Path: /path/to/superuser/key.pem
# Memberships:
# # MemberType System allows the user to access all APIs for all instances or organizations
# - MemberType: System
@@ -826,7 +828,7 @@ SystemAPIUsers:
# AggregateID: "123456789012345678"
# - superuser2:
# # If no memberships are specified, the user has a membership of type System with the role "SYSTEM_OWNER"
# KeyData: <base64 encoded key> # or you can directly embed it as base64 encoded value
# KeyData: <base64 encoded key> # or you can directly embed it as base64 encoded value or X.509 certificate contents
# Configure the SystemAPIUsers by environment variable using JSON notation:
# ZITADEL_SYSTEMAPIUSERS='{"systemuser":{"Path":"/path/to/superuser/key.pem"},"systemuser2":{"KeyData":"<base64 encoded key>"}}'
+75 -15
View File
@@ -3,6 +3,8 @@ package authz
import (
"context"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"errors"
"os"
"sync"
@@ -44,7 +46,7 @@ func StartSystemTokenVerifierFromConfig(issuer string, keys map[string]*SystemAP
systemJWTProfile: op.NewJWTProfileVerifier(
&systemJWTStorage{
keys: keys,
cachedKeys: make(map[string]*rsa.PublicKey),
cachedKeys: make(map[string]*SystemAPIPublicKey),
},
issuer,
1*time.Hour,
@@ -77,16 +79,22 @@ func (s *SystemTokenVerifierFromConfig) VerifySystemToken(ctx context.Context, t
type systemJWTStorage struct {
keys map[string]*SystemAPIUser
mutex sync.RWMutex
cachedKeys map[string]*rsa.PublicKey
cachedKeys map[string]*SystemAPIPublicKey
}
type SystemAPIUser struct {
Path string // if a path is specified, the key will be read from that path
Path string // if a path is specified, the key/cert will be read from that path
KeyData []byte // else you can also specify the data directly in the KeyData
Memberships Memberships
}
func (s *SystemAPIUser) readKey() (*rsa.PublicKey, error) {
type SystemAPIPublicKey struct {
Data *rsa.PublicKey
NotBefore *time.Time
NotAfter *time.Time
}
func (s *SystemAPIUser) readKey() (*SystemAPIPublicKey, error) {
if s.Path != "" {
var err error
s.KeyData, err = os.ReadFile(s.Path)
@@ -94,26 +102,78 @@ func (s *SystemAPIUser) readKey() (*rsa.PublicKey, error) {
return nil, zerrors.ThrowInternal(err, "AUTHZ-JK31F", "Errors.NotFound")
}
}
return crypto.BytesToPublicKey(s.KeyData)
// when an RSA key is provided, use the raw data
key, err := crypto.BytesToPublicKey(s.KeyData)
if err == nil {
return &SystemAPIPublicKey{Data: key}, nil
}
// when x.509 cert is provided, parse it and extract RSA key
block, _ := pem.Decode(s.KeyData)
if block == nil {
return nil, zerrors.ThrowInternal(err, "AUTHZ-FC8ohc", "Errors.SystemApiUser.CertDecodeFailed")
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return nil, zerrors.ThrowInternal(err, "AUTHZ-64nMHP", "Errors.SystemApiUser.CertParseFailed")
}
key, ok := cert.PublicKey.(*rsa.PublicKey)
if !ok {
return nil, zerrors.ThrowInternal(err, "AUTHZ-PNKOMf", "Errors.SystemApiUser.UnsupportedPublicKey")
}
return &SystemAPIPublicKey{
Data: key,
NotBefore: &cert.NotBefore,
NotAfter: &cert.NotAfter,
}, nil
}
func (s *systemJWTStorage) GetKeyByIDAndClientID(_ context.Context, _, userID string) (*jose.JSONWebKey, error) {
now := time.Now().UTC()
s.mutex.RLock()
cachedKey, ok := s.cachedKeys[userID]
s.mutex.RUnlock()
if ok {
return &jose.JSONWebKey{KeyID: userID, Key: cachedKey}, nil
key, ok := s.cachedKeys[userID]
// If a key is found but expired, read delete it and mark it as not found. This will trigger the key to be read from
// file again in case the file was replaced with a new key.
if ok && key.NotAfter != nil && now.After(*key.NotAfter) {
delete(s.cachedKeys, userID)
key = nil
ok = false
}
key, ok := s.keys[userID]
s.mutex.RUnlock()
var err error
if !ok {
if key, err = s.readKey(userID); err != nil {
return nil, err
}
}
if key.NotBefore != nil && now.Before(*key.NotBefore) {
return nil, zerrors.ThrowNotFound(nil, "AUTHZ-NiJstf", "Errors.User.NotBefore")
}
if key.NotAfter != nil && now.After(*key.NotAfter) {
return nil, zerrors.ThrowNotFound(nil, "AUTHZ-CGmV4b", "Errors.User.NotAfter")
}
return &jose.JSONWebKey{KeyID: userID, Key: key.Data}, nil
}
func (s *systemJWTStorage) readKey(userID string) (*SystemAPIPublicKey, error) {
s.mutex.Lock()
defer s.mutex.Unlock()
user, ok := s.keys[userID]
if !ok {
return nil, zerrors.ThrowNotFound(nil, "AUTHZ-asfd3", "Errors.User.NotFound")
}
s.mutex.Lock()
defer s.mutex.Unlock()
publicKey, err := key.readKey()
key, err := user.readKey()
if err != nil {
return nil, err
}
s.cachedKeys[userID] = publicKey
return &jose.JSONWebKey{KeyID: userID, Key: publicKey}, nil
s.cachedKeys[userID] = key
return key, err
}
+239
View File
@@ -0,0 +1,239 @@
package authz
import (
"bytes"
"context"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"fmt"
"math/big"
"testing"
"time"
"github.com/brianvoe/gofakeit/v6"
"github.com/go-jose/go-jose/v4"
"github.com/muhlemmer/gu"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
var exampleRsaPrivateKey *rsa.PrivateKey
var exampleRsaPublicKeyBs []byte
func init() {
var err error
exampleRsaPrivateKey, err = rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
panic(fmt.Sprintf("failed to generate RSA private key: %v", err))
}
publicBs, err := x509.MarshalPKIXPublicKey(&exampleRsaPrivateKey.PublicKey)
if err != nil {
panic(fmt.Sprintf("failed to marshal RSA public key: %v", err))
}
writer := &bytes.Buffer{}
err = pem.Encode(writer, &pem.Block{Type: "PUBLIC KEY", Bytes: publicBs})
if err != nil {
panic(fmt.Sprintf("failed to PEM-encode RSA public key: %v", err))
}
exampleRsaPublicKeyBs = writer.Bytes()
}
func Test_SystemAPIUser_readKey(t *testing.T) {
t.Run("rsa key", func(tt *testing.T) {
// given
publicKey := exampleRsaPrivateKey.PublicKey
user := SystemAPIUser{
KeyData: exampleRsaPublicKeyBs,
}
// when
key, err := user.readKey()
// then
assert.NoError(tt, err)
assert.Nil(tt, key.NotBefore)
assert.Nil(tt, key.NotAfter)
assert.True(tt, publicKey.Equal(key.Data))
})
t.Run("x.509 cert", func(tt *testing.T) {
// given
publicKey := exampleRsaPrivateKey.PublicKey
now := time.Now().Round(time.Second)
cert := createExampleX509Cert(now, now.Add(1*time.Hour))
user := SystemAPIUser{
KeyData: encodeCert(cert),
}
// when
key, err := user.readKey()
// then
assert.NoError(tt, err)
assert.Equal(tt, cert.NotBefore.UTC(), key.NotBefore.UTC())
assert.Equal(tt, cert.NotAfter.UTC(), key.NotAfter.UTC())
assert.True(tt, publicKey.Equal(key.Data))
})
}
func Test_systemJWTStorage_GetKeyByIDAndClientID_Ok(t *testing.T) {
type TestCase struct {
name string
storage *systemJWTStorage
userID string
keyID string
publicKey *SystemAPIPublicKey
privateKey *rsa.PrivateKey
}
testCases := []TestCase{
func() TestCase {
key := &SystemAPIPublicKey{Data: &exampleRsaPrivateKey.PublicKey}
return TestCase{
name: "get from cache, no notBefore or notAfter",
storage: &systemJWTStorage{
cachedKeys: map[string]*SystemAPIPublicKey{
"user-1": key,
},
},
userID: "user-1",
publicKey: key,
privateKey: exampleRsaPrivateKey,
}
}(),
func() TestCase {
key := &SystemAPIPublicKey{
Data: &exampleRsaPrivateKey.PublicKey,
NotBefore: gu.Ptr(time.Now().UTC()),
NotAfter: gu.Ptr(time.Now().UTC().Add(time.Second * 2)),
}
return TestCase{
name: "get from cache, with notBefore and notAfter",
storage: &systemJWTStorage{
cachedKeys: map[string]*SystemAPIPublicKey{
"user-2": key,
},
},
userID: "user-2",
publicKey: key,
privateKey: exampleRsaPrivateKey,
}
}(),
func() TestCase {
now := time.Now().Add(-1 * time.Second).Round(time.Second).UTC()
until := now.Add(1 * time.Hour)
cert := createExampleX509Cert(now, until)
return TestCase{
name: "no cache, with notBefore and notAfter",
storage: &systemJWTStorage{
cachedKeys: make(map[string]*SystemAPIPublicKey),
keys: map[string]*SystemAPIUser{
"user": {
KeyData: encodeCert(cert),
},
},
},
userID: "user",
publicKey: &SystemAPIPublicKey{
Data: &exampleRsaPrivateKey.PublicKey,
NotBefore: &now,
NotAfter: &until,
},
privateKey: exampleRsaPrivateKey,
}
}(),
}
for _, tc := range testCases {
t.Run(tc.name, func(tt *testing.T) {
jwk, err := tc.storage.GetKeyByIDAndClientID(context.Background(), tc.keyID, tc.userID)
assert.NoError(tt, err)
assert.IsType(tt, &rsa.PublicKey{}, jwk.Key)
assert.Equal(tt, tc.publicKey.Data, jwk.Key)
// create a signed payload to test the verification using the public key works
signer, err := jose.NewSigner(jose.SigningKey{
Algorithm: jose.RS256,
Key: tc.privateKey,
}, nil)
require.NoError(tt, err)
signed, err := signer.Sign([]byte("This is a test payload"))
require.NoError(tt, err)
_, err = signed.Verify(jwk)
assert.NoError(tt, err)
})
}
}
func Test_systemJWTStorage_GetKeyByIDAndClientID_Nok(t *testing.T) {
type TestCase struct {
name string
storage *systemJWTStorage
userID string
keyID string
err string
}
testCases := []TestCase{
{
name: "user not found",
storage: &systemJWTStorage{
cachedKeys: make(map[string]*SystemAPIPublicKey),
},
userID: "does not exist",
err: "AUTHZ-asfd3",
},
{
name: "get from cache, not before",
storage: &systemJWTStorage{
cachedKeys: map[string]*SystemAPIPublicKey{
"user": {
NotBefore: gu.Ptr(time.Now().UTC().Add(time.Second * 2)),
},
},
},
userID: "user",
err: "AUTHZ-NiJstf",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(tt *testing.T) {
jwk, err := tc.storage.GetKeyByIDAndClientID(context.Background(), tc.keyID, tc.userID)
assert.Nil(tt, jwk)
assert.ErrorContains(tt, err, tc.err)
})
}
}
func createExampleX509Cert(notBefore, notAfter time.Time) *x509.Certificate {
return &x509.Certificate{
SerialNumber: big.NewInt(1658),
Subject: pkix.Name{
Organization: []string{gofakeit.Company()},
},
NotBefore: notBefore,
NotAfter: notAfter,
SubjectKeyId: []byte{1, 2, 3, 4, 5},
PublicKey: &exampleRsaPrivateKey.PublicKey,
}
}
func encodeCert(pub *x509.Certificate) []byte {
ca := createExampleX509Cert(time.Now(), time.Now().Add(240*time.Hour))
certBytes, err := x509.CreateCertificate(rand.Reader, pub, ca, &exampleRsaPrivateKey.PublicKey, exampleRsaPrivateKey)
if err != nil {
panic(err)
}
buff := &bytes.Buffer{}
_ = pem.Encode(buff, &pem.Block{
Type: "CERTIFICATE",
Bytes: certBytes,
})
return buff.Bytes()
}