90 lines
2.6 KiB
Go
90 lines
2.6 KiB
Go
// Package zitadel talks to the ZITADEL management API with the controller's own
|
|||
|
|
// machine key.
|
||
|
|
//
|
||
|
|
// The controller needs a credential to rotate credentials. It signs a short
|
||
|
|
// assertion with its private key, exchanges it for an access token, and reuses
|
||
|
|
// that token until shortly before it expires.
|
||
|
|
package zitadel
|
||
|
|
|
||
|
|
import (
|
||
|
|
"crypto"
|
||
|
|
"crypto/rand"
|
||
|
|
"crypto/rsa"
|
||
|
|
"crypto/sha256"
|
||
|
|
"crypto/x509"
|
||
|
|
"encoding/base64"
|
||
|
|
"encoding/json"
|
||
|
|
"encoding/pem"
|
||
|
|
"fmt"
|
||
|
|
"time"
|
||
|
|
)
|
||
|
|
|
||
|
|
// assertionLifetime is deliberately short. The assertion proves one request.
|
||
|
|
const assertionLifetime = 60 * time.Second
|
||
|
|
|
||
|
|
// newAssertionClaims builds the claim set for a machine-key assertion.
|
||
|
|
//
|
||
|
|
// ZITADEL expects `iss` to name the caller, `sub` to name the user it acts as,
|
||
|
|
// and `aud` to equal the issuer of the instance.
|
||
|
|
func newAssertionClaims(clientID, userID, issuer string, now time.Time) map[string]any {
|
||
|
|
if userID == "" {
|
||
|
|
userID = clientID
|
||
|
|
}
|
||
|
|
return map[string]any{
|
||
|
|
"iss": clientID,
|
||
|
|
"sub": userID,
|
||
|
|
"aud": issuer,
|
||
|
|
"iat": now.Unix(),
|
||
|
|
"exp": now.Add(assertionLifetime).Unix(),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func base64URL(b []byte) string {
|
||
|
|
return base64.RawURLEncoding.EncodeToString(b)
|
||
|
|
}
|
||
|
|
|
||
|
|
// SignAssertion produces a signed RS256 JWT for the jwt-bearer grant.
|
||
|
|
//
|
||
|
|
// The header carries `kid`, so ZITADEL selects the matching registered key. That
|
||
|
|
// is what makes overlapping keys possible: two keys can be valid at once and the
|
||
|
|
// assertion says which one it used.
|
||
|
|
func SignAssertion(privateKeyPEM []byte, keyID, clientID, userID, issuer string, now time.Time) (string, error) {
|
||
|
|
block, _ := pem.Decode(privateKeyPEM)
|
||
|
|
if block == nil {
|
||
|
|
return "", fmt.Errorf("private key is not PEM encoded")
|
||
|
|
}
|
||
|
|
|
||
|
|
key, err := x509.ParsePKCS8PrivateKey(block.Bytes)
|
||
|
|
if err != nil {
|
||
|
|
// ZITADEL exports PKCS#1 keys as well.
|
||
|
|
if rsaKey, rsaErr := x509.ParsePKCS1PrivateKey(block.Bytes); rsaErr == nil {
|
||
|
|
key = rsaKey
|
||
|
|
} else {
|
||
|
|
return "", fmt.Errorf("private key does not parse: %w", err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
rsaKey, ok := key.(*rsa.PrivateKey)
|
||
|
|
if !ok {
|
||
|
|
return "", fmt.Errorf("private key is not RSA")
|
||
|
|
}
|
||
|
|
|
||
|
|
header := map[string]any{"alg": "RS256", "typ": "JWT", "kid": keyID}
|
||
|
|
headerJSON, err := json.Marshal(header)
|
||
|
|
if err != nil {
|
||
|
|
return "", err
|
||
|
|
}
|
||
|
|
claimsJSON, err := json.Marshal(newAssertionClaims(clientID, userID, issuer, now))
|
||
|
|
if err != nil {
|
||
|
|
return "", err
|
||
|
|
}
|
||
|
|
|
||
|
|
signingInput := base64URL(headerJSON) + "." + base64URL(claimsJSON)
|
||
|
|
digest := sha256.Sum256([]byte(signingInput))
|
||
|
|
signature, err := rsa.SignPKCS1v15(rand.Reader, rsaKey, crypto.SHA256, digest[:])
|
||
|
|
if err != nil {
|
||
|
|
return "", fmt.Errorf("cannot sign the assertion: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
return signingInput + "." + base64URL(signature), nil
|
||
|
|
}
|