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//! `{origin}_openapp_{secret}`, where `origin` is the deployment's public origin
5//! (`OPENAPP_API_BASE_URL` / `OPENAPP_BASE_URL` on the backend — scheme, host and
6//! optional port, never a path). The backend rejects a token whose origin differs from
7//! its own, so the origin is also where the SDK sends requests: the API root is
8//! `{origin}{API_PATH_PREFIX}`. See `notes/contracts/api-key-authentication.md`.
9
10use thiserror::Error;
11use url::Url;
12
13/// Separator between `origin` and `secret` in an API-key token.
14pub const API_KEY_SEPARATOR: &str = "_openapp_";
15
16/// Path prefix of the versioned API on every deployment origin (gateway rules match
17/// `/api/v1/...`; see `ory/oathkeeper/rules.json.tmpl`).
18pub const API_PATH_PREFIX: &str = "/api/v1";
19
20/// Errors raised when parsing an `OpenApp` API-key token.
21#[derive(Debug, Error, Clone, PartialEq, Eq)]
22pub enum TokenFormatError {
23    #[error("token is empty")]
24    Empty,
25
26    #[error("token does not contain the `{API_KEY_SEPARATOR}` separator")]
27    MissingSeparator,
28
29    #[error("token secret is empty")]
30    EmptySecret,
31
32    #[error("token origin `{0}` is not a valid absolute http(s) URL")]
33    InvalidOrigin(String),
34
35    #[error(
36        "token origin `{0}` must be a bare origin (scheme, host, optional port) with no path, \
37         query or fragment"
38    )]
39    OriginHasPath(String),
40}
41
42/// A parsed `OpenApp` API-key token.
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct ApiKey {
45    raw: String,
46    origin: Url,
47    secret: String,
48}
49
50impl ApiKey {
51    /// Parse a token string of the form `{origin}_openapp_{secret}`.
52    ///
53    /// # Errors
54    /// Returns a [`TokenFormatError`] if the token is empty, lacks the separator, has an
55    /// empty secret, or if `origin` is not a bare absolute `http(s)` origin.
56    pub fn parse(token: impl Into<String>) -> Result<Self, TokenFormatError> {
57        let raw = token.into();
58        let trimmed = raw.trim();
59        if trimmed.is_empty() {
60            return Err(TokenFormatError::Empty);
61        }
62
63        let (origin_str, secret) = trimmed
64            .split_once(API_KEY_SEPARATOR)
65            .ok_or(TokenFormatError::MissingSeparator)?;
66
67        if secret.is_empty() {
68            return Err(TokenFormatError::EmptySecret);
69        }
70
71        let origin = Url::parse(origin_str)
72            .map_err(|_| TokenFormatError::InvalidOrigin(origin_str.to_string()))?;
73        if !matches!(origin.scheme(), "http" | "https") || origin.host().is_none() {
74            return Err(TokenFormatError::InvalidOrigin(origin_str.to_string()));
75        }
76        // `Url` normalizes an empty path to `/`; anything else means the token was not
77        // minted by the backend (which embeds a bare origin).
78        if origin.path() != "/" || origin.query().is_some() || origin.fragment().is_some() {
79            return Err(TokenFormatError::OriginHasPath(origin_str.to_string()));
80        }
81
82        Ok(Self {
83            raw: trimmed.to_string(),
84            origin,
85            secret: secret.to_string(),
86        })
87    }
88
89    /// The deployment origin the token was issued by.
90    #[must_use]
91    pub fn origin(&self) -> &Url {
92        &self.origin
93    }
94
95    /// The versioned API root requests are resolved against: `{origin}/api/v1`.
96    #[must_use]
97    pub fn api_base_url(&self) -> Url {
98        let mut url = self.origin.clone();
99        url.set_path(API_PATH_PREFIX);
100        url
101    }
102
103    /// The secret component of the token (never log this).
104    #[must_use]
105    pub fn secret(&self) -> &str {
106        &self.secret
107    }
108
109    /// The full token string, exactly as the backend issued it. This is the value
110    /// sent in the `X-API-Key` header.
111    #[must_use]
112    pub fn as_str(&self) -> &str {
113        &self.raw
114    }
115}
116
117impl std::fmt::Display for ApiKey {
118    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
119        // Never print the secret. Show only the origin and a redacted suffix.
120        let suffix = if self.secret.len() > 6 {
121            format!("…{}", &self.secret[self.secret.len() - 6..])
122        } else {
123            "…".to_string()
124        };
125        write!(f, "ApiKey({} {})", self.origin, suffix)
126    }
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132
133    #[test]
134    fn parses_valid_token() {
135        let tok = ApiKey::parse("https://openapp.house_openapp_SECRET").unwrap();
136        assert_eq!(tok.origin().as_str(), "https://openapp.house/");
137        assert_eq!(tok.secret(), "SECRET");
138        assert_eq!(tok.as_str(), "https://openapp.house_openapp_SECRET");
139    }
140
141    #[test]
142    fn api_base_url_appends_versioned_prefix() {
143        let tok = ApiKey::parse("https://openapp.house_openapp_SECRET").unwrap();
144        assert_eq!(tok.api_base_url().as_str(), "https://openapp.house/api/v1");
145    }
146
147    #[test]
148    fn parses_origin_with_port() {
149        let tok = ApiKey::parse("http://oathkeeper:4455_openapp_SECRET").unwrap();
150        assert_eq!(tok.api_base_url().as_str(), "http://oathkeeper:4455/api/v1");
151    }
152
153    #[test]
154    fn accepts_origin_with_trailing_slash() {
155        let tok = ApiKey::parse("https://openapp.house/_openapp_SECRET").unwrap();
156        assert_eq!(tok.api_base_url().as_str(), "https://openapp.house/api/v1");
157    }
158
159    #[test]
160    fn rejects_origin_with_path() {
161        assert_eq!(
162            ApiKey::parse("https://openapp.house/api/v1_openapp_SECRET").unwrap_err(),
163            TokenFormatError::OriginHasPath("https://openapp.house/api/v1".into())
164        );
165    }
166
167    #[test]
168    fn rejects_origin_with_query() {
169        assert!(matches!(
170            ApiKey::parse("https://openapp.house?x=1_openapp_SECRET").unwrap_err(),
171            TokenFormatError::OriginHasPath(_)
172        ));
173    }
174
175    #[test]
176    fn rejects_non_http_scheme() {
177        assert!(matches!(
178            ApiKey::parse("ftp://openapp.house_openapp_SECRET").unwrap_err(),
179            TokenFormatError::InvalidOrigin(_)
180        ));
181    }
182
183    #[test]
184    fn rejects_empty() {
185        assert_eq!(ApiKey::parse("").unwrap_err(), TokenFormatError::Empty);
186        assert_eq!(ApiKey::parse("   ").unwrap_err(), TokenFormatError::Empty);
187    }
188
189    #[test]
190    fn rejects_missing_separator() {
191        assert_eq!(
192            ApiKey::parse("https://openapp.house").unwrap_err(),
193            TokenFormatError::MissingSeparator
194        );
195    }
196
197    #[test]
198    fn rejects_empty_secret() {
199        assert_eq!(
200            ApiKey::parse("https://openapp.house_openapp_").unwrap_err(),
201            TokenFormatError::EmptySecret
202        );
203    }
204
205    #[test]
206    fn rejects_invalid_origin() {
207        let err = ApiKey::parse("not a url_openapp_SECRET").unwrap_err();
208        assert!(matches!(err, TokenFormatError::InvalidOrigin(_)));
209    }
210
211    #[test]
212    fn display_hides_secret() {
213        let tok = ApiKey::parse("https://openapp.house_openapp_supersecret").unwrap();
214        let s = format!("{tok}");
215        assert!(!s.contains("supersecret"), "display leaked secret: {s}");
216    }
217}