use std::path::PathBuf;
use std::sync::Arc;
use std::sync::LazyLock;
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 fn scopes() -> &'static [String] {
static SCOPES: LazyLock<Vec<String>> = LazyLock::new(|| {
let mut set: Vec<String> = CURATED_SCOPES.iter().map(|s| (*s).to_owned()).collect();
set.extend(crate::discovery::all_scopes());
set.sort_unstable();
set.dedup();
set
});
&SCOPES
}
pub(crate) fn scopes_joined() -> String {
scopes().join(" ")
}
const CURATED_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",
"https://www.googleapis.com/auth/webmasters.readonly",
];
#[derive(Clone)]
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
}
pub async fn new_for(config: OAuthConfig, account: impl Into<String>) -> Result<Self> {
let account = store::validate_account_name(account.into().as_str())?;
let store = TokenStore::auto_named(&account).await?;
Self::with_store(config, store).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)))?;
Ok(Self {
config,
http_client,
token_storage: Arc::new(RwLock::new(None)),
store,
token_endpoint: GOOGLE_TOKEN_URL.to_string(),
})
}
pub async fn with_account_store(&self, account: impl Into<String>) -> Result<Self> {
Self::new_for(self.config.clone(), account).await
}
pub async fn with_default_account_store(&self) -> Result<Self> {
Self::with_store(self.config.clone(), TokenStore::default_named().await?).await
}
pub fn token_backend(&self) -> &'static str {
self.store.backend()
}
pub fn http(&self) -> &HttpClient {
&self.http_client
}
#[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 storage_guard.is_none() {
*storage_guard = self.store.load().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 token_request(
&self,
mut form: Vec<(&str, String)>,
) -> Result<(reqwest::StatusCode, serde_json::Value)> {
if let Some(secret) = self
.config
.client_secret
.as_deref()
.filter(|s| !s.is_empty())
{
form.push(("client_secret", secret.to_string()));
}
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)?;
let body: serde_json::Value = serde_json::from_str(&body_text).map_err(|e| {
GrrError::Auth(
anyhow!("token endpoint returned {status}: unparseable body ({e})").into(),
)
})?;
Ok((status, body))
}
async fn refresh_token(&self, refresh_token: &str) -> Result<TokenStorage> {
let (status, token_data) = self
.token_request(vec![
("client_id", self.config.client_id.clone()),
("refresh_token", refresh_token.to_string()),
("grant_type", "refresh_token".to_string()),
])
.await?;
if !status.is_success() {
return Err(GrrError::Auth(
anyhow!(
"token endpoint returned {}: {}",
status,
crate::core::error::json_error_detail(&token_data)
)
.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()
}
}