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