OIDC : des identités sans secret
Pourquoi
La leçon précédente a laissé une clé Scaleway dans les secrets de GitHub, avec une date d'expiration à un an. C'est le mieux que l'on puisse faire avec une clé, et c'est insatisfaisant : une clé de longue durée peut fuir (un journal, une dépendance compromise, une branche malveillante), elle reste valide jusqu'à son expiration, et il faut penser à la renouveler.
Il existe une autre façon de procéder. Le workflow ne détient aucune clé : il prouve qui il est (« le job de production du dépôt signalements, déclenché par l'étiquette v1.2.0 »), et le fournisseur, qui fait confiance à GitHub pour attester cette identité, lui délivre un identifiant valable quelques minutes. C'est la fédération d'identité par OpenID Connect (OIDC). Rien à stocker, rien à renouveler, et un identifiant volé ne vaut plus rien à l'expiration de sa courte durée de vie (de quelques minutes à une heure selon le fournisseur).
AWS, Azure et Google Cloud la proposent depuis des années. Scaleway, le fournisseur de Lyneko, ne la propose pas au 1er octobre 2026 : son service IAM gère des clés d'API, des applications, des politiques et la fédération SAML des utilisateurs, mais aucune fédération d'identité de charge de travail. Cette leçon explique le mécanisme, le met en œuvre là où il existe, et montre comment s'en approcher sur Scaleway avec un intermédiaire, OpenBao.
Les concepts
Le flux
sequenceDiagram
participant J as Job (runner)
participant G as GitHub (émetteur OIDC)
participant F as Fournisseur (AWS, OpenBao...)
J->>G: demande de jeton (audience X)
G-->>J: jeton signé : qui je suis
J->>F: « voici mon jeton, donne-moi un accès »
F->>G: récupère les clés publiques (JWKS)
F->>F: vérifie signature, émetteur, audience, expiration, sujet, conditions
F-->>J: identifiant temporaire (minutes)
J->>F: utilise l'identifiant
- Le job demande à GitHub un jeton d'identité pour une audience donnée (le destinataire prévu du jeton).
- GitHub renvoie un JWT signé avec sa clé privée, qui décrit le job : dépôt, référence, événement, environnement, workflow.
- Le job présente ce jeton au fournisseur.
- Le fournisseur vérifie la signature avec les clés publiques de GitHub, puis compare les revendications (claims) du jeton à sa politique de confiance : ce rôle n'est accessible qu'au dépôt X, dans l'environnement Y.
- Si tout correspond, il délivre un identifiant temporaire, propre à ce fournisseur.
Le jeton
Un JWT est trois parties encodées en base64, séparées par des points : un en-tête (algorithme, identifiant de clé), un corps (les revendications) et une signature. GitHub publie tout ce qu'il faut pour vérifier ses jetons, à des adresses publiques :
$ curl -s https://token.actions.githubusercontent.com/.well-known/openid-configuration
{
"issuer": "https://token.actions.githubusercontent.com",
"jwks_uri": "https://token.actions.githubusercontent.com/.well-known/jwks",
"subject_types_supported": [
"public",
"pairwise"
],
"response_types_supported": [
"id_token"
],
"claims_supported": [
"sub",
"aud",
"exp",
"iat",
"iss",
"jti",
"nbf",
"ref",
"sha",
"repository",
"repository_id",
"repository_owner",
"repository_owner_id",
"enterprise",
"enterprise_id",
"run_id",
"run_number",
"run_attempt",
"actor",
"actor_id",
"workflow",
"workflow_ref",
"workflow_sha",
"head_ref",
"base_ref",
"event_name",
"ref_type",
"ref_protected",
"environment",
"environment_node_id",
"job_workflow_ref",
"job_workflow_sha",
"repository_visibility",
"runner_environment",
"issuer_scope",
"check_run_id"
],
"id_token_signing_alg_values_supported": [
"RS256"
],
"scopes_supported": [
"openid"
]
}
$ curl -s https://token.actions.githubusercontent.com/.well-known/jwks | jq -r '.keys[] | "\(.kid) \(.kty) \(.alg)"'
cc413527-173f-5a05-976e-9c52b1d7b431 RSA RS256
38826b17-6a30-5f9b-b169-8beb8202f723 RSA RS256
38E9B30B3A023A1B72309921A69A42FCC496C42C RSA RS256
4F3E9AD8C9A6F5EB3173006F4FA630E28F43DCE9 RSA RS256
Le document de découverte annonce l'émetteur, l'adresse des clés et la liste des revendications ; le JWKS contient les clés publiques de signature (quatre au 1er octobre 2026, ce qui permet d'en faire tourner une sans interruption). Un fournisseur n'a besoin que de l'adresse de l'émetteur pour tout le reste.
Les revendications qui comptent pour une politique de confiance :
| Revendication | Exemple | Usage |
|---|---|---|
iss | https://token.actions.githubusercontent.com | toujours vérifiée |
aud | sts.amazonaws.com | le jeton est destiné à ce fournisseur, et à lui seul |
sub | repo:lyneko-formation/signalements:environment:production | l'identité résumée, voir plus bas |
repository_id, repository_owner_id | 905117331 | identifiants numériques, stables même si les noms changent |
ref, ref_type, ref_protected | refs/tags/v1.2.0, tag | la référence du run |
environment | production | présente si le job déclare un environnement |
job_workflow_ref | .../livraison.yml@refs/tags/v1.2.0 | le fichier qui définit le job, utile avec les workflows réutilisables |
event_name, runner_environment | push, github-hosted | le déclencheur, le type de runner |
exp, iat, nbf | fenêtre de validité, courte |
Le sujet
La revendication sub résume l'identité en une chaîne, selon des règles de priorité :
| Situation du job | Sujet par défaut |
|---|---|
| déclare un environnement | repo:<propriétaire>/<dépôt>:environment:<nom> |
| sinon, déclenché par une demande de fusion | repo:<propriétaire>/<dépôt>:pull_request |
| sinon, sur une branche | repo:<propriétaire>/<dépôt>:ref:refs/heads/<branche> |
| sinon, sur une étiquette | repo:<propriétaire>/<dépôt>:ref:refs/tags/<étiquette> |
L'environnement l'emporte sur la référence : un job de l'environnement production, déclenché par l'étiquette v1.2.0, a pour sujet ...:environment:production, sans trace de l'étiquette. C'est logique (l'environnement, avec ses règles de branches et ses approbations, est la garantie la plus forte), mais c'est la source d'erreurs de configuration la plus courante. Les deux-points d'une valeur sont encodés en %3A.
Le sujet immuable
Un sujet fait de noms a un défaut : un nom peut être réutilisé. Si une organisation est renommée ou supprimée, quelqu'un d'autre peut reprendre son nom, recréer un dépôt du même nom, et obtenir des jetons dont le sujet correspond aux politiques de confiance écrites pour l'ancien propriétaire. Depuis avril 2026, GitHub propose un format immuable, qui ajoute les identifiants numériques :
repo:lyneko-formation@201445879/signalements@905117331:environment:productionLe @ ne peut pas apparaître dans un nom GitHub, ce qui rend la chaîne sans ambiguïté. Le format est automatique pour les dépôts créés après le 15 juillet 2026, et pour tout dépôt renommé ou transféré après cette date ; les dépôts plus anciens gardent l'ancien format, sauf adhésion par les réglages OIDC de l'organisation ou du dépôt. Conséquence pratique : renommer un dépôt ancien change son sujet, et les politiques de confiance écrites avec l'ancien format cessent de correspondre. À l'inverse, une politique qui vérifie repository_id et repository_owner_id reste juste dans tous les cas.
La permission id-token: write
Un job n'obtient de jeton OIDC que s'il a la permission id-token: write. Le runner lui fournit alors deux variables, ACTIONS_ID_TOKEN_REQUEST_URL et ACTIONS_ID_TOKEN_REQUEST_TOKEN, qui permettent de demander un jeton pour l'audience de son choix. Cette permission ne donne aucun droit sur le dépôt : elle permet seulement de prouver son identité. Les demandes de fusion venues d'une bifurcation ne peuvent pas l'obtenir.
En pratique
Chez un fournisseur qui fédère : AWS
Le principe est le même chez les trois grands fournisseurs : déclarer GitHub comme fournisseur d'identité, puis créer un rôle dont la politique de confiance exige des revendications précises. Chez AWS, la politique du rôle de déploiement de Signalements serait :
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::111122223333:oidc-provider/token.actions.githubusercontent.com"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
"token.actions.githubusercontent.com:sub": "repo:lyneko-formation/signalements:environment:production"
}
}
}
]
}Et le job utilise l'action d'AWS, qui fait l'échange :
permissions:
contents: read
id-token: write
steps:
- uses: aws-actions/configure-aws-credentials@<empreinte>
with:
role-to-assume: arn:aws:iam::111122223333:role/signalements-production
aws-region: eu-west-3Azure (une identité fédérée sur une application Entra ID, audience api://AzureADTokenExchange) et Google Cloud (un pool de fédération d'identité de charge de travail, avec une condition sur assertion.sub ou assertion.repository_id) suivent le même schéma. Pour les clients de Lyneko hébergés chez ces fournisseurs, il n'y a aucune raison de stocker une clé dans GitHub.
Chez Scaleway : pas de fédération
On peut le constater depuis la ligne de commande : les espaces de commandes du service IAM ne contiennent ni fournisseur OIDC ni politique de confiance pour une charge de travail.
$ scw iam --help
...
AVAILABLE COMMANDS:
api-key API keys management commands
application Applications management commands
group Groups management commands
jwt JWTs management commands
log Log management commands
organization Organization-wide management commands
permission-set Permission sets management commands
policy Policies management commands
rule Rules management commands
saml SAML management commands
saml-certificates SAML Certificates management commands
scim SCIM management commands
scim-tokens SCIM tokens management commands
security-settings Security settings management commands
ssh-key SSH keys management commands
user Users management commands
$ scw iam jwt --help
JWTs management commands.
...
AVAILABLE COMMANDS:
delete Delete a JWT
get Get a JWT
list List JWTs
Les commandes jwt gèrent les jetons de session des utilisateurs de la console ; saml et scim concernent la connexion des personnes par un fournisseur d'identité d'entreprise. Rien ne permet de dire « fais confiance aux jetons de GitHub pour ce dépôt ». (Ce constat date du 1er octobre 2026, avec la version 2.62.0 de la ligne de commande ; vérifiez-le à nouveau, l'offre évolue.)
Deux voies restent possibles :
- Une clé d'API dédiée et courte, comme à la leçon précédente, avec une expiration plus proche qu'un an, au prix d'une rotation plus fréquente (que l'on peut automatiser depuis un poste ou un service de confiance).
- Un intermédiaire qui, lui, fédère : un coffre de secrets qui accepte les jetons OIDC de GitHub, et qui délivre la clé Scaleway seulement au job autorisé. La clé existe toujours, mais elle ne quitte plus le coffre que pour le bon job, pour quelques minutes, avec une trace d'audit, et elle n'est plus stockée dans GitHub.
Un intermédiaire : OpenBao
OpenBao est le fork libre de Vault, maintenu sous l'égide de la Linux Foundation. Sa méthode d'authentification JWT sait valider les jetons de GitHub. La confiance envers GitHub se configure en une commande, qui donne seulement l'adresse de l'émetteur : OpenBao récupère le document de découverte et les clés lui-même. On l'essaie avec un serveur de développement local :
$ docker run -d --name bao-cours4 -p 127.0.0.1:8200:8200 -e BAO_DEV_ROOT_TOKEN_ID=racine-demo \
-v "$PWD":/oidc:ro openbao/openbao:latest server -dev -dev-listen-address=0.0.0.0:8200 # en production, épinglez la version
$ bao version
OpenBao v2.7.1 (a5db72cef75c24b920ade02065b18dd8eb666bac), committed 2026-10-01T15:32:54Z
$ bao auth enable -path=github jwt
Success! Enabled jwt auth method at: github/
$ bao write auth/github/config oidc_discovery_url=https://token.actions.githubusercontent.com \
bound_issuer=https://token.actions.githubusercontent.com
Success! Data written to: auth/github/config
(Les commandes bao sont exécutées dans le conteneur, avec BAO_ADDR et BAO_TOKEN positionnés.) OpenBao a accepté la configuration après avoir lu le document de découverte de GitHub : il est prêt à valider de vrais jetons.
Tester la politique sans run réel
Pour éprouver une politique de confiance, il faut des jetons, et on ne veut pas dépendre de runs réels pour chaque essai (ni, dans le cadre de ce cours, en déclencher). On monte donc à côté une seconde méthode JWT, demo, qui fait confiance à une clé locale, et un petit programme qui signe avec cette clé des jetons ayant exactement la forme de ceux de GitHub, mêmes revendications, mêmes règles de sujet :
"""Émetteur de démonstration : signe des jetons qui ont la forme de ceux de GitHub.
Ce ne sont PAS des jetons GitHub : la clé est générée localement. Ils servent à
tester une politique de confiance sans dépendre d'un vrai run.
Usage : python emetteur.py cle -> crée cle.pem et cle-publique.pem
python emetteur.py jeton <ref> [aud] [age_en_secondes] [environnement]
"""
import json
import sys
import time
import uuid
import jwt
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa
if sys.argv[1] == "cle":
cle = rsa.generate_private_key(public_exponent=65537, key_size=2048)
with open("cle.pem", "wb") as f:
f.write(cle.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption()))
with open("cle-publique.pem", "wb") as f:
f.write(cle.public_key().public_bytes(serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo))
sys.exit(0)
ref = sys.argv[2]
aud = sys.argv[3] if len(sys.argv) > 3 else "https://bao.lyneko.example"
age = int(sys.argv[4]) if len(sys.argv) > 4 else 0
environnement = sys.argv[5] if len(sys.argv) > 5 else None
maintenant = int(time.time()) - age
proprietaire, depot = "lyneko-formation", "signalements"
if environnement:
sujet = f"repo:{proprietaire}/{depot}:environment:{environnement}"
elif ref.startswith("refs/pull/"):
sujet = f"repo:{proprietaire}/{depot}:pull_request"
else:
sujet = f"repo:{proprietaire}/{depot}:ref:{ref}"
revendications = {
"iss": "https://token.actions.githubusercontent.com",
"aud": aud,
"sub": sujet,
"jti": str(uuid.uuid4()),
"iat": maintenant, "nbf": maintenant - 5, "exp": maintenant + 300,
"repository": f"{proprietaire}/{depot}",
"repository_id": "905117331",
"repository_owner": proprietaire,
"repository_owner_id": "201445879",
"ref": ref,
"ref_type": "tag" if ref.startswith("refs/tags/") else "branch",
"event_name": "push",
"workflow_ref": f"{proprietaire}/{depot}/.github/workflows/livraison.yml@{ref}",
"job_workflow_ref": f"{proprietaire}/{depot}/.github/workflows/livraison.yml@{ref}",
"runner_environment": "github-hosted",
}
if environnement:
revendications["environment"] = environnement
cle = open("cle.pem", "rb").read()
print(jwt.encode(revendications, cle, algorithm="RS256", headers={"kid": "demo"}))Les identifiants numériques de ce programme sont fictifs, et la durée de validité de cinq minutes est un choix de la démonstration. Le corps d'un jeton produit pour le job de production :
$ python emetteur.py cle
$ python emetteur.py jeton refs/tags/v1.2.0 https://bao.lyneko.example 0 production | cut -d. -f2 |
python3 -c 'import base64,sys,json; s=sys.stdin.read().strip(); s+="="*(-len(s)%4); print(json.dumps(json.loads(base64.urlsafe_b64decode(s)), indent=2))'
{
"iss": "https://token.actions.githubusercontent.com",
"aud": "https://bao.lyneko.example",
"sub": "repo:lyneko-formation/signalements:environment:production",
"jti": "35a937d8-7a6f-4730-89e1-0d03893b15da",
"iat": 1790885886,
"nbf": 1790885881,
"exp": 1790886186,
"repository": "lyneko-formation/signalements",
"repository_id": "905117331",
"repository_owner": "lyneko-formation",
"repository_owner_id": "201445879",
"ref": "refs/tags/v1.2.0",
"ref_type": "tag",
"event_name": "push",
"workflow_ref": "lyneko-formation/signalements/.github/workflows/livraison.yml@refs/tags/v1.2.0",
"job_workflow_ref": "lyneko-formation/signalements/.github/workflows/livraison.yml@refs/tags/v1.2.0",
"runner_environment": "github-hosted",
"environment": "production"
}
(Le corps d'un JWT est encodé en base64 « URL » sans rembourrage, d'où les deux lignes de Python plutôt qu'un simple base64 -d.)
La politique OpenBao : un rôle qui n'accepte que le job de l'environnement production de ce dépôt, identifié par son numéro, et une politique d'accès qui ne lit qu'un seul secret.
{
"role_type": "jwt",
"user_claim": "sub",
"bound_audiences": ["https://bao.lyneko.example"],
"bound_claims_type": "string",
"bound_claims": {
"repository_id": "905117331",
"environment": "production"
},
"bound_subject": "repo:lyneko-formation/signalements:environment:production",
"token_policies": ["signalements-production"],
"token_ttl": "10m",
"token_max_ttl": "15m"
}# Lecture seule du secret de production de Signalements, et de rien d'autre.
path "secret/data/signalements/production" {
capabilities = ["read"]
}$ bao auth enable -path=demo jwt
Success! Enabled jwt auth method at: demo/
$ bao write auth/demo/config jwt_validation_pubkeys=@/oidc/cle-publique.pem \
bound_issuer=https://token.actions.githubusercontent.com
Success! Data written to: auth/demo/config
$ bao policy write signalements-production /oidc/politique.hcl
Success! Uploaded policy: signalements-production
$ bao write auth/demo/role/signalements-production @/oidc/role.json
Success! Data written to: auth/demo/role/signalements-production
$ bao kv put -mount=secret signalements/production SCW_SECRET_KEY=valeur-de-demonstration
=========== Secret Path ===========
secret/data/signalements/production
...
Le rôle combine trois vérifications : l'audience (le jeton a été demandé pour ce coffre, pas pour un autre service), le sujet (l'environnement production de ce dépôt), et deux revendications numériques qui ne dépendent d'aucun nom. Le jeton délivré par OpenBao vit dix minutes au plus.
Le script essais.sh présente cinq jetons au coffre et affiche sa réponse :
$ ./essais.sh
--- job de l'environnement production
accepté : politiques ["default","signalements-production"], durée 600 s
valeur-de-demonstration
Code: 403. Errors: * permission denied
--- job de main, sans environnement
Code: 400. Errors:
* error validating token: invalid subject (sub) claim
--- demande de fusion
Code: 400. Errors:
* error validating token: invalid subject (sub) claim
--- mauvaise audience
Code: 400. Errors:
* error validating token: invalid audience (aud) claim: audience claim does not match any expected audience
--- jeton expiré (émis il y a 10 min)
Code: 400. Errors:
* error validating token: invalid expiration time (exp) claim: token is expired
Le job de production obtient un jeton de dix minutes, lit son secret, et se voit refuser la lecture d'un autre (signalements/recette, le 403 de la quatrième ligne). Le job de main sans environnement et la demande de fusion sont refusés sur le sujet ; un jeton demandé pour une autre audience, ou trop vieux, aussi. Cette batterie se rejoue en quelques secondes à chaque modification de la politique : c'est le test unitaire de la confiance.
Le côté workflow
Le job de production obtient le jeton OIDC auprès du runner, l'échange contre un jeton OpenBao, lit la clé Scaleway et la rend disponible, masquée, aux étapes suivantes. Le corps de l'étape, ci/secrets-oidc.sh :
#!/usr/bin/env bash
# Corps de l'étape « Obtenir les secrets de production » du workflow.
set -euo pipefail
reponse=$(curl -sSf -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
"$ACTIONS_ID_TOKEN_REQUEST_URL&audience=$AUDIENCE")
jeton_oidc=$(jq -r .value <<<"$reponse")
echo "::add-mask::$jeton_oidc"
jeton_bao=$(jq -n --arg role "$ROLE" --arg jwt "$jeton_oidc" '{role: $role, jwt: $jwt}' |
curl -sSf --data @- "$BAO_ADDR/v1/auth/$MONTAGE/login" | jq -r .auth.client_token)
echo "::add-mask::$jeton_bao"
cle=$(curl -sSf -H "X-Vault-Token: $jeton_bao" "$BAO_ADDR/v1/secret/data/signalements/production" |
jq -r .data.data.SCW_SECRET_KEY)
echo "::add-mask::$cle"
echo "SCW_SECRET_KEY=$cle" >> "$GITHUB_ENV"
echo "secret de production obtenu (jeton OpenBao valable 10 minutes)"Le job qui l'utilise est illustratif : il ne fait pas partie du livraison.yml final du cours, dont le job de production écrit une étiquette dans Git et n'a pas besoin de clé Scaleway. Il représenterait, par exemple, la copie de l'image vers le registre Scaleway de Lyneko lors d'une mise en production :
publier-scaleway:
runs-on: ubuntu-24.04
environment: production
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- name: Obtenir les secrets de production
env:
AUDIENCE: https://bao.lyneko.example
ROLE: signalements-production
BAO_ADDR: ${{ vars.BAO_ADDR }}
MONTAGE: github
run: ci/secrets-oidc.sh
- name: Se connecter au registre Scaleway
run: echo "$SCW_SECRET_KEY" | docker login rg.fr-par.scw.cloud -u nologin --password-stdinLe script a été exécuté localement de bout en bout, avec un petit serveur qui imite le point d'accès du runner (il répond {"value": "<jeton>"} à la requête authentifiée, avec un jeton de démonstration) et le coffre OpenBao configuré plus haut :
$ ACTIONS_ID_TOKEN_REQUEST_TOKEN=jeton-de-requete-demo \
ACTIONS_ID_TOKEN_REQUEST_URL="http://127.0.0.1:8201/token?api-version=2.0" \
AUDIENCE=https://bao.lyneko.example ROLE=signalements-production \
BAO_ADDR=http://127.0.0.1:8200 MONTAGE=demo GITHUB_ENV=env-demo \
bash --noprofile --norc -eo pipefail ci/secrets-oidc.sh
::add-mask::eyJhbGciOiJS...
::add-mask::s.BWrZgo5quq...
::add-mask::valeur-de-de...
secret de production obtenu (jeton OpenBao valable 10 minutes)
$ cat env-demo
SCW_SECRET_KEY=valeur-de-demonstration
(Les valeurs masquées sont tronquées à l'affichage.) Sur GitHub, les trois commandes ::add-mask:: disparaissent du journal et déclarent ces valeurs comme secrètes pour le reste du job. Le montage github remplace demo, et BAO_ADDR désigne un OpenBao joignable par les runners.
Dans cette architecture, GitHub ne contient plus aucun secret : ni la clé Scaleway, ni l'accès au coffre. Une branche malveillante qui ajoute environment: production à son job est arrêtée par la règle de branches de l'environnement (leçon 9) ; un job qui l'omet n'a pas le bon sujet et OpenBao le refuse. La clé Scaleway, elle, existe toujours dans le coffre : l'intermédiaire réduit son exposition, il ne la supprime pas.
Sous le capot
Quand un job a id-token: write, le runner reçoit avec le message de job une adresse de service et un jeton de requête, qu'il expose dans ACTIONS_ID_TOKEN_REQUEST_URL et ACTIONS_ID_TOKEN_REQUEST_TOKEN. Le job appelle cette adresse en ajoutant le paramètre audience ; le service d'Actions construit le JWT à partir de ce qu'il sait du job (dépôt, référence, environnement, workflow), le signe avec la clé privée correspondant à l'un des identifiants publiés dans le JWKS, et le renvoie dans un objet JSON ({"count": ..., "value": "<jeton>"}). La méthode core.getIDToken(audience) de @actions/core fait exactement cette requête.
Côté fournisseur, la vérification suit toujours le même ordre : choisir la clé publique par le champ kid de l'en-tête, vérifier la signature, puis iss, aud, exp et nbf, puis les conditions propres au rôle. Une erreur sur l'une des étapes produit un message qui nomme la revendication fautive, comme dans les essais d'OpenBao : c'est la première chose à lire quand une fédération refuse un job.
Le jeton OIDC est une preuve d'identité au porteur : quiconque le détient avant son expiration peut l'utiliser auprès du fournisseur pour l'audience prévue. C'est ce qui s'est produit lors de la compromission de TanStack en mai 2026 : selon le compte rendu du projet, le code malveillant, exécuté dans le workflow de publication, a lu le jeton OIDC dans la mémoire du runner et s'en est servi pour publier directement sur npm. La fédération supprime le secret de longue durée ; elle ne protège pas un job dans lequel un attaquant exécute déjà du code.
Pièges courants
Unable to get ACTIONS_ID_TOKEN_REQUEST_URL env variable. Le job n'a pas id-token: write. Attention, déclarer une seule permission au niveau du job ramène toutes les autres à none : id-token: write seul retire contents: read, et checkout échoue sur un dépôt privé.
invalid subject (sub) claim alors que le dépôt et la branche sont bons. Le job déclare un environnement : le sujet est ...:environment:<nom>, pas ...:ref:.... Ou le dépôt utilise le format immuable (créé, renommé ou transféré après le 15 juillet 2026) et la politique attend l'ancien.
invalid audience (aud) claim. L'audience demandée par le job ne correspond pas à celle qu'attend le fournisseur. Chaque fournisseur a sa valeur (sts.amazonaws.com, api://AzureADTokenExchange, l'adresse du coffre) ; les actions officielles la positionnent seules, un script doit la passer explicitement.
La fédération fonctionnait, puis tout est refusé après un renommage. Le renommage a fait passer le dépôt au format de sujet immuable. Les politiques fondées sur repository_id ne sont pas touchées ; celles fondées sur le sujet par noms doivent être mises à jour. Le même changement a cassé la publication de confiance (trusted publishing) de npm pour les dépôts au format immuable : un ticket est ouvert chez npm au moment de la rédaction.
Une politique accepte des jobs qu'elle ne devrait pas. Un motif de sujet trop large (repo:lyneko-team/*), ou aucune condition au-delà de l'audience. GitHub l'écrit en toutes lettres : une politique doit avoir au moins une condition, faute de quoi n'importe quel dépôt GitHub peut obtenir l'accès.
Sécurité
Conditionner sur des identifiants, pas seulement sur des noms. repository_id et repository_owner_id ne changent jamais ; un nom peut être repris. Le format de sujet immuable règle le problème pour les nouveaux dépôts ; pour les anciens, ajoutez les revendications numériques aux conditions, ou adhérez au format immuable.
Le moins de jobs possible. Un rôle par environnement et par usage. Le rôle de production n'accepte que le sujet de l'environnement production ; la recette a son propre rôle, avec ses propres droits.
Combiner avec les environnements. L'environnement apporte les règles de branches et les approbations ; la fédération apporte l'absence de secret. Ensemble, ils garantissent que seul un job approuvé, sur une référence autorisée, obtient un accès, et seulement pour quelques minutes.
Les workflows réutilisables comme point de contrôle. Avec job_workflow_ref, une politique peut exiger que le jeton vienne d'un workflow réutilisable précis, à une version précise : les équipes appellent le workflow central de déploiement, et lui seul peut obtenir les accès de production.
Ne pas élargir id-token: write. Accordez-la au seul job qui en a besoin, jamais au niveau du workflow : tout job qui l'a peut demander un jeton pour n'importe quelle audience.
En production
Chez AWS, Azure et Google Cloud, la fédération est la norme : aucun secret de fournisseur dans GitHub. Les modules Terraform des fournisseurs savent créer le fournisseur d'identité et les rôles ; les politiques de confiance se relisent comme du code.
Chez Scaleway, en attendant une fédération native, deux options : des clés d'API dédiées, de portée minimale, à expiration courte et renouvelées automatiquement ; ou un coffre OpenBao fédéré avec GitHub, qui centralise les clés, journalise chaque lecture, et ne les délivre qu'aux jobs autorisés. Le second a un coût d'exploitation (un service à haute disponibilité, critique pour tous les déploiements) qui se justifie à partir de plusieurs applications ou de plusieurs fournisseurs.
Les attestations de la leçon 6 reposent sur le même jeton : actions/attest présente le jeton OIDC du job à Sigstore, qui délivre un certificat de signature éphémère portant l'identité du workflow. Comprendre le jeton, c'est comprendre ce que l'attestation prouve.
Observer. Les fournisseurs journalisent chaque échange de jeton (CloudTrail chez AWS, les journaux d'audit d'OpenBao) : on y voit quel dépôt, quelle référence, quel run a obtenu un accès. C'est la trace à consulter après un incident.
Exercices
1. Quel est le sujet du jeton dans chacun de ces cas, pour le dépôt lyneko-formation/signalements créé en 2025 ? (a) un job sans environnement, sur une poussée de main ; (b) le même job, déclenché par une demande de fusion ; (c) un job de l'environnement recette, sur une poussée de main ; (d) le cas (c), après le renommage du dépôt en signalements-api en septembre 2026.
Solution
(a) repo:lyneko-formation/signalements:ref:refs/heads/main. (b) repo:lyneko-formation/signalements:pull_request. (c) repo:lyneko-formation/signalements:environment:recette : l'environnement l'emporte sur la référence. (d) Le renommage, postérieur au 15 juillet 2026, fait passer le dépôt au format immuable : repo:lyneko-formation@<id propriétaire>/signalements-api@<id dépôt>:environment:recette.
2. Relisez cette condition de politique de confiance AWS et dites ce qu'elle autorise réellement.
"Condition": {
"StringLike": {
"token.actions.githubusercontent.com:sub": "repo:lyneko-formation/*"
}
}Solution
Tout job de tout dépôt de l'organisation, sur n'importe quelle branche, étiquette, demande de fusion ou environnement : un dépôt de test, une branche expérimentale, une demande de fusion d'un collaborateur peuvent prendre le rôle. L'audience n'est même pas vérifiée. Une politique de production doit au minimum fixer aud, un sujet exact (...:environment:production), et de préférence le repository_id.
3. Ajoutez à essais.sh deux cas : un jeton du bon environnement mais d'un autre dépôt (repository_id différent), et un jeton dont l'environnement s'appelle Production avec une majuscule. Prévoyez la réponse d'OpenBao avant d'exécuter.
Solution
Il faut rendre repository_id et le nom de dépôt paramétrables dans emetteur.py. Le premier jeton est refusé : sa revendication repository_id ne correspond pas (bound_claims), et si le dépôt porte un autre nom, son sujet non plus (bound_subject). Le cas le plus instructif est celui d'un dépôt recréé sous le même nom (après suppression, ou par un autre propriétaire qui a repris le nom) : le sujet par noms correspond, et seule la vérification de repository_id le rejette. C'est pour ce cas que la politique la contient. Le second est refusé aussi : bound_subject et bound_claims comparent les chaînes exactement, Production n'est pas production. Écrivez le nom de l'environnement de la même façon partout, dans le workflow comme dans les politiques.
4. Un collègue propose, pour éviter OpenBao, de stocker la clé Scaleway dans un secret d'environnement production avec une expiration d'un mois et un renouvellement manuel. Comparez avec la solution OpenBao sur quatre critères : exposition de la clé, effort d'exploitation, traçabilité, comportement en cas de fuite d'un journal.
Solution
Exposition : le secret d'environnement n'est remis qu'aux jobs autorisés, comme avec OpenBao, mais la clé est stockée dans GitHub ; avec OpenBao, elle n'est stockée que dans le coffre. Effort : un renouvellement mensuel manuel est simple mais s'oublie ; OpenBao demande d'exploiter un service critique. Traçabilité : GitHub trace les déploiements, pas les lectures du secret ; OpenBao journalise chaque lecture avec l'identité du job. Fuite d'un journal : dans les deux cas la clé Scaleway reste valide jusqu'à son expiration (un mois au plus d'un côté ; de l'autre, sa durée de vie dans le coffre, que l'on peut réduire en la renouvelant souvent sans toucher à GitHub). Pour une seule application, la solution du collègue est raisonnable ; à partir de plusieurs applications et fournisseurs, le coffre l'emporte.
5. Pourquoi la politique OpenBao vérifie-t-elle l'audience, alors que le sujet suffit à identifier le job ?
Solution
Parce qu'un jeton OIDC est un jeton au porteur. Sans vérification d'audience, un jeton que le job a demandé pour un autre service (un outil tiers de confiance moyenne, par exemple) pourrait être rejoué contre le coffre par ce service, ou par quiconque l'intercepte chez lui. L'audience lie le jeton à son destinataire : un jeton demandé pour https://autre.example est refusé par le coffre, comme dans le quatrième essai.
Récapitulatif
- La fédération OIDC remplace la clé de longue durée par une preuve d'identité signée par GitHub, échangée contre un identifiant de quelques minutes.
- Le jeton s'obtient avec
id-token: write; il porteiss,aud,subet des revendications détaillées (repository_id,environment,job_workflow_ref...). - Le sujet privilégie l'environnement sur la référence ; le format immuable (dépôts créés, renommés ou transférés après le 15 juillet 2026) ajoute les identifiants numériques.
- Une politique de confiance vérifie toujours l'audience, un sujet exact, et de préférence les identifiants numériques.
- Scaleway n'offre pas de fédération (octobre 2026) : clés dédiées et courtes, ou intermédiaire OpenBao fédéré avec GitHub.
- Une politique se teste sans run réel, avec des jetons de démonstration signés localement.
- La fédération supprime le secret, pas le risque d'un job compromis : le jeton reste un jeton au porteur.
Pour aller plus loin
- GitHub Docs, OpenID Connect reference : toutes les revendications et la personnalisation du sujet.
- OpenBao, méthode JWT/OIDC : rôles, revendications liées, durées.
- github/actions-oidc-debugger : une action qui affiche les revendications réelles d'un job, pour mettre au point une politique.
- Leçon suivante : Sécuriser ses workflows.
Sources
- GitHub Docs, OpenID Connect reference (revendications, sujets, sujet immuable, permissions)
- GitHub, document de découverte OIDC des Actions
- GitHub Changelog, Immutable subject claims for GitHub Actions OIDC tokens (23 avril 2026)
- OpenID Connect Core 1.0, ID Token
- RFC 7519, JSON Web Token
- OpenBao, méthode d'authentification JWT/OIDC
- GitHub Docs, Configuring OpenID Connect in Amazon Web Services
- Scaleway, documentation IAM (clés d'API, applications, fédération d'identité des utilisateurs)
- TanStack, npm supply chain compromise postmortem (vol du jeton OIDC en mémoire, mai 2026)