Refactor authentication from database to direct CalDAV authentication

Major architectural change to simplify authentication by authenticating directly against CalDAV servers instead of maintaining a local user database.

Backend changes:
- Remove SQLite database dependencies and user storage
- Refactor AuthService to authenticate directly against CalDAV servers
- Update JWT tokens to store CalDAV server info instead of user IDs
- Implement proper CalDAV calendar discovery with XML parsing
- Fix URL construction for CalDAV REPORT requests
- Add comprehensive debug logging for authentication flow

Frontend changes:
- Add server URL input field to login form
- Remove registration functionality entirely
- Update calendar service to pass CalDAV passwords via headers
- Store CalDAV credentials in localStorage for API calls

Key improvements:
- Simplified architecture eliminates database complexity
- Direct CalDAV authentication ensures credentials always work
- Proper calendar discovery automatically finds user calendars
- Robust error handling and debug logging for troubleshooting

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Connor Johnstone
2025-08-28 18:40:22 -04:00
parent 0741afd0b2
commit d85898cae7
12 changed files with 276 additions and 582 deletions

View File

@@ -1,210 +1,118 @@
use bcrypt::{hash, verify, DEFAULT_COST};
use chrono::{Duration, Utc};
use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation};
use serde::{Deserialize, Serialize};
use sqlx::{Row, SqlitePool};
use uuid::Uuid;
use crate::models::{User, UserInfo, LoginRequest, RegisterRequest, AuthResponse, ApiError};
use crate::models::{CalDAVLoginRequest, AuthResponse, ApiError};
use crate::config::CalDAVConfig;
use crate::calendar::CalDAVClient;
#[derive(Debug, Serialize, Deserialize)]
pub struct Claims {
pub sub: String, // Subject (user ID)
pub username: String,
pub server_url: String,
pub exp: i64, // Expiration time
pub iat: i64, // Issued at
pub username: String,
pub email: String,
}
#[derive(Clone)]
pub struct AuthService {
db: SqlitePool,
jwt_secret: String,
}
impl AuthService {
pub fn new(db: SqlitePool, jwt_secret: String) -> Self {
Self { db, jwt_secret }
pub fn new(jwt_secret: String) -> Self {
Self { jwt_secret }
}
pub async fn init_db(&self) -> Result<(), ApiError> {
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT UNIQUE NOT NULL,
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
"#,
)
.execute(&self.db)
.await
.map_err(|e| ApiError::Database(e.to_string()))?;
Ok(())
}
pub async fn register(&self, request: RegisterRequest) -> Result<AuthResponse, ApiError> {
/// Authenticate user directly against CalDAV server
pub async fn login(&self, request: CalDAVLoginRequest) -> Result<AuthResponse, ApiError> {
// Validate input
self.validate_registration(&request)?;
self.validate_login(&request)?;
println!("✅ Input validation passed");
// Check if user already exists
if self.user_exists(&request.username, &request.email).await? {
return Err(ApiError::Conflict("Username or email already exists".to_string()));
}
// Create CalDAV config with provided credentials
let caldav_config = CalDAVConfig {
server_url: request.server_url.clone(),
username: request.username.clone(),
password: request.password.clone(),
calendar_path: None,
tasks_path: None,
};
println!("📝 Created CalDAV config");
// Hash password
let password_hash = hash(&request.password, DEFAULT_COST)
.map_err(|e| ApiError::Internal(format!("Password hashing failed: {}", e)))?;
// Create user
let user_id = Uuid::new_v4().to_string();
let now = Utc::now();
sqlx::query(
r#"
INSERT INTO users (id, username, email, password_hash, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
"#,
)
.bind(&user_id)
.bind(&request.username)
.bind(&request.email)
.bind(&password_hash)
.bind(now)
.bind(now)
.execute(&self.db)
.await
.map_err(|e| ApiError::Database(e.to_string()))?;
// Get the created user
let user = self.get_user_by_id(&user_id).await?;
// Generate token
let token = self.generate_token(&user)?;
Ok(AuthResponse {
token,
user: UserInfo {
id: user.id,
username: user.username,
email: user.email,
},
})
}
pub async fn login(&self, request: LoginRequest) -> Result<AuthResponse, ApiError> {
// Get user by username
let user = self.get_user_by_username(&request.username).await?;
// Verify password
let is_valid = verify(&request.password, &user.password_hash)
.map_err(|e| ApiError::Internal(format!("Password verification failed: {}", e)))?;
if !is_valid {
return Err(ApiError::Unauthorized("Invalid credentials".to_string()));
}
// Generate token
let token = self.generate_token(&user)?;
Ok(AuthResponse {
token,
user: UserInfo {
id: user.id,
username: user.username,
email: user.email,
},
})
}
pub async fn verify_token(&self, token: &str) -> Result<UserInfo, ApiError> {
let claims = self.decode_token(token)?;
let user = self.get_user_by_id(&claims.sub).await?;
// Test authentication against CalDAV server
let caldav_client = CalDAVClient::new(caldav_config.clone());
println!("🔗 Created CalDAV client, attempting to discover calendars...");
Ok(UserInfo {
id: user.id,
username: user.username,
email: user.email,
// Try to discover calendars as an authentication test
match caldav_client.discover_calendars().await {
Ok(calendars) => {
println!("✅ Authentication successful! Found {} calendars", calendars.len());
// Authentication successful, generate JWT token
let token = self.generate_token(&request.username, &request.server_url)?;
Ok(AuthResponse {
token,
username: request.username,
server_url: request.server_url,
})
}
Err(err) => {
println!("❌ Authentication failed: {:?}", err);
// Authentication failed
Err(ApiError::Unauthorized("Invalid CalDAV credentials or server unavailable".to_string()))
}
}
}
/// Verify JWT token and extract CalDAV credentials info
pub fn verify_token(&self, token: &str) -> Result<Claims, ApiError> {
self.decode_token(token)
}
/// Create CalDAV config from token
pub fn caldav_config_from_token(&self, token: &str, password: &str) -> Result<CalDAVConfig, ApiError> {
let claims = self.verify_token(token)?;
Ok(CalDAVConfig {
server_url: claims.server_url,
username: claims.username,
password: password.to_string(),
calendar_path: None,
tasks_path: None,
})
}
async fn get_user_by_username(&self, username: &str) -> Result<User, ApiError> {
let row = sqlx::query("SELECT id, username, email, password_hash, created_at FROM users WHERE username = ?")
.bind(username)
.fetch_one(&self.db)
.await
.map_err(|_| ApiError::Unauthorized("Invalid credentials".to_string()))?;
self.row_to_user(row)
}
async fn get_user_by_id(&self, user_id: &str) -> Result<User, ApiError> {
let row = sqlx::query("SELECT id, username, email, password_hash, created_at FROM users WHERE id = ?")
.bind(user_id)
.fetch_one(&self.db)
.await
.map_err(|_| ApiError::NotFound("User not found".to_string()))?;
self.row_to_user(row)
}
fn row_to_user(&self, row: sqlx::sqlite::SqliteRow) -> Result<User, ApiError> {
Ok(User {
id: row.try_get("id").map_err(|e| ApiError::Database(e.to_string()))?,
username: row.try_get("username").map_err(|e| ApiError::Database(e.to_string()))?,
email: row.try_get("email").map_err(|e| ApiError::Database(e.to_string()))?,
password_hash: row.try_get("password_hash").map_err(|e| ApiError::Database(e.to_string()))?,
created_at: row.try_get("created_at").map_err(|e| ApiError::Database(e.to_string()))?,
})
}
async fn user_exists(&self, username: &str, email: &str) -> Result<bool, ApiError> {
let count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM users WHERE username = ? OR email = ?"
)
.bind(username)
.bind(email)
.fetch_one(&self.db)
.await
.map_err(|e| ApiError::Database(e.to_string()))?;
Ok(count > 0)
}
fn validate_registration(&self, request: &RegisterRequest) -> Result<(), ApiError> {
fn validate_login(&self, request: &CalDAVLoginRequest) -> Result<(), ApiError> {
if request.username.trim().is_empty() {
return Err(ApiError::BadRequest("Username is required".to_string()));
}
if request.username.len() < 3 {
return Err(ApiError::BadRequest("Username must be at least 3 characters".to_string()));
if request.password.trim().is_empty() {
return Err(ApiError::BadRequest("Password is required".to_string()));
}
if request.email.trim().is_empty() || !request.email.contains('@') {
return Err(ApiError::BadRequest("Valid email is required".to_string()));
if request.server_url.trim().is_empty() {
return Err(ApiError::BadRequest("Server URL is required".to_string()));
}
if request.password.len() < 6 {
return Err(ApiError::BadRequest("Password must be at least 6 characters".to_string()));
// Basic URL validation
if !request.server_url.starts_with("http://") && !request.server_url.starts_with("https://") {
return Err(ApiError::BadRequest("Server URL must start with http:// or https://".to_string()));
}
Ok(())
}
fn generate_token(&self, user: &User) -> Result<String, ApiError> {
fn generate_token(&self, username: &str, server_url: &str) -> Result<String, ApiError> {
let now = Utc::now();
let expires_at = now + Duration::hours(24); // Token valid for 24 hours
let claims = Claims {
sub: user.id.clone(),
username: username.to_string(),
server_url: server_url.to_string(),
exp: expires_at.timestamp(),
iat: now.timestamp(),
username: user.username.clone(),
email: user.email.clone(),
};
let token = encode(

View File

@@ -149,12 +149,26 @@ impl CalDAVClient {
let url = if calendar_path.starts_with("http") {
calendar_path.to_string()
} else {
format!("{}{}", self.config.server_url.trim_end_matches('/'), calendar_path)
// Extract the base URL (scheme + host + port) from server_url
let server_url = &self.config.server_url;
// Find the first '/' after "https://" or "http://"
let scheme_end = if server_url.starts_with("https://") { 8 } else { 7 };
if let Some(path_start) = server_url[scheme_end..].find('/') {
let base_url = &server_url[..scheme_end + path_start];
format!("{}{}", base_url, calendar_path)
} else {
// No path in server_url, so just append the calendar_path
format!("{}{}", server_url.trim_end_matches('/'), calendar_path)
}
};
let basic_auth = self.config.get_basic_auth();
println!("🔑 REPORT Basic Auth: Basic {}", basic_auth);
println!("🌐 REPORT URL: {}", url);
let response = self.http_client
.request(reqwest::Method::from_bytes(b"REPORT").unwrap(), &url)
.header("Authorization", format!("Basic {}", self.config.get_basic_auth()))
.header("Authorization", format!("Basic {}", basic_auth))
.header("Content-Type", "application/xml")
.header("Depth", "1")
.header("User-Agent", "calendar-app/0.1.0")
@@ -296,7 +310,7 @@ impl CalDAVClient {
// Parse end time (optional - use start time if not present)
let end = if let Some(dtend) = properties.get("DTEND") {
Some(self.parse_datetime(dtend, properties.get("DTEND"))?)
} else if let Some(duration) = properties.get("DURATION") {
} else if let Some(_duration) = properties.get("DURATION") {
// TODO: Parse duration and add to start time
Some(start)
} else {
@@ -443,18 +457,16 @@ impl CalDAVClient {
println!("Using configured calendar path: {}", calendar_path);
return Ok(vec![calendar_path.clone()]);
}
println!("No calendar path configured, discovering calendars...");
// Try different common CalDAV discovery paths
// Note: paths should be relative to the server URL base
let user_calendar_path = format!("/calendars/{}/", self.config.username);
let user_dav_calendar_path = format!("/dav.php/calendars/{}/", self.config.username);
let discovery_paths = vec![
"/calendars/",
user_calendar_path.as_str(),
user_dav_calendar_path.as_str(),
"/dav.php/calendars/",
];
let mut all_calendars = Vec::new();
@@ -499,6 +511,7 @@ impl CalDAVClient {
.map_err(CalDAVError::RequestError)?;
if response.status().as_u16() != 207 {
println!("❌ Discovery PROPFIND failed for {}: HTTP {}", path, response.status().as_u16());
return Err(CalDAVError::ServerError(response.status().as_u16()));
}
@@ -512,15 +525,33 @@ impl CalDAVClient {
if let Some(end_pos) = response_block.find("</d:response>") {
let response_content = &response_block[..end_pos];
// Look for actual calendar collections (not just containers)
if response_content.contains("<c:supported-calendar-component-set") ||
(response_content.contains("<d:collection/>") &&
response_content.contains("calendar")) {
if let Some(href) = self.extract_xml_content(response_content, "href") {
// Only include actual calendar paths, not container directories
if href.ends_with('/') && href.contains("calendar") && !href.ends_with("/calendars/") {
// Extract href first
if let Some(href) = self.extract_xml_content(response_content, "href") {
println!("🔍 Checking resource: {}", href);
// Check if this is a calendar collection by looking for supported-calendar-component-set
// This indicates it's an actual calendar that can contain events
let has_supported_components = response_content.contains("supported-calendar-component-set") &&
(response_content.contains("VEVENT") || response_content.contains("VTODO"));
let has_calendar_resourcetype = response_content.contains("<cal:calendar") || response_content.contains("<c:calendar");
let is_calendar = has_supported_components || has_calendar_resourcetype;
// Also check resourcetype for collection
let has_collection = response_content.contains("<d:collection") || response_content.contains("<collection");
if is_calendar && has_collection {
// Exclude system directories like inbox, outbox, and root calendar directories
if !href.contains("/inbox/") && !href.contains("/outbox/") &&
!href.ends_with("/calendars/") && href.ends_with('/') {
println!("📅 Found calendar collection: {}", href);
calendar_paths.push(href);
} else {
println!("❌ Skipping system/root directory: {}", href);
}
} else {
println!(" Not a calendar collection: {} (is_calendar: {}, has_collection: {})",
href, is_calendar, has_collection);
}
}
}

View File

@@ -7,9 +7,8 @@ use serde::Deserialize;
use std::sync::Arc;
use chrono::Datelike;
use crate::{AppState, models::{LoginRequest, RegisterRequest, AuthResponse, ApiError}};
use crate::{AppState, models::{CalDAVLoginRequest, AuthResponse, ApiError}};
use crate::calendar::{CalDAVClient, CalendarEvent};
use crate::config::CalDAVConfig;
#[derive(Deserialize)]
pub struct CalendarQuery {
@@ -18,31 +17,17 @@ pub struct CalendarQuery {
}
pub async fn get_calendar_events(
State(_state): State<Arc<AppState>>,
State(state): State<Arc<AppState>>,
Query(params): Query<CalendarQuery>,
headers: HeaderMap,
) -> Result<Json<Vec<CalendarEvent>>, ApiError> {
// Verify authentication (extract token from Authorization header)
let _token = if let Some(auth_header) = headers.get("authorization") {
let auth_str = auth_header
.to_str()
.map_err(|_| ApiError::Unauthorized("Invalid authorization header".to_string()))?;
if auth_str.starts_with("Bearer ") {
auth_str.strip_prefix("Bearer ").unwrap().to_string()
} else {
return Err(ApiError::Unauthorized("Invalid authorization format".to_string()));
}
} else {
return Err(ApiError::Unauthorized("Missing authorization header".to_string()));
};
// TODO: Validate JWT token here
// Load CalDAV configuration
let config = CalDAVConfig::from_env()
.map_err(|e| ApiError::Internal(format!("Failed to load CalDAV config: {}", e)))?;
// Extract and verify token
let token = extract_bearer_token(&headers)?;
let password = extract_password_header(&headers)?;
println!("🔑 API call with password length: {}", password.len());
// Create CalDAV config from token and password
let config = state.auth_service.caldav_config_from_token(&token, &password)?;
let client = CalDAVClient::new(config);
// Discover calendars if needed
@@ -74,31 +59,16 @@ pub async fn get_calendar_events(
}
pub async fn refresh_event(
State(_state): State<Arc<AppState>>,
State(state): State<Arc<AppState>>,
Path(uid): Path<String>,
headers: HeaderMap,
) -> Result<Json<Option<CalendarEvent>>, ApiError> {
// Verify authentication (extract token from Authorization header)
let _token = if let Some(auth_header) = headers.get("authorization") {
let auth_str = auth_header
.to_str()
.map_err(|_| ApiError::Unauthorized("Invalid authorization header".to_string()))?;
if auth_str.starts_with("Bearer ") {
auth_str.strip_prefix("Bearer ").unwrap().to_string()
} else {
return Err(ApiError::Unauthorized("Invalid authorization format".to_string()));
}
} else {
return Err(ApiError::Unauthorized("Missing authorization header".to_string()));
};
// TODO: Validate JWT token here
// Load CalDAV configuration
let config = CalDAVConfig::from_env()
.map_err(|e| ApiError::Internal(format!("Failed to load CalDAV config: {}", e)))?;
// Extract and verify token
let token = extract_bearer_token(&headers)?;
let password = extract_password_header(&headers)?;
// Create CalDAV config from token and password
let config = state.auth_service.caldav_config_from_token(&token, &password)?;
let client = CalDAVClient::new(config);
// Discover calendars if needed
@@ -119,18 +89,15 @@ pub async fn refresh_event(
Ok(Json(event))
}
pub async fn register(
State(state): State<Arc<AppState>>,
Json(request): Json<RegisterRequest>,
) -> Result<Json<AuthResponse>, ApiError> {
let response = state.auth_service.register(request).await?;
Ok(Json(response))
}
pub async fn login(
State(state): State<Arc<AppState>>,
Json(request): Json<LoginRequest>,
Json(request): Json<CalDAVLoginRequest>,
) -> Result<Json<AuthResponse>, ApiError> {
println!("🔐 Login attempt:");
println!(" Server URL: {}", request.server_url);
println!(" Username: {}", request.username);
println!(" Password length: {}", request.password.len());
let response = state.auth_service.login(request).await?;
Ok(Json(response))
}
@@ -139,25 +106,40 @@ pub async fn verify_token(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> Result<Json<serde_json::Value>, ApiError> {
// Try to get token from Authorization header
let token = if let Some(auth_header) = headers.get("authorization") {
let auth_str = auth_header
.to_str()
.map_err(|_| ApiError::BadRequest("Invalid authorization header".to_string()))?;
if let Some(token) = auth_str.strip_prefix("Bearer ") {
token.to_string()
} else {
return Err(ApiError::BadRequest("Authorization header must start with 'Bearer '".to_string()));
}
} else {
return Err(ApiError::Unauthorized("Authorization header required".to_string()));
};
let user_info = state.auth_service.verify_token(&token).await?;
let token = extract_bearer_token(&headers)?;
let claims = state.auth_service.verify_token(&token)?;
Ok(Json(serde_json::json!({
"valid": true,
"user": user_info
"username": claims.username,
"server_url": claims.server_url
})))
}
// Helper functions
fn extract_bearer_token(headers: &HeaderMap) -> Result<String, ApiError> {
if let Some(auth_header) = headers.get("authorization") {
let auth_str = auth_header
.to_str()
.map_err(|_| ApiError::Unauthorized("Invalid authorization header".to_string()))?;
if let Some(token) = auth_str.strip_prefix("Bearer ") {
Ok(token.to_string())
} else {
Err(ApiError::Unauthorized("Authorization header must start with 'Bearer '".to_string()))
}
} else {
Err(ApiError::Unauthorized("Authorization header required".to_string()))
}
}
fn extract_password_header(headers: &HeaderMap) -> Result<String, ApiError> {
if let Some(password_header) = headers.get("x-caldav-password") {
let password = password_header
.to_str()
.map_err(|_| ApiError::BadRequest("Invalid password header".to_string()))?;
Ok(password.to_string())
} else {
Err(ApiError::BadRequest("X-CalDAV-Password header required".to_string()))
}
}

View File

@@ -3,7 +3,6 @@ use axum::{
routing::{get, post},
Router,
};
use sqlx::sqlite::SqlitePool;
use tower_http::cors::{CorsLayer, Any};
use std::sync::Arc;
@@ -23,25 +22,12 @@ pub struct AppState {
pub async fn run_server() -> Result<(), Box<dyn std::error::Error>> {
// Initialize logging
println!("🚀 Starting Calendar Backend Server");
// Set up database
let database_url = std::env::var("DATABASE_URL")
.unwrap_or_else(|_| "sqlite:calendar.db?mode=rwc".to_string());
let db_pool = SqlitePool::connect(&database_url).await?;
// Run migrations - create database file if it doesn't exist
// The migrate!() macro looks for migrations in the current directory
// so we don't need to run explicit migrations here since we handle it in init_db()
// Create auth service
let jwt_secret = std::env::var("JWT_SECRET")
.unwrap_or_else(|_| "your-super-secret-jwt-key-change-in-production".to_string());
let auth_service = AuthService::new(db_pool, jwt_secret);
// Initialize database schema
auth_service.init_db().await?;
let auth_service = AuthService::new(jwt_secret);
let app_state = AppState { auth_service };
@@ -49,7 +35,6 @@ pub async fn run_server() -> Result<(), Box<dyn std::error::Error>> {
let app = Router::new()
.route("/", get(root))
.route("/api/health", get(health_check))
.route("/api/auth/register", post(handlers::register))
.route("/api/auth/login", post(handlers::login))
.route("/api/auth/verify", get(handlers::verify_token))
.route("/api/calendar/events", get(handlers::get_calendar_events))

View File

@@ -3,44 +3,21 @@ use axum::{
response::{IntoResponse, Response},
Json,
};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
// Database models
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct User {
pub id: String,
pub username: String,
pub email: String,
pub password_hash: String,
pub created_at: DateTime<Utc>,
}
// API request/response types
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UserInfo {
pub id: String,
pub username: String,
pub email: String,
}
#[derive(Debug, Deserialize)]
pub struct RegisterRequest {
pub username: String,
pub email: String,
pub password: String,
}
#[derive(Debug, Deserialize)]
pub struct LoginRequest {
pub struct CalDAVLoginRequest {
pub username: String,
pub password: String,
pub server_url: String,
}
#[derive(Debug, Serialize)]
pub struct AuthResponse {
pub token: String,
pub user: UserInfo,
pub username: String,
pub server_url: String,
}
// Error handling