diff --git a/CHANGELOG.md b/CHANGELOG.md index b7a81bc..369cdb5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,3 @@ - # Changelog All notable changes to this project will be documented in this file. @@ -6,19 +5,28 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- Implemented basic authentication for configurable endpoint paths (#73) + ## [1.2.0] - 2025-10-14 ### Changed + - Publisher origin backend now uses `publisher.origin_url` to dynamically create backends, deprecated `publisher.origin_backend` field - Prebid backend now uses `prebid.server_url` to dynamically create backends, deprecated `prebid.prebid_backend` field - Removed static backend definitions from `fastly.toml` for publisher and prebid ### Added + - Added `.rust-analyzer.json` for improved development environment support with Neovim/rust-analyzer ## [1.1.0] - 2025-10-05 ### Added + - Added basic unit tests - Added publisher config - Add AI assist rules. Based on https://github.com/hashintel/hash @@ -31,6 +39,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added Trusted Server TSJS SDK with bundled build, lint, and test tools for serving creatives in first-party domain ### Changed + - Upgrade to rust 1.90.0 - Upgrade to fastly-cli 12.0.0 - Changed to use constants for headers @@ -41,14 +50,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added TypeScript CI lint, format, and test jobs for TSJS ### Fixed + - Rebuild when `TRUSTED_SERVER__*` env variables change ## [1.0.6] - 2025-05-29 ### Changed + - Remove hard coded Fast ID in fastly.tom - Updated README to better describe what Trusted Server does and high-level goal -- Use Rust toolchain version from .tool-versions for GitHub actions +- Use Rust toolchain version from .tool-versions for GitHub actions ## [1.0.5] - 2025-05-19 @@ -77,6 +88,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [1.0.2] - 2025-03-28 ### Added + - Documented project gogernance in [ProjectGovernance.md] - Document FAQ for POC [FAQ_POC.md] @@ -92,13 +104,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial implementation of Trusted Server -[Unreleased]:https://github.com/IABTechLab/trusted-server/compare/v1.2.0...HEAD -[1.2.0]:https://github.com/IABTechLab/trusted-server/compare/v1.1.0...v1.2.0 -[1.1.0]:https://github.com/IABTechLab/trusted-server/compare/v1.0.6...v1.1.0 -[1.0.6]:https://github.com/IABTechLab/trusted-server/compare/v1.0.5...v1.0.6 -[1.0.5]:https://github.com/IABTechLab/trusted-server/compare/v1.0.4...v1.0.5 -[1.0.4]:https://github.com/IABTechLab/trusted-server/compare/v1.0.3...v1.0.4 -[1.0.3]:https://github.com/IABTechLab/trusted-server/compare/v1.0.2...v1.0.3 -[1.0.2]:https://github.com/IABTechLab/trusted-server/compare/v1.0.1...v1.0.2 -[1.0.1]:https://github.com/IABTechLab/trusted-server/compare/v1.0.0...v1.0.1 -[1.0.0]:https://github.com/IABTechLab/trusted-server/releases/tag/v1.0.0 +[Unreleased]: https://github.com/IABTechLab/trusted-server/compare/v1.2.0...HEAD +[1.2.0]: https://github.com/IABTechLab/trusted-server/compare/v1.1.0...v1.2.0 +[1.1.0]: https://github.com/IABTechLab/trusted-server/compare/v1.0.6...v1.1.0 +[1.0.6]: https://github.com/IABTechLab/trusted-server/compare/v1.0.5...v1.0.6 +[1.0.5]: https://github.com/IABTechLab/trusted-server/compare/v1.0.4...v1.0.5 +[1.0.4]: https://github.com/IABTechLab/trusted-server/compare/v1.0.3...v1.0.4 +[1.0.3]: https://github.com/IABTechLab/trusted-server/compare/v1.0.2...v1.0.3 +[1.0.2]: https://github.com/IABTechLab/trusted-server/compare/v1.0.1...v1.0.2 +[1.0.1]: https://github.com/IABTechLab/trusted-server/compare/v1.0.0...v1.0.1 +[1.0.0]: https://github.com/IABTechLab/trusted-server/releases/tag/v1.0.0 diff --git a/crates/common/Cargo.toml b/crates/common/Cargo.toml index 05ed9c7..8261ca0 100644 --- a/crates/common/Cargo.toml +++ b/crates/common/Cargo.toml @@ -29,6 +29,7 @@ log = { workspace = true } log-fastly = { workspace = true } lol_html = { workspace = true } pin-project-lite = { workspace = true } +regex = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } sha2 = { workspace = true } @@ -44,6 +45,7 @@ config = { workspace = true } derive_more = { workspace = true } error-stack = { workspace = true } http = { workspace = true } +regex = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } toml = { workspace = true } @@ -54,6 +56,5 @@ validator = { workspace = true } default = [] [dev-dependencies] -regex = { workspace = true } temp-env = { workspace = true } tokio-test = { workspace = true } diff --git a/crates/common/src/auth.rs b/crates/common/src/auth.rs new file mode 100644 index 0000000..a85abf3 --- /dev/null +++ b/crates/common/src/auth.rs @@ -0,0 +1,119 @@ +use base64::{engine::general_purpose::STANDARD, Engine as _}; +use fastly::http::{header, StatusCode}; +use fastly::{Request, Response}; + +use crate::settings::Settings; + +const BASIC_AUTH_REALM: &str = r#"Basic realm="Trusted Server""#; + +pub fn enforce_basic_auth(settings: &Settings, req: &Request) -> Option { + let handler = settings.handler_for_path(req.get_path())?; + + let (username, password) = match extract_credentials(req) { + Some(credentials) => credentials, + None => return Some(unauthorized_response()), + }; + + if username == handler.username && password == handler.password { + None + } else { + Some(unauthorized_response()) + } +} + +fn extract_credentials(req: &Request) -> Option<(String, String)> { + let header_value = req + .get_header(header::AUTHORIZATION) + .and_then(|value| value.to_str().ok())?; + + let mut parts = header_value.splitn(2, ' '); + let scheme = parts.next()?.trim(); + if !scheme.eq_ignore_ascii_case("basic") { + return None; + } + + let token = parts.next()?.trim(); + if token.is_empty() { + return None; + } + + let decoded = STANDARD.decode(token).ok()?; + let credentials = String::from_utf8(decoded).ok()?; + + let mut credentials_parts = credentials.splitn(2, ':'); + let username = credentials_parts.next()?.to_string(); + let password = credentials_parts.next()?.to_string(); + + Some((username, password)) +} + +fn unauthorized_response() -> Response { + Response::from_status(StatusCode::UNAUTHORIZED) + .with_header(header::WWW_AUTHENTICATE, BASIC_AUTH_REALM) + .with_header(header::CONTENT_TYPE, "text/plain; charset=utf-8") + .with_body_text_plain("Unauthorized") +} + +#[cfg(test)] +mod tests { + use super::*; + use base64::engine::general_purpose::STANDARD; + use fastly::http::{header, Method}; + + use crate::test_support::tests::crate_test_settings_str; + + fn settings_with_handlers() -> Settings { + let config = crate_test_settings_str(); + Settings::from_toml(&config).expect("should parse settings with handlers") + } + + #[test] + fn no_challenge_for_non_protected_path() { + let settings = settings_with_handlers(); + let req = Request::new(Method::GET, "https://example.com/open"); + + assert!(enforce_basic_auth(&settings, &req).is_none()); + } + + #[test] + fn challenge_when_missing_credentials() { + let settings = settings_with_handlers(); + let req = Request::new(Method::GET, "https://example.com/secure"); + + let response = enforce_basic_auth(&settings, &req).expect("should challenge"); + assert_eq!(response.get_status(), StatusCode::UNAUTHORIZED); + let realm = response.get_header(header::WWW_AUTHENTICATE).unwrap(); + assert_eq!(realm, BASIC_AUTH_REALM); + } + + #[test] + fn allow_when_credentials_match() { + let settings = settings_with_handlers(); + let mut req = Request::new(Method::GET, "https://example.com/secure/data"); + let token = STANDARD.encode("user:pass"); + req.set_header(header::AUTHORIZATION, format!("Basic {token}")); + + assert!(enforce_basic_auth(&settings, &req).is_none()); + } + + #[test] + fn challenge_when_credentials_mismatch() { + let settings = settings_with_handlers(); + let mut req = Request::new(Method::GET, "https://example.com/secure/data"); + let token = STANDARD.encode("user:wrong"); + req.set_header(header::AUTHORIZATION, format!("Basic {token}")); + + let response = enforce_basic_auth(&settings, &req).expect("should challenge"); + assert_eq!(response.get_status(), StatusCode::UNAUTHORIZED); + } + + #[test] + fn challenge_when_scheme_is_not_basic() { + let settings = settings_with_handlers(); + let mut req = Request::new(Method::GET, "https://example.com/secure"); + req.set_header(header::AUTHORIZATION, "Bearer token"); + + let response = enforce_basic_auth(&settings, &req).expect("should challenge"); + assert_eq!(response.get_status(), StatusCode::UNAUTHORIZED); + } +} diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs index 2414a1a..f48048b 100644 --- a/crates/common/src/lib.rs +++ b/crates/common/src/lib.rs @@ -5,6 +5,7 @@ //! //! # Modules //! +//! - [`auth`]: Basic authentication enforcement helpers //! - [`advertiser`]: Ad serving and advertiser integration functionality //! - [`constants`]: Application-wide constants and configuration values //! - [`cookies`]: Cookie parsing and generation utilities @@ -23,6 +24,7 @@ //! - [`why`]: Debugging and introspection utilities pub mod ad; +pub mod auth; pub mod backend; pub mod constants; pub mod cookies; diff --git a/crates/common/src/settings.rs b/crates/common/src/settings.rs index e6fc6ab..7653500 100644 --- a/crates/common/src/settings.rs +++ b/crates/common/src/settings.rs @@ -2,8 +2,10 @@ use core::str; use config::{Config, Environment, File, FileFormat}; use error_stack::{Report, ResultExt}; +use regex::Regex; use serde::{de::DeserializeOwned, Deserialize, Deserializer, Serialize}; use serde_json::Value as JsonValue; +use std::sync::OnceLock; use url::Url; use validator::{Validate, ValidationError}; @@ -96,6 +98,31 @@ impl Synthetic { } } +#[derive(Debug, Default, Deserialize, Serialize, Validate)] +pub struct Handler { + #[validate(length(min = 1), custom(function = validate_path))] + pub path: String, + #[validate(length(min = 1))] + pub username: String, + #[validate(length(min = 1))] + pub password: String, + #[serde(skip, default)] + #[validate(skip)] + regex: OnceLock, +} + +impl Handler { + fn compiled_regex(&self) -> &Regex { + self.regex.get_or_init(|| { + Regex::new(&self.path).expect("configuration validation should ensure regex compiles") + }) + } + + pub fn matches_path(&self, path: &str) -> bool { + self.compiled_regex().is_match(path) + } +} + #[derive(Debug, Default, Deserialize, Serialize, Validate)] pub struct Settings { #[validate(nested)] @@ -104,6 +131,9 @@ pub struct Settings { pub prebid: Prebid, #[validate(nested)] pub synthetic: Synthetic, + #[serde(default, deserialize_with = "vec_from_seq_or_map")] + #[validate(nested)] + pub handlers: Vec, } #[allow(unused)] @@ -163,6 +193,22 @@ impl Settings { message: "Failed to deserialize configuration".to_string(), }) } + + #[must_use] + pub fn handler_for_path(&self, path: &str) -> Option<&Handler> { + self.handlers + .iter() + .find(|handler| handler.matches_path(path)) + } +} + +fn validate_path(value: &str) -> Result<(), ValidationError> { + Regex::new(value).map(|_| ()).map_err(|err| { + let mut validation_error = ValidationError::new("invalid_regex"); + validation_error.add_param("value".into(), &value); + validation_error.add_param("message".into(), &err.to_string()); + validation_error + }) } // Helper: allow Vec fields to deserialize from either a JSON array or a map of numeric indices. @@ -400,6 +446,59 @@ mod tests { ); } + #[test] + fn test_handlers_override_with_env() { + let toml_str = crate_test_settings_str(); + + let origin_key = format!( + "{}{}PUBLISHER{}ORIGIN_URL", + ENVIRONMENT_VARIABLE_PREFIX, + ENVIRONMENT_VARIABLE_SEPARATOR, + ENVIRONMENT_VARIABLE_SEPARATOR + ); + let path_key = format!( + "{}{}HANDLERS{}0{}PATH", + ENVIRONMENT_VARIABLE_PREFIX, + ENVIRONMENT_VARIABLE_SEPARATOR, + ENVIRONMENT_VARIABLE_SEPARATOR, + ENVIRONMENT_VARIABLE_SEPARATOR + ); + let username_key = format!( + "{}{}HANDLERS{}0{}USERNAME", + ENVIRONMENT_VARIABLE_PREFIX, + ENVIRONMENT_VARIABLE_SEPARATOR, + ENVIRONMENT_VARIABLE_SEPARATOR, + ENVIRONMENT_VARIABLE_SEPARATOR + ); + let password_key = format!( + "{}{}HANDLERS{}0{}PASSWORD", + ENVIRONMENT_VARIABLE_PREFIX, + ENVIRONMENT_VARIABLE_SEPARATOR, + ENVIRONMENT_VARIABLE_SEPARATOR, + ENVIRONMENT_VARIABLE_SEPARATOR + ); + + temp_env::with_var( + origin_key, + Some("https://origin.test-publisher.com"), + || { + temp_env::with_var(path_key, Some("^/env-handler"), || { + temp_env::with_var(username_key, Some("env-user"), || { + temp_env::with_var(password_key, Some("env-pass"), || { + let settings = Settings::from_toml(&toml_str) + .expect("Settings should load from env"); + assert_eq!(settings.handlers.len(), 1); + let handler = &settings.handlers[0]; + assert_eq!(handler.path, "^/env-handler"); + assert_eq!(handler.username, "env-user"); + assert_eq!(handler.password, "env-pass"); + }); + }); + }); + }, + ); + } + #[test] fn test_settings_extra_fields() { let toml_str = crate_test_settings_str() + "\nhello = 1"; diff --git a/crates/common/src/test_support.rs b/crates/common/src/test_support.rs index c2c31f7..012d759 100644 --- a/crates/common/src/test_support.rs +++ b/crates/common/src/test_support.rs @@ -4,6 +4,11 @@ pub mod tests { pub fn crate_test_settings_str() -> String { r#" + [[handlers]] + path = "^/secure" + username = "user" + password = "pass" + [publisher] domain = "test-publisher.com" cookie_domain = ".test-publisher.com" diff --git a/crates/fastly/src/main.rs b/crates/fastly/src/main.rs index 32a2745..0086b1a 100644 --- a/crates/fastly/src/main.rs +++ b/crates/fastly/src/main.rs @@ -3,6 +3,7 @@ use fastly::{Error, Request, Response}; use log_fastly::Logger; use trusted_server_common::ad::{handle_server_ad, handle_server_ad_get}; +use trusted_server_common::auth::enforce_basic_auth; use trusted_server_common::proxy::{ handle_first_party_click, handle_first_party_proxy, handle_first_party_proxy_rebuild, handle_first_party_proxy_sign, @@ -36,6 +37,10 @@ async fn route_request(settings: Settings, req: Request) -> Result