fetcher_core/auth/
google.rs

1/*
2 * This Source Code Form is subject to the terms of the Mozilla Public
3 * License, v. 2.0. If a copy of the MPL was not distributed with this
4 * file, You can obtain one at https://mozilla.org/MPL/2.0/.
5 */
6
7// I can avoid the clippy::doc_markdown lint this way :P
8#![doc = "This module contains the Google authenticator that can access Google services via OAuth2"]
9
10use serde::Deserialize;
11use std::time::{Duration, Instant};
12
13const GOOGLE_AUTH_URL: &str = "https://accounts.google.com/o/oauth2/token";
14
15#[allow(clippy::doc_markdown)]
16/// An OAuth2 access token. It can be used to actually access stuff via OAuth2
17#[derive(Clone, Debug)]
18pub struct AccessToken {
19	/// The token itself
20	pub token: String,
21
22	/// When it expires and will no longer be valid
23	pub expires: Instant,
24}
25
26#[derive(Deserialize)]
27struct AccessTokenResponce {
28	access_token: String,
29	expires_in: u64,
30}
31
32#[allow(clippy::doc_markdown)]
33/// Google OAuth2 authenticator
34// TODO: link docs to the oauth2 spec
35#[derive(Clone, Debug)]
36pub struct Google {
37	/// OAuth2 client id
38	pub client_id: String,
39
40	/// OAuth2 client secret
41	pub client_secret: String,
42
43	/// OAuth2 refresh token. It doesn't expire and is used to get new shortlived access tokens
44	pub refresh_token: String,
45
46	/// OAuth2 access token. It's used for the actual accessing of the data
47	access_token: Option<AccessToken>,
48}
49
50#[allow(missing_docs)] // error message is self-documenting
51#[derive(thiserror::Error, Debug)]
52pub enum GoogleOAuth2Error {
53	#[error("Error contacting Google servers for authentication")]
54	Post(#[source] reqwest::Error),
55
56	#[error("Can't get a new OAuth2 refresh token from Google: {0}")]
57	RefreshToken(String),
58
59	#[error("Can't get a new OAuth2 access token from Google: {0}")]
60	AccessToken(String),
61}
62
63impl Google {
64	#[allow(clippy::doc_markdown)]
65	/// Creates a new Google OAuth2 authenticator
66	#[must_use]
67	pub fn new(client_id: String, client_secret: String, refresh_token: String) -> Self {
68		Self {
69			client_id,
70			client_secret,
71			refresh_token,
72			access_token: None,
73		}
74	}
75
76	/// Force fetch a new access token and overwrite the old one
77	///
78	/// # Errors
79	/// * if there was a network connection error
80	/// * if the responce isn't a valid `refresh_token`
81	pub async fn get_new_access_token(&mut self) -> Result<&AccessToken, GoogleOAuth2Error> {
82		let AccessTokenResponce {
83			access_token,
84			expires_in,
85		} = generate_access_token(&self.client_id, &self.client_secret, &self.refresh_token).await?;
86
87		tracing::debug!("New access token expires in {expires_in}s");
88
89		self.access_token = Some(AccessToken {
90			token: access_token,
91			expires: Instant::now() + Duration::from_secs(expires_in),
92		});
93
94		Ok(self
95			.access_token
96			.as_ref()
97			.expect("Token should have just been validated and thus be present and valid"))
98	}
99
100	/// Return a previously gotten `access_token` or fetch a new one
101	///
102	/// # Errors
103	/// * if there was a network connection error
104	/// * if the responce isn't a valid `refresh_token`
105	#[tracing::instrument(name = "google_oauth2_access_token")]
106	pub async fn access_token(&mut self) -> Result<&str, GoogleOAuth2Error> {
107		// FIXME: for some reason the token sometimes expires by itself and should be renewed manually
108
109		// Update the token if:
110		if {
111			// we haven't done that yet
112			let access_token_doesnt_exist = self.access_token.is_none();
113			if access_token_doesnt_exist {
114				tracing::trace!("Access token doesn't exist");
115			}
116
117			access_token_doesnt_exist
118		} || {
119			// or if if has expired
120			let is_expired = self
121				.access_token
122				.as_ref()
123				.and_then(|x| Instant::now().checked_duration_since(x.expires))
124				.is_some();
125
126			if is_expired {
127				tracing::trace!("Access token has expired");
128			}
129
130			is_expired
131		} {
132			self.get_new_access_token().await?;
133		}
134
135		#[allow(clippy::missing_panics_doc)] // this should never panic
136		let access_token = self
137			.access_token
138			.as_ref()
139			.expect("Token should have just been validated and thus be present and valid");
140
141		tracing::debug!(
142			"Access token is still valid for {:?}s",
143			access_token
144				.expires
145				.checked_duration_since(Instant::now())
146				.map(|dur| dur.as_secs())
147		);
148
149		Ok(&access_token.token)
150	}
151}
152
153impl GoogleOAuth2Error {
154	pub(crate) fn is_connection_err(&self) -> Option<&(dyn std::error::Error + Send + Sync)> {
155		// I know it will match any future variants automatically but I actually want it to do that anyways
156		#[allow(clippy::match_wildcard_for_single_variants)]
157		match self {
158			GoogleOAuth2Error::Post(_) => Some(self),
159			_ => None,
160		}
161	}
162}
163
164#[allow(clippy::doc_markdown)]
165/// Generate and return a new Google OAuth2 refresh token using the `client_id`, `client_secret`, and `access_code`
166///
167/// # Errors
168/// * if there was a network connection error
169/// * if the responce isn't a valid refresh_token
170pub async fn generate_refresh_token(
171	client_id: &str,
172	client_secret: &str,
173	access_code: &str,
174) -> Result<String, GoogleOAuth2Error> {
175	#[derive(Deserialize)]
176	struct Response {
177		refresh_token: String,
178	}
179
180	tracing::debug!("Generating a new OAuth2 refresh token from client_id: {client_id:?}, client_secret: {client_secret:?}, and access_code: {access_code:?}");
181
182	let body = [
183		("client_id", client_id),
184		("client_secret", client_secret),
185		("code", access_code),
186		("redirect_uri", "urn:ietf:wg:oauth:2.0:oob"),
187		("grant_type", "authorization_code"),
188	];
189
190	let resp = reqwest::Client::new()
191		.post(GOOGLE_AUTH_URL)
192		.form(&body)
193		.send()
194		.await
195		.map_err(GoogleOAuth2Error::Post)?
196		.text()
197		.await
198		.map_err(GoogleOAuth2Error::Post)?;
199
200	tracing::debug!("Got {resp:?} from the Google OAuth2 endpoint");
201
202	let Response { refresh_token } =
203		serde_json::from_str(&resp).map_err(|_| GoogleOAuth2Error::RefreshToken(resp))?;
204
205	Ok(refresh_token)
206}
207
208async fn generate_access_token(
209	client_id: &str,
210	client_secret: &str,
211	refresh_token: &str,
212) -> Result<AccessTokenResponce, GoogleOAuth2Error> {
213	tracing::debug!("Generating a new OAuth2 access token from client_id: {client_id:?}, client_secret: {client_secret:?}, and refresh_token: {refresh_token:?}");
214
215	let body = [
216		("client_id", client_id),
217		("client_secret", client_secret),
218		("refresh_token", refresh_token),
219		("redirect_uri", "urn:ietf:wg:oauth:2.0:oob"),
220		("grant_type", "refresh_token"),
221	];
222
223	let resp = reqwest::Client::new()
224		.post(GOOGLE_AUTH_URL)
225		.form(&body)
226		.send()
227		.await
228		.map_err(GoogleOAuth2Error::Post)?
229		.text()
230		.await
231		.map_err(GoogleOAuth2Error::Post)?;
232
233	tracing::debug!("Got {resp:?} from the Google OAuth2 endpoint");
234
235	serde_json::from_str(&resp).map_err(|_| GoogleOAuth2Error::AccessToken(resp))
236}