Fix authentication validation to properly reject invalid CalDAV servers
All checks were successful
Build and Push Docker Image / docker (push) Successful in 1m9s

- Backend: Enhance CalDAV discovery to require at least one valid 207 response
- Backend: Fail authentication if no valid CalDAV endpoints are found
- Frontend: Add token verification on app startup to validate stored tokens
- Frontend: Clear invalid tokens when login fails or token verification fails
- Frontend: Prevent users with invalid tokens from accessing calendar page

This resolves the issue where invalid servers (like google.com) were incorrectly
accepted as valid CalDAV servers, and ensures proper authentication flow.

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Connor Johnstone
2025-09-04 16:06:18 -04:00
parent b16603b50b
commit 8329244c69
4 changed files with 107 additions and 4 deletions

View File

@@ -580,14 +580,34 @@ impl CalDAVClient {
let mut all_calendars = Vec::new();
let mut has_valid_caldav_response = false;
for path in discovery_paths {
println!("Trying discovery path: {}", path);
if let Ok(calendars) = self.discover_calendars_at_path(&path).await {
println!("Found {} calendar(s) at {}", calendars.len(), path);
all_calendars.extend(calendars);
match self.discover_calendars_at_path(&path).await {
Ok(calendars) => {
println!("Found {} calendar(s) at {}", calendars.len(), path);
has_valid_caldav_response = true;
all_calendars.extend(calendars);
}
Err(CalDAVError::ServerError(status)) => {
// HTTP error - this might be expected for some paths, continue trying
println!("Discovery path {} returned HTTP {}, trying next path", path, status);
}
Err(e) => {
// Network or other error - this suggests the server isn't reachable or isn't CalDAV
println!("Discovery failed for path {}: {:?}", path, e);
return Err(e);
}
}
}
// If we never got a valid CalDAV response (e.g., all requests failed),
// this is likely not a CalDAV server
if !has_valid_caldav_response {
return Err(CalDAVError::ServerError(404));
}
// Remove duplicates
all_calendars.sort();
all_calendars.dedup();

View File

@@ -55,7 +55,42 @@ fn get_theme_event_colors() -> Vec<String> {
#[function_component]
pub fn App() -> Html {
let auth_token = use_state(|| -> Option<String> { LocalStorage::get("auth_token").ok() });
let auth_token = use_state(|| -> Option<String> { None });
// Validate token on app startup
{
let auth_token = auth_token.clone();
use_effect_with((), move |_| {
let auth_token = auth_token.clone();
wasm_bindgen_futures::spawn_local(async move {
// Check if there's a stored token
if let Ok(stored_token) = LocalStorage::get::<String>("auth_token") {
// Verify the stored token
let auth_service = crate::auth::AuthService::new();
match auth_service.verify_token(&stored_token).await {
Ok(true) => {
// Token is valid, set it
web_sys::console::log_1(&"✅ Stored auth token is valid".into());
auth_token.set(Some(stored_token));
}
_ => {
// Token is invalid or verification failed, clear it
web_sys::console::log_1(&"❌ Stored auth token is invalid, clearing".into());
let _ = LocalStorage::delete("auth_token");
let _ = LocalStorage::delete("session_token");
let _ = LocalStorage::delete("caldav_credentials");
auth_token.set(None);
}
}
} else {
// No stored token
web_sys::console::log_1(&" No stored auth token found".into());
auth_token.set(None);
}
});
|| ()
});
}
let user_info = use_state(|| -> Option<UserInfo> { None });
let color_picker_open = use_state(|| -> Option<String> { None });

View File

@@ -53,6 +53,50 @@ impl AuthService {
self.post_json("/auth/login", &request).await
}
pub async fn verify_token(&self, token: &str) -> Result<bool, String> {
let window = web_sys::window().ok_or("No global window exists")?;
let opts = RequestInit::new();
opts.set_method("GET");
opts.set_mode(RequestMode::Cors);
let url = format!("{}/auth/verify", self.base_url);
let request = Request::new_with_str_and_init(&url, &opts)
.map_err(|e| format!("Request creation failed: {:?}", e))?;
request
.headers()
.set("Authorization", &format!("Bearer {}", token))
.map_err(|e| format!("Header setting failed: {:?}", e))?;
let resp_value = JsFuture::from(window.fetch_with_request(&request))
.await
.map_err(|e| format!("Network request failed: {:?}", e))?;
let resp: Response = resp_value
.dyn_into()
.map_err(|e| format!("Response cast failed: {:?}", e))?;
if resp.ok() {
let text = JsFuture::from(
resp.text()
.map_err(|e| format!("Text extraction failed: {:?}", e))?,
)
.await
.map_err(|e| format!("Text promise failed: {:?}", e))?;
let text_string = text.as_string().ok_or("Response text is not a string")?;
// Parse the response to get the "valid" field
let response: serde_json::Value = serde_json::from_str(&text_string)
.map_err(|e| format!("JSON parsing failed: {}", e))?;
Ok(response.get("valid").and_then(|v| v.as_bool()).unwrap_or(false))
} else {
Ok(false) // Invalid token
}
}
// Helper method for POST requests with JSON body
async fn post_json<T: Serialize, R: for<'de> Deserialize<'de>>(
&self,

View File

@@ -145,6 +145,10 @@ pub fn Login(props: &LoginProps) -> Html {
}
Err(err) => {
web_sys::console::log_1(&format!("❌ Login failed: {}", err).into());
// Clear any existing invalid tokens
let _ = LocalStorage::delete("auth_token");
let _ = LocalStorage::delete("session_token");
let _ = LocalStorage::delete("caldav_credentials");
error_message.set(Some(err));
is_loading.set(false);
}