Aller au contenu
ecdsa.com
← Tous les articles

DPoP expliqué : lier les jetons OAuth à une clé client

Un jeton porteur fonctionne pour quiconque le détient. Un jeton lié par DPoP ne fonctionne qu’accompagné d’une signature récente produite avec la clé privée du client : un jeton copié est donc inutile à lui seul. Voici le protocole, le code client et serveur, et ce qu’un serveur de ressources a rejeté lorsque des jetons émis par Keycloak et leurs preuves ont été volés, rejoués et réutilisés.

En bref. DPoP (Demonstrating Proof of Possession, « démonstration de preuve de possession », RFC 9449, septembre 2023) lie un jeton d’accès à une paire de clés que génère le client. Le serveur d’autorisation inscrit l’empreinte de la clé publique dans le jeton (cnf.jkt) et répond avec token_type: DPoP. À chaque requête, le client envoie le jeton avec le schéma DPoP, accompagné d’un en-tête DPoP : un JWT court, signé avec la clé privée, qui indique la méthode HTTP (htm), l’URL (htu), un jti unique, l’heure (iat) et le condensat du jeton d’accès (ath). Le serveur de ressources vérifie la preuve, rejette les valeurs de jti réutilisées et les preuves trop anciennes, et contrôle que la clé de la preuve est bien celle à laquelle le jeton est lié. Utilisez-le lorsque des jetons d’accès ou d’actualisation résident à un endroit d’où ils peuvent être copiés, comme les navigateurs et les applications mobiles, ou lorsqu’un profil tel que FAPI 2.0 exige des jetons liés à l’expéditeur.

Le problème que résout DPoP

Un jeton porteur OAuth (RFC 6750) fonctionne comme de l’argent liquide : quiconque le présente obtient l’accès. Les jetons fuient par les journaux, le stockage du navigateur, les proxys, les rapports de plantage et les dépendances compromises, et un jeton porteur divulgué fonctionne depuis n’importe où jusqu’à son expiration. Les jetons liés à l’expéditeur ajoutent un second facteur, qui ne voyage pas avec le jeton. OAuth propose deux façons standard d’y parvenir : le TLS mutuel (RFC 8705), qui lie le jeton à un certificat client, et DPoP, qui le lie à une clé utilisée au niveau de la couche applicative. DPoP ne nécessite aucun certificat client TLS, fonctionne depuis un navigateur et à travers des proxys qui terminent TLS, et coûte une signature par requête.

Ce que DPoP ne fait pas : il ne signe ni le corps ni les en-têtes de la requête, seulement la méthode et l’URL (§4.2), et il n’aide en rien si un attaquant peut exécuter du code dans le client, car ce code peut demander à la clé du client de signer des preuves (§11.4). Pour signer des requêtes entières, consultez HTTP Message Signatures.

Le flux

  1. Le client génère une paire de clés, idéalement non exportable, et la conserve pendant toute la durée de vie de ses jetons.
  2. Requête de jeton. Le client envoie sa requête de jeton habituelle avec un en-tête DPoP contenant une preuve pour POST et l’URL du point de terminaison de jeton. Si le serveur d’autorisation veut un nonce, il répond 400 avec use_dpop_nonce et un en-tête DPoP-Nonce, et le client réessaie avec ce nonce dans la preuve.
  3. Réponse de jeton. token_type vaut DPoP, et non Bearer. Un jeton d’accès JWT porte "cnf": {"jkt": "<SHA-256 JWK thumbprint>"} (§6.1, RFC 7638) ; un jeton opaque expose la même valeur par introspection (§6.2). Les jetons d’actualisation émis à des clients publics sont eux aussi liés à la clé (§5).
  4. Requête vers la ressource. Authorization: DPoP <access token>, plus une nouvelle preuve pour cette méthode et cette URL, qui inclut désormais ath, le condensat SHA-256 du jeton d’accès, encodé en base64url.
  5. Le serveur de ressources vérifie la preuve et la liaison, et peut lui-même exiger un nonce avec 401 et WWW-Authenticate: DPoP error="use_dpop_nonce" (§9).

La preuve contient les membres suivants (§4.2) :

