1mod authenticator;
14mod device;
15
16use std::fmt;
17use std::future::Future;
18use std::pin::Pin;
19use std::sync::Mutex;
20use std::time::{Duration, SystemTime};
21
22use serde::{Deserialize, Serialize};
23
24pub use authenticator::{AuthStatus, Authenticator};
25pub use device::{DeviceCode, DeviceFlow, Login};
26
27pub const AUTH_BASE: &str = "https://auth.tidal.com/v1/oauth2";
29pub const API_BASE: &str = "https://api.tidal.com/v1";
31pub const CLIENT_ID: &str = "fX2JxdmntZWK0ixT";
33pub const CLIENT_SECRET: &str = "1Nn9AfDAjxrgJFJbKNWLeAyKGVGmINuXPPLHVXAvxAg=";
35pub const PKCE_CLIENT_ID: &str = "6BDSRdpK9hqEBTgU";
39pub const PKCE_CLIENT_SECRET: &str = "xeuPmY7nbpZ9IIbLAcQ93shka1VNheUAqN6IcszjTG8=";
42pub const SCOPE: &str = "r_usr w_usr w_sub";
44
45pub const DEVICE_CODE_GRANT: &str = "urn:ietf:params:oauth:grant-type:device_code";
47pub const REFRESH_MARGIN: Duration = Duration::from_secs(60);
49pub const SLOW_DOWN_STEP: Duration = Duration::from_secs(5);
51pub const LOST_RECHECK: Duration = Duration::from_secs(5);
53
54#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
56pub struct Session {
57 pub access_token: String,
58 pub refresh_token: String,
59 pub expires_at: SystemTime,
61 pub user_id: u64,
62 pub country_code: String,
63}
64
65impl fmt::Debug for Session {
67 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
68 f.debug_struct("Session")
69 .field("access_token", &"<redacted>")
70 .field("refresh_token", &"<redacted>")
71 .field("expires_at", &self.expires_at)
72 .field("user_id", &self.user_id)
73 .field("country_code", &self.country_code)
74 .finish()
75 }
76}
77
78#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
80pub enum StoreError {
81 #[error("session store unavailable: {0}")]
84 Unavailable(String),
85 #[error("wrong passphrase for the session file")]
87 WrongPassphrase,
88 #[error("session store error: {0}")]
90 Other(String),
91}
92
93pub trait SessionStore: Send + Sync {
96 fn load(&self) -> Result<Option<Session>, StoreError>;
99 fn save(&self, session: &Session) -> Result<(), StoreError>;
101 fn delete(&self) -> Result<(), StoreError>;
103}
104
105#[derive(Debug, Default)]
107pub struct MemoryStore {
108 session: Mutex<Option<Session>>,
109}
110
111impl MemoryStore {
112 pub fn with_session(session: Session) -> Self {
114 Self {
115 session: Mutex::new(Some(session)),
116 }
117 }
118}
119
120impl SessionStore for MemoryStore {
121 fn load(&self) -> Result<Option<Session>, StoreError> {
122 Ok(self.session.lock().expect("poisoned").clone())
123 }
124
125 fn save(&self, session: &Session) -> Result<(), StoreError> {
126 *self.session.lock().expect("poisoned") = Some(session.clone());
127 Ok(())
128 }
129
130 fn delete(&self) -> Result<(), StoreError> {
131 *self.session.lock().expect("poisoned") = None;
132 Ok(())
133 }
134}
135
136#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
139pub enum AuthError {
140 #[error("network error talking to Tidal: {0}")]
142 Transport(String),
143 #[error("unexpected HTTP status {status}{}", code.as_deref().map(|c| format!(" ({c})")).unwrap_or_default())]
147 Http { status: u16, code: Option<String> },
148 #[error("malformed {0} response from Tidal")]
150 Decode(&'static str),
151 #[error("Login code expired, run \"tidal-player login\" again")]
153 CodeExpired,
154 #[error("Login was denied")]
156 Denied,
157 #[error("Tidal's login response has no {0}")]
159 SessionInfo(&'static str),
160 #[error("Session expired: run \"tidal-player login\"")]
162 LoginRequired,
163 #[error("Tidal rejected the access token (HTTP 401) after a refresh")]
165 Unauthorized,
166 #[error(transparent)]
168 Store(#[from] StoreError),
169}
170
171impl AuthError {
172 pub fn is_transient(&self) -> bool {
175 match self {
176 Self::Transport(_) => true,
177 Self::Http { status, .. } => *status == 429 || *status >= 500,
178 _ => false,
179 }
180 }
181
182 pub(crate) fn transport(error: &reqwest::Error) -> Self {
185 Self::Transport(error.to_string())
186 }
187}
188
189pub trait Clock: Send + Sync {
191 fn now(&self) -> SystemTime;
192}
193
194pub type BoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
196
197pub trait Sleeper: Send + Sync {
199 fn sleep(&self, duration: Duration) -> BoxFuture<'_, ()>;
200}
201
202#[derive(Debug, Clone, Copy, Default)]
204pub struct SystemClock;
205
206impl Clock for SystemClock {
207 fn now(&self) -> SystemTime {
208 SystemTime::now()
209 }
210}
211
212impl Sleeper for SystemClock {
213 fn sleep(&self, duration: Duration) -> BoxFuture<'_, ()> {
214 Box::pin(tokio::time::sleep(duration))
215 }
216}
217
218#[derive(Debug)]
221pub struct ManualClock {
222 now: Mutex<SystemTime>,
223 sleeps: Mutex<Vec<Duration>>,
224}
225
226impl ManualClock {
227 pub fn new(start: SystemTime) -> Self {
229 Self {
230 now: Mutex::new(start),
231 sleeps: Mutex::new(Vec::new()),
232 }
233 }
234
235 pub fn advance(&self, by: Duration) {
237 *self.now.lock().expect("poisoned") += by;
238 }
239
240 pub fn sleeps(&self) -> Vec<Duration> {
242 self.sleeps.lock().expect("poisoned").clone()
243 }
244}
245
246impl Clock for ManualClock {
247 fn now(&self) -> SystemTime {
248 *self.now.lock().expect("poisoned")
249 }
250}
251
252impl Sleeper for ManualClock {
253 fn sleep(&self, duration: Duration) -> BoxFuture<'_, ()> {
254 self.sleeps.lock().expect("poisoned").push(duration);
255 self.advance(duration);
256 Box::pin(std::future::ready(()))
257 }
258}
259
260#[derive(Debug, Clone, PartialEq, Eq)]
262pub struct AuthConfig {
263 pub auth_base: String,
265 pub api_base: String,
267 pub client_id: String,
269 pub client_secret: String,
270 pub pkce_client_id: String,
272 pub pkce_client_secret: String,
273}
274
275impl AuthConfig {
276 pub fn production() -> Self {
278 Self::with_bases(AUTH_BASE, API_BASE)
279 }
280
281 pub fn with_bases(auth_base: impl Into<String>, api_base: impl Into<String>) -> Self {
283 Self {
284 auth_base: auth_base.into(),
285 api_base: api_base.into(),
286 client_id: CLIENT_ID.into(),
287 client_secret: CLIENT_SECRET.into(),
288 pkce_client_id: PKCE_CLIENT_ID.into(),
289 pkce_client_secret: PKCE_CLIENT_SECRET.into(),
290 }
291 }
292
293 pub(crate) fn auth_url(&self, endpoint: &str) -> String {
294 join(&self.auth_base, endpoint)
295 }
296
297 pub(crate) fn api_url(&self, path: &str) -> String {
298 join(&self.api_base, path)
299 }
300}
301
302#[derive(Debug, Clone, Copy, PartialEq, Eq)]
304pub enum RefreshClient {
305 Pkce,
307 DeviceFlow,
310}
311
312pub const LOSSY_WARNING: &str = "Could not switch this session to the PKCE client: \
316 CD-quality tracks will stream as AAC instead of FLAC";
317
318pub(crate) async fn post_refresh(
324 http: &reqwest::Client,
325 config: &AuthConfig,
326 client: RefreshClient,
327 refresh_token: &str,
328) -> Result<(u16, String), AuthError> {
329 let (id, secret) = match client {
330 RefreshClient::Pkce => (&config.pkce_client_id, &config.pkce_client_secret),
331 RefreshClient::DeviceFlow => (&config.client_id, &config.client_secret),
332 };
333 let response = http
334 .post(config.auth_url("token"))
335 .header("content-type", "application/x-www-form-urlencoded")
336 .body(form_body(&[
337 ("client_id", id),
338 ("client_secret", secret),
339 ("grant_type", "refresh_token"),
340 ("refresh_token", refresh_token),
341 ("scope", SCOPE),
342 ]))
343 .send()
344 .await
345 .map_err(|e| AuthError::transport(&e))?;
346 let status = response.status().as_u16();
347 let body = response
348 .text()
349 .await
350 .map_err(|e| AuthError::transport(&e))?;
351 Ok((status, body))
352}
353
354pub(crate) fn rejects_client(status: u16, body: &str) -> bool {
357 matches!(
358 (status, error_code(body)),
359 (400 | 401, Some("invalid_client" | "unauthorized_client"))
360 )
361}
362
363fn join(base: &str, path: &str) -> String {
365 format!(
366 "{}/{}",
367 base.trim_end_matches('/'),
368 path.trim_start_matches('/')
369 )
370}
371
372pub(crate) fn form_body(pairs: &[(&str, &str)]) -> String {
374 let mut url = reqwest::Url::parse("http://form.invalid/").expect("static URL");
375 url.query_pairs_mut().extend_pairs(pairs);
376 url.query().unwrap_or_default().to_owned()
377}
378
379#[derive(Clone, PartialEq, Eq, Deserialize)]
382pub struct TokenGrant {
383 access_token: String,
384 #[serde(default)]
385 refresh_token: Option<String>,
386 expires_in: u64,
387 #[serde(default)]
388 user_id: Option<u64>,
389 #[serde(default)]
390 user: Option<GrantUser>,
391}
392
393#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
394struct GrantUser {
395 #[serde(rename = "countryCode", default)]
396 country_code: Option<String>,
397}
398
399impl fmt::Debug for TokenGrant {
400 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
401 f.debug_struct("TokenGrant")
402 .field("access_token", &"<redacted>")
403 .field(
404 "refresh_token",
405 &self.refresh_token.as_ref().map(|_| "<redacted>"),
406 )
407 .field("expires_in", &self.expires_in)
408 .field("user_id", &self.user_id)
409 .field("user", &self.user)
410 .finish()
411 }
412}
413
414impl TokenGrant {
415 pub fn from_json(body: &str) -> Result<Self, AuthError> {
417 serde_json::from_str(body).map_err(|_| AuthError::Decode("token"))
418 }
419
420 fn expires_at(&self, now: SystemTime) -> SystemTime {
421 now + Duration::from_secs(self.expires_in)
422 }
423
424 fn account(&self) -> Result<(u64, String), AuthError> {
426 let user_id = self.user_id.ok_or(AuthError::SessionInfo("user ID"))?;
427 let country = self
428 .country_code()
429 .ok_or(AuthError::SessionInfo("country code"))?;
430 Ok((user_id, country))
431 }
432
433 fn country_code(&self) -> Option<String> {
435 self.user
436 .as_ref()?
437 .country_code
438 .clone()
439 .filter(|country| !country.is_empty())
440 }
441}
442
443pub fn session_from_login(grant: &TokenGrant, now: SystemTime) -> Result<Session, AuthError> {
446 let (user_id, country_code) = grant.account()?;
447 Ok(Session {
448 access_token: grant.access_token.clone(),
449 refresh_token: grant.refresh_token.clone().unwrap_or_default(),
450 expires_at: grant.expires_at(now),
451 user_id,
452 country_code,
453 })
454}
455
456pub fn session_from_refresh(previous: &Session, grant: &TokenGrant, now: SystemTime) -> Session {
460 let (user_id, country_code) = match grant.account() {
461 Ok(account) => account,
462 Err(error) => {
463 tracing::warn!(%error, "refresh response lacks account data; keeping the previous values");
464 (previous.user_id, previous.country_code.clone())
465 }
466 };
467 Session {
468 access_token: grant.access_token.clone(),
469 refresh_token: grant
470 .refresh_token
471 .clone()
472 .unwrap_or_else(|| previous.refresh_token.clone()),
473 expires_at: grant.expires_at(now),
474 user_id,
475 country_code,
476 }
477}
478
479#[derive(Debug, Clone, PartialEq, Eq)]
481pub enum PollOutcome {
482 Pending { interval: Duration },
484 Granted(TokenGrant),
486}
487
488const KNOWN_ERROR_CODES: [&str; 10] = [
492 "authorization_pending",
493 "slow_down",
494 "expired_token",
495 "access_denied",
496 "invalid_grant",
497 "invalid_client",
498 "invalid_request",
499 "invalid_scope",
500 "unauthorized_client",
501 "unsupported_grant_type",
502];
503
504pub(crate) fn error_code(body: &str) -> Option<&'static str> {
506 #[derive(Deserialize)]
507 struct ErrorBody {
508 error: Option<String>,
509 }
510 let error = serde_json::from_str::<ErrorBody>(body).ok()?.error?;
511 KNOWN_ERROR_CODES.into_iter().find(|known| *known == error)
512}
513
514pub fn classify_poll(
518 status: u16,
519 body: &str,
520 interval: Duration,
521) -> Result<PollOutcome, AuthError> {
522 if (200..300).contains(&status) {
523 return TokenGrant::from_json(body).map(PollOutcome::Granted);
524 }
525 if status == 429 || status >= 500 {
526 return Ok(PollOutcome::Pending { interval });
527 }
528 match error_code(body) {
529 Some("authorization_pending") => Ok(PollOutcome::Pending { interval }),
530 Some("slow_down") => Ok(PollOutcome::Pending {
531 interval: interval + SLOW_DOWN_STEP,
532 }),
533 Some("expired_token") => Err(AuthError::CodeExpired),
534 Some("access_denied") => Err(AuthError::Denied),
535 code => Err(AuthError::Http {
536 status,
537 code: code.map(Into::into),
538 }),
539 }
540}
541
542#[derive(Debug, Clone, Copy, PartialEq, Eq)]
544pub enum RefreshFailure {
545 SessionLost,
547 Transient,
549}
550
551pub fn classify_refresh_failure(status: u16, body: &str) -> RefreshFailure {
554 match (status, error_code(body)) {
555 (400 | 401, Some("invalid_grant" | "invalid_client" | "unauthorized_client")) => {
558 RefreshFailure::SessionLost
559 }
560 (401, _) if body.trim().is_empty() => RefreshFailure::SessionLost,
561 _ => RefreshFailure::Transient,
564 }
565}
566
567pub fn needs_refresh(session: &Session, now: SystemTime) -> bool {
570 match session.expires_at.duration_since(now) {
571 Ok(left) => left < REFRESH_MARGIN,
572 Err(_) => true,
573 }
574}
575
576#[cfg(test)]
577mod tests {
578 use super::*;
579 use std::sync::Arc;
580 use wiremock::matchers::{body_string_contains, method, path};
581 use wiremock::{Mock, MockServer, ResponseTemplate};
582
583 const T0: Duration = Duration::from_secs(1_800_000_000);
584
585 fn t0() -> SystemTime {
586 SystemTime::UNIX_EPOCH + T0
587 }
588
589 fn fixture(name: &str) -> &'static str {
590 match name {
591 "token_pending" => include_str!("../tests/fixtures/auth/token_pending.json"),
592 "token_slow_down" => include_str!("../tests/fixtures/auth/token_slow_down.json"),
593 "token_expired" => include_str!("../tests/fixtures/auth/token_expired.json"),
594 "token_denied" => include_str!("../tests/fixtures/auth/token_denied.json"),
595 "token_granted" => include_str!("../tests/fixtures/auth/token_granted.json"),
596 "token_granted_no_user" => {
597 include_str!("../tests/fixtures/auth/token_granted_no_user.json")
598 }
599 "token_granted_empty_country" => {
600 include_str!("../tests/fixtures/auth/token_granted_empty_country.json")
601 }
602 "token_granted_no_user_id" => {
603 include_str!("../tests/fixtures/auth/token_granted_no_user_id.json")
604 }
605 "token_refreshed" => include_str!("../tests/fixtures/auth/token_refreshed.json"),
606 "token_refreshed_rotated" => {
607 include_str!("../tests/fixtures/auth/token_refreshed_rotated.json")
608 }
609 "token_refreshed_country_changed" => {
610 include_str!("../tests/fixtures/auth/token_refreshed_country_changed.json")
611 }
612 "token_refreshed_no_user" => {
613 include_str!("../tests/fixtures/auth/token_refreshed_no_user.json")
614 }
615 "refresh_invalid_grant" => {
616 include_str!("../tests/fixtures/auth/refresh_invalid_grant.json")
617 }
618 "refresh_invalid_client" => {
619 include_str!("../tests/fixtures/auth/refresh_invalid_client.json")
620 }
621 "refresh_unauthorized_client" => {
622 include_str!("../tests/fixtures/auth/refresh_unauthorized_client.json")
623 }
624 "echoing_error" => include_str!("../tests/fixtures/auth/echoing_error.json"),
625 "echoing_malformed_grant" => {
626 include_str!("../tests/fixtures/auth/echoing_malformed_grant.json")
627 }
628 other => panic!("unknown fixture {other}"),
629 }
630 }
631
632 fn session(access: &str, refresh: &str, expires_at: SystemTime) -> Session {
633 Session {
634 access_token: access.into(),
635 refresh_token: refresh.into(),
636 expires_at,
637 user_id: 1,
638 country_code: "FI".into(),
639 }
640 }
641
642 #[test]
643 fn ac2_poll_outcome() {
644 let two = Duration::from_secs(2);
645 let granted = TokenGrant::from_json(fixture("token_granted")).unwrap();
646 let cases: [(&str, u16, &str, Result<PollOutcome, AuthError>); 8] = [
647 (
648 "pending keeps the interval",
649 400,
650 "token_pending",
651 Ok(PollOutcome::Pending { interval: two }),
652 ),
653 (
654 "slow_down adds 5 s",
655 400,
656 "token_slow_down",
657 Ok(PollOutcome::Pending {
658 interval: Duration::from_secs(7),
659 }),
660 ),
661 (
662 "expired_token",
663 400,
664 "token_expired",
665 Err(AuthError::CodeExpired),
666 ),
667 ("access_denied", 400, "token_denied", Err(AuthError::Denied)),
668 (
669 "granted",
670 200,
671 "token_granted",
672 Ok(PollOutcome::Granted(granted)),
673 ),
674 (
675 "5xx is not yet",
676 503,
677 "token_pending",
678 Ok(PollOutcome::Pending { interval: two }),
679 ),
680 (
681 "other OAuth error",
682 401,
683 "refresh_invalid_client",
684 Err(AuthError::Http {
685 status: 401,
686 code: Some("invalid_client".into()),
687 }),
688 ),
689 (
690 "malformed 200",
691 200,
692 "echoing_malformed_grant",
693 Err(AuthError::Decode("token")),
694 ),
695 ];
696 for (name, status, body, expected) in cases {
697 assert_eq!(
698 classify_poll(status, fixture(body), two),
699 expected,
700 "{name}"
701 );
702 }
703 }
704
705 #[tokio::test]
708 async fn ac3_session_from_token_response() {
709 type Account = Result<(u64, &'static str), AuthError>;
710 let cases: [(&str, Account); 4] = [
711 ("token_granted", Ok((1, "FI"))),
712 (
713 "token_granted_no_user",
714 Err(AuthError::SessionInfo("country code")),
715 ),
716 (
717 "token_granted_empty_country",
718 Err(AuthError::SessionInfo("country code")),
719 ),
720 (
721 "token_granted_no_user_id",
722 Err(AuthError::SessionInfo("user ID")),
723 ),
724 ];
725 for (body, expected) in cases {
726 let server = MockServer::start().await;
727 Mock::given(method("POST"))
730 .and(path("/oauth2/token"))
731 .and(body_string_contains("device_code=FAKE-DEVICE-CODE"))
732 .respond_with(
733 ResponseTemplate::new(200).set_body_raw(fixture(body), "application/json"),
734 )
735 .expect(1)
736 .mount(&server)
737 .await;
738 Mock::given(path("/v1/sessions"))
739 .respond_with(ResponseTemplate::new(500))
740 .expect(0)
741 .mount(&server)
742 .await;
743 let clock = Arc::new(ManualClock::new(t0()));
744 let config = AuthConfig::with_bases(
745 format!("{}/oauth2", server.uri()),
746 format!("{}/v1", server.uri()),
747 );
748 let flow = DeviceFlow::with_time(config, clock.clone(), clock).unwrap();
749 let code = DeviceCode {
750 device_code: "FAKE-DEVICE-CODE".into(),
751 user_code: "ABCDE".into(),
752 verification_uri: "link.tidal.com".into(),
753 verification_uri_complete: None,
754 expires_in: Duration::from_secs(300),
755 interval: Duration::from_secs(2),
756 };
757 let store = MemoryStore::default();
758 let result = flow
759 .complete_login(&code, &store)
760 .await
761 .map(|login| login.session);
762 match expected {
763 Ok((user_id, country)) => {
764 let session = result.unwrap_or_else(|e| panic!("{body}: {e}"));
765 assert_eq!(
766 (session.user_id, session.country_code.as_str()),
767 (user_id, country),
768 "{body}"
769 );
770 assert_eq!(session.expires_at, t0() + Duration::from_secs(14_400));
771 assert_eq!(store.load(), Ok(Some(session)), "{body}: stored");
772 }
773 Err(error) => {
774 assert_eq!(result, Err(error), "{body}");
775 assert_eq!(store.load(), Ok(None), "{body}: nothing stored");
776 }
777 }
778 server.verify().await;
779 }
780
781 let previous = session("FAKE-ACCESS", "FAKE-REFRESH", t0());
784 let refresh_cases = [
785 ("token_refreshed_country_changed", 1, "SE"),
786 ("token_refreshed_no_user", 1, "FI"),
787 ("token_refreshed", 1, "FI"),
788 ];
789 for (body, user_id, country) in refresh_cases {
790 let grant = TokenGrant::from_json(fixture(body)).unwrap();
791 let refreshed = session_from_refresh(&previous, &grant, t0());
792 assert_eq!(
793 (
794 refreshed.access_token.as_str(),
795 refreshed.user_id,
796 refreshed.country_code.as_str()
797 ),
798 ("FAKE-ACCESS-2", user_id, country),
799 "{body}"
800 );
801 }
802 }
803
804 #[test]
805 fn ac4_needs_refresh() {
806 let cases = [
807 ("far future", Duration::from_secs(14_400), 0, false),
808 ("exactly 60 s", Duration::from_secs(60), 0, false),
809 ("59 s", Duration::from_secs(59), 0, true),
810 ("expires now", Duration::ZERO, 0, true),
811 ("expired", Duration::ZERO, 10, true),
812 ];
813 for (name, expires_in, elapsed, expected) in cases {
814 let s = session("FAKE-ACCESS", "FAKE-REFRESH", t0() + expires_in);
815 let now = t0() + Duration::from_secs(elapsed);
816 assert_eq!(needs_refresh(&s, now), expected, "{name}");
817 }
818 }
819
820 #[test]
821 fn ac6_refresh_token_kept_or_replaced() {
822 let previous = session("FAKE-ACCESS", "FAKE-REFRESH", t0());
823 let cases = [
824 ("token_refreshed", "FAKE-REFRESH"),
825 ("token_refreshed_rotated", "FAKE-REFRESH-2"),
826 ];
827 for (body, refresh) in cases {
828 let grant = TokenGrant::from_json(fixture(body)).unwrap();
829 let refreshed = session_from_refresh(&previous, &grant, t0());
830 assert_eq!(refreshed.refresh_token, refresh, "{body}");
831 assert_eq!(refreshed.access_token, "FAKE-ACCESS-2", "{body}");
832 assert_eq!(refreshed.expires_at, t0() + Duration::from_secs(14_400));
833 }
834 }
835
836 #[test]
837 fn ac7_classify_refresh_failure() {
838 use RefreshFailure::{SessionLost, Transient};
839 let cases = [
840 (400, "refresh_invalid_grant", SessionLost),
841 (401, "refresh_invalid_grant", SessionLost),
842 (400, "refresh_invalid_client", SessionLost),
843 (401, "refresh_invalid_client", SessionLost),
844 (400, "refresh_unauthorized_client", SessionLost),
845 (401, "refresh_unauthorized_client", SessionLost),
846 (401, "", SessionLost),
847 (500, "", Transient),
848 (502, "refresh_invalid_grant", Transient),
849 (503, "", Transient),
850 (429, "", Transient),
851 (400, "token_pending", Transient),
853 (403, "", Transient),
854 ];
855 for (status, body, expected) in cases {
856 let body = if body.is_empty() { "" } else { fixture(body) };
857 assert_eq!(
858 classify_refresh_failure(status, body),
859 expected,
860 "{status} {body}"
861 );
862 }
863 }
864
865 #[test]
867 fn ac19_warning_names_no_cause() {
868 assert!(LOSSY_WARNING.contains("AAC"), "{LOSSY_WARNING}");
869 for cause in ["did not accept", "rejected", "refused"] {
870 assert!(!LOSSY_WARNING.contains(cause), "{LOSSY_WARNING}");
871 }
872 }
873
874 #[test]
875 fn ac15_no_tokens_in_debug_or_errors() {
876 let s = session("FAKE-ACCESS", "FAKE-REFRESH", t0());
877 let debug = format!("{s:?}");
878 assert!(!debug.contains("FAKE-ACCESS"), "{debug}");
879 assert!(!debug.contains("FAKE-REFRESH"), "{debug}");
880 assert!(debug.contains("<redacted>"), "{debug}");
881
882 let grant = TokenGrant::from_json(fixture("token_granted")).unwrap();
883 let debug = format!("{grant:?} {:?}", PollOutcome::Granted(grant.clone()));
884 assert!(!debug.contains("FAKE-ACCESS"), "{debug}");
885 assert!(!debug.contains("FAKE-REFRESH"), "{debug}");
886
887 let two = Duration::from_secs(2);
889 let echoed = [
890 classify_poll(400, fixture("echoing_error"), two).unwrap_err(),
891 classify_poll(200, fixture("echoing_malformed_grant"), two).unwrap_err(),
892 TokenGrant::from_json(fixture("echoing_malformed_grant")).unwrap_err(),
893 ];
894 let mut errors = vec![
895 AuthError::Transport("connection refused".into()),
896 AuthError::Http {
897 status: 500,
898 code: None,
899 },
900 AuthError::Decode("token"),
901 AuthError::CodeExpired,
902 AuthError::Denied,
903 AuthError::SessionInfo("country code"),
904 AuthError::LoginRequired,
905 AuthError::Unauthorized,
906 AuthError::Store(StoreError::WrongPassphrase),
907 ];
908 errors.extend(echoed);
909 for error in &errors {
910 match error {
912 AuthError::Transport(_)
913 | AuthError::Http { .. }
914 | AuthError::Decode(_)
915 | AuthError::CodeExpired
916 | AuthError::Denied
917 | AuthError::SessionInfo(_)
918 | AuthError::LoginRequired
919 | AuthError::Unauthorized
920 | AuthError::Store(_) => {}
921 }
922 for text in [error.to_string(), format!("{error:?}")] {
923 assert!(!text.contains("FAKE-ACCESS"), "{text}");
924 assert!(!text.contains("FAKE-REFRESH"), "{text}");
925 }
926 }
927 }
928
929 #[test]
930 fn memory_store_round_trip() {
931 let session = session(
932 "FAKE-ACCESS",
933 "FAKE-REFRESH",
934 SystemTime::UNIX_EPOCH + Duration::from_secs(14_400),
935 );
936 let store = MemoryStore::default();
937 assert_eq!(store.load(), Ok(None));
938 store.save(&session).unwrap();
939 assert_eq!(store.load(), Ok(Some(session)));
940 store.delete().unwrap();
941 store.delete().unwrap();
942 assert_eq!(store.load(), Ok(None));
943 }
944
945 #[test]
946 fn form_body_encodes() {
947 assert_eq!(
948 form_body(&[("scope", SCOPE), ("grant_type", DEVICE_CODE_GRANT)]),
949 "scope=r_usr+w_usr+w_sub&grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code"
950 );
951 }
952}