# OIDCClient

> Reference for the nauthera.io/v1alpha1 OIDCClient resource: an OAuth 2.0 / OpenID Connect client that the Nauthera operator registers with the server.

An `OIDCClient` declares one OAuth 2.0 / OpenID Connect client. The
[operator](https://www.nauthera.io/docs/v1/operator) sends its spec to the server's gRPC `AdminService`
(`UpsertOIDCClient`), writes the server's answer to `status`, and delivers a generated
client secret into a Kubernetes `Secret`.

| Property | Value |
|---|---|
| API group / version | `nauthera.io/v1alpha1` |
| Kind | `OIDCClient` |
| Plural / short name | `oidcclients` / `oidcc` |
| Scope | Namespaced |
| Definition | `deploy/crds/oidcclient-crd.yaml` in nauthera-server |

## Example

```yaml
apiVersion: nauthera.io/v1alpha1
kind: OIDCClient
metadata:
  name: example-webapp
  namespace: nauthera     # a namespace the organization admits
spec:
  clientId: example-webapp
  displayName: Example Web App
  redirectUris:
    - https://app.example.com/callback
    - https://app.example.com/silent-renew
  postLogoutRedirectUris:
    - https://app.example.com/
  grantTypes:
    - authorization_code
    - refresh_token
  responseTypes:
    - code
  scopes:
    - openid
    - profile
    - email
    - offline_access
  tokenEndpointAuthMethod: client_secret_basic
  pkcePolicy: required_s256
  allowRefresh: true
  accessTokenTtlSeconds: 900        # 15m
  idTokenTtlSeconds: 900            # 15m
  deletionPolicy: Retain
  secret:
    generate: true
```

The server applies all three token lifetimes. `refreshTokenTtlSeconds` may not exceed the
deployment's `issuer.refresh_token_ttl` (a longer one is refused), and a client that sets
none gets that lifetime.

The spec below comes from the resource definition, and two of its field descriptions are
wrong. `pkcePolicy: disabled` does not reject PKCE. It behaves like `optional`: a
`code_challenge` is accepted and verified if sent, but not required. A public client
always needs PKCE, whatever its policy says. The `idTokenSignedResponseAlg` and
`pkcePolicy` descriptions also say the operator surfaces a refused apply as a `NotReady`
condition. There is no such condition: a refusal is the `Ready` condition with status
`False` and reason `Refused`, and `phase: Error` (see [Status](#status)).

## How secrets work

The resource never holds a client secret. When `secret.generate` is `true`, the server
generates a high-entropy secret and stores only its SHA-256 digest. It returns the
plaintext **once**, in the response to the upsert that minted it. That is normally the
upsert that created the client. An update that asks for a secret for a client without one
(a public client made confidential, for example) also mints and returns one. Re-applying a
spec never rotates the secret. Every other response carries only a non-reversible
fingerprint (`status.secretFingerprint`).

The operator writes the plaintext into a `Secret` in the resource's namespace, named by
`spec.secret.secretName` or, by default, after the resource. The Secret holds the keys
`client-id`, `client-secret` and `issuer`, carries the fingerprint in its
`nauthera.io/secret-fingerprint` annotation, and is recorded in `status.secretRef`. It is
owned by the resource, and the operator never writes over a Secret it does not own; that
case is reported as `SecretUndeliverable`.

To rotate the secret, delete the Secret. The operator mints a new secret, writes it again
and records a `SecretRotated` event with both fingerprints. Workloads that read the old
secret must reload it.

### A secret you supply

`spec.secret.secretRef` takes the secret from a Secret you hold instead, such as one a
[SealedSecret](https://www.nauthera.io/docs/v1/guides/sealed-secrets#oidcclient-client-secrets) or the External
Secrets operator produces, or one an application outside the cluster also uses:

```yaml
spec:
  tokenEndpointAuthMethod: client_secret_basic
  secret:
    secretRef:
      name: storefront-oidc
      key: client-secret        # the default
```

The operator reads the value and sends it; the server stores only its digest and reports
its fingerprint in `status.secretFingerprint`, as for a minted one. The operator never
writes or owns that Secret. Editing it sends the new value, which replaces the old one at
once, with no overlap. The value must be at least 43 printable ASCII characters with no
whitespace, drawn at random (`openssl rand -base64 48 | tr -d '\n'`); the server refuses a
shorter one, and a value with leading or trailing whitespace is `SecretPadded`. A missing
Secret or key is `SecretNotFound` or `SecretKeyMissing`, and nothing is sent until it
appears. `secretRef` excludes `generate` and `secretName`, and is refused for `none` and
`private_key_jwt`. It needs a server that serves control-plane contract 8; an older one
leaves the resource `Unsupported`.

A `private_key_jwt` client authenticates with an RFC 7523 §2.2 assertion signed by a key
it publishes at `jwksUri`. For that method `jwksUri` is required, and `secret.generate`
is refused.

## Second factor and identity providers

`spec.secondFactor` is the client's second-factor policy. It narrows its organization's
policy, which narrows the deployment's, and can never loosen it: it can remove methods,
require a factor, shorten `rememberForSeconds`, spare in fewer zones, always challenge,
or add `rules` that narrow it for sign-ins outside or inside named network zones, from a
new network or from a new browser. Passkey sign-in with no password is open only where
the deployment, the organization and the client all set `passwordless: true`. A policy
that states more than the levels above allow is refused, and the resource reports
`Refused` naming the field and the bound. See
[Multi-Factor Authentication](https://www.nauthera.io/docs/v1/mfa).

```yaml
  secondFactor:
    required: true
    methods: [passkey]
    rules:
      - name: off-site
        outsideZones: [office]
        require: true
```

`spec.identityProviders` lists the [identity providers](https://www.nauthera.io/docs/v1/identity-provider-resource)
in the client's organization that its users may use, by slug. A provider the client does
not list is not offered to it, and none is listed by default. `allowSignIn` lets a user
whose account at the provider is already known, or links to an existing account, sign in
through it. `allowSignUp` lets an account at the provider that no user holds create one,
and still needs the deployment's `auth.registration.enabled`. See
[Social sign-in](https://www.nauthera.io/docs/v1/sign-in-flows/social-sign-in).

```yaml
  identityProviders:
    - slug: google
      allowSignIn: true
      allowSignUp: true
```

## Spec

{/* AUTO-GENERATED-SPEC-START */}

### `spec.accessTokenTtlSeconds`

**Type:** `integer` | **Optional**

Access token lifetime in seconds.

### `spec.allowRefresh`

**Type:** `boolean` | **Optional**

Whether refresh tokens may be issued to this client.

Defaults to `false`.

### `spec.clientId`

**Type:** `string` | **Required**

Stable, unique OAuth2 client_id. Immutable.

### `spec.deletionPolicy`

**Type:** `string` | **Optional**

What deleting this resource does to the client it registered.
Retain leaves the client registered and the Secret holding its
credential in place, so deleting a namespace cannot take a live
client with it. Delete removes the client from the server, and
the Secret goes with the resource. A removal that fails keeps
the resource, in phase Deleting, until it succeeds.

Defaults to `"Retain"`.

One of: `Retain`, `Delete`.

### `spec.displayName`

**Type:** `string` | **Optional**

Human-readable name shown on consent screens.

### `spec.frontchannelLogoutSessionRequired`

**Type:** `boolean` | **Optional**

The client requires the iss and sid query parameters on the
front-channel logout request. It is SKIPPED when no sid is
available, rather than sent a request it cannot act on.

### `spec.frontchannelLogoutUri`

**Type:** `string` | **Optional**

RP URL the provider renders in an iframe to log this client
out (OIDC Front-Channel Logout 1.0 §2). Validated as a
redirection target: the browser fetches it, not the server.

### `spec.grantTypes`

**Type:** `[]string` | **Optional**

OAuth2 grant types this client may use.

### `spec.idTokenSignedResponseAlg`

**Type:** `string` | **Optional**

JWS algorithm this client's ID tokens are signed with
(RFC 7591 id_token_signed_response_alg). Omit to accept the
algorithm the deployment signs with.

An organization held to FAPI 2.0 accepts only the algorithms
that profile permits (§5.4.1), which this build narrows to
ES256. That cannot be expressed here — the org's FAPI flag is
server state, not part of this resource — so the server
refuses the apply, and the resource reports Ready False with
reason Refused and phase Error.

One of: `ES256`, `RS256`.

### `spec.idTokenTtlSeconds`

**Type:** `integer` | **Optional**

ID token lifetime in seconds.

### `spec.identityProviders`

**Type:** `[]object` | **Optional**

The identity providers in this client's organization its users
may sign in or sign up with, each named by its IdentityProvider's
slug. A provider not listed is not offered to this client.

| Field | Type | Required | Default | Description |
|---|---|---|---|---|
| `allowSignIn` | `boolean` | No | `false` | Whether a user whose account at the provider is already known, or links to an existing account, may sign in through it. |
| `allowSignUp` | `boolean` | No | `false` | Whether an account at the provider that no user holds may create one. The organization's own sign-up setting still applies. |
| `slug` | `string` | Yes | `—` | The slug of an IdentityProvider, or of an LDAPDirectory, in the client's organization. |

### `spec.jwksUri`

**Type:** `string` | **Optional**

HTTPS URL where this client publishes its own public keys
(RFC 7591 jwks_uri). Required for tokenEndpointAuthMethod
'private_key_jwt', where it IS the credential: the RFC 7523
client assertion is verified against the keys served here.
Also used to verify an RFC 9101 signed request object.

The server FETCHES this URL, which makes it a server-side
request-forgery surface rather than a displayed link. It must
be https and must not name a loopback, private, link-local or
shared-address-space host; the server re-checks the resolved
address at dial time and never follows redirects.

### `spec.ldapDirectories`

**Type:** `[]object` | **Optional**

The LDAP directories in this client's organization whose users
sign in with their directory password, each named by its
LDAPDirectory's slug and tried in order. An identifier no listed
directory holds signs in with a local password.

| Field | Type | Required | Default | Description |
|---|---|---|---|---|
| `allowSignIn` | `boolean` | No | `false` | Whether a user whose account at the provider is already known, or links to an existing account, may sign in through it. |
| `allowSignUp` | `boolean` | No | `false` | Whether an account at the provider that no user holds may create one. The organization's own sign-up setting still applies. |
| `slug` | `string` | Yes | `—` | The slug of an IdentityProvider, or of an LDAPDirectory, in the client's organization. |

### `spec.organization`

**Type:** `string` | **Optional**

The slug of the organization this client belongs to. Needed only
where more than one organization admits the namespace in its
spec.namespaces; otherwise the one that does is taken. The main
organization is "default". Immutable.

### `spec.pkcePolicy`

**Type:** `string` | **Optional**

PKCE enforcement. required_s256 mandates code_challenge with
S256. optional and disabled both permit it without requiring
it, and a challenge that is sent is verified. A public client
must use PKCE whatever the policy.

If the organization this client is homed at runs in FAPI mode,
only required_s256 is accepted for a client registering the
authorization_code grant (FAPI 2.0 Security Profile 5.3.2.2).
That cannot be expressed as a CEL rule here — the org's FAPI
flag is server state, not part of this resource — so the server
refuses the apply, and the resource reports Ready False with
reason Refused and phase Error.

Defaults to `"required_s256"`.

One of: `required_s256`, `optional`, `disabled`.

### `spec.postLogoutRedirectUris`

**Type:** `[]string` | **Optional**

Allowed post_logout_redirect_uri values.

### `spec.redirectUris`

**Type:** `[]string` | **Optional**

Allowed redirect_uri values (exact match).

### `spec.refreshTokenTtlSeconds`

**Type:** `integer` | **Optional**

Refresh token lifetime in seconds. The server refuses a value longer than
its issuer.refresh_token_ttl.

### `spec.responseTypes`

**Type:** `[]string` | **Optional**

OAuth2/OIDC response types. Only the authorization code
response is served; the implicit and hybrid flows are not
implemented, so no other value is accepted.

### `spec.scopes`

**Type:** `[]string` | **Optional**

Scopes the client is permitted to request.

### `spec.secondFactor`

**Type:** `object` | **Optional**

The client's second-factor policy, narrowing its organization's.
Passwordless sign-in stays closed for a client that does not open
it, whatever its organization says.

| Field | Type | Required | Default | Description |
|---|---|---|---|---|
| `alwaysChallenge` | `boolean` | No | `—` | Spares no sign-in, on a remembered browser or in a zone. |
| `methods` | `[]string` | No | `—` | The methods a user may enroll and answer with. Empty inherits the level above. |
| `passwordless` | `boolean` | No | `—` | Opens sign-in with a discoverable passkey and no password. It is open only where the deployment, the organization and the client all open it. |
| `rememberForSeconds` | `integer` | No | `—` | Spares a user who holds a permitted factor the challenge on a browser that passed a second factor for them within this many seconds, while their password and factors are the ones it passed under. Zero inherits the level above's window. |
| `required` | `boolean` | No | `—` | Sends a user who holds no permitted factor through enrollment before a session is issued. |
| `rules` | `[]object` | No | `—` | Narrow this level's policy for the sign-ins that meet them. A rule only ever narrows its own level. |
| `spareZones` | `[]string` | No | `—` | Spares such a user the challenge from these of the deployment's network zones. Empty inherits the level above's. |

### `spec.secret`

**Type:** `object` | **Optional**

Client secret handling. The secret value itself is never
placed in this resource; setting generate=true asks the server
to mint one and return it exactly once.

Defaults to `{"generate":false}`.

| Field | Type | Required | Default | Description |
|---|---|---|---|---|
| `generate` | `boolean` | No | `false` | Ask the server to generate a client secret. |
| `secretName` | `string` | No | `—` | The Secret in this namespace the generated secret is written to, under the keys client-id, client-secret and issuer. Defaults to the resource's own name. The Secret is owned by this resource and is never written over one that is not; deleting it makes the operator mint a new secret and write it again. |
| `secretRef` | `object` | No | `—` | The Secret in this namespace, and its key, holding a client secret the deployer chose, in place of one the server generates. The operator reads it and sends it to the server, which stores only its hash; it never writes or owns the Secret. Editing the Secret sends the new secret. It must be at least 43 printable ASCII characters with no whitespace, drawn at random. |

### `spec.tokenEndpointAuthMethod`

**Type:** `string` | **Optional**

Client authentication method at the token endpoint.

Defaults to `"client_secret_basic"`.

One of: `none`, `client_secret_basic`, `client_secret_post`, `private_key_jwt`.

### `spec.tokenEndpointAuthSigningAlg`

**Type:** `string` | **Optional**

JWS algorithm this client signs its private_key_jwt assertions
with (RFC 7591 token_endpoint_auth_signing_alg). Omit to accept
any algorithm the server supports.

It is enforced, not recorded: an assertion signed with another
algorithm is refused. Pinning it is what stops a second key -
one added to the published JWK set by someone else - from being
usable against an unchanged registration.

One of: `ES256`, `RS256`.

{/* AUTO-GENERATED-SPEC-END */}

## Deleting a resource

`spec.deletionPolicy` decides what deleting the resource does, and defaults to `Retain`:

- **`Retain`** keeps the client registered. The Secret is released from the resource (its
  owner reference is removed and it is annotated `nauthera.io/retained-from`), so
  deleting a namespace cannot take a live client or its credential with it.
- **`Delete`** removes the client from the server, and the Secret is deleted with the
  resource.

A finalizer (`nauthera.io/oidcclient`) holds the resource until the policy has been
carried out. A removal the server does not take keeps the resource in phase `Deleting`
with reason `ClientNotRemoved`, and is retried. Each outcome is recorded as a `Removed` or
`Retained` event.

## Status

The operator writes `status` from the server's answer. `clientId`, `issuer`, `etag`,
`registeredAt` and `observedGeneration` come from the server and are not changed by a
refusal. `lastReconciledTime` moves on every reconcile.

`phase` is one of `Pending`, `Ready`, `Error` or `Deleting`. The `Ready` condition's
reason says why:

| Reason | Phase | Meaning |
|---|---|---|
| `Registered` | `Ready` | The server holds the current generation. |
| `Refused` | `Error` | The server refused the spec. The message gives its objections field by field. |
| `Unreachable` | `Pending` | The server did not answer. The message names the endpoint and the cause. |
| `Unsupported` | `Pending` | The server serves a control-plane contract outside the operator's range. Nothing is written. |
| `Drifted` | `Error` | The client was changed or removed outside the operator. Nothing is written over the change. |
| `NamespaceNotCovered` | `Error` | The resource's namespace is not in the operator's `--namespaces`. Nothing is sent to the server. |
| `NamespaceNotAdmitted` | `Error` | No organization admits the resource's namespace, or the one `spec.organization` names does not. Nothing is sent to the server. |
| `OrganizationAmbiguous` | `Error` | Several organizations admit the namespace and `spec.organization` names none of them. Nothing is sent to the server. |
| `OrganizationNotReady` | `Pending` | The admitting organization has no issuer yet, because the server has not created it. The manifest is sent once it has one. |
| `OrganizationChanged` | `Error` | The server holds the client in one organization and the namespace now configures another. Delete and recreate the resource to move it. |
| `CredentialUnavailable` | `Pending` | The operator's client certificate or key is missing, unreadable or expired. |
| `TrustAnchorUnavailable` | `Pending` | The CA file is missing or holds no certificate. |
| `SecretUndeliverable` | `Error` or `Pending` | The server holds the secret, but the `Secret` could not be written. |
| `ClientNotRemoved` | `Deleting` | Under `deletionPolicy: Delete`, the server has not removed the client yet. |

`kubectl get oidcclients` shows the phase and the `Ready` condition's status and reason;
`-o wide` adds the message. To write the declared state back over a drifted client,
annotate the resource `nauthera.io/restore`. See [Operator](https://www.nauthera.io/docs/v1/operator) for
what each reason asks you to check.

{/* AUTO-GENERATED-STATUS-START */}

| Field | Type | Description |
|---|---|---|
| `clientId` | `string` | The resolved client_id registered server-side. |
| `conditions` | `[]object` | Standard Kubernetes conditions (Ready, Synced, ...). |
| `etag` | `string` | The server's revision of the client as the operator last wrote it. The operator compares it against the revision the server holds to tell its own change from one made elsewhere; a client changed elsewhere is reported as drifted and not written over until the manifest changes or the resource is annotated nauthera.io/restore. |
| `issuer` | `string` | The issuer the server homed the client at, after its default was applied: the organization whose rules the client is held to. |
| `lastReconciledTime` | `string` | When the operator last reconciled the resource, whatever the outcome. A stale value means the operator is not running, not the leader, or cannot see the resource. |
| `observedGeneration` | `integer` | .metadata.generation last reconciled by the server. |
| `organization` | `string` | The slug of the organization the server holds the client in. Once set, a namespace that comes to configure another organization is refused rather than moving the client. |
| `phase` | `string` | High-level lifecycle phase. |
| `registeredAt` | `string` | When the client was first registered server-side. |
| `secretFingerprint` | `string` | A non-reversible fingerprint of the client secret the server holds, as the server reports it; never the secret. The Secret written for it carries the same value in its nauthera.io/secret-fingerprint annotation, so the two can be told to match. |
| `secretRef` | `object` | Reference to the Kubernetes Secret holding the one-time generated client secret (populated only when spec.secret.generate was true). |

{/* AUTO-GENERATED-STATUS-END */}
