Aller au contenu
ecdsa.com
← Tous les articles

Validation du jeton d’ID OIDC, étape par étape

Un jeton d’ID est un JWT qui obéit à ses propres règles. Treize étapes numérotées de la spécification déterminent si vous avez réellement authentifié un utilisateur ou simplement analysé un blob signé. Voici chaque étape, un validateur qui les implémente, et ce qu’il a intercepté face à de vrais jetons émis par un realm Keycloak.

En bref. Pour chaque jeton d’ID OpenID Connect : vérifiez la signature avec une clé issue du jwks_uri du fournisseur et uniquement avec l’algorithme que vous avez enregistré (RS256 par défaut) ; exigez que iss soit exactement égal à l’issuer du document de découverte ; exigez que aud contienne votre client_id et aucune valeur à laquelle vous ne faites pas confiance ; si azp est présent, il doit désigner l’un de vos propres clients ; exigez un exp situé dans le futur ; et comparez nonce à la valeur que vous avez stockée pour cette tentative de connexion. Vérifiez at_hash et c_hash lorsque le jeton accompagne un jeton d’accès ou un code. Identifiez ensuite l’utilisateur par la paire iss et sub, jamais par email.

Les treize étapes

La section OpenID Connect Core 1.0 §3.1.3.7, dans sa version actuelle qui intègre le jeu d’errata 2 (décembre 2023), énumère les vérifications qu’un client effectue sur un jeton d’ID reçu du point de terminaison de jeton. En résumé :

ÉtapeVérificationQui s’en charge d’habitude
1Déchiffrement, si un chiffrement a été enregistréla bibliothèque
2iss est exactement égal à l’émetteur issu de la découvertela bibliothèque, si vous lui passez l’émetteur
3aud contient votre client_id, et aucune audience à laquelle vous ne faites pas confiancela bibliothèque fait la première moitié ; la seconde vous revient souvent
4, 5azp, s’il est présent, est validé (ce qui peut inclure : il est égal à votre client_id)cela dépend
6Signature, avec les clés de l’émetteur ; facultative uniquement pour un jeton reçu directement du point de terminaison de jeton via TLSla bibliothèque
7alg vaut RS256 ou l’id_token_signed_response_alg que vous avez enregistréla bibliothèque, si vous l’épinglez
8Pour HS256/384/512, la clé est votre client_secretla bibliothèque
9L’heure actuelle est antérieure à expla bibliothèque
10iat n’est pas trop éloigné de l’instant présentvous
11nonce est égal à la valeur envoyée dans la requêtevous, ou une bibliothèque qui accepte le nonce attendu
12acr est acceptable, s’il a été demandévous
13auth_time est assez récent, s’il a été demandé ou si max_age a été envoyévous

Deux vérifications connexes figurent dans d’autres sections : at_hash lie le jeton d’accès au jeton d’ID (§3.1.3.8, facultatif dans le flux par code), et c_hash lie le code d’autorisation à un jeton d’ID renvoyé par le point de terminaison d’autorisation dans le flux hybride (§3.3.2.10). L’un comme l’autre est la moitié gauche du condensat du jeton ou du code, encodée en base64url et calculée avec la fonction de hachage de l’alg du jeton d’ID : pour RS256, les 128 premiers bits de SHA-256.

Un validateur qui les implémente

Le validateur ci-dessous s’appuie sur jose pour ce qu’une bibliothèque JWT fait bien (signature, alg, iss, inclusion dans aud, exp) et ajoute les étapes dont une bibliothèque JWT ne peut rien savoir, parce qu’elles dépendent de la tentative de connexion ou de l’enregistrement de votre client.

validate-id-token.mjs (jose 6.2.13, Node.js 24.22)
import { createHash } from "node:crypto";
import { jwtVerify } from "jose";

// at_hash and c_hash: the left half of the hash of the ASCII value, base64url-encoded,
// with the hash function of the ID token's alg (OIDC Core 3.1.3.6 and 3.3.2.11).
const HASH = { RS256: "sha256", PS256: "sha256", ES256: "sha256", RS384: "sha384", PS384: "sha384", ES384: "sha384", RS512: "sha512", PS512: "sha512", ES512: "sha512" };
export function halfHash(value, alg) {
  const digest = createHash(HASH[alg]).update(value, "ascii").digest();
  return digest.subarray(0, digest.length / 2).toString("base64url");
}