EmplacementMembreSignification
en-têtetypdpop+jwt, pour qu’une preuve ne puisse jamais être confondue avec un autre JWT
en-têtealgasymétrique uniquement ; jamais none ni HS*
en-têtejwkla clé publique du client ; jamais une clé privée
charge utilejtiunique pour chaque preuve : au moins 96 bits aléatoires, ou un UUID v4
charge utilehtm, htuméthode HTTP, et URI cible sans chaîne de requête ni fragment
charge utileiatheure de création
charge utileathcondensat du jeton d’accès, obligatoire lorsqu’un jeton est présenté
charge utilenoncele dernier DPoP-Nonce du serveur, lorsqu’il en a envoyé un

Client : produire les preuves

dpop-client.mjs (jose 6.2.13, Node.js 24.22)
import { randomUUID, createHash } from "node:crypto";
import { SignJWT, exportJWK, generateKeyPair } from "jose";

// One key pair per client instance. The private key is a non-extractable CryptoKey:
// code that can call sign() can make proofs, but cannot copy the key elsewhere.
const { privateKey, publicKey } = await generateKeyPair("ES256");
const jwk = await exportJWK(publicKey);

export const ath = (accessToken) => createHash("sha256").update(accessToken, "ascii").digest("base64url");

/** A DPoP proof for one request (RFC 9449 §4.2). */
export function dpopProof({ method, url, accessToken, nonce }) {
  const target = new URL(url);
  return new SignJWT({
    jti: randomUUID(),
    htm: method,
    htu: target.origin + target.pathname, // without query and fragment
    ...(accessToken && { ath: ath(accessToken) }),
    ...(nonce && { nonce }),
  })
    .setProtectedHeader({ typ: "dpop+jwt", alg: "ES256", jwk })
    .setIssuedAt()
    .sign(privateKey);
}

/** Calls fetch with a fresh proof; retries once when the server asks for a nonce. */
export async function dpopFetch(url, { method = "GET", accessToken, headers = {}, body } = {}, state = {}) {
  for (let attempt = 0; attempt < 2; attempt++) {
    const proof = await dpopProof({ method, url, accessToken, nonce: state.nonce });
    const res = await fetch(url, {
      method,
      body,
      headers: { ...headers, DPoP: proof, ...(accessToken && { Authorization: `DPoP ${accessToken}` }) },
    });
    if (res.headers.has("dpop-nonce")) state.nonce = res.headers.get("dpop-nonce");
    const challenge = res.headers.get("www-authenticate") ?? "";
    const bodyText = res.status === 400 ? await res.clone().text() : "";
    const askedForNonce = challenge.includes('error="use_dpop_nonce"') || bodyText.includes('"use_dpop_nonce"');
    if (!askedForNonce || attempt === 1) return res;
  }
}

Dans un navigateur, la même approche fonctionne avec la Web Crypto API : une clé générée avec extractable: false peut être stockée dans IndexedDB et servir à signer, mais aucun script ne peut lire sa moitié privée. Cela protège contre le vol de la clé, pas contre un script malveillant de la page qui lui demande de signer.

Serveur de ressources : vérifier les preuves

La RFC 9449 §4.3 énumère douze vérifications, qui « peuvent être effectuées dans n’importe quel ordre », et le §7 y ajoute la liaison du jeton. Le vérificateur ci-dessous les implémente pour une requête, avec les numéros d’étape dans les commentaires :

dpop-verify.mjs (jose 6.2.13)
import { createHash, randomBytes } from "node:crypto";
import { EmbeddedJWK, calculateJwkThumbprint, createRemoteJWKSet, jwtVerify } from "jose";

const PROOF_ALGS = ["ES256", "EdDSA", "PS256"]; // asymmetric only; never none or HS*
const MAX_PROOF_AGE = 60; // seconds
const SKEW = 5; // seconds a client clock may run ahead

export class DpopError extends Error {
  constructor(error, description, headers = {}) {
    super(description);
    this.error = error; // invalid_token | invalid_dpop_proof | use_dpop_nonce
    this.headers = headers;
  }
}

