earl 0.5.2

AI-safe CLI for AI agents
use anyhow::{Context, Result};
use chrono::{DateTime, Utc};
use secrecy::{ExposeSecret, SecretString};
use serde::{Deserialize, Serialize};

use crate::secrets::SecretManager;

#[derive(Clone, Serialize, Deserialize)]
pub struct StoredOAuthToken {
    pub access_token: String,
    pub refresh_token: Option<String>,
    pub token_type: Option<String>,
    pub expires_at: Option<DateTime<Utc>>,
    pub scopes: Vec<String>,
}

impl std::fmt::Debug for StoredOAuthToken {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("StoredOAuthToken")
            .field("access_token", &"[REDACTED]")
            .field(
                "refresh_token",
                &self.refresh_token.as_ref().map(|_| "[REDACTED]"),
            )
            .field("token_type", &self.token_type)
            .field("expires_at", &self.expires_at)
            .field("scopes", &self.scopes)
            .finish()
    }
}

impl StoredOAuthToken {
    pub fn is_expired(&self) -> bool {
        self.expires_at
            .map(|expires| expires <= Utc::now() + chrono::Duration::seconds(30))
            .unwrap_or(false)
    }
}

pub struct OAuthTokenStore<'a> {
    secrets: &'a SecretManager,
}

impl<'a> OAuthTokenStore<'a> {
    pub fn new(secrets: &'a SecretManager) -> Self {
        Self { secrets }
    }

    pub fn load(&self, profile: &str) -> Result<Option<StoredOAuthToken>> {
        let key = key_for_profile(profile);
        let Some(secret) = self.secrets.store().get_secret(&key)? else {
            return Ok(None);
        };

        let token = serde_json::from_str::<StoredOAuthToken>(secret.expose_secret())
            .with_context(|| format!("failed decoding token payload for profile `{profile}`"))?;
        Ok(Some(token))
    }

    pub fn save(&self, profile: &str, token: &StoredOAuthToken) -> Result<()> {
        let key = key_for_profile(profile);
        let json = serde_json::to_string(token)?;
        self.secrets.set(&key, SecretString::new(json.into()))?;
        Ok(())
    }

    pub fn delete(&self, profile: &str) -> Result<bool> {
        let key = key_for_profile(profile);
        self.secrets.delete(&key)
    }
}

fn key_for_profile(profile: &str) -> String {
    format!("oauth2.{profile}.token")
}