feat(auth): add dev-token local server auth

Replace local no-auth startup with a shared dev-token flow for CLI-managed
servers. This provisions and validates dev tokens, preserves dev-token
provenance through browser sessions, and teaches local CLI and web clients how
to authenticate against local Unix and TCP servers.
This commit is contained in:
Bryan Helmkamp 2026-04-13 07:04:47 -04:00
parent 7d01c4e42b
commit a6775a051c
19 changed files with 1132 additions and 116 deletions

View file

@ -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" }),
});
});
});

View file

@ -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;

View file

@ -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<string | null>(null);
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
setError(null);
try {
await loginDevToken(token);
navigate("/start");
} catch {
setError("Invalid dev token");
}
}
return (
<AuthLayout>
<h1 className="text-center text-lg font-semibold text-fg">
Sign in to Fabro
</h1>
<p className="mt-2 text-center text-sm text-fg-3">
Authenticate with your GitHub account to continue.
{isDevToken
? "Paste your dev token to continue."
: "Authenticate with your GitHub account to continue."}
</p>
<div className="mt-6">
<a
href="/auth/login/github"
className="flex w-full items-center justify-center gap-2 rounded-lg bg-teal-500 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-teal-300"
>
<GitHubMark />
Sign in with GitHub
</a>
{isDevToken ? (
<form className="space-y-3" onSubmit={handleSubmit}>
<input
type="password"
value={token}
onChange={(event) => 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"
/>
<button
type="submit"
className="flex w-full items-center justify-center rounded-lg bg-teal-500 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-teal-300"
>
Sign in with Dev Token
</button>
<p className="text-center text-xs text-fg-muted">
Paste the dev token from your terminal or <code>cat ~/.fabro/dev-token</code>
</p>
{error ? (
<p className="text-center text-sm text-red-500">{error}</p>
) : null}
</form>
) : (
<a
href="/auth/login/github"
className="flex w-full items-center justify-center gap-2 rounded-lg bg-teal-500 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-teal-300"
>
<GitHubMark />
Sign in with GitHub
</a>
)}
</div>
</AuthLayout>
);

View file

@ -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(),
})
},

View file

@ -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<PathBuf>,
pub started_at: DateTime<Utc>,
}
@ -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(),
}
}

View file

@ -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
// ---------------------------------------------------------------------------

View file

@ -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<ServerS
if !path.is_absolute() {
bail!("server target must be an http(s) URL or absolute Unix socket path");
}
connect_unix_socket_api_client_bundle(path).await
connect_unix_socket_api_client_bundle(path, None).await
}
}
@ -116,10 +121,19 @@ async fn connect_api_client_bundle(storage_dir: &Path) -> Result<ServerStoreClie
.await
.with_context(|| format!("Failed to start fabro server for {}", storage_dir.display()))?;
match bind {
Bind::Unix(path) => 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<fabro_http::HttpClient> {
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<String> {
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<String> {
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<String> {
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<fabro_http::HttpClientBuilder> {
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<fabro_http::HttpClientBuilder> {
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<ServerStoreClient> {
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<ServerStoreClient> {
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<ServerStoreClient> {
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<ServerStoreClient> {
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<Mutex<()>> = 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<E>(err: &progenitor_client::Error<E>) -> bool
where
E: serde::Serialize + std::fmt::Debug,

View file

@ -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")

View file

@ -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();

View file

@ -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 {

View file

@ -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<Sha256>;
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<DecodingKey>,
validation: Arc<Validation>,
@ -64,6 +64,12 @@ pub enum AuthMode {
Disabled,
}
#[derive(Clone, Debug)]
pub struct ResolvedAuth {
pub mode: AuthMode,
pub session_key_override: Option<Key>,
}
/// 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<String> {
/// 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<String> {
///
/// Walks the v2 `server.auth.api.{jwt,mtls}` subtree and
/// `server.auth.web.allowed_usernames`.
pub fn resolve_auth_mode(settings: &ResolvedServerSettings) -> Result<AuthMode> {
pub fn resolve_auth_mode(settings: &ResolvedServerSettings) -> Result<ResolvedAuth> {
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<F>(
settings: &ResolvedServerSettings,
lookup: F,
) -> Result<AuthMode>
) -> Result<ResolvedAuth>
where
F: Fn(&str) -> Option<String>,
{
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<S: Send + Sync> FromRequestParts<S> 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::<SessionCookie>() {
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![]));

View file

@ -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 {

View file

@ -523,6 +523,7 @@ pub struct AppState {
pub(crate) settings: Arc<RwLock<SettingsLayer>>,
pub(crate) server_settings: RwLock<Arc<ResolvedServerSettings>>,
pub(crate) config_path: PathBuf,
session_key_override: Option<Key>,
pub(crate) local_daemon_mode: bool,
shutting_down: AtomicBool,
registry_factory_override: Option<Box<RegistryFactoryOverride>>,
@ -614,8 +615,10 @@ impl AppState {
}
pub(crate) fn session_key(&self) -> Option<Key> {
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<Key>,
local_daemon_mode: bool,
) -> Arc<AppState> {
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<Database>, ArtifactStore) {
let object_store: Arc<dyn object_store::ObjectStore> = 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<Key>,
local_daemon_mode: bool,
) -> anyhow::Result<Arc<AppState>> {
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,

View file

@ -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<i64>,
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<String>,
}
#[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<Arc<AppState>> {
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<Arc<AppState>> {
pub fn api_routes() -> Router<Arc<AppState>> {
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<String> {
.map_err(anyhow::Error::from)
}
fn auth_methods_from_mode(auth_mode: &AuthMode) -> Vec<String> {
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<Arc<AppState>>,
Extension(auth_mode): Extension<AuthMode>,
Json(payload): Json<DevTokenLoginRequest>,
) -> 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<AuthMode>) -> Response {
Json(AuthConfigResponse {
methods: auth_methods_from_mode(&auth_mode),
})
.into_response()
}
async fn login_github(State(state): State<Arc<AppState>>) -> 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<Arc<AppState>>, 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<Arc<AppState>>) -> Response {
async fn setup_status(
State(state): State<Arc<AppState>>,
Extension(auth_mode): Extension<AuthMode>,
) -> 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"] }));
}
}

View file

@ -13,6 +13,7 @@ use crate::settings::SettingsLayer;
pub enum RunAuthMethod {
Disabled,
Cookie,
DevToken,
Jwt,
Mtls,
}

View file

@ -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<String> {
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::<u64>()
));
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::<u64>()
));
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(())
}

View file

@ -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<Mutex<()>> = 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"));
}
}

View file

@ -1,5 +1,6 @@
pub mod backoff;
pub mod check_report;
pub mod dev_token;
pub mod env;
pub mod home;
pub mod json;

View file

@ -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")
);
}