RFC — OAuth : code_verifier PKCE manquant à l'échange (providers GitHub & Discord)
| Statut | Implémenté (2026-06-18) |
| Date | 2026-06-18 |
| Composant | rustwarden-core — src/services/oauth/providers/ |
| Auteur | Intégrateur PasterMan (remonté depuis une instance en production) |
| Destinataires | Mainteneurs 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
stateCSRF : 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
| Provider | Fichier | code_challenge à l'autorisation | code_verifier à l'échange | État |
|---|---|---|---|---|
| GitHub | providers/github.rs | oui (~l.65) | non (_pkce_verifier, ~l.85) | ❌ |
| Discord | providers/discord.rs | oui (~l.61) | non (_pkce_verifier, ~l.81) | ❌ |
providers/google.rs | oui (~l.48) | oui (set_pkce_verifier, ~l.100) | ✅ | |
| Microsoft | providers/microsoft.rs | oui (~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
- Test unitaire d'échange : vérifier que le formulaire POST contient
code_verifierlorsqueexchange_codereçoitSome(verifier), et qu'il ne le contient pas pourNone(mock du endpoint token viawiremock). - Test de non-régression PKCE : pour les 4 providers, garantir qu'un
code_challengeémis à l'autorisation est toujours accompagné d'uncode_verifierà l'échange (invariant testé directement). - 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
OAuthProvidern'est pas modifiée ; seul le corps deexchange_codechange (un_pkce_verifierjusque-là ignoré devient utilisé). - Rétro-compatibilité : l'ajout est conditionné par
if let Some(verifier), donc un déploiement sans PKCE (verifierNone) se comporte comme avant. - Sécurité : envoyer le
code_verifierest 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_codesrc/services/oauth/providers/discord.rs—authorization_url,exchange_codesrc/services/oauth/providers/google.rs— référence d'implémentation correctesrc/services/oauth/providers/microsoft.rs— référence d'implémentation correctesrc/auth/oauth_handlers.rs—oauth_callback(transmetpkce_verifier)
GitRust