Skip to content

Commit 8bd6e31

Browse files
committed
Replace deprecated OOB auth flow with headless-compatible redirect flow
Google deprecated the OAuth out-of-band (OOB) flow in October 2022, causing "Error 400: invalid_request" when using authorize_using_code=true. Replace with a hybrid localhost redirect flow that supports both automatic browser redirect and manual URL paste for remote/SSH scenarios. Changes: - Add auth module with headless_login() supporting stdin URL paste - Always use HTTPPortRedirect instead of broken Interactive (OOB) method - Add configurable auth_port option (default: 8081) for redirect server - Add url, urlencoding, reqwest dependencies for OAuth token exchange
1 parent b6fc78f commit 8bd6e31

File tree

8 files changed

+684
-13
lines changed

8 files changed

+684
-13
lines changed

Cargo.lock

Lines changed: 398 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
[package]
22
name = "gcsf"
33
description = "Filesystem based on Google Drive"
4-
version = "0.3.3"
4+
version = "0.3.4"
55
repository = "https://github.com/harababurel/gcsf"
66
authors = ["Sergiu Puscas <srg.pscs@gmail.com>"]
77
license = "MIT"
@@ -34,6 +34,9 @@ serde_derive = "1.0.228"
3434
serde_json = "1.0.145"
3535
time = "0.3"
3636
tokio = { version = "1.48", features = ["full"] }
37+
url = "2"
38+
urlencoding = "2"
39+
reqwest = { version = "0.12", features = ["blocking", "json"] }
3740
xdg = "3.0.0"
3841

3942
# The profile that 'dist' will build with

