Skip to content
All articles

Refusing unsafe OAuth client configurations at admission time

The nine CEL rules on Nauthera's OIDCClient definition, what each refuses at kubectl apply, and what only the server can refuse after it.

By Michael Beutler, FounderPublished

Most OAuth misconfigurations are not subtle. A single-page app registered without PKCE, a client asking for refresh tokens it is not allowed to use, a private_key_jwt client with no key set to verify its assertions against. Each one is a contradiction you can see in the configuration alone, without knowing anything about the running server.

Nauthera's OIDCClient resource refuses these contradictions in the Kubernetes API server, when the manifest is applied. This article walks through every rule the definition carries, how to watch each one fire, and where admission stops and the server takes over.

Where the rules run

The rules are validation rules on the CustomResourceDefinition: CEL expressions in x-kubernetes-validations, stable in Kubernetes since 1.29. The API server evaluates them on every create and update, before the object is stored. No webhook runs, so there is nothing extra to deploy or keep available: the rules hold while the operator is scaled to zero and while the Nauthera server is down.

Before the rules run, the API server applies the schema's defaults. That order matters, because three defaults take part in the rules: tokenEndpointAuthMethod defaults to client_secret_basic, pkcePolicy to required_s256, and allowRefresh to false. A manifest that never mentions allowRefresh is checked as if it said false.

The rules

The definition in deploy/crds/oidcclient-crd.yaml carries nine rules on spec. Their messages are what kubectl prints, verbatim:

RuleMessage
The client ID never changesspec.clientId is immutable
The organization never changes once setspec.organization is immutable
The code flow needs somewhere to return toredirectUris must contain at least one entry when grantTypes includes 'authorization_code'
A public client uses PKCE with S256public clients (tokenEndpointAuthMethod 'none') must set pkcePolicy to 'required_s256'
A public client has no secretpublic clients (tokenEndpointAuthMethod 'none') must not request a generated secret
The refresh grant needs refresh allowedgrantTypes includes 'refresh_token' but allowRefresh is false
private_key_jwt uses no shared secrettokenEndpointAuthMethod 'private_key_jwt' does not use a generated client secret
private_key_jwt needs a key settokenEndpointAuthMethod 'private_key_jwt' requires jwksUri: the client assertion is verified against the keys published there
A signing algorithm only for private_key_jwttokenEndpointAuthSigningAlg applies only to tokenEndpointAuthMethod 'private_key_jwt'

The PKCE rule follows the OAuth 2.0 Security Best Current Practice, which says public clients must use PKCE (RFC 9700 §2.1.1). The two private_key_jwt rules exist because that method's credential is the key the client publishes (RFC 7523 §2.2): a generated secret would be a credential the token endpoint never accepts, and a registration without jwksUri is a client that can never authenticate.

Beside the rules, the schema itself refuses a lot: grant types outside authorization_code, refresh_token, client_credentials and the device grant (so no implicit or password grant), any response type but code, a jwksUri that is not https://, token lifetimes outside their bounds (30 seconds to a day for access and ID tokens), and the same redirect URI listed twice.

Watch them fire

You need a cluster with the Nauthera CRDs installed. The Helm chart renders them when operator.enabled is on and operator.crds.install keeps its default, true, and they are also in nauthera-server's deploy/crds/; the chart and the repository are open to the team only today. The operator does not have to run: admission is the API server's job. Apply a valid public client first:

apiVersion: nauthera.io/v1alpha1
kind: OIDCClient
metadata:
  name: storefront
  namespace: storefront
spec:
  clientId: storefront
  displayName: Storefront
  tokenEndpointAuthMethod: none
  grantTypes:
    - authorization_code
    - refresh_token
  allowRefresh: true
  scopes: [openid, profile, offline_access]
  redirectUris:
    - https://shop.example.com/callback

It is admitted: a public client, PKCE required by default, refresh allowed, one redirect URI, and offline_access among its scopes, which the server wants before it issues a refresh token. Now break one rule at a time with a server-side dry run, which runs the whole validation and stores nothing:

patch() {
  kubectl -n storefront patch oidcclient storefront \
    --type merge --dry-run=server -p "$1"
}
 
patch '{"spec":{"pkcePolicy":"optional"}}'
patch '{"spec":{"secret":{"generate":true}}}'
patch '{"spec":{"allowRefresh":false}}'
patch '{"spec":{"redirectUris":null}}'
patch '{"spec":{"tokenEndpointAuthMethod":"private_key_jwt"}}'
patch '{"spec":{"tokenEndpointAuthSigningAlg":"ES256"}}'
patch '{"spec":{"clientId":"shop"}}'

