coinbase_v3/
basic_oauth.rs

1//! OAuth2 related functionalities
2
3use std::collections::HashSet;
4use std::io::{BufRead, BufReader, Write};
5use std::net::TcpListener;
6
7use oauth2::reqwest::async_http_client;
8use oauth2::{
9    basic::BasicClient, revocation::StandardRevocableToken, AccessToken, AuthUrl,
10    AuthorizationCode, ClientId, ClientSecret, CsrfToken, RedirectUrl, RefreshToken, RevocationUrl,
11    Scope, TokenResponse, TokenUrl,
12};
13use url::Url;
14
15use crate::scopes::VALID_SCOPES;
16
17const AUTH_URL_STR: &str = "https://www.coinbase.com/oauth/authorize";
18const TOKEN_URL_STR: &str = "https://www.coinbase.com/oauth/token";
19const REVOKE_URL_STR: &str = "https://api.coinbase.com/oauth/revoke";
20
21/// Trait to implement for any class proviging authentication functionalities to the client.
22///
23/// For instance:
24/// ```no_run
25/// # use coinbase_v3::basic_oauth;
26/// # use coinbase_v3::client;
27/// # let client_with_access_token_provider_trait = basic_oauth::OAuthCbClient::new("", "", "");
28/// let cb_client = client::CbClient::new(&client_with_access_token_provider_trait);
29/// ```
30/// the `oauth_cb_client` should implement this trait.
31pub trait AccessTokenProvider {
32    /// Should return a valid [`oauth2::AccessToken()`](https://docs.rs/oauth2/latest/oauth2/struct.AccessToken.html).
33    fn access_token(&self) -> AccessToken;
34}
35
36/// Returning the access token stored by the OAuthCbClient.
37///
38/// Note that the token might be expired and invalid.
39impl AccessTokenProvider for OAuthCbClient {
40    fn access_token(&self) -> AccessToken {
41        self.access_token.clone().unwrap()
42    }
43}
44
45fn set_oauth_cb_urls() -> (AuthUrl, TokenUrl, RevocationUrl) {
46    let auth_url =
47        AuthUrl::new(AUTH_URL_STR.to_string()).expect("Invalid authorization endpoint URL");
48    let token_url = TokenUrl::new(TOKEN_URL_STR.to_string()).expect("Invalid token endpoint URL");
49    let revoke_url =
50        RevocationUrl::new(REVOKE_URL_STR.to_string()).expect("Invalid revocation endpoint URL");
51
52    (auth_url, token_url, revoke_url)
53}
54
55/// A simple client to manage OAuth2 access tokens and permissions
56pub struct OAuthCbClient {
57    client: BasicClient,
58    access_token: Option<AccessToken>,
59    refresh_token: Option<RefreshToken>,
60    scopes: HashSet<Scope>,
61}
62
63impl OAuthCbClient {
64    /// Instantiate a new OAuthCbClient
65    ///
66    /// ```no_run
67    /// # use coinbase_v3::basic_oauth::OAuthCbClient;
68    /// let client_id = "my_secret_client_id_provided_by_coinbase";
69    /// let client_secret = "my_client_secret_provided_by_coinbase";
70    /// let redirect_url = "http://localhost:3001";
71    /// let oauth_cb_client = OAuthCbClient::new(client_id, client_secret, redirect_url);
72    /// ```
73    ///
74    /// - `client_id` and `client secret` are given to you by the API service provider. Store them
75    /// in a safe place. For instance hardcodding them in the source code is a bad idea.
76    /// - `redirect_url` is the url you will be asked to access to authenticate. Make sure it is
77    /// accessible to you.
78    pub fn new(client_id: &str, client_secret: &str, redirect_url: &str) -> Self {
79        let client_id = ClientId::new(client_id.to_string());
80        let client_secret = ClientSecret::new(client_secret.to_string());
81        let redirect_url =
82            RedirectUrl::new(redirect_url.to_string()).expect("Invalid redirect_url");
83
84        let (auth_url, token_url, revoke_url) = set_oauth_cb_urls();
85
86        let client = BasicClient::new(client_id, Some(client_secret), auth_url, Some(token_url))
87            .set_redirect_uri(redirect_url)
88            .set_revocation_uri(revoke_url);
89
90        Self {
91            client,
92            access_token: None,
93            refresh_token: None,
94            scopes: HashSet::new(),
95        }
96    }
97
98    /// AccessToken are only valid for predifnied scopes.
99    ///
100    /// To add one scope, for instance
101    /// ```no_run
102    /// # use coinbase_v3::basic_oauth::OAuthCbClient;
103    /// # let oauth_cb_client = OAuthCbClient::new("", "", "");
104    /// oauth_cb_client.add_scope("wallet:transactions:read");
105    /// ```
106    /// It can be called multiple times to add mutliple scopes.
107    /// Refer to Coinbase's documentation for adding the appropriate scopes.
108    /// As it can be confusing, you may refer to the examples of the current package
109    /// to find out which ones are needed.
110    pub fn add_scope(mut self, scope_description: &str) -> Self {
111        assert!(VALID_SCOPES.contains(&scope_description));
112
113        self.scopes
114            .insert(Scope::new(scope_description.to_string()));
115
116        self
117    }
118
119    /// Get Tokens from the issuing authority. Returns once it has stored them. Otherwise crashes.
120    ///
121    /// ```no_run
122    /// # use coinbase_v3::basic_oauth::OAuthCbClient;
123    /// # use tokio_test;
124    /// # tokio_test::block_on(async {
125    /// # let oauth_cb_client = OAuthCbClient::new("", "", "");
126    /// oauth_cb_client.add_scope("wallet:transactions:read")
127    ///             .authorize_once().await;
128    /// # });
129    /// ```
130    ///
131    /// *Once*, because it does not instantiate a mechanism to renew tokens.
132    /// So after 2 hours, the tokens will be invalid.
133    pub async fn authorize_once(mut self: Self) -> Self {
134        let redirect_url = self.client.redirect_url().unwrap();
135        let scheme = redirect_url.url().scheme().to_string();
136        let host = redirect_url.url().host().unwrap().to_string();
137        let port = redirect_url.url().port().unwrap();
138
139        let listener_address = host.to_string() + ":" + &port.to_string();
140
141        let (authorize_url, csrf_state) = self
142            .client
143            .authorize_url(CsrfToken::new_random)
144            .add_scopes(self.scopes.clone())
145            .url();
146
147        println!(
148            "\nOpen this URL in your browser:\n{}\n\n",
149            authorize_url.to_string()
150        );
151
152        let listener = TcpListener::bind(listener_address).unwrap();
153        for stream in listener.incoming() {
154            if let Ok(mut stream) = stream {
155                let code;
156                let state;
157                {
158                    let mut reader = BufReader::new(&stream);
159
160                    let mut request_line = String::new();
161                    reader.read_line(&mut request_line).unwrap();
162
163                    let redirect_url = request_line.split_whitespace().nth(1).unwrap();
164                    let url = Url::parse(&(scheme + "://" + &host + redirect_url)).unwrap();
165
166                    let code_pair = url
167                        .query_pairs()
168                        .find(|pair| {
169                            let &(ref key, _) = pair;
170                            key == "code"
171                        })
172                        .unwrap();
173
174                    let (_, value) = code_pair;
175                    code = AuthorizationCode::new(value.into_owned());
176
177                    let state_pair = url
178                        .query_pairs()
179                        .find(|pair| {
180                            let &(ref key, _) = pair;
181                            key == "state"
182                        })
183                        .unwrap();
184
185                    let (_, value) = state_pair;
186                    state = CsrfToken::new(value.into_owned());
187                }
188
189                let message = "Go back to your terminal :)";
190                let response = format!(
191                    "HTTP/1.1 200 OK\r\ncontent-length: {}\r\n\r\n{}",
192                    message.len(),
193                    message
194                );
195                stream.write_all(response.as_bytes()).unwrap();
196                assert!(state.secret() == csrf_state.secret());
197
198                // Exchange the code with a token.
199                let token_response = self
200                    .client
201                    .exchange_code(code)
202                    .request_async(async_http_client)
203                    .await;
204
205                let token_response = token_response.unwrap();
206                if let Some(tok) = token_response.refresh_token() {
207                    self.refresh_token = Some(tok.clone());
208                }
209                self.access_token = Some(token_response.access_token().clone());
210
211                break;
212            }
213        }
214        self
215    }
216
217    /// Revoke the obtained token
218    ///
219    /// Just to make sure no one can use it afterwards.
220    /// Note that without calling this function, Coinbase tokens normally expire after 2 hours.
221    pub async fn revoke_access(&self) {
222        let token_to_revoke: StandardRevocableToken = match self.refresh_token.as_ref() {
223            Some(token) => token.into(),
224            None => self.access_token.as_ref().unwrap().into(),
225        };
226
227        self.client
228            .revoke_token(token_to_revoke)
229            .unwrap()
230            .request_async(async_http_client)
231            .await
232            .expect("Failed to revoke token");
233
234        println!("=============== ACCESS REVOKED =================");
235    }
236}
237
238// impl Drop for OauthCbClient {
239//     fn drop(&mut self) {
240//         tokio::spawn(self.revoke_access());
241//         println!("oauth cb client dropped.");
242//     }
243// }