fix(auth): clarify dev-token login recovery

Format the dev-token-only login failure as an action block and include the resolved --server value in the recovery command.
This commit is contained in:
Bryan Helmkamp 2026-04-29 08:20:21 -04:00
parent 843ef3c6a9
commit d58c45fe80
No known key found for this signature in database
2 changed files with 152 additions and 16 deletions

View file

@ -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 <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 <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 <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 <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 <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 <TOKEN>")
);
}
#[test]
fn auth_login_accepts_http_target() {
let target = ServerTarget::http_url("http://fabro.example.com/api/v1").unwrap();

View file

@ -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 <SERVER> --dev-token <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 <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<crate::server::AppState>) {
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<crate::server::AppState>) {
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::<std::collections::HashMap<_, _>>();
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();