/**
 * Validates an ID token as OpenID Connect Core 1.0 §3.1.3.7 describes.
 * rp:       { issuer, jwks, clientId, alg, trustedAudiences, trustedPresenters, maxIatAgeSeconds }
 * expected: { nonce, maxAge?, accessToken?, code? } from this login's stored state and the response
 */
export async function validateIdToken(idToken, rp, expected) {
  // Steps 2, 3 (aud contains client_id), 6, 7 and 9: issuer, audience, signature, alg, expiry.
  const { payload: claims, protectedHeader } = await jwtVerify(idToken, rp.jwks, {
    issuer: rp.issuer, // exact string from the discovery document
    audience: rp.clientId,
    algorithms: [rp.alg], // RS256 unless id_token_signed_response_alg says otherwise
    requiredClaims: ["iss", "sub", "aud", "exp", "iat"],
    clockTolerance: 30,
  });
  const now = Math.floor(Date.now() / 1000);
  const fail = (step, why) => {
    throw new Error(`step ${step}: ${why}`);
  };

  // Step 3, second half: reject audiences this client does not trust.
  const untrusted = [claims.aud].flat().filter((a) => a !== rp.clientId && !rp.trustedAudiences.includes(a));
  if (untrusted.length > 0) fail(3, `untrusted audience ${untrusted.join(", ")}`);
  // Steps 4-5: an azp, when present, must name a client of this application.
  if (claims.azp !== undefined && ![rp.clientId, ...rp.trustedPresenters].includes(claims.azp)) {
    fail(5, `azp is ${claims.azp}`);
  }
  // Step 10: iat bounds how long nonces must be remembered.
  if (now - claims.iat > rp.maxIatAgeSeconds) fail(10, `issued ${now - claims.iat} s ago`);
  // Step 11: the nonce stored with this login attempt, compared exactly.
  if (claims.nonce !== expected.nonce) fail(11, `nonce ${claims.nonce} does not match the stored value`);
  // Step 13: when max_age was sent, auth_time is required and bounded.
  if (expected.maxAge !== undefined) {
    if (typeof claims.auth_time !== "number") fail(13, "auth_time missing");
    if (now - claims.auth_time > expected.maxAge + 30) fail(13, "authentication too old");
  }
  // Hashes: tie the access token (at_hash) and the code (c_hash) to this ID token.
  if (expected.accessToken !== undefined && claims.at_hash !== undefined) {
    if (claims.at_hash !== halfHash(expected.accessToken, protectedHeader.alg)) fail("3.1.3.8", "at_hash mismatch");
  }
  if (expected.code !== undefined) {
    if (claims.c_hash !== halfHash(expected.code, protectedHeader.alg)) fail("3.3.2.10", "c_hash missing or mismatch");
  }
  return claims;
}

rp.jwks vaut createRemoteJWKSet(new URL(discovery.jwks_uri)), créé une seule fois par émetteur. L’étape 1 est absente parce que le banc d’essai ne chiffre pas les jetons d’ID, et les étapes 8 et 12 parce qu’il utilise RS256 et ne demande pas acr.

Testé sur de vrais jetons

Pour tester le validateur sur des jetons émis par un vrai fournisseur plutôt que fabriqués à la main, le banc d’essai faisait tourner Keycloak 26.8.1 (quay.io/keycloak/keycloak:26.8.1, en mode développement) avec un realm contenant un utilisateur et deux clients confidentiels : web-app, la partie de confiance (relying party, RP) à protéger, et other-app, une autre application du même fournisseur, configurée avec un mappeur d’audience pour que ses jetons d’ID mentionnent aussi web-app. Un petit script de test jouait le rôle du navigateur : il ouvrait l’URL d’autorisation avec un state, un nonce et un challenge PKCE neufs, soumettait le formulaire de connexion, récupérait le code dans la redirection et l’échangeait auprès du point de terminaison de jeton. Chaque jeton d’ID passait ensuite par validateIdToken en tant que web-app :

