feat(auth): store dev tokens in auth store

Persist dev-token credentials in auth.json alongside OAuth entries so CLI targets resolve credentials consistently across TCP and Unix socket flows.

Move install-time token minting to runtime storage, add auth login --dev-token, and refresh the embedded SPA after updating the stale dev-token hint.
This commit is contained in:
Bryan Helmkamp 2026-04-24 16:02:36 -04:00
parent db071953c0
commit f537222372
No known key found for this signature in database
28 changed files with 678 additions and 403 deletions

View file

@ -113,8 +113,7 @@ function DevTokenForm({
</button>
{showLocation ? (
<p className="text-center text-xs text-fg-muted">
Paste the dev token from your terminal or{" "}
<code className="font-mono text-fg-3">cat ~/.fabro/dev-token</code>.
Paste the dev token from your server terminal or install output.
</p>
) : null}
</form>

View file

@ -1452,6 +1452,10 @@ pub(crate) struct AuthLoginArgs {
#[command(flatten)]
pub(crate) server: ServerTargetArgs,
/// Log in with a dev-token instead of browser OAuth
#[arg(long, conflicts_with_all = ["no_browser", "timeout"])]
pub(crate) dev_token: Option<String>,
/// Print the browser URL instead of opening it automatically
#[arg(long)]
pub(crate) no_browser: bool,

View file

@ -2,9 +2,10 @@ use std::time::Duration;
use anyhow::{Context as _, Result, bail};
use chrono::{DateTime, Utc};
use fabro_client::{AuthEntry, AuthStore, StoredSubject};
use fabro_client::{AuthEntry, AuthStore, DevTokenEntry, OAuthEntry, StoredSubject};
use fabro_http::header::CONTENT_TYPE;
use fabro_util::browser;
use fabro_util::dev_token::validate_dev_token_format;
use fabro_util::printer::Printer;
use serde::Deserialize;
use tokio::time::timeout;
@ -36,6 +37,22 @@ pub(super) async fn login_command(args: AuthLoginArgs, base_ctx: &CommandContext
base_ctx.require_no_json_override()?;
let printer = base_ctx.printer();
if let Some(token) = args.dev_token.as_ref() {
if !validate_dev_token_format(token) {
bail!("invalid dev-token format");
}
let target = user_config::resolve_server_target(&args.server, base_ctx.user_settings())?;
AuthStore::default().put(
&target,
AuthEntry::DevToken(DevTokenEntry {
token: token.clone(),
logged_in_at: Utc::now(),
}),
)?;
fabro_util::printerr!(printer, "Logged in to {} with dev-token", target);
return Ok(());
}
#[cfg(not(unix))]
{
let _ = (args, printer);
@ -80,7 +97,7 @@ pub(super) async fn login_command(args: AuthLoginArgs, base_ctx: &CommandContext
};
let tokens = exchange_cli_token(&target, &code, &pkce.verifier, &redirect_uri).await?;
let entry = AuthEntry {
let entry = OAuthEntry {
access_token: tokens.access_token,
access_token_expires_at: tokens.access_token_expires_at,
refresh_token: tokens.refresh_token,
@ -95,7 +112,7 @@ pub(super) async fn login_command(args: AuthLoginArgs, base_ctx: &CommandContext
logged_in_at: Utc::now(),
};
let summary = identity_summary(&entry.subject);
AuthStore::default().put(&target, entry)?;
AuthStore::default().put(&target, AuthEntry::OAuth(entry))?;
fabro_util::printerr!(printer, "Logged in to {} as {}", target, summary);
Ok(())
}
@ -199,7 +216,7 @@ fn login_failure_message(error_code: &str, error_description: Option<&str>) -> S
"GitHub session required. Complete sign-in in the browser and try again.".to_string()
}
"github_not_configured" => {
"The fabro server does not have GitHub login enabled. Ask the operator to enable it or use a dev-token.".to_string()
"This server uses dev-token auth. Copy the token from the server and run: `fabro auth login --dev-token <TOKEN>`".to_string()
}
"access_denied" => "Authorization denied.".to_string(),
"unauthorized" => "Login not permitted.".to_string(),
@ -280,7 +297,7 @@ mod tests {
"github_not_configured",
Some("GitHub authentication is not enabled on this server")
),
"The fabro server does not have GitHub login enabled. Ask the operator to enable it or use a dev-token."
"This server uses dev-token auth. Copy the token from the server and run: `fabro auth login --dev-token <TOKEN>`"
);
assert_eq!(
login_failure_message("server_error", Some("SESSION_SECRET is not configured")),

View file

@ -1,5 +1,5 @@
use anyhow::{Result, bail};
use fabro_client::{AuthEntry, AuthStore};
use fabro_client::{AuthEntry, AuthStore, OAuthEntry};
use fabro_http::header::AUTHORIZATION;
use crate::args::AuthLogoutArgs;
@ -21,8 +21,10 @@ pub(super) async fn logout_command(args: AuthLogoutArgs, base_ctx: &CommandConte
let mut warnings = Vec::new();
for (target, entry) in entries {
if let Err(error) = revoke_remote_session(&target, &entry).await {
warnings.push(format_warning(&target, &error.to_string()));
if let AuthEntry::OAuth(entry) = &entry {
if let Err(error) = revoke_remote_session(&target, entry).await {
warnings.push(format_warning(&target, &error.to_string()));
}
}
store.remove(&target)?;
}
@ -40,15 +42,17 @@ pub(super) async fn logout_command(args: AuthLogoutArgs, base_ctx: &CommandConte
return Ok(());
};
if let Err(error) = revoke_remote_session(&target, &entry).await {
fabro_util::printerr!(printer, "{}", format_warning(&target, &error.to_string()));
if let AuthEntry::OAuth(entry) = &entry {
if let Err(error) = revoke_remote_session(&target, entry).await {
fabro_util::printerr!(printer, "{}", format_warning(&target, &error.to_string()));
}
}
store.remove(&target)?;
fabro_util::printerr!(printer, "Logged out from {}.", target);
Ok(())
}
async fn revoke_remote_session(target: &ServerTarget, entry: &AuthEntry) -> Result<()> {
async fn revoke_remote_session(target: &ServerTarget, entry: &OAuthEntry) -> Result<()> {
let (http_client, base_url) = target.build_public_http_client()?;
let response = http_client
.post(format!("{base_url}/auth/cli/logout"))

View file

@ -1,8 +1,9 @@
use anyhow::Result;
use chrono::{DateTime, Utc};
use fabro_client::{AuthEntry, AuthStore};
use fabro_client::{AuthEntry, AuthStore, OAuthEntry};
use fabro_static::EnvVars;
use fabro_util::dev_token::{read_dev_token_file, validate_dev_token_format};
use fabro_util::dev_token::validate_dev_token_format;
use fabro_util::printer::Printer;
use serde::Serialize;
use crate::args::AuthStatusArgs;
@ -20,23 +21,32 @@ enum OAuthState {
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
struct StatusRow {
server: String,
oauth_state: OAuthState,
access_token_expires_at: DateTime<Utc>,
refresh_token_expires_at: DateTime<Utc>,
logged_in_at: DateTime<Utc>,
login: String,
name: String,
email: String,
idp_issuer: String,
idp_subject: String,
#[serde(tag = "kind")]
enum StatusRow {
#[serde(rename = "oauth")]
OAuth {
server: String,
oauth_state: OAuthState,
access_token_expires_at: DateTime<Utc>,
refresh_token_expires_at: DateTime<Utc>,
logged_in_at: DateTime<Utc>,
login: String,
name: String,
email: String,
idp_issuer: String,
idp_subject: String,
},
#[serde(rename = "dev-token")]
DevToken {
server: String,
logged_in_at: DateTime<Utc>,
},
}
#[derive(Serialize)]
struct StatusOutput {
servers: Vec<StatusRow>,
dev_token: &'static str,
servers: Vec<StatusRow>,
env_dev_token: &'static str,
}
pub(super) fn status_command(args: &AuthStatusArgs, ctx: &CommandContext) -> Result<()> {
@ -49,7 +59,7 @@ pub(super) fn status_command(args: &AuthStatusArgs, ctx: &CommandContext) -> Res
} else {
all_rows(&store, now)?
};
let dev_token = if load_dev_token_if_available() {
let env_dev_token = if load_env_dev_token_if_available() {
"active"
} else {
"not_set"
@ -58,14 +68,14 @@ pub(super) fn status_command(args: &AuthStatusArgs, ctx: &CommandContext) -> Res
if ctx.explicit_json_requested() {
print_json_pretty(&StatusOutput {
servers: rows,
dev_token,
env_dev_token,
})?;
return Ok(());
}
if rows.is_empty() {
fabro_util::printerr!(printer, "Not logged in to any servers.");
fabro_util::printerr!(printer, "Dev token: {dev_token}");
print_env_dev_token_status(env_dev_token, printer);
return Ok(());
}
@ -73,44 +83,65 @@ pub(super) fn status_command(args: &AuthStatusArgs, ctx: &CommandContext) -> Res
if index > 0 {
fabro_util::printerr!(printer, "");
}
fabro_util::printerr!(printer, "{}", row.server);
fabro_util::printerr!(
printer,
" OAuth: {} as {}",
human_state(row.oauth_state),
row.login
);
fabro_util::printerr!(
printer,
" Name: {}",
if row.name.is_empty() {
"(not set)"
} else {
row.name.as_str()
match row {
StatusRow::OAuth {
server,
oauth_state,
access_token_expires_at,
refresh_token_expires_at,
login,
name,
email,
..
} => {
fabro_util::printerr!(printer, "{server}");
fabro_util::printerr!(
printer,
" OAuth: {} as {}",
human_state(*oauth_state),
login
);
fabro_util::printerr!(
printer,
" Name: {}",
if name.is_empty() {
"(not set)"
} else {
name.as_str()
}
);
fabro_util::printerr!(
printer,
" Email: {}",
if email.is_empty() {
"(not set)"
} else {
email.as_str()
}
);
fabro_util::printerr!(
printer,
" Access expires: {}",
access_token_expires_at.to_rfc3339()
);
fabro_util::printerr!(
printer,
" Refresh expires: {}",
refresh_token_expires_at.to_rfc3339()
);
}
);
fabro_util::printerr!(
printer,
" Email: {}",
if row.email.is_empty() {
"(not set)"
} else {
row.email.as_str()
StatusRow::DevToken {
server,
logged_in_at,
} => {
fabro_util::printerr!(printer, "{server}");
fabro_util::printerr!(printer, " Auth: dev-token");
fabro_util::printerr!(printer, " Logged in: {}", logged_in_at.to_rfc3339());
}
);
fabro_util::printerr!(
printer,
" Access expires: {}",
row.access_token_expires_at.to_rfc3339()
);
fabro_util::printerr!(
printer,
" Refresh expires: {}",
row.refresh_token_expires_at.to_rfc3339()
);
}
}
fabro_util::printerr!(printer, "");
fabro_util::printerr!(printer, "Dev token: {dev_token}");
print_env_dev_token_status(env_dev_token, printer);
Ok(())
}
@ -135,21 +166,27 @@ fn filter_rows(
}
fn status_row(target: &ServerTarget, entry: AuthEntry, now: DateTime<Utc>) -> StatusRow {
StatusRow {
server: target.to_string(),
oauth_state: oauth_state(&entry, now),
access_token_expires_at: entry.access_token_expires_at,
refresh_token_expires_at: entry.refresh_token_expires_at,
logged_in_at: entry.logged_in_at,
login: entry.subject.login,
name: entry.subject.name,
email: entry.subject.email,
idp_issuer: entry.subject.idp_issuer,
idp_subject: entry.subject.idp_subject,
match entry {
AuthEntry::OAuth(entry) => StatusRow::OAuth {
server: target.to_string(),
oauth_state: oauth_state(&entry, now),
access_token_expires_at: entry.access_token_expires_at,
refresh_token_expires_at: entry.refresh_token_expires_at,
logged_in_at: entry.logged_in_at,
login: entry.subject.login,
name: entry.subject.name,
email: entry.subject.email,
idp_issuer: entry.subject.idp_issuer,
idp_subject: entry.subject.idp_subject,
},
AuthEntry::DevToken(entry) => StatusRow::DevToken {
server: target.to_string(),
logged_in_at: entry.logged_in_at,
},
}
}
fn oauth_state(entry: &AuthEntry, now: DateTime<Utc>) -> OAuthState {
fn oauth_state(entry: &OAuthEntry, now: DateTime<Utc>) -> OAuthState {
if entry.access_token_expires_at > now {
OAuthState::Active
} else if entry.refresh_token_expires_at > now {
@ -159,6 +196,17 @@ fn oauth_state(entry: &AuthEntry, now: DateTime<Utc>) -> OAuthState {
}
}
fn print_env_dev_token_status(env_dev_token: &str, printer: Printer) {
if env_dev_token == "active" {
fabro_util::printerr!(
printer,
"FABRO_DEV_TOKEN: active (overrides persisted credentials)"
);
} else {
fabro_util::printerr!(printer, "FABRO_DEV_TOKEN: not_set");
}
}
fn human_state(state: OAuthState) -> &'static str {
match state {
OAuthState::Active => "active",
@ -171,24 +219,23 @@ fn human_state(state: OAuthState) -> &'static str {
clippy::disallowed_methods,
reason = "Auth status reports whether the documented dev-token env source is configured."
)]
fn load_dev_token_if_available() -> bool {
fn load_env_dev_token_if_available() -> bool {
let env_token = std::env::var(EnvVars::FABRO_DEV_TOKEN)
.ok()
.filter(|token| validate_dev_token_format(token));
env_token.is_some()
|| read_dev_token_file(&fabro_util::Home::from_env().dev_token_path()).is_some()
}
#[cfg(test)]
mod tests {
use chrono::Duration;
use fabro_client::{AuthEntry, StoredSubject};
use fabro_client::{OAuthEntry, StoredSubject};
use super::{OAuthState, human_state, oauth_state};
fn entry(access_offset_secs: i64, refresh_offset_secs: i64) -> AuthEntry {
fn entry(access_offset_secs: i64, refresh_offset_secs: i64) -> OAuthEntry {
let now = chrono::Utc::now();
AuthEntry {
OAuthEntry {
access_token: "access".to_string(),
access_token_expires_at: now + Duration::seconds(access_offset_secs),
refresh_token: "refresh".to_string(),

View file

@ -21,6 +21,7 @@ use dialoguer::theme::ColorfulTheme;
use dialoguer::{MultiSelect, Select};
use fabro_api::types::{CreateSecretRequest, SecretType as ApiSecretType};
use fabro_auth::{AuthCredential, AuthMethod, codex_oauth_config, credential_id_for};
use fabro_client::{AuthEntry, AuthStore, DevTokenEntry, ServerTarget};
use fabro_config::bind::Bind;
use fabro_config::daemon::ServerDaemon;
use fabro_config::user::{SETTINGS_CONFIG_FILENAME, default_storage_dir};
@ -1773,6 +1774,7 @@ async fn run_install_inner(args: &InstallArgs, ctx: &CommandContext) -> Result<(
let install_server_settings = fabro_config::ServerSettingsBuilder::from_toml(&settings_toml)?;
// Secrets and auth material
let mut dev_token_for_auth_store = None;
{
let session_secret = session_secret::generate_session_secret();
fabro_util::printerr!(
@ -1787,15 +1789,11 @@ async fn run_install_inner(args: &InstallArgs, ctx: &CommandContext) -> Result<(
.methods
.contains(&ServerAuthMethod::DevToken)
{
let token = dev_token::read_or_mint_dev_token_for_install(
&fabro_util::Home::from_env().dev_token_path(),
)?;
dev_token::write_dev_token(
&Storage::new(&storage_dir)
.runtime_directory()
.dev_token_path(),
&token,
)?;
let dev_token_path = Storage::new(&storage_dir)
.runtime_directory()
.dev_token_path();
let token = dev_token::read_or_mint_dev_token_for_install(&dev_token_path)?;
dev_token_for_auth_store = Some(token.clone());
fabro_util::printerr!(
printer,
" {} Development token generated",
@ -1825,6 +1823,22 @@ async fn run_install_inner(args: &InstallArgs, ctx: &CommandContext) -> Result<(
server_was_running,
)
.await?;
if let Some(token) = dev_token_for_auth_store {
let target = ServerTarget::http_url(&args.web_url)?;
if let Err(err) = AuthStore::default().put(
&target,
AuthEntry::DevToken(DevTokenEntry {
token,
logged_in_at: chrono::Utc::now(),
}),
) {
fabro_util::printerr!(
printer,
" {} Installed successfully, but failed to save CLI auth: {err}",
s.yellow.apply_to("Warning:")
);
}
}
if let Err(err) = write_artifact_store_metadata(&install_server_settings, FABRO_VERSION).await {
fabro_util::printerr!(
printer,

View file

@ -3,14 +3,16 @@ pub(crate) mod start;
pub(crate) mod status;
pub(crate) mod stop;
use std::sync::Arc;
use std::time::Duration;
use anyhow::Result;
use base64::Engine as _;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use fabro_client::{AuthEntry, AuthStore, DevTokenEntry, ServerTarget};
use fabro_config::bind::{self, Bind, BindRequest};
use fabro_config::user::{active_settings_path, default_storage_dir};
use fabro_server::install::{self, InstallAppState};
use fabro_server::install::{self, InstallAppState, InstallFinishHook, InstallFinishInfo};
use fabro_server::serve::{self, ServeArgs};
use fabro_static::EnvVars;
use fabro_util::browser;
@ -207,7 +209,8 @@ async fn run_install_mode(bootstrap: InstallBootstrap, printer: Printer) -> Resu
bootstrap.token,
&bootstrap.storage_dir,
&bootstrap.config_path,
);
)
.with_finish_hook(persist_install_dev_token_hook());
install::serve_install_command(bootstrap.bind_request, state, move |bind| {
announce_install_mode(bind, &token, styles, printer);
Ok(())
@ -215,6 +218,23 @@ async fn run_install_mode(bootstrap: InstallBootstrap, printer: Printer) -> Resu
.await
}
fn persist_install_dev_token_hook() -> InstallFinishHook {
Arc::new(|info: &InstallFinishInfo| {
let Some(token) = &info.dev_token else {
return Ok(());
};
let target = ServerTarget::http_url(&info.canonical_url)?;
AuthStore::default().put(
&target,
AuthEntry::DevToken(DevTokenEntry {
token: token.clone(),
logged_in_at: chrono::Utc::now(),
}),
)?;
Ok(())
})
}
fn announce_install_mode(bind: &Bind, token: &str, styles: &Styles, printer: Printer) {
info!(
bind = %bind,

View file

@ -1,45 +1,32 @@
use std::net::IpAddr;
use std::path::Path;
use std::sync::Arc;
use std::time::Duration;
use anyhow::{Context as _, Result, anyhow, bail};
use fabro_client::{
AuthStore, Credential, CredentialFallback, OAuthSession, ServerTarget, TransportConnector,
AuthEntry, AuthStore, Credential, OAuthSession, ServerTarget, TransportConnector,
apply_bearer_token_auth,
};
pub(crate) use fabro_client::{Client, RunEventStream};
use fabro_config::Storage;
use fabro_config::bind::Bind;
use fabro_static::EnvVars;
pub(crate) use fabro_types::RunProjection;
use fabro_types::UserSettings;
use fabro_util::dev_token;
use fabro_util::dev_token::validate_dev_token_format;
use fabro_util::{Home, dev_token};
use tokio::time::sleep;
use crate::args::ServerTargetArgs;
use crate::commands::server::start;
use crate::user_config::{self, cli_http_client_builder};
#[derive(Debug)]
struct CliDevTokenFallback;
impl CredentialFallback for CliDevTokenFallback {
fn resolve(&self) -> Option<Credential> {
load_cli_dev_token().map(Credential::DevToken)
}
}
fn refreshable_oauth(
target: &ServerTarget,
credential: Option<&Credential>,
) -> Option<OAuthSession> {
if matches!(credential, Some(Credential::OAuth(_))) {
let session = OAuthSession::new(target.clone(), AuthStore::default());
if local_dev_token_fallback(target) {
return Some(session.with_fallback(Arc::new(CliDevTokenFallback)));
}
return Some(session);
return Some(OAuthSession::new(target.clone(), AuthStore::default()));
}
None
}
@ -92,22 +79,34 @@ async fn connect_managed_unix_socket_api_client_bundle(
active_config_path: &Path,
) -> Result<Client> {
let target = ServerTarget::unix_socket_path(path)?;
let credential = resolve_target_credential(&target, local_dev_token_fallback(&target))?;
let oauth_session = refreshable_oauth(&target, credential.as_ref());
let bearer_token = credential.as_ref().map(Credential::bearer_token);
let runtime_token_path = Storage::new(storage_dir)
.runtime_directory()
.dev_token_path();
let pre_spawn_credential = resolve_target_credential(&target)?
.or_else(|| dev_token::read_dev_token_file(&runtime_token_path).map(Credential::DevToken));
let pre_spawn_bearer = pre_spawn_credential.as_ref().map(Credential::bearer_token);
let http_client = if let Ok(http_client) =
try_connect_unix_socket_http_client(path, true, bearer_token).await
let (http_client, credential) = if let Ok(http_client) =
try_connect_unix_socket_http_client(path, pre_spawn_bearer).await
{
http_client
(http_client, pre_spawn_credential)
} else {
start::ensure_server_running_on_socket(path, active_config_path, storage_dir)
.await
.with_context(|| format!("Failed to start fabro server for {}", path.display()))?;
connect_unix_socket_http_client(path, true, bearer_token)
let post_spawn_credential = match resolve_target_credential(&target)? {
Some(credential) => Some(credential),
None => Some(Credential::DevToken(
wait_for_runtime_dev_token(&runtime_token_path).await?,
)),
};
let post_spawn_bearer = post_spawn_credential.as_ref().map(Credential::bearer_token);
let http_client = connect_unix_socket_http_client(path, post_spawn_bearer)
.await
.with_context(|| format!("Failed to connect to fabro server at {}", path.display()))?
.with_context(|| format!("Failed to connect to fabro server at {}", path.display()))?;
(http_client, post_spawn_credential)
};
let oauth_session = refreshable_oauth(&target, credential.as_ref());
build_client(
target,
@ -127,12 +126,16 @@ async fn connect_local_api_client_bundle(
.with_context(|| format!("Failed to start fabro server for {}", storage_dir.display()))?;
match bind {
Bind::Unix(path) => {
let http_client = connect_unix_socket_http_client(&path, true, None).await?;
let runtime_token_path = Storage::new(storage_dir)
.runtime_directory()
.dev_token_path();
let token = wait_for_runtime_dev_token(&runtime_token_path).await?;
let http_client = connect_unix_socket_http_client(&path, Some(&token)).await?;
Ok(Client::from_http_client("http://fabro", http_client))
}
Bind::Tcp(addr) => {
let target = ServerTarget::http_url(format!("http://{addr}"))?;
let credential = resolve_local_tcp_credential(&target)?;
let credential = resolve_target_credential(&target)?;
let oauth_session = refreshable_oauth(&target, credential.as_ref());
build_client(target, credential, oauth_session, None).await
}
@ -140,7 +143,7 @@ async fn connect_local_api_client_bundle(
}
async fn connect_target_api_client_bundle(target: &ServerTarget) -> Result<Client> {
let credential = resolve_target_credential(target, local_dev_token_fallback(target))?;
let credential = resolve_target_credential(target)?;
let oauth_session = refreshable_oauth(target, credential.as_ref());
build_client(target.clone(), credential, oauth_session, None).await
}
@ -204,15 +207,6 @@ fn connect_cli_target_transport(
Ok((http_client, "http://fabro".to_string()))
}
fn local_dev_token_fallback(target: &ServerTarget) -> bool {
target.is_unix_socket()
}
fn load_cli_dev_token() -> Option<String> {
let env_token = process_env_var(EnvVars::FABRO_DEV_TOKEN);
load_cli_dev_token_from_sources(env_token.as_deref(), &Home::from_env())
}
#[expect(
clippy::disallowed_methods,
reason = "Server client authentication supports the documented local dev-token env source."
@ -221,38 +215,29 @@ fn process_env_var(name: &str) -> Option<String> {
std::env::var(name).ok()
}
fn load_cli_dev_token_from_sources(env_token: Option<&str>, home: &Home) -> Option<String> {
if let Some(token) = env_token.filter(|token| validate_dev_token_format(token)) {
return Some(token.to_owned());
}
dev_token::read_dev_token_file(&home.dev_token_path())
}
async fn wait_for_cli_dev_token() -> Result<String> {
async fn wait_for_runtime_dev_token(path: &Path) -> Result<String> {
let deadline = std::time::Instant::now() + Duration::from_secs(5);
while std::time::Instant::now() < deadline {
if let Some(token) = load_cli_dev_token() {
if let Some(token) = dev_token::read_dev_token_file(path) {
return Ok(token);
}
sleep(Duration::from_millis(50)).await;
}
bail!("local CLI dev token did not become available");
bail!(
"runtime dev token did not become available at {}",
path.display()
);
}
async fn build_authed_unix_socket_http_client(
fn build_authed_unix_socket_http_client(
path: &Path,
wait_for_cli_dev_token_fallback: bool,
bearer_token: Option<&str>,
) -> Result<fabro_http::HttpClient> {
let builder = cli_http_client_builder().unix_socket(path).no_proxy();
let builder = if let Some(token) = bearer_token {
apply_bearer_token_auth(builder, token)?
} else if wait_for_cli_dev_token_fallback {
let token = wait_for_cli_dev_token().await?;
apply_bearer_token_auth(builder, &token)?
} else {
builder
};
@ -272,37 +257,21 @@ fn build_unix_socket_probe_client(path: &Path) -> Result<fabro_http::HttpClient>
async fn try_connect_unix_socket_http_client(
path: &Path,
wait_for_cli_dev_token_fallback: bool,
bearer_token: Option<&str>,
) -> Result<fabro_http::HttpClient> {
check_server_ready(&build_unix_socket_probe_client(path)?).await?;
build_authed_unix_socket_http_client(path, wait_for_cli_dev_token_fallback, bearer_token).await
build_authed_unix_socket_http_client(path, bearer_token)
}
async fn connect_unix_socket_http_client(
path: &Path,
wait_for_cli_dev_token_fallback: bool,
bearer_token: Option<&str>,
) -> Result<fabro_http::HttpClient> {
wait_for_server_ready(&build_unix_socket_probe_client(path)?).await?;
build_authed_unix_socket_http_client(path, wait_for_cli_dev_token_fallback, bearer_token).await
build_authed_unix_socket_http_client(path, bearer_token)
}
fn resolve_oauth_credential(
target: &ServerTarget,
store: &AuthStore,
now: chrono::DateTime<chrono::Utc>,
) -> Result<Option<Credential>> {
if let Some(entry) = store.get(target)? {
if entry.access_token_expires_at > now || entry.refresh_token_expires_at > now {
return Ok(Some(Credential::OAuth(entry)));
}
}
Ok(None)
}
fn resolve_local_tcp_credential_with_store(
fn resolve_target_credential_with_store(
target: &ServerTarget,
env_token: Option<&str>,
store: &AuthStore,
@ -312,43 +281,24 @@ fn resolve_local_tcp_credential_with_store(
return Ok(Some(Credential::DevToken(token.to_owned())));
}
resolve_oauth_credential(target, store, now)
let Some(entry) = store.get(target)? else {
return Ok(None);
};
match entry {
AuthEntry::DevToken(entry) => Ok(Some(Credential::DevToken(entry.token))),
AuthEntry::OAuth(entry)
if entry.access_token_expires_at > now || entry.refresh_token_expires_at > now =>
{
Ok(Some(Credential::OAuth(entry)))
}
AuthEntry::OAuth(_) => Ok(None),
}
}
fn resolve_local_tcp_credential(target: &ServerTarget) -> Result<Option<Credential>> {
fn resolve_target_credential(target: &ServerTarget) -> Result<Option<Credential>> {
let env_token = process_env_var(EnvVars::FABRO_DEV_TOKEN);
let store = AuthStore::default();
resolve_local_tcp_credential_with_store(
target,
env_token.as_deref(),
&store,
chrono::Utc::now(),
)
}
fn resolve_target_credential(
target: &ServerTarget,
allow_local_dev_token_fallback: bool,
) -> Result<Option<Credential>> {
let env_token = process_env_var(EnvVars::FABRO_DEV_TOKEN);
let store = AuthStore::default();
if let Some(credential) = resolve_local_tcp_credential_with_store(
target,
env_token.as_deref(),
&store,
chrono::Utc::now(),
)? {
return Ok(Some(credential));
}
if allow_local_dev_token_fallback {
return Ok(
load_cli_dev_token_from_sources(env_token.as_deref(), &Home::from_env())
.map(Credential::DevToken),
);
}
Ok(None)
resolve_target_credential_with_store(target, env_token.as_deref(), &store, chrono::Utc::now())
}
#[expect(
@ -394,56 +344,20 @@ async fn wait_for_server_ready(http_client: &fabro_http::HttpClient) -> Result<(
}
#[cfg(test)]
#[expect(
clippy::disallowed_methods,
reason = "server-client tests stage local dev-token fixtures with sync std::fs::write"
)]
mod tests {
use chrono::{Duration as ChronoDuration, Utc};
use fabro_client::{AuthEntry, StoredSubject};
use fabro_client::{AuthEntry, DevTokenEntry, OAuthEntry, StoredSubject};
use httpmock::Method::{GET, POST};
use serde_json::json;
use super::*;
#[test]
fn load_cli_dev_token_prefers_env() {
let temp_home = tempfile::tempdir().unwrap();
let token_path = temp_home.path().join("dev-token");
std::fs::write(
&token_path,
"fabro_dev_abababababababababababababababababababababababababababababababab",
)
.unwrap();
let token = load_cli_dev_token_from_sources(
Some("fabro_dev_cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd"),
&Home::new(temp_home.path()),
);
assert_eq!(
token.as_deref(),
Some("fabro_dev_cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd")
);
}
#[test]
fn load_cli_dev_token_reads_home_file() {
let temp_home = tempfile::tempdir().unwrap();
let token = "fabro_dev_abababababababababababababababababababababababababababababababab";
std::fs::write(temp_home.path().join("dev-token"), token).unwrap();
let loaded = load_cli_dev_token_from_sources(None, &Home::new(temp_home.path()));
assert_eq!(loaded.as_deref(), Some(token));
}
#[test]
fn resolve_local_tcp_credential_prefers_valid_env_token() {
let target = ServerTarget::http_url("http://127.0.0.1:32276").unwrap();
let store = AuthStore::new(tempfile::tempdir().unwrap().path().join("auth.json"));
let credential = resolve_local_tcp_credential_with_store(
let credential = resolve_target_credential_with_store(
&target,
Some("fabro_dev_cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd"),
&store,
@ -454,6 +368,28 @@ mod tests {
assert!(matches!(credential, Some(Credential::DevToken(_))));
}
#[test]
fn resolve_target_credential_uses_persisted_dev_token_entry() {
let dir = tempfile::tempdir().unwrap();
let target = ServerTarget::http_url("http://127.0.0.1:32276").unwrap();
let store = AuthStore::new(dir.path().join("auth.json"));
let token = "fabro_dev_abababababababababababababababababababababababababababababababab";
store
.put(
&target,
AuthEntry::DevToken(DevTokenEntry {
token: token.to_string(),
logged_in_at: Utc::now(),
}),
)
.unwrap();
let credential =
resolve_target_credential_with_store(&target, None, &store, Utc::now()).unwrap();
assert!(matches!(credential, Some(Credential::DevToken(found)) if found == token));
}
#[test]
fn resolve_local_tcp_credential_uses_live_oauth_entry() {
let dir = tempfile::tempdir().unwrap();
@ -470,8 +406,7 @@ mod tests {
)
.unwrap();
let credential =
resolve_local_tcp_credential_with_store(&target, None, &store, now).unwrap();
let credential = resolve_target_credential_with_store(&target, None, &store, now).unwrap();
assert!(matches!(credential, Some(Credential::OAuth(_))));
}
@ -492,8 +427,7 @@ mod tests {
)
.unwrap();
let credential =
resolve_local_tcp_credential_with_store(&target, None, &store, now).unwrap();
let credential = resolve_target_credential_with_store(&target, None, &store, now).unwrap();
assert!(matches!(credential, Some(Credential::OAuth(_))));
}
@ -514,31 +448,11 @@ mod tests {
)
.unwrap();
let credential =
resolve_local_tcp_credential_with_store(&target, None, &store, now).unwrap();
let credential = resolve_target_credential_with_store(&target, None, &store, now).unwrap();
assert!(credential.is_none());
}
#[test]
fn resolve_local_tcp_credential_does_not_fallback_to_home_dev_token() {
let temp_home = tempfile::tempdir().unwrap();
let token = "fabro_dev_abababababababababababababababababababababababababababababababab";
std::fs::write(temp_home.path().join("dev-token"), token).unwrap();
let target = ServerTarget::http_url("http://127.0.0.1:32276").unwrap();
let store = AuthStore::new(temp_home.path().join("auth.json"));
assert_eq!(
load_cli_dev_token_from_sources(None, &Home::new(temp_home.path())).as_deref(),
Some(token)
);
assert!(
resolve_local_tcp_credential_with_store(&target, None, &store, Utc::now())
.unwrap()
.is_none()
);
}
#[test]
fn bypasses_proxy_for_loopback_http_targets() {
assert!(should_bypass_proxy_for_http_target(
@ -556,18 +470,6 @@ mod tests {
));
}
#[test]
fn explicit_http_targets_do_not_allow_local_dev_token_fallback() {
let target = ServerTarget::http_url("https://fabro.example.com/api/v1").unwrap();
assert!(!local_dev_token_fallback(&target));
}
#[test]
fn unix_socket_targets_keep_local_dev_token_fallback() {
let target = ServerTarget::unix_socket_path("/tmp/fabro.sock").unwrap();
assert!(local_dev_token_fallback(&target));
}
#[tokio::test]
async fn connect_server_target_with_bearer_sends_worker_bearer_token() {
let server = httpmock::MockServer::start();
@ -653,7 +555,7 @@ mod tests {
access_token_expires_at: chrono::DateTime<chrono::Utc>,
refresh_token_expires_at: chrono::DateTime<chrono::Utc>,
) -> AuthEntry {
AuthEntry {
AuthEntry::OAuth(OAuthEntry {
access_token: "access-token".to_string(),
access_token_expires_at,
refresh_token: "refresh-token".to_string(),
@ -666,6 +568,6 @@ mod tests {
email: "octocat@example.com".to_string(),
},
logged_in_at: Utc::now(),
}
})
}
}

View file

@ -1,5 +1,8 @@
use fabro_test::{fabro_snapshot, test_context};
const DEV_TOKEN: &str =
"fabro_dev_abababababababababababababababababababababababababababababababab";
#[test]
fn help() {
let context = test_context!();
@ -44,19 +47,75 @@ fn login_help() {
Usage: fabro auth login [OPTIONS]
Options:
--json Output as JSON [env: FABRO_JSON=]
--server <SERVER> Fabro server target: http(s) URL or absolute Unix socket path [env: FABRO_SERVER=]
--debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=]
--no-browser Print the browser URL instead of opening it automatically
--no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true]
--timeout <TIMEOUT> Timeout in seconds waiting for the browser flow to complete [default: 300]
--quiet Suppress non-essential output [env: FABRO_QUIET=]
--verbose Enable verbose output [env: FABRO_VERBOSE=]
-h, --help Print help
--json Output as JSON [env: FABRO_JSON=]
--server <SERVER> Fabro server target: http(s) URL or absolute Unix socket path [env: FABRO_SERVER=]
--debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=]
--dev-token <DEV_TOKEN> Log in with a dev-token instead of browser OAuth
--no-browser Print the browser URL instead of opening it automatically
--no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true]
--quiet Suppress non-essential output [env: FABRO_QUIET=]
--timeout <TIMEOUT> Timeout in seconds waiting for the browser flow to complete [default: 300]
--verbose Enable verbose output [env: FABRO_VERBOSE=]
-h, --help Print help
----- stderr -----
");
}
#[test]
#[expect(
clippy::disallowed_methods,
reason = "Integration test inspects the CLI auth store fixture synchronously."
)]
fn login_with_dev_token_writes_auth_store_entry() {
let context = test_context!();
let mut cmd = context.command();
cmd.args([
"auth",
"login",
"--server",
"http://127.0.0.1:32276",
"--dev-token",
DEV_TOKEN,
]);
fabro_snapshot!(context.filters(), cmd, @"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Logged in to http://127.0.0.1:32276 with dev-token
");
let auth_file = context.home_dir.join(".fabro").join("auth.json");
let auth: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(auth_file).unwrap()).unwrap();
let entry = &auth["servers"]["http://127.0.0.1:32276"];
assert_eq!(entry["kind"], "dev-token");
assert_eq!(entry["token"], DEV_TOKEN);
}
#[test]
fn login_with_invalid_dev_token_fails_before_writing_auth_store() {
let context = test_context!();
let mut cmd = context.command();
cmd.args([
"auth",
"login",
"--server",
"http://127.0.0.1:32276",
"--dev-token",
"not-a-dev-token",
]);
fabro_snapshot!(context.filters(), cmd, @"
success: false
exit_code: 1
----- stdout -----
----- stderr -----
error: invalid dev-token format
");
assert!(!context.home_dir.join(".fabro").join("auth.json").exists());
}
#[test]
fn status_help() {
let context = test_context!();
@ -81,3 +140,21 @@ fn status_help() {
----- stderr -----
");
}
#[test]
fn status_json_reports_env_dev_token_separately() {
let context = test_context!();
let mut cmd = context.command();
cmd.args(["auth", "status", "--json"])
.env("FABRO_DEV_TOKEN", DEV_TOKEN);
fabro_snapshot!(context.filters(), cmd, @"
success: true
exit_code: 0
----- stdout -----
{
\"servers\": [],
\"env_dev_token\": \"active\"
}
----- stderr -----
");
}

View file

@ -410,6 +410,7 @@ fn write_auth_entry(
serde_json::to_string_pretty(&json!({
"servers": {
canonical: {
"kind": "oauth",
"access_token": access_token,
"access_token_expires_at": (now + ChronoDuration::minutes(10)).to_rfc3339(),
"refresh_token": refresh_token,

View file

@ -69,7 +69,7 @@ fn auth_status_ignores_json_output_format_from_home_config() {
);
let stderr = output_stderr(&output);
assert!(stderr.contains("Not logged in to any servers."));
assert!(stderr.contains("Dev token:"));
assert!(stderr.contains("FABRO_DEV_TOKEN:"));
}
#[test]

View file

@ -14,17 +14,15 @@ const TEST_SESSION_SECRET: &str =
fn provision_local_server_auth(context: &fabro_test::TestContext, storage_dir: &std::path::Path) {
context.ensure_home_server_auth_methods();
let server_env_path = Storage::new(storage_dir).runtime_directory().env_path();
let runtime_directory = Storage::new(storage_dir).runtime_directory();
let server_env_path = runtime_directory.env_path();
envfile::merge_env_file(&server_env_path, [
("FABRO_DEV_TOKEN", TEST_DEV_TOKEN),
("SESSION_SECRET", TEST_SESSION_SECRET),
])
.expect("merging server auth into server.env");
dev_token::write_dev_token(
&context.home_dir.join(".fabro").join("dev-token"),
TEST_DEV_TOKEN,
)
.expect("writing home dev-token");
dev_token::write_dev_token(&runtime_directory.dev_token_path(), TEST_DEV_TOKEN)
.expect("writing runtime dev-token");
}
#[test]

View file

@ -32,15 +32,16 @@ fn write_dev_token_server_settings(config_path: &std::path::Path, rest: &str) {
.expect("writing dev-token server settings fixture");
}
fn provision_dev_token_auth(home_dir: &std::path::Path, storage_dir: &std::path::Path) {
let server_env_path = Storage::new(storage_dir).runtime_directory().env_path();
fn provision_dev_token_auth(_home_dir: &std::path::Path, storage_dir: &std::path::Path) {
let runtime_directory = Storage::new(storage_dir).runtime_directory();
let server_env_path = runtime_directory.env_path();
envfile::merge_env_file(&server_env_path, [
("FABRO_DEV_TOKEN", TEST_DEV_TOKEN),
("SESSION_SECRET", TEST_SESSION_SECRET),
])
.expect("merging server auth into server.env");
dev_token::write_dev_token(&home_dir.join(".fabro").join("dev-token"), TEST_DEV_TOKEN)
.expect("writing home dev-token");
dev_token::write_dev_token(&runtime_directory.dev_token_path(), TEST_DEV_TOKEN)
.expect("writing runtime dev-token");
}
#[derive(Clone, Copy, Debug)]

View file

@ -17,7 +17,7 @@ use std::process::{Child, Command, Stdio};
use std::time::{Duration, Instant};
use chrono::{Duration as ChronoDuration, Utc};
use fabro_client::{AuthEntry, AuthStore, ServerTarget, StoredSubject};
use fabro_client::{AuthEntry, AuthStore, OAuthEntry, ServerTarget, StoredSubject};
use fabro_config::{Storage, envfile};
use fabro_store::EventEnvelope;
use fabro_test::{apply_test_isolation, expect_reqwest_json, isolated_storage_dir, test_context};
@ -180,20 +180,23 @@ fn write_submitter_auth(home_dir: &Path, target: &str, access_token: &str) {
let target = ServerTarget::http_url(target).unwrap();
let now = Utc::now();
auth_store
.put(&target, AuthEntry {
access_token: access_token.to_string(),
access_token_expires_at: now + ChronoDuration::minutes(10),
refresh_token: "refresh-unused".to_string(),
refresh_token_expires_at: now + ChronoDuration::days(30),
subject: StoredSubject {
idp_issuer: "https://github.com".to_string(),
idp_subject: "12345".to_string(),
login: "octocat".to_string(),
name: "The Octocat".to_string(),
email: "octocat@example.com".to_string(),
},
logged_in_at: now,
})
.put(
&target,
AuthEntry::OAuth(OAuthEntry {
access_token: access_token.to_string(),
access_token_expires_at: now + ChronoDuration::minutes(10),
refresh_token: "refresh-unused".to_string(),
refresh_token_expires_at: now + ChronoDuration::days(30),
subject: StoredSubject {
idp_issuer: "https://github.com".to_string(),
idp_subject: "12345".to_string(),
login: "octocat".to_string(),
name: "The Octocat".to_string(),
email: "octocat@example.com".to_string(),
},
logged_in_at: now,
}),
)
.unwrap();
}

View file

@ -151,11 +151,6 @@ fn auth_refresh_failure_clears_local_session() {
let context = test_context!();
let server = MockServer::start();
let target = server_target(&server);
context.write_home(
".fabro/dev-token",
"fabro_dev_abababababababababababababababababababababababababababababababab\n",
);
server.mock(|when, then| {
when.method(POST)
.path("/auth/cli/token")

View file

@ -10,9 +10,8 @@ fn start_status_stop_lifecycle() {
".fabro/settings.toml",
"[server.auth]\nmethods = [\"dev-token\"]\n",
);
let server_env_path = fabro_config::Storage::new(&storage_dir)
.runtime_directory()
.env_path();
let runtime_directory = fabro_config::Storage::new(&storage_dir).runtime_directory();
let server_env_path = runtime_directory.env_path();
fabro_config::envfile::merge_env_file(&server_env_path, [
(
"FABRO_DEV_TOKEN",
@ -25,7 +24,7 @@ fn start_status_stop_lifecycle() {
])
.unwrap();
fabro_util::dev_token::write_dev_token(
&context.home_dir.join(".fabro").join("dev-token"),
&runtime_directory.dev_token_path(),
"fabro_dev_abababababababababababababababababababababababababababababababab",
)
.unwrap();

View file

@ -31,7 +31,16 @@ pub struct StoredSubject {
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AuthEntry {
#[serde(tag = "kind")]
pub enum AuthEntry {
#[serde(rename = "oauth")]
OAuth(OAuthEntry),
#[serde(rename = "dev-token")]
DevToken(DevTokenEntry),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct OAuthEntry {
pub access_token: String,
pub access_token_expires_at: DateTime<Utc>,
pub refresh_token: String,
@ -40,6 +49,12 @@ pub struct AuthEntry {
pub logged_in_at: DateTime<Utc>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DevTokenEntry {
pub token: String,
pub logged_in_at: DateTime<Utc>,
}
#[derive(Debug, Error)]
pub enum AuthStoreError {
#[allow(
@ -364,14 +379,18 @@ mod tests {
use chrono::Duration;
use fabro_static::EnvVars;
use super::{AuthEntry, AuthStore, StoredSubject, key_for_target};
use super::{AuthEntry, AuthStore, DevTokenEntry, OAuthEntry, StoredSubject, key_for_target};
#[cfg(unix)]
use super::{LockError, classify_lock_error};
use crate::target::ServerTarget;
fn entry(login: &str) -> AuthEntry {
AuthEntry::OAuth(oauth_entry(login))
}
fn oauth_entry(login: &str) -> OAuthEntry {
let now = chrono::Utc::now();
AuthEntry {
OAuthEntry {
access_token: format!("access-{login}"),
access_token_expires_at: now + Duration::minutes(10),
refresh_token: format!("refresh-{login}"),
@ -401,9 +420,58 @@ mod tests {
store.put(&target, entry("octocat")).unwrap();
let saved = store.get(&target).unwrap().unwrap();
let AuthEntry::OAuth(saved) = saved else {
panic!("expected OAuth entry");
};
assert_eq!(saved.subject.login, "octocat");
}
#[cfg(unix)]
#[test]
fn round_trips_dev_token_entry() {
let temp = tempfile::tempdir().unwrap();
let store = AuthStore::new(temp.path().join("auth.json"));
let target = https_target("https://fabro.example.com");
let now = chrono::Utc::now();
store
.put(
&target,
AuthEntry::DevToken(DevTokenEntry {
token:
"fabro_dev_abababababababababababababababababababababababababababababababab"
.to_string(),
logged_in_at: now,
}),
)
.unwrap();
let saved = store.get(&target).unwrap().unwrap();
let AuthEntry::DevToken(saved) = saved else {
panic!("expected dev-token entry");
};
assert_eq!(
saved.token,
"fabro_dev_abababababababababababababababababababababababababababababababab"
);
assert_eq!(saved.logged_in_at, now);
}
#[test]
fn serializes_explicit_variant_kinds() {
let oauth = serde_json::to_value(AuthEntry::OAuth(oauth_entry("octocat"))).unwrap();
assert_eq!(oauth["kind"], "oauth");
let dev_token = serde_json::to_value(AuthEntry::DevToken(DevTokenEntry {
token:
"fabro_dev_abababababababababababababababababababababababababababababababab"
.to_string(),
logged_in_at: chrono::Utc::now(),
}))
.unwrap();
assert_eq!(dev_token["kind"], "dev-token");
}
#[cfg(unix)]
#[test]
fn round_trips_loopback_http_entry() {
@ -414,6 +482,9 @@ mod tests {
store.put(&target, entry("alice")).unwrap();
let saved = store.get(&target).unwrap().unwrap();
let AuthEntry::OAuth(saved) = saved else {
panic!("expected OAuth entry");
};
assert_eq!(saved.subject.login, "alice");
}
@ -429,6 +500,9 @@ mod tests {
store.put(&target, entry("unix")).unwrap();
let saved = store.get(&target).unwrap().unwrap();
let AuthEntry::OAuth(saved) = saved else {
panic!("expected OAuth entry");
};
assert_eq!(saved.subject.login, "unix");
}
@ -507,6 +581,9 @@ mod tests {
}
let saved = store.get(&target).unwrap().unwrap();
let AuthEntry::OAuth(saved) = saved else {
panic!("expected OAuth entry");
};
assert!(matches!(saved.subject.login.as_str(), "alice" | "bob"));
}

View file

@ -29,7 +29,7 @@ use crate::error::{
};
use crate::session::OAuthSession;
use crate::target::ServerTarget;
use crate::{AuthEntry, StoredSubject, sse};
use crate::{AuthEntry, OAuthEntry, StoredSubject, sse};
type TransportFuture = BoxFuture<'static, Result<(fabro_http::HttpClient, String)>>;
@ -361,7 +361,14 @@ impl Client {
self.rebuild_with_fallback(oauth_session).await?;
return Err(session_expired());
};
if entry.refresh_token_expires_at <= chrono::Utc::now() {
let oauth_entry = match entry {
AuthEntry::DevToken(entry) => {
self.rebuild_client(Some(entry.token)).await?;
return Ok(());
}
AuthEntry::OAuth(entry) => entry,
};
if oauth_entry.refresh_token_expires_at <= chrono::Utc::now() {
oauth_session.auth_store.remove(&oauth_session.target)?;
self.rebuild_with_fallback(oauth_session).await?;
return Err(session_expired());
@ -369,7 +376,10 @@ impl Client {
let (http_client, base_url) = oauth_session.target.build_public_http_client()?;
let response = http_client
.post(format!("{base_url}/auth/cli/refresh"))
.header(AUTHORIZATION, format!("Bearer {}", entry.refresh_token))
.header(
AUTHORIZATION,
format!("Bearer {}", oauth_entry.refresh_token),
)
.send()
.await?;
@ -378,7 +388,7 @@ impl Client {
.json::<CliTokenResponse>()
.await
.context("failed to parse CLI auth refresh response")?;
let entry = AuthEntry {
let entry = OAuthEntry {
access_token: tokens.access_token.clone(),
access_token_expires_at: tokens.access_token_expires_at,
refresh_token: tokens.refresh_token.clone(),
@ -390,11 +400,11 @@ impl Client {
name: tokens.subject.name,
email: tokens.subject.email,
},
logged_in_at: entry.logged_in_at,
logged_in_at: oauth_entry.logged_in_at,
};
oauth_session
.auth_store
.put(&oauth_session.target, entry.clone())
.put(&oauth_session.target, AuthEntry::OAuth(entry.clone()))
.context("failed to persist refreshed CLI auth tokens")?;
self.rebuild_client(Some(entry.access_token)).await?;
return Ok(());
@ -1480,6 +1490,8 @@ fn add_pr_upgrade_hint(err: anyhow::Error) -> anyhow::Error {
#[cfg(test)]
mod tests {
use std::sync::Arc;
use chrono::Duration as ChronoDuration;
use fabro_util::exit;
use httpmock::Method::POST;
@ -1489,12 +1501,12 @@ mod tests {
use tokio::net::TcpListener;
use super::*;
use crate::AuthStore;
use crate::error::tag_with_failure;
use crate::{AuthStore, DevTokenEntry};
fn oauth_entry(login: &str) -> AuthEntry {
fn oauth_entry(login: &str) -> OAuthEntry {
let now = chrono::Utc::now();
AuthEntry {
OAuthEntry {
access_token: format!("access-{login}"),
access_token_expires_at: now + ChronoDuration::minutes(10),
refresh_token: format!("refresh-{login}"),
@ -1549,7 +1561,9 @@ mod tests {
});
let target = ServerTarget::http_url(format!("http://localhost:{port}")).unwrap();
let entry = oauth_entry("octocat");
auth_store.put(&target, entry.clone()).unwrap();
auth_store
.put(&target, AuthEntry::OAuth(entry.clone()))
.unwrap();
let client = Client::builder()
.target(target.clone())
@ -1562,6 +1576,9 @@ mod tests {
client.refresh_access_token("access-octocat").await.unwrap();
let refreshed = auth_store.get(&target).unwrap().unwrap();
let AuthEntry::OAuth(refreshed) = refreshed else {
panic!("expected OAuth entry");
};
assert_eq!(refreshed.access_token, "access-refreshed");
assert_eq!(refreshed.refresh_token, "refresh-refreshed");
server.abort();
@ -1574,7 +1591,9 @@ mod tests {
let auth_store = AuthStore::new(temp.path().join("auth.json"));
let target = ServerTarget::http_url(server.base_url()).unwrap();
let entry = oauth_entry("octocat");
auth_store.put(&target, entry.clone()).unwrap();
auth_store
.put(&target, AuthEntry::OAuth(entry.clone()))
.unwrap();
let client = Client::builder()
.target(target.clone())
@ -1594,6 +1613,58 @@ mod tests {
(temp, client, auth_store, target)
}
#[tokio::test]
async fn refresh_access_token_reinstalls_stored_dev_token_without_refresh_request() {
let server = MockServer::start();
let refresh_mock = server.mock(|when, then| {
when.method(POST).path("/auth/cli/refresh");
then.status(500);
});
let temp = tempfile::tempdir().unwrap();
let auth_store = AuthStore::new(temp.path().join("auth.json"));
let target = ServerTarget::http_url(server.base_url()).unwrap();
let old_token =
"fabro_dev_cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd";
let stored_token =
"fabro_dev_abababababababababababababababababababababababababababababababab";
auth_store
.put(
&target,
AuthEntry::DevToken(DevTokenEntry {
token: stored_token.to_string(),
logged_in_at: chrono::Utc::now(),
}),
)
.unwrap();
let seen_tokens = Arc::new(std::sync::Mutex::new(Vec::new()));
let seen_tokens_for_connector = Arc::clone(&seen_tokens);
let base_url = server.base_url();
let client = Client::builder()
.target(target.clone())
.credential(Credential::DevToken(old_token.to_string()))
.oauth_session(OAuthSession::new(target.clone(), auth_store.clone()))
.transport_connector(TransportConnector::new(move |bearer_token| {
let seen_tokens = Arc::clone(&seen_tokens_for_connector);
let base_url = base_url.clone();
async move {
seen_tokens.lock().unwrap().push(bearer_token);
Ok((fabro_http::test_http_client().unwrap(), base_url))
}
}))
.connect()
.await
.unwrap();
client.refresh_access_token(old_token).await.unwrap();
assert_eq!(refresh_mock.calls(), 0);
assert_eq!(*seen_tokens.lock().unwrap(), vec![
Some(old_token.to_string()),
Some(stored_token.to_string()),
]);
}
#[tokio::test]
async fn refresh_access_token_classifies_expired_refresh_tokens() {
let server = MockServer::start();

View file

@ -1,12 +1,12 @@
use std::fmt;
use crate::AuthEntry;
use crate::OAuthEntry;
#[derive(Clone)]
pub enum Credential {
DevToken(String),
Worker(String),
OAuth(AuthEntry),
OAuth(OAuthEntry),
}
pub trait CredentialFallback: Send + Sync {

View file

@ -8,7 +8,9 @@ pub mod session;
pub mod sse;
pub mod target;
pub use auth_store::{AuthEntry, AuthStore, AuthStoreError, LockError, StoredSubject};
pub use auth_store::{
AuthEntry, AuthStore, AuthStoreError, DevTokenEntry, LockError, OAuthEntry, StoredSubject,
};
pub use client::{Client, RunEventStream, TransportConnector, apply_bearer_token_auth};
pub use credential::{Credential, CredentialFallback};
pub use error::{

View file

@ -130,7 +130,7 @@ async fn start(
&redirect_uri,
state_token,
"github_not_configured",
"GitHub authentication is not enabled on this server",
"This server uses dev-token auth. Copy the token from the server and run: `fabro auth login --dev-token <TOKEN>`",
);
}

View file

@ -58,8 +58,16 @@ pub struct InstallAppState {
finish_in_progress: Arc<AtomicBool>,
upstreams: InstallUpstreamConfig,
on_finish: Option<Arc<dyn Fn() + Send + Sync>>,
finish_hook: Option<InstallFinishHook>,
}
pub struct InstallFinishInfo {
pub canonical_url: String,
pub dev_token: Option<String>,
}
pub type InstallFinishHook = Arc<dyn Fn(&InstallFinishInfo) -> anyhow::Result<()> + Send + Sync>;
#[derive(Clone, Debug, Default)]
struct InstallUpstreamConfig {
provider_base_urls: HashMap<Provider, String>,
@ -97,6 +105,7 @@ impl InstallAppState {
finish_in_progress: Arc::new(AtomicBool::new(false)),
upstreams: InstallUpstreamConfig::default(),
on_finish: None,
finish_hook: None,
}
}
@ -137,6 +146,7 @@ impl InstallAppState {
finish_in_progress: Arc::new(AtomicBool::new(false)),
upstreams: InstallUpstreamConfig::default(),
on_finish: None,
finish_hook: None,
}
}
@ -148,6 +158,14 @@ impl InstallAppState {
}
}
#[must_use]
pub fn with_finish_hook(self, finish_hook: InstallFinishHook) -> Self {
Self {
finish_hook: Some(finish_hook),
..self
}
}
#[must_use]
pub fn with_home(mut self, home: Home) -> Self {
self.home = Some(home);
@ -1356,9 +1374,10 @@ async fn post_install_finish(
secret_type: VaultSecretType::Environment,
description: None,
});
let home = state.home.clone().unwrap_or_else(Home::from_env);
let token = match dev_token::read_or_mint_dev_token_for_install(&home.dev_token_path())
{
let dev_token_path = Storage::new(state.storage_dir.as_ref())
.runtime_directory()
.dev_token_path();
let token = match dev_token::read_or_mint_dev_token_for_install(&dev_token_path) {
Ok(value) => value,
Err(err) => {
return install_error_response(
@ -1367,14 +1386,6 @@ async fn post_install_finish(
);
}
};
if let Err(err) = dev_token::write_dev_token(
&Storage::new(state.storage_dir.as_ref())
.runtime_directory()
.dev_token_path(),
&token,
) {
return install_error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string());
}
dev_token = Some(token);
}
GithubInstallState::App(github) => {
@ -1481,6 +1492,16 @@ async fn post_install_finish(
*manual_credentials = None;
}
if let Some(finish_hook) = state.finish_hook.clone() {
let info = InstallFinishInfo {
canonical_url: server.canonical_url.clone(),
dev_token: dev_token.clone(),
};
if let Err(err) = finish_hook(&info) {
warn!(error = %err, "install finish hook failed");
}
}
if let Some(on_finish) = state.on_finish.clone() {
info!(restart_url = %server.canonical_url, "install finish succeeded");
info!("install exit scheduled");

View file

@ -13,7 +13,9 @@ use axum::http::{Request, StatusCode};
use fabro_config::{ServerSettingsBuilder, Storage};
use fabro_install::OBJECT_STORE_MANAGED_COMMENT;
use fabro_model::Provider;
use fabro_server::install::{InstallAppState, build_install_router};
use fabro_server::install::{
InstallAppState, InstallFinishHook, InstallFinishInfo, build_install_router,
};
use fabro_util::{Home, dev_token};
use fabro_vault::Vault;
use httpmock::MockServer;
@ -775,6 +777,53 @@ async fn token_install_finish_persists_settings_env_and_vault() {
assert_eq!(vault.get("GITHUB_TOKEN"), Some("ghp_test_token"));
}
#[tokio::test]
async fn token_install_finish_invokes_finish_hook_before_response_returns() {
let temp_dir = tempfile::tempdir().unwrap();
let config_path = temp_dir.path().join("settings.toml");
let observed = Arc::new(StdMutex::new(None));
let observed_for_hook = Arc::clone(&observed);
let hook: InstallFinishHook = Arc::new(move |info: &InstallFinishInfo| {
*observed_for_hook.lock().unwrap() =
Some((info.canonical_url.clone(), info.dev_token.clone()));
Ok(())
});
let app = build_install_router(
InstallAppState::for_test_with_paths("test-install-token", temp_dir.path(), &config_path)
.with_finish_hook(hook),
)
.await;
configure_token_install(&app, "test-install-token").await;
let finish_response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/install/finish")
.header("authorization", "Bearer test-install-token")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let finish_body = response_json(
finish_response,
StatusCode::ACCEPTED,
"POST /install/finish",
)
.await;
let Some((canonical_url, dev_token)) = observed.lock().unwrap().clone() else {
panic!("finish hook should run before response returns");
};
assert_eq!(canonical_url, "https://fabro.example.com");
assert_eq!(
dev_token.as_deref(),
finish_body["dev_token"].as_str(),
"hook receives the same token returned to the browser"
);
}
#[tokio::test]
async fn app_install_finish_omits_dev_token_and_does_not_write_it() {
let temp_dir = tempfile::tempdir().unwrap();
@ -929,7 +978,7 @@ async fn app_install_finish_omits_dev_token_and_does_not_write_it() {
assert!(!server_env.contains("FABRO_DEV_TOKEN="));
assert!(
!home.dev_token_path().exists(),
!home.root().join("dev-token").exists(),
"home dev token file should not be created for App installs"
);
assert!(
@ -1909,7 +1958,7 @@ async fn install_finish_failure_reports_only_env_keys_actually_removed() {
}
#[tokio::test]
async fn install_finish_failure_leaves_home_dev_token_mirror_written() {
async fn install_finish_failure_does_not_create_home_dev_token() {
let temp_dir = tempfile::tempdir().unwrap();
let home_root = tempfile::tempdir().unwrap();
let home = Home::new(home_root.path().join(".fabro"));
@ -1947,13 +1996,14 @@ async fn install_finish_failure_leaves_home_dev_token_mirror_written() {
)
.await;
let home_dev_token = dev_token::read_dev_token_file(&home.dev_token_path())
.expect("home dev token should exist");
assert!(
!home.root().join("dev-token").exists(),
"home dev token file should not be created"
);
let storage_dev_token =
dev_token::read_dev_token_file(&storage.runtime_directory().dev_token_path())
.expect("storage dev token should exist");
assert_eq!(home_dev_token, storage_dev_token);
let server_env = std::fs::read_to_string(storage.runtime_directory().env_path()).unwrap();
assert!(server_env.contains(&format!("FABRO_DEV_TOKEN={home_dev_token}")));
assert!(server_env.contains(&format!("FABRO_DEV_TOKEN={storage_dev_token}")));
}

View file

@ -58,7 +58,7 @@
<script type="module" src="/assets/chunk-sadshphz.js"></script>
<script type="module" src="/assets/chunk-pmthkscp.js"></script>
<script type="module" src="/assets/chunk-v61ks9f7.js"></script>
<script type="module" src="/assets/entry-eynqnh92.js"></script>
<script type="module" src="/assets/entry-jtmy0vnc.js"></script>
<script type="module" src="/assets/chunk-n1k68xa8.js"></script>
<script type="module" src="/assets/chunk-rsph5pvm.js"></script>
<script type="module" src="/assets/chunk-9t57pdty.js"></script>

View file

@ -629,19 +629,14 @@ fn write_settings_file(path: &Path, storage_dir: &Path, rest: &str) {
}
fn write_test_server_dev_token(storage_dir: &Path) {
let server_env_path = Storage::new(storage_dir).runtime_directory().env_path();
let runtime_directory = Storage::new(storage_dir).runtime_directory();
let server_env_path = runtime_directory.env_path();
envfile::merge_env_file(&server_env_path, [
("FABRO_DEV_TOKEN", TEST_DEV_TOKEN),
("SESSION_SECRET", TEST_SESSION_SECRET),
])
.unwrap_or_else(|err| panic!("failed to write {}: {err}", server_env_path.display()));
}
fn write_test_home_dev_token(settings_path: &Path) {
let home_dir = settings_path
.parent()
.unwrap_or_else(|| panic!("expected {} to have a parent", settings_path.display()));
let dev_token_path = home_dir.join("dev-token");
let dev_token_path = runtime_directory.dev_token_path();
ensure_parent_dir(&dev_token_path);
std::fs::write(&dev_token_path, TEST_DEV_TOKEN)
.unwrap_or_else(|err| panic!("failed to write {}: {err}", dev_token_path.display()));
@ -718,8 +713,6 @@ fn sync_home_settings(
socket_path: &Path,
force_server_target: bool,
) {
write_test_home_dev_token(settings_path);
let (mut table, had_explicit_storage, had_explicit_target) =
match std::fs::read_to_string(settings_path) {
Ok(contents) => {

View file

@ -60,11 +60,6 @@ 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")
@ -102,10 +97,6 @@ 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")

View file

@ -5,7 +5,6 @@
use std::fs;
use fabro_util::Home;
use fabro_util::dev_token::{
DEV_TOKEN_PREFIX, generate_dev_token, read_dev_token_or_err,
read_or_mint_dev_token_for_install, validate_dev_token_format,
@ -103,13 +102,3 @@ fn read_dev_token_or_err_reports_missing_file() {
assert!(error.to_string().contains("read dev token"));
}
#[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")
);
}