oro_client/api/
login.rs

1use crate::notify::Notify;
2use crate::{OroClient, OroClientError};
3use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC};
4use reqwest::header::{HeaderMap, WWW_AUTHENTICATE};
5use reqwest::StatusCode;
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8use std::time::Duration;
9
10#[derive(Debug, PartialEq)]
11pub enum DoneURLResponse {
12    Token(String),
13    Duration(Duration),
14}
15
16#[derive(Debug, PartialEq, Eq, Clone, Copy)]
17pub enum AuthType {
18    Web,
19    Legacy,
20}
21
22#[derive(Debug, PartialEq, Clone)]
23pub enum LoginCouchResponse {
24    WebOTP { auth_url: String, done_url: String },
25    ClassicOTP,
26    Token(String),
27}
28
29#[derive(Serialize, Deserialize, Default)]
30pub struct Token {
31    pub token: String,
32}
33
34#[derive(Deserialize, Serialize, PartialEq, Debug)]
35#[serde(rename_all = "camelCase")]
36pub struct LoginWeb {
37    pub login_url: String,
38    pub done_url: String,
39}
40
41#[derive(Debug, Clone, Default)]
42pub struct LoginOptions {
43    pub scope: Option<String>,
44    pub client: Option<OroClient>,
45}
46
47#[derive(Deserialize, Serialize)]
48struct LoginCouch {
49    _id: String,
50    name: String,
51    password: String,
52    r#type: String,
53    roles: Vec<String>,
54    date: String,
55}
56
57#[derive(Deserialize, Serialize, Default)]
58#[serde(rename_all = "camelCase")]
59struct WebOTPResponse {
60    auth_url: Option<String>,
61    done_url: Option<String>,
62}
63
64impl OroClient {
65    fn build_header(auth_type: AuthType, options: &LoginOptions) -> HeaderMap {
66        let mut headers = HashMap::new();
67
68        if let Some(scope) = options.scope.clone() {
69            headers.insert("npm-scope".to_owned(), scope);
70        }
71
72        headers.insert(
73            "npm-auth-type".to_owned(),
74            match auth_type {
75                AuthType::Web => "web".to_owned(),
76                AuthType::Legacy => "legacy".to_owned(),
77            },
78        );
79        headers.insert("npm-command".to_owned(), "login".to_owned());
80        headers.insert("Content-Type".to_owned(), "application/json".to_owned());
81
82        (&headers)
83            .try_into()
84            .expect("This type conversion should work")
85    }
86
87    pub async fn login_web(&self, options: &LoginOptions) -> Result<LoginWeb, OroClientError> {
88        let headers = Self::build_header(AuthType::Web, options);
89        let url = self.registry.join("-/v1/login")?;
90        let text = self
91            .client
92            .post(url.clone())
93            .headers(headers)
94            .header("X-Oro-Registry", self.registry.to_string())
95            .send()
96            .await?
97            .notify()
98            .error_for_status()?
99            .text()
100            .await?;
101
102        serde_json::from_str::<LoginWeb>(&text)
103            .map_err(|e| OroClientError::from_json_err(e, url.to_string(), text))
104    }
105
106    pub async fn login_couch(
107        &self,
108        username: &str,
109        password: &str,
110        otp: Option<&str>,
111        options: &LoginOptions,
112    ) -> Result<LoginCouchResponse, OroClientError> {
113        let mut headers = Self::build_header(AuthType::Legacy, options);
114        let username_ = utf8_percent_encode(username, NON_ALPHANUMERIC).to_string();
115        let url = self
116            .registry
117            .join(&format!("-/user/org.couchdb.user:{username_}"))?;
118
119        if let Some(otp) = otp {
120            headers.insert(
121                "npm-otp",
122                otp.try_into().expect("This type conversion should work"),
123            );
124        }
125
126        let response = self
127            .client
128            .put(url.clone())
129            .header("X-Oro-Registry", self.registry.to_string())
130            .headers(headers)
131            .body(
132                serde_json::to_string(&LoginCouch {
133                    _id: format!("org.couchdb.user:{username}"),
134                    name: username.to_owned(),
135                    password: password.to_owned(),
136                    r#type: "user".to_owned(),
137                    roles: vec![],
138                    date: chrono::Local::now().to_rfc3339(),
139                })
140                .expect("This type conversion should work"),
141            )
142            .send()
143            .await?
144            .notify();
145
146        match response.status() {
147            StatusCode::BAD_REQUEST => Err(OroClientError::NoSuchUserError(username.to_owned())),
148            StatusCode::UNAUTHORIZED => {
149                let www_authenticate = response
150                    .headers()
151                    .get(WWW_AUTHENTICATE)
152                    .map_or(String::default(), |header| {
153                        header.to_str().unwrap().to_lowercase()
154                    });
155
156                let text = response.text().await?;
157                let json = serde_json::from_str::<WebOTPResponse>(&text).unwrap_or_default();
158
159                if www_authenticate.contains("otp") || text.to_lowercase().contains("one-time pass")
160                {
161                    if otp.is_none() {
162                        if let (Some(auth_url), Some(done_url)) = (json.auth_url, json.done_url) {
163                            Ok(LoginCouchResponse::WebOTP { auth_url, done_url })
164                        } else {
165                            Ok(LoginCouchResponse::ClassicOTP)
166                        }
167                    } else {
168                        Err(OroClientError::OTPRequiredError)
169                    }
170                } else {
171                    Err(if www_authenticate.contains("basic") {
172                        OroClientError::IncorrectPasswordError
173                    } else if www_authenticate.contains("bearer") {
174                        OroClientError::InvalidTokenError
175                    } else {
176                        OroClientError::ResponseError(Some(text).into())
177                    })
178                }
179            }
180            _ if response.status() >= StatusCode::BAD_REQUEST => Err(
181                OroClientError::ResponseError(Some(response.text().await?).into()),
182            ),
183            _ => {
184                let text = response.text().await?;
185                Ok(LoginCouchResponse::Token(
186                    serde_json::from_str::<Token>(&text)
187                        .map_err(|e| OroClientError::from_json_err(e, url.to_string(), text))?
188                        .token,
189                ))
190            }
191        }
192    }
193
194    pub async fn fetch_done_url(
195        &self,
196        done_url: impl AsRef<str>,
197    ) -> Result<DoneURLResponse, OroClientError> {
198        let headers = Self::build_header(AuthType::Web, &LoginOptions::default());
199
200        let response = self
201            .client_uncached
202            .get(done_url.as_ref())
203            .header("X-Oro-Registry", self.registry.to_string())
204            .headers(headers)
205            .send()
206            .await?
207            .notify();
208
209        match response.status() {
210            StatusCode::OK => {
211                let text = response.text().await?;
212                Ok(DoneURLResponse::Token(
213                    serde_json::from_str::<Token>(&text)
214                        .map_err(|e| {
215                            OroClientError::from_json_err(e, done_url.as_ref().to_string(), text)
216                        })?
217                        .token,
218                ))
219            }
220            StatusCode::ACCEPTED => {
221                if let Some(retry_after) = response.headers().get("retry-after") {
222                    let retry_after = retry_after.to_str()
223                        .expect("The \"retry-after\" header that's included in the response should be string.")
224                        .parse::<u64>()
225                        .expect("The \"retry-after\" header that's included in the response should be able to parse to number.");
226                    Ok(DoneURLResponse::Duration(Duration::from_secs(retry_after)))
227                } else {
228                    Err(OroClientError::ResponseError(
229                        Some(response.text().await?).into(),
230                    ))
231                }
232            }
233            _ => Err(OroClientError::ResponseError(
234                Some(response.text().await?).into(),
235            )),
236        }
237    }
238}
239
240#[cfg(test)]
241mod test {
242    use super::*;
243    use miette::{IntoDiagnostic, Result};
244    use pretty_assertions::assert_eq;
245    use serde_json::json;
246    use wiremock::matchers::{body_json_schema, header, header_exists, method, path};
247    use wiremock::{Mock, MockServer, ResponseTemplate};
248
249    #[async_std::test]
250    async fn login_web() -> Result<()> {
251        let mock_server = MockServer::start().await;
252        let client = OroClient::new(mock_server.uri().parse().into_diagnostic()?);
253
254        let body = LoginWeb {
255            login_url: "https://example.com/login?next=/login/cli/foo".to_owned(),
256            done_url: "https://registry.example.org/-/v1/done?sessionId=foo".to_owned(),
257        };
258
259        {
260            let _guard = Mock::given(method("POST"))
261                .and(path("-/v1/login"))
262                .and(header_exists("npm-auth-type"))
263                .and(header_exists("npm-command"))
264                .respond_with(ResponseTemplate::new(200).set_body_json(&body))
265                .expect(1)
266                .mount_as_scoped(&mock_server)
267                .await;
268
269            assert_eq!(client.login_web(&LoginOptions::default()).await?, body);
270        }
271
272        Ok(())
273    }
274
275    #[async_std::test]
276    async fn login_couch() -> Result<()> {
277        let mock_server = MockServer::start().await;
278        let client = OroClient::new(mock_server.uri().parse().into_diagnostic()?);
279
280        {
281            let body = Token {
282                token: "XXXXXX".to_owned(),
283            };
284
285            let _guard = Mock::given(method("PUT"))
286                .and(path("-/user/org.couchdb.user:test"))
287                .and(header_exists("npm-auth-type"))
288                .and(header_exists("npm-command"))
289                .and(header("npm-scope", "@mycompany"))
290                .and(body_json_schema::<LoginCouch>)
291                .respond_with(ResponseTemplate::new(200).set_body_json(&body))
292                .expect(1)
293                .mount_as_scoped(&mock_server)
294                .await;
295
296            assert_eq!(
297                client
298                    .login_couch(
299                        "test",
300                        "password",
301                        None,
302                        &LoginOptions {
303                            scope: Some("@mycompany".to_owned()),
304                            client: None,
305                        }
306                    )
307                    .await?,
308                LoginCouchResponse::Token(body.token),
309                "Works with credentials"
310            );
311        }
312
313        {
314            let body = WebOTPResponse {
315                auth_url: Some("https://example.com/login?next=/login/cli/foo".to_owned()),
316                done_url: Some("https://registry.example.org/-/v1/done?sessionId=foo".to_owned()),
317            };
318
319            let _guard = Mock::given(method("PUT"))
320                .and(path("-/user/org.couchdb.user:test"))
321                .and(header_exists("npm-auth-type"))
322                .and(header_exists("npm-command"))
323                .and(body_json_schema::<LoginCouch>)
324                .respond_with(
325                    ResponseTemplate::new(401)
326                        .append_header("www-authenticate", "OTP")
327                        .set_body_json(&body),
328                )
329                .expect(1)
330                .mount_as_scoped(&mock_server)
331                .await;
332
333            assert_eq!(
334                client
335                    .login_couch("test", "password", None, &LoginOptions::default())
336                    .await?,
337                LoginCouchResponse::WebOTP {
338                    auth_url: body.auth_url.unwrap(),
339                    done_url: body.done_url.unwrap()
340                }
341            )
342        }
343
344        {
345            let _guard = Mock::given(method("PUT"))
346                .and(path("-/user/org.couchdb.user:test"))
347                .and(header_exists("npm-auth-type"))
348                .and(header_exists("npm-command"))
349                .and(body_json_schema::<LoginCouch>)
350                .respond_with(ResponseTemplate::new(401).set_body_string("One-time pass"))
351                .expect(1)
352                .mount_as_scoped(&mock_server)
353                .await;
354
355            assert_eq!(
356                client
357                    .login_couch("test", "password", None, &LoginOptions::default())
358                    .await?,
359                LoginCouchResponse::ClassicOTP
360            )
361        }
362
363        {
364            let _guard = Mock::given(method("PUT"))
365                .and(path("-/user/org.couchdb.user:test"))
366                .and(header_exists("npm-auth-type"))
367                .and(header_exists("npm-command"))
368                .respond_with(ResponseTemplate::new(200).set_body_string(""))
369                .expect(1)
370                .mount_as_scoped(&mock_server)
371                .await;
372
373            assert!(
374                matches!(
375                    client
376                        .login_couch("test", "password", None, &LoginOptions::default())
377                        .await,
378                    Err(OroClientError::BadJson { .. })
379                ),
380                "If the response has no \"token\" key and the status code is 200, this will fail"
381            );
382        }
383
384        {
385            let _guard = Mock::given(method("PUT"))
386                .and(path("-/user/org.couchdb.user:test"))
387                .and(header_exists("npm-auth-type"))
388                .and(header_exists("npm-command"))
389                .respond_with(ResponseTemplate::new(400))
390                .expect(1)
391                .mount_as_scoped(&mock_server)
392                .await;
393
394            assert!(
395                matches!(
396                    client
397                        .login_couch("test", "password", None, &LoginOptions::default())
398                        .await,
399                    Err(OroClientError::NoSuchUserError(_))
400                ),
401                "If the status code is 400, the client returns \"NoSuchUserError\""
402            );
403        }
404
405        {
406            let _guard = Mock::given(method("PUT"))
407                .and(path("-/user/org.couchdb.user:test"))
408                .and(header_exists("npm-auth-type"))
409                .and(header_exists("npm-command"))
410                .respond_with(ResponseTemplate::new(503))
411                .expect(1)
412                .mount_as_scoped(&mock_server)
413                .await;
414
415            assert!(
416                matches!(
417                    client
418                        .login_couch("test", "password", None, &LoginOptions::default())
419                        .await,
420                    Err(OroClientError::ResponseError(_))
421                ),
422                "If the status code is 402 or higher, this will fail"
423            );
424        }
425
426        Ok(())
427    }
428
429    #[async_std::test]
430    async fn fetch_done_url() -> Result<()> {
431        let mock_server = MockServer::start().await;
432        let client = OroClient::new(mock_server.uri().parse().into_diagnostic()?);
433        let done_url = client.registry.join("-/v1/done").unwrap();
434        let done_url = done_url.as_str();
435
436        {
437            let body = Token {
438                token: "XXXXXXX".to_owned(),
439            };
440
441            let _guard = Mock::given(method("GET"))
442                .and(path("-/v1/done"))
443                .and(header_exists("npm-auth-type"))
444                .and(header_exists("npm-command"))
445                .respond_with(ResponseTemplate::new(200).set_body_json(&body))
446                .expect(1)
447                .mount_as_scoped(&mock_server)
448                .await;
449
450            assert_eq!(
451                client.fetch_done_url(done_url).await?,
452                DoneURLResponse::Token(body.token)
453            );
454        }
455
456        {
457            let _guard = Mock::given(method("GET"))
458                .and(path("-/v1/done"))
459                .and(header_exists("npm-auth-type"))
460                .and(header_exists("npm-command"))
461                .respond_with(ResponseTemplate::new(202).append_header("retry-after", "5"))
462                .expect(1)
463                .mount_as_scoped(&mock_server)
464                .await;
465
466            assert_eq!(
467                client.fetch_done_url(done_url).await?,
468                DoneURLResponse::Duration(Duration::from_secs(5)),
469                "Works with \"retry-after\" header"
470            );
471        }
472
473        {
474            let _guard = Mock::given(method("GET"))
475                .and(path("-/v1/done"))
476                .and(header_exists("npm-auth-type"))
477                .and(header_exists("npm-command"))
478                .respond_with(ResponseTemplate::new(200).set_body_json(json!({})))
479                .expect(1)
480                .mount_as_scoped(&mock_server)
481                .await;
482
483            assert!(
484                matches!(
485                    client.fetch_done_url(done_url).await,
486                    Err(OroClientError::BadJson { .. })
487                ),
488                "If the response has no \"token\" key and the status code is 200, this will fail"
489            )
490        }
491
492        {
493            let _guard = Mock::given(method("GET"))
494                .and(path("-/v1/done"))
495                .and(header_exists("npm-auth-type"))
496                .and(header_exists("npm-command"))
497                .respond_with(ResponseTemplate::new(202))
498                .expect(1)
499                .mount_as_scoped(&mock_server)
500                .await;
501
502            assert!(
503                matches!(
504                    client.fetch_done_url(done_url).await,
505                    Err(OroClientError::ResponseError(_))
506                ),
507                "If the retry-after header is not set and the status code is 202, this will fail"
508            );
509        }
510
511        {
512            let _guard = Mock::given(method("GET"))
513                .and(path("-/v1/done"))
514                .and(header_exists("npm-auth-type"))
515                .and(header_exists("npm-command"))
516                .respond_with(ResponseTemplate::new(503))
517                .expect(1)
518                .mount_as_scoped(&mock_server)
519                .await;
520
521            assert!(
522                matches!(
523                    client.fetch_done_url(done_url).await,
524                    Err(OroClientError::ResponseError(_))
525                ),
526                "If the status code is not 200 or 202, this will fail"
527            );
528        }
529
530        Ok(())
531    }
532}