const sha256 = (text) => createHash("sha256").update(text, "ascii").digest("base64url");
const normalizeHtu = (htu) => {
  const u = new URL(htu); // lowercases scheme and host, drops default ports (RFC 3986 §6.2.2-6.2.3)
  return u.origin + u.pathname;
};

/** Resource-server side of RFC 9449: checks §4.3 and §7 for one request. */
export function createDpopVerifier({ issuer, jwksUri, audience, publicOrigin, requireNonce }) {
  const jwks = createRemoteJWKSet(new URL(jwksUri));
  const seen = new Map(); // "htu jti" -> expiry, for replay detection (§11.1)
  const nonce = randomBytes(16).toString("base64url"); // rotate it on a timer in production

  return async function verify(req) {
    // §7.1: a DPoP-bound access token arrives with the DPoP scheme, never as Bearer (§7.2).
    const [scheme, accessToken, ...extra] = (req.headers.authorization ?? "").split(" ");
    if (scheme !== "DPoP" || !accessToken || extra.length > 0) {
      throw new DpopError("invalid_token", "DPoP authorization scheme required");
    }
    // Steps 1-2: exactly one DPoP header holding one JWT.
    const proofs = req.headersDistinct.dpop ?? [];
    if (proofs.length !== 1) throw new DpopError("invalid_dpop_proof", `${proofs.length} DPoP headers`);
    // Steps 3-7 and 11: claims present, typ, asymmetric alg, signature with the embedded
    // public key (EmbeddedJWK refuses private keys), and iat within the window.
    let proof;
    try {
      proof = await jwtVerify(proofs[0], EmbeddedJWK, {
        typ: "dpop+jwt",
        algorithms: PROOF_ALGS,
        requiredClaims: ["jti", "htm", "htu", "iat", "ath"],
        maxTokenAge: MAX_PROOF_AGE,
        clockTolerance: SKEW,
      });
    } catch (e) {
      throw new DpopError("invalid_dpop_proof", e.message);
    }
    const { payload: p, protectedHeader } = proof;
    // Steps 8-9: the proof was made for this method and this URI.
    const target = new URL(req.url, publicOrigin);
    if (p.htm !== req.method) throw new DpopError("invalid_dpop_proof", `htm ${p.htm} != ${req.method}`);
    if (normalizeHtu(p.htu) !== target.origin + target.pathname) {
      throw new DpopError("invalid_dpop_proof", `htu ${p.htu} != ${target.origin}${target.pathname}`);
    }
    // Step 10: the server's current nonce, if this server hands them out.
    if (requireNonce && p.nonce !== nonce) {
      throw new DpopError("use_dpop_nonce", "Resource server requires nonce in DPoP proof", { "DPoP-Nonce": nonce });
    }
    // Replay: each jti once per target URI while the proof could still be accepted.
    const key = `${p.htu} ${p.jti}`;
    const now = Date.now();
    for (const [k, expiry] of seen) if (expiry < now) seen.delete(k);
    if (seen.has(key)) throw new DpopError("invalid_dpop_proof", "DPoP proof already used");
    seen.set(key, (p.iat + MAX_PROOF_AGE + SKEW) * 1000);
    // The access token itself: issuer, audience, signature, expiry.
    let token;
    try {
      ({ payload: token } = await jwtVerify(accessToken, jwks, { issuer, audience, algorithms: ["RS256"] }));
    } catch (e) {
      throw new DpopError("invalid_token", e.message);
    }
    // Step 12: the proof belongs to this token, and the token is bound to the proof's key.
    if (p.ath !== sha256(accessToken)) throw new DpopError("invalid_dpop_proof", "ath does not match the access token");
    if (token.cnf?.jkt !== (await calculateJwkThumbprint(protectedHeader.jwk, "sha256"))) {
      throw new DpopError("invalid_token", "Invalid DPoP key binding");
    }
    return token;
  };
}

/** Turns a DpopError into the 401 response RFC 9449 §7.1 describes. */
export function challenge(err) {
  const description = err.message.replaceAll('"', "'");
  return {
    status: 401,
    headers: {
      "WWW-Authenticate": `DPoP error="${err.error}", error_description="${description}", algs="${PROOF_ALGS.join(" ")}"`,
      ...err.headers,
    },
  };
}

