github.rs
252 lignes · 8413 octets
use super::super::provider_trait::{AuthorizationUrl, OAuthProvider, ProviderUserInfo}; use crate::error::AppError; use async_trait::async_trait; use openidconnect::{CsrfToken, PkceCodeChallenge}; use serde::Deserialize; /// OAuth 2.0 provider implementation for GitHub. pub struct GitHubProvider { client_id: String, client_secret: String, } impl GitHubProvider { /// Create a new `GitHubProvider` with the given client credentials. pub fn new(client_id: String, client_secret: String) -> Self { Self { client_id, client_secret, } } /// Build the form body for the token-exchange request. /// /// RFC 0035: the PKCE `code_verifier` is appended only when present, so a /// non-PKCE flow (verifier `None`) behaves exactly as before. GitHub has /// enforced PKCE since 2025-07-14, so a `code_challenge` emitted at the /// authorization step must be matched by the verifier here. fn token_form<'a>( &'a self, code: &'a str, pkce_verifier: Option<&'a str>, ) -> Vec<(&'a str, &'a str)> { 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)); } form } } #[derive(Deserialize, Debug)] struct GitHubTokenResponse { access_token: String, } /// Parse the GitHub token endpoint response. /// /// RFC 0035 §6: on failure GitHub returns `{"error":...,"error_description":...}` /// with an HTTP 200 status. Reading the body as text and parsing it here lets the /// real provider error surface instead of an opaque "error decoding response body". fn parse_github_token( status: reqwest::StatusCode, body: &str, ) -> Result<GitHubTokenResponse, AppError> { serde_json::from_str::<GitHubTokenResponse>(body).map_err(|_| { AppError::Internal(format!( "GitHub token exchange rejected (HTTP {status}): {body}" )) }) } #[derive(Deserialize)] struct GitHubUser { id: i64, login: String, name: Option<String>, email: Option<String>, avatar_url: Option<String>, } #[derive(Deserialize)] struct GitHubEmail { email: String, primary: bool, verified: bool, } #[async_trait] impl OAuthProvider for GitHubProvider { fn name(&self) -> &str { "github" } fn display_name(&self) -> &str { "GitHub" } fn authorization_url(&self, redirect_uri: &str) -> Result<AuthorizationUrl, AppError> { let state = CsrfToken::new_random(); // SEC-H6: PKCE S256 for GitHub 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={}&\ code_challenge_method=S256", self.client_id, urlencoding::encode(redirect_uri), state.secret(), pkce_challenge.as_str(), ); Ok(AuthorizationUrl { url, csrf_state: state.secret().to_string(), nonce: None, pkce_verifier: Some(pkce_verifier.secret().to_string()), }) } async fn exchange_code( &self, code: &str, _redirect_uri: &str, pkce_verifier: Option<&str>, _nonce: Option<&str>, ) -> Result<ProviderUserInfo, AppError> { let http = reqwest::Client::new(); let response = http .post("https://github.com/login/oauth/access_token") .header("Accept", "application/json") .form(&self.token_form(code, pkce_verifier)) .send() .await .map_err(|e| AppError::Internal(format!("GitHub token exchange failed: {}", e)))?; let status = response.status(); let body = response .text() .await .map_err(|e| AppError::Internal(format!("GitHub token read failed: {}", e)))?; let token_resp = parse_github_token(status, &body)?; let user: GitHubUser = http .get("https://api.github.com/user") .header( "Authorization", format!("Bearer {}", token_resp.access_token), ) .header("User-Agent", "Rustwarden") .send() .await .map_err(|e| AppError::Internal(format!("GitHub user fetch failed: {}", e)))? .json() .await .map_err(|e| AppError::Internal(format!("GitHub user parse failed: {}", e)))?; let (email, email_verified) = if user.email.is_some() { (user.email.clone(), true) } else { let emails: Vec<GitHubEmail> = http .get("https://api.github.com/user/emails") .header( "Authorization", format!("Bearer {}", token_resp.access_token), ) .header("User-Agent", "Rustwarden") .send() .await .map_err(|e| AppError::Internal(format!("GitHub emails fetch failed: {}", e)))? .json() .await .unwrap_or_default(); let primary = emails.into_iter().find(|e| e.primary && e.verified); match primary { Some(e) => (Some(e.email), true), None => (None, false), } }; Ok(ProviderUserInfo { provider_user_id: user.id.to_string(), email, email_verified, display_name: user.name.or(Some(user.login)), avatar_url: user.avatar_url, }) } } #[cfg(test)] mod tests { use super::*; fn provider() -> GitHubProvider { GitHubProvider::new("client-id".to_string(), "client-secret".to_string()) } // RFC 0035 §7.1: the verifier must be present in the token form when supplied. #[test] fn token_form_includes_code_verifier_when_present() { let p = provider(); let form = p.token_form("the-code", Some("the-verifier")); assert!( form.contains(&("code_verifier", "the-verifier")), "code_verifier must be sent to the token endpoint when PKCE is used" ); assert!(form.contains(&("code", "the-code"))); } // RFC 0035 §7.1: a non-PKCE flow (verifier None) must not add the parameter. #[test] fn token_form_omits_code_verifier_when_absent() { let p = provider(); let form = p.token_form("the-code", None); assert!(form.iter().all(|(k, _)| *k != "code_verifier")); } // RFC 0035 §7.2: a code_challenge is emitted at authorization, so a matching // verifier must be carried in the session for the exchange. #[test] fn authorization_url_emits_challenge_and_keeps_verifier() { let auth = provider() .authorization_url("https://app.example/callback") .expect("authorization_url should succeed"); assert!(auth.url.contains("code_challenge=")); assert!(auth.url.contains("code_challenge_method=S256")); assert!( auth.pkce_verifier.is_some(), "verifier must be retained whenever a challenge is sent" ); } // RFC 0035 §6: an error body returned with HTTP 200 must surface the raw // provider message instead of an opaque deserialization error. #[test] fn parse_github_token_surfaces_error_body() { let body = r#"{"error":"bad_verifier","error_description":"code_verifier mismatch"}"#; let err = parse_github_token(reqwest::StatusCode::OK, body) .expect_err("error body must not parse as success"); let msg = err.to_string(); assert!(msg.contains("code_verifier mismatch"), "got: {msg}"); } #[test] fn parse_github_token_accepts_success_body() { let body = r#"{"access_token":"gho_token","token_type":"bearer"}"#; let token = parse_github_token(reqwest::StatusCode::OK, body) .expect("valid token response should parse"); assert_eq!(token.access_token, "gho_token"); } }
GitRust