stack_auth/
service_token.rs1use cts_common::claims::Audience;
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 audience: Audience,
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 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 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}