use anyhow::{Context, Result};
use base64::{engine::general_purpose, Engine as _};
use serde::{Deserialize, Serialize};
use std::path::Path;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SyncSession {
pub jwt: String,
pub user_id: String,
pub email: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum UserId {
Integer(i64),
String(String),
}
#[derive(Debug, Deserialize)]
struct JwtClaims {
uid: UserId,
exp: i64,
email: Option<String>,
#[serde(default)]
sync_until: Option<i64>,
}
impl SyncSession {
pub fn from_jwt(jwt: String) -> Result<Self> {
let claims = decode_jwt_claims(&jwt)?;
let user_id = match claims.uid {
UserId::Integer(id) => id.to_string(),
UserId::String(id) => id,
};
Ok(SyncSession {
jwt,
user_id,
email: claims.email,
})
}
pub fn load(path: &Path) -> Result<Option<Self>> {
if !path.exists() {
return Ok(None);
}
let content = std::fs::read_to_string(path).context("Failed to read session file")?;
let session: SyncSession =
serde_json::from_str(&content).context("Failed to parse session file")?;
if is_jwt_expired(&session.jwt)? {
tracing::info!("Session expired, removing");
let _ = std::fs::remove_file(path);
return Ok(None);
}
Ok(Some(session))
}
pub fn save(&self, path: &Path) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).context("Failed to create config directory")?;
}
let content = serde_json::to_string_pretty(self)?;
#[cfg(unix)]
{
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
let mut file = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(path)
.context("Failed to create session file")?;
file.write_all(content.as_bytes())
.context("Failed to write session file")?;
}
#[cfg(not(unix))]
{
std::fs::write(path, &content).context("Failed to write session file")?;
}
Ok(())
}
pub fn delete(path: &Path) -> Result<()> {
if path.exists() {
std::fs::remove_file(path).context("Failed to delete session file")?;
}
Ok(())
}
}
fn decode_jwt_claims(jwt: &str) -> Result<JwtClaims> {
let parts: Vec<&str> = jwt.split('.').collect();
anyhow::ensure!(parts.len() == 3, "Invalid JWT format");
let decoded = general_purpose::STANDARD_NO_PAD
.decode(parts[1])
.or_else(|_| general_purpose::URL_SAFE_NO_PAD.decode(parts[1]))
.context("Failed to base64-decode JWT payload")?;
serde_json::from_slice(&decoded).context("Failed to parse JWT claims")
}
fn is_jwt_expired(jwt: &str) -> Result<bool> {
let claims = decode_jwt_claims(jwt)?;
Ok(claims.exp <= chrono::Utc::now().timestamp())
}
pub fn jwt_expires_in(jwt: &str) -> Result<i64> {
let claims = decode_jwt_claims(jwt)?;
Ok(claims.exp - chrono::Utc::now().timestamp())
}
pub fn jwt_lacks_sync_until_claim(jwt: &str) -> Result<bool> {
let claims = decode_jwt_claims(jwt)?;
Ok(claims.sync_until.is_none())
}
#[cfg(test)]
pub(crate) mod tests_support {
use super::*;
pub(crate) fn make_test_jwt(user_id: &str, exp: i64) -> String {
let header = general_purpose::URL_SAFE_NO_PAD.encode(r#"{"alg":"none"}"#);
let payload = general_purpose::URL_SAFE_NO_PAD.encode(
serde_json::json!({ "uid": user_id, "exp": exp, "email": serde_json::Value::Null })
.to_string(),
);
format!("{header}.{payload}.signature")
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn make_jwt(payload: serde_json::Value) -> String {
let header = general_purpose::URL_SAFE_NO_PAD.encode(r#"{"alg":"none"}"#);
let payload = general_purpose::URL_SAFE_NO_PAD.encode(payload.to_string());
format!("{header}.{payload}.signature")
}
fn future_exp() -> i64 {
chrono::Utc::now().timestamp() + 100 * 24 * 3600 }
#[test]
fn jwt_without_sync_until_is_detected_as_missing() {
let jwt = make_jwt(json!({
"uid": 42,
"exp": future_exp(),
"email": "user@example.com",
}));
assert!(jwt_lacks_sync_until_claim(&jwt).unwrap());
}
#[test]
fn jwt_with_sync_until_is_detected_as_present() {
let jwt = make_jwt(json!({
"uid": 42,
"exp": future_exp(),
"email": "user@example.com",
"sync_until": future_exp(),
}));
assert!(!jwt_lacks_sync_until_claim(&jwt).unwrap());
}
#[test]
fn jwt_with_null_sync_until_is_treated_as_missing() {
let jwt = make_jwt(json!({
"uid": 42,
"exp": future_exp(),
"email": "user@example.com",
"sync_until": null,
}));
assert!(jwt_lacks_sync_until_claim(&jwt).unwrap());
}
#[test]
fn jwt_expires_in_reflects_remaining_lifetime() {
let exp = chrono::Utc::now().timestamp() + 3600;
let jwt = make_jwt(json!({
"uid": 1,
"exp": exp,
"email": null,
}));
let remaining = jwt_expires_in(&jwt).unwrap();
assert!((3595..=3600).contains(&remaining), "remaining={remaining}");
}
#[test]
fn string_user_id_is_supported() {
let jwt = make_jwt(json!({
"uid": "abc-123",
"exp": future_exp(),
"email": null,
}));
let session = SyncSession::from_jwt(jwt).unwrap();
assert_eq!(session.user_id, "abc-123");
assert!(jwt_lacks_sync_until_claim(&session.jwt).unwrap());
}
}