1use std::time::Duration;
5
6use serde_json::json;
7use url::{Url, form_urlencoded};
8use zeroize::Zeroizing;
9
10use self::{
11 enrollment::enroll_device,
12 ui::{SsoLoginResult, SsoUi},
13};
14use super::{
15 credentials::SsoCredentials,
16 device::ClientInfo,
17 error::OnePasswordError,
18 login::{AUTH_COMPLETE_ENDPOINT, AUTH_START_ENDPOINT, SignInMethods, fetch_auth_methods},
19 mac::MacSigner,
20 opdata::{AesKey, decode64_loose},
21 rest::RestClient,
22 session::Session,
23 srp::perform_and_verify_with_x,
24 wire::{AuthComplete, LoginInfo, NewSession, SsoLoginUrl, SsoSession},
25};
26
27mod cpace;
28mod enrollment;
29#[cfg(test)]
30mod test_support;
31pub mod ui;
32
33const SSO_START_ENDPOINT: &str = "v3/auth/sso/oidc/start";
34const SSO_VERIFY_ENDPOINT: &str = "v3/auth/sso/oidc/verify";
35const DEVICE_NOT_ENROLLED: &str = "device_not_enrolled";
36
37pub(super) struct Timing {
39 until_result_delay: Duration,
42 until_result_attempts: u32,
44 enrollment_poll_interval: Duration,
47 enrollment_poll_timeout: Duration,
49}
50
51impl Default for Timing {
52 fn default() -> Timing {
53 Timing {
54 until_result_delay: Duration::from_millis(500),
55 until_result_attempts: 20,
56 enrollment_poll_interval: Duration::from_secs(2),
57 enrollment_poll_timeout: Duration::from_secs(5 * 60),
58 }
59 }
60}
61
62impl Timing {
63 fn enrollment_poll_attempts(&self) -> u128 {
65 self.enrollment_poll_timeout
66 .as_nanos()
67 .checked_div(self.enrollment_poll_interval.as_nanos())
68 .unwrap_or(0)
69 }
70}
71
72pub(super) async fn sso_login(
78 credentials: &SsoCredentials,
79 client_info: &ClientInfo,
80 ui: &dyn SsoUi,
81 rest: &RestClient,
82 timing: &Timing,
83) -> Result<(Session, Zeroizing<Vec<u8>>), OnePasswordError> {
84 let login_info = fetch_auth_methods(&credentials.username, rest).await?;
86
87 let account = require_sso(&login_info, &credentials.username)?;
89
90 let sso_rest = rest.with_base_url(account.api_url());
92
93 let sso_login_url = start_sso_login(&account, &sso_rest).await?;
95
96 let (code, state) = perform_sso_login(&sso_login_url, ui).await?;
98
99 let sso_session = verify_sso_login(&code, &state, client_info, &sso_rest).await?;
101
102 let sso_rest = sso_rest.with_session_id(&sso_session.user.session_uuid)?;
104
105 sso_session.require_new_device()?;
107
108 let sign_in_token = sso_session.sso_auth.sign_in_token_details.token.as_str();
110 let bundle = enroll_device(
111 &credentials.username,
112 &account.sign_in_address,
113 sign_in_token,
114 ui,
115 &sso_rest,
116 timing,
117 )
118 .await?;
119
120 let srp_x = Zeroizing::new(decode64_loose(&bundle.srpx)?);
122 let master_key = Zeroizing::new(decode64_loose(&bundle.auk.k)?);
123 let (session_key, session_rest) = login(
124 &credentials.username,
125 &account,
126 client_info,
127 &sso_session,
128 &srp_x,
129 &sso_rest,
130 )
131 .await?;
132
133 Ok((Session::new(session_key, session_rest), master_key))
135}
136
137struct SsoAccount {
139 user_uuid: String,
140 sign_in_address: String,
143}
144
145impl SsoAccount {
146 fn api_url(&self) -> String {
148 format!("{}/api", self.sign_in_address.trim_end_matches('/'))
149 }
150}
151
152fn require_sso(login_info: &LoginInfo, username: &str) -> Result<SsoAccount, OnePasswordError> {
155 if !SignInMethods::of(login_info).sso {
156 return Err(OnePasswordError::Unsupported(format!(
157 "no SSO login method found for account {username}"
158 )));
159 }
160
161 let (Some(user_uuid), Some(sign_in_address)) = (
162 non_empty(&login_info.user_uuid),
163 non_empty(&login_info.sign_in_address),
164 ) else {
165 return Err(OnePasswordError::Internal(
166 "SSO login info is missing userUuid or signInAddress".into(),
167 ));
168 };
169 validate_sign_in_address(sign_in_address)?;
170
171 Ok(SsoAccount {
172 user_uuid: user_uuid.to_string(),
173 sign_in_address: sign_in_address.to_string(),
174 })
175}
176
177fn non_empty(value: &Option<String>) -> Option<&str> {
178 value.as_deref().filter(|value| !value.is_empty())
179}
180
181fn validate_sign_in_address(address: &str) -> Result<(), OnePasswordError> {
185 let valid = Url::parse(address).is_ok_and(|url| {
186 has_allowed_scheme(&url)
187 && url.has_host()
188 && url.query().is_none()
189 && url.fragment().is_none()
190 });
191 if !valid {
192 return Err(OnePasswordError::Internal(
193 "the SSO sign-in address is not an https URL".into(),
194 ));
195 }
196 Ok(())
197}
198
199fn has_allowed_scheme(url: &Url) -> bool {
202 url.scheme() == "https" || (cfg!(test) && url.scheme() == "http")
203}
204
205fn is_web_url(url: &str) -> bool {
206 Url::parse(url).is_ok_and(|url| has_allowed_scheme(&url) && url.has_host())
207}
208
209fn is_redirect_uri(url: &str) -> bool {
212 is_web_url(url)
213 || Url::parse(url).is_ok_and(|url| url.scheme() == "onepassword" && url.has_host())
214}
215
216async fn start_sso_login(
218 account: &SsoAccount,
219 rest: &RestClient,
220) -> Result<String, OnePasswordError> {
221 let response: SsoLoginUrl = rest
222 .post_json(SSO_START_ENDPOINT, json!({ "userUuid": account.user_uuid }))
223 .await?;
224 Ok(response.auth_redirect)
225}
226
227async fn perform_sso_login(
230 sso_login_url: &str,
231 ui: &dyn SsoUi,
232) -> Result<(String, String), OnePasswordError> {
233 if !is_web_url(sso_login_url) {
235 return Err(OnePasswordError::Internal(
236 "the SSO login URL is not an https URL".into(),
237 ));
238 }
239
240 let redirect_to = url_parameter(sso_login_url, "redirect_uri").ok_or_else(|| {
241 OnePasswordError::Internal("no redirect_uri found in the SSO login URL".into())
242 })?;
243 if !is_redirect_uri(&redirect_to) {
244 return Err(OnePasswordError::Internal(
245 "the SSO redirect URI is neither an https nor a onepassword URL".into(),
246 ));
247 }
248
249 let redirected_to = match ui.perform_sso_login(sso_login_url, &redirect_to).await {
250 SsoLoginResult::RedirectedTo(url) => url,
251 SsoLoginResult::Cancel => return Err(OnePasswordError::Canceled),
252 };
253
254 let code = url_parameter(&redirected_to, "code").ok_or_else(|| {
256 OnePasswordError::Internal("no code returned from the SSO provider".into())
257 })?;
258 let state = url_parameter(&redirected_to, "state").ok_or_else(|| {
259 OnePasswordError::Internal("no state returned from the SSO provider".into())
260 })?;
261 Ok((code, state))
262}
263
264async fn verify_sso_login(
266 code: &str,
267 state: &str,
268 client_info: &ClientInfo,
269 rest: &RestClient,
270) -> Result<SsoSession, OnePasswordError> {
271 rest.post_json(
272 SSO_VERIFY_ENDPOINT,
273 json!({
274 "code": code,
275 "state": state,
276 "device": client_info.sso_verify_device_body(),
277 }),
278 )
279 .await
280}
281
282impl SsoSession {
283 fn require_new_device(&self) -> Result<(), OnePasswordError> {
286 if self.state != DEVICE_NOT_ENROLLED {
287 return Err(OnePasswordError::Internal(format!(
288 "unexpected SSO session state: {}",
289 self.state
290 )));
291 }
292 Ok(())
293 }
294}
295
296async fn login(
301 username: &str,
302 account: &SsoAccount,
303 client_info: &ClientInfo,
304 sso_session: &SsoSession,
305 srp_x: &[u8],
306 rest: &RestClient,
307) -> Result<(AesKey, RestClient), OnePasswordError> {
308 let sign_in_token = sso_session.sso_auth.sign_in_token_details.token.as_str();
309
310 let started = start_auth(
312 sign_in_token,
313 &client_info.device_uuid,
314 &account.user_uuid,
315 rest,
316 )
317 .await?;
318 let (Some(key_uuid), Some(auth)) = (started.key_uuid, started.auth) else {
319 return Err(OnePasswordError::Internal(format!(
320 "missing SRP parameters in the start response, its status is '{}'",
321 started.status
322 )));
323 };
324
325 let session_key = perform_and_verify_with_x(
328 srp_x,
329 username,
330 &key_uuid,
331 &decode64_loose(&auth.salt)?,
332 &sso_session.user.session_uuid,
333 rest,
334 )
335 .await?;
336
337 let signed_rest = rest.with_signer(MacSigner::with_request_id(&session_key, 1));
339
340 complete_auth(client_info, sign_in_token, &session_key, &signed_rest).await?;
342
343 Ok((session_key, signed_rest))
344}
345
346async fn start_auth(
347 sign_in_token: &str,
348 device_uuid: &str,
349 user_uuid: &str,
350 rest: &RestClient,
351) -> Result<NewSession, OnePasswordError> {
352 rest.post_json(
353 AUTH_START_ENDPOINT,
354 json!({
355 "signInToken": sign_in_token,
356 "deviceUuid": device_uuid,
357 "userUuid": user_uuid,
358 }),
359 )
360 .await
361}
362
363async fn complete_auth(
364 client_info: &ClientInfo,
365 sign_in_token: &str,
366 session_key: &AesKey,
367 rest: &RestClient,
368) -> Result<(), OnePasswordError> {
369 let response: AuthComplete = rest
370 .post_encrypted_json(
371 AUTH_COMPLETE_ENDPOINT,
372 json!({
373 "client": client_info.client_id(),
374 "signInToken": sign_in_token,
375 "device": client_info.sso_complete_device_body(),
376 }),
377 session_key,
378 )
379 .await?;
380
381 if response.mfa.is_some() {
382 return Err(OnePasswordError::Unsupported(
383 "two-factor authentication after SSO is not supported".into(),
384 ));
385 }
386 Ok(())
387}
388
389fn url_parameter(url: &str, name: &str) -> Option<String> {
392 let (url, fragment) = url.split_once('#').unwrap_or((url, ""));
393 let query = url.split_once('?').map_or("", |(_, query)| query);
394
395 [query, fragment]
396 .into_iter()
397 .flat_map(|part| form_urlencoded::parse(part.as_bytes()))
398 .find(|(key, _)| key == name)
399 .map(|(_, value)| value.into_owned())
400 .filter(|value| !value.is_empty())
401}
402
403#[cfg(test)]
404mod tests {
405 use std::sync::Mutex;
406
407 use async_trait::async_trait;
408 use serde_json::{Value, json};
409 use wiremock::{Mock, MockServer, ResponseTemplate, matchers};
410
411 use super::{
412 test_support::{SIGN_IN_TOKEN, rest_client, vectors},
413 ui::SsoEnrollmentContext,
414 *,
415 };
416
417 const LOGIN_URL: &str = "https://idp.example.com/authorize?client_id=abc&response_type=code\
418 &redirect_uri=https%3A%2F%2Facme.1password.com%2Fsso%2Foidc%2Fredirect%2F&scope=openid";
419 const REDIRECT_TO: &str = "https://acme.1password.com/sso/oidc/redirect/";
420
421 struct ScriptedUi {
423 result: Mutex<Option<SsoLoginResult>>,
424 calls: Mutex<Vec<(String, String)>>,
425 }
426
427 impl ScriptedUi {
428 fn new(result: SsoLoginResult) -> ScriptedUi {
429 ScriptedUi {
430 result: Mutex::new(Some(result)),
431 calls: Mutex::new(Vec::new()),
432 }
433 }
434
435 fn redirected_to(url: &str) -> ScriptedUi {
436 ScriptedUi::new(SsoLoginResult::RedirectedTo(url.into()))
437 }
438
439 fn calls(&self) -> Vec<(String, String)> {
440 self.calls.lock().expect("not poisoned").clone()
441 }
442 }
443
444 #[async_trait]
445 impl SsoUi for ScriptedUi {
446 async fn perform_sso_login(
447 &self,
448 sso_login_url: &str,
449 redirect_to: &str,
450 ) -> SsoLoginResult {
451 self.calls
452 .lock()
453 .expect("not poisoned")
454 .push((sso_login_url.into(), redirect_to.into()));
455 self.result
456 .lock()
457 .expect("not poisoned")
458 .take()
459 .expect("the login runs once")
460 }
461
462 async fn begin_sso_enrollment(&self) -> Box<dyn SsoEnrollmentContext> {
463 unreachable!("the login steps never enroll")
464 }
465 }
466
467 fn login_info(user_uuid: Option<&str>, sign_in_address: Option<&str>) -> LoginInfo {
468 serde_json::from_value(json!({
469 "userUuid": user_uuid,
470 "signInAddress": sign_in_address,
471 "authMethods": [
472 {"type": "PASSWORD+SK"},
473 {"type": "OIDC", "provider": "Acme", "iconUrl": null},
474 ],
475 }))
476 .expect("valid login info")
477 }
478
479 fn sso_account() -> SsoAccount {
480 require_sso(
481 &login_info(Some("USERUUID"), Some("https://acme.1password.com")),
482 "[email protected]",
483 )
484 .expect("an SSO account")
485 }
486
487 fn client_info() -> ClientInfo {
488 ClientInfo::for_desktop("device-uuid")
489 }
490
491 fn verify_response(state: &str) -> Value {
493 json!({
494 "type": state,
495 "user": {
496 "sessionUuid": "SESSIONUUID",
497 "accountUuid": "ACCOUNTUUID",
498 "userUuid": "USERUUID",
499 "email": "[email protected]",
500 },
501 "ssoAuth": {
502 "signInTokenDetails": {
503 "token": SIGN_IN_TOKEN,
504 "publicKey": "PUBLICKEY",
505 "exp": "2030-01-01T00:00:00Z",
506 },
507 },
508 })
509 }
510
511 fn session(state: &str) -> SsoSession {
512 serde_json::from_value(verify_response(state)).expect("a valid session")
513 }
514
515 #[test]
516 fn requires_the_oidc_method() {
517 let mut info = login_info(Some("USERUUID"), Some("https://acme.1password.com"));
518 info.auth_methods.retain(|method| method.kind != "OIDC");
519
520 let error = require_sso(&info, "[email protected]").err();
521
522 assert!(
523 matches!(&error, Some(OnePasswordError::Unsupported(what)) if what.contains("[email protected]")),
524 "unexpected result: {error:?}"
525 );
526 }
527
528 #[test]
529 fn requires_the_user_uuid_and_the_sign_in_address() {
530 for (user_uuid, sign_in_address) in [
531 (None, Some("https://acme.1password.com")),
532 (Some(""), Some("https://acme.1password.com")),
533 (Some("USERUUID"), None),
534 (Some("USERUUID"), Some("")),
535 (None, None),
536 ] {
537 let error =
538 require_sso(&login_info(user_uuid, sign_in_address), "[email protected]").err();
539
540 assert!(
541 matches!(error, Some(OnePasswordError::Internal(_))),
542 "unexpected result for {user_uuid:?} and {sign_in_address:?}: {error:?}"
543 );
544 }
545 }
546
547 #[test]
548 fn accepts_the_login_info_of_an_sso_account() {
549 let account = sso_account();
550
551 assert_eq!(account.user_uuid, "USERUUID");
552 assert_eq!(account.sign_in_address, "https://acme.1password.com");
553 assert_eq!(account.api_url(), "https://acme.1password.com/api");
554 }
555
556 #[test]
557 fn a_trailing_slash_is_not_doubled_in_the_api_url() {
558 let account = require_sso(
559 &login_info(Some("USERUUID"), Some("https://acme.1password.com/")),
560 "[email protected]",
561 )
562 .expect("an SSO account");
563
564 assert_eq!(account.sign_in_address, "https://acme.1password.com/");
565 assert_eq!(account.api_url(), "https://acme.1password.com/api");
566 }
567
568 #[test]
569 fn accepts_the_address_forms_of_a_web_server() {
570 for address in [
571 "https://acme.1password.com",
572 "https://acme.1password.com/",
573 "http://127.0.0.1:8080",
574 "https://sso.example.com:8443/base",
575 ] {
576 require_sso(
577 &login_info(Some("USERUUID"), Some(address)),
578 "[email protected]",
579 )
580 .unwrap_or_else(|error| panic!("'{address}' is rejected: {error}"));
581 }
582 }
583
584 #[test]
585 fn rejects_a_sign_in_address_that_is_not_a_web_address() {
586 for address in [
587 "acme.1password.com",
588 "//acme.1password.com",
589 "/api",
590 "ftp://acme.1password.com",
591 "file://acme.1password.com/api",
592 "javascript:alert(1)",
593 "mailto:[email protected]",
594 "https://",
595 "https://acme.1password.com?next=/",
596 "https://acme.1password.com/?",
597 "https://acme.1password.com#top",
598 "https://acme.1password.com/#",
599 "not a url",
600 "http://user:[email protected]?next=/",
601 "ftp://user:[email protected]",
602 ] {
603 let error = require_sso(
604 &login_info(Some("USERUUID"), Some(address)),
605 "[email protected]",
606 )
607 .err();
608
609 assert!(
610 matches!(&error, Some(OnePasswordError::Internal(what)) if !what.contains(address) && !what.contains("SECRET")),
611 "unexpected result for '{address}': {error:?}"
612 );
613 }
614 }
615
616 #[tokio::test]
617 async fn start_sends_only_the_user_uuid() {
618 let server = MockServer::start().await;
619 server
620 .register(
621 Mock::given(matchers::method("POST"))
622 .and(matchers::path("/api/v3/auth/sso/oidc/start"))
623 .and(matchers::body_json(json!({"userUuid": "USERUUID"})))
624 .respond_with(
625 ResponseTemplate::new(200)
626 .set_body_json(json!({"authRedirect": LOGIN_URL})),
627 )
628 .expect(1),
629 )
630 .await;
631
632 let url = start_sso_login(&sso_account(), &rest_client(&server))
633 .await
634 .expect("starts");
635
636 assert_eq!(url, LOGIN_URL);
637 server.verify().await;
638 }
639
640 #[tokio::test]
641 async fn start_returns_a_server_error() {
642 let server = MockServer::start().await;
643 server
644 .register(
645 Mock::given(matchers::path("/api/v3/auth/sso/oidc/start")).respond_with(
646 ResponseTemplate::new(400)
647 .set_body_json(json!({"errorCode": 117, "errorMessage": "gone"})),
648 ),
649 )
650 .await;
651
652 let error = start_sso_login(&sso_account(), &rest_client(&server))
653 .await
654 .err();
655
656 assert!(matches!(error, Some(OnePasswordError::NotFound)));
657 }
658
659 #[tokio::test]
660 async fn perform_returns_the_code_and_state_of_the_c_example() {
661 let redirect = vectors().redirect;
662 let ui = ScriptedUi::redirected_to(&redirect.url);
663
664 let (code, state) = perform_sso_login(LOGIN_URL, &ui).await.expect("performs");
665
666 assert_eq!(code, redirect.code);
667 assert_eq!(state, redirect.state);
668 assert_eq!(
669 ui.calls(),
670 [(LOGIN_URL.to_string(), REDIRECT_TO.to_string())]
671 );
672 }
673
674 #[tokio::test]
675 async fn perform_accepts_the_desktop_app_redirect_uri() {
676 let login_url = "https://idp.example.com/authorize?client_id=abc\
677 &redirect_uri=onepassword%3A%2F%2Fsso%2Foidc%2Fredirect&response_mode=query";
678 let ui = ScriptedUi::redirected_to("onepassword://sso/oidc/redirect?code=c0de&state=st4te");
679
680 let (code, state) = perform_sso_login(login_url, &ui).await.expect("performs");
681
682 assert_eq!((code.as_str(), state.as_str()), ("c0de", "st4te"));
683 assert_eq!(
684 ui.calls(),
685 [(
686 login_url.to_string(),
687 "onepassword://sso/oidc/redirect".to_string()
688 )]
689 );
690 }
691
692 #[tokio::test]
693 async fn perform_reads_the_parameters_from_the_query_too() {
694 let ui = ScriptedUi::redirected_to("https://acme.1password.com/cb?code=c0de&state=st4te");
695
696 let (code, state) = perform_sso_login(LOGIN_URL, &ui).await.expect("performs");
697
698 assert_eq!((code.as_str(), state.as_str()), ("c0de", "st4te"));
699 }
700
701 #[tokio::test]
702 async fn perform_does_not_mistake_session_state_for_state() {
703 let ui = ScriptedUi::redirected_to(
704 "https://acme.1password.com/cb#session_state=WRONG&code=c0de&state=st4te",
705 );
706
707 let (_, state) = perform_sso_login(LOGIN_URL, &ui).await.expect("performs");
708
709 assert_eq!(state, "st4te");
710 }
711
712 #[tokio::test]
713 async fn perform_requires_a_state_of_its_own() {
714 let ui = ScriptedUi::redirected_to(
715 "https://acme.1password.com/cb#code=c0de&session_state=WRONG",
716 );
717
718 let error = perform_sso_login(LOGIN_URL, &ui).await.err();
719
720 assert!(
721 matches!(&error, Some(OnePasswordError::Internal(what)) if what.contains("no state")),
722 "unexpected result: {error:?}"
723 );
724 }
725
726 #[tokio::test]
727 async fn perform_form_decodes_the_redirect() {
728 let ui = ScriptedUi::redirected_to("https://acme.1password.com/cb#code=a%2Bb&state=c+d");
729
730 let (code, state) = perform_sso_login(LOGIN_URL, &ui).await.expect("performs");
731
732 assert_eq!(code, "a+b");
733 assert_eq!(state, "c d");
734 }
735
736 #[tokio::test]
737 async fn perform_requires_a_code_and_a_state() {
738 for (url, missing) in [
739 ("https://acme.1password.com/cb#state=st4te", "code"),
740 ("https://acme.1password.com/cb#code=&state=st4te", "code"),
741 ("https://acme.1password.com/cb#code=c0de", "state"),
742 ("https://acme.1password.com/cb#code=c0de&state=", "state"),
743 ("https://acme.1password.com/cb", "code"),
744 ] {
745 let error = perform_sso_login(LOGIN_URL, &ScriptedUi::redirected_to(url))
746 .await
747 .err();
748
749 assert!(
750 matches!(&error, Some(OnePasswordError::Internal(what)) if what.contains(&format!("no {missing}"))),
751 "unexpected result for '{url}': {error:?}"
752 );
753 }
754 }
755
756 #[tokio::test]
757 async fn perform_requires_a_redirect_uri_before_asking_the_user() {
758 for login_url in [
759 "https://idp.example.com/authorize?client_id=abc",
760 "https://idp.example.com/authorize?post_redirect_uri=https%3A%2F%2Fexample.com",
761 "https://idp.example.com/authorize?redirect_uri=",
762 ] {
763 let ui = ScriptedUi::redirected_to("https://acme.1password.com/cb#code=c&state=s");
764
765 let error = perform_sso_login(login_url, &ui).await.err();
766
767 assert!(
768 matches!(&error, Some(OnePasswordError::Internal(what)) if what.contains("redirect_uri")),
769 "unexpected result for '{login_url}': {error:?}"
770 );
771 assert!(ui.calls().is_empty());
772 }
773 }
774
775 #[tokio::test]
776 async fn perform_rejects_a_redirect_uri_that_is_not_a_web_address() {
777 for redirect_uri in [
778 "javascript%3Aalert(1)",
779 "file%3A%2F%2F%2Fcb",
780 "ftp%3A%2F%2Facme.1password.com%2Fcb",
781 "%2F%2Facme.1password.com%2Fcb",
782 "acme.1password.com%2Fcb",
783 "onepassword%3A%2Fsso",
784 ] {
785 let login_url = format!(
786 "https://idp.example.com/authorize?redirect_uri={redirect_uri}&state=SECRET"
787 );
788 let ui = ScriptedUi::redirected_to("https://acme.1password.com/cb#code=c&state=s");
789
790 let error = perform_sso_login(&login_url, &ui).await.err();
791
792 assert!(
793 matches!(&error, Some(OnePasswordError::Internal(what)) if what.contains("redirect URI") && !what.contains("SECRET")),
794 "unexpected result for '{login_url}': {error:?}"
795 );
796 assert!(ui.calls().is_empty(), "'{login_url}' reached the user");
797 }
798 }
799
800 #[tokio::test]
801 async fn perform_rejects_a_login_url_that_is_not_a_web_address() {
802 for login_url in [
803 "javascript:alert(1)//?redirect_uri=https%3A%2F%2Facme.1password.com&state=SECRET",
804 "file://idp.example.com/authorize?redirect_uri=https%3A%2F%2Facme.1password.com&state=SECRET",
805 "ftp://idp.example.com/authorize?redirect_uri=https%3A%2F%2Facme.1password.com&state=SECRET",
806 "file:///authorize?redirect_uri=https%3A%2F%2Facme.1password.com&state=SECRET",
807 "https://?redirect_uri=https%3A%2F%2Facme.1password.com&state=SECRET",
808 "//idp.example.com/authorize?redirect_uri=https%3A%2F%2Facme.1password.com&state=SECRET",
809 "idp.example.com/authorize?redirect_uri=https%3A%2F%2Facme.1password.com&state=SECRET",
810 ] {
811 let ui = ScriptedUi::redirected_to("https://acme.1password.com/cb#code=c&state=s");
812
813 let error = perform_sso_login(login_url, &ui).await.err();
814
815 assert!(
816 matches!(&error, Some(OnePasswordError::Internal(what)) if !what.contains("SECRET")),
817 "unexpected result for '{login_url}': {error:?}"
818 );
819 assert!(ui.calls().is_empty(), "'{login_url}' reached the user");
820 }
821 }
822
823 #[tokio::test]
824 async fn perform_reports_a_cancel_as_canceled() {
825 let ui = ScriptedUi::new(SsoLoginResult::Cancel);
826
827 let error = perform_sso_login(LOGIN_URL, &ui).await.err();
828
829 assert!(
830 matches!(error, Some(OnePasswordError::Canceled)),
831 "unexpected result: {error:?}"
832 );
833 assert_eq!(ui.calls().len(), 1);
834 }
835
836 #[test]
837 fn url_parameters_match_whole_keys_in_the_query_and_the_fragment() {
838 let url = "https://h/p?x_code=1&code=2&y=code=3#z=4&code=5";
839
840 assert_eq!(url_parameter(url, "code").as_deref(), Some("2"));
841 assert_eq!(url_parameter(url, "x_code").as_deref(), Some("1"));
842 assert_eq!(url_parameter(url, "z").as_deref(), Some("4"));
843 assert_eq!(url_parameter(url, "y").as_deref(), Some("code=3"));
844 assert_eq!(url_parameter(url, "cod"), None);
845 assert_eq!(
846 url_parameter("https://h/p#code=5", "code").as_deref(),
847 Some("5")
848 );
849 assert_eq!(
850 url_parameter("https://h/p?a&code=1", "code").as_deref(),
851 Some("1")
852 );
853 }
854
855 #[test]
856 fn url_parameters_are_form_decoded() {
857 assert_eq!(
858 url_parameter("u?a=%E2%9C%93+%2B%3D%26", "a").as_deref(),
859 Some("\u{2713} +=&")
860 );
861 }
862
863 #[tokio::test]
864 async fn verify_sends_the_code_state_and_device_descriptor() {
865 let server = MockServer::start().await;
866 server
867 .register(
868 Mock::given(matchers::method("POST"))
869 .and(matchers::path("/api/v3/auth/sso/oidc/verify"))
870 .and(matchers::body_json(json!({
871 "code": "c0de",
872 "state": "st4te",
873 "device": client_info().sso_verify_device_body(),
874 })))
875 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
876 "type": "device_not_enrolled",
877 "user": {
878 "sessionUuid": "SESSIONUUID",
879 "accountUuid": "ACCOUNTUUID",
880 "userUuid": "USERUUID",
881 "email": "[email protected]",
882 },
883 "ssoAuth": {
884 "signInTokenDetails": {
885 "token": "SIGN_IN_TOKEN",
886 "publicKey": "PUBLICKEY",
887 "exp": "2030-01-01T00:00:00Z",
888 },
889 },
890 })))
891 .expect(1),
892 )
893 .await;
894
895 let session = verify_sso_login("c0de", "st4te", &client_info(), &rest_client(&server))
896 .await
897 .expect("verifies");
898
899 assert_eq!(session.state, "device_not_enrolled");
900 assert_eq!(session.user.session_uuid, "SESSIONUUID");
901 assert_eq!(
902 session.sso_auth.sign_in_token_details.token.as_str(),
903 "SIGN_IN_TOKEN"
904 );
905 server.verify().await;
906 }
907
908 #[tokio::test]
909 async fn verify_returns_a_server_error() {
910 let server = MockServer::start().await;
911 server
912 .register(
913 Mock::given(matchers::path("/api/v3/auth/sso/oidc/verify")).respond_with(
914 ResponseTemplate::new(400)
915 .set_body_json(json!({"errorCode": 500, "errorMessage": "bad code"})),
916 ),
917 )
918 .await;
919
920 let error = verify_sso_login("c0de", "st4te", &client_info(), &rest_client(&server))
921 .await
922 .err();
923
924 assert!(
925 matches!(error, Some(OnePasswordError::Internal(what)) if what.contains("bad code"))
926 );
927 }
928
929 #[test]
930 fn the_sign_in_token_stays_out_of_debug_output() {
931 let output = format!("{:?}", session("device_not_enrolled"));
932
933 assert!(!output.contains("SIGN_IN_TOKEN"), "leaked: {output}");
934 }
935
936 #[test]
937 fn a_new_device_is_not_enrolled() {
938 assert!(session("device_not_enrolled").require_new_device().is_ok());
939 }
940
941 #[test]
942 fn any_other_session_state_is_an_error() {
943 for state in ["", "found", "Found", "device_deleted", "unknown"] {
944 let error = session(state).require_new_device().err();
945
946 assert!(
947 matches!(&error, Some(OnePasswordError::Internal(what)) if what.contains(state)),
948 "unexpected result for '{state}': {error:?}"
949 );
950 }
951 }
952}