src/gcsf/auth.rs

Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
//! Headless OAuth authentication for remote/SSH scenarios.
2+
//!
3+
//! This module provides authentication for users running GCSF on remote servers
4+
//! where the browser is on a different machine. It supports both automatic localhost
5+
//! redirect (when browser and GCSF are on the same machine) and manual URL paste
6+
//! (when they're on different machines).
7+
8+
use failure::{err_msg, Error};
9+
use serde::{Deserialize, Serialize};
10+
use std::io::{BufRead, BufReader, Write};
11+
use std::net::TcpListener;
12+
use std::path::Path;
13+
use std::sync::mpsc;
14+
use std::thread;
15+
use std::time::Duration;
16+
use time::OffsetDateTime;
17+
use url::Url;
18+
19+
/// Token structure compatible with yup_oauth2's storage format.
20+
#[derive(Serialize)]
21+
struct StoredTokenEntry {
22+
scopes: Vec<String>,
23+
token: StoredToken,
24+
}
25+
26+
#[derive(Serialize)]
27+
struct StoredToken {
28+
access_token: String,
29+
refresh_token: String,
30+
/// Time tuple: (year, day_of_year, hour, minute, second, nanosecond, 0, 0, 0)
31+
expires_at: (i32, u16, u8, u8, u8, u32, u8, u8, u8),
32+
id_token: Option<String>,
33+
}
34+
35+
#[derive(Deserialize)]
36+
struct TokenResponse {
37+
access_token: String,
38+
refresh_token: Option<String>,
39+
expires_in: Option<u64>,
40+
#[allow(dead_code)]
41+
token_type: String,
42+
}
43+
44+
/// Performs headless OAuth login.
45+
///
46+
/// Starts a server on the specified port and also accepts pasted redirect URLs.
47+
/// Returns the authorization code from whichever method succeeds first.
48+
pub fn headless_login(
49+
client_id: &str,
50+
client_secret: &str,
51+
token_file: &Path,
52+
port: u16,
53+
) -> Result<(), Error> {
54+
let redirect_uri = format!("http://127.0.0.1:{}", port);
55+
56+
// Build the authorization URL
57+
let auth_url = format!(
58+
"https://accounts.google.com/o/oauth2/auth?\
59+
client_id={}&\
60+
redirect_uri={}&\
61+
response_type=code&\
62+
scope=https://www.googleapis.com/auth/drive&\
63+
access_type=offline&\
64+
prompt=consent",
65+
urlencoding::encode(client_id),
66+
urlencoding::encode(&redirect_uri)
67+
);
68+
69+
println!("\n=== GCSF Authentication ===\n");
70+
println!("Please visit this URL to authorize GCSF:\n");
71+
println!("{}\n", auth_url);
72+
println!("After authorizing:");
73+
println!(" - If running locally: authentication completes automatically");
74+
println!(" - If on a remote server: copy the FULL URL from your browser");
75+
println!(" (it will show 'connection refused') and paste it below\n");
76+
77+
// Get code via redirect server or manual paste
78+
let code = get_auth_code(port)?;
79+
80+
// Exchange code for tokens
81+
let tokens = exchange_code_for_tokens(client_id, client_secret, &code, &redirect_uri)?;
82+
83+
// Save tokens in yup_oauth2 format
84+
save_tokens(token_file, &tokens)?;
85+
86+
Ok(())
87+
}
88+
89+
/// Waits for auth code from either localhost redirect or stdin paste.
90+
fn get_auth_code(port: u16) -> Result<String, Error> {
91+
let (tx, rx) = mpsc::channel::<Result<String, String>>();
92+
93+
// Spawn thread to listen for HTTP redirect
94+
let tx_http = tx.clone();
95+
thread::spawn(move || {
96+
if let Ok(listener) = TcpListener::bind(format!("127.0.0.1:{}", port)) {
97+
listener.set_nonblocking(false).ok();
98+
if let Ok((mut stream, _)) = listener.accept() {
99+
let mut reader = BufReader::new(&stream);
100+
let mut request_line = String::new();
101+
if reader.read_line(&mut request_line).is_ok() {
102+
// Parse: GET /?code=xxx&scope=... HTTP/1.1
103+
if let Some(code) = extract_code_from_request(&request_line) {
104+
// Send success response to browser
105+
let response = "HTTP/1.1 200 OK\r\n\
106+
Content-Type: text/html\r\n\r\n\
107+
<html><body><h1>Success!</h1>\
108+
<p>You can close this window and return to GCSF.</p>\
109+
</body></html>";
110+
stream.write_all(response.as_bytes()).ok();
111+
tx_http.send(Ok(code)).ok();
112+
return;
113+
}
114+
}
115+
}
116+
}
117+
tx_http.send(Err("HTTP listener failed".to_string())).ok();
118+
});
119+
120+
// Spawn thread to read from stdin
121+
let tx_stdin = tx;
122+
thread::spawn(move || {
123+
print!("Paste redirect URL here (or wait for automatic redirect): ");
124+
std::io::stdout().flush().ok();
125+
126+
let stdin = std::io::stdin();
127+
for line in stdin.lock().lines().map_while(Result::ok) {
128+
let trimmed = line.trim();
129+
if trimmed.is_empty() {
130+
continue;
131+
}
132+
if let Some(code) = extract_code_from_url(trimmed) {
133+
tx_stdin.send(Ok(code)).ok();
134+
return;
135+
} else {
136+
println!("Could not find 'code' parameter in URL. Please try again.");
137+
print!("Paste redirect URL: ");
138+
std::io::stdout().flush().ok();
139+
}
140+
}
141+
});
142+
143+
// Wait for either method (with timeout)
144+
match rx.recv_timeout(Duration::from_secs(300)) {
145+
Ok(Ok(code)) => {
146+
println!("\nAuthorization code received!");
147+
Ok(code)
148+
}
149+
Ok(Err(e)) => Err(err_msg(e)),
150+
Err(_) => Err(err_msg("Authentication timed out after 5 minutes")),
151+
}
152+
}
153+
154+
/// Extract code from HTTP request line: "GET /?code=xxx&scope=... HTTP/1.1"
155+
fn extract_code_from_request(request_line: &str) -> Option<String> {
156+
let parts: Vec<&str> = request_line.split_whitespace().collect();
157+
if parts.len() >= 2 {
158+
let path = parts[1];
159+
let full_url = format!("http://localhost{}", path);
160+
extract_code_from_url(&full_url)
161+
} else {
162+
None
163+
}
164+
}
165+
166+
/// Extract code from full URL or path with query string
167+
fn extract_code_from_url(url_str: &str) -> Option<String> {
168+
Url::parse(url_str).ok().and_then(|url| {
169+
url.query_pairs()
170+
.find(|(key, _)| key == "code")
171+
.map(|(_, value)| value.to_string())
172+
})
173+
}
174+
175+
/// Exchange authorization code for access/refresh tokens
176+
fn exchange_code_for_tokens(
177+
client_id: &str,
178+
client_secret: &str,
179+
code: &str,
180+
redirect_uri: &str,
181+
) -> Result<TokenResponse, Error> {
182+
let client = reqwest::blocking::Client::new();
183+
184+
let response = client
185+
.post("https://oauth2.googleapis.com/token")
186+
.form(&[
187+
("client_id", client_id),
188+
("client_secret", client_secret),
189+
("code", code),
190+
("grant_type", "authorization_code"),
191+
("redirect_uri", redirect_uri),
192+
])
193+
.send()
194+
.map_err(|e| err_msg(format!("Token request failed: {}", e)))?;
195+
196+
if response.status().is_success() {
197+
response
198+
.json::<TokenResponse>()
199+
.map_err(|e| err_msg(format!("Failed to parse token response: {}", e)))
200+
} else {
201+
let error_text = response.text().unwrap_or_default();
202+
Err(err_msg(format!("Token exchange failed: {}", error_text)))
203+
}
204+
}
205+
206+
/// Save tokens in yup_oauth2 compatible JSON format
207+
fn save_tokens(path: &Path, tokens: &TokenResponse) -> Result<(), Error> {
208+
// Calculate expiration time
209+
let now = OffsetDateTime::now_utc();
210+
let expires_in_secs = tokens.expires_in.unwrap_or(3600) as i64;
211+
let expires_at = now + time::Duration::seconds(expires_in_secs);
212+
213+
let entry = StoredTokenEntry {
214+
scopes: vec!["https://www.googleapis.com/auth/drive".to_string()],
215+
token: StoredToken {
216+
access_token: tokens.access_token.clone(),
217+
refresh_token: tokens.refresh_token.clone().unwrap_or_default(),
218+
expires_at: (
219+
expires_at.year(),
220+
expires_at.ordinal(),
221+
expires_at.hour(),
222+
expires_at.minute(),
223+
expires_at.second(),
224+
expires_at.nanosecond(),
225+
0,
226+
0,
227+
0,
228+
),
229+
id_token: None,
230+
},
231+
};
232+
233+
// yup_oauth2 expects an array of token entries
234+
let json = serde_json::to_string(&vec![entry])
235+
.map_err(|e| err_msg(format!("Failed to serialize tokens: {}", e)))?;
236+
237+
std::fs::write(path, json)
238+
.map_err(|e| err_msg(format!("Failed to write token file: {}", e)))?;
239+
240+
Ok(())
241+
}

