microsoft.rs
169 lignes · 6031 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 Microsoft (Azure AD / Entra ID). pub struct MicrosoftProvider { client_id: String, client_secret: String, tenant: String, } impl MicrosoftProvider { /// Create a new `MicrosoftProvider`. Defaults to the `"common"` tenant when none is specified. pub fn new(client_id: String, client_secret: String, tenant: Option<String>) -> Self { Self { client_id, client_secret, tenant: tenant.unwrap_or_else(|| "common".to_string()), } } } #[async_trait] impl OAuthProvider for MicrosoftProvider { fn name(&self) -> &str { "microsoft" } fn display_name(&self) -> &str { "Microsoft" } 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://login.microsoftonline.com/{}/oauth2/v2.0/authorize?\ client_id={}&\ redirect_uri={}&\ response_type=code&\ scope=openid%20email%20profile&\ state={}&\ nonce={}&\ code_challenge={}&\ code_challenge_method=S256", self.tenant, 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(format!( "https://login.microsoftonline.com/{}/v2.0", self.tenant )) .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 = MicrosoftProvider::new("client-id".to_string(), "client-secret".to_string(), None); 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()); } }
GitRust