Trois détails comptent plus qu’il n’y paraît :

  • publicOrigin relève de la configuration, pas de l’en-tête Host. Derrière un répartiteur de charge, le serveur voit une URL interne. Reconstruire l’URL à partir des en-têtes de la requête revient soit à faire échouer toutes les requêtes, soit à faire confiance à un en-tête que contrôle le client.
  • Le registre anti-rejeu est tenu par URI cible, et seulement pendant la fenêtre de validité des preuves. Avec plusieurs instances derrière un même point de terminaison, ce registre doit être partagé, ce qui, de l’aveu même de la RFC 9449, « peut ne pas toujours être réalisable » ; des nonces de serveur à courte durée de vie constituent l’alternative.
  • Rejetez un jeton lié par DPoP envoyé comme Bearer (§7.2). Sinon, la liaison devient facultative pour quiconque a volé le jeton.

Testé avec des jetons émis par Keycloak

Le banc d’essai faisait tourner Keycloak 26.8.1 (quay.io/keycloak/keycloak:26.8.1, en mode développement) avec un client confidentiel, orders-cli, configuré pour exiger des jetons d’accès liés par DPoP (l’attribut de client dpop.bound.access.tokens), et un mappeur d’audience qui ajoute orders-api. Un script pilote a demandé des jetons avec des preuves produites par dpop-client.mjs, a lancé sur le port 4000 un serveur de ressources construit sur dpop-verify.mjs, nonces activés, puis a tenté ce que tenterait un attaquant muni d’un jeton copié ou d’une requête copiée :

Sortie : Keycloak 26.8.1 et le serveur de ressources ci-dessus (node:24.22.0-bookworm-slim)
dpop_signing_alg_values_supported: PS384 RS384 EdDSA ES384 ES256 RS256 ES512 PS256 PS512 RS512
token request, proof with htm GET: 400 {"error":"invalid_request","error_description":"DPoP HTTP method mismatch"}
token request without DPoP header: 400 {"error":"invalid_request","error_description":"DPoP proof is missing"}
token response: 200, token_type DPoP
access token aud ["orders-api","account"], cnf {"jkt":"jxtFYlJLzPaVIiNgZHu-vReD5s26NbuoroqLJ-H7A1k","kc-jkt-type":"DPoP"}
thumbprint of the client's DPoP key: jxtFYlJLzPaVIiNgZHu-vReD5s26NbuoroqLJ-H7A1k
first call, no nonce yet                     401 DPoP error="use_dpop_nonce", error_description="Resource server requires nonce in DPoP proof", algs="ES256 EdDSA PS256"
retry with the server's nonce                200 {"orders":[],"client":"orders-cli"}
proof sent: {"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","x":"M5K5xM809NM5egv0kblUzobF_h2cNudX-6ENCI0MjiQ","y":"qtatemMO_oibrlk6VpoXDbHPtCiZNksoyVMcIGRx33w","crv":"P-256"}} {"jti":"993999b4-d7f8-4018-9f8e-e20f2a109a71","htm":"GET","htu":"http://127.0.0.1:4000/orders","ath":"CFA2MS0HVrzwNCH1DJZgOjreMZ0qSfyrNWXR6W-rKb8","nonce":"ItQnwia8Goi2bVqV4toJFQ","iat":1792497394}
dpopFetch, no nonce known (2 requests)       200 {"orders":[],"client":"orders-cli"}
same proof replayed                          401 DPoP error="invalid_dpop_proof", error_description="DPoP proof already used", algs="ES256 EdDSA PS256"
stolen token sent as Bearer                  401 DPoP error="invalid_token", error_description="DPoP authorization scheme required", algs="ES256 EdDSA PS256"
stolen token, attacker's own key             401 DPoP error="invalid_token", error_description="Invalid DPoP key binding", algs="ES256 EdDSA PS256"
GET proof used for DELETE                    401 DPoP error="invalid_dpop_proof", error_description="htm GET != DELETE", algs="ES256 EdDSA PS256"
proof made for another access token          401 DPoP error="invalid_dpop_proof", error_description="ath does not match the access token", algs="ES256 EdDSA PS256"
proof created two minutes ago                401 DPoP error="invalid_dpop_proof", error_description="'iat' claim timestamp check failed (too far in the past)", algs="ES256 EdDSA PS256"

