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, the set its clients offer in the 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)]
17pub enum SignInDomain {
18    /// `1password.com`, the default. Data hosted in the United States.
19    Global,
20    /// `1password.eu`. Data hosted in the European Union.
21    Europe,
22    /// `1password.ca`. Data hosted in Canada.
23    Canada,
24    /// `ent.1password.com`, for 1Password Enterprise.
25    Enterprise,
26}
27
28impl SignInDomain {
29    /// The domain on its own, without an account subdomain.
30    pub fn as_str(&self) -> &'static str {
31        match self {
32            SignInDomain::Global => "1password.com",
33            SignInDomain::Europe => "1password.eu",
34            SignInDomain::Canada => "1password.ca",
35            SignInDomain::Enterprise => "ent.1password.com",
36        }
37    }
38}
39
40/// Where an account signs in, such as `my.1password.com`.
41///
42/// An individual account uses `my`; a team or business account uses its own name. The domain is a
43/// closed set, so only the subdomain needs checking, and [`SignInAddress::new`] is the only way to
44/// build one.
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct SignInAddress {
47    subdomain: String,
48    domain: SignInDomain,
49}
50
51impl SignInAddress {
52    /// Builds an address from an account subdomain, rejecting anything that is not a DNS label.
53    ///
54    /// The subdomain is trimmed and lowercased first, so what a user typed into a sign-in form
55    /// goes through unchanged.
56    pub fn new(subdomain: &str, domain: SignInDomain) -> Result<SignInAddress, OnePasswordError> {
57        let subdomain = subdomain.trim().to_lowercase();
58        validate_subdomain(&subdomain)?;
59
60        Ok(SignInAddress { subdomain, domain })
61    }
62
63    /// The domain half of the address.
64    pub fn domain(&self) -> SignInDomain {
65        self.domain
66    }
67}
68
69impl fmt::Display for SignInAddress {
70    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
71        write!(f, "{}.{}", self.subdomain, self.domain.as_str())
72    }
73}
74
75/// Rejects a subdomain that is not a DNS label, which is what keeps a stray `/`, `@` or `?` from
76/// pointing the whole session at another host.
77fn validate_subdomain(subdomain: &str) -> Result<(), OnePasswordError> {
78    let invalid = |reason: &str| {
79        Err(OnePasswordError::Internal(format!(
80            "invalid subdomain '{subdomain}': {reason}"
81        )))
82    };
83
84    if subdomain.is_empty() {
85        return invalid("it is empty");
86    }
87    if subdomain.len() > MAX_SUBDOMAIN_LENGTH {
88        return invalid(&format!(
89            "it is longer than {MAX_SUBDOMAIN_LENGTH} characters"
90        ));
91    }
92    if !subdomain
93        .bytes()
94        .all(|b| b.is_ascii_alphanumeric() || b == b'-')
95    {
96        return invalid("only letters, digits and dashes are allowed");
97    }
98    if subdomain.starts_with('-') || subdomain.ends_with('-') {
99        return invalid("it starts or ends with a dash");
100    }
101
102    Ok(())
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108
109    fn address(subdomain: &str) -> Result<SignInAddress, OnePasswordError> {
110        SignInAddress::new(subdomain, SignInDomain::Global)
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::new("my", domain).expect("a valid subdomain");
122            assert_eq!(address.to_string(), expected);
123            assert_eq!(address.domain(), domain);
124        }
125    }
126
127    #[test]
128    fn address_cleans_up_the_subdomain() {
129        assert_eq!(
130            address("  ACME-Team \n")
131                .expect("a valid subdomain")
132                .to_string(),
133            "acme-team.1password.com"
134        );
135    }
136
137    /// Each of these would otherwise point the session at a host of the input's choosing.
138    #[test]
139    fn address_rejects_a_subdomain_that_is_not_a_label() {
140        for subdomain in [
141            "",
142            "   ",
143            "my.1password.com",
144            "evil.com/x",
145            "[email protected]",
146            "my?x=",
147            "my#x",
148            "my team",
149            "-my",
150            "my-",
151            &"m".repeat(MAX_SUBDOMAIN_LENGTH + 1),
152        ] {
153            let error = address(subdomain).expect_err("not a DNS label");
154            assert!(
155                error.to_string().contains("invalid subdomain"),
156                "unexpected error for '{subdomain}': {error}"
157            );
158        }
159    }
160
161    #[test]
162    fn address_accepts_the_longest_label() {
163        address(&"m".repeat(MAX_SUBDOMAIN_LENGTH)).expect("63 characters is a valid label");
164    }
165}