spotify_cli/oauth/
token.rs

1//! OAuth token types and expiry handling.
2
3use serde::{Deserialize, Serialize};
4use std::time::{Duration, SystemTime, UNIX_EPOCH};
5
6/// OAuth token with expiry tracking.
7///
8/// Stores both access and refresh tokens, along with the absolute expiry timestamp.
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct Token {
11    /// Bearer token for API requests.
12    pub access_token: String,
13    /// Token type (always "Bearer" for Spotify).
14    pub token_type: String,
15    /// Space-separated list of granted scopes.
16    pub scope: String,
17    /// Unix timestamp when the access token expires.
18    pub expires_at: u64,
19    /// Token used to obtain new access tokens.
20    pub refresh_token: Option<String>,
21}
22
23/// Raw token response from Spotify's token endpoint.
24#[derive(Debug, Deserialize)]
25pub struct SpotifyTokenResponse {
26    pub access_token: String,
27    pub token_type: String,
28    pub scope: String,
29    /// Seconds until token expires.
30    pub expires_in: u64,
31    pub refresh_token: Option<String>,
32}
33
34impl Token {
35    /// Create a token from Spotify's API response.
36    ///
37    /// Converts the relative `expires_in` to an absolute timestamp.
38    pub fn from_response(response: SpotifyTokenResponse) -> Self {
39        let expires_at = current_timestamp() + response.expires_in;
40
41        Self {
42            access_token: response.access_token,
43            token_type: response.token_type,
44            scope: response.scope,
45            expires_at,
46            refresh_token: response.refresh_token,
47        }
48    }
49
50    /// Check if the token is expired or about to expire.
51    ///
52    /// Returns true if the token expires within the buffer period.
53    pub fn is_expired(&self) -> bool {
54        use crate::constants::TOKEN_EXPIRY_BUFFER_SECS;
55        let now = current_timestamp();
56
57        now + TOKEN_EXPIRY_BUFFER_SECS >= self.expires_at
58    }
59
60    /// Get seconds until the token expires.
61    ///
62    /// Returns negative value if already expired.
63    pub fn seconds_until_expiry(&self) -> i64 {
64        let now = current_timestamp();
65        self.expires_at as i64 - now as i64
66    }
67}
68
69fn current_timestamp() -> u64 {
70    SystemTime::now()
71        .duration_since(UNIX_EPOCH)
72        .unwrap_or(Duration::ZERO)
73        .as_secs()
74}
75
76#[cfg(test)]
77mod tests {
78    use super::*;
79
80    fn make_token(expires_in: u64) -> Token {
81        let response = SpotifyTokenResponse {
82            access_token: "test_access".to_string(),
83            token_type: "Bearer".to_string(),
84            scope: "user-read-playback-state".to_string(),
85            expires_in,
86            refresh_token: Some("test_refresh".to_string()),
87        };
88
89        Token::from_response(response)
90    }
91
92    #[test]
93    fn fresh_token_is_not_expired() {
94        let token = make_token(3600);
95        assert!(!token.is_expired());
96    }
97
98    #[test]
99    fn token_expiring_soon_is_expired() {
100        let token = make_token(30);
101        assert!(token.is_expired());
102    }
103
104    #[test]
105    fn seconds_until_expiry_is_positive_for_fresh_token() {
106        let token = make_token(3600);
107        assert!(token.seconds_until_expiry() > 0);
108    }
109
110    #[test]
111    fn token_from_response_sets_fields() {
112        let response = SpotifyTokenResponse {
113            access_token: "access123".to_string(),
114            token_type: "Bearer".to_string(),
115            scope: "scope1 scope2".to_string(),
116            expires_in: 3600,
117            refresh_token: Some("refresh456".to_string()),
118        };
119
120        let token = Token::from_response(response);
121        assert_eq!(token.access_token, "access123");
122        assert_eq!(token.token_type, "Bearer");
123        assert_eq!(token.scope, "scope1 scope2");
124        assert_eq!(token.refresh_token, Some("refresh456".to_string()));
125    }
126
127    #[test]
128    fn token_from_response_without_refresh_token() {
129        let response = SpotifyTokenResponse {
130            access_token: "access123".to_string(),
131            token_type: "Bearer".to_string(),
132            scope: "scope1".to_string(),
133            expires_in: 3600,
134            refresh_token: None,
135        };
136
137        let token = Token::from_response(response);
138        assert!(token.refresh_token.is_none());
139    }
140
141    #[test]
142    fn token_serializes_to_json() {
143        let token = make_token(3600);
144        let json = serde_json::to_value(&token).unwrap();
145        assert!(json.get("access_token").is_some());
146        assert!(json.get("token_type").is_some());
147        assert!(json.get("scope").is_some());
148        assert!(json.get("expires_at").is_some());
149    }
150
151    #[test]
152    fn token_deserializes_from_json() {
153        let now = SystemTime::now()
154            .duration_since(UNIX_EPOCH)
155            .unwrap()
156            .as_secs();
157
158        let json = serde_json::json!({
159            "access_token": "access123",
160            "token_type": "Bearer",
161            "scope": "user-read-playback-state",
162            "expires_at": now + 3600,
163            "refresh_token": "refresh456"
164        });
165
166        let token: Token = serde_json::from_value(json).unwrap();
167        assert_eq!(token.access_token, "access123");
168        assert!(!token.is_expired());
169    }
170
171    #[test]
172    fn expired_token_is_expired() {
173        let now = SystemTime::now()
174            .duration_since(UNIX_EPOCH)
175            .unwrap()
176            .as_secs();
177
178        let token = Token {
179            access_token: "expired".to_string(),
180            token_type: "Bearer".to_string(),
181            scope: "scope".to_string(),
182            expires_at: now - 100, // Already expired
183            refresh_token: None,
184        };
185
186        assert!(token.is_expired());
187    }
188
189    #[test]
190    fn seconds_until_expiry_negative_for_expired() {
191        let now = SystemTime::now()
192            .duration_since(UNIX_EPOCH)
193            .unwrap()
194            .as_secs();
195
196        let token = Token {
197            access_token: "expired".to_string(),
198            token_type: "Bearer".to_string(),
199            scope: "scope".to_string(),
200            expires_at: now - 100,
201            refresh_token: None,
202        };
203
204        assert!(token.seconds_until_expiry() < 0);
205    }
206}