0035-oauth-pkce-code-verifier.md 10783 octets

RFC — OAuth : code_verifier PKCE manquant à l'échange (providers GitHub & Discord)

StatutImplémenté (2026-06-18)
Date2026-06-18
Composantrustwarden-core — src/services/oauth/providers/
AuteurIntégrateur PasterMan (remonté depuis une instance en production)
DestinatairesMainteneurs du framework Rustwarden
GravitéÉlevée — connexion OAuth GitHub totalement cassée en production

1. Résumé (TL;DR)

Les providers OAuth GitHub et Discord envoient un code_challenge PKCE (méthode S256) à l'étape d'autorisation, mais n'envoient jamais le code_verifier correspondant à l'étape d'échange du code contre un token. Le paramètre pkce_verifier, pourtant transmis par le handler de callback, est ignoré (_pkce_verifier).

Depuis que GitHub impose PKCE (14 juillet 2025), un code_challenge envoyé sans code_verifier est rejeté : le endpoint token renvoie un corps JSON d'erreur (sans access_token). Résultat côté Rustwarden :

ERROR rustwarden_core::error: Internal error: GitHub token parse failed: error decoding response body

et l'utilisateur reçoit un 500 {"error":"Internal server error"}.

Les providers Google et Microsoft ne sont pas affectés : ils passent par le crate openidconnect et appellent set_pkce_verifier(...) correctement.

Demande : inclure code_verifier dans l'échange des providers GitHub et Discord. Correctif minime, sans changement d'API publique.


2. Symptôme observé en production

  • L'utilisateur clique « Se connecter avec GitHub ».
  • Redirection vers https://github.com/login/oauth/authorize?...&code_challenge=...&code_challenge_method=S256 — OK.
  • Retour sur https://<domaine>/api/v1/auth/oauth/github/callback?code=...&state=....
  • Validation du state CSRF : OK (aucune erreur « state mismatch » dans les logs).
  • Échange du code → échec :
DEBUG reqwest::connect: starting new connection: https://github.com/
DEBUG hyper_util...: connected to 140.82.121.4:443
ERROR rustwarden_core::error: Internal error: GitHub token parse failed: error decoding response body

Le message est trompeur : GitHub répond 200 OK avec un corps du type {"error":"...","error_description":"..."}. Comme le code désérialise directement dans la struct de succès (GitHubTokenResponse { access_token }), le champ manquant produit une erreur « error decoding response body » qui masque le vrai message d'erreur de GitHub (voir §6).


3. Cause racine

3.1. Étape d'autorisation — un code_challenge est émis

src/services/oauth/providers/github.rs (authorization_url) :

// ~ligne 57
let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();

let url = format!(
    "https://github.com/login/oauth/authorize?\
    client_id={}&redirect_uri={}&scope=user:email&state={}&\
    code_challenge={}&\               // ← challenge envoyé
    code_challenge_method=S256",      // ← méthode S256
    ...
);

Ok(AuthorizationUrl {
    url,
    csrf_state: state.secret().to_string(),
    nonce: None,
    pkce_verifier: Some(pkce_verifier.secret().to_string()),  // ← verifier conservé en session
})

3.2. Le handler transmet bien le verifier

src/auth/oauth_handlers.rs (oauth_callback) :

// ~ligne 170 : récupéré depuis la session
let pkce_verifier: Option<String> = session.get(OAUTH_PKCE_KEY).await.unwrap_or(None);

// ~ligne 186 : passé à l'échange
let user_info = provider
    .exchange_code(code, &redirect_uri, pkce_verifier.as_deref(), nonce.as_deref())
    .await?;

3.3. L'échange ignore le verifier — le bug

src/services/oauth/providers/github.rs (exchange_code) :

async fn exchange_code(
    &self,
    code: &str,
    _redirect_uri: &str,
    _pkce_verifier: Option<&str>,     // ← reçu mais IGNORÉ (préfixe _)
    _nonce: Option<&str>,
) -> Result<ProviderUserInfo, AppError> {
    let http = reqwest::Client::new();

    let token_resp = http
        .post("https://github.com/login/oauth/access_token")
        .header("Accept", "application/json")
        .form(&[
            ("client_id", self.client_id.as_str()),
            ("client_secret", self.client_secret.as_str()),
            ("code", code),
            // ← MANQUE: ("code_verifier", verifier)
        ])
        .send().await?
        .json::<GitHubTokenResponse>().await?;   // ← échoue ici quand GitHub renvoie une erreur
    ...
}

3.4. Pourquoi maintenant

GitHub a généralisé la prise en charge de PKCE le 14 juillet 2025 (cf. §9). Auparavant, un code_challenge envoyé était ignoré, et l'échange sans code_verifier réussissait. Désormais, le code d'autorisation est lié au challenge et l'échange exige le verifier ; son absence provoque le rejet. C'est une régression côté serveur GitHub qui révèle un défaut latent du provider.


4. Périmètre — providers concernés

ProviderFichiercode_challenge à l'autorisationcode_verifier à l'échangeÉtat
GitHubproviders/github.rsoui (~l.65)non (_pkce_verifier, ~l.85)❌
Discordproviders/discord.rsoui (~l.61)non (_pkce_verifier, ~l.81)❌
Googleproviders/google.rsoui (~l.48)oui (set_pkce_verifier, ~l.100)✅
Microsoftproviders/microsoft.rsoui (~l.50)oui (set_pkce_verifier, ~l.106)✅

