diff --git a/apps/docs/content/guides/integrate/zitadel-apis/access-zitadel-system-api.mdx b/apps/docs/content/guides/integrate/zitadel-apis/access-zitadel-system-api.mdx index 60c2d5b6e..152ef434b 100644 --- a/apps/docs/content/guides/integrate/zitadel-apis/access-zitadel-system-api.mdx +++ b/apps/docs/content/guides/integrate/zitadel-apis/access-zitadel-system-api.mdx @@ -54,6 +54,9 @@ SystemAPIUsers: KeyData: ``` +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 diff --git a/cmd/defaults.yaml b/cmd/defaults.yaml index 8e0497977..ae1103908 100644 --- a/cmd/defaults.yaml +++ b/cmd/defaults.yaml @@ -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: # or you can directly embed it as base64 encoded value +# KeyData: # 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":""}}' diff --git a/internal/api/authz/system_token.go b/internal/api/authz/system_token.go index b8e360f8f..c7c2e49ec 100644 --- a/internal/api/authz/system_token.go +++ b/internal/api/authz/system_token.go @@ -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 } diff --git a/internal/api/authz/system_token_test.go b/internal/api/authz/system_token_test.go new file mode 100644 index 000000000..1114fabae --- /dev/null +++ b/internal/api/authz/system_token_test.go @@ -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() +}