feat(auth): tighten server auth surface with single origin

Implements plan: single origin, drop CLI preflight, gate demo toggle.
Removes loopback client target and CLI auth config preflight endpoint;
adds canonical_origin module on the server; regenerates SPA and TS API
client.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-04-21 23:38:31 -04:00
parent 720fbb210a
commit 537a5125cb
No known key found for this signature in database
36 changed files with 2245 additions and 3211 deletions

View file

@ -38,7 +38,7 @@ url = "https://fabro.example.com/api/v1"
[server.web]
enabled = true
url = "https://fabro-web.example.com"
url = "https://fabro.example.com"
[server.auth]
methods = ["dev-token", "github"]
@ -127,10 +127,12 @@ Control the embedded SPA and browser-oriented routes.
| Key | Description | Default |
|---|---|---|
| `enabled` | Serve the embedded SPA, `/auth/*`, and the web-only helper endpoints under `/api/v1` | `true` |
| `url` | External web UI URL used for OAuth redirects | none (no implicit derivation from `server.listen`) |
| `url` | Required single canonical origin for the server. The browser UI and `/auth/*` routes use this origin, and it must be an absolute `http(s)` URL. | `http://localhost:3000` |
When `enabled = false`, the server still exposes the machine API and `/health`, but `/`, `/auth/*`, SPA client routes, `/api/v1/auth/me`, `/api/v1/setup/*`, and `/api/v1/demo/toggle` all return `404`.
`server.web.url` is not a secondary web host. Fabro supports a single public origin for API and web traffic. In local development that can be plain HTTP such as `http://localhost:3000` or `http://127.0.0.1:3000`. In production, operators are responsible for terminating HTTPS upstream.
### `[server.auth]` section
Configure how users authenticate with the server.

View file

@ -416,21 +416,6 @@ paths:
schema:
$ref: "#/components/schemas/ErrorResponse"
/api/v1/auth/cli/config:
get:
operationId: getCliAuthConfig
tags: [Discovery]
summary: CLI Auth Configuration
description: Returns whether CLI OAuth login is available for this server and which web origin should handle the browser flow.
security: []
responses:
"200":
description: CLI authentication configuration
content:
application/json:
schema:
$ref: "#/components/schemas/CliAuthConfig"
# ── Runs ──────────────────────────────────────────────────────────────
/api/v1/runs:
@ -5190,33 +5175,6 @@ components:
description: Health status indicator.
example: ok
CliAuthConfig:
description: Capability and routing information for CLI OAuth login.
type: object
required:
- enabled
- web_url
- methods
properties:
enabled:
type: boolean
description: Whether CLI OAuth login is currently available.
web_url:
type: ["string", "null"]
description: Canonical browser origin for the OAuth flow when enabled.
example: https://fabro.example.com
methods:
type: array
description: Authentication methods configured on the server.
items:
type: string
example: ["github", "dev-token"]
reason:
type: ["string", "null"]
description: >
Optional machine-readable reason when CLI OAuth login is unavailable.
Known values currently include `github_not_enabled` and `web_not_enabled`.
SecretType:
description: The way a secret is consumed by the sandbox.
type: string

View file

