fix
All checks were successful
/ upload (release) Successful in 21s

This commit is contained in:
pavel 2026-02-24 21:58:25 +01:00
commit 1ec1ba0028
4 changed files with 192 additions and 30 deletions

View file

@ -148,11 +148,44 @@ async function api(path, options = {}) {
headers["Authorization"] = `Bearer ${token}`; headers["Authorization"] = `Bearer ${token}`;
} }
const res = await fetch(fullUrl, { let res = await fetch(fullUrl, {
...options, ...options,
headers, 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) { if (!res.ok) {
let detail = "request failed"; let detail = "request failed";
try { try {
@ -1191,9 +1224,14 @@ async function init() {
try { try {
const searchParams = new URLSearchParams(location.search); const searchParams = new URLSearchParams(location.search);
const jwtToken = searchParams.get("token"); const jwtToken = searchParams.get("token");
if (jwtToken) { const refreshToken = searchParams.get("refresh_token");
localStorage.setItem("chattz_token", jwtToken);
if (jwtToken || refreshToken) {
if (jwtToken) localStorage.setItem("chattz_token", jwtToken);
if (refreshToken) localStorage.setItem("chattz_refresh_token", refreshToken);
searchParams.delete("token"); searchParams.delete("token");
searchParams.delete("refresh_token");
const nextQuery = searchParams.toString(); const nextQuery = searchParams.toString();
const nextUrl = `${location.pathname}${nextQuery ? `?${nextQuery}` : ""}`; const nextUrl = `${location.pathname}${nextQuery ? `?${nextQuery}` : ""}`;
history.replaceState(null, "", nextUrl); history.replaceState(null, "", nextUrl);
@ -1210,6 +1248,7 @@ async function init() {
el.logoutBtn.onclick = async () => { el.logoutBtn.onclick = async () => {
await leaveVoice(); await leaveVoice();
localStorage.removeItem("chattz_token"); localStorage.removeItem("chattz_token");
localStorage.removeItem("chattz_refresh_token");
try { await api("/auth/logout", { method: "POST" }); } catch (e) { } try { await api("/auth/logout", { method: "POST" }); } catch (e) { }
location.reload(); location.reload();
}; };

View file

@ -14,13 +14,15 @@ use uuid::Uuid;
use crate::{AppState, db}; use crate::{AppState, db};
const OAUTH_STATE_COOKIE: &str = "chattz_oauth_state"; 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)] #[derive(Debug, Serialize, Deserialize, Clone)]
struct SessionClaims { pub struct SessionClaims {
sub: String, pub sub: String,
exp: usize, pub kind: String, // "access" or "refresh"
iat: usize, pub exp: usize,
pub iat: usize,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@ -94,8 +96,8 @@ where
.or_else(|| read_query_token(parts)) .or_else(|| read_query_token(parts))
.ok_or_else(|| ApiError::unauthorized("missing jwt token"))?; .ok_or_else(|| ApiError::unauthorized("missing jwt token"))?;
let user_id = verify_session(&token, &app.settings.session_secret) let user_id = verify_session(&token, &app.settings.session_secret, "access")
.map_err(|_| ApiError::unauthorized("invalid token"))?; .map_err(|_| ApiError::unauthorized("invalid or expired token"))?;
let exists = db::user_exists(&app.db, user_id) let exists = db::user_exists(&app.db, user_id)
.await .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<String> { pub fn new_jwt_tokens(user_id: Uuid, secret: &str) -> Result<(String, String)> {
let now = now_ts(); let now = now_ts();
let claims = SessionClaims {
let access_claims = SessionClaims {
sub: user_id.to_string(), sub: user_id.to_string(),
kind: "access".to_string(),
iat: now as usize, iat: now as usize,
exp: (now + SESSION_TTL_SECS) 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(), &Header::default(),
&claims, &access_claims,
&EncodingKey::from_secret(secret.as_bytes()), &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<Uuid> { pub fn verify_session(token: &str, secret: &str, expected_kind: &str) -> Result<Uuid> {
let mut validation = Validation::default(); let mut validation = Validation::default();
validation.validate_exp = true; validation.validate_exp = true;
@ -167,6 +185,10 @@ fn verify_session(token: &str, secret: &str) -> Result<Uuid> {
) )
.context("failed to decode session token")?; .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")?; let user_id = Uuid::parse_str(&data.claims.sub).context("invalid sub in session token")?;
Ok(user_id) Ok(user_id)
} }

View file

@ -22,6 +22,7 @@ pub fn routes() -> Router<AppState> {
.route("/", get(index)) .route("/", get(index))
.route("/auth/login", get(auth_login)) .route("/auth/login", get(auth_login))
.route("/auth/callback", get(auth_callback)) .route("/auth/callback", get(auth_callback))
.route("/auth/refresh", post(auth_refresh))
.route("/auth/logout", post(auth_logout)) .route("/auth/logout", post(auth_logout))
.route("/me", get(me)) .route("/me", get(me))
.route("/dms", get(list_dm_conversations)) .route("/dms", get(list_dm_conversations))
@ -213,8 +214,9 @@ async fn auth_callback(
.await .await
.map_err(|e| ApiError::internal(&format!("failed to persist user: {e}")))?; .map_err(|e| ApiError::internal(&format!("failed to persist user: {e}")))?;
let jwt_token = auth::new_jwt_token(user.id, &state.settings.session_secret) let (access_token, refresh_token) =
.map_err(|e| ApiError::internal(&e.to_string()))?; auth::new_jwt_tokens(user.id, &state.settings.session_secret)
.map_err(|e| ApiError::internal(&e.to_string()))?;
let mut headers = HeaderMap::new(); let mut headers = HeaderMap::new();
headers.append( headers.append(
@ -224,7 +226,53 @@ async fn auth_callback(
.map_err(|_| ApiError::internal("failed to clear oauth state cookie"))?, .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<AppState>,
Json(body): Json<AuthRefreshBody>,
) -> Result<impl IntoResponse, ApiError> {
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<impl IntoResponse, ApiError> { async fn auth_logout() -> Result<impl IntoResponse, ApiError> {

View file

@ -110,15 +110,53 @@ const el = {
// --- API Helpers --- // --- API Helpers ---
async function api(path, options = {}) { 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, ...options,
headers: { headers,
"content-type": "application/json",
...(options.headers || {}),
},
credentials: "same-origin",
}); });
// 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) { if (!res.ok) {
let detail = "request failed"; let detail = "request failed";
try { try {
@ -523,7 +561,11 @@ function startVoicePresencePolling() {
function initChatWs() { function initChatWs() {
if (state.chatWs) state.chatWs.close(); if (state.chatWs) state.chatWs.close();
const protocol = location.protocol === "https:" ? "wss:" : "ws:"; 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); const ws = new WebSocket(wsUrl);
state.chatWs = ws; state.chatWs = ws;
@ -567,7 +609,12 @@ function initChatWs() {
function getVoiceWsUrl(channelId) { function getVoiceWsUrl(channelId) {
const proto = location.protocol === "https:" ? "wss" : "ws"; 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) { function shouldInitiateOffer(peerId) {
@ -1159,9 +1206,14 @@ async function init() {
try { try {
const searchParams = new URLSearchParams(location.search); const searchParams = new URLSearchParams(location.search);
const jwtToken = searchParams.get("token"); const jwtToken = searchParams.get("token");
if (jwtToken) { const refreshToken = searchParams.get("refresh_token");
localStorage.setItem("chattz_token", jwtToken);
if (jwtToken || refreshToken) {
if (jwtToken) localStorage.setItem("chattz_token", jwtToken);
if (refreshToken) localStorage.setItem("chattz_refresh_token", refreshToken);
searchParams.delete("token"); searchParams.delete("token");
searchParams.delete("refresh_token");
const nextQuery = searchParams.toString(); const nextQuery = searchParams.toString();
const nextUrl = `${location.pathname}${nextQuery ? `?${nextQuery}` : ""}`; const nextUrl = `${location.pathname}${nextQuery ? `?${nextQuery}` : ""}`;
history.replaceState(null, "", nextUrl); history.replaceState(null, "", nextUrl);
@ -1175,6 +1227,7 @@ async function init() {
el.logoutBtn.onclick = async () => { el.logoutBtn.onclick = async () => {
await leaveVoice(); await leaveVoice();
localStorage.removeItem("chattz_token"); localStorage.removeItem("chattz_token");
localStorage.removeItem("chattz_refresh_token");
try { await api("/auth/logout", { method: "POST" }); } catch (e) { } try { await api("/auth/logout", { method: "POST" }); } catch (e) { }
location.reload(); location.reload();
}; };