mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-08-28 05:27:41 +00:00
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:
parent
7d01c4e42b
commit
a6775a051c
19 changed files with 1132 additions and 116 deletions
|
|
@ -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" }),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
})
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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![]));
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"] }));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ use crate::settings::SettingsLayer;
|
|||
pub enum RunAuthMethod {
|
||||
Disabled,
|
||||
Cookie,
|
||||
DevToken,
|
||||
Jwt,
|
||||
Mtls,
|
||||
}
|
||||
|
|
|
|||
106
lib/crates/fabro-util/src/dev_token.rs
Normal file
106
lib/crates/fabro-util/src/dev_token.rs
Normal 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(())
|
||||
}
|
||||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
pub mod backoff;
|
||||
pub mod check_report;
|
||||
pub mod dev_token;
|
||||
pub mod env;
|
||||
pub mod home;
|
||||
pub mod json;
|
||||
|
|
|
|||
99
lib/crates/fabro-util/tests/dev_token.rs
Normal file
99
lib/crates/fabro-util/tests/dev_token.rs
Normal 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")
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue