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 id: None,
262 organization_id: None,
263 folder_id: value.folder_id.map(FolderId::new),
264 collection_ids: vec![],
265 key: None,
266 name: value.name,
267 notes: value.notes,
268 r#type: cipher_type,
269 login,
270 identity,
271 card,
272 secure_note,
273 ssh_key,
274 bank_account: None,
275 drivers_license: None,
276 passport: None,
277 favorite: value.favorite,
278 reprompt: CipherRepromptType::None,
279 organization_use_totp: true,
280 edit: true,
281 permissions: None,
282 view_password: true,
283 local_data: None,
284 attachments: None,
285 attachment_decryption_failures: None,
286 fields: {
287 let fields: Vec<FieldView> = value.fields.into_iter().map(Into::into).collect();
288 if fields.is_empty() {
289 None
290 } else {
291 Some(fields)
292 }
293 },
294 password_history: None,
295 creation_date: value.creation_date,
296 deleted_date: None,
297 revision_date: value.revision_date,
298 archived_date: None,
299 }
300 }
301}
302
303impl From<LoginUri> for bitwarden_vault::LoginUriView {
304 fn from(value: LoginUri) -> Self {
305 Self {
306 uri: value.uri,
307 r#match: value.r#match.and_then(|m| match m {
308 0 => Some(UriMatchType::Domain),
309 1 => Some(UriMatchType::Host),
310 2 => Some(UriMatchType::StartsWith),
311 3 => Some(UriMatchType::Exact),
312 4 => Some(UriMatchType::RegularExpression),
313 5 => Some(UriMatchType::Never),
314 _ => None,
315 }),
316 uri_checksum: None,
317 }
318 }
319}
320
321#[allow(missing_docs)]
322#[derive(Clone, Debug, PartialEq, Eq)]
323pub struct Field {
324 pub name: Option<String>,
325 pub value: Option<String>,
326 pub r#type: u8,
327 pub linked_id: Option<u32>,
328}
329
330#[allow(missing_docs)]
331#[derive(Clone)]
332#[cfg_attr(test, derive(Debug, PartialEq, Eq))]
333pub enum CipherType {
334 Login(Box<Login>),
335 SecureNote(Box<SecureNote>),
336 Card(Box<Card>),
337 Identity(Box<Identity>),
338 SshKey(Box<SshKey>),
339 BankAccount,
340 Passport,
341 DriversLicense,
342}
343
344impl fmt::Display for CipherType {
345 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
346 match self {
347 CipherType::Login(_) => write!(f, "login"),
348 CipherType::SecureNote(_) => write!(f, "note"),
349 CipherType::Card(_) => write!(f, "card"),
350 CipherType::Identity(_) => write!(f, "identity"),
351 CipherType::SshKey(_) => write!(f, "ssh_key"),
352 CipherType::BankAccount => write!(f, "bank_account"),
353 CipherType::DriversLicense => write!(f, "drivers_license"),
354 CipherType::Passport => write!(f, "passport"),
355 }
356 }
357}
358
359#[allow(missing_docs)]
360#[derive(Clone)]
361#[cfg_attr(test, derive(Debug, PartialEq, Eq))]
362pub struct Login {
363 pub username: Option<String>,
364 pub password: Option<String>,
365 pub login_uris: Vec<LoginUri>,
366 pub totp: Option<String>,
367
368 pub fido2_credentials: Option<Vec<Fido2Credential>>,
369}
370
371impl Login {
372 pub fn sanitize_uris(&mut self) {
378 for login_uri in &mut self.login_uris {
379 if let Some(uri) = &login_uri.uri {
380 login_uri.uri = Some(sanitize_uri(uri));
381 }
382 }
383 }
384}
385
386fn sanitize_uri(uri: &str) -> String {
394 if let Ok(parsed) = url::Url::parse(uri)
395 && parsed.has_host()
396 {
397 return uri.to_string();
398 }
399
400 let with_http = format!("http://{uri}");
401 if let Ok(parsed) = url::Url::parse(&with_http)
402 && let Some(host) = parsed.host()
403 && matches!(host, url::Host::Ipv4(_))
404 {
405 return with_http;
406 }
407
408 if !uri.starts_with("http://") || !uri.starts_with("https://") {
409 return format!("https://{uri}");
410 }
411
412 uri.to_string()
413}
414
415#[allow(missing_docs)]
416#[derive(Clone, Debug, PartialEq, Eq)]
417pub struct LoginUri {
418 pub uri: Option<String>,
419 pub r#match: Option<u8>,
420}
421
422#[allow(missing_docs)]
423#[derive(Clone)]
424#[cfg_attr(test, derive(Debug, PartialEq, Eq))]
425pub struct Fido2Credential {
426 pub credential_id: String,
427 pub key_type: String,
428 pub key_algorithm: String,
429 pub key_curve: String,
430 pub key_value: String,
431 pub rp_id: String,
432 pub user_handle: Option<String>,
433 pub user_name: Option<String>,
434 pub counter: u32,
435 pub rp_name: Option<String>,
436 pub user_display_name: Option<String>,
437 pub discoverable: String,
438 pub creation_date: DateTime<Utc>,
439}
440
441impl From<Fido2Credential> for Fido2CredentialFullView {
442 fn from(value: Fido2Credential) -> Self {
443 Fido2CredentialFullView {
444 credential_id: value.credential_id,
445 key_type: value.key_type,
446 key_algorithm: value.key_algorithm,
447 key_curve: value.key_curve,
448 key_value: value.key_value,
449 rp_id: value.rp_id,
450 user_handle: value.user_handle,
451 user_name: value.user_name,
452 counter: value.counter.to_string(),
453 rp_name: value.rp_name,
454 user_display_name: value.user_display_name,
455 discoverable: value.discoverable,
456 creation_date: value.creation_date,
457 }
458 }
459}
460
461#[allow(missing_docs)]
462#[derive(Clone)]
463#[cfg_attr(test, derive(Debug, PartialEq, Eq))]
464pub struct Card {
465 pub cardholder_name: Option<String>,
466 pub exp_month: Option<String>,
467 pub exp_year: Option<String>,
468 pub code: Option<String>,
469 pub brand: Option<String>,
470 pub number: Option<String>,
471}
472
473#[allow(missing_docs)]
474#[derive(Clone)]
475#[cfg_attr(test, derive(Debug, PartialEq, Eq))]
476pub struct SecureNote {
477 pub r#type: SecureNoteType,
478}
479
480#[allow(missing_docs)]
481#[derive(Clone)]
482#[cfg_attr(test, derive(Debug, PartialEq, Eq))]
483pub enum SecureNoteType {
484 Generic = 0,
485}
486
487#[allow(missing_docs)]
488#[derive(Clone, Default, Debug, PartialEq, Eq)]
489pub struct Identity {
490 pub title: Option<String>,
491 pub first_name: Option<String>,
492 pub middle_name: Option<String>,
493 pub last_name: Option<String>,
494 pub address1: Option<String>,
495 pub address2: Option<String>,
496 pub address3: Option<String>,
497 pub city: Option<String>,
498 pub state: Option<String>,
499 pub postal_code: Option<String>,
500 pub country: Option<String>,
501 pub company: Option<String>,
502 pub email: Option<String>,
503 pub phone: Option<String>,
504 pub ssn: Option<String>,
505 pub username: Option<String>,
506 pub passport_number: Option<String>,
507 pub license_number: Option<String>,
508}
509
510#[allow(missing_docs)]
511#[derive(Clone)]
512#[cfg_attr(test, derive(Debug, PartialEq, Eq))]
513pub struct SshKey {
514 pub private_key: String,
516 pub public_key: String,
518 pub fingerprint: String,
520}
521
522#[cfg(test)]
523mod tests {
524 use bitwarden_vault::{CipherType as VaultCipherType, FieldType};
525 use chrono::{DateTime, Utc};
526
527 use super::*;
528
529 #[test]
530 fn test_importing_cipher_to_cipher_view_login() {
531 let test_date: DateTime<Utc> = "2024-01-30T17:55:36.150Z".parse().unwrap();
532 let test_folder_id = uuid::Uuid::new_v4();
533
534 let importing_cipher = ImportingCipher {
535 folder_id: Some(test_folder_id),
536 name: "Test Login".to_string(),
537 notes: Some("Test notes".to_string()),
538 r#type: CipherType::Login(Box::new(Login {
539 username: Some("[email protected]".to_string()),
540 password: Some("password123".to_string()),
541 login_uris: vec![LoginUri {
542 uri: Some("https://example.com".to_string()),
543 r#match: Some(0), }],
545 totp: Some("otpauth://totp/test".to_string()),
546 fido2_credentials: None,
547 })),
548 favorite: true,
549 reprompt: 1,
550 fields: vec![Field {
551 name: Some("CustomField".to_string()),
552 value: Some("CustomValue".to_string()),
553 r#type: 0,
554 linked_id: None,
555 }],
556 revision_date: test_date,
557 creation_date: test_date,
558 deleted_date: None,
559 };
560
561 let cipher_view: CipherView = importing_cipher.into();
562
563 assert_eq!(cipher_view.id, None);
564 assert_eq!(cipher_view.organization_id, None);
565 assert_eq!(
566 cipher_view.folder_id.unwrap().to_string(),
567 test_folder_id.to_string()
568 );
569 assert_eq!(cipher_view.name, "Test Login");
570 assert_eq!(cipher_view.notes.unwrap(), "Test notes");
571 assert_eq!(cipher_view.r#type, VaultCipherType::Login);
572 assert!(cipher_view.favorite);
573 assert_eq!(cipher_view.creation_date, test_date);
574 assert_eq!(cipher_view.revision_date, test_date);
575
576 let fields = cipher_view.fields.unwrap();
577 assert_eq!(fields.len(), 1);
578
579 let field = fields.first().unwrap();
580 assert_eq!(field.name, Some("CustomField".to_string()));
581 assert_eq!(field.value, Some("CustomValue".to_string()));
582 assert_eq!(field.r#type, FieldType::Text);
583 assert_eq!(field.linked_id, None);
584
585 let login = cipher_view.login.expect("Login should be present");
586 assert_eq!(login.username, Some("[email protected]".to_string()));
587 assert_eq!(login.password, Some("password123".to_string()));
588 assert_eq!(login.totp, Some("otpauth://totp/test".to_string()));
589
590 let uris = login.uris.expect("URIs should be present");
591 assert_eq!(uris.len(), 1);
592 assert_eq!(uris[0].uri, Some("https://example.com".to_string()));
593 assert_eq!(uris[0].r#match, Some(bitwarden_vault::UriMatchType::Domain));
594 }
595
596 #[test]
597 fn test_importing_cipher_to_cipher_view_secure_note() {
598 let test_date: DateTime<Utc> = "2024-01-30T17:55:36.150Z".parse().unwrap();
599
600 let importing_cipher = ImportingCipher {
601 folder_id: None,
602 name: "My Note".to_string(),
603 notes: Some("This is a secure note".to_string()),
604 r#type: CipherType::SecureNote(Box::new(SecureNote {
605 r#type: SecureNoteType::Generic,
606 })),
607 favorite: false,
608 reprompt: 0,
609 fields: vec![],
610 revision_date: test_date,
611 creation_date: test_date,
612 deleted_date: None,
613 };
614
615 let cipher_view: CipherView = importing_cipher.into();
616
617 assert_eq!(cipher_view.id, None);
619 assert_eq!(cipher_view.organization_id, None);
620 assert_eq!(cipher_view.folder_id, None);
621 assert_eq!(cipher_view.name, "My Note");
622 assert_eq!(cipher_view.notes, Some("This is a secure note".to_string()));
623 assert_eq!(cipher_view.r#type, bitwarden_vault::CipherType::SecureNote);
624 assert!(!cipher_view.favorite);
625 assert_eq!(cipher_view.creation_date, test_date);
626 assert_eq!(cipher_view.revision_date, test_date);
627
628 assert!(cipher_view.login.is_none());
630 assert!(cipher_view.identity.is_none());
631 assert!(cipher_view.card.is_none());
632 assert!(cipher_view.secure_note.is_some());
633 assert!(cipher_view.ssh_key.is_none());
634
635 let secure_note = cipher_view.secure_note.unwrap();
637 assert!(matches!(
638 secure_note.r#type,
639 bitwarden_vault::SecureNoteType::Generic
640 ));
641 }
642
643 #[test]
644 fn test_importing_cipher_to_cipher_view_card() {
645 let test_date: DateTime<Utc> = "2024-01-30T17:55:36.150Z".parse().unwrap();
646
647 let importing_cipher = ImportingCipher {
648 folder_id: None,
649 name: "My Credit Card".to_string(),
650 notes: Some("Credit card notes".to_string()),
651 r#type: CipherType::Card(Box::new(Card {
652 cardholder_name: Some("John Doe".to_string()),
653 brand: Some("Visa".to_string()),
654 number: Some("1234567812345678".to_string()),
655 exp_month: Some("12".to_string()),
656 exp_year: Some("2025".to_string()),
657 code: Some("123".to_string()),
658 })),
659 favorite: false,
660 reprompt: 0,
661 fields: vec![],
662 revision_date: test_date,
663 creation_date: test_date,
664 deleted_date: None,
665 };
666
667 let cipher_view: CipherView = importing_cipher.into();
668
669 assert_eq!(cipher_view.r#type, bitwarden_vault::CipherType::Card);
670 assert!(cipher_view.card.is_some());
671 assert!(cipher_view.login.is_none());
672
673 let card = cipher_view.card.unwrap();
674 assert_eq!(card.cardholder_name, Some("John Doe".to_string()));
675 assert_eq!(card.brand, Some("Visa".to_string()));
676 assert_eq!(card.number, Some("1234567812345678".to_string()));
677 }
678
679 #[test]
680 fn test_importing_cipher_to_cipher_view_identity() {
681 let test_date: DateTime<Utc> = "2024-01-30T17:55:36.150Z".parse().unwrap();
682
683 let importing_cipher = ImportingCipher {
684 folder_id: None,
685 name: "My Identity".to_string(),
686 notes: None,
687 r#type: CipherType::Identity(Box::new(Identity {
688 title: Some("Dr.".to_string()),
689 first_name: Some("Jane".to_string()),
690 last_name: Some("Smith".to_string()),
691 email: Some("[email protected]".to_string()),
692 ..Default::default()
693 })),
694 favorite: false,
695 reprompt: 0,
696 fields: vec![],
697 revision_date: test_date,
698 creation_date: test_date,
699 deleted_date: None,
700 };
701
702 let cipher_view: CipherView = importing_cipher.into();
703
704 assert_eq!(cipher_view.r#type, bitwarden_vault::CipherType::Identity);
705 assert!(cipher_view.identity.is_some());
706 assert!(cipher_view.login.is_none());
707
708 let identity = cipher_view.identity.unwrap();
709 assert_eq!(identity.title, Some("Dr.".to_string()));
710 assert_eq!(identity.first_name, Some("Jane".to_string()));
711 assert_eq!(identity.last_name, Some("Smith".to_string()));
712 assert_eq!(identity.email, Some("[email protected]".to_string()));
713 }
714
715 #[test]
716 fn test_sanitize_uri_valid_url_unchanged() {
717 assert_eq!(
718 sanitize_uri("https://bitwarden.com"),
719 "https://bitwarden.com"
720 );
721 assert_eq!(
722 sanitize_uri("https://bitwarden.com/path?q=1"),
723 "https://bitwarden.com/path?q=1"
724 );
725 assert_eq!(
726 sanitize_uri("http://192.168.0.1:8080"),
727 "http://192.168.0.1:8080"
728 );
729 }
730
731 #[test]
732 fn test_sanitize_uri_ip_address_gets_http() {
733 assert_eq!(sanitize_uri("192.168.0.1:8080"), "http://192.168.0.1:8080");
734 assert_eq!(sanitize_uri("10.0.0.1"), "http://10.0.0.1");
735 assert_eq!(
736 sanitize_uri("192.168.0.1:8080/path"),
737 "http://192.168.0.1:8080/path"
738 );
739 }
740
741 #[test]
742 fn test_sanitize_uri_non_ip_gets_https() {
743 assert_eq!(sanitize_uri("bitwarden.com"), "https://bitwarden.com");
744 assert_eq!(
745 sanitize_uri("bitwarden.co.uk/login"),
746 "https://bitwarden.co.uk/login"
747 );
748 }
749
750 #[test]
751 fn test_sanitize_uri_broken_http_prefix_unchanged() {
752 assert_eq!(
754 sanitize_uri("ht tp://bitwarden.com"),
755 "https://ht tp://bitwarden.com"
756 );
757 }
758
759 #[test]
760 fn test_sanitize_uri_schemeless_host_port() {
761 assert_eq!(sanitize_uri("localhost:8080"), "https://localhost:8080");
762 }
763
764 #[test]
765 fn test_sanitize_uri_scheme_on_string() {
766 assert_eq!(sanitize_uri("foo:bar"), "https://foo:bar");
767 }
768
769 #[test]
770 fn test_sanitize_uri_with_real_schemes_no_change() {
771 assert_eq!(
772 sanitize_uri("ftp://files.example.com"),
773 "ftp://files.example.com"
774 );
775 assert_eq!(
776 sanitize_uri("androidapp://com.example"),
777 "androidapp://com.example"
778 );
779 }
780
781 #[test]
782 fn test_login_sanitize_uris() {
783 let mut login = Login {
784 username: None,
785 password: None,
786 login_uris: vec![
787 LoginUri {
788 uri: Some("https://bitwarden.com".to_string()),
789 r#match: None,
790 },
791 LoginUri {
792 uri: Some("192.168.0.1:8080".to_string()),
793 r#match: None,
794 },
795 LoginUri {
796 uri: Some("example.com".to_string()),
797 r#match: None,
798 },
799 LoginUri {
800 uri: None,
801 r#match: None,
802 },
803 ],
804 totp: None,
805 fido2_credentials: None,
806 };
807
808 login.sanitize_uris();
809
810 assert_eq!(
811 login.login_uris[0].uri,
812 Some("https://bitwarden.com".to_string())
813 );
814 assert_eq!(
815 login.login_uris[1].uri,
816 Some("http://192.168.0.1:8080".to_string())
817 );
818 assert_eq!(
819 login.login_uris[2].uri,
820 Some("https://example.com".to_string())
821 );
822 assert_eq!(login.login_uris[3].uri, None);
823 }
824}