Différence structurelle : Google et Microsoft sont des providers OIDC qui délèguent l'échange au crate openidconnect (token_request.set_pkce_verifier(PkceCodeVerifier::new(verifier))). GitHub et Discord construisent manuellement un POST reqwest et ont omis le paramètre.

Discord applique également PKCE lorsqu'un challenge est fourni ; le provider Discord est donc cassé de la même manière (ou le deviendra), même si ce RFC est motivé par GitHub.


5. Correctif proposé

Ajouter code_verifier au formulaire d'échange, uniquement s'il est présent (rétro-compatible avec un flux sans PKCE), pour github.rs et discord.rs.

5.1. providers/github.rs

async fn exchange_code(
    &self,
    code: &str,
    _redirect_uri: &str,
    pkce_verifier: Option<&str>,        // était: _pkce_verifier
    _nonce: Option<&str>,
) -> Result<ProviderUserInfo, AppError> {
    let http = reqwest::Client::new();

    let mut form = vec![
        ("client_id", self.client_id.as_str()),
        ("client_secret", self.client_secret.as_str()),
        ("code", code),
    ];
    if let Some(verifier) = pkce_verifier {
        form.push(("code_verifier", verifier));   // ← pièce manquante
    }

    let token_resp = http
        .post("https://github.com/login/oauth/access_token")
        .header("Accept", "application/json")
        .form(&form)
        .send().await
        .map_err(|e| AppError::Internal(format!("GitHub token exchange failed: {}", e)))?
        .json::<GitHubTokenResponse>().await
        .map_err(|e| AppError::Internal(format!("GitHub token parse failed: {}", e)))?;
    // ... reste inchangé
}

5.2. providers/discord.rs

Même correction : dé-préfixer _pkce_verifier → pkce_verifier et l'ajouter au formulaire de la requête POST https://discord.com/api/oauth2/token sous la clé code_verifier quand il est présent.


6. Amélioration secondaire recommandée (qualité de diagnostic)

Indépendamment du PKCE, l'échange désérialise directement la réponse dans la struct de succès, ce qui efface le message d'erreur du provider et rend tout incident illisible (« error decoding response body »). Recommandation : lire le corps en texte d'abord, et remonter l'erreur OAuth structurée.

let status = resp.status();
let body = resp.text().await
    .map_err(|e| AppError::Internal(format!("GitHub token read failed: {e}")))?;

match serde_json::from_str::<GitHubTokenResponse>(&body) {
    Ok(t) => t,
    Err(_) => {
        // GitHub renvoie {"error":"...","error_description":"..."} en HTTP 200
        return Err(AppError::Internal(format!(
            "GitHub token exchange rejected (HTTP {status}): {body}"
        )));
    }
}

Cela aurait fait apparaître directement dans les logs, par exemple, {"error":"...","error_description":"... code_verifier ..."} au lieu d'un message générique — divisant par dix le temps de diagnostic.


7. Tests recommandés

  1. Test unitaire d'échange : vérifier que le formulaire POST contient code_verifier lorsque exchange_code reçoit Some(verifier), et qu'il ne le contient pas pour None (mock du endpoint token via wiremock).
  2. Test de non-régression PKCE : pour les 4 providers, garantir qu'un code_challenge émis à l'autorisation est toujours accompagné d'un code_verifier à l'échange (invariant testé directement).
  3. Test d'intégration GitHub (manuel/CI gated) : flux complet sur une OAuth App de test → obtention d'un access_token.

8. Compatibilité et risques

  • API publique : inchangée. La signature du trait OAuthProvider n'est pas modifiée ; seul le corps de exchange_code change (un _pkce_verifier jusque-là ignoré devient utilisé).
  • Rétro-compatibilité : l'ajout est conditionné par if let Some(verifier), donc un déploiement sans PKCE (verifier None) se comporte comme avant.
  • Sécurité : envoyer le code_verifier est précisément le comportement PKCE attendu (RFC 7636) ; cela renforce la sécurité du flux (protection contre l'interception du code d'autorisation).
  • Risque de ne rien faire : connexion GitHub définitivement cassée pour tous les intégrateurs à jour des changements GitHub ; Discord cassé de même.

9. Références

  • GitHub Changelog — PKCE support for OAuth and GitHub App authentication (14 juillet 2025) : https://github.blog/changelog/2025-07-14-pkce-support-for-oauth-and-github-app-authentication/
  • RFC 7636 — Proof Key for Code Exchange by OAuth Public Clients : https://www.rfc-editor.org/rfc/rfc7636
  • Fichiers concernés (chemins relatifs au crate rustwarden-core) :
    • src/services/oauth/providers/github.rs — authorization_url, exchange_code
    • src/services/oauth/providers/discord.rs — authorization_url, exchange_code
    • src/services/oauth/providers/google.rs — référence d'implémentation correcte
    • src/services/oauth/providers/microsoft.rs — référence d'implémentation correcte
    • src/auth/oauth_handlers.rs — oauth_callback (transmet pkce_verifier)