@ -24,14 +24,14 @@ We need per-user, per-device CLI credentials that are server-side revocable, sho
- **R2.** Access credential is a short-lived (10 min) HS256 JWT; refresh credential is an opaque 32-byte secret rotated on every use with 30 d sliding expiry. (origin §Token model)
- **R3.** Refresh-token rotation is atomic: two concurrent refreshes cannot both succeed. Reuse detection deletes the entire chain. (origin §Revocation semantics)
- **R4.** Identity is OIDC-style `(idp_issuer, idp_subject)` — not GitHub username — forward-compatible with Google/GHES. `login` is display-only. (origin §Identity model)
- **R5.** Browser flow runs on `server.web.url`, not on the `--server` target. CLI discovers the canonical origin via `GET /api/v1/auth/cli/config` preflight. (origin §Architecture overview, §Canonical origin)
- **R6.** `fabro auth login` succeeds on servers reachable only over HTTPS or Unix socket (browser hits web_url; token endpoints use the CLI transport). (origin §Canonical origin)
- **R5.** `server.web.url` is the server's single canonical origin. `fabro auth login` opens the browser against the resolved HTTP(S) `--server` target directly; no `/api/v1/auth/cli/config` preflight exists. (origin §Architecture overview, §Canonical origin)
- **R6.** `fabro auth login` requires an HTTP(S) server target. Unix-socket targets use the local dev-token flow instead of browser OAuth. Plain HTTP is allowed; operator-managed TLS is a deployment concern. (origin §Canonical origin)
- **R7.** Dev-token flow is unchanged. Bearer-priority order: `FABRO_DEV_TOKEN` env → `AuthStore` JWT → dev-token file fallback. (origin §CLI UX, §Bearer priority)
- **R8.** Reactive auto-refresh triggers on 401 with `code == "access_token_expired"` in the `ApiError` envelope; refresh failures surface as "session expired — run `fabro auth login`". (origin §Bearer priority)
- **R9.** Server-side errors in the browser flow reach the CLI via OAuth-style redirect to loopback (`?error=&state=&error_description=`) when `redirect_uri`+`state` have validated; plain HTML page otherwise. (origin §Browser-to-CLI error handoff)
- **R10.** GitHub allowlist rejection at `/auth/callback/github` happens **before** a session is minted; `/auth/cli/resume` must forward the callback's `?error=` to the CLI loopback without checking session first. (origin §/auth/cli/resume algorithm)
- **R11.** `SESSION_SECRET` is reused as an HKDF master; cookie and JWT keys are domain-separated subkeys. No new env vars. (origin §Settings and secrets)
- **R12.** `server.auth.methods=[github]` combined with `web.enabled=false` fails at server startup with a clear config error. Other "CLI login unavailable" states (github not in methods) are running-server states reported via preflight. (origin §Web mode and config validation)
- **R12.** `server.auth.methods=[github]` combined with `web.enabled=false` fails at server startup with a clear config error. When web is disabled and `/auth/*` is not mounted, CLI login is unsupported and `/auth/cli/start` returns `404`. (origin §Web mode and config validation)
- **R13.** IP allowlist, when enabled, covers every new endpoint (no carve-outs). (origin §IP allowlist)
- **R14.** `fabro auth status` runs fully offline against local state (no server call); `--json` emits structured output; credentials and their expiry windows are shown per server. (origin §CLI UX)
- **R15.** Integration tests exercise the real `web_auth.rs` OAuth glue end-to-end against a black-box `twin-github` extended with OAuth endpoints, not via `#[cfg(test)]` session injection. (origin §Prerequisite: twin-github OAuth extension)
@ -44,7 +44,7 @@ Explicit non-goals for this plan (deferred to fast-follow work):
- **Windows support for `fabro auth login` writes.** `fabro auth login` returns a clear error on Windows; users work around via WSL or dev-token. Dropped to avoid DPAPI/ACL complexity that doesn't clearly improve on the 0600 file baseline for the same-user threat model. Note: `fabro auth status` and `fabro auth logout` DO work on Windows (see R14 — status must remain cross-platform for dev-token detection and logged-out-state reporting).
- **Idempotency grace on refresh.** Network failures during refresh-response delivery force re-login. A future grace window (if needed) must key by stable per-install identifier, not UA/source-IP.
- **`--browser-url` override for split-network topologies.** Browser always opens against `config.web_url` in v1.
- **`--browser-url` override for split-network topologies.** Browser always opens against the resolved HTTP(S) `--server` target in v1.
- **Public `oauth_base_url` / `api_base_url` settings** for GHES / self-hosted GitHub. Test harness uses a test-only injection point; public settings land when GHES itself lands.
- Device flow (`--device`). Same token model, different exchange endpoint.
- OS keychain storage. Plain 0600 file is the v1 Unix storage baseline.
@ -105,7 +105,7 @@ Design-time external work was consolidated in the spec (OAuth 2.0 RFC 6749 §4.1
- **Reactive-only auto-refresh (no pre-flight).** CLI refreshes on 401 with `code == "access_token_expired"` and retries once. No clock-based pre-flight check. Avoids concurrent-refresh storms across parallel CLI invocations, removes clock-sync assumptions, and shrinks the refresh code path by half. Latency cost is at most one extra round-trip per ~10 min window of activity.
- **HTTPS-or-loopback-or-unix-socket-only for refresh traffic, checked by URL parsing, not string matching.** CLI parses the server target as `url::Url`. Accepts: `scheme == "https"`; OR `scheme == "http"` with `url.host()` parsing to an `IpAddr` that `is_loopback()` returns true (covers `127.0.0.0/8`, `::1`, and `::ffff:127.0.0.1`); OR the Unix-socket target type. Rejects literal `localhost` (DNS-overridable), any host containing dots after a loopback prefix (e.g., `127.0.0.1.evil.com`), any encoded form (decimal `2130706433`, hex `0x7f000001`, octal). Surfaces an actionable error pointing at the scheme or host. Prevents silent refresh-token leaks over plaintext.
- **`map_api_error_structured` wraps `progenitor_client::Error` into `ApiFailure { status, code, detail }`.** Existing `map_api_error` is refactored to call `map_api_error_structured` and discard `code`, reducing drift risk. Auto-refresh wrapper uses the structured helper.
- **OpenAPI-first for preflight.** `GET /api/v1/auth/cli/config` lands in `fabro-api.yaml` and regenerates the Rust+TS clients. Preflight response carries `reason` enum only (not `reason_description`) — the CLI renders user-facing text from the enum locally. The OAuth-style `/auth/cli/{token,refresh,logout}` endpoints are outside the canonical API surface (different envelope, different auth); they are **not** in the OpenAPI spec and are hand-wired.
- **No OpenAPI preflight surface.** The CLI constructs browser URLs from its resolved HTTP(S) target directly. The OAuth-style `/auth/cli/{token,refresh,logout}` endpoints remain outside the canonical API surface and are hand-wired.
- **Browser URL always equals `config.web_url` in v1.** Split-network topologies (dockerized dev, VPN mismatches, SSH-plus-browser-on-different-hosts) are deferred to a fast-follow `--browser-url` flag. For v1, users whose `server.web.url` is not reachable from their browser cannot complete login; the preflight handler returns the exact URL the CLI will open so the failure mode is at least visible.
## Open Questions

View file

@ -11,6 +11,8 @@ date: 2026-04-20
Tighten the CLI/server contract so an explicit `http(s)://...` target is always treated as a remote server, never as a disguised local daemon. This removes the remaining places where the CLI still derives remote behavior from local storage, local dev-token files, or colocated-network assumptions left over from the earlier same-host architecture.
Historical note (2026-04-21): the auth flow now also assumes a single origin. `fabro auth login` opens the browser flow on the resolved HTTP(S) target directly; the old `/api/v1/auth/cli/config` preflight no longer exists.
## Problem Frame
The codebase already moved most commands to a server-target model, but a few central helpers still collapse explicit remote targets back into local-machine state:

View file

@ -5,6 +5,8 @@ status: active
date: 2026-04-20
---
Historical note (2026-04-21): the follow-up auth-surface tightening removed `LoopbackClassification`, `target.loopback_classification()`, `ensure_refresh_target_transport`, and the plain-HTTP refresh rejection. References to those APIs below describe the planned state at the time, not the current implementation.
# refactor: Extract fabro-client crate and lift domain DTOs to fabro-types
## Overview

View file

@ -39,7 +39,7 @@ Key server config options:
| Setting | Description |
|---|---|
| `server.listen` | Bind transport: Unix socket or plain TCP listener |
| `server.api.url` / `server.web.url` | External HTTPS URLs advertised to clients |
| `server.api.url` / `server.web.url` | External API base URL and the single canonical browser/API origin |
| `server.auth.methods` | Bootstrap auth methods: `dev-token`, `github`, or both |
| `server.scheduler.max_concurrent_runs` | Scheduler concurrency limit (default 5) |
| `[run.*]` | Defaults applied to every run (overridable per-run) |

View file

@ -257,6 +257,8 @@ fabro model list --server https://fabro.example.com/api/v1
An explicit `http(s)://...` target is always remote-by-contract. Fabro does not derive auth for that target from a local storage dir, an active local daemon record, or `~/.fabro/dev-token`. Use CLI OAuth (`fabro auth login --server ...`) or an explicit `FABRO_DEV_TOKEN` when you need remote auth.
`fabro auth login` only works with `type = "http"` targets. Unix-socket targets use the local dev-token flow instead of browser OAuth. Plain `http://...` targets are supported for local or trusted deployments; operators remain responsible for providing HTTPS anywhere real credentials cross an untrusted network.
`fabro exec` does not automatically use `[cli.target]`. It only routes model traffic through a Fabro server when you pass `--server` for that invocation.
## `[run.pull_request]`

View file

@ -40,16 +40,14 @@ Identity is the OIDC-style composite `(idp_issuer, idp_subject)`, future-proof f
No new auth primitives on the wire: bearer credentials ride `Authorization: Bearer <token>`; web sessions continue to ride the `Cookie` header. The existing `jwt_auth.rs` extractor keeps reading session cookies where it already does, and the bearer-header validation is extended to distinguish (by prefix) dev-token vs JWT access token.
**Canonical origin.** All browser-visible CLI-flow URLs (`/auth/cli/start`, `/auth/cli/resume`, the chained `/auth/login/github``/auth/callback/github`) run against `server.web.url`, not against whatever `--server` URL the CLI was given. The existing OAuth flow is hard-coded to redirect to `{server.web.url}/auth/callback/github` (`web_auth.rs:339`), and `fabro_oauth_state` / the new `fabro_cli_flow` cookies are cookie-jar-bound to that origin. If the CLI target differs (e.g. `--server http://127.0.0.1:3000` with `server.web.url=https://fabro.example.com`, or a Unix-socket target), the browser flow still runs on `server.web.url`. The CLI discovers this origin via a new unauthenticated preflight endpoint `GET /api/v1/auth/cli/config` (see Server endpoints), called over the CLI's normal transport. The non-browser step (POST `/auth/cli/token`, `/auth/cli/refresh`, `/auth/cli/logout`) continues to use the CLI's normal transport and can therefore target a Unix socket.
**Canonical origin.** `server.web.url` is the single canonical origin for the server. The browser-visible CLI-flow URLs (`/auth/cli/start`, `/auth/cli/resume`, `/auth/login/github`, `/auth/callback/github`) all run on that same origin, and `fabro auth login` opens the browser against its resolved HTTP(S) `--server` target directly. Dual-origin API/web deployments and the old `/api/v1/auth/cli/config` preflight are no longer part of the design. Unix-socket targets do not support browser OAuth login; they use the local dev-token flow instead.
**Web mode and config validation.** `/auth/*` only mounts when `server.web.enabled = true` (`server.rs:916-918`). The config-validity matrix:
- `auth.methods = [..., "github", ...]` **and** `web.enabled = false`**startup error**. These two settings contradict each other; the admin must pick one.
- `auth.methods` excludes `"github"` (regardless of `web.enabled`) → server starts normally. CLI login is not available; preflight `GET /api/v1/auth/cli/config` returns `enabled: false`.
- `web.enabled = false` with `"github"` also absent → server starts; `/auth/cli/*` routes don't mount; preflight (mounted on the always-available API router) returns `enabled: false`.
- `web.enabled = true` and `auth.methods` includes `"github"` → everything mounted; preflight returns `enabled: true`.
The CLI always hits the preflight first and fails fast with an actionable message rather than opening a doomed browser.
- `auth.methods` excludes `"github"` (regardless of `web.enabled`) → server starts normally. Direct hits to `/auth/cli/start` redirect back to the validated CLI loopback with `error=github_not_configured`.
- `web.enabled = false` with `"github"` also absent → server starts; `/auth/cli/*` routes do not mount, so CLI login is unsupported and `/auth/cli/start` returns `404`.
- `web.enabled = true` and `auth.methods` includes `"github"` → the browser flow is mounted on the server origin and CLI login is available for HTTP(S) targets.
**Browser-to-CLI error handoff.** Server-side failures during the browser flow (invalid PKCE params, ineligible session, allowlist rejection at callback) need to reach the terminal without devolving into a loopback-listener timeout. The contract, matching OAuth 2.0 RFC 6749 §4.1.2.1:

View file

@ -1,9 +1,8 @@
use std::time::Duration;
use anyhow::{Context as _, Result, anyhow, bail};
use anyhow::{Context as _, Result, bail};
use chrono::{DateTime, Utc};
use fabro_api::types;
use fabro_client::{AuthEntry, AuthStore, StoredSubject, ensure_refresh_target_transport};
use fabro_client::{AuthEntry, AuthStore, StoredSubject};
use fabro_http::header::CONTENT_TYPE;
use fabro_types::settings::CliSettings;
use fabro_types::settings::cli::CliLayer;
@ -55,16 +54,7 @@ pub(super) async fn login_command(
{
let ctx = CommandContext::base(printer, cli.clone(), cli_layer)?;
let target = user_config::resolve_server_target(&args.server, ctx.machine_settings())?;
let config = fetch_cli_auth_config(&target).await?;
if !config.enabled {
bail!("{}", cli_auth_unavailable_message(config.reason.as_deref()));
}
let web_url = config
.web_url
.as_deref()
.filter(|value| !value.is_empty())
.ok_or_else(|| anyhow!("CLI login is not available on this server."))?;
let web_url = browser_origin(&target)?;
let pkce = fabro_oauth::generate_pkce();
let state = fabro_oauth::generate_state();
let callback_path = "/callback";
@ -96,8 +86,6 @@ pub(super) async fn login_command(
}
};
ensure_refresh_target_transport(&target)?;
let tokens = exchange_cli_token(&target, &code, &pkce.verifier, &redirect_uri).await?;
let entry = AuthEntry {
access_token: tokens.access_token,
@ -121,15 +109,16 @@ pub(super) async fn login_command(
}
#[cfg(unix)]
async fn fetch_cli_auth_config(target: &ServerTarget) -> Result<types::CliAuthConfig> {
let (http_client, base_url) = target.build_public_http_client()?;
let client = fabro_api::ApiClient::new_with_client(&base_url, http_client);
client
.get_cli_auth_config()
.send()
.await
.map(progenitor_client::ResponseValue::into_inner)
.map_err(|err| anyhow!("{err}"))
fn browser_origin(target: &ServerTarget) -> Result<&str> {
if target.as_unix_socket_path().is_some() {
bail!(
"fabro auth login requires an HTTP(S) server target. Unix-socket targets use a dev-token instead. Pass --server http(s)://... or configure [cli.target] with an http/https URL."
);
}
target
.as_http_url()
.ok_or_else(|| anyhow::anyhow!("server target must be an http(s) URL"))
}
#[cfg(unix)]
@ -207,26 +196,20 @@ fn open_browser_or_print(browser_url: &str, no_browser: bool, printer: Printer)
}
}
fn cli_auth_unavailable_message(reason: Option<&str>) -> &'static str {
match reason {
Some("github_not_enabled") => {
"CLI login is not available on this server because GitHub login is not enabled."
}
Some("web_not_enabled") => {
"CLI login is not available on this server because the web UI is disabled."
}
_ => "CLI login is not available on this server.",
}
}
fn login_failure_message(error_code: &str, error_description: Option<&str>) -> String {
match error_code {
"github_session_required" => {
"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()
}
"access_denied" => "Authorization denied.".to_string(),
"unauthorized" => "Login not permitted.".to_string(),
"server_error" => "Could not complete GitHub sign-in.".to_string(),
"server_error" => error_description
.filter(|value| !value.is_empty())
.unwrap_or("Could not complete GitHub sign-in.")
.to_string(),
_ => error_description
.filter(|value| !value.is_empty())
.unwrap_or("Could not complete login.")
@ -255,11 +238,10 @@ struct OAuthErrorBody {
mod tests {
use base64::Engine as _;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use fabro_client::LoopbackClassification;
use insta::assert_snapshot;
use sha2::{Digest, Sha256};
use super::{build_browser_url, cli_auth_unavailable_message, login_failure_message};
use super::{browser_origin, build_browser_url, login_failure_message};
use crate::user_config::ServerTarget;
#[test]
@ -270,36 +252,16 @@ mod tests {
}
#[test]
fn browser_url_uses_web_origin_and_callback_params() {
fn browser_url_uses_server_target_origin_and_callback_params() {
let url = build_browser_url(
"https://app.fabro.example",
"https://fabro.example",
"http://127.0.0.1:41234/callback",
"state-123",
"challenge-abc",
)
.unwrap();
assert_snapshot!(url, @"https://app.fabro.example/auth/cli/start?redirect_uri=http%3A%2F%2F127.0.0.1%3A41234%2Fcallback&state=state-123&code_challenge=challenge-abc&code_challenge_method=S256");
}
#[test]
fn unavailable_reason_messages_cover_known_and_unknown_values() {
assert_eq!(
cli_auth_unavailable_message(Some("github_not_enabled")),
"CLI login is not available on this server because GitHub login is not enabled."
);
assert_eq!(
cli_auth_unavailable_message(Some("web_not_enabled")),
"CLI login is not available on this server because the web UI is disabled."
);
assert_eq!(
cli_auth_unavailable_message(Some("future_reason")),
"CLI login is not available on this server."
);
assert_eq!(
cli_auth_unavailable_message(None),
"CLI login is not available on this server."
);
assert_snapshot!(url, @"https://fabro.example/auth/cli/start?redirect_uri=http%3A%2F%2F127.0.0.1%3A41234%2Fcallback&state=state-123&code_challenge=challenge-abc&code_challenge_method=S256");
}
#[test]
@ -317,21 +279,31 @@ mod tests {
"Login not permitted."
);
assert_eq!(
login_failure_message("server_error", Some("Could not complete GitHub sign-in")),
"Could not complete GitHub sign-in."
login_failure_message(
"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."
);
assert_eq!(
login_failure_message("future_code", Some("Future description")),
"Future description"
login_failure_message("server_error", Some("SESSION_SECRET is not configured")),
"SESSION_SECRET is not configured"
);
}
#[test]
fn token_transport_accepts_only_https_loopback_or_unix() {
let target = ServerTarget::http_url("https://fabro.example.com").unwrap();
assert_eq!(
target.loopback_classification().unwrap(),
LoopbackClassification::Https
fn auth_login_accepts_http_target() {
let target = ServerTarget::http_url("http://fabro.example.com/api/v1").unwrap();
assert_eq!(browser_origin(&target).unwrap(), "http://fabro.example.com");
}
#[test]
fn auth_login_rejects_unix_socket_target() {
let target = ServerTarget::unix_socket_path("/tmp/fabro.sock").unwrap();
let err = browser_origin(&target).unwrap_err();
assert!(
err.to_string()
.contains("fabro auth login requires an HTTP(S) server target")
);
}
}

View file

@ -30,16 +30,6 @@ fn auth_login_refresh_logout_flow() {
let server = MockServer::start();
let target = server_target(&server);
let config_mock = server.mock(|when, then| {
when.method(GET).path("/api/v1/auth/cli/config");
then.status(200)
.header("Content-Type", "application/json")
.json_body(json!({
"enabled": true,
"web_url": server.base_url(),
"methods": ["github"]
}));
});
let token_mock = server.mock(|when, then| {
when.method(POST)
.path("/auth/cli/token")
@ -61,7 +51,6 @@ fn auth_login_refresh_logout_flow() {
"login output should confirm success:\n{}",
String::from_utf8_lossy(&login_output.stderr)
);
config_mock.assert();
token_mock.assert();
let status = auth_status(&context, &target);
@ -167,16 +156,6 @@ fn auth_refresh_failure_clears_local_session() {
"fabro_dev_abababababababababababababababababababababababababababababababab\n",
);
server.mock(|when, then| {
when.method(GET).path("/api/v1/auth/cli/config");
then.status(200)
.header("Content-Type", "application/json")
.json_body(json!({
"enabled": true,
"web_url": server.base_url(),
"methods": ["github"]
}));
});
server.mock(|when, then| {
when.method(POST)
.path("/auth/cli/token")
@ -289,6 +268,37 @@ fn auth_refresh_failure_clears_local_session() {
auth_required_mock.assert();
}
#[test]
fn auth_login_rejects_unix_socket_target() {
let context = test_context!();
let socket_path = context.temp_dir.join("fabro.sock");
let output = context
.command()
.args([
"auth",
"login",
"--no-browser",
"--server",
socket_path.to_str().unwrap(),
])
.output()
.expect("auth login should execute");
assert!(
!output.status.success(),
"auth login should fail for unix-socket targets\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
assert!(
String::from_utf8_lossy(&output.stderr)
.contains("fabro auth login requires an HTTP(S) server target"),
"unix-socket rejection should explain the transport requirement:\n{}",
String::from_utf8_lossy(&output.stderr)
);
}
#[tokio::test(flavor = "multi_thread")]
async fn auth_login_refresh_logout_flow_against_real_server_and_twin_github() {
let context = test_context!();
@ -303,22 +313,19 @@ async fn auth_login_refresh_logout_flow_against_real_server_and_twin_github() {
String::from_utf8_lossy(&login_output.stderr)
);
assert!(
browser_url.starts_with(&format!("{}/auth/cli/start", harness.web_base_url)),
"browser flow should open the configured web origin, got: {browser_url}"
browser_url.starts_with(&format!("{}/auth/cli/start", harness.api_base_url)),
"browser flow should open the server target origin, got: {browser_url}"
);
assert_ne!(harness.api_base_url, harness.web_base_url);
let status = auth_status(&context, &target);
assert_eq!(status["servers"].as_array().map(Vec::len), Some(1));
assert_eq!(status["servers"][0]["oauth_state"], "active");
assert_eq!(status["servers"][0]["login"], "octocat");
assert_eq!(status["servers"][0]["server"], harness.api_base_url);
assert!(harness.api_requests.contains("GET /api/v1/auth/cli/config"));
assert!(harness.web_requests.contains("GET /auth/cli/start"));
assert!(harness.web_requests.contains("GET /auth/cli/resume"));
assert!(harness.web_requests.contains("POST /auth/cli/resume"));
assert!(harness.api_requests.contains("POST /auth/cli/token"));
assert!(!harness.web_requests.contains("POST /auth/cli/token"));
assert!(harness.api_requests.contains("GET /auth/cli/start"));
assert!(harness.api_requests.contains("GET /auth/cli/resume"));
assert!(harness.api_requests.contains("POST /auth/cli/resume"));
harness.api_requests.clear();
let exec_output = context
@ -358,7 +365,7 @@ async fn auth_login_refresh_logout_flow_against_real_server_and_twin_github() {
);
harness.api_requests.clear();
expire_saved_access_token(&context, &harness.web_base_url);
expire_saved_access_token(&context, &harness.api_base_url);
let expired_exec_output = context
.exec_cmd()
.args([
@ -464,8 +471,8 @@ async fn auth_login_surfaces_access_denied_from_real_browser_flow() {
let (login_output, browser_url) = complete_login_via_browser(&context, &target).await;
assert!(
browser_url.starts_with(&format!("{}/auth/cli/start", harness.web_base_url)),
"browser flow should open the configured web origin, got: {browser_url}"
browser_url.starts_with(&format!("{}/auth/cli/start", harness.api_base_url)),
"browser flow should open the server target origin, got: {browser_url}"
);
assert!(
!login_output.status.success(),
@ -491,7 +498,7 @@ async fn auth_cli_start_ignores_dev_token_session_and_redirects_to_github_login(
let client = no_redirect_browser_client();
let login_response = client
.post(format!("{}/auth/login/dev-token", harness.web_base_url))
.post(format!("{}/auth/login/dev-token", harness.api_base_url))
.json(&json!({ "token": TEST_DEV_TOKEN }))
.send()
.await
@ -501,7 +508,7 @@ async fn auth_cli_start_ignores_dev_token_session_and_redirects_to_github_login(
let response = client
.get(format!(
"{}/auth/cli/start?redirect_uri=http://127.0.0.1:4444/callback&state=abcdefghijklmnop&code_challenge=challenge&code_challenge_method=S256",
harness.web_base_url
harness.api_base_url
))
.send()
.await

View file

@ -43,12 +43,9 @@ pub(crate) const TEST_DEV_TOKEN: &str =
pub(crate) struct RealAuthHarness {
pub(crate) api_base_url: String,
pub(crate) web_base_url: String,
api_server: RunningHttpServer,
web_server: RunningHttpServer,
twin: fabro_test::TwinGitHub,
pub(crate) api_requests: ListenerRequestLog,
pub(crate) web_requests: ListenerRequestLog,
}
impl RealAuthHarness {
@ -71,9 +68,8 @@ impl RealAuthHarness {
let twin = fabro_test::TwinGitHub::start(github_state).await;
let (api_listener, api_base_url) = bind_listener().await;
let (web_listener, web_base_url) = bind_listener().await;
let settings = auth_settings(&web_base_url, &github_client_id, auth_methods);
let settings = auth_settings(&api_base_url, &github_client_id, auth_methods);
let resolved = resolve_server_from_file(&settings).expect("settings should resolve");
let dev_token = dev_token.map(str::to_string);
let auth_mode = resolve_auth_mode_with_lookup(&resolved, |name| match name {
@ -105,22 +101,15 @@ impl RealAuthHarness {
);
let api_requests = ListenerRequestLog::default();
let web_requests = ListenerRequestLog::default();
let api_server = RunningHttpServer::start(api_listener, router.clone(), &api_requests);
let web_server = RunningHttpServer::start(web_listener, router, &web_requests);
let api_server = RunningHttpServer::start(api_listener, router, &api_requests);
wait_for_http_ready(&api_base_url).await;
wait_for_http_ready(&web_base_url).await;
api_requests.clear();
web_requests.clear();
Self {
api_base_url,
web_base_url,
api_server,
web_server,
twin,
api_requests,
web_requests,
}
}
@ -130,7 +119,6 @@ impl RealAuthHarness {
pub(crate) async fn shutdown(self) {
self.api_server.shutdown().await;
self.web_server.shutdown().await;
self.twin.shutdown().await;
}
}
@ -354,7 +342,7 @@ async fn bind_listener() -> (TcpListener, String) {
}
fn auth_settings(
web_base_url: &str,
api_base_url: &str,
github_client_id: &str,
auth_methods: &[&str],
) -> fabro_types::settings::SettingsLayer {
@ -374,7 +362,7 @@ methods = [{auth_methods}]
allowed_usernames = ["octocat"]
[server.web]
url = "{web_base_url}"
url = "{api_base_url}"
[server.integrations.github]
client_id = "{github_client_id}"

View file

@ -25,7 +25,6 @@ use crate::error::{
ApiError, ApiFailure, classify_api_error, classify_http_response, convert_type,
is_not_found_error, map_api_error, raw_response_failure_error,
};
use crate::loopback::LoopbackClassification;
use crate::session::OAuthSession;
use crate::target::ServerTarget;
use crate::{AuthEntry, StoredSubject, sse};
@ -355,8 +354,6 @@ impl Client {
self.rebuild_with_fallback(oauth_session).await?;
bail!("CLI session has expired. Run `fabro auth login` again.");
}
ensure_refresh_target_transport(&oauth_session.target)?;
let (http_client, base_url) = oauth_session.target.build_public_http_client()?;
let response = http_client
.post(format!("{base_url}/auth/cli/refresh"))
@ -1307,21 +1304,6 @@ pub fn apply_bearer_token_auth(
Ok(builder.default_headers(headers))
}
pub fn ensure_refresh_target_transport(target: &ServerTarget) -> Result<()> {
match target.loopback_classification()? {
LoopbackClassification::Https
| LoopbackClassification::LoopbackHttp
| LoopbackClassification::UnixSocket => Ok(()),
LoopbackClassification::Rejected => bail!(refresh_transport_error(target)),
}
}
fn refresh_transport_error(target: &ServerTarget) -> String {
format!(
"Refusing to send refresh-token credentials over plaintext HTTP to a non-loopback host ({target}). Use HTTPS, or bind the server to 127.0.0.1 / ::1."
)
}
fn non_zero_u64_from_u32(value: u32) -> Option<NonZeroU64> {
NonZeroU64::new(u64::from(value))
}
@ -1333,6 +1315,8 @@ fn non_zero_u64_from_usize(value: usize) -> Option<NonZeroU64> {
#[cfg(test)]
mod tests {
use chrono::Duration as ChronoDuration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
use super::*;
use crate::AuthStore;
@ -1357,10 +1341,42 @@ mod tests {
#[cfg(unix)]
#[tokio::test]
async fn refresh_access_token_rejects_plain_http_non_loopback_targets() {
async fn refresh_access_token_allows_plain_http_targets() {
let temp = tempfile::tempdir().unwrap();
let auth_store = AuthStore::new(temp.path().join("auth.json"));
let target = ServerTarget::http_url("http://fabro.example.com").unwrap();
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let server = tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.unwrap();
let mut request = vec![0_u8; 4096];
let read = stream.read(&mut request).await.unwrap();
let request = String::from_utf8_lossy(&request[..read]);
assert!(
request.starts_with("POST /auth/cli/refresh HTTP/1.1"),
"unexpected refresh request: {request}"
);
let body = serde_json::json!({
"access_token": "access-refreshed",
"access_token_expires_at": (chrono::Utc::now() + ChronoDuration::minutes(10)).to_rfc3339(),
"refresh_token": "refresh-refreshed",
"refresh_token_expires_at": (chrono::Utc::now() + ChronoDuration::days(30)).to_rfc3339(),
"subject": {
"idp_issuer": "https://github.com",
"idp_subject": "12345",
"login": "octocat",
"name": "Name octocat",
"email": "octocat@example.com"
}
})
.to_string();
let response = format!(
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
body.len(),
body
);
stream.write_all(response.as_bytes()).await.unwrap();
});
let target = ServerTarget::http_url(format!("http://localhost:{port}")).unwrap();
let entry = oauth_entry("octocat");
auth_store.put(&target, entry.clone()).unwrap();
@ -1368,25 +1384,15 @@ mod tests {
.target(target.clone())
.credential(Credential::OAuth(entry))
.oauth_session(OAuthSession::new(target.clone(), auth_store.clone()))
.transport(
"http://fabro.example.com",
fabro_http::HttpClientBuilder::new()
.no_proxy()
.build()
.unwrap(),
)
.transport("http://localhost", fabro_http::test_http_client().unwrap())
.connect()
.await
.unwrap();
let err = client
.refresh_access_token("access-octocat")
.await
.unwrap_err();
assert!(
err.to_string()
.contains("Refusing to send refresh-token credentials over plaintext HTTP")
);
assert!(auth_store.get(&target).unwrap().is_some());
client.refresh_access_token("access-octocat").await.unwrap();
let refreshed = auth_store.get(&target).unwrap().unwrap();
assert_eq!(refreshed.access_token, "access-refreshed");
assert_eq!(refreshed.refresh_token, "refresh-refreshed");
server.abort();
}
}

View file

@ -4,22 +4,17 @@ pub mod auth_store;
pub mod client;
pub mod credential;
pub mod error;
pub mod loopback;
pub mod session;
pub mod sse;
pub mod target;
pub use auth_store::{AuthEntry, AuthStore, AuthStoreError, LockError, StoredSubject};
pub use client::{
Client, RunEventStream, TransportConnector, apply_bearer_token_auth,
ensure_refresh_target_transport,
};
pub use client::{Client, RunEventStream, TransportConnector, apply_bearer_token_auth};
pub use credential::{Credential, CredentialFallback};
pub use error::{
ApiError, ApiFailure, StructuredApiError, classify_api_error, classify_http_response,
convert_type, is_not_found_error, map_api_error, parse_error_response_value,
raw_response_failure_error,
};
pub use loopback::{LoopbackClassification, TargetSchemeError};
pub use session::OAuthSession;
pub use target::ServerTarget;

View file

@ -1,208 +0,0 @@
use std::net::IpAddr;
use thiserror::Error;
use crate::target::ServerTarget;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LoopbackClassification {
Https,
LoopbackHttp,
UnixSocket,
Rejected,
}
#[derive(Debug, Error)]
pub enum TargetSchemeError {
#[error("invalid server URL `{value}`: {reason}")]
InvalidUrl { value: String, reason: String },
#[error("unsupported server URL scheme `{scheme}`")]
UnsupportedScheme { scheme: String },
#[error("server URL `{value}` is missing a host")]
MissingHost { value: String },
}
pub(crate) fn classify_target(
target: &ServerTarget,
) -> Result<LoopbackClassification, TargetSchemeError> {
if target.is_unix_socket() {
Ok(LoopbackClassification::UnixSocket)
} else if let Some(api_url) = target.as_http_url() {
classify_http_target(api_url)
} else {
Err(TargetSchemeError::MissingHost {
value: target.to_string(),
})
}
}
fn classify_http_target(api_url: &str) -> Result<LoopbackClassification, TargetSchemeError> {
let url = fabro_http::Url::parse(api_url).map_err(|source| TargetSchemeError::InvalidUrl {
value: api_url.to_string(),
reason: source.to_string(),
})?;
match url.scheme() {
"https" => Ok(LoopbackClassification::Https),
"http" => {
if url.host_str().is_none() {
return Err(TargetSchemeError::MissingHost {
value: api_url.to_string(),
});
}
if !url.username().is_empty() || url.password().is_some() {
return Ok(LoopbackClassification::Rejected);
}
let Some(authority) = raw_authority(api_url) else {
return Err(TargetSchemeError::MissingHost {
value: api_url.to_string(),
});
};
Ok(if raw_host_is_loopback_literal(authority) {
LoopbackClassification::LoopbackHttp
} else {
LoopbackClassification::Rejected
})
}
scheme => Err(TargetSchemeError::UnsupportedScheme {
scheme: scheme.to_string(),
}),
}
}
fn raw_authority(url: &str) -> Option<&str> {
let (_, remainder) = url.split_once("://")?;
let end = remainder
.find(|ch| ['/', '?', '#'].contains(&ch))
.unwrap_or(remainder.len());
Some(&remainder[..end])
}
fn raw_host_is_loopback_literal(authority: &str) -> bool {
if authority.contains('@') {
return false;
}
let Some(host) = raw_host(authority) else {
return false;
};
match host.parse::<IpAddr>().ok() {
Some(IpAddr::V4(ipv4)) => host.contains('.') && ipv4.is_loopback(),
Some(ip @ IpAddr::V6(_)) => ip_is_loopback(&ip),
None => false,
}
}
fn raw_host(authority: &str) -> Option<&str> {
if authority.is_empty() {
return None;
}
if authority.starts_with('[') {
let end = authority.find(']')?;
let remainder = &authority[end + 1..];
if !remainder.is_empty() && !remainder.starts_with(':') {
return None;
}
return Some(&authority[1..end]);
}
let host = authority
.split_once(':')
.map_or(authority, |(host, _)| host);
if host.is_empty() { None } else { Some(host) }
}
fn ip_is_loopback(ip: &IpAddr) -> bool {
match ip {
IpAddr::V4(ipv4) => ipv4.is_loopback(),
IpAddr::V6(ipv6) => {
ipv6.is_loopback() || ipv6.to_ipv4_mapped().is_some_and(|ip| ip.is_loopback())
}
}
}
#[cfg(test)]
mod tests {
use super::LoopbackClassification;
use crate::target::ServerTarget;
#[test]
fn classifies_https_loopback_and_unix_targets() {
let cases = [
(
ServerTarget::http_url("https://fabro.example.com").unwrap(),
LoopbackClassification::Https,
),
(
ServerTarget::http_url("http://127.0.0.1:3000").unwrap(),
LoopbackClassification::LoopbackHttp,
),
(
ServerTarget::http_url("http://[::1]:3000").unwrap(),
LoopbackClassification::LoopbackHttp,
),
(
ServerTarget::http_url("http://[::ffff:127.0.0.1]:3000").unwrap(),
LoopbackClassification::LoopbackHttp,
),
(
ServerTarget::unix_socket_path("/tmp/fabro.sock").unwrap(),
LoopbackClassification::UnixSocket,
),
];
for (target, expected) in cases {
assert_eq!(target.loopback_classification().unwrap(), expected);
}
}
#[test]
fn rejects_plain_http_non_loopback_targets() {
let cases = [
"http://fabro.example.com",
"http://127.0.0.1.evil.com",
"http://127.0.0.1:1@attacker.com",
"http://localhost",
"http://localhost.evil.com",
];
for api_url in cases {
let target = ServerTarget::http_url(api_url).unwrap();
assert_eq!(
target.loopback_classification().unwrap(),
LoopbackClassification::Rejected
);
}
}
#[test]
fn rejects_obfuscated_ipv4_literals_at_parse_time() {
let cases = [
"http://2130706433", // decimal integer
"http://0x7f000001", // hex integer
"http://0177.0.0.1", // octal dotted
"http://127.1", // two-part short
"http://127.0.1", // three-part short
"http://0x7f.0.0.1", // mixed hex/decimal
"http://127.00.0.1", // leading-zero octet
"http://127.0.0.001", // leading-zero octet
];
for api_url in cases {
assert!(
ServerTarget::http_url(api_url).is_err(),
"{api_url} should not parse as a server target"
);
}
}
#[test]
fn rejects_non_http_server_targets_at_parse_time() {
let error = "ftp://fabro.example.com"
.parse::<ServerTarget>()
.unwrap_err();
assert!(
error
.to_string()
.contains("server target must be an http(s) URL or absolute Unix socket path")
);
}
}

View file

@ -4,8 +4,6 @@ use std::str::FromStr;
use anyhow::{Result, bail};
use crate::loopback::{LoopbackClassification, TargetSchemeError, classify_target};
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum ServerTarget {
HttpUrl(CanonicalHttpUrl),
@ -74,10 +72,6 @@ impl ServerTarget {
bail!("Unix-socket HTTP client is not supported on this platform")
}
}
pub fn loopback_classification(&self) -> Result<LoopbackClassification, TargetSchemeError> {
classify_target(self)
}
}
impl fmt::Display for ServerTarget {

View file

@ -22,7 +22,7 @@ use tracing::{info, warn};
use url::{Host, Url};
use crate::auth::{self, AuthCode, ConsumeOutcome, JwtSubject, RefreshToken};
use crate::jwt_auth::{AuthMode, ConfiguredAuth, auth_method_name};
use crate::jwt_auth::{AuthMode, ConfiguredAuth};
use crate::server::AppState;
use crate::web_auth::{SessionCookie, read_private_session};
@ -44,16 +44,6 @@ struct OAuthErrorResponse<'a> {
error_description: &'a str,
}
#[derive(Serialize)]
struct CliAuthConfigResponse {
enabled: bool,
#[serde(rename = "web_url")]
web_url: Option<String>,
methods: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
reason: Option<&'static str>,
}
#[derive(Deserialize)]
struct CliStartParams {
redirect_uri: Option<String>,
@ -100,10 +90,6 @@ struct CliTokenResponse {
subject: CliAuthSubjectResponse,
}
pub(crate) fn api_routes() -> Router<Arc<AppState>> {
Router::new().route("/auth/cli/config", get(config))
}
pub(crate) fn web_routes() -> Router<Arc<AppState>> {
Router::new()
.route("/cli/start", get(start))
@ -113,42 +99,12 @@ pub(crate) fn web_routes() -> Router<Arc<AppState>> {
.route("/cli/logout", post(logout))
}
async fn config(
State(state): State<Arc<AppState>>,
Extension(auth_mode): Extension<AuthMode>,
) -> Json<CliAuthConfigResponse> {
let methods = configured_methods(&auth_mode);
let settings = state.server_settings();
let web_enabled = settings.web.enabled;
let web_url = resolved_web_url(state.as_ref());
let github_enabled = methods.iter().any(|method| method == "github");
let enabled = web_enabled && github_enabled;
let reason = if enabled {
None
} else if !web_enabled {
Some("web_not_enabled")
} else {
Some("github_not_enabled")
};
Json(CliAuthConfigResponse {
enabled,
web_url: enabled.then_some(web_url).flatten(),
methods,
reason,
})
}
async fn start(
State(state): State<Arc<AppState>>,
Extension(auth_mode): Extension<AuthMode>,
Query(params): Query<CliStartParams>,
headers: HeaderMap,
) -> Response {
if !github_enabled(&auth_mode) {
return static_error_page(GITHUB_NOT_CONFIGURED);
}
let Some(redirect_uri) = params
.redirect_uri
.as_deref()
@ -164,6 +120,24 @@ async fn start(
return static_error_page(INVALID_OR_MISSING_STATE);
}
if !github_enabled(&auth_mode) {
return redirect_with_error(
&redirect_uri,
state_token,
"github_not_configured",
"GitHub authentication is not enabled on this server",
);
}
let Some(session_key) = state.session_key() else {
return redirect_with_error(
&redirect_uri,
state_token,
"server_error",
"SESSION_SECRET is not configured on this server",
);
};
let Some(code_challenge) = params.code_challenge.as_deref() else {
return redirect_with_error(
&redirect_uri,
@ -180,10 +154,6 @@ async fn start(
"Invalid PKCE parameters",
);
}
let Some(session_key) = state.session_key() else {
return static_error_page(GITHUB_NOT_CONFIGURED);
};
let session = read_private_session(&headers, &session_key);
let secure = session_cookie_secure(state.as_ref());
@ -703,17 +673,6 @@ async fn logout(
StatusCode::NO_CONTENT.into_response()
}
fn configured_methods(auth_mode: &AuthMode) -> Vec<String> {
match auth_mode {
AuthMode::Enabled(config) => config
.methods
.iter()
.map(|method| auth_method_name(*method).to_string())
.collect(),
AuthMode::Disabled => Vec::new(),
}
}
fn github_enabled(auth_mode: &AuthMode) -> bool {
matches!(
auth_mode,
@ -739,14 +698,7 @@ fn github_auth_not_configured() -> Response {
}
fn resolved_web_url(state: &AppState) -> Option<String> {
state
.server_settings()
.web
.url
.resolve(|name| std::env::var(name).ok())
.ok()
.map(|resolved| resolved.value)
.filter(|value| !value.is_empty())
state.canonical_origin().ok()
}
fn session_cookie_secure(state: &AppState) -> bool {
@ -1367,8 +1319,8 @@ mod tests {
use uuid::Uuid;
use super::{
CliFlowCookie, add_cli_flow_cookie, api_routes, read_private_cli_flow,
user_agent_fingerprint, web_routes,
CliFlowCookie, add_cli_flow_cookie, read_private_cli_flow, user_agent_fingerprint,
web_routes,
};
use crate::auth::{self, AuthCode, RefreshToken};
use crate::jwt_auth::{AuthMode, ConfiguredAuth};
@ -1423,7 +1375,6 @@ mod tests {
false,
);
let app = axum::Router::new()
.nest("/api/v1", api_routes())
.nest("/auth", web_routes())
.layer(Extension(github_auth_mode()))
.with_state(Arc::clone(&state));

View file

@ -4,7 +4,7 @@ mod jwt;
mod keys;
mod translate;
pub(crate) use cli_flow::{api_routes, web_routes};
pub(crate) use cli_flow::web_routes;
pub(crate) use fabro_store::{AuthCode, ConsumeOutcome, RefreshToken};
pub use github_endpoints::GithubEndpoints;
pub(crate) use jwt::{JwtError, JwtSubject, issue, verify};

View file

@ -0,0 +1,37 @@
use fabro_types::settings::ServerSettings as ResolvedServerSettings;
use url::Url;
use crate::server::EnvLookup;
pub(crate) fn validate_canonical_origin(
resolved: &ResolvedServerSettings,
env_lookup: &EnvLookup,
) -> Result<(), String> {
resolve_canonical_origin(resolved, env_lookup).map(|_| ())
}
pub(crate) fn resolve_canonical_origin(
resolved: &ResolvedServerSettings,
env_lookup: &EnvLookup,
) -> Result<String, String> {
let value = resolved
.web
.url
.resolve(|name| env_lookup(name))
.map_err(|_| canonical_origin_error(&resolved.web.url.as_source()))?
.value;
let parsed = Url::parse(&value).map_err(|_| canonical_origin_error(&value))?;
let scheme = parsed.scheme();
if !matches!(scheme, "http" | "https") || parsed.host_str().is_none() {
return Err(canonical_origin_error(&value));
}
Ok(value)
}
fn canonical_origin_error(value: &str) -> String {
format!(
"server.web.url is required and must be an absolute http(s) URL (got \"{value}\"). Set it in your settings file or via the FABRO_WEB_URL environment variable."
)
}

View file

@ -10,6 +10,7 @@
pub mod auth;
pub mod bind;
mod canonical_origin;
pub mod csp;
#[allow(
clippy::wildcard_imports,

View file

@ -27,6 +27,7 @@ use tokio::time::interval;
use tracing::{error, info, warn};
use crate::bind::{self, Bind, BindRequest};
use crate::canonical_origin::validate_canonical_origin;
use crate::github_webhooks::{TailscaleFunnelManager, WEBHOOK_ROUTE, WEBHOOK_SECRET_ENV};
use crate::ip_allowlist::{GitHubMetaResolver, IpAllowlistConfig, resolve_ip_allowlist_config};
use crate::jwt_auth::resolve_auth_mode_with_lookup;
@ -510,6 +511,8 @@ where
build_artifact_object_store(&resolved_server_settings)?;
let artifact_store = fabro_store::ArtifactStore::new(artifact_object_store, artifact_prefix);
let env_lookup: EnvLookup = Arc::new(|name| std::env::var(name).ok());
validate_canonical_origin(&resolved_server_settings, &env_lookup)
.map_err(anyhow::Error::msg)?;
let state = build_app_state(AppStateConfig {
settings: Arc::clone(&shared_settings),
registry_factory_override: None,
@ -607,14 +610,8 @@ where
.expect("config lock poisoned");
*cfg != effective
};
if changed {
match state_for_poll.replace_settings(effective) {
Ok(()) => info!("Server config reloaded"),
Err(error) => warn!(
error = %error,
"Failed to resolve reloaded server config, keeping previous"
),
}
if changed && state_for_poll.replace_settings(effective).is_ok() {
info!("Server config reloaded");
}
}
Err(e) => {

View file

@ -108,6 +108,7 @@ use ulid::Ulid;
use crate::auth::{self, GithubEndpoints, auth_translation_middleware, demo_routing_middleware};
use crate::bind::Bind;
use crate::canonical_origin::{resolve_canonical_origin, validate_canonical_origin};
use crate::error::ApiError;
use crate::github_webhooks::{
WEBHOOK_ROUTE, WEBHOOK_SECRET_ENV, parse_event_metadata, verify_signature,
@ -575,6 +576,7 @@ pub struct AppState {
pub(crate) settings: Arc<RwLock<SettingsLayer>>,
pub(crate) server_settings: RwLock<Arc<ResolvedServerSettings>>,
pub(crate) local_daemon_mode: bool,
pub(crate) env_lookup: EnvLookup,
http_client: Option<fabro_http::HttpClient>,
shutting_down: AtomicBool,
registry_factory_override: Option<Box<RegistryFactoryOverride>>,
@ -691,6 +693,17 @@ impl AppState {
self.server_secrets.get(name)
}
pub(crate) fn resolve_interp(&self, value: &InterpString) -> anyhow::Result<String> {
value
.resolve(|name| (self.env_lookup)(name))
.map(|resolved| resolved.value)
.map_err(anyhow::Error::from)
}
pub(crate) fn canonical_origin(&self) -> Result<String, String> {
resolve_canonical_origin(&self.server_settings(), &self.env_lookup)
}
pub(crate) fn session_key(&self) -> Option<Key> {
self.server_secret("SESSION_SECRET")
.and_then(|value| auth::derive_cookie_key(value.as_bytes()).ok())
@ -780,6 +793,15 @@ impl AppState {
.join("\n")
)
})?);
let resolved_ref = Arc::as_ref(&resolved);
if let Err(error) = validate_canonical_origin(resolved_ref, &self.env_lookup) {
let error = anyhow::anyhow!(error);
warn!(
error = %error,
"Failed to resolve reloaded server config, keeping previous"
);
return Err(error);
}
*self.settings.write().expect("settings lock poisoned") = settings;
*self
@ -965,12 +987,9 @@ pub fn build_router_with_options(
let api_common = if web_enabled {
Router::new()
.route("/openapi.json", get(openapi_spec))
.merge(auth::api_routes())
.merge(web_auth::api_routes())
} else {
Router::new()
.route("/openapi.json", get(openapi_spec))
.merge(auth::api_routes())
Router::new().route("/openapi.json", get(openapi_spec))
};
let demo_router = Router::new()
@ -2674,6 +2693,7 @@ pub(crate) fn build_app_state(config: AppStateConfig) -> anyhow::Result<Arc<AppS
settings,
server_settings: RwLock::new(resolved_server_settings),
local_daemon_mode,
env_lookup: Arc::clone(&env_lookup),
http_client,
shutting_down: AtomicBool::new(false),
registry_factory_override,
@ -7297,6 +7317,7 @@ mod tests {
use std::path::{Path, PathBuf};
#[cfg(unix)]
use std::process::Stdio;
use std::sync::{Arc as StdArc, Mutex as StdMutex};
use axum::body::Body;
use axum::http::{Request, header};
@ -7306,6 +7327,10 @@ mod tests {
use fabro_types::{InterviewQuestionRecord, InterviewQuestionType, RunBlobId, RunId, fixtures};
use serde_json::json;
use tower::ServiceExt;
use tracing::field::{Field, Visit};
use tracing::{Event, Subscriber};
use tracing_subscriber::layer::{Context, SubscriberExt};
use tracing_subscriber::{Layer, Registry};
use super::*;
use crate::github_webhooks::compute_signature;
@ -7413,6 +7438,66 @@ mod tests {
})
}
#[derive(Debug)]
struct LogCapture {
level: tracing::Level,
target: String,
fields: Vec<(String, String)>,
}
#[derive(Default)]
struct LogCaptureVisitor {
fields: Vec<(String, String)>,
}
impl Visit for LogCaptureVisitor {
fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
self.fields
.push((field.name().to_string(), format!("{value:?}")));
}
}
struct LogCaptureLayer {
events: StdArc<StdMutex<Vec<LogCapture>>>,
}
impl<S: Subscriber> Layer<S> for LogCaptureLayer {
fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) {
if event.metadata().target() != "fabro_server::server" {
return;
}
let mut visitor = LogCaptureVisitor::default();
event.record(&mut visitor);
self.events.lock().unwrap().push(LogCapture {
level: *event.metadata().level(),
target: event.metadata().target().to_string(),
fields: visitor.fields,
});
}
}
fn capture_logs<T>(f: impl FnOnce() -> T) -> (T, StdArc<StdMutex<Vec<LogCapture>>>) {
let events = StdArc::new(StdMutex::new(Vec::<LogCapture>::new()));
let subscriber = Registry::default().with(LogCaptureLayer {
events: StdArc::clone(&events),
});
let result = tracing::subscriber::with_default(subscriber, f);
(result, events)
}
fn canonical_origin_settings(url: &str) -> SettingsLayer {
fabro_config::parse_settings_layer(&format!(
r#"
_version = 1
[server.web]
url = "{url}"
"#
))
.expect("settings fixture should parse")
}
#[tokio::test]
async fn resolved_settings_view_returns_internal_error_when_runtime_settings_stop_resolving() {
let state = create_app_state();
@ -7441,6 +7526,47 @@ type = "http"
assert_status!(response, StatusCode::INTERNAL_SERVER_ERROR).await;
}
#[test]
fn replace_settings_rejects_invalid_canonical_origin_and_keeps_previous_settings() {
for invalid in ["", "/relative/path", "ftp://fabro.example.com"] {
let state = create_app_state_with_env_lookup(
canonical_origin_settings("http://valid.example.com"),
5,
{
let invalid = invalid.to_string();
move |name| (name == "FABRO_WEB_URL").then(|| invalid.clone())
},
);
let (result, logs) = capture_logs(|| {
state.replace_settings(canonical_origin_settings("{{ env.FABRO_WEB_URL }}"))
});
let err = result.expect_err("invalid canonical origin should be rejected");
assert!(
err.to_string()
.contains("server.web.url is required and must be an absolute http(s) URL"),
"unexpected error for {invalid}: {err}"
);
assert_eq!(
state.canonical_origin().unwrap(),
"http://valid.example.com".to_string()
);
let logs = logs.lock().unwrap();
assert!(logs.iter().any(|event| {
event.level == tracing::Level::WARN
&& event.target == "fabro_server::server"
&& event.fields.iter().any(|(name, value)| {
name == "message"
&& value.contains(
"Failed to resolve reloaded server config, keeping previous",
)
})
}));
}
}
#[tokio::test]
async fn create_secret_stores_file_secret_and_excludes_it_from_snapshot() {
let state = create_app_state();

View file

@ -16,7 +16,9 @@ use serde_json::json;
use tracing::{debug, error, info, warn};
use crate::auth::GithubEndpoints;
use crate::jwt_auth::{AuthMode, AuthenticatedSubject, auth_method_name, dev_token_matches};
use crate::jwt_auth::{
AuthMode, AuthenticatedService, AuthenticatedSubject, auth_method_name, dev_token_matches,
};
use crate::server::AppState;
pub const SESSION_COOKIE_NAME: &str = "__fabro_session";
@ -255,11 +257,8 @@ fn callback_error_redirect(
}
}
fn resolve_interp(value: &InterpString) -> anyhow::Result<String> {
value
.resolve(|name| std::env::var(name).ok())
.map(|resolved| resolved.value)
.map_err(anyhow::Error::from)
fn resolve_interp(state: &AppState, value: &InterpString) -> anyhow::Result<String> {
state.resolve_interp(value)
}
fn auth_methods_from_mode(auth_mode: &AuthMode) -> Vec<String> {
@ -386,7 +385,7 @@ async fn login_github(
json!({"error": "GitHub App client_id is not configured"}),
);
};
let client_id = match resolve_interp(client_id) {
let client_id = match resolve_interp(state.as_ref(), client_id) {
Ok(client_id) => client_id,
Err(err) => {
warn!(error = %err, "OAuth login failed: client_id could not be resolved");
@ -396,7 +395,7 @@ async fn login_github(
);
}
};
let web_url = match resolve_interp(&settings.web.url) {
let web_url = match resolve_interp(state.as_ref(), &settings.web.url) {
Ok(web_url) => web_url,
Err(err) => {
warn!(error = %err, "OAuth login failed: server.web.url could not be resolved");
@ -538,7 +537,7 @@ async fn callback_github(
json!({"error": "GitHub App client_id is not configured"}),
);
};
let client_id = match resolve_interp(client_id) {
let client_id = match resolve_interp(state.as_ref(), client_id) {
Ok(client_id) => client_id,
Err(err) => {
error!(error = %err, "OAuth callback failed: client_id could not be resolved");
@ -555,7 +554,7 @@ async fn callback_github(
json!({"error": "GITHUB_APP_CLIENT_SECRET is not configured"}),
);
};
let web_url = match resolve_interp(&settings.web.url) {
let web_url = match resolve_interp(state.as_ref(), &settings.web.url) {
Ok(web_url) => web_url,
Err(err) => {
error!(error = %err, "OAuth callback failed: server.web.url could not be resolved");
@ -820,6 +819,7 @@ async fn auth_me(subject: AuthenticatedSubject, headers: HeaderMap) -> Response
}
async fn toggle_demo(
_auth: AuthenticatedService,
State(state): State<Arc<AppState>>,
Json(payload): Json<DemoToggleRequest>,
) -> Response {

View file

@ -1,171 +0,0 @@
use std::sync::Arc;
use axum::body::Body;
use axum::http::{Request, StatusCode};
use fabro_config::parse_settings_layer;
use fabro_server::ip_allowlist::IpAllowlistConfig;
use fabro_server::jwt_auth::{AuthMode, ConfiguredAuth};
use fabro_server::server::{
RouterOptions, build_router_with_options, create_app_state_with_options,
};
use fabro_types::settings::ServerAuthMethod;
use tower::ServiceExt;
use crate::helpers::body_json;
fn settings(source: &str) -> fabro_types::settings::SettingsLayer {
parse_settings_layer(source).expect("fixture should parse")
}
fn build_app(
settings: fabro_types::settings::SettingsLayer,
auth_mode: &AuthMode,
options: RouterOptions,
) -> axum::Router {
build_router_with_options(
create_app_state_with_options(settings, 5),
auth_mode,
Arc::new(IpAllowlistConfig::default()),
options,
)
}
#[tokio::test]
async fn cli_auth_config_reports_enabled_github_login() {
let app = build_app(
settings(
r#"
_version = 1
[server.auth]
methods = ["github"]
[server.auth.github]
allowed_usernames = ["alice"]
[server.web]
url = "https://fabro.example"
[server.integrations.github]
client_id = "Iv1.test"
"#,
),
&AuthMode::Enabled(ConfiguredAuth::new(vec![ServerAuthMethod::Github], None)),
RouterOptions::default(),
);
let response = app
.oneshot(
Request::builder()
.uri("/api/v1/auth/cli/config")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = body_json(response.into_body()).await;
assert_eq!(
body,
serde_json::json!({
"enabled": true,
"web_url": "https://fabro.example",
"methods": ["github"]
})
);
}
#[tokio::test]
async fn cli_auth_config_reports_github_not_enabled() {
let app = build_app(
settings(
r#"
_version = 1
[server.auth]
methods = ["dev-token"]
"#,
),
&AuthMode::Enabled(ConfiguredAuth::new(vec![ServerAuthMethod::DevToken], None)),
RouterOptions::default(),
);
let response = app
.oneshot(
Request::builder()
.uri("/api/v1/auth/cli/config")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = body_json(response.into_body()).await;
assert_eq!(
body,
serde_json::json!({
"enabled": false,
"web_url": null,
"methods": ["dev-token"],
"reason": "github_not_enabled"
})
);
}
#[tokio::test]
async fn cli_auth_config_reports_web_not_enabled_and_api_mount_survives() {
let app = build_app(
settings(
r#"
_version = 1
[server.auth]
methods = ["dev-token"]
[server.web]
enabled = false
"#,
),
&AuthMode::Enabled(ConfiguredAuth::new(vec![ServerAuthMethod::DevToken], None)),
RouterOptions {
web_enabled: false,
..RouterOptions::default()
},
);
let config_response = app
.clone()
.oneshot(
Request::builder()
.uri("/api/v1/auth/cli/config")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(config_response.status(), StatusCode::OK);
let body = body_json(config_response.into_body()).await;
assert_eq!(
body,
serde_json::json!({
"enabled": false,
"web_url": null,
"methods": ["dev-token"],
"reason": "web_not_enabled"
})
);
let start_response = app
.oneshot(
Request::builder()
.uri("/auth/cli/start")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(start_response.status(), StatusCode::NOT_FOUND);
}

View file

@ -1,4 +1,3 @@
mod cli_auth_config;
mod cli_auth_token;
mod docs;
mod install;

View file

@ -4,9 +4,9 @@ use std::sync::Arc;
use axum::body::Body;
use axum::extract::ConnectInfo;
use axum::http::{Method, Request, StatusCode};
use fabro_config::parse_settings_layer;
use fabro_config::{parse_settings_layer, resolve_server_from_file};
use fabro_server::ip_allowlist::{IpAllowlist, IpAllowlistConfig};
use fabro_server::jwt_auth::AuthMode;
use fabro_server::jwt_auth::{AuthMode, resolve_auth_mode_with_lookup};
use fabro_server::server::{
RouterOptions, build_router, build_router_with_options, create_app_state,
create_app_state_with_options,
@ -16,6 +16,29 @@ use tower::ServiceExt;
use crate::helpers::{checked_response, response_json, response_status, response_text};
const DEV_TOKEN: &str =
"fabro_dev_abababababababababababababababababababababababababababababababab";
const SESSION_SECRET: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
fn dev_token_enabled_auth_mode() -> AuthMode {
let settings = parse_settings_layer(
r#"
_version = 1
[server.auth]
methods = ["dev-token"]
"#,
)
.expect("settings fixture should parse");
let resolved = resolve_server_from_file(&settings).expect("settings should resolve");
resolve_auth_mode_with_lookup(&resolved, |name| match name {
"SESSION_SECRET" => Some(SESSION_SECRET.to_string()),
"FABRO_DEV_TOKEN" => Some(DEV_TOKEN.to_string()),
_ => None,
})
.expect("auth mode should resolve")
}
#[tokio::test]
async fn old_unversioned_routes_return_404() {
let app = build_router(create_app_state(), AuthMode::Disabled);
@ -215,6 +238,59 @@ async fn web_enabled_serves_web_only_routes() {
.await;
}
#[tokio::test]
async fn toggle_demo_rejects_unauthenticated_requests() {
let app = build_router(create_app_state(), dev_token_enabled_auth_mode());
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/v1/demo/toggle")
.header("content-type", "application/json")
.body(Body::from(r#"{"enabled":true}"#))
.unwrap(),
)
.await
.unwrap();
response_status(
response,
StatusCode::UNAUTHORIZED,
"POST /api/v1/demo/toggle without auth",
)
.await;
}
#[tokio::test]
async fn toggle_demo_allows_authenticated_requests() {
let app = build_router(create_app_state(), dev_token_enabled_auth_mode());
let response = checked_response(
app.oneshot(
Request::builder()
.method("POST")
.uri("/api/v1/demo/toggle")
.header("authorization", format!("Bearer {DEV_TOKEN}"))
.header("content-type", "application/json")
.body(Body::from(r#"{"enabled":true}"#))
.unwrap(),
)
.await
.unwrap(),
StatusCode::OK,
"POST /api/v1/demo/toggle with dev token",
)
.await;
assert!(
response
.headers()
.get("set-cookie")
.and_then(|value| value.to_str().ok())
.is_some_and(|value| value.contains("fabro-demo=1")),
"authenticated demo toggle should set the demo cookie"
);
}
#[tokio::test]
async fn security_headers_are_applied_to_all_responses() {
let app = build_router(create_app_state(), AuthMode::Disabled);

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

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-q11nrnd3.js"></script>
<script type="module" src="/assets/entry-vy8xzak0.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>
@ -72,8 +72,8 @@
<script type="module" src="/assets/chunk-6ma2q84r.js"></script>
<script type="module" src="/assets/chunk-0tq24xt3.js"></script>
<script type="module" src="/assets/chunk-z512569f.js"></script>
<script type="module" src="/assets/chunk-7fpy4pmb.js"></script>
<script type="module" src="/assets/chunk-nghb2mxb.js"></script>
<script type="module" src="/assets/chunk-q2qv5qr4.js"></script>
<script type="module" src="/assets/chunk-tqzz87j8.js"></script>
<script type="module" src="/assets/chunk-w4txx8sc.js"></script>
<script type="module" src="/assets/chunk-eaexpy25.js"></script>
<script type="module" src="/assets/chunk-zb6gezq1.js"></script>

View file

@ -38,7 +38,6 @@ models/board-column-definition.ts
models/board-column.ts
models/check-run-status.ts
models/check-run.ts
models/cli-auth-config.ts
models/code-location.ts
models/completion-content-part.ts
models/completion-message.ts

View file

@ -22,8 +22,6 @@ import { DUMMY_BASE_URL, assertParamExists, setApiKeyToObject, setBasicAuthToObj
// @ts-ignore
import { BASE_PATH, COLLECTION_FORMATS, type RequestArgs, BaseAPI, RequiredError, operationServerMap } from '../base';
// @ts-ignore
import type { CliAuthConfig } from '../models';
// @ts-ignore
import type { DiagnosticsReport } from '../models';
// @ts-ignore
import type { ErrorResponse } from '../models';
@ -38,36 +36,6 @@ import type { UserResponse } from '../models';
*/
export const DiscoveryApiAxiosParamCreator = function (configuration?: Configuration) {
return {
/**
* Returns whether CLI OAuth login is available for this server and which web origin should handle the browser flow.
* @summary CLI Auth Configuration
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getCliAuthConfig: async (options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
const localVarPath = `/api/v1/auth/cli/config`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
localVarHeaderParameter['Accept'] = 'application/json';
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
* Returns service health status. Used by load balancers and monitoring.
* @summary Health Check
@ -239,18 +207,6 @@ export const DiscoveryApiAxiosParamCreator = function (configuration?: Configura
export const DiscoveryApiFp = function(configuration?: Configuration) {
const localVarAxiosParamCreator = DiscoveryApiAxiosParamCreator(configuration)
return {
/**
* Returns whether CLI OAuth login is available for this server and which web origin should handle the browser flow.
* @summary CLI Auth Configuration
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async getCliAuthConfig(options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<CliAuthConfig>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.getCliAuthConfig(options);
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
const localVarOperationServerBasePath = operationServerMap['DiscoveryApi.getCliAuthConfig']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
/**
* Returns service health status. Used by load balancers and monitoring.
* @summary Health Check
@ -320,15 +276,6 @@ export const DiscoveryApiFp = function(configuration?: Configuration) {
export const DiscoveryApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) {
const localVarFp = DiscoveryApiFp(configuration)
return {
/**
* Returns whether CLI OAuth login is available for this server and which web origin should handle the browser flow.
* @summary CLI Auth Configuration
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getCliAuthConfig(options?: RawAxiosRequestConfig): AxiosPromise<CliAuthConfig> {
return localVarFp.getCliAuthConfig(options).then((request) => request(axios, basePath));
},
/**
* Returns service health status. Used by load balancers and monitoring.
* @summary Health Check
@ -381,16 +328,6 @@ export const DiscoveryApiFactory = function (configuration?: Configuration, base
* DiscoveryApi - object-oriented interface
*/
export class DiscoveryApi extends BaseAPI {
/**
* Returns whether CLI OAuth login is available for this server and which web origin should handle the browser flow.
* @summary CLI Auth Configuration
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
public getCliAuthConfig(options?: RawAxiosRequestConfig) {
return DiscoveryApiFp(this.configuration).getCliAuthConfig(options).then((request) => request(this.axios, this.basePath));
}
/**
* Returns service health status. Used by load balancers and monitoring.
* @summary Health Check

View file

@ -1,38 +0,0 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
/**
* Capability and routing information for CLI OAuth login.
*/
export interface CliAuthConfig {
/**
* Whether CLI OAuth login is currently available.
*/
'enabled': boolean;
/**
* Canonical browser origin for the OAuth flow when enabled.
*/
'web_url': string | null;
/**
* Authentication methods configured on the server.
*/
'methods': Array<string>;
/**
* Optional machine-readable reason when CLI OAuth login is unavailable. Known values currently include `github_not_enabled` and `web_not_enabled`.
*/
'reason'?: string | null;
}

View file

@ -18,7 +18,6 @@ export * from './board-column';
export * from './board-column-definition';
export * from './check-run';
export * from './check-run-status';
export * from './cli-auth-config';
export * from './code-location';
export * from './completion-content-part';
export * from './completion-message';