mirror of
https://github.com/andwati/zitadel.git
synced 2026-11-10 16:15:12 +00:00
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:
co-authored by
Livio Spring
parent
85ce8c153b
commit
70adaff793
@@ -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
@@ -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>"}}'
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user