mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-11-11 08:37:16 +00:00
feat(server): derive GitHub OAuth, CSP and install URLs from the configured host
This commit is contained in:
parent
b981b6fea0
commit
8eaaa1f2c2
9 changed files with 229 additions and 66 deletions
|
|
@ -224,6 +224,7 @@ async fn run_install_mode(bootstrap: InstallBootstrap, printer: Printer) -> Resu
|
|||
&bootstrap.storage_dir,
|
||||
&bootstrap.config_path,
|
||||
)
|
||||
.with_github_host(crate::user_config::github_host()?)
|
||||
.with_finish_hook(persist_install_dev_token_hook());
|
||||
install::serve_install_command(bootstrap.bind_request, state, move |bind| {
|
||||
announce_install_mode(bind, &token, styles, printer);
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
)]
|
||||
|
||||
use fabro_http::Url;
|
||||
use fabro_types::GitHubHost;
|
||||
|
||||
#[doc(hidden)]
|
||||
#[derive(Debug, Clone)]
|
||||
|
|
@ -13,11 +14,12 @@ pub struct GithubEndpoints {
|
|||
}
|
||||
|
||||
impl GithubEndpoints {
|
||||
pub fn production_defaults() -> Self {
|
||||
pub fn from_host(host: &GitHubHost) -> Self {
|
||||
Self {
|
||||
oauth_base: Url::parse("https://github.com/").expect("github oauth base should parse"),
|
||||
api_base: Url::parse("https://api.github.com/")
|
||||
.expect("github api base should parse"),
|
||||
oauth_base: Url::parse(&format!("{}/", host.web_url()))
|
||||
.expect("GitHubHost web_url should parse as a URL"),
|
||||
api_base: Url::parse(&format!("{}/", host.api_url()))
|
||||
.expect("GitHubHost api_url should parse as a URL"),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -32,16 +34,42 @@ impl GithubEndpoints {
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use fabro_types::GitHubHost;
|
||||
|
||||
use super::GithubEndpoints;
|
||||
|
||||
#[test]
|
||||
fn production_defaults_match_current_github_urls() {
|
||||
let endpoints = GithubEndpoints::production_defaults();
|
||||
fn default_host_matches_current_github_urls() {
|
||||
let endpoints = GithubEndpoints::from_host(&GitHubHost::default());
|
||||
|
||||
assert_eq!(endpoints.oauth_base.as_str(), "https://github.com/");
|
||||
assert_eq!(endpoints.api_base.as_str(), "https://api.github.com/");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enterprise_host_uses_its_web_origin_and_api_url() {
|
||||
let host = GitHubHost::new("https://ghe.example.com", None).unwrap();
|
||||
let endpoints = GithubEndpoints::from_host(&host);
|
||||
|
||||
assert_eq!(endpoints.oauth_base.as_str(), "https://ghe.example.com/");
|
||||
assert_eq!(
|
||||
endpoints.api_base.as_str(),
|
||||
"https://ghe.example.com/api/v3/"
|
||||
);
|
||||
assert_eq!(
|
||||
endpoints
|
||||
.oauth_base
|
||||
.join("login/oauth/authorize")
|
||||
.unwrap()
|
||||
.as_str(),
|
||||
"https://ghe.example.com/login/oauth/authorize"
|
||||
);
|
||||
assert_eq!(
|
||||
endpoints.api_base.join("user").unwrap().as_str(),
|
||||
"https://ghe.example.com/api/v3/user"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_bases_uses_custom_urls() {
|
||||
let endpoints = GithubEndpoints::with_bases(
|
||||
|
|
|
|||
|
|
@ -4,24 +4,17 @@
|
|||
//! `index.html` so any inline `<script>` hashes don't drift from the
|
||||
//! template. Third-party sources are enumerated explicitly.
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use base64::Engine as _;
|
||||
use base64::engine::general_purpose::STANDARD;
|
||||
use fabro_types::GitHubHost;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
static POLICY: OnceLock<String> = OnceLock::new();
|
||||
|
||||
pub(crate) const INSTALL_MODE_SCRIPT_BODY: &str = "window.__FABRO_MODE__ = \"install\";";
|
||||
|
||||
pub fn policy() -> &'static str {
|
||||
POLICY.get_or_init(build_policy)
|
||||
}
|
||||
|
||||
fn build_policy() -> String {
|
||||
pub fn policy(host: &GitHubHost) -> String {
|
||||
let mut script_hashes = inline_script_hashes_from_embedded_index();
|
||||
script_hashes.push(script_hash(INSTALL_MODE_SCRIPT_BODY));
|
||||
build_policy_with_hashes(&script_hashes)
|
||||
build_policy_with_hashes(&script_hashes, host)
|
||||
}
|
||||
|
||||
fn inline_script_hashes_from_embedded_index() -> Vec<String> {
|
||||
|
|
@ -70,7 +63,18 @@ fn script_hash(script: &str) -> String {
|
|||
format!("sha256-{}", STANDARD.encode(hash))
|
||||
}
|
||||
|
||||
fn build_policy_with_hashes(script_hashes: &[String]) -> String {
|
||||
/// Avatars of a GitHub Enterprise host are served from the host itself
|
||||
/// (`https://avatars.<host>`) or from its web origin, not from
|
||||
/// githubusercontent.com.
|
||||
fn github_img_sources(host: &GitHubHost) -> String {
|
||||
if host.is_github_com() {
|
||||
"https://avatars.githubusercontent.com".to_string()
|
||||
} else {
|
||||
format!("{} https://avatars.{}", host.web_url(), host.host())
|
||||
}
|
||||
}
|
||||
|
||||
fn build_policy_with_hashes(script_hashes: &[String], host: &GitHubHost) -> String {
|
||||
let inline_script_sources = if script_hashes.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
|
|
@ -92,7 +96,7 @@ fn build_policy_with_hashes(script_hashes: &[String]) -> String {
|
|||
// terminal WebSocket working across browsers that do not treat `'self'`
|
||||
// as matching WebSocket schemes for connect-src. `frame-src https:`
|
||||
// allows signed sandbox VNC preview iframes from dynamic Daytona preview
|
||||
// hosts. `https://github.com` in form-action allows the install-mode
|
||||
// hosts. The GitHub web origin in form-action allows the install-mode
|
||||
// GitHub App manifest POST handoff. Loopback resume targets keep CLI
|
||||
// OAuth working when the browser-visible origin and canonical origin use
|
||||
// different loopback hostnames on a dynamically chosen port.
|
||||
|
|
@ -101,15 +105,17 @@ fn build_policy_with_hashes(script_hashes: &[String]) -> String {
|
|||
script-src 'self'{inline_script_sources} 'wasm-unsafe-eval'; \
|
||||
style-src 'self' https://fonts.googleapis.com 'unsafe-inline'; \
|
||||
font-src 'self' https://fonts.gstatic.com; \
|
||||
img-src 'self' data: blob: https://avatars.githubusercontent.com; \
|
||||
img-src 'self' data: blob: {img_sources}; \
|
||||
connect-src 'self' ws: wss:; \
|
||||
worker-src 'self' blob:; \
|
||||
frame-src 'self' https:; \
|
||||
manifest-src 'self'; \
|
||||
frame-ancestors 'none'; \
|
||||
base-uri 'self'; \
|
||||
form-action 'self' https://github.com http://127.0.0.1:*/auth/cli/resume http://localhost:*/auth/cli/resume; \
|
||||
object-src 'none'"
|
||||
form-action 'self' {web_url} http://127.0.0.1:*/auth/cli/resume http://localhost:*/auth/cli/resume; \
|
||||
object-src 'none'",
|
||||
img_sources = github_img_sources(host),
|
||||
web_url = host.web_url(),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -151,7 +157,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn policy_allows_install_mode_inline_bootstrap() {
|
||||
let policy = build_policy();
|
||||
let policy = policy(&GitHubHost::default());
|
||||
let expected_hash = script_hash(INSTALL_MODE_SCRIPT_BODY);
|
||||
assert!(
|
||||
policy.contains(&format!("'{expected_hash}'")),
|
||||
|
|
@ -161,7 +167,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn policy_allows_cli_loopback_resume_form_posts() {
|
||||
let policy = build_policy_with_hashes(&[]);
|
||||
let policy = build_policy_with_hashes(&[], &GitHubHost::default());
|
||||
assert!(
|
||||
policy.contains("form-action 'self' https://github.com http://127.0.0.1:*/auth/cli/resume http://localhost:*/auth/cli/resume"),
|
||||
"CLI auth can submit the resume form to the canonical loopback origin on a dynamic port: {policy}"
|
||||
|
|
@ -170,7 +176,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn policy_is_constructed_with_expected_directives() {
|
||||
let policy = build_policy_with_hashes(&["sha256-abc".to_string()]);
|
||||
let policy = build_policy_with_hashes(&["sha256-abc".to_string()], &GitHubHost::default());
|
||||
assert!(policy.contains("default-src 'self'"));
|
||||
assert!(
|
||||
policy.contains("script-src 'self' 'sha256-abc' 'wasm-unsafe-eval'"),
|
||||
|
|
@ -191,9 +197,31 @@ mod tests {
|
|||
assert!(policy.contains("object-src 'none'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn github_com_policy_is_unchanged() {
|
||||
let policy = build_policy_with_hashes(&[], &GitHubHost::default());
|
||||
assert!(policy.contains(
|
||||
"img-src 'self' data: blob: https://avatars.githubusercontent.com; connect-src"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enterprise_policy_uses_the_configured_web_origin() {
|
||||
let host = GitHubHost::new("https://ghe.example.com", None).unwrap();
|
||||
let policy = build_policy_with_hashes(&[], &host);
|
||||
assert!(policy.contains(
|
||||
"img-src 'self' data: blob: https://ghe.example.com https://avatars.ghe.example.com; connect-src"
|
||||
));
|
||||
assert!(policy.contains(
|
||||
"form-action 'self' https://ghe.example.com http://127.0.0.1:*/auth/cli/resume"
|
||||
));
|
||||
assert!(!policy.contains("https://github.com"));
|
||||
assert!(!policy.contains("githubusercontent.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn policy_is_valid_without_inline_scripts() {
|
||||
let policy = build_policy_with_hashes(&[]);
|
||||
let policy = build_policy_with_hashes(&[], &GitHubHost::default());
|
||||
assert!(
|
||||
policy.contains("script-src 'self' 'wasm-unsafe-eval'"),
|
||||
"empty hash list should not produce a stray space: {policy}"
|
||||
|
|
@ -204,7 +232,7 @@ mod tests {
|
|||
fn embedded_spa_index_builds_a_policy() {
|
||||
// Guards against the embedded asset going missing or becoming
|
||||
// unreadable in a way that would leave the CSP header blank.
|
||||
let policy = build_policy();
|
||||
let policy = policy(&GitHubHost::default());
|
||||
assert!(
|
||||
policy.contains("script-src 'self'"),
|
||||
"embedded SPA policy should contain a script-src directive"
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ use fabro_static::EnvVars;
|
|||
use fabro_store::ArtifactStore;
|
||||
use fabro_types::settings::server::ObjectStoreSettings;
|
||||
use fabro_types::settings::{is_wildcard_host, validate_public_url_with_label};
|
||||
use fabro_types::{SandboxProviderKind, ServerSettings};
|
||||
use fabro_types::{GitHubHost, SandboxProviderKind, ServerSettings};
|
||||
use fabro_util::version::FABRO_VERSION;
|
||||
use fabro_util::{Home, session_secret};
|
||||
use fabro_vault::SecretType as VaultSecretType;
|
||||
|
|
@ -52,7 +52,7 @@ use crate::sandbox_access::{
|
|||
};
|
||||
use crate::serve::{self, DEFAULT_TCP_PORT};
|
||||
use crate::server_secrets::{ServerSecrets, process_env_snapshot};
|
||||
use crate::{security_headers, server, static_files};
|
||||
use crate::{csp, security_headers, server, static_files};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct InstallAppState {
|
||||
|
|
@ -65,6 +65,7 @@ pub struct InstallAppState {
|
|||
first_operator: Arc<Mutex<Option<InstallOperatorFingerprint>>>,
|
||||
finish_in_progress: Arc<AtomicBool>,
|
||||
upstreams: InstallUpstreamConfig,
|
||||
github_host: GitHubHost,
|
||||
static_asset_root: Option<Arc<Path>>,
|
||||
on_finish: Option<Arc<dyn Fn() + Send + Sync>>,
|
||||
finish_hook: Option<InstallFinishHook>,
|
||||
|
|
@ -91,7 +92,6 @@ struct InstallOperatorFingerprint {
|
|||
remote_ip: Option<String>,
|
||||
}
|
||||
|
||||
pub const DEFAULT_INSTALL_GITHUB_API_BASE_URL: &str = "https://api.github.com";
|
||||
const DEFAULT_INSTALL_TCP_LISTEN_ADDRESS: &str = "127.0.0.1:32276";
|
||||
const REDACTED_SECRET_VALUE: &str = "[REDACTED]";
|
||||
const VALIDATION_TIMEOUT: Duration = Duration::from_secs(20);
|
||||
|
|
@ -115,6 +115,7 @@ impl InstallAppState {
|
|||
first_operator: Arc::new(Mutex::new(None)),
|
||||
finish_in_progress: Arc::new(AtomicBool::new(false)),
|
||||
upstreams: InstallUpstreamConfig::default(),
|
||||
github_host: GitHubHost::default(),
|
||||
static_asset_root: None,
|
||||
on_finish: None,
|
||||
finish_hook: None,
|
||||
|
|
@ -157,6 +158,7 @@ impl InstallAppState {
|
|||
first_operator: Arc::new(Mutex::new(None)),
|
||||
finish_in_progress: Arc::new(AtomicBool::new(false)),
|
||||
upstreams: InstallUpstreamConfig::default(),
|
||||
github_host: GitHubHost::default(),
|
||||
static_asset_root: None,
|
||||
on_finish: None,
|
||||
finish_hook: None,
|
||||
|
|
@ -203,6 +205,12 @@ impl InstallAppState {
|
|||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_github_host(mut self, host: GitHubHost) -> Self {
|
||||
self.github_host = host;
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_github_api_base_url(mut self, base_url: impl Into<String>) -> Self {
|
||||
self.upstreams.github_api_base_url = Some(base_url.into());
|
||||
|
|
@ -566,11 +574,12 @@ enum GitHubAppOwner {
|
|||
}
|
||||
|
||||
impl GitHubAppOwner {
|
||||
fn manifest_form_action(&self) -> String {
|
||||
fn manifest_form_action(&self, host: &GitHubHost) -> String {
|
||||
let web_url = host.web_url();
|
||||
match self {
|
||||
Self::Personal => "https://github.com/settings/apps/new".to_string(),
|
||||
Self::Personal => format!("{web_url}/settings/apps/new"),
|
||||
Self::Organization(org) => {
|
||||
format!("https://github.com/organizations/{org}/settings/apps/new")
|
||||
format!("{web_url}/organizations/{org}/settings/apps/new")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -602,6 +611,7 @@ impl TryFrom<GithubAppOwnerInput> for GitHubAppOwner {
|
|||
}
|
||||
|
||||
pub fn build_install_router(state: InstallAppState) -> Router {
|
||||
let csp_policy = Arc::new(csp::policy(&state.github_host));
|
||||
let static_asset_root = state.static_asset_root.clone();
|
||||
|
||||
Router::new()
|
||||
|
|
@ -670,7 +680,10 @@ pub fn build_install_router(state: InstallAppState) -> Router {
|
|||
// router; a first-run setup over a slow link needs compression just
|
||||
// as much.
|
||||
.layer(server::compression_layer())
|
||||
.layer(middleware::from_fn(security_headers::layer))
|
||||
.layer(middleware::from_fn_with_state(
|
||||
csp_policy,
|
||||
security_headers::layer,
|
||||
))
|
||||
}
|
||||
|
||||
struct InstallFinishGuard {
|
||||
|
|
@ -1422,7 +1435,7 @@ async fn post_install_github_app_manifest(
|
|||
|
||||
Json(serde_json::json!({
|
||||
"manifest": manifest,
|
||||
"github_form_action": owner.manifest_form_action(),
|
||||
"github_form_action": owner.manifest_form_action(&state.github_host),
|
||||
"state": state_token,
|
||||
}))
|
||||
.into_response()
|
||||
|
|
@ -2178,7 +2191,7 @@ async fn validate_github_token(state: &InstallAppState, token: &str) -> anyhow::
|
|||
.upstreams
|
||||
.github_api_base_url
|
||||
.clone()
|
||||
.unwrap_or_else(|| DEFAULT_INSTALL_GITHUB_API_BASE_URL.to_string());
|
||||
.unwrap_or_else(|| state.github_host.api_url().to_string());
|
||||
let endpoint = install_upstream_endpoint(&base_url, &["user"])?;
|
||||
let client = install_http_client_for_url(&base_url)?;
|
||||
let response = client
|
||||
|
|
@ -2210,7 +2223,7 @@ async fn exchange_github_app_manifest_code(
|
|||
.upstreams
|
||||
.github_api_base_url
|
||||
.clone()
|
||||
.unwrap_or_else(|| DEFAULT_INSTALL_GITHUB_API_BASE_URL.to_string());
|
||||
.unwrap_or_else(|| state.github_host.api_url().to_string());
|
||||
let endpoint = install_upstream_endpoint(&base_url, &["app-manifests", code, "conversions"])?;
|
||||
let client = install_http_client_for_url(&base_url)?;
|
||||
let response = client
|
||||
|
|
@ -2319,15 +2332,15 @@ mod tests {
|
|||
use serde_json::json;
|
||||
|
||||
use super::{
|
||||
DEFAULT_INSTALL_GITHUB_API_BASE_URL, GitHubAppOwner, GithubAppInstall, GithubInstallState,
|
||||
InstallAppState, InstallAwsCredentialPair, InstallFinishGuard,
|
||||
InstallObjectStoreCredentialMode, InstallObjectStoreInput, InstallObjectStoreProvider,
|
||||
InstallObjectStoreState, InstallSandboxProviderState, InstallSandboxState,
|
||||
InstallTokenQuery, LlmProvidersInput, PendingInstall, ServerConfigInput, ServerSecrets,
|
||||
build_github_app_manifest, classify_object_store_validation_error, detect_canonical_url,
|
||||
install_catalog_provider, install_object_store_lookup, lock_unpoisoned,
|
||||
post_install_finish, provider_base_url_override, resolve_install_object_store_state,
|
||||
token_is_valid, write_artifact_store_metadata,
|
||||
GitHubAppOwner, GitHubHost, GithubAppInstall, GithubInstallState, InstallAppState,
|
||||
InstallAwsCredentialPair, InstallFinishGuard, InstallObjectStoreCredentialMode,
|
||||
InstallObjectStoreInput, InstallObjectStoreProvider, InstallObjectStoreState,
|
||||
InstallSandboxProviderState, InstallSandboxState, InstallTokenQuery, LlmProvidersInput,
|
||||
PendingInstall, ServerConfigInput, ServerSecrets, build_github_app_manifest,
|
||||
classify_object_store_validation_error, detect_canonical_url, install_catalog_provider,
|
||||
install_object_store_lookup, lock_unpoisoned, post_install_finish,
|
||||
provider_base_url_override, resolve_install_object_store_state, token_is_valid,
|
||||
write_artifact_store_metadata,
|
||||
};
|
||||
|
||||
#[test]
|
||||
|
|
@ -2529,10 +2542,38 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn install_github_requests_default_to_fixed_github_api_base_url() {
|
||||
fn install_github_requests_default_to_the_host_api_url() {
|
||||
let state = InstallAppState::for_test("expected");
|
||||
assert_eq!(state.github_host.api_url(), "https://api.github.com");
|
||||
let state =
|
||||
state.with_github_host(GitHubHost::new("https://ghe.example.com", None).unwrap());
|
||||
assert_eq!(
|
||||
DEFAULT_INSTALL_GITHUB_API_BASE_URL,
|
||||
"https://api.github.com"
|
||||
state.github_host.api_url(),
|
||||
"https://ghe.example.com/api/v3"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_form_action_targets_the_host_web_origin() {
|
||||
let github_com = GitHubHost::default();
|
||||
let ghes = GitHubHost::new("https://ghe.example.com", None).unwrap();
|
||||
let org = GitHubAppOwner::Organization("acme".to_string());
|
||||
|
||||
assert_eq!(
|
||||
GitHubAppOwner::Personal.manifest_form_action(&github_com),
|
||||
"https://github.com/settings/apps/new"
|
||||
);
|
||||
assert_eq!(
|
||||
org.manifest_form_action(&github_com),
|
||||
"https://github.com/organizations/acme/settings/apps/new"
|
||||
);
|
||||
assert_eq!(
|
||||
GitHubAppOwner::Personal.manifest_form_action(&ghes),
|
||||
"https://ghe.example.com/settings/apps/new"
|
||||
);
|
||||
assert_eq!(
|
||||
org.manifest_form_action(&ghes),
|
||||
"https://ghe.example.com/organizations/acme/settings/apps/new"
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,26 +4,26 @@
|
|||
//! can still override any header by setting their own value first — the
|
||||
//! defaults here only fill in what's missing.
|
||||
|
||||
use axum::extract::Request;
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::{Request, State};
|
||||
use axum::http::{HeaderMap, HeaderName, HeaderValue, header};
|
||||
use axum::middleware::Next;
|
||||
use axum::response::Response;
|
||||
|
||||
use crate::csp;
|
||||
|
||||
pub async fn layer(req: Request, next: Next) -> Response {
|
||||
pub async fn layer(State(csp_policy): State<Arc<String>>, req: Request, next: Next) -> Response {
|
||||
let is_https = request_is_https(&req);
|
||||
let mut response = next.run(req).await;
|
||||
apply_defaults(response.headers_mut(), is_https);
|
||||
apply_csp(response.headers_mut());
|
||||
apply_csp(response.headers_mut(), &csp_policy);
|
||||
response
|
||||
}
|
||||
|
||||
fn apply_csp(headers: &mut HeaderMap) {
|
||||
fn apply_csp(headers: &mut HeaderMap, policy: &str) {
|
||||
// Report-only while we tune the policy: browsers evaluate violations and log
|
||||
// them to the console without blocking any resources. Switch back to the
|
||||
// enforcing `content-security-policy` header once the policy is clean.
|
||||
if let Ok(value) = HeaderValue::from_str(csp::policy()) {
|
||||
if let Ok(value) = HeaderValue::from_str(policy) {
|
||||
headers
|
||||
.entry(HeaderName::from_static(
|
||||
"content-security-policy-report-only",
|
||||
|
|
@ -202,7 +202,7 @@ mod tests {
|
|||
#[test]
|
||||
fn csp_is_report_only_not_enforced() {
|
||||
let mut headers = HeaderMap::new();
|
||||
apply_csp(&mut headers);
|
||||
apply_csp(&mut headers, "default-src 'self'");
|
||||
assert!(headers.contains_key("content-security-policy-report-only"));
|
||||
assert!(!headers.contains_key("content-security-policy"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -159,7 +159,7 @@ use crate::worker_control::{
|
|||
use crate::worker_runtime::{LocalWorkerRuntime, WorkerLaunchSpec, WorkerRef, WorkerRuntime};
|
||||
use crate::worker_token::{WorkerScopeSet, WorkerTokenKeys, issue_worker_token_with_scopes};
|
||||
use crate::{
|
||||
canonical_host, demo, diagnostics, run_manifest, security_headers, static_files, web_auth,
|
||||
canonical_host, csp, demo, diagnostics, run_manifest, security_headers, static_files, web_auth,
|
||||
};
|
||||
|
||||
mod automation_scheduler;
|
||||
|
|
@ -1924,8 +1924,9 @@ pub fn build_router_with_options(
|
|||
} = options;
|
||||
let translation_state = Arc::clone(&state);
|
||||
let state_for_canonical_host = Arc::clone(&state);
|
||||
let github_endpoints =
|
||||
github_endpoints.unwrap_or_else(|| Arc::new(GithubEndpoints::production_defaults()));
|
||||
let github_endpoints = github_endpoints
|
||||
.unwrap_or_else(|| Arc::new(GithubEndpoints::from_host(&state.github_host)));
|
||||
let csp_policy = Arc::new(csp::policy(&state.github_host));
|
||||
let webhook_secret = state.github_webhook_secret.clone();
|
||||
let principal_layer = middleware::from_fn_with_state(Arc::clone(&state), principal_middleware);
|
||||
let api_common = if web_enabled {
|
||||
|
|
@ -2032,7 +2033,10 @@ pub fn build_router_with_options(
|
|||
},
|
||||
canonical_host::redirect_middleware,
|
||||
))
|
||||
.layer(middleware::from_fn(security_headers::layer))
|
||||
.layer(middleware::from_fn_with_state(
|
||||
csp_policy,
|
||||
security_headers::layer,
|
||||
))
|
||||
.layer(middleware::from_fn(http_log_middleware))
|
||||
.layer(middleware::from_fn(request_id::layer))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -496,7 +496,10 @@ async fn get_github_repo(
|
|||
let install_url = creds
|
||||
.installation_url(&state.github_host, &owner)
|
||||
.unwrap_or_else(|| {
|
||||
format!("https://github.com/organizations/{owner}/settings/installations")
|
||||
format!(
|
||||
"{}/organizations/{owner}/settings/installations",
|
||||
state.github_host.web_url()
|
||||
)
|
||||
});
|
||||
|
||||
let client = match state.http_client() {
|
||||
|
|
|
|||
|
|
@ -6304,6 +6304,60 @@ slug = "fabro"
|
|||
assert!(location.starts_with("https://github.com/login/oauth/authorize?"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn auth_login_github_redirects_to_the_configured_enterprise_host() {
|
||||
let source = r#"
|
||||
_version = 1
|
||||
|
||||
[server.auth]
|
||||
methods = ["github"]
|
||||
|
||||
[server.web]
|
||||
enabled = true
|
||||
url = "http://localhost:3000"
|
||||
|
||||
[server.auth.github]
|
||||
allowed_usernames = ["octocat"]
|
||||
|
||||
[server.integrations.github]
|
||||
app_id = "123"
|
||||
client_id = "Iv1.testclient"
|
||||
slug = "fabro"
|
||||
web_url = "https://ghe.example.com"
|
||||
"#;
|
||||
let app = build_router(
|
||||
test_app_state_with_session_key(
|
||||
server_settings_from_toml(source),
|
||||
manifest_run_defaults_from_toml(source),
|
||||
Some("github-redirect-test-key-0123456789"),
|
||||
),
|
||||
AuthMode::Enabled(ConfiguredAuth {
|
||||
methods: vec![ServerAuthMethod::Github],
|
||||
dev_token: None,
|
||||
jwt_key: None,
|
||||
jwt_issuer: None,
|
||||
}),
|
||||
);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/auth/login/github")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let response = checked_response!(response, StatusCode::SEE_OTHER).await;
|
||||
let location = response
|
||||
.headers()
|
||||
.get(axum::http::header::LOCATION)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap();
|
||||
assert!(location.starts_with("https://ghe.example.com/login/oauth/authorize?"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn logout_redirects_to_login_page() {
|
||||
let app = test_app_with();
|
||||
|
|
|
|||
|
|
@ -762,12 +762,12 @@ async fn callback_github(
|
|||
v: 2,
|
||||
login: profile.login.clone(),
|
||||
auth_method: AuthMethod::Github,
|
||||
identity: IdpIdentity::new("https://github.com", profile.id.to_string())
|
||||
identity: IdpIdentity::new(state.github_host.web_url(), profile.id.to_string())
|
||||
.expect("GitHub profile id should produce a valid identity"),
|
||||
name: profile.name.unwrap_or_else(|| profile.login.clone()),
|
||||
email: primary_email,
|
||||
avatar_url: profile.avatar_url,
|
||||
user_url: format!("https://github.com/{}", profile.login),
|
||||
user_url: format!("{}/{}", state.github_host.web_url(), profile.login),
|
||||
iat: now.timestamp(),
|
||||
exp: (now + chrono::Duration::days(30)).timestamp(),
|
||||
};
|
||||
|
|
@ -997,7 +997,7 @@ mod tests {
|
|||
use fabro_config::{RunLayer, ServerSettingsBuilder};
|
||||
use fabro_static::EnvVars;
|
||||
use fabro_types::settings::server::ServerAuthMethod;
|
||||
use fabro_types::{AuthMethod, IdpIdentity, Principal};
|
||||
use fabro_types::{AuthMethod, GitHubHost, IdpIdentity, Principal};
|
||||
use fabro_vault::SecretType;
|
||||
use serde_json::json;
|
||||
use tower::ServiceExt;
|
||||
|
|
@ -1109,7 +1109,9 @@ client_id = "github-client-id"
|
|||
translation_state,
|
||||
crate::auth::auth_translation_middleware,
|
||||
))
|
||||
.layer(Extension(Arc::new(GithubEndpoints::production_defaults())))
|
||||
.layer(Extension(Arc::new(GithubEndpoints::from_host(
|
||||
&GitHubHost::default(),
|
||||
))))
|
||||
.layer(Extension(auth_mode))
|
||||
.with_state(state)
|
||||
}
|
||||
|
|
@ -1130,7 +1132,9 @@ client_id = "github-client-id"
|
|||
Arc::clone(&captured),
|
||||
crate::test_support::capture_auth_context,
|
||||
))
|
||||
.layer(Extension(Arc::new(GithubEndpoints::production_defaults())))
|
||||
.layer(Extension(Arc::new(GithubEndpoints::from_host(
|
||||
&GitHubHost::default(),
|
||||
))))
|
||||
.layer(Extension(auth_mode))
|
||||
.with_state(state);
|
||||
(app, captured)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue