Skip to content

Commit 8403993

Browse files
authored
Basic auth for handlers
1 parent b114b67 commit 8403993

File tree

8 files changed

+261
-13
lines changed

8 files changed

+261
-13
lines changed

CHANGELOG.md

Lines changed: 24 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,32 @@
1-
21
# Changelog
32

43
All notable changes to this project will be documented in this file.
54

65
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
76
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
87

8+
## [Unreleased]
9+
10+
### Added
11+
12+
- Implemented basic authentication for configurable endpoint paths (#73)
13+
914
## [1.2.0] - 2025-10-14
1015

1116
### Changed
17+
1218
- Publisher origin backend now uses `publisher.origin_url` to dynamically create backends, deprecated `publisher.origin_backend` field
1319
- Prebid backend now uses `prebid.server_url` to dynamically create backends, deprecated `prebid.prebid_backend` field
1420
- Removed static backend definitions from `fastly.toml` for publisher and prebid
1521

1622
### Added
23+
1724
- Added `.rust-analyzer.json` for improved development environment support with Neovim/rust-analyzer
1825

1926
## [1.1.0] - 2025-10-05
2027

2128
### Added
29+
2230
- Added basic unit tests
2331
- Added publisher config
2432
- 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
3139
- Added Trusted Server TSJS SDK with bundled build, lint, and test tools for serving creatives in first-party domain
3240

3341
### Changed
42+
3443
- Upgrade to rust 1.90.0
3544
- Upgrade to fastly-cli 12.0.0
3645
- Changed to use constants for headers
@@ -41,14 +50,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
4150
- Added TypeScript CI lint, format, and test jobs for TSJS
4251

4352
### Fixed
53+
4454
- Rebuild when `TRUSTED_SERVER__*` env variables change
4555

4656
## [1.0.6] - 2025-05-29
4757

4858
### Changed
59+
4960
- Remove hard coded Fast ID in fastly.tom
5061
- Updated README to better describe what Trusted Server does and high-level goal
51-
- Use Rust toolchain version from .tool-versions for GitHub actions
62+
- Use Rust toolchain version from .tool-versions for GitHub actions
5263

5364
## [1.0.5] - 2025-05-19
5465

@@ -77,6 +88,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7788
## [1.0.2] - 2025-03-28
7889

7990
### Added
91+
8092
- Documented project gogernance in [ProjectGovernance.md]
8193
- Document FAQ for POC [FAQ_POC.md]
8294

@@ -92,13 +104,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
92104

93105
- Initial implementation of Trusted Server
94106

95-
[Unreleased]:https://github.com/IABTechLab/trusted-server/compare/v1.2.0...HEAD
96-
[1.2.0]:https://github.com/IABTechLab/trusted-server/compare/v1.1.0...v1.2.0
97-
[1.1.0]:https://github.com/IABTechLab/trusted-server/compare/v1.0.6...v1.1.0
98-
[1.0.6]:https://github.com/IABTechLab/trusted-server/compare/v1.0.5...v1.0.6
99-
[1.0.5]:https://github.com/IABTechLab/trusted-server/compare/v1.0.4...v1.0.5
100-
[1.0.4]:https://github.com/IABTechLab/trusted-server/compare/v1.0.3...v1.0.4
101-
[1.0.3]:https://github.com/IABTechLab/trusted-server/compare/v1.0.2...v1.0.3
102-
[1.0.2]:https://github.com/IABTechLab/trusted-server/compare/v1.0.1...v1.0.2
103-
[1.0.1]:https://github.com/IABTechLab/trusted-server/compare/v1.0.0...v1.0.1
104-
[1.0.0]:https://github.com/IABTechLab/trusted-server/releases/tag/v1.0.0
107+
[Unreleased]: https://github.com/IABTechLab/trusted-server/compare/v1.2.0...HEAD
108+
[1.2.0]: https://github.com/IABTechLab/trusted-server/compare/v1.1.0...v1.2.0
109+
[1.1.0]: https://github.com/IABTechLab/trusted-server/compare/v1.0.6...v1.1.0
110+
[1.0.6]: https://github.com/IABTechLab/trusted-server/compare/v1.0.5...v1.0.6
111+
[1.0.5]: https://github.com/IABTechLab/trusted-server/compare/v1.0.4...v1.0.5
112+
[1.0.4]: https://github.com/IABTechLab/trusted-server/compare/v1.0.3...v1.0.4
113+
[1.0.3]: https://github.com/IABTechLab/trusted-server/compare/v1.0.2...v1.0.3
114+
[1.0.2]: https://github.com/IABTechLab/trusted-server/compare/v1.0.1...v1.0.2
115+
[1.0.1]: https://github.com/IABTechLab/trusted-server/compare/v1.0.0...v1.0.1
116+
[1.0.0]: https://github.com/IABTechLab/trusted-server/releases/tag/v1.0.0

crates/common/Cargo.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ log = { workspace = true }
2929
log-fastly = { workspace = true }
3030
lol_html = { workspace = true }
3131
pin-project-lite = { workspace = true }
32+
regex = { workspace = true }
3233
serde = { workspace = true }
3334
serde_json = { workspace = true }
3435
sha2 = { workspace = true }
@@ -44,6 +45,7 @@ config = { workspace = true }
4445
derive_more = { workspace = true }
4546
error-stack = { workspace = true }
4647
http = { workspace = true }
48+
regex = { workspace = true }
4749
serde = { workspace = true }
4850
serde_json = { workspace = true }
4951
toml = { workspace = true }
@@ -54,6 +56,5 @@ validator = { workspace = true }
5456
default = []
5557

5658
[dev-dependencies]
57-
regex = { workspace = true }
5859
temp-env = { workspace = true }
5960
tokio-test = { workspace = true }

crates/common/src/auth.rs

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
use base64::{engine::general_purpose::STANDARD, Engine as _};
2+
use fastly::http::{header, StatusCode};
3+
use fastly::{Request, Response};
4+
5+
use crate::settings::Settings;
6+
7+
const BASIC_AUTH_REALM: &str = r#"Basic realm="Trusted Server""#;
8+
9+
pub fn enforce_basic_auth(settings: &Settings, req: &Request) -> Option<Response> {
10+
let handler = settings.handler_for_path(req.get_path())?;
11+
12+
let (username, password) = match extract_credentials(req) {
13+
Some(credentials) => credentials,
14+
None => return Some(unauthorized_response()),
15+
};
16+
17+
if username == handler.username && password == handler.password {
18+
None
19+
} else {
20+
Some(unauthorized_response())
21+
}
22+
}
23+
24+
fn extract_credentials(req: &Request) -> Option<(String, String)> {
25+
let header_value = req
26+
.get_header(header::AUTHORIZATION)
27+
.and_then(|value| value.to_str().ok())?;
28+
29+
let mut parts = header_value.splitn(2, ' ');
30+
let scheme = parts.next()?.trim();
31+
if !scheme.eq_ignore_ascii_case("basic") {
32+
return None;
33+
}
34+
35+
let token = parts.next()?.trim();
36+
if token.is_empty() {
37+
return None;
38+
}
39+
40+
let decoded = STANDARD.decode(token).ok()?;
41+
let credentials = String::from_utf8(decoded).ok()?;
42+
43+
let mut credentials_parts = credentials.splitn(2, ':');
44+
let username = credentials_parts.next()?.to_string();
45+
let password = credentials_parts.next()?.to_string();
46+
47+
Some((username, password))
48+
}
49+
50+
fn unauthorized_response() -> Response {
51+
Response::from_status(StatusCode::UNAUTHORIZED)
52+
.with_header(header::WWW_AUTHENTICATE, BASIC_AUTH_REALM)
53+
.with_header(header::CONTENT_TYPE, "text/plain; charset=utf-8")
54+
.with_body_text_plain("Unauthorized")
55+
}
56+
57+
#[cfg(test)]
58+
mod tests {
59+
use super::*;
60+
use base64::engine::general_purpose::STANDARD;
61+
use fastly::http::{header, Method};
62+
63+
use crate::test_support::tests::crate_test_settings_str;
64+
65+
fn settings_with_handlers() -> Settings {
66+
let config = crate_test_settings_str();
67+
Settings::from_toml(&config).expect("should parse settings with handlers")
68+
}
69+
70+
#[test]
71+
fn no_challenge_for_non_protected_path() {
72+
let settings = settings_with_handlers();
73+
let req = Request::new(Method::GET, "https://example.com/open");
74+
75+
assert!(enforce_basic_auth(&settings, &req).is_none());
76+
}
77+
78+
#[test]
79+
fn challenge_when_missing_credentials() {
80+
let settings = settings_with_handlers();
81+
let req = Request::new(Method::GET, "https://example.com/secure");
82+
83+
let response = enforce_basic_auth(&settings, &req).expect("should challenge");
84+
assert_eq!(response.get_status(), StatusCode::UNAUTHORIZED);
85+
let realm = response.get_header(header::WWW_AUTHENTICATE).unwrap();
86+
assert_eq!(realm, BASIC_AUTH_REALM);
87+
}
88+
89+
#[test]
90+
fn allow_when_credentials_match() {
91+
let settings = settings_with_handlers();
92+
let mut req = Request::new(Method::GET, "https://example.com/secure/data");
93+
let token = STANDARD.encode("user:pass");
94+
req.set_header(header::AUTHORIZATION, format!("Basic {token}"));
95+
96+
assert!(enforce_basic_auth(&settings, &req).is_none());
97+
}
98+
99+
#[test]
100+
fn challenge_when_credentials_mismatch() {
101+
let settings = settings_with_handlers();
102+
let mut req = Request::new(Method::GET, "https://example.com/secure/data");
103+
let token = STANDARD.encode("user:wrong");
104+
req.set_header(header::AUTHORIZATION, format!("Basic {token}"));
105+
106+
let response = enforce_basic_auth(&settings, &req).expect("should challenge");
107+
assert_eq!(response.get_status(), StatusCode::UNAUTHORIZED);
108+
}
109+
110+
#[test]
111+
fn challenge_when_scheme_is_not_basic() {
112+
let settings = settings_with_handlers();
113+
let mut req = Request::new(Method::GET, "https://example.com/secure");
114+
req.set_header(header::AUTHORIZATION, "Bearer token");
115+
116+
let response = enforce_basic_auth(&settings, &req).expect("should challenge");
117+
assert_eq!(response.get_status(), StatusCode::UNAUTHORIZED);
118+
}
119+
}

crates/common/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
//!
66
//! # Modules
77
//!
8+
//! - [`auth`]: Basic authentication enforcement helpers
89
//! - [`advertiser`]: Ad serving and advertiser integration functionality
910
//! - [`constants`]: Application-wide constants and configuration values
1011
//! - [`cookies`]: Cookie parsing and generation utilities
@@ -23,6 +24,7 @@
2324
//! - [`why`]: Debugging and introspection utilities
2425
2526
pub mod ad;
27+
pub mod auth;
2628
pub mod backend;
2729
pub mod constants;
2830
pub mod cookies;

crates/common/src/settings.rs

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@ use core::str;
22

33
use config::{Config, Environment, File, FileFormat};
44
use error_stack::{Report, ResultExt};
5+
use regex::Regex;
56
use serde::{de::DeserializeOwned, Deserialize, Deserializer, Serialize};
67
use serde_json::Value as JsonValue;
8+
use std::sync::OnceLock;
79
use url::Url;
810
use validator::{Validate, ValidationError};
911

@@ -96,6 +98,31 @@ impl Synthetic {
9698
}
9799
}
98100