Ce que montre l’exécution :

  • La liaison est réelle. Keycloak a renvoyé token_type DPoP et un cnf.jkt égal à l’empreinte de la clé du client. Il ajoute aussi un membre non standard, kc-jkt-type, à côté de jkt ; les serveurs de ressources doivent ignorer les membres qu’ils ne connaissent pas, comme le fait le vérificateur.
  • Un jeton volé ne suffit pas. Envoyé comme Bearer, il a été refusé ; envoyé avec une preuve valide produite par la propre clé de l’attaquant, il a échoué au contrôle de liaison. Une requête copiée a échoué elle aussi : la même preuve n’a pas pu être rejouée, une preuve pour GET n’a pas fonctionné pour DELETE, et une preuve créée deux minutes plus tôt était trop ancienne.
  • ath attache une preuve à un seul jeton. Une preuve valide produite pour un autre jeton d’accès du même client et de la même clé a été rejetée.
  • Les nonces coûtent un aller-retour. La première requête sans nonce a reçu 401 use_dpop_nonce ; dpopFetch a eu besoin de 2 requêtes pour son premier appel, et de 1 pour les suivants.

Le point de terminaison de jeton de Keycloak contrôle lui aussi les preuves. Un second court script, qui réutilise le module client :

as-replay.mjs
// Does Keycloak's token endpoint accept the same DPoP proof twice? And a proof with a wrong htu?
import { dpopProof } from "./dpop-client.mjs";

const ISSUER = "http://127.0.0.1:8080/realms/demo";
const { token_endpoint } = await (await fetch(`${ISSUER}/.well-known/openid-configuration`)).json();
const send = (proof) =>
  fetch(token_endpoint, {
    method: "POST",
    headers: {
      "content-type": "application/x-www-form-urlencoded",
      authorization: `Basic ${Buffer.from(`orders-cli:${process.env.CLI}`).toString("base64")}`,
      DPoP: proof,
    },
    body: "grant_type=client_credentials",
  });
const proof = await dpopProof({ method: "POST", url: token_endpoint });
for (const label of ["first use of a proof", "same proof again"]) {
  const res = await send(proof);
  const body = await res.json();
  console.log(`${label.padEnd(24)} ${res.status} ${body.token_type ?? JSON.stringify(body)}`);
}
const wrongUri = await send(await dpopProof({ method: "POST", url: `${ISSUER}/protocol/openid-connect/userinfo` }));
console.log(`${"proof for another URI".padEnd(24)} ${wrongUri.status} ${await wrongUri.text()}`);
Sortie
first use of a proof     200 DPoP
same proof again         400 {"error":"invalid_request","error_description":"DPoP proof has already been used"}
proof for another URI    400 {"error":"invalid_request","error_description":"DPoP HTTP URL mismatch"}

Un écart à connaître : selon le §5 de la RFC 9449, une preuve invalide au point de terminaison de jeton reçoit le code d’erreur invalid_dpop_proof, alors que Keycloak 26.8.1 a répondu invalid_request pour chacun des problèmes de preuve ci-dessus. Écrivez des clients dont la logique ne réagit qu’à use_dpop_nonce, et traitez les autres erreurs d’une requête de jeton DPoP comme des échecs à signaler plutôt que comme des codes à reconnaître.

Nonces : pourquoi les serveurs les demandent

Sans nonce de serveur, la fraîcheur d’une preuve repose sur iat, une valeur que choisit le client. Quiconque contrôle le client, y compris son utilisateur légitime, peut signer des preuves datées dans le futur et les utiliser plus tard depuis une machine qui ne détient pas la clé (§11.2, « pré-génération »). Un nonce fourni par le serveur, imprévisible et renouvelé par lui, fait expirer les preuves selon l’horloge du serveur. Il élimine aussi le décalage d’horloge de l’équation : le serveur peut encoder sa propre heure dans le nonce au lieu de se fier à iat (§11.1).

