oauth2_tiktok/
authorization_code_grant.rs

1use oauth2_client::{
2    authorization_code_grant::provider_ext::AccessTokenRequestBody,
3    oauth2_core::{
4        access_token_request::GRANT_TYPE_WITH_AUTHORIZATION_CODE_GRANT,
5        re_exports::{
6            AccessTokenResponseErrorBody, AccessTokenResponseErrorBodyError,
7            AccessTokenResponseSuccessfulBody,
8        },
9        types::AccessTokenType,
10    },
11    re_exports::{
12        http::Method, serde_json, serde_qs, thiserror, Body, ClientId, ClientSecret, HttpError,
13        Map, RedirectUri, Request, Response, SerdeJsonError, SerdeQsError, Url, UrlParseError,
14        Value,
15    },
16    Provider, ProviderExtAuthorizationCodeGrant,
17};
18use serde::{Deserialize, Serialize};
19
20use crate::{TiktokScope, AUTHORIZATION_URL, TOKEN_URL};
21
22pub const KEY_OPENID: &str = "open_id";
23
24//
25#[derive(Debug, Clone)]
26pub struct TiktokProviderWithWebApplication {
27    client_id: ClientId,
28    client_secret: ClientSecret,
29    redirect_uri: RedirectUri,
30    //
31    token_endpoint_url: Url,
32    authorization_endpoint_url: Url,
33}
34impl TiktokProviderWithWebApplication {
35    pub fn new(
36        client_key: ClientId,
37        client_secret: ClientSecret,
38        redirect_uri: RedirectUri,
39    ) -> Result<Self, UrlParseError> {
40        Ok(Self {
41            client_id: client_key,
42            client_secret,
43            redirect_uri,
44            token_endpoint_url: TOKEN_URL.parse()?,
45            authorization_endpoint_url: AUTHORIZATION_URL.parse()?,
46        })
47    }
48}
49impl Provider for TiktokProviderWithWebApplication {
50    type Scope = TiktokScope;
51
52    fn client_id(&self) -> Option<&ClientId> {
53        Some(&self.client_id)
54    }
55
56    fn client_secret(&self) -> Option<&ClientSecret> {
57        Some(&self.client_secret)
58    }
59
60    fn token_endpoint_url(&self) -> &Url {
61        &self.token_endpoint_url
62    }
63}
64impl ProviderExtAuthorizationCodeGrant for TiktokProviderWithWebApplication {
65    fn redirect_uri(&self) -> Option<&RedirectUri> {
66        Some(&self.redirect_uri)
67    }
68
69    fn scopes_default(&self) -> Option<Vec<<Self as Provider>::Scope>> {
70        Some(vec![TiktokScope::UserInfoBasic, TiktokScope::VideoList])
71    }
72
73    fn authorization_endpoint_url(&self) -> &Url {
74        &self.authorization_endpoint_url
75    }
76
77    fn authorization_request_url_modifying(&self, url: &mut Url) {
78        let query_pairs: Vec<_> = url
79            .query_pairs()
80            .map(|(k, v)| (k.to_string(), v.to_string()))
81            .collect::<Vec<_>>();
82        let mut query_pairs_mut = url.query_pairs_mut();
83        query_pairs_mut.clear();
84        for (k, v) in query_pairs {
85            match k.as_str() {
86                "client_id" => {
87                    query_pairs_mut.append_pair("client_key", v.as_str());
88                }
89                "scope" => {
90                    query_pairs_mut
91                        .append_pair("scope", v.split(' ').collect::<Vec<_>>().join(",").as_str());
92                }
93                _ => {
94                    query_pairs_mut.append_pair(k.as_str(), v.as_str());
95                }
96            }
97        }
98        query_pairs_mut.finish();
99    }
100
101    // https://developers.tiktok.com/doc/login-kit-manage-user-access-tokens/
102    fn access_token_request_rendering(
103        &self,
104        body: &AccessTokenRequestBody,
105    ) -> Option<Result<Request<Body>, Box<dyn std::error::Error + Send + Sync + 'static>>> {
106        fn doing(
107            this: &TiktokProviderWithWebApplication,
108            body: &AccessTokenRequestBody,
109        ) -> Result<Request<Body>, Box<dyn std::error::Error + Send + Sync + 'static>> {
110            let client_key = this.client_id.to_owned();
111            let query = TiktokAccessTokenRequestQuery {
112                client_key,
113                client_secret: this.client_secret.to_owned(),
114                code: body.code.to_owned(),
115                grant_type: GRANT_TYPE_WITH_AUTHORIZATION_CODE_GRANT.to_owned(),
116            };
117            let query_str = serde_qs::to_string(&query)
118                .map_err(AccessTokenRequestRenderingError::SerRequestQueryFailed)?;
119
120            let mut url = this.token_endpoint_url().to_owned();
121            url.set_query(Some(query_str.as_str()));
122
123            let request = Request::builder()
124                .method(Method::POST)
125                .uri(url.as_str())
126                .body(vec![])
127                .map_err(AccessTokenRequestRenderingError::MakeRequestFailed)?;
128
129            Ok(request)
130        }
131
132        Some(doing(self, body))
133    }
134
135    // https://developers.tiktok.com/doc/login-kit-manage-user-access-tokens/
136    #[allow(clippy::type_complexity)]
137    fn access_token_response_parsing(
138        &self,
139        response: &Response<Body>,
140    ) -> Option<
141        Result<
142            Result<
143                AccessTokenResponseSuccessfulBody<<Self as Provider>::Scope>,
144                AccessTokenResponseErrorBody,
145            >,
146            Box<dyn std::error::Error + Send + Sync + 'static>,
147        >,
148    > {
149        fn doing(
150            response: &Response<Body>,
151        ) -> Result<TiktokAccessTokenResponseBody, Box<dyn std::error::Error + Send + Sync + 'static>>
152        {
153            let body = serde_json::from_slice::<TiktokAccessTokenResponseBody>(response.body())
154                .map_err(AccessTokenResponseParsingError::DeResponseBodyFailed)?;
155
156            Ok(body)
157        }
158
159        Some(doing(response).map(Into::into))
160    }
161}
162
163//
164#[derive(Serialize, Deserialize)]
165pub struct TiktokAccessTokenRequestQuery {
166    pub client_key: String,
167    pub client_secret: String,
168    pub code: String,
169    pub grant_type: String,
170}
171
172#[derive(thiserror::Error, Debug)]
173pub enum AccessTokenRequestRenderingError {
174    #[error("SerRequestQueryFailed {0}")]
175    SerRequestQueryFailed(SerdeQsError),
176    #[error("MakeRequestFailed {0}")]
177    MakeRequestFailed(HttpError),
178}
179
180//
181#[derive(Serialize, Deserialize)]
182#[serde(tag = "message", content = "data")]
183pub enum TiktokAccessTokenResponseBody {
184    #[serde(rename = "success")]
185    Success(TiktokAccessTokenResponseBodySuccessfulData),
186    #[serde(rename = "error")]
187    Error(TiktokAccessTokenResponseBodyErrorData),
188}
189
190#[derive(Serialize, Deserialize)]
191pub struct TiktokAccessTokenResponseBodySuccessfulData {
192    pub open_id: String,
193    pub scope: String,
194    pub access_token: String,
195    pub expires_in: i64,
196    pub refresh_token: String,
197    pub refresh_expires_in: i64,
198}
199
200#[derive(Serialize, Deserialize)]
201pub struct TiktokAccessTokenResponseBodyErrorData {
202    pub captcha: String,
203    pub desc_url: String,
204    pub description: String,
205    pub error_code: i64,
206}
207
208impl From<TiktokAccessTokenResponseBody>
209    for Result<AccessTokenResponseSuccessfulBody<TiktokScope>, AccessTokenResponseErrorBody>
210{
211    fn from(body: TiktokAccessTokenResponseBody) -> Self {
212        match body {
213            TiktokAccessTokenResponseBody::Success(body) => {
214                let scope: Vec<_> = body
215                    .scope
216                    .split(',')
217                    .map(|x| {
218                        x.parse::<TiktokScope>()
219                            .unwrap_or_else(|_| TiktokScope::Other(x.to_owned()))
220                    })
221                    .collect();
222
223                let mut map = Map::new();
224                map.insert(
225                    KEY_OPENID.to_owned(),
226                    Value::String(body.open_id.to_owned()),
227                );
228                map.insert(
229                    "refresh_expires_in".to_owned(),
230                    Value::Number(body.refresh_expires_in.into()),
231                );
232
233                let mut body = AccessTokenResponseSuccessfulBody::<TiktokScope>::new(
234                    body.access_token.to_owned(),
235                    AccessTokenType::Bearer,
236                    Some(body.expires_in as usize),
237                    Some(body.refresh_token),
238                    if scope.is_empty() {
239                        None
240                    } else {
241                        Some(scope.into())
242                    },
243                );
244                body.set_extra(map);
245
246                Ok(body)
247            }
248            TiktokAccessTokenResponseBody::Error(body) => {
249                let body = AccessTokenResponseErrorBody::new(
250                    AccessTokenResponseErrorBodyError::Other(body.error_code.to_string()),
251                    Some(body.description),
252                    None,
253                );
254
255                Err(body)
256            }
257        }
258    }
259}
260
261#[derive(thiserror::Error, Debug)]
262pub enum AccessTokenResponseParsingError {
263    //
264    #[error("DeResponseBodyFailed {0}")]
265    DeResponseBodyFailed(SerdeJsonError),
266}
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271
272    use oauth2_client::{
273        authorization_code_grant::{AccessTokenEndpoint, AuthorizationEndpoint},
274        re_exports::{Endpoint as _, Response},
275    };
276
277    #[test]
278    fn authorization_request() -> Result<(), Box<dyn std::error::Error>> {
279        let provider = TiktokProviderWithWebApplication::new(
280            "CLIENT_KEY".to_owned(),
281            "CLIENT_SECRET".to_owned(),
282            RedirectUri::new("https://client.example.com/cb")?,
283        )?;
284
285        let request = AuthorizationEndpoint::new(
286            &provider,
287            vec![TiktokScope::UserInfoBasic, TiktokScope::VideoList],
288        )
289        .configure(|x| x.state = Some("STATE".to_owned()))
290        .render_request()?;
291
292        assert_eq!(request.uri(), "https://www.tiktok.com/auth/authorize/?response_type=code&client_key=CLIENT_KEY&redirect_uri=https%3A%2F%2Fclient.example.com%2Fcb&scope=user.info.basic%2Cvideo.list&state=STATE");
293
294        Ok(())
295    }
296
297    #[test]
298    fn access_token_response() -> Result<(), Box<dyn std::error::Error>> {
299        let provider = TiktokProviderWithWebApplication::new(
300            "CLIENT_KEY".to_owned(),
301            "CLIENT_SECRET".to_owned(),
302            RedirectUri::new("https://client.example.com/cb")?,
303        )?;
304
305        //
306        let response_body = include_str!(
307            "../tests/response_body_json_files/access_token_with_authorization_code_grant.json"
308        );
309        let body_ret = AccessTokenEndpoint::new(&provider, "CODE".to_owned())
310            .parse_response(Response::builder().body(response_body.as_bytes().to_vec())?)?;
311
312        match body_ret {
313            Ok(body) => {
314                let map = body.extra().unwrap();
315                assert_eq!(
316                    map.get("open_id").unwrap().as_str(),
317                    Some("_000fwZ23Mw4RY9cB4lDQyKCgQg4Ft6SyTuE")
318                );
319            }
320            Err(body) => panic!("{body:?}"),
321        }
322
323        //
324        let response_body = include_str!(
325            "../tests/response_body_json_files/access_token_failed_with_authorization_code_grant.json"
326        );
327        let body_ret = AccessTokenEndpoint::new(&provider, "CODE".to_owned())
328            .parse_response(Response::builder().body(response_body.as_bytes().to_vec())?)?;
329
330        match body_ret {
331            Ok(body) => {
332                panic!("{body:?}")
333            }
334            Err(body) => assert_eq!(body.error.to_string(), "10007"),
335        }
336
337        Ok(())
338    }
339}