use base64::Engine; use rand::Rng; use time::OffsetDateTime; use url::Url; use uuid::Uuid; use db::conn::sea_orm::{ ActiveModelTrait, DatabaseConnection, EntityTrait, QueryFilter, TransactionTrait, }; use db::user::user_emails::{ ActiveModel as EmailActiveModel, Entity as EmailEntity, }; use db::user::user_oauth_provider::{ Column as ProviderColumn, ActiveModel as ProviderActiveModel, Entity as ProviderEntity, }; use db::user::users::{ActiveModel as UserActiveModel, Entity as UserEntity, ProfileVisibility}; use jwt::handlers::TokenPairResponse; use jwt::rotation::{RotationContext, issue_login_pair}; use cache::redis::RedisError; use crate::config::{AuthConfig, OAuthProviderConfig}; use crate::error::AuthError; use crate::features::AuthService; use crate::features::TempKey; use super::oauth_apple::fetch_apple_identity; use super::oauth_github::fetch_github_identity; use super::oauth_google::fetch_google_identity; use super::types::{ AuthorizationUrlResp, AuthorizeCmd, CallbackCmd, OAuthLoginResp, OAuthProvider, OAuthProviderInfo, ProviderIdentity, }; fn map_redis(e: RedisError) -> AuthError { AuthError::Internal(format!("redis: {e}")) } fn generate_state() -> String { let mut buf = vec![0u8; 32]; rand::rng().fill_bytes(&mut buf); base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(buf) } fn sha256_hex(data: &[u8]) -> String { use sha2::Digest; let digest = sha2::Sha256::digest(data); base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(digest) } fn redis_state_key(uuid: &Uuid) -> String { format!("auth:oauth_state:{}", uuid.simple()) } pub(crate) fn cookie_name(prefix: &str, provider: OAuthProvider) -> String { format!("{}oauth_state_{}", prefix, provider.as_str()) } fn lower_email(email: &str) -> String { email.trim().to_ascii_lowercase() } fn derive_username(email: &str) -> String { let at = email.find('@').unwrap_or(email.len()); email[..at].to_string() } /// Return the enabled providers (those configured + flagged enabled). pub fn list_providers(config: &AuthConfig) -> Vec { let mut out = Vec::new(); if let Some(p) = config.oauth.google.as_ref() && p.enabled { out.push(OAuthProviderInfo { name: "google".into(), display_name: "Google".into(), authorize_url: p.authorize_url.clone(), }); } if let Some(p) = config.oauth.github.as_ref() && p.enabled { out.push(OAuthProviderInfo { name: "github".into(), display_name: "GitHub".into(), authorize_url: p.authorize_url.clone(), }); } if let Some(p) = config.oauth.apple.as_ref() && p.enabled { out.push(OAuthProviderInfo { name: "apple".into(), display_name: "Apple".into(), authorize_url: p.authorize_url.clone(), }); } out } fn get_provider_cfg( config: &AuthConfig, p: OAuthProvider, ) -> Result<&OAuthProviderConfig, AuthError> { let cfg = match p { OAuthProvider::Google => config.oauth.google.as_ref(), OAuthProvider::Github => config.oauth.github.as_ref(), OAuthProvider::Apple => config.oauth.apple.as_ref(), } .ok_or_else(|| AuthError::BadRequest(format!("provider {} not configured", p.as_str())))?; if !cfg.enabled { return Err(AuthError::BadRequest(format!( "provider {} disabled", p.as_str() ))); } Ok(cfg) } /// Build the provider's authorize URL with `state` embedded, store `state` in /// Redis keyed by a fresh UUID, and return the URL + a TempKey the caller /// turns into an HttpOnly+Secure cookie. pub async fn oauth_authorize( cmd: AuthorizeCmd, svc: &AuthService, ) -> Result { let cfg = get_provider_cfg(&svc.config, cmd.provider)?; let temp_id = Uuid::new_v4(); let state = generate_state(); let ttl = svc.config.oauth_state_ttl_secs; // VULN-005: derive code_challenge = BASE64URL(SHA256(verifier)) per RFC 7636. let code_challenge = { use sha2::Digest; let digest = sha2::Sha256::digest(cmd.code_verifier.as_bytes()); base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(digest) }; // Persist state + verifier together so the callback can recover both. let payload = serde_json::json!({ "state": state, "code_verifier": cmd.code_verifier, }) .to_string(); svc.kv .set_bytes(&redis_state_key(&temp_id), payload.into_bytes(), ttl) .await .map_err(map_redis)?; let mut url = Url::parse(&cfg.authorize_url) .map_err(|e| AuthError::Internal(format!("bad authorize_url: {e}")))?; { let mut q = url.query_pairs_mut(); q.append_pair("response_type", "code"); q.append_pair("client_id", &cfg.client_id); q.append_pair("redirect_uri", &cfg.redirect_uri); q.append_pair("scope", &cfg.scopes.join(" ")); q.append_pair("state", &state); q.append_pair("code_challenge", &code_challenge); q.append_pair("code_challenge_method", "S256"); } let temp_key = TempKey { name: cookie_name(&svc.config.cookie_name_prefix, cmd.provider), value: temp_id.to_string(), ttl_seconds: ttl, secure: svc.config.cookie_secure, http_only: true, same_site: svc.config.cookie_same_site.clone(), domain: svc.config.cookie_domain.clone(), path: Some("/".to_string()), }; Ok(AuthorizationUrlResp { url: url.to_string(), temp_key, }) } /// Fetch the provider identity for the given code. Dispatches to the /// provider-specific implementation. `code_verifier` is the PKCE verifier /// recovered from server-side state and must be sent to the provider's /// token endpoint. async fn fetch_identity( provider: OAuthProvider, code: &str, code_verifier: &str, cfg: &OAuthProviderConfig, client: &reqwest::Client, ) -> Result { match provider { OAuthProvider::Google => fetch_google_identity(code, code_verifier, cfg, client).await, OAuthProvider::Github => fetch_github_identity(code, code_verifier, cfg, client).await, OAuthProvider::Apple => fetch_apple_identity(code, code_verifier, cfg, client).await, } } /// Resolve the local user for an OAuth identity. If the identity links to an /// existing account, returns that user. Otherwise, if `email_verified` is /// true and an existing local account owns the email, links the provider to /// that user. Otherwise auto-creates a passwordless account. async fn resolve_or_create_user( identity: &ProviderIdentity, db: &DatabaseConnection, ) -> Result<(db::user::users::Model, bool), AuthError> { // 1. Existing link? let link = ProviderEntity::find() .filter(ProviderEntity::COLUMN.provider.eq(identity.provider.as_str().to_string())) .filter(ProviderEntity::COLUMN.provider_user_id.eq(identity.provider_user_id.clone())) .find_also_related(UserEntity) .one(db) .await?; if let Some((_link, Some(user))) = link { return Ok((user, false)); } // 2. Email match (only when provider verified the email). if identity.email_verified && let Some(ref email) = identity.email { let lower = lower_email(email); if let Some((em, Some(user))) = EmailEntity::find() .filter(EmailEntity::COLUMN.lower_email.eq(lower)) .filter(EmailEntity::COLUMN.deleted_at.is_null()) .find_also_related(UserEntity) .one(db) .await? { // Link provider identity to existing user. let now = OffsetDateTime::now_utc(); let am: ProviderActiveModel = ProviderActiveModel::builder() .set_id(Uuid::new_v4()) .set_user_id(em.user_id) .set_provider(identity.provider.as_str().to_string()) .set_provider_user_id(identity.provider_user_id.clone()) .set_provider_username(identity.provider_username.clone()) .set_access_token_hash(Some(sha256_hex(identity.access_token.as_bytes()))) .set_refresh_token_hash(identity .refresh_token .as_ref() .map(|t| sha256_hex(t.as_bytes()))) .set_token_expires_at(identity.expires_at) .set_scopes(identity.scopes.clone()) .set_created_at(now) .set_updated_at(now) .set_last_used_at(Some(now)) .into(); // TOCTOU: a concurrent request may have already linked this // provider identity. ON CONFLICT DO NOTHING turns the unique // constraint violation into a no-op; re-read the winning row. let inserted = ProviderEntity::insert(am) .on_conflict_do_nothing_on([ ProviderColumn::Provider, ProviderColumn::ProviderUserId, ]) .exec(db) .await?; match inserted { db::conn::sea_orm::TryInsertResult::Inserted(_) => return Ok((user, false)), db::conn::sea_orm::TryInsertResult::Conflicted => { let winner = ProviderEntity::find() .filter(ProviderEntity::COLUMN.provider .eq(identity.provider.as_str().to_string())) .filter(ProviderEntity::COLUMN.provider_user_id .eq(identity.provider_user_id.clone())) .find_also_related(UserEntity) .one(db) .await?; if let Some((_l, Some(winning_user))) = winner { return Ok((winning_user, false)); } return Err(AuthError::Internal( "provider link conflicted but not found".into(), )); } db::conn::sea_orm::TryInsertResult::Empty => unreachable!("single insert"), } } } // 3. Auto-register a passwordless account. let now = OffsetDateTime::now_utc(); let uid = Uuid::new_v4(); let email = identity.email.clone().ok_or_else(|| { AuthError::BadRequest( "provider did not return a verified email; cannot auto-register".into(), ) })?; if !identity.email_verified { return Err(AuthError::BadRequest( "provider did not verify the email; cannot auto-register".into(), )); } let username = derive_username(&email); let display_name = if !identity.provider_username.is_empty() { identity.provider_username.clone() } else { username.clone() }; let txn = db.begin().await?; let user_am: UserActiveModel = UserActiveModel::builder() .set_uid(uid) .set_username(username.clone()) .set_lower_name(username.to_ascii_lowercase()) .set_display_name(display_name.clone()) .set_avatar(String::new()) .set_is_active(true) .set_is_admin(false) .set_is_restricted(false) .set_prohibit_login(false) .set_visibility(ProfileVisibility::Public) .set_bot(false) .set_system(false) .set_last_login_at(None) .set_last_login_ip_hash(None) .set_version(1) .set_passwd_hash(None) .set_password_last_changed_at(None) .set_passwd_version(Some(1)) .set_totp_encrypted_secret(None) .set_totp_enabled(false) .set_totp_last_used_at(None) .set_totp_last_counter(None) .set_timezone(None) .set_locale(None) .set_language(None) .set_created_at(now) .set_updated_at(now) .into(); user_am.insert(&txn).await?; let email_am: EmailActiveModel = EmailActiveModel::builder() .set_id(Uuid::new_v4()) .set_user_id(uid) .set_email(email.clone()) .set_lower_email(lower_email(&email)) .set_is_activated(true) .set_is_primary(true) .set_created_at(now) .set_updated_at(now) .set_activated_at(Some(now)) .set_deleted_at(None) .into(); email_am.insert(&txn).await?; let provider_am: ProviderActiveModel = ProviderActiveModel::builder() .set_id(Uuid::new_v4()) .set_user_id(uid) .set_provider(identity.provider.as_str().to_string()) .set_provider_user_id(identity.provider_user_id.clone()) .set_provider_username(identity.provider_username.clone()) .set_access_token_hash(Some(sha256_hex(identity.access_token.as_bytes()))) .set_refresh_token_hash(identity .refresh_token .as_ref() .map(|t| sha256_hex(t.as_bytes()))) .set_token_expires_at(identity.expires_at) .set_scopes(identity.scopes.clone()) .set_created_at(now) .set_updated_at(now) .set_last_used_at(Some(now)) .into(); // TOCTOU: a concurrent request may have already created this provider // identity between our lookup and this transaction. ON CONFLICT DO // NOTHING makes the loser roll back its partial user/email rows and // re-read the winner's link instead of failing with a unique violation. let inserted_provider = ProviderEntity::insert(provider_am) .on_conflict_do_nothing_on([ ProviderColumn::Provider, ProviderColumn::ProviderUserId, ]) .exec(&txn) .await?; match inserted_provider { db::conn::sea_orm::TryInsertResult::Inserted(_) => {} db::conn::sea_orm::TryInsertResult::Conflicted => { txn.rollback().await?; let winner = ProviderEntity::find() .filter(ProviderEntity::COLUMN.provider.eq(identity.provider.as_str().to_string())) .filter(ProviderEntity::COLUMN.provider_user_id.eq(identity.provider_user_id.clone())) .find_also_related(UserEntity) .one(db) .await?; if let Some((_l, Some(winning_user))) = winner { return Ok((winning_user, false)); } return Err(AuthError::Internal( "provider link conflicted but not found".into(), )); } db::conn::sea_orm::TryInsertResult::Empty => unreachable!("single insert"), } txn.commit().await?; let user = UserEntity::find_by_id(uid) .one(db) .await? .ok_or_else(|| AuthError::Internal("just-created user missing".into()))?; Ok((user, true)) } /// Final OAuth callback: validates state, exchanges code for identity, links /// or auto-creates a local user, and returns a JWT token pair. pub async fn oauth_callback( cmd: CallbackCmd, svc: &AuthService, ) -> Result { let db = svc.db.writer(); // Validate state. let temp_id = Uuid::parse_str(&cmd.temp_key_value)?; let key = redis_state_key(&temp_id); let stored_raw = svc .kv .get_bytes(&key) .await .map_err(map_redis)? .ok_or_else(|| AuthError::BadRequest("oauth state expired".into()))?; svc.kv.delete(&key).await.map_err(map_redis)?; let stored: serde_json::Value = serde_json::from_slice(&stored_raw) .map_err(|e| AuthError::Internal(format!("oauth state parse: {e}")))?; if stored["state"].as_str() != Some(cmd.state.as_str()) { return Err(AuthError::BadRequest("oauth state mismatch".into())); } let code_verifier = stored["code_verifier"] .as_str() .ok_or_else(|| AuthError::BadRequest("missing code_verifier".into()))?; let cfg = get_provider_cfg(&svc.config, cmd.provider)?; let client = reqwest::Client::new(); let identity = fetch_identity(cmd.provider, &cmd.code, code_verifier, cfg, &client).await?; let (user, auto_registered) = resolve_or_create_user(&identity, &db).await?; if user.prohibit_login { return Err(AuthError::Forbidden("login prohibited".into())); } if user.is_restricted { return Err(AuthError::Forbidden("account restricted".into())); } if !user.is_active { return Err(AuthError::Forbidden("account inactive".into())); } // Issue tokens. let cache = svc.jwt.cache.read().await; let ctx = RotationContext { db: &db, settings: &svc.jwt.settings, cache: &cache, }; let pair = issue_login_pair( &ctx, user.uid, user.username.clone(), user.is_admin, user.is_restricted, user.passwd_version.unwrap_or(0), cmd.app_id.clone(), ) .await?; drop(cache); let resp: TokenPairResponse = pair.into(); // Auto-record configured agreements on successful login. for doc in svc.config.agreements.iter() { let _ = svc.accept_agreement(user.uid, &doc.key, &doc.version).await; } Ok(OAuthLoginResp { access_token: resp.access_token, refresh_token: resp.refresh_token, token_type: resp.token_type, expires_in: resp.expires_in, user_id: user.uid, auto_registered, }) }