mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-10 03:30:59 +00:00
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:
parent
db071953c0
commit
f537222372
28 changed files with 678 additions and 403 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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")),
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 -----
|
||||
");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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::{
|
||||
|
|
|
|||
|
|
@ -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>`",
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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}")));
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
2
lib/crates/fabro-spa/assets/index.html
generated
2
lib/crates/fabro-spa/assets/index.html
generated
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue