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}