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        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    // Strip the `AuthError::InvalidToken` prefix — callers re-wrap this string
185    // in `AuthError::InvalidToken(reason)`, and we don't want "Invalid token:
186    // Invalid token: ..." in the final message.
187    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}