From 9faa542486293750eacac7eb5d50542acd5b25c6 Mon Sep 17 00:00:00 2001 From: Geoffrey Ragot Date: Wed, 14 Oct 2026 23:28:33 +0200 Subject: [PATCH 1/3] feat(auth): generate a signing key per stack Use a persistent Secret for Auth instances without an explicit signing key so stacks do not share the service default key. --- api/formance.com/v1beta1/auth_types.go | 4 +- docs/04-Modules/05-Auth.md | 6 ++- .../02-Custom Resource Definitions.md | 4 +- internal/resources/auths/deployment.go | 18 ++++++- internal/resources/auths/init.go | 1 + internal/resources/auths/signing_key.go | 49 +++++++++++++++++++ internal/tests/auth_controller_test.go | 33 +++++++++++++ 7 files changed, 108 insertions(+), 7 deletions(-) create mode 100644 internal/resources/auths/signing_key.go diff --git a/api/formance.com/v1beta1/auth_types.go b/api/formance.com/v1beta1/auth_types.go index d6cd79cb7..d65c53c70 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/docs/04-Modules/05-Auth.md b/docs/04-Modules/05-Auth.md index 1d01a2c16..eb69f4acb 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/09-Configuration reference/02-Custom Resource Definitions.md b/docs/09-Configuration reference/02-Custom Resource Definitions.md index 4932f6efa..507f1450f 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/internal/resources/auths/deployment.go b/internal/resources/auths/deployment.go index 5b9f84e73..132330355 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 472ef561f..916edf852 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 000000000..49431ff67 --- /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 0274756c9..9284cf72b 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 { From 456302c7ac0cc8dfe7a630a0b8463acdec59f7e5 Mon Sep 17 00:00:00 2001 From: Geoffrey Ragot Date: Wed, 14 Oct 2026 23:40:39 +0200 Subject: [PATCH 2/3] fix(auth): commit generated signing key CRD descriptions The dirty CI check regenerated the Auth CRD descriptions from the updated API comments. Commit both generated copies so pre-commit leaves the worktree clean. --- config/crd/bases/formance.com_auths.yaml | 7 ++++--- ....io_v1_customresourcedefinition_auths.formance.com.yaml | 7 ++++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/config/crd/bases/formance.com_auths.yaml b/config/crd/bases/formance.com_auths.yaml index ca7339909..bd6b98a24 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/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 e7f887e91..b3c312460 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 From e4e5ebeee23c88123607f59a17813137dc72b581 Mon Sep 17 00:00:00 2001 From: Geoffrey Ragot Date: Wed, 14 Oct 2026 23:40:43 +0200 Subject: [PATCH 3/3] chore(helm): prepare Operator v3.18.0 charts Version the operator and CRD charts together so the release can be upgraded with one Helm operation. --- docs/07-Upgrade/01-Upgrade from the operator.md | 6 ++++++ helm/crds/Chart.yaml | 4 ++-- helm/operator/Chart.lock | 6 +++--- helm/operator/Chart.yaml | 4 ++-- 4 files changed, 13 insertions(+), 7 deletions(-) diff --git a/docs/07-Upgrade/01-Upgrade from the operator.md b/docs/07-Upgrade/01-Upgrade from the operator.md index 50e0f2723..9582cda9a 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/helm/crds/Chart.yaml b/helm/crds/Chart.yaml index b7d3a539e..6681ad280 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/operator/Chart.lock b/helm/operator/Chart.lock index 9d718a944..535d1ac56 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 7d8930d63..bd530225c 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"