epik/auth/
mod.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use std::fs;
4use std::path::PathBuf;
5
6use crate::config::Config;
7use crate::{Error, Result};
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct AuthToken {
11    pub access_token: String,
12    pub refresh_token: String,
13    pub expires_at: DateTime<Utc>,
14    pub account_id: String,
15}
16
17impl AuthToken {
18    pub fn is_expired(&self) -> bool {
19        Utc::now() >= self.expires_at
20    }
21
22    pub fn save(&self) -> Result<()> {
23        // Use OS keychain/credential manager for secure storage
24        let entry = keyring::Entry::new("epik", "auth_token")
25            .map_err(|e| Error::Auth(format!("Failed to access keyring: {}", e)))?;
26
27        let contents = serde_json::to_string(self)?;
28        entry
29            .set_password(&contents)
30            .map_err(|e| Error::Auth(format!("Failed to save to keyring: {}", e)))?;
31
32        // Cleanup old insecure file if it exists
33        // We do this to ensure we don't leave plain text credentials lying around
34        let auth_path = Self::auth_path()?;
35        if auth_path.exists() {
36            let _ = fs::remove_file(auth_path);
37        }
38
39        Ok(())
40    }
41
42    pub fn load() -> Result<Option<Self>> {
43        // Try loading from keyring first
44        let entry = keyring::Entry::new("epik", "auth_token");
45
46        match entry {
47            Ok(e) => {
48                match e.get_password() {
49                    Ok(contents) => {
50                        let token: AuthToken = serde_json::from_str(&contents)?;
51                        return Ok(Some(token));
52                    }
53                    Err(_) => {
54                        // Keyring might be empty or error, fallthrough to legacy file check
55                    }
56                }
57            }
58            Err(_) => {
59                // Keyring access failed, fallthrough to legacy file check
60            }
61        }
62
63        // Handle migration from old token formats (plain JSON file)
64        let auth_path = Self::auth_path()?;
65
66        if !auth_path.exists() {
67            return Ok(None);
68        }
69
70        let contents = fs::read_to_string(&auth_path)?;
71        let token: AuthToken = serde_json::from_str(&contents)?;
72
73        // Automatically migrate to keyring
74        if let Err(e) = token.save() {
75            // If save fails, we log it but don't crash, we still have the token in memory
76            // Just warn user via standard error or log if available (log used elsewhere)
77            // We can't easily log here without importing log, but returning Ok(Some) is main priority.
78            eprintln!("Warning: Failed to migrate token to keyring: {}", e);
79        }
80
81        Ok(Some(token))
82    }
83
84    pub fn delete() -> Result<()> {
85        // Delete from keyring
86        let entry = keyring::Entry::new("epik", "auth_token");
87        if let Ok(e) = entry {
88            let _ = e.delete_password();
89        }
90
91        // Delete from legacy file just in case
92        let auth_path = Self::auth_path()?;
93        if auth_path.exists() {
94            fs::remove_file(&auth_path)?;
95        }
96
97        Ok(())
98    }
99
100    fn auth_path() -> Result<PathBuf> {
101        let data_dir = Config::data_dir()?;
102        Ok(data_dir.join("auth.json"))
103    }
104}
105
106#[derive(Clone)]
107pub struct AuthManager {
108    token: Option<AuthToken>,
109}
110
111impl AuthManager {
112    pub fn new() -> Result<Self> {
113        let token = AuthToken::load()?;
114        Ok(Self { token })
115    }
116
117    pub fn is_authenticated(&self) -> bool {
118        if let Some(token) = &self.token {
119            !token.is_expired()
120        } else {
121            false
122        }
123    }
124
125    pub fn get_token(&self) -> Result<&AuthToken> {
126        match &self.token {
127            Some(token) if !token.is_expired() => Ok(token),
128            _ => Err(Error::NotAuthenticated),
129        }
130    }
131
132    pub fn get_stored_token(&self) -> Option<&AuthToken> {
133        self.token.as_ref()
134    }
135
136    // Check if token will expire soon (within 5 minutes)
137    pub fn token_needs_refresh(&self) -> bool {
138        if let Some(token) = &self.token {
139            let now = chrono::Utc::now();
140            let time_until_expiry = token.expires_at.signed_duration_since(now);
141            time_until_expiry.num_minutes() < 5
142        } else {
143            false
144        }
145    }
146
147    pub fn get_refresh_token(&self) -> Option<String> {
148        self.token.as_ref().map(|t| t.refresh_token.clone())
149    }
150
151    pub fn set_token(&mut self, token: AuthToken) -> Result<()> {
152        token.save()?;
153        self.token = Some(token);
154        Ok(())
155    }
156
157    pub fn logout(&mut self) -> Result<()> {
158        AuthToken::delete()?;
159        self.token = None;
160        Ok(())
161    }
162}
163
164impl Default for AuthManager {
165    fn default() -> Self {
166        Self::new().unwrap_or(Self { token: None })
167    }
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173
174    #[test]
175    fn test_auth_manager_not_authenticated_by_default() {
176        let manager = AuthManager { token: None };
177        assert!(!manager.is_authenticated());
178    }
179
180    #[test]
181    fn test_auth_token_expiry() {
182        let expired_token = AuthToken {
183            access_token: "test".to_string(),
184            refresh_token: "test".to_string(),
185            expires_at: Utc::now() - chrono::Duration::hours(1),
186            account_id: "test".to_string(),
187        };
188        assert!(expired_token.is_expired());
189
190        let valid_token = AuthToken {
191            access_token: "test".to_string(),
192            refresh_token: "test".to_string(),
193            expires_at: Utc::now() + chrono::Duration::hours(1),
194            account_id: "test".to_string(),
195        };
196        assert!(!valid_token.is_expired());
197    }
198}