Skip to main content

cargo_faasta/
auth.rs

1use anyhow::Error;
2use compio::buf::BufResult;
3use cyper::Client as HttpClient;
4use dirs::config_dir;
5use github_app_auth::{GithubAuthParams, InstallationAccessToken};
6use http::header::HeaderMap;
7use http::header::{HeaderName, HeaderValue};
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10use std::fs;
11use std::path::{Path, PathBuf};
12
13const CONFIG_FILE: &str = "faasta/github_auth.json";
14const USER_AGENT: &str = "faasta-cli";
15
16#[derive(Debug, Serialize, Deserialize, Default)]
17pub struct AuthConfig {
18    pub app_id: u64,
19    pub installation_id: u64,
20    pub private_key: Vec<u8>,
21    pub user_id: Option<String>,
22    pub project_hmacs: HashMap<String, String>, // project_name -> hmac
23}
24
25/// Manages GitHub authentication for the CLI
26pub struct GitHubAuth {
27    config: AuthConfig,
28    token: Option<InstallationAccessToken>,
29    config_path: PathBuf,
30}
31
32impl GitHubAuth {
33    /// Initialize GitHub authentication
34    pub async fn new() -> Result<Self, Error> {
35        let config_path = Self::get_config_path()?;
36        let config = Self::load_config(&config_path).await?;
37
38        Ok(Self {
39            config,
40            token: None,
41            config_path,
42        })
43    }
44
45    /// Authenticate with GitHub
46    pub async fn authenticate(&mut self) -> Result<(), Error> {
47        if self.config.app_id == 0
48            || self.config.installation_id == 0
49            || self.config.private_key.is_empty()
50        {
51            return Err(anyhow::anyhow!(
52                "GitHub App not configured. Run 'cargo faasta auth setup' first."
53            ));
54        }
55
56        self.token = Some(
57            InstallationAccessToken::new(GithubAuthParams {
58                user_agent: USER_AGENT.into(),
59                private_key: self.config.private_key.clone(),
60                app_id: self.config.app_id,
61                installation_id: self.config.installation_id,
62            })
63            .await?,
64        );
65
66        // Get user ID if we don't have it yet
67        if self.config.user_id.is_none() {
68            self.fetch_and_store_user_id().await?;
69        }
70
71        Ok(())
72    }
73
74    /// Get authentication header for API requests
75    pub async fn header(&mut self) -> Result<HeaderMap, Error> {
76        if self.token.is_none() {
77            self.authenticate().await?;
78        }
79
80        // Retrieve the OAuth2 header map and convert to an HTTP header map
81        let oauth_headers = self.token.as_mut().unwrap().header().await?;
82        let mut headers = HeaderMap::new();
83        for (name, value) in oauth_headers.iter() {
84            // Convert header name and value into http types
85            let hn = HeaderName::from_bytes(name.as_str().as_bytes())?;
86            let hv = HeaderValue::from_bytes(value.as_bytes())?;
87            headers.insert(hn, hv);
88        }
89        Ok(headers)
90    }
91
92    /// Store project HMAC for ownership verification
93    pub async fn store_project_hmac(
94        &mut self,
95        project_name: &str,
96        hmac: &str,
97    ) -> Result<(), Error> {
98        self.config
99            .project_hmacs
100            .insert(project_name.to_string(), hmac.to_string());
101        self.save_config().await?;
102        Ok(())
103    }
104
105    /// Get project HMAC if it exists
106    pub fn get_project_hmac(&self, project_name: &str) -> Option<&String> {
107        self.config.project_hmacs.get(project_name)
108    }
109
110    /// Check if a project is owned by the current user
111    pub fn owns_project(&self, project_name: &str) -> bool {
112        self.config.project_hmacs.contains_key(project_name)
113    }
114
115    /// Get the list of projects owned by the user
116    pub fn get_owned_projects(&self) -> Vec<String> {
117        self.config.project_hmacs.keys().cloned().collect()
118    }
119
120    /// Check if user has reached project limit
121    pub fn has_reached_project_limit(&self) -> bool {
122        self.config.project_hmacs.len() >= 5
123    }
124
125    /// Setup GitHub app credentials
126    pub async fn setup(
127        &mut self,
128        app_id: u64,
129        installation_id: u64,
130        private_key: Vec<u8>,
131    ) -> Result<(), Error> {
132        self.config.app_id = app_id;
133        self.config.installation_id = installation_id;
134        self.config.private_key = private_key;
135        self.save_config().await?;
136        Ok(())
137    }
138
139    /// Get user ID from authenticated GitHub instance
140    async fn fetch_and_store_user_id(&mut self) -> Result<(), Error> {
141        // Retrieve the underlying OAuth2 headers and convert to http HeaderMap
142        let oauth_headers = self.token.as_mut().unwrap().header().await?;
143        let mut header = HeaderMap::new();
144        for (name, value) in oauth_headers.iter() {
145            let hn = HeaderName::from_bytes(name.as_str().as_bytes())?;
146            let hv = HeaderValue::from_bytes(value.as_bytes())?;
147            header.insert(hn, hv);
148        }
149
150        // Create authenticated client
151        let response = HttpClient::new()
152            .get("https://api.github.com/app")?
153            .headers(header)
154            .send()
155            .await?;
156
157        if response.status().is_success() {
158            let app_info: serde_json::Value = response.json().await?;
159            if let Some(id) = app_info.get("id").and_then(|v| v.as_str()) {
160                self.config.user_id = Some(id.to_string());
161                self.save_config().await?;
162            }
163        }
164
165        Ok(())
166    }
167
168    /// Get the path to the config file
169    fn get_config_path() -> Result<PathBuf, Error> {
170        let mut path =
171            config_dir().ok_or_else(|| anyhow::anyhow!("Could not find user config directory"))?;
172
173        path.push(CONFIG_FILE);
174
175        // Ensure parent directory exists
176        if let Some(parent) = path.parent() {
177            if !parent.exists() {
178                fs::create_dir_all(parent)?;
179            }
180        }
181
182        Ok(path)
183    }
184
185    /// Load config from disk
186    async fn load_config(path: &Path) -> Result<AuthConfig, Error> {
187        if path.exists() {
188            let data = compio::fs::read(path).await?;
189            let content = String::from_utf8(data)?;
190            Ok(serde_json::from_str(&content)?)
191        } else {
192            // Create default config
193            let default_config = AuthConfig::default();
194            let content = serde_json::to_string_pretty(&default_config)?;
195            let BufResult(result, _) = compio::fs::write(path, content.into_bytes()).await;
196            result?;
197            Ok(default_config)
198        }
199    }
200
201    /// Save config to disk
202    pub async fn save_config(&self) -> Result<(), Error> {
203        let content = serde_json::to_string_pretty(&self.config)?;
204        let BufResult(result, _) = compio::fs::write(&self.config_path, content.into_bytes()).await;
205        result?;
206        Ok(())
207    }
208
209    /// Check if GitHub app is configured
210    pub fn is_configured(&self) -> bool {
211        self.config.app_id != 0
212            && self.config.installation_id != 0
213            && !self.config.private_key.is_empty()
214    }
215
216    /// Get user ID if available
217    pub fn get_user_id(&self) -> Option<&str> {
218        self.config.user_id.as_deref()
219    }
220}