@@ -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 \n http://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\n more 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+ }
0 commit comments