d5939db
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<OAuthProviderInfo> {
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<AuthorizationUrlResp, AuthError> {
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<ProviderIdentity, AuthError> {
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<OAuthLoginResp, AuthError> {
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,
})
}