diff --git a/lib/crates/fabro-cli/src/commands/auth/login.rs b/lib/crates/fabro-cli/src/commands/auth/login.rs index df1a33154..fcdba412d 100644 --- a/lib/crates/fabro-cli/src/commands/auth/login.rs +++ b/lib/crates/fabro-cli/src/commands/auth/login.rs @@ -7,6 +7,7 @@ use fabro_http::header::CONTENT_TYPE; use fabro_util::browser; use fabro_util::dev_token::validate_dev_token_format; use fabro_util::printer::Printer; +use fabro_util::terminal::Styles; use serde::Deserialize; use tokio::time::timeout; @@ -90,9 +91,15 @@ pub(super) async fn login_command(args: AuthLoginArgs, base_ctx: &CommandContext let code = match callback { Ok(success) => success.code, Err(failure) => { + let styles = Styles::detect_stderr(); bail!( "{}", - login_failure_message(&failure.error_code, Some(&failure.error_description)) + login_failure_message( + &failure.error_code, + Some(&failure.error_description), + &target, + &styles, + ) ); } }; @@ -224,14 +231,17 @@ fn open_browser_or_print(browser_url: &str, no_browser: bool, printer: Printer) } } -fn login_failure_message(error_code: &str, error_description: Option<&str>) -> String { +fn login_failure_message( + error_code: &str, + error_description: Option<&str>, + target: &ServerTarget, + styles: &Styles, +) -> String { match error_code { "github_session_required" => { "GitHub session required. Complete sign-in in the browser and try again.".to_string() } - "github_not_configured" => { - "This server uses dev-token auth. Copy the token from the server and run: `fabro auth login --dev-token `".to_string() - } + "github_not_configured" => dev_token_auth_required_message_with_styles(target, styles), "access_denied" => "Authorization denied.".to_string(), "unauthorized" => "Login not permitted.".to_string(), "server_error" => error_description @@ -245,6 +255,14 @@ fn login_failure_message(error_code: &str, error_description: Option<&str>) -> S } } +fn dev_token_auth_required_message_with_styles(target: &ServerTarget, styles: &Styles) -> String { + let command = format!("fabro auth login --server {target} --dev-token "); + let command = styles.bold_cyan.apply_to(command); + format!( + "This server uses dev-token auth.\n\nFind the dev token:\n - In the server terminal output\n - In the install output\n - For file-based installs, in `server.dev-token` under the configured server storage directory\n\nThen run:\n {command}" + ) +} + fn identity_summary(subject: &StoredSubject) -> String { if !subject.name.is_empty() && !subject.email.is_empty() { format!("{} ({} <{}>)", subject.login, subject.name, subject.email) @@ -266,10 +284,14 @@ struct OAuthErrorBody { mod tests { use base64::Engine as _; use base64::engine::general_purpose::URL_SAFE_NO_PAD; + use fabro_util::terminal::Styles; use insta::assert_snapshot; use sha2::{Digest, Sha256}; - use super::{browser_origin, build_browser_url, login_failure_message}; + use super::{ + browser_origin, build_browser_url, dev_token_auth_required_message_with_styles, + login_failure_message, + }; use crate::user_config::ServerTarget; #[test] @@ -294,31 +316,77 @@ mod tests { #[test] fn login_failure_messages_render_known_server_codes() { + let target = ServerTarget::http_url("http://127.0.0.1:32276").unwrap(); + let styles = Styles::new(false); assert_eq!( - login_failure_message("github_session_required", Some("GitHub session required")), + login_failure_message( + "github_session_required", + Some("GitHub session required"), + &target, + &styles + ), "GitHub session required. Complete sign-in in the browser and try again." ); assert_eq!( - login_failure_message("access_denied", Some("Authorization denied")), + login_failure_message( + "access_denied", + Some("Authorization denied"), + &target, + &styles + ), "Authorization denied." ); assert_eq!( - login_failure_message("unauthorized", Some("Login not permitted")), + login_failure_message( + "unauthorized", + Some("Login not permitted"), + &target, + &styles + ), "Login not permitted." ); assert_eq!( login_failure_message( "github_not_configured", - Some("GitHub authentication is not enabled on this server") + Some("GitHub authentication is not enabled on this server"), + &target, + &styles ), - "This server uses dev-token auth. Copy the token from the server and run: `fabro auth login --dev-token `" + "This server uses dev-token auth.\n\nFind the dev token:\n - In the server terminal output\n - In the install output\n - For file-based installs, in `server.dev-token` under the configured server storage directory\n\nThen run:\n fabro auth login --server http://127.0.0.1:32276 --dev-token " ); assert_eq!( - login_failure_message("server_error", Some("SESSION_SECRET is not configured")), + login_failure_message( + "server_error", + Some("SESSION_SECRET is not configured"), + &target, + &styles + ), "SESSION_SECRET is not configured" ); } + #[test] + fn dev_token_auth_required_message_formats_recovery_steps() { + let target = ServerTarget::http_url("http://127.0.0.1:32276").unwrap(); + + assert_eq!( + dev_token_auth_required_message_with_styles(&target, &Styles::new(false)), + "This server uses dev-token auth.\n\nFind the dev token:\n - In the server terminal output\n - In the install output\n - For file-based installs, in `server.dev-token` under the configured server storage directory\n\nThen run:\n fabro auth login --server http://127.0.0.1:32276 --dev-token " + ); + } + + #[test] + fn dev_token_auth_required_message_colors_example_command_when_enabled() { + let target = ServerTarget::http_url("http://127.0.0.1:32276").unwrap(); + let message = dev_token_auth_required_message_with_styles(&target, &Styles::new(true)); + + assert!(message.contains("\x1b[")); + assert!( + message + .contains("fabro auth login --server http://127.0.0.1:32276 --dev-token ") + ); + } + #[test] fn auth_login_accepts_http_target() { let target = ServerTarget::http_url("http://fabro.example.com/api/v1").unwrap(); diff --git a/lib/crates/fabro-server/src/auth/cli_flow.rs b/lib/crates/fabro-server/src/auth/cli_flow.rs index c2e79dd3f..68bb840d4 100644 --- a/lib/crates/fabro-server/src/auth/cli_flow.rs +++ b/lib/crates/fabro-server/src/auth/cli_flow.rs @@ -38,6 +38,16 @@ const ACCESS_TOKEN_TTL_MINUTES: i64 = 10; const REFRESH_TOKEN_TTL_DAYS: i64 = 30; const REFRESH_TOKEN_PREFIX: &str = "fabro_refresh_"; const GITHUB_NOT_CONFIGURED: &str = "GitHub login is not configured for this server."; +const DEV_TOKEN_LOGIN_INSTRUCTIONS: &str = concat!( + "This server uses dev-token auth.\n\n", + "Find the dev token:\n", + " - In the server terminal output\n", + " - In the install output\n", + " - For file-based installs, in `server.dev-token` under the configured server storage ", + "directory\n\n", + "Then run:\n", + " fabro auth login --server --dev-token " +); const INVALID_REDIRECT_URI: &str = "The provided redirect URI is not valid for CLI login."; const INVALID_OR_MISSING_STATE: &str = "The login state is missing or invalid."; const MISSING_FLOW_COOKIE: &str = "Your login session has expired. Please start again."; @@ -131,7 +141,7 @@ async fn start( &redirect_uri, state_token, "github_not_configured", - "This server uses dev-token auth. Copy the token from the server and run: `fabro auth login --dev-token `", + DEV_TOKEN_LOGIN_INSTRUCTIONS, ); } @@ -1138,8 +1148,8 @@ mod tests { use uuid::Uuid; use super::{ - CliFlowCookie, add_cli_flow_cookie, read_private_cli_flow, user_agent_fingerprint, - web_routes, + CliFlowCookie, DEV_TOKEN_LOGIN_INSTRUCTIONS, add_cli_flow_cookie, read_private_cli_flow, + user_agent_fingerprint, web_routes, }; use crate::auth::{self, AuthCode, RefreshToken}; use crate::jwt_auth::{AuthMode, ConfiguredAuth}; @@ -1161,6 +1171,16 @@ mod tests { AuthMode::Enabled(config) } + fn dev_token_auth_mode() -> AuthMode { + AuthMode::Enabled(ConfiguredAuth::new( + vec![ServerAuthMethod::DevToken], + Some( + "fabro_dev_abababababababababababababababababababababababababababababababab" + .to_string(), + ), + )) + } + fn github_settings(web_url: &str) -> fabro_types::ServerSettings { ServerSettingsBuilder::from_toml(&format!( r#" @@ -1185,6 +1205,13 @@ client_id = "github-client-id" fn test_router( settings: fabro_types::ServerSettings, + ) -> (axum::Router, Arc) { + test_router_with_auth_mode(settings, github_auth_mode()) + } + + fn test_router_with_auth_mode( + settings: fabro_types::ServerSettings, + auth_mode: AuthMode, ) -> (axum::Router, Arc) { let state = server::create_test_app_state_with_runtime_settings_and_session_key( settings, @@ -1193,7 +1220,7 @@ client_id = "github-client-id" ); let app = axum::Router::new() .nest("/auth", web_routes()) - .layer(Extension(github_auth_mode())) + .layer(Extension(auth_mode)) .with_state(Arc::clone(&state)); (app, state) } @@ -1369,6 +1396,47 @@ client_id = "github-client-id" }); } + #[tokio::test] + async fn start_without_github_auth_redirects_with_dev_token_instructions() { + let (app, _state) = test_router_with_auth_mode( + github_settings("https://fabro.example"), + dev_token_auth_mode(), + ); + + let response = app + .oneshot( + Request::builder() + .uri("/auth/cli/start?redirect_uri=http://127.0.0.1:4444/callback&state=abcdefghijklmnop&code_challenge=challenge&code_challenge_method=S256") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::SEE_OTHER); + let location = response + .headers() + .get(header::LOCATION) + .and_then(|value| value.to_str().ok()) + .expect("redirect location should be present"); + let url = url::Url::parse(location).expect("location should be a valid URL"); + let query = url + .query_pairs() + .collect::>(); + assert_eq!( + query.get("error").map(std::borrow::Cow::as_ref), + Some("github_not_configured") + ); + assert_eq!( + query.get("error_description").map(std::borrow::Cow::as_ref), + Some(DEV_TOKEN_LOGIN_INSTRUCTIONS) + ); + assert_eq!( + query.get("state").map(std::borrow::Cow::as_ref), + Some("abcdefghijklmnop") + ); + } + #[tokio::test] async fn start_with_github_session_sets_flow_cookie_and_redirects_to_resume() { let key = test_cookie_key();