diff --git a/desktop/app.js b/desktop/app.js index 885a5e6..ca0b334 100644 --- a/desktop/app.js +++ b/desktop/app.js @@ -148,11 +148,44 @@ async function api(path, options = {}) { headers["Authorization"] = `Bearer ${token}`; } - const res = await fetch(fullUrl, { + let res = await fetch(fullUrl, { ...options, headers, }); + // Handle 401 Unauthorized via Refresh Token + if (res.status === 401 && !path.includes('/auth/refresh')) { + const refreshToken = localStorage.getItem("chattz_refresh_token"); + if (refreshToken) { + try { + const refreshUrl = API_BASE_URL ? `${API_BASE_URL}/auth/refresh` : '/auth/refresh'; + const refreshRes = await fetch(refreshUrl, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ refresh_token: refreshToken }) + }); + + if (refreshRes.ok) { + const newTokens = await refreshRes.json(); + localStorage.setItem("chattz_token", newTokens.access_token); + localStorage.setItem("chattz_refresh_token", newTokens.refresh_token); + + // Retry original request with new token + headers["Authorization"] = `Bearer ${newTokens.access_token}`; + res = await fetch(fullUrl, { ...options, headers }); + } else { + // Both tokens invalid/expired, wipe out to force clear state + throw new Error("Refresh token expired or invalid"); + } + } catch (err) { + localStorage.removeItem("chattz_token"); + localStorage.removeItem("chattz_refresh_token"); + location.href = API_BASE_URL ? `${API_BASE_URL}/auth/login` : "/auth/login"; + throw new Error("Session expired, please log in again"); + } + } + } + if (!res.ok) { let detail = "request failed"; try { @@ -1191,9 +1224,14 @@ async function init() { try { const searchParams = new URLSearchParams(location.search); const jwtToken = searchParams.get("token"); - if (jwtToken) { - localStorage.setItem("chattz_token", jwtToken); + const refreshToken = searchParams.get("refresh_token"); + + if (jwtToken || refreshToken) { + if (jwtToken) localStorage.setItem("chattz_token", jwtToken); + if (refreshToken) localStorage.setItem("chattz_refresh_token", refreshToken); + searchParams.delete("token"); + searchParams.delete("refresh_token"); const nextQuery = searchParams.toString(); const nextUrl = `${location.pathname}${nextQuery ? `?${nextQuery}` : ""}`; history.replaceState(null, "", nextUrl); @@ -1210,6 +1248,7 @@ async function init() { el.logoutBtn.onclick = async () => { await leaveVoice(); localStorage.removeItem("chattz_token"); + localStorage.removeItem("chattz_refresh_token"); try { await api("/auth/logout", { method: "POST" }); } catch (e) { } location.reload(); }; diff --git a/src/auth.rs b/src/auth.rs index 63c901f..667c64d 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -14,13 +14,15 @@ use uuid::Uuid; use crate::{AppState, db}; const OAUTH_STATE_COOKIE: &str = "chattz_oauth_state"; -const SESSION_TTL_SECS: u64 = 60 * 60 * 24 * 7; +const SESSION_TTL_SECS: u64 = 60 * 15; // 15 minutes +const REFRESH_TTL_SECS: u64 = 60 * 60 * 24 * 30; // 30 days #[derive(Debug, Serialize, Deserialize, Clone)] -struct SessionClaims { - sub: String, - exp: usize, - iat: usize, +pub struct SessionClaims { + pub sub: String, + pub kind: String, // "access" or "refresh" + pub exp: usize, + pub iat: usize, } #[derive(Debug, Clone)] @@ -94,8 +96,8 @@ where .or_else(|| read_query_token(parts)) .ok_or_else(|| ApiError::unauthorized("missing jwt token"))?; - let user_id = verify_session(&token, &app.settings.session_secret) - .map_err(|_| ApiError::unauthorized("invalid token"))?; + let user_id = verify_session(&token, &app.settings.session_secret, "access") + .map_err(|_| ApiError::unauthorized("invalid or expired token"))?; let exists = db::user_exists(&app.db, user_id) .await @@ -138,25 +140,41 @@ pub fn read_oauth_state_from_headers(headers: &axum::http::HeaderMap) -> Option< }) } -pub fn new_jwt_token(user_id: Uuid, secret: &str) -> Result { +pub fn new_jwt_tokens(user_id: Uuid, secret: &str) -> Result<(String, String)> { let now = now_ts(); - let claims = SessionClaims { + + let access_claims = SessionClaims { sub: user_id.to_string(), + kind: "access".to_string(), iat: now as usize, exp: (now + SESSION_TTL_SECS) as usize, }; - let token = encode( + let refresh_claims = SessionClaims { + sub: user_id.to_string(), + kind: "refresh".to_string(), + iat: now as usize, + exp: (now + REFRESH_TTL_SECS) as usize, + }; + + let access_token = encode( &Header::default(), - &claims, + &access_claims, &EncodingKey::from_secret(secret.as_bytes()), ) - .context("failed to encode session token")?; + .context("failed to encode access token")?; - Ok(token) + let refresh_token = encode( + &Header::default(), + &refresh_claims, + &EncodingKey::from_secret(secret.as_bytes()), + ) + .context("failed to encode refresh token")?; + + Ok((access_token, refresh_token)) } -fn verify_session(token: &str, secret: &str) -> Result { +pub fn verify_session(token: &str, secret: &str, expected_kind: &str) -> Result { let mut validation = Validation::default(); validation.validate_exp = true; @@ -167,6 +185,10 @@ fn verify_session(token: &str, secret: &str) -> Result { ) .context("failed to decode session token")?; + if data.claims.kind != expected_kind { + return Err(anyhow!("invalid token kind")); + } + let user_id = Uuid::parse_str(&data.claims.sub).context("invalid sub in session token")?; Ok(user_id) } diff --git a/src/handlers.rs b/src/handlers.rs index 55f4352..9337339 100644 --- a/src/handlers.rs +++ b/src/handlers.rs @@ -22,6 +22,7 @@ pub fn routes() -> Router { .route("/", get(index)) .route("/auth/login", get(auth_login)) .route("/auth/callback", get(auth_callback)) + .route("/auth/refresh", post(auth_refresh)) .route("/auth/logout", post(auth_logout)) .route("/me", get(me)) .route("/dms", get(list_dm_conversations)) @@ -213,8 +214,9 @@ async fn auth_callback( .await .map_err(|e| ApiError::internal(&format!("failed to persist user: {e}")))?; - let jwt_token = auth::new_jwt_token(user.id, &state.settings.session_secret) - .map_err(|e| ApiError::internal(&e.to_string()))?; + let (access_token, refresh_token) = + auth::new_jwt_tokens(user.id, &state.settings.session_secret) + .map_err(|e| ApiError::internal(&e.to_string()))?; let mut headers = HeaderMap::new(); headers.append( @@ -224,7 +226,53 @@ async fn auth_callback( .map_err(|_| ApiError::internal("failed to clear oauth state cookie"))?, ); - Ok((headers, Redirect::to(&format!("/?token={}", jwt_token)))) + Ok(( + headers, + Redirect::to(&format!( + "/?token={}&refresh_token={}", + access_token, refresh_token + )), + )) +} + +#[derive(Deserialize)] +struct AuthRefreshBody { + refresh_token: String, +} + +#[derive(Serialize)] +struct AuthRefreshResponse { + access_token: String, + refresh_token: String, +} + +async fn auth_refresh( + State(state): State, + Json(body): Json, +) -> Result { + let user_id = auth::verify_session( + &body.refresh_token, + &state.settings.session_secret, + "refresh", + ) + .map_err(|_| ApiError::unauthorized("invalid or expired refresh token"))?; + + let exists = db::user_exists(&state.db, user_id) + .await + .map_err(|_| ApiError::internal("user verification failed"))?; + + if !exists { + return Err(ApiError::unauthorized("user not found")); + } + + let (access_token, refresh_token) = + auth::new_jwt_tokens(user_id, &state.settings.session_secret) + .map_err(|e| ApiError::internal(&e.to_string()))?; + + Ok(Json(AuthRefreshResponse { + access_token, + refresh_token, + })) } async fn auth_logout() -> Result { diff --git a/static/app.js b/static/app.js index c0d1669..c17ecee 100644 --- a/static/app.js +++ b/static/app.js @@ -110,15 +110,53 @@ const el = { // --- API Helpers --- async function api(path, options = {}) { - const res = await fetch(path, { + const headers = { + "content-type": "application/json", + ...(options.headers || {}), + }; + + const token = localStorage.getItem("chattz_token"); + if (token) { + headers["Authorization"] = `Bearer ${token}`; + } + + let res = await fetch(path, { ...options, - headers: { - "content-type": "application/json", - ...(options.headers || {}), - }, - credentials: "same-origin", + headers, }); + // Handle 401 Unauthorized via Refresh Token + if (res.status === 401 && !path.includes('/auth/refresh')) { + const refreshToken = localStorage.getItem("chattz_refresh_token"); + if (refreshToken) { + try { + const refreshRes = await fetch('/auth/refresh', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ refresh_token: refreshToken }) + }); + + if (refreshRes.ok) { + const newTokens = await refreshRes.json(); + localStorage.setItem("chattz_token", newTokens.access_token); + localStorage.setItem("chattz_refresh_token", newTokens.refresh_token); + + // Retry original request with new token + headers["Authorization"] = `Bearer ${newTokens.access_token}`; + res = await fetch(path, { ...options, headers }); + } else { + // Both tokens invalid/expired + throw new Error("Refresh token expired or invalid"); + } + } catch (err) { + localStorage.removeItem("chattz_token"); + localStorage.removeItem("chattz_refresh_token"); + location.href = "/auth/login"; + throw new Error("Session expired, please log in again"); + } + } + } + if (!res.ok) { let detail = "request failed"; try { @@ -523,7 +561,11 @@ function startVoicePresencePolling() { function initChatWs() { if (state.chatWs) state.chatWs.close(); const protocol = location.protocol === "https:" ? "wss:" : "ws:"; - const wsUrl = `${protocol}//${location.host}/ws`; + let wsUrl = `${protocol}//${location.host}/ws`; + const token = localStorage.getItem("chattz_token"); + if (token) { + wsUrl += (wsUrl.includes('?') ? '&' : '?') + `token=${token}`; + } const ws = new WebSocket(wsUrl); state.chatWs = ws; @@ -567,7 +609,12 @@ function initChatWs() { function getVoiceWsUrl(channelId) { const proto = location.protocol === "https:" ? "wss" : "ws"; - return `${proto}://${location.host}/channels/${channelId}/voice/ws`; + let urlStr = `${proto}://${location.host}/channels/${channelId}/voice/ws`; + const token = localStorage.getItem("chattz_token"); + if (token) { + urlStr += (urlStr.includes('?') ? '&' : '?') + `token=${token}`; + } + return urlStr; } function shouldInitiateOffer(peerId) { @@ -1159,9 +1206,14 @@ async function init() { try { const searchParams = new URLSearchParams(location.search); const jwtToken = searchParams.get("token"); - if (jwtToken) { - localStorage.setItem("chattz_token", jwtToken); + const refreshToken = searchParams.get("refresh_token"); + + if (jwtToken || refreshToken) { + if (jwtToken) localStorage.setItem("chattz_token", jwtToken); + if (refreshToken) localStorage.setItem("chattz_refresh_token", refreshToken); + searchParams.delete("token"); + searchParams.delete("refresh_token"); const nextQuery = searchParams.toString(); const nextUrl = `${location.pathname}${nextQuery ? `?${nextQuery}` : ""}`; history.replaceState(null, "", nextUrl); @@ -1175,6 +1227,7 @@ async function init() { el.logoutBtn.onclick = async () => { await leaveVoice(); localStorage.removeItem("chattz_token"); + localStorage.removeItem("chattz_refresh_token"); try { await api("/auth/logout", { method: "POST" }); } catch (e) { } location.reload(); };