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/ carries nine rules on spec. Their
messages are what kubectl prints, verbatim:
| Rule | Message |
|---|---|
| The client ID never changes | spec. |
| The organization never changes once set | spec. |
| The code flow needs somewhere to return to | redirectUris must contain at least one entry when grantTypes includes 'authorization_ |
| A public client uses PKCE with S256 | public clients (tokenEndpointAuthMethod 'none') must set pkcePolicy to 'required_ |
| A public client has no secret | public clients (tokenEndpointAuthMethod 'none') must not request a generated secret |
| The refresh grant needs refresh allowed | grantTypes includes 'refresh_ |
private_key_jwt uses no shared secret | tokenEndpointAuthMethod 'private_ |
private_key_jwt needs a key set | tokenEndpointAuthMethod 'private_ |
A signing algorithm only for private_key_jwt | tokenEndpointAuthSigningAlg applies only to tokenEndpointAuthMethod 'private_ |
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/callbackIt 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. and spec. 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":: 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
httpsexcept 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_s256for a code-flow client and a narrower set of signing algorithms. - The server's limits. A
refreshTokenTtlSecondslonger than the server'sissuer.refresh_token_ttl, or anidTokenSignedResponseAlgthe 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. for the main one, or an
Organization's spec.namespaces; see
Which organization a resource configures),
change the redirect URI to http: for real, without
--dry-run:
kubectl -n storefront get oidcclient storefront -o wideThe resource shows phase Error, Ready False with reason Refused, and a message that
names the field: redirect_.
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.