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