Spaces:
Sleeping
Sleeping
| /// 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 ─────────────────────────────────────────────────── | |
| 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) ───────────────────────────── | |
| pub struct OAuthProfile { | |
| pub provider: &'static str, // "google" | "github" | |
| pub provider_user_id: String, | |
| pub email: String, | |
| pub display_name: Option<String>, | |
| pub avatar_url: Option<String>, | |
| } | |
| // ─── Token Exchange Response (shared shape) ─────────────────────────────────── | |
| struct TokenResponse { | |
| access_token: String, | |
| } | |
| // ─── Google Raw API Types ───────────────────────────────────────────────────── | |
| struct GoogleUserInfo { | |
| sub: String, // unique Google user ID | |
| email: String, | |
| name: Option<String>, | |
| picture: Option<String>, | |
| } | |
| // ─── GitHub Raw API Types ───────────────────────────────────────────────────── | |
| struct GithubUser { | |
| id: u64, | |
| login: String, | |
| name: Option<String>, | |
| avatar_url: Option<String>, | |
| email: Option<String>, | |
| } | |
| 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::<Vec<_>>() | |
| .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<OAuthProfile> { | |
| 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::<serde_json::Value>() | |
| .await | |
| .map_err(|e| AppError::Internal(format!("Google token parse failed: {}", e))) | |
| .and_then(|v| { | |
| serde_json::from_value::<TokenResponse>(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::<Vec<_>>() | |
| .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<OAuthProfile> { | |
| 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::<serde_json::Value>() | |
| .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<GithubEmail> = 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. | |
| 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<OAuthResult> { | |
| 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::<String, _>("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::<String, _>("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::<Vec<_>>() | |
| .join("-") | |
| .chars() | |
| .filter(|c| c.is_alphanumeric() || *c == '-') | |
| .collect::<String>(); | |
| // Collision-safe slug | |
| let mut final_slug = base_slug.clone(); | |
| let mut attempts = 0u8; | |
| loop { | |
| let taken: Option<String> = | |
| 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 | |
| } | |