diff --git a/src/auth.rs b/src/auth.rs index 947e190..3b334e6 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -20,6 +20,9 @@ pub struct AuthTokens { #[serde(default, skip_serializing_if = "Option::is_none")] pub id_token: Option, pub domain: String, + /// Gitea personal access token (created during auth login). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub gitea_token: Option, } /// Default client ID when the K8s secret is unavailable. @@ -265,6 +268,7 @@ async fn refresh_token(cached: &AuthTokens) -> Result { expires_at, id_token: token_resp.id_token.or_else(|| cached.id_token.clone()), domain: cached.domain.clone(), + gitea_token: cached.gitea_token.clone(), }; write_cache(&new_tokens)?; @@ -342,6 +346,7 @@ async fn bind_callback_listener() -> Result<(tokio::net::TcpListener, u16)> { async fn wait_for_callback( listener: tokio::net::TcpListener, expected_state: &str, + redirect_url: Option<&str>, ) -> Result { use tokio::io::{AsyncReadExt, AsyncWriteExt}; @@ -400,23 +405,44 @@ async fn wait_for_callback( )); } - // Send success response - let html = concat!( - "
", - "

Authentication successful

", - "

You can close this tab and return to the terminal.

", - "
" - ); - let response = format!( - "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", - html.len(), - html - ); + // Send success response — redirect to next step if provided, otherwise show done page + let response = if let Some(next_url) = redirect_url { + let html = format!( + "\ + \ +
\ +

SSO login successful

\ +

Redirecting to Gitea token setup...

\ +

Click here if not redirected

\ +
" + ); + format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + html.len(), + html + ) + } else { + let html = "\ +
\ +

Authentication successful

\ +

You can close this tab and return to the terminal.

\ +
"; + format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + html.len(), + html + ) + }; let _ = stream.write_all(response.as_bytes()).await; let _ = stream.shutdown().await; @@ -464,7 +490,12 @@ pub async fn get_token() -> Result { } /// Interactive browser-based OAuth2 login. -pub async fn cmd_auth_login(domain_override: Option<&str>) -> Result<()> { +/// SSO login — Hydra OIDC authorization code flow with PKCE. +/// `gitea_redirect`: if Some, the browser callback page auto-redirects to Gitea token page. +pub async fn cmd_auth_sso_login_with_redirect( + domain_override: Option<&str>, + gitea_redirect: Option<&str>, +) -> Result<()> { crate::output::step("Authenticating with Hydra"); // Resolve domain: explicit flag > cached token domain > config > cluster discovery @@ -507,7 +538,7 @@ pub async fn cmd_auth_login(domain_override: Option<&str>) -> Result<()> { // Wait for callback crate::output::ok("Waiting for authentication callback..."); - let callback = wait_for_callback(listener, &state).await?; + let callback = wait_for_callback(listener, &state, gitea_redirect).await?; // Exchange code for tokens crate::output::ok("Exchanging authorization code for tokens..."); @@ -529,24 +560,117 @@ pub async fn cmd_auth_login(domain_override: Option<&str>) -> Result<()> { expires_at, id_token: token_resp.id_token.clone(), domain: domain.clone(), + gitea_token: None, }; - write_cache(&tokens)?; - // Print success with email if available - if let Some(ref id_token) = tokens.id_token { - if let Some(email) = extract_email(id_token) { - crate::output::ok(&format!("Logged in as {email}")); - } else { - crate::output::ok("Logged in successfully"); - } + let email = tokens + .id_token + .as_ref() + .and_then(|t| extract_email(t)); + if let Some(ref email) = email { + crate::output::ok(&format!("Logged in as {email}")); } else { crate::output::ok("Logged in successfully"); } + write_cache(&tokens)?; Ok(()) } +/// SSO login — standalone (no redirect after callback). +pub async fn cmd_auth_sso_login(domain_override: Option<&str>) -> Result<()> { + cmd_auth_sso_login_with_redirect(domain_override, None).await +} + +/// Gitea token login — opens the PAT creation page and prompts for the token. +pub async fn cmd_auth_git_login(domain_override: Option<&str>) -> Result<()> { + crate::output::step("Setting up Gitea API access"); + + let domain = resolve_domain(domain_override).await?; + let url = format!("https://src.{domain}/user/settings/applications"); + + crate::output::ok("Opening Gitea token page in your browser..."); + crate::output::ok("Create a token with all scopes selected, then paste it below."); + println!("\n {url}\n"); + + let _ = open_browser(&url); + + // Prompt for the token + eprint!(" Gitea token: "); + let mut token = String::new(); + std::io::stdin() + .read_line(&mut token) + .ctx("Failed to read token from stdin")?; + let token = token.trim().to_string(); + + if token.is_empty() { + return Err(SunbeamError::identity("No token provided.")); + } + + // Verify the token works + let client = reqwest::Client::new(); + let resp = client + .get(format!("https://src.{domain}/api/v1/user")) + .header("Authorization", format!("token {token}")) + .send() + .await + .ctx("Failed to verify Gitea token")?; + + if !resp.status().is_success() { + return Err(SunbeamError::identity(format!( + "Gitea token is invalid (HTTP {}). Check the token and try again.", + resp.status() + ))); + } + + let user: serde_json::Value = resp.json().await?; + let login = user + .get("login") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + + // Save to cache + let mut tokens = read_cache().unwrap_or_else(|_| AuthTokens { + access_token: String::new(), + refresh_token: String::new(), + expires_at: Utc::now(), + id_token: None, + domain: domain.clone(), + gitea_token: None, + }); + tokens.gitea_token = Some(token); + if tokens.domain.is_empty() { + tokens.domain = domain; + } + write_cache(&tokens)?; + + crate::output::ok(&format!("Gitea authenticated as {login}")); + Ok(()) +} + +/// Combined login — SSO first, then Gitea. +pub async fn cmd_auth_login_all(domain_override: Option<&str>) -> Result<()> { + // Resolve domain early so we can build the Gitea redirect URL + let domain = resolve_domain(domain_override).await?; + let gitea_url = format!("https://src.{domain}/user/settings/applications"); + cmd_auth_sso_login_with_redirect(Some(&domain), Some(&gitea_url)).await?; + cmd_auth_git_login(Some(&domain)).await?; + Ok(()) +} + +/// Get the Gitea API token (for use by pm.rs). +pub fn get_gitea_token() -> Result { + let tokens = read_cache().map_err(|_| { + SunbeamError::identity("Not logged in. Run `sunbeam auth login` first.") + })?; + tokens.gitea_token.ok_or_else(|| { + SunbeamError::identity( + "No Gitea token. Run `sunbeam auth login` or `sunbeam auth set-gitea-token `.", + ) + }) +} + /// Remove cached auth tokens. pub async fn cmd_auth_logout() -> Result<()> { let path = cache_path(); @@ -674,6 +798,7 @@ mod tests { expires_at: Utc::now() + Duration::hours(1), id_token: Some("eyJhbGciOiJSUzI1NiJ9.eyJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20ifQ.sig".to_string()), domain: "sunbeam.pt".to_string(), + gitea_token: None, }; let json = serde_json::to_string_pretty(&tokens).unwrap(); @@ -699,6 +824,7 @@ mod tests { expires_at: Utc::now() + Duration::hours(1), id_token: None, domain: "example.com".to_string(), + gitea_token: None, }; let json = serde_json::to_string(&tokens).unwrap(); @@ -717,6 +843,7 @@ mod tests { expires_at: Utc::now() + Duration::hours(1), id_token: None, domain: "example.com".to_string(), + gitea_token: None, }; let now = Utc::now(); @@ -732,6 +859,7 @@ mod tests { expires_at: Utc::now() - Duration::hours(1), id_token: None, domain: "example.com".to_string(), + gitea_token: None, }; let now = Utc::now(); @@ -747,6 +875,7 @@ mod tests { expires_at: Utc::now() + Duration::seconds(30), id_token: None, domain: "example.com".to_string(), + gitea_token: None, }; let now = Utc::now(); diff --git a/src/cli.rs b/src/cli.rs index 2e8edb0..85b3b91 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -149,13 +149,25 @@ pub enum Verb { #[derive(Subcommand, Debug)] pub enum AuthAction { - /// Log in via browser (OAuth2 authorization code flow). + /// Log in to both SSO and Gitea. Login { /// Domain to authenticate against (e.g. sunbeam.pt). #[arg(long)] domain: Option, }, - /// Log out (remove cached tokens). + /// Log in to SSO only (Hydra OIDC — for Planka, identity management). + Sso { + /// Domain to authenticate against. + #[arg(long)] + domain: Option, + }, + /// Log in to Gitea only (personal access token). + Git { + /// Domain to authenticate against. + #[arg(long)] + domain: Option, + }, + /// Log out (remove all cached tokens). Logout, /// Show current authentication status. Status, @@ -1022,11 +1034,15 @@ pub async fn dispatch() -> Result<()> { }, Some(Verb::Auth { action }) => match action { - None => { - crate::auth::cmd_auth_status().await - } + None => crate::auth::cmd_auth_status().await, Some(AuthAction::Login { domain }) => { - crate::auth::cmd_auth_login(domain.as_deref()).await + crate::auth::cmd_auth_login_all(domain.as_deref()).await + } + Some(AuthAction::Sso { domain }) => { + crate::auth::cmd_auth_sso_login(domain.as_deref()).await + } + Some(AuthAction::Git { domain }) => { + crate::auth::cmd_auth_git_login(domain.as_deref()).await } Some(AuthAction::Logout) => crate::auth::cmd_auth_logout().await, Some(AuthAction::Status) => crate::auth::cmd_auth_status().await, diff --git a/src/pm.rs b/src/pm.rs index bf2e288..4fa9197 100644 --- a/src/pm.rs +++ b/src/pm.rs @@ -142,7 +142,7 @@ pub struct CardUpdate { #[serde(skip_serializing_if = "Option::is_none")] pub description: Option, #[serde(skip_serializing_if = "Option::is_none")] - pub list_id: Option, + pub list_id: Option, } struct PlankaClient { @@ -168,13 +168,13 @@ mod planka_json { #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Card { - pub id: u64, + pub id: serde_json::Value, #[serde(default)] pub name: String, #[serde(default)] pub description: Option, #[serde(default)] - pub list_id: Option, + pub list_id: Option, #[serde(default)] pub created_at: Option, #[serde(default)] @@ -208,21 +208,21 @@ mod planka_json { #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct CardMembership { - pub card_id: u64, - pub user_id: u64, + pub card_id: serde_json::Value, + pub user_id: serde_json::Value, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct CardLabel { - pub card_id: u64, - pub label_id: u64, + pub card_id: serde_json::Value, + pub label_id: serde_json::Value, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Label { - pub id: u64, + pub id: serde_json::Value, #[serde(default)] pub name: Option, } @@ -230,7 +230,7 @@ mod planka_json { #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct List { - pub id: u64, + pub id: serde_json::Value, #[serde(default)] pub name: String, } @@ -238,7 +238,7 @@ mod planka_json { #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct User { - pub id: u64, + pub id: serde_json::Value, #[serde(default)] pub name: Option, #[serde(default)] @@ -301,7 +301,7 @@ mod planka_json { // Derive web URL from API base URL (strip `/api`). let web_base = base_url.trim_end_matches("/api"); Ticket { - id: format!("planka:{}", self.id), + id: format!("p:{}", self.id.as_str().unwrap_or(&self.id.to_string())), source: Source::Planka, title: self.name, description: self.description.unwrap_or_default(), @@ -342,27 +342,29 @@ impl PlankaClient { .build() .map_err(|e| SunbeamError::network(format!("Failed to build HTTP client: {e}")))?; - // Try OIDC exchange first to get a Planka-native JWT. - let exchange_url = format!("{base_url}/access-tokens/exchange-using-oidc"); + // Exchange the Hydra access token for a Planka JWT via our custom endpoint. + let exchange_url = format!("{base_url}/access-tokens/exchange-using-token"); let exchange_resp = http .post(&exchange_url) - .bearer_auth(&hydra_token) + .json(&serde_json::json!({ "token": hydra_token })) .send() - .await; + .await + .map_err(|e| SunbeamError::network(format!("Planka token exchange failed: {e}")))?; - let token = match exchange_resp { - Ok(resp) if resp.status().is_success() => { - let body: planka_json::ExchangeResponse = resp - .json() - .await - .map_err(|e| SunbeamError::network(format!("Planka OIDC exchange parse error: {e}")))?; - body.token - .or(body.item) - .unwrap_or_else(|| hydra_token.clone()) - } - // Exchange not available or failed -- fall back to using the Hydra token directly. - _ => hydra_token, - }; + if !exchange_resp.status().is_success() { + let status = exchange_resp.status(); + let body = exchange_resp.text().await.unwrap_or_default(); + return Err(SunbeamError::identity(format!( + "Planka token exchange returned {status}: {body}" + ))); + } + + let body: serde_json::Value = exchange_resp.json().await?; + let token = body + .get("item") + .and_then(|v| v.as_str()) + .ok_or_else(|| SunbeamError::identity("Planka exchange response missing 'item' field"))? + .to_string(); Ok(Self { base_url, @@ -371,8 +373,66 @@ impl PlankaClient { }) } + /// Discover all projects and boards, then fetch cards from each. + async fn list_all_cards(&self) -> Result> { + // GET /api/projects returns all projects the user has access to, + // with included boards. + let url = format!("{}/projects", self.base_url); + let resp = self + .http + .get(&url) + .bearer_auth(&self.token) + .send() + .await + .map_err(|e| SunbeamError::network(format!("Planka list projects: {e}")))?; + + if !resp.status().is_success() { + return Err(SunbeamError::network(format!( + "Planka GET projects returned {}", + resp.status() + ))); + } + + let body: serde_json::Value = resp.json().await?; + // Extract board IDs — Planka uses string IDs (snowflake-style) + let board_ids: Vec = body + .get("included") + .and_then(|inc| inc.get("boards")) + .and_then(|b| b.as_array()) + .map(|boards| { + boards + .iter() + .filter_map(|b| { + b.get("id").and_then(|id| { + id.as_str() + .map(|s| s.to_string()) + .or_else(|| id.as_u64().map(|n| n.to_string())) + }) + }) + .collect() + }) + .unwrap_or_default(); + + if board_ids.is_empty() { + return Ok(vec![]); + } + + // Fetch cards from each board + let mut all_tickets = Vec::new(); + for board_id in &board_ids { + match self.list_cards(board_id).await { + Ok(tickets) => all_tickets.extend(tickets), + Err(e) => { + crate::output::warn(&format!("Planka board {board_id}: {e}")); + } + } + } + + Ok(all_tickets) + } + /// GET /api/boards/{id} and extract all cards. - async fn list_cards(&self, board_id: u64) -> Result> { + async fn list_cards(&self, board_id: &str) -> Result> { let url = format!("{}/boards/{board_id}", self.base_url); let resp = self .http @@ -501,7 +561,7 @@ impl PlankaClient { self.update_card( id, &CardUpdate { - list_id: Some(list_id), + list_id: Some(serde_json::Value::Number(list_id.into())), ..Default::default() }, ) @@ -642,7 +702,7 @@ mod gitea_json { .unwrap_or_else(|| format!("{web_base}/{org}/{repo}/issues/{}", self.number)); Ticket { - id: format!("gitea:{org}/{repo}#{}", self.number), + id: format!("g:{org}/{repo}#{}", self.number), source: Source::Gitea, title: self.title, description: self.body.unwrap_or_default(), @@ -670,7 +730,8 @@ impl GiteaClient { /// Create a new Gitea client using the Hydra OAuth2 token. async fn new(domain: &str) -> Result { let base_url = format!("https://src.{domain}/api/v1"); - let token = get_token().await?; + // Gitea needs its own PAT, not the Hydra access token + let token = crate::auth::get_gitea_token()?; let http = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(30)) .build() @@ -683,6 +744,25 @@ impl GiteaClient { }) } + /// Build a request with Gitea PAT auth (`Authorization: token `). + fn authed_get(&self, url: &str) -> reqwest::RequestBuilder { + self.http + .get(url) + .header("Authorization", format!("token {}", self.token)) + } + + fn authed_post(&self, url: &str) -> reqwest::RequestBuilder { + self.http + .post(url) + .header("Authorization", format!("token {}", self.token)) + } + + fn authed_patch(&self, url: &str) -> reqwest::RequestBuilder { + self.http + .patch(url) + .header("Authorization", format!("token {}", self.token)) + } + /// List issues for an org/repo (or search across an org). fn list_issues<'a>( &'a self, @@ -705,7 +785,7 @@ impl GiteaClient { let resp = self .http .get(&url) - .bearer_auth(&self.token) + .header("Authorization", format!("token {}", self.token)) .query(&[("state", state), ("type", "issues"), ("limit", "50")]) .send() .await @@ -734,7 +814,7 @@ impl GiteaClient { let repos_resp = self .http .get(&repos_url) - .bearer_auth(&self.token) + .header("Authorization", format!("token {}", self.token)) .query(&[("limit", "50")]) .send() .await @@ -774,7 +854,7 @@ impl GiteaClient { let resp = self .http .get(&url) - .bearer_auth(&self.token) + .header("Authorization", format!("token {}", self.token)) .send() .await .map_err(|e| SunbeamError::network(format!("Gitea get_issue: {e}")))?; @@ -811,7 +891,7 @@ impl GiteaClient { let resp = self .http .post(&url) - .bearer_auth(&self.token) + .header("Authorization", format!("token {}", self.token)) .json(&payload) .send() .await @@ -844,7 +924,7 @@ impl GiteaClient { let resp = self .http .patch(&url) - .bearer_auth(&self.token) + .header("Authorization", format!("token {}", self.token)) .json(updates) .send() .await @@ -890,7 +970,7 @@ impl GiteaClient { let resp = self .http .post(&url) - .bearer_auth(&self.token) + .header("Authorization", format!("token {}", self.token)) .json(&payload) .send() .await @@ -923,7 +1003,7 @@ impl GiteaClient { let resp = self .http .post(&url) - .bearer_auth(&self.token) + .header("Authorization", format!("token {}", self.token)) .json(&payload) .send() .await @@ -1009,8 +1089,7 @@ pub async fn cmd_pm_list(source: Option<&str>, state: &str) -> Result<()> { let planka_fut = async { if fetch_planka { let client = PlankaClient::new(&domain).await?; - // Board ID 1 as default; in practice this would be configurable. - client.list_cards(1).await + client.list_all_cards().await } else { Ok(vec![]) } @@ -1314,7 +1393,7 @@ mod tests { fn test_display_ticket_list_table() { let tickets = vec![ Ticket { - id: "planka:1".to_string(), + id: "p:1".to_string(), source: Source::Planka, title: "Fix login".to_string(), description: String::new(), @@ -1326,7 +1405,7 @@ mod tests { url: "https://projects.example.com/cards/1".to_string(), }, Ticket { - id: "gitea:studio/cli#7".to_string(), + id: "g:studio/cli#7".to_string(), source: Source::Gitea, title: "Add tests".to_string(), description: "We need more tests.".to_string(), @@ -1353,8 +1432,8 @@ mod tests { .collect(); let tbl = output::table(&rows, &["ID", "STATUS", "TITLE", "ASSIGNEES", "SOURCE"]); - assert!(tbl.contains("planka:1")); - assert!(tbl.contains("gitea:studio/cli#7")); + assert!(tbl.contains("p:1")); + assert!(tbl.contains("g:studio/cli#7")); assert!(tbl.contains("open")); assert!(tbl.contains("in-progress")); assert!(tbl.contains("Fix login")); @@ -1379,7 +1458,7 @@ mod tests { let update = CardUpdate { name: Some("New name".to_string()), description: None, - list_id: Some(5), + list_id: Some(serde_json::json!(5)), }; let json = serde_json::to_value(&update).unwrap(); assert_eq!(json["name"], "New name"); @@ -1411,19 +1490,19 @@ mod tests { card_labels: vec![], labels: vec![], lists: vec![ - List { id: 1, name: "To Do".to_string() }, - List { id: 2, name: "In Progress".to_string() }, - List { id: 3, name: "Done".to_string() }, - List { id: 4, name: "Archived / Closed".to_string() }, + List { id: serde_json::json!(1), name: "To Do".to_string() }, + List { id: serde_json::json!(2), name: "In Progress".to_string() }, + List { id: serde_json::json!(3), name: "Done".to_string() }, + List { id: serde_json::json!(4), name: "Archived / Closed".to_string() }, ], users: vec![], }; - let make_card = |list_id| Card { - id: 1, + let make_card = |list_id: u64| Card { + id: serde_json::json!(1), name: "test".to_string(), description: None, - list_id: Some(list_id), + list_id: Some(serde_json::json!(list_id)), created_at: None, updated_at: None, }; @@ -1466,7 +1545,7 @@ mod tests { }; let ticket = issue.to_ticket("https://src.example.com/api/v1", "studio", "app"); - assert_eq!(ticket.id, "gitea:studio/app#42"); + assert_eq!(ticket.id, "g:studio/app#42"); assert_eq!(ticket.source, Source::Gitea); assert_eq!(ticket.title, "Bug report"); assert_eq!(ticket.description, "Something broke");