src/gcsf/config.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ pub struct Config {
3333
pub skip_trash: Option<bool>,
3434
/// The Google OAuth client secret for Google Drive APIs (see <https://console.developers.google.com>)
3535
pub client_secret: Option<String>,
36+
/// Port for OAuth redirect during authentication.
37+
pub auth_port: Option<u16>,
3638
}
3739

3840
impl Config {
@@ -120,4 +122,9 @@ impl Config {
120122
pub fn client_secret(&self) -> &String {
121123
self.client_secret.as_ref().unwrap()
122124
}
125+
126+
/// Port for OAuth redirect during authentication. Default is 8081.
127+
pub fn auth_port(&self) -> u16 {
128+
self.auth_port.unwrap_or(8081)
129+
}
123130
}

src/gcsf/drive_facade.rs

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -103,11 +103,8 @@ impl DriveFacade {
103103
let auth = rt.block_on(
104104
oauth2::InstalledFlowAuthenticator::builder(
105105
secret,
106-
if config.authorize_using_code() {
107-
oauth2::InstalledFlowReturnMethod::Interactive
108-
} else {
109-
oauth2::InstalledFlowReturnMethod::HTTPPortRedirect(8081)
110-
},
106+
// Always use HTTPPortRedirect - the OOB/Interactive flow is deprecated by Google
107+
oauth2::InstalledFlowReturnMethod::HTTPPortRedirect(config.auth_port()),
111108
)
112109
.persist_tokens_to_disk(config.token_file())
113110
.build(),

src/gcsf/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ pub use self::drive_facade::DriveFacade;
33
pub use self::file::{File, FileId};
44
pub use self::file_manager::FileManager;
55

6+
pub mod auth;
67
mod config;
78
mod drive_facade;
89
mod file;

src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ mod gcsf;
124124

125125
pub use crate::gcsf::filesystem::{Gcsf, NullFs};
126126
pub use crate::gcsf::{Config, DriveFacade, FileManager};
127+
pub use crate::gcsf::auth;
127128

128129
#[cfg(test)]
129130
mod tests;

src/main.rs

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ const INFO_LOG: &str =
4848

4949
#[derive(Parser)]
5050
#[command(name = "GCSF")]
51-
#[command(version = "0.3.3")]
51+
#[command(version = "0.3.4")]
5252
#[command(author = "Sergiu Puscas <srg.pscs@gmail.com>")]
5353
#[command(about = "File system based on Google Drive")]
5454
#[command(after_help = "Note: this is a work in progress. It might cause data loss. Use with caution.")]
@@ -138,6 +138,10 @@ mount_options = [
138138
# This is usually faster and more convenient.
139139
authorize_using_code = false
140140
141+
# Port for OAuth redirect during authentication. Change this if port 8081
142+
# is already in use by another application.
143+
auth_port = 8081
144+
141145
# If set to true, all files with identical name will get an increasing number
142146
# attached to the suffix. This is most likely not necessary.
143147
rename_identical_files = false
@@ -280,7 +284,31 @@ fn main() {
280284
Commands::Login { session_name } => {
281285
config.session_name = Some(session_name);
282286

283-
match login(&mut config) {
287+
if config.token_file().exists() {
288+
error!("Token file {:?} already exists.", config.token_file());
289+
return;
290+
}
291+
292+
let result = if config.authorize_using_code() {
293+
// Headless mode: use manual URL paste flow for remote servers
294+
let secret: serde_json::Value =
295+
serde_json::from_str(config.client_secret()).expect("Invalid client_secret");
296+
let installed = &secret["installed"];
297+
298+
gcsf::auth::headless_login(
299+
installed["client_id"].as_str().expect("Missing client_id"),
300+
installed["client_secret"]
301+
.as_str()
302+
.expect("Missing client_secret"),
303+
&config.token_file(),
304+
config.auth_port(),
305+
)
306+
} else {
307+
// Standard mode: automatic localhost redirect
308+
login(&mut config)
309+
};
310+
311+
match result {
284312
Ok(_) => {
285313
println!(
286314
"Successfully logged in. Saved credentials to {:?}",

0 commit comments

Comments
 (0)