#![doc = "This module contains the Google authenticator that can access Google services via OAuth2"]
use serde::Deserialize;
use std::time::{Duration, Instant};
use crate::{StaticStr, error::Error};
const GOOGLE_AUTH_URL: &str = "https://accounts.google.com/o/oauth2/token";
#[expect(clippy::doc_markdown, reason = "false positive")]
#[derive(Clone, Debug)]
pub struct Google {
pub client_id: StaticStr,
pub client_secret: StaticStr,
pub refresh_token: StaticStr,
access_token: Option<AccessToken>,
}
#[expect(missing_docs, reason = "error message is self-documenting")]
#[derive(thiserror::Error, Debug)]
pub enum GoogleOAuth2Error {
#[error("Error contacting Google servers for authentication")]
Post(#[source] reqwest::Error),
#[error("Can't get a new OAuth2 refresh token from Google: {0}")]
RefreshToken(String),
#[error("Can't get a new OAuth2 access token from Google: {0}")]
AccessToken(String),
}
#[expect(clippy::doc_markdown, reason = "false positive")]
#[derive(Clone, Debug)]
struct AccessToken {
pub token: String,
pub expires: Instant,
}
#[derive(Deserialize)]
struct AccessTokenResponce {
access_token: String,
expires_in: u64,
}
impl Google {
#[expect(clippy::doc_markdown, reason = "false positive")]
#[must_use]
pub fn new(
client_id: impl Into<StaticStr>,
client_secret: impl Into<StaticStr>,
refresh_token: impl Into<StaticStr>,
) -> Self {
Self {
client_id: client_id.into(),
client_secret: client_secret.into(),
refresh_token: refresh_token.into(),
access_token: None,
}
}
pub async fn get_new_access_token(&mut self) -> Result<(), GoogleOAuth2Error> {
let AccessTokenResponce {
access_token,
expires_in,
} = generate_access_token(&self.client_id, &self.client_secret, &self.refresh_token).await?;
tracing::debug!("New access token expires in {expires_in}s");
self.access_token = Some(AccessToken {
token: access_token,
expires: Instant::now() + Duration::from_secs(expires_in),
});
Ok(())
}
#[tracing::instrument(name = "google_oauth2_access_token")]
pub async fn access_token(&mut self) -> Result<&str, GoogleOAuth2Error> {
if {
let access_token_doesnt_exist = self.access_token.is_none();
if access_token_doesnt_exist {
tracing::trace!("Access token doesn't exist");
}
access_token_doesnt_exist
} || {
let is_expired = self
.access_token
.as_ref()
.and_then(|x| Instant::now().checked_duration_since(x.expires))
.is_some();
if is_expired {
tracing::trace!("Access token has expired");
}
is_expired
} {
self.get_new_access_token().await?;
}
let access_token = self
.access_token
.as_ref()
.expect("Token should have just been validated and thus be present and valid");
tracing::debug!(
"Access token is still valid for {:?}s",
access_token
.expires
.checked_duration_since(Instant::now())
.map(|dur| dur.as_secs())
);
Ok(&access_token.token)
}
}
impl Error for GoogleOAuth2Error {
fn is_network_related(&self) -> Option<&dyn Error> {
match self {
GoogleOAuth2Error::Post(_) => Some(self),
_ => None,
}
}
}
#[expect(clippy::doc_markdown, reason = "false positive")]
pub async fn generate_refresh_token(
client_id: &str,
client_secret: &str,
access_code: &str,
) -> Result<String, GoogleOAuth2Error> {
#[derive(Deserialize)]
struct Response {
refresh_token: String,
}
tracing::debug!(
"Generating a new OAuth2 refresh token from client_id: {client_id:?}, client_secret: {client_secret:?}, and access_code: {access_code:?}"
);
let body = [
("client_id", client_id),
("client_secret", client_secret),
("code", access_code),
("redirect_uri", "urn:ietf:wg:oauth:2.0:oob"),
("grant_type", "authorization_code"),
];
let resp = reqwest::Client::new()
.post(GOOGLE_AUTH_URL)
.form(&body)
.send()
.await
.map_err(GoogleOAuth2Error::Post)?
.text()
.await
.map_err(GoogleOAuth2Error::Post)?;
tracing::debug!("Got {resp:?} from the Google OAuth2 endpoint");
let Response { refresh_token } =
serde_json::from_str(&resp).map_err(|_| GoogleOAuth2Error::RefreshToken(resp))?;
Ok(refresh_token)
}
async fn generate_access_token(
client_id: &str,
client_secret: &str,
refresh_token: &str,
) -> Result<AccessTokenResponce, GoogleOAuth2Error> {
tracing::debug!(
"Generating a new OAuth2 access token from client_id: {client_id:?}, client_secret: {client_secret:?}, and refresh_token: {refresh_token:?}"
);
let body = [
("client_id", client_id),
("client_secret", client_secret),
("refresh_token", refresh_token),
("redirect_uri", "urn:ietf:wg:oauth:2.0:oob"),
("grant_type", "refresh_token"),
];
let resp = reqwest::Client::new()
.post(GOOGLE_AUTH_URL)
.form(&body)
.send()
.await
.map_err(GoogleOAuth2Error::Post)?
.text()
.await
.map_err(GoogleOAuth2Error::Post)?;
tracing::debug!("Got {resp:?} from the Google OAuth2 endpoint");
serde_json::from_str(&resp).map_err(|_| GoogleOAuth2Error::AccessToken(resp))
}