Skip to main content

ably_chat/
jwt.rs

1//! Ably JWT minting (ADR-0012, SPEC §13.2). Feature `jwt`. SERVER-SIDE ONLY:
2//! this carries your API secret and must never run in client-shipped code.
3
4use crate::error::{Error, Result};
5
6/// An Ably API key split into its name (`appId.keyId`) and secret, for signing
7/// Ably JWTs. `Debug` redacts the secret.
8#[derive(Clone)]
9pub struct SigningKey {
10    name: String,
11    secret: String,
12}
13
14impl SigningKey {
15    /// Parse a full API key `appId.keyId:keySecret`.
16    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    /// The key name (`appId.keyId`), used as the JWT `kid`.
25    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
41/// Inputs for minting an Ably JWT. `capability` is the capability JSON **string**
42/// (with the `capabilities` feature, produce it via `Capability::to_capability_string`).
43pub struct TokenParams {
44    capability: String,
45    client_id: Option<String>,
46    revocation_key: Option<String>,
47    ttl: Duration,
48}
49
50impl TokenParams {
51    /// New params with the given capability JSON string and a default 1-hour TTL.
52    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    /// Bind the token to a `clientId` (`x-ably-clientId`).
61    pub fn client_id(mut self, id: impl Into<String>) -> Self {
62        self.client_id = Some(id.into());
63        self
64    }
65    /// Set an `x-ably-revocation-key` so the JWT can be revoked by that key.
66    pub fn revocation_key(mut self, key: impl Into<String>) -> Self {
67        self.revocation_key = Some(key.into());
68        self
69    }
70    /// Time-to-live (default 1 hour; keep ≤ 1 hour if the key has revocable tokens).
71    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
92/// Mint an HS256 Ably JWT signed with the key secret. No network call.
93///
94/// SERVER-SIDE ONLY — never run where the API secret could reach a client.
95pub 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: &params.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, &params).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}