gcal/
oauth.rs

1//! OAuth tooling for gcal usage. This gives you enough to capture access tokens.
2//!
3//! ## Example
4//!
5//! ```ignore
6//! #[tokio::main]
7//! async fn main() -> Result<(), anyhow::Error> {
8//!     let mut params = ClientParameters {
9//!         client_id: std::env::args().nth(1).expect("Requires a client ID"),
10//!         client_secret: std:::args().nth(2).expect("Requires a client secret"),
11//!         ..Default::default()
12//!     };
13//!    
14//!     let state = State::new(Mutex::new(params.clone()));
15//!     let host = oauth_listener(state.clone()).await?;
16//!     params.redirect_url = Some(format!("http://{}", host));
17//!    
18//!     let url = oauth_user_url(params.clone());
19//!     println!("Click on this and login: {}", url);
20//!    
21//!     loop {
22//!         let lock = state.lock().await;
23//!         if lock.access_key.is_some() {
24//!             println!("Captured {:?}. Thanks!", lock.access_key.unwrap());
25//!             return Ok(());
26//!         }
27//!    
28//!         tokio::time::sleep(std::time::Duration::new(1, 0)).await;
29//!     }
30//! }
31//! ```
32use davisjr::prelude::*;
33use reqwest::{header::HeaderMap, ClientBuilder};
34use serde_derive::{Deserialize, Serialize};
35use std::sync::Arc;
36use tokio::sync::Mutex;
37
38/// The scope required to access Google Calendar from the Google API.
39pub const CALENDAR_SCOPE: &str = "https://www.googleapis.com/auth/calendar";
40/// The OAuth token URL
41pub const TOKEN_URL: &str = "https://oauth2.googleapis.com/token";
42/// The user authentication URL
43pub const USER_URL: &str = "https://accounts.google.com/o/oauth2/v2/auth";
44
45/// State encapsulates the ClientParameters in a way suitable for use with the oauth_listener.
46pub type State = Arc<Mutex<ClientParameters>>;
47
48/// A deserialization of an Access Token structure from OAuth.
49#[derive(Debug, Clone, Default, Serialize, Deserialize)]
50pub struct AccessToken {
51    pub token_type: Option<String>,
52    pub access_token: String,
53    pub expires_in: i64,
54    pub refresh_token: Option<String>,
55    pub refresh_token_expires_in: Option<i64>,
56    pub scope: Option<String>,
57}
58
59/// A construction of Client Parameters required to negotiate OAuth.
60#[derive(Clone, Debug, Default)]
61pub struct ClientParameters {
62    pub client_id: String,
63    pub client_secret: String,
64    pub redirect_url: Option<String>,
65    pub access_key: Option<String>,
66    pub expires_at: Option<chrono::NaiveDateTime>,
67    pub refresh_token: Option<String>,
68    pub refresh_token_expires_at: Option<chrono::NaiveDateTime>,
69}
70
71async fn handler(
72    req: Request<Body>,
73    _resp: Option<Response<Body>>,
74    _params: Params,
75    app: App<State, NoState>,
76    state: NoState,
77) -> HTTPResult<NoState> {
78    let pairs = req
79        .uri()
80        .query()
81        .map(|s| {
82            s.split("&")
83                .map(|n| n.split("=").collect::<Vec<&str>>())
84                .collect::<Vec<Vec<&str>>>()
85        })
86        .unwrap_or(Vec::new());
87
88    let mut code: Option<String> = None;
89    let mut oauth_state: Option<String> = None;
90
91    for pair in pairs {
92        if pair[0] == "code" {
93            code = Some(pair[1].to_string());
94        } else if pair[0] == "state" {
95            oauth_state = Some(pair[1].to_string());
96        }
97
98        if code.is_some() && oauth_state.is_some() {
99            break;
100        }
101    }
102
103    let lock = app.state().await.unwrap();
104    let lock = lock.lock().await;
105    let mut lock = lock.lock().await;
106
107    let token =
108        request_access_token(lock.clone(), code.as_deref(), oauth_state.as_deref(), false).await?;
109    lock.access_key = Some(token.access_token);
110    lock.expires_at = Some(
111        chrono::Local::now().naive_utc()
112            + chrono::TimeDelta::try_seconds(token.expires_in).unwrap_or_default(),
113    );
114
115    if let Some(refresh_token) = token.refresh_token {
116        lock.refresh_token = Some(refresh_token);
117        if let Some(expires_in) = token.refresh_token_expires_in {
118            lock.refresh_token_expires_at = Some(
119                chrono::Local::now().naive_utc()
120                    + chrono::TimeDelta::try_seconds(expires_in).unwrap_or_default(),
121            );
122        } else {
123            lock.refresh_token_expires_at = Some(
124                chrono::Local::now().naive_utc()
125                    + chrono::TimeDelta::try_seconds(3600).unwrap_or_default(),
126            );
127        }
128    }
129
130    Ok((
131        req,
132        Some(Response::new(Body::from(
133            "Please close this browser tab. Thanks!".to_string(),
134        ))),
135        state,
136    ))
137}
138
139/// Requests an access token. The redirect_url must point at the oauth_listener service.
140pub async fn request_access_token(
141    client_params: ClientParameters,
142    code: Option<&str>,
143    state: Option<&str>,
144    refresh: bool,
145) -> Result<AccessToken, Error> {
146    let grant = if refresh {
147        "refresh_token"
148    } else {
149        "authorization_code"
150    };
151
152    let mut params = vec![
153        ("grant_type", grant),
154        ("client_id", &client_params.client_id),
155        ("client_secret", &client_params.client_secret),
156    ];
157
158    let mut headers = HeaderMap::default();
159    headers.insert(
160        reqwest::header::ACCEPT,
161        reqwest::header::HeaderValue::from_static("application/json"),
162    );
163
164    let redirect_url = client_params.redirect_url.unwrap_or_default();
165    let token = if refresh {
166        client_params.refresh_token.unwrap()
167    } else {
168        Default::default()
169    };
170
171    if !refresh {
172        params.push(("code", code.unwrap()));
173        params.push(("redirect_uri", &redirect_url));
174        params.push(("state", state.unwrap()));
175    } else {
176        params.push(("refresh_token", &token));
177    }
178
179    let client = ClientBuilder::new()
180        .default_headers(headers)
181        .https_only(true)
182        .build()?;
183
184    Ok(client
185        .post(TOKEN_URL)
186        .form(&params)
187        .basic_auth(&client_params.client_id, Some(&client_params.client_secret))
188        .send()
189        .await?
190        .json()
191        .await?)
192}
193
194/// Produce a OAuth capture URL.
195pub fn oauth_user_url(params: ClientParameters) -> String {
196    // using the uuid is taken from a sight read of google_calendar; I'm not
197    // sure it's necessary to use a uuid but I am lazy
198    let u = uuid::Uuid::new_v4();
199    format!(
200        "{}?client_id={}&access_type=offline&response_type=code&redirect_uri={}&state={}&scope={}",
201        USER_URL,
202        params.client_id,
203        params.redirect_url.expect("Expected a redirect URL"),
204        u,
205        CALENDAR_SCOPE,
206    )
207}
208
209/// Create a local listener which is ready to become the redirect_url. Once the state has been
210/// captured, it will mutate the provided state with the access credentials. Returned is the
211/// address of the listener suitable for coercing to the redirect_url.
212pub async fn oauth_listener(state: State) -> Result<String, ServerError> {
213    let mut app = App::with_state(state.clone());
214
215    app.get("/", compose_handler!(handler))?;
216
217    // find a free port. this is susceptible to timing races and if that happens I guess they'll
218    // just have to start the program again.
219    let lis = tokio::net::TcpListener::bind("localhost:0").await?;
220    let addr = lis.local_addr()?.clone();
221    drop(lis);
222
223    let mut lock = state.lock().await;
224    lock.redirect_url = Some(format!("http://{}", addr.to_string()));
225
226    tokio::spawn(async move { app.serve(&addr.to_string()).await.unwrap() });
227
228    Ok(addr.to_string())
229}