Durant mon article sur Authentik, j’ai évoqué la possibilité d’avoir du SSO sur l’API-Server de Kubernetes.

Pour rappel, pour activer l’authentification par l’OIDC lorsqu’on passe par kubectl, il faut dans un premier temps configurer l’API de Kubernetes pour qu’elle puisse accepter les tokens JWT émis par un fournisseur d’identité (IdP) compatible OIDC. Cela se fait en ajoutant des paramètres à la ligne de commande du serveur API Kubernetes:

cluster:
  apiServer:
    extraArgs:
      oidc-issuer-url: "https://goauthentik.une-tasse-de.cafe/application/o/k8s-lucca-poc/"
      oidc-client-id: mon-super-client-id
      oidc-username-claim: email
      oidc-groups-claim: groups

En installant oidc-login (un plugin pour kubectl), on peut avoir un workflow qui ouvre son navigateur pour se connecter à l’IdP et obtenir un token JWT, qui sera ensuite utilisé pour s’authentifier auprès de l’API Kubernetes.

C’est propre, efficace, et ça fonctionne très bien !

Mais si vous voulez authentifier plusieurs populations d’utilisateurs avec différents IdP… coup dur !

Typiquement, j’utilise Dex et Authentik sur mes clusters Kubernetes pour les utilisateurs internes, et j’ai le besoin de pouvoir faire du Machine2Machine en authentifiant des pipelines Github Actions !

À savoir que Github ne propose pas d’OIDC (donc si vous voulez faire du Social-login avec Github, il faudra passer par un intermédiaire) pour les utilisateurs … mais pour les pipelines, c’est possible !

On peut donc, techniquement, s’authentifier auprès de l’API-Server avec un token JWT émis par GitHub dans un workflow GitHub Actions en direct (l’usage d’un Dex ou autre aurait été un chtit peu plus complexe).

Mais il y a une grosse limitation : les arguments passés à l’API-Server sont globaux, ce qui signifie que si vous avez plusieurs IdP, vous ne pouvez pas les utiliser en même temps. Il faut donc choisir entre vos utilisateurs, ou vos pipelines !

Mais ça, c’était avant que je découvre la fonctionnalité game-changer : la configuration de l’APIServer via un fichier (et surtout qui permet de configurer plusieurs Issuers).

Au revoir les arguments --oidc-issuer-url, --oidc-client-id, etc. et bonjour le fichier de configuration, voici un peu ce que ça donne :

apiVersion: apiserver.config.k8s.io/v1beta1
kind: AuthenticationConfiguration
jwt:
  - issuer:
      url: https://goauthentik.une-tasse-de.cafe/application/o/k8s-lucca-poc/ # équivalent de oidc-issuer-url
      audiences:
        - mon-client-id-sur-goauthentik # équivalent de oidc-client-id
      audienceMatchPolicy: MatchAny
    claimValidationRules:
      - expression: "claims.email_verified == true"
        message: "email must be verified"
    claimMappings:
      username:
        expression: 'claims.email + ":authentik"' # équivalent de oidc-username-claim
      groups:
        expression: "claims.groups" # équivalent de oidc-groups-claim
      uid:
        expression: "claims.sub"
    userValidationRules:
      - expression: "!user.username.startsWith('system:')"
        message: "username cannot used reserved system: prefix"
      - expression: "user.groups.all(group, !group.startsWith('system:'))"
        message: "groups cannot used reserved system: prefix"

En plus de pouvoir enfin configurer ça dans un fichier plutôt que via des arguments, on a aussi accès à plein de nouvelles fonctionnalités :

  • Valider un champ spécifique du token JWT (par exemple, vérifier que l’email est vérifié comme dans l’exemple ci-dessus).
  • Mapper des claims du JWT vers des champs spécifiques (en appliquant un traitement en CEL).
  • Vérifier des champs (pour valider le JWT ou l’utilisateur) avec des règles CEL.

Pour configurer l’APIServer avec ce fichier, il faut ajouter l’argument --authentication-config-file à la ligne de commande du serveur API Kubernetes. Dans un contexte Talos ou RKE2, il faut tenir compte du fait que le kubelet est conteneurisé, donc il faut ajouter le fichier sur le système et créer un point de montage pour le conteneur du kubelet.

Pour Talos (je n’allais pas faire un article sans en parler), voici un exemple de configuration :

