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