stack_auth/
service_token.rs1use cts_common::claims::{ServiceType, Services};
2use url::Url;
3use vitaminc::protected::OpaqueDebug;
4use zeroize::ZeroizeOnDrop;
5
6use crate::{AuthError, SecretToken};
7
8#[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 pub fn new(secret: SecretToken) -> Self {
50 let decoded = Self::try_decode(&secret);
51 Self { secret, decoded }
52 }
53
54 pub fn as_str(&self) -> &str {
56 self.secret.as_str()
57 }
58
59 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 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 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}