diff --git a/apps/fabro-web/app/api.test.ts b/apps/fabro-web/app/api.test.ts index d308e3a19..ef9a618e7 100644 --- a/apps/fabro-web/app/api.test.ts +++ b/apps/fabro-web/app/api.test.ts @@ -1,5 +1,9 @@ -import { describe, expect, test } from "bun:test"; -import { isNotImplemented } from "./api"; +import { afterEach, describe, expect, mock, test } from "bun:test"; +import { getAuthConfig, isNotImplemented, loginDevToken } from "./api"; + +afterEach(() => { + mock.restore(); +}); describe("isNotImplemented", () => { test("returns true for 501 status", () => { @@ -14,3 +18,46 @@ describe("isNotImplemented", () => { expect(isNotImplemented(404)).toBe(false); }); }); + +describe("auth helpers", () => { + test("getAuthConfig fetches auth methods without triggering auth redirect behavior", async () => { + const fetchMock = mock(() => + Promise.resolve( + new Response(JSON.stringify({ methods: ["dev-token"] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ), + ); + globalThis.fetch = fetchMock as typeof fetch; + + const result = await getAuthConfig(); + + expect(result).toEqual({ methods: ["dev-token"] }); + expect(fetchMock).toHaveBeenCalledWith("/api/v1/auth/config", { + credentials: "include", + }); + }); + + test("loginDevToken posts the token payload", async () => { + const fetchMock = mock(() => + Promise.resolve( + new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ), + ); + globalThis.fetch = fetchMock as typeof fetch; + + const result = await loginDevToken("fabro_dev_token"); + + expect(result).toEqual({ ok: true }); + expect(fetchMock).toHaveBeenCalledWith("/auth/login/dev-token", { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token: "fabro_dev_token" }), + }); + }); +}); diff --git a/apps/fabro-web/app/api.ts b/apps/fabro-web/app/api.ts index 59baa48fc..d7171fbdc 100644 --- a/apps/fabro-web/app/api.ts +++ b/apps/fabro-web/app/api.ts @@ -56,6 +56,27 @@ export async function getSetupStatus(): Promise<{ configured: boolean }> { return response.json(); } +export async function getAuthConfig(): Promise<{ methods: string[] }> { + const response = await fetch("/api/v1/auth/config", { credentials: "include" }); + if (!response.ok) { + throw new Response(null, { status: response.status, statusText: response.statusText }); + } + return response.json(); +} + +export async function loginDevToken(token: string): Promise<{ ok: boolean }> { + const response = await fetch("/auth/login/dev-token", { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token }), + }); + if (!response.ok) { + throw new Response(null, { status: response.status, statusText: response.statusText }); + } + return response.json(); +} + export async function getAuthMe(): Promise<{ user: { login: string; diff --git a/apps/fabro-web/app/routes/auth-login.tsx b/apps/fabro-web/app/routes/auth-login.tsx index d0dc9ec0e..1b934c505 100644 --- a/apps/fabro-web/app/routes/auth-login.tsx +++ b/apps/fabro-web/app/routes/auth-login.tsx @@ -1,21 +1,73 @@ +import { useState } from "react"; +import { useNavigate } from "react-router"; import { AuthLayout } from "../components/auth-layout"; -export default function AuthLogin() { +import { getAuthConfig, loginDevToken } from "../api"; + +export async function loader() { + return getAuthConfig(); +} + +export default function AuthLogin({ loaderData }: any) { + const methods = loaderData?.methods ?? []; + const isDevToken = methods.includes("dev-token"); + const navigate = useNavigate(); + const [token, setToken] = useState(""); + const [error, setError] = useState(null); + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault(); + setError(null); + + try { + await loginDevToken(token); + navigate("/start"); + } catch { + setError("Invalid dev token"); + } + } + return (

Sign in to Fabro

- Authenticate with your GitHub account to continue. + {isDevToken + ? "Paste your dev token to continue." + : "Authenticate with your GitHub account to continue."}

- - - Sign in with GitHub - + {isDevToken ? ( +
+ setToken(event.target.value)} + placeholder="fabro_dev_..." + className="w-full rounded-lg border border-line-strong bg-panel px-4 py-2.5 text-sm text-fg outline-none focus:border-teal-500" + /> + +

+ Paste the dev token from your terminal or cat ~/.fabro/dev-token +

+ {error ? ( +

{error}

+ ) : null} +
+ ) : ( + + + Sign in with GitHub + + )}
); diff --git a/lib/crates/fabro-cli/src/commands/server/foreground.rs b/lib/crates/fabro-cli/src/commands/server/foreground.rs index 79a6501fa..390dc4a38 100644 --- a/lib/crates/fabro-cli/src/commands/server/foreground.rs +++ b/lib/crates/fabro-cli/src/commands/server/foreground.rs @@ -44,6 +44,7 @@ pub(crate) async fn execute( }, |dir| Storage::new(dir).server_state().log_path(), ); + let dev_token_path = std::env::var_os("FABRO_DEV_TOKEN_PATH").map(PathBuf::from); let pid = std::process::id(); Box::pin(serve::serve_command( @@ -55,6 +56,7 @@ pub(crate) async fn execute( pid, bind: resolved_bind.clone(), log_path: log_path.clone(), + dev_token_path: dev_token_path.clone(), started_at: Utc::now(), }) }, diff --git a/lib/crates/fabro-cli/src/commands/server/record.rs b/lib/crates/fabro-cli/src/commands/server/record.rs index 274d7c00d..ee5e0a822 100644 --- a/lib/crates/fabro-cli/src/commands/server/record.rs +++ b/lib/crates/fabro-cli/src/commands/server/record.rs @@ -12,6 +12,8 @@ pub(crate) struct ServerRecord { pub pid: u32, pub bind: Bind, pub log_path: PathBuf, + #[serde(skip_serializing_if = "Option::is_none")] + pub dev_token_path: Option, pub started_at: DateTime, } @@ -109,6 +111,7 @@ mod tests { pid: std::process::id(), bind, log_path: PathBuf::from("/tmp/server.log"), + dev_token_path: None, started_at: Utc::now(), } } diff --git a/lib/crates/fabro-cli/src/commands/server/start.rs b/lib/crates/fabro-cli/src/commands/server/start.rs index 54d8d43f5..5188248b3 100644 --- a/lib/crates/fabro-cli/src/commands/server/start.rs +++ b/lib/crates/fabro-cli/src/commands/server/start.rs @@ -6,9 +6,10 @@ use chrono::Utc; use fabro_config::Storage; use fabro_config::user::default_socket_path; use fabro_server::bind::{Bind, BindRequest}; -use fabro_server::jwt_auth::FABRO_LOCAL_NO_AUTH_ENV; use fabro_server::serve; use fabro_server::serve::{DEFAULT_TCP_PORT, ServeArgs}; +use fabro_util::Home; +use fabro_util::dev_token; use fabro_util::printer::Printer; use fabro_util::terminal::Styles; use tokio::process::Command as TokioCommand; @@ -27,7 +28,15 @@ pub(crate) async fn execute( serve_args.bind = Some(bind.to_string()); if foreground { - Box::pin(execute_foreground(bind, serve_args, storage_dir, styles)).await + Box::pin(execute_foreground( + bind, + serve_args, + storage_dir, + styles, + true, + printer, + )) + .await } else { execute_daemon(&bind, &serve_args, &storage_dir, true, printer).await } @@ -124,7 +133,22 @@ async fn execute_foreground( serve_args: ServeArgs, storage_dir: PathBuf, styles: &'static Styles, + announce: bool, + printer: Printer, ) -> Result<()> { + let home = Home::from_env(); + let token = dev_token::load_or_create_dev_token(&home.dev_token_path())?; + dev_token::write_dev_token( + &Storage::new(&storage_dir).server_state().dev_token_path(), + &token, + )?; + let prior_token = std::env::var_os("FABRO_DEV_TOKEN"); + std::env::set_var("FABRO_DEV_TOKEN", &token); + let _env_guard = scopeguard::guard(prior_token, |prior_token| match prior_token { + Some(value) => std::env::set_var("FABRO_DEV_TOKEN", value), + None => std::env::remove_var("FABRO_DEV_TOKEN"), + }); + let lock_file = acquire_lock(&storage_dir).await?; let _lock_file = lock_file; // keep alive for the duration @@ -159,10 +183,14 @@ async fn execute_foreground( styles, Some(storage_dir), move |resolved_bind| { + if announce { + print_dev_token(printer, &home, &token); + } record::write_server_record(&record_path, &record::ServerRecord { pid, bind: resolved_bind.clone(), log_path: log_path.clone(), + dev_token_path: Some(home.dev_token_path()), started_at: Utc::now(), }) }, @@ -238,10 +266,12 @@ async fn execute_daemon( cmd.arg("--config").arg(config); } + let home = Home::from_env(); + let token = dev_token::load_or_create_dev_token(&home.dev_token_path())?; + dev_token::write_dev_token(&server_state.dev_token_path(), &token)?; cmd.arg("--storage-dir").arg(storage_dir); - if matches!(bind, BindRequest::Unix(_)) { - cmd.env(FABRO_LOCAL_NO_AUTH_ENV, "1"); - } + cmd.env("FABRO_DEV_TOKEN", &token); + cmd.env("FABRO_DEV_TOKEN_PATH", home.dev_token_path()); cmd.env_remove("FABRO_JSON"); cmd.stdout(stdout_log) @@ -278,6 +308,7 @@ async fn execute_daemon( pid, record.bind ); + print_dev_token(printer, &home, &token); } return Ok(()); } @@ -306,6 +337,11 @@ async fn execute_daemon( bail!("Server did not become ready within {timeout:?}"); } +fn print_dev_token(printer: Printer, home: &Home, token: &str) { + fabro_util::printerr!(printer, "Dev token: {token}"); + fabro_util::printerr!(printer, "Token file: {}", home.dev_token_path().display()); +} + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- diff --git a/lib/crates/fabro-cli/src/server_client.rs b/lib/crates/fabro-cli/src/server_client.rs index 60512872a..877c4c1d8 100644 --- a/lib/crates/fabro-cli/src/server_client.rs +++ b/lib/crates/fabro-cli/src/server_client.rs @@ -1,4 +1,5 @@ use std::collections::VecDeque; +use std::fs; use std::num::NonZeroU64; use std::path::{Path, PathBuf}; use std::time::Duration; @@ -6,6 +7,7 @@ use std::time::Duration; use anyhow::{Context as _, Result, anyhow, bail}; use bytes::Bytes; use fabro_api::types; +use fabro_config::Storage; use fabro_http::header::{CONTENT_LENGTH, CONTENT_TYPE}; use fabro_http::multipart::{Form, Part}; use fabro_server::bind::Bind; @@ -22,8 +24,11 @@ use tokio_util::io::ReaderStream; use crate::args::ServerTargetArgs; use crate::commands::server::start; +use crate::commands::server::record; use crate::user_config::cli_http_client_builder; use crate::{sse, user_config}; +use fabro_util::Home; +use fabro_util::dev_token::validate_dev_token_format; #[derive(Clone)] pub(crate) struct ServerStoreClient { @@ -93,7 +98,7 @@ pub(crate) async fn connect_server_target_direct(target: &str) -> Result Result connect_unix_socket_api_client_bundle(&path).await, - Bind::Tcp(addr) => Err(anyhow!( - "Unsupported server bind for store client auto-connect: {addr}" - )), + Bind::Unix(path) => connect_unix_socket_api_client_bundle(&path, Some(storage_dir)).await, + Bind::Tcp(addr) => { + let token = wait_for_local_dev_token(storage_dir)?; + let builder = cli_http_client_builder().no_proxy(); + let http_client = apply_bearer_token_auth(builder, &token)?.build()?; + let base_url = format!("http://{addr}"); + let client = fabro_api::Client::new_with_client(&base_url, http_client.clone()); + Ok(ServerStoreClient { + client, + http_client, + base_url, + }) + } } } @@ -138,7 +152,9 @@ async fn connect_target_api_client_bundle( connect_remote_api_client_bundle(api_url, tls.as_ref()) } user_config::ServerTarget::UnixSocket(path) => { - if let Ok(client) = try_connect_unix_socket_api_client_bundle(path).await { + if let Ok(client) = + try_connect_unix_socket_api_client_bundle(path, Some(&runtime.storage_dir)).await + { Ok(client) } else { start::ensure_server_running_on_socket( @@ -148,7 +164,7 @@ async fn connect_target_api_client_bundle( ) .await .with_context(|| format!("Failed to start fabro server for {}", path.display()))?; - connect_unix_socket_api_client_bundle(path).await + connect_unix_socket_api_client_bundle(path, Some(&runtime.storage_dir)).await } } } @@ -176,12 +192,77 @@ fn normalize_remote_server_target(api_url: &str) -> String { .to_string() } -fn build_unix_socket_http_client(path: &Path) -> Result { - cli_http_client_builder() - .unix_socket(path) - .no_proxy() - .build() - .context("Failed to build Unix-socket HTTP client for fabro server") +fn read_dev_token_file(path: &Path) -> Option { + fs::read_to_string(path) + .ok() + .map(|token| token.trim().to_string()) + .filter(|token| validate_dev_token_format(token)) +} + +fn load_dev_token_if_available(storage_dir: Option<&Path>) -> Option { + if let Some(token) = std::env::var("FABRO_DEV_TOKEN") + .ok() + .filter(|token| validate_dev_token_format(token)) + { + return Some(token); + } + + if let Some(storage_dir) = storage_dir { + let storage_token_path = Storage::new(storage_dir).server_state().dev_token_path(); + if let Some(token) = read_dev_token_file(&storage_token_path) { + return Some(token); + } + + let record_path = Storage::new(storage_dir).server_state().record_path(); + if let Some(token) = record::read_server_record(&record_path) + .and_then(|server| server.dev_token_path) + .as_deref() + .and_then(read_dev_token_file) + { + return Some(token); + } + } + + read_dev_token_file(&Home::from_env().dev_token_path()) +} + +fn wait_for_local_dev_token(storage_dir: &Path) -> Result { + let deadline = std::time::Instant::now() + Duration::from_secs(5); + + while std::time::Instant::now() < deadline { + if let Some(token) = load_dev_token_if_available(Some(storage_dir)) { + return Ok(token); + } + std::thread::sleep(Duration::from_millis(50)); + } + + bail!( + "local server dev token did not become available for {}", + storage_dir.display() + ); +} + +fn apply_bearer_token_auth( + builder: fabro_http::HttpClientBuilder, + token: &str, +) -> Result { + let mut headers = fabro_http::HeaderMap::new(); + headers.insert( + fabro_http::header::AUTHORIZATION, + fabro_http::HeaderValue::from_str(&format!("Bearer {token}")) + .context("invalid dev token header value")?, + ); + Ok(builder.default_headers(headers)) +} + +fn apply_dev_token_auth( + builder: fabro_http::HttpClientBuilder, + storage_dir: Option<&Path>, +) -> Result { + let Some(token) = load_dev_token_if_available(storage_dir) else { + return Ok(builder); + }; + apply_bearer_token_auth(builder, &token) } fn unix_socket_api_client_bundle(http_client: fabro_http::HttpClient) -> ServerStoreClient { @@ -194,15 +275,51 @@ fn unix_socket_api_client_bundle(http_client: fabro_http::HttpClient) -> ServerS } } -async fn try_connect_unix_socket_api_client_bundle(path: &Path) -> Result { - let http_client = build_unix_socket_http_client(path)?; - check_server_ready(&http_client).await?; +async fn try_connect_unix_socket_api_client_bundle( + path: &Path, + storage_dir: Option<&Path>, +) -> Result { + let probe_client = cli_http_client_builder() + .unix_socket(path) + .no_proxy() + .build() + .context("Failed to build Unix-socket HTTP client for fabro server")?; + check_server_ready(&probe_client).await?; + + let http_client = if let Some(storage_dir) = storage_dir { + let token = wait_for_local_dev_token(storage_dir)?; + apply_bearer_token_auth(cli_http_client_builder().unix_socket(path).no_proxy(), &token)? + .build() + .context("Failed to build Unix-socket HTTP client for fabro server")? + } else { + apply_dev_token_auth(cli_http_client_builder().unix_socket(path).no_proxy(), None)? + .build() + .context("Failed to build Unix-socket HTTP client for fabro server")? + }; Ok(unix_socket_api_client_bundle(http_client)) } -async fn connect_unix_socket_api_client_bundle(path: &Path) -> Result { - let http_client = build_unix_socket_http_client(path)?; - wait_for_server_ready(&http_client).await?; +async fn connect_unix_socket_api_client_bundle( + path: &Path, + storage_dir: Option<&Path>, +) -> Result { + let probe_client = cli_http_client_builder() + .unix_socket(path) + .no_proxy() + .build() + .context("Failed to build Unix-socket HTTP client for fabro server")?; + wait_for_server_ready(&probe_client).await?; + + let http_client = if let Some(storage_dir) = storage_dir { + let token = wait_for_local_dev_token(storage_dir)?; + apply_bearer_token_auth(cli_http_client_builder().unix_socket(path).no_proxy(), &token)? + .build() + .context("Failed to build Unix-socket HTTP client for fabro server")? + } else { + apply_dev_token_auth(cli_http_client_builder().unix_socket(path).no_proxy(), None)? + .build() + .context("Failed to build Unix-socket HTTP client for fabro server")? + }; Ok(unix_socket_api_client_bundle(http_client)) } @@ -874,6 +991,94 @@ async fn ensure_raw_response_success(response: fabro_http::Response) -> Result<( bail!("request failed with status {status}: {body}"); } +#[cfg(test)] +mod tests { + use std::sync::{LazyLock, Mutex}; + + use super::*; + + static ENV_LOCK: LazyLock> = LazyLock::new(|| Mutex::new(())); + + #[test] + fn load_dev_token_if_available_prefers_env() { + let _guard = ENV_LOCK.lock().unwrap(); + let temp_home = tempfile::tempdir().unwrap(); + let token_path = temp_home.path().join("dev-token"); + std::fs::write( + &token_path, + "fabro_dev_abababababababababababababababababababababababababababababababab", + ) + .unwrap(); + + std::env::set_var("FABRO_HOME", temp_home.path()); + std::env::set_var( + "FABRO_DEV_TOKEN", + "fabro_dev_cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd", + ); + + let token = load_dev_token_if_available(None); + + std::env::remove_var("FABRO_DEV_TOKEN"); + std::env::remove_var("FABRO_HOME"); + + assert_eq!( + token.as_deref(), + Some( + "fabro_dev_cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd" + ) + ); + } + + #[test] + fn load_dev_token_if_available_reads_file() { + let _guard = ENV_LOCK.lock().unwrap(); + let temp_home = tempfile::tempdir().unwrap(); + let token = "fabro_dev_abababababababababababababababababababababababababababababababab"; + std::fs::write(temp_home.path().join("dev-token"), token).unwrap(); + + std::env::remove_var("FABRO_DEV_TOKEN"); + std::env::set_var("FABRO_HOME", temp_home.path()); + + let loaded = load_dev_token_if_available(None); + + std::env::remove_var("FABRO_HOME"); + + assert_eq!(loaded.as_deref(), Some(token)); + } + + #[test] + fn load_dev_token_if_available_reads_path_from_active_server_record() { + let _guard = ENV_LOCK.lock().unwrap(); + let temp_home = tempfile::tempdir().unwrap(); + let storage = tempfile::tempdir().unwrap(); + let token_dir = tempfile::tempdir().unwrap(); + let token = "fabro_dev_abababababababababababababababababababababababababababababababab"; + let token_path = token_dir.path().join("dev-token"); + std::fs::write(&token_path, token).unwrap(); + + let record_path = fabro_config::Storage::new(storage.path()) + .server_state() + .record_path(); + record::write_server_record(&record_path, &record::ServerRecord { + pid: std::process::id(), + bind: fabro_server::bind::Bind::Unix(temp_home.path().join("fabro.sock")), + log_path: storage.path().join("server.log"), + dev_token_path: Some(token_path), + started_at: chrono::Utc::now(), + }) + .unwrap(); + + std::env::remove_var("FABRO_DEV_TOKEN"); + std::env::set_var("FABRO_HOME", temp_home.path()); + + let loaded = load_dev_token_if_available(Some(storage.path())); + + std::env::remove_var("FABRO_HOME"); + + assert_eq!(loaded.as_deref(), Some(token)); + } +} + fn is_not_found_error(err: &progenitor_client::Error) -> bool where E: serde::Serialize + std::fmt::Debug, diff --git a/lib/crates/fabro-cli/tests/it/cmd/server_start.rs b/lib/crates/fabro-cli/tests/it/cmd/server_start.rs index 18a30c22d..491be1d73 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/server_start.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/server_start.rs @@ -2,7 +2,6 @@ use std::process::Stdio; use std::sync::{Arc, Barrier}; use std::time::{Duration, Instant}; -use fabro_server::jwt_auth::FABRO_LOCAL_NO_AUTH_ENV; use fabro_test::{fabro_snapshot, test_context}; fn isolated_storage_dir() -> tempfile::TempDir { @@ -145,12 +144,8 @@ fn start_with_tcp_host_only_bind_resolves_to_host_and_port() { let storage_root = isolated_storage_dir(); let storage_dir = storage_root.path().join("storage"); - // TCP binds don't auto-enable `FABRO_LOCAL_NO_AUTH`; the test is - // exercising bind resolution, not auth, so opt into insecure - // startup explicitly. let mut cmd = context.command(); cmd.env("FABRO_STORAGE_DIR", &storage_dir); - cmd.env(FABRO_LOCAL_NO_AUTH_ENV, "1"); cmd.args(["server", "start", "--dry-run", "--bind", "127.0.0.1"]); let output = cmd.output().expect("server start command should run"); assert!( @@ -209,13 +204,13 @@ fn start_with_tcp_host_only_bind_warns_and_falls_back_when_default_port_is_unava let mut filters = context.filters(); filters.push((r"pid \d+".to_string(), "pid [PID]".to_string())); filters.push((r"127\.0\.0\.1:\d+".to_string(), "[TCP_BIND]".to_string())); + filters.push(( + r"fabro_dev_[0-9a-f]{64}".to_string(), + "fabro_dev_[DEV_TOKEN]".to_string(), + )); - // TCP binds don't auto-enable `FABRO_LOCAL_NO_AUTH`; the test is - // exercising bind resolution, not auth, so opt into insecure - // startup explicitly. let mut cmd = context.command(); cmd.env("FABRO_STORAGE_DIR", &storage_dir); - cmd.env(FABRO_LOCAL_NO_AUTH_ENV, "1"); cmd.args(["server", "start", "--dry-run", "--bind", "127.0.0.1"]); fabro_snapshot!(filters, cmd, @" success: true @@ -224,6 +219,8 @@ fn start_with_tcp_host_only_bind_warns_and_falls_back_when_default_port_is_unava ----- stderr ----- Warning: TCP port 32276 is unavailable on 127.0.0.1; falling back to a random port. Server started (pid [PID]) on [TCP_BIND] + Dev token: fabro_dev_[DEV_TOKEN] + Token file: [HOME]/.fabro/dev-token "); let output = context @@ -354,6 +351,7 @@ fn concurrent_autostart_converges_on_one_shared_daemon_and_cleans_up() { .env("FABRO_TEST_IN_MEMORY_STORE", "1") .env("NO_COLOR", "1") .env("HOME", home_dir) + .env("FABRO_HOME", home_dir.join(".fabro")) .env("FABRO_CONFIG", config_path) .env("FABRO_NO_UPGRADE_CHECK", "true") .env("FABRO_HTTP_PROXY_POLICY", "disabled") diff --git a/lib/crates/fabro-cli/tests/it/scenario/server_lifecycle.rs b/lib/crates/fabro-cli/tests/it/scenario/server_lifecycle.rs index ac65aa757..e6d74b3fe 100644 --- a/lib/crates/fabro-cli/tests/it/scenario/server_lifecycle.rs +++ b/lib/crates/fabro-cli/tests/it/scenario/server_lifecycle.rs @@ -18,6 +18,10 @@ fn start_status_stop_lifecycle() { r"started \d+[hms] (?:\d+[hms] )*ago".to_string(), "started [UPTIME] ago".to_string(), )); + filters.push(( + r"fabro_dev_[0-9a-f]{64}".to_string(), + "fabro_dev_[DEV_TOKEN]".to_string(), + )); let mut cmd = context.command(); cmd.env("FABRO_STORAGE_DIR", &storage_dir); @@ -28,6 +32,8 @@ fn start_status_stop_lifecycle() { ----- stdout ----- ----- stderr ----- Server started (pid [PID]) on [SOCKET_PATH] + Dev token: fabro_dev_[DEV_TOKEN] + Token file: [HOME_DIR]/.fabro/dev-token "); let mut cmd = context.command(); diff --git a/lib/crates/fabro-config/src/storage.rs b/lib/crates/fabro-config/src/storage.rs index 7c999f8f6..654eaf54f 100644 --- a/lib/crates/fabro-config/src/storage.rs +++ b/lib/crates/fabro-config/src/storage.rs @@ -90,6 +90,11 @@ impl ServerState { pub fn env_path(&self) -> PathBuf { self.root.join("server.env") } + + #[must_use] + pub fn dev_token_path(&self) -> PathBuf { + self.root.join("server.dev-token") + } } impl RunScratch { diff --git a/lib/crates/fabro-server/src/jwt_auth.rs b/lib/crates/fabro-server/src/jwt_auth.rs index e03241dc4..f75455990 100644 --- a/lib/crates/fabro-server/src/jwt_auth.rs +++ b/lib/crates/fabro-server/src/jwt_auth.rs @@ -4,24 +4,21 @@ use anyhow::{Result, anyhow}; use axum::extract::FromRequestParts; use axum::http::request::Parts; use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; +use cookie::Key; use fabro_types::RunAuthMethod; use fabro_types::settings::{ServerListenSettings, ServerSettings as ResolvedServerSettings}; +use hmac::{Hmac, Mac}; use jsonwebtoken::{Algorithm, DecodingKey, Validation}; use rustls_pki_types::CertificateDer; use serde::Deserialize; -use tracing::warn; +use sha2::Sha256; use crate::error::ApiError; use crate::web_auth::SessionCookie; +use fabro_util::dev_token::validate_dev_token_format; -/// Env var that explicitly opts the server into unauthenticated startup. -/// -/// When set to `"1"`, [`resolve_auth_mode_with_lookup`] returns -/// [`AuthMode::Disabled`] regardless of what `server.auth` says. This is the -/// only escape hatch for running the server without configured -/// authentication; it is off by default, so accidental misconfigurations -/// fail closed. -pub const FABRO_LOCAL_NO_AUTH_ENV: &str = "FABRO_LOCAL_NO_AUTH"; +type HmacSha256 = Hmac; +const DEV_TOKEN_COMPARE_KEY: &[u8] = b"fabro-dev-token-compare-key"; /// JWT claims for service-to-service authentication. #[derive(Debug, Deserialize)] @@ -38,6 +35,9 @@ struct Claims { /// A single authentication strategy resolved at startup. #[derive(Clone, Debug)] pub enum AuthStrategy { + DevToken { + token: String, + }, Jwt { key: Arc, validation: Arc, @@ -64,6 +64,12 @@ pub enum AuthMode { Disabled, } +#[derive(Clone, Debug)] +pub struct ResolvedAuth { + pub mode: AuthMode, + pub session_key_override: Option, +} + /// Peer certificates extracted from the TLS connection, inserted as a request /// extension. #[derive(Clone)] @@ -81,10 +87,7 @@ pub fn decode_pem_env(name: &str, value: &str) -> Result { /// Resolve the authentication mode from resolved server settings. /// -/// Call this once at startup before serving requests. Returns -/// [`AuthMode::Disabled`] when [`FABRO_LOCAL_NO_AUTH_ENV`] is set to `"1"` -/// (explicit insecure-startup opt-in). Returns `AuthMode::Strategies(...)` -/// when `server.auth` resolves to at least one enabled strategy. +/// Call this once at startup before serving requests. /// /// Fails closed when `server.auth` is absent or resolves to zero enabled /// strategies, or when a configured strategy is missing its required @@ -93,7 +96,7 @@ pub fn decode_pem_env(name: &str, value: &str) -> Result { /// /// Walks the v2 `server.auth.api.{jwt,mtls}` subtree and /// `server.auth.web.allowed_usernames`. -pub fn resolve_auth_mode(settings: &ResolvedServerSettings) -> Result { +pub fn resolve_auth_mode(settings: &ResolvedServerSettings) -> Result { resolve_auth_mode_with_lookup(settings, |name| std::env::var(name).ok()) } @@ -137,18 +140,10 @@ fn resolve_auth_strategies(settings: &ResolvedServerSettings) -> ResolvedAuthStr pub fn resolve_auth_mode_with_lookup( settings: &ResolvedServerSettings, lookup: F, -) -> Result +) -> Result where F: Fn(&str) -> Option, { - if lookup(FABRO_LOCAL_NO_AUTH_ENV).as_deref() == Some("1") { - warn!( - "{FABRO_LOCAL_NO_AUTH_ENV}=1 set; allowing unauthenticated local daemon access. \ - Do not use this flag outside local development or demo environments." - ); - return Ok(AuthMode::Disabled); - } - let ResolvedAuthStrategies { jwt_enabled, mtls_enabled, @@ -193,21 +188,36 @@ where strategies.push(AuthStrategy::Mtls); } + let mut session_key_override = None; if strategies.is_empty() { - return Err(anyhow!( - "Fabro server refuses to start: no authentication strategies are configured.\n\ - \n\ - Configure at least one of the following in `[server.auth]`:\n\ - - `[server.auth.api.jwt]` (requires `FABRO_JWT_PUBLIC_KEY` in process env or server.env)\n\ - - `[server.auth.api.mtls]` (requires `[server.listen.tls]` cert/key/ca)\n\ - - `SESSION_SECRET` in process env or server.env (enables cookie-based web auth)\n\ - \n\ - Or set `{FABRO_LOCAL_NO_AUTH_ENV}=1` to explicitly opt in to \ - unauthenticated local daemon access." - )); + let Some(token) = lookup("FABRO_DEV_TOKEN") else { + return Err(anyhow!( + "Fabro server refuses to start: no authentication strategies are configured.\n\ + \n\ + Configure at least one of the following in `[server.auth]`:\n\ + - `[server.auth.api.jwt]` (requires `FABRO_JWT_PUBLIC_KEY` in process env or server.env)\n\ + - `[server.auth.api.mtls]` (requires `[server.listen.tls]` cert/key/ca)\n\ + - `SESSION_SECRET` in process env or server.env (enables cookie-based web auth)\n\ + \n\ + For CLI-managed local starts, set `FABRO_DEV_TOKEN` to enable dev-token authentication." + )); + }; + + if !validate_dev_token_format(&token) { + return Err(anyhow!( + "Fabro server refuses to start: FABRO_DEV_TOKEN has invalid format." + )); + } + + session_key_override = Some(Key::derive_from(token.as_bytes())); + strategies.push(AuthStrategy::DevToken { token }); + strategies.push(AuthStrategy::Cookie); } - Ok(AuthMode::Strategies(strategies)) + Ok(ResolvedAuth { + mode: AuthMode::Strategies(strategies), + session_key_override, + }) } /// Extract the login from JWT claims. @@ -283,6 +293,42 @@ fn try_jwt( Ok(()) } +pub(crate) fn dev_token_matches(provided: &str, expected: &str) -> bool { + let Ok(mut provided_mac) = HmacSha256::new_from_slice(DEV_TOKEN_COMPARE_KEY) else { + return false; + }; + provided_mac.update(provided.as_bytes()); + let provided_mac = provided_mac.finalize().into_bytes(); + + let Ok(mut expected_mac) = HmacSha256::new_from_slice(DEV_TOKEN_COMPARE_KEY) else { + return false; + }; + expected_mac.update(expected.as_bytes()); + expected_mac.verify_slice(&provided_mac).is_ok() +} + +fn try_dev_token(parts: &Parts, expected: &str) -> Result<(), ApiError> { + let header = parts + .headers + .get("authorization") + .and_then(|v| v.to_str().ok()) + .ok_or_else(ApiError::unauthorized)?; + + let token = header + .strip_prefix("Bearer ") + .ok_or_else(ApiError::unauthorized)?; + + if !validate_dev_token_format(token) || !validate_dev_token_format(expected) { + return Err(ApiError::unauthorized()); + } + + if dev_token_matches(token, expected) { + Ok(()) + } else { + Err(ApiError::unauthorized()) + } +} + /// Try to authenticate via mTLS peer certificates. fn try_mtls(parts: &Parts) -> Result<(), ApiError> { let peer_certs = parts @@ -346,6 +392,7 @@ pub fn authenticate_service_parts(parts: &Parts) -> Result<(), ApiError> { let result = match strategy { AuthStrategy::Mtls => try_mtls(parts), AuthStrategy::Cookie => try_cookie(parts), + AuthStrategy::DevToken { token } => try_dev_token(parts, token), AuthStrategy::Jwt { key, validation, @@ -403,11 +450,24 @@ impl FromRequestParts for AuthenticatedSubject { for strategy in strategies { match strategy { + AuthStrategy::DevToken { token } => { + if try_dev_token(parts, token).is_ok() { + return Ok(Self { + login: Some("dev".to_string()), + auth_method: RunAuthMethod::DevToken, + }); + } + last_err = ApiError::unauthorized(); + } AuthStrategy::Cookie => { if let Some(session) = parts.extensions.get::() { return Ok(Self { login: Some(session.login.clone()), - auth_method: RunAuthMethod::Cookie, + auth_method: if session.provider == "dev-token" { + RunAuthMethod::DevToken + } else { + RunAuthMethod::Cookie + }, }); } last_err = ApiError::unauthorized(); @@ -481,7 +541,7 @@ mod tests { let err = resolve_auth_mode_with_lookup(&file, empty_lookup).expect_err("should refuse startup"); assert!(err.to_string().contains("refuses to start")); - assert!(err.to_string().contains("FABRO_LOCAL_NO_AUTH")); + assert!(err.to_string().contains("FABRO_DEV_TOKEN")); } #[test] @@ -503,33 +563,42 @@ enabled = false } #[test] - fn opt_in_insecure_startup_via_env() { + fn dev_token_fallback_activates_when_env_set() { let file = settings("_version = 1\n"); - let mode = resolve_auth_mode_with_lookup(&file, |name| { - (name == FABRO_LOCAL_NO_AUTH_ENV).then(|| "1".to_string()) + let resolved = resolve_auth_mode_with_lookup(&file, |name| { + (name == "FABRO_DEV_TOKEN").then(|| { + "fabro_dev_abababababababababababababababababababababababababababababababab" + .to_string() + }) }) - .expect("FABRO_LOCAL_NO_AUTH=1 should allow startup"); - assert!(matches!(mode, AuthMode::Disabled)); + .expect("valid FABRO_DEV_TOKEN should allow startup"); + let AuthMode::Strategies(strategies) = resolved.mode else { + panic!("expected Strategies"); + }; + assert_eq!(strategies.len(), 2); + assert!(matches!(strategies[0], AuthStrategy::DevToken { .. })); + assert!(matches!(strategies[1], AuthStrategy::Cookie)); + assert!(resolved.session_key_override.is_some()); } #[test] - fn insecure_startup_flag_any_other_value_still_fails_closed() { + fn dev_token_fallback_rejects_invalid_format() { let file = settings("_version = 1\n"); let err = resolve_auth_mode_with_lookup(&file, |name| { - (name == FABRO_LOCAL_NO_AUTH_ENV).then(|| "true".to_string()) + (name == "FABRO_DEV_TOKEN").then(|| "fabro_dev_not_hex".to_string()) }) - .expect_err("only the literal string \"1\" opts in"); - assert!(err.to_string().contains("refuses to start")); + .expect_err("invalid dev token format should refuse startup"); + assert!(err.to_string().contains("invalid")); } #[test] fn cookie_strategy_alone_unlocks_startup() { let file = settings("_version = 1\n"); - let mode = resolve_auth_mode_with_lookup(&file, |name| { + let resolved = resolve_auth_mode_with_lookup(&file, |name| { (name == "SESSION_SECRET").then(|| "deadbeef".to_string()) }) .expect("SESSION_SECRET alone should unlock startup"); - let AuthMode::Strategies(strategies) = mode else { + let AuthMode::Strategies(strategies) = resolved.mode else { panic!("expected Strategies, got Disabled"); }; assert_eq!(strategies.len(), 1); @@ -555,9 +624,9 @@ key = "/etc/fabro/tls/key.pem" ca = "/etc/fabro/tls/ca.pem" "#, ); - let mode = + let resolved = resolve_auth_mode_with_lookup(&file, empty_lookup).expect("mTLS config should resolve"); - let AuthMode::Strategies(strategies) = mode else { + let AuthMode::Strategies(strategies) = resolved.mode else { panic!("expected Strategies, got Disabled"); }; assert!(strategies.iter().any(|s| matches!(s, AuthStrategy::Mtls))); @@ -1009,11 +1078,12 @@ enabled = true .unwrap(); req.extensions_mut().insert(SessionCookie { login: "brynary".to_string(), + provider: "github".to_string(), name: "Brynary".to_string(), email: "b@example.com".to_string(), avatar_url: "https://example.com/avatar.png".to_string(), user_url: "https://github.com/brynary".to_string(), - github_id: 1, + provider_id: Some(1), exp: 9_999_999_999, }); @@ -1024,6 +1094,69 @@ enabled = true assert_eq!(body["auth_method"], "cookie"); } + #[tokio::test] + async fn dev_token_strategy_accepts_valid_bearer() { + let app = test_router(AuthMode::Strategies(vec![AuthStrategy::DevToken { + token: "fabro_dev_abababababababababababababababababababababababababababababababab" + .to_string(), + }])); + + let req = Request::builder() + .uri("/test") + .header( + "authorization", + "Bearer fabro_dev_abababababababababababababababababababababababababababababababab", + ) + .body(Body::empty()) + .unwrap(); + + let response = app.oneshot(req).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + } + + #[tokio::test] + async fn dev_token_strategy_rejects_wrong_bearer() { + let app = test_router(AuthMode::Strategies(vec![AuthStrategy::DevToken { + token: "fabro_dev_abababababababababababababababababababababababababababababababab" + .to_string(), + }])); + + let req = Request::builder() + .uri("/test") + .header( + "authorization", + "Bearer fabro_dev_cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd", + ) + .body(Body::empty()) + .unwrap(); + + let response = app.oneshot(req).await.unwrap(); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn dev_token_subject_extracts_dev_login() { + let app = subject_router(AuthMode::Strategies(vec![AuthStrategy::DevToken { + token: "fabro_dev_abababababababababababababababababababababababababababababababab" + .to_string(), + }])); + + let req = Request::builder() + .uri("/subject") + .header( + "authorization", + "Bearer fabro_dev_abababababababababababababababababababababababababababababababab", + ) + .body(Body::empty()) + .unwrap(); + + let response = app.oneshot(req).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = response_json(response).await; + assert_eq!(body["login"], "dev"); + assert_eq!(body["auth_method"], "dev_token"); + } + #[tokio::test] async fn empty_strategies_rejects() { let app = test_router(AuthMode::Strategies(vec![])); diff --git a/lib/crates/fabro-server/src/serve.rs b/lib/crates/fabro-server/src/serve.rs index 1dbaed0b4..d3c34975e 100644 --- a/lib/crates/fabro-server/src/serve.rs +++ b/lib/crates/fabro-server/src/serve.rs @@ -26,7 +26,7 @@ use tracing::{error, info, warn}; use crate::bind::{self, Bind, BindRequest}; use crate::github_webhooks::WebhookManager; -use crate::jwt_auth::{AuthMode, AuthStrategy, resolve_auth_mode_with_lookup}; +use crate::jwt_auth::{AuthMode, AuthStrategy, ResolvedAuth, resolve_auth_mode_with_lookup}; use crate::server::{ RouterOptions, build_app_state_with_path, build_router_with_options, reconcile_incomplete_runs_on_startup, shutdown_active_workers, spawn_scheduler, @@ -305,20 +305,24 @@ where let resolved_server_settings = resolve_server_settings(&effective_settings)?; let shared_settings = Arc::new(RwLock::new(effective_settings)); std::fs::create_dir_all(&data_dir)?; - let (auth_mode, client_auth, max_concurrent_runs) = { - let auth_mode = resolve_auth_mode_with_lookup(&resolved_server_settings, |name| { + let (resolved_auth, client_auth, max_concurrent_runs) = { + let resolved_auth = resolve_auth_mode_with_lookup(&resolved_server_settings, |name| { server_secrets.get(name) })?; let tls_present = matches!( resolved_server_settings.listen, ServerListenSettings::Tcp { ref tls, .. } if tls.is_some() ); - let client_auth = tls_present.then(|| client_auth_from_mode(&auth_mode)); + let client_auth = tls_present.then(|| client_auth_from_mode(&resolved_auth.mode)); let max_concurrent_runs = args .max_concurrent_runs .unwrap_or(resolved_server_settings.scheduler.max_concurrent_runs); - (auth_mode, client_auth, max_concurrent_runs) + (resolved_auth, client_auth, max_concurrent_runs) }; + let ResolvedAuth { + mode: auth_mode, + session_key_override, + } = resolved_auth; let web_enabled = router_web_enabled(&resolved_server_settings); let store_path = storage.store_dir(); @@ -339,7 +343,8 @@ where artifact_store, &vault_path, active_config_path, - matches!(&auth_mode, AuthMode::Disabled), + session_key_override, + true, )?; let reconciled = reconcile_incomplete_runs_on_startup(&state).await?; if reconciled > 0 { diff --git a/lib/crates/fabro-server/src/server.rs b/lib/crates/fabro-server/src/server.rs index babe221e7..b96660313 100644 --- a/lib/crates/fabro-server/src/server.rs +++ b/lib/crates/fabro-server/src/server.rs @@ -523,6 +523,7 @@ pub struct AppState { pub(crate) settings: Arc>, pub(crate) server_settings: RwLock>, pub(crate) config_path: PathBuf, + session_key_override: Option, pub(crate) local_daemon_mode: bool, shutting_down: AtomicBool, registry_factory_override: Option>, @@ -614,8 +615,10 @@ impl AppState { } pub(crate) fn session_key(&self) -> Option { - self.server_secret("SESSION_SECRET") + self.session_key_override.clone().or_else(|| { + self.server_secret("SESSION_SECRET") .map(|value| Key::derive_from(value.as_bytes())) + }) } pub(crate) fn github_credentials( @@ -2147,6 +2150,7 @@ pub fn create_app_state_with_settings_and_registry_factory( artifact_store, &test_secret_store_path(), test_config_path(), + None, false, ) .expect("test app state should build") @@ -2166,6 +2170,27 @@ pub fn create_app_state_with_options( ) } +#[cfg(test)] +pub(crate) fn create_test_app_state_with_session_key( + settings: SettingsLayer, + session_key_override: Option, + local_daemon_mode: bool, +) -> Arc { + let (store, artifact_store) = test_store_bundle(); + build_app_state_with_path( + Arc::new(RwLock::new(settings)), + None, + 5, + store, + artifact_store, + &test_secret_store_path(), + test_config_path(), + session_key_override, + local_daemon_mode, + ) + .expect("test app state should build") +} + fn test_store_bundle() -> (Arc, ArtifactStore) { let object_store: Arc = Arc::new(MemoryObjectStore::new()); let store = Arc::new(fabro_store::Database::new( @@ -2191,6 +2216,7 @@ pub fn create_app_state_with_store( artifact_store, &test_secret_store_path(), test_config_path(), + None, false, ) .expect("test app state should build") @@ -2204,6 +2230,7 @@ pub(crate) fn build_app_state_with_path( artifact_store: ArtifactStore, vault_path: &std::path::Path, config_path: PathBuf, + session_key_override: Option, local_daemon_mode: bool, ) -> anyhow::Result> { let vault = Arc::new(AsyncRwLock::new(Vault::load(vault_path.to_path_buf())?)); @@ -2266,6 +2293,7 @@ pub(crate) fn build_app_state_with_path( settings, server_settings: RwLock::new(resolved_server_settings), config_path, + session_key_override, local_daemon_mode, shutting_down: AtomicBool::new(false), registry_factory_override, diff --git a/lib/crates/fabro-server/src/web_auth.rs b/lib/crates/fabro-server/src/web_auth.rs index 64f907564..191c3884c 100644 --- a/lib/crates/fabro-server/src/web_auth.rs +++ b/lib/crates/fabro-server/src/web_auth.rs @@ -5,7 +5,7 @@ use axum::extract::{Query, State}; use axum::http::{HeaderMap, HeaderValue, StatusCode, header}; use axum::response::{IntoResponse, Redirect, Response}; use axum::routing::{get, post}; -use axum::{Json, Router}; +use axum::{Extension, Json, Router}; use base64::Engine as _; use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; use cookie::time::Duration; @@ -16,8 +16,10 @@ use serde::{Deserialize, Serialize}; use serde_json::json; use tracing::{debug, error, info, warn}; +use crate::jwt_auth::{AuthMode, AuthStrategy, dev_token_matches}; use crate::server::AppState; use crate::server_secrets::ServerSecrets; +use fabro_util::dev_token::validate_dev_token_format; pub const SESSION_COOKIE_NAME: &str = "__fabro_session"; const OAUTH_STATE_COOKIE_NAME: &str = "fabro_oauth_state"; @@ -25,11 +27,12 @@ const OAUTH_STATE_COOKIE_NAME: &str = "fabro_oauth_state"; #[derive(Clone, Debug, Serialize, Deserialize)] pub struct SessionCookie { pub login: String, + pub provider: String, pub name: String, pub email: String, pub avatar_url: String, pub user_url: String, - pub github_id: i64, + pub provider_id: Option, pub exp: i64, } @@ -39,6 +42,11 @@ struct OAuthCallbackParams { state: String, } +#[derive(Deserialize)] +struct DevTokenLoginRequest { + token: String, +} + #[derive(Deserialize)] struct SetupRegisterRequest { code: String, @@ -54,10 +62,15 @@ struct SetupStatusResponse { configured: bool, } +#[derive(Serialize)] +struct AuthConfigResponse { + methods: Vec, +} + #[derive(Serialize)] struct AuthMeResponse { user: SessionUser, - provider: &'static str, + provider: String, #[serde(rename = "demoMode")] demo_mode: bool, features: serde_json::Value, @@ -106,6 +119,7 @@ struct GitHubManifestConversion { pub fn routes() -> Router> { Router::new() + .route("/login/dev-token", post(login_dev_token)) .route("/login/github", get(login_github)) .route("/callback/github", get(callback_github)) .route("/logout", post(logout)) @@ -113,6 +127,7 @@ pub fn routes() -> Router> { pub fn api_routes() -> Router> { Router::new() + .route("/auth/config", get(auth_config)) .route("/auth/me", get(auth_me)) .route("/setup/register", post(setup_register)) .route("/setup/status", get(setup_status)) @@ -172,6 +187,93 @@ fn resolve_interp(value: &InterpString) -> anyhow::Result { .map_err(anyhow::Error::from) } +fn auth_methods_from_mode(auth_mode: &AuthMode) -> Vec { + match auth_mode { + AuthMode::Strategies(strategies) => { + if strategies + .iter() + .any(|strategy| matches!(strategy, AuthStrategy::DevToken { .. })) + { + vec!["dev-token".to_string()] + } else if strategies + .iter() + .any(|strategy| matches!(strategy, AuthStrategy::Cookie)) + { + vec!["github".to_string()] + } else { + Vec::new() + } + } + AuthMode::Disabled => Vec::new(), + } +} + +async fn login_dev_token( + State(state): State>, + Extension(auth_mode): Extension, + Json(payload): Json, +) -> Response { + let expected = match auth_mode { + AuthMode::Strategies(strategies) => strategies.iter().find_map(|strategy| match strategy { + AuthStrategy::DevToken { token } => Some(token.clone()), + _ => None, + }), + AuthMode::Disabled => None, + }; + let Some(expected) = expected else { + return json_response(StatusCode::UNAUTHORIZED, json!({"error": "Unauthorized"})); + }; + + if !validate_dev_token_format(&payload.token) || !dev_token_matches(&payload.token, &expected) + { + return json_response(StatusCode::UNAUTHORIZED, json!({"error": "Unauthorized"})); + } + + let Some(session_key) = state.session_key() else { + return json_response( + StatusCode::CONFLICT, + json!({"error": "SESSION_SECRET is not configured"}), + ); + }; + + let now = chrono::Utc::now(); + let session = SessionCookie { + login: "dev".to_string(), + provider: "dev-token".to_string(), + name: "Development User".to_string(), + email: "dev@localhost".to_string(), + avatar_url: "/logo.svg".to_string(), + user_url: String::new(), + provider_id: None, + exp: (now + chrono::Duration::days(30)).timestamp(), + }; + + let mut jar = CookieJar::new(); + jar.private_mut(&session_key).add( + Cookie::build(( + SESSION_COOKIE_NAME, + serde_json::to_string(&session).unwrap_or_default(), + )) + .path("/") + .http_only(true) + .same_site(SameSite::Lax) + .secure(false) + .max_age(Duration::days(30)) + .build(), + ); + + let mut response = Json(json!({ "ok": true })).into_response(); + append_jar_delta(response.headers_mut(), &jar); + response +} + +async fn auth_config(Extension(auth_mode): Extension) -> Response { + Json(AuthConfigResponse { + methods: auth_methods_from_mode(&auth_mode), + }) + .into_response() +} + async fn login_github(State(state): State>) -> Response { let settings = state.server_settings(); let Some(client_id) = settings.integrations.github.client_id.as_ref() else { @@ -409,13 +511,14 @@ async fn callback_github( .unwrap_or_default(); let now = chrono::Utc::now(); let session = SessionCookie { - login: profile.login.clone(), - name: profile.name.unwrap_or_else(|| profile.login.clone()), - email: primary_email, - avatar_url: profile.avatar_url, - user_url: format!("https://github.com/{}", profile.login), - github_id: profile.id, - exp: (now + chrono::Duration::days(30)).timestamp(), + login: profile.login.clone(), + provider: "github".to_string(), + name: profile.name.unwrap_or_else(|| profile.login.clone()), + email: primary_email, + avatar_url: profile.avatar_url, + user_url: format!("https://github.com/{}", profile.login), + provider_id: Some(profile.id), + exp: (now + chrono::Duration::days(30)).timestamp(), }; info!(login = %session.login, "OAuth login succeeded"); @@ -495,20 +598,26 @@ async fn auth_me(State(state): State>, headers: HeaderMap) -> Resp avatar_url: session.avatar_url, user_url: session.user_url, }, - provider: "github", + provider: session.provider, demo_mode, features: features_json(&settings), }) .into_response() } -async fn setup_status(State(state): State>) -> Response { +async fn setup_status( + State(state): State>, + Extension(auth_mode): Extension, +) -> Response { let configured = state .server_settings() .integrations .github .client_id - .is_some(); + .is_some() + || auth_methods_from_mode(&auth_mode) + .iter() + .any(|method| method == "dev-token"); Json(SetupStatusResponse { configured }).into_response() } @@ -772,7 +881,45 @@ fn merge_settings_keys( #[cfg(test)] mod tests { - use super::{GitHubManifestConversion, merge_settings_keys}; + use axum::Extension; + use axum::body::{Body, to_bytes}; + use axum::http::{Request, StatusCode, header}; + use axum_extra::extract::cookie::Key; + use fabro_types::settings::SettingsLayer; + use serde_json::{Value, json}; + use tower::ServiceExt; + + use super::{GitHubManifestConversion, api_routes, merge_settings_keys, read_private_session, routes}; + use crate::jwt_auth::{AuthMode, AuthStrategy}; + use crate::server; + + const DEV_TOKEN: &str = + "fabro_dev_abababababababababababababababababababababababababababababababab"; + + fn dev_token_auth_mode() -> AuthMode { + AuthMode::Strategies(vec![ + AuthStrategy::DevToken { + token: DEV_TOKEN.to_string(), + }, + AuthStrategy::Cookie, + ]) + } + + fn test_auth_router(key: &Key, auth_mode: AuthMode) -> axum::Router { + axum::Router::new() + .nest("/auth", routes()) + .nest("/api/v1", api_routes()) + .layer(Extension(auth_mode)) + .with_state(server::create_test_app_state_with_session_key( + SettingsLayer::default(), + Some(key.clone()), + false, + )) + } + + async fn response_json(response: axum::response::Response) -> Value { + serde_json::from_slice(&to_bytes(response.into_body(), usize::MAX).await.unwrap()).unwrap() + } fn sample_conversion() -> GitHubManifestConversion { GitHubManifestConversion { @@ -902,4 +1049,96 @@ name = "claude-sonnet" fabro_config::parse_settings_layer(&emitted) .expect("merged output should still parse as v2 after the edit"); } + + #[tokio::test] + async fn login_dev_token_mints_session_with_dev_token_provider() { + let key = Key::derive_from(b"web-auth-test-key-material-0123456789"); + let app = test_auth_router(&key, dev_token_auth_mode()); + + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/auth/login/dev-token") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(json!({ "token": DEV_TOKEN }).to_string())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + + let session_cookie = response + .headers() + .get(header::SET_COOKIE) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.split(';').next()) + .expect("session cookie should be set") + .to_string(); + + let mut cookie_headers = axum::http::HeaderMap::new(); + cookie_headers.insert( + header::COOKIE, + axum::http::HeaderValue::from_str(&session_cookie).unwrap(), + ); + let session = read_private_session(&cookie_headers, &key).expect("session should decode"); + assert_eq!(session.provider, "dev-token"); + + let response = app + .oneshot( + Request::builder() + .uri("/api/v1/auth/me") + .header(header::COOKIE, &session_cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = response_json(response).await; + assert_eq!(body["provider"], "dev-token"); + assert_eq!(body["user"]["login"], "dev"); + } + + #[tokio::test] + async fn login_dev_token_rejects_invalid_token() { + let key = Key::derive_from(b"web-auth-test-key-material-0123456789"); + let app = test_auth_router(&key, dev_token_auth_mode()); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/auth/login/dev-token") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from( + json!({ "token": "fabro_dev_cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd" }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn auth_config_returns_dev_token_method() { + let key = Key::derive_from(b"web-auth-test-key-material-0123456789"); + let app = test_auth_router(&key, dev_token_auth_mode()); + + let response = app + .oneshot( + Request::builder() + .uri("/api/v1/auth/config") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = response_json(response).await; + assert_eq!(body, json!({ "methods": ["dev-token"] })); + } } diff --git a/lib/crates/fabro-types/src/run.rs b/lib/crates/fabro-types/src/run.rs index 30650f451..e7eba1f5f 100644 --- a/lib/crates/fabro-types/src/run.rs +++ b/lib/crates/fabro-types/src/run.rs @@ -13,6 +13,7 @@ use crate::settings::SettingsLayer; pub enum RunAuthMethod { Disabled, Cookie, + DevToken, Jwt, Mtls, } diff --git a/lib/crates/fabro-util/src/dev_token.rs b/lib/crates/fabro-util/src/dev_token.rs new file mode 100644 index 000000000..dbca567bb --- /dev/null +++ b/lib/crates/fabro-util/src/dev_token.rs @@ -0,0 +1,106 @@ +use std::fs; +use std::io::Write as _; +use std::path::Path; + +use anyhow::{Result, anyhow}; +use rand::RngCore; +use rand::rngs::OsRng; + +pub const DEV_TOKEN_PREFIX: &str = "fabro_dev_"; +const DEV_TOKEN_RANDOM_BYTES: usize = 32; +const DEV_TOKEN_HEX_LEN: usize = DEV_TOKEN_RANDOM_BYTES * 2; +const DEV_TOKEN_LEN: usize = DEV_TOKEN_PREFIX.len() + DEV_TOKEN_HEX_LEN; + +pub fn generate_dev_token() -> String { + let mut bytes = [0_u8; DEV_TOKEN_RANDOM_BYTES]; + OsRng.fill_bytes(&mut bytes); + + let mut token = String::with_capacity(DEV_TOKEN_LEN); + token.push_str(DEV_TOKEN_PREFIX); + for byte in bytes { + use std::fmt::Write as _; + + let _ = write!(&mut token, "{byte:02x}"); + } + token +} + +pub fn validate_dev_token_format(token: &str) -> bool { + let Some(hex) = token.strip_prefix(DEV_TOKEN_PREFIX) else { + return false; + }; + + token.len() == DEV_TOKEN_LEN + && hex.len() == DEV_TOKEN_HEX_LEN + && hex.bytes().all(|byte| byte.is_ascii_hexdigit()) +} + +pub fn load_or_create_dev_token(path: &Path) -> Result { + match fs::read_to_string(path) { + Ok(contents) => { + let token = contents.trim().to_string(); + if validate_dev_token_format(&token) { + return Ok(token); + } + return Err(anyhow!("invalid dev token format in {}", path.display())); + } + Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} + Err(err) => return Err(err.into()), + } + + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + + let token = generate_dev_token(); + let temp_path = path.with_file_name(format!( + ".{}.tmp-{:x}", + path.file_name().and_then(|name| name.to_str()).unwrap_or("dev-token"), + rand::random::() + )); + write_private_token_file(&temp_path, &token)?; + fs::rename(&temp_path, path)?; + + Ok(token) +} + +pub fn write_dev_token(path: &Path, token: &str) -> Result<()> { + if !validate_dev_token_format(token) { + return Err(anyhow!("invalid dev token format for {}", path.display())); + } + + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + + let temp_path = path.with_file_name(format!( + ".{}.tmp-{:x}", + path.file_name().and_then(|name| name.to_str()).unwrap_or("dev-token"), + rand::random::() + )); + write_private_token_file(&temp_path, token)?; + fs::rename(&temp_path, path)?; + Ok(()) +} + +fn write_private_token_file(path: &Path, contents: &str) -> Result<()> { + #[cfg(unix)] + let mut file = { + use std::fs::OpenOptions; + use std::os::unix::fs::OpenOptionsExt; + + OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(0o600) + .open(path)? + }; + + #[cfg(not(unix))] + let mut file = std::fs::File::create(path)?; + + file.write_all(contents.as_bytes())?; + file.sync_all()?; + Ok(()) +} diff --git a/lib/crates/fabro-util/src/home.rs b/lib/crates/fabro-util/src/home.rs index ac32d50c2..b3f3d40db 100644 --- a/lib/crates/fabro-util/src/home.rs +++ b/lib/crates/fabro-util/src/home.rs @@ -17,6 +17,10 @@ impl Home { return Self::new(root); } + if let Some(home) = std::env::var_os("HOME") { + return Self::new(PathBuf::from(home).join(".fabro")); + } + let root = dirs::home_dir().map_or_else(|| PathBuf::from(".fabro"), |home| home.join(".fabro")); Self::new(root) @@ -57,6 +61,11 @@ impl Home { self.root.join("fabro.sock") } + #[must_use] + pub fn dev_token_path(&self) -> PathBuf { + self.root.join("dev-token") + } + #[must_use] pub fn workflows_dir(&self) -> PathBuf { self.root.join("workflows") @@ -76,6 +85,9 @@ impl Home { #[cfg(test)] mod tests { use super::Home; + use std::sync::{LazyLock, Mutex}; + + static ENV_LOCK: LazyLock> = LazyLock::new(|| Mutex::new(())); #[test] fn accessors_are_relative_to_root() { @@ -106,6 +118,10 @@ mod tests { home.socket_path(), std::path::Path::new("/tmp/fabro-home/fabro.sock") ); + assert_eq!( + home.dev_token_path(), + std::path::Path::new("/tmp/fabro-home/dev-token") + ); assert_eq!( home.workflows_dir(), std::path::Path::new("/tmp/fabro-home/workflows") @@ -116,4 +132,17 @@ mod tests { ); assert_eq!(home.tmp_dir(), std::path::Path::new("/tmp/fabro-home/tmp")); } + + #[test] + fn from_env_prefers_home_env_when_fabro_home_is_absent() { + let _guard = ENV_LOCK.lock().unwrap(); + std::env::remove_var("FABRO_HOME"); + std::env::set_var("HOME", "/tmp/fabro-home-env"); + + let home = Home::from_env(); + + std::env::remove_var("HOME"); + + assert_eq!(home.root(), std::path::Path::new("/tmp/fabro-home-env/.fabro")); + } } diff --git a/lib/crates/fabro-util/src/lib.rs b/lib/crates/fabro-util/src/lib.rs index 56f200f0d..d4c68414f 100644 --- a/lib/crates/fabro-util/src/lib.rs +++ b/lib/crates/fabro-util/src/lib.rs @@ -1,5 +1,6 @@ pub mod backoff; pub mod check_report; +pub mod dev_token; pub mod env; pub mod home; pub mod json; diff --git a/lib/crates/fabro-util/tests/dev_token.rs b/lib/crates/fabro-util/tests/dev_token.rs new file mode 100644 index 000000000..6d177a7b9 --- /dev/null +++ b/lib/crates/fabro-util/tests/dev_token.rs @@ -0,0 +1,99 @@ +use std::fs; + +use fabro_util::Home; +use fabro_util::dev_token::{ + DEV_TOKEN_PREFIX, generate_dev_token, load_or_create_dev_token, validate_dev_token_format, +}; + +#[test] +fn generate_has_correct_prefix_and_length() { + let token = generate_dev_token(); + + assert!(token.starts_with(DEV_TOKEN_PREFIX)); + assert_eq!(token.len(), 74); + assert!(validate_dev_token_format(&token)); +} + +#[test] +fn generate_is_unique() { + assert_ne!(generate_dev_token(), generate_dev_token()); +} + +#[test] +fn validate_format_accepts_valid() { + let token = format!("{DEV_TOKEN_PREFIX}{}", "ab".repeat(32)); + + assert!(validate_dev_token_format(&token)); +} + +#[test] +fn validate_format_rejects_short() { + let token = format!("{DEV_TOKEN_PREFIX}{}", "ab".repeat(31)); + + assert!(!validate_dev_token_format(&token)); +} + +#[test] +fn validate_format_rejects_non_hex() { + let token = format!("{DEV_TOKEN_PREFIX}{}zz", "ab".repeat(31)); + + assert!(!validate_dev_token_format(&token)); +} + +#[test] +fn validate_format_rejects_wrong_prefix() { + let token = format!("fabro_nope_{}", "ab".repeat(32)); + + assert!(!validate_dev_token_format(&token)); +} + +#[test] +fn load_or_create_creates_file() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("dev-token"); + + let token = load_or_create_dev_token(&path).unwrap(); + + assert!(validate_dev_token_format(&token)); + assert_eq!(fs::read_to_string(&path).unwrap(), token); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + + let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600); + } +} + +#[test] +fn load_or_create_reads_existing() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("dev-token"); + let token = format!("{DEV_TOKEN_PREFIX}{}", "cd".repeat(32)); + fs::write(&path, &token).unwrap(); + + let loaded = load_or_create_dev_token(&path).unwrap(); + + assert_eq!(loaded, token); +} + +#[test] +fn load_or_create_rejects_malformed_file() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("dev-token"); + fs::write(&path, "not-a-token").unwrap(); + + let error = load_or_create_dev_token(&path).unwrap_err(); + + assert!(error.to_string().contains("invalid")); +} + +#[test] +fn home_dev_token_path_is_relative_to_root() { + let home = Home::new("/tmp/fabro-home"); + + assert_eq!( + home.dev_token_path(), + std::path::Path::new("/tmp/fabro-home/dev-token") + ); +}