ably_chat/
token_provider.rs1use std::time::Duration;
6
7use futures::future::BoxFuture;
8
9use crate::config::TokenProvider;
10use crate::error::{Error, Result};
11
12#[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 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 pub fn capability(mut self, cap: impl Into<String>) -> Self {
43 self.capability = Some(cap.into());
44 self
45 }
46 pub fn client_id(mut self, id: impl Into<String>) -> Self {
48 self.client_id = Some(id.into());
49 self
50 }
51 pub fn ttl(mut self, ttl: Duration) -> Self {
53 self.ttl = Some(ttl);
54 self
55 }
56 pub fn host(mut self, host: impl Into<String>) -> Self {
58 self.host = host.into().trim_end_matches('/').to_owned();
59 self
60 }
61 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 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
107fn 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), 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 .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 assert_eq!(err.info().unwrap().code, 40100);
185 }
186}