diff --git a/api/formance.com/v1beta1/auth_types.go b/api/formance.com/v1beta1/auth_types.go index d6cd79cb..d65c53c7 100644 --- a/api/formance.com/v1beta1/auth_types.go +++ b/api/formance.com/v1beta1/auth_types.go @@ -40,10 +40,10 @@ type AuthSpec struct { // Contains information about a delegated authentication server to use to delegate authentication DelegatedOIDCServer *DelegatedOIDCServerConfiguration `json:"delegatedOIDCServer,omitempty"` //+optional - // Overrides the default signing key used to sign JWT tokens. + // Overrides the generated per-stack signing key used to sign JWT tokens. SigningKey string `json:"signingKey,omitempty"` //+optional - // Overrides the default signing key used to sign JWT tokens, using a k8s secret + // Overrides the generated per-stack signing key using a Kubernetes Secret. SigningKeyFromSecret *v1.SecretKeySelector `json:"signingKeyFromSecret,omitempty"` //+optional // Enables scope checking during authentication. diff --git a/config/crd/bases/formance.com_auths.yaml b/config/crd/bases/formance.com_auths.yaml index ca733990..bd6b98a2 100644 --- a/config/crd/bases/formance.com_auths.yaml +++ b/config/crd/bases/formance.com_auths.yaml @@ -127,11 +127,12 @@ spec: In that case, being authenticated is sufficient. type: boolean signingKey: - description: Overrides the default signing key used to sign JWT tokens. + description: Overrides the generated per-stack signing key used to + sign JWT tokens. type: string signingKeyFromSecret: - description: Overrides the default signing key used to sign JWT tokens, - using a k8s secret + description: Overrides the generated per-stack signing key using a + Kubernetes Secret. properties: key: description: The key of the secret to select from. Must be a diff --git a/docs/04-Modules/05-Auth.md b/docs/04-Modules/05-Auth.md index 1d01a2c1..eb69f4ac 100644 --- a/docs/04-Modules/05-Auth.md +++ b/docs/04-Modules/05-Auth.md @@ -21,6 +21,10 @@ spec: stack: formance-dev ``` +When no signing key is configured, the operator generates a unique RSA private key for the stack in the `auth-signing-key` Secret and injects it into Auth. The Secret is retained across reconciliations. Use `signingKeyFromSecret` when key ownership or rotation is managed externally. + +When you upgrade an existing Auth instance that used the service's built-in key, the new key invalidates tokens signed before the upgrade. Clients must request new tokens after the Auth Deployment restarts. + ### Define oAuth2 clients You can define oAuth2 clients to secure your stack. The Auth will then be able to authenticate the incoming requests and forward them to the right service. @@ -40,4 +44,4 @@ spec: - payments:read - payments:write stack: formance-dev -``` \ No newline at end of file +``` diff --git a/docs/07-Upgrade/01-Upgrade from the operator.md b/docs/07-Upgrade/01-Upgrade from the operator.md index 50e0f272..9582cda9 100644 --- a/docs/07-Upgrade/01-Upgrade from the operator.md +++ b/docs/07-Upgrade/01-Upgrade from the operator.md @@ -27,6 +27,12 @@ postgresql://postgres:5432?secret=postgres&secretCredentialsEncoding=raw The operator consumes both `secret` and `secretCredentialsEncoding`; neither parameter is included in the final PostgreSQL connection URI. +## Auth signing keys in Operator v3.18.0 + +The `operator` chart `3.18.0` deploys Operator `v3.18.0` and its matching `operator-crds` chart in one Helm upgrade. If an Auth resource does not set `signingKey` or `signingKeyFromSecret`, the operator creates a private signing key in that stack's `auth-signing-key` Secret. + +Existing Auth instances that used the service's built-in key switch to the generated key when Auth rolls out. Previously issued JWTs then stop validating, so clients must request new tokens. Auth instances with an explicit signing key keep using that key. + ## Components upgrade The upgrade process is managed by the operator, who will upgrade the components one by one, as specified in the `versions` CRD. Any migration that needs to be carried out will also be managed by the operator. diff --git a/docs/09-Configuration reference/02-Custom Resource Definitions.md b/docs/09-Configuration reference/02-Custom Resource Definitions.md index 4932f6ef..507f1450 100644 --- a/docs/09-Configuration reference/02-Custom Resource Definitions.md +++ b/docs/09-Configuration reference/02-Custom Resource Definitions.md @@ -443,8 +443,8 @@ The auth service is basically a proxy to another OIDC compliant server. | `version` _string_ | Version overrides, for a specific module, the global version defined at stack level | | | | `stack` _string_ | Stack indicates the stack on which the module is installed | | | | `delegatedOIDCServer` _[DelegatedOIDCServerConfiguration](#delegatedoidcserverconfiguration)_ | Contains information about a delegated authentication server to use to delegate authentication | | | -| `signingKey` _string_ | Overrides the default signing key used to sign JWT tokens. | | | -| `signingKeyFromSecret` _[SecretKeySelector](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#secretkeyselector-v1-core)_ | Overrides the default signing key used to sign JWT tokens, using a k8s secret | | | +| `signingKey` _string_ | Overrides the generated per-stack signing key used to sign JWT tokens. | | | +| `signingKeyFromSecret` _[SecretKeySelector](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#secretkeyselector-v1-core)_ | Overrides the generated per-stack signing key using a Kubernetes Secret. | | | | `enableScopes` _boolean_ | Enables scope checking during authentication.
If not enabled, each service will check the authentication but will not restrict access according to scopes.
In that case, being authenticated is sufficient. | false | | ###### DelegatedOIDCServerConfiguration diff --git a/helm/crds/Chart.yaml b/helm/crds/Chart.yaml index b7d3a539..6681ad28 100644 --- a/helm/crds/Chart.yaml +++ b/helm/crds/Chart.yaml @@ -13,9 +13,9 @@ type: application # This is the chart version. This version number should be incremented each time you make changes # to the chart and its templates, including the app version. # Versions are expected to follow Semantic Versioning (https://semver.org/) -version: "3.17.0" +version: "3.18.0" # This is the version number of the application being deployed. This version number should be # incremented each time you make changes to the application. Versions are not expected to # follow Semantic Versioning. They should reflect the version the application is using. # It is recommended to use it with quotes. -appVersion: "v3.17.0" +appVersion: "v3.18.0" diff --git a/helm/crds/templates/crds/apiextensions.k8s.io_v1_customresourcedefinition_auths.formance.com.yaml b/helm/crds/templates/crds/apiextensions.k8s.io_v1_customresourcedefinition_auths.formance.com.yaml index e7f887e9..b3c31246 100644 --- a/helm/crds/templates/crds/apiextensions.k8s.io_v1_customresourcedefinition_auths.formance.com.yaml +++ b/helm/crds/templates/crds/apiextensions.k8s.io_v1_customresourcedefinition_auths.formance.com.yaml @@ -130,11 +130,12 @@ spec: In that case, being authenticated is sufficient. type: boolean signingKey: - description: Overrides the default signing key used to sign JWT tokens. + description: Overrides the generated per-stack signing key used to + sign JWT tokens. type: string signingKeyFromSecret: - description: Overrides the default signing key used to sign JWT tokens, - using a k8s secret + description: Overrides the generated per-stack signing key using a + Kubernetes Secret. properties: key: description: The key of the secret to select from. Must be a diff --git a/helm/operator/Chart.lock b/helm/operator/Chart.lock index 9d718a94..535d1ac5 100644 --- a/helm/operator/Chart.lock +++ b/helm/operator/Chart.lock @@ -1,6 +1,6 @@ dependencies: - name: operator-crds repository: file://../crds - version: 3.17.0 -digest: sha256:be121e8cf94b9d0eb4ea801d88e3dd7a1b442faf66c50de28a44076bc3dd06cc -generated: "2026-10-08T18:06:46.372994+02:00" + version: 3.18.0 +digest: sha256:1e12f1d09324de085cdda7da879bdace174347d53717bdc7096325c8d71ba800 +generated: "2026-10-14T23:36:33.647638+02:00" diff --git a/helm/operator/Chart.yaml b/helm/operator/Chart.yaml index 7d8930d6..bd530225 100644 --- a/helm/operator/Chart.yaml +++ b/helm/operator/Chart.yaml @@ -13,12 +13,12 @@ type: application # This is the chart version. This version number should be incremented each time you make changes # to the chart and its templates, including the app version. # Versions are expected to follow Semantic Versioning (https://semver.org/) -version: "3.17.0" +version: "3.18.0" # This is the version number of the application being deployed. This version number should be # incremented each time you make changes to the application. Versions are not expected to # follow Semantic Versioning. They should reflect the version the application is using. # It is recommended to use it with quotes. -appVersion: "v3.17.0" +appVersion: "v3.18.0" dependencies: - name: operator-crds version: "3.X" diff --git a/internal/resources/auths/deployment.go b/internal/resources/auths/deployment.go index 5b9f84e7..13233035 100644 --- a/internal/resources/auths/deployment.go +++ b/internal/resources/auths/deployment.go @@ -79,14 +79,28 @@ func createDeployment(ctx Context, stack *v1beta1.Stack, auth *v1beta1.Auth, dat } if auth.Spec.SigningKey != "" { env = append(env, Env("SIGNING_KEY", auth.Spec.SigningKey)) - } - if auth.Spec.SigningKeyFromSecret != nil { + } else if auth.Spec.SigningKeyFromSecret != nil { env = append(env, corev1.EnvVar{ Name: "SIGNING_KEY", ValueFrom: &corev1.EnvVarSource{ SecretKeyRef: auth.Spec.SigningKeyFromSecret, }, }) + } else { + secret, err := ensureSigningKeySecret(ctx, auth) + if err != nil { + return err + } + annotations["auth-signing-key-version"] = secret.ResourceVersion + env = append(env, corev1.EnvVar{ + Name: "SIGNING_KEY", + ValueFrom: &corev1.EnvVarSource{ + SecretKeyRef: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{Name: signingKeySecretName}, + Key: signingKeySecretData, + }, + }, + }) } if auth.Spec.DelegatedOIDCServer != nil { if auth.Spec.DelegatedOIDCServer.ClientSecret != "" && auth.Spec.DelegatedOIDCServer.ClientSecretFromSecret != nil { diff --git a/internal/resources/auths/init.go b/internal/resources/auths/init.go index 472ef561..916edf85 100644 --- a/internal/resources/auths/init.go +++ b/internal/resources/auths/init.go @@ -115,6 +115,7 @@ func init() { WithOwn[*v1beta1.Auth](&v1beta1.GatewayHTTPAPI{}), WithOwn[*v1beta1.Auth](&v1beta1.Database{}), WithOwn[*v1beta1.Auth](&corev1.ConfigMap{}), + WithOwn[*v1beta1.Auth](&corev1.Secret{}), WithOwn[*v1beta1.Auth](&batchv1.Job{}), WithOwn[*v1beta1.Auth](&v1beta1.ResourceReference{}), WithWatchSettings[*v1beta1.Auth](), diff --git a/internal/resources/auths/signing_key.go b/internal/resources/auths/signing_key.go new file mode 100644 index 00000000..49431ff6 --- /dev/null +++ b/internal/resources/auths/signing_key.go @@ -0,0 +1,49 @@ +package auths + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/types" + + "github.com/formancehq/operator/v3/api/formance.com/v1beta1" + . "github.com/formancehq/operator/v3/internal/core" +) + +const ( + signingKeySecretName = "auth-signing-key" + signingKeySecretData = "signing-key" + signingKeyBits = 2048 +) + +func ensureSigningKeySecret(ctx Context, auth *v1beta1.Auth) (*corev1.Secret, error) { + secret, _, err := CreateOrUpdate[*corev1.Secret](ctx, types.NamespacedName{ + Namespace: auth.Spec.Stack, + Name: signingKeySecretName, + }, func(secret *corev1.Secret) error { + secret.Type = corev1.SecretTypeOpaque + if len(secret.Data[signingKeySecretData]) == 0 { + privateKey, err := rsa.GenerateKey(rand.Reader, signingKeyBits) + if err != nil { + return err + } + encodedKey := pem.EncodeToMemory(&pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: x509.MarshalPKCS1PrivateKey(privateKey), + }) + if secret.Data == nil { + secret.Data = make(map[string][]byte) + } + secret.Data[signingKeySecretData] = encodedKey + } + return nil + }, WithController[*corev1.Secret](ctx.GetScheme(), auth)) + if err != nil { + return nil, err + } + + return secret, nil +} diff --git a/internal/tests/auth_controller_test.go b/internal/tests/auth_controller_test.go index 0274756c..9284cf72 100644 --- a/internal/tests/auth_controller_test.go +++ b/internal/tests/auth_controller_test.go @@ -1,6 +1,8 @@ package tests_test import ( + "crypto/x509" + "encoding/pem" "fmt" "strings" @@ -64,8 +66,20 @@ var _ = Describe("AuthController", func() { Expect(deployment).To(BeControlledBy(auth)) Expect(deployment.Spec.Template.Spec.Containers[0].Env).To(ContainElements( core.Env("BASE_URL", "http://auth:8080"), + core.EnvFromSecret("SIGNING_KEY", "auth-signing-key", "signing-key"), )) }) + By("Should create a per-stack signing key secret", func() { + secret := &corev1.Secret{} + Eventually(func() error { + return LoadResource(stack.Name, "auth-signing-key", secret) + }).Should(Succeed()) + Expect(secret).To(BeControlledBy(auth)) + block, _ := pem.Decode(secret.Data["signing-key"]) + Expect(block).NotTo(BeNil()) + _, err := x509.ParsePKCS1PrivateKey(block.Bytes) + Expect(err).NotTo(HaveOccurred()) + }) By("Should create a pdb", func() { pdb := &v1.PodDisruptionBudget{} Eventually(func() error { @@ -96,6 +110,25 @@ var _ = Describe("AuthController", func() { }).Should(BeTrue()) }) }) + It("keeps the generated signing key across reconciliations", func() { + secret := &corev1.Secret{} + Eventually(func() error { + return LoadResource(stack.Name, "auth-signing-key", secret) + }).Should(Succeed()) + originalKey := append([]byte(nil), secret.Data["signing-key"]...) + + patch := client.MergeFrom(auth.DeepCopy()) + auth.Spec.Dev = true + Expect(Patch(auth, patch)).To(Succeed()) + + Eventually(func(g Gomega) []corev1.EnvVar { + deployment := &appsv1.Deployment{} + g.Expect(LoadResource(stack.Name, "auth", deployment)).To(Succeed()) + return deployment.Spec.Template.Spec.Containers[0].Env + }).Should(ContainElement(core.Env("CAOS_OIDC_DEV", "1"))) + Expect(LoadResource(stack.Name, "auth-signing-key", secret)).To(Succeed()) + Expect(secret.Data["signing-key"]).To(Equal(originalKey)) + }) Context("Then when disabling the stack", func() { JustBeforeEach(func() { Eventually(func(g Gomega) *v1beta1.Auth {