botserver/src/contacts/google_client.rs
Rodrigo Rodriguez e143968179 feat: Add JWT secret rotation and health verification
SEC-02: Implement credential rotation security improvements

- Add JWT secret rotation to rotate-secret command
- Generate 64-character HS512-compatible secrets
- Automatic .env backup with timestamp
- Atomic file updates via temp+rename pattern
- Add health verification for rotated credentials
- Route rotate-secret, rotate-secrets, vault commands in CLI
- Add verification attempts for database and JWT endpoints

Security improvements:
- JWT_SECRET now rotatable (previously impossible)
- Automatic rollback via backup files
- Health checks catch configuration errors
- Clear warnings about token invalidation

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-19 19:42:41 +00:00

493 lines
16 KiB
Rust

// Google People API client extracted from external_sync.rs
use crate::contacts::external_sync::{ExternalContact, ExternalEmail, ExternalPhone};
use chrono::{DateTime, Utc};
use reqwest::Client;
use serde::Deserialize;
use std::collections::HashMap;
#[derive(Debug, Clone)]
pub struct GoogleClient {
pub client: Client,
pub base_url: String,
}
#[derive(Debug, Clone)]
pub struct GoogleConfig {
pub client_id: String,
pub client_secret: String,
}
pub struct GoogleContactsClient {
config: GoogleConfig,
client: Client,
}
impl GoogleContactsClient {
pub fn new(config: GoogleConfig) -> Self {
Self {
config,
client: Client::new(),
}
}
pub fn get_auth_url(&self, redirect_uri: &str, state: &str) -> String {
format!(
"https://accounts.google.com/o/oauth2/v2/auth?client_id={}&redirect_uri={}&response_type=code&scope=https://www.googleapis.com/auth/contacts&state={}",
self.config.client_id, redirect_uri, state
)
}
pub async fn exchange_code(&self, code: &str, redirect_uri: &str) -> Result<TokenResponse, GoogleError> {
let response = self.client
.post("https://oauth2.googleapis.com/token")
.form(&[
("client_id", self.config.client_id.as_str()),
("client_secret", self.config.client_secret.as_str()),
("code", code),
("redirect_uri", redirect_uri),
("grant_type", "authorization_code"),
])
.send()
.await
.map_err(|e| GoogleError::NetworkError(e.to_string()))?;
if !response.status().is_success() {
return Err(GoogleError::ApiError(format!("Token exchange failed: {}", response.status())));
}
#[derive(Deserialize)]
struct GoogleTokenResponse {
access_token: String,
refresh_token: Option<String>,
expires_in: i64,
scope: Option<String>,
}
let token_data: GoogleTokenResponse = response.json().await
.map_err(|e| GoogleError::ParseError(e.to_string()))?;
Ok(TokenResponse {
access_token: token_data.access_token,
refresh_token: token_data.refresh_token,
expires_in: token_data.expires_in,
expires_at: Some(Utc::now() + chrono::Duration::seconds(token_data.expires_in)),
scopes: token_data.scope.map(|s| s.split(' ').map(String::from).collect()).unwrap_or_default(),
})
}
pub async fn get_user_info(&self, access_token: &str) -> Result<UserInfo, GoogleError> {
let response = self.client
.get("https://www.googleapis.com/oauth2/v2/userinfo")
.bearer_auth(access_token)
.send()
.await
.map_err(|e| GoogleError::NetworkError(e.to_string()))?;
if !response.status().is_success() {
return Err(GoogleError::ApiError("Failed to get user info".to_string()));
}
#[derive(Deserialize)]
struct GoogleUserInfo {
id: String,
email: String,
name: Option<String>,
}
let info: GoogleUserInfo = response.json().await
.map_err(|e| GoogleError::ParseError(e.to_string()))?;
Ok(UserInfo {
id: info.id,
email: info.email,
name: info.name,
})
}
pub async fn revoke_token(&self, _access_token: &str) -> Result<(), GoogleError> {
// Simple revoke - in real implementation would call revoke endpoint
Ok(())
}
}
#[derive(Debug, Clone)]
pub struct UserInfo {
pub id: String,
pub email: String,
pub name: Option<String>,
}
#[derive(Debug, Clone)]
pub struct TokenResponse {
pub access_token: String,
pub refresh_token: Option<String>,
pub expires_in: i64,
pub expires_at: Option<DateTime<Utc>>,
pub scopes: Vec<String>,
}
impl GoogleClient {
pub fn new() -> Self {
Self {
client: Client::new(),
base_url: "https://people.googleapis.com/v1".to_string(),
}
}
pub async fn fetch_contacts(&self, access_token: &str) -> Result<(Vec<ExternalContact>, Option<String>), GoogleError> {
let mut all_contacts = Vec::new();
let mut page_token: Option<String> = None;
loop {
let (contacts, next_token) = self.list_contacts(access_token, page_token.as_deref()).await?;
all_contacts.extend(contacts);
if next_token.is_none() {
break;
}
page_token = next_token;
if all_contacts.len() > 10000 {
log::warn!("Reached contact fetch limit");
break;
}
}
Ok((all_contacts, None))
}
pub async fn list_contacts(
&self,
access_token: &str,
page_token: Option<&str>,
) -> Result<(Vec<ExternalContact>, Option<String>), GoogleError> {
let mut url = format!(
"{}/people/me/connections?personFields=names,emailAddresses,phoneNumbers,organizations,biographies",
self.base_url
);
if let Some(token) = page_token {
url.push_str(&format!("&pageToken={}", token));
}
let response = self
.client
.get(&url)
.bearer_auth(access_token)
.send()
.await
.map_err(|e| GoogleError::NetworkError(e.to_string()))?;
if !response.status().is_success() {
return Err(GoogleError::ApiError(format!(
"Failed to list contacts: {}",
response.status()
)));
}
#[derive(Deserialize)]
struct GoogleResponse {
connections: Option<Vec<GooglePerson>>,
next_page_token: Option<String>,
}
let data: GoogleResponse = response
.json()
.await
.map_err(|e| GoogleError::ParseError(e.to_string()))?;
let contacts = data
.connections
.unwrap_or_default()
.into_iter()
.map(|person| {
let first_name = person
.names
.as_ref()
.and_then(|n| n.first().map(|n| n.given_name.clone()))
.unwrap_or_default();
let last_name = person
.names
.as_ref()
.and_then(|n| n.first().map(|n| n.family_name.clone()))
.unwrap_or_default();
let display_name = person
.names
.as_ref()
.and_then(|n| n.first().and_then(|n| n.display_name.clone()))
.unwrap_or_default();
let email = person.email_addresses.as_ref().and_then(|emails| {
emails
.first()
.and_then(|e| e.value.clone())
.map(|addr| ExternalEmail {
address: addr,
label: e.metadata.as_ref().and_then(|m| m.primary.clone()),
primary: e.metadata.as_ref().map(|m| m.primary).unwrap_or(false),
})
});
let phone = person.phone_numbers.as_ref().and_then(|phones| {
phones.first().map(|p| ExternalPhone {
number: p.value.clone().unwrap_or_default(),
label: p.metadata.as_ref().and_then(|m| m.primary.clone()),
primary: p.metadata.as_ref().map(|m| m.primary).unwrap_or(false),
})
});
ExternalContact {
id: person.resource_name.unwrap_or_default(),
etag: person.etag,
first_name,
last_name,
display_name,
email_addresses: email.map(|e| vec![e]).unwrap_or_default(),
phone_numbers: phone.map(|p| vec![p]).unwrap_or_default(),
addresses: vec![],
company: person
.organizations
.as_ref()
.and_then(|o| o.first().and_then(|org| org.name.clone())),
job_title: person
.organizations
.as_ref()
.and_then(|o| o.first().and_then(|org| org.title.clone())),
department: None,
notes: person.biographies.as_ref().and_then(|b| {
b.first()
.and_then(|bio| bio.content.clone())
.map(|c| c.clone())
}),
birthday: None,
photo_url: person.photos.as_ref().and_then(|photos| {
photos.first().and_then(|photo| photo.url.clone())
}),
groups: vec![],
custom_fields: Default::default(),
created_at: None,
updated_at: None,
}
})
.collect();
Ok((contacts, data.next_page_token))
}
pub async fn create_contact(
&self,
access_token: &str,
contact: &ExternalContact,
) -> Result<String, GoogleError> {
let body = serde_json::json!({
"names": [{
"givenName": contact.first_name,
"familyName": contact.last_name,
"displayName": contact.display_name
}],
"emailAddresses": if contact.email_addresses.is_empty() { None } else {
Some(contact.email_addresses.iter().map(|e| serde_json::json!({
"value": e.address,
"metadata": {"primary": e.primary}
})).collect::<Vec<_>>())
},
"phoneNumbers": if contact.phone_numbers.is_empty() { None } else {
Some(contact.phone_numbers.iter().map(|p| serde_json::json!({
"value": p.number,
"metadata": {"primary": p.primary}
})).collect::<Vec<_>>())
},
"organizations": if contact.company.is_some() || contact.job_title.is_some() {
Some(vec![serde_json::json!({
"name": contact.company.unwrap_or_default(),
"title": contact.job_title.unwrap_or_default()
})])
} else { None }
});
let response = self
.client
.post(&format!(
"{}/people/me/connections:create",
self.base_url
))
.query(&[("personFields", "names,emailAddresses,phoneNumbers,organizations")])
.bearer_auth(access_token)
.json(&body)
.send()
.await
.map_err(|e| GoogleError::NetworkError(e.to_string()))?;
if !response.status().is_success() {
return Err(GoogleError::ApiError(format!(
"Create contact failed: {}",
response.status()
)));
}
#[derive(Deserialize)]
struct CreateResponse {
resourceName: String,
}
let data: CreateResponse = response
.json()
.await
.map_err(|e| GoogleError::ParseError(e.to_string()))?;
Ok(data.resourceName)
}
pub async fn update_contact(
&self,
access_token: &str,
resource_name: &str,
contact: &ExternalContact,
) -> Result<(), GoogleError> {
let body = serde_json::json!({
"names": [{
"givenName": contact.first_name,
"familyName": contact.last_name,
"displayName": contact.display_name
}],
"emailAddresses": if contact.email_addresses.is_empty() { None } else {
Some(contact.email_addresses.iter().map(|e| serde_json::json!({
"value": e.address,
"metadata": {"primary": e.primary}
})).collect::<Vec<_>>())
},
"phoneNumbers": if contact.phone_numbers.is_empty() { None } else {
Some(contact.phone_numbers.iter().map(|p| serde_json::json!({
"value": p.number,
"metadata": {"primary": p.primary}
})).collect::<Vec<_>>())
},
"organizations": if contact.company.is_some() || contact.job_title.is_some() {
Some(vec![serde_json::json!({
"name": contact.company.unwrap_or_default(),
"title": contact.job_title.unwrap_or_default()
})])
} else { None }
});
let response = self
.client
.patch(&format!(
"{}/people/me/{}:update",
self.base_url, resource_name
))
.query(&[("personFields", "names,emailAddresses,phoneNumbers,organizations")])
.bearer_auth(access_token)
.json(&body)
.send()
.await
.map_err(|e| GoogleError::NetworkError(e.to_string()))?;
if !response.status().is_success() {
return Err(GoogleError::ApiError(format!(
"Update contact failed: {}",
response.status()
)));
}
Ok(())
}
pub async fn delete_contact(
&self,
access_token: &str,
resource_name: &str,
) -> Result<(), GoogleError> {
let response = self
.client
.delete(&format!(
"{}/people/me/{}",
self.base_url, resource_name
))
.bearer_auth(access_token)
.send()
.await
.map_err(|e| GoogleError::NetworkError(e.to_string()))?;
if !response.status().is_success() {
return Err(GoogleError::ApiError(format!(
"Delete contact failed: {}",
response.status()
)));
}
Ok(())
}
}
#[derive(Debug, Clone)]
pub enum GoogleError {
NetworkError(String),
ApiError(String),
ParseError(String),
}
impl std::fmt::Display for GoogleError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NetworkError(e) => write!(f, "Network error: {e}"),
Self::ApiError(e) => write!(f, "API error: {e}"),
Self::ParseError(e) => write!(f, "Parse error: {e}"),
}
}
}
impl std::error::Error for GoogleError {}
#[derive(Debug, Clone, Deserialize)]
struct GooglePerson {
resource_name: Option<String>,
etag: Option<String>,
names: Option<Vec<GoogleName>>,
email_addresses: Option<Vec<GoogleEmail>>,
phone_numbers: Option<Vec<GooglePhone>>,
organizations: Option<Vec<GoogleOrganization>>,
biographies: Option<Vec<GoogleBiography>>,
photos: Option<Vec<GooglePhoto>>,
}
#[derive(Debug, Clone, Deserialize)]
struct GoogleName {
given_name: String,
family_name: String,
display_name: Option<String>,
metadata: Option<GoogleMetadata>,
}
#[derive(Debug, Clone, Deserialize)]
struct GoogleEmail {
value: String,
metadata: Option<GoogleMetadata>,
}
#[derive(Debug, Clone, Deserialize)]
struct GooglePhone {
value: Option<String>,
metadata: Option<GoogleMetadata>,
}
#[derive(Debug, Clone, Deserialize)]
struct GoogleOrganization {
name: Option<String>,
title: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
struct GoogleBiography {
content: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
struct GooglePhoto {
url: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
struct GoogleMetadata {
primary: Option<bool>,
}