🤖 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)
- Per-client secret plumbing — extend the OpenBao seed + ExternalSecret from one
dex_client_secret to one secret per client. (gates the rest)
- Dedicated
kubernetes API-server client + trustedPeers for kubectl; cut the API server's
audience over. Coordinate with the Talos 1.14 KubeAuthenticationConfig migration.
- Split the low-risk web apps — one per PR: actual-budget, crossview, ksail.
- Split oauth2-proxy + Headlamp (highest blast radius — oauth2-proxy fronts other apps).
- Split Vault (
bound_audiences in lockstep).
- 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
Size
L (epic). Child 1 is S–M and gates the rest; children 3–5 are S each, one consumer per PR.
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}). Consumersregistered against it today (see
k8s/bases/infrastructure/controllers/dex/helm-release.yaml):public-clientclientID: "public-client"clientId: "public-client"oidc_client_id+bound_audiencesoidc-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 anyof 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
audis 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:
trustedPeers— the clients permitted to obtain tokens whose audience is it.audience:server:client_id:<client-id>.aud: <target-client>andazp: <requesting-client>— theauthorized party, i.e. who actually performed the login.
We already use this mechanism:
public-clientdeclarestrustedPeers: [kubectl], which is howthe separate public
kubectlclient obtains a token the API server accepts. The roadmap is to applythat existing, proven pattern consistently so each application is its own identity, rather than
having one identity serve everything.
Target state
staticClientper application, each with its own id, its own secret, and only its ownredirect URIs.
kubernetes), with theinteractive clients that must mint cluster tokens (
kubectl, and Headlamp if it needs clusteraccess) declared as its
trustedPeers, requestingaudience:server:client_id:kubernetes.aud, so a token minted for one app is not accepted by another.vault-seed/push-secret-seed-dex-client-secret.yaml+dex/external-secret.yaml, which aresingle-secret today).
Sequencing risk —
audchanges must land with their verifierAn
audchange breaks a consumer the moment the two sides disagree, so each client split is onlysafe when its verifier is updated in the same change:
(
cluster.apiServer.extraArgs.oidc-client-id), so changing it is a machine-config rollout, not aHelm value.
oidc-*extraArgs out ofapiServer.extraArgsintoKubeAuthenticationConfig— see ksail#5771 / ksail#6167. Sequence thiswork against that migration rather than against the legacy field, or it lands on a shape that is
about to change.*
bound_audiencesin its JWT auth config — must move in lockstep.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)
dex_client_secretto one secret per client. (gates the rest)kubernetesAPI-server client +trustedPeersforkubectl; cut the API server'saudience over. Coordinate with the Talos 1.14
KubeAuthenticationConfigmigration.bound_audiencesin lockstep).public-client; assert in CI that no two clients share an id/secret and that everyclient's redirect URIs belong to exactly one app.
Acceptance criteria
staticClientwith its own secret.trustedPeers+audience:server:client_id:<id>, withazpidentifying the requesting client.public-clientno longer exists, and no client's redirect URIs span more than one application.aud/azp, not just that the manifest renders (static validation ≠ working).Size
L (epic). Child 1 is S–M and gates the rest; children 3–5 are S each, one consumer per PR.