use cts_common::claims::{ServiceType, Services};
use cts_common::WorkspaceId;
use url::Url;
use vitaminc::protected::OpaqueDebug;
use zeroize::ZeroizeOnDrop;
use crate::{AuthError, SecretToken};
#[derive(Clone, OpaqueDebug, ZeroizeOnDrop)]
pub struct ServiceToken {
secret: SecretToken,
#[zeroize(skip)]
decoded: Result<DecodedClaims, String>,
}
#[derive(Clone, Debug)]
struct DecodedClaims {
subject: String,
workspace: WorkspaceId,
issuer: Url,
services: Services,
}
impl ServiceToken {
pub fn new(secret: SecretToken) -> Self {
let decoded = Self::try_decode(&secret);
Self { secret, decoded }
}
pub fn as_str(&self) -> &str {
self.secret.as_str()
}
pub fn subject(&self) -> Result<&str, AuthError> {
self.decoded
.as_ref()
.map(|d| d.subject.as_str())
.map_err(|reason| AuthError::InvalidToken(reason.clone()))
}
pub fn workspace_id(&self) -> Result<&WorkspaceId, AuthError> {
self.decoded
.as_ref()
.map(|d| &d.workspace)
.map_err(|reason| AuthError::InvalidToken(reason.clone()))
}
pub fn issuer(&self) -> Result<&Url, AuthError> {
self.decoded
.as_ref()
.map(|d| &d.issuer)
.map_err(|reason| AuthError::InvalidToken(reason.clone()))
}
pub fn services(&self) -> Result<&Services, AuthError> {
self.decoded
.as_ref()
.map(|d| &d.services)
.map_err(|reason| AuthError::InvalidToken(reason.clone()))
}
pub fn zerokms_url(&self) -> Result<Url, AuthError> {
self.services()?
.get(ServiceType::ZeroKms)
.cloned()
.ok_or_else(|| {
AuthError::InvalidToken(
"Token does not include a ZeroKMS endpoint in the services claim".into(),
)
})
}
fn try_decode(secret: &SecretToken) -> Result<DecodedClaims, String> {
use jsonwebtoken::{decode, decode_header, DecodingKey, Validation};
use std::collections::HashSet;
let token_str = secret.as_str();
let header =
decode_header(token_str).map_err(|e| format!("failed to decode JWT header: {e}"))?;
let dummy_key = DecodingKey::from_secret(&[]);
let mut validation = Validation::new(header.alg);
validation.validate_exp = false;
validation.validate_aud = false;
validation.required_spec_claims = HashSet::new();
validation.insecure_disable_signature_validation();
let data: jsonwebtoken::TokenData<cts_common::claims::Claims> =
decode(token_str, &dummy_key, &validation)
.map_err(|e| format!("failed to decode JWT claims: {e}"))?;
let issuer: Url = data
.claims
.iss
.parse()
.map_err(|e| format!("iss claim is not a valid URL: {e}"))?;
Ok(DecodedClaims {
subject: data.claims.sub,
workspace: data.claims.workspace,
issuer,
services: data.claims.services,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeMap;
fn make_jwt(iss: &str, services: Option<BTreeMap<&str, &str>>) -> String {
use jsonwebtoken::{encode, EncodingKey, Header};
use std::time::{SystemTime, UNIX_EPOCH};
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
let mut claims = serde_json::json!({
"iss": iss,
"sub": "CS|test-user",
"aud": "legacy-aud-value",
"iat": now,
"exp": now + 3600,
"workspace": "ZVATKW3VHMFG27DY",
"scope": "",
});
if let Some(svc) = services {
claims["services"] = serde_json::to_value(svc).unwrap();
}
encode(
&Header::default(),
&claims,
&EncodingKey::from_secret(b"test-secret"),
)
.unwrap()
}
fn services_with_zerokms(url: &str) -> Option<BTreeMap<&str, &str>> {
Some(BTreeMap::from([("zerokms", url)]))
}
#[test]
fn jwt_token_provides_issuer() {
let jwt = make_jwt(
"https://cts.example.com/",
services_with_zerokms("https://zerokms.example.com/"),
);
let token = ServiceToken::new(SecretToken::new(jwt.clone()));
assert_eq!(token.as_str(), jwt);
assert_eq!(token.issuer().unwrap().as_str(), "https://cts.example.com/");
}
#[test]
fn non_jwt_token_returns_errors_with_reason() {
let token = ServiceToken::new(SecretToken::new("not-a-jwt"));
assert_eq!(token.as_str(), "not-a-jwt");
let err = token.issuer().unwrap_err().to_string();
assert!(
err.contains("failed to decode JWT header"),
"expected specific decode error, got: {err}"
);
}
#[test]
fn zerokms_url_from_services_claim() {
let jwt = make_jwt(
"https://cts.example.com/",
services_with_zerokms("https://zerokms.example.com/"),
);
let token = ServiceToken::new(SecretToken::new(jwt));
assert_eq!(
token.zerokms_url().unwrap().as_str(),
"https://zerokms.example.com/"
);
}
#[test]
fn zerokms_url_from_services_claim_localhost() {
let jwt = make_jwt(
"https://cts.example.com/",
services_with_zerokms("http://localhost:3002/"),
);
let token = ServiceToken::new(SecretToken::new(jwt));
assert_eq!(
token.zerokms_url().unwrap().as_str(),
"http://localhost:3002/"
);
}
#[test]
fn zerokms_url_errors_when_services_claim_missing() {
let jwt = make_jwt("https://cts.example.com/", None);
let token = ServiceToken::new(SecretToken::new(jwt));
let err = token.zerokms_url().unwrap_err().to_string();
assert!(
err.contains("services claim"),
"expected services claim error, got: {err}"
);
}
#[test]
fn zerokms_url_errors_for_non_jwt() {
let token = ServiceToken::new(SecretToken::new("not-a-jwt"));
assert!(token.zerokms_url().is_err());
}
#[test]
fn services_returns_map_for_valid_jwt() {
let jwt = make_jwt(
"https://cts.example.com/",
services_with_zerokms("https://zerokms.example.com/"),
);
let token = ServiceToken::new(SecretToken::new(jwt));
let services = token.services().unwrap();
assert_eq!(
services
.get(cts_common::claims::ServiceType::ZeroKms)
.map(|u| u.as_str()),
Some("https://zerokms.example.com/")
);
}
#[test]
fn services_returns_empty_map_when_claim_missing() {
let jwt = make_jwt("https://cts.example.com/", None);
let token = ServiceToken::new(SecretToken::new(jwt));
let services = token.services().unwrap();
assert!(services.is_empty());
}
#[test]
fn services_errors_for_non_jwt() {
let token = ServiceToken::new(SecretToken::new("not-a-jwt"));
let err = token.services().unwrap_err().to_string();
assert!(
err.contains("failed to decode JWT header"),
"expected specific decode error, got: {err}"
);
}
#[test]
fn subject_from_valid_jwt() {
let jwt = make_jwt(
"https://cts.example.com/",
services_with_zerokms("https://zerokms.example.com/"),
);
let token = ServiceToken::new(SecretToken::new(jwt));
assert_eq!(
token.subject().unwrap(),
"CS|test-user",
"subject should match JWT sub claim"
);
}
#[test]
fn subject_errors_for_non_jwt() {
let token = ServiceToken::new(SecretToken::new("not-a-jwt"));
assert!(
token.subject().is_err(),
"subject should error for non-JWT token"
);
}
#[test]
fn workspace_id_from_valid_jwt() {
let jwt = make_jwt(
"https://cts.example.com/",
services_with_zerokms("https://zerokms.example.com/"),
);
let token = ServiceToken::new(SecretToken::new(jwt));
assert_eq!(
token.workspace_id().unwrap().to_string(),
"ZVATKW3VHMFG27DY",
"workspace_id should match JWT workspace claim"
);
}
#[test]
fn workspace_id_errors_for_non_jwt() {
let token = ServiceToken::new(SecretToken::new("not-a-jwt"));
assert!(
token.workspace_id().is_err(),
"workspace_id should error for non-JWT token"
);
}
#[test]
fn debug_does_not_leak_secret() {
let jwt = make_jwt(
"https://cts.example.com/",
services_with_zerokms("https://zerokms.example.com/"),
);
let token = ServiceToken::new(SecretToken::new(jwt.clone()));
let debug = format!("{:?}", token);
assert!(!debug.contains(&jwt));
}
}