Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 24 additions & 12 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,24 +1,32 @@

# Changelog

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
Expand All @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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]

Expand All @@ -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
3 changes: 2 additions & 1 deletion crates/common/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand All @@ -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 }
Expand All @@ -54,6 +56,5 @@ validator = { workspace = true }
default = []

[dev-dependencies]
regex = { workspace = true }
temp-env = { workspace = true }
tokio-test = { workspace = true }
119 changes: 119 additions & 0 deletions crates/common/src/auth.rs
Original file line number Diff line number Diff line change
@@ -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<Response> {
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);
}
}
2 changes: 2 additions & 0 deletions crates/common/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -23,6 +24,7 @@
//! - [`why`]: Debugging and introspection utilities

pub mod ad;
pub mod auth;
pub mod backend;
pub mod constants;
pub mod cookies;
Expand Down
99 changes: 99 additions & 0 deletions crates/common/src/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down Expand Up @@ -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<Regex>,
}

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)]
Expand All @@ -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<Handler>,
}

#[allow(unused)]
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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";
Expand Down
5 changes: 5 additions & 0 deletions crates/common/src/test_support.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading