flams_git/gl/
auth.rs

1use super::GitLab;
2use flams_ontology::uris::ArchiveId;
3use flams_utils::unwrap;
4use gitlab::api::AsyncQuery;
5pub use oauth2::AccessToken;
6use oauth2::{url::Url, TokenResponse};
7use tracing::{instrument, Instrument};
8
9#[derive(Debug, Clone)]
10pub struct GitLabOAuth(
11    oauth2::basic::BasicClient<
12        oauth2::EndpointSet,
13        oauth2::EndpointNotSet,
14        oauth2::EndpointNotSet,
15        oauth2::EndpointNotSet,
16        oauth2::EndpointSet,
17    >,
18    GitLab,
19);
20
21#[derive(Debug, Clone, serde::Deserialize)]
22pub struct AuthRequest {
23    pub code: String,
24    //state: String
25}
26
27impl GitLabOAuth {
28    pub fn login_url(&self) -> Url {
29        let (url, _) = self
30            .0
31            .authorize_url(oauth2::CsrfToken::new_random)
32            .add_scope(oauth2::Scope::new("read_user".to_string()))
33            .add_scope(oauth2::Scope::new("api".to_string()))
34            .url();
35        url
36    }
37    /// #### Errors
38    pub async fn callback(
39        &self,
40        request: AuthRequest,
41    ) -> Result<AccessToken, impl std::error::Error> {
42        use oauth2::AuthorizationCode;
43        let code = AuthorizationCode::new(request.code);
44        let req = unwrap!(oauth2::reqwest::ClientBuilder::new().build().ok());
45        self.0
46            .exchange_code(code)
47            .request_async(&req)
48            .instrument(crate::REMOTE_SPAN.clone())
49            .await
50            .map(|token| token.access_token().clone())
51    }
52
53    /// #### Errors
54    pub async fn get_projects(&self, token: String) -> Result<Vec<crate::Project>, super::Err> {
55        self.get_projects_i(token)
56            .instrument(crate::REMOTE_SPAN.clone())
57            .await
58    }
59    #[instrument(
60        level = "debug",
61        target = "git",
62        name = "getting all projects for user",
63        skip_all
64    )]
65    async fn get_projects_i(&self, token: String) -> Result<Vec<crate::Project>, super::Err> {
66        let mut client = gitlab::ImpersonationClient::new(&self.1 .0.inner, token);
67        client.oauth2_token();
68        let r = gitlab::api::projects::Projects::builder()
69            .simple(true)
70            .min_access_level(gitlab::api::common::AccessLevel::Developer)
71            //.membership(false)
72            .build()
73            .unwrap_or_else(|_| unreachable!());
74        let r: Vec<crate::Project> = gitlab::api::paged(r, gitlab::api::Pagination::All)
75            .query_async(&client)
76            .await?;
77        let mut vs = self.1 .0.projects.lock();
78        for p in &r {
79            if !vs.contains(&p.id) {
80                vs.insert(super::ProjectWithId {
81                    project: p.clone(),
82                    id: None,
83                });
84            }
85        }
86        drop(vs);
87        Ok(r)
88    }
89
90    /// #### Errors
91    pub async fn get_archive_id(
92        &self,
93        id: u64,
94        token: String,
95        branch: &str,
96    ) -> Result<Option<ArchiveId>, super::Err> {
97        self.get_archive_id_i(id, token, branch)
98            .instrument(crate::REMOTE_SPAN.clone())
99            .await
100    }
101    #[instrument(
102        level = "debug",
103        target = "git",
104        name = "getting archive id",
105        skip(self, token)
106    )]
107    pub async fn get_archive_id_i(
108        &self,
109        id: u64,
110        token: String,
111        branch: &str,
112    ) -> Result<Option<ArchiveId>, super::Err> {
113        {
114            let vs = self.1 .0.projects.lock();
115            if let Some(super::ProjectWithId { id: Some(id), .. }) = vs.get(&id) {
116                return Ok(id.clone());
117            }
118        }
119
120        macro_rules! ret {
121            ($v:expr) => {{
122                tracing::info!("Found {:?}", $v);
123                let mut lock = self.1 .0.projects.lock();
124                if let Some(mut v) = lock.take(&id) {
125                    v.id = Some($v.clone());
126                    lock.insert(v);
127                }
128                drop(lock);
129                return Ok($v);
130            }};
131        }
132        let mut client = gitlab::ImpersonationClient::new(&self.1 .0.inner, token);
133        client.oauth2_token();
134        let r = gitlab::api::projects::repository::TreeBuilder::default()
135            .project(id)
136            .ref_(branch)
137            .recursive(false)
138            .build()
139            .unwrap_or_else(|_| unreachable!());
140        let r: Vec<crate::TreeEntry> = r.query_async(&client).await?;
141        let Some(p) = r.into_iter().find_map(|e| {
142            if e.path.eq_ignore_ascii_case("meta-inf") && matches!(e.kind, crate::DirOrFile::Dir) {
143                Some(e.path)
144            } else {
145                None
146            }
147        }) else {
148            ret!(None::<ArchiveId>)
149        };
150
151        let r = gitlab::api::projects::repository::TreeBuilder::default()
152            .project(id)
153            .ref_(branch)
154            .path(p)
155            .recursive(false)
156            .build()
157            .unwrap_or_else(|_| unreachable!());
158        let r: Vec<crate::TreeEntry> = r.query_async(&client).await?;
159        let Some(p) = r.into_iter().find_map(|e| {
160            if e.name.eq_ignore_ascii_case("manifest.mf")
161                && matches!(e.kind, crate::DirOrFile::File)
162            {
163                Some(e.path)
164            } else {
165                None
166            }
167        }) else {
168            ret!(None::<ArchiveId>)
169        };
170
171        let blob = gitlab::api::projects::repository::files::FileRaw::builder()
172            .project(id)
173            .file_path(p)
174            .ref_(branch)
175            .build()
176            .unwrap_or_else(|_| unreachable!());
177        let r = gitlab::api::raw(blob).query_async(&client).await?;
178        let r = std::str::from_utf8(&r)?;
179        let r = r.split('\n').find_map(|line| {
180            let line = line.trim();
181            line.strip_prefix("id:")
182                .map(|rest| ArchiveId::new(rest.trim()))
183        });
184        ret!(r)
185    }
186
187    /// #### Errors
188    pub async fn get_branches(
189        &self,
190        id: u64,
191        token: String,
192    ) -> Result<Vec<crate::Branch>, super::Err> {
193        self.get_branches_i(id, token)
194            .instrument(crate::REMOTE_SPAN.clone())
195            .await
196    }
197    #[instrument(
198        level = "debug",
199        target = "git",
200        name = "getting branches for archive",
201        skip(self, token)
202    )]
203    pub async fn get_branches_i(
204        &self,
205        id: u64,
206        token: String,
207    ) -> Result<Vec<crate::Branch>, super::Err> {
208        let mut client = gitlab::ImpersonationClient::new(&self.1 .0.inner, token);
209        client.oauth2_token();
210        let r = gitlab::api::projects::repository::branches::Branches::builder()
211            .project(id)
212            .build()
213            .unwrap_or_else(|_| unreachable!())
214            .query_async(&client)
215            .await?;
216        Ok(r)
217    }
218}
219
220impl GitLab {
221    /// #### Errors
222    pub fn new_oauth(&self, redirect: &str) -> Result<GitLabOAuth, OAuthError> {
223        use oauth2::{AuthUrl, ClientId, ClientSecret, RedirectUrl, TokenUrl};
224        let url = &self.0.url;
225        let Some(app_id) = &self.0.id else {
226            return Err(OAuthError::MissingAppID);
227        };
228        let Some(app_secret) = &self.0.secret else {
229            return Err(OAuthError::MissingAppSecret);
230        };
231        let Ok(auth_url) = AuthUrl::new(format!("{url}/oauth/authorize")) else {
232            return Err(OAuthError::InvalidAuthURL(format!("{url}/oauth/authorize")));
233        };
234        let Ok(token_url) = TokenUrl::new(format!("{url}/oauth/token")) else {
235            return Err(OAuthError::InvalidTokenURL(format!("{url}/oauth/token")));
236        };
237        let Ok(redirect_url) = RedirectUrl::new(redirect.to_string()) else {
238            return Err(OAuthError::InvalidRedirectURL(redirect.to_string()));
239        };
240        let auth = oauth2::basic::BasicClient::new(ClientId::new(app_id.to_string()))
241            .set_client_secret(ClientSecret::new(app_secret.to_string()))
242            .set_auth_uri(auth_url)
243            .set_token_uri(token_url)
244            .set_redirect_uri(redirect_url);
245        Ok(GitLabOAuth(auth, self.clone()))
246    }
247
248    /// #### Errors
249    pub async fn get_oauth_user(
250        &self,
251        token: &oauth2::AccessToken,
252    ) -> Result<GitlabUser, reqwest::Error> {
253        let client = reqwest::Client::new();
254        let resp = client
255            .get(format!("{}/api/v4/user", self.0.url))
256            .bearer_auth(token.secret())
257            .send()
258            .instrument(crate::REMOTE_SPAN.clone())
259            .await?;
260        resp.json().instrument(crate::REMOTE_SPAN.clone()).await
261    }
262}
263
264#[derive(Debug)]
265pub enum OAuthError {
266    InvalidAuthURL(String),
267    InvalidTokenURL(String),
268    InvalidRedirectURL(String),
269    MissingAppID,
270    MissingAppSecret,
271}
272impl std::fmt::Display for OAuthError {
273    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
274        match self {
275            Self::InvalidAuthURL(s) => write!(f, "Invalid auth url: {s}"),
276            Self::InvalidTokenURL(s) => write!(f, "Invalid token url: {s}"),
277            Self::InvalidRedirectURL(s) => write!(f, "Invalid redirect url: {s}"),
278            Self::MissingAppID => write!(f, "Missing app id"),
279            Self::MissingAppSecret => write!(f, "Missing secret"),
280        }
281    }
282}
283
284impl std::error::Error for OAuthError {}
285
286#[derive(Debug, serde::Deserialize, serde::Serialize)]
287pub struct GitlabUser {
288    pub id: i64,
289    pub username: String,
290    pub name: String,
291    //state:String,
292    pub avatar_url: String,
293    //pronouns:Option<String>,
294    pub email: Option<String>,
295    //commit_email:Option<String>,
296    pub can_create_group: bool,
297    pub can_create_project: bool,
298}
299
300/*
301{
302  "id":2,
303  "username":"jazzpirate",
304  "name":"Dennis Test",
305  "state":"active",
306  "locked":false,
307  "avatar_url":"https://www.gravatar.com/avatar/46a2a1db127c1d43862f7313d137b5ed3bdac8ffe356c7dcc868f25e413e83c0?s=80\\u0026d=identicon",
308  "web_url":"http://gitlab.example.com/jazzpirate",
309  "created_at":"2024-12-23T10:35:40.099Z",
310  "bio":"",
311  "location":"",
312  "public_email":null,
313  "skype":"",
314  "linkedin":"",
315  "twitter":"",
316  "discord":"",
317  "website_url":"",
318  "organization":"",
319  "job_title":"",
320  "pronouns":null,
321  "bot":false,
322  "work_information":null,
323  "local_time":null,
324  "last_sign_in_at":"2024-12-23T10:36:24.234Z",
325  "confirmed_at":"2024-12-23T10:35:39.994Z",
326  "last_activity_on":"2024-12-29",
327  "email":"d.mueller@kwarc.info",
328  "theme_id":3,
329  "color_scheme_id":1,
330  "projects_limit":100000,
331  "current_sign_in_at":
332  "2024-12-29T08:48:51.974Z",
333  "identities":[],
334  "can_create_group":true,
335  "can_create_project":true,
336  "two_factor_enabled":false,
337  "external":false,
338  "private_profile":false,
339  "commit_email":"d.mueller@kwarc.info"
340}
341 */