101+
#[derive(Debug, Default, Deserialize, Serialize, Validate)]
102+
pub struct Handler {
103+
#[validate(length(min = 1), custom(function = validate_path))]
104+
pub path: String,
105+
#[validate(length(min = 1))]
106+
pub username: String,
107+
#[validate(length(min = 1))]
108+
pub password: String,
109+
#[serde(skip, default)]
110+
#[validate(skip)]
111+
regex: OnceLock<Regex>,
112+
}
113+
114+
impl Handler {
115+
fn compiled_regex(&self) -> &Regex {
116+
self.regex.get_or_init(|| {
117+
Regex::new(&self.path).expect("configuration validation should ensure regex compiles")
118+
})
119+
}
120+
121+
pub fn matches_path(&self, path: &str) -> bool {
122+
self.compiled_regex().is_match(path)
123+
}
124+
}
125+
99126
#[derive(Debug, Default, Deserialize, Serialize, Validate)]
100127
pub struct Settings {
101128
#[validate(nested)]
@@ -104,6 +131,9 @@ pub struct Settings {
104131
pub prebid: Prebid,
105132
#[validate(nested)]
106133
pub synthetic: Synthetic,
134+
#[serde(default, deserialize_with = "vec_from_seq_or_map")]
135+
#[validate(nested)]
136+
pub handlers: Vec<Handler>,
107137
}
108138

109139
#[allow(unused)]
@@ -163,6 +193,22 @@ impl Settings {
163193
message: "Failed to deserialize configuration".to_string(),
164194
})
165195
}
196+
197+
#[must_use]
198+
pub fn handler_for_path(&self, path: &str) -> Option<&Handler> {
199+
self.handlers
200+
.iter()
201+
.find(|handler| handler.matches_path(path))
202+
}
203+
}
204+
205+
fn validate_path(value: &str) -> Result<(), ValidationError> {
206+
Regex::new(value).map(|_| ()).map_err(|err| {
207+
let mut validation_error = ValidationError::new("invalid_regex");
208+
validation_error.add_param("value".into(), &value);
209+
validation_error.add_param("message".into(), &err.to_string());
210+
validation_error
211+
})
166212
}
167213

