Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions api/formance.com/v1beta1/auth_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 4 additions & 3 deletions config/crd/bases/formance.com_auths.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion docs/04-Modules/05-Auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -40,4 +44,4 @@ spec:
- payments:read
- payments:write
stack: formance-dev
```
```
6 changes: 6 additions & 0 deletions docs/07-Upgrade/01-Upgrade from the operator.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.<br />If not enabled, each service will check the authentication but will not restrict access according to scopes.<br />In that case, being authenticated is sufficient. | false | |

###### DelegatedOIDCServerConfiguration
Expand Down
4 changes: 2 additions & 2 deletions helm/crds/Chart.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions helm/operator/Chart.lock
Original file line number Diff line number Diff line change
@@ -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"
4 changes: 2 additions & 2 deletions helm/operator/Chart.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
18 changes: 16 additions & 2 deletions internal/resources/auths/deployment.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions internal/resources/auths/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -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](),
Expand Down
49 changes: 49 additions & 0 deletions internal/resources/auths/signing_key.go
Original file line number Diff line number Diff line change
@@ -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
}
33 changes: 33 additions & 0 deletions internal/tests/auth_controller_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package tests_test

import (
"crypto/x509"
"encoding/pem"
"fmt"
"strings"

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
Loading