Skip to content
All articles

Why we built a Kubernetes operator for OAuth clients

An app's OAuth client changes with the app, so it belongs in the app's manifests. What we wanted from an OIDCClient resource, and what it costs.

By Michael Beutler, FounderPublished

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 OAuth2Client resource into Ory Hydra. Keycloak's operator has had KeycloakOIDCClient since 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's provider-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:

  1. 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.
  2. 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.
  3. Refuse contradictions at kubectl apply. A public client without PKCE should never reach the server. See Refusing unsafe OAuth client configurations at admission time.
  4. 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.
  5. 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.
  6. 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.mainOrganizationNamespaces 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: true

The 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-secret

How 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/oidcclient before 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 Ready condition 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   40s

Drift 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.

Hear when the operator ships

One email when the operator ships. No newsletter.

By joining, you agree to our Privacy Policy and Terms.