Skip to main content

raps_cli/commands/
auth.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2024-2025 Dmytro Yemelianov
3
4//! Authentication commands
5//!
6//! Commands for testing authentication, logging in with 3-legged OAuth, and logging out.
7
8use anyhow::Result;
9use clap::{Subcommand, ValueEnum};
10use colored::Colorize;
11use raps_kernel::prompts;
12use serde::Serialize;
13
14use crate::commands::tracked::tracked_op;
15use crate::output::OutputFormat;
16use raps_kernel::auth::AuthClient;
17// use raps_kernel::output::OutputFormat;
18use raps_kernel::storage::{StorageBackend, TokenStorage};
19
20/// Available OAuth scopes for 3-legged authentication
21/// Reference: aps-sdk-openapi/authentication/authentication.yaml (Scopes enum)
22const AVAILABLE_SCOPES: &[(&str, &str)] = &[
23    ("data:read", "Read data (hubs, projects, folders, items)"),
24    ("data:write", "Write data (create/update items)"),
25    ("data:create", "Create new data"),
26    ("data:search", "Search for data"),
27    ("bucket:create", "Create OSS buckets"),
28    ("bucket:read", "Read OSS buckets"),
29    ("bucket:update", "Update OSS buckets"),
30    ("bucket:delete", "Delete OSS buckets"),
31    ("account:read", "Read account information"),
32    ("account:write", "Write account information"),
33    ("user:read", "Read user profile"),
34    ("user:write", "Write user profile"),
35    ("user-profile:read", "Read user profile (OpenID Connect)"),
36    ("viewables:read", "Read viewable content"),
37    ("code:all", "Design Automation (all engines)"),
38    ("openid", "OpenID Connect identity"),
39];
40
41/// Preset scope collections for `--preset`
42#[derive(Debug, Clone, Copy, ValueEnum)]
43pub enum LoginPreset {
44    /// All available scopes
45    All,
46    /// Read-only access (data, buckets, account, viewables)
47    Viewer,
48    /// Read + write access (no delete, no admin write)
49    Editor,
50    /// Object Storage Service (buckets and objects)
51    Storage,
52    /// Design Automation (engines, activities, work items)
53    Automation,
54    /// Account administration (read/write accounts and users)
55    Admin,
56}
57
58impl LoginPreset {
59    fn scopes(&self) -> Vec<&'static str> {
60        match self {
61            LoginPreset::All => AVAILABLE_SCOPES.iter().map(|(s, _)| *s).collect(),
62            LoginPreset::Viewer => vec![
63                "data:read",
64                "data:search",
65                "bucket:read",
66                "account:read",
67                "user:read",
68                "viewables:read",
69            ],
70            LoginPreset::Editor => vec![
71                "data:read",
72                "data:write",
73                "data:create",
74                "data:search",
75                "bucket:read",
76                "bucket:create",
77                "bucket:update",
78                "account:read",
79                "user:read",
80                "viewables:read",
81            ],
82            LoginPreset::Storage => vec![
83                "data:read",
84                "data:write",
85                "data:create",
86                "bucket:create",
87                "bucket:read",
88                "bucket:update",
89                "bucket:delete",
90            ],
91            LoginPreset::Automation => vec![
92                "code:all",
93                "data:read",
94                "data:write",
95                "data:create",
96                "bucket:read",
97                "bucket:create",
98            ],
99            LoginPreset::Admin => vec![
100                "account:read",
101                "account:write",
102                "user:read",
103                "user:write",
104                "data:read",
105            ],
106        }
107    }
108}
109
110/// Default scopes for login
111const DEFAULT_SCOPES: &[&str] = &[
112    "data:read",
113    "data:write",
114    "data:create",
115    "data:search",
116    "bucket:create",
117    "bucket:read",
118    "bucket:update",
119    "bucket:delete",
120    "account:read",
121    "account:write",
122    "user:read",
123    "viewables:read",
124    "code:all",
125];
126
127#[derive(Debug, Subcommand)]
128pub enum AuthCommands {
129    /// Test 2-legged (client credentials) authentication
130    Test,
131
132    /// Login with 3-legged OAuth (opens browser)
133    Login {
134        /// Use default scopes without prompting
135        #[arg(short, long, conflicts_with = "preset")]
136        default: bool,
137        /// Use a preset scope collection (e.g. "all" for every scope)
138        #[arg(short = 'p', long, value_enum, conflicts_with = "default")]
139        preset: Option<LoginPreset>,
140        /// Use device code flow instead of browser (headless-friendly)
141        #[arg(long)]
142        device: bool,
143        /// Provide access token directly (for CI/CD - use with caution)
144        #[arg(long)]
145        token: Option<String>,
146        /// Refresh token (optional, used with --token)
147        #[arg(long)]
148        refresh_token: Option<String>,
149        /// Token expiry in seconds (default: 3600, used with --token)
150        #[arg(long, default_value = "3600")]
151        expires_in: u64,
152    },
153
154    /// Logout and clear stored tokens
155    Logout,
156
157    /// Show current authentication status
158    Status,
159
160    /// Show logged-in user profile (requires 3-legged auth)
161    Whoami,
162
163    /// Inspect token details (scopes, expiry) - useful for CI
164    Inspect {
165        /// Exit with code 1 if token expires within N seconds (for CI)
166        #[arg(long)]
167        warn_expiry_seconds: Option<u64>,
168    },
169}
170
171impl AuthCommands {
172    pub async fn execute(
173        self,
174        auth_client: &AuthClient,
175        output_format: OutputFormat,
176    ) -> Result<()> {
177        match self {
178            AuthCommands::Test => test_auth(auth_client, output_format).await,
179            AuthCommands::Login {
180                default,
181                preset,
182                device,
183                token,
184                refresh_token,
185                expires_in,
186            } => {
187                login(
188                    auth_client,
189                    default,
190                    preset,
191                    device,
192                    token,
193                    refresh_token,
194                    expires_in,
195                    output_format,
196                )
197                .await
198            }
199            AuthCommands::Logout => logout(auth_client, output_format).await,
200            AuthCommands::Status => status(auth_client, output_format).await,
201            AuthCommands::Whoami => whoami(auth_client, output_format).await,
202            AuthCommands::Inspect {
203                warn_expiry_seconds,
204            } => inspect_token(auth_client, warn_expiry_seconds, output_format).await,
205        }
206    }
207}
208
209#[derive(Serialize)]
210struct TestAuthOutput {
211    success: bool,
212    client_id: String,
213    base_url: String,
214}
215
216async fn test_auth(auth_client: &AuthClient, output_format: OutputFormat) -> Result<()> {
217    if output_format.supports_colors() {
218        println!("{}", "Testing 2-legged authentication...".dimmed());
219    }
220    auth_client.test_auth().await?;
221
222    let output = TestAuthOutput {
223        success: true,
224        client_id: mask_string(&auth_client.config().client_id),
225        base_url: auth_client.config().base_url.clone(),
226    };
227
228    match output_format {
229        OutputFormat::Table => {
230            println!("{} 2-legged authentication successful!", "✓".green().bold());
231            println!("  {} {}", "Client ID:".bold(), output.client_id);
232            println!("  {} {}", "Base URL:".bold(), output.base_url);
233        }
234        _ => {
235            output_format.write(&output)?;
236        }
237    }
238    Ok(())
239}
240
241#[derive(Serialize)]
242struct LoginOutput {
243    success: bool,
244    access_token: String,
245    refresh_token_stored: bool,
246    scopes: Vec<String>,
247}
248
249#[allow(clippy::too_many_arguments)]
250async fn login(
251    auth_client: &AuthClient,
252    use_defaults: bool,
253    preset: Option<LoginPreset>,
254    device: bool,
255    token: Option<String>,
256    refresh_token: Option<String>,
257    expires_in: u64,
258    output_format: OutputFormat,
259) -> Result<()> {
260    // Check if already logged in
261    if auth_client.is_logged_in().await {
262        let msg = "Already logged in. Use 'raps auth logout' to logout first.";
263        match output_format {
264            OutputFormat::Table => println!("{}", msg.yellow()),
265            _ => output_format.write_message(msg)?,
266        }
267        return Ok(());
268    }
269
270    // Handle token-based login (CI/CD scenario)
271    if let Some(access_token) = token {
272        eprintln!(
273            "{}",
274            "WARNING: Using token-based login. Tokens should be kept secure!"
275                .yellow()
276                .bold()
277        );
278        eprintln!(
279            "{}",
280            "   This is intended for CI/CD environments. Never commit tokens to version control."
281                .dimmed()
282        );
283
284        let scopes: Vec<String> = if let Some(p) = preset {
285            p.scopes().iter().map(|s| s.to_string()).collect()
286        } else {
287            DEFAULT_SCOPES.iter().map(|s| s.to_string()).collect()
288        };
289
290        let stored = auth_client
291            .login_with_token(access_token, refresh_token, expires_in, scopes)
292            .await?;
293
294        let output = LoginOutput {
295            success: true,
296            access_token: mask_string(&stored.access_token),
297            refresh_token_stored: stored.refresh_token.is_some(),
298            scopes: stored.scopes.clone(),
299        };
300
301        match output_format {
302            OutputFormat::Table => {
303                println!("\n{} Login successful!", "✓".green().bold());
304                println!("  {} {}", "Access Token:".bold(), output.access_token);
305                if output.refresh_token_stored {
306                    println!("  {} {}", "Refresh Token:".bold(), "stored".green());
307                }
308                println!("  {} {:?}", "Scopes:".bold(), output.scopes);
309            }
310            _ => {
311                output_format.write(&output)?;
312            }
313        }
314
315        return Ok(());
316    }
317
318    // Select scopes
319    let scopes: Vec<&str> = if let Some(p) = preset {
320        p.scopes()
321    } else if use_defaults || raps_kernel::interactive::is_non_interactive() {
322        DEFAULT_SCOPES.to_vec()
323    } else {
324        let scope_labels: Vec<String> = AVAILABLE_SCOPES
325            .iter()
326            .map(|(scope, desc)| format!("{} - {}", scope, desc))
327            .collect();
328
329        // Find default selections
330        let selections = prompts::multi_select("Select OAuth scopes", &scope_labels)?;
331
332        if selections.is_empty() {
333            anyhow::bail!("At least one scope must be selected");
334        }
335
336        selections.iter().map(|&i| AVAILABLE_SCOPES[i].0).collect()
337    };
338
339    if output_format.supports_colors() {
340        println!("{}", "Starting 3-legged OAuth login...".dimmed());
341        println!("  {} {:?}", "Scopes:".bold(), scopes);
342    }
343
344    // Auto-detect headless environments and switch to device code flow
345    let device = if !device && raps_kernel::interactive::is_headless() {
346        eprintln!(
347            "{}",
348            "Headless environment detected (no browser available). \
349             Switching to device code flow automatically."
350                .yellow()
351        );
352        eprintln!(
353            "{}",
354            "   Tip: use 'raps auth login --device' to skip this detection.".dimmed()
355        );
356        true
357    } else {
358        device
359    };
360
361    // Use device code flow if requested or auto-detected
362    let token = if device {
363        auth_client.login_device(&scopes).await?
364    } else {
365        auth_client.login(&scopes).await?
366    };
367
368    let output = LoginOutput {
369        success: true,
370        access_token: mask_string(&token.access_token),
371        refresh_token_stored: token.refresh_token.is_some(),
372        scopes: token.scopes.clone(),
373    };
374
375    match output_format {
376        OutputFormat::Table => {
377            println!("\n{} Login successful!", "✓".green().bold());
378            println!("  {} {}", "Access Token:".bold(), output.access_token);
379            if output.refresh_token_stored {
380                println!("  {} {}", "Refresh Token:".bold(), "stored".green());
381            }
382            println!("  {} {:?}", "Scopes:".bold(), output.scopes);
383        }
384        _ => {
385            output_format.write(&output)?;
386        }
387    }
388
389    Ok(())
390}
391
392#[derive(Serialize)]
393struct LogoutOutput {
394    success: bool,
395    message: String,
396}
397
398async fn logout(auth_client: &AuthClient, output_format: OutputFormat) -> Result<()> {
399    if !auth_client.is_logged_in().await {
400        let msg = "Not currently logged in.";
401        match output_format {
402            OutputFormat::Table => println!("{}", msg.yellow()),
403            _ => {
404                let output = LogoutOutput {
405                    success: false,
406                    message: msg.to_string(),
407                };
408                output_format.write(&output)?;
409            }
410        }
411        return Ok(());
412    }
413
414    auth_client.logout().await?;
415
416    let output = LogoutOutput {
417        success: true,
418        message: "Logged out successfully. Stored tokens cleared.".to_string(),
419    };
420
421    match output_format {
422        OutputFormat::Table => {
423            println!("{} {}", "✓".green().bold(), output.message);
424        }
425        _ => {
426            output_format.write(&output)?;
427        }
428    }
429    Ok(())
430}
431
432#[derive(Serialize)]
433struct StatusOutput {
434    two_legged: TwoLeggedStatus,
435    three_legged: ThreeLeggedStatus,
436}
437
438#[derive(Serialize)]
439struct TwoLeggedStatus {
440    available: bool,
441}
442
443#[derive(Serialize)]
444struct ThreeLeggedStatus {
445    logged_in: bool,
446    token: Option<String>,
447    expires_at: Option<i64>,
448    expires_in_seconds: Option<i64>,
449}
450
451async fn status(auth_client: &AuthClient, output_format: OutputFormat) -> Result<()> {
452    let two_legged_available = auth_client.test_auth().await.is_ok();
453    let three_legged_logged_in = auth_client.is_logged_in().await;
454    let token = if three_legged_logged_in {
455        auth_client
456            .get_3leg_token()
457            .await
458            .ok()
459            .map(|t| mask_string(&t))
460    } else {
461        None
462    };
463
464    let expires_at = auth_client.get_token_expiry().await;
465    let expires_in_seconds = expires_at.map(|exp| {
466        let now = chrono::Utc::now().timestamp();
467        (exp - now).max(0)
468    });
469
470    let output = StatusOutput {
471        two_legged: TwoLeggedStatus {
472            available: two_legged_available,
473        },
474        three_legged: ThreeLeggedStatus {
475            logged_in: three_legged_logged_in,
476            token,
477            expires_at,
478            expires_in_seconds,
479        },
480    };
481
482    match output_format {
483        OutputFormat::Table => {
484            println!("{}", "Authentication Status".bold());
485            println!("{}", "-".repeat(40));
486
487            print!("  {} ", "2-legged (Client Credentials):".bold());
488            if output.two_legged.available {
489                println!("{}", "Available".green());
490            } else {
491                println!("{}", "Not configured".red());
492            }
493
494            print!("  {} ", "3-legged (User Login):".bold());
495            if output.three_legged.logged_in {
496                println!("{}", "Logged in".green());
497                if let Some(ref token) = output.three_legged.token {
498                    println!("    {} {}", "Token:".dimmed(), token);
499                }
500                if let Some(expires_in) = output.three_legged.expires_in_seconds {
501                    if expires_in > 0 {
502                        let hours = expires_in / 3600;
503                        let minutes = (expires_in % 3600) / 60;
504                        println!("    {} {}h {}m", "Expires in:".dimmed(), hours, minutes);
505                    } else {
506                        println!("    {} {}", "Status:".dimmed(), "Expired".red());
507                    }
508                }
509            } else {
510                println!("{}", "Not logged in".yellow());
511                println!("    {}", "Run 'raps auth login' to authenticate".dimmed());
512            }
513
514            println!("{}", "-".repeat(40));
515        }
516        _ => {
517            output_format.write(&output)?;
518        }
519    }
520    Ok(())
521}
522
523#[derive(Serialize)]
524struct WhoamiOutput {
525    name: Option<String>,
526    email: Option<String>,
527    email_verified: Option<bool>,
528    username: Option<String>,
529    aps_id: String,
530    profile_url: Option<String>,
531}
532
533async fn whoami(auth_client: &AuthClient, output_format: OutputFormat) -> Result<()> {
534    if !auth_client.is_logged_in().await {
535        let msg = "Not logged in. Please run 'raps auth login' first.";
536        match output_format {
537            OutputFormat::Table => println!("{}", msg.yellow()),
538            _ => output_format.write_message(msg)?,
539        }
540        return Ok(());
541    }
542
543    let user = tracked_op("Fetching user profile", output_format, || {
544        auth_client.get_user_info()
545    })
546    .await?;
547
548    let output = WhoamiOutput {
549        name: user.name.clone(),
550        email: user.email.clone(),
551        email_verified: user.email_verified,
552        username: user.preferred_username.clone(),
553        aps_id: user.sub.clone(),
554        profile_url: user.profile.clone(),
555    };
556
557    match output_format {
558        OutputFormat::Table => {
559            println!("\n{}", "User Profile".bold());
560            println!("{}", "-".repeat(50));
561
562            if let Some(ref name) = output.name {
563                println!("  {} {}", "Name:".bold(), name.cyan());
564            }
565
566            if let Some(ref email) = output.email {
567                let verified = if output.email_verified.unwrap_or(false) {
568                    " (verified)".green().to_string()
569                } else {
570                    "".to_string()
571                };
572                println!("  {} {}{}", "Email:".bold(), email, verified);
573            }
574
575            if let Some(ref username) = output.username {
576                println!("  {} {}", "Username:".bold(), username);
577            }
578
579            println!("  {} {}", "APS ID:".bold(), output.aps_id.dimmed());
580
581            if let Some(ref profile) = output.profile_url {
582                println!("  {} {}", "Profile URL:".bold(), profile.dimmed());
583            }
584
585            println!("{}", "-".repeat(50));
586        }
587        _ => {
588            output_format.write(&output)?;
589        }
590    }
591    Ok(())
592}
593
594/// Mask a string for display (show first 4 and last 4 characters)
595fn mask_string(s: &str) -> String {
596    let chars: Vec<char> = s.chars().collect();
597    if chars.len() <= 8 {
598        "*".repeat(chars.len())
599    } else {
600        let prefix: String = chars[..4].iter().collect();
601        let suffix: String = chars[chars.len() - 4..].iter().collect();
602        format!("{}...{}", prefix, suffix)
603    }
604}
605
606#[derive(Serialize)]
607struct InspectOutput {
608    authenticated: bool,
609    token_type: Option<String>,
610    expires_in_seconds: Option<i64>,
611    expires_at: Option<String>,
612    scopes: Option<Vec<String>>,
613    is_expiring_soon: bool,
614    warning: Option<String>,
615}
616
617async fn inspect_token(
618    _auth_client: &AuthClient,
619    warn_expiry_seconds: Option<u64>,
620    output_format: OutputFormat,
621) -> Result<()> {
622    let backend = StorageBackend::from_env();
623    let storage = TokenStorage::new(backend);
624
625    // Try to load stored token info
626    let token_data = storage.load()?;
627
628    let output = if let Some(data) = token_data {
629        let now = chrono::Utc::now().timestamp();
630        let expires_at = data.expires_at;
631        let expires_in = expires_at - now;
632
633        // Use scopes directly (already Vec<String>)
634        let scopes: Vec<String> = data.scopes.clone();
635
636        // Check if expiring soon
637        let warn_threshold = warn_expiry_seconds.unwrap_or(300).min(86400) as i64; // Default 5 min, cap 1 day
638        let is_expiring_soon = expires_in > 0 && expires_in < warn_threshold;
639        let is_expired = expires_in <= 0;
640
641        let warning = if is_expired {
642            Some("Token has expired!".to_string())
643        } else if is_expiring_soon {
644            Some(format!("Token expires in {} seconds", expires_in))
645        } else {
646            None
647        };
648
649        InspectOutput {
650            authenticated: !is_expired,
651            token_type: Some(if data.access_token.starts_with("ey") {
652                "JWT".to_string()
653            } else {
654                "Opaque".to_string()
655            }),
656            expires_in_seconds: Some(expires_in),
657            expires_at: Some(
658                chrono::DateTime::from_timestamp(expires_at, 0)
659                    .map(|dt| dt.to_rfc3339())
660                    .unwrap_or_else(|| "Unknown".to_string()),
661            ),
662            scopes: Some(scopes),
663            is_expiring_soon: is_expiring_soon || is_expired,
664            warning,
665        }
666    } else {
667        InspectOutput {
668            authenticated: false,
669            token_type: None,
670            expires_in_seconds: None,
671            expires_at: None,
672            scopes: None,
673            is_expiring_soon: true,
674            warning: Some("No token found. Run 'raps auth login' first.".to_string()),
675        }
676    };
677
678    match output_format {
679        OutputFormat::Table => {
680            println!("\n{}", "Token Inspection".bold());
681            println!("{}", "-".repeat(60));
682
683            if output.authenticated {
684                println!("  {} {}", "Authenticated:".bold(), "Yes".green());
685            } else {
686                println!("  {} {}", "Authenticated:".bold(), "No".red());
687            }
688
689            if let Some(ref token_type) = output.token_type {
690                println!("  {} {}", "Token type:".bold(), token_type);
691            }
692
693            if let Some(expires_in) = output.expires_in_seconds {
694                let color = if expires_in <= 0 {
695                    "Expired".red().to_string()
696                } else if expires_in < 300 {
697                    format!("{} seconds", expires_in).yellow().to_string()
698                } else {
699                    format!(
700                        "{} seconds ({:.1} hours)",
701                        expires_in,
702                        expires_in as f64 / 3600.0
703                    )
704                    .to_string()
705                };
706                println!("  {} {}", "Expires in:".bold(), color);
707            }
708
709            if let Some(ref expires_at) = output.expires_at {
710                println!("  {} {}", "Expires at:".bold(), expires_at.dimmed());
711            }
712
713            if let Some(ref scopes) = output.scopes {
714                println!("  {} {}", "Scopes:".bold(), scopes.len());
715                for scope in scopes {
716                    println!("    {} {}", "-".cyan(), scope);
717                }
718            }
719
720            if let Some(ref warning) = output.warning {
721                println!("\n  {} {}", "!".yellow().bold(), warning.yellow());
722            }
723
724            println!("{}", "-".repeat(60));
725        }
726        _ => {
727            output_format.write(&output)?;
728        }
729    }
730
731    // Exit with code 3 (AuthFailure) if token is expiring soon (for CI)
732    if warn_expiry_seconds.is_some() && output.is_expiring_soon {
733        raps_kernel::error::ExitCode::AuthFailure.exit();
734    }
735
736    Ok(())
737}