1#![doc = include_str!("../README.md")]
2
3use std::fmt;
4
5use bitwarden_vault::{
6 CipherRepromptType, CipherView, Fido2CredentialFullView, FieldView, FolderId, LoginUriView,
7 UriMatchType,
8};
9use chrono::{DateTime, Utc};
10use uuid::Uuid;
11
12#[cfg(feature = "uniffi")]
13uniffi::setup_scaffolding!();
14#[cfg(feature = "uniffi")]
15mod uniffi_support;
16
17mod csv;
18mod cxf;
19pub use cxf::Account;
20mod encrypted_json;
21mod exporter_client;
22mod json;
23mod models;
24pub use exporter_client::{ExporterClient, ExporterClientExt};
25mod error;
26mod export;
27pub use error::ExportError;
28pub use export::encrypt_import;
29
30#[allow(missing_docs)]
31#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
32#[cfg_attr(
33 feature = "wasm",
34 derive(serde::Serialize, serde::Deserialize, tsify::Tsify),
35 tsify(into_wasm_abi, from_wasm_abi)
36)]
37pub enum ExportFormat {
38 Csv,
39 Json,
40 EncryptedJson { password: String },
41}
42
43#[allow(missing_docs)]
48pub struct Folder {
49 pub id: Uuid,
50 pub name: String,
51}
52
53#[allow(missing_docs)]
58#[derive(Clone)]
59pub struct Cipher {
60 pub id: Uuid,
61 pub folder_id: Option<Uuid>,
62
63 pub name: String,
64 pub notes: Option<String>,
65
66 pub r#type: CipherType,
67
68 pub favorite: bool,
69 pub reprompt: u8,
70
71 pub fields: Vec<Field>,
72
73 pub password_history: Option<Vec<PasswordHistory>>,
74
75 pub revision_date: DateTime<Utc>,
76 pub creation_date: DateTime<Utc>,
77 pub deleted_date: Option<DateTime<Utc>>,
78}
79
80#[allow(missing_docs)]
82#[derive(Clone)]
83#[cfg_attr(test, derive(Debug, PartialEq, Eq))]
84pub struct PasswordHistory {
85 pub password: String,
86 pub last_used_date: DateTime<Utc>,
87}
88
89#[allow(missing_docs)]
94#[derive(Clone)]
95#[cfg_attr(test, derive(Debug, PartialEq, Eq))]
96pub struct ImportingCipher {
97 pub folder_id: Option<Uuid>,
98
99 pub name: String,
100 pub notes: Option<String>,
101
102 pub r#type: CipherType,
103
104 pub favorite: bool,
105 pub reprompt: u8,
106
107 pub fields: Vec<Field>,
108
109 pub revision_date: DateTime<Utc>,
110 pub creation_date: DateTime<Utc>,
111 pub deleted_date: Option<DateTime<Utc>>,
112}
113
114impl From<Login> for bitwarden_vault::LoginView {
115 fn from(login: Login) -> Self {
116 let l: Vec<LoginUriView> = login
117 .login_uris
118 .into_iter()
119 .map(LoginUriView::from)
120 .collect();
121
122 bitwarden_vault::LoginView {
123 username: login.username,
124 password: login.password,
125 password_revision_date: None,
126 uris: if l.is_empty() { None } else { Some(l) },
127 totp: login.totp,
128 autofill_on_page_load: None,
129 fido2_credentials: None,
131 }
132 }
133}
134
135impl From<SecureNote> for bitwarden_vault::SecureNoteView {
136 fn from(secure_note: SecureNote) -> Self {
137 bitwarden_vault::SecureNoteView {
138 r#type: secure_note.r#type.into(),
139 }
140 }
141}
142
143impl From<Card> for bitwarden_vault::CardView {
144 fn from(card: Card) -> Self {
145 bitwarden_vault::CardView {
146 cardholder_name: card.cardholder_name,
147 brand: card.brand,
148 number: card.number,
149 exp_month: card.exp_month,
150 exp_year: card.exp_year,
151 code: card.code,
152 }
153 }
154}
155
156impl From<Identity> for bitwarden_vault::IdentityView {
157 fn from(identity: Identity) -> Self {
158 bitwarden_vault::IdentityView {
159 title: identity.title,
160 first_name: identity.first_name,
161 middle_name: identity.middle_name,
162 last_name: identity.last_name,
163 address1: identity.address1,
164 address2: identity.address2,
165 address3: identity.address3,
166 city: identity.city,
167 state: identity.state,
168 postal_code: identity.postal_code,
169 country: identity.country,
170 company: identity.company,
171 email: identity.email,
172 phone: identity.phone,
173 ssn: identity.ssn,
174 username: identity.username,
175 passport_number: identity.passport_number,
176 license_number: identity.license_number,
177 }
178 }
179}
180
181impl From<SshKey> for bitwarden_vault::SshKeyView {
182 fn from(ssh_key: SshKey) -> Self {
183 bitwarden_vault::SshKeyView {
184 private_key: ssh_key.private_key,
185 public_key: ssh_key.public_key,
186 fingerprint: ssh_key.fingerprint,
187 }
188 }
189}
190
191impl From<ImportingCipher> for CipherView {
192 fn from(value: ImportingCipher) -> Self {
193 let (cipher_type, login, identity, card, secure_note, ssh_key) = match value.r#type {
194 CipherType::Login(login) => (
195 bitwarden_vault::CipherType::Login,
196 Some((*login).into()),
197 None,
198 None,
199 None,
200 None,
201 ),
202 CipherType::SecureNote(secure_note) => (
203 bitwarden_vault::CipherType::SecureNote,
204 None,
205 None,
206 None,
207 Some((*secure_note).into()),
208 None,
209 ),
210 CipherType::Card(card) => (
211 bitwarden_vault::CipherType::Card,
212 None,
213 None,
214 Some((*card).into()),
215 None,
216 None,
217 ),
218 CipherType::Identity(identity) => (
219 bitwarden_vault::CipherType::Identity,
220 None,
221 Some((*identity).into()),
222 None,
223 None,
224 None,
225 ),
226 CipherType::SshKey(ssh_key) => (
227 bitwarden_vault::CipherType::SshKey,
228 None,
229 None,
230 None,
231 None,
232 Some((*ssh_key).into()),
233 ),
234 CipherType::BankAccount => (
235 bitwarden_vault::CipherType::BankAccount,
236 None,
237 None,
238 None,
239 None,
240 None,
241 ),
242 CipherType::Passport => (
243 bitwarden_vault::CipherType::Passport,
244 None,
245 None,
246 None,
247 None,
248 None,
249 ),
250 CipherType::DriversLicense => (
251 bitwarden_vault::CipherType::DriversLicense,
252 None,
253 None,
254 None,
255 None,
256 None,
257 ),
258 };
259
260 Self {
261 partial: false,
262 id: None,
263 organization_id: None,
264 folder_id: value.folder_id.map(FolderId::new),
265 collection_ids: vec![],
266 key: None,
267 name: value.name,
268 notes: value.notes,
269 r#type: cipher_type,
270 login,
271 identity,
272 card,
273 secure_note,
274 ssh_key,
275 bank_account: None,
276 drivers_license: None,
277 passport: None,
278 favorite: value.favorite,
279 reprompt: CipherRepromptType::None,
280 organization_use_totp: true,
281 edit: true,
282 permissions: None,
283 view_password: true,
284 local_data: None,
285 attachments: None,
286 attachment_decryption_failures: None,
287 fields: {
288 let fields: Vec<FieldView> = value.fields.into_iter().map(Into::into).collect();
289 if fields.is_empty() {
290 None
291 } else {
292 Some(fields)
293 }
294 },
295 password_history: None,
296 creation_date: value.creation_date,
297 deleted_date: None,
298 revision_date: value.revision_date,
299 archived_date: None,
300 }
301 }
302}
303
304impl From<LoginUri> for bitwarden_vault::LoginUriView {
305 fn from(value: LoginUri) -> Self {
306 Self {
307 uri: value.uri,
308 r#match: value.r#match.and_then(|m| match m {
309 0 => Some(UriMatchType::Domain),
310 1 => Some(UriMatchType::Host),
311 2 => Some(UriMatchType::StartsWith),
312 3 => Some(UriMatchType::Exact),
313 4 => Some(UriMatchType::RegularExpression),
314 5 => Some(UriMatchType::Never),
315 _ => None,
316 }),
317 uri_checksum: None,
318 }
319 }
320}
321
322#[allow(missing_docs)]
323#[derive(Clone, Debug, PartialEq, Eq)]
324pub struct Field {
325 pub name: Option<String>,
326 pub value: Option<String>,
327 pub r#type: u8,
328 pub linked_id: Option<u32>,
329}
330
331#[allow(missing_docs)]
332#[derive(Clone)]
333#[cfg_attr(test, derive(Debug, PartialEq, Eq))]
334pub enum CipherType {
335 Login(Box<Login>),
336 SecureNote(Box<SecureNote>),
337 Card(Box<Card>),
338 Identity(Box<Identity>),
339 SshKey(Box<SshKey>),
340 BankAccount,
341 Passport,
342 DriversLicense,
343}
344
345impl fmt::Display for CipherType {
346 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
347 match self {
348 CipherType::Login(_) => write!(f, "login"),
349 CipherType::SecureNote(_) => write!(f, "note"),
350 CipherType::Card(_) => write!(f, "card"),
351 CipherType::Identity(_) => write!(f, "identity"),
352 CipherType::SshKey(_) => write!(f, "ssh_key"),
353 CipherType::BankAccount => write!(f, "bank_account"),
354 CipherType::DriversLicense => write!(f, "drivers_license"),
355 CipherType::Passport => write!(f, "passport"),
356 }
357 }
358}
359
360#[allow(missing_docs)]
361#[derive(Clone)]
362#[cfg_attr(test, derive(Debug, PartialEq, Eq))]
363pub struct Login {
364 pub username: Option<String>,
365 pub password: Option<String>,
366 pub login_uris: Vec<LoginUri>,
367 pub totp: Option<String>,
368
369 pub fido2_credentials: Option<Vec<Fido2Credential>>,
370}
371
372impl Login {
373 pub fn sanitize_uris(&mut self) {
379 for login_uri in &mut self.login_uris {
380 if let Some(uri) = &login_uri.uri {
381 login_uri.uri = Some(sanitize_uri(uri));
382 }
383 }
384 }
385}
386
387fn sanitize_uri(uri: &str) -> String {
395 if let Ok(parsed) = url::Url::parse(uri)
396 && parsed.has_host()
397 {
398 return uri.to_string();
399 }
400
401 let with_http = format!("http://{uri}");
402 if let Ok(parsed) = url::Url::parse(&with_http)
403 && let Some(host) = parsed.host()
404 && matches!(host, url::Host::Ipv4(_))
405 {
406 return with_http;
407 }
408
409 if !uri.starts_with("http://") || !uri.starts_with("https://") {
410 return format!("https://{uri}");
411 }
412
413 uri.to_string()
414}
415
416#[allow(missing_docs)]
417#[derive(Clone, Debug, PartialEq, Eq)]
418pub struct LoginUri {
419 pub uri: Option<String>,
420 pub r#match: Option<u8>,
421}
422
423#[allow(missing_docs)]
424#[derive(Clone)]
425#[cfg_attr(test, derive(Debug, PartialEq, Eq))]
426pub struct Fido2Credential {
427 pub credential_id: String,
428 pub key_type: String,
429 pub key_algorithm: String,
430 pub key_curve: String,
431 pub key_value: String,
432 pub rp_id: String,
433 pub user_handle: Option<String>,
434 pub user_name: Option<String>,
435 pub counter: u32,
436 pub rp_name: Option<String>,
437 pub user_display_name: Option<String>,
438 pub discoverable: String,
439 pub creation_date: DateTime<Utc>,
440}
441
442impl From<Fido2Credential> for Fido2CredentialFullView {
443 fn from(value: Fido2Credential) -> Self {
444 Fido2CredentialFullView {
445 credential_id: value.credential_id,
446 key_type: value.key_type,
447 key_algorithm: value.key_algorithm,
448 key_curve: value.key_curve,
449 key_value: value.key_value,
450 rp_id: value.rp_id,
451 user_handle: value.user_handle,
452 user_name: value.user_name,
453 counter: value.counter.to_string(),
454 rp_name: value.rp_name,
455 user_display_name: value.user_display_name,
456 discoverable: value.discoverable,
457 creation_date: value.creation_date,
458 }
459 }
460}
461
462#[allow(missing_docs)]
463#[derive(Clone)]
464#[cfg_attr(test, derive(Debug, PartialEq, Eq))]
465pub struct Card {
466 pub cardholder_name: Option<String>,
467 pub exp_month: Option<String>,
468 pub exp_year: Option<String>,
469 pub code: Option<String>,
470 pub brand: Option<String>,
471 pub number: Option<String>,
472}
473
474#[allow(missing_docs)]
475#[derive(Clone)]
476#[cfg_attr(test, derive(Debug, PartialEq, Eq))]
477pub struct SecureNote {
478 pub r#type: SecureNoteType,
479}
480
481#[allow(missing_docs)]
482#[derive(Clone)]
483#[cfg_attr(test, derive(Debug, PartialEq, Eq))]
484pub enum SecureNoteType {
485 Generic = 0,
486}
487
488#[allow(missing_docs)]
489#[derive(Clone, Default, Debug, PartialEq, Eq)]
490pub struct Identity {
491 pub title: Option<String>,
492 pub first_name: Option<String>,
493 pub middle_name: Option<String>,
494 pub last_name: Option<String>,
495 pub address1: Option<String>,
496 pub address2: Option<String>,
497 pub address3: Option<String>,
498 pub city: Option<String>,
499 pub state: Option<String>,
500 pub postal_code: Option<String>,
501 pub country: Option<String>,
502 pub company: Option<String>,
503 pub email: Option<String>,
504 pub phone: Option<String>,
505 pub ssn: Option<String>,
506 pub username: Option<String>,
507 pub passport_number: Option<String>,
508 pub license_number: Option<String>,
509}
510
511#[allow(missing_docs)]
512#[derive(Clone)]
513#[cfg_attr(test, derive(Debug, PartialEq, Eq))]
514pub struct SshKey {
515 pub private_key: String,
517 pub public_key: String,
519 pub fingerprint: String,
521}
522
523#[cfg(test)]
524mod tests {
525 use bitwarden_vault::{CipherType as VaultCipherType, FieldType};
526 use chrono::{DateTime, Utc};
527
528 use super::*;
529
530 #[test]
531 fn test_importing_cipher_to_cipher_view_login() {
532 let test_date: DateTime<Utc> = "2024-01-30T17:55:36.150Z".parse().unwrap();
533 let test_folder_id = uuid::Uuid::new_v4();
534
535 let importing_cipher = ImportingCipher {
536 folder_id: Some(test_folder_id),
537 name: "Test Login".to_string(),
538 notes: Some("Test notes".to_string()),
539 r#type: CipherType::Login(Box::new(Login {
540 username: Some("[email protected]".to_string()),
541 password: Some("password123".to_string()),
542 login_uris: vec![LoginUri {
543 uri: Some("https://example.com".to_string()),
544 r#match: Some(0), }],
546 totp: Some("otpauth://totp/test".to_string()),
547 fido2_credentials: None,
548 })),
549 favorite: true,
550 reprompt: 1,
551 fields: vec![Field {
552 name: Some("CustomField".to_string()),
553 value: Some("CustomValue".to_string()),
554 r#type: 0,
555 linked_id: None,
556 }],
557 revision_date: test_date,
558 creation_date: test_date,
559 deleted_date: None,
560 };
561
562 let cipher_view: CipherView = importing_cipher.into();
563
564 assert_eq!(cipher_view.id, None);
565 assert_eq!(cipher_view.organization_id, None);
566 assert_eq!(
567 cipher_view.folder_id.unwrap().to_string(),
568 test_folder_id.to_string()
569 );
570 assert_eq!(cipher_view.name, "Test Login");
571 assert_eq!(cipher_view.notes.unwrap(), "Test notes");
572 assert_eq!(cipher_view.r#type, VaultCipherType::Login);
573 assert!(cipher_view.favorite);
574 assert_eq!(cipher_view.creation_date, test_date);
575 assert_eq!(cipher_view.revision_date, test_date);
576
577 let fields = cipher_view.fields.unwrap();
578 assert_eq!(fields.len(), 1);
579
580 let field = fields.first().unwrap();
581 assert_eq!(field.name, Some("CustomField".to_string()));
582 assert_eq!(field.value, Some("CustomValue".to_string()));
583 assert_eq!(field.r#type, FieldType::Text);
584 assert_eq!(field.linked_id, None);
585
586 let login = cipher_view.login.expect("Login should be present");
587 assert_eq!(login.username, Some("[email protected]".to_string()));
588 assert_eq!(login.password, Some("password123".to_string()));
589 assert_eq!(login.totp, Some("otpauth://totp/test".to_string()));
590
591 let uris = login.uris.expect("URIs should be present");
592 assert_eq!(uris.len(), 1);
593 assert_eq!(uris[0].uri, Some("https://example.com".to_string()));
594 assert_eq!(uris[0].r#match, Some(bitwarden_vault::UriMatchType::Domain));
595 }
596
597 #[test]
598 fn test_importing_cipher_to_cipher_view_secure_note() {
599 let test_date: DateTime<Utc> = "2024-01-30T17:55:36.150Z".parse().unwrap();
600
601 let importing_cipher = ImportingCipher {
602 folder_id: None,
603 name: "My Note".to_string(),
604 notes: Some("This is a secure note".to_string()),
605 r#type: CipherType::SecureNote(Box::new(SecureNote {
606 r#type: SecureNoteType::Generic,
607 })),
608 favorite: false,
609 reprompt: 0,
610 fields: vec![],
611 revision_date: test_date,
612 creation_date: test_date,
613 deleted_date: None,
614 };
615
616 let cipher_view: CipherView = importing_cipher.into();
617
618 assert_eq!(cipher_view.id, None);
620 assert_eq!(cipher_view.organization_id, None);
621 assert_eq!(cipher_view.folder_id, None);
622 assert_eq!(cipher_view.name, "My Note");
623 assert_eq!(cipher_view.notes, Some("This is a secure note".to_string()));
624 assert_eq!(cipher_view.r#type, bitwarden_vault::CipherType::SecureNote);
625 assert!(!cipher_view.favorite);
626 assert_eq!(cipher_view.creation_date, test_date);
627 assert_eq!(cipher_view.revision_date, test_date);
628
629 assert!(cipher_view.login.is_none());
631 assert!(cipher_view.identity.is_none());
632 assert!(cipher_view.card.is_none());
633 assert!(cipher_view.secure_note.is_some());
634 assert!(cipher_view.ssh_key.is_none());
635
636 let secure_note = cipher_view.secure_note.unwrap();
638 assert!(matches!(
639 secure_note.r#type,
640 bitwarden_vault::SecureNoteType::Generic
641 ));
642 }
643
644 #[test]
645 fn test_importing_cipher_to_cipher_view_card() {
646 let test_date: DateTime<Utc> = "2024-01-30T17:55:36.150Z".parse().unwrap();
647
648 let importing_cipher = ImportingCipher {
649 folder_id: None,
650 name: "My Credit Card".to_string(),
651 notes: Some("Credit card notes".to_string()),
652 r#type: CipherType::Card(Box::new(Card {
653 cardholder_name: Some("John Doe".to_string()),
654 brand: Some("Visa".to_string()),
655 number: Some("1234567812345678".to_string()),
656 exp_month: Some("12".to_string()),
657 exp_year: Some("2025".to_string()),
658 code: Some("123".to_string()),
659 })),
660 favorite: false,
661 reprompt: 0,
662 fields: vec![],
663 revision_date: test_date,
664 creation_date: test_date,
665 deleted_date: None,
666 };
667
668 let cipher_view: CipherView = importing_cipher.into();
669
670 assert_eq!(cipher_view.r#type, bitwarden_vault::CipherType::Card);
671 assert!(cipher_view.card.is_some());
672 assert!(cipher_view.login.is_none());
673
674 let card = cipher_view.card.unwrap();
675 assert_eq!(card.cardholder_name, Some("John Doe".to_string()));
676 assert_eq!(card.brand, Some("Visa".to_string()));
677 assert_eq!(card.number, Some("1234567812345678".to_string()));
678 }
679
680 #[test]
681 fn test_importing_cipher_to_cipher_view_identity() {
682 let test_date: DateTime<Utc> = "2024-01-30T17:55:36.150Z".parse().unwrap();
683
684 let importing_cipher = ImportingCipher {
685 folder_id: None,
686 name: "My Identity".to_string(),
687 notes: None,
688 r#type: CipherType::Identity(Box::new(Identity {
689 title: Some("Dr.".to_string()),
690 first_name: Some("Jane".to_string()),
691 last_name: Some("Smith".to_string()),
692 email: Some("[email protected]".to_string()),
693 ..Default::default()
694 })),
695 favorite: false,
696 reprompt: 0,
697 fields: vec![],
698 revision_date: test_date,
699 creation_date: test_date,
700 deleted_date: None,
701 };
702
703 let cipher_view: CipherView = importing_cipher.into();
704
705 assert_eq!(cipher_view.r#type, bitwarden_vault::CipherType::Identity);
706 assert!(cipher_view.identity.is_some());
707 assert!(cipher_view.login.is_none());
708
709 let identity = cipher_view.identity.unwrap();
710 assert_eq!(identity.title, Some("Dr.".to_string()));
711 assert_eq!(identity.first_name, Some("Jane".to_string()));
712 assert_eq!(identity.last_name, Some("Smith".to_string()));
713 assert_eq!(identity.email, Some("[email protected]".to_string()));
714 }
715
716 #[test]
717 fn test_sanitize_uri_valid_url_unchanged() {
718 assert_eq!(
719 sanitize_uri("https://bitwarden.com"),
720 "https://bitwarden.com"
721 );
722 assert_eq!(
723 sanitize_uri("https://bitwarden.com/path?q=1"),
724 "https://bitwarden.com/path?q=1"
725 );
726 assert_eq!(
727 sanitize_uri("http://192.168.0.1:8080"),
728 "http://192.168.0.1:8080"
729 );
730 }
731
732 #[test]
733 fn test_sanitize_uri_ip_address_gets_http() {
734 assert_eq!(sanitize_uri("192.168.0.1:8080"), "http://192.168.0.1:8080");
735 assert_eq!(sanitize_uri("10.0.0.1"), "http://10.0.0.1");
736 assert_eq!(
737 sanitize_uri("192.168.0.1:8080/path"),
738 "http://192.168.0.1:8080/path"
739 );
740 }
741
742 #[test]
743 fn test_sanitize_uri_non_ip_gets_https() {
744 assert_eq!(sanitize_uri("bitwarden.com"), "https://bitwarden.com");
745 assert_eq!(
746 sanitize_uri("bitwarden.co.uk/login"),
747 "https://bitwarden.co.uk/login"
748 );
749 }
750
751 #[test]
752 fn test_sanitize_uri_broken_http_prefix_unchanged() {
753 assert_eq!(
755 sanitize_uri("ht tp://bitwarden.com"),
756 "https://ht tp://bitwarden.com"
757 );
758 }
759
760 #[test]
761 fn test_sanitize_uri_schemeless_host_port() {
762 assert_eq!(sanitize_uri("localhost:8080"), "https://localhost:8080");
763 }
764
765 #[test]
766 fn test_sanitize_uri_scheme_on_string() {
767 assert_eq!(sanitize_uri("foo:bar"), "https://foo:bar");
768 }
769
770 #[test]
771 fn test_sanitize_uri_with_real_schemes_no_change() {
772 assert_eq!(
773 sanitize_uri("ftp://files.example.com"),
774 "ftp://files.example.com"
775 );
776 assert_eq!(
777 sanitize_uri("androidapp://com.example"),
778 "androidapp://com.example"
779 );
780 }
781
782 #[test]
783 fn test_login_sanitize_uris() {
784 let mut login = Login {
785 username: None,
786 password: None,
787 login_uris: vec![
788 LoginUri {
789 uri: Some("https://bitwarden.com".to_string()),
790 r#match: None,
791 },
792 LoginUri {
793 uri: Some("192.168.0.1:8080".to_string()),
794 r#match: None,
795 },
796 LoginUri {
797 uri: Some("example.com".to_string()),
798 r#match: None,
799 },
800 LoginUri {
801 uri: None,
802 r#match: None,
803 },
804 ],
805 totp: None,
806 fido2_credentials: None,
807 };
808
809 login.sanitize_uris();
810
811 assert_eq!(
812 login.login_uris[0].uri,
813 Some("https://bitwarden.com".to_string())
814 );
815 assert_eq!(
816 login.login_uris[1].uri,
817 Some("http://192.168.0.1:8080".to_string())
818 );
819 assert_eq!(
820 login.login_uris[2].uri,
821 Some("https://example.com".to_string())
822 );
823 assert_eq!(login.login_uris[3].uri, None);
824 }
825}