Skip to main content

bitwarden_importers/importers/onepassword/access/
sign_in.rs

1//! The sign-in address an account lives at: its subdomain and one of 1Password's domains.
2
3use std::fmt;
4
5use super::error::OnePasswordError;
6
7/// The longest a DNS label may be.
8const MAX_SUBDOMAIN_LENGTH: usize = 63;
9
10/// One of the domains 1Password serves accounts on, as offered in its sign-in form.
11///
12/// The first three are regions, each storing its accounts in a different jurisdiction; an account
13/// belongs to exactly one of them. Enterprise accounts sit on their own domain instead.
14///
15/// See <https://support.1password.com/regions/>.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
17#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
18#[bitwarden_ffi::wasm_record]
19pub enum SignInDomain {
20    /// `1password.com`, the default. Data hosted in the United States.
21    Global,
22    /// `1password.eu`. Data hosted in the European Union.
23    Europe,
24    /// `1password.ca`. Data hosted in Canada.
25    Canada,
26    /// `ent.1password.com`, for 1Password Enterprise.
27    Enterprise,
28}
29
30impl SignInDomain {
31    /// The domain on its own, without an account subdomain.
32    pub fn as_str(&self) -> &'static str {
33        match self {
34            SignInDomain::Global => "1password.com",
35            SignInDomain::Europe => "1password.eu",
36            SignInDomain::Canada => "1password.ca",
37            SignInDomain::Enterprise => "ent.1password.com",
38        }
39    }
40}
41
42/// Where an account signs in, such as `my.1password.com`.
43///
44/// An individual account uses `my`; a team or business account uses its own name. The domain is a
45/// closed set, so only the subdomain needs checking. The access client normalizes and validates the
46/// record before using it because foreign bindings construct records directly.
47#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
48#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
49#[bitwarden_ffi::wasm_record]
50pub struct SignInAddress {
51    /// The account-specific DNS label, such as `my`.
52    pub subdomain: String,
53    /// The 1Password domain on which the account is hosted.
54    pub domain: SignInDomain,
55}
56
57impl SignInAddress {
58    pub(crate) fn normalize(&mut self) -> Result<(), OnePasswordError> {
59        self.subdomain = self.subdomain.trim().to_lowercase();
60        self.validate()
61    }
62
63    fn validate(&self) -> Result<(), OnePasswordError> {
64        let invalid = |reason: &str| {
65            Err(OnePasswordError::InvalidSignInAddress(format!(
66                "subdomain '{}': {reason}",
67                self.subdomain
68            )))
69        };
70
71        if self.subdomain.is_empty() {
72            return invalid("it is empty");
73        }
74        if self.subdomain.len() > MAX_SUBDOMAIN_LENGTH {
75            return invalid(&format!(
76                "it is longer than {MAX_SUBDOMAIN_LENGTH} characters"
77            ));
78        }
79        if !self
80            .subdomain
81            .bytes()
82            .all(|b| b.is_ascii_alphanumeric() || b == b'-')
83        {
84            return invalid("only letters, digits and dashes are allowed");
85        }
86        if self.subdomain.starts_with('-') || self.subdomain.ends_with('-') {
87            return invalid("it starts or ends with a dash");
88        }
89
90        Ok(())
91    }
92}
93
94impl fmt::Display for SignInAddress {
95    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
96        write!(f, "{}.{}", self.subdomain, self.domain.as_str())
97    }
98}
99
100#[cfg(test)]
101mod tests {
102    use super::*;
103
104    fn address(subdomain: &str) -> Result<SignInAddress, OnePasswordError> {
105        let mut address = SignInAddress {
106            subdomain: subdomain.into(),
107            domain: SignInDomain::Global,
108        };
109        address.normalize()?;
110        Ok(address)
111    }
112
113    #[test]
114    fn address_joins_the_subdomain_and_the_domain() {
115        for (domain, expected) in [
116            (SignInDomain::Global, "my.1password.com"),
117            (SignInDomain::Europe, "my.1password.eu"),
118            (SignInDomain::Canada, "my.1password.ca"),
119            (SignInDomain::Enterprise, "my.ent.1password.com"),
120        ] {
121            let address = SignInAddress {
122                subdomain: "my".into(),
123                domain,
124            };
125            assert_eq!(address.to_string(), expected);
126            assert_eq!(address.domain, domain);
127        }
128    }
129
130    #[test]
131    fn address_cleans_up_the_subdomain() {
132        assert_eq!(
133            address("  ACME-Team \n")
134                .expect("a valid subdomain")
135                .to_string(),
136            "acme-team.1password.com"
137        );
138    }
139
140    /// Each of these would otherwise point the session at a host of the input's choosing.
141    #[test]
142    fn address_rejects_a_subdomain_that_is_not_a_label() {
143        for subdomain in [
144            "",
145            "   ",
146            "my.1password.com",
147            "evil.com/x",
148            "[email protected]",
149            "my?x=",
150            "my#x",
151            "my team",
152            "-my",
153            "my-",
154            &"m".repeat(MAX_SUBDOMAIN_LENGTH + 1),
155        ] {
156            let error = address(subdomain).expect_err("not a DNS label");
157            assert!(
158                matches!(&error, OnePasswordError::InvalidSignInAddress(_)),
159                "unexpected error for '{subdomain}': {error}"
160            );
161        }
162    }
163
164    #[test]
165    fn address_accepts_the_longest_label() {
166        address(&"m".repeat(MAX_SUBDOMAIN_LENGTH)).expect("63 characters is a valid label");
167    }
168
169    #[test]
170    fn address_constructed_as_a_record_is_still_validated() {
171        let mut address = SignInAddress {
172            subdomain: "evil.com/x".into(),
173            domain: SignInDomain::Global,
174        };
175
176        assert!(matches!(
177            address.normalize(),
178            Err(OnePasswordError::InvalidSignInAddress(_))
179        ));
180    }
181}