mirror of
https://github.com/zitadel/zitadel.git
synced 2026-07-25 18:28:00 +00:00
# Which Problems Are Solved Currently ZITADEL only allows the use of RSA public keys for machine user authentication (jwt-bearer grant), including for system API users. Attempting to use ECDSA (e.g. P-256) or ED25519 keys results in `Errors.Internal` because `BytesToPublicKey` performs an `ifc.(*rsa.PublicKey)` type assertion that returns `(nil, nil)` for non-RSA keys, which then causes a nil key panic in go-jose during JWT verification. This is the same fix as #8433 (by @livio-a), rebased onto current `main`. # How the Problems Are Solved - `BytesToPublicKey` now returns `crypto.PublicKey` (the standard library interface) instead of `*rsa.PublicKey` - A type switch validates the parsed key is one of `*rsa.PublicKey`, `*ecdsa.PublicKey`, or `ed25519.PublicKey` - A new `ErrNoPublicKey` sentinel error is returned for unsupported key types instead of silently returning nil - Callers in `system_token.go` and `query/key.go` are updated to use the generic `crypto.PublicKey` interface # Additional Changes None # Additional Context Duplicate of #8433 which has been open since August 2024. We hit this bug while implementing OIDC bootstrap for an SGX enclave that generates ECDSA P-256 keys at runtime -- the `AddKey` API accepts the ECDSA SPKI PEM fine, but the subsequent `jwt-bearer` token exchange fails with `Errors.Internal` due to the nil key. --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Wim Van Laer <wim+github@zitadel.com>
181 lines
5.3 KiB
Go
181 lines
5.3 KiB
Go
package authz
|
|
|
|
import (
|
|
"context"
|
|
"crypto"
|
|
"crypto/rsa"
|
|
"crypto/x509"
|
|
"encoding/pem"
|
|
"errors"
|
|
"os"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/go-jose/go-jose/v4"
|
|
"github.com/zitadel/oidc/v3/pkg/op"
|
|
|
|
zcrypto "github.com/zitadel/zitadel/internal/crypto"
|
|
"github.com/zitadel/zitadel/internal/zerrors"
|
|
)
|
|
|
|
var _ SystemTokenVerifier = (*SystemTokenVerifierFromConfig)(nil)
|
|
|
|
type SystemTokenVerifierFromConfig struct {
|
|
systemJWTProfile *op.JWTProfileVerifier
|
|
systemUsers map[string]Memberships
|
|
}
|
|
|
|
func StartSystemTokenVerifierFromConfig(issuer string, keys map[string]*SystemAPIUser) (*SystemTokenVerifierFromConfig, error) {
|
|
systemUsers := make(map[string]Memberships, len(keys))
|
|
for userID, key := range keys {
|
|
if len(key.Memberships) == 0 {
|
|
systemUsers[userID] = Memberships{{MemberType: MemberTypeSystem, Roles: []string{"SYSTEM_OWNER"}}}
|
|
continue
|
|
}
|
|
for _, membership := range key.Memberships {
|
|
switch membership.MemberType {
|
|
case MemberTypeSystem, MemberTypeIAM, MemberTypeOrganization:
|
|
systemUsers[userID] = key.Memberships
|
|
case MemberTypeUnspecified, MemberTypeProject, MemberTypeProjectGrant:
|
|
return nil, errors.New("for system users, only the membership types System, IAM and Organization are supported")
|
|
default:
|
|
return nil, errors.New("unknown membership type")
|
|
}
|
|
}
|
|
}
|
|
return &SystemTokenVerifierFromConfig{
|
|
systemJWTProfile: op.NewJWTProfileVerifier(
|
|
&systemJWTStorage{
|
|
keys: keys,
|
|
cachedKeys: make(map[string]*SystemAPIPublicKey),
|
|
},
|
|
issuer,
|
|
1*time.Hour,
|
|
time.Second,
|
|
),
|
|
systemUsers: systemUsers,
|
|
}, nil
|
|
}
|
|
|
|
func (s *SystemTokenVerifierFromConfig) VerifySystemToken(ctx context.Context, token string, orgID string) (matchingMemberships Memberships, userID string, err error) {
|
|
jwtReq, err := op.VerifyJWTAssertion(ctx, token, s.systemJWTProfile)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
systemUserMemberships, ok := s.systemUsers[jwtReq.Subject]
|
|
if !ok {
|
|
return nil, "", zerrors.ThrowPermissionDenied(nil, "AUTH-Bohd2", "Errors.User.UserIDWrong")
|
|
}
|
|
matchingMemberships = make(Memberships, 0, len(systemUserMemberships))
|
|
for _, membership := range systemUserMemberships {
|
|
if membership.MemberType == MemberTypeSystem ||
|
|
membership.MemberType == MemberTypeIAM && GetInstance(ctx).InstanceID() == membership.AggregateID ||
|
|
membership.MemberType == MemberTypeOrganization && orgID == membership.AggregateID {
|
|
matchingMemberships = append(matchingMemberships, membership)
|
|
}
|
|
}
|
|
return matchingMemberships, jwtReq.Subject, nil
|
|
}
|
|
|
|
type systemJWTStorage struct {
|
|
keys map[string]*SystemAPIUser
|
|
mutex sync.RWMutex
|
|
cachedKeys map[string]*SystemAPIPublicKey
|
|
}
|
|
|
|
type SystemAPIUser struct {
|
|
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
|
|
}
|
|
|
|
type SystemAPIPublicKey struct {
|
|
Data crypto.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)
|
|
if err != nil {
|
|
return nil, zerrors.ThrowInternal(err, "AUTHZ-JK31F", "Errors.NotFound")
|
|
}
|
|
}
|
|
|
|
// when an RSA key is provided, use the raw data
|
|
key, err := zcrypto.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()
|
|
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
|
|
}
|
|
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")
|
|
}
|
|
key, err := user.readKey()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
s.cachedKeys[userID] = key
|
|
return key, err
|
|
}
|