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 let claims = decode_claims(secret.as_str())?;
148 let issuer: Url = claims
149 .iss
150 .parse()
151 .map_err(|e| format!("iss claim is not a valid URL: {e}"))?;
152
153 Ok(DecodedClaims {
154 subject: claims.sub,
155 workspace: claims.workspace,
156 issuer,
157 services: claims.services,
158 })
159 }
160}
161
162#[cfg(not(target_arch = "wasm32"))]
163fn decode_claims(token_str: &str) -> Result<cts_common::claims::Claims, String> {
164 use jsonwebtoken::{decode, decode_header, DecodingKey, Validation};
165 use std::collections::HashSet;
166
167 let header =
168 decode_header(token_str).map_err(|e| format!("failed to decode JWT header: {e}"))?;
169
170 let dummy_key = DecodingKey::from_secret(&[]);
171 let mut validation = Validation::new(header.alg);
172 validation.validate_exp = false;
173 validation.validate_aud = false;
174 validation.required_spec_claims = HashSet::new();
175 validation.insecure_disable_signature_validation();
176
177 decode(token_str, &dummy_key, &validation)
178 .map(|data| data.claims)
179 .map_err(|e| format!("failed to decode JWT claims: {e}"))
180}
181
182#[cfg(target_arch = "wasm32")]
183fn decode_claims(token_str: &str) -> Result<cts_common::claims::Claims, String> {
184 crate::decode_jwt_payload_wasm(token_str).map_err(|e| match e {
188 crate::AuthError::InvalidToken(reason) => reason,
189 other => other.to_string(),
190 })
191}
192
193#[cfg(test)]
194mod tests {
195 use super::*;
196 use std::collections::BTreeMap;
197
198 fn make_jwt(iss: &str, services: Option<BTreeMap<&str, &str>>) -> String {
199 use jsonwebtoken::{encode, EncodingKey, Header};
200 use std::time::{SystemTime, UNIX_EPOCH};
201
202 let now = SystemTime::now()
203 .duration_since(UNIX_EPOCH)
204 .unwrap()
205 .as_secs();
206
207 let mut claims = serde_json::json!({
208 "iss": iss,
209 "sub": "CS|test-user",
210 "aud": "legacy-aud-value",
211 "iat": now,
212 "exp": now + 3600,
213 "workspace": "ZVATKW3VHMFG27DY",
214 "scope": "",
215 });
216
217 if let Some(svc) = services {
218 claims["services"] = serde_json::to_value(svc).unwrap();
219 }
220
221 encode(
222 &Header::default(),
223 &claims,
224 &EncodingKey::from_secret(b"test-secret"),
225 )
226 .unwrap()
227 }
228
229 fn services_with_zerokms(url: &str) -> Option<BTreeMap<&str, &str>> {
230 Some(BTreeMap::from([("zerokms", url)]))
231 }
232
233 #[test]
234 fn jwt_token_provides_issuer() {
235 let jwt = make_jwt(
236 "https://cts.example.com/",
237 services_with_zerokms("https://zerokms.example.com/"),
238 );
239 let token = ServiceToken::new(SecretToken::new(jwt.clone()));
240
241 assert_eq!(token.as_str(), jwt);
242 assert_eq!(token.issuer().unwrap().as_str(), "https://cts.example.com/");
243 }
244
245 #[test]
246 fn non_jwt_token_returns_errors_with_reason() {
247 let token = ServiceToken::new(SecretToken::new("not-a-jwt"));
248
249 assert_eq!(token.as_str(), "not-a-jwt");
250
251 let err = token.issuer().unwrap_err().to_string();
252 assert!(
253 err.contains("failed to decode JWT header"),
254 "expected specific decode error, got: {err}"
255 );
256 }
257
258 #[test]
259 fn zerokms_url_from_services_claim() {
260 let jwt = make_jwt(
261 "https://cts.example.com/",
262 services_with_zerokms("https://zerokms.example.com/"),
263 );
264 let token = ServiceToken::new(SecretToken::new(jwt));
265 assert_eq!(
266 token.zerokms_url().unwrap().as_str(),
267 "https://zerokms.example.com/"
268 );
269 }
270
271 #[test]
272 fn zerokms_url_from_services_claim_localhost() {
273 let jwt = make_jwt(
274 "https://cts.example.com/",
275 services_with_zerokms("http://localhost:3002/"),
276 );
277 let token = ServiceToken::new(SecretToken::new(jwt));
278 assert_eq!(
279 token.zerokms_url().unwrap().as_str(),
280 "http://localhost:3002/"
281 );
282 }
283
284 #[test]
285 fn zerokms_url_errors_when_services_claim_missing() {
286 let jwt = make_jwt("https://cts.example.com/", None);
287 let token = ServiceToken::new(SecretToken::new(jwt));
288 let err = token.zerokms_url().unwrap_err().to_string();
289 assert!(
290 err.contains("services claim"),
291 "expected services claim error, got: {err}"
292 );
293 }
294
295 #[test]
296 fn zerokms_url_errors_for_non_jwt() {
297 let token = ServiceToken::new(SecretToken::new("not-a-jwt"));
298 assert!(token.zerokms_url().is_err());
299 }
300
301 #[test]
302 fn services_returns_map_for_valid_jwt() {
303 let jwt = make_jwt(
304 "https://cts.example.com/",
305 services_with_zerokms("https://zerokms.example.com/"),
306 );
307 let token = ServiceToken::new(SecretToken::new(jwt));
308 let services = token.services().unwrap();
309 assert_eq!(
310 services
311 .get(cts_common::claims::ServiceType::ZeroKms)
312 .map(|u| u.as_str()),
313 Some("https://zerokms.example.com/")
314 );
315 }
316
317 #[test]
318 fn services_returns_empty_map_when_claim_missing() {
319 let jwt = make_jwt("https://cts.example.com/", None);
320 let token = ServiceToken::new(SecretToken::new(jwt));
321 let services = token.services().unwrap();
322 assert!(services.is_empty());
323 }
324
325 #[test]
326 fn services_errors_for_non_jwt() {
327 let token = ServiceToken::new(SecretToken::new("not-a-jwt"));
328 let err = token.services().unwrap_err().to_string();
329 assert!(
330 err.contains("failed to decode JWT header"),
331 "expected specific decode error, got: {err}"
332 );
333 }
334
335 #[test]
336 fn subject_from_valid_jwt() {
337 let jwt = make_jwt(
338 "https://cts.example.com/",
339 services_with_zerokms("https://zerokms.example.com/"),
340 );
341 let token = ServiceToken::new(SecretToken::new(jwt));
342 assert_eq!(
343 token.subject().unwrap(),
344 "CS|test-user",
345 "subject should match JWT sub claim"
346 );
347 }
348
349 #[test]
350 fn subject_errors_for_non_jwt() {
351 let token = ServiceToken::new(SecretToken::new("not-a-jwt"));
352 assert!(
353 token.subject().is_err(),
354 "subject should error for non-JWT token"
355 );
356 }
357
358 #[test]
359 fn workspace_id_from_valid_jwt() {
360 let jwt = make_jwt(
361 "https://cts.example.com/",
362 services_with_zerokms("https://zerokms.example.com/"),
363 );
364 let token = ServiceToken::new(SecretToken::new(jwt));
365 assert_eq!(
366 token.workspace_id().unwrap().to_string(),
367 "ZVATKW3VHMFG27DY",
368 "workspace_id should match JWT workspace claim"
369 );
370 }
371
372 #[test]
373 fn workspace_id_errors_for_non_jwt() {
374 let token = ServiceToken::new(SecretToken::new("not-a-jwt"));
375 assert!(
376 token.workspace_id().is_err(),
377 "workspace_id should error for non-JWT token"
378 );
379 }
380
381 #[test]
382 fn debug_does_not_leak_secret() {
383 let jwt = make_jwt(
384 "https://cts.example.com/",
385 services_with_zerokms("https://zerokms.example.com/"),
386 );
387 let token = ServiceToken::new(SecretToken::new(jwt.clone()));
388 let debug = format!("{:?}", token);
389 assert!(!debug.contains(&jwt));
390 }
391}