1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
use std::sync::Arc;
use reqwest::header::{AUTHORIZATION, HeaderMap, HeaderValue};
use tokio::sync::Mutex;
use crate::error::{Error, Result, TransportResultExt};
use crate::http::check_status;
use crate::models::Tokens;
use crate::oauth::parse_token_response;
/// Default base URL for the TONE3000 v1 API.
pub const DEFAULT_BASE_URL: &str = "https://www.tone3000.com/api/v1";
/// Callback invoked whenever tokens change (after exchange or refresh), so the
/// consuming app can persist them.
pub type TokensChanged = Arc<dyn Fn(&Tokens) + Send + Sync>;
/// Mutable token/auth state, guarded for interior mutability.
#[derive(Debug, Default)]
pub(crate) struct TokenState {
pub access: Option<String>,
pub refresh: Option<String>,
/// Unix-epoch seconds at which `access` expires, if known.
pub expires_at: Option<u64>,
}
/// Async client for the TONE3000 API.
#[derive(Clone)]
pub struct Client {
pub(crate) http: reqwest::Client,
pub(crate) base_url: String,
pub(crate) pubkey: String,
pub(crate) tokens: Arc<Mutex<TokenState>>,
/// Serializes token refreshes so concurrent requests don't all POST `/oauth/token`
/// and invalidate each other's rotated refresh tokens.
pub(crate) refresh_lock: Arc<Mutex<()>>,
pub(crate) auto_refresh: bool,
pub(crate) on_tokens_changed: Option<TokensChanged>,
}
impl Client {
/// Start building a configured client.
///
/// Every TONE3000 endpoint requires a user access token, so a client built without
/// one — via [`access_token`](ClientBuilder::access_token) or
/// [`refresh_token`](ClientBuilder::refresh_token) — is only useful for driving the
/// OAuth bootstrap. API calls on it return [`Error::Unauthenticated`] until
/// `exchange_code`/`refresh` mints a token.
pub fn builder(publishable_key: impl Into<String>) -> ClientBuilder {
ClientBuilder::new(publishable_key)
}
/// Snapshot the access token under a single lock and build the `Authorization` header
/// from it, returning both the header and the exact token used.
///
/// Returning the token lets [`Client::send`]'s reactive-refresh guard compare against
/// the credential actually attached to the request, rather than a value re-read in a
/// separate lock acquisition that a concurrent refresh could have rotated in between.
/// Errors with [`Error::Unauthenticated`] if no access token is set; callers go through
/// [`Client::send`], which first ensures a token via [`Client::ensure_authenticated`].
pub(crate) async fn authorized_headers(&self) -> Result<(HeaderMap, String)> {
let access = {
let guard = self.tokens.lock().await;
guard.access.clone().ok_or(Error::Unauthenticated)?
};
let mut h = HeaderMap::new();
h.insert(
AUTHORIZATION,
HeaderValue::from_str(&format!("Bearer {access}"))
.expect("header value is valid ascii"),
);
Ok((h, access))
}
/// Ensure an access token is available, minting one from the refresh token if needed.
/// Returns [`Error::Unauthenticated`] when there is neither an access nor a refresh token.
pub(crate) async fn ensure_authenticated(&self) -> Result<()> {
if self.has_access_token().await {
return Ok(());
}
if !self.has_refresh_token().await {
return Err(Error::Unauthenticated);
}
let _guard = self.refresh_lock.lock().await;
if !self.has_access_token().await {
self.refresh_locked().await?;
}
Ok(())
}
/// True if the client currently holds a user access token.
pub(crate) async fn has_access_token(&self) -> bool {
self.tokens.lock().await.access.is_some()
}
/// Snapshot of the current access token, used to detect refreshes by other tasks.
async fn current_access(&self) -> Option<String> {
self.tokens.lock().await.access.clone()
}
/// True if a refresh token is stored.
async fn has_refresh_token(&self) -> bool {
self.tokens.lock().await.refresh.is_some()
}
/// True if the access token's known expiry has passed.
async fn is_expired(&self) -> bool {
let guard = self.tokens.lock().await;
matches!(
(guard.access.as_ref(), guard.expires_at),
(Some(_), Some(exp)) if now_unix() >= exp
)
}
/// Returns true if auto-refresh is enabled and the access token is at/near expiry.
pub(crate) async fn needs_proactive_refresh(&self) -> bool {
if !self.auto_refresh {
return false;
}
let guard = self.tokens.lock().await;
match (guard.access.as_ref(), guard.expires_at) {
// refresh when within 30s of expiry
(Some(_), Some(exp)) => now_unix() + 30 >= exp,
_ => false,
}
}
/// Refresh proactively if the token is near expiry. Serialized so racing requests
/// don't all refresh at once. Only surfaces an error if the token is already expired;
/// otherwise the (still-valid) request is allowed to proceed.
pub(crate) async fn maybe_proactive_refresh(&self) -> Result<()> {
if !self.needs_proactive_refresh().await {
return Ok(());
}
let _guard = self.refresh_lock.lock().await;
// Another task may have refreshed while we waited for the lock.
if !self.needs_proactive_refresh().await {
return Ok(());
}
match self.refresh_locked().await {
Ok(_) => Ok(()),
Err(e) if self.is_expired().await => Err(e),
Err(_) => Ok(()),
}
}
pub(crate) async fn send(&self, req: reqwest::RequestBuilder) -> Result<reqwest::Response> {
self.ensure_authenticated().await?;
self.maybe_proactive_refresh().await?;
let retry = req.try_clone();
// Snapshot the token and header together so the guard below compares against the
// exact credential we send, not a separately re-read value.
let (headers, used) = self.authorized_headers().await?;
let resp = req.headers(headers).send().await.transport()?;
match check_status(resp).await {
Err(Error::Unauthorized) if self.auto_refresh && self.has_refresh_token().await => {
self.reactive_refresh(Some(used)).await?;
match retry {
Some(rb) => {
let (headers, _) = self.authorized_headers().await?;
let resp = rb.headers(headers).send().await.transport()?;
check_status(resp).await
}
None => Err(Error::Unauthorized),
}
}
other => other,
}
}
/// Refresh once on a 401, serialized and only if no other task already rotated the
/// token we just used.
async fn reactive_refresh(&self, used: Option<String>) -> Result<()> {
let _guard = self.refresh_lock.lock().await;
// If the stored access token already changed, another task refreshed; reuse it.
if self.current_access().await == used {
self.refresh_locked().await?;
}
Ok(())
}
}
impl Client {
/// Exchange an authorization code for tokens, storing them on the client.
pub async fn exchange_code(
&self,
code: &str,
verifier: &str,
redirect_uri: &str,
) -> Result<Tokens> {
let form = [
("grant_type", "authorization_code"),
("code", code),
("code_verifier", verifier),
("redirect_uri", redirect_uri),
("client_id", self.pubkey.as_str()),
];
self.post_token(&form).await
}
/// Refresh using the stored refresh token, updating stored tokens.
///
/// Serialized via the refresh lock so concurrent callers don't race the token
/// rotation endpoint.
pub async fn refresh(&self) -> Result<Tokens> {
let _guard = self.refresh_lock.lock().await;
self.refresh_locked().await
}
/// Refresh without taking the refresh lock. Callers that already hold it
/// (proactive/reactive refresh) use this to avoid re-entrant locking.
async fn refresh_locked(&self) -> Result<Tokens> {
let refresh = {
let guard = self.tokens.lock().await;
guard.refresh.clone()
};
let refresh = refresh.ok_or(Error::Unauthenticated)?;
let form = [
("grant_type", "refresh_token"),
("refresh_token", refresh.as_str()),
("client_id", self.pubkey.as_str()),
];
self.post_token(&form).await
}
/// Shared token-endpoint POST + state update + change callback.
async fn post_token(&self, form: &[(&str, &str)]) -> Result<Tokens> {
let resp = self
.http
.post(format!("{}/oauth/token", self.base_url))
.form(form)
.send()
.await
.transport()?;
let status = resp.status().as_u16();
let body = resp.bytes().await.transport()?;
let tokens = parse_token_response(status, &body)?;
self.store_tokens(&tokens).await;
Ok(tokens)
}
/// Persist tokens into client state and fire the change callback.
pub(crate) async fn store_tokens(&self, tokens: &Tokens) {
{
let mut guard = self.tokens.lock().await;
guard.access = Some(tokens.access_token.clone());
if tokens.refresh_token.is_some() {
guard.refresh = tokens.refresh_token.clone();
}
guard.expires_at = tokens.expires_in.map(|secs| now_unix() + secs);
}
if let Some(cb) = &self.on_tokens_changed {
cb(tokens);
}
}
}
/// Current unix-epoch seconds.
pub(crate) fn now_unix() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
/// Builder for [`Client`].
pub struct ClientBuilder {
pubkey: String,
base_url: String,
access: Option<String>,
refresh: Option<String>,
expires_at: Option<u64>,
auto_refresh: bool,
on_tokens_changed: Option<TokensChanged>,
}
impl ClientBuilder {
fn new(publishable_key: impl Into<String>) -> Self {
Self {
pubkey: publishable_key.into(),
base_url: DEFAULT_BASE_URL.to_string(),
access: None,
refresh: None,
expires_at: None,
auto_refresh: false,
on_tokens_changed: None,
}
}
/// Override the base URL (useful for tests / self-hosting).
pub fn base_url(mut self, url: impl Into<String>) -> Self {
self.base_url = url.into();
self
}
/// Set the user access token used as the Bearer credential for API calls.
pub fn access_token(mut self, token: impl Into<String>) -> Self {
self.access = Some(token.into());
self
}
/// Set the refresh token used by `refresh()` and auto-refresh.
pub fn refresh_token(mut self, token: impl Into<String>) -> Self {
self.refresh = Some(token.into());
self
}
/// Seed the access token's expiry as Unix-epoch seconds.
///
/// Required for proactive [`auto_refresh`](Self::auto_refresh) to fire when
/// restoring a session from persisted tokens — otherwise the client has no
/// idea when the access token expires and will only refresh reactively on a 401.
pub fn expires_at(mut self, unix_secs: u64) -> Self {
self.expires_at = Some(unix_secs);
self
}
/// Enable transparent proactive token refresh shortly before the access token expires.
pub fn auto_refresh(mut self, enabled: bool) -> Self {
self.auto_refresh = enabled;
self
}
/// Register a callback fired whenever tokens change.
pub fn on_tokens_changed<F>(mut self, f: F) -> Self
where
F: Fn(&Tokens) + Send + Sync + 'static,
{
self.on_tokens_changed = Some(Arc::new(f));
self
}
/// Finish building the client.
pub fn build(self) -> Client {
Client {
http: reqwest::Client::new(),
base_url: self.base_url,
pubkey: self.pubkey,
tokens: Arc::new(Mutex::new(TokenState {
access: self.access,
refresh: self.refresh,
expires_at: self.expires_at,
})),
refresh_lock: Arc::new(Mutex::new(())),
auto_refresh: self.auto_refresh,
on_tokens_changed: self.on_tokens_changed,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn no_token_authorized_headers_errors() {
let c = Client::builder("t3k_pub_abc").build();
assert!(matches!(
c.authorized_headers().await,
Err(Error::Unauthenticated)
));
assert!(!c.has_access_token().await);
}
#[tokio::test]
async fn bearer_mode_uses_access_token() {
let c = Client::builder("t3k_pub_abc")
.access_token("user_tok")
.build();
let (headers, used) = c.authorized_headers().await.unwrap();
assert_eq!(
headers.get(AUTHORIZATION).unwrap().to_str().unwrap(),
"Bearer user_tok"
);
assert_eq!(used, "user_tok");
assert!(c.has_access_token().await);
}
#[tokio::test]
async fn ensure_authenticated_errors_without_any_token() {
let c = Client::builder("t3k_pub_abc").build();
assert!(matches!(
c.ensure_authenticated().await,
Err(Error::Unauthenticated)
));
}
}