mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-08-28 05:27:41 +00:00
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:
parent
720fbb210a
commit
537a5125cb
36 changed files with 2245 additions and 3211 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) |
|
||||
|
|
|
|||
|
|
@ -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]`
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
37
lib/crates/fabro-server/src/canonical_origin.rs
Normal file
37
lib/crates/fabro-server/src/canonical_origin.rs
Normal 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."
|
||||
)
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@
|
|||
|
||||
pub mod auth;
|
||||
pub mod bind;
|
||||
mod canonical_origin;
|
||||
pub mod csp;
|
||||
#[allow(
|
||||
clippy::wildcard_imports,
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -1,4 +1,3 @@
|
|||
mod cli_auth_config;
|
||||
mod cli_auth_token;
|
||||
mod docs;
|
||||
mod install;
|
||||
|
|
|
|||
|
|
@ -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
1
lib/crates/fabro-spa/assets/assets/chunk-q2qv5qr4.js
generated
Normal file
1
lib/crates/fabro-spa/assets/assets/chunk-q2qv5qr4.js
generated
Normal file
File diff suppressed because one or more lines are too long
1
lib/crates/fabro-spa/assets/assets/chunk-tqzz87j8.js
generated
Normal file
1
lib/crates/fabro-spa/assets/assets/chunk-tqzz87j8.js
generated
Normal file
File diff suppressed because one or more lines are too long
2402
lib/crates/fabro-spa/assets/assets/entry-q11nrnd3.js
generated
2402
lib/crates/fabro-spa/assets/assets/entry-q11nrnd3.js
generated
File diff suppressed because one or more lines are too long
1805
lib/crates/fabro-spa/assets/assets/entry-vy8xzak0.js
generated
Normal file
1805
lib/crates/fabro-spa/assets/assets/entry-vy8xzak0.js
generated
Normal file
File diff suppressed because one or more lines are too long
6
lib/crates/fabro-spa/assets/index.html
generated
6
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-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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue