Skip to content

Commit 6d333f1

Browse files
committed
fix: harden auth and input handling
1 parent d88c5c6 commit 6d333f1

14 files changed

Lines changed: 580 additions & 146 deletions

File tree

‎README.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -414,11 +414,14 @@ Browser-based Authorization Code + PKCE flow with automatic token refresh.
414414

415415
```bash
416416
linear-cli auth oauth # Opens browser for authorization
417+
linear-cli auth oauth --secure # Store OAuth tokens in OS keyring (best on official release builds)
417418
linear-cli auth status # Show auth type, token expiry
418419
linear-cli auth revoke # Revoke OAuth tokens
419420
linear-cli auth logout # Remove stored credentials
420421
```
421422

423+
> On macOS, `--secure` works best with an official signed release binary. Locally built or frequently rebuilt CLI binaries can trigger repeated Keychain prompts and may fail keychain readback verification. If that happens, use plain `linear-cli auth oauth` or `LINEAR_API_KEY` instead.
424+
422425
**Auth priority:** `LINEAR_API_KEY` env var > OS keyring > OAuth tokens > config file API key.
423426

424427
## Configuration

‎src/api.rs‎

Lines changed: 63 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
use anyhow::{Context, Result};
22
use futures::StreamExt;
33
use reqwest::header::HeaderMap;
4-
use reqwest::{Client, StatusCode};
4+
use reqwest::redirect::Policy;
5+
use reqwest::{Client, StatusCode, Url};
56
use serde_json::{json, Value};
67
use std::sync::Arc;
78
use std::time::Duration;
@@ -16,6 +17,7 @@ use crate::text::is_uuid;
1617
use std::sync::OnceLock;
1718

1819
const LINEAR_API_URL: &str = "https://api.linear.app/graphql";
20+
const LINEAR_UPLOADS_HOST: &str = "uploads.linear.app";
1921

2022
/// Configuration for generic ID resolution
2123
struct ResolverConfig<'a> {
@@ -135,6 +137,47 @@ fn http_error(status: StatusCode, headers: &HeaderMap, context: &str) -> CliErro
135137
err.with_details(details)
136138
}
137139

