Skip to content

Commit 7208497

Browse files
Merge pull request #465 from srosenthal-dd/stephen.rosenthal/headless-oauth-stdin-paste
feat(auth): accept pasted callback URL as a fallback when no browser
2 parents cba6c55 + 548e102 commit 7208497

2 files changed

Lines changed: 225 additions & 5 deletions

File tree

src/auth/callback.rs

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,3 +181,198 @@ h1{{color:#c00}}p{{color:#555}}</style></head>
181181
<p>Please close this window and try again.</p></div></body></html>"#
182182
)
183183
}
184+
185+
/// Parse a pasted OAuth callback URL into a `CallbackResult`. Used as a
186+
/// fallback for users on remote machines where the laptop browser cannot
187+
/// reach the workspace's `127.0.0.1` listener.
188+
#[cfg(not(target_arch = "wasm32"))]
189+
fn parse_callback_url(input: &str) -> Result<CallbackResult> {
190+
let url = url::Url::parse(input.trim()).map_err(|e| anyhow::anyhow!("not a valid URL: {e}"))?;
191+
let mut code = None;
192+
let mut state = None;
193+
let mut error = None;
194+
let mut error_description = None;
195+
for (k, v) in url.query_pairs() {
196+
match k.as_ref() {
197+
"code" => code = Some(v.into_owned()),
198+
"state" => state = Some(v.into_owned()),
199+
"error" => error = Some(v.into_owned()),
200+
"error_description" => error_description = Some(v.into_owned()),
201+
_ => {}
202+
}
203+
}
204+
if error.is_none() && (code.is_none() || state.is_none()) {
205+
bail!("URL is missing 'code' and 'state' query parameters");
206+
}
207+
Ok(CallbackResult {
208+
code: code.unwrap_or_default(),
209+
state: state.unwrap_or_default(),
210+
error,
211+
error_description,
212+
})
213+
}
214+
215+
/// Read pasted callback URLs from stdin until one parses, then return it.
216+
/// Thin wrapper around `read_callback_url_from_reader` so the loop logic
217+
/// stays unit-testable against a synthetic reader.
218+
#[cfg(not(target_arch = "wasm32"))]
219+
pub async fn read_callback_url_from_stdin() -> Result<CallbackResult> {
220+
read_callback_url_from_reader(tokio::io::BufReader::new(tokio::io::stdin())).await
221+
}
222+
223+
/// Read pasted callback URLs from `reader` until one parses, then return it.
224+
/// Errors are printed and the loop continues, so a typo doesn't end the
225+
/// login session: the HTTP listener may still fire.
226+
///
227+
/// On EOF without a valid URL the future stays pending forever rather than
228+
/// resolving to an error. This matters when the function is raced against
229+
/// the HTTP listener via `tokio::select!`: a closed or piped stdin must not
230+
/// short-circuit the HTTP branch.
231+
#[cfg(not(target_arch = "wasm32"))]
232+
async fn read_callback_url_from_reader<R: tokio::io::AsyncBufRead + Unpin>(
233+
reader: R,
234+
) -> Result<CallbackResult> {
235+
use tokio::io::AsyncBufReadExt;
236+
let mut lines = reader.lines();
237+
while let Some(line) = lines.next_line().await? {
238+
if line.trim().is_empty() {
239+
continue;
240+
}
241+
match parse_callback_url(&line) {
242+
Ok(result) => return Ok(result),
243+
Err(e) => eprintln!("⚠️ {e}. Paste the full callback URL again:"),
244+
}
245+
}
246+
std::future::pending().await
247+
}
248+
249+
#[cfg(all(test, not(target_arch = "wasm32")))]
250+
mod tests {
251+
use super::*;
252+
253+
#[test]
254+
fn parse_callback_url_extracts_code_and_state() {
255+
let r = parse_callback_url("http://127.0.0.1:8000/oauth/callback?code=abc123&state=xyz789")
256+
.unwrap();
257+
assert_eq!(r.code, "abc123");
258+
assert_eq!(r.state, "xyz789");
259+
assert!(r.error.is_none());
260+
assert!(r.error_description.is_none());
261+
}
262+
263+
#[test]
264+
fn parse_callback_url_extracts_error() {
265+
let r = parse_callback_url(
266+
"http://127.0.0.1:8000/oauth/callback?error=access_denied&error_description=user%20cancelled",
267+
)
268+
.unwrap();
269+
assert_eq!(r.error.as_deref(), Some("access_denied"));
270+
assert_eq!(r.error_description.as_deref(), Some("user cancelled"));
271+
}
272+
273+
#[test]
274+
fn parse_callback_url_trims_whitespace() {
275+
let r = parse_callback_url(" http://127.0.0.1:8000/oauth/callback?code=abc&state=xyz\n")
276+
.unwrap();
277+
assert_eq!(r.code, "abc");
278+
assert_eq!(r.state, "xyz");
279+
}
280+
281+
#[test]
282+
fn parse_callback_url_rejects_missing_params() {
283+
assert!(parse_callback_url("http://127.0.0.1:8000/oauth/callback").is_err());
284+
assert!(parse_callback_url("http://127.0.0.1:8000/oauth/callback?code=abc").is_err());
285+
assert!(parse_callback_url("http://127.0.0.1:8000/oauth/callback?state=xyz").is_err());
286+
}
287+
288+
#[test]
289+
fn parse_callback_url_rejects_garbage() {
290+
assert!(parse_callback_url("not a url").is_err());
291+
assert!(parse_callback_url("").is_err());
292+
}
293+
294+
#[test]
295+
fn parse_callback_url_accepts_any_host() {
296+
// Tolerant of broker-style or non-localhost redirect URIs as long as
297+
// the query carries the right params.
298+
let r = parse_callback_url("https://oauth.example.com/cli/callback?code=abc&state=xyz")
299+
.unwrap();
300+
assert_eq!(r.code, "abc");
301+
assert_eq!(r.state, "xyz");
302+
}
303+
304+
fn reader(input: &str) -> tokio::io::BufReader<&[u8]> {
305+
tokio::io::BufReader::new(input.as_bytes())
306+
}
307+
308+
#[tokio::test]
309+
async fn read_callback_url_returns_first_valid_line() {
310+
let r = read_callback_url_from_reader(reader(
311+
"http://127.0.0.1:8000/oauth/callback?code=abc&state=xyz\n",
312+
))
313+
.await
314+
.unwrap();
315+
assert_eq!(r.code, "abc");
316+
assert_eq!(r.state, "xyz");
317+
}
318+
319+
#[tokio::test]
320+
async fn read_callback_url_skips_blank_lines() {
321+
let r = read_callback_url_from_reader(reader(
322+
"\n\n \nhttp://127.0.0.1:8000/oauth/callback?code=abc&state=xyz\n",
323+
))
324+
.await
325+
.unwrap();
326+
assert_eq!(r.code, "abc");
327+
}
328+
329+
#[tokio::test]
330+
async fn read_callback_url_loops_through_parse_errors_until_valid() {
331+
// Garbage and a half-complete URL precede the valid one; the loop
332+
// must keep going until a parse succeeds, not bail on first error.
333+
let r = read_callback_url_from_reader(reader(
334+
"not a url\n\
335+
http://127.0.0.1:8000/oauth/callback?code=alpha\n\
336+
http://127.0.0.1:8000/oauth/callback?code=beta&state=charlie\n",
337+
))
338+
.await
339+
.unwrap();
340+
assert_eq!(r.code, "beta");
341+
assert_eq!(r.state, "charlie");
342+
}
343+
344+
#[tokio::test]
345+
async fn read_callback_url_stays_pending_on_eof_without_match() {
346+
// Reader closes after delivering only garbage. The future must NOT
347+
// resolve to an error — that would let it short-circuit a `select!`
348+
// race against the HTTP listener. Verify by timing out.
349+
let fut = read_callback_url_from_reader(reader("garbage\nmore garbage\n"));
350+
let timed = tokio::time::timeout(std::time::Duration::from_millis(50), fut).await;
351+
assert!(
352+
timed.is_err(),
353+
"expected pending (timeout), but future resolved"
354+
);
355+
}
356+
357+
#[tokio::test]
358+
async fn read_callback_url_stays_pending_on_immediate_eof() {
359+
// Closed/empty stdin (ex: `cmd </dev/null`) must not short-circuit.
360+
let fut = read_callback_url_from_reader(reader(""));
361+
let timed = tokio::time::timeout(std::time::Duration::from_millis(50), fut).await;
362+
assert!(
363+
timed.is_err(),
364+
"expected pending (timeout), but future resolved"
365+
);
366+
}
367+
368+
#[tokio::test]
369+
async fn read_callback_url_passes_through_oauth_error_redirect() {
370+
let r = read_callback_url_from_reader(reader(
371+
"http://127.0.0.1:8000/oauth/callback?error=access_denied&error_description=denied\n",
372+
))
373+
.await
374+
.unwrap();
375+
assert_eq!(r.error.as_deref(), Some("access_denied"));
376+
assert_eq!(r.error_description.as_deref(), Some("denied"));
377+
}
378+
}

