1use 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(crate::error::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(crate::error::InvalidToken(reason.clone())))
92 }
93
94 pub(crate) fn verify_workspace(self, expected: WorkspaceId) -> Result<Self, AuthError> {
111 let token_workspace = *self.workspace_id()?;
112 if token_workspace != expected {
113 return Err(AuthError::WorkspaceMismatch(
114 crate::error::WorkspaceMismatch {
115 expected_workspace: expected,
116 token_workspace,
117 },
118 ));
119 }
120 Ok(self)
121 }
122
123 pub fn issuer(&self) -> Result<&Url, AuthError> {
132 self.decoded
133 .as_ref()
134 .map(|d| &d.issuer)
135 .map_err(|reason| AuthError::InvalidToken(crate::error::InvalidToken(reason.clone())))
136 }
137
138 pub fn services(&self) -> Result<&Services, AuthError> {
145 self.decoded
146 .as_ref()
147 .map(|d| &d.services)
148 .map_err(|reason| AuthError::InvalidToken(crate::error::InvalidToken(reason.clone())))
149 }
150
151 pub fn zerokms_url(&self) -> Result<Url, AuthError> {
161 self.services()?
162 .get(ServiceType::ZeroKms)
163 .cloned()
164 .ok_or_else(|| {
165 AuthError::InvalidToken(crate::error::InvalidToken(
166 "Token does not include a ZeroKMS endpoint in the services claim".into(),
167 ))
168 })
169 }
170
171 fn try_decode(secret: &SecretToken) -> Result<DecodedClaims, String> {
176 let claims = decode_claims(secret.as_str())?;
177 let issuer: Url = claims
178 .iss
179 .parse()
180 .map_err(|e| format!("iss claim is not a valid URL: {e}"))?;
181
182 Ok(DecodedClaims {
183 subject: claims.sub,
184 workspace: claims.workspace,
185 issuer,
186 services: claims.services,
187 })
188 }
189}
190
191fn decode_claims(token_str: &str) -> Result<cts_common::claims::Claims, String> {
195 crate::decode_jwt_payload(token_str).map_err(|e| match e {
199 crate::AuthError::InvalidToken(crate::error::InvalidToken(reason)) => reason,
200 other => other.to_string(),
201 })
202}
203
204#[cfg(test)]
205mod tests {
206 use super::*;
207 use std::collections::BTreeMap;
208
209 fn make_jwt(iss: &str, services: Option<BTreeMap<&str, &str>>) -> String {
210 use jsonwebtoken::{encode, EncodingKey, Header};
211 use std::time::{SystemTime, UNIX_EPOCH};
212
213 let now = SystemTime::now()
214 .duration_since(UNIX_EPOCH)
215 .unwrap()
216 .as_secs();
217
218 let mut claims = serde_json::json!({
219 "iss": iss,
220 "sub": "CS|test-user",
221 "aud": "legacy-aud-value",
222 "iat": now,
223 "exp": now + 3600,
224 "workspace": "ZVATKW3VHMFG27DY",
225 "scope": "",
226 });
227
228 if let Some(svc) = services {
229 claims["services"] = serde_json::to_value(svc).unwrap();
230 }
231
232 encode(
233 &Header::default(),
234 &claims,
235 &EncodingKey::from_secret(b"test-secret"),
236 )
237 .unwrap()
238 }
239
240 fn services_with_zerokms(url: &str) -> Option<BTreeMap<&str, &str>> {
241 Some(BTreeMap::from([("zerokms", url)]))
242 }
243
244 #[test]
245 fn jwt_token_provides_issuer() {
246 let jwt = make_jwt(
247 "https://cts.example.com/",
248 services_with_zerokms("https://zerokms.example.com/"),
249 );
250 let token = ServiceToken::new(SecretToken::new(jwt.clone()));
251
252 assert_eq!(token.as_str(), jwt);
253 assert_eq!(token.issuer().unwrap().as_str(), "https://cts.example.com/");
254 }
255
256 #[test]
257 fn non_jwt_token_returns_errors_with_reason() {
258 let token = ServiceToken::new(SecretToken::new("not-a-jwt"));
259
260 assert_eq!(token.as_str(), "not-a-jwt");
261
262 let err = token.issuer().unwrap_err().to_string();
263 assert!(
264 err.contains("three segments"),
265 "expected specific decode error, got: {err}"
266 );
267 }
268
269 #[test]
270 fn zerokms_url_from_services_claim() {
271 let jwt = make_jwt(
272 "https://cts.example.com/",
273 services_with_zerokms("https://zerokms.example.com/"),
274 );
275 let token = ServiceToken::new(SecretToken::new(jwt));
276 assert_eq!(
277 token.zerokms_url().unwrap().as_str(),
278 "https://zerokms.example.com/"
279 );
280 }
281
282 #[test]
283 fn zerokms_url_from_services_claim_localhost() {
284 let jwt = make_jwt(
285 "https://cts.example.com/",
286 services_with_zerokms("http://localhost:3002/"),
287 );
288 let token = ServiceToken::new(SecretToken::new(jwt));
289 assert_eq!(
290 token.zerokms_url().unwrap().as_str(),
291 "http://localhost:3002/"
292 );
293 }
294
295 #[test]
296 fn zerokms_url_errors_when_services_claim_missing() {
297 let jwt = make_jwt("https://cts.example.com/", None);
298 let token = ServiceToken::new(SecretToken::new(jwt));
299 let err = token.zerokms_url().unwrap_err().to_string();
300 assert!(
301 err.contains("services claim"),
302 "expected services claim error, got: {err}"
303 );
304 }
305
306 #[test]
307 fn zerokms_url_errors_for_non_jwt() {
308 let token = ServiceToken::new(SecretToken::new("not-a-jwt"));
309 assert!(token.zerokms_url().is_err());
310 }
311
312 #[test]
313 fn services_returns_map_for_valid_jwt() {
314 let jwt = make_jwt(
315 "https://cts.example.com/",
316 services_with_zerokms("https://zerokms.example.com/"),
317 );
318 let token = ServiceToken::new(SecretToken::new(jwt));
319 let services = token.services().unwrap();
320 assert_eq!(
321 services
322 .get(cts_common::claims::ServiceType::ZeroKms)
323 .map(|u| u.as_str()),
324 Some("https://zerokms.example.com/")
325 );
326 }
327
328 #[test]
329 fn services_returns_empty_map_when_claim_missing() {
330 let jwt = make_jwt("https://cts.example.com/", None);
331 let token = ServiceToken::new(SecretToken::new(jwt));
332 let services = token.services().unwrap();
333 assert!(services.is_empty());
334 }
335
336 #[test]
337 fn services_errors_for_non_jwt() {
338 let token = ServiceToken::new(SecretToken::new("not-a-jwt"));
339 let err = token.services().unwrap_err().to_string();
340 assert!(
341 err.contains("three segments"),
342 "expected specific decode error, got: {err}"
343 );
344 }
345
346 #[test]
347 fn subject_from_valid_jwt() {
348 let jwt = make_jwt(
349 "https://cts.example.com/",
350 services_with_zerokms("https://zerokms.example.com/"),
351 );
352 let token = ServiceToken::new(SecretToken::new(jwt));
353 assert_eq!(
354 token.subject().unwrap(),
355 "CS|test-user",
356 "subject should match JWT sub claim"
357 );
358 }
359
360 #[test]
361 fn subject_errors_for_non_jwt() {
362 let token = ServiceToken::new(SecretToken::new("not-a-jwt"));
363 assert!(
364 token.subject().is_err(),
365 "subject should error for non-JWT token"
366 );
367 }
368
369 #[test]
370 fn workspace_id_from_valid_jwt() {
371 let jwt = make_jwt(
372 "https://cts.example.com/",
373 services_with_zerokms("https://zerokms.example.com/"),
374 );
375 let token = ServiceToken::new(SecretToken::new(jwt));
376 assert_eq!(
377 token.workspace_id().unwrap().to_string(),
378 "ZVATKW3VHMFG27DY",
379 "workspace_id should match JWT workspace claim"
380 );
381 }
382
383 #[test]
384 fn workspace_id_errors_for_non_jwt() {
385 let token = ServiceToken::new(SecretToken::new("not-a-jwt"));
386 assert!(
387 token.workspace_id().is_err(),
388 "workspace_id should error for non-JWT token"
389 );
390 }
391
392 #[test]
393 fn verify_workspace_returns_token_when_workspace_matches() {
394 let jwt = make_jwt(
395 "https://cts.example.com/",
396 services_with_zerokms("https://zerokms.example.com/"),
397 );
398 let token = ServiceToken::new(SecretToken::new(jwt));
399 let expected: WorkspaceId = "ZVATKW3VHMFG27DY".parse().unwrap();
400
401 let verified = token
402 .verify_workspace(expected)
403 .expect("matching workspace should pass verification");
404 assert_eq!(
405 verified.workspace_id().unwrap().to_string(),
406 "ZVATKW3VHMFG27DY",
407 "verified token should still carry its workspace claim",
408 );
409 }
410
411 #[test]
412 fn verify_workspace_errors_with_mismatch_when_workspace_differs() {
413 let jwt = make_jwt(
415 "https://cts.example.com/",
416 services_with_zerokms("https://zerokms.example.com/"),
417 );
418 let token = ServiceToken::new(SecretToken::new(jwt));
419 let expected: WorkspaceId = "AAAAAAAAAAAAAAAA".parse().unwrap();
420
421 let err = token
422 .verify_workspace(expected)
423 .expect_err("a different expected workspace must be rejected");
424 match err {
425 AuthError::WorkspaceMismatch(crate::error::WorkspaceMismatch {
426 expected_workspace,
427 token_workspace,
428 }) => {
429 assert_eq!(expected_workspace.to_string(), "AAAAAAAAAAAAAAAA");
430 assert_eq!(token_workspace.to_string(), "ZVATKW3VHMFG27DY");
431 }
432 other => panic!("expected WorkspaceMismatch, got {other:?}"),
433 }
434 }
435
436 #[test]
437 fn verify_workspace_errors_with_invalid_token_for_non_jwt() {
438 let token = ServiceToken::new(SecretToken::new("not-a-jwt"));
440 let expected: WorkspaceId = "ZVATKW3VHMFG27DY".parse().unwrap();
441
442 let err = token
443 .verify_workspace(expected)
444 .expect_err("a non-JWT token must surface InvalidToken");
445 assert!(
446 matches!(err, AuthError::InvalidToken(_)),
447 "expected InvalidToken, got {err:?}",
448 );
449 }
450
451 #[test]
452 fn debug_does_not_leak_secret() {
453 let jwt = make_jwt(
454 "https://cts.example.com/",
455 services_with_zerokms("https://zerokms.example.com/"),
456 );
457 let token = ServiceToken::new(SecretToken::new(jwt.clone()));
458 let debug = format!("{:?}", token);
459 assert!(!debug.contains(&jwt));
460 }
461}