use std::time::Duration;
use futures::future::BoxFuture;
use crate::config::TokenProvider;
use crate::error::{Error, Result};
#[derive(Clone)]
pub struct KeyTokenProvider {
name: String,
secret: String,
host: String,
capability: Option<String>,
client_id: Option<String>,
ttl: Option<Duration>,
http: reqwest::Client,
}
impl KeyTokenProvider {
pub fn new(api_key: impl AsRef<str>) -> Result<Self> {
let (name, secret) = crate::config::split_api_key(api_key.as_ref())?;
Ok(Self {
name: name.to_owned(),
secret: secret.to_owned(),
host: "https://rest.ably.io".to_owned(),
capability: None,
client_id: None,
ttl: None,
http: reqwest::Client::new(),
})
}
pub fn capability(mut self, cap: impl Into<String>) -> Self {
self.capability = Some(cap.into());
self
}
pub fn client_id(mut self, id: impl Into<String>) -> Self {
self.client_id = Some(id.into());
self
}
pub fn ttl(mut self, ttl: Duration) -> Self {
self.ttl = Some(ttl);
self
}
pub fn host(mut self, host: impl Into<String>) -> Self {
self.host = host.into().trim_end_matches('/').to_owned();
self
}
pub fn http_client(mut self, client: reqwest::Client) -> Self {
self.http = client;
self
}
}
impl std::fmt::Debug for KeyTokenProvider {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("KeyTokenProvider")
.field("key_name", &self.name)
.field("key_secret", &"<redacted>")
.field("host", &self.host)
.field("client_id", &self.client_id)
.finish()
}
}
use ably_auth_openapi::apis::authentication_api;
use ably_auth_openapi::apis::configuration::Configuration;
use ably_auth_openapi::models::{RequestTokenRequest, TokenParams};
impl TokenProvider for KeyTokenProvider {
fn token(&self) -> BoxFuture<'_, Result<String>> {
Box::pin(async move {
let mut cfg = Configuration::new();
cfg.base_path = self.host.clone();
cfg.client = self.http.clone();
cfg.basic_auth = Some((self.name.clone(), Some(self.secret.clone())));
let params = TokenParams {
ttl: self.ttl.map(|d| d.as_millis() as i64),
capability: self.capability.clone(),
client_id: self.client_id.clone(),
};
let body = RequestTokenRequest::TokenParams(Box::new(params));
match authentication_api::request_token(&cfg, &self.name, body, None).await {
Ok(details) => Ok(details.token),
Err(e) => Err(map_auth_error(e)),
}
})
}
}
fn map_auth_error(
e: ably_auth_openapi::apis::Error<authentication_api::RequestTokenError>,
) -> Error {
use ably_auth_openapi::apis::Error as AuthErr;
match e {
AuthErr::Reqwest(re) => Error::from(re), AuthErr::ResponseError(rc) => {
Error::from_api_body(rc.status.as_u16(), rc.content.as_bytes())
}
other => Error::Decode(other.to_string()),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn builds_and_redacts_secret() {
let p = KeyTokenProvider::new("app.key:supersecret")
.unwrap()
.client_id("user-1")
.ttl(Duration::from_secs(3600));
let dbg = format!("{p:?}");
assert!(
!dbg.contains("supersecret"),
"secret must be redacted: {dbg}"
);
assert!(dbg.contains("KeyTokenProvider"));
}
#[test]
fn rejects_malformed_key() {
assert!(KeyTokenProvider::new("no-colon").is_err());
}
use wiremock::matchers::{body_partial_json, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
#[tokio::test]
async fn mints_token_via_request_token() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/keys/app.key/requestToken"))
.and(body_partial_json(serde_json::json!({"ttl": 3_600_000})))
.respond_with(ResponseTemplate::new(200).set_body_raw(
r#"{"token":"tok-XYZ","keyName":"app.key"}"#,
"application/json",
))
.mount(&server)
.await;
let p = KeyTokenProvider::new("app.key:secret")
.unwrap()
.host(server.uri())
.ttl(Duration::from_secs(3600));
assert_eq!(p.token().await.unwrap(), "tok-XYZ");
}
#[tokio::test]
async fn maps_request_token_api_error() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/keys/app.key/requestToken"))
.respond_with(ResponseTemplate::new(401).set_body_string(
r#"{"error":{"code":40100,"message":"bad key","statusCode":401}}"#,
))
.mount(&server)
.await;
let p = KeyTokenProvider::new("app.key:secret")
.unwrap()
.host(server.uri());
let err = p.token().await.unwrap_err();
assert_eq!(err.status(), Some(401));
assert_eq!(err.info().unwrap().code, 40100);
}
}