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