// ABOUTME: User authentication route handlers for registration, login, and OAuth flows
// ABOUTME: Provides REST endpoints for user account management and fitness provider OAuth callbacks
//
// SPDX-License-Identifier: MIT OR Apache-2.0
// Copyright (c) 2025 Pierre Fitness Intelligence
//! Authentication routes for user management and OAuth flows
//!
//! This module handles user registration, login, and OAuth callback processing
//! for fitness providers like Strava. All handlers are thin wrappers that
//! delegate business logic to service layers.
use std::{
collections::{HashMap, HashSet},
fmt::Write,
sync::Arc,
time::Duration as StdDuration,
};
use axum::{
extract::{Form, Path, Query, State},
http::{header, HeaderMap, StatusCode},
response::{IntoResponse, Response},
Json, Router,
};
use chrono::Utc;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value as JsonValue};
use tokio::task;
use tracing::{debug, error, field::Empty, info, warn, Span};
use urlencoding::encode;
use crate::{
admin::{AdminAuthService, FirebaseAuth, FirebaseClaims},
config::environment::get_oauth_config,
constants::{error_messages, limits, tiers},
context::{AuthContext, ConfigContext, DataContext, NotificationContext, ServerContext},
database_plugins::{factory::Database, DatabaseProvider},
errors::{AppError, AppResult, ErrorCode},
mcp::{
oauth_flow_manager::OAuthTemplateRenderer, resources::ServerResources,
schema::OAuthCompletedNotification,
},
models::{Tenant, User, UserOAuthToken, UserStatus, UserTier},
oauth2_client::{OAuth2Client, OAuth2Config, OAuth2Token},
permissions::UserRole,
security::cookies::{clear_auth_cookie, get_cookie_value, set_auth_cookie, set_csrf_cookie},
tenant::{TenantContext, TenantRole},
utils::{
auth::extract_bearer_token_owned,
errors::{auth_error, user_state_error, validation_error},
http_client::get_oauth_callback_notification_timeout_secs,
uuid::parse_user_id,
},
};
/// User registration request
#[derive(Debug, Clone, Deserialize)]
pub struct RegisterRequest {
/// User's email address
pub email: String,
/// User's password (will be hashed)
pub password: String,
/// Optional display name for the user
pub display_name: Option<String>,
}
/// User registration response
#[derive(Debug, Serialize)]
pub struct RegisterResponse {
/// Unique identifier for the newly created user
pub user_id: String,
/// Success message for the registration
pub message: String,
}
/// User login request
#[derive(Debug, Deserialize)]
pub struct LoginRequest {
/// User's email address
pub email: String,
/// User's password
pub password: String,
}
/// Firebase login request - authenticate with Firebase ID token
#[derive(Debug, Deserialize)]
pub struct FirebaseLoginRequest {
/// Firebase ID token from client-side Firebase SDK
pub id_token: String,
}
/// User info for login response
#[derive(Debug, Serialize)]
pub struct UserInfo {
/// Unique identifier for the user
pub user_id: String,
/// User's email address
pub email: String,
/// User's display name if set
pub display_name: Option<String>,
/// Whether the user has admin privileges (legacy - use role instead)
pub is_admin: bool,
/// User role for permission system (`super_admin`, `admin`, `user`)
pub role: String,
/// User account status (`pending`, `active`, `suspended`)
pub user_status: String,
}
/// User login response
#[derive(Debug, Serialize)]
pub struct LoginResponse {
/// JWT authentication token (optional, set in httpOnly cookie)
#[serde(skip_serializing_if = "Option::is_none")]
pub jwt_token: Option<String>,
/// CSRF token for request validation (client must include in X-CSRF-Token header)
pub csrf_token: String,
/// When the token expires (ISO 8601 format)
pub expires_at: String,
/// User information
pub user: UserInfo,
}
/// User profile update request
#[derive(Debug, Deserialize)]
pub struct UpdateProfileRequest {
/// New display name for the user
pub display_name: String,
}
/// User profile update response
#[derive(Debug, Serialize)]
pub struct UpdateProfileResponse {
/// Success message
pub message: String,
/// Updated user information
pub user: UserInfo,
}
/// User stats response for dashboard
#[derive(Debug, Serialize)]
pub struct UserStatsResponse {
/// Number of connected fitness providers
pub connected_providers: i64,
/// Number of days the user has been active
pub days_active: i64,
}
/// Refresh token request
#[derive(Debug, Deserialize)]
pub struct RefreshTokenRequest {
/// Current JWT token to refresh
pub token: String,
/// User ID for validation
pub user_id: String,
}
/// `OAuth2` ROPC (Resource Owner Password Credentials) token request
/// Per RFC 6749 Section 4.3 - uses form-encoded body
#[derive(Debug, Deserialize)]
pub struct OAuth2TokenRequest {
/// Grant type - must be "password" for ROPC
pub grant_type: String,
/// User's email address (RFC calls this "username")
pub username: String,
/// User's password
pub password: String,
/// `OAuth2` client identifier (optional for first-party clients)
pub client_id: Option<String>,
/// `OAuth2` client secret (optional for public clients)
pub client_secret: Option<String>,
/// Requested `OAuth2` scopes (optional, space-separated)
pub scope: Option<String>,
}
/// `OAuth2` token response per RFC 6749 Section 5.1
/// Extended with optional user info for frontend compatibility
#[derive(Debug, Serialize)]
pub struct OAuth2TokenResponse {
/// The access token issued by the authorization server
pub access_token: String,
/// The type of the token issued (always "Bearer")
pub token_type: String,
/// The lifetime in seconds of the access token
pub expires_in: i64,
/// Optional refresh token for obtaining new access tokens
#[serde(skip_serializing_if = "Option::is_none")]
pub refresh_token: Option<String>,
/// The scope of the access token (space-separated)
#[serde(skip_serializing_if = "Option::is_none")]
pub scope: Option<String>,
// --- Pierre extensions (allowed per RFC 6749 Section 5.1) ---
/// User information (Pierre extension for frontend compatibility)
#[serde(skip_serializing_if = "Option::is_none")]
pub user: Option<UserInfo>,
/// CSRF token for web clients (Pierre extension)
#[serde(skip_serializing_if = "Option::is_none")]
pub csrf_token: Option<String>,
}
/// `OAuth2` error response per RFC 6749 Section 5.2
#[derive(Debug, Serialize)]
pub struct OAuth2ErrorResponse {
/// Error code per RFC 6749 (e.g., `invalid_grant`, `invalid_client`)
pub error: String,
/// Human-readable description of the error
#[serde(skip_serializing_if = "Option::is_none")]
pub error_description: Option<String>,
}
/// OAuth provider connection status
#[derive(Debug, Serialize)]
pub struct OAuthStatus {
/// Name of the OAuth provider (e.g., "strava", "google")
pub provider: String,
/// Whether the user is currently connected to this provider
pub connected: bool,
/// When the last sync occurred (ISO 8601 format)
pub last_sync: Option<String>,
}
/// Setup status response for admin setup endpoint
#[derive(Debug, Clone, Serialize)]
pub struct SetupStatusResponse {
/// Whether the system needs initial setup
pub needs_setup: bool,
/// Whether an admin user already exists
pub admin_user_exists: bool,
/// Optional status message
pub message: Option<String>,
}
/// OAuth authorization response for provider auth URLs
#[derive(Debug, Serialize)]
pub struct OAuthAuthorizationResponse {
/// URL to redirect user to for OAuth authorization
pub authorization_url: String,
/// CSRF state token for validating callback
pub state: String,
/// Human-readable instructions for the user
pub instructions: String,
/// How long the authorization URL is valid (minutes)
pub expires_in_minutes: i64,
}
/// Connection status for fitness providers
#[derive(Debug, Serialize)]
pub struct ConnectionStatus {
/// Name of the fitness provider (e.g., "strava", "garmin")
pub provider: String,
/// Whether the user is connected to this provider
pub connected: bool,
/// When the connection expires (ISO 8601 format)
pub expires_at: Option<String>,
/// Space-separated list of granted OAuth scopes
pub scopes: Option<String>,
}
/// Authentication service for business logic
#[derive(Clone)]
pub struct AuthService {
auth: AuthContext,
config: ConfigContext,
data: DataContext,
}
impl AuthService {
/// Creates a new authentication service
#[must_use]
pub const fn new(auth: AuthContext, config: ConfigContext, data: DataContext) -> Self {
Self { auth, config, data }
}
/// Handle user registration - implementation from existing routes.rs
///
/// # Errors
/// Returns error if user validation fails or database operation fails
#[tracing::instrument(skip(self, request), fields(route = "register", email = %request.email))]
pub async fn register(&self, request: RegisterRequest) -> AppResult<RegisterResponse> {
info!("User registration attempt for email: {}", request.email);
// Validate email format
if !Self::is_valid_email(&request.email) {
return Err(validation_error(error_messages::INVALID_EMAIL_FORMAT));
}
// Validate password strength
if !Self::is_valid_password(&request.password) {
return Err(validation_error(error_messages::PASSWORD_TOO_WEAK));
}
// Check if user already exists
if let Ok(Some(_)) = self.data.database().get_user_by_email(&request.email).await {
return Err(user_state_error(error_messages::USER_ALREADY_EXISTS));
}
// Hash password
let password_hash = bcrypt::hash(&request.password, bcrypt::DEFAULT_COST)
.map_err(|e| AppError::internal(format!("Password hashing failed: {e}")))?;
// Create user with default Pending status
let mut user = User::new(request.email.clone(), password_hash, request.display_name); // Safe: String ownership needed for user model
// Check if auto-approval is enabled (database setting takes precedence over config)
if self.is_auto_approval_enabled().await {
user.user_status = UserStatus::Active;
user.approved_at = Some(Utc::now());
info!("Auto-approving user registration (auto_approval_enabled=true)");
}
// Save user to database
let user_id = self
.data
.database()
.create_user(&user)
.await
.map_err(|e| AppError::database(format!("Failed to create user: {e}")))?;
// Create a personal tenant for the user (required for MCP operations)
let display_name = user
.display_name
.as_deref()
.unwrap_or_else(|| request.email.split('@').next().unwrap_or("user"));
let tenant_id = self
.create_personal_tenant(user_id, display_name, tiers::STARTER)
.await?;
// Assign user to their personal tenant
self.data
.database()
.update_user_tenant_id(user_id, &tenant_id.to_string())
.await
.map_err(|e| {
error!("Failed to assign user to tenant: {}", e);
AppError::database(format!("Failed to assign tenant: {e}"))
})?;
info!(user_id = %user_id, email = %request.email, "User registered successfully");
let message = if user.user_status == UserStatus::Active {
"User registered successfully. Your account is ready to use.".to_owned()
} else {
"User registered successfully. Your account is pending admin approval.".to_owned()
};
Ok(RegisterResponse {
user_id: user_id.to_string(),
message,
})
}
/// Handle user login - implementation from existing routes.rs
///
/// # Errors
/// Returns error if authentication fails or token generation fails
#[tracing::instrument(skip(self, request), fields(route = "login", email = %request.email))]
pub async fn login(&self, request: LoginRequest) -> AppResult<LoginResponse> {
info!("User login attempt for email: {}", request.email);
// Get user from database
let user = self
.data
.database()
.get_user_by_email_required(&request.email)
.await
.map_err(|e| {
debug!(email = %request.email, error = %e, "Login failed: user lookup error");
AppError::auth_invalid("Invalid email or password")
})?;
// Verify password using spawn_blocking to avoid blocking async executor
let password = request.password.clone();
let password_hash = user.password_hash.clone();
let is_valid = task::spawn_blocking(move || bcrypt::verify(&password, &password_hash))
.await
.map_err(|e| AppError::internal(format!("Password verification task failed: {e}")))?
.map_err(|_| AppError::auth_invalid("Invalid email or password"))?;
if !is_valid {
error!("Invalid password for user: {}", request.email);
return Err(auth_error(error_messages::INVALID_CREDENTIALS));
}
// Log user status for auditing (pending/suspended users can authenticate
// but frontend restricts access based on user_status)
if !user.user_status.can_login() {
info!(
"User login with restricted status: {} - status: {:?}",
request.email, user.user_status
);
}
// Update last active timestamp
self.data
.database()
.update_last_active(user.id)
.await
.map_err(|e| AppError::database(format!("Failed to update last active: {e}")))?;
// Generate JWT token using RS256
let jwt_token = self
.auth
.auth_manager()
.generate_token(&user, self.auth.jwks_manager())
.map_err(|e| AppError::auth_invalid(format!("Failed to generate token: {e}")))?;
let expires_at =
chrono::Utc::now() + chrono::Duration::hours(limits::DEFAULT_SESSION_HOURS); // Default 24h expiry
info!(
"User logged in successfully: {} ({})",
request.email, user.id
);
Ok(LoginResponse {
jwt_token: Some(jwt_token),
csrf_token: String::new(), // Will be set by HTTP handler
expires_at: expires_at.to_rfc3339(),
user: UserInfo {
user_id: user.id.to_string(),
email: user.email.clone(),
display_name: user.display_name,
is_admin: user.is_admin,
role: user.role.as_str().to_owned(),
user_status: user.user_status.to_string(),
},
})
}
/// Handle Firebase login - authenticate with Firebase ID token
///
/// This method validates the Firebase ID token, finds or creates a user,
/// and returns a JWT token for our authentication system.
///
/// # Errors
/// Returns error if Firebase validation fails, or user creation fails
pub async fn login_with_firebase(
&self,
request: FirebaseLoginRequest,
firebase_auth: &FirebaseAuth,
) -> AppResult<LoginResponse> {
tracing::info!("Firebase login attempt");
// Validate the Firebase ID token
let claims = firebase_auth.validate_token(&request.id_token).await?;
// Get the email from the claims (required)
let email = claims
.email
.as_ref()
.ok_or_else(|| AppError::auth_invalid("Firebase token missing email claim"))?;
// Find or create user from Firebase claims
let user = self.find_or_create_firebase_user(&claims, email).await?;
// Check if user can login (not suspended)
Self::validate_user_can_login(&user)?;
// Generate session and return response
self.complete_firebase_login(&user, &claims.provider).await
}
/// Find existing user or create new one from Firebase claims
async fn find_or_create_firebase_user(
&self,
claims: &FirebaseClaims,
email: &str,
) -> AppResult<User> {
// Try to find user by Firebase UID first
if let Some(user) = self
.data
.database()
.get_user_by_firebase_uid(&claims.sub)
.await?
{
tracing::info!(user_id = %user.id, firebase_uid = %claims.sub, "Found user by Firebase UID");
return Ok(user);
}
// Check if user exists by email (might need linking)
if let Some(mut user) = self.data.database().get_user_by_email(email).await? {
tracing::info!(user_id = %user.id, "Linking existing email user to Firebase UID");
user.firebase_uid = Some(claims.sub.clone());
user.auth_provider.clone_from(&claims.provider);
self.data.database().create_user(&user).await?;
return Ok(user);
}
// Create new user from Firebase claims
self.create_firebase_user(claims, email).await
}
/// Create a personal tenant for a user (required for MCP operations)
///
/// # Errors
/// Returns error if tenant creation fails
async fn create_personal_tenant(
&self,
user_id: uuid::Uuid,
display_name: &str,
plan: &str,
) -> AppResult<uuid::Uuid> {
let tenant_id = uuid::Uuid::new_v4();
let tenant_name = format!("{display_name}'s Workspace");
let tenant_slug = format!("user-{}", user_id.as_simple());
let now = Utc::now();
let tenant = Tenant {
id: tenant_id,
name: tenant_name.clone(),
slug: tenant_slug,
domain: None,
plan: plan.to_owned(),
owner_user_id: user_id,
created_at: now,
updated_at: now,
};
self.data
.database()
.create_tenant(&tenant)
.await
.map_err(|e| {
error!(
"Failed to create personal tenant for user {}: {}",
user_id, e
);
AppError::database(format!("Failed to create personal tenant: {e}"))
})?;
debug!("Created personal tenant: {} ({})", tenant_name, tenant_id);
Ok(tenant_id)
}
/// Check if auto-approval is enabled (database setting takes precedence over config)
async fn is_auto_approval_enabled(&self) -> bool {
match self.data.database().is_auto_approval_enabled().await {
Ok(Some(db_setting)) => db_setting,
Ok(None) => self.config.config().app_behavior.auto_approve_users,
Err(e) => {
tracing::warn!(
"Failed to check auto-approval setting, falling back to config: {e}"
);
self.config.config().app_behavior.auto_approve_users
}
}
}
/// Determine user approval status based on auto-approval setting
async fn determine_approval_status(&self) -> (UserStatus, Option<chrono::DateTime<Utc>>) {
let now = Utc::now();
if self.is_auto_approval_enabled().await {
tracing::debug!("Auto-approval enabled for new user");
(UserStatus::Active, Some(now))
} else {
(UserStatus::Pending, None)
}
}
/// Create a new user from Firebase claims
async fn create_firebase_user(&self, claims: &FirebaseClaims, email: &str) -> AppResult<User> {
tracing::info!(firebase_uid = %claims.sub, email = %email, "Creating new Firebase user");
let (user_status, approved_at) = self.determine_approval_status().await;
let user_id = uuid::Uuid::new_v4();
let display_name = claims
.name
.as_deref()
.unwrap_or_else(|| email.split('@').next().unwrap_or("user"));
// Step 1: Create user first (without tenant_id) - required for tenant FK constraint
let now = Utc::now();
let new_user = User {
id: user_id,
email: email.to_owned(),
display_name: claims.name.clone(),
password_hash: "!firebase-auth-only!".to_owned(),
tier: UserTier::Starter,
tenant_id: None,
strava_token: None,
fitbit_token: None,
created_at: now,
last_active: now,
is_active: true,
user_status,
is_admin: false,
role: UserRole::User,
approved_by: None,
approved_at,
firebase_uid: Some(claims.sub.clone()),
auth_provider: claims.provider.clone(),
};
self.data.database().create_user(&new_user).await?;
// Step 2: Create personal tenant (owner_user_id FK now satisfied)
let tenant_id = self
.create_personal_tenant(user_id, display_name, tiers::STARTER)
.await?;
// Step 3: Link user to tenant
self.data
.database()
.update_user_tenant_id(user_id, &tenant_id.to_string())
.await?;
// Return user with updated tenant_id
let mut user_with_tenant = new_user;
user_with_tenant.tenant_id = Some(tenant_id.to_string());
info!(firebase_uid = %claims.sub, user_id = %user_id, "Firebase user registered");
Ok(user_with_tenant)
}
/// Validate that user is allowed to login
fn validate_user_can_login(user: &User) -> AppResult<()> {
if user.user_status.can_login() {
return Ok(());
}
tracing::warn!(user_id = %user.id, status = %user.user_status, "Login denied: user status");
let status_msg = match user.user_status {
UserStatus::Pending => "Account pending approval",
UserStatus::Suspended => "Account suspended",
UserStatus::Active => "Account active",
};
Err(user_state_error(status_msg))
}
/// Complete Firebase login: generate JWT and update last active
async fn complete_firebase_login(
&self,
user: &User,
provider: &str,
) -> AppResult<LoginResponse> {
let jwt_token = self
.auth
.auth_manager()
.generate_token(user, self.auth.jwks_manager())
.map_err(|e| AppError::auth_invalid(format!("Failed to generate token: {e}")))?;
let expires_at = Utc::now() + chrono::Duration::hours(limits::DEFAULT_SESSION_HOURS);
self.data.database().update_last_active(user.id).await?;
tracing::info!(user_id = %user.id, provider = %provider, "Firebase login successful");
Ok(LoginResponse {
jwt_token: Some(jwt_token),
csrf_token: String::new(),
expires_at: expires_at.to_rfc3339(),
user: UserInfo {
user_id: user.id.to_string(),
email: user.email.clone(),
display_name: user.display_name.clone(),
is_admin: user.is_admin,
role: user.role.as_str().to_owned(),
user_status: user.user_status.to_string(),
},
})
}
/// Handle token refresh - implementation from existing routes.rs
///
/// # Errors
/// Returns error if refresh token is invalid or token generation fails
pub async fn refresh_token(&self, request: RefreshTokenRequest) -> AppResult<LoginResponse> {
info!("Token refresh attempt for user with refresh token");
// Extract user from refresh token using RS256 validation
let token_claims = self
.auth
.auth_manager()
.validate_token(&request.token, self.auth.jwks_manager())
.map_err(|_| AppError::auth_invalid("Invalid or expired token"))?;
let user_id = uuid::Uuid::parse_str(&token_claims.sub)
.map_err(|e| AppError::auth_invalid(format!("Invalid token format: {e}")))?;
// Validate that the user_id matches the one in the request
let request_user_id = uuid::Uuid::parse_str(&request.user_id)?;
if user_id != request_user_id {
return Err(AppError::auth_invalid("User ID mismatch"));
}
// Get user from database
let user = self
.data
.database()
.get_user(user_id)
.await
.map_err(|e| AppError::database(format!("Failed to get user: {e}")))?
.ok_or_else(|| AppError::not_found("User"))?;
// Generate new JWT token using RS256
let new_jwt_token = self
.auth
.auth_manager()
.generate_token(&user, self.auth.jwks_manager())
.map_err(|e| AppError::auth_invalid(format!("Failed to generate token: {e}")))?;
let expires_at =
chrono::Utc::now() + chrono::Duration::hours(limits::DEFAULT_SESSION_HOURS);
// Update last active timestamp
self.data
.database()
.update_last_active(user.id)
.await
.map_err(|e| AppError::database(format!("Failed to update last active: {e}")))?;
info!("Token refreshed successfully for user: {}", user.id);
Ok(LoginResponse {
jwt_token: Some(new_jwt_token),
csrf_token: String::new(), // Will be set by HTTP handler
expires_at: expires_at.to_rfc3339(),
user: UserInfo {
user_id: user.id.to_string(),
email: user.email.clone(),
display_name: user.display_name,
is_admin: user.is_admin,
role: user.role.as_str().to_owned(),
user_status: user.user_status.to_string(),
},
})
}
/// Validate email format - from existing routes.rs
#[must_use]
pub fn is_valid_email(email: &str) -> bool {
// Simple email validation
if email.len() <= 5 {
return false;
}
let Some(at_pos) = email.find('@') else {
return false;
};
if at_pos == 0 || at_pos == email.len() - 1 {
return false; // @ at start or end
}
let domain_part = &email[at_pos + 1..];
domain_part.contains('.')
}
/// Validate password strength - from existing routes.rs
#[must_use]
pub const fn is_valid_password(password: &str) -> bool {
password.len() >= 8
}
}
/// OAuth service for OAuth flow business logic
#[derive(Clone)]
pub struct OAuthService {
data: DataContext,
config: ConfigContext,
notifications: NotificationContext,
}
/// Parsed OAuth state containing user ID and optional mobile redirect URL
struct ParsedOAuthState {
user_id: uuid::Uuid,
/// Optional redirect URL for mobile OAuth flows (base64 encoded in state)
mobile_redirect_url: Option<String>,
}
impl OAuthService {
/// Creates a new OAuth service instance
#[must_use]
pub const fn new(
data_context: DataContext,
config_context: ConfigContext,
notification_context: NotificationContext,
) -> Self {
Self {
data: data_context,
config: config_context,
notifications: notification_context,
}
}
/// Get configuration context
#[must_use]
pub const fn config(&self) -> &ConfigContext {
&self.config
}
/// Handle OAuth callback
///
/// # Errors
/// Returns error if OAuth state is invalid or callback processing fails
pub async fn handle_callback(
&self,
code: &str,
state: &str,
provider: &str,
) -> AppResult<OAuthCallbackResponse> {
// Use async block to satisfy clippy
task::yield_now().await;
// Validate state and extract user ID and optional mobile redirect URL
let parsed_state = Self::validate_oauth_state(state)?;
let user_id = parsed_state.user_id;
let mobile_redirect_url = parsed_state.mobile_redirect_url;
// Validate provider is supported
self.validate_provider(provider)?;
info!(
"Processing OAuth callback for user {} provider {} with code {}{}",
user_id,
provider,
code,
if mobile_redirect_url.is_some() {
" (mobile flow)"
} else {
""
}
);
// Get user and tenant from database
let (user, tenant_id) = self.get_user_and_tenant(user_id, provider).await?;
// Exchange OAuth code for access token
let token = self
.exchange_oauth_code(code, provider, user_id, &user)
.await?;
info!(
"Successfully exchanged OAuth code for user {} provider {}",
user_id, provider
);
// Store token and send notifications
let expires_at = self
.store_oauth_token(user_id, tenant_id, provider, &token)
.await?;
self.send_oauth_notifications(user_id, provider, &expires_at)
.await?;
self.notify_bridge_oauth_success(provider, &token).await;
Ok(OAuthCallbackResponse {
user_id: user_id.to_string(),
provider: provider.to_owned(),
expires_at: expires_at.to_rfc3339(),
scopes: token.scope.unwrap_or_else(|| "read".to_owned()),
mobile_redirect_url,
})
}
/// Validate OAuth state parameter and extract user ID and optional redirect URL
///
/// State format: `{user_id}:{random}` or `{user_id}:{random}:{base64_redirect_url}`
/// The redirect URL allows mobile apps to specify where to redirect after OAuth completes.
fn validate_oauth_state(state: &str) -> AppResult<ParsedOAuthState> {
let parts: Vec<&str> = state.splitn(3, ':').collect();
if parts.len() < 2 {
return Err(AppError::invalid_input("Invalid state parameter format"));
}
let user_id_str = parts[0];
let random_part = parts[1];
// Validate state for CSRF protection
if random_part.len() < 16
|| !random_part
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-')
{
return Err(AppError::invalid_input("Invalid OAuth state parameter"));
}
let user_id = parse_user_id(user_id_str)
.map_err(|e| AppError::invalid_input(format!("Invalid user ID in state: {e}")))?;
// Extract optional mobile redirect URL (base64 encoded, third part of state)
let mobile_redirect_url = parts
.get(2)
.filter(|s| !s.is_empty())
.and_then(|encoded| Self::decode_mobile_redirect_url(encoded));
Ok(ParsedOAuthState {
user_id,
mobile_redirect_url,
})
}
/// Decode and validate a base64-encoded mobile redirect URL
fn decode_mobile_redirect_url(encoded: &str) -> Option<String> {
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
URL_SAFE_NO_PAD
.decode(encoded)
.map_err(|e| {
warn!("Failed to decode base64 redirect URL: {}", e);
e
})
.ok()
.and_then(|bytes| {
String::from_utf8(bytes)
.map_err(|e| {
warn!("Failed to decode redirect URL as UTF-8: {}", e);
e
})
.ok()
})
.and_then(|url| {
// Validate URL scheme for security (only allow specific schemes)
if url.starts_with("pierre://")
|| url.starts_with("exp://")
|| url.starts_with("http://localhost")
|| url.starts_with("https://")
{
Some(url)
} else {
warn!("Invalid redirect URL scheme in OAuth state: {}", url);
None
}
})
}
/// Validate that provider is supported by checking the provider registry
fn validate_provider(&self, provider: &str) -> AppResult<()> {
if self.data.provider_registry().is_supported(provider) {
Ok(())
} else {
Err(AppError::invalid_input(format!(
"Unsupported provider: {provider}"
)))
}
}
/// Get user and tenant from database
async fn get_user_and_tenant(
&self,
user_id: uuid::Uuid,
provider: &str,
) -> AppResult<(User, String)> {
let database = self.data.database();
let user = database
.get_user(user_id)
.await
.map_err(|e| AppError::database(format!("Failed to get user: {e}")))?
.ok_or_else(|| {
error!(
"OAuth callback failed: User not found - user_id: {}, provider: {}",
user_id, provider
);
AppError::not_found("User")
})?;
let tenant_id = user
.tenant_id
.as_ref()
.ok_or_else(|| {
error!(
"OAuth callback failed: Missing tenant - user_id: {}, email: {}, provider: {}",
user.id, user.email, provider
);
AppError::invalid_input("User has no tenant")
})?
.clone(); // Safe: Uuid ownership for return value
Ok((user, tenant_id))
}
/// Exchange OAuth code for access token
async fn exchange_oauth_code(
&self,
code: &str,
provider: &str,
user_id: uuid::Uuid,
user: &User,
) -> AppResult<OAuth2Token> {
let oauth_config = self.create_oauth_config(provider)?;
let oauth_client = OAuth2Client::new(oauth_config.clone());
let token = oauth_client.exchange_code(code).await.map_err(|e| {
error!(
"OAuth token exchange failed for {provider} - user_id: {user_id}, email: {}, code: {code}, error: {e}",
user.email
);
AppError::internal(format!("Failed to exchange OAuth code for token: {e}"))
})?;
Ok(token)
}
/// Create `OAuth2` config for provider using descriptor and configuration
///
/// # Errors
/// Returns error if provider is unsupported or required credentials are not configured
fn create_oauth_config(&self, provider: &str) -> AppResult<OAuth2Config> {
// Get provider descriptor from registry
let descriptor = self
.data
.provider_registry()
.get_descriptor(provider)
.ok_or_else(|| AppError::invalid_input(format!("Unsupported provider: {provider}")))?;
// Get OAuth endpoints from descriptor
let endpoints = descriptor.oauth_endpoints().ok_or_else(|| {
AppError::invalid_input(format!("Provider {provider} does not support OAuth"))
})?;
// Get OAuth params from descriptor
let params = descriptor.oauth_params().ok_or_else(|| {
AppError::invalid_input(format!("Provider {provider} OAuth params not configured"))
})?;
// Get credentials from environment/config
let env_config = get_oauth_config(provider);
let client_id = env_config.client_id.ok_or_else(|| {
AppError::invalid_input(format!(
"{provider} client_id not configured for token exchange"
))
})?;
let client_secret = env_config.client_secret.ok_or_else(|| {
AppError::invalid_input(format!(
"{provider} client_secret not configured for token exchange"
))
})?;
// Build redirect URI
let server_config = self.config.config();
let redirect_uri = env_config.redirect_uri.unwrap_or_else(|| {
format!(
"http://localhost:{}/api/oauth/callback/{}",
server_config.http_port, provider
)
});
// Get default scopes and join with provider's separator
let scopes = descriptor
.default_scopes()
.iter()
.map(|s| (*s).to_owned())
.collect::<Vec<_>>()
.join(params.scope_separator);
Ok(OAuth2Config {
client_id,
client_secret,
auth_url: endpoints.auth_url.to_owned(),
token_url: endpoints.token_url.to_owned(),
redirect_uri,
scopes: vec![scopes],
use_pkce: params.use_pkce,
})
}
/// Store OAuth token in database
async fn store_oauth_token(
&self,
user_id: uuid::Uuid,
tenant_id: String,
provider: &str,
token: &OAuth2Token,
) -> AppResult<chrono::DateTime<chrono::Utc>> {
let expires_at = token
.expires_at
.unwrap_or_else(|| chrono::Utc::now() + chrono::Duration::hours(1));
let user_oauth_token = UserOAuthToken {
id: uuid::Uuid::new_v4().to_string(),
user_id,
tenant_id,
provider: provider.to_owned(),
access_token: token.access_token.clone(),
refresh_token: token.refresh_token.clone(),
token_type: token.token_type.clone(),
expires_at: Some(expires_at),
scope: token.scope.clone(),
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
};
self.data
.database()
.upsert_user_oauth_token(&user_oauth_token)
.await
.map_err(|e| AppError::database(format!("Failed to upsert OAuth token: {e}")))?;
Ok(expires_at)
}
/// Send OAuth completion notifications
async fn send_oauth_notifications(
&self,
user_id: uuid::Uuid,
provider: &str,
expires_at: &chrono::DateTime<chrono::Utc>,
) -> AppResult<()> {
let notification_id = self
.store_oauth_notification(user_id, provider, expires_at)
.await?;
self.broadcast_oauth_notification(¬ification_id, user_id, provider);
Ok(())
}
/// Store OAuth notification in database
async fn store_oauth_notification(
&self,
user_id: uuid::Uuid,
provider: &str,
expires_at: &chrono::DateTime<chrono::Utc>,
) -> AppResult<String> {
let notification_id = self
.data
.database()
.store_oauth_notification(
user_id,
provider,
true,
"OAuth authorization completed successfully",
Some(&expires_at.to_rfc3339()),
)
.await
.map_err(|e| AppError::database(format!("Failed to store OAuth notification: {e}")))?;
info!(
"Created OAuth completion notification {} for user {} provider {}",
notification_id, user_id, provider
);
Ok(notification_id)
}
/// Broadcast OAuth completion notification via WebSocket/SSE
fn broadcast_oauth_notification(
&self,
notification_id: &str,
user_id: uuid::Uuid,
provider: &str,
) {
let Some(sender) = self.notifications.oauth_notification_sender() else {
debug!(
notification_id = %notification_id,
user_id = %user_id,
provider = %provider,
"OAuth notification sender not configured"
);
return;
};
let notification = OAuthCompletedNotification::new(
provider.to_owned(),
true,
format!("{provider} connected successfully"),
Some(user_id.to_string()),
);
match sender.send(notification) {
Ok(receiver_count) => {
info!(
notification_id = %notification_id,
user_id = %user_id,
provider = %provider,
receiver_count = %receiver_count,
"OAuth notification broadcast to {} receivers",
receiver_count
);
}
Err(e) => {
debug!(
notification_id = %notification_id,
user_id = %user_id,
provider = %provider,
error = %e,
"No active receivers for OAuth notification"
);
}
}
}
/// Build OAuth token data for bridge notification
fn build_bridge_token_data(token: &OAuth2Token) -> JsonValue {
// Calculate expires_in from expires_at if available
let expires_in = token.expires_at.map(|expires_at| {
let duration = expires_at - chrono::Utc::now();
duration.num_seconds().max(0)
});
json!({
"access_token": token.access_token,
"refresh_token": token.refresh_token,
"expires_in": expires_in,
"token_type": token.token_type,
"scope": token.scope
})
}
/// Log bridge notification response
fn log_bridge_notification_result(
result: Result<reqwest::Response, reqwest::Error>,
provider: &str,
) {
match result {
Ok(response) if response.status().is_success() => {
info!(
"✅ Successfully notified bridge about {} OAuth completion",
provider
);
}
Ok(response) => {
warn!(
"Bridge notification responded with status {} for provider {}",
response.status(),
provider
);
}
Err(e) => {
warn!(
"Failed to notify bridge about {} OAuth (bridge may not be running): {}",
provider, e
);
}
}
}
/// Notify bridge about successful OAuth (for client-side token storage and focus recovery)
async fn notify_bridge_oauth_success(&self, provider: &str, token: &OAuth2Token) {
let oauth_callback_port = self.config.config().oauth_callback_port;
let callback_url =
format!("http://localhost:{oauth_callback_port}/oauth/provider-callback/{provider}");
let token_data = Self::build_bridge_token_data(token);
debug!(
"Notifying bridge about {} OAuth success at {}",
provider, callback_url
);
// Best-effort notification with configured timeout - don't fail OAuth flow if bridge notification fails
// Configuration must be initialized via initialize_http_clients() at server startup
let timeout_secs = get_oauth_callback_notification_timeout_secs();
let result = reqwest::Client::new()
.post(&callback_url)
.json(&token_data)
.timeout(StdDuration::from_secs(timeout_secs))
.send()
.await;
Self::log_bridge_notification_result(result, provider);
}
/// Disconnect OAuth provider for user
///
/// # Errors
/// Returns error if provider is unsupported or disconnection fails
pub async fn disconnect_provider(&self, user_id: uuid::Uuid, provider: &str) -> AppResult<()> {
debug!(
"Processing OAuth provider disconnect for user {} provider {}",
user_id, provider
);
// Validate provider is supported
self.validate_provider(provider)?;
// Get user to find tenant_id
let user = self
.data
.database()
.get_user(user_id)
.await
.map_err(|e| AppError::database(format!("Failed to get user: {e}")))?
.ok_or_else(|| AppError::not_found("User"))?;
let tenant_id = user.tenant_id.as_deref().unwrap_or("default");
// Delete OAuth tokens from database
self.data
.database()
.delete_user_oauth_token(user_id, tenant_id, provider)
.await
.map_err(|e| AppError::database(format!("Failed to delete OAuth token: {e}")))?;
info!("Disconnected {} for user {}", provider, user_id);
Ok(())
}
/// Generate OAuth authorization URL for provider
///
/// This function supports both multi-tenant and single-tenant modes:
/// - Multi-tenant: Uses tenant-specific OAuth credentials from database
/// - Single-tenant: Falls back to server-level configuration
///
/// # Errors
/// Returns error if provider is unsupported or OAuth credentials not configured
pub async fn get_auth_url(
&self,
user_id: uuid::Uuid,
tenant_id: uuid::Uuid,
provider: &str,
) -> AppResult<OAuthAuthorizationResponse> {
// Get provider descriptor from registry
let descriptor = self
.data
.provider_registry()
.get_descriptor(provider)
.ok_or_else(|| AppError::invalid_input(format!("Unsupported provider: {provider}")))?;
// Get OAuth endpoints and params from descriptor
let endpoints = descriptor.oauth_endpoints().ok_or_else(|| {
AppError::invalid_input(format!("Provider {provider} does not support OAuth"))
})?;
let params = descriptor.oauth_params().ok_or_else(|| {
AppError::invalid_input(format!("Provider {provider} OAuth params not configured"))
})?;
// Check for tenant-specific OAuth credentials first (multi-tenant mode)
let tenant_creds = self
.data
.database()
.get_tenant_oauth_credentials(tenant_id, provider)
.await
.map_err(|e| {
AppError::database(format!("Failed to get tenant OAuth credentials: {e}"))
})?;
let state = format!("{}:{}", user_id, uuid::Uuid::new_v4());
let base_url = format!("http://localhost:{}", self.config.config().http_port);
let redirect_uri = format!("{base_url}/api/oauth/callback/{provider}");
// URL-encode parameters for OAuth URLs
let encoded_state = encode(&state);
let encoded_redirect_uri = encode(&redirect_uri);
// Determine client_id and scopes (tenant-specific or environment)
let (client_id, scope) = if let Some(creds) = tenant_creds {
// Multi-tenant: use tenant-specific credentials
let scope = creds.scopes.join(params.scope_separator);
(creds.client_id, scope)
} else {
// Single-tenant: use environment configuration
let env_config = get_oauth_config(provider);
let client_id = env_config.client_id.ok_or_else(|| {
AppError::invalid_input(format!(
"{provider} client_id not configured (set in environment or database)"
))
})?;
let scope = descriptor.default_scopes().join(params.scope_separator);
(client_id, scope)
};
let encoded_scope = encode(&scope);
// Build authorization URL with provider-specific parameters
let mut auth_url = format!(
"{}?client_id={}&response_type=code&redirect_uri={}&scope={}&state={}",
endpoints.auth_url, client_id, encoded_redirect_uri, encoded_scope, encoded_state
);
// Add provider-specific additional parameters
for (key, value) in params.additional_auth_params {
use Write;
// Writing to String cannot fail
let _ = write!(&mut auth_url, "&{}={}", encode(key), encode(value));
}
let authorization_url = auth_url;
debug!(
"Generated OAuth authorization URL for user {} tenant {} provider {}",
user_id, tenant_id, provider
);
Ok(OAuthAuthorizationResponse {
authorization_url,
state,
instructions: format!("Click the link to authorize {provider} access"),
expires_in_minutes: 10,
})
}
/// Get OAuth connection status for user
///
/// # Errors
/// Returns error if database operation fails
pub async fn get_connection_status(
&self,
user_id: uuid::Uuid,
) -> AppResult<Vec<ConnectionStatus>> {
debug!("Getting OAuth connection status for user {}", user_id);
// Get all OAuth tokens for the user from database
let tokens = self
.data
.database()
.get_user_oauth_tokens(user_id)
.await
.map_err(|e| AppError::database(format!("Failed to get user OAuth tokens: {e}")))?;
// Create a set of connected providers
let mut providers_seen = HashSet::new();
let mut statuses = Vec::new();
// Add status for each connected provider
for token in tokens {
if providers_seen.insert(token.provider.clone()) {
statuses.push(ConnectionStatus {
provider: token.provider.clone(),
connected: true,
expires_at: token.expires_at.map(|dt| dt.to_rfc3339()),
scopes: token.scope.clone(),
});
}
}
// Add default status for all registered OAuth providers that are not connected
for provider_name in self.data.provider_registry().oauth_providers() {
if !providers_seen.contains(provider_name) {
statuses.push(ConnectionStatus {
provider: provider_name.to_owned(),
connected: false,
expires_at: None,
scopes: None,
});
}
}
Ok(statuses)
}
}
/// OAuth routes - alias for OAuth service to match test expectations
pub type OAuthRoutes = OAuthService;
/// OAuth callback response
#[derive(Debug, Serialize)]
pub struct OAuthCallbackResponse {
/// User ID for the connected account
pub user_id: String,
/// Name of the OAuth provider
pub provider: String,
/// When the OAuth token expires (ISO 8601 format)
pub expires_at: String,
/// Space-separated list of granted OAuth scopes
pub scopes: String,
/// Optional mobile redirect URL from OAuth state (for mobile app flows)
#[serde(skip_serializing_if = "Option::is_none")]
pub mobile_redirect_url: Option<String>,
}
/// Authentication routes implementation
#[derive(Clone)]
/// Authentication routes implementation (Axum)
///
/// Provides user registration, login, logout, and OAuth client authentication endpoints.
pub struct AuthRoutes;
impl AuthRoutes {
/// Create all authentication routes (Axum)
pub fn routes(resources: Arc<ServerResources>) -> Router {
use axum::{
routing::{get, post, put},
Router,
};
Router::new()
.route("/api/auth/register", post(Self::handle_public_register))
.route("/api/auth/admin/register", post(Self::handle_register))
.route("/api/auth/firebase", post(Self::handle_firebase_login))
.route("/api/auth/logout", post(Self::handle_logout))
.route("/api/auth/refresh", post(Self::handle_refresh))
.route("/api/user/profile", put(Self::handle_update_profile))
.route("/api/user/stats", get(Self::handle_user_stats))
// OAuth2 ROPC endpoint (RFC 6749 Section 4.3) - unified login for all clients
.route("/oauth/token", post(Self::handle_oauth2_token))
.route(
"/api/oauth/callback/:provider",
get(Self::handle_oauth_callback),
)
.route("/api/oauth/status", get(Self::handle_oauth_status))
.route(
"/api/oauth/auth/:provider/:user_id",
get(Self::handle_oauth_auth_initiate),
)
// Mobile OAuth initiation - returns OAuth URL in JSON (requires auth)
.route(
"/api/oauth/mobile/init/:provider",
get(Self::handle_mobile_oauth_init),
)
.with_state(resources)
}
/// Handle user registration (Axum)
///
/// REQUIRES: Admin authentication (Bearer token in Authorization header)
///
/// Security: Only administrators can create new users to prevent
/// unauthorized user creation, database pollution, and `DoS` attacks.
#[tracing::instrument(
skip(resources, headers, request),
fields(
route = "admin_register",
email = %request.email,
user_id = Empty,
success = Empty,
)
)]
async fn handle_register(
State(resources): State<Arc<ServerResources>>,
headers: HeaderMap,
Json(request): Json<RegisterRequest>,
) -> Result<Response, AppError> {
// Extract and validate admin token
let auth_header = headers
.get("authorization")
.and_then(|h| h.to_str().ok())
.ok_or_else(|| {
AppError::auth_invalid(
"Missing Authorization header for user registration - admin token required",
)
})?;
let token = extract_bearer_token_owned(auth_header)
.map_err(|_| AppError::auth_invalid("Invalid Authorization header format"))?;
// Validate admin token
let admin_auth_service = AdminAuthService::new(
resources.database.as_ref().clone(),
resources.jwks_manager.clone(),
resources.config.auth.admin_token_cache_ttl_secs,
);
// Authenticate admin (no specific permission check - any valid admin token can register users)
admin_auth_service
.authenticate(&token, None)
.await
.map_err(|e| {
warn!(error = %e, "Failed to authenticate admin token for user registration");
AppError::auth_invalid(format!("Admin authentication failed: {e}"))
})?;
info!(
"Admin-authenticated user registration attempt for email: {}",
request.email
);
let server_context = ServerContext::from(resources.as_ref());
let auth_routes = AuthService::new(
server_context.auth().clone(),
server_context.config().clone(),
server_context.data().clone(),
);
match auth_routes.register(request).await {
Ok(response) => Ok((StatusCode::CREATED, Json(response)).into_response()),
Err(e) => {
error!("Registration failed: {}", e);
Err(e)
}
}
}
/// Handle public user self-registration (Axum)
///
/// This endpoint allows users to register themselves without admin authentication.
/// New users are created in "Pending" status by default and require admin approval,
/// unless `AUTO_APPROVE_USERS` environment variable is set to true.
#[tracing::instrument(
skip(resources, request),
fields(
route = "public_register",
email = %request.email,
user_id = Empty,
success = Empty,
)
)]
async fn handle_public_register(
State(resources): State<Arc<ServerResources>>,
Json(request): Json<RegisterRequest>,
) -> Result<Response, AppError> {
info!(
"Public self-registration attempt for email: {}",
request.email
);
let server_context = ServerContext::from(resources.as_ref());
let auth_routes = AuthService::new(
server_context.auth().clone(),
server_context.config().clone(),
server_context.data().clone(),
);
match auth_routes.register(request).await {
Ok(response) => Ok((StatusCode::CREATED, Json(response)).into_response()),
Err(e) => {
error!("Public registration failed: {}", e);
Err(e)
}
}
}
/// Handle Firebase authentication login (Axum)
///
/// Authenticates users via Firebase ID tokens (Google Sign-In, Apple, etc.)
#[tracing::instrument(
skip(resources, request),
fields(
route = "firebase_login",
user_id = Empty,
auth_provider = Empty,
success = Empty,
)
)]
async fn handle_firebase_login(
State(resources): State<Arc<ServerResources>>,
Json(request): Json<FirebaseLoginRequest>,
) -> Result<Response, AppError> {
// Check if Firebase is configured
let firebase_auth = resources.firebase_auth.as_ref().ok_or_else(|| {
AppError::invalid_input("Firebase authentication is not configured on this server")
})?;
let server_context = ServerContext::from(resources.as_ref());
let auth_service = AuthService::new(
server_context.auth().clone(),
server_context.config().clone(),
server_context.data().clone(),
);
match auth_service
.login_with_firebase(request, firebase_auth)
.await
{
Ok(mut response) => {
// Clone JWT for cookie (keep in response for backward compatibility)
let jwt_token = response
.jwt_token
.clone() // Safe: JWT string ownership for cookie
.ok_or_else(|| AppError::internal("JWT token missing from login response"))?;
// Parse user ID for CSRF token generation
let user_id = uuid::Uuid::parse_str(&response.user.user_id)
.map_err(|e| AppError::internal(format!("Invalid user ID format: {e}")))?;
// Generate CSRF token
let csrf_token = resources
.csrf_manager
.generate_token(user_id)
.await
.map_err(|e| {
AppError::internal(format!("Failed to generate CSRF token: {e}"))
})?;
// Set response CSRF token
response.csrf_token.clone_from(&csrf_token);
// Build response with secure cookies
let mut headers = HeaderMap::new();
// Set httpOnly auth cookie (24 hour expiry to match JWT)
set_auth_cookie(&mut headers, &jwt_token, 24 * 60 * 60);
// Set CSRF cookie (30 minute expiry to match CSRF token)
set_csrf_cookie(&mut headers, &csrf_token, 30 * 60);
Ok((StatusCode::OK, headers, Json(response)).into_response())
}
Err(e) => {
tracing::error!("Firebase login failed: {}", e);
Err(e)
}
}
}
/// Handle token refresh (Axum)
#[tracing::instrument(
skip(resources, request),
fields(
route = "token_refresh",
user_id = %request.user_id,
success = Empty,
)
)]
async fn handle_refresh(
State(resources): State<Arc<ServerResources>>,
Json(request): Json<RefreshTokenRequest>,
) -> Result<Response, AppError> {
let server_context = ServerContext::from(resources.as_ref());
let auth_service = AuthService::new(
server_context.auth().clone(),
server_context.config().clone(),
server_context.data().clone(),
);
match auth_service.refresh_token(request).await {
Ok(mut response) => {
// Clone JWT for cookie (keep in response for backward compatibility)
let jwt_token = response
.jwt_token
.clone() // Safe: JWT string ownership for cookie
.ok_or_else(|| AppError::internal("JWT token missing from refresh response"))?;
// Parse user ID for CSRF token generation
let user_id = uuid::Uuid::parse_str(&response.user.user_id)
.map_err(|e| AppError::internal(format!("Invalid user ID format: {e}")))?;
// Generate new CSRF token
let csrf_token = resources
.csrf_manager
.generate_token(user_id)
.await
.map_err(|e| {
AppError::internal(format!("Failed to generate CSRF token: {e}"))
})?;
// Set response CSRF token
response.csrf_token.clone_from(&csrf_token);
// Build response with secure cookies
let mut headers = HeaderMap::new();
// Set httpOnly auth cookie (24 hour expiry to match JWT)
set_auth_cookie(&mut headers, &jwt_token, 24 * 60 * 60);
// Set CSRF cookie (30 minute expiry to match CSRF token)
set_csrf_cookie(&mut headers, &csrf_token, 30 * 60);
Ok((StatusCode::OK, headers, Json(response)).into_response())
}
Err(e) => {
error!("Token refresh failed: {}", e);
Err(e)
}
}
}
/// Handle user logout (Axum)
async fn handle_logout() -> Result<Response, AppError> {
// Yield to allow async context (required for Axum handler)
task::yield_now().await;
// Build response with cleared cookies
let mut headers = HeaderMap::new();
// Clear auth cookie
clear_auth_cookie(&mut headers);
// Return success response
Ok((
StatusCode::OK,
headers,
Json(json!({
"message": "Logged out successfully"
})),
)
.into_response())
}
/// Handle user profile update (Axum)
///
/// Updates the authenticated user's display name.
/// Requires valid JWT authentication via cookie or Bearer token.
#[tracing::instrument(
skip(resources, headers, request),
fields(
route = "update_profile",
success = Empty,
)
)]
async fn handle_update_profile(
State(resources): State<Arc<ServerResources>>,
headers: HeaderMap,
Json(request): Json<UpdateProfileRequest>,
) -> Result<Response, AppError> {
// Extract JWT from cookie or Authorization header
let auth_value =
if let Some(auth_header) = headers.get("authorization").and_then(|h| h.to_str().ok()) {
auth_header.to_owned()
} else if let Some(token) = get_cookie_value(&headers, "auth_token") {
// Fall back to auth_token cookie, format as Bearer token
format!("Bearer {token}")
} else {
return Err(AppError::auth_invalid(
"Missing authorization header or cookie",
));
};
// Authenticate and get user ID
let auth = resources
.auth_middleware
.authenticate_request(Some(&auth_value))
.await
.map_err(|e| AppError::auth_invalid(format!("Authentication failed: {e}")))?;
let user_id = auth.user_id;
// Validate display name
let display_name = request.display_name.trim();
if display_name.is_empty() {
return Err(AppError::invalid_input("Display name cannot be empty"));
}
if display_name.len() > 100 {
return Err(AppError::invalid_input(
"Display name must be 100 characters or less",
));
}
// Update user in database
let updated_user = resources
.database
.update_user_display_name(user_id, display_name)
.await?;
// Build response
let response = UpdateProfileResponse {
message: "Profile updated successfully".to_owned(),
user: UserInfo {
user_id: updated_user.id.to_string(),
email: updated_user.email,
display_name: updated_user.display_name,
is_admin: updated_user.is_admin,
role: updated_user.role.to_string(),
user_status: updated_user.user_status.to_string(),
},
};
info!(user_id = %user_id, "User profile updated successfully");
Ok((StatusCode::OK, Json(response)).into_response())
}
/// Handle user stats request for dashboard
///
/// Returns aggregated stats: connected providers, activities synced, and days active.
#[tracing::instrument(
skip(resources, headers),
fields(
route = "user_stats",
user_id = Empty,
)
)]
async fn handle_user_stats(
State(resources): State<Arc<ServerResources>>,
headers: HeaderMap,
) -> Result<Response, AppError> {
// Extract JWT from cookie or Authorization header
let auth_value =
if let Some(auth_header) = headers.get("authorization").and_then(|h| h.to_str().ok()) {
auth_header.to_owned()
} else if let Some(token) = get_cookie_value(&headers, "auth_token") {
format!("Bearer {token}")
} else {
return Err(AppError::auth_invalid(
"Missing authorization header or cookie",
));
};
// Authenticate and get user ID
let auth = resources
.auth_middleware
.authenticate_request(Some(&auth_value))
.await
.map_err(|e| AppError::auth_invalid(format!("Authentication failed: {e}")))?;
let user_id = auth.user_id;
Span::current().record("user_id", user_id.to_string());
// Get connected providers count from OAuth tokens
let oauth_tokens = resources.database.get_user_oauth_tokens(user_id).await?;
let connected_providers = i64::try_from(oauth_tokens.len()).unwrap_or(0);
// Get user creation date to calculate days active
let user = resources.database.get_user(user_id).await?;
let days_active = match user {
Some(u) => {
let now = chrono::Utc::now();
let duration = now.signed_duration_since(u.created_at);
duration.num_days().max(1)
}
None => 1,
};
let response = UserStatsResponse {
connected_providers,
days_active,
};
Ok((StatusCode::OK, Json(response)).into_response())
}
/// Handle `OAuth2` ROPC (Resource Owner Password Credentials) token request
///
/// This endpoint implements RFC 6749 Section 4.3 for MCP and CLI clients
/// that need to obtain tokens without a browser-based OAuth flow.
///
/// Request format: `application/x-www-form-urlencoded`
/// ```text
/// grant_type=password&username=user@example.com&password=secret
/// ```
///
/// Response format: RFC 6749 Section 5.1 compliant JSON
#[tracing::instrument(
skip(resources, request),
fields(
route = "oauth2_token",
grant_type = %request.grant_type,
username = %request.username,
user_id = Empty,
success = Empty,
)
)]
async fn handle_oauth2_token(
State(resources): State<Arc<ServerResources>>,
Form(request): Form<OAuth2TokenRequest>,
) -> Result<Response, AppError> {
// Validate grant_type
if request.grant_type != "password" {
let error_response = OAuth2ErrorResponse {
error: "unsupported_grant_type".to_owned(),
error_description: Some(format!(
"Grant type '{}' is not supported. Use 'password' for ROPC.",
request.grant_type
)),
};
return Ok((StatusCode::BAD_REQUEST, Json(error_response)).into_response());
}
// Delegate to existing login logic
let login_request = LoginRequest {
email: request.username,
password: request.password,
};
let server_context = ServerContext::from(resources.as_ref());
let auth_service = AuthService::new(
server_context.auth().clone(),
server_context.config().clone(),
server_context.data().clone(),
);
match auth_service.login(login_request).await {
Ok(response) => {
let jwt_token = response
.jwt_token
.clone()
.ok_or_else(|| AppError::internal("JWT token missing from login response"))?;
// Parse expiration to calculate expires_in
let expires_at = chrono::DateTime::parse_from_rfc3339(&response.expires_at)
.map_or_else(
|_| chrono::Utc::now() + chrono::Duration::hours(24),
|dt| dt.with_timezone(&chrono::Utc),
);
let expires_in = (expires_at - chrono::Utc::now()).num_seconds();
// Generate CSRF token for web clients
let user_id = uuid::Uuid::parse_str(&response.user.user_id)
.map_err(|e| AppError::internal(format!("Invalid user ID format: {e}")))?;
let csrf_token = resources
.csrf_manager
.generate_token(user_id)
.await
.map_err(|e| {
AppError::internal(format!("Failed to generate CSRF token: {e}"))
})?;
let oauth2_response = OAuth2TokenResponse {
access_token: jwt_token.clone(),
token_type: "Bearer".to_owned(),
expires_in,
refresh_token: None,
scope: request.scope,
// Pierre extensions for frontend compatibility
user: Some(response.user),
csrf_token: Some(csrf_token.clone()),
};
// Build response with secure cookies for web clients
let mut headers = HeaderMap::new();
set_auth_cookie(&mut headers, &jwt_token, 24 * 60 * 60);
set_csrf_cookie(&mut headers, &csrf_token, 30 * 60);
Ok((StatusCode::OK, headers, Json(oauth2_response)).into_response())
}
Err(e) => {
// Map to OAuth2 error format based on error code
let error_code = match e.code {
ErrorCode::AuthInvalid | ErrorCode::AuthRequired | ErrorCode::AuthExpired => {
"invalid_grant"
}
ErrorCode::PermissionDenied => "unauthorized_client",
ErrorCode::InvalidInput | ErrorCode::InvalidFormat => "invalid_request",
_ => "server_error",
};
let error_desc = e.message;
let error_response = OAuth2ErrorResponse {
error: error_code.to_owned(),
error_description: Some(error_desc),
};
// OAuth2 spec: invalid_grant returns 400, server_error returns 500
let status = if error_code == "server_error" {
StatusCode::INTERNAL_SERVER_ERROR
} else {
StatusCode::BAD_REQUEST
};
Ok((status, Json(error_response)).into_response())
}
}
}
/// Handle OAuth callback (Axum)
#[tracing::instrument(
skip(resources, params),
fields(
route = "oauth_callback",
provider = %provider,
user_id = Empty,
success = Empty,
)
)]
async fn handle_oauth_callback(
State(resources): State<Arc<ServerResources>>,
Path(provider): Path<String>,
Query(params): Query<HashMap<String, String>>,
) -> Result<Response, AppError> {
let server_context = ServerContext::from(resources.as_ref());
let oauth_routes = OAuthService::new(
server_context.data().clone(),
server_context.config().clone(),
server_context.notification().clone(),
);
let code = params
.get("code")
.ok_or_else(|| AppError::auth_invalid("Missing OAuth code parameter"))?;
let state = params
.get("state")
.ok_or_else(|| AppError::auth_invalid("Missing OAuth state parameter"))?;
// Check if we should redirect to a separate frontend URL
let frontend_url = server_context.config().config().frontend_url.clone();
match oauth_routes.handle_callback(code, state, &provider).await {
Ok(response) => {
// Priority: mobile redirect URL > frontend URL > render template
// Mobile apps pass redirect URL through OAuth state for deep linking
if let Some(mobile_url) = &response.mobile_redirect_url {
let redirect_url = format!(
"{}?provider={}&success=true",
mobile_url.trim_end_matches('/'),
encode(&provider)
);
info!("Redirecting OAuth success to mobile app: {}", redirect_url);
return Ok(
(StatusCode::FOUND, [(header::LOCATION, redirect_url)], "").into_response()
);
}
// If frontend URL is configured, redirect to frontend with success params
if let Some(url) = frontend_url {
let redirect_url = format!(
"{}/oauth-callback?provider={}&success=true",
url.trim_end_matches('/'),
encode(&provider)
);
info!("Redirecting OAuth success to frontend: {}", redirect_url);
return Ok(
(StatusCode::FOUND, [(header::LOCATION, redirect_url)], "").into_response()
);
}
// Otherwise serve the success page directly (same-origin production)
let html = OAuthTemplateRenderer::render_success_template(&provider, &response)
.map_err(|e| {
error!("Failed to render OAuth success template: {}", e);
AppError::internal("Template rendering failed")
})?;
Ok((StatusCode::OK, [(header::CONTENT_TYPE, "text/html")], html).into_response())
}
Err(e) => {
error!("OAuth callback failed: {}", e);
// Determine error message and description based on error type
let (error_msg, description) = Self::categorize_oauth_error(&e);
// For errors, we need to parse the state to check for mobile redirect URL
// since handle_callback failed and didn't return the parsed state
let mobile_redirect_url = Self::extract_mobile_redirect_from_state(state);
// Priority: mobile redirect URL > frontend URL > render template
if let Some(mobile_url) = mobile_redirect_url {
let redirect_url = format!(
"{}?provider={}&success=false&error={}",
mobile_url.trim_end_matches('/'),
encode(&provider),
encode(error_msg)
);
info!("Redirecting OAuth error to mobile app: {}", redirect_url);
return Ok(
(StatusCode::FOUND, [(header::LOCATION, redirect_url)], "").into_response()
);
}
// If frontend URL is configured, redirect to frontend with error params
if let Some(url) = frontend_url {
let redirect_url = format!(
"{}/oauth-callback?provider={}&success=false&error={}",
url.trim_end_matches('/'),
encode(&provider),
encode(error_msg)
);
info!("Redirecting OAuth error to frontend: {}", redirect_url);
return Ok(
(StatusCode::FOUND, [(header::LOCATION, redirect_url)], "").into_response()
);
}
let html =
OAuthTemplateRenderer::render_error_template(&provider, error_msg, description)
.map_err(|template_err| {
error!(
"Critical: Failed to render OAuth error template: {}",
template_err
);
AppError::internal("Template rendering failed")
})?;
Ok((
StatusCode::INTERNAL_SERVER_ERROR,
[(header::CONTENT_TYPE, "text/html")],
html,
)
.into_response())
}
}
}
/// Handle OAuth status check (Axum)
#[tracing::instrument(
skip(resources, headers),
fields(
route = "oauth_status",
user_id = Empty,
)
)]
async fn handle_oauth_status(
State(resources): State<Arc<ServerResources>>,
headers: HeaderMap,
) -> Result<Response, AppError> {
// Authenticate using middleware (supports both cookies and Authorization header)
let auth_result = resources
.auth_middleware
.authenticate_request_with_headers(&headers)
.await?;
let user_id = auth_result.user_id;
// Check OAuth provider connection status for the user
let provider_statuses = resources
.database
.get_user_oauth_tokens(user_id)
.await
.map_or_else(
|_| {
vec![
OAuthStatus {
provider: "strava".to_owned(),
connected: false,
last_sync: None,
},
OAuthStatus {
provider: "fitbit".to_owned(),
connected: false,
last_sync: None,
},
]
},
|tokens| {
// Convert tokens to status objects
let mut statuses = vec![];
let mut providers_seen = HashSet::new();
for token in tokens {
if providers_seen.insert(token.provider.clone()) {
statuses.push(OAuthStatus {
provider: token.provider,
connected: true,
last_sync: Some(token.created_at.to_rfc3339()),
});
}
}
// Add default providers if not connected
for provider in ["strava", "fitbit"] {
if !providers_seen.contains(provider) {
statuses.push(OAuthStatus {
provider: provider.to_owned(),
connected: false,
last_sync: None,
});
}
}
statuses
},
);
Ok((StatusCode::OK, Json(provider_statuses)).into_response())
}
/// Parse a user ID string to UUID
fn parse_user_id(user_id_str: &str) -> Result<uuid::Uuid, AppError> {
uuid::Uuid::parse_str(user_id_str).map_err(|_| {
error!("Invalid user_id format: {}", user_id_str);
AppError::invalid_input("Invalid user ID format")
})
}
/// Retrieve user from database with proper error handling
async fn get_user_for_oauth(
database: &Database,
user_id: uuid::Uuid,
) -> Result<User, AppError> {
match database.get_user(user_id).await {
Ok(Some(user)) => Ok(user),
Ok(None) => {
error!("User {} not found in database", user_id);
Err(AppError::not_found("User account not found"))
}
Err(e) => {
error!("Failed to get user {} for OAuth: {}", user_id, e);
Err(AppError::database(format!(
"Failed to retrieve user information: {e}"
)))
}
}
}
/// Extract tenant ID from user, falling back to `user_id` if no tenant
fn extract_tenant_id(user: &User, user_id: uuid::Uuid) -> Result<uuid::Uuid, AppError> {
let Some(tid) = &user.tenant_id else {
debug!(user_id = %user_id, "User has no tenant_id - using user_id as tenant");
return Ok(user_id);
};
uuid::Uuid::parse_str(tid.as_str()).map_err(|e| {
error!(
user_id = %user_id,
tenant_id_str = %tid,
error = ?e,
"Invalid tenant_id format in database - tenant isolation compromised"
);
AppError::internal("User tenant configuration is invalid - please contact support")
})
}
/// Handle OAuth authorization initiation (Axum)
#[tracing::instrument(
skip(resources),
fields(
route = "oauth_auth_initiate",
provider = %provider,
user_id = %user_id_str,
tenant_id = Empty,
)
)]
async fn handle_oauth_auth_initiate(
State(resources): State<Arc<ServerResources>>,
Path((provider, user_id_str)): Path<(String, String)>,
) -> Result<Response, AppError> {
info!(
"OAuth authorization initiation for provider: {} user: {}",
provider, user_id_str
);
let user_id = Self::parse_user_id(&user_id_str)?;
let user = Self::get_user_for_oauth(&resources.database, user_id).await?;
let tenant_id = Self::extract_tenant_id(&user, user_id)?;
let server_context = ServerContext::from(resources.as_ref());
let oauth_service = OAuthService::new(
server_context.data().clone(),
server_context.config().clone(),
server_context.notification().clone(),
);
let auth_response = oauth_service
.get_auth_url(user_id, tenant_id, &provider)
.await
.map_err(|e| {
error!(
"Failed to generate OAuth URL for {} user {}: {}",
provider, user_id, e
);
AppError::internal(format!("Failed to generate OAuth URL for {provider}: {e}"))
})?;
info!(
"Generated OAuth URL for {} user {}: {}",
provider, user_id, auth_response.authorization_url
);
Ok((
StatusCode::FOUND,
[(header::LOCATION, auth_response.authorization_url)],
)
.into_response())
}
/// Handle mobile OAuth initiation (Axum)
///
/// Returns OAuth URL in JSON format for mobile apps to use with in-app browsers.
/// Accepts optional `redirect_url` query parameter for deep linking back to the app.
#[tracing::instrument(
skip(resources, headers, query),
fields(
route = "mobile_oauth_init",
provider = %provider,
user_id = Empty,
)
)]
async fn handle_mobile_oauth_init(
State(resources): State<Arc<ServerResources>>,
Path(provider): Path<String>,
headers: HeaderMap,
Query(query): Query<HashMap<String, String>>,
) -> Result<Response, AppError> {
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
// Authenticate using middleware
let auth_result = resources
.auth_middleware
.authenticate_request_with_headers(&headers)
.await?;
let user_id = auth_result.user_id;
info!(
"Mobile OAuth initiation for provider: {} user: {}",
provider, user_id
);
// Get optional redirect_url from query parameters
let redirect_url = query.get("redirect_url");
// Validate redirect URL scheme if provided
if let Some(url) = redirect_url {
let is_valid_scheme = url.starts_with("pierre://")
|| url.starts_with("exp://")
|| url.starts_with("http://localhost")
|| url.starts_with("https://");
if !is_valid_scheme {
return Err(AppError::invalid_input(
"Invalid redirect_url scheme. Allowed schemes: pierre://, exp://, http://localhost, https://",
));
}
}
let user = Self::get_user_for_oauth(&resources.database, user_id).await?;
let tenant_id = Self::extract_tenant_id(&user, user_id)?;
// Build OAuth state with optional redirect URL
let state = redirect_url.map_or_else(
|| format!("{}:{}", user_id, uuid::Uuid::new_v4()),
|url| {
let encoded_url = URL_SAFE_NO_PAD.encode(url.as_bytes());
format!("{}:{}:{}", user_id, uuid::Uuid::new_v4(), encoded_url)
},
);
// Generate OAuth URL using the state with embedded redirect URL
let tenant_name = resources
.database
.get_tenant_by_id(tenant_id)
.await
.map_or_else(|_| "Unknown Tenant".to_owned(), |t| t.name);
let ctx = TenantContext {
tenant_id,
user_id,
tenant_name,
user_role: TenantRole::Member,
};
let authorization_url = resources
.tenant_oauth_client
.get_authorization_url(&ctx, &provider, &state, resources.database.as_ref())
.await
.map_err(|e| {
error!(
"Failed to generate OAuth URL for {} user {}: {}",
provider, user_id, e
);
AppError::internal(format!("Failed to generate OAuth URL for {provider}: {e}"))
})?;
info!(
"Generated mobile OAuth URL for {} user {}{}",
provider,
user_id,
if redirect_url.is_some() {
" (with redirect)"
} else {
""
}
);
// Return JSON response with OAuth URL (mobile apps need this for in-app browsers)
Ok((
StatusCode::OK,
Json(json!({
"authorization_url": authorization_url,
"provider": provider,
"state": state,
"message": format!("Visit the authorization URL to connect your {} account", provider)
})),
)
.into_response())
}
/// Categorize OAuth errors for better user messaging
fn categorize_oauth_error(error: &AppError) -> (&'static str, Option<&'static str>) {
let error_str = error.to_string().to_lowercase();
if error_str.contains("jwt") && error_str.contains("expired") {
(
"Your session has expired",
Some("Please log in again to continue with OAuth authorization"),
)
} else if error_str.contains("jwt") && error_str.contains("invalid signature") {
(
"Invalid authentication token",
Some("The authentication token signature is invalid. This may happen if the server's secret key has changed. Please log in again."),
)
} else if error_str.contains("jwt") && error_str.contains("malformed") {
(
"Malformed authentication token",
Some("The authentication token format is invalid. Please log in again."),
)
} else if error_str.contains("jwt") {
(
"Authentication token validation failed",
Some(
"There was an issue validating your authentication token. Please log in again.",
),
)
} else if error_str.contains("user not found") {
(
"User account not found",
Some("The user account associated with this OAuth request could not be found."),
)
} else if error_str.contains("tenant") {
(
"Tenant configuration error",
Some("There was an issue with your account's tenant configuration. Please contact support."),
)
} else if error_str.contains("oauth code") || error_str.contains("token exchange") {
(
"OAuth token exchange failed",
Some("Failed to exchange the authorization code for an access token. The provider may have rejected the request."),
)
} else if error_str.contains("state parameter") {
(
"Invalid OAuth state",
Some("The OAuth state parameter is invalid or has been tampered with. This is a security measure to prevent CSRF attacks."),
)
} else {
(
"OAuth authorization failed",
Some("An unexpected error occurred during the OAuth authorization process."),
)
}
}
/// Extract mobile redirect URL from OAuth state parameter for error handling
///
/// This is used when the OAuth callback fails and we need to redirect
/// the error to the mobile app. Duplicates some logic from `OAuthService::validate_oauth_state`
/// but only extracts the redirect URL without full validation.
fn extract_mobile_redirect_from_state(state: &str) -> Option<String> {
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
let parts: Vec<&str> = state.splitn(3, ':').collect();
if parts.len() < 3 || parts[2].is_empty() {
return None;
}
URL_SAFE_NO_PAD
.decode(parts[2])
.ok()
.and_then(|bytes| String::from_utf8(bytes).ok())
.filter(|url| {
// Validate URL scheme for security (only allow specific schemes)
url.starts_with("pierre://")
|| url.starts_with("exp://")
|| url.starts_with("http://localhost")
|| url.starts_with("https://")
})
}
}