Skip to main content

ratune_scrobble/
auth.rs

1//! Browser authentication flow for obtaining a session key.
2
3use std::collections::BTreeMap;
4
5use anyhow::{bail, Context, Result};
6use reqwest::Client;
7use serde_json::Value;
8
9use crate::lastfm::{api_sig, ScrobbleService};
10
11/// Application credentials only — no session key yet.
12#[derive(Debug, Clone)]
13pub struct AuthClient {
14    http: Client,
15    api_key: String,
16    api_secret: String,
17    service: ScrobbleService,
18}
19
20impl AuthClient {
21    pub fn new(service: ScrobbleService, api_key: String, api_secret: String) -> Self {
22        Self {
23            http: Client::new(),
24            api_key,
25            api_secret,
26            service,
27        }
28    }
29
30    pub fn service(&self) -> ScrobbleService {
31        self.service
32    }
33
34    /// Step 1: request a short-lived token from the API.
35    pub async fn get_token(&self) -> Result<String> {
36        let mut params = BTreeMap::new();
37        params.insert("method".into(), "auth.getToken".into());
38        params.insert("api_key".into(), self.api_key.clone());
39        params.insert("format".into(), "json".into());
40
41        let body = self.post_signed(params).await?;
42        body.get("token")
43            .and_then(|t| t.as_str())
44            .map(str::to_string)
45            .context("auth.getToken response missing token")
46    }
47
48    /// Step 2: URL the user must open in a browser to approve the app.
49    pub fn authorize_url(&self, token: &str) -> String {
50        let base = match self.service {
51            ScrobbleService::LastFm => "https://www.last.fm/api/auth/",
52            ScrobbleService::LibreFm => "https://libre.fm/api/auth/",
53        };
54        format!("{base}?api_key={}&token={token}", self.api_key)
55    }
56
57    /// Step 3: exchange the approved token for a permanent session key.
58    pub async fn get_session(&self, token: &str) -> Result<AuthSession> {
59        let mut params = BTreeMap::new();
60        params.insert("method".into(), "auth.getSession".into());
61        params.insert("api_key".into(), self.api_key.clone());
62        params.insert("token".into(), token.to_string());
63        params.insert("format".into(), "json".into());
64
65        let body = self.post_signed(params).await?;
66        let session = body
67            .get("session")
68            .context("auth.getSession response missing session")?;
69        let key = session
70            .get("key")
71            .and_then(|k| k.as_str())
72            .context("session missing key")?
73            .to_string();
74        let name = session
75            .get("name")
76            .and_then(|n| n.as_str())
77            .unwrap_or("")
78            .to_string();
79        Ok(AuthSession {
80            key,
81            username: name,
82        })
83    }
84
85    async fn post_signed(&self, mut params: BTreeMap<String, String>) -> Result<Value> {
86        let sig = api_sig(&params, &self.api_secret);
87        params.insert("api_sig".into(), sig);
88
89        let resp = self
90            .http
91            .post(self.service.api_base())
92            .form(&params)
93            .send()
94            .await
95            .with_context(|| format!("POST {}", self.service.api_base()))?;
96
97        let status = resp.status();
98        let body: Value = resp
99            .json()
100            .await
101            .with_context(|| format!("parsing {} response", self.service.display_name()))?;
102
103        if let Some(err) = body.get("error") {
104            let code = err.as_i64().unwrap_or(-1);
105            let message = body
106                .get("message")
107                .and_then(|m| m.as_str())
108                .unwrap_or("unknown error");
109            bail!(
110                "{} API error {code}: {message}",
111                self.service.display_name()
112            );
113        }
114
115        if !status.is_success() {
116            bail!("{} API HTTP {status}", self.service.display_name());
117        }
118
119        Ok(body)
120    }
121}
122
123/// Result of a successful `auth.getSession` call.
124#[derive(Debug, Clone)]
125pub struct AuthSession {
126    pub key: String,
127    pub username: String,
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133
134    #[test]
135    fn authorize_url_lastfm() {
136        let client = AuthClient::new(ScrobbleService::LastFm, "abc123".into(), "secret".into());
137        assert_eq!(
138            client.authorize_url("tok"),
139            "https://www.last.fm/api/auth/?api_key=abc123&token=tok"
140        );
141    }
142
143    #[test]
144    fn authorize_url_librefm() {
145        let client = AuthClient::new(ScrobbleService::LibreFm, "abc123".into(), "secret".into());
146        assert_eq!(
147            client.authorize_url("tok"),
148            "https://libre.fm/api/auth/?api_key=abc123&token=tok"
149        );
150    }
151}