cluster:
  apiServer:
    extraArgs:
      authentication-config: /var/lib/apiserver/authentication.yaml
    extraVolumes:
      - hostPath: /var/lib/apiserver
        mountPath: /var/lib/apiserver
        readonly: true
machine:
  files:
    - content: |
        apiVersion: apiserver.config.k8s.io/v1beta1
        kind: AuthenticationConfiguration
        jwt:
        - issuer:
            url: https://goauthentik.une-tasse-de.cafe/application/o/k8s-lucca-poc/ # équivalent de oidc-issuer-url
            audiences:
                - mon-client-id-sur-goauthentik # équivalent de oidc-client-id
            audienceMatchPolicy: MatchAny
            claimValidationRules:
            - expression: "claims.email_verified == true"
                message: "email must be verified"
            claimMappings:
            username:
                expression: 'claims.email + ":authentik"' # équivalent de oidc-username-claim (+ un suffixe)
            groups:
                expression: "claims.groups" # équivalent de oidc-groups-claim
            uid:
                expression: "claims.sub"
            userValidationRules:
            - expression: "!user.username.startsWith('system:')"
                message: "username cannot used reserved system: prefix"
            - expression: "user.groups.all(group, !group.startsWith('system:'))"
                message: "groups cannot used reserved system: prefix"        
      permissions: 0o444
      path: /var/lib/apiserver/authentication.yaml
      op: create

Pour RKE2, je vous laisse vous débrouiller, je suis ambassadeur Talos, pas Rancher ! (après si rancher passe par ici, je suis open hein 😉)

Pour tester ça :

$ kubectl config set-credentials oidc \
          --exec-api-version=client.authentication.k8s.io/v1beta1 \
          --exec-command=kubectl \
          --exec-arg=oidc-login \
          --exec-arg=get-token \
          --exec-arg=--oidc-issuer-url=https://goauthentik.une-tasse-de.cafe/application/o/k8s-lucca-poc/ \
          --exec-arg=--oidc-client-id=mon-client-id-sur-goauthentik \
          --exec-arg=--oidc-client-secret=mon-secret-id-sur-goauthentik \
          --exec-arg=--oidc-extra-scope=profile \
          --exec-arg=--oidc-extra-scope=email
$ kubectl auth whoami --user=oidc
ATTRIBUTE   VALUE
Username    goauthentik@une-pause-cafe.fr:second
UID         6d47d08157e7d71d1a3b18087dc068ae689b142934cbb3517562d9b74162edba
Groups      [authentik Admins Tech Omni system:authenticated]

Maintenant qu’on sait comment appliquer notre configuration, on peut aller un peu plus loin en ajoutant notre deuxième IdP : celui de Github actions.

L’issuer GH est https://token.actions.githubusercontent.com, on peut librement choisir le client ID (tant qu’il est identique sur l’API-Server et dans le workflow GitHub Actions) et on peut mapper les claims du JWT comme on le souhaite.

# ... après le premier issuer
  - issuer:
      url: https://token.actions.githubusercontent.com
      audiences:
        - coffee-lucca-poc
      audienceMatchPolicy: MatchAny
    claimMappings:
      username:
        expression: '"github-actions:" + claims.sub'
      uid:
        expression: "claims.sub"
      extra:
        - key: "github.com/repository"
          valueExpression: "claims.repository"
        - key: "github.com/repository_owner"
          valueExpression: "claims.repository_owner"
        - key: "github.com/ref"
          valueExpression: "claims.ref"

C’est plus ou moins la même configuration que pour Authentik, mais avec des claims différents (et je rajoute des claims supplémentaires pour avoir des infos sur le repository et la branche dans mes logs d’audit).

Comment on teste ça ? Déjà on va créer un kubeconfig qu’on utilisera dans le pipeline:

