From 25d04daaeef584ce856e826086c6efbd12b23fd9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Mon, 26 Oct 2026 15:47:16 +0100 Subject: [PATCH] 0.2.94 --- Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 12 +++ src/app/mod.rs | 1 + src/auth/mod.rs | 97 ++++++++++++++++---- src/tests/app.rs | 2 + src/tests/auth.rs | 202 ++++++++++++++++++++++++++++++++++------- static/js/api.js | 43 ++++----- static/js/auth-ui.js | 17 ++-- static/js/home.js | 8 +- static/js/session.js | 11 ++- static/js/workspace.js | 16 +++- 12 files changed, 323 insertions(+), 90 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index fa67799..de715a3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2674,7 +2674,7 @@ dependencies = [ [[package]] name = "rustpad" -version = "0.2.93" +version = "0.2.94" dependencies = [ "argon2", "aws-config", diff --git a/Cargo.toml b/Cargo.toml index 0330fe4..4e6595e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rustpad" -version = "0.2.93" +version = "0.2.94" edition = "2024" rust-version = "1.94" description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL" diff --git a/README.md b/README.md index 9373a7c..fcb5541 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,18 @@ RustPad is a collaborative Markdown editor with standalone notes and workspaces. +## Release 0.2.94 — authentication session handling + +- `GET /api/auth/session` checks the visitor's login state: `200 {"authenticated":false,"session":null}` for guests, missing/expired cookies, or logged-out users; `200 {"authenticated":true,"session":{...}}` for a valid account session. The response is never cached. Expired cookies are cleared; valid session cookies are refreshed as before. +- `GET /api/auth/me` remains a protected endpoint and returns `401` when unauthenticated. Other authenticated endpoints continue to use `401` for missing sessions. +- Frontend pages now distinguish a logged-out visitor from a server/network failure while checking the session. Session expiry clears client state once and raises the existing expiry notification; unexpected errors no longer force logout. + +Run the frontend session regression tests from the project root with Node.js 22+: + +```bash +node --experimental-vm-modules scripts/test_auth_session.mjs +``` + ## Development setup ```bash diff --git a/src/app/mod.rs b/src/app/mod.rs index afeb60a..63245ba 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -107,6 +107,7 @@ pub fn router( post(auth::resend_confirmation), ) .route("/api/auth/me", get(auth::me)) + .route("/api/auth/session", get(auth::session)) .route("/api/auth/profile", post(auth::update_profile)) .route( "/api/auth/account/delete", diff --git a/src/auth/mod.rs b/src/auth/mod.rs index 1c663d8..64b5a72 100644 --- a/src/auth/mod.rs +++ b/src/auth/mod.rs @@ -374,6 +374,11 @@ pub struct SessionResponse { language: String, } #[derive(Serialize)] +struct SessionStatusResponse { + authenticated: bool, + session: Option, +} +#[derive(Serialize)] pub struct IdentityResponse { nickname: String, registered: bool, @@ -808,6 +813,30 @@ fn suggested_directory_nickname(display_name: Option<&str>, email: &str) -> Opti .and_then(|value| validate_nickname(&value).ok()) } +/// Check whether a visitor has an active account session without treating guests as errors. +pub async fn session( + State(state): State, + headers: HeaderMap, +) -> Result { + let Some(token) = crate::security::session_token(&headers) else { + return Ok(session_status_response(None, state.user_session_ttl_days)); + }; + let Some(user) = user_from_token(&state, token).await? else { + let mut response = session_status_response(None, state.user_session_ttl_days); + response + .headers_mut() + .insert(header::SET_COOKIE, crate::security::clear_session_cookie()); + return Ok(response); + }; + debug!(user_id = user.id, "session validation successful"); + let details = session_details(&state, token, user).await?; + Ok(session_status_response( + Some(details), + state.user_session_ttl_days, + )) +} + +/// Preserve the existing protected `/api/auth/me` API contract (401 for guests). pub async fn me( State(state): State, headers: HeaderMap, @@ -822,6 +851,19 @@ pub async fn me( return Ok(response); }; debug!(user_id = user.id, "session validation successful"); + let details = session_details(&state, token, user).await?; + Ok(session_json_response( + StatusCode::OK, + details, + state.user_session_ttl_days, + )) +} + +async fn session_details( + state: &SharedState, + token: &str, + user: User, +) -> Result { let expires_at: String = sqlx::query_scalar(queries::get( state.db.kind(), queries::AUTH_SESSION_EXPIRES_AT, @@ -831,7 +873,7 @@ pub async fn me( .await .map_err(AuthError::database)?; let (directory_managed, directory_display_name, directory_organization, suggested_nickname) = - directory_profile_metadata(&state, &user).await?; + directory_profile_metadata(state, &user).await?; let editor_color: Option = sqlx::query_scalar(queries::get( state.db.kind(), queries::AUTH_EDITOR_COLOR_BY_USER, @@ -840,23 +882,19 @@ pub async fn me( .fetch_one(state.db.pool()) .await .map_err(AuthError::database)?; - Ok(session_json_response( - StatusCode::OK, - SessionResponse { - token: token.into(), - nickname: user.nickname, - email: user.email, - expires_at, - directory_managed, - directory_display_name, - directory_organization, - suggested_nickname, - editor_color, - theme: user.theme, - language: user.language, - }, - state.user_session_ttl_days, - )) + Ok(SessionResponse { + token: token.into(), + nickname: user.nickname, + email: user.email, + expires_at, + directory_managed, + directory_display_name, + directory_organization, + suggested_nickname, + editor_color, + theme: user.theme, + language: user.language, + }) } pub async fn update_profile( @@ -3135,6 +3173,29 @@ fn session_json_response(status: StatusCode, session: SessionResponse, ttl_days: let cookie = crate::security::session_cookie(&session.token, ttl_days); let mut response = (status, Json(session)).into_response(); response.headers_mut().insert(header::SET_COOKIE, cookie); + response.headers_mut().insert( + header::CACHE_CONTROL, + axum::http::HeaderValue::from_static("no-store"), + ); + response +} + +fn session_status_response(session: Option, ttl_days: i64) -> Response { + let cookie = session + .as_ref() + .map(|details| crate::security::session_cookie(&details.token, ttl_days)); + let mut response = Json(SessionStatusResponse { + authenticated: session.is_some(), + session, + }) + .into_response(); + response.headers_mut().insert( + header::CACHE_CONTROL, + axum::http::HeaderValue::from_static("no-store"), + ); + if let Some(cookie) = cookie { + response.headers_mut().insert(header::SET_COOKIE, cookie); + } response } diff --git a/src/tests/app.rs b/src/tests/app.rs index f37e913..8632dee 100644 --- a/src/tests/app.rs +++ b/src/tests/app.rs @@ -38,6 +38,7 @@ fn classifies_other_routes_as_application() { for path in [ "/", "/api/auth/me", + "/api/auth/session", "/static/missing.css", "/files/legacy/image.png", "/unknown", @@ -163,6 +164,7 @@ async fn file_listener_never_exposes_application_routes() { for path in [ "/", "/api/auth/me", + "/api/auth/session", "/assets/css/styles.css", "/f", "/robots.txt", diff --git a/src/tests/auth.rs b/src/tests/auth.rs index 7fe8a56..1583f54 100644 --- a/src/tests/auth.rs +++ b/src/tests/auth.rs @@ -8,11 +8,7 @@ */ use super::*; -use crate::{ - database::Database, - state::AppState, - storage::Storage, -}; +use crate::{database::Database, state::AppState, storage::Storage}; use std::sync::Arc; async fn logout_test_state() -> SharedState { @@ -85,12 +81,8 @@ async fn logout_only_revokes_current_browser_session_and_password_access() { .unwrap(); } - let access_cookie = crate::security::resource_cookie( - "pad", - "current-pad", - ¤t_access_token, - 7, - ); + let access_cookie = + crate::security::resource_cookie("pad", "current-pad", ¤t_access_token, 7); let access_cookie_name = access_cookie .to_str() .unwrap() @@ -129,20 +121,18 @@ async fn logout_only_revokes_current_browser_session_and_password_access() { .fetch_one(state.db.pool()) .await .unwrap(); - let current_access_count: i64 = sqlx::query_scalar( - "SELECT COUNT(*) FROM resource_access_tokens WHERE token_hash = ?", - ) - .bind(hash_token(¤t_access_token)) - .fetch_one(state.db.pool()) - .await - .unwrap(); - let other_access_count: i64 = sqlx::query_scalar( - "SELECT COUNT(*) FROM resource_access_tokens WHERE token_hash = ?", - ) - .bind(hash_token(&other_access_token)) - .fetch_one(state.db.pool()) - .await - .unwrap(); + let current_access_count: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM resource_access_tokens WHERE token_hash = ?") + .bind(hash_token(¤t_access_token)) + .fetch_one(state.db.pool()) + .await + .unwrap(); + let other_access_count: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM resource_access_tokens WHERE token_hash = ?") + .bind(hash_token(&other_access_token)) + .fetch_one(state.db.pool()) + .await + .unwrap(); let user_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE id = 1") .fetch_one(state.db.pool()) .await @@ -160,11 +150,159 @@ async fn logout_only_revokes_current_browser_session_and_password_access() { .iter() .map(|value| value.to_str().unwrap()) .collect::>(); - assert!(set_cookies.iter().any(|value| { - value.starts_with(&format!("{}=;", crate::security::SESSION_COOKIE)) - })); - assert!(set_cookies.iter().any(|value| { - value.starts_with(&format!("{access_cookie_name}=;")) - })); - assert!(!set_cookies.iter().any(|value| value.starts_with(share_cookie_name))); + assert!( + set_cookies + .iter() + .any(|value| { value.starts_with(&format!("{}=;", crate::security::SESSION_COOKIE)) }) + ); + assert!( + set_cookies + .iter() + .any(|value| { value.starts_with(&format!("{access_cookie_name}=;")) }) + ); + assert!( + !set_cookies + .iter() + .any(|value| value.starts_with(share_cookie_name)) + ); +} + +fn account_cookie_headers(token: &str) -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert( + header::COOKIE, + axum::http::HeaderValue::from_str(&format!("{}={token}", crate::security::SESSION_COOKIE)) + .unwrap(), + ); + headers +} + +async fn auth_response_body(response: Response) -> serde_json::Value { + let bytes = axum::body::to_bytes(response.into_body(), 64 * 1024) + .await + .unwrap(); + serde_json::from_slice(&bytes).unwrap() +} + +#[tokio::test] +async fn anonymous_session_status_is_ok_without_creating_a_cookie() { + let state = logout_test_state().await; + let response = session(State(state.clone()), HeaderMap::new()) + .await + .unwrap_or_else(|error| panic!("session failed: {}", error.message)); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store"); + assert!(!response.headers().contains_key(header::SET_COOKIE)); + assert_eq!( + auth_response_body(response).await, + serde_json::json!({"authenticated": false, "session": null}) + ); + + // The older `/api/auth/me` contract remains protected. + let error = me(State(state), HeaderMap::new()).await.err().unwrap(); + assert_eq!(error.status, StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn valid_session_status_returns_profile_and_refreshes_cookie() { + let state = logout_test_state().await; + sqlx::query( + "INSERT INTO users (nickname, nickname_key, email, email_key, password_hash) VALUES (?, ?, ?, ?, ?)", + ) + .bind("Session Test") + .bind("session test") + .bind("session@example.test") + .bind("session@example.test") + .bind("password-hash") + .execute(state.db.pool()) + .await + .unwrap(); + sqlx::query("INSERT INTO user_sessions (token, user_id, expires_at) VALUES (?, 1, ?)") + .bind("valid-session") + .bind((Utc::now() + Duration::days(7)).to_rfc3339()) + .execute(state.db.pool()) + .await + .unwrap(); + + let headers = account_cookie_headers("valid-session"); + let response = session(State(state.clone()), headers.clone()) + .await + .unwrap_or_else(|error| panic!("session failed: {}", error.message)); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store"); + assert!( + response.headers()[header::SET_COOKIE] + .to_str() + .unwrap() + .starts_with("__Host-rustpad_session=valid-session;") + ); + let body = auth_response_body(response).await; + assert_eq!(body["authenticated"], true); + assert_eq!(body["session"]["nickname"], "Session Test"); + assert_eq!(body["session"]["email"], "session@example.test"); + assert!(body["session"].get("token").is_none()); + + let me_response = me(State(state), headers) + .await + .unwrap_or_else(|error| panic!("me failed: {}", error.message)); + assert_eq!(me_response.status(), StatusCode::OK); + let body = auth_response_body(me_response).await; + assert_eq!(body["nickname"], "Session Test"); + assert!(body.get("authenticated").is_none()); +} + +#[tokio::test] +async fn missing_or_expired_session_returns_guest_and_clears_stale_cookie() { + let state = logout_test_state().await; + sqlx::query( + "INSERT INTO users (nickname, nickname_key, email, email_key, password_hash) VALUES (?, ?, ?, ?, ?)", + ) + .bind("Expired Test") + .bind("expired test") + .bind("expired@example.test") + .bind("expired@example.test") + .bind("password-hash") + .execute(state.db.pool()) + .await + .unwrap(); + sqlx::query("INSERT INTO user_sessions (token, user_id, expires_at) VALUES (?, 1, ?)") + .bind("expired-session") + .bind((Utc::now() - Duration::days(1)).to_rfc3339()) + .execute(state.db.pool()) + .await + .unwrap(); + + for token in ["expired-session", "unknown-session"] { + let response = session(State(state.clone()), account_cookie_headers(token)) + .await + .unwrap_or_else(|error| panic!("session failed: {}", error.message)); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store"); + assert!( + response.headers()[header::SET_COOKIE] + .to_str() + .unwrap() + .starts_with("__Host-rustpad_session=;") + ); + assert_eq!( + auth_response_body(response).await, + serde_json::json!({"authenticated": false, "session": null}) + ); + } + let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM user_sessions") + .fetch_one(state.db.pool()) + .await + .unwrap(); + assert_eq!(count, 0); +} + +#[tokio::test] +async fn session_check_propagates_database_errors_instead_of_reporting_guest() { + let state = logout_test_state().await; + state.db.pool().close().await; + let error = session(State(state), account_cookie_headers("valid-session")) + .await + .err() + .unwrap(); + assert_eq!(error.status, StatusCode::INTERNAL_SERVER_ERROR); } diff --git a/static/js/api.js b/static/js/api.js index bfd87f4..02d9ba1 100644 --- a/static/js/api.js +++ b/static/js/api.js @@ -8,8 +8,8 @@ */ import { logDebug, logError, logWarn } from "@rustpad/logger"; -import { formatNumber, restoreGuestLanguage, translateApiMessage } from "@rustpad/i18n"; -import { restoreGuestTheme } from "@rustpad/theme"; +import { formatNumber, translateApiMessage } from "@rustpad/i18n"; +import { expireAuthSession, getAuthToken } from "@rustpad/session"; const DEFAULT_ERRORS = { 400: "Invalid request.", @@ -65,29 +65,26 @@ function formatBytes(bytes) { return `${formatNumber(bytes)} B`; } -function clearExpiredSession() { - localStorage.removeItem("rustpad:auth-state"); - localStorage.removeItem("rustpad:auth-token"); - sessionStorage.removeItem("rustpad:auth-token"); - localStorage.removeItem("rustpad:nickname"); - sessionStorage.removeItem("rustpad:nickname"); - document.cookie = "rustpad_nickname=; Path=/; SameSite=Lax; Max-Age=0"; - restoreGuestTheme(); - void restoreGuestLanguage(); - window.dispatchEvent(new CustomEvent("rustpad:session-expired")); - window.dispatchEvent(new CustomEvent("rustpad:session-change", { detail: { session: null } })); -} - +// Multiple simultaneous 401s must not start multiple session checks. +let sessionCheckPromise = null; async function clearSessionIfInvalid() { - if (!localStorage.getItem("rustpad:auth-state")) return; - try { - const response = await fetch("/api/auth/me", { - signal: AbortSignal.timeout(5000), - }); - if (response.status === 401) clearExpiredSession(); - } catch { - // A network failure does not prove that the session is invalid. + if (!getAuthToken()) return; + if (!sessionCheckPromise) { + sessionCheckPromise = (async () => { + const response = await fetch("/api/auth/session", { + credentials: "same-origin", + cache: "no-store", + signal: AbortSignal.timeout(5000), + }); + // A failed check is not proof that the user is logged out. + if (!response.ok) return; + const data = await response.json(); + if (data.authenticated === false && data.session === null) await expireAuthSession(); + })().catch(() => { + // Network and parsing errors must not erase the saved session. + }).finally(() => { sessionCheckPromise = null; }); } + await sessionCheckPromise; } function validateUploadSize(body, configuredMaxBytes) { diff --git a/static/js/auth-ui.js b/static/js/auth-ui.js index 8c3f4b5..749ec36 100644 --- a/static/js/auth-ui.js +++ b/static/js/auth-ui.js @@ -12,7 +12,7 @@ import * as sessionStore from "@rustpad/session"; import { askConfirm, askInput, showMessage } from "@rustpad/modal"; import { toast } from "@rustpad/toast"; -const { getAuthToken, setAuthSession, setNickname } = sessionStore; +const { getAuthToken, setAuthSession, setNickname, expireAuthSession } = sessionStore; const clearAuthSession = sessionStore.clearAuthSession || (() => { localStorage.removeItem("rustpad:auth-state"); localStorage.removeItem("rustpad:auth-token"); @@ -343,15 +343,16 @@ function bindLegacyIdentityDialog({ dialog, onIdentity }) { } export async function validateCurrentSession() { - const expectedSession = Boolean(getAuthToken()); - try { - const session = await api("/api/auth/me"); - await setAuthSession(session); - return session; - } catch { - if (expectedSession) await clearAuthSession(); + const result = await api("/api/auth/session", { cache: "no-store" }); + if (result.authenticated === false && result.session === null) { + await expireAuthSession(); return null; } + if (result.authenticated !== true || !result.session || typeof result.session.nickname !== "string") { + throw new Error("Invalid authentication session response."); + } + await setAuthSession(result.session); + return result.session; } export async function logoutCurrentSession() { diff --git a/static/js/home.js b/static/js/home.js index f4a9ff4..562f6de 100644 --- a/static/js/home.js +++ b/static/js/home.js @@ -618,6 +618,12 @@ if (identityDialog) { }) .catch(error => { console.warn("Could not validate the saved session", error); - if (accountStateRevision === lookupRevision) renderAccount(null); + if (accountStateRevision !== lookupRevision) return; + if (accountSlot) { + accountSlot.dataset.state = "error"; + accountSlot.setAttribute("aria-busy", "false"); + } + const retry = document.querySelector("#footer-account-retry"); + if (retry) retry.hidden = false; }); } diff --git a/static/js/session.js b/static/js/session.js index 42d725f..e731146 100644 --- a/static/js/session.js +++ b/static/js/session.js @@ -93,6 +93,11 @@ export async function setAuthSession(session) { await setLanguage(session?.language || "en", { scope: "account" }); window.dispatchEvent(new CustomEvent("rustpad:session-change", { detail: { session } })); } +export async function expireAuthSession() { + if (!getAuthToken()) return; + await clearAuthSession(); + window.dispatchEvent(new CustomEvent("rustpad:session-expired")); +} export async function clearAuthSession() { localStorage.removeItem(AUTH_STATE_KEY); localStorage.removeItem("rustpad:auth-token"); @@ -101,6 +106,10 @@ export async function clearAuthSession() { sessionStorage.removeItem(NICKNAME_KEY); setNicknameCookie(""); restoreGuestTheme(); - await restoreGuestLanguage(); + try { + await restoreGuestLanguage(); + } catch { + // A language bundle failure must not block logout or session-expiry events. + } window.dispatchEvent(new CustomEvent("rustpad:session-change", { detail: { session: null } })); } diff --git a/static/js/workspace.js b/static/js/workspace.js index 001c2f1..6e16c37 100644 --- a/static/js/workspace.js +++ b/static/js/workspace.js @@ -266,11 +266,17 @@ async function toggleNoteFavorite(button) { { title: t("favorites.title", {}, "Favorites") }, ); } catch (error) { - if (error.status === 401 || error.status === 403) { - const session = await validateCurrentSession(); - if (!session) { - accountSession = null; - renderNotes(); + // The API client already rechecks the session after a 401. + // Only a 403 needs an additional check for an unrelated access restriction. + if (error.status === 403) { + try { + const session = await validateCurrentSession(); + if (!session) { + accountSession = null; + renderNotes(); + } + } catch { + // A failed recheck is not evidence that the account session expired. } } toast.danger(error.message, { title: t("favorites.updateFailed", {}, "Could not update favorites") });