Skip to main content

openapp_sdk_common/
token.rs

1//! `OpenApp` API-key token parsing.
2//!
3//! Mirrors `apps/backend/local_server/src/api_key_store.rs`: tokens have the shape
4//! `{base_url}_openapp_{secret}`. The base URL embedded in the token is the canonical
5//! one the backend was issued for, which lets SDK clients auto-derive the endpoint
6//! without extra configuration.
7
8use thiserror::Error;
9use url::Url;
10
11/// Separator between `base_url` and `secret` in an API-key token.
12pub const API_KEY_SEPARATOR: &str = "_openapp_";
13
14/// Errors raised when parsing an `OpenApp` API-key token.
15#[derive(Debug, Error, Clone, PartialEq, Eq)]
16pub enum TokenFormatError {
17    #[error("token is empty")]
18    Empty,
19
20    #[error("token does not contain the `{API_KEY_SEPARATOR}` separator")]
21    MissingSeparator,
22
23    #[error("token secret is empty")]
24    EmptySecret,
25
26    #[error("token base URL `{0}` is not a valid absolute URL")]
27    InvalidBaseUrl(String),
28}
29
30/// A parsed `OpenApp` API-key token.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct ApiKey {
33    raw: String,
34    base_url: Url,
35    secret: String,
36}
37
38impl ApiKey {
39    /// Parse a token string of the form `{base_url}_openapp_{secret}`.
40    ///
41    /// # Errors
42    /// Returns a [`TokenFormatError`] if the token is empty, lacks the separator, has an
43    /// empty secret, or if `base_url` is not a parseable absolute URL.
44    pub fn parse(token: impl Into<String>) -> Result<Self, TokenFormatError> {
45        let raw = token.into();
46        let trimmed = raw.trim();
47        if trimmed.is_empty() {
48            return Err(TokenFormatError::Empty);
49        }
50
51        let (base_url_str, secret) = trimmed
52            .split_once(API_KEY_SEPARATOR)
53            .ok_or(TokenFormatError::MissingSeparator)?;
54
55        if secret.is_empty() {
56            return Err(TokenFormatError::EmptySecret);
57        }
58
59        let base_url = Url::parse(base_url_str)
60            .map_err(|_| TokenFormatError::InvalidBaseUrl(base_url_str.to_string()))?;
61
62        Ok(Self {
63            raw: trimmed.to_string(),
64            base_url,
65            secret: secret.to_string(),
66        })
67    }
68
69    /// The canonical base URL the token was issued for.
70    #[must_use]
71    pub fn base_url(&self) -> &Url {
72        &self.base_url
73    }
74
75    /// The secret component of the token (never log this).
76    #[must_use]
77    pub fn secret(&self) -> &str {
78        &self.secret
79    }
80
81    /// The raw token string, suitable for use as an `Authorization: Bearer` value.
82    #[must_use]
83    pub fn as_bearer(&self) -> &str {
84        &self.raw
85    }
86}
87
88impl std::fmt::Display for ApiKey {
89    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
90        // Never print the secret. Show only the base URL and a redacted suffix.
91        let suffix = if self.secret.len() > 6 {
92            format!("…{}", &self.secret[self.secret.len() - 6..])
93        } else {
94            "…".to_string()
95        };
96        write!(f, "ApiKey({} {})", self.base_url, suffix)
97    }
98}
99
100#[cfg(test)]
101mod tests {
102    use super::*;
103
104    #[test]
105    fn parses_valid_token() {
106        let tok = ApiKey::parse("https://api.openapp.house/api/v1_openapp_SECRET").unwrap();
107        assert_eq!(tok.base_url().as_str(), "https://api.openapp.house/api/v1");
108        assert_eq!(tok.secret(), "SECRET");
109        assert_eq!(
110            tok.as_bearer(),
111            "https://api.openapp.house/api/v1_openapp_SECRET"
112        );
113    }
114
115    #[test]
116    fn rejects_empty() {
117        assert_eq!(ApiKey::parse("").unwrap_err(), TokenFormatError::Empty);
118        assert_eq!(ApiKey::parse("   ").unwrap_err(), TokenFormatError::Empty);
119    }
120
121    #[test]
122    fn rejects_missing_separator() {
123        assert_eq!(
124            ApiKey::parse("https://api.openapp.house/api/v1").unwrap_err(),
125            TokenFormatError::MissingSeparator
126        );
127    }
128
129    #[test]
130    fn rejects_empty_secret() {
131        assert_eq!(
132            ApiKey::parse("https://api.openapp.house/api/v1_openapp_").unwrap_err(),
133            TokenFormatError::EmptySecret
134        );
135    }
136
137    #[test]
138    fn rejects_invalid_base_url() {
139        let err = ApiKey::parse("not a url_openapp_SECRET").unwrap_err();
140        assert!(matches!(err, TokenFormatError::InvalidBaseUrl(_)));
141    }
142
143    #[test]
144    fn display_hides_secret() {
145        let tok = ApiKey::parse("https://api.openapp.house/api/v1_openapp_supersecret").unwrap();
146        let s = format!("{tok}");
147        assert!(!s.contains("supersecret"), "display leaked secret: {s}");
148    }
149}