Skip to main content

xbp_cli/commands/
cli_session.rs

1use crate::config::{ApiConfig, CliAuthState, SshConfig};
2use chrono::{DateTime, Utc};
3use reqwest::{Client, StatusCode};
4use serde::{Deserialize, Serialize};
5use std::env;
6use tokio::time::Duration;
7
8const CLI_LOGIN_REQUIRED_HINT: &str =
9    "Run `xbp login` first so XBP can verify your session against the dashboard.";
10
11#[derive(Debug, Clone, Deserialize)]
12#[serde(rename_all = "camelCase")]
13pub(crate) struct CliAuthSessionResponse {
14    pub(crate) user: CliAuthSessionUser,
15    pub(crate) token: CliAuthSessionToken,
16}
17
18#[derive(Debug, Clone, Deserialize)]
19pub(crate) struct CliAuthSessionUser {
20    pub(crate) id: String,
21    pub(crate) name: String,
22    pub(crate) email: String,
23}
24
25#[derive(Debug, Clone, Deserialize)]
26#[serde(rename_all = "camelCase")]
27pub(crate) struct CliAuthSessionToken {
28    pub(crate) id: String,
29    pub(crate) label: Option<String>,
30    pub(crate) prefix: String,
31    pub(crate) created_at: Option<String>,
32    pub(crate) expires_at: Option<String>,
33    pub(crate) last_used_at: Option<String>,
34}
35
36#[derive(Debug)]
37pub(crate) enum CliSessionError {
38    Unauthorized,
39    Other(String),
40}
41
42#[derive(Debug, Clone)]
43enum CliTokenSource {
44    Config(String),
45    Env(String),
46}
47
48#[derive(Debug, Clone, Serialize)]
49#[serde(rename_all = "camelCase")]
50pub(crate) struct VersionActivityLinearInitiative {
51    pub(crate) id: String,
52    pub(crate) name: String,
53    pub(crate) url: Option<String>,
54}
55
56#[derive(Debug, Clone, Serialize)]
57#[serde(rename_all = "camelCase")]
58pub(crate) struct CliVersionActivityPayload {
59    pub(crate) command_kind: String,
60    pub(crate) repository_owner: Option<String>,
61    pub(crate) repository_name: Option<String>,
62    pub(crate) scope_kind: String,
63    pub(crate) scope_label: String,
64    pub(crate) version: String,
65    pub(crate) tag_name: Option<String>,
66    pub(crate) title: Option<String>,
67    pub(crate) release_url: Option<String>,
68    pub(crate) message_markdown: Option<String>,
69    pub(crate) published_initiatives: Vec<VersionActivityLinearInitiative>,
70}
71
72pub(crate) fn cli_request_client() -> Result<Client, String> {
73    Client::builder()
74        .timeout(Duration::from_secs(20))
75        .build()
76        .map_err(|e| format!("Failed to create HTTP client: {}", e))
77}
78
79pub(crate) async fn fetch_cli_session(
80    client: &Client,
81    api: &ApiConfig,
82    token: &str,
83) -> Result<CliAuthSessionResponse, CliSessionError> {
84    let response = client
85        .get(api.cli_auth_session_endpoint())
86        .bearer_auth(token)
87        .send()
88        .await
89        .map_err(|e| CliSessionError::Other(format!("Failed to verify CLI login token: {}", e)))?;
90
91    if matches!(
92        response.status(),
93        StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN
94    ) {
95        return Err(CliSessionError::Unauthorized);
96    }
97
98    let response = response.error_for_status().map_err(|e| {
99        CliSessionError::Other(format!("CLI login token verification failed: {}", e))
100    })?;
101
102    response
103        .json::<CliAuthSessionResponse>()
104        .await
105        .map_err(|e| CliSessionError::Other(format!("Failed to parse CLI session response: {}", e)))
106}
107
108pub(crate) fn save_cli_login_state(
109    token: String,
110    session: &CliAuthSessionResponse,
111) -> Result<(), String> {
112    let mut config = SshConfig::load().unwrap_or_else(|_| SshConfig::new());
113    config.xbp_api_token = Some(token);
114    config.cli_auth = Some(cli_auth_state_from_session(session));
115    config.save()
116}
117
118pub(crate) fn clear_cli_login_state(clear_token: bool) -> Result<(), String> {
119    let mut config = SshConfig::load().unwrap_or_else(|_| SshConfig::new());
120    if clear_token {
121        config.xbp_api_token = None;
122    }
123    config.cli_auth = None;
124    config.save()
125}
126
127pub(crate) fn resolve_cli_access_token() -> Result<String, String> {
128    resolve_cli_token_source()
129        .map(|source| source.token().to_string())
130        .ok_or_else(|| CLI_LOGIN_REQUIRED_HINT.to_string())
131}
132
133pub(crate) async fn require_authenticated_cli_session() -> Result<CliAuthSessionResponse, String> {
134    let token_source =
135        resolve_cli_token_source().ok_or_else(|| CLI_LOGIN_REQUIRED_HINT.to_string())?;
136    let api = ApiConfig::load();
137    let client = cli_request_client()?;
138    let token = token_source.token().to_string();
139
140    match fetch_cli_session(&client, &api, &token).await {
141        Ok(session) => {
142            if matches!(token_source, CliTokenSource::Config(_)) {
143                save_verified_state(&session)?;
144            }
145            Ok(session)
146        }
147        Err(CliSessionError::Unauthorized) => {
148            if matches!(token_source, CliTokenSource::Config(_)) {
149                let _ = clear_cli_login_state(true);
150            }
151            Err("Your stored CLI session is no longer valid. Run `xbp login` again.".to_string())
152        }
153        Err(CliSessionError::Other(error)) => Err(error),
154    }
155}
156
157pub(crate) async fn run_login_status() -> Result<(), String> {
158    let stored = SshConfig::load().ok().and_then(|cfg| cfg.cli_auth);
159    let token_source = resolve_cli_token_source();
160
161    let Some(token_source) = token_source else {
162        println!("CLI login status: not signed in.");
163        if let Some(stored) = stored {
164            if let Some(expires_at) = stored.token_expires_at {
165                println!("Last known token expiry: {}", expires_at.to_rfc3339());
166            }
167        }
168        println!("{}", CLI_LOGIN_REQUIRED_HINT);
169        return Ok(());
170    };
171
172    let api = ApiConfig::load();
173    let client = cli_request_client()?;
174    match fetch_cli_session(&client, &api, token_source.token()).await {
175        Ok(session) => {
176            if matches!(token_source, CliTokenSource::Config(_)) {
177                save_verified_state(&session)?;
178            }
179            println!("CLI login status: signed in.");
180            println!("User: {} <{}>", session.user.name, session.user.email);
181            println!("Token id: {}", session.token.id);
182            println!("Token prefix: {}", session.token.prefix);
183            if let Some(label) = session.token.label.as_deref() {
184                println!("Token label: {}", label);
185            }
186            if let Some(created_at) = session.token.created_at.as_deref() {
187                println!("Issued at: {}", created_at);
188            }
189            if let Some(expires_at) = session.token.expires_at.as_deref() {
190                println!("Expires at: {}", expires_at);
191            } else {
192                println!("Expires at: none");
193            }
194            if let Some(last_used_at) = session.token.last_used_at.as_deref() {
195                println!("Last used at: {}", last_used_at);
196            }
197            println!("Token source: {}", token_source.label());
198            Ok(())
199        }
200        Err(CliSessionError::Unauthorized) => {
201            if matches!(token_source, CliTokenSource::Config(_)) {
202                let _ = clear_cli_login_state(true);
203            }
204            Err("The current CLI token is no longer valid. Run `xbp login` again.".to_string())
205        }
206        Err(CliSessionError::Other(error)) => Err(error),
207    }
208}
209
210pub(crate) async fn run_logout() -> Result<(), String> {
211    let token_source = resolve_cli_token_source();
212    if token_source.is_none() {
213        let _ = clear_cli_login_state(true);
214        println!("CLI login status: already signed out.");
215        return Ok(());
216    }
217
218    let token_source = token_source.expect("checked above");
219    let api = ApiConfig::load();
220    let client = cli_request_client()?;
221
222    let response = client
223        .delete(api.cli_auth_session_endpoint())
224        .bearer_auth(token_source.token())
225        .send()
226        .await
227        .map_err(|e| format!("Failed to contact dashboard while signing out: {}", e))?;
228
229    if !matches!(
230        response.status(),
231        StatusCode::OK
232            | StatusCode::NO_CONTENT
233            | StatusCode::UNAUTHORIZED
234            | StatusCode::FORBIDDEN
235            | StatusCode::NOT_FOUND
236    ) {
237        return Err(format!(
238            "Dashboard sign-out failed with status {} {}.",
239            response.status().as_u16(),
240            response.status().canonical_reason().unwrap_or("")
241        ));
242    }
243
244    if matches!(token_source, CliTokenSource::Config(_)) {
245        clear_cli_login_state(true)?;
246    } else {
247        clear_cli_login_state(false)?;
248    }
249
250    println!("CLI login status: signed out.");
251    if matches!(token_source, CliTokenSource::Env(_)) {
252        println!("Environment token remains set in `XBP_API_TOKEN` until you unset it.");
253    }
254    Ok(())
255}
256
257pub(crate) async fn fetch_linear_api_key_from_dashboard() -> Result<Option<String>, String> {
258    let Some(token_source) = resolve_cli_token_source() else {
259        return Ok(None);
260    };
261    let api = ApiConfig::load();
262    let client = cli_request_client()?;
263    let response = client
264        .get(api.cli_linear_key_endpoint())
265        .bearer_auth(token_source.token())
266        .send()
267        .await
268        .map_err(|e| format!("Failed to fetch Linear key from dashboard: {}", e))?;
269
270    match response.status() {
271        StatusCode::OK => {
272            #[derive(Deserialize)]
273            #[serde(rename_all = "camelCase")]
274            struct LinearKeyResponse {
275                api_key: String,
276            }
277
278            let body = response
279                .json::<LinearKeyResponse>()
280                .await
281                .map_err(|e| format!("Failed to parse dashboard Linear key response: {}", e))?;
282            Ok(Some(body.api_key))
283        }
284        StatusCode::NOT_FOUND => Ok(None),
285        StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => Err(
286            "The current CLI session cannot read dashboard integrations. Run `xbp login` again."
287                .to_string(),
288        ),
289        _ => Err(format!(
290            "Dashboard Linear key lookup failed with status {} {}.",
291            response.status().as_u16(),
292            response.status().canonical_reason().unwrap_or("")
293        )),
294    }
295}
296
297pub(crate) async fn post_version_activity(
298    payload: &CliVersionActivityPayload,
299) -> Result<(), String> {
300    let Some(token_source) = resolve_cli_token_source() else {
301        return Err(CLI_LOGIN_REQUIRED_HINT.to_string());
302    };
303
304    let api = ApiConfig::load();
305    let client = cli_request_client()?;
306    let response = client
307        .post(api.cli_version_activity_endpoint())
308        .bearer_auth(token_source.token())
309        .json(payload)
310        .send()
311        .await
312        .map_err(|e| format!("Failed to sync version activity to dashboard: {}", e))?;
313
314    if response.status().is_success() {
315        return Ok(());
316    }
317
318    if should_skip_missing_version_activity_route(response.status()) {
319        return Ok(());
320    }
321
322    Err(format!(
323        "Dashboard version activity sync failed with status {} {}.",
324        response.status().as_u16(),
325        response.status().canonical_reason().unwrap_or("")
326    ))
327}
328
329fn should_skip_missing_version_activity_route(status: StatusCode) -> bool {
330    status == StatusCode::NOT_FOUND
331}
332
333fn resolve_cli_token_source() -> Option<CliTokenSource> {
334    if let Ok(value) = env::var("XBP_API_TOKEN") {
335        let trimmed = value.trim();
336        if !trimmed.is_empty() {
337            return Some(CliTokenSource::Env(trimmed.to_string()));
338        }
339    }
340
341    SshConfig::load()
342        .ok()
343        .and_then(|cfg| cfg.xbp_api_token)
344        .map(|value| value.trim().to_string())
345        .filter(|value| !value.is_empty())
346        .map(CliTokenSource::Config)
347}
348
349fn parse_optional_datetime(value: Option<&str>) -> Option<DateTime<Utc>> {
350    let raw = value?.trim();
351    if raw.is_empty() {
352        return None;
353    }
354
355    DateTime::parse_from_rfc3339(raw)
356        .ok()
357        .map(|value| value.with_timezone(&Utc))
358}
359
360#[cfg(test)]
361mod tests {
362    use super::should_skip_missing_version_activity_route;
363    use reqwest::StatusCode;
364
365    #[test]
366    fn version_activity_sync_skips_missing_dashboard_route() {
367        assert!(should_skip_missing_version_activity_route(
368            StatusCode::NOT_FOUND
369        ));
370        assert!(!should_skip_missing_version_activity_route(
371            StatusCode::INTERNAL_SERVER_ERROR
372        ));
373        assert!(!should_skip_missing_version_activity_route(
374            StatusCode::UNAUTHORIZED
375        ));
376    }
377}
378
379fn cli_auth_state_from_session(session: &CliAuthSessionResponse) -> CliAuthState {
380    CliAuthState {
381        user_id: Some(session.user.id.clone()),
382        user_name: Some(session.user.name.clone()),
383        user_email: Some(session.user.email.clone()),
384        token_label: session.token.label.clone(),
385        token_prefix: Some(session.token.prefix.clone()),
386        token_created_at: parse_optional_datetime(session.token.created_at.as_deref()),
387        token_expires_at: parse_optional_datetime(session.token.expires_at.as_deref()),
388        last_verified_at: Some(Utc::now()),
389    }
390}
391
392fn save_verified_state(session: &CliAuthSessionResponse) -> Result<(), String> {
393    let mut config = SshConfig::load().unwrap_or_else(|_| SshConfig::new());
394    if config.xbp_api_token.is_none() {
395        return Ok(());
396    }
397    config.cli_auth = Some(cli_auth_state_from_session(session));
398    config.save()
399}
400
401impl CliTokenSource {
402    fn token(&self) -> &str {
403        match self {
404            Self::Config(value) | Self::Env(value) => value.as_str(),
405        }
406    }
407
408    fn label(&self) -> &'static str {
409        match self {
410            Self::Config(_) => "config",
411            Self::Env(_) => "environment",
412        }
413    }
414}