Skip to main content

super_types/
credential.rs

1//! Environment routing carried by a Schematic API key. Decoding is not authentication.
2
3use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
4use uuid::Uuid;
5
6/// A canonical API origin. Public Super routes are appended by the client.
7#[derive(Clone, Debug, PartialEq, Eq)]
8pub struct ApiBase(String);
9
10impl ApiBase {
11    /// Validates an origin before it can receive credentials. HTTP is loopback-only.
12    ///
13    /// # Errors
14    /// Returns a fixed message without including the input or any credentials.
15    pub fn parse(value: &str) -> Result<Self, &'static str> {
16        if value.len() > 512 || value.bytes().any(|b| b.is_ascii_whitespace() || b.is_ascii_control()) {
17            return Err("the Super API URL is invalid");
18        }
19        let url = url::Url::parse(value).map_err(|_| "the Super API URL is invalid")?;
20        if !matches!(url.scheme(), "http" | "https") || url.host().is_none() {
21            return Err("the Super API URL must be an HTTP or HTTPS origin");
22        }
23        if !url.username().is_empty()
24            || url.password().is_some()
25            || url.path() != "/"
26            || url.query().is_some()
27            || url.fragment().is_some()
28            || value.contains('\\')
29        {
30            return Err("the Super API URL must be an origin without credentials, path, query, or fragment");
31        }
32        let loopback = match url.host() {
33            Some(url::Host::Domain("localhost")) => true,
34            Some(url::Host::Ipv4(address)) => address.is_loopback(),
35            Some(url::Host::Ipv6(address)) => address.is_loopback(),
36            _ => false,
37        };
38        if url.scheme() == "http" && !loopback {
39            return Err("the Super API URL must use HTTPS outside localhost development");
40        }
41        Ok(Self(url.origin().ascii_serialization()))
42    }
43
44    #[must_use]
45    pub fn as_str(&self) -> &str {
46        &self.0
47    }
48}
49
50/// Only non-secret metadata is returned. The original key remains the bearer credential.
51#[derive(Clone, Debug, PartialEq, Eq)]
52pub struct RoutedKey {
53    pub id: Uuid,
54    pub api_base: ApiBase,
55}
56
57impl RoutedKey {
58    /// Recognizes versioned keys; legacy credentials return `None` for migration compatibility.
59    ///
60    /// # Errors
61    /// Rejects malformed or unsupported versioned keys without echoing their contents.
62    pub fn parse(value: &str) -> Result<Option<Self>, &'static str> {
63        if !value.starts_with("super_v") && !value.starts_with("pup_v") {
64            return Ok(None);
65        }
66        let invalid = "the Schematic API key is malformed or uses an unsupported version";
67        if value.len() > 1024 {
68            return Err(invalid);
69        }
70        let mut fields = value.split('.');
71        if !matches!(fields.next(), Some("super_v2" | "pup_v2")) {
72            return Err(invalid);
73        }
74        let id = fields.next().ok_or(invalid)?;
75        let encoded = fields.next().ok_or(invalid)?;
76        let secret = fields.next().ok_or(invalid)?;
77        let lower_hex = |s: &str| s.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b));
78        if fields.next().is_some() || id.len() != 32 || !lower_hex(id) || secret.len() != 64 || !lower_hex(secret) {
79            return Err(invalid);
80        }
81        let origin = URL_SAFE_NO_PAD.decode(encoded).map_err(|_| invalid)?;
82        let origin = std::str::from_utf8(&origin).map_err(|_| invalid)?;
83        let api_base = ApiBase::parse(origin).map_err(|_| invalid)?;
84        if api_base.as_str() != origin {
85            return Err(invalid);
86        }
87        Ok(Some(Self {
88            id: Uuid::parse_str(id).map_err(|_| invalid)?,
89            api_base,
90        }))
91    }
92
93    /// Encodes a key using 256 independently generated random secret bits supplied by the issuer.
94    #[must_use]
95    pub fn encode(&self, secret: &[u8; 32]) -> String {
96        format!(
97            "super_v2.{}.{}.{}",
98            self.id.simple(),
99            URL_SAFE_NO_PAD.encode(self.api_base.as_str()),
100            hex::encode(secret)
101        )
102    }
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108
109    #[test]
110    fn keys_round_trip_for_separate_environments() {
111        for origin in [
112            "https://api.schematic.tech",
113            "https://api.staging.schematic.tech",
114            "http://127.0.0.1:8080",
115        ] {
116            let metadata = RoutedKey {
117                id: Uuid::new_v4(),
118                api_base: ApiBase::parse(origin).unwrap(),
119            };
120            let key = metadata.encode(&[0xab; 32]);
121            assert_eq!(RoutedKey::parse(&key).unwrap(), Some(metadata.clone()));
122            let legacy = key.replacen("super_v2.", "pup_v2.", 1);
123            assert_eq!(RoutedKey::parse(&legacy).unwrap(), Some(metadata));
124            assert!(RoutedKey::parse(&format!("{key}.extra")).is_err());
125            assert!(RoutedKey::parse(&key[..key.len() - 1]).is_err());
126            assert!(!format!("{:?}", RoutedKey::parse(&key).unwrap()).contains(&hex::encode([0xab; 32])));
127        }
128        assert!(RoutedKey::parse("pup_live_legacy").unwrap().is_none());
129        assert!(RoutedKey::parse("hydra_live_legacy").unwrap().is_none());
130        assert!(RoutedKey::parse("pup_v3.unknown").is_err());
131        assert!(RoutedKey::parse("super_v3.unknown").is_err());
132    }
133
134    #[test]
135    fn origins_reject_credential_leaks_and_are_canonical() {
136        for value in [
137            "http://remote.test",
138            "https://user:password@api.test",
139            "https://api.test/path",
140            "https://api.test?token=secret",
141            "https://api.test/#fragment",
142            " https://api.test",
143            "https://api.test\\path",
144            "file:///secret",
145        ] {
146            assert!(ApiBase::parse(value).is_err(), "{value}");
147        }
148        assert_eq!(
149            ApiBase::parse("https://API.TEST:443/").unwrap().as_str(),
150            "https://api.test"
151        );
152        assert!(ApiBase::parse("http://[::1]:8080").is_ok());
153    }
154}