Skip to main content

discogs_rs/
oauth.rs

1use crate::error::{DiscogsError, Result};
2use rand::{Rng, distr::Alphanumeric};
3use reqwest::header::{AUTHORIZATION, CONTENT_TYPE, USER_AGENT};
4use std::collections::BTreeMap;
5use std::time::{SystemTime, UNIX_EPOCH};
6use url::form_urlencoded;
7
8const API_BASE: &str = "https://api.discogs.com";
9
10#[derive(Debug, Clone)]
11pub struct DiscogsOAuthClient {
12    consumer_key: String,
13    consumer_secret: String,
14    user_agent: String,
15    http: reqwest::Client,
16}
17
18#[derive(Debug, Clone)]
19pub struct RequestToken {
20    pub token: String,
21    pub token_secret: String,
22    pub callback_confirmed: bool,
23    pub authorize_url: String,
24}
25
26#[derive(Debug, Clone)]
27pub struct AccessToken {
28    pub access_token: String,
29    pub access_token_secret: String,
30}
31
32impl DiscogsOAuthClient {
33    pub fn new(
34        consumer_key: impl Into<String>,
35        consumer_secret: impl Into<String>,
36        user_agent: impl Into<String>,
37    ) -> Result<Self> {
38        Ok(Self {
39            consumer_key: consumer_key.into(),
40            consumer_secret: consumer_secret.into(),
41            user_agent: user_agent.into(),
42            http: reqwest::Client::builder().build()?,
43        })
44    }
45
46    pub async fn request_token(&self, callback_url: &str) -> Result<RequestToken> {
47        let nonce = oauth_nonce();
48        let timestamp = oauth_timestamp_seconds();
49        let callback_encoded: String =
50            form_urlencoded::byte_serialize(callback_url.as_bytes()).collect();
51
52        let header_value = format!(
53            "OAuth oauth_consumer_key=\"{}\", oauth_nonce=\"{}\", oauth_signature=\"{}&\", oauth_signature_method=\"PLAINTEXT\", oauth_timestamp=\"{}\", oauth_callback=\"{}\"",
54            self.consumer_key,
55            self.nonce_safe(&nonce),
56            self.consumer_secret,
57            timestamp,
58            callback_encoded
59        );
60
61        let response = self
62            .http
63            .get(format!("{API_BASE}/oauth/request_token"))
64            .header(USER_AGENT, &self.user_agent)
65            .header(CONTENT_TYPE, "application/x-www-form-urlencoded")
66            .header(AUTHORIZATION, header_value)
67            .send()
68            .await?;
69
70        if !response.status().is_success() {
71            let status = response.status();
72            let message = response
73                .text()
74                .await
75                .unwrap_or_else(|_| "unknown error".to_string());
76            return Err(DiscogsError::Http { status, message });
77        }
78
79        let text = response.text().await?;
80        // Discogs OAuth endpoints return URL-encoded key/value pairs instead of JSON.
81        let values = parse_oauth_form(&text);
82
83        let token = values
84            .get("oauth_token")
85            .cloned()
86            .ok_or_else(|| DiscogsError::InvalidOAuthResponse(text.clone()))?;
87        let token_secret = values
88            .get("oauth_token_secret")
89            .cloned()
90            .ok_or_else(|| DiscogsError::InvalidOAuthResponse(text.clone()))?;
91        let callback_confirmed = values
92            .get("oauth_callback_confirmed")
93            .map(|v| v == "true")
94            .unwrap_or(false);
95
96        Ok(RequestToken {
97            authorize_url: format!("https://discogs.com/oauth/authorize?oauth_token={token}"),
98            token,
99            token_secret,
100            callback_confirmed,
101        })
102    }
103
104    pub async fn access_token(
105        &self,
106        request_token: &str,
107        request_token_secret: &str,
108        verifier: &str,
109    ) -> Result<AccessToken> {
110        let nonce = oauth_nonce();
111        let timestamp = oauth_timestamp_seconds();
112
113        let header_value = format!(
114            "OAuth oauth_consumer_key=\"{}\", oauth_nonce=\"{}\", oauth_token=\"{}\", oauth_signature=\"{}&{}\", oauth_signature_method=\"PLAINTEXT\", oauth_timestamp=\"{}\", oauth_verifier=\"{}\"",
115            self.consumer_key,
116            self.nonce_safe(&nonce),
117            request_token,
118            self.consumer_secret,
119            request_token_secret,
120            timestamp,
121            verifier
122        );
123
124        let response = self
125            .http
126            .post(format!("{API_BASE}/oauth/access_token"))
127            .header(USER_AGENT, &self.user_agent)
128            .header(CONTENT_TYPE, "application/x-www-form-urlencoded")
129            .header(AUTHORIZATION, header_value)
130            .send()
131            .await?;
132
133        if !response.status().is_success() {
134            let status = response.status();
135            let message = response
136                .text()
137                .await
138                .unwrap_or_else(|_| "unknown error".to_string());
139            return Err(DiscogsError::Http { status, message });
140        }
141
142        let text = response.text().await?;
143        // Access token exchange uses the same URL-encoded payload shape as request token.
144        let values = parse_oauth_form(&text);
145        let access_token = values
146            .get("oauth_token")
147            .cloned()
148            .ok_or_else(|| DiscogsError::InvalidOAuthResponse(text.clone()))?;
149        let access_token_secret = values
150            .get("oauth_token_secret")
151            .cloned()
152            .ok_or_else(|| DiscogsError::InvalidOAuthResponse(text.clone()))?;
153
154        Ok(AccessToken {
155            access_token,
156            access_token_secret,
157        })
158    }
159
160    fn nonce_safe(&self, nonce: &str) -> String {
161        nonce
162            .chars()
163            .filter(|c| c.is_ascii_alphanumeric())
164            .collect()
165    }
166}
167
168pub fn build_oauth_header(
169    consumer_key: &str,
170    consumer_secret: &str,
171    access_token: &str,
172    access_token_secret: &str,
173) -> String {
174    let nonce = oauth_nonce();
175    let timestamp = oauth_timestamp_seconds();
176
177    format!(
178        "OAuth oauth_consumer_key=\"{consumer_key}\", oauth_token=\"{access_token}\", oauth_signature_method=\"PLAINTEXT\", oauth_signature=\"{consumer_secret}&{access_token_secret}\", oauth_timestamp=\"{timestamp}\", oauth_nonce=\"{nonce}\", oauth_token_secret=\"{access_token_secret}\", oauth_version=\"1.0\""
179    )
180}
181
182fn oauth_nonce() -> String {
183    rand::rng()
184        .sample_iter(&Alphanumeric)
185        .take(64)
186        .map(char::from)
187        .collect()
188}
189
190fn oauth_timestamp_seconds() -> u64 {
191    SystemTime::now()
192        .duration_since(UNIX_EPOCH)
193        .map(|d| d.as_secs())
194        .unwrap_or(0)
195}
196
197fn parse_oauth_form(raw: &str) -> BTreeMap<String, String> {
198    form_urlencoded::parse(raw.as_bytes())
199        .into_owned()
200        .collect()
201}