use serde::{Deserialize, Serialize};
use crate::config::{ConfigStore, Env};
use crate::error::{Error, Result};
const SUPABASE_SESSION_KEY: &str = "supabase.auth.token";
const EXPIRY_MARGIN: i64 = 10;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SupabaseSession {
pub access_token: String,
pub refresh_token: String,
pub expires_in: i64,
pub expires_at: i64,
pub token_type: String,
#[serde(default)]
pub provider_token: Option<String>,
#[serde(default)]
pub provider_refresh_token: Option<String>,
#[serde(default)]
pub user: Option<serde_json::Value>,
}
fn decode_jwt_payload(token: &str) -> Option<serde_json::Value> {
use base64::prelude::*;
let parts: Vec<&str> = token.split('.').collect();
if parts.len() != 3 {
return None;
}
let payload = parts[1];
let bytes = BASE64_URL_SAFE_NO_PAD.decode(payload).ok()?;
serde_json::from_slice(&bytes).ok()
}
pub fn email_from_jwt(token: &str) -> Option<String> {
let claims = decode_jwt_payload(token)?;
claims.get("email")?.as_str().map(String::from)
}
pub fn is_token_expired_pub(token: &str) -> bool {
is_token_expired(token)
}
fn is_token_expired(token: &str) -> bool {
let Some(claims) = decode_jwt_payload(token) else {
return true; };
let Some(exp) = claims.get("exp").and_then(|v| v.as_i64()) else {
return true;
};
let now = chrono::Utc::now().timestamp();
exp <= now + EXPIRY_MARGIN
}
pub fn load_session() -> Option<SupabaseSession> {
let store = ConfigStore::global();
let raw = store.get_str(SUPABASE_SESSION_KEY)?;
serde_json::from_str(raw).ok()
}
pub fn save_session_pub(session: &SupabaseSession) {
save_session(session);
}
fn save_session(session: &SupabaseSession) {
if let Ok(json_str) = serde_json::to_string(session) {
let mut store = ConfigStore::global();
store.set_str(SUPABASE_SESSION_KEY, &json_str);
}
}
pub fn ensure_fresh_token() -> Result<(String, String)> {
let session = load_session().ok_or_else(|| {
Error::Authentication("No Supabase session found. Run `hy login` first.".into())
})?;
let access_token = if is_token_expired(&session.access_token) {
let refreshed = refresh_token(&session.refresh_token)?;
let new_token = refreshed.access_token.clone();
save_session(&refreshed);
new_token
} else {
session.access_token.clone()
};
let email = email_from_jwt(&access_token).unwrap_or_default();
Ok((access_token, email))
}
fn refresh_token(refresh_tok: &str) -> Result<SupabaseSession> {
let env = Env::global();
let url = format!(
"{}/auth/v1/token?grant_type=refresh_token",
env.supabase_url
);
let client = reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_secs(15))
.build()?;
let resp = client
.post(&url)
.header("Content-Type", "application/json;charset=UTF-8")
.header("apiKey", &env.supabase_anon_key)
.header("Authorization", format!("Bearer {}", env.supabase_anon_key))
.header("X-Supabase-Api-Version", "2024-01-01")
.json(&serde_json::json!({ "refresh_token": refresh_tok }))
.send()?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().unwrap_or_default();
return Err(Error::Authentication(format!(
"Token refresh failed ({status}): {body}"
)));
}
let session: SupabaseSession = resp.json()?;
Ok(session)
}