apiVersion: v1
clusters:
  - cluster:
      certificate-authority-data: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUJpakNDQVRDZ0F3SUJBZ0lSQVBydklDT2xMMGNCZ05iLzI4QlNtaUl3Q2dZSUtvWkl6ajBFQXdJd0ZURVQKTUJFR0ExVUVDaE1LYTNWaVpYSnVaWFJsY3pBZUZ3MHlOVEEzTVRVd09EVXlOREJhRncwek5UQTNNVE13T0RVeQpOREJhTUJVeEV6QVJCZ05WQkFvVENtdDFZbVZ5Ym1WMFpYTXdXVEFUQmdjcWhrak9QUUlCQmdncWhrak9QUU1CCkJ3TkNBQVJQcGpwWmM5blc4Sm5YQXV2VWVXdjlNeG1IeDRuUXVhbGdFVWtuT1VmMTZYRlU4S0N1M1NvY0tLRS8KUHNQY3ZYclVHQnV3V21Ib1lSamZWOE8yZVZ0a28yRXdYekFPQmdOVkhROEJBZjhFQkFNQ0FvUXdIUVlEVlIwbApCQll3RkFZSUt3WUJCUVVIQXdFR0NDc0dBUVVGQndNQ01BOEdBMVVkRXdFQi93UUZNQU1CQWY4d0hRWURWUjBPCkJCWUVGQ2Q0TnllTlppRFlQSHBSeWtUYXd4TVdIelJQTUFvR0NDcUdTTTQ5QkFNQ0EwZ0FNRVVDSVFDY1lzTzgKOHUyUXZMKzN6UlY2UGJNMWF3Tk0zSUNPaHZwU2tTZElEVzEydXdJZ0ZrUjcyRFhRVTZhMFU0ZlREZ09pU1FVRQpZZEJFT0VxdzFFMHNrT0UreGlZPQotLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0tCg==
      server: https://192.168.1.170:6443
    name: lucca-oidc
contexts:
  - context:
      cluster: lucca-oidc
      namespace: default
      user: existe-pas
    name: admin@lucca-oidc
current-context: admin@lucca-oidc
kind: Config
preferences: {}

Et non, vous ne rêvez pas : Il n’y a pas d’utilisateur défini dans le kubeconfig ! On injectera nous-même le JWT dans la commande kubectl pour s’authentifier.

On va devoir créer un dépôt pour tester notre kubeconfig dans un workflow.

name: K8S OIDC Authentication Test

on:
  push:
  workflow_dispatch:

