Skip to main content

stack_auth/
service_token.rs

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/// A CipherStash service token returned by an [`AuthStrategy`](crate::AuthStrategy).
10///
11/// Wraps a bearer credential ([`SecretToken`]) together with eagerly decoded
12/// JWT claims that are used for service discovery. The JWT is decoded (but
13/// **not** signature-verified) using [`cts_common::claims::Claims`], so only
14/// CipherStash-issued service tokens (from CTS or the access-key exchange)
15/// will have their claims resolved.
16///
17/// # Decoded claims
18///
19/// * `subject()` — the `sub` claim (e.g. `"CS|auth0|user123"`).
20/// * `workspace_id()` — the workspace identifier from the token.
21/// * `issuer()` — the `iss` URL, i.e. the CTS host for this workspace.
22/// * `zerokms_url()` — the ZeroKMS endpoint from the `services` claim.
23///
24/// For non-JWT tokens (e.g. static test tokens) or JWTs that don't match
25/// the CipherStash claims schema, these methods return
26/// `Err(AuthError::InvalidToken)`.
27///
28/// # Security
29///
30/// Like [`SecretToken`], this is zeroized on drop and hidden from [`Debug`]
31/// output.
32#[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    /// Create a `ServiceToken` from a [`SecretToken`].
49    ///
50    /// If the token string is a valid JWT with `iss` and `services` claims,
51    /// they are decoded eagerly. If decoding fails (not a JWT, missing claims,
52    /// etc.) the token is still usable as a bearer credential — `issuer()` and
53    /// `zerokms_url()` will simply return an error.
54    pub fn new(secret: SecretToken) -> Self {
55        let decoded = Self::try_decode(&secret);
56        Self { secret, decoded }
57    }
58
59    /// Expose the inner token string for use as a bearer credential.
60    pub fn as_str(&self) -> &str {
61        self.secret.as_str()
62    }
63
64    /// Return the `sub` (subject) claim from the JWT.
65    ///
66    /// In CipherStash tokens the subject encodes the principal identity,
67    /// e.g. `"CS|auth0|user123"` for a user or `"CS|CSAKkeyId"` for an
68    /// access key.
69    ///
70    /// # Errors
71    ///
72    /// Returns [`AuthError::InvalidToken`] if the token is not a valid JWT or
73    /// the claims could not be decoded.
74    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    /// Return the workspace identifier from the JWT claims.
82    ///
83    /// # Errors
84    ///
85    /// Returns [`AuthError::InvalidToken`] if the token is not a valid JWT or
86    /// the claims could not be decoded.
87    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    /// Verify the token's `workspace` claim matches `expected`, returning the
95    /// token unchanged on a match.
96    ///
97    /// This is the shared post-auth check that every strategy bound to a
98    /// workspace CRN ([`AccessKeyStrategy`](crate::AccessKeyStrategy),
99    /// [`OidcFederationStrategy`](crate::OidcFederationStrategy)) runs on each
100    /// [`get_token`](crate::AuthStrategy::get_token), so a token CTS minted for
101    /// a different workspace (or loaded from a poisoned shared cache) is never
102    /// handed back.
103    ///
104    /// # Errors
105    ///
106    /// - [`AuthError::WorkspaceMismatch`] if the token's `workspace` claim is a
107    ///   different workspace than `expected`.
108    /// - [`AuthError::InvalidToken`] if the token is not a valid JWT or its
109    ///   `workspace` claim could not be decoded, so verification can't run.
110    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    /// Return the `iss` (issuer) URL from the JWT claims.
122    ///
123    /// In CipherStash tokens the issuer is the CTS host URL for the workspace.
124    ///
125    /// # Errors
126    ///
127    /// Returns [`AuthError::InvalidToken`] if the token is not a valid JWT or
128    /// the `iss` claim could not be parsed as a URL.
129    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    /// Return the decoded services map from the JWT claims.
137    ///
138    /// # Errors
139    ///
140    /// Returns [`AuthError::InvalidToken`] if the token is not a valid JWT or
141    /// the claims could not be decoded.
142    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    /// Return the ZeroKMS endpoint URL from the `services` claim.
150    ///
151    /// CTS-issued JWTs include a `services` claim containing a map of service
152    /// type to endpoint URL. This method looks up the `zerokms` entry.
153    ///
154    /// # Errors
155    ///
156    /// Returns [`AuthError::InvalidToken`] if the token is not a valid JWT or
157    /// the `services` claim does not include a ZeroKMS endpoint.
158    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    /// Attempt to decode the JWT claims from the token string.
170    ///
171    /// NOTE: This does not verify the token signature or validate any claims,
172    /// it only decodes the claims if the token is a well-formed JWT.
173    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    // Strip the `AuthError::InvalidToken` prefix — callers re-wrap this string
212    // in `AuthError::InvalidToken(reason)`, and we don't want "Invalid token:
213    // Invalid token: ..." in the final message.
214    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        // make_jwt mints a token for workspace ZVATKW3VHMFG27DY.
430        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        // A non-JWT can't be decoded, so verification can't run.
455        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}