Skip to main content

ably_chat/
token_provider.rs

1//! Server-side `TokenProvider` that mints Ably Tokens via the platform
2//! `requestToken` endpoint (feature `token-issuance`). SERVER-SIDE ONLY: holds
3//! the API secret. See ADR-0012 item 5 and SPEC ยง13.
4
5use std::time::Duration;
6
7use futures::future::BoxFuture;
8
9use crate::config::TokenProvider;
10use crate::error::{Error, Result};
11
12/// Mints Ably Tokens by calling `POST /keys/{keyName}/requestToken` with an
13/// unsigned `TokenParams` body under HTTP Basic auth. Pair with
14/// [`Auth::provider`](crate::Auth::provider) for automatic use + refresh.
15#[derive(Clone)]
16pub struct KeyTokenProvider {
17    name: String,
18    secret: String,
19    host: String,
20    capability: Option<String>,
21    client_id: Option<String>,
22    ttl: Option<Duration>,
23    http: reqwest::Client,
24}
25
26impl KeyTokenProvider {
27    /// New provider from a full API key `appId.keyId:keySecret`.
28    pub fn new(api_key: impl AsRef<str>) -> Result<Self> {
29        let (name, secret) = crate::config::split_api_key(api_key.as_ref())?;
30        Ok(Self {
31            name: name.to_owned(),
32            secret: secret.to_owned(),
33            host: "https://rest.ably.io".to_owned(),
34            capability: None,
35            client_id: None,
36            ttl: None,
37            http: reqwest::Client::new(),
38        })
39    }
40    /// Restrict issued tokens to this capability (a JSON string; with the
41    /// `capabilities` feature, build it via `Capability::to_capability_string`).
42    pub fn capability(mut self, cap: impl Into<String>) -> Self {
43        self.capability = Some(cap.into());
44        self
45    }
46    /// Bind issued tokens to a `clientId`.
47    pub fn client_id(mut self, id: impl Into<String>) -> Self {
48        self.client_id = Some(id.into());
49        self
50    }
51    /// Requested token TTL (default: Ably's 60 minutes).
52    pub fn ttl(mut self, ttl: Duration) -> Self {
53        self.ttl = Some(ttl);
54        self
55    }
56    /// Override the platform host (defaults to `https://rest.ably.io`).
57    pub fn host(mut self, host: impl Into<String>) -> Self {
58        self.host = host.into().trim_end_matches('/').to_owned();
59        self
60    }
61    /// Supply a preconfigured `reqwest::Client`.
62    pub fn http_client(mut self, client: reqwest::Client) -> Self {
63        self.http = client;
64        self
65    }
66}
67
68impl std::fmt::Debug for KeyTokenProvider {
69    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
70        f.debug_struct("KeyTokenProvider")
71            .field("key_name", &self.name)
72            .field("key_secret", &"<redacted>")
73            .field("host", &self.host)
74            .field("client_id", &self.client_id)
75            .finish()
76    }
77}
78
79use ably_auth_openapi::apis::authentication_api;
80use ably_auth_openapi::apis::configuration::Configuration;
81use ably_auth_openapi::models::{RequestTokenRequest, TokenParams};
82
83impl TokenProvider for KeyTokenProvider {
84    fn token(&self) -> BoxFuture<'_, Result<String>> {
85        Box::pin(async move {
86            let mut cfg = Configuration::new();
87            cfg.base_path = self.host.clone();
88            cfg.client = self.http.clone();
89            cfg.basic_auth = Some((self.name.clone(), Some(self.secret.clone())));
90
91            let params = TokenParams {
92                ttl: self.ttl.map(|d| d.as_millis() as i64),
93                capability: self.capability.clone(),
94                client_id: self.client_id.clone(),
95            };
96            let body = RequestTokenRequest::TokenParams(Box::new(params));
97
98            // x-ably-version omitted (None): the platform API applies its default.
99            match authentication_api::request_token(&cfg, &self.name, body, None).await {
100                Ok(details) => Ok(details.token),
101                Err(e) => Err(map_auth_error(e)),
102            }
103        })
104    }
105}
106
107/// Maps an `ably-auth-openapi` error into this crate's `Error`.
108fn map_auth_error(
109    e: ably_auth_openapi::apis::Error<authentication_api::RequestTokenError>,
110) -> Error {
111    use ably_auth_openapi::apis::Error as AuthErr;
112    match e {
113        AuthErr::Reqwest(re) => Error::from(re), // -> Error::Transport
114        AuthErr::ResponseError(rc) => {
115            Error::from_api_body(rc.status.as_u16(), rc.content.as_bytes())
116        }
117        other => Error::Decode(other.to_string()),
118    }
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124
125    #[test]
126    fn builds_and_redacts_secret() {
127        let p = KeyTokenProvider::new("app.key:supersecret")
128            .unwrap()
129            .client_id("user-1")
130            .ttl(Duration::from_secs(3600));
131        let dbg = format!("{p:?}");
132        assert!(
133            !dbg.contains("supersecret"),
134            "secret must be redacted: {dbg}"
135        );
136        assert!(dbg.contains("KeyTokenProvider"));
137    }
138
139    #[test]
140    fn rejects_malformed_key() {
141        assert!(KeyTokenProvider::new("no-colon").is_err());
142    }
143
144    use wiremock::matchers::{body_partial_json, method, path};
145    use wiremock::{Mock, MockServer, ResponseTemplate};
146
147    #[tokio::test]
148    async fn mints_token_via_request_token() {
149        let server = MockServer::start().await;
150        Mock::given(method("POST"))
151            .and(path("/keys/app.key/requestToken"))
152            // The body is the unsigned `TokenParams` shape and `ttl` is in
153            // MILLISECONDS (1h -> 3_600_000), which is what Ably expects.
154            .and(body_partial_json(serde_json::json!({"ttl": 3_600_000})))
155            .respond_with(ResponseTemplate::new(200).set_body_raw(
156                r#"{"token":"tok-XYZ","keyName":"app.key"}"#,
157                "application/json",
158            ))
159            .mount(&server)
160            .await;
161        let p = KeyTokenProvider::new("app.key:secret")
162            .unwrap()
163            .host(server.uri())
164            .ttl(Duration::from_secs(3600));
165        assert_eq!(p.token().await.unwrap(), "tok-XYZ");
166    }
167
168    #[tokio::test]
169    async fn maps_request_token_api_error() {
170        let server = MockServer::start().await;
171        Mock::given(method("POST"))
172            .and(path("/keys/app.key/requestToken"))
173            .respond_with(ResponseTemplate::new(401).set_body_string(
174                r#"{"error":{"code":40100,"message":"bad key","statusCode":401}}"#,
175            ))
176            .mount(&server)
177            .await;
178        let p = KeyTokenProvider::new("app.key:secret")
179            .unwrap()
180            .host(server.uri());
181        let err = p.token().await.unwrap_err();
182        assert_eq!(err.status(), Some(401));
183        // Status and body are mapped independently; pin the body half too.
184        assert_eq!(err.info().unwrap().code, 40100);
185    }
186}