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