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(crate::error::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(crate::error::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                crate::error::WorkspaceMismatch {
115                    expected_workspace: expected,
116                    token_workspace,
117                },
118            ));
119        }
120        Ok(self)
121    }
122
123    /// Return the `iss` (issuer) URL from the JWT claims.
124    ///
125    /// In CipherStash tokens the issuer is the CTS host URL for the workspace.
126    ///
127    /// # Errors
128    ///
129    /// Returns [`AuthError::InvalidToken`] if the token is not a valid JWT or
130    /// the `iss` claim could not be parsed as a URL.
131    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    /// Return the decoded services map from the JWT claims.
139    ///
140    /// # Errors
141    ///
142    /// Returns [`AuthError::InvalidToken`] if the token is not a valid JWT or
143    /// the claims could not be decoded.
144    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    /// Return the ZeroKMS endpoint URL from the `services` claim.
152    ///
153    /// CTS-issued JWTs include a `services` claim containing a map of service
154    /// type to endpoint URL. This method looks up the `zerokms` entry.
155    ///
156    /// # Errors
157    ///
158    /// Returns [`AuthError::InvalidToken`] if the token is not a valid JWT or
159    /// the `services` claim does not include a ZeroKMS endpoint.
160    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    /// Attempt to decode the JWT claims from the token string.
172    ///
173    /// NOTE: This does not verify the token signature or validate any claims,
174    /// it only decodes the claims if the token is a well-formed JWT.
175    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    // Strip the `AuthError::InvalidToken` prefix — callers re-wrap this string
214    // in `AuthError::InvalidToken(reason)`, and we don't want "Invalid token:
215    // Invalid token: ..." in the final message.
216    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        // make_jwt mints a token for workspace ZVATKW3VHMFG27DY.
432        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        // A non-JWT can't be decoded, so verification can't run.
457        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}