Skip to main content

cloudconvert_sdk/
oauth.rs

1//! CloudConvert OAuth authorization and token exchange helpers.
2//!
3//! [`OAuthClient`] builds authorization URLs and exchanges authorization codes
4//! or refresh tokens for [`OAuthTokenResponse`] values that can seed a
5//! [`CloudConvertClient`] through [`OAuthTokenResponse::client_builder`].
6
7use std::{collections::BTreeMap, fmt};
8
9use reqwest::header::CONTENT_TYPE;
10use serde::{Deserialize, Serialize};
11use serde_json::Value;
12use url::Url;
13
14use crate::{
15    ClientBuilder, CloudConvertClient, Error, Result,
16    config::{OAuthAccessToken, OAuthClientSecret, OAuthRefreshToken},
17};
18
19const OAUTH_AUTHORIZE_URL: &str = "https://cloudconvert.com/oauth/authorize";
20const OAUTH_TOKEN_URL: &str = "https://cloudconvert.com/oauth/token";
21
22/// OAuth scope token requested during authorization.
23#[derive(Clone, Debug, Eq, PartialEq)]
24#[non_exhaustive]
25pub enum OAuthScope {
26    UserRead,
27    UserWrite,
28    TaskRead,
29    TaskWrite,
30    WebhookRead,
31    WebhookWrite,
32    Other(String),
33}
34
35impl OAuthScope {
36    pub fn as_str(&self) -> &str {
37        match self {
38            Self::UserRead => "user.read",
39            Self::UserWrite => "user.write",
40            Self::TaskRead => "task.read",
41            Self::TaskWrite => "task.write",
42            Self::WebhookRead => "webhook.read",
43            Self::WebhookWrite => "webhook.write",
44            Self::Other(value) => value.as_str(),
45        }
46    }
47}
48
49impl From<&str> for OAuthScope {
50    fn from(value: &str) -> Self {
51        match value {
52            "user.read" => Self::UserRead,
53            "user.write" => Self::UserWrite,
54            "task.read" => Self::TaskRead,
55            "task.write" => Self::TaskWrite,
56            "webhook.read" => Self::WebhookRead,
57            "webhook.write" => Self::WebhookWrite,
58            _ => Self::Other(value.to_string()),
59        }
60    }
61}
62
63impl From<String> for OAuthScope {
64    fn from(value: String) -> Self {
65        Self::from(value.as_str())
66    }
67}
68
69impl Serialize for OAuthScope {
70    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
71    where
72        S: serde::Serializer,
73    {
74        serializer.serialize_str(self.as_str())
75    }
76}
77
78/// OAuth client used to start authorization flows and exchange tokens.
79#[derive(Clone)]
80pub struct OAuthClient {
81    client_id: String,
82    client_secret: OAuthClientSecret,
83    authorize_url: Url,
84    token_url: Url,
85    http: reqwest::Client,
86}
87
88impl OAuthClient {
89    pub fn new(client_id: impl Into<String>, client_secret: OAuthClientSecret) -> Result<Self> {
90        Self::with_http_client(client_id, client_secret, reqwest::Client::new())
91    }
92
93    pub fn with_http_client(
94        client_id: impl Into<String>,
95        client_secret: OAuthClientSecret,
96        http: reqwest::Client,
97    ) -> Result<Self> {
98        Ok(Self {
99            client_id: client_id.into(),
100            client_secret,
101            authorize_url: Url::parse(OAUTH_AUTHORIZE_URL)?,
102            token_url: Url::parse(OAUTH_TOKEN_URL)?,
103            http,
104        })
105    }
106
107    pub fn with_endpoints(mut self, authorize_url: Url, token_url: Url) -> Self {
108        self.authorize_url = authorize_url;
109        self.token_url = token_url;
110        self
111    }
112
113    pub fn client_id(&self) -> &str {
114        self.client_id.as_str()
115    }
116
117    pub fn authorize_url(&self) -> &Url {
118        &self.authorize_url
119    }
120
121    pub fn token_url(&self) -> &Url {
122        &self.token_url
123    }
124
125    /// Builds an authorization-code flow URL for the given redirect URI and scopes.
126    pub fn authorization_code_url(
127        &self,
128        redirect_uri: impl AsRef<str>,
129        scopes: impl IntoIterator<Item = OAuthScope>,
130    ) -> Result<Url> {
131        self.authorization_url("code", redirect_uri.as_ref(), scopes, None)
132    }
133
134    pub fn authorization_code_url_with_state(
135        &self,
136        redirect_uri: impl AsRef<str>,
137        scopes: impl IntoIterator<Item = OAuthScope>,
138        state: impl Into<String>,
139    ) -> Result<Url> {
140        self.authorization_url("code", redirect_uri.as_ref(), scopes, Some(state.into()))
141    }
142
143    pub fn implicit_url(
144        &self,
145        redirect_uri: impl AsRef<str>,
146        scopes: impl IntoIterator<Item = OAuthScope>,
147    ) -> Result<Url> {
148        self.authorization_url("token", redirect_uri.as_ref(), scopes, None)
149    }
150
151    pub fn implicit_url_with_state(
152        &self,
153        redirect_uri: impl AsRef<str>,
154        scopes: impl IntoIterator<Item = OAuthScope>,
155        state: impl Into<String>,
156    ) -> Result<Url> {
157        self.authorization_url("token", redirect_uri.as_ref(), scopes, Some(state.into()))
158    }
159
160    /// Exchanges an authorization code for access and optional refresh tokens.
161    pub async fn exchange_code(
162        &self,
163        code: impl Into<String>,
164        redirect_uri: impl Into<String>,
165    ) -> Result<OAuthTokenResponse> {
166        self.send_token_request(vec![
167            ("grant_type", "authorization_code".to_string()),
168            ("code", code.into()),
169            ("redirect_uri", redirect_uri.into()),
170            ("client_id", self.client_id.clone()),
171            ("client_secret", self.client_secret.expose().to_string()),
172        ])
173        .await
174    }
175
176    /// Refreshes an access token using a stored refresh token.
177    pub async fn refresh_access_token(
178        &self,
179        refresh_token: &OAuthRefreshToken,
180    ) -> Result<OAuthTokenResponse> {
181        self.send_token_request(vec![
182            ("grant_type", "refresh_token".to_string()),
183            ("refresh_token", refresh_token.expose().to_string()),
184            ("client_id", self.client_id.clone()),
185            ("client_secret", self.client_secret.expose().to_string()),
186        ])
187        .await
188    }
189
190    fn authorization_url(
191        &self,
192        response_type: &str,
193        redirect_uri: &str,
194        scopes: impl IntoIterator<Item = OAuthScope>,
195        state: Option<String>,
196    ) -> Result<Url> {
197        let mut url = self.authorize_url.clone();
198        let scope = scopes
199            .into_iter()
200            .map(|scope| scope.as_str().to_string())
201            .collect::<Vec<_>>()
202            .join(" ");
203
204        {
205            let mut query = url.query_pairs_mut();
206            query
207                .append_pair("response_type", response_type)
208                .append_pair("client_id", &self.client_id)
209                .append_pair("redirect_uri", redirect_uri);
210            if !scope.is_empty() {
211                query.append_pair("scope", &scope);
212            }
213            if let Some(state) = state {
214                query.append_pair("state", &state);
215            }
216        }
217
218        Ok(url)
219    }
220
221    async fn send_token_request(
222        &self,
223        form: Vec<(&'static str, String)>,
224    ) -> Result<OAuthTokenResponse> {
225        let body = form_body(&form);
226        let response = self
227            .http
228            .post(self.token_url.clone())
229            .header(CONTENT_TYPE, "application/x-www-form-urlencoded")
230            .body(body)
231            .send()
232            .await?;
233
234        if !response.status().is_success() {
235            return Err(oauth_api_error(response).await);
236        }
237
238        let raw = response.json::<RawOAuthTokenResponse>().await?;
239        Ok(raw.into())
240    }
241}
242
243impl fmt::Debug for OAuthClient {
244    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
245        formatter
246            .debug_struct("OAuthClient")
247            .field("client_id", &self.client_id)
248            .field("client_secret", &self.client_secret)
249            .field("authorize_url", &self.authorize_url)
250            .field("token_url", &self.token_url)
251            .field("http", &"reqwest::Client")
252            .finish()
253    }
254}
255
256/// Token payload returned by CloudConvert OAuth token endpoints.
257#[derive(Clone)]
258#[non_exhaustive]
259pub struct OAuthTokenResponse {
260    pub access_token: OAuthAccessToken,
261    pub refresh_token: Option<OAuthRefreshToken>,
262    pub token_type: Option<String>,
263    pub expires_in: Option<u64>,
264    pub scope: Option<String>,
265    pub extra: BTreeMap<String, Value>,
266}
267
268impl OAuthTokenResponse {
269    pub fn access_token(&self) -> &OAuthAccessToken {
270        &self.access_token
271    }
272
273    pub fn refresh_token(&self) -> Option<&OAuthRefreshToken> {
274        self.refresh_token.as_ref()
275    }
276
277    pub fn into_access_token(self) -> OAuthAccessToken {
278        self.access_token
279    }
280
281    /// Starts a [`CloudConvertClient`] builder authenticated with this access token.
282    pub fn client_builder(&self) -> ClientBuilder {
283        CloudConvertClient::builder_with_access_token(self.access_token.clone())
284    }
285
286    pub fn into_client_builder(self) -> ClientBuilder {
287        CloudConvertClient::builder_with_access_token(self.access_token)
288    }
289}
290
291impl fmt::Debug for OAuthTokenResponse {
292    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
293        formatter
294            .debug_struct("OAuthTokenResponse")
295            .field("access_token", &self.access_token)
296            .field("refresh_token", &self.refresh_token)
297            .field("token_type", &self.token_type)
298            .field("expires_in", &self.expires_in)
299            .field("scope", &self.scope)
300            .field("extra", &self.extra)
301            .finish()
302    }
303}
304
305#[derive(Deserialize)]
306struct RawOAuthTokenResponse {
307    access_token: String,
308    #[serde(default)]
309    refresh_token: Option<String>,
310    #[serde(default)]
311    token_type: Option<String>,
312    #[serde(default)]
313    expires_in: Option<u64>,
314    #[serde(default)]
315    scope: Option<String>,
316    #[serde(flatten)]
317    extra: BTreeMap<String, Value>,
318}
319
320impl From<RawOAuthTokenResponse> for OAuthTokenResponse {
321    fn from(value: RawOAuthTokenResponse) -> Self {
322        Self {
323            access_token: OAuthAccessToken::new(value.access_token),
324            refresh_token: value.refresh_token.map(OAuthRefreshToken::new),
325            token_type: value.token_type,
326            expires_in: value.expires_in,
327            scope: value.scope,
328            extra: value.extra,
329        }
330    }
331}
332
333fn form_body(form: &[(&'static str, String)]) -> String {
334    let mut serializer = url::form_urlencoded::Serializer::new(String::new());
335    for (key, value) in form {
336        serializer.append_pair(key, value);
337    }
338    serializer.finish()
339}
340
341async fn oauth_api_error(response: reqwest::Response) -> Error {
342    let status = response.status().as_u16();
343    let body = response.text().await.unwrap_or_default();
344    let parsed = serde_json::from_str::<Value>(&body).ok();
345    let message = parsed
346        .as_ref()
347        .and_then(|body| {
348            body.get("error_description")
349                .or_else(|| body.get("message"))
350                .and_then(Value::as_str)
351        })
352        .filter(|message| !message.is_empty())
353        .unwrap_or("OAuth token request failed")
354        .to_string();
355    let code = parsed
356        .as_ref()
357        .and_then(|body| {
358            body.get("error")
359                .or_else(|| body.get("code"))
360                .and_then(Value::as_str)
361        })
362        .map(ToString::to_string);
363
364    Error::Api {
365        status,
366        message,
367        code,
368        errors: parsed.map(Box::new),
369        rate_limit: None,
370    }
371}