Sortie : vraies connexions à Keycloak 26.8.1, validées en tant que web-app (node:24.22.0-bookworm-slim)
issuer http://127.0.0.1:8080/realms/demo
code flow callback parameters: state, session_state, iss, code
ID token header: alg RS256, typ JWT, kid present: true
ID token claims: exp, iat, auth_time, jti, iss, aud, sub, typ, azp, nonce, sid, at_hash, acr, email_verified, name, preferred_username, given_name, family_name, email
aud "web-app", azp web-app, at_hash present
code flow ID token                                   valid
hybrid callback parameters: state, session_state, iss, code, id_token
front-channel ID token: c_hash present, at_hash absent
hybrid front-channel ID token, with its code         valid
same ID token, code from the first login             rejected: step 3.3.2.10: c_hash missing or mismatch
other-app ID token: aud ["other-app","web-app"], azp other-app
other-app token, aud-contains check only             valid
other-app token, full validation                     rejected: step 3: untrusted audience other-app
first ID token, nonce of the second login            rejected: step 11: nonce lsamKJG6vC-xAIOzo1LIrw does not match the stored value
first ID token, access token of the second login     rejected: step 3.1.3.8: at_hash mismatch

Ce que chaque rejet empêche :

  • Un jeton d’ID émis pour une autre application. Le jeton de other-app est signé par le même fournisseur et mentionne web-app dans aud. Une vérification qui se contente de demander « aud contient-il mon ID client ? », ce que font la plupart des bibliothèques JWT, l’a accepté. L’étape 3 demande de rejeter les audiences auxquelles vous ne faites pas confiance, et l’étape 5 l’aurait intercepté elle aussi : son azp vaut other-app. Sans l’une ni l’autre de ces vérifications, n’importe quelle application enregistrée auprès du même fournisseur, y compris une application enregistrée par un attaquant, peut obtenir des jetons qui connectent des utilisateurs à la vôtre.
  • Un jeton issu d’une autre tentative de connexion. La vérification du nonce a rejeté un jeton d’ID valide, non expiré et correctement signé, parce qu’il appartenait à une autre requête d’autorisation. C’est ce qui empêche un attaquant d’injecter dans la connexion d’une victime un jeton ou un code qu’il a obtenu dans sa propre session (étape 11), et c’est pourquoi le nonce doit être stocké pour chaque tentative de connexion, généralement dans la même session côté serveur ou le même cookie chiffré que state.
  • Artefacts intervertis. at_hash et c_hash lient le jeton d’accès et le code au jeton d’ID. Dans le flux hybride, le jeton d’ID du canal frontal (front channel) transite par le navigateur, et c’est c_hash qui prouve que le code qui l’accompagne est bien celui que le fournisseur a émis avec lui.

Remarquez le paramètre iss dans les deux callbacks. Keycloak l’ajoute à la réponse d’autorisation (RFC 9207), ce qui permet à un client qui dialogue avec plusieurs fournisseurs de détecter une réponse venant du mauvais fournisseur avant d’échanger le code.

Ce que les bibliothèques font pour vous

Une bibliothèque cliente OpenID Connect certifiée effectue la plupart de ces étapes si vous lui fournissez les valeurs dont elle a besoin. openid-client en fait partie ; si vous lui passez le nonce attendu, elle le vérifie :

openid-client-flow.mjs (openid-client 6.8.8)
// The library route: openid-client 6.8.8 runs discovery, the code exchange and the ID token checks.
import * as client from "openid-client";
import { browserLogin } from "./login.mjs";

const issuer = new URL("http://127.0.0.1:8080/realms/demo");
// allowInsecureRequests only because the lab issuer is plain http; never in production.
const config = await client.discovery(issuer, "web-app", process.env.WEB, undefined, {
  execute: [client.allowInsecureRequests],
});

