Skip to content

Roadmap: give each Dex client its own identity via cross-client trust and authorized party (azp) #2663

Description

@devantler

🤖 Generated by the Daily AI Engineer

Problem

Dex currently issues tokens for nearly every platform consumer under one shared confidential
client, public-client
, with one shared client secret (${dex_client_secret}). Consumers
registered against it today (see k8s/bases/infrastructure/controllers/dex/helm-release.yaml):

Consumer How it uses public-client
oauth2-proxy clientID: "public-client"
Headlamp redirect URI on the shared client
actual-budget clientId: "public-client"
Vault (JWT auth) oidc_client_id + bound_audiences
ksail redirect URI on the shared client
crossview reuses the shared confidential client
kube-apiserver oidc-client-id: public-client (talos/cluster/enable-dex-oidc.yaml)

Because the OIDC aud (audience) claim carries the client id, every ID token Dex mints for any
of these consumers presents the same audience. No consumer can therefore distinguish a token
minted for it from one minted for a different application — including the cluster's API server,
whose accepted audience is that same shared id. They also share a single secret, so the secret's
blast radius is the whole set rather than one app.

This is the OIDC audience-confusion / shared-client-identity control class. Per-client identity
plus a correctly scoped aud is the standard remediation, and Dex supports it directly.

(Detailed reachability assessment is deliberately not recorded here — this repo is public. Keep that
in private operator notes.)

What Dex provides

Cross-client trust and authorized party:

  • A client declares trustedPeers — the clients permitted to obtain tokens whose audience is it.
  • A peer requests the scope audience:server:client_id:<client-id>.
  • The resulting ID token carries aud: <target-client> and azp: <requesting-client> — the
    authorized party, i.e. who actually performed the login.

We already use this mechanism: public-client declares trustedPeers: [kubectl], which is how
the separate public kubectl client obtains a token the API server accepts. The roadmap is to apply
that existing, proven pattern consistently so each application is its own identity, rather than
having one identity serve everything.

Target state

  • One Dex staticClient per application, each with its own id, its own secret, and only its own
    redirect URIs.
  • A dedicated client for the cluster's API-server audience (e.g. kubernetes), with the
    interactive clients that must mint cluster tokens (kubectl, and Headlamp if it needs cluster
    access) declared as its trustedPeers, requesting audience:server:client_id:kubernetes.
  • Every consumer verifies its own aud, so a token minted for one app is not accepted by another.
  • Secrets are per client, materialized individually from OpenBao (extends
    vault-seed/push-secret-seed-dex-client-secret.yaml + dex/external-secret.yaml, which are
    single-secret today).

Sequencing risk — aud changes must land with their verifier

An aud change breaks a consumer the moment the two sides disagree, so each client split is only
safe when its verifier is updated in the same change:

  • kube-apiserver is the highest-risk one: its audience lives in Talos machine config
    (cluster.apiServer.extraArgs.oidc-client-id), so changing it is a machine-config rollout, not a
    Helm value. ⚠️ Cross-link: Talos 1.14 moves those oidc-* extraArgs out of
    apiServer.extraArgs into KubeAuthenticationConfig — see ksail#5771 / ksail#6167. Sequence this
    work against that migration rather than against the legacy field, or it lands on a shape that is
    about to change.
    *
  • Vault binds bound_audiences in its JWT auth config — must move in lockstep.
  • oauth2-proxy / actual-budget / crossview / Headlamp / ksail each pin the client id in their own
    Helm values.

Per feature-flag-first delivery, migrate one consumer at a time, keeping the shared client alive
until the last one is off it. Each app can be cut over independently once it has its own client +
secret, so this decomposes cleanly.

Proposed children (each independently shippable)

  1. Per-client secret plumbing — extend the OpenBao seed + ExternalSecret from one
    dex_client_secret to one secret per client. (gates the rest)
  2. Dedicated kubernetes API-server client + trustedPeers for kubectl; cut the API server's
    audience over. Coordinate with the Talos 1.14 KubeAuthenticationConfig migration.
  3. Split the low-risk web apps — one per PR: actual-budget, crossview, ksail.
  4. Split oauth2-proxy + Headlamp (highest blast radius — oauth2-proxy fronts other apps).
  5. Split Vault (bound_audiences in lockstep).
  6. Retire public-client; assert in CI that no two clients share an id/secret and that every
    client's redirect URIs belong to exactly one app.

Acceptance criteria

  • Every application authenticates as its own Dex staticClient with its own secret.
  • The kube-apiserver has a dedicated audience not shared with any application client.
  • Cross-client token issuance happens only via explicit trustedPeers +
    audience:server:client_id:<id>, with azp identifying the requesting client.
  • public-client no longer exists, and no client's redirect URIs span more than one application.
  • Each cutover is verified behaviourally — log in through the app and confirm the token's
    aud/azp, not just that the manifest renders (static validation ≠ working).
  • A CI guard prevents regression to a shared client id/secret.

Size

L (epic). Child 1 is S–M and gates the rest; children 3–5 are S each, one consumer per PR.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    Status
    No status

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions