google.rs 162 lignes · 5738 octets
use super::super::provider_trait::{AuthorizationUrl, OAuthProvider, ProviderUserInfo};
use crate::error::AppError;
use async_trait::async_trait;
use openidconnect::{
    AuthorizationCode, ClientId, ClientSecret, CsrfToken, IssuerUrl, Nonce, PkceCodeChallenge,
    PkceCodeVerifier, RedirectUrl, core::CoreProviderMetadata,
};

/// OIDC provider implementation for Google.
pub struct GoogleProvider {
    client_id: String,
    client_secret: String,
}

impl GoogleProvider {
    /// Create a new `GoogleProvider` with the given client credentials.
    pub fn new(client_id: String, client_secret: String) -> Self {
        Self {
            client_id,
            client_secret,
        }
    }
}

#[async_trait]
impl OAuthProvider for GoogleProvider {
    fn name(&self) -> &str {
        "google"
    }

    fn display_name(&self) -> &str {
        "Google"
    }

    fn authorization_url(&self, redirect_uri: &str) -> Result<AuthorizationUrl, AppError> {
        let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();
        let nonce = Nonce::new_random();
        let state = CsrfToken::new_random();

        let url = format!(
            "https://accounts.google.com/o/oauth2/v2/auth?\
            client_id={}&\
            redirect_uri={}&\
            response_type=code&\
            scope=openid%20email%20profile&\
            state={}&\
            nonce={}&\
            code_challenge={}&\
            code_challenge_method=S256",
            self.client_id,
            urlencoding::encode(redirect_uri),
            state.secret(),
            nonce.secret(),
            pkce_challenge.as_str(),
        );

        Ok(AuthorizationUrl {
            url,
            csrf_state: state.secret().to_string(),
            nonce: Some(nonce.secret().to_string()),
            pkce_verifier: Some(pkce_verifier.secret().to_string()),
        })
    }

    async fn exchange_code(
        &self,
        code: &str,
        redirect_uri: &str,
        pkce_verifier: Option<&str>,
        nonce_str: Option<&str>,
    ) -> Result<ProviderUserInfo, AppError> {
        let http_client = reqwest::Client::builder()
            .redirect(reqwest::redirect::Policy::none())
            .build()
            .map_err(|e| AppError::Internal(format!("HTTP client error: {}", e)))?;

        let issuer_url = IssuerUrl::new("https://accounts.google.com".to_string())
            .map_err(|e| AppError::Internal(format!("Invalid issuer URL: {}", e)))?;

        let provider_metadata = CoreProviderMetadata::discover_async(issuer_url, &http_client)
            .await
            .map_err(|e| AppError::Internal(format!("OIDC discovery failed: {}", e)))?;

        let client = openidconnect::core::CoreClient::from_provider_metadata(
            provider_metadata,
            ClientId::new(self.client_id.clone()),
            Some(ClientSecret::new(self.client_secret.clone())),
        )
        .set_redirect_uri(
            RedirectUrl::new(redirect_uri.to_string())
                .map_err(|e| AppError::Internal(format!("Invalid redirect URI: {}", e)))?,
        );

        let mut token_request = client
            .exchange_code(AuthorizationCode::new(code.to_string()))
            .map_err(|e| AppError::Internal(format!("Token request config error: {}", e)))?;

        if let Some(verifier) = pkce_verifier {
            token_request =
                token_request.set_pkce_verifier(PkceCodeVerifier::new(verifier.to_string()));
        }

        let token_response = token_request
            .request_async(&http_client)
            .await
            .map_err(|e| AppError::Internal(format!("Token exchange failed: {}", e)))?;

        let id_token = token_response
            .extra_fields()
            .id_token()
            .ok_or_else(|| AppError::Internal("Missing ID token in response".to_string()))?;

        // SEC-H8: Reject missing nonce — empty string would bypass replay protection
        let nonce_value = nonce_str.ok_or_else(|| {
            AppError::Internal(
                "OIDC nonce missing from session — possible replay attack".to_string(),
            )
        })?;
        let nonce = Nonce::new(nonce_value.to_string());
        let claims = id_token
            .claims(&client.id_token_verifier(), &nonce)
            .map_err(|e| AppError::Internal(format!("ID token verification failed: {}", e)))?;

        let subject = claims.subject().to_string();
        let email = claims.email().map(|e| e.to_string());
        let email_verified = claims.email_verified().unwrap_or(false);
        let name = claims
            .name()
            .and_then(|n| n.get(None))
            .map(|n| n.to_string());
        let picture = claims
            .picture()
            .and_then(|p| p.get(None))
            .map(|p| p.to_string());

        Ok(ProviderUserInfo {
            provider_user_id: subject,
            email,
            email_verified,
            display_name: name,
            avatar_url: picture,
        })
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    // RFC 0035 §7.2: a code_challenge is emitted at authorization, so a matching
    // verifier must be retained for the exchange (delegated to `openidconnect`).
    #[test]
    fn authorization_url_emits_challenge_and_keeps_verifier() {
        let provider = GoogleProvider::new("client-id".to_string(), "client-secret".to_string());
        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());
    }
}