1use crate::error::{Error, Result};
5
6#[derive(Clone)]
9pub struct SigningKey {
10 name: String,
11 secret: String,
12}
13
14impl SigningKey {
15 pub fn new(api_key: impl AsRef<str>) -> Result<Self> {
17 let (name, secret) = crate::config::split_api_key(api_key.as_ref())?;
18 Ok(Self {
19 name: name.to_owned(),
20 secret: secret.to_owned(),
21 })
22 }
23
24 pub fn name(&self) -> &str {
26 &self.name
27 }
28}
29
30impl std::fmt::Debug for SigningKey {
31 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
32 f.debug_struct("SigningKey")
33 .field("name", &self.name)
34 .field("secret", &"<redacted>")
35 .finish()
36 }
37}
38
39use std::time::{Duration, SystemTime, UNIX_EPOCH};
40
41pub struct TokenParams {
44 capability: String,
45 client_id: Option<String>,
46 revocation_key: Option<String>,
47 ttl: Duration,
48}
49
50impl TokenParams {
51 pub fn new(capability: impl Into<String>) -> Self {
53 Self {
54 capability: capability.into(),
55 client_id: None,
56 revocation_key: None,
57 ttl: Duration::from_secs(3600),
58 }
59 }
60 pub fn client_id(mut self, id: impl Into<String>) -> Self {
62 self.client_id = Some(id.into());
63 self
64 }
65 pub fn revocation_key(mut self, key: impl Into<String>) -> Self {
67 self.revocation_key = Some(key.into());
68 self
69 }
70 pub fn ttl(mut self, ttl: Duration) -> Self {
72 self.ttl = ttl;
73 self
74 }
75}
76
77#[derive(serde::Serialize)]
78struct Claims<'a> {
79 iat: i64,
80 exp: i64,
81 #[serde(rename = "x-ably-capability")]
82 capability: &'a str,
83 #[serde(rename = "x-ably-clientId", skip_serializing_if = "Option::is_none")]
84 client_id: Option<&'a str>,
85 #[serde(
86 rename = "x-ably-revocation-key",
87 skip_serializing_if = "Option::is_none"
88 )]
89 revocation_key: Option<&'a str>,
90}
91
92pub fn mint_ably_jwt(key: &SigningKey, params: &TokenParams) -> Result<String> {
96 let now = SystemTime::now()
97 .duration_since(UNIX_EPOCH)
98 .map_err(|e| Error::InvalidRequest(format!("system clock before epoch: {e}")))?
99 .as_secs() as i64;
100 let claims = Claims {
101 iat: now,
102 exp: now + params.ttl.as_secs() as i64,
103 capability: ¶ms.capability,
104 client_id: params.client_id.as_deref(),
105 revocation_key: params.revocation_key.as_deref(),
106 };
107 let mut header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::HS256);
108 header.kid = Some(key.name.clone());
109 jsonwebtoken::encode(
110 &header,
111 &claims,
112 &jsonwebtoken::EncodingKey::from_secret(key.secret.as_bytes()),
113 )
114 .map_err(|e| Error::InvalidRequest(format!("JWT signing failed: {e}")))
115}
116
117#[cfg(test)]
118mod tests {
119 use super::*;
120 use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
121 use std::time::Duration;
122
123 #[test]
124 fn parses_and_redacts() {
125 let k = SigningKey::new("app.keyid:supersecret").unwrap();
126 assert_eq!(k.name(), "app.keyid");
127 let dbg = format!("{k:?}");
128 assert!(
129 !dbg.contains("supersecret"),
130 "secret must be redacted: {dbg}"
131 );
132 }
133
134 #[test]
135 fn rejects_malformed_key() {
136 assert!(SigningKey::new("no-colon-here").is_err());
137 }
138
139 fn decode_part(part: &str) -> serde_json::Value {
140 let bytes = URL_SAFE_NO_PAD.decode(part).expect("valid base64url");
141 serde_json::from_slice(&bytes).expect("valid json")
142 }
143
144 #[test]
145 fn mints_jwt_with_ably_header_and_claims() {
146 let key = SigningKey::new("app.keyid:secret").unwrap();
147 let params = TokenParams::new(r#"{"sports":["history","publish"]}"#)
148 .client_id("user-123")
149 .revocation_key("grp-7")
150 .ttl(Duration::from_secs(3600));
151 let jwt = mint_ably_jwt(&key, ¶ms).unwrap();
152
153 let parts: Vec<&str> = jwt.split('.').collect();
154 assert_eq!(parts.len(), 3, "header.payload.signature");
155
156 let header = decode_part(parts[0]);
157 assert_eq!(header["alg"], "HS256");
158 assert_eq!(header["typ"], "JWT");
159 assert_eq!(header["kid"], "app.keyid");
160
161 let claims = decode_part(parts[1]);
162 assert_eq!(
163 claims["x-ably-capability"],
164 r#"{"sports":["history","publish"]}"#
165 );
166 assert_eq!(claims["x-ably-clientId"], "user-123");
167 assert_eq!(claims["x-ably-revocation-key"], "grp-7");
168 let iat = claims["iat"].as_i64().unwrap();
169 let exp = claims["exp"].as_i64().unwrap();
170 assert_eq!(exp - iat, 3600);
171 }
172
173 #[test]
174 fn omits_optional_claims_when_unset() {
175 let key = SigningKey::new("app.keyid:secret").unwrap();
176 let jwt = mint_ably_jwt(&key, &TokenParams::new("{}")).unwrap();
177 let claims = decode_part(jwt.split('.').nth(1).unwrap());
178 assert!(claims.get("x-ably-clientId").is_none());
179 assert!(claims.get("x-ably-revocation-key").is_none());
180 }
181}