From 9536f7c1f8bf62d30803f92b2da4e6db27748825 Mon Sep 17 00:00:00 2001 From: Stefano Sala Date: Fri, 22 May 2026 17:54:46 +0200 Subject: [PATCH 1/3] feat(auth): add headless OAuth login mode Add a headless login path that prints the authorization URL and accepts a pasted callback URL, so OAuth can complete without a local listener. This keeps PKCE/state validation and token persistence aligned with the existing login flow. Co-authored-by: Cursor --- README.md | 9 ++ src/cli/mod.rs | 10 +++ src/commands/auth_login.rs | 167 ++++++++++++++++++++++++++++++++++--- 3 files changed, 175 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 7bceef5..82d12c8 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,14 @@ npx skills add stefanosala/vc-cli vc-cli auth login ``` +For headless hosts (agents/servers), use: + +```bash +vc-cli auth login --headless +``` + +This prints an authorization URL. Open it in any browser, complete login, then paste the redirected callback URL back into the CLI. + If developer credentials are missing, the command shows the Volvo developer account URL and redirect URI to use, then waits for you to return and enter the API key, client ID, and client secret. 2. Discover your vehicles: @@ -74,6 +82,7 @@ vc-cli location get - Configuration is loaded from `~/.config/vc-cli/config` in env variable format before CLI parsing. - Profile/session/VIN state is stored locally per profile in SQLite. - `auth login` starts a temporary local HTTP listener for the OAuth redirect. +- `auth login --headless` skips browser/listener setup, prints the authorize URL, and prompts for the redirected callback URL. - `auth login` prompts for missing `VCC_API_KEY`, `VOLVO_CLIENT_ID`, and `VOLVO_CLIENT_SECRET`, then saves them to `~/.config/vc-cli/config`. - `auth login` requests all Connected Vehicle, Energy, and Location scopes by default. Use `--scopes` or `VOLVO_SCOPES` to override the requested scope set. diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 7186734..922e879 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -114,6 +114,9 @@ struct AuthLoginCliArgs { default_value_t = crate::config::DEFAULT_AUTH_LISTEN_TIMEOUT_SECONDS )] auth_listen_timeout_seconds: u64, + + #[arg(long)] + headless: bool, } #[derive(Debug, Args)] @@ -407,6 +410,7 @@ pub async fn run_with_config_dir(config_dir: PathBuf) -> Result<()> { client_secret: login.client_secret, redirect_uri: login.redirect_uri, auth_listen_timeout_seconds: login.auth_listen_timeout_seconds, + headless: login.headless, }, ) .await?; @@ -975,6 +979,12 @@ mod tests { assert!(parsed.is_ok()); } + #[test] + fn auth_login_parses_headless_mode() { + let parsed = Cli::try_parse_from(["vc-cli", "auth", "login", "--headless"]); + assert!(parsed.is_ok()); + } + #[test] fn energy_tree_parses() { let state = Cli::try_parse_from(["vc-cli", "energy", "state", "get"]); diff --git a/src/commands/auth_login.rs b/src/commands/auth_login.rs index b58183c..f3f6be4 100644 --- a/src/commands/auth_login.rs +++ b/src/commands/auth_login.rs @@ -29,6 +29,7 @@ pub struct AuthLoginArgs { pub client_secret: Option, pub redirect_uri: Option, pub auth_listen_timeout_seconds: u64, + pub headless: bool, } #[derive(Debug, Serialize)] @@ -49,6 +50,7 @@ impl Default for AuthLoginArgs { client_secret: None, redirect_uri: Some(DEFAULT_AUTH_REDIRECT_URI.to_owned()), auth_listen_timeout_seconds: DEFAULT_AUTH_LISTEN_TIMEOUT_SECONDS, + headless: false, } } } @@ -100,6 +102,7 @@ pub async fn execute( &redirect_uri, &scopes, listen_timeout_seconds, + args.headless, ) .await?; @@ -257,12 +260,12 @@ async fn local_oauth_login( redirect_uri: &str, scopes: &str, listen_timeout_seconds: u64, + headless: bool, ) -> Result { let normalized_issuer = normalize_base_url(auth_issuer)?; let issuer_url = validate_auth_issuer(&normalized_issuer)?; let discovery = VolvoClient::fetch_discovery(&normalized_issuer, http_client).await?; validate_oauth_discovery(&issuer_url, &discovery)?; - let (listener, expected_path) = bind_redirect_listener(redirect_uri).await?; let state = random_urlsafe(24); let code_verifier = random_urlsafe(64); let code_challenge = pkce_challenge(&code_verifier); @@ -274,15 +277,20 @@ async fn local_oauth_login( &state, &code_challenge, )?; - - webbrowser::open(authorization_url.as_str()).with_context(|| { - format!("failed to open browser for Volvo OAuth URL: {authorization_url}") - })?; - eprintln!("Opened browser for Volvo login. Waiting for OAuth callback on {redirect_uri} ..."); - - let callback = + let callback = if headless { + let expected_path = expected_redirect_path(redirect_uri)?; + prompt_headless_callback(&authorization_url, &expected_path, &state)? + } else { + let (listener, expected_path) = bind_redirect_listener(redirect_uri).await?; + webbrowser::open(authorization_url.as_str()).with_context(|| { + format!("failed to open browser for Volvo OAuth URL: {authorization_url}") + })?; + eprintln!( + "Opened browser for Volvo login. Waiting for OAuth callback on {redirect_uri} ..." + ); wait_for_authorization_callback(listener, &expected_path, &state, listen_timeout_seconds) - .await?; + .await? + }; let token_response = VolvoClient::exchange_authorization_code( http_client, &discovery.token_endpoint, @@ -302,6 +310,78 @@ async fn local_oauth_login( ) } +fn prompt_headless_callback( + authorization_url: &Url, + expected_path: &str, + expected_state: &str, +) -> Result { + eprintln!( + "\ +Headless login mode: +1. Open this URL in any browser where you can sign in: + {authorization_url} +2. Complete login/consent. +3. Copy the final redirected URL from the browser and paste it below. +" + ); + let pasted = prompt_text("Paste redirected callback URL")?; + parse_pasted_callback(&pasted, expected_path, expected_state) +} + +fn parse_pasted_callback( + pasted: &str, + expected_path: &str, + expected_state: &str, +) -> Result { + let raw = pasted.trim(); + if raw.is_empty() { + return Err(anyhow!("callback URL cannot be empty")); + } + + let callback_url = if raw.starts_with("http://") || raw.starts_with("https://") { + Url::parse(raw).context("invalid callback URL")? + } else if raw.starts_with('/') { + Url::parse(&format!("http://localhost{raw}")).context("invalid callback path")? + } else if raw.contains('=') { + Url::parse(&format!( + "http://localhost{expected_path}?{}", + raw.trim_start_matches('?') + )) + .context("invalid callback query")? + } else { + return Err(anyhow!( + "invalid callback input; paste a full URL (or path/query containing code and state)" + )); + }; + + if callback_url.path() != expected_path { + return Err(anyhow!( + "unexpected callback path `{}`; expected `{expected_path}`", + callback_url.path() + )); + } + let params = callback_url + .query_pairs() + .map(|(k, v)| (k.into_owned(), v.into_owned())) + .collect::>(); + let get = |key: &str| -> Option { + params + .iter() + .find(|(k, _)| k == key) + .map(|(_, v)| v.clone()) + }; + if let Some(error) = get("error") { + let detail = get("error_description").unwrap_or_default(); + return Err(anyhow!("OAuth callback failed: {error} {detail}")); + } + let state = get("state").ok_or_else(|| anyhow!("OAuth callback did not include state"))?; + if state != expected_state { + return Err(anyhow!("OAuth callback state mismatch")); + } + let code = get("code").ok_or_else(|| anyhow!("OAuth callback did not include code"))?; + Ok(AuthorizationCallback { code }) +} + fn validate_auth_issuer(auth_issuer: &str) -> Result { let issuer_url = Url::parse(auth_issuer).context("auth issuer is invalid")?; if issuer_url.scheme() != "https" { @@ -439,6 +519,31 @@ async fn bind_redirect_listener(redirect_uri: &str) -> Result<(CallbackListener, Ok((listener, expected_path)) } +fn expected_redirect_path(redirect_uri: &str) -> Result { + let redirect_url = Url::parse(redirect_uri).context("redirect URI is invalid")?; + if redirect_url.scheme() != "http" { + return Err(anyhow!( + "redirect URI must use http for local OAuth callbacks" + )); + } + let host = redirect_url + .host_str() + .ok_or_else(|| anyhow!("redirect URI must include a host"))?; + if !is_loopback_redirect_host(host) { + return Err(anyhow!( + "redirect URI host `{host}` is not a supported loopback host" + )); + } + redirect_url + .port() + .ok_or_else(|| anyhow!("redirect URI must include an explicit port"))?; + Ok(if redirect_url.path().is_empty() { + "/".to_owned() + } else { + redirect_url.path().to_owned() + }) +} + async fn bind_localhost_listeners(port: u16) -> Result { let ipv4 = TcpListener::bind((Ipv4Addr::LOCALHOST, port)) .await @@ -650,8 +755,9 @@ fn parse_request_line(request_line: &str) -> Result<(&str, &str)> { #[cfg(test)] mod tests { use super::{ - build_authorization_url, is_loopback_redirect_host, parse_form_body, parse_request_line, - pkce_challenge, validate_auth_issuer, validate_oauth_discovery, + build_authorization_url, expected_redirect_path, is_loopback_redirect_host, + parse_form_body, parse_pasted_callback, parse_request_line, pkce_challenge, + validate_auth_issuer, validate_oauth_discovery, }; use crate::http::client::OAuthDiscovery; @@ -734,4 +840,43 @@ mod tests { }; assert!(validate_oauth_discovery(&issuer, &insecure).is_err()); } + + #[test] + fn expected_redirect_path_validates_loopback_http_uri() { + let path = + expected_redirect_path("http://127.0.0.1:1410/callback").expect("path should parse"); + assert_eq!(path, "/callback"); + assert!(expected_redirect_path("https://127.0.0.1:1410/callback").is_err()); + assert!(expected_redirect_path("http://example.com:1410/callback").is_err()); + } + + #[test] + fn parse_pasted_callback_accepts_full_url() { + let callback = parse_pasted_callback( + "http://127.0.0.1:1410/callback?code=abc&state=xyz", + "/callback", + "xyz", + ) + .expect("callback should parse"); + assert_eq!(callback.code, "abc"); + } + + #[test] + fn parse_pasted_callback_accepts_query_only_input() { + let callback = + parse_pasted_callback("code=abc&state=xyz", "/callback", "xyz").expect("should parse"); + assert_eq!(callback.code, "abc"); + } + + #[test] + fn parse_pasted_callback_rejects_state_mismatch() { + assert!( + parse_pasted_callback( + "http://127.0.0.1:1410/callback?code=abc&state=wrong", + "/callback", + "xyz" + ) + .is_err() + ); + } } From 8f60241c32030e73c945c3d831435414c79de5d1 Mon Sep 17 00:00:00 2001 From: Stefano Sala Date: Fri, 22 May 2026 17:56:46 +0200 Subject: [PATCH 2/3] docs(skills): clarify agent skill usage and headless auth flow Document where to find the vc-cli skill reference and add explicit guidance for using `auth login --headless` in agent/server contexts so users can complete OAuth pasteback reliably. Co-authored-by: Cursor --- README.md | 12 ++++++++++++ skills/vc-cli/SKILL.md | 13 +++++++++++-- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 82d12c8..ce64af4 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,8 @@ Install the skills for your agent: npx skills add stefanosala/vc-cli ``` +Agent-facing command guidance lives in `skills/vc-cli/SKILL.md` (the repo `@skills` entry for this CLI). + ## Quick Start 1. Log in with Volvo OAuth: @@ -76,6 +78,16 @@ vc-cli auth whoami vc-cli location get ``` +## Agent Skills + +If you are using an AI agent (for example via Cursor skills), install: + +```bash +npx skills add stefanosala/vc-cli +``` + +Then reference `skills/vc-cli/SKILL.md` for command map, auth workflows, and safety notes (including headless login guidance). + ## Notes - Command output is JSON by default. diff --git a/skills/vc-cli/SKILL.md b/skills/vc-cli/SKILL.md index fe21f80..56186b2 100644 --- a/skills/vc-cli/SKILL.md +++ b/skills/vc-cli/SKILL.md @@ -22,7 +22,7 @@ cargo run -- [flags] ## Command Map -- `auth login` - Start browser-based OAuth login and persist session tokens. +- `auth login` - Start OAuth login (browser listener by default, or headless pasteback with `--headless`) and persist session tokens. - `auth token-set` - Persist tokens directly (script/manual token flow). - `auth whoami` - Print current session identity info. - `auth logout` - Clear local auth session. @@ -110,7 +110,7 @@ vc-cli vehicle vin default --vin --api-key "$VCC_API_KEY" ### `auth login` ```bash -vc-cli auth login [--scopes ] [--auth-issuer ] [--client-id ] [--client-secret ] [--redirect-uri ] [--auth-listen-timeout-seconds ] +vc-cli auth login [--headless] [--scopes ] [--auth-issuer ] [--client-id ] [--client-secret ] [--redirect-uri ] [--auth-listen-timeout-seconds ] ``` | Flag | Required | Description | @@ -121,13 +121,22 @@ vc-cli auth login [--scopes ] [--auth-issuer ] [--c | `--client-secret` | prompted if missing | Volvo OAuth client secret (`VOLVO_CLIENT_SECRET`) | | `--redirect-uri` | no | Registered local redirect URI (`VOLVO_REDIRECT_URI` default is `http://127.0.0.1:1410/callback`) | | `--auth-listen-timeout-seconds` | no | Local callback listener timeout | +| `--headless` | no | Skip browser open + local callback listener; print auth URL and prompt for pasted callback URL | `auth login` also prompts for missing `VCC_API_KEY`. If any of `VCC_API_KEY`, `VOLVO_CLIENT_ID`, or `VOLVO_CLIENT_SECRET` is missing, it first tells the user to create and publish a Volvo app at `https://developer.volvocars.com/account/`, shows the redirect URI to configure, and asks them to return to the terminal after publishing. Newly provided values are saved to `~/.config/vc-cli/config`. +Headless flow details: + +1. Run `vc-cli auth login --headless` on the agent/server. +2. Open the printed authorization URL in any browser and complete login/consent. +3. Browser redirects to the configured localhost callback (for example `http://127.0.0.1:1410/callback?...`) and likely shows a connection error because no listener is running. +4. Copy the full redirected URL from the browser address bar and paste it into the CLI prompt. + Example: ```bash vc-cli auth login +vc-cli auth login --headless vc-cli auth login --redirect-uri http://localtest.me:1410/callback ``` From f1704175557fe2fb10d9ccca825c7ed6b1afdc5a Mon Sep 17 00:00:00 2001 From: Stefano Sala Date: Fri, 22 May 2026 18:05:25 +0200 Subject: [PATCH 3/3] fix(auth): harden headless login redirect handling Skip listener-timeout validation in headless mode and reuse a single redirect URI parser for both headless and listener flows. Also reject redirect URIs that use port 0 to prevent impossible callback targets. Co-authored-by: Cursor --- src/commands/auth_login.rs | 121 +++++++++++++++++++++---------------- 1 file changed, 68 insertions(+), 53 deletions(-) diff --git a/src/commands/auth_login.rs b/src/commands/auth_login.rs index f3f6be4..dbf80cc 100644 --- a/src/commands/auth_login.rs +++ b/src/commands/auth_login.rs @@ -92,7 +92,8 @@ pub async fn execute( ]; let values_to_save = values_to_save.into_iter().flatten().collect::>(); save_config_values(config_dir, &values_to_save)?; - let listen_timeout_seconds = normalize_timeout(args.auth_listen_timeout_seconds)?; + let listen_timeout_seconds = + resolve_listen_timeout(args.auth_listen_timeout_seconds, args.headless)?; let http_client = Client::new(); let token_set = local_oauth_login( &http_client, @@ -224,6 +225,13 @@ fn normalize_timeout(value: u64) -> Result { Ok(value) } +fn resolve_listen_timeout(value: u64, headless: bool) -> Result> { + if headless { + return Ok(None); + } + Ok(Some(normalize_timeout(value)?)) +} + fn random_urlsafe(bytes_len: usize) -> String { let mut bytes = vec![0u8; bytes_len]; rand::rng().fill(bytes.as_mut_slice()); @@ -259,13 +267,14 @@ async fn local_oauth_login( client_secret: &str, redirect_uri: &str, scopes: &str, - listen_timeout_seconds: u64, + listen_timeout_seconds: Option, headless: bool, ) -> Result { let normalized_issuer = normalize_base_url(auth_issuer)?; let issuer_url = validate_auth_issuer(&normalized_issuer)?; let discovery = VolvoClient::fetch_discovery(&normalized_issuer, http_client).await?; validate_oauth_discovery(&issuer_url, &discovery)?; + let redirect_target = parse_redirect_callback_target(redirect_uri)?; let state = random_urlsafe(24); let code_verifier = random_urlsafe(64); let code_challenge = pkce_challenge(&code_verifier); @@ -278,18 +287,19 @@ async fn local_oauth_login( &code_challenge, )?; let callback = if headless { - let expected_path = expected_redirect_path(redirect_uri)?; - prompt_headless_callback(&authorization_url, &expected_path, &state)? + prompt_headless_callback(&authorization_url, &redirect_target.expected_path, &state)? } else { - let (listener, expected_path) = bind_redirect_listener(redirect_uri).await?; + let (listener, expected_path) = + bind_redirect_listener(redirect_uri, &redirect_target).await?; + let timeout_seconds = listen_timeout_seconds + .ok_or_else(|| anyhow!("missing listener timeout for browser login flow"))?; webbrowser::open(authorization_url.as_str()).with_context(|| { format!("failed to open browser for Volvo OAuth URL: {authorization_url}") })?; eprintln!( "Opened browser for Volvo login. Waiting for OAuth callback on {redirect_uri} ..." ); - wait_for_authorization_callback(listener, &expected_path, &state, listen_timeout_seconds) - .await? + wait_for_authorization_callback(listener, &expected_path, &state, timeout_seconds).await? }; let token_response = VolvoClient::exchange_authorization_code( http_client, @@ -472,7 +482,14 @@ enum CallbackListener { }, } -async fn bind_redirect_listener(redirect_uri: &str) -> Result<(CallbackListener, String)> { +#[derive(Debug)] +struct RedirectCallbackTarget { + host: String, + port: u16, + expected_path: String, +} + +fn parse_redirect_callback_target(redirect_uri: &str) -> Result { let redirect_url = Url::parse(redirect_uri).context("redirect URI is invalid")?; if redirect_url.scheme() != "http" { return Err(anyhow!( @@ -485,17 +502,35 @@ async fn bind_redirect_listener(redirect_uri: &str) -> Result<(CallbackListener, let port = redirect_url .port() .ok_or_else(|| anyhow!("redirect URI must include an explicit port"))?; + if port == 0 { + return Err(anyhow!("redirect URI port must be greater than zero")); + } if !is_loopback_redirect_host(host) { return Err(anyhow!( "redirect URI host `{host}` is not a supported loopback host" )); } + let expected_path = if redirect_url.path().is_empty() { + "/".to_owned() + } else { + redirect_url.path().to_owned() + }; + Ok(RedirectCallbackTarget { + host: host.to_owned(), + port, + expected_path, + }) +} - let listener = if host.eq_ignore_ascii_case("localhost") { - bind_localhost_listeners(port).await? - } else if host == "::1" { +async fn bind_redirect_listener( + redirect_uri: &str, + target: &RedirectCallbackTarget, +) -> Result<(CallbackListener, String)> { + let listener = if target.host.eq_ignore_ascii_case("localhost") { + bind_localhost_listeners(target.port).await? + } else if target.host == "::1" { CallbackListener::Single( - TcpListener::bind((Ipv6Addr::LOCALHOST, port)) + TcpListener::bind((Ipv6Addr::LOCALHOST, target.port)) .await .with_context(|| { format!("failed to bind OAuth callback listener on {redirect_uri}") @@ -503,45 +538,14 @@ async fn bind_redirect_listener(redirect_uri: &str) -> Result<(CallbackListener, ) } else { CallbackListener::Single( - TcpListener::bind((Ipv4Addr::LOCALHOST, port)) + TcpListener::bind((Ipv4Addr::LOCALHOST, target.port)) .await .with_context(|| { format!("failed to bind OAuth callback listener on {redirect_uri}") })?, ) }; - - let expected_path = if redirect_url.path().is_empty() { - "/".to_owned() - } else { - redirect_url.path().to_owned() - }; - Ok((listener, expected_path)) -} - -fn expected_redirect_path(redirect_uri: &str) -> Result { - let redirect_url = Url::parse(redirect_uri).context("redirect URI is invalid")?; - if redirect_url.scheme() != "http" { - return Err(anyhow!( - "redirect URI must use http for local OAuth callbacks" - )); - } - let host = redirect_url - .host_str() - .ok_or_else(|| anyhow!("redirect URI must include a host"))?; - if !is_loopback_redirect_host(host) { - return Err(anyhow!( - "redirect URI host `{host}` is not a supported loopback host" - )); - } - redirect_url - .port() - .ok_or_else(|| anyhow!("redirect URI must include an explicit port"))?; - Ok(if redirect_url.path().is_empty() { - "/".to_owned() - } else { - redirect_url.path().to_owned() - }) + Ok((listener, target.expected_path.clone())) } async fn bind_localhost_listeners(port: u16) -> Result { @@ -755,8 +759,8 @@ fn parse_request_line(request_line: &str) -> Result<(&str, &str)> { #[cfg(test)] mod tests { use super::{ - build_authorization_url, expected_redirect_path, is_loopback_redirect_host, - parse_form_body, parse_pasted_callback, parse_request_line, pkce_challenge, + build_authorization_url, is_loopback_redirect_host, parse_form_body, parse_pasted_callback, + parse_redirect_callback_target, parse_request_line, pkce_challenge, resolve_listen_timeout, validate_auth_issuer, validate_oauth_discovery, }; use crate::http::client::OAuthDiscovery; @@ -842,12 +846,23 @@ mod tests { } #[test] - fn expected_redirect_path_validates_loopback_http_uri() { - let path = - expected_redirect_path("http://127.0.0.1:1410/callback").expect("path should parse"); - assert_eq!(path, "/callback"); - assert!(expected_redirect_path("https://127.0.0.1:1410/callback").is_err()); - assert!(expected_redirect_path("http://example.com:1410/callback").is_err()); + fn parse_redirect_callback_target_validates_loopback_http_uri() { + let target = parse_redirect_callback_target("http://127.0.0.1:1410/callback") + .expect("target should parse"); + assert_eq!(target.expected_path, "/callback"); + assert_eq!(target.port, 1410); + assert!(parse_redirect_callback_target("https://127.0.0.1:1410/callback").is_err()); + assert!(parse_redirect_callback_target("http://example.com:1410/callback").is_err()); + assert!(parse_redirect_callback_target("http://127.0.0.1:0/callback").is_err()); + } + + #[test] + fn resolve_listen_timeout_skips_validation_when_headless() { + assert_eq!( + resolve_listen_timeout(0, true).expect("headless timeout should skip validation"), + None + ); + assert!(resolve_listen_timeout(0, false).is_err()); } #[test]