On a cluster run with GitOps, almost everything an application needs arrives through a pull request: its Deployment, its Service, its Ingress, its network policy. Then there is the OAuth client it signs users in with. That one is usually created by hand in an identity provider's admin console, and its secret is copied from the console into a Kubernetes Secret by whoever happened to set the app up.
Nauthera exists to close that gap. Its operator reconciles an OIDCClient resource that
sits next to the application's Deployment, registers it with the Nauthera server, and
writes the generated client secret into a Secret the workload mounts. This article
explains why we chose an operator, what we wanted the resource to do, and what the choice
costs you.
The client changes with the application
An OAuth client is not identity-team configuration that happens to mention an app. Its redirect URIs are the app's URLs. Its grant types follow from what the app is: a browser app, a server-rendered site, a batch job. Its scopes are what the app asks for. When the app moves to a new hostname, gains a CLI, or stops needing refresh tokens, the client has to change in the same release.
A client in a console misses everything the rest of the release gets. Nobody reviews the change. Nothing records why it was made. The staging cluster has a client someone created months ago with a slightly different redirect URI. When the app is deleted, its client lives on with a working credential.
What we looked at first
We were not the first to want clients as code, and the options are worth stating fairly.
- Dynamic client registration (RFC 7591) lets an application register itself. It is the right tool for clients the server cannot know in advance, and Nauthera serves it. For your own apps it inverts the trust: every value is the client's word, each replica can register its own client, and the app has to keep the credential it was handed. We compared the two in detail in Operator or dynamic registration?.
- Infrastructure as code. Terraform providers for Keycloak, authentik and Zitadel manage clients well. The model is a pipeline run against a state file, which has consequences for secrets and drift that we cover in What a Kubernetes operator can do that Terraform cannot.
- Other operators. The idea itself has prior art.
Hydra Maester, maintained by the Ory community,
reconciles an
OAuth2Clientresource into Ory Hydra. Keycloak's operator has hadKeycloakOIDCClientsince 26.7, experimental there and in preview since 26.8, where the user supplies the client secret and the operator overwrites changes made in the admin console without detecting them. Crossplane'sprovider-keycloak, generated from the Keycloak Terraform provider, manages Keycloak clients as Kubernetes objects.
What we wanted was a provider where the resource is the primary interface rather than an add-on to a console, and where the secret never has to pass through a person.
What the resource had to do
We wrote down the behavior before the code. An OIDCClient should:
- Live in the application's namespace, shipped by the app's own chart or Argo CD Application, reviewed in the same pull request as the code that needs it.
- Never hold a secret. The server mints it, the operator writes it into a Secret, and Git never sees it. See Delivering OIDC client secrets without storing them in Git.
- Refuse contradictions at
kubectl apply. A public client without PKCE should never reach the server. See Refusing unsafe OAuth client configurations at admission time. - Report on itself. Whether the server holds the client, and if not why, belongs on
the resource, where
kubectl, Argo CD and Flux already look. - Report drift instead of hiding it. If someone changes the client on the server, the resource should say so, not silently write over a hotfix.
- Survive mistakes. Deleting a namespace by accident should not delete a live client and its credential.
Here is what that looks like for a server-rendered web app. Its storefront namespace is
admitted to an organization first: listed in the chart's operator.
for the main organization, or in an Organization's spec.namespaces
(Which organization a resource configures).
apiVersion: nauthera.io/v1alpha1
kind: OIDCClient
metadata:
name: storefront-web
namespace: storefront
spec:
clientId: storefront-web
displayName: Storefront
redirectUris:
- https://shop.example.com/callback
grantTypes: [authorization_code, refresh_token]
allowRefresh: true
scopes: [openid, profile, email, offline_access]
secret:
generate: trueThe method defaults to client_secret_basic and PKCE to required_s256. The Deployment
reads the secret the operator writes:
env:
- name: OIDC_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: storefront-web
key: client-secretHow the operator works
The operator is a separate binary and image, and it talks to the Nauthera server only
over the server's gRPC AdminService, with mutual TLS. It watches OIDCClient resources,
the Secrets they own and the Organization resources that decide which tenant a
namespace configures. For each resource it:
- adds the finalizer
nauthera.io/oidcclientbefore the first call, so no client is ever registered that a deletion could leave behind unasked; - sends the whole spec, with the resource's generation, for every generation the server does not hold yet, and nothing for one it does;
- records what the server answers in
status: the client ID, the issuer, the server's revision of the client (etag) and the secret's fingerprint; - writes the minted secret into the Secret, and the outcome into the
Readycondition and an event.
$ kubectl -n storefront get oidcclients
NAME CLIENTID AUTHMETHOD PHASE READY REASON AGE
storefront-web storefront-web client_secret_basic Ready True Registered 40sDrift is detected without polling every client. The operator probes the server once a
minute, and the answer carries a revision of the server's configuration. Every 15 seconds
the operator checks whether that revision moved, and only then lists its clients and
compares each one's revision with the one it last wrote. A client changed elsewhere is
reported as Drifted, and nothing is written over it until the manifest changes or the
resource is annotated nauthera.io/restore. The server's audit log says who made the
change.
When the server stops answering, resources go to Pending with reason Unreachable.
Nothing is removed, the Secrets stay where they are, and the operator probes with a
backoff instead of retrying every resource. Deletion follows spec.deletionPolicy, which
defaults to Retain: the client stays registered and its Secret is released from the
resource, so a deleted namespace cannot take a live credential with it.
What it costs
An operator is not free, and the costs are worth knowing before you adopt one.
- You run the identity provider. Nauthera needs PostgreSQL, and Redis for any production issuer. Sign-in for every app in the cluster depends on it.
- Cluster-wide pieces. CRDs are cluster-scoped, so installing or upgrading them needs cluster-level rights. The operator reads, watches and writes Secrets in every namespace, as any controller that delivers credentials into Secrets does, since Kubernetes RBAC cannot narrow a watch to the Secrets one controller owns.
- A second gate. The server decides what the operator may do: its mTLS identity is granted operations, organizations and namespaces in the server's configuration. That is a feature, and also one more thing to set up.
- Maturity. Nauthera is pre-1.0. The resource is
nauthera.io/v1alpha1, and fields can still change.
In exchange, adding sign-in to an app is one more file in the app's chart, reviewed like the rest of it, and the credential goes from the server to the pod without anyone copying it. The operator guide covers installation and every setting, the OIDCClient reference every field, and the GitOps guide how the pieces fit in an Argo CD repository.