diff --git a/app/src-tauri/src/marketplace/auth.rs b/app/src-tauri/src/marketplace/auth.rs new file mode 100644 index 0000000..d91e4da --- /dev/null +++ b/app/src-tauri/src/marketplace/auth.rs @@ -0,0 +1,555 @@ +//! Marketplace accounts: where a fetch credential comes from, checking a +//! pasted token, and turning a failed fetch into advice a person can act on. +//! +//! Nothing here logs, returns or formats a token into an error string. A +//! `GhHost` account stores nothing at all: its token is asked of the host's +//! `gh` every time, so a later `gh auth refresh` or logout takes effect. + +use std::time::Duration; + +use crate::marketplace::git::{Credential, FetchError}; +use crate::models::marketplace::{AccountMethod, MarketplaceAccount}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HostKind { + GitHub, + Gitea, + GitLab, + Unknown, +} + +/// Known by name only; Gitea (and self-hosted GitLab) are recognised by +/// probing their API in [`validate_token`]. +pub fn host_kind(host: &str) -> HostKind { + match host.to_ascii_lowercase().as_str() { + "github.com" => HostKind::GitHub, + "gitlab.com" => HostKind::GitLab, + _ => HostKind::Unknown, + } +} + +/// `host[:port]` characters only — also what keeps a host safe as a `gh` argument. +/// +/// `pub(crate)` so other validators (the add-marketplace form, `gh_login`) use +/// this same rule instead of a divergent copy (pre-flight F13). +pub(crate) fn valid_host(host: &str) -> bool { + !host.is_empty() + && host.len() <= 253 + && !host.starts_with('-') + && host + .bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'-' | b':')) +} + +/// The host of an `https://` marketplace URL, lowercased, with a non-default port kept. +pub fn host_of(url: &str) -> Result { + // Pre-flight N17: never echo the raw URL back on a parse failure — a + // malformed URL can carry `user:token@` and this is the one branch that + // has not already stripped it. + let parsed = url::Url::parse(url.trim()).map_err(|e| format!("Not a valid URL: {}", e))?; + if parsed.scheme() != "https" { + return Err("Only https:// marketplace URLs are supported.".to_string()); + } + if !parsed.username().is_empty() || parsed.password().is_some() { + return Err("Put credentials in a marketplace account, not in the URL.".to_string()); + } + let host = parsed + .host_str() + .ok_or_else(|| "The URL has no host".to_string())? + .to_ascii_lowercase(); + let host = match parsed.port() { + Some(port) => format!("{}:{}", host, port), + None => host, + }; + if !valid_host(&host) { + return Err(format!("{:?} is not a supported host name", host)); + } + Ok(host) +} + +/// The username sent with the token over HTTPS. +pub fn fetch_username(account: &MarketplaceAccount) -> String { + if host_kind(&account.host) == HostKind::GitHub { + return "x-access-token".to_string(); + } + account + .username + .clone() + .filter(|u| !u.trim().is_empty()) + .unwrap_or_else(|| "oauth2".to_string()) +} + +// ───────────────────────────────────────────────────────────────────────────── +// Host `gh` +// ───────────────────────────────────────────────────────────────────────────── + +const GH_TIMEOUT: Duration = Duration::from_secs(15); + +/// Run the host's `gh` with a plain argv (no shell) and return trimmed stdout. +async fn run_gh(args: &[&str]) -> Result { + let mut cmd = tokio::process::Command::new("gh"); + cmd.args(args) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .kill_on_drop(true); + let output = tokio::time::timeout(GH_TIMEOUT, cmd.output()) + .await + .map_err(|_| "gh did not answer within 15 seconds".to_string())? + .map_err(|e| format!("Could not run gh: {}", e))?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(stderr + .lines() + .next() + .unwrap_or("gh failed") + .trim() + .to_string()); + } + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) +} + +pub async fn gh_host_available() -> bool { + run_gh(&["--version"]).await.is_ok() +} + +fn gh_login_instructions(host: &str) -> String { + format!( + "gh on this computer is not logged in to {host}. Run `gh auth login --hostname {host}` \ + in a terminal, then try again.", + host = host + ) +} + +/// The login name `gh` on the host is signed in as for `host`. +pub async fn gh_host_login(host: &str) -> Result { + if !valid_host(host) { + return Err(format!("{:?} is not a supported host name", host)); + } + run_gh(&["auth", "status", "--hostname", host]) + .await + .map_err(|_| gh_login_instructions(host))?; + let login = run_gh(&["api", "user", "--hostname", host, "--jq", ".login"]).await?; + if login.is_empty() { + return Err(gh_login_instructions(host)); + } + Ok(login) +} + +/// Resolve the credential for an account: `GhHost` → `gh auth token +/// --hostname `; `GhContainer`/`Token` → the keychain. +pub async fn resolve_credential(account: &MarketplaceAccount) -> Result { + let password = match account.method { + AccountMethod::GhHost => { + if !valid_host(&account.host) { + return Err(format!("{:?} is not a supported host name", account.host)); + } + let token = run_gh(&["auth", "token", "--hostname", &account.host]) + .await + .map_err(|_| gh_login_instructions(&account.host))?; + if token.is_empty() { + return Err(gh_login_instructions(&account.host)); + } + token + } + AccountMethod::GhContainer | AccountMethod::Token => { + crate::storage::secure::get_marketplace_token(&account.id)?.ok_or_else(|| { + format!( + "No token is stored for the account \"{}\". Remove it and sign in again.", + account.label + ) + })? + } + }; + Ok(Credential { + username: fetch_username(account), + password, + }) +} + +// ───────────────────────────────────────────────────────────────────────────── +// Token validation +// ───────────────────────────────────────────────────────────────────────────── + +#[derive(Debug, PartialEq, Eq)] +enum Probe { + Login(String), + Rejected(u16), + NotThisKind, +} + +fn http_client() -> Result { + reqwest::Client::builder() + .user_agent("Triple-C") + .timeout(Duration::from_secs(15)) + .build() + .map_err(|e| format!("Could not create an HTTP client: {}", e)) +} + +/// One "who am I" call. `base` is the API root for GitHub +/// (`https://api.github.com`) and the site root for Gitea/GitLab. +async fn who_am_i( + client: &reqwest::Client, + kind: HostKind, + base: &str, + token: &str, +) -> Result { + let (url, header, value, field) = match kind { + HostKind::GitHub => ( + format!("{}/user", base), + "Authorization", + format!("Bearer {}", token), + "login", + ), + HostKind::Gitea => ( + format!("{}/api/v1/user", base), + "Authorization", + format!("token {}", token), + "login", + ), + HostKind::GitLab => ( + format!("{}/api/v4/user", base), + "PRIVATE-TOKEN", + token.to_string(), + "username", + ), + HostKind::Unknown => return Ok(Probe::NotThisKind), + }; + let response = client + .get(&url) + .header(header, value) + .header("Accept", "application/json") + .send() + .await + // reqwest's error text carries the URL, never the header. + .map_err(|e| format!("Could not reach {}: {}", base, e.without_url()))?; + let status = response.status().as_u16(); + match status { + 200 => { + let json: serde_json::Value = match response.json().await { + Ok(json) => json, + Err(_) => return Ok(Probe::NotThisKind), + }; + match json.get(field).and_then(|v| v.as_str()) { + Some(login) if !login.is_empty() => Ok(Probe::Login(login.to_string())), + _ => Ok(Probe::NotThisKind), + } + } + 401 | 403 => Ok(Probe::Rejected(status)), + 404 => Ok(Probe::NotThisKind), + other => Err(format!( + "{} answered HTTP {} when checking the token", + base, other + )), + } +} + +fn rejected(host: &str, status: u16) -> String { + format!( + "{} rejected the token (HTTP {}). Check that it has not expired and can read repositories.", + host, status + ) +} + +/// GitHub is asked at `github_api`; anything else is probed as Gitea, then +/// GitLab, at `site`. `Ok(None)`: the host is neither, so the token could not +/// be checked here — the marketplace's test fetch checks it instead. +async fn validate_token_at( + host: &str, + github_api: Option<&str>, + site: &str, + token: &str, +) -> Result, String> { + let client = http_client()?; + if let Some(api) = github_api { + return match who_am_i(&client, HostKind::GitHub, api, token).await? { + Probe::Login(login) => Ok(Some(login)), + Probe::Rejected(status) => Err(rejected(host, status)), + Probe::NotThisKind => Err(format!("{} did not return a user for this token", host)), + }; + } + for kind in [HostKind::Gitea, HostKind::GitLab] { + match who_am_i(&client, kind, site, token).await? { + Probe::Login(login) => return Ok(Some(login)), + Probe::Rejected(status) => return Err(rejected(host, status)), + Probe::NotThisKind => {} + } + } + Ok(None) +} + +/// "Who am I" check for a pasted token. `Ok(Some(login))` when the host +/// confirmed it; `Ok(None)` when the host is not GitHub, Gitea or GitLab and +/// the token is left to the first fetch to prove. +pub async fn validate_token(host: &str, token: &str) -> Result, String> { + if !valid_host(host) { + return Err(format!("{:?} is not a supported host name", host)); + } + if token.trim().is_empty() { + return Err("Paste a token first.".to_string()); + } + let site = format!("https://{}", host); + match host_kind(host) { + HostKind::GitHub => { + validate_token_at(host, Some("https://api.github.com"), &site, token.trim()).await + } + _ => validate_token_at(host, None, &site, token.trim()).await, + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Fetch errors +// ───────────────────────────────────────────────────────────────────────────── + +fn who(account: Option<&MarketplaceAccount>) -> String { + match account { + None => "anonymously (no account)".to_string(), + Some(a) => match &a.username { + Some(u) if !u.is_empty() => format!("with the account \"{}\" ({})", a.label, u), + _ => format!("with the account \"{}\"", a.label), + }, + } +} + +/// User-facing message for a failed fetch, naming the account used and, for +/// access problems, the usual organisation causes with the page that fixes each. +pub fn describe_fetch_error( + err: &FetchError, + account: Option<&MarketplaceAccount>, + url: &str, +) -> String { + let host = host_of(url).unwrap_or_else(|_| url.to_string()); + match err { + FetchError::Auth { .. } | FetchError::NotFound => { + let what = match err { + FetchError::Auth { status } => format!("access was denied (HTTP {})", status), + _ => "the repository was not found".to_string(), + }; + let mut msg = format!("Could not read {} {}: {}.", url, who(account), what); + if account.is_none() { + msg.push_str( + "\n• The repository may be private — choose an account that can read it.", + ); + } + if host_kind(&host) == HostKind::GitHub { + msg.push_str( + "\n• The organization may restrict third-party app access and not have approved \ + the GitHub CLI or your token: \ + https://docs.github.com/en/organizations/managing-oauth-access-to-your-organizations-data/about-oauth-app-access-restrictions\ + \n• If the organization uses SAML single sign-on, the token must be authorized for it: \ + https://github.com/settings/tokens\ + \n• A fine-grained token only reaches repositories of the owner it was created for: \ + https://github.com/settings/personal-access-tokens", + ); + } else if account.is_some() { + msg.push_str("\n• Check that the account's token has not expired and can read this repository."); + } + msg + } + FetchError::Network(m) => format!( + "Could not reach {}: {}. The last fetched copy is still used.", + host, m + ), + FetchError::Other(m) => format!("Fetching {} failed: {}", url, m), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn account(host: &str, username: Option<&str>) -> MarketplaceAccount { + MarketplaceAccount { + id: "acc-1".into(), + label: "Work".into(), + host: host.into(), + method: AccountMethod::Token, + username: username.map(str::to_string), + } + } + + #[test] + fn host_of_accepts_https_only() { + assert_eq!(host_of("https://GitHub.com/a/b.git").unwrap(), "github.com"); + assert_eq!( + host_of("https://git.example.com:8443/a/b").unwrap(), + "git.example.com:8443" + ); + assert!(host_of("http://github.com/a/b").is_err()); + assert!(host_of("git@github.com:a/b.git").is_err()); + assert!(host_of("file:///tmp/x").is_err()); + let err = host_of("https://user:test-token-not-real@github.com/a/b").unwrap_err(); + assert!(!err.contains("test-token-not-real")); + } + + #[test] + fn fetch_username_per_host() { + assert_eq!( + fetch_username(&account("github.com", Some("me"))), + "x-access-token" + ); + assert_eq!( + fetch_username(&account("repo.example.net", Some("jk"))), + "jk" + ); + assert_eq!(fetch_username(&account("repo.example.net", None)), "oauth2"); + assert_eq!( + fetch_username(&account("repo.example.net", Some(" "))), + "oauth2" + ); + } + + #[test] + fn host_kinds() { + assert_eq!(host_kind("GITHUB.com"), HostKind::GitHub); + assert_eq!(host_kind("gitlab.com"), HostKind::GitLab); + assert_eq!(host_kind("repo.example.net"), HostKind::Unknown); + } + + #[test] + fn describe_access_errors_names_account_and_org_causes() { + let url = "https://github.com/acme/private-market.git"; + let msg = describe_fetch_error( + &FetchError::Auth { status: 403 }, + Some(&account("github.com", Some("me"))), + url, + ); + assert!(msg.contains("\"Work\" (me)"), "{}", msg); + assert!(msg.contains("HTTP 403")); + assert!(msg.contains("third-party app access")); + assert!(msg.contains("single sign-on")); + assert!(msg.contains("fine-grained")); + + let anon = describe_fetch_error(&FetchError::NotFound, None, url); + assert!(anon.contains("anonymously")); + assert!(anon.contains("may be private")); + + let gitea = describe_fetch_error( + &FetchError::Auth { status: 401 }, + Some(&account("repo.example.net", None)), + "https://repo.example.net/o/r.git", + ); + assert!(!gitea.contains("single sign-on")); + assert!(gitea.contains("expired")); + } + + #[test] + fn describe_network_and_other_errors() { + let msg = describe_fetch_error( + &FetchError::Network("dns error".into()), + None, + "https://github.com/a/b", + ); + assert!(msg.contains("Could not reach github.com")); + assert!(msg.contains("last fetched copy")); + let msg = describe_fetch_error( + &FetchError::Other("weird".into()), + None, + "https://github.com/a/b", + ); + assert!(msg.contains("weird")); + } + + // ── validate_token against a local mock API ────────────────────────────── + + const FAKE: &str = "test-token-not-real"; + + async fn serve(app: axum::Router) -> String { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + format!("http://{}", addr) + } + + fn authorised(headers: &axum::http::HeaderMap, name: &str, want: &str) -> bool { + headers.get(name).and_then(|v| v.to_str().ok()) == Some(want) + } + + #[tokio::test] + async fn github_token_returns_login_or_is_rejected() { + use axum::{http::HeaderMap, http::StatusCode, routing::get, Json, Router}; + let app = Router::new().route( + "/user", + get(|headers: HeaderMap| async move { + if authorised(&headers, "authorization", &format!("Bearer {}", FAKE)) { + Ok(Json(serde_json::json!({ "login": "octo" }))) + } else { + Err(StatusCode::UNAUTHORIZED) + } + }), + ); + let base = serve(app).await; + assert_eq!( + validate_token_at("github.com", Some(&base), "unused", FAKE) + .await + .unwrap(), + Some("octo".to_string()) + ); + let err = validate_token_at("github.com", Some(&base), "unused", "wrong") + .await + .unwrap_err(); + assert!(err.contains("HTTP 401"), "{}", err); + assert!( + !err.contains("wrong"), + "the token must not appear in the error" + ); + } + + #[tokio::test] + async fn gitea_is_detected_first() { + use axum::{http::HeaderMap, http::StatusCode, routing::get, Json, Router}; + let app = Router::new().route( + "/api/v1/user", + get(|headers: HeaderMap| async move { + if authorised(&headers, "authorization", &format!("token {}", FAKE)) { + Ok(Json(serde_json::json!({ "login": "jk" }))) + } else { + Err(StatusCode::UNAUTHORIZED) + } + }), + ); + let site = serve(app).await; + assert_eq!( + validate_token_at("h", None, &site, FAKE).await.unwrap(), + Some("jk".to_string()) + ); + assert!(validate_token_at("h", None, &site, "wrong").await.is_err()); + } + + #[tokio::test] + async fn gitlab_is_tried_after_gitea_404() { + use axum::{http::HeaderMap, http::StatusCode, routing::get, Json, Router}; + let app = Router::new().route( + "/api/v4/user", + get(|headers: HeaderMap| async move { + if authorised(&headers, "private-token", FAKE) { + Ok(Json(serde_json::json!({ "username": "gl-user" }))) + } else { + Err(StatusCode::UNAUTHORIZED) + } + }), + ); + let site = serve(app).await; + assert_eq!( + validate_token_at("h", None, &site, FAKE).await.unwrap(), + Some("gl-user".to_string()) + ); + } + + #[tokio::test] + async fn unknown_host_is_left_unchecked() { + let site = serve(axum::Router::new()).await; // every path 404s + assert_eq!( + validate_token_at("h", None, &site, FAKE).await.unwrap(), + None + ); + } + + #[tokio::test] + async fn validate_token_refuses_bad_input_without_network() { + assert!(validate_token("-evil", FAKE).await.is_err()); + assert!(validate_token("github.com", " ").await.is_err()); + } +} diff --git a/app/src-tauri/src/marketplace/mod.rs b/app/src-tauri/src/marketplace/mod.rs index be7b083..84695ed 100644 --- a/app/src-tauri/src/marketplace/mod.rs +++ b/app/src-tauri/src/marketplace/mod.rs @@ -1,5 +1,6 @@ //! Marketplace support — see `docs/superpowers/specs/2026-09-27-marketplace-design.md`. +pub mod auth; pub mod catalog; pub mod git; pub mod tree; diff --git a/app/src-tauri/src/storage/secure.rs b/app/src-tauri/src/storage/secure.rs index 081dbf6..ef68da2 100644 --- a/app/src-tauri/src/storage/secure.rs +++ b/app/src-tauri/src/storage/secure.rs @@ -369,6 +369,44 @@ pub fn store_gateway_master_key(key: &str) -> Result<(), String> { bump_gateway_secret_version() } +// ───────────────────────────────────────────────────────────────────────────── +// Marketplace account tokens (global, one entry per account) +// ───────────────────────────────────────────────────────────────────────────── + +/// Keychain service prefix; the account id completes it. +const MARKETPLACE_TOKEN_SERVICE_PREFIX: &str = "triple-c-marketplace-account-"; + +/// The service name for one account. Ids are uuids; anything else is refused +/// before a keychain entry is constructed. +fn marketplace_token_service(account_id: &str) -> Result { + let ok = !account_id.is_empty() + && account_id.len() <= 64 + && account_id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-'); + if !ok { + return Err(format!("Invalid marketplace account id {:?}", account_id)); + } + Ok(format!("{}{}", MARKETPLACE_TOKEN_SERVICE_PREFIX, account_id)) +} + +pub fn store_marketplace_token(account_id: &str, token: &str) -> Result<(), String> { + let service = marketplace_token_service(account_id)?; + if token.trim().is_empty() { + return Err("Refusing to store an empty marketplace token.".to_string()); + } + let entry = keyring::Entry::new(&service, KEYCHAIN_ACCOUNT) + .map_err(|e| format!("Keyring error: {}", e))?; + entry + .set_password(token.trim()) + .map_err(|e| format!("Failed to store the marketplace account token: {}", e)) +} + +pub fn get_marketplace_token(account_id: &str) -> Result, String> { + read_entry(&marketplace_token_service(account_id)?, "the marketplace account token") +} + +pub fn delete_marketplace_token(account_id: &str) -> Result<(), String> { + delete_entry(&marketplace_token_service(account_id)?, "the marketplace account token") +} #[cfg(test)] mod tests { @@ -426,6 +464,22 @@ mod tests { assert!(err.contains("brand-new-token"), "{}", err); } + /// Account ids become part of a keychain service name, so a malformed one + /// is refused before any entry is constructed — and so before the + /// keychain is touched, which is also what lets this run in CI. + #[test] + fn marketplace_token_ids_are_validated_before_the_keychain() { + for bad in ["", "../x", "a b", "x;y", &"a".repeat(65)] { + let err = store_marketplace_token(bad, "test-token-not-real").unwrap_err(); + assert!(err.contains("Invalid marketplace account id"), "{bad:?}: {err}"); + assert!(!err.contains("test-token-not-real")); + assert!(get_marketplace_token(bad).is_err()); + assert!(delete_marketplace_token(bad).is_err()); + } + let err = store_marketplace_token("0b9e6a2c-1111-4222-8333-944445555666", " ").unwrap_err(); + assert!(err.contains("empty")); + } + /// The blanked-field case. `AccessSection.tsx` sends `gitToken || null`, so /// a cleared field arrives as `None` — and before this existed, `None` was /// skipped and the old secret stayed in the keychain forever.