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>, }
24
25pub struct GitHubAuth {
27 config: AuthConfig,
28 token: Option<InstallationAccessToken>,
29 config_path: PathBuf,
30}
31
32impl GitHubAuth {
33 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 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 if self.config.user_id.is_none() {
68 self.fetch_and_store_user_id().await?;
69 }
70
71 Ok(())
72 }
73
74 pub async fn header(&mut self) -> Result<HeaderMap, Error> {
76 if self.token.is_none() {
77 self.authenticate().await?;
78 }
79
80 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 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 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 pub fn get_project_hmac(&self, project_name: &str) -> Option<&String> {
107 self.config.project_hmacs.get(project_name)
108 }
109
110 pub fn owns_project(&self, project_name: &str) -> bool {
112 self.config.project_hmacs.contains_key(project_name)
113 }
114
115 pub fn get_owned_projects(&self) -> Vec<String> {
117 self.config.project_hmacs.keys().cloned().collect()
118 }
119
120 pub fn has_reached_project_limit(&self) -> bool {
122 self.config.project_hmacs.len() >= 5
123 }
124
125 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 async fn fetch_and_store_user_id(&mut self) -> Result<(), Error> {
141 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 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 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 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 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 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 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 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 pub fn get_user_id(&self) -> Option<&str> {
218 self.config.user_id.as_deref()
219 }
220}