140+
pub fn parse_linear_upload_url(raw: &str) -> Result<Url> {
141+
let url = Url::parse(raw).context("Failed to parse upload URL")?;
142+
143+
if url.scheme() != "https" {
144+
anyhow::bail!("Invalid upload URL: expected https");
145+
}
146+
if url.host_str() != Some(LINEAR_UPLOADS_HOST) {
147+
anyhow::bail!(
148+
"Invalid upload URL: expected exact host '{}'",
149+
LINEAR_UPLOADS_HOST
150+
);
151+
}
152+
if !url.username().is_empty() || url.password().is_some() {
153+
anyhow::bail!("Invalid upload URL: credentials are not allowed");
154+
}
155+
if let Some(port) = url.port() {
156+
if port != 443 {
157+
anyhow::bail!("Invalid upload URL: non-default ports are not allowed");
158+
}
159+
}
160+
161+
Ok(url)
162+
}
163+
164+
fn upload_redirect_policy() -> Policy {
165+
Policy::custom(|attempt| {
166+
let url = attempt.url();
167+
let is_allowed = url.scheme() == "https"
168+
&& url.host_str() == Some(LINEAR_UPLOADS_HOST)
169+
&& url.username().is_empty()
170+
&& url.password().is_none()
171+
&& url.port_or_known_default() == Some(443);
172+
173+
if is_allowed {
174+
attempt.follow()
175+
} else {
176+
attempt.stop()
177+
}
178+
})
179+
}
180+
138181
/// Resolves a team key (like "SCW") or name to a team UUID.
139182
/// If the input is already a UUID (36 characters with dashes), returns it as-is.
140183
pub async fn resolve_team_id(
@@ -618,10 +661,15 @@ impl LinearClient {
618661
url: &str,
619662
writer: &mut impl std::io::Write,
620663
) -> Result<u64> {
664+
let url = parse_linear_upload_url(url)?;
621665
let auth_header = self.ensure_fresh_auth().await?;
666+
let upload_client = Client::builder()
667+
.timeout(Duration::from_secs(30))
668+
.redirect(upload_redirect_policy())
669+
.build()
670+
.context("Failed to build upload client")?;
622671

623-
let response = self
624-
.client
672+
let response = upload_client
625673
.get(url)
626674
.header("Authorization", &auth_header)
627675
.send()
@@ -699,7 +747,7 @@ impl LinearClient {
699747
let new_tokens = crate::oauth::refresh_tokens(client_id, refresh_token).await?;
700748

701749
// Persist the new tokens
702-
let scopes = if let Ok(Some(existing)) = config::get_oauth_config(profile) {
750+
let scopes = if let Ok(Some(existing)) = config::get_oauth_metadata(profile) {
703751
existing.scopes
704752
} else {
705753
vec![]
@@ -713,7 +761,17 @@ impl LinearClient {
713761
token_type: new_tokens.token_type.clone(),
714762
scopes,
715763
};
716-
if let Err(e) = config::save_oauth_config(profile, &oauth_config) {
764+
#[cfg(feature = "secure-storage")]
765+
let persist_result = if config::oauth_uses_secure_storage(profile).unwrap_or(false) {
766+
config::save_oauth_config_secure(profile, &oauth_config)
767+
} else {
768+
config::save_oauth_config(profile, &oauth_config)
769+
};
770+
771+
#[cfg(not(feature = "secure-storage"))]
772+
let persist_result = config::save_oauth_config(profile, &oauth_config);
773+
774+
if let Err(e) = persist_result {
717775
eprintln!("Warning: Failed to persist refreshed OAuth tokens: {}", e);
718776
}
719777

‎src/commands/auth.rs‎

Lines changed: 39 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use anyhow::Result;
1+
use anyhow::{Context, Result};
22
use clap::Subcommand;
33
use dialoguer::{Confirm, Password};
44
use serde_json::json;
@@ -108,6 +108,11 @@ async fn login(
108108
#[cfg(feature = "secure-storage")]
109109
if secure {
110110
crate::keyring::set_key(&profile, &key)?;
111+
let saved = crate::keyring::get_key(&profile)?
112+
.context("API key was written to keyring but could not be read back")?;
113+
if saved != key {
114+
anyhow::bail!("API key stored in keyring could not be verified after saving");
115+
}
111116

112117
if output.is_json() || output.has_template() {
113118
print_json_owned(
@@ -168,8 +173,10 @@ async fn logout(force: bool, output: &OutputOptions) -> Result<()> {
168173
#[cfg(feature = "secure-storage")]
169174
{
170175
let _ = crate::keyring::delete_key(&profile); // Ignore errors (may not exist)
176+
let _ = crate::keyring::delete_oauth_tokens(&profile); // Ignore errors (may not exist)
171177
}
172178

179+
config::clear_oauth_config(&profile)?;
173180
config::workspace_remove(&profile)?;
174181

175182
if output.is_json() || output.has_template() {
@@ -197,34 +204,50 @@ async fn status(validate: bool, output: &OutputOptions) -> Result<()> {
197204
.ok()
198205
.filter(|p| !p.is_empty());
199206

200-
let configured = profile
207+
let config_file_configured = profile
201208
.as_ref()
202209
.and_then(|p| config_data.workspaces.get(p))
203210
.map(|w| !w.api_key.is_empty())
204211
.unwrap_or(false);
205212

206213
// Check keyring storage
207214
#[cfg(feature = "secure-storage")]
208-
let keyring_configured = profile
215+
let api_key_keyring_configured = profile
209216
.as_ref()
210217
.and_then(|p| crate::keyring::get_key(p).ok())
211218
.flatten()
212219
.is_some();
213220
#[cfg(not(feature = "secure-storage"))]
214-
let keyring_configured = false;
221+
let api_key_keyring_configured = false;
222+
223+
#[cfg(feature = "secure-storage")]
224+
let oauth_keyring_configured = profile
225+
.as_ref()
226+
.and_then(|p| crate::keyring::get_oauth_tokens(p).ok())
227+
.flatten()
228+
.is_some();
229+
#[cfg(not(feature = "secure-storage"))]
230+
let oauth_keyring_configured = false;
215231

216232
#[cfg(feature = "secure-storage")]
217233
let keyring_available = crate::keyring::is_available();
218234
#[cfg(not(feature = "secure-storage"))]
219235
let keyring_available = false;
220236

237+
let keyring_configured = api_key_keyring_configured || oauth_keyring_configured;
238+
221239
// Check OAuth config
240+
let oauth_metadata = profile
241+
.as_ref()
242+
.and_then(|p| config::get_oauth_metadata(p).ok())
243+
.flatten();
244+
let oauth_configured = oauth_metadata.is_some();
222245
let oauth_config = profile
223246
.as_ref()
224247
.and_then(|p| config::get_oauth_config(p).ok())
225248
.flatten();
226-
let oauth_configured = oauth_config.is_some();
227249
let auth_type = if oauth_configured { "oauth" } else { "api_key" };
250+
let configured = config_file_configured || keyring_configured || oauth_configured;
228251

229252
let mut validated = None;
230253
if validate {
@@ -271,11 +294,12 @@ async fn status(validate: bool, output: &OutputOptions) -> Result<()> {
271294
"profile": profile,
272295
"configured": configured,
273296
"keyring_configured": keyring_configured,
297+
"oauth_keyring_configured": oauth_keyring_configured,
274298
"keyring_available": keyring_available,
275299
"auth_type": auth_type,
276300
"oauth_configured": oauth_configured,
277-
"oauth_scopes": oauth_config.as_ref().map(|o| &o.scopes),
278-
"oauth_expires_at": oauth_config.as_ref().and_then(|o| o.expires_at),
301+
"oauth_scopes": oauth_metadata.as_ref().map(|o| &o.scopes),
302+
"oauth_expires_at": oauth_metadata.as_ref().and_then(|o| o.expires_at),
279303
"env_api_key": env_key.is_some(),
280304
"env_profile": env_profile,
281305
"validated": validated,
@@ -295,6 +319,10 @@ async fn status(validate: bool, output: &OutputOptions) -> Result<()> {
295319
"Keyring available: {}",
296320
if keyring_available { "yes" } else { "no" }
297321
);
322+
println!(
323+
"OAuth keyring: {}",
324+
if oauth_keyring_configured { "yes" } else { "no" }
325+
);
298326
println!(
299327
"Env API key override: {}",
300328
if env_key.is_some() { "yes" } else { "no" }
@@ -303,7 +331,7 @@ async fn status(validate: bool, output: &OutputOptions) -> Result<()> {
303331
println!("Validated: {}", if validated { "yes" } else { "no" });
304332
}
305333
println!("Auth type: {}", auth_type);
306-
if let Some(ref oauth) = oauth_config {
334+
if let Some(ref oauth) = oauth_metadata {
307335
println!("OAuth scopes: {:?}", oauth.scopes);
308336
if let Some(expires) = oauth.expires_at {
309337
let dt = chrono::DateTime::from_timestamp(expires, 0)
@@ -503,18 +531,9 @@ async fn oauth_login(
503531

504532
#[cfg(feature = "secure-storage")]
505533
if secure {
506-
let json = serde_json::to_string(&oauth_config)?;
507-
crate::keyring::set_oauth_tokens(&profile, &json)?;
508-
// Save only metadata in config (no secrets)
509-
let metadata_only = config::OAuthConfig {
510-
client_id: oauth_config.client_id.clone(),
511-
access_token: String::new(),
512-
refresh_token: None,
513-
expires_at: oauth_config.expires_at,
514-
token_type: oauth_config.token_type.clone(),
515-
scopes: oauth_config.scopes.clone(),
516-
};
517-
config::save_oauth_config(&profile, &metadata_only)?;
534+
config::save_oauth_config_secure(&profile, &oauth_config).context(
535+
"Secure OAuth storage failed. On macOS, locally built or unsigned binaries may not be able to read back Keychain items. Try the official signed release or fall back to plain `linear-cli auth oauth`.",
536+
)?;
518537

519538
if output.is_json() || output.has_template() {
520539
print_json_owned(

‎src/commands/doctor.rs‎

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,34 @@ pub async fn run(output: &OutputOptions, check_api: bool, fix: bool) -> Result<(
1818
.filter(|p| !p.is_empty());
1919
let cache_dir = cache::cache_dir_path()?;
2020

21-
let configured = profile
21+
let config_file_configured = profile
2222
.as_ref()
2323
.and_then(|p| config_data.workspaces.get(p))
2424
.map(|w| !w.api_key.is_empty())
2525
.unwrap_or(false);
2626

27+
#[cfg(feature = "secure-storage")]
28+
let keyring_available = crate::keyring::is_available();
29+
#[cfg(not(feature = "secure-storage"))]
30+
let keyring_available = false;
31+
32+
#[cfg(feature = "secure-storage")]
33+
let oauth_keyring_configured = profile
34+
.as_ref()
35+
.and_then(|p| crate::keyring::get_oauth_tokens(p).ok())
36+
.flatten()
37+
.is_some();
38+
#[cfg(not(feature = "secure-storage"))]
39+
let oauth_keyring_configured = false;
40+
41+
let oauth_configured = profile
42+
.as_ref()
43+
.and_then(|p| config::get_oauth_metadata(p).ok())
44+
.flatten()
45+
.is_some();
46+
47+
let configured = config_file_configured || oauth_configured || oauth_keyring_configured;
48+
2749
let mut api_ok = None;
2850
let mut api_error = None;
2951
if check_api || fix {
@@ -141,6 +163,9 @@ pub async fn run(output: &OutputOptions, check_api: bool, fix: bool) -> Result<(
141163
"config_path": config_path.to_string_lossy(),
142164
"profile": profile,
143165
"configured": configured,
166+
"keyring_available": keyring_available,
167+
"oauth_configured": oauth_configured,
168+
"oauth_keyring_configured": oauth_keyring_configured,
144169
"env_api_key": env_key.is_some(),
145170
"env_profile": env_profile,
146171
"cache_dir": cache_dir.to_string_lossy(),
@@ -156,6 +181,18 @@ pub async fn run(output: &OutputOptions, check_api: bool, fix: bool) -> Result<(
156181
println!("Config path: {}", config_path.display());
157182
println!("Profile: {}", profile.unwrap_or_else(|| "none".to_string()));
158183
println!("Configured: {}", if configured { "yes" } else { "no" });
184+
println!(
185+
"Keyring available: {}",
186+
if keyring_available { "yes" } else { "no" }
187+
);
188+
println!(
189+
"OAuth configured: {}",
190+
if oauth_configured { "yes" } else { "no" }
191+
);
192+
println!(
193+
"OAuth keyring configured: {}",
194+
if oauth_keyring_configured { "yes" } else { "no" }
195+
);
159196
println!(
160197
"Env API key override: {}",
161198
if env_key.is_some() { "yes" } else { "no" }

0 commit comments

Comments
 (0)