Skip to main content

radion_sdk/realtime/
auth.rs

1//! Auth helpers for the realtime client: token providers and query-string URLs.
2
3use std::fmt;
4use std::future::Future;
5use std::pin::Pin;
6use std::sync::Arc;
7
8use crate::error::Result;
9
10type BoxFuture = Pin<Box<dyn Future<Output = Result<String>> + Send>>;
11type ProviderFn = Arc<dyn Fn() -> BoxFuture + Send + Sync>;
12
13/// Resolves the current user JWT for the public-key (`pk_jwt_`) flow. Cloneable
14/// and shared across reconnect attempts; invoked once per connection attempt.
15#[derive(Clone)]
16pub struct TokenProvider(ProviderFn);
17
18impl TokenProvider {
19    /// A provider that always returns the same token.
20    pub fn from_static(token: impl Into<String>) -> Self {
21        let token = token.into();
22        Self(Arc::new(move || {
23            let token = token.clone();
24            Box::pin(async move { Ok(token) })
25        }))
26    }
27
28    /// A provider backed by an async closure, called on every (re)connect.
29    pub fn new<F, Fut>(f: F) -> Self
30    where
31        F: Fn() -> Fut + Send + Sync + 'static,
32        Fut: Future<Output = Result<String>> + Send + 'static,
33    {
34        Self(Arc::new(move || Box::pin(f())))
35    }
36
37    /// Resolve the current token.
38    pub fn fetch(&self) -> BoxFuture {
39        (self.0)()
40    }
41}
42
43impl fmt::Debug for TokenProvider {
44    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
45        f.write_str("TokenProvider(..)")
46    }
47}
48
49/// Percent-encode a query value (encode everything that is not unreserved).
50fn encode(value: &str) -> String {
51    let mut out = String::with_capacity(value.len());
52    for byte in value.bytes() {
53        match byte {
54            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
55                out.push(byte as char);
56            }
57            _ => out.push_str(&format!("%{byte:02X}")),
58        }
59    }
60    out
61}
62
63/// Append `api-key` (and optional `token`) to a WS URL's query string,
64/// preserving any existing query and percent-encoding the values.
65pub fn build_auth_query_url(url: &str, api_key: &str, token: Option<&str>) -> String {
66    let separator = if url.contains('?') { '&' } else { '?' };
67    let mut query = format!("api-key={}", encode(api_key));
68    if let Some(token) = token {
69        query.push_str(&format!("&token={}", encode(token)));
70    }
71    format!("{url}{separator}{query}")
72}
73
74#[cfg(test)]
75mod tests {
76    use super::*;
77
78    #[test]
79    fn build_url_appends_api_key() {
80        assert_eq!(
81            build_auth_query_url("wss://api.radion.app/ws", "k1", None),
82            "wss://api.radion.app/ws?api-key=k1"
83        );
84    }
85
86    #[test]
87    fn build_url_appends_token() {
88        assert_eq!(
89            build_auth_query_url("wss://api.radion.app/ws", "k1", Some("jwt-2")),
90            "wss://api.radion.app/ws?api-key=k1&token=jwt-2"
91        );
92    }
93
94    #[test]
95    fn build_url_preserves_existing_query() {
96        assert_eq!(
97            build_auth_query_url("wss://api.radion.app/ws?v=1", "k1", None),
98            "wss://api.radion.app/ws?v=1&api-key=k1"
99        );
100    }
101
102    #[test]
103    fn build_url_encodes_values() {
104        assert_eq!(
105            build_auth_query_url("wss://api.radion.app/ws", "a b", Some("x/y=")),
106            "wss://api.radion.app/ws?api-key=a%20b&token=x%2Fy%3D"
107        );
108    }
109
110    #[tokio::test]
111    async fn static_provider_returns_token() {
112        let provider = TokenProvider::from_static("jwt");
113        assert_eq!(provider.fetch().await.unwrap(), "jwt");
114    }
115
116    #[tokio::test]
117    async fn closure_provider_returns_token() {
118        let provider = TokenProvider::new(|| async { Ok("fresh".to_string()) });
119        assert_eq!(provider.fetch().await.unwrap(), "fresh");
120    }
121}