168214
// Helper: allow Vec fields to deserialize from either a JSON array or a map of numeric indices.
@@ -400,6 +446,59 @@ mod tests {
400446
);
401447
}
402448

449+
#[test]
450+
fn test_handlers_override_with_env() {
451+
let toml_str = crate_test_settings_str();
452+
453+
let origin_key = format!(
454+
"{}{}PUBLISHER{}ORIGIN_URL",
455+
ENVIRONMENT_VARIABLE_PREFIX,
456+
ENVIRONMENT_VARIABLE_SEPARATOR,
457+
ENVIRONMENT_VARIABLE_SEPARATOR
458+
);
459+
let path_key = format!(
460+
"{}{}HANDLERS{}0{}PATH",
461+
ENVIRONMENT_VARIABLE_PREFIX,
462+
ENVIRONMENT_VARIABLE_SEPARATOR,
463+
ENVIRONMENT_VARIABLE_SEPARATOR,
464+
ENVIRONMENT_VARIABLE_SEPARATOR
465+
);
466+
let username_key = format!(
467+
"{}{}HANDLERS{}0{}USERNAME",
468+
ENVIRONMENT_VARIABLE_PREFIX,
469+
ENVIRONMENT_VARIABLE_SEPARATOR,
470+
ENVIRONMENT_VARIABLE_SEPARATOR,
471+
ENVIRONMENT_VARIABLE_SEPARATOR
472+
);
473+
let password_key = format!(
474+
"{}{}HANDLERS{}0{}PASSWORD",
475+
ENVIRONMENT_VARIABLE_PREFIX,
476+
ENVIRONMENT_VARIABLE_SEPARATOR,
477+
ENVIRONMENT_VARIABLE_SEPARATOR,
478+
ENVIRONMENT_VARIABLE_SEPARATOR
479+
);
480+
481+
temp_env::with_var(
482+
origin_key,
483+
Some("https://origin.test-publisher.com"),
484+
|| {
485+
temp_env::with_var(path_key, Some("^/env-handler"), || {
486+
temp_env::with_var(username_key, Some("env-user"), || {
487+
temp_env::with_var(password_key, Some("env-pass"), || {
488+
let settings = Settings::from_toml(&toml_str)
489+
.expect("Settings should load from env");
490+
assert_eq!(settings.handlers.len(), 1);
491+
let handler = &settings.handlers[0];
492+
assert_eq!(handler.path, "^/env-handler");
493+
assert_eq!(handler.username, "env-user");
494+
assert_eq!(handler.password, "env-pass");
495+
});
496+
});
497+
});
498+
},
499+
);
500+
}
501+
403502
#[test]
404503
fn test_settings_extra_fields() {
405504
let toml_str = crate_test_settings_str() + "\nhello = 1";

crates/common/src/test_support.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@ pub mod tests {
44

55
pub fn crate_test_settings_str() -> String {
66
r#"
7+
[[handlers]]
8+
path = "^/secure"
9+
username = "user"
10+
password = "pass"
11+
712
[publisher]
813
domain = "test-publisher.com"
914
cookie_domain = ".test-publisher.com"

0 commit comments

Comments
 (0)