RTIX / src /application /services /oauth.rs
github-actions
deploy: clean backend production release
7856e60
Raw
History Blame Contribute Delete
17.8 kB
/// 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<String>,
pub avatar_url: Option<String>,
}
// ─── 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<String>,
picture: Option<String>,
}
// ─── GitHub Raw API Types ─────────────────────────────────────────────────────
#[derive(Deserialize)]
struct GithubUser {
id: u64,
login: String,
name: Option<String>,
avatar_url: Option<String>,
email: Option<String>,
}
#[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::<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.
#[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<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
}