Skip to main content

stack_auth/
service_token.rs

1use cts_common::claims::Audience;
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/// * `audience()` — the `aud` claim, typically the ZeroKMS endpoint.
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    audience: Audience,
40}
41
42impl ServiceToken {
43    /// Create a `ServiceToken` from a [`SecretToken`].
44    ///
45    /// If the token string is a valid JWT with `iss` and `aud` claims, they
46    /// are decoded eagerly. If decoding fails (not a JWT, missing claims, etc.)
47    /// the token is still usable as a bearer credential — `issuer()` and
48    /// `audience()` 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 `aud` (audience) from the JWT claims.
75    ///
76    /// # Errors
77    ///
78    /// Returns [`AuthError::InvalidToken`] if the token is not a valid JWT.
79    pub fn audience(&self) -> Result<&Audience, AuthError> {
80        self.decoded
81            .as_ref()
82            .map(|d| &d.audience)
83            .map_err(|reason| AuthError::InvalidToken(reason.clone()))
84    }
85
86    /// Attempt to decode the JWT claims from the token string.
87    ///
88    /// NOTE: This does not verify the token signature or validate any claims,
89    /// it only decodes the claims if the token is a well-formed JWT.
90    fn try_decode(secret: &SecretToken) -> Result<DecodedClaims, String> {
91        use jsonwebtoken::{decode, decode_header, DecodingKey, Validation};
92        use std::collections::HashSet;
93
94        let token_str = secret.as_str();
95        let header =
96            decode_header(token_str).map_err(|e| format!("failed to decode JWT header: {e}"))?;
97
98        let dummy_key = DecodingKey::from_secret(&[]);
99        let mut validation = Validation::new(header.alg);
100        validation.validate_exp = false;
101        validation.validate_aud = false;
102        validation.required_spec_claims = HashSet::new();
103        validation.insecure_disable_signature_validation();
104
105        let data: jsonwebtoken::TokenData<cts_common::claims::Claims> =
106            decode(token_str, &dummy_key, &validation)
107                .map_err(|e| format!("failed to decode JWT claims: {e}"))?;
108
109        let issuer: Url = data
110            .claims
111            .iss
112            .parse()
113            .map_err(|e| format!("iss claim is not a valid URL: {e}"))?;
114
115        Ok(DecodedClaims {
116            issuer,
117            audience: data.claims.aud,
118        })
119    }
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125
126    fn make_jwt(iss: &str, aud: &str) -> String {
127        use jsonwebtoken::{encode, EncodingKey, Header};
128        use std::time::{SystemTime, UNIX_EPOCH};
129
130        let now = SystemTime::now()
131            .duration_since(UNIX_EPOCH)
132            .unwrap()
133            .as_secs();
134
135        let claims = serde_json::json!({
136            "iss": iss,
137            "sub": "CS|test-user",
138            "aud": aud,
139            "iat": now,
140            "exp": now + 3600,
141            "workspace": "ZVATKW3VHMFG27DY",
142            "scope": "",
143        });
144
145        encode(
146            &Header::default(),
147            &claims,
148            &EncodingKey::from_secret(b"test-secret"),
149        )
150        .unwrap()
151    }
152
153    #[test]
154    fn jwt_token_provides_issuer_and_audience() {
155        let jwt = make_jwt("https://cts.example.com/", "https://zerokms.example.com/");
156        let token = ServiceToken::new(SecretToken::new(jwt.clone()));
157
158        assert_eq!(token.as_str(), jwt);
159        assert_eq!(token.issuer().unwrap().as_str(), "https://cts.example.com/");
160        assert!(token.audience().is_ok());
161    }
162
163    #[test]
164    fn non_jwt_token_returns_errors_with_reason() {
165        let token = ServiceToken::new(SecretToken::new("not-a-jwt"));
166
167        assert_eq!(token.as_str(), "not-a-jwt");
168
169        let err = token.issuer().unwrap_err().to_string();
170        assert!(
171            err.contains("failed to decode JWT header"),
172            "expected specific decode error, got: {err}"
173        );
174    }
175
176    #[test]
177    fn debug_does_not_leak_secret() {
178        let jwt = make_jwt("https://cts.example.com/", "https://zerokms.example.com/");
179        let token = ServiceToken::new(SecretToken::new(jwt.clone()));
180        let debug = format!("{:?}", token);
181        assert!(!debug.contains(&jwt));
182    }
183}