use serde::Deserialize; use crate::config::OAuthProviderConfig; use crate::error::AuthError; use crate::features::oauth::types::{OAuthProvider, ProviderIdentity}; #[derive(Debug, Deserialize)] struct TokenResp { access_token: String, refresh_token: Option, expires_in: Option, #[serde(default)] scope: Option, } #[derive(Debug, Deserialize)] struct UserInfo { id: i64, login: String, name: Option, email: Option, } #[derive(Debug, Deserialize)] struct UserEmail { email: String, primary: bool, verified: bool, } async fn get_emails( client: &reqwest::Client, token: &str, url: &str, ) -> Result, AuthError> { let v: Vec = client .get(url) .bearer_auth(token) .header("User-Agent", "untitled-auth") .send() .await .map_err(|e| AuthError::Internal(format!("github emails: {e}")))? .error_for_status() .map_err(|e| AuthError::Internal(format!("github emails status: {e}")))? .json() .await .map_err(|e| AuthError::Internal(format!("github emails parse: {e}")))?; Ok(v) } /// Exchange a GitHub authorization code for an access token, fetch the user /// profile, and resolve a primary verified email when needed. pub async fn fetch_github_identity( code: &str, code_verifier: &str, cfg: &OAuthProviderConfig, client: &reqwest::Client, ) -> Result { let token: TokenResp = client .post(&cfg.token_url) .header("Accept", "application/json") .form(&[ ("code", code), ("client_id", &cfg.client_id), ("client_secret", &cfg.client_secret), ("redirect_uri", &cfg.redirect_uri), ("code_verifier", code_verifier), ]) .send() .await .map_err(|e| AuthError::Internal(format!("github token: {e}")))? .error_for_status() .map_err(|e| AuthError::Internal(format!("github token status: {e}")))? .json() .await .map_err(|e| AuthError::Internal(format!("github token parse: {e}")))?; let user: UserInfo = client .get(&cfg.userinfo_url) .bearer_auth(&token.access_token) .header("User-Agent", "untitled-auth") .send() .await .map_err(|e| AuthError::Internal(format!("github user: {e}")))? .error_for_status() .map_err(|e| AuthError::Internal(format!("github user status: {e}")))? .json() .await .map_err(|e| AuthError::Internal(format!("github user parse: {e}")))?; let mut email = user.email.clone(); let mut email_verified = false; if (email.is_none() || email.as_deref().map(|e| e.is_empty()).unwrap_or(true)) && let Some(url) = cfg.emails_url.as_deref() { let emails = get_emails(client, &token.access_token, url).await?; if let Some(primary) = emails.iter().find(|e| e.primary && e.verified) { email = Some(primary.email.clone()); email_verified = true; } else if let Some(any_verified) = emails.iter().find(|e| e.verified) { email = Some(any_verified.email.clone()); email_verified = true; } else if let Some(first) = emails.first() { email = Some(first.email.clone()); } } let scopes = token .scope .as_deref() .map(|s| s.split(',').map(|x| x.trim().to_string()).collect()) .unwrap_or_default(); let expires_at = token .expires_in .map(|secs| time::OffsetDateTime::now_utc() + time::Duration::seconds(secs)); Ok(ProviderIdentity { provider: OAuthProvider::Github, provider_user_id: user.id.to_string(), provider_username: user.name.unwrap_or(user.login), email, email_verified, access_token: token.access_token, refresh_token: token.refresh_token, expires_at, scopes, }) }