use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use anyhow::anyhow;
use reqwest::Client as HttpClient;
use tokio::sync::RwLock;
use tracing::{debug, info, warn};
use crate::core::config::OAuthConfig;
use crate::core::error::{GrrError, Result};
mod device;
mod oauth;
mod server;
mod store;
mod token;
pub use device::DeviceAuthChallenge;
pub use token::TokenStorage;
use store::TokenStore;
const GOOGLE_TOKEN_URL: &str = "https://oauth2.googleapis.com/token";
pub(crate) const REDIRECT_URI: &str = "http://localhost:3434/oauth/callback";
pub(crate) const SCOPES: &[&str] = &[
"https://www.googleapis.com/auth/gmail.readonly",
"https://www.googleapis.com/auth/gmail.compose",
"https://www.googleapis.com/auth/gmail.modify",
"https://www.googleapis.com/auth/gmail.labels",
"https://mail.google.com/",
"https://www.googleapis.com/auth/calendar",
"https://www.googleapis.com/auth/drive",
"https://www.googleapis.com/auth/contacts",
"https://www.googleapis.com/auth/contacts.other.readonly",
"https://www.googleapis.com/auth/chat.messages",
"https://www.googleapis.com/auth/chat.spaces",
"https://www.googleapis.com/auth/chat.delete",
"https://www.googleapis.com/auth/chat.memberships",
"https://www.googleapis.com/auth/chat.messages.reactions",
"https://www.googleapis.com/auth/forms.body",
"https://www.googleapis.com/auth/forms.responses.readonly",
];
pub(crate) fn scopes_joined() -> String {
SCOPES.join(" ")
}
pub struct GoogleAuth {
config: OAuthConfig,
http_client: HttpClient,
token_storage: Arc<RwLock<Option<TokenStorage>>>,
store: TokenStore,
token_endpoint: String,
}
impl GoogleAuth {
pub async fn new(config: OAuthConfig) -> Result<Self> {
Self::with_store(config, TokenStore::auto().await?).await
}
async fn with_store(config: OAuthConfig, store: TokenStore) -> Result<Self> {
let http_client = HttpClient::builder()
.timeout(Duration::from_secs(30))
.build()
.map_err(|e| GrrError::Config(format!("Failed to create HTTP client: {}", e)))?;
let token_storage = store.load().await?;
Ok(Self {
config,
http_client,
token_storage: Arc::new(RwLock::new(token_storage)),
store,
token_endpoint: GOOGLE_TOKEN_URL.to_string(),
})
}
pub fn token_backend(&self) -> &'static str {
self.store.backend()
}
#[doc(hidden)]
pub async fn with_token(config: OAuthConfig, storage: TokenStorage) -> Result<Self> {
let this = Self::with_store(config, TokenStore::Memory).await?;
*this.token_storage.write().await = Some(storage);
Ok(this)
}
#[doc(hidden)]
pub fn with_token_endpoint(mut self, url: impl Into<String>) -> Self {
self.token_endpoint = url.into();
self
}
#[doc(hidden)]
pub fn with_token_path(mut self, path: PathBuf) -> Self {
self.store = TokenStore::file(path);
self
}
pub async fn get_access_token(&self) -> Result<String> {
let mut storage_guard = self.token_storage.write().await;
if let Some(storage) = storage_guard.as_ref() {
if !storage.is_expired() {
debug!(
"Using cached access token ({}s remaining)",
storage.remaining_secs()
);
return Ok(storage.access_token.clone());
}
let Some(refresh_token) = storage.refresh_token.clone() else {
return Err(GrrError::Auth(
anyhow!(
"stored token is expired and has no refresh token; rerun `grr auth login`"
)
.into(),
));
};
info!("Refreshing expired access token");
match self.refresh_token(&refresh_token).await {
Ok(new_storage) => {
*storage_guard = Some(new_storage.clone());
self.save_token(&new_storage).await?;
return Ok(new_storage.access_token);
}
Err(e) => {
warn!("Token refresh failed: {}", e);
return Err(GrrError::Auth(
anyhow!("{e}; rerun `grr auth login`").into(),
));
}
}
}
info!("No valid token, starting OAuth flow");
let storage = self.run_oauth_flow().await?;
*storage_guard = Some(storage.clone());
self.save_token(&storage).await?;
Ok(storage.access_token)
}
async fn refresh_token(&self, refresh_token: &str) -> Result<TokenStorage> {
let mut form = vec![
("client_id", self.config.client_id.as_str()),
("refresh_token", refresh_token),
("grant_type", "refresh_token"),
];
if let Some(secret) = self
.config
.client_secret
.as_deref()
.filter(|s| !s.is_empty())
{
form.push(("client_secret", secret));
}
let response = self
.http_client
.post(self.token_endpoint.as_str())
.form(&form)
.send()
.await
.map_err(GrrError::Http)?;
let status = response.status();
let body_text = response.text().await.map_err(GrrError::Http)?;
if !status.is_success() {
let body: serde_json::Value = serde_json::from_str(&body_text).map_err(|e| {
GrrError::Auth(
anyhow!("token endpoint returned {}: unparseable body ({e})", status).into(),
)
})?;
return Err(GrrError::Auth(
anyhow!(
"token endpoint returned {}: {}",
status,
crate::core::error::json_error_detail(&body)
)
.into(),
));
}
let token_data: serde_json::Value = serde_json::from_str(&body_text).map_err(|e| {
GrrError::Auth(anyhow!("token endpoint returned unparseable success body ({e})").into())
})?;
let mut storage = self::device::token_storage_from_response(&token_data, SCOPES)?;
if storage.refresh_token.is_none() {
storage.refresh_token = Some(refresh_token.to_string());
}
Ok(storage)
}
pub async fn revoke(&self) -> Result<()> {
let mut storage_guard = self.token_storage.write().await;
*storage_guard = None;
self.store.delete().await?;
info!("Token revoked and removed");
Ok(())
}
}
pub struct AuthConfigBuilder {
config: OAuthConfig,
}
impl AuthConfigBuilder {
pub fn new() -> Self {
Self {
config: OAuthConfig::default(),
}
}
pub fn client_id(mut self, id: impl Into<String>) -> Self {
self.config.client_id = id.into();
self
}
pub fn client_secret(mut self, secret: Option<String>) -> Self {
self.config.client_secret = secret.filter(|s| !s.is_empty());
self
}
pub async fn build(self) -> Result<GoogleAuth> {
GoogleAuth::new(self.config).await
}
}
impl Default for AuthConfigBuilder {
fn default() -> Self {
Self::new()
}
}