1use std::fmt;
43use std::sync::{Arc, RwLock, Weak};
44use std::time::Duration;
45
46use async_trait::async_trait;
47use base64::Engine;
48use base64::engine::general_purpose::URL_SAFE_NO_PAD;
49use tokio::sync::{Mutex, Notify};
50use tokio::time::Instant;
51use tonic::transport::Channel;
52use tracing::{debug, warn};
53
54use crate::error::FinamError;
55use crate::pb::grpc::tradeapi::v1::auth::auth_service_client::AuthServiceClient;
56use crate::pb::grpc::tradeapi::v1::auth::{AuthRequest, TokenDetailsRequest};
57
58pub const ASSUMED_TTL: Duration = Duration::from_secs(5 * 60);
60
61pub const AUTH_TIMEOUT: Duration = Duration::from_secs(10);
64
65const RETRY_MIN: Duration = Duration::from_secs(1);
67const RETRY_MAX: Duration = Duration::from_secs(30);
68
69#[derive(Debug, Clone, Default, PartialEq, Eq)]
71pub struct TokenInfo {
72 pub ttl: Option<Duration>,
74 pub account_ids: Vec<String>,
76 pub readonly: bool,
78}
79
80#[async_trait]
115pub trait TokenApi: Send + Sync {
116 async fn auth(&self, secret: &str, source_app_id: &str) -> Result<String, FinamError>;
124 async fn token_details(&self, token: &str) -> Result<TokenInfo, FinamError>;
133}
134
135pub struct GrpcTokenApi {
139 client: AuthServiceClient<Channel>,
140}
141
142impl GrpcTokenApi {
143 pub fn new(channel: Channel) -> Self {
146 Self {
147 client: AuthServiceClient::new(channel),
148 }
149 }
150}
151
152#[async_trait]
153impl TokenApi for GrpcTokenApi {
154 async fn auth(&self, secret: &str, source_app_id: &str) -> Result<String, FinamError> {
155 let resp = self
156 .client
157 .clone()
158 .auth(AuthRequest {
159 secret: secret.to_string(),
160 source_app_id: source_app_id.to_string(),
161 })
162 .await?
163 .into_inner();
164 Ok(resp.token)
165 }
166
167 async fn token_details(&self, token: &str) -> Result<TokenInfo, FinamError> {
168 let d = self
169 .client
170 .clone()
171 .token_details(TokenDetailsRequest {
172 token: token.to_string(),
173 })
174 .await?
175 .into_inner();
176 let ttl = match (d.created_at, d.expires_at) {
177 (Some(c), Some(e)) => {
178 let secs = e.seconds - c.seconds;
179 (secs > 0).then(|| Duration::from_secs(secs as u64))
180 }
181 _ => None,
182 };
183 Ok(TokenInfo {
184 ttl,
185 account_ids: d.account_ids,
186 readonly: d.readonly,
187 })
188 }
189}
190
191pub fn jwt_ttl(token: &str) -> Option<Duration> {
208 let payload = token.split('.').nth(1)?;
209 let bytes = URL_SAFE_NO_PAD.decode(payload.trim_end_matches('=')).ok()?;
210 let v: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
211 let exp = v.get("exp")?.as_i64()?;
212 let iat = v.get("iat")?.as_i64()?;
213 (exp > iat).then(|| Duration::from_secs((exp - iat) as u64))
214}
215
216pub fn refresh_after(ttl: Duration) -> Duration {
232 let lead = (ttl / 5).clamp(Duration::from_secs(5), Duration::from_secs(120));
233 if ttl <= lead * 2 { ttl / 2 } else { ttl - lead }
234}
235
236struct State {
237 token: Arc<str>,
238 info: TokenInfo,
239 refresh_at: Instant,
240 generation: u64,
242}
243
244pub struct TokenManager {
254 api: Arc<dyn TokenApi>,
255 secret: String,
256 source_app_id: String,
257 state: RwLock<State>,
258 wake: Arc<Notify>,
259 refresh_lock: Mutex<()>,
260}
261
262impl fmt::Debug for TokenManager {
263 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
264 f.debug_struct("TokenManager")
265 .field("secret", &"<redacted>")
266 .field("generation", &self.generation())
267 .finish()
268 }
269}
270
271impl TokenManager {
272 pub async fn start(
286 api: Arc<dyn TokenApi>,
287 secret: impl Into<String>,
288 source_app_id: impl Into<String>,
289 ) -> Result<Arc<Self>, FinamError> {
290 let secret = secret.into();
291 if secret.trim().is_empty() {
292 return Err(FinamError::Config("empty secret".into()));
293 }
294 let source_app_id = source_app_id.into();
295 let fresh = fetch(api.as_ref(), &secret, &source_app_id).await?;
296 let manager = Arc::new(Self {
297 api,
298 secret,
299 source_app_id,
300 state: RwLock::new(fresh),
301 wake: Arc::new(Notify::new()),
302 refresh_lock: Mutex::new(()),
303 });
304 tokio::spawn(refresh_loop(Arc::downgrade(&manager)));
305 Ok(manager)
306 }
307
308 pub fn current(&self) -> Arc<str> {
312 Arc::clone(&self.state.read().expect("token state poisoned").token)
313 }
314
315 pub fn info(&self) -> TokenInfo {
317 self.state
318 .read()
319 .expect("token state poisoned")
320 .info
321 .clone()
322 }
323
324 pub fn account_ids(&self) -> Vec<String> {
328 self.info().account_ids
329 }
330
331 pub fn generation(&self) -> u64 {
335 self.state.read().expect("token state poisoned").generation
336 }
337
338 pub fn request_refresh(&self) {
342 self.wake.notify_one();
343 }
344
345 pub async fn refresh(&self) -> Result<(), FinamError> {
355 let seen = self.generation();
356 let _guard = self.refresh_lock.lock().await;
357 if self.generation() != seen {
358 return Ok(());
360 }
361 let mut fresh = fetch(self.api.as_ref(), &self.secret, &self.source_app_id).await?;
362 let mut st = self.state.write().expect("token state poisoned");
363 fresh.generation = st.generation + 1;
364 *st = fresh;
365 Ok(())
366 }
367
368 fn until_refresh(&self) -> Duration {
369 let at = self.state.read().expect("token state poisoned").refresh_at;
370 at.saturating_duration_since(Instant::now())
371 }
372}
373
374async fn fetch(api: &dyn TokenApi, secret: &str, source_app_id: &str) -> Result<State, FinamError> {
375 let started = Instant::now();
376 let token = with_deadline("Auth", api.auth(secret, source_app_id)).await?;
377 if token.is_empty() {
378 return Err(FinamError::Auth("server returned an empty token".into()));
379 }
380 let mut info = match with_deadline("TokenDetails", api.token_details(&token)).await {
381 Ok(info) => info,
382 Err(e) => {
383 warn!(error = %e, "TokenDetails failed, falling back to JWT claims");
385 TokenInfo::default()
386 }
387 };
388 info.ttl = match (info.ttl, jwt_ttl(&token)) {
391 (Some(a), Some(b)) => Some(a.min(b)),
392 (a, b) => a.or(b),
393 };
394 let ttl = info.ttl.unwrap_or(ASSUMED_TTL);
395 debug!(?ttl, "finam token issued");
396 Ok(State {
397 token: Arc::from(token),
398 info,
399 refresh_at: started + refresh_after(ttl),
401 generation: 1,
402 })
403}
404
405async fn with_deadline<T>(
406 what: &str,
407 call: impl std::future::Future<Output = Result<T, FinamError>>,
408) -> Result<T, FinamError> {
409 match tokio::time::timeout(AUTH_TIMEOUT, call).await {
410 Ok(result) => result,
411 Err(_) => Err(FinamError::Status {
412 code: tonic::Code::DeadlineExceeded,
413 message: format!("{what}: no response within {AUTH_TIMEOUT:?}"),
414 }),
415 }
416}
417
418async fn refresh_loop(weak: Weak<TokenManager>) {
419 let mut backoff = RETRY_MIN;
420 loop {
421 let (wait, wake) = {
423 let Some(m) = weak.upgrade() else { return };
424 (m.until_refresh(), Arc::clone(&m.wake))
425 };
426 if !wait.is_zero() {
427 tokio::select! {
428 _ = tokio::time::sleep(wait) => {}
429 _ = wake.notified() => {}
430 }
431 }
432 let Some(m) = weak.upgrade() else { return };
433 match m.refresh().await {
434 Ok(()) => backoff = RETRY_MIN,
435 Err(e) => {
436 warn!(error = %e, retry_in = ?backoff, "finam token refresh failed");
437 drop(m);
438 tokio::time::sleep(backoff).await;
439 backoff = (backoff * 2).min(RETRY_MAX);
440 }
441 }
442 }
443}
444
445#[cfg(test)]
446mod tests {
447 use std::sync::Mutex as StdMutex;
448 use std::sync::atomic::{AtomicU64, Ordering};
449
450 use super::*;
451
452 #[test]
453 fn refresh_policy() {
454 assert_eq!(
456 refresh_after(Duration::from_secs(900)),
457 Duration::from_secs(780)
458 );
459 assert_eq!(
461 refresh_after(Duration::from_secs(60)),
462 Duration::from_secs(48)
463 );
464 assert_eq!(
466 refresh_after(Duration::from_secs(8)),
467 Duration::from_secs(4)
468 );
469 assert_eq!(
471 refresh_after(Duration::from_secs(86_400)),
472 Duration::from_secs(86_280)
473 );
474 }
475
476 fn jwt(iat: i64, exp: i64) -> String {
477 let h = URL_SAFE_NO_PAD.encode(r#"{"alg":"HS256"}"#);
478 let p = URL_SAFE_NO_PAD.encode(format!(r#"{{"iat":{iat},"exp":{exp}}}"#));
479 format!("{h}.{p}.sig")
480 }
481
482 #[test]
483 fn jwt_claims_give_ttl() {
484 assert_eq!(jwt_ttl(&jwt(1000, 1900)), Some(Duration::from_secs(900)));
485 assert_eq!(jwt_ttl("not-a-jwt"), None);
486 assert_eq!(jwt_ttl(&jwt(2000, 1000)), None);
487 }
488
489 struct FakeApi {
491 issued: AtomicU64,
492 ttl: Option<Duration>,
493 details_fail: bool,
494 fail_next_auths: StdMutex<u32>,
495 }
496
497 impl FakeApi {
498 fn new(ttl: Option<Duration>) -> Arc<Self> {
499 Arc::new(Self {
500 issued: AtomicU64::new(0),
501 ttl,
502 details_fail: false,
503 fail_next_auths: StdMutex::new(0),
504 })
505 }
506 }
507
508 #[async_trait]
509 impl TokenApi for FakeApi {
510 async fn auth(&self, _s: &str, _a: &str) -> Result<String, FinamError> {
511 tokio::task::yield_now().await;
512 {
513 let mut f = self.fail_next_auths.lock().unwrap();
514 if *f > 0 {
515 *f -= 1;
516 return Err(FinamError::Status {
517 code: tonic::Code::Unavailable,
518 message: "down".into(),
519 });
520 }
521 }
522 let n = self.issued.fetch_add(1, Ordering::SeqCst) + 1;
523 Ok(format!("jwt-{n}"))
524 }
525
526 async fn token_details(&self, _t: &str) -> Result<TokenInfo, FinamError> {
527 if self.details_fail {
528 return Err(FinamError::Status {
529 code: tonic::Code::Internal,
530 message: "x".into(),
531 });
532 }
533 Ok(TokenInfo {
534 ttl: self.ttl,
535 account_ids: vec!["A1".into(), "A2".into()],
536 readonly: false,
537 })
538 }
539 }
540
541 #[tokio::test(start_paused = true)]
542 async fn token_is_renewed_before_expiry_not_after() {
543 let api = FakeApi::new(Some(Duration::from_secs(900)));
544 let m = TokenManager::start(api.clone(), "secret", "")
545 .await
546 .unwrap();
547 assert_eq!(&*m.current(), "jwt-1");
548 assert_eq!(m.account_ids(), vec!["A1", "A2"]);
549
550 tokio::time::sleep(Duration::from_secs(770)).await;
552 assert_eq!(&*m.current(), "jwt-1");
553
554 tokio::time::sleep(Duration::from_secs(20)).await;
556 assert_eq!(&*m.current(), "jwt-2");
557 assert_eq!(m.generation(), 2);
558
559 tokio::time::sleep(Duration::from_secs(780)).await;
561 assert_eq!(&*m.current(), "jwt-3");
562 }
563
564 #[tokio::test(start_paused = true)]
565 async fn failed_refresh_is_retried_and_old_token_kept() {
566 let api = FakeApi::new(Some(Duration::from_secs(600)));
567 let m = TokenManager::start(api.clone(), "secret", "")
568 .await
569 .unwrap();
570 *api.fail_next_auths.lock().unwrap() = 2;
571 tokio::time::sleep(Duration::from_secs(482)).await;
573 assert_eq!(&*m.current(), "jwt-1", "old token kept while refresh fails");
574 tokio::time::sleep(Duration::from_secs(5)).await;
575 assert_eq!(&*m.current(), "jwt-2");
576 }
577
578 #[tokio::test(start_paused = true)]
579 async fn lifetime_falls_back_to_jwt_claims_then_assumption() {
580 let api = Arc::new(FakeApi {
582 issued: AtomicU64::new(0),
583 ttl: None,
584 details_fail: true,
585 fail_next_auths: StdMutex::new(0),
586 });
587 let m = TokenManager::start(api, "secret", "").await.unwrap();
588 assert!(m.account_ids().is_empty());
589 tokio::time::sleep(Duration::from_secs(245)).await;
590 assert_eq!(m.generation(), 2);
591 }
592
593 #[tokio::test(start_paused = true)]
594 async fn request_refresh_renews_immediately() {
595 let api = FakeApi::new(Some(Duration::from_secs(900)));
596 let m = TokenManager::start(api, "secret", "").await.unwrap();
597 m.request_refresh();
598 tokio::time::sleep(Duration::from_millis(10)).await;
599 assert_eq!(&*m.current(), "jwt-2");
600 }
601
602 #[tokio::test(start_paused = true)]
603 async fn concurrent_refresh_calls_issue_one_token() {
604 let api = FakeApi::new(Some(Duration::from_secs(900)));
605 let m = TokenManager::start(api.clone(), "secret", "")
606 .await
607 .unwrap();
608 let (a, b) = tokio::join!(m.refresh(), m.refresh());
609 a.unwrap();
610 b.unwrap();
611 assert_eq!(api.issued.load(Ordering::SeqCst), 2);
612 }
613
614 #[tokio::test(start_paused = true)]
615 async fn refresher_stops_when_manager_is_dropped() {
616 let api = FakeApi::new(Some(Duration::from_secs(60)));
617 let m = TokenManager::start(api.clone(), "secret", "")
618 .await
619 .unwrap();
620 drop(m);
621 tokio::time::sleep(Duration::from_secs(600)).await;
622 assert_eq!(
623 api.issued.load(Ordering::SeqCst),
624 1,
625 "no refresh after drop"
626 );
627 }
628
629 #[tokio::test]
630 async fn empty_secret_is_a_config_error() {
631 let api = FakeApi::new(None);
632 let err = TokenManager::start(api, " ", "").await.unwrap_err();
633 assert!(matches!(err, FinamError::Config(_)));
634 }
635
636 #[tokio::test]
637 async fn debug_hides_the_secret() {
638 let m = TokenManager::start(FakeApi::new(None), "hunter2-secret", "")
639 .await
640 .unwrap();
641 let shown = format!("{m:?}");
642 assert!(!shown.contains("hunter2"), "{shown}");
643 }
644
645 struct JwtApi {
647 jwt_ttl: i64,
648 details_ttl: Option<Duration>,
649 }
650
651 #[async_trait]
652 impl TokenApi for JwtApi {
653 async fn auth(&self, _s: &str, _a: &str) -> Result<String, FinamError> {
654 Ok(jwt(1000, 1000 + self.jwt_ttl))
655 }
656
657 async fn token_details(&self, _t: &str) -> Result<TokenInfo, FinamError> {
658 Ok(TokenInfo {
659 ttl: self.details_ttl,
660 account_ids: vec![],
661 readonly: false,
662 })
663 }
664 }
665
666 async fn ttl_of(jwt_ttl: i64, details_ttl: Option<u64>) -> Option<Duration> {
667 let api = Arc::new(JwtApi {
668 jwt_ttl,
669 details_ttl: details_ttl.map(Duration::from_secs),
670 });
671 TokenManager::start(api, "secret", "")
672 .await
673 .unwrap()
674 .info()
675 .ttl
676 }
677
678 #[tokio::test]
679 async fn the_lifetime_is_the_shorter_of_the_details_and_the_claims() {
680 assert_eq!(ttl_of(600, Some(900)).await, Some(Duration::from_secs(600)));
682 assert_eq!(ttl_of(900, Some(300)).await, Some(Duration::from_secs(300)));
684 assert_eq!(ttl_of(600, None).await, Some(Duration::from_secs(600)));
686 assert_eq!(ttl_of(-5, Some(120)).await, Some(Duration::from_secs(120)));
688 }
689
690 struct HangApi {
692 auth_hangs: bool,
693 }
694
695 #[async_trait]
696 impl TokenApi for HangApi {
697 async fn auth(&self, _s: &str, _a: &str) -> Result<String, FinamError> {
698 if self.auth_hangs {
699 std::future::pending::<()>().await;
700 }
701 Ok("jwt-1".into())
702 }
703
704 async fn token_details(&self, _t: &str) -> Result<TokenInfo, FinamError> {
705 std::future::pending::<()>().await;
706 unreachable!()
707 }
708 }
709
710 #[tokio::test(start_paused = true)]
711 async fn an_auth_call_that_never_answers_ends_with_a_deadline() {
712 let started = tokio::time::Instant::now();
713 let err = TokenManager::start(Arc::new(HangApi { auth_hangs: true }), "secret", "")
714 .await
715 .unwrap_err();
716 assert!(
717 matches!(
718 err,
719 FinamError::Status { code: tonic::Code::DeadlineExceeded, ref message } if message.contains("Auth")
720 ),
721 "{err:?}"
722 );
723 assert_eq!(started.elapsed(), AUTH_TIMEOUT);
724 }
725
726 #[tokio::test(start_paused = true)]
727 async fn token_details_that_never_answer_do_not_stop_the_start() {
728 let m = TokenManager::start(Arc::new(HangApi { auth_hangs: false }), "secret", "")
730 .await
731 .unwrap();
732 assert_eq!(&*m.current(), "jwt-1");
733 assert!(m.account_ids().is_empty());
734 assert_eq!(m.info().ttl, None);
736 }
737
738 #[tokio::test(start_paused = true)]
739 async fn a_hanging_renewal_does_not_hold_the_lock_for_ever() {
740 struct Flip(std::sync::atomic::AtomicBool);
743 #[async_trait]
744 impl TokenApi for Flip {
745 async fn auth(&self, _s: &str, _a: &str) -> Result<String, FinamError> {
746 if self.0.load(Ordering::SeqCst) {
747 std::future::pending::<()>().await;
748 }
749 Ok("jwt-1".into())
750 }
751 async fn token_details(&self, _t: &str) -> Result<TokenInfo, FinamError> {
752 Ok(TokenInfo::default())
753 }
754 }
755 let api = Arc::new(Flip(std::sync::atomic::AtomicBool::new(false)));
756 let m = TokenManager::start(api.clone(), "secret", "")
757 .await
758 .unwrap();
759 api.0.store(true, Ordering::SeqCst);
760 let err = m.refresh().await.unwrap_err();
761 assert!(matches!(
762 err,
763 FinamError::Status {
764 code: tonic::Code::DeadlineExceeded,
765 ..
766 }
767 ));
768 assert_eq!(&*m.current(), "jwt-1");
769 assert!(m.refresh().await.is_err());
771 }
772}