openapp_sdk_common/
token.rs1use thiserror::Error;
11use url::Url;
12
13pub const API_KEY_SEPARATOR: &str = "_openapp_";
15
16pub const API_PATH_PREFIX: &str = "/api/v1";
19
20#[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#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct ApiKey {
45 raw: String,
46 origin: Url,
47 secret: String,
48}
49
50impl ApiKey {
51 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 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 #[must_use]
91 pub fn origin(&self) -> &Url {
92 &self.origin
93 }
94
95 #[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 #[must_use]
105 pub fn secret(&self) -> &str {
106 &self.secret
107 }
108
109 #[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 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}