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");
    }
}