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;
pub(crate) use device::device_flow_scopes;
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.retain(|scope| !APP_AUTH_SCOPES.contains(&scope.as_str()));
set.sort_unstable();
set.dedup();
set
});
&SCOPES
}
const LOGIN_SCOPES: &[&str] = &[
"https://mail.google.com/",
"https://www.googleapis.com/auth/drive",
"https://www.googleapis.com/auth/calendar",
"https://www.googleapis.com/auth/calendar.events",
"https://www.googleapis.com/auth/tasks",
"https://www.googleapis.com/auth/documents",
"https://www.googleapis.com/auth/spreadsheets",
"https://www.googleapis.com/auth/presentations",
"https://www.googleapis.com/auth/userinfo.email",
"https://www.googleapis.com/auth/userinfo.profile",
"openid",
];
pub fn login_scopes() -> Vec<String> {
let available = scopes();
LOGIN_SCOPES
.iter()
.filter(|scope| available.iter().any(|known| known.as_str() == **scope))
.map(|scope| (*scope).to_owned())
.collect()
}
pub fn scopes_for_service(service: &str) -> Option<Vec<String>> {
let mut set: Vec<String> = Vec::new();
let entry = crate::discovery::services().get(service)?;
for method in &entry.methods {
for scope in &method.scopes {
if !APP_AUTH_SCOPES.contains(&scope.as_str()) {
set.push(scope.clone());
}
}
}
set.sort_unstable();
set.dedup();
Some(set)
}
const CURATED_SCOPES: &[&str] = &[
"openid",
"https://www.googleapis.com/auth/userinfo.email",
"https://www.googleapis.com/auth/userinfo.profile",
"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",
];
pub(crate) const APP_AUTH_SCOPES: &[&str] = &[
"https://www.googleapis.com/auth/chat.bot",
"https://www.googleapis.com/auth/chat.import",
"https://www.googleapis.com/auth/chat.app.delete",
"https://www.googleapis.com/auth/chat.app.memberships",
"https://www.googleapis.com/auth/chat.app.memberships.readonly",
"https://www.googleapis.com/auth/chat.app.messages.readonly",
"https://www.googleapis.com/auth/chat.app.spaces",
"https://www.googleapis.com/auth/chat.app.spaces.create",
"https://www.googleapis.com/auth/chat.app.spaces.readonly",
"https://www.googleapis.com/auth/chat.app.all.memberships.readonly",
"https://www.googleapis.com/auth/chat.app.all.messages.readonly",
"https://www.googleapis.com/auth/chat.app.all.spaces.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(mut 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)))?;
config.client_secret = config.client_secret.take().filter(|s| !s.is_empty());
config.device_client_secret = config.device_client_secret.take().filter(|s| !s.is_empty());
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 async fn granted_scopes(&self) -> Vec<String> {
self.token_storage
.read()
.await
.as_ref()
.map(|storage| {
storage
.scope
.split_whitespace()
.map(str::to_owned)
.collect()
})
.unwrap_or_default()
}
#[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 refreshed = {
let mut guard = self.token_storage.write().await;
if guard.is_none() {
*guard = self.store.load().await?;
}
match guard.as_ref() {
None => None,
Some(storage) if !storage.is_expired() => {
debug!(
"Using cached access token ({}s remaining)",
storage.remaining_secs()
);
return Ok(storage.access_token.clone());
}
Some(storage) => {
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(),
));
};
let stored_scopes: Vec<String> = storage
.scope
.split_whitespace()
.map(str::to_owned)
.collect();
Some((refresh_token, stored_scopes))
}
}
};
if let Some((refresh_token, stored_scopes)) = refreshed {
info!("Refreshing expired access token");
match self.refresh_token(&refresh_token, &stored_scopes).await {
Ok(new_storage) => {
*self.token_storage.write().await = 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(&login_scopes()).await?;
*self.token_storage.write().await = Some(storage.clone());
self.save_token(&storage).await?;
Ok(storage.access_token)
}
async fn token_request(
&self,
endpoint: &str,
client_secret: Option<&str>,
mut form: Vec<(&str, String)>,
) -> Result<(reqwest::StatusCode, serde_json::Value)> {
if let Some(secret) = client_secret.filter(|s| !s.is_empty()) {
form.push(("client_secret", secret.to_string()));
}
let response = self
.http_client
.post(endpoint)
.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!("{endpoint} returned {status}: unparseable body ({e})").into())
})?;
Ok((status, body))
}
async fn refresh_token(
&self,
refresh_token: &str,
requested_scopes: &[String],
) -> Result<TokenStorage> {
let (status, token_data) = self
.token_request(
&self.token_endpoint,
self.config.client_secret.as_deref(),
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, requested_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(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_consent_set_drops_app_authentication_scopes_only() {
let consent = scopes();
for scope in APP_AUTH_SCOPES {
assert!(
!consent.iter().any(|consented| consented == scope),
"{scope} is app-authentication-only; Google refuses to show it and \
fails the entire authorization request"
);
}
for scope in [
"https://www.googleapis.com/auth/chat.messages",
"https://www.googleapis.com/auth/chat.admin.spaces",
"https://www.googleapis.com/auth/chat.memberships.app",
] {
assert!(
consent.iter().any(|consented| consented == scope),
"{scope} is a user-consent scope and must stay in the request"
);
}
}
#[test]
fn the_default_login_set_is_small_and_inside_the_full_set() {
let login = login_scopes();
let full = scopes();
assert!(
login.len() <= 20,
"the default login set is {} scopes: Google's consent screen stops \
submitting past ~27 ticked boxes, so this is a regression",
login.len()
);
assert!(
login.len() >= 10,
"the default set lost its core services: {login:?}"
);
for scope in &login {
assert!(
full.iter().any(|known| known == scope),
"{scope} is not in scopes() — requesting an undeclared scope hangs too"
);
}
}
#[test]
fn service_scopes_come_from_the_index_and_stay_narrow() {
let docs = scopes_for_service("docs").unwrap_or_default();
assert!(!docs.is_empty(), "docs is one of the 14 services");
assert!(
docs.iter()
.any(|scope| scope == "https://www.googleapis.com/auth/documents")
);
assert!(
docs.len() < scopes().len(),
"one service's scopes must stay far below the whole index"
);
for scope in &docs {
assert!(
scopes().iter().any(|known| known == scope),
"{scope} is not in scopes()"
);
}
assert!(scopes_for_service("not-a-service").is_none());
}
#[tokio::test]
async fn granted_scopes_read_the_stored_grant_not_the_request() -> anyhow::Result<()> {
let config = crate::core::config::OAuthConfig::default();
let granted = "openid https://www.googleapis.com/auth/calendar";
let loaded = GoogleAuth::with_token(
config.clone(),
TokenStorage {
access_token: "t".into(),
refresh_token: None,
expires_at: u64::MAX,
token_type: "Bearer".into(),
scope: granted.into(),
},
)
.await?;
assert_eq!(
loaded.granted_scopes().await,
vec![
"openid".to_owned(),
"https://www.googleapis.com/auth/calendar".to_owned()
]
);
let empty = GoogleAuth::with_token(
config,
TokenStorage {
access_token: "t".into(),
refresh_token: None,
expires_at: u64::MAX,
token_type: "Bearer".into(),
scope: String::new(),
},
)
.await?;
assert!(empty.granted_scopes().await.is_empty());
Ok(())
}
}