use axum::{extract::State, http::HeaderMap, response::IntoResponse, Json};
use chrono::{Duration, Utc};
use std::sync::Arc;
use crate::callback::{AuthCallback, AuthCallbackPayload};
use crate::errors::AppError;
use crate::handlers::auth::call_authenticated_callback_with_timeout;
use crate::models::{AuthMethod, AuthResponse, LinkOAuthRequest};
use crate::repositories::{normalize_email, AuditEventType, SessionEntity};
use crate::services::EmailService;
use crate::utils::{
build_json_response_with_cookies, compute_post_login, extract_client_ip_with_fallback,
get_default_org_context, hash_refresh_token, user_entity_to_auth_user, PeerIp,
};
use crate::AppState;
pub async fn link_oauth<C: AuthCallback, E: EmailService>(
State(state): State<Arc<AppState<C, E>>>,
headers: HeaderMap,
PeerIp(peer_ip): PeerIp,
Json(req): Json<LinkOAuthRequest>,
) -> Result<impl IntoResponse, AppError> {
let (auth_method, oauth_sub, oauth_email) = match req.provider.as_str() {
"google" => {
let client_id = resolve_google_client_id(&state).await?;
let claims = state
.google_service
.verify_id_token(&req.id_token, &client_id)
.await?;
let email = claims
.email
.ok_or(AppError::Validation("Email not provided by Google".into()))?;
(AuthMethod::Google, claims.sub, normalize_email(&email))
}
"apple" => {
let client_id = resolve_apple_client_id(&state).await?;
let claims = state
.apple_service
.verify_id_token(&req.id_token, &client_id)
.await?;
let email = claims
.email
.ok_or(AppError::Validation("Email not provided by Apple".into()))?;
(AuthMethod::Apple, claims.sub, normalize_email(&email))
}
_ => {
return Err(AppError::Validation(
"provider must be \"google\" or \"apple\"".into(),
))
}
};
let user = state.user_repo.find_by_email(&oauth_email).await?;
let user = match user {
None => {
state
.password_service
.verify_dummy(req.password.clone())
.await;
return Err(AppError::InvalidCredentials);
}
Some(u) => match &u.password_hash {
None => {
state
.password_service
.verify_dummy(req.password.clone())
.await;
return Err(AppError::InvalidCredentials);
}
Some(hash) => {
if !state
.password_service
.verify(req.password.clone(), hash.clone())
.await?
{
return Err(AppError::InvalidCredentials);
}
u
}
},
};
let conflict = match auth_method {
AuthMethod::Google => state.user_repo.find_by_google_id(&oauth_sub).await?,
AuthMethod::Apple => state.user_repo.find_by_apple_id(&oauth_sub).await?,
_ => None,
};
if let Some(other) = conflict {
if other.id != user.id {
return Err(AppError::Validation(
"OAuth account already linked to another user".into(),
));
}
}
let mut updated = user.clone();
let now = Utc::now();
updated.updated_at = now;
updated.last_login_at = Some(now);
match auth_method {
AuthMethod::Google => {
updated.google_id = Some(oauth_sub);
if !updated.auth_methods.contains(&AuthMethod::Google) {
updated.auth_methods.push(AuthMethod::Google);
}
}
AuthMethod::Apple => {
updated.apple_id = Some(oauth_sub);
if !updated.auth_methods.contains(&AuthMethod::Apple) {
updated.auth_methods.push(AuthMethod::Apple);
}
}
_ => unreachable!(),
}
let user = state.user_repo.update(updated).await?;
let memberships = state.membership_repo.find_by_user(user.id).await?;
let token_context =
get_default_org_context(&memberships, user.is_system_admin, user.email_verified);
let session_id = uuid::Uuid::new_v4();
let token_pair =
state
.jwt_service
.generate_token_pair_with_context(user.id, session_id, &token_context)?;
let refresh_expiry =
Utc::now() + Duration::seconds(state.jwt_service.refresh_expiry_secs() as i64);
let ip_address =
extract_client_ip_with_fallback(&headers, state.config.server.trust_proxy, peer_ip);
let user_agent = headers
.get(axum::http::header::USER_AGENT)
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
let mut session = SessionEntity::new_with_id(
session_id,
user.id,
hash_refresh_token(&token_pair.refresh_token, &state.config.jwt.secret),
refresh_expiry,
ip_address.clone(),
user_agent.clone(),
);
session.last_strong_auth_at = Some(Utc::now());
state.session_repo.create(session).await?;
let auth_user = user_entity_to_auth_user(&user);
let payload = AuthCallbackPayload {
user: auth_user.clone(),
method: auth_method,
is_new_user: false,
session_id: session_id.to_string(),
ip_address,
user_agent,
};
let callback_data = call_authenticated_callback_with_timeout(&state.callback, &payload).await;
let _ = state
.audit_service
.log_user_event(AuditEventType::UserLogin, user.id, Some(&headers))
.await;
let response_tokens = if state.config.cookie.enabled {
None
} else {
Some(token_pair.clone())
};
let response = AuthResponse {
user: auth_user,
tokens: response_tokens,
is_new_user: false,
callback_data,
api_key: None,
email_queued: None,
post_login: compute_post_login(&user, &state.settings_service, &*state.totp_repo, &*state.credential_repo).await,
};
Ok(build_json_response_with_cookies(
&state.config.cookie,
&token_pair,
state.jwt_service.refresh_expiry_secs(),
response,
))
}
async fn resolve_google_client_id<C: AuthCallback, E: EmailService>(
state: &AppState<C, E>,
) -> Result<String, AppError> {
state
.settings_service
.get("auth_google_client_id")
.await
.ok()
.flatten()
.filter(|s| !s.is_empty())
.or_else(|| state.config.google.client_id.clone())
.ok_or_else(|| AppError::Config("Google client ID not configured".into()))
}
async fn resolve_apple_client_id<C: AuthCallback, E: EmailService>(
state: &AppState<C, E>,
) -> Result<String, AppError> {
state
.settings_service
.get("auth_apple_client_id")
.await
.ok()
.flatten()
.filter(|s| !s.is_empty())
.or_else(|| state.config.apple.client_id.clone())
.ok_or_else(|| AppError::Config("Apple client ID not configured".into()))
}