async function signIn(expectedNonceOverride) {
  const codeVerifier = client.randomPKCECodeVerifier();
  const state = client.randomState();
  const nonce = client.randomNonce();
  const url = client.buildAuthorizationUrl(config, {
    redirect_uri: "http://127.0.0.1:3000/callback",
    scope: "openid email",
    code_challenge: await client.calculatePKCECodeChallenge(codeVerifier),
    code_challenge_method: "S256",
    state,
    nonce,
  });
  const { location } = await browserLogin(url, "alice", process.env.PW);
  const tokens = await client.authorizationCodeGrant(config, new URL(location), {
    pkceCodeVerifier: codeVerifier,
    expectedState: state,
    expectedNonce: expectedNonceOverride ?? nonce,
    idTokenExpected: true,
  });
  return tokens.claims();
}

const claims = await signIn();
console.log(`signed in: sub ${claims.sub.slice(0, 8)}..., email ${claims.email}, nonce checked`);
try {
  await signIn(client.randomNonce());
} catch (e) {
  console.log(`wrong expectedNonce: ${e.name}: ${e.message}; cause: ${e.cause?.message}`);
}
Sortie
signed in: sub aa5e60df..., email alice@example.com, nonce checked
wrong expectedNonce: ClientError: unexpected JWT claim value encountered; cause: unexpected ID Token "nonce" claim value

browserLogin est le script du banc d’essai qui remplace le navigateur de l’utilisateur ; dans une application, l’URL d’autorisation est une redirection et location est l’URL de la requête de callback. La couche inférieure d’openid-client, oauth4webapi 3.8.8, applique aussi la règle du jeu d’errata 1 pour azp : lorsque aud a plus d’une valeur, azp doit être présent et égal à l’ID client.

go-oidc v3.21.0 est un vérificateur plutôt qu’un client complet, et sa documentation le dit clairement : « Verify does NOT do nonce validation, which is the caller’s responsibility. » Le script pilote du banc d’essai a écrit dans un fichier deux jetons neufs du même realm, le nonce qu’il avait envoyé, ainsi qu’un nonce et un jeton d’accès issus d’une autre connexion, et ce programme les a vérifiés :

main.go (go-oidc v3.21.0, Go 1.27.3)
package main

// What go-oidc's IDTokenVerifier checks on its own, against ID tokens from the lab Keycloak realm.

import (
	"context"
	"encoding/json"
	"fmt"
	"os"

	"github.com/coreos/go-oidc/v3/oidc"
)

func main() {
	var h struct {
		Issuer, IDToken, AccessToken, NonceSent, NonceOfAnotherLogin, OtherAppIDToken, OtherAccessToken string
	}
	data, _ := os.ReadFile("/node/handoff.json")
	if err := json.Unmarshal(data, &h); err != nil {
		panic(err)
	}
	ctx := context.Background()
	ctx = oidc.InsecureIssuerURLContext(ctx, h.Issuer) // only because the lab issuer is plain http
	provider, err := oidc.NewProvider(ctx, h.Issuer)
	if err != nil {
		panic(err)
	}
	verifier := provider.Verifier(&oidc.Config{ClientID: "web-app"})

	token, err := verifier.Verify(ctx, h.IDToken)
	fmt.Printf("%-44s err=%v\n", "Verify(web-app ID token)", err)
	fmt.Printf("%-44s %v\n", "  nonce equals the nonce we sent", token.Nonce == h.NonceSent)
	fmt.Printf("%-44s %v\n", "  nonce equals another login's nonce", token.Nonce == h.NonceOfAnotherLogin)
	fmt.Printf("%-44s err=%v\n", "  VerifyAccessToken(its access token)", token.VerifyAccessToken(h.AccessToken))
	fmt.Printf("%-44s err=%v\n", "  VerifyAccessToken(another access token)", token.VerifyAccessToken(h.OtherAccessToken))

	other, err := verifier.Verify(ctx, h.OtherAppIDToken)
	if err == nil {
		var c struct {
			Azp string `json:"azp"`
		}
		_ = other.Claims(&c)
		fmt.Printf("%-44s err=<nil>, aud=%v, azp=%s\n", "Verify(other-app ID token)", other.Audience, c.Azp)
	} else {
		fmt.Printf("%-44s err=%v\n", "Verify(other-app ID token)", err)
	}
}
Sortie (golang:1.27.3-trixie)
Verify(web-app ID token)                     err=<nil>
  nonce equals the nonce we sent             true
  nonce equals another login's nonce         false
  VerifyAccessToken(its access token)        err=<nil>
  VerifyAccessToken(another access token)    err=access token hash does not match value in ID token
