/// OAuth 2.0 Service — Google & GitHub /// /// Implements the standard Authorization Code flow: /// 1. `auth_url()` — build the redirect URL the browser goes to /// 2. `exchange_code()` — swap the code for an access token /// 3. `fetch_profile()` — get the user's email/name from the provider API /// 4. `find_or_create_merchant()` — upsert into `oauth_accounts`, auto-create /// a merchant row if this is a brand-new user /// /// No external OAuth crate needed — plain `reqwest` + `serde_json`. use crate::core::session; use crate::domain::error::{AppError, AppResult}; use crate::infrastructure::db::DbPool; use rand::Rng; use serde::{Deserialize, Serialize}; use uuid::Uuid; // ─── Provider Configuration ─────────────────────────────────────────────────── #[derive(Clone)] pub struct OAuthConfig { pub google_client_id: String, pub google_client_secret: String, pub github_client_id: String, pub github_client_secret: String, /// Base URL of this API server (used to build callback URLs) pub redirect_base: String, /// Frontend URL (used to redirect the user after successful auth) pub frontend_url: String, } impl OAuthConfig { pub fn from_env() -> Self { Self { google_client_id: std::env::var("GOOGLE_CLIENT_ID").unwrap_or_default(), google_client_secret: std::env::var("GOOGLE_CLIENT_SECRET").unwrap_or_default(), github_client_id: std::env::var("GITHUB_CLIENT_ID").unwrap_or_default(), github_client_secret: std::env::var("GITHUB_CLIENT_SECRET").unwrap_or_default(), redirect_base: std::env::var("OAUTH_REDIRECT_BASE") .unwrap_or_else(|_| "http://localhost:3000".to_string()), frontend_url: std::env::var("FRONTEND_URL") .unwrap_or_else(|_| "http://localhost:5173".to_string()), } } pub fn google_redirect_uri(&self) -> String { format!("{}/v1/auth/oauth/google/callback", self.redirect_base) } pub fn github_redirect_uri(&self) -> String { format!("{}/v1/auth/oauth/github/callback", self.redirect_base) } } // ─── Normalised User Profile (provider-agnostic) ───────────────────────────── #[derive(Debug)] pub struct OAuthProfile { pub provider: &'static str, // "google" | "github" pub provider_user_id: String, pub email: String, pub display_name: Option, pub avatar_url: Option, } // ─── Token Exchange Response (shared shape) ─────────────────────────────────── #[derive(Deserialize)] struct TokenResponse { access_token: String, } // ─── Google Raw API Types ───────────────────────────────────────────────────── #[derive(Deserialize)] struct GoogleUserInfo { sub: String, // unique Google user ID email: String, name: Option, picture: Option, } // ─── GitHub Raw API Types ───────────────────────────────────────────────────── #[derive(Deserialize)] struct GithubUser { id: u64, login: String, name: Option, avatar_url: Option, email: Option, } #[derive(Deserialize)] struct GithubEmail { email: String, primary: bool, verified: bool, } // ─── State Nonce ───────────────────────────────────────────────────────────── /// Generates a short cryptographically-random state string to prevent CSRF /// on the OAuth callback. In production this should be stored in a signed /// cookie or server-side cache; here we generate it and embed it in the URL /// for stateless verification. pub fn generate_state() -> String { let mut rng = rand::thread_rng(); (0..32) .map(|_| rng.sample(rand::distributions::Alphanumeric) as char) .collect() } // ─── Google ─────────────────────────────────────────────────────────────────── /// Build the Google OAuth authorization URL that the browser redirects to. pub fn google_auth_url(config: &OAuthConfig, state: &str) -> String { let params = [ ("client_id", config.google_client_id.as_str()), ("redirect_uri", &config.google_redirect_uri()), ("response_type", "code"), ("scope", "openid email profile"), ("state", state), ("access_type", "online"), ("prompt", "select_account"), ]; let query = params .iter() .map(|(k, v)| format!("{}={}", k, urlencoding::encode(v))) .collect::>() .join("&"); format!("https://accounts.google.com/o/oauth2/v2/auth?{}", query) } /// Exchange a Google authorization code for a user profile. pub async fn exchange_google_code(config: &OAuthConfig, code: &str) -> AppResult { let client = reqwest::Client::new(); // 1. Exchange code → access token let token_res: TokenResponse = client .post("https://oauth2.googleapis.com/token") .form(&[ ("code", code), ("client_id", &config.google_client_id), ("client_secret", &config.google_client_secret), ("redirect_uri", &config.google_redirect_uri()), ("grant_type", "authorization_code"), ]) .send() .await .map_err(|e| AppError::Internal(format!("Google token exchange failed: {}", e)))? .json::() .await .map_err(|e| AppError::Internal(format!("Google token parse failed: {}", e))) .and_then(|v| { serde_json::from_value::(v.clone()).map_err(|_| { let err = v .get("error_description") .and_then(|e| e.as_str()) .unwrap_or("unknown error"); AppError::Auth(format!("Google OAuth error: {}", err)) }) })?; // 2. Fetch user profile let user: GoogleUserInfo = client .get("https://www.googleapis.com/oauth2/v3/userinfo") .bearer_auth(&token_res.access_token) .send() .await .map_err(|e| AppError::Internal(format!("Google userinfo fetch failed: {}", e)))? .json() .await .map_err(|e| AppError::Internal(format!("Google userinfo parse failed: {}", e)))?; Ok(OAuthProfile { provider: "google", provider_user_id: user.sub, email: user.email, display_name: user.name, avatar_url: user.picture, }) } // ─── GitHub ─────────────────────────────────────────────────────────────────── /// Build the GitHub OAuth authorization URL. pub fn github_auth_url(config: &OAuthConfig, state: &str) -> String { let params = [ ("client_id", config.github_client_id.as_str()), ("redirect_uri", &config.github_redirect_uri()), ("scope", "user:email read:user"), ("state", state), ]; let query = params .iter() .map(|(k, v)| format!("{}={}", k, urlencoding::encode(v))) .collect::>() .join("&"); format!("https://github.com/login/oauth/authorize?{}", query) } /// Exchange a GitHub authorization code for a user profile. pub async fn exchange_github_code(config: &OAuthConfig, code: &str) -> AppResult { let client = reqwest::Client::new(); // 1. Exchange code → access token (GitHub returns form-encoded) let token_body = client .post("https://github.com/login/oauth/access_token") .header("Accept", "application/json") .form(&[ ("code", code), ("client_id", &config.github_client_id), ("client_secret", &config.github_client_secret), ("redirect_uri", &config.github_redirect_uri()), ]) .send() .await .map_err(|e| AppError::Internal(format!("GitHub token exchange failed: {}", e)))? .json::() .await .map_err(|e| AppError::Internal(format!("GitHub token parse failed: {}", e)))?; let access_token = token_body .get("access_token") .and_then(|v| v.as_str()) .ok_or_else(|| { let err = token_body .get("error_description") .and_then(|v| v.as_str()) .unwrap_or("access_token missing"); AppError::Auth(format!("GitHub OAuth error: {}", err)) })? .to_string(); // 2. Fetch user profile let user: GithubUser = client .get("https://api.github.com/user") .bearer_auth(&access_token) .header("User-Agent", "Rtix-OAuth/1.0") .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)))?; // 3. Fetch primary verified email (may not be in the user object if private) let email = if let Some(e) = user.email.filter(|e| !e.is_empty()) { e } else { let emails: Vec = client .get("https://api.github.com/user/emails") .bearer_auth(&access_token) .header("User-Agent", "Rtix-OAuth/1.0") .send() .await .map_err(|e| AppError::Internal(format!("GitHub emails fetch failed: {}", e)))? .json() .await .map_err(|e| AppError::Internal(format!("GitHub emails parse failed: {}", e)))?; emails .into_iter() .find(|e| e.primary && e.verified) .map(|e| e.email) .ok_or_else(|| AppError::Auth( "GitHub account has no verified primary email. Please add one in GitHub settings.".to_string() ))? }; Ok(OAuthProfile { provider: "github", provider_user_id: user.id.to_string(), email, display_name: user.name.or(Some(user.login)), avatar_url: user.avatar_url, }) } // ─── Find or Create Merchant ────────────────────────────────────────────────── /// OAuth result returned to the route handler after successful authentication. #[derive(Serialize)] pub struct OAuthResult { pub merchant_id: String, pub brand_name: String, pub slug: String, pub role: String, pub jwt_token: String, pub is_new_user: bool, } /// Core OAuth business logic: given a verified `OAuthProfile`, either: /// - Find the existing `oauth_accounts` row → load merchant → issue JWT, or /// - Find merchant by email → link OAuth account → issue JWT, or /// - Create a brand-new merchant from OAuth profile → issue JWT pub async fn find_or_create_merchant( pool: &DbPool, profile: OAuthProfile, jwt_secret: &[u8], ) -> AppResult { use sqlx::Row; // ── Step 1: Check if this OAuth identity already exists ──────────────── let existing_link = sqlx::query( "SELECT merchant_id FROM oauth_accounts WHERE provider = $1 AND provider_user_id = $2", ) .bind(profile.provider) .bind(&profile.provider_user_id) .fetch_optional(pool) .await .map_err(AppError::Database)?; let (merchant_id, is_new_user) = if let Some(row) = existing_link { // Known OAuth user — just load merchant_id (row.get::("merchant_id"), false) } else { // ── Step 2: Check if a merchant with this email already exists ───── let existing_merchant = sqlx::query("SELECT merchant_id FROM merchants WHERE email = $1") .bind(&profile.email) .fetch_optional(pool) .await .map_err(AppError::Database)?; let mid = if let Some(row) = existing_merchant { // Existing email-password account — link the OAuth provider to it row.get::("merchant_id") } else { // ── Step 3: Brand-new user — create merchant ─────────────────── let new_id = Uuid::new_v4().to_string(); let display = profile.display_name.as_deref().unwrap_or("My Store"); let brand_name = display.to_string(); // Build a URL-safe slug from display name let base_slug = display .to_lowercase() .split_whitespace() .collect::>() .join("-") .chars() .filter(|c| c.is_alphanumeric() || *c == '-') .collect::(); // Collision-safe slug let mut final_slug = base_slug.clone(); let mut attempts = 0u8; loop { let taken: Option = sqlx::query_scalar("SELECT merchant_id FROM merchants WHERE slug = $1") .bind(&final_slug) .fetch_optional(pool) .await .map_err(AppError::Database)?; if taken.is_none() { break; } attempts += 1; if attempts > 5 { return Err(AppError::Internal("Could not generate unique slug".into())); } final_slug = format!("{}-{}", base_slug, &Uuid::new_v4().to_string()[..4]); } sqlx::query( r#"INSERT INTO merchants (merchant_id, email, password_hash, brand_name, slug, session_version, delivery_rate_per_km, delivery_base_fee, base_pincode, auto_settle_threshold, trust_score, verification_level, max_order_value_inr, plan, role, logistics_config) VALUES ($1,$2,'oauth_no_password',$3,$4, 1,10.0,20.0,'560001',0.5,100.0, 'UNVERIFIED',10000.0,'FREE','MERCHANT', '{"complexity_bias":1.0,"weight_coefficient":0.02,"distance_coefficient":1.0}')"#, ) .bind(&new_id) .bind(&profile.email) .bind(&brand_name) .bind(&final_slug) .execute(pool) .await .map_err(AppError::Database)?; new_id }; // ── Step 4: Link the OAuth account ──────────────────────────────── sqlx::query( r#"INSERT INTO oauth_accounts (id, merchant_id, provider, provider_user_id, email, display_name, avatar_url) VALUES ($1,$2,$3,$4,$5,$6,$7) ON CONFLICT (provider, provider_user_id) DO NOTHING"#, ) .bind(Uuid::new_v4().to_string()) .bind(&mid) .bind(profile.provider) .bind(&profile.provider_user_id) .bind(&profile.email) .bind(&profile.display_name) .bind(&profile.avatar_url) .execute(pool) .await .map_err(AppError::Database)?; (mid, existing_merchant_was_none(&profile.email, pool).await) }; // ── Step 5: Load merchant & issue JWT ───────────────────────────────── let merchant = sqlx::query( "SELECT merchant_id, brand_name, slug, role, session_version FROM merchants WHERE merchant_id = $1" ) .bind(&merchant_id) .fetch_one(pool) .await .map_err(AppError::Database)?; let brand_name: String = merchant.get("brand_name"); let slug: String = merchant.get("slug"); let role: String = merchant.get("role"); let session_version: i64 = merchant.get("session_version"); // Issue a JWT identical in structure to the password-auth JWT let claims = crate::interfaces::http::routes::auth::Claims { sub: merchant_id.clone(), email: profile.email, brand_name: brand_name.clone(), slug: slug.clone(), role: Some(role.clone()), version: session_version, exp: session::access_token_expiry(), }; let jwt_token = jsonwebtoken::encode( &jsonwebtoken::Header::default(), &claims, &jsonwebtoken::EncodingKey::from_secret(jwt_secret), ) .map_err(|e| AppError::Internal(format!("JWT encoding failed: {}", e)))?; Ok(OAuthResult { merchant_id, brand_name, slug, role, jwt_token, is_new_user, }) } // Helper: was the email absent from merchants before we created it? // We call this after insert so we read from the row we just made. async fn existing_merchant_was_none(email: &str, pool: &DbPool) -> bool { sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM oauth_accounts WHERE email = $1") .bind(email) .fetch_one(pool) .await .unwrap_or(0) <= 1 // If only 1 row exists, this is a brand-new user }