1use 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;
17use raps_kernel::storage::{StorageBackend, TokenStorage};
19
20const 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#[derive(Debug, Clone, Copy, ValueEnum)]
43pub enum LoginPreset {
44 All,
46 Viewer,
48 Editor,
50 Storage,
52 Automation,
54 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
110const 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,
131
132 Login {
134 #[arg(short, long, conflicts_with = "preset")]
136 default: bool,
137 #[arg(short = 'p', long, value_enum, conflicts_with = "default")]
139 preset: Option<LoginPreset>,
140 #[arg(long)]
142 device: bool,
143 #[arg(long)]
145 token: Option<String>,
146 #[arg(long)]
148 refresh_token: Option<String>,
149 #[arg(long, default_value = "3600")]
151 expires_in: u64,
152 },
153
154 Logout,
156
157 Status,
159
160 Whoami,
162
163 Inspect {
165 #[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 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 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 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 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 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 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
594fn 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 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 let scopes: Vec<String> = data.scopes.clone();
635
636 let warn_threshold = warn_expiry_seconds.unwrap_or(300).min(86400) as i64; 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 if warn_expiry_seconds.is_some() && output.is_expiring_soon {
733 raps_kernel::error::ExitCode::AuthFailure.exit();
734 }
735
736 Ok(())
737}