Each is refused with the rule's message. The first prints:

The OIDCClient "storefront" is invalid: spec: Invalid value: public clients (tokenEndpointAuthMethod 'none') must set pkcePolicy to 'required_s256'

Older API servers also print the refused object between Invalid value: and the message. Setting redirectUris to null removes the field (a JSON merge patch deletes a key set to null), which leaves the code flow with nowhere to return to. Switching the method to private_key_jwt breaks only the jwksUri rule: the PKCE rule is satisfied as soon as the client is no longer public.

Two rules compare with the past

spec.clientId is immutable and spec.organization is immutable read oldSelf, the object as stored. Kubernetes calls these transition rules: they run only on updates, never on create, which is why the patch above is the only way to see the first one. The client ID is the client's identity on the server; changing it in place would orphan every token issued to it and break every relying party configured with it.

The organization rule is looser. It refuses a change or a removal, but a resource that named no organization may name one later:

kubectl -n storefront patch oidcclient storefront \
  --type merge --dry-run=server -p '{"spec":{"organization":"acme"}}'

That is admitted. Which organization a namespace configures is decided by the organizations that admit it, and a namespace can come to be admitted by a second one, at which point its resources have to say which they mean. Whether the server moves a client it already holds is a separate question: if the server holds the client in another organization, the operator refuses to move it and reports OrganizationChanged.

Rules that do not run

Two cases surprise people. First, the API server skips the CEL rules for an object that already failed on a missing required field, a wrong type, an unsupported enum value or a string or list over its maximum length, and says that some validation rules were not checked. Try patch '{"spec":{"grantTypes":["implicit"]}}': the answer names the unsupported value and the skipped rules, and only after you fix it do you learn whether the rules pass.

Second, an update that leaves an invalid part of an object unchanged is accepted with a warning rather than refused (validation ratcheting, on by default since Kubernetes 1.30 and stable since 1.33). That only matters for an object stored before a rule existed, and transition rules are never ratcheted.

A rule that reads an optional field with no default has to test it with has() first. CEL raises an error for a field that is absent instead of treating it as false, so an unguarded read refuses every manifest that omits the field. nauthera-server's tests run each rule through the API server's own validation packages, adding one optional field at a time to a minimal manifest and removing one at a time from a full one, and assert every refusal by its message.

What admission cannot see

A CEL rule sees one object. Some refusals depend on state that lives on the server, so the definition cannot express them:

  • Redirect URIs. The schema only checks that a URI has a scheme. The server requires https except for loopback hosts (RFC 8252 §7.3) and refuses fragments, for redirect, post-logout and front-channel logout URIs alike.
  • The organization's policy. An organization in FAPI mode accepts only required_s256 for a code-flow client and a narrower set of signing algorithms.
  • The server's limits. A refreshTokenTtlSeconds longer than the server's issuer.refresh_token_ttl, or an idTokenSignedResponseAlg the organization holds no key for.

One more the definition could state but leaves to the server: a client must register at least one scope, since the token endpoint grants only from the registered set.

These manifests are admitted, and the operator reports the server's answer on the resource instead. With the operator running and the storefront namespace admitted to an organization (the chart's operator.mainOrganizationNamespaces for the main one, or an Organization's spec.namespaces; see Which organization a resource configures), change the redirect URI to http://shop.example.com/callback for real, without --dry-run:

kubectl -n storefront get oidcclient storefront -o wide

The resource shows phase Error, Ready False with reason Refused, and a message that names the field: redirect_uris: invalid redirect_uri http://shop.example.com/callback. The server keeps the last generation it accepted, and the message says which. A refused spec is sent again when the manifest changes, or after five minutes. Every reason the condition can carry is listed in the OIDCClient reference, and each failure reason has a runbook.

Why both layers

The control plane is not the only way to write a client: dynamic registration and the admin API do too, so the server enforces what matters at the moment it matters, whichever door a client came through. A public client must present PKCE at the authorization endpoint whatever its policy says, no secret is minted for a public client, and a refresh token needs both allowRefresh and the offline_access scope.

Those runtime checks are safe but quiet: the client is stored, and the contradiction shows up later as a failed sign-in. The CRD rules exist for the person applying the manifest. A refusal at kubectl apply arrives in the terminal or the GitOps sync that made the change, seconds after it was made, rather than as a confused relying party in production.

The field reference lists every field with its type, default and bounds, and what the operator does with a manifest covers what happens once one is admitted.

Hear when the operator ships

One email when the operator ships. No newsletter.

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