jobs:
  get-nodes:
    runs-on: ubuntu-latest
    permissions:
      id-token: write # Cette permission est nécessaire pour obtenir le token OIDC
      contents: read
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Fetch kubeconfig secret
        id: kubeconfig
        run: |
          echo "${{ secrets.KUBECONFIG_B64 }}" | base64 -d > kubeconfig          
        env:
          KUBECONFIG_B64: ${{ secrets.KUBECONFIG_B64 }}

      - name: Set KUBECONFIG env
        run: echo "KUBECONFIG=$PWD/kubeconfig" >> $GITHUB_ENV

      - name: Install kubectl
        uses: azure/setup-kubectl@v3

      - name: Get OIDC token from GitHub
        id: get_token
        run: |
          token_json=$(curl -s -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=coffee-lucca-poc")          
          oidc_token=$(echo "$token_json" | jq -r '.value')
          echo "token=${oidc_token}" >> $GITHUB_OUTPUT          

      - name: Debug JWT token claims
        run: |
          echo "Decoding JWT token for debugging..."
          echo "${{ steps.get_token.outputs.token }}" | cut -d'.' -f2 | base64 -d 2>/dev/null | jq . || echo "Failed to decode JWT payload"          

      - name: Test authentication with kubectl
        run: |
          echo "Testing kubectl auth..."
          kubectl auth whoami --token="${{ steps.get_token.outputs.token }}" || echo "Auth failed"          

Les variables ACTIONS_ID_TOKEN_REQUEST_TOKEN et ACTIONS_ID_TOKEN_REQUEST_URL sont automatiquement définies par GitHub Actions pour obtenir le token OIDC.

Ce workflow va :

  • Récupérer le kubeconfig encodé en base64 depuis les secrets du dépôt.
  • Récupérer le token OIDC depuis GitHub avec une audience spécifique (celle qu’on a définie dans notre kubeconfig + API-Server).
  • S’authentifier en injectant le token dans la commande kubectl (ce qui évite de devoir contacter l’issuer pour obtenir ce token quand on passe par Kubelogin).

Voyons ce qu’il décode dans le token JWT :

{
  "actor": "qjoly",
  "actor_id": "82603435",
  "aud": "coffee-lucca-poc",
  "base_ref": "",
  "event_name": "workflow_dispatch",
  "exp": 1752630402,
  "head_ref": "",
  "iat": 1752608802,
  "iss": "https://token.actions.githubusercontent.com",
  "job_workflow_ref": "qjoly/lucca-oidc-poc/.github/workflows/test.yaml@refs/heads/main",
  "job_workflow_sha": "81ae75f767dd22d31f21ae4cff3460bcffbcea1c",
  "jti": "72b5e0be-16a1-46d2-930a-6a184407f2fc",
  "nbf": 1752608502,
  "ref": "refs/heads/main",
  "ref_protected": "false",
  "ref_type": "branch",
  "repository": "qjoly/lucca-oidc-poc",
  "repository_id": "1020257999",
  "repository_owner": "qjoly",
  "repository_owner_id": "82603435",
  "repository_visibility": "private",
  "run_attempt": "1",
  "run_id": "16302845430",
  "run_number": "2",
  "runner_environment": "github-hosted",
  "sha": "81ae75f767dd22d31f21ae4cff3460bcffbcea1c",
  "sub": "repo:qjoly/lucca-oidc-poc:ref:refs/heads/main",
  "workflow": "K8S OIDC Get Nodes",
  "workflow_ref": "qjoly/lucca-oidc-poc/.github/workflows/test.yaml@refs/heads/main",
  "workflow_sha": "81ae75f767dd22d31f21ae4cff3460bcffbcea1c"
}

Et pour l’étape d’après :

ATTRIBUTE                                           VALUE
Username                                            github-actions:repo:qjoly/lucca-oidc-poc:ref:refs/heads/main
UID                                                 repo:qjoly/lucca-oidc-poc:ref:refs/heads/main
Groups                                              [system:authenticated]
Extra: authentication.kubernetes.io/credential-id   [JTI=72b5e0be-16a1-46d2-930a-6a184407f2fc]
Extra: github.com/ref                               [refs/heads/main]
Extra: github.com/repository                        [qjoly/lucca-oidc-poc]
Extra: github.com/repository_owner                  [qjoly]

C’est beau tout ça, mais dans l’état actuel : il ne faudra pas beaucoup de temps avant que l’on retrouve un mineur de bitcoin dans notre cluster puisque tous les dépôts GitHub ont accès à notre Kubernetes, même ceux qui ne nous appartiennent pas, oups ! (pour peu qu’ils aient le bon claim audience).

Histoire d’être ceinture et bretelles, on doit procéder à une vérification en 2 étapes :

  • Vérifier que le JWT provient bien de dépôts autorisés (via une claimValidationRules ) et refuser ceux qui ne sont pas dans la liste.
  • Limiter les droits en fonction du dépôt et de la branche (via le RBAC Kubernetes).

Pour la première partie, on va ajouter une règle qui vérifie que le dépôt est bien dans la liste de ceux autorisés. On peut faire ça en utilisant claimValidationRules dans notre configuration de l’APIServer :

  - issuer:
      url: https://token.actions.githubusercontent.com
      audiences:
        - coffee-lucca-poc
      audienceMatchPolicy: MatchAny
    claimMappings:
      username:
        expression: '"github-actions:" + claims.sub'
      uid:
        expression: "claims.sub"
      extra:
        - key: "github.com/repository"
          valueExpression: "claims.repository"
        - key: "github.com/repository_owner"
          valueExpression: "claims.repository_owner"
        - key: "github.com/ref"
          valueExpression: "claims.ref"
+   claimValidationRules:
+       - expression: 'claims.repository in ["qjoly/lucca-oidc-poc", "qjoly/another-repo", "myorg/yet-another-repo"]'
+         message: "repository must be in the allowed list"

Puis coté RBAC Kubernetes, c’est un peu galère car on ne peut pas utiliser de wildcard dans les subjects (donc pas de repo:qjoly/lucca-oidc-poc:ref:.*), on ne peut pas non-plus se baser sur les claims “extra”… il va falloir être un peu plus créatif.

Je distingue 2 cas (complémentaires) où on peut faire du RBAC dans Kubernetes avec notre setup sur Github Actions :

  • Donner des droits différents en fonction du dépôt.
  • Donner des droits différents en fonction de la branche.

Dans un (Cluster)RoleBinding, on ne peut prendre en compte que deux paramètres dans le subjects :

  • un User
  • un Group
# Exemple de RoleBinding pour un dépôt spécifique
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: read-secrets-global
subjects:
- kind: Group
  name: les-sysadmins-de-la-mort
  apiGroup: rbac.authorization.k8s.io
- kind: User
  name: qjoly
  apiGroup: rbac.authorization.k8s.io
roleRef:
  kind: ClusterRole
  name: can-destroy-everything
  apiGroup: rbac.authorization.k8s.io

Les conditions dans les Subjects sont des OR, ce qui veut dire que même si on arrive à mettre la branche dans le Group du JWT (via claimMappings), on ne pourra pas faire de distinction entre les branches dans le RoleBinding avec une règle Si le dépôt est qjoly/lucca-oidc-poc et la branche est main, alors on a les droits.

Ce qu’on peut faire à la place, toujours en rajoutant un claimMappings, est de pouvoir stocker l’information du dépôt dans un groupe et conserver le dépôt+branche (repo:qjoly/lucca-oidc-poc:ref:refs/heads/main) dans le username. Ainsi, on peut faire un RoleBinding possédant des privilèges élevés pour une branche spécifique sur le dépôt et un autre RoleBinding qui ciblera le groupe portant le nom du dépôt. De cette manière : on n’a pas à faire un RoleBinding par branche (ce qui, logiquement, n’aurait pas été possible, car imprédictible).

  - issuer:
      url: https://token.actions.githubusercontent.com
      audiences:
        - coffee-lucca-poc
      audienceMatchPolicy: MatchAny
    claimMappings:
      username:
        expression: '"github-actions:" + claims.sub'
      uid:
        expression: "claims.sub"
+     groups:
+       expression: "claims.repository"
      extra:
        - key: "github.com/repository"
          valueExpression: "claims.repository"
        - key: "github.com/repository_owner"
          valueExpression: "claims.repository_owner"
        - key: "github.com/ref"
          valueExpression: "claims.ref"
    claimValidationRules:
        - expression: 'claims.repository in ["qjoly/lucca-oidc-poc", "qjoly/another-repo", "myorg/yet-another-repo"]'
          message: "repository must be in the allowed list"

Résultat, Kubernetes va maintenant reconnaître le groupe qjoly/lucca-oidc-poc et on peut lui donner des permissions spécifiques dans un RoleBinding :

ATTRIBUTE                                           VALUE
Username                                            repo:qjoly/lucca-oidc-poc:ref:refs/heads/main
UID                                                 repo:qjoly/lucca-oidc-poc:ref:refs/heads/main
Groups                                              [qjoly/lucca-oidc-poc system:authenticated]
Extra: authentication.kubernetes.io/credential-id   [JTI=133d3a4d-631c-48e8-ba3f-8d55315b4bfd]
Extra: github.com/ref                               [refs/heads/main]
Extra: github.com/repository                        [qjoly/lucca-oidc-poc]
Extra: github.com/repository_owner                  [qjoly]

Donc, voici nos deux RoleBinding avec lesquels on différencie les droits en fonction du dépôt et de la branche :

apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: godmode
subjects:
- kind: User
  name: repo:qjoly/lucca-oidc-poc:ref:refs/heads/main
  apiGroup: rbac.authorization.k8s.io
roleRef:
  kind: ClusterRole
  name: can-destroy-everything
  apiGroup: rbac.authorization.k8s.io
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: list-pod-pods
subjects:
- kind: Group
  name: qjoly/lucca-oidc-poc
  apiGroup: rbac.authorization.k8s.io
roleRef:
    kind: ClusterRole
    name: can-list-pods # Safe ClusterRole
    apiGroup: rbac.authorization.k8s.io

Les pipelines du dépôt qjoly/lucca-oidc-poc peuvent juste lister les pods mais ceux sur la branche main pourront avoir des accès bien plus permissifs via la ClusterRole can-destroy-everything.


Si j’ai écrit cet article (qui est un mélange entre un expresso et un vrai blogpost), c’est parce que j’ai trouvé peu de ressources sur les AuthenticationConfiguration et l’authentification Github Actions avec Kubernetes, c’était l’occasion de partager ma découverte et mes expérimentations.

Il doit existe de nombreux cas que je n’ai pas abordés (ou prévus), si vous avez des compléments d’information ou des suggestions, n’hésitez pas à les partager plus bas 😄