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>
493 lines
16 KiB
Rust
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>,
|
|
}
|