Skip to main content

stack_auth/
service_token.rs

1use cts_common::claims::{ServiceType, Services};
2use url::Url;
3use vitaminc::protected::OpaqueDebug;
4use zeroize::ZeroizeOnDrop;
5
6use crate::{AuthError, SecretToken};
7
8/// A CipherStash service token returned by an [`AuthStrategy`](crate::AuthStrategy).
9///
10/// Wraps a bearer credential ([`SecretToken`]) together with eagerly decoded
11/// JWT claims that are used for service discovery. The JWT is decoded (but
12/// **not** signature-verified) using [`cts_common::claims::Claims`], so only
13/// CipherStash-issued service tokens (from CTS or the access-key exchange)
14/// will have their claims resolved.
15///
16/// # Decoded claims
17///
18/// * `issuer()` — the `iss` URL, i.e. the CTS host for this workspace.
19/// * `zerokms_url()` — the ZeroKMS endpoint from the `services` claim.
20///
21/// For non-JWT tokens (e.g. static test tokens) or JWTs that don't match
22/// the CipherStash claims schema, both methods return
23/// `Err(AuthError::InvalidToken)`.
24///
25/// # Security
26///
27/// Like [`SecretToken`], this is zeroized on drop and hidden from [`Debug`]
28/// output.
29#[derive(Clone, OpaqueDebug, ZeroizeOnDrop)]
30pub struct ServiceToken {
31    secret: SecretToken,
32    #[zeroize(skip)]
33    decoded: Result<DecodedClaims, String>,
34}
35
36#[derive(Clone, Debug)]
37struct DecodedClaims {
38    issuer: Url,
39    services: Services,
40}
41
42impl ServiceToken {
43    /// Create a `ServiceToken` from a [`SecretToken`].
44    ///
45    /// If the token string is a valid JWT with `iss` and `services` claims,
46    /// they are decoded eagerly. If decoding fails (not a JWT, missing claims,
47    /// etc.) the token is still usable as a bearer credential — `issuer()` and
48    /// `zerokms_url()` will simply return an error.
49    pub fn new(secret: SecretToken) -> Self {
50        let decoded = Self::try_decode(&secret);
51        Self { secret, decoded }
52    }
53
54    /// Expose the inner token string for use as a bearer credential.
55    pub fn as_str(&self) -> &str {
56        self.secret.as_str()
57    }
58
59    /// Return the `iss` (issuer) URL from the JWT claims.
60    ///
61    /// In CipherStash tokens the issuer is the CTS host URL for the workspace.
62    ///
63    /// # Errors
64    ///
65    /// Returns [`AuthError::InvalidToken`] if the token is not a valid JWT or
66    /// the `iss` claim could not be parsed as a URL.
67    pub fn issuer(&self) -> Result<&Url, AuthError> {
68        self.decoded
69            .as_ref()
70            .map(|d| &d.issuer)
71            .map_err(|reason| AuthError::InvalidToken(reason.clone()))
72    }
73
74    /// Return the ZeroKMS endpoint URL from the `services` claim.
75    ///
76    /// CTS-issued JWTs include a `services` claim containing a map of service
77    /// type to endpoint URL. This method looks up the `zerokms` entry.
78    ///
79    /// # Errors
80    ///
81    /// Returns [`AuthError::InvalidToken`] if the token is not a valid JWT or
82    /// the `services` claim does not include a ZeroKMS endpoint.
83    pub fn zerokms_url(&self) -> Result<Url, AuthError> {
84        let decoded = self
85            .decoded
86            .as_ref()
87            .map_err(|reason| AuthError::InvalidToken(reason.clone()))?;
88
89        decoded
90            .services
91            .get(ServiceType::ZeroKms)
92            .cloned()
93            .ok_or_else(|| {
94                AuthError::InvalidToken(
95                    "Token does not include a ZeroKMS endpoint in the services claim".into(),
96                )
97            })
98    }
99
100    /// Attempt to decode the JWT claims from the token string.
101    ///
102    /// NOTE: This does not verify the token signature or validate any claims,
103    /// it only decodes the claims if the token is a well-formed JWT.
104    fn try_decode(secret: &SecretToken) -> Result<DecodedClaims, String> {
105        use jsonwebtoken::{decode, decode_header, DecodingKey, Validation};
106        use std::collections::HashSet;
107
108        let token_str = secret.as_str();
109        let header =
110            decode_header(token_str).map_err(|e| format!("failed to decode JWT header: {e}"))?;
111
112        let dummy_key = DecodingKey::from_secret(&[]);
113        let mut validation = Validation::new(header.alg);
114        validation.validate_exp = false;
115        validation.validate_aud = false;
116        validation.required_spec_claims = HashSet::new();
117        validation.insecure_disable_signature_validation();
118
119        let data: jsonwebtoken::TokenData<cts_common::claims::Claims> =
120            decode(token_str, &dummy_key, &validation)
121                .map_err(|e| format!("failed to decode JWT claims: {e}"))?;
122
123        let issuer: Url = data
124            .claims
125            .iss
126            .parse()
127            .map_err(|e| format!("iss claim is not a valid URL: {e}"))?;
128
129        Ok(DecodedClaims {
130            issuer,
131            services: data.claims.services,
132        })
133    }
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139    use std::collections::BTreeMap;
140
141    fn make_jwt(iss: &str, services: Option<BTreeMap<&str, &str>>) -> String {
142        use jsonwebtoken::{encode, EncodingKey, Header};
143        use std::time::{SystemTime, UNIX_EPOCH};
144
145        let now = SystemTime::now()
146            .duration_since(UNIX_EPOCH)
147            .unwrap()
148            .as_secs();
149
150        let mut claims = serde_json::json!({
151            "iss": iss,
152            "sub": "CS|test-user",
153            "aud": "legacy-aud-value",
154            "iat": now,
155            "exp": now + 3600,
156            "workspace": "ZVATKW3VHMFG27DY",
157            "scope": "",
158        });
159
160        if let Some(svc) = services {
161            claims["services"] = serde_json::to_value(svc).unwrap();
162        }
163
164        encode(
165            &Header::default(),
166            &claims,
167            &EncodingKey::from_secret(b"test-secret"),
168        )
169        .unwrap()
170    }
171
172    fn services_with_zerokms(url: &str) -> Option<BTreeMap<&str, &str>> {
173        Some(BTreeMap::from([("zerokms", url)]))
174    }
175
176    #[test]
177    fn jwt_token_provides_issuer() {
178        let jwt = make_jwt(
179            "https://cts.example.com/",
180            services_with_zerokms("https://zerokms.example.com/"),
181        );
182        let token = ServiceToken::new(SecretToken::new(jwt.clone()));
183
184        assert_eq!(token.as_str(), jwt);
185        assert_eq!(token.issuer().unwrap().as_str(), "https://cts.example.com/");
186    }
187
188    #[test]
189    fn non_jwt_token_returns_errors_with_reason() {
190        let token = ServiceToken::new(SecretToken::new("not-a-jwt"));
191
192        assert_eq!(token.as_str(), "not-a-jwt");
193
194        let err = token.issuer().unwrap_err().to_string();
195        assert!(
196            err.contains("failed to decode JWT header"),
197            "expected specific decode error, got: {err}"
198        );
199    }
200
201    #[test]
202    fn zerokms_url_from_services_claim() {
203        let jwt = make_jwt(
204            "https://cts.example.com/",
205            services_with_zerokms("https://zerokms.example.com/"),
206        );
207        let token = ServiceToken::new(SecretToken::new(jwt));
208        assert_eq!(
209            token.zerokms_url().unwrap().as_str(),
210            "https://zerokms.example.com/"
211        );
212    }
213
214    #[test]
215    fn zerokms_url_from_services_claim_localhost() {
216        let jwt = make_jwt(
217            "https://cts.example.com/",
218            services_with_zerokms("http://localhost:3002/"),
219        );
220        let token = ServiceToken::new(SecretToken::new(jwt));
221        assert_eq!(
222            token.zerokms_url().unwrap().as_str(),
223            "http://localhost:3002/"
224        );
225    }
226
227    #[test]
228    fn zerokms_url_errors_when_services_claim_missing() {
229        let jwt = make_jwt("https://cts.example.com/", None);
230        let token = ServiceToken::new(SecretToken::new(jwt));
231        let err = token.zerokms_url().unwrap_err().to_string();
232        assert!(
233            err.contains("services claim"),
234            "expected services claim error, got: {err}"
235        );
236    }
237
238    #[test]
239    fn zerokms_url_errors_for_non_jwt() {
240        let token = ServiceToken::new(SecretToken::new("not-a-jwt"));
241        assert!(token.zerokms_url().is_err());
242    }
243
244    #[test]
245    fn debug_does_not_leak_secret() {
246        let jwt = make_jwt(
247            "https://cts.example.com/",
248            services_with_zerokms("https://zerokms.example.com/"),
249        );
250        let token = ServiceToken::new(SecretToken::new(jwt.clone()));
251        let debug = format!("{:?}", token);
252        assert!(!debug.contains(&jwt));
253    }
254}