stack_auth/
service_token.rs1use cts_common::claims::{ServiceType, Services};
2use cts_common::WorkspaceId;
3use url::Url;
4use vitaminc::protected::OpaqueDebug;
5use zeroize::ZeroizeOnDrop;
6
7use crate::{AuthError, SecretToken};
8
9#[derive(Clone, OpaqueDebug, ZeroizeOnDrop)]
33pub struct ServiceToken {
34 secret: SecretToken,
35 #[zeroize(skip)]
36 decoded: Result<DecodedClaims, String>,
37}
38
39#[derive(Clone, Debug)]
40struct DecodedClaims {
41 subject: String,
42 workspace: WorkspaceId,
43 issuer: Url,
44 services: Services,
45}
46
47impl ServiceToken {
48 pub fn new(secret: SecretToken) -> Self {
55 let decoded = Self::try_decode(&secret);
56 Self { secret, decoded }
57 }
58
59 pub fn as_str(&self) -> &str {
61 self.secret.as_str()
62 }
63
64 pub fn subject(&self) -> Result<&str, AuthError> {
75 self.decoded
76 .as_ref()
77 .map(|d| d.subject.as_str())
78 .map_err(|reason| AuthError::InvalidToken(reason.clone()))
79 }
80
81 pub fn workspace_id(&self) -> Result<&WorkspaceId, AuthError> {
88 self.decoded
89 .as_ref()
90 .map(|d| &d.workspace)
91 .map_err(|reason| AuthError::InvalidToken(reason.clone()))
92 }
93
94 pub fn issuer(&self) -> Result<&Url, AuthError> {
103 self.decoded
104 .as_ref()
105 .map(|d| &d.issuer)
106 .map_err(|reason| AuthError::InvalidToken(reason.clone()))
107 }
108
109 pub fn services(&self) -> Result<&Services, AuthError> {
116 self.decoded
117 .as_ref()
118 .map(|d| &d.services)
119 .map_err(|reason| AuthError::InvalidToken(reason.clone()))
120 }
121
122 pub fn zerokms_url(&self) -> Result<Url, AuthError> {
132 self.services()?
133 .get(ServiceType::ZeroKms)
134 .cloned()
135 .ok_or_else(|| {
136 AuthError::InvalidToken(
137 "Token does not include a ZeroKMS endpoint in the services claim".into(),
138 )
139 })
140 }
141
142 fn try_decode(secret: &SecretToken) -> Result<DecodedClaims, String> {
147 use jsonwebtoken::{decode, decode_header, DecodingKey, Validation};
148 use std::collections::HashSet;
149
150 let token_str = secret.as_str();
151 let header =
152 decode_header(token_str).map_err(|e| format!("failed to decode JWT header: {e}"))?;
153
154 let dummy_key = DecodingKey::from_secret(&[]);
155 let mut validation = Validation::new(header.alg);
156 validation.validate_exp = false;
157 validation.validate_aud = false;
158 validation.required_spec_claims = HashSet::new();
159 validation.insecure_disable_signature_validation();
160
161 let data: jsonwebtoken::TokenData<cts_common::claims::Claims> =
162 decode(token_str, &dummy_key, &validation)
163 .map_err(|e| format!("failed to decode JWT claims: {e}"))?;
164
165 let issuer: Url = data
166 .claims
167 .iss
168 .parse()
169 .map_err(|e| format!("iss claim is not a valid URL: {e}"))?;
170
171 Ok(DecodedClaims {
172 subject: data.claims.sub,
173 workspace: data.claims.workspace,
174 issuer,
175 services: data.claims.services,
176 })
177 }
178}
179
180#[cfg(test)]
181mod tests {
182 use super::*;
183 use std::collections::BTreeMap;
184
185 fn make_jwt(iss: &str, services: Option<BTreeMap<&str, &str>>) -> String {
186 use jsonwebtoken::{encode, EncodingKey, Header};
187 use std::time::{SystemTime, UNIX_EPOCH};
188
189 let now = SystemTime::now()
190 .duration_since(UNIX_EPOCH)
191 .unwrap()
192 .as_secs();
193
194 let mut claims = serde_json::json!({
195 "iss": iss,
196 "sub": "CS|test-user",
197 "aud": "legacy-aud-value",
198 "iat": now,
199 "exp": now + 3600,
200 "workspace": "ZVATKW3VHMFG27DY",
201 "scope": "",
202 });
203
204 if let Some(svc) = services {
205 claims["services"] = serde_json::to_value(svc).unwrap();
206 }
207
208 encode(
209 &Header::default(),
210 &claims,
211 &EncodingKey::from_secret(b"test-secret"),
212 )
213 .unwrap()
214 }
215
216 fn services_with_zerokms(url: &str) -> Option<BTreeMap<&str, &str>> {
217 Some(BTreeMap::from([("zerokms", url)]))
218 }
219
220 #[test]
221 fn jwt_token_provides_issuer() {
222 let jwt = make_jwt(
223 "https://cts.example.com/",
224 services_with_zerokms("https://zerokms.example.com/"),
225 );
226 let token = ServiceToken::new(SecretToken::new(jwt.clone()));
227
228 assert_eq!(token.as_str(), jwt);
229 assert_eq!(token.issuer().unwrap().as_str(), "https://cts.example.com/");
230 }
231
232 #[test]
233 fn non_jwt_token_returns_errors_with_reason() {
234 let token = ServiceToken::new(SecretToken::new("not-a-jwt"));
235
236 assert_eq!(token.as_str(), "not-a-jwt");
237
238 let err = token.issuer().unwrap_err().to_string();
239 assert!(
240 err.contains("failed to decode JWT header"),
241 "expected specific decode error, got: {err}"
242 );
243 }
244
245 #[test]
246 fn zerokms_url_from_services_claim() {
247 let jwt = make_jwt(
248 "https://cts.example.com/",
249 services_with_zerokms("https://zerokms.example.com/"),
250 );
251 let token = ServiceToken::new(SecretToken::new(jwt));
252 assert_eq!(
253 token.zerokms_url().unwrap().as_str(),
254 "https://zerokms.example.com/"
255 );
256 }
257
258 #[test]
259 fn zerokms_url_from_services_claim_localhost() {
260 let jwt = make_jwt(
261 "https://cts.example.com/",
262 services_with_zerokms("http://localhost:3002/"),
263 );
264 let token = ServiceToken::new(SecretToken::new(jwt));
265 assert_eq!(
266 token.zerokms_url().unwrap().as_str(),
267 "http://localhost:3002/"
268 );
269 }
270
271 #[test]
272 fn zerokms_url_errors_when_services_claim_missing() {
273 let jwt = make_jwt("https://cts.example.com/", None);
274 let token = ServiceToken::new(SecretToken::new(jwt));
275 let err = token.zerokms_url().unwrap_err().to_string();
276 assert!(
277 err.contains("services claim"),
278 "expected services claim error, got: {err}"
279 );
280 }
281
282 #[test]
283 fn zerokms_url_errors_for_non_jwt() {
284 let token = ServiceToken::new(SecretToken::new("not-a-jwt"));
285 assert!(token.zerokms_url().is_err());
286 }
287
288 #[test]
289 fn services_returns_map_for_valid_jwt() {
290 let jwt = make_jwt(
291 "https://cts.example.com/",
292 services_with_zerokms("https://zerokms.example.com/"),
293 );
294 let token = ServiceToken::new(SecretToken::new(jwt));
295 let services = token.services().unwrap();
296 assert_eq!(
297 services
298 .get(cts_common::claims::ServiceType::ZeroKms)
299 .map(|u| u.as_str()),
300 Some("https://zerokms.example.com/")
301 );
302 }
303
304 #[test]
305 fn services_returns_empty_map_when_claim_missing() {
306 let jwt = make_jwt("https://cts.example.com/", None);
307 let token = ServiceToken::new(SecretToken::new(jwt));
308 let services = token.services().unwrap();
309 assert!(services.is_empty());
310 }
311
312 #[test]
313 fn services_errors_for_non_jwt() {
314 let token = ServiceToken::new(SecretToken::new("not-a-jwt"));
315 let err = token.services().unwrap_err().to_string();
316 assert!(
317 err.contains("failed to decode JWT header"),
318 "expected specific decode error, got: {err}"
319 );
320 }
321
322 #[test]
323 fn subject_from_valid_jwt() {
324 let jwt = make_jwt(
325 "https://cts.example.com/",
326 services_with_zerokms("https://zerokms.example.com/"),
327 );
328 let token = ServiceToken::new(SecretToken::new(jwt));
329 assert_eq!(
330 token.subject().unwrap(),
331 "CS|test-user",
332 "subject should match JWT sub claim"
333 );
334 }
335
336 #[test]
337 fn subject_errors_for_non_jwt() {
338 let token = ServiceToken::new(SecretToken::new("not-a-jwt"));
339 assert!(
340 token.subject().is_err(),
341 "subject should error for non-JWT token"
342 );
343 }
344
345 #[test]
346 fn workspace_id_from_valid_jwt() {
347 let jwt = make_jwt(
348 "https://cts.example.com/",
349 services_with_zerokms("https://zerokms.example.com/"),
350 );
351 let token = ServiceToken::new(SecretToken::new(jwt));
352 assert_eq!(
353 token.workspace_id().unwrap().to_string(),
354 "ZVATKW3VHMFG27DY",
355 "workspace_id should match JWT workspace claim"
356 );
357 }
358
359 #[test]
360 fn workspace_id_errors_for_non_jwt() {
361 let token = ServiceToken::new(SecretToken::new("not-a-jwt"));
362 assert!(
363 token.workspace_id().is_err(),
364 "workspace_id should error for non-JWT token"
365 );
366 }
367
368 #[test]
369 fn debug_does_not_leak_secret() {
370 let jwt = make_jwt(
371 "https://cts.example.com/",
372 services_with_zerokms("https://zerokms.example.com/"),
373 );
374 let token = ServiceToken::new(SecretToken::new(jwt.clone()));
375 let debug = format!("{:?}", token);
376 assert!(!debug.contains(&jwt));
377 }
378}