src/commands/auth.rs

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -87,13 +87,38 @@ pub async fn login(cfg: &Config, scopes: Vec<String>, subdomain: Option<&str>) -
8787
// 5. Open browser
8888
eprintln!("\n🌐 Opening browser for authentication...");
8989
eprintln!("If the browser doesn't open, visit: {auth_url}");
90-
let _ = open::that(&auth_url);
90+
let browser_opened = open::that(&auth_url).is_ok();
91+
if !browser_opened {
92+
eprintln!(
93+
"\nNo local browser detected (remote/SSH session?). To complete login:\n \
94+
1. Open the URL above on a machine with a browser and authorize.\n \
95+
2. Your browser will redirect to {redirect_uri}?... and fail to load\n \
96+
(expected). Copy that full URL from the address bar.\n \
97+
3. Paste it below, then press Enter.\n \
98+
Example: {redirect_uri}?code=...&state=..."
99+
);
100+
}
91101

92-
// 6. Wait for callback
102+
// 6. Wait for callback. The happy path waits on the HTTP listener only,
103+
// exactly as before this change. When the browser failed to open, also
104+
// race a stdin paste path so users on remote machines can manually relay
105+
// the redirect URL. The stdin path is only enabled in the headless branch
106+
// so legitimate non-interactive launches (closed stdin, piped /dev/null)
107+
// can't short-circuit a working browser flow.
93108
eprintln!("\n⏳ Waiting for authorization...");
94-
let result = server
95-
.wait_for_callback(std::time::Duration::from_secs(300))
96-
.await?;
109+
let result = if browser_opened {
110+
server
111+
.wait_for_callback(std::time::Duration::from_secs(300))
112+
.await?
113+
} else {
114+
use std::io::Write;
115+
eprint!("> ");
116+
let _ = std::io::stderr().flush();
117+
tokio::select! {
118+
r = server.wait_for_callback(std::time::Duration::from_secs(300)) => r?,
119+
r = crate::auth::callback::read_callback_url_from_stdin() => r?,
120+
}
121+
};
97122

98123
if let Some(err) = &result.error {
99124
let desc = result.error_description.as_deref().unwrap_or("");

0 commit comments

Comments
 (0)