Les règles des §8 et §9 :

  • Les serveurs d’autorisation demandent un nonce avec 400 et "error": "use_dpop_nonce", les serveurs de ressources avec 401 et WWW-Authenticate: DPoP error="use_dpop_nonce". Les deux envoient la valeur dans DPoP-Nonce.
  • Dès qu’un serveur a envoyé un nonce, il ne doit plus accepter de preuves sans nonce (§11.3).
  • Un serveur peut envoyer le nonce suivant dans une réponse réussie, ce qui évite l’aller-retour supplémentaire ; de telles réponses ne devraient pas pouvoir être mises en cache.
  • Les clients exécutés dans un navigateur ne voient DPoP-Nonce et WWW-Authenticate que si le serveur les liste dans Access-Control-Expose-Headers. Sans cela, une application monopage ne peut pas lire le nonce qu’on lui demande d’utiliser.

Le serveur d’autorisation d’Okta, par exemple, répond à la première requête de jeton DPoP par « Authorization server requires nonce in DPoP proof » (guide d’Okta) : les clients ont donc besoin de la nouvelle tentative dès le départ.

Lier le code d’autorisation : dpop_jkt

DPoP lie les jetons au point de terminaison de jeton, mais le code d’autorisation est émis plus tôt, via le navigateur. Avec dpop_jkt dans la requête d’autorisation (§10), le client annonce l’empreinte de la clé qu’il utilisera, et le serveur d’autorisation rejette toute requête de jeton dont la preuve utilise une autre clé. Associé à PKCE, ce mécanisme protège le flux de bout en bout, à condition que le client utilise une nouvelle clé pour chaque requête d’autorisation. FAPI 2.0 impose aux serveurs d’autorisation qui utilisent DPoP de prendre en charge cette liaison, tout en laissant son utilisation facultative pour les clients (FAPI 2.0 Security Profile, §5.3.2.1 et §5.3.3.1).

Quand utiliser DPoP

SituationDPoP ?
Application monopage ou application mobile qui détient des jetons d’actualisationOui : les jetons d’actualisation émis à des clients publics sont liés à la clé, et c’est là que DPoP est le plus utile
FAPI 2.0 (open banking et assimilés)Obligatoire en pratique : FAPI 2.0 exige des jetons liés à l’expéditeur, via mTLS ou DPoP
Communication de serveur à serveur avec TLS mutuel déjà en placeLa liaison mTLS (RFC 8705) est le choix le plus simple
Application web confidentielle qui n’expose jamais les jetons au navigateurFacultatif ; les jetons restent côté serveur, et ses jetons d’actualisation sont déjà liés à l’authentification du client
Serveurs de ressources que vous ne contrôlez pas et qui n’acceptent que BearerLimité : le jeton ne peut y être utilisé que comme jeton porteur, sans la protection (§7.2)

Keycloak (testé ci-dessus), Okta et Auth0 (documentation) documentent DPoP pour leurs points de terminaison de jeton, et les serveurs annoncent les algorithmes de preuve acceptés sous dpop_signing_alg_values_supported dans leurs métadonnées (§5.1). La lacune se situe généralement du côté des serveurs de ressources, d’où l’importance du vérificateur ci-dessus.

Erreurs fréquentes

  1. Vérifier la preuve, mais pas la liaison. Une preuve signée par n’importe quelle clé est valide en elle-même ; seul cnf.jkt la rattache au jeton.
  2. Accepter aussi le jeton comme Bearer. L’attaquant utilise alors tout simplement le schéma Bearer.
  3. Comparer htu à une URL qui inclut la chaîne de requête, ou à une URL interne. htu exclut la chaîne de requête et le fragment, et doit correspondre à l’URL publique après normalisation.
  4. Ni registre anti-rejeu, ni nonce. Une preuve copiée fonctionne alors à nouveau pendant toute la fenêtre de validité fixée par iat.
  5. Une nouvelle clé à chaque requête. La clé doit rester la même tant que les jetons qui lui sont liés sont utilisés.
  6. Un alg symétrique, ou une clé privée dans jwk. Les deux sont interdits par le §4.2 ; EmbeddedJWK de jose refuse le second cas.

Sources