Skip to content

Commit f6f53eb

Browse files
sovanesyanclaude
andcommitted
Switch Google auth to desktop OAuth flow with embedded credentials
Replace device code flow with localhost redirect OAuth flow for simpler UX. Embed default OAuth client credentials in binary with env var overrides (CALENDARCHY_GOOGLE_CLIENT_ID, CALENDARCHY_GOOGLE_CLIENT_SECRET). Simplify setup wizard to single y/n for Google, add 'S' key to re-enter setup, and ensure Google auth completes before iCloud setup begins. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 91a9429 commit f6f53eb

8 files changed

Lines changed: 262 additions & 270 deletions

File tree

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ edition = "2024"
66
[dependencies]
77
crossterm = "0.28"
88
chrono = { version = "0.4", features = ["serde"] }
9-
tokio = { version = "1", features = ["rt-multi-thread", "macros", "time", "sync"] }
9+
tokio = { version = "1", features = ["rt-multi-thread", "macros", "time", "sync", "net", "io-util"] }
1010
reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false }
1111
serde = { version = "1.0", features = ["derive"] }
1212
serde_json = "1.0"

src/app.rs

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,7 @@ pub struct SearchResult {
3030
pub enum SetupStep {
3131
Welcome,
3232
GoogleAsk,
33-
GoogleOpenUrl,
34-
GoogleClientId,
35-
GoogleSecret,
33+
GoogleAuthWaiting,
3634
ICloudAsk,
3735
ICloudMethod, // Choose EventKit vs CalDAV (macOS only)
3836
ICloudOpenUrl,
@@ -52,8 +50,7 @@ pub enum ICloudMethod {
5250
pub struct SetupState {
5351
pub step: SetupStep,
5452
pub input: String,
55-
pub google_client_id: Option<String>,
56-
pub google_client_secret: Option<String>,
53+
pub google_enabled: bool,
5754
pub icloud_method: Option<ICloudMethod>,
5855
pub icloud_apple_id: Option<String>,
5956
pub icloud_password: Option<String>,
@@ -66,8 +63,7 @@ impl SetupState {
6663
Self {
6764
step: SetupStep::Welcome,
6865
input: String::new(),
69-
google_client_id: None,
70-
google_client_secret: None,
66+
google_enabled: false,
7167
icloud_method: None,
7268
icloud_apple_id: None,
7369
icloud_password: None,

src/auth.rs

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
use chrono::{DateTime, Utc};
21
use crate::google::TokenInfo;
32

43
/// Trait for auth state display
@@ -11,16 +10,8 @@ pub trait AuthDisplay {
1110
pub enum GoogleAuthState {
1211
NotConfigured,
1312
NotAuthenticated,
14-
AwaitingUserCode {
15-
#[allow(dead_code)]
16-
user_code: String,
17-
#[allow(dead_code)]
18-
verification_url: String,
19-
device_code: String,
20-
expires_at: DateTime<Utc>,
21-
},
13+
Authenticating,
2214
Authenticated(TokenInfo),
23-
#[allow(dead_code)]
2415
Error(String),
2516
}
2617

src/config.rs

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,15 +14,42 @@ pub struct Config {
1414
pub icloud: Option<ICloudConfig>,
1515
}
1616

17+
/// Built-in Google OAuth credentials (public, identifies the app)
18+
pub const DEFAULT_GOOGLE_CLIENT_ID: &str =
19+
"313544353824-1g092hbgrmd6pemvklv58ld9radn0rg3.apps.googleusercontent.com";
20+
pub const DEFAULT_GOOGLE_CLIENT_SECRET: &str = "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";
21+
1722
/// Google Calendar configuration
1823
#[derive(Debug, Clone, Serialize, Deserialize)]
1924
pub struct GoogleConfig {
25+
#[serde(default = "default_google_client_id")]
2026
pub client_id: String,
27+
#[serde(default = "default_google_client_secret")]
2128
pub client_secret: String,
2229
#[serde(default = "default_calendar_id")]
2330
pub calendar_id: String,
2431
}
2532

33+
impl Default for GoogleConfig {
34+
fn default() -> Self {
35+
Self {
36+
client_id: default_google_client_id(),
37+
client_secret: default_google_client_secret(),
38+
calendar_id: "primary".to_string(),
39+
}
40+
}
41+
}
42+
43+
fn default_google_client_id() -> String {
44+
std::env::var("CALENDARCHY_GOOGLE_CLIENT_ID")
45+
.unwrap_or_else(|_| DEFAULT_GOOGLE_CLIENT_ID.to_string())
46+
}
47+
48+
fn default_google_client_secret() -> String {
49+
std::env::var("CALENDARCHY_GOOGLE_CLIENT_SECRET")
50+
.unwrap_or_else(|_| DEFAULT_GOOGLE_CLIENT_SECRET.to_string())
51+
}
52+
2653
/// iCloud Calendar configuration
2754
#[derive(Debug, Clone, Serialize, Deserialize)]
2855
pub struct ICloudConfig {
@@ -109,7 +136,18 @@ impl Config {
109136
}
110137

111138
let content = fs::read_to_string(&path)?;
112-
let config: Config = serde_json::from_str(&content)?;
139+
let mut config: Config = serde_json::from_str(&content)?;
140+
141+
// Env vars always override saved config
142+
if let Some(ref mut google) = config.google {
143+
if let Ok(id) = std::env::var("CALENDARCHY_GOOGLE_CLIENT_ID") {
144+
google.client_id = id;
145+
}
146+
if let Ok(secret) = std::env::var("CALENDARCHY_GOOGLE_CLIENT_SECRET") {
147+
google.client_secret = secret;
148+
}
149+
}
150+
113151
Ok(config)
114152
}
115153

src/google/auth.rs

Lines changed: 76 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,22 @@
11
use crate::config::GoogleConfig;
22
use crate::error::{CalendarchyError, Result};
3-
use crate::google::types::{DeviceCodeResponse, TokenInfo, TokenResponse};
3+
use crate::google::types::{TokenInfo, TokenResponse};
44
use crate::logging::{log_request, log_response};
55
use chrono::Utc;
66
use reqwest::Client;
7+
use tokio::io::{AsyncReadExt, AsyncWriteExt};
8+
use tokio::net::TcpListener;
79

8-
const DEVICE_CODE_URL: &str = "https://oauth2.googleapis.com/device/code";
10+
const AUTH_URL: &str = "https://accounts.google.com/o/oauth2/v2/auth";
911
const TOKEN_URL: &str = "https://oauth2.googleapis.com/token";
10-
const CALENDAR_SCOPE: &str = "https://www.googleapis.com/auth/calendar";
12+
const CALENDAR_SCOPE: &str = "https://www.googleapis.com/auth/calendar.events";
13+
const REDIRECT_URI: &str = "http://127.0.0.1:18457";
1114

1215
pub struct GoogleAuth {
1316
client: Client,
1417
config: GoogleConfig,
1518
}
1619

17-
#[derive(Debug)]
18-
pub enum PollResult {
19-
Success(TokenInfo),
20-
Pending,
21-
SlowDown,
22-
Denied,
23-
Expired,
24-
}
25-
2620
impl GoogleAuth {
2721
pub fn new(config: GoogleConfig) -> Self {
2822
Self {
@@ -31,70 +25,80 @@ impl GoogleAuth {
3125
}
3226
}
3327

34-
/// Step 1: Request device code
35-
pub async fn request_device_code(&self) -> Result<DeviceCodeResponse> {
36-
log_request("POST", DEVICE_CODE_URL);
37-
let response = self
38-
.client
39-
.post(DEVICE_CODE_URL)
40-
.form(&[
41-
("client_id", self.config.client_id.as_str()),
42-
("scope", CALENDAR_SCOPE),
43-
])
44-
.send()
45-
.await?;
46-
log_response(response.status().as_u16(), DEVICE_CODE_URL);
28+
/// Get the authorization URL that the user should open in their browser
29+
pub fn auth_url(&self) -> String {
30+
format!(
31+
"{}?client_id={}&redirect_uri={}&response_type=code&scope={}&access_type=offline&prompt=consent",
32+
AUTH_URL,
33+
urlencoding::encode(&self.config.client_id),
34+
urlencoding::encode(REDIRECT_URI),
35+
urlencoding::encode(CALENDAR_SCOPE),
36+
)
37+
}
4738

48-
if !response.status().is_success() {
49-
let body = response.text().await.unwrap_or_default();
50-
return Err(CalendarchyError::Auth(format!(
51-
"Failed to get device code: {}",
52-
body
53-
)));
54-
}
39+
/// Start a localhost server, wait for the OAuth callback, and exchange the code for tokens.
40+
/// Returns the tokens on success.
41+
pub async fn authenticate_with_browser(&self) -> Result<TokenInfo> {
42+
let listener = TcpListener::bind("127.0.0.1:18457").await
43+
.map_err(|e| CalendarchyError::Auth(format!("Failed to start auth server: {}", e)))?;
44+
45+
// Wait for the callback
46+
let (mut stream, _) = listener.accept().await
47+
.map_err(|e| CalendarchyError::Auth(format!("Failed to accept connection: {}", e)))?;
48+
49+
let mut buf = vec![0u8; 4096];
50+
let n = stream.read(&mut buf).await
51+
.map_err(|e| CalendarchyError::Auth(format!("Failed to read request: {}", e)))?;
52+
53+
let request = String::from_utf8_lossy(&buf[..n]);
5554

56-
let device_code: DeviceCodeResponse = response.json().await?;
57-
Ok(device_code)
55+
// Extract the authorization code from the request
56+
let code = extract_code(&request)
57+
.ok_or_else(|| CalendarchyError::Auth("No authorization code in callback".to_string()))?;
58+
59+
// Send a response to the browser
60+
let response = "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\n\r\n\
61+
<html><body style=\"font-family:system-ui;display:flex;justify-content:center;align-items:center;height:100vh;margin:0\">\
62+
<h2>Authenticated! You can close this tab.</h2></body></html>";
63+
let _ = stream.write_all(response.as_bytes()).await;
64+
let _ = stream.shutdown().await;
65+
66+
// Exchange the code for tokens
67+
self.exchange_code(&code).await
5868
}
5969

60-
/// Step 2: Poll for token (call this repeatedly)
61-
pub async fn poll_for_token(&self, device_code: &str) -> Result<PollResult> {
70+
/// Exchange an authorization code for tokens
71+
async fn exchange_code(&self, code: &str) -> Result<TokenInfo> {
6272
log_request("POST", TOKEN_URL);
6373
let response = self
6474
.client
6575
.post(TOKEN_URL)
6676
.form(&[
6777
("client_id", self.config.client_id.as_str()),
6878
("client_secret", self.config.client_secret.as_str()),
69-
("device_code", device_code),
70-
("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
79+
("code", code),
80+
("grant_type", "authorization_code"),
81+
("redirect_uri", REDIRECT_URI),
7182
])
7283
.send()
7384
.await?;
7485
log_response(response.status().as_u16(), TOKEN_URL);
7586

76-
if response.status().is_success() {
77-
let token_response: TokenResponse = response.json().await?;
78-
let token_info = TokenInfo {
79-
access_token: token_response.access_token,
80-
refresh_token: token_response.refresh_token,
81-
expires_at: Utc::now() + chrono::Duration::seconds(token_response.expires_in as i64),
82-
token_type: token_response.token_type,
83-
};
84-
Ok(PollResult::Success(token_info))
85-
} else {
86-
let error: serde_json::Value = response.json().await?;
87-
match error.get("error").and_then(|e| e.as_str()) {
88-
Some("authorization_pending") => Ok(PollResult::Pending),
89-
Some("slow_down") => Ok(PollResult::SlowDown),
90-
Some("access_denied") => Ok(PollResult::Denied),
91-
Some("expired_token") => Ok(PollResult::Expired),
92-
_ => Err(CalendarchyError::Auth(format!(
93-
"Unknown error: {:?}",
94-
error
95-
))),
96-
}
87+
if !response.status().is_success() {
88+
let body = response.text().await.unwrap_or_default();
89+
return Err(CalendarchyError::Auth(format!(
90+
"Failed to exchange code: {}",
91+
body
92+
)));
9793
}
94+
95+
let token_response: TokenResponse = response.json().await?;
96+
Ok(TokenInfo {
97+
access_token: token_response.access_token,
98+
refresh_token: token_response.refresh_token,
99+
expires_at: Utc::now() + chrono::Duration::seconds(token_response.expires_in as i64),
100+
token_type: token_response.token_type,
101+
})
98102
}
99103

100104
/// Refresh an expired token
@@ -130,3 +134,16 @@ impl GoogleAuth {
130134
})
131135
}
132136
}
137+
138+
/// Extract the authorization code from an HTTP request line
139+
fn extract_code(request: &str) -> Option<String> {
140+
let first_line = request.lines().next()?;
141+
let path = first_line.split_whitespace().nth(1)?;
142+
let query = path.split('?').nth(1)?;
143+
for param in query.split('&') {
144+
if let Some(value) = param.strip_prefix("code=") {
145+
return Some(urlencoding::decode(value).ok()?.to_string());
146+
}
147+
}
148+
None
149+
}

src/google/types.rs

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,6 @@ impl TokenInfo {
1616
}
1717
}
1818

19-
/// Device code response from Google
20-
#[derive(Debug, Deserialize)]
21-
pub struct DeviceCodeResponse {
22-
pub device_code: String,
23-
pub user_code: String,
24-
pub verification_url: String,
25-
pub expires_in: u64,
26-
}
27-
2819
/// Token endpoint response
2920
#[derive(Debug, Deserialize)]
3021
pub struct TokenResponse {

0 commit comments

Comments
 (0)