Verify(other-app ID token)                   err=<nil>, aud=[other-app web-app], azp=other-app

Verify a accepté le premier jeton sans connaître aucun nonce ; la comparaison des deux lignes suivantes est faite par l’appelant. Il a aussi accepté le jeton émis pour other-app, parce que sa vérification de l’audience se limite à l’inclusion et qu’il ne regarde pas azp. Avec go-oidc, comparez idToken.Nonce à la valeur que vous avez stockée, appelez idToken.VerifyAccessToken lorsque vous utilisez le jeton d’accès, et vérifiez vous-même la liste des audiences et azp si votre fournisseur peut émettre des jetons à plusieurs audiences. Les erreurs que go-oidc lève bel et bien figurent dans le catalogue, par exemple expected audience et issuer did not match.

Écarts entre fournisseurs qui font échouer les validateurs

Ce qu’indiquaient les documents de découverte de quatre fournisseurs le 20 octobre 2026 :

discovery.py
# Prints issuer, jwks_uri and ID token signing algorithms from public OpenID Connect discovery documents.
import json
import urllib.request

URLS = [
    "https://accounts.google.com/.well-known/openid-configuration",
    "https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration",
    "https://login.microsoftonline.com/common/.well-known/openid-configuration",
    "https://samples.auth0.com/.well-known/openid-configuration",
    "https://appleid.apple.com/.well-known/openid-configuration",
]
for url in URLS:
    with urllib.request.urlopen(url, timeout=20) as response:
        doc = json.load(response)
    print(f"== {url}")
    print(f"   issuer   {doc['issuer']}")
    print(f"   jwks_uri {doc['jwks_uri']}")
    print(f"   id_token_signing_alg_values_supported {doc.get('id_token_signing_alg_values_supported')}")
Sortie (python:3.14.8-slim-trixie)
== https://accounts.google.com/.well-known/openid-configuration
   issuer   https://accounts.google.com
   jwks_uri https://www.googleapis.com/oauth2/v3/certs
   id_token_signing_alg_values_supported ['RS256']
== https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration
   issuer   https://login.microsoftonline.com/{tenantid}/v2.0
   jwks_uri https://login.microsoftonline.com/common/discovery/v2.0/keys
   id_token_signing_alg_values_supported ['RS256']
== https://login.microsoftonline.com/common/.well-known/openid-configuration
   issuer   https://sts.windows.net/{tenantid}/
   jwks_uri https://login.microsoftonline.com/common/discovery/keys
   id_token_signing_alg_values_supported ['RS256']
== https://samples.auth0.com/.well-known/openid-configuration
   issuer   https://samples.auth0.com/
   jwks_uri https://samples.auth0.com/.well-known/jwks.json
   id_token_signing_alg_values_supported ['HS256', 'RS256', 'PS256']
== https://appleid.apple.com/.well-known/openid-configuration
   issuer   https://appleid.apple.com
   jwks_uri https://appleid.apple.com/auth/keys
   id_token_signing_alg_values_supported ['RS256']

Microsoft Entra ID : deux versions de jeton et un émetteur modèle

  • La version dépend du point de terminaison. Les jetons d’ID émis par /oauth2/authorize sont en v1.0, avec iss https://sts.windows.net/{tid}/ ; ceux de /oauth2/v2.0/authorize sont en v2.0, avec iss https://login.microsoftonline.com/{tid}/v2.0 (référence des claims du jeton d’ID). Le claim ver indique laquelle. Un validateur configuré avec un format d’émetteur rejette l’autre, et le message .NET est IDX10205: Issuer validation failed. Pour les jetons d’accès, la version est choisie par la propriété requestedAccessTokenVersion de l’API dans le manifeste de l’application, et non par le point de terminaison.
  • {tenantid} n’est pas une valeur. Les documents de découverte multilocataires ci-dessus renvoient un émetteur contenant littéralement l’espace réservé {tenantid}. Une comparaison exacte avec cette chaîne n’aboutit jamais. Les recommandations de Microsoft sur les jetons d’accès demandent de remplacer l’espace réservé par le tid du jeton, de vérifier que tid est un GUID et que iss correspond exactement au modèle avec ce tid, et de vérifier la propriété issuer qu’Entra associe à chaque clé de son JWKS.
  • Restreignez vous-même les locataires. Une configuration multilocataire accepte les utilisateurs de tous les locataires Entra, y compris ceux qu’un attaquant crée. Si votre application sert des organisations précises, tenez une liste d’autorisation de valeurs tid. Les comptes Microsoft personnels ont le locataire fixe 9188040d-6c67-4c5b-b112-36a304b66dad.
  • Identifiez les utilisateurs par tid associé à oid ou à sub. Le même sub dans deux locataires correspond à deux utilisateurs différents.

Google : deux graphies de l’émetteur et azp pour Android

  • iss vaut soit https://accounts.google.com, soit accounts.google.com. La documentation OpenID Connect de Google indique les deux comme valides, alors que le document de découverte ne contient que la forme https://. Un validateur strict qui prend l’émetteur dans le document de découverte rejette les jetons portant la forme courte ; acceptez exactement ces deux chaînes (l’option issuer de jose accepte un tableau), et rien de plus permissif.
  • aud est votre ID client web, même quand c’est une application Android qui a fait la demande. Lorsqu’une application Android demande un jeton d’ID pour son backend, aud est l’ID client web du backend et azp l’ID client de l’application Android. Ajoutez vos ID client Android à la liste des présentateurs de confiance ; ne supprimez pas la vérification de azp.
  • hd restreint les domaines Workspace. Si seuls les utilisateurs de certains domaines Google Workspace peuvent se connecter, vérifiez le claim hd ; selon la documentation de Google, son absence signifie que le compte n’appartient pas à un domaine hébergé par Google. Le domaine de l’adresse e-mail ne suffit pas à le remplacer.

Auth0 : une barre oblique finale et HS256

  • L’émetteur se termine par une barre oblique : https://samples.auth0.com/. Configurer https://your-tenant.auth0.com sans elle fait échouer tous les jetons ; la version go-oidc de cette erreur est issuer did not match, dont l’exemple est précisément une barre oblique finale manquante.
  • Les jetons d’ID peuvent être en HS256. Le document de découverte mentionne HS256, et une application configurée ainsi reçoit des jetons d’ID signés avec son secret client (étape 8). Cela fonctionne pour un client confidentiel côté serveur, mais le secret devient alors une clé de signature, et tout service qui le connaît peut fabriquer des jetons d’ID. La page d’Auth0 sur les algorithmes de signature recommande elle-même RS256. Choisissez-le dans les paramètres de l’application et épinglez algorithms: ["RS256"], afin qu’un changement de configuration ne puisse pas basculer silencieusement votre vérificateur vers HMAC.

Erreurs courantes

  1. Valider un jeton d’ID comme s’il s’agissait d’un jeton d’accès, ou l’inverse. Un jeton d’ID indique à votre client qui s’est connecté ; ce n’est pas un identifiant permettant d’appeler votre API. Les API devraient accepter des jetons d’accès portant leur propre audience (l’article Erreurs de vérification JWT traite de typ: at+jwt).
  2. Ignorer la signature parce que « le jeton est arrivé par TLS ». L’étape 6 ne l’autorise que pour un jeton que votre serveur a reçu directement du point de terminaison de jeton. Un jeton d’ID qu’une application mobile ou un navigateur envoie à votre backend provient du client, et non du fournisseur, et doit être vérifié intégralement.
  3. Ne pas stocker le nonce. Générer un nonce sans jamais le comparer revient à ne pas en envoyer.
  4. Connecter les utilisateurs d’après email. Seule la paire iss et sub est stable et unique (§5.7). Avec une correspondance sur l’adresse e-mail, quiconque peut obtenir une adresse non vérifiée auprès de n’importe lequel des fournisseurs que vous acceptez peut prendre le contrôle du compte associé à cette adresse.
  5. Coder les clés en dur. Récupérez-les depuis jwks_uri et suivez leurs rotations ; consultez Rotation des clés JWKS sans interruption de service.

Sources