Échange de jeton
Une connexion au proxy pour tous les clusters (upstream_auth TokenExchange, RFC 0010), sans jeton de cluster sur le poste
En TokenExchange, le client présente le jeton du proxy (celui du dashboard, émis par
l'IdP du proxy) et le proxy l'échange, côté serveur, contre un jeton de l'IdP du cluster
(RFC 8693). L'apiserver valide toujours un
vrai jeton de son IdP : sa configuration ne change pas. Le jeton échangé ne quitte jamais
le proxy : il remplace l'Authorization de la requête relayée et vit en mémoire, par
réplica, au plus max_cache_seconds.
Côté utilisateur, une seule connexion SSO au proxy (kubectl-proxyauth login --server-url …)
sert tous les clusters en échange ; le CLI ne garde qu'un secret, le refresh token de cette
session.
Ce que vous acceptez
- Le jeton du proxy de chaque utilisateur part chez l'IdP du cluster à chaque échange. Si cet IdP n'est pas le vôtre, vous lui confiez ces jetons.
- Un groupe retiré chez l'IdP peut rester visible de l'apiserver jusqu'à
max_cache_seconds(300 s par défaut, 3 600 au plus,0pour échanger à chaque requête). - Dex ne vérifie pas l'audience du jeton qu'on lui présente : quiconque joint le Dex du
cluster avec un jeton de l'IdP du proxy obtient un jeton du cluster sans passer par le
proxy. Le proxy, lui, n'échange qu'un jeton émis pour
OIDC_AUDIENCE.
1. Ouvrir le mode (admin du proxy)
# values.yaml du chart
upstreamAuth:
allowedModes: [Passthrough, TokenExchange]Le proxy exige OIDC_AUDIENCE_VALIDATION=enforce (le défaut) pour un cluster en échange :
sans elle, il le ferme en 503 et le message nomme le réglage.
Pour la connexion SSO du CLI au serveur, l'IdP du proxy doit accepter la redirection
<API_CLUSTER_OIDC_BASE_REDIRECT_URL>/api/v1/auth/callback pour le client OIDC_CLIENT_ID
(une URI de plus sur le client du dashboard). Le proxy échange le code avec ce client,
puis remet au CLI un code à usage unique sur son listener loopback : aucun jeton ne passe
dans une URL.
Ajoutez offline_access à OIDC_SCOPES (par exemple openid profile email groups offline_access) : Dex et Keycloak ne rendent un refresh token qu'à cette portée. Sans lui,
la session du CLI ne se renouvelle pas et l'utilisateur refait login --server-url à
l'expiration de chaque jeton d'accès.
Si l'IdP du proxy est Dex v2.46.0 ou v2.46.1, un seul renouvellement de session
(grant_type=refresh_token) bloque son /token et son /keys jusqu'au redémarrage, quel
que soit le stockage (dexidp/dex#5017, ouvert) :
plus aucune connexion, et les réplicas du proxy qui démarrent restent non prêts faute de
discovery. Restez en v2.45.1 tant que ce ticket n'est pas corrigé. Le Dex du cluster
n'est pas touché : il ne reçoit que des échanges.
2. Configurer l'IdP du cluster
Le client qui échange est celui du cluster, oidc_provider (client_id, et
client_secret ou config_from) : un client confidentiel avec l'échange activé.
Keycloak (même realm)
Keycloak 26.2 échange un jeton du realm contre un jeton pour un autre client du même realm (standard token exchange) :
- Sur le client du cluster : Client authentication activé, Standard Token Exchange activé.
- Sur le client du dashboard (
OIDC_CLIENT_ID) : un mapper Audience qui ajoute le client du cluster à l'auddu jeton. Keycloak n'échange qu'un jeton dont l'audnomme le client qui échange. token_exchange.audience: le client du cluster, si l'apiserver attend cette audience.
Authentik
Authentik (2026.8) sert l'échange sur /application/o/token/ ; il accepte l'authentification
du client en Basic, comme l'envoie le proxy. Deux cas, qui ne donnent pas la même identité au
cluster :
Authentik est aussi l'IdP du proxy (même instance). Le provider du cluster vérifie le jeton du proxy parce qu'il l'a émis lui-même : l'utilisateur échangé est le vrai compte, avec ses groupes Authentik.
- Provider du cluster (
oidc_provider.client_id) : client confidentiel, Token exchange coché dans Grant types, et le provider du dashboard (OIDC_CLIENT_ID) dans Federated OAuth2/OpenID Providers. - Une application liée à ce provider : Authentik refuse l'échange (
invalid_grant) sans elle, et applique ses policy bindings à l'utilisateur. token_exchange.scope: par exempleopenid email profile. Sansscope, le jeton échangé ne porte aucun scope, donc ni email ni groupes.
L'IdP du proxy est un autre IdP, fédéré par une source OIDC. Le provider du cluster
liste cette source dans Federated OIDC Sources ; Authentik vérifie la signature du jeton du
proxy avec le JWKS de la source et son exp, sans contrôler ni iss ni aud (une
politique d'expression peut ajouter le contrôle d'iss). L'utilisateur échangé est un
compte de service généré (<provider>-<sub>, ou le nom donné par les property mappings
de la source), sans les groupes de l'IdP du proxy et avec email_verified: false : faites lire
à l'apiserver preferred_username et écrivez le RBAC du cluster sur ce nom, ou utilisez le
premier cas. Ce cas est prouvé sur le banc (suite heavy, heavy_rfc0010.rs).
L'issuer d'Authentik dépend du provider (/application/o/<slug>/) : même sur une seule
instance, le proxy voit deux IdP différents et affiche l'avis TokenExchangeAtRisk, sauf en
mode d'issuer global. connector_id reste vide.
Dex (fédération)
Dex n'échange qu'un jeton d'un IdP amont, par un connecteur OIDC : l'IdP du proxy est l'amont du Dex du cluster.
# config du Dex du cluster
oauth2:
grantTypes: [authorization_code, refresh_token, "urn:ietf:params:oauth:grant-type:token-exchange"]
connectors:
- type: oidc
id: proxy # token_exchange.connector_id
name: ProxyAuthK8S
config:
issuer: https://sso.example.com/realms/corp # l'IdP du proxy
clientID: kube-dex
clientSecret: …
getUserInfo: true
insecureEnableGroups: true
staticClients:
- id: kube-prod # oidc_provider.client_id du ProxyKubeApi
secret: …Dex réencode le sub : écrivez le RBAC du cluster sur les groupes.
3. Passer le cluster en échange (auteur du cluster)
apiVersion: weebo.si.rs/v1
kind: ProxyKubeApi
spec:
auth_config:
validate_against: Kubernetes # ne sert plus au jeton entrant
oidc_provider:
enabled: true
issuer_url: https://dex.prod.example.com
client_id: kube-prod
config_from: { name: kube-prod-oidc } # client_secret
upstream_auth:
mode: TokenExchange
token_exchange:
audience: "" # paramètre audience ; vide = omis
scope: "" # paramètre scope ; vide = omis
connector_id: proxy # Dex seulement
max_cache_seconds: 300L'admission refuse token_exchange hors de ce mode, ce mode sans token_exchange, sans
oidc_provider.enabled ou avec disable_validation, max_cache_seconds au-delà de 3 600,
et un CR, un LF ou un & dans audience, scope ou connector_id.
status.warnings et une condition d'avis TokenExchangeAtRisk signalent un IdP du cluster
qui n'est pas celui du proxy sans connector_id, et un client sans secret.
4. Côté utilisateur
kubectl-proxyauth login --server-url https://proxy.example.com # SSO au proxy, une fois
kubectl-proxyauth login <cluster> # aucun SSO pour un cluster en échange
kubectl get podsget-token rend le jeton du proxy et le rafraîchit, sans navigateur, quand il lui reste
moins de 60 s (/api/v1/auth/refresh).
Dans le dashboard, Accéder au cluster ouvre pour un cluster en échange la page
/clusters/<namespace>/<nom>/nosso : elle donne le kubeconfig du plugin et la marche à
suivre (login --server-url, puis login <cluster>), sans demander de jeton du cluster.
login --server-url … --token … reste possible (scripts, CI), sans
rafraîchissement.
Passer un cluster en échange rend inutiles les jetons de cluster déjà distribués pour lui :
les utilisateurs refont kubectl-proxyauth login <cluster>. Revenir en Passthrough rétablit
l'ancien chemin ; rien n'est persisté.
5. Exploiter
| Réponse | Cause | Compte pour fail2login ? |
|---|---|---|
401 « token could not be validated » | le jeton n'est pas un jeton du proxy pour OIDC_AUDIENCE | oui |
401 « refused the token exchange » | l'IdP du cluster refuse (invalid_grant, client mal configuré) | non |
502 | réponse de l'IdP du cluster illisible ou sans access_token | non |
503 | IdP du cluster injoignable ou lent (plus de 5 s) | non |
Le jeton du proxy n'est jamais relayé à l'apiserver en cas d'échec.
- Métriques :
proxyauth_token_exchange_total{result}(exchanged,cache_hit,refused,unavailable,unreadable) etproxyauth_token_exchange_duration_seconds. Alerte proposée :rate(proxyauth_token_exchange_total{result="unavailable"}[5m]) > 0pendant 5 minutes. Sur la session du CLI (/api/v1/auth/login,callback,token,refresh), les knobsUNSCOPED_*s'appliquent comme sur/api/v1/clusters(variables de sécurité) :
/api/v1/auth/refresh | Cause | Compte pour le ban non scopé ? |
|---|---|---|
200 | session renouvelée ; un IdP qui ne fait pas tourner le refresh token laisse celui présenté | — (efface les échecs de l'adresse) |
400 invalid_grant | l'IdP refuse le refresh token (invalid_grant, ou invalid_request comme Dex jusqu'à v2.45) | oui |
429 | adresse bannie ou quota dépassé | — |
503 temporarily_unavailable | IdP du proxy injoignable, ou qui refuse le client du proxy (invalid_client) | non |
- Audit : une ligne
token_exchangedpar échange réel (jamais sur un cache hit), avec l'utilisateur, le cluster et le résultat, jamais un jeton ;server_loginettoken_refreshedpour la session du CLI.