diff --git a/Cargo.lock b/Cargo.lock index 076dae3..ae979b4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -580,7 +580,7 @@ dependencies = [ [[package]] name = "edgezero-adapter-axum" version = "0.1.0" -source = "git+https://github.com/stackpop/edgezero.git?branch=main#7595003f8334f9e8a09f7cadfbc1907bef865bab" +source = "git+https://github.com/stackpop/edgezero.git?branch=feature%2Fconfigureable-axum-host#b9c114442ff34cde0ee0c5918abd16b2da97cbaa" dependencies = [ "anyhow", "async-trait", @@ -602,7 +602,7 @@ dependencies = [ [[package]] name = "edgezero-adapter-cloudflare" version = "0.1.0" -source = "git+https://github.com/stackpop/edgezero.git?branch=main#7595003f8334f9e8a09f7cadfbc1907bef865bab" +source = "git+https://github.com/stackpop/edgezero.git?branch=feature%2Fconfigureable-axum-host#b9c114442ff34cde0ee0c5918abd16b2da97cbaa" dependencies = [ "async-trait", "brotli", @@ -619,7 +619,7 @@ dependencies = [ [[package]] name = "edgezero-adapter-fastly" version = "0.1.0" -source = "git+https://github.com/stackpop/edgezero.git?branch=main#7595003f8334f9e8a09f7cadfbc1907bef865bab" +source = "git+https://github.com/stackpop/edgezero.git?branch=feature%2Fconfigureable-axum-host#b9c114442ff34cde0ee0c5918abd16b2da97cbaa" dependencies = [ "async-stream", "async-trait", @@ -639,7 +639,7 @@ dependencies = [ [[package]] name = "edgezero-core" version = "0.1.0" -source = "git+https://github.com/stackpop/edgezero.git?branch=main#7595003f8334f9e8a09f7cadfbc1907bef865bab" +source = "git+https://github.com/stackpop/edgezero.git?branch=feature%2Fconfigureable-axum-host#b9c114442ff34cde0ee0c5918abd16b2da97cbaa" dependencies = [ "anyhow", "async-compression", @@ -667,7 +667,7 @@ dependencies = [ [[package]] name = "edgezero-macros" version = "0.1.0" -source = "git+https://github.com/stackpop/edgezero.git?branch=main#7595003f8334f9e8a09f7cadfbc1907bef865bab" +source = "git+https://github.com/stackpop/edgezero.git?branch=feature%2Fconfigureable-axum-host#b9c114442ff34cde0ee0c5918abd16b2da97cbaa" dependencies = [ "log", "proc-macro2", @@ -1503,6 +1503,8 @@ dependencies = [ "serde", "serde_json", "serde_repr", + "sha2 0.10.9", + "subtle", "thiserror 1.0.69", "toml", "url", diff --git a/Cargo.toml b/Cargo.toml index 05e4399..92e0e45 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,11 +21,11 @@ async-trait = "0.1" axum = "0.8" base64 = "0.22" ed25519-dalek = "2.1" -edgezero-adapter-axum = { git = "https://github.com/stackpop/edgezero.git", branch = "main", package = "edgezero-adapter-axum", default-features = false } -edgezero-adapter-cloudflare = { git = "https://github.com/stackpop/edgezero.git", branch = "main", package = "edgezero-adapter-cloudflare", default-features = false } -edgezero-adapter-fastly = { git = "https://github.com/stackpop/edgezero.git", branch = "main", package = "edgezero-adapter-fastly", default-features = false } -edgezero-cli = { git = "https://github.com/stackpop/edgezero.git", branch = "main", package = "edgezero-cli" } -edgezero-core = { git = "https://github.com/stackpop/edgezero.git", branch = "main", package = "edgezero-core" } +edgezero-adapter-axum = { git = "https://github.com/stackpop/edgezero.git", branch = "feature/configureable-axum-host", package = "edgezero-adapter-axum", default-features = false } +edgezero-adapter-cloudflare = { git = "https://github.com/stackpop/edgezero.git", branch = "feature/configureable-axum-host", package = "edgezero-adapter-cloudflare", default-features = false } +edgezero-adapter-fastly = { git = "https://github.com/stackpop/edgezero.git", branch = "feature/configureable-axum-host", package = "edgezero-adapter-fastly", default-features = false } +edgezero-cli = { git = "https://github.com/stackpop/edgezero.git", branch = "feature/configureable-axum-host", package = "edgezero-cli" } +edgezero-core = { git = "https://github.com/stackpop/edgezero.git", branch = "feature/configureable-axum-host", package = "edgezero-core" } fastly = "0.11.9" futures = { version = "0.3", features = ["std", "executor"] } futures-util = "0.3.31" @@ -36,7 +36,9 @@ phf = { version = "0.11", features = ["macros"] } serde = { version = "1", features = ["derive"] } serde_json = "1" serde_repr = "0.1" +sha2 = "0.10" simple_logger = "5" +subtle = "2" thiserror = "1.0" tokio = { version = "1", features = ["macros", "rt-multi-thread"] } toml = "1.0" diff --git a/crates/mocktioneer-core/Cargo.toml b/crates/mocktioneer-core/Cargo.toml index 16ff986..ee93412 100644 --- a/crates/mocktioneer-core/Cargo.toml +++ b/crates/mocktioneer-core/Cargo.toml @@ -17,6 +17,8 @@ phf = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } serde_repr = { workspace = true } +sha2 = { workspace = true } +subtle = { workspace = true } thiserror = { workspace = true } toml = { workspace = true } url = { workspace = true } diff --git a/crates/mocktioneer-core/src/auction.rs b/crates/mocktioneer-core/src/auction.rs index 50d9b4e..aec2ee2 100644 --- a/crates/mocktioneer-core/src/auction.rs +++ b/crates/mocktioneer-core/src/auction.rs @@ -2,7 +2,7 @@ use crate::aps::{ApsBidRequest, ApsBidResponse, ApsContextual, ApsSlotResponse}; use crate::openrtb::{ Bid as OpenrtbBid, Imp as OpenrtbImp, MediaType, OpenRTBRequest, OpenRTBResponse, SeatBid, }; -use crate::render::{iframe_html, CreativeMetadata, SignatureStatus}; +use crate::render::{extract_ec_info, iframe_html, CreativeMetadata, SignatureStatus}; use phf::phf_map; use serde_json::json; use uuid::Uuid; @@ -181,6 +181,7 @@ pub fn build_openrtb_response( // Build metadata with sanitized response let metadata = CreativeMetadata { signature: signature_status, + edge_cookie: extract_ec_info(req), request: req, response: sanitized_response, }; diff --git a/crates/mocktioneer-core/src/mediation.rs b/crates/mocktioneer-core/src/mediation.rs index 6ba1399..7b3acff 100644 --- a/crates/mocktioneer-core/src/mediation.rs +++ b/crates/mocktioneer-core/src/mediation.rs @@ -4,7 +4,7 @@ //! and selects winners based on price (highest price wins). use crate::openrtb::{Bid as OpenRTBBid, Imp, MediaType, OpenRTBRequest, OpenRTBResponse, SeatBid}; -use crate::render::{CreativeMetadata, SignatureStatus}; +use crate::render::{extract_ec_info, CreativeMetadata, SignatureStatus}; use serde::{Deserialize, Serialize}; use std::cmp::Ordering; use std::collections::HashMap; @@ -204,6 +204,7 @@ fn build_openrtb_response( signature: SignatureStatus::NotPresent { reason: "Mediation response".to_string(), }, + edge_cookie: extract_ec_info(&ortb_request), request: &ortb_request, response: None, }; diff --git a/crates/mocktioneer-core/src/openrtb.rs b/crates/mocktioneer-core/src/openrtb.rs index 284df2c..bd015da 100644 --- a/crates/mocktioneer-core/src/openrtb.rs +++ b/crates/mocktioneer-core/src/openrtb.rs @@ -416,6 +416,31 @@ pub struct Geo { pub ext: Option, } +/// OpenRTB 2.6 Extended Identifier (EID) — a user identity from an external source. +#[derive(Debug, Default, Clone, Serialize, Deserialize)] +pub struct Eid { + /// Identity source domain (e.g., "liveramp.com", "uidapi.com"). + pub source: String, + /// One or more UIDs from this source. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub uids: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub ext: Option, +} + +/// A single UID within an EID entry. +#[derive(Debug, Default, Clone, Serialize, Deserialize)] +pub struct EidUid { + /// The identifier value. + pub id: String, + /// Agent type — see OpenRTB 2.6 `atype` enum. + /// 3 = partner-defined (typical for EC-derived IDs). + #[serde(skip_serializing_if = "Option::is_none")] + pub atype: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub ext: Option, +} + #[derive(Debug, Default, Clone, Serialize, Deserialize)] pub struct User { #[serde(skip_serializing_if = "Option::is_none")] @@ -432,6 +457,9 @@ pub struct User { pub geo: Option, #[serde(skip_serializing_if = "Option::is_none")] pub consent: Option, + /// OpenRTB 2.6 Extended Identifiers — synced partner IDs from the identity graph. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub eids: Vec, #[serde(skip_serializing_if = "Option::is_none")] pub ext: Option, } diff --git a/crates/mocktioneer-core/src/render.rs b/crates/mocktioneer-core/src/render.rs index 02e1673..bbcad18 100644 --- a/crates/mocktioneer-core/src/render.rs +++ b/crates/mocktioneer-core/src/render.rs @@ -3,7 +3,7 @@ use serde::Serialize; use serde_json::Value as JsonValue; use uuid::Uuid; -use crate::openrtb::OpenRTBRequest; +use crate::openrtb::{Eid, OpenRTBRequest}; /// Signature verification status for creative metadata #[derive(Debug, Clone, Serialize)] @@ -29,10 +29,103 @@ impl SignatureStatus { } } +/// Edge Cookie identity information extracted from an OpenRTB bid request. +/// +/// Populated from `user.id` (the EC value), `user.eids` (synced partner IDs), +/// `user.consent` (TCF string), and `user.buyeruid`. When trusted-server +/// decorates bid requests with EC data (§12 of the EC spec), this struct +/// captures that identity pipeline state for embedding in creative metadata. +#[derive(Debug, Clone, Serialize)] +pub struct EdgeCookieInfo { + /// The full EC identifier from `user.id` (format: `{64-hex}.{6-alnum}`). + #[serde(skip_serializing_if = "Option::is_none")] + pub ec_id: Option, + /// The buyer UID from `user.buyeruid` or matched from `user.eids`. + #[serde(skip_serializing_if = "Option::is_none")] + pub buyer_uid: Option, + /// TCF consent string from `user.consent`. + #[serde(skip_serializing_if = "Option::is_none")] + pub consent: Option, + /// Number of EID sources in the bid request. + pub eids_count: usize, + /// Full EIDs array for inspection. + pub eids: Vec, + /// Whether mocktioneer's own UID appeared in `user.eids`. + pub mocktioneer_matched: bool, +} + +/// Extract the stable 64-char hex prefix from a full EC value. +/// +/// Returns `None` if the value is not in `{64-hex}.{6-alnum}` format. +pub fn extract_ec_hash(ec_value: &str) -> Option<&str> { + let (prefix, suffix) = ec_value.split_once('.')?; + if prefix.len() != 64 + || !prefix.chars().all(|c| c.is_ascii_hexdigit()) + || suffix.len() != 6 + || !suffix.chars().all(|c| c.is_ascii_alphanumeric()) + { + return None; + } + Some(prefix) +} + +const MOCKTIONEER_SOURCE_DOMAIN: &str = "mocktioneer.dev"; + +/// Build `EdgeCookieInfo` from an OpenRTB request's user object. +/// +/// Checks both `user.eids` (OpenRTB 2.6 top-level) and `user.ext.eids` +/// (Prebid Server / OpenRTB 2.5 convention). The top-level field takes +/// priority; `ext.eids` is used as a fallback when the top-level is empty. +pub fn extract_ec_info(req: &OpenRTBRequest) -> EdgeCookieInfo { + let user = req.user.as_ref(); + + let ec_id = user.and_then(|u| u.id.clone()); + + // Try top-level user.eids (OpenRTB 2.6), fall back to user.ext.eids (Prebid/2.5) + let eids = user + .map(|u| { + if !u.eids.is_empty() { + u.eids.clone() + } else { + u.ext + .as_ref() + .and_then(|ext| ext.get("eids")) + .and_then(|v| serde_json::from_value::>(v.clone()).ok()) + .unwrap_or_default() + } + }) + .unwrap_or_default(); + + let mocktioneer_eid_uid = eids.iter().find_map(|eid| { + if eid.source == MOCKTIONEER_SOURCE_DOMAIN { + eid.uids.first().map(|u| u.id.clone()) + } else { + None + } + }); + + // Prefer buyeruid, fall back to matched EID + let buyer_uid = user + .and_then(|u| u.buyeruid.clone()) + .or(mocktioneer_eid_uid.clone()); + + let mocktioneer_matched = mocktioneer_eid_uid.is_some(); + + EdgeCookieInfo { + ec_id, + buyer_uid, + consent: user.and_then(|u| u.consent.clone()), + eids_count: eids.len(), + eids, + mocktioneer_matched, + } +} + /// Metadata to embed in creative HTML comments #[derive(Debug, Clone, Serialize)] pub struct CreativeMetadata<'a> { pub signature: SignatureStatus, + pub edge_cookie: EdgeCookieInfo, pub request: &'a OpenRTBRequest, /// The OpenRTB response with `adm` fields stripped (to avoid recursion) #[serde(skip_serializing_if = "Option::is_none")] @@ -156,6 +249,7 @@ mod tests { let metadata = CreativeMetadata { signature, + edge_cookie: extract_ec_info(req), request: req, response: None, }; @@ -203,6 +297,7 @@ mod tests { signature: SignatureStatus::Verified { kid: "key-001".to_string(), }, + edge_cookie: extract_ec_info(&req), request: &req, response: None, }; @@ -241,6 +336,7 @@ mod tests { signature: SignatureStatus::Failed { reason: "Test--failure--reason".to_string(), }, + edge_cookie: extract_ec_info(&req), request: &req, response: None, }; @@ -281,6 +377,7 @@ mod tests { signature: SignatureStatus::NotPresent { reason: "No site.domain present".to_string(), }, + edge_cookie: extract_ec_info(&req), request: &req, response: None, }; @@ -322,6 +419,7 @@ mod tests { signature: SignatureStatus::Verified { kid: "key-001".to_string(), }, + edge_cookie: extract_ec_info(&req), request: &req, response: Some(response), }; @@ -334,4 +432,225 @@ mod tests { assert!(adm.contains("\"seat\": \"mocktioneer\"")); assert!(adm.contains("\"price\": 1.23")); } + + #[test] + fn test_extract_ec_hash_valid() { + let ec = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2.AbC123"; + assert_eq!( + extract_ec_hash(ec), + Some("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2") + ); + } + + #[test] + fn test_extract_ec_hash_invalid_formats() { + assert_eq!(extract_ec_hash("too-short.abc123"), None); + assert_eq!(extract_ec_hash("not-hex-at-all"), None); + assert_eq!( + extract_ec_hash("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2.ab"), + None + ); // suffix too short + assert_eq!( + extract_ec_hash("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"), + None + ); // no dot + } + + #[test] + fn test_extract_ec_info_with_ec_user() { + let req: OpenRTBRequest = serde_json::from_value(serde_json::json!({ + "id": "ec-req", + "imp": [{"id": "1", "banner": {"w": 300, "h": 250}}], + "user": { + "id": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2.AbC123", + "buyeruid": "mtk-abc123", + "consent": "CPtest123", + "eids": [ + { + "source": "mocktioneer.dev", + "uids": [{"id": "mtk-abc123", "atype": 3}] + }, + { + "source": "liveramp.com", + "uids": [{"id": "LR_xyz", "atype": 3}] + } + ] + } + })) + .unwrap(); + + let info = extract_ec_info(&req); + assert_eq!( + info.ec_id.as_deref(), + Some("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2.AbC123") + ); + assert_eq!(info.buyer_uid.as_deref(), Some("mtk-abc123")); + assert_eq!(info.consent.as_deref(), Some("CPtest123")); + assert_eq!(info.eids_count, 2); + assert!( + info.mocktioneer_matched, + "should match mocktioneer.dev source" + ); + } + + #[test] + fn test_extract_ec_info_no_user() { + let req: OpenRTBRequest = serde_json::from_value(serde_json::json!({ + "id": "no-user-req", + "imp": [{"id": "1", "banner": {"w": 300, "h": 250}}] + })) + .unwrap(); + + let info = extract_ec_info(&req); + assert!(info.ec_id.is_none()); + assert!(info.buyer_uid.is_none()); + assert!(info.consent.is_none()); + assert_eq!(info.eids_count, 0); + assert!(!info.mocktioneer_matched); + } + + #[test] + fn test_extract_ec_info_eids_without_mocktioneer() { + let req: OpenRTBRequest = serde_json::from_value(serde_json::json!({ + "id": "other-eids-req", + "imp": [{"id": "1", "banner": {"w": 300, "h": 250}}], + "user": { + "eids": [ + { + "source": "liveramp.com", + "uids": [{"id": "LR_xyz", "atype": 3}] + } + ] + } + })) + .unwrap(); + + let info = extract_ec_info(&req); + assert_eq!(info.eids_count, 1); + assert!(!info.mocktioneer_matched); + assert!(info.buyer_uid.is_none()); + } + + #[test] + fn test_iframe_html_includes_ec_metadata() { + let req: OpenRTBRequest = serde_json::from_value(serde_json::json!({ + "id": "ec-metadata-req", + "imp": [{"id": "1", "banner": {"w": 300, "h": 250}}], + "user": { + "id": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2.AbC123", + "eids": [ + { + "source": "mocktioneer.dev", + "uids": [{"id": "mtk-abc123", "atype": 3}] + } + ] + } + })) + .unwrap(); + + let metadata = CreativeMetadata { + signature: SignatureStatus::NotPresent { + reason: "test".to_string(), + }, + edge_cookie: extract_ec_info(&req), + request: &req, + response: None, + }; + + let adm = iframe_html("host.test", "crid123", 300, 250, None, &metadata); + assert!( + adm.contains("\"edge_cookie\":"), + "should contain edge_cookie section" + ); + assert!(adm.contains("\"mocktioneer_matched\": true")); + assert!(adm.contains("\"eids_count\": 1")); + } + + #[test] + fn test_extract_ec_info_from_ext_eids_prebid_style() { + // Prebid Server puts eids under user.ext.eids (OpenRTB 2.5 convention) + let req: OpenRTBRequest = serde_json::from_value(serde_json::json!({ + "id": "prebid-eids-req", + "imp": [{"id": "1", "banner": {"w": 300, "h": 250}}], + "user": { + "id": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2.AbC123", + "ext": { + "eids": [ + { + "source": "mocktioneer.dev", + "uids": [{"id": "mtk-476b99ce5ff5", "atype": 3}] + }, + { + "source": "liveramp.com", + "uids": [{"id": "LR_xyz", "atype": 3}] + } + ] + } + } + })) + .unwrap(); + + let info = extract_ec_info(&req); + assert_eq!(info.eids_count, 2, "should find eids from user.ext.eids"); + assert!( + info.mocktioneer_matched, + "should match mocktioneer.dev in ext.eids" + ); + assert_eq!( + info.buyer_uid.as_deref(), + Some("mtk-476b99ce5ff5"), + "should extract buyer_uid from ext.eids" + ); + assert_eq!( + info.ec_id.as_deref(), + Some("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2.AbC123") + ); + } + + #[test] + fn test_extract_ec_info_top_level_eids_takes_priority_over_ext() { + // When both user.eids and user.ext.eids are present, top-level wins + let req: OpenRTBRequest = serde_json::from_value(serde_json::json!({ + "id": "both-eids-req", + "imp": [{"id": "1", "banner": {"w": 300, "h": 250}}], + "user": { + "eids": [ + {"source": "top-level.com", "uids": [{"id": "top-uid", "atype": 3}]} + ], + "ext": { + "eids": [ + {"source": "mocktioneer.dev", "uids": [{"id": "ext-uid", "atype": 3}]} + ] + } + } + })) + .unwrap(); + + let info = extract_ec_info(&req); + assert_eq!(info.eids_count, 1, "should use top-level eids"); + assert_eq!(info.eids[0].source, "top-level.com"); + assert!( + !info.mocktioneer_matched, + "ext.eids should be ignored when top-level is present" + ); + } + + #[test] + fn test_extract_ec_info_ext_eids_malformed_ignored() { + // Malformed ext.eids should not crash — just produce empty eids + let req: OpenRTBRequest = serde_json::from_value(serde_json::json!({ + "id": "bad-ext-req", + "imp": [{"id": "1", "banner": {"w": 300, "h": 250}}], + "user": { + "ext": { + "eids": "not-an-array" + } + } + })) + .unwrap(); + + let info = extract_ec_info(&req); + assert_eq!(info.eids_count, 0); + assert!(!info.mocktioneer_matched); + } } diff --git a/crates/mocktioneer-core/src/routes.rs b/crates/mocktioneer-core/src/routes.rs index 24d4d0b..ea97bfb 100644 --- a/crates/mocktioneer-core/src/routes.rs +++ b/crates/mocktioneer-core/src/routes.rs @@ -12,11 +12,13 @@ use edgezero_core::http::{ }; use edgezero_core::middleware::{Middleware, Next}; use edgezero_core::{body::Body, error::EdgeError}; -use serde::Deserialize; -use uuid::Uuid; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use subtle::ConstantTimeEq; use validator::{Validate, ValidationError}; use crate::aps::ApsBidRequest; +use crate::render::extract_ec_hash; use crate::auction::{ build_aps_response, build_openrtb_response, is_standard_size, standard_sizes, }; @@ -337,30 +339,51 @@ fn parse_cookie<'a>(cookie_header: &'a str, name: &str) -> Option<&'a str> { const PIXEL_GIF: &[u8] = include_bytes!("../static/pixel.gif"); +const MTKID_COOKIE_NAME: &str = "mtkid"; +const MTKID_MAX_AGE: u64 = 60 * 60 * 24 * 365; + +/// Read an existing `mtkid` cookie or generate a new one deterministically. +/// +/// When no `mtkid` cookie is present, generates a deterministic ID using +/// `SHA-256("mtkid:" || host)` truncated to 32 hex chars. This satisfies the +/// project's determinism requirement (same host always produces the same ID) +/// while still producing unique IDs per deployment. +/// +/// Returns `(mtkid_value, Option)`. +fn get_or_create_mtkid(headers: &HeaderMap, host: &str) -> (String, Option) { + let existing = headers + .get(header::COOKIE) + .and_then(|c| c.to_str().ok()) + .and_then(|c| parse_cookie(c, MTKID_COOKIE_NAME)); + + match existing { + Some(id) => (id.to_string(), None), + None => { + // Deterministic: SHA-256("mtkid:" || host), truncated to 32 hex chars. + // Same host always produces the same mtkid. Different hosts differ. + let mut hasher = Sha256::new(); + hasher.update(b"mtkid:"); + hasher.update(host.as_bytes()); + let hash = hasher.finalize(); + let id = hex_encode(&hash)[..32].to_string(); + let cookie_val = format!( + "{}={}; Path=/; Max-Age={}; SameSite=None; Secure; HttpOnly", + MTKID_COOKIE_NAME, id, MTKID_MAX_AGE + ); + (id, Some(cookie_val)) + } + } +} + #[action] pub async fn handle_pixel( Headers(headers): Headers, + ForwardedHost(host): ForwardedHost, ValidatedQuery(params): ValidatedQuery, ) -> Response { - let cookie_name = "mtkid"; - let mut set_cookie = None; - let PixelQueryParams { pid: _ } = params; - let existing = headers - .get(header::COOKIE) - .and_then(|c| c.to_str().ok()) - .and_then(|c| parse_cookie(c, cookie_name)); - - if existing.is_none() { - let id = Uuid::now_v7().as_simple().to_string(); - let max_age = 60 * 60 * 24 * 365; - let cookie_val = format!( - "{}={}; Path=/; Max-Age={}; SameSite=None; Secure; HttpOnly", - cookie_name, id, max_age - ); - set_cookie = Some(cookie_val); - } + let (_, set_cookie) = get_or_create_mtkid(&headers, &host); let mut response = build_response(StatusCode::OK, Body::from(PIXEL_GIF)); { @@ -527,6 +550,336 @@ pub async fn handle_sizes() -> Response { response } +// --------------------------------------------------------------------------- +// Edge Cookie (EC) sync endpoints +// --------------------------------------------------------------------------- + +/// The partner ID that mocktioneer uses when registering with trusted-server. +const PARTNER_ID: &str = "mocktioneer"; + +/// Env var for the bearer token expected on inbound pull sync requests. +const PULL_TOKEN_ENV: &str = "MOCKTIONEER_PULL_TOKEN"; + +/// Env var for allowed trusted-server domains (comma-separated). +/// When set, `/sync/start` only redirects to domains in this list. +/// When unset, any `ts_domain` is accepted (development mode). +/// +/// **WASM note:** `std::env::var` returns `Err` on Cloudflare Workers +/// (no env var support via `std::env`). On that platform, the allowlist +/// is effectively disabled. For production Cloudflare deployments, use +/// a platform-native config mechanism or accept the open-redirect risk +/// in controlled environments. +const TS_ALLOWED_DOMAINS_ENV: &str = "MOCKTIONEER_TS_DOMAINS"; + +/// Returns true if `s` looks like a valid hostname (no path, auth, port, or fragment). +fn is_valid_hostname(s: &str) -> bool { + !s.is_empty() && s.len() <= 256 && !s.contains(['/', '@', ':', '?', '#', ' ', '\t', '\n', '\r']) +} + +/// Validates that a string is a valid EC identifier in `{64-hex}.{6-alnum}` format. +fn validate_ec_id(value: &str) -> Result<(), ValidationError> { + if extract_ec_hash(value).is_none() { + let mut err = ValidationError::new("invalid_ec_id"); + err.message = Some("ec_id must be in {64-hex}.{6-alnum} format".into()); + return Err(err); + } + Ok(()) +} + +#[derive(Deserialize, Validate)] +struct SyncStartParams { + /// The trusted-server hostname (e.g., "ts.publisher.com"). + #[validate(length(min = 1, max = 256))] + ts_domain: String, +} + +#[derive(Deserialize, Validate)] +struct SyncDoneParams { + /// Whether the sync succeeded ("1") or failed ("0"). + ts_synced: String, + /// Failure reason — present only when ts_synced=0. + #[serde(default)] + ts_reason: Option, +} + +#[derive(Deserialize, Validate)] +struct ResolveParams { + /// Full EC identifier in `{64-hex}.{6-alnum}` format. + #[validate(custom(function = "validate_ec_id"))] + ec_id: String, + /// Client IP address. + #[validate(length(min = 1, max = 45))] + ip: String, +} + +#[derive(Serialize)] +struct ResolveResponse { + uid: Option, +} + +/// `GET /sync/start?ts_domain=publisher.example.com` +/// +/// Initiates the pixel sync redirect chain: +/// 1. Reads/sets the `mtkid` cookie (mocktioneer's buyer UID). +/// 2. Redirects to trusted-server's `GET /sync` with `partner=mocktioneer`, +/// `uid={mtkid}`, and `return={self}/sync/done`. +/// +/// **Open-redirect protection:** When `MOCKTIONEER_TS_DOMAINS` is set +/// (comma-separated allowlist), the `ts_domain` query param is validated +/// against it. Requests with unlisted domains receive `403 Forbidden`. +/// When unset, any domain is accepted (development/demo mode). +/// +/// Additionally, `ts_domain` is always validated as a clean hostname — +/// values containing `/`, `@`, `:`, `?`, `#`, or whitespace are rejected +/// with `400 Bad Request` to prevent path injection even without an allowlist. +#[action] +pub async fn handle_sync_start( + Headers(headers): Headers, + ForwardedHost(host): ForwardedHost, + ValidatedQuery(params): ValidatedQuery, +) -> Response { + // Reject ts_domain values that contain path/auth/port/fragment characters + if !is_valid_hostname(¶ms.ts_domain) { + log::warn!( + "EC sync start rejected: ts_domain={} is not a valid hostname", + sanitize_for_log(¶ms.ts_domain, 64) + ); + return build_response(StatusCode::BAD_REQUEST, Body::empty()); + } + + // Validate ts_domain against allowlist when configured + if let Ok(allowed) = std::env::var(TS_ALLOWED_DOMAINS_ENV) { + let is_allowed = allowed + .split(',') + .any(|d| d.trim().eq_ignore_ascii_case(¶ms.ts_domain)); + if !is_allowed { + log::warn!( + "EC sync start rejected: ts_domain={} not in {}", + params.ts_domain, + TS_ALLOWED_DOMAINS_ENV + ); + return build_response(StatusCode::FORBIDDEN, Body::empty()); + } + } + + let (mtkid, set_cookie) = get_or_create_mtkid(&headers, &host); + + // Build the return URL (where TS redirects back after sync) + let scheme = if is_local_host(&host) { + "http" + } else { + "https" + }; + let return_url = format!("{}://{}/sync/done", scheme, host); + + // Build the redirect to trusted-server's /sync endpoint + let redirect_url = format!( + "https://{}/sync?partner={}&uid={}&return={}", + params.ts_domain, + PARTNER_ID, + urlencoding(&mtkid), + urlencoding(&return_url), + ); + + log::info!( + "EC sync start: mtkid={}, redirect to {}", + mtkid, + redirect_url + ); + + let loc = match HeaderValue::from_str(&redirect_url) { + Ok(v) => v, + Err(_) => { + log::error!("EC sync start: invalid redirect URL: {}", redirect_url); + return build_response(StatusCode::INTERNAL_SERVER_ERROR, Body::empty()); + } + }; + + let mut response = build_response(StatusCode::FOUND, Body::empty()); + { + let h = response.headers_mut(); + h.insert(header::LOCATION, loc); + h.insert( + header::CACHE_CONTROL, + HeaderValue::from_static("no-store, no-cache, must-revalidate, max-age=0"), + ); + } + + if let Some(cookie) = set_cookie { + if let Ok(value) = HeaderValue::from_str(&cookie) { + response.headers_mut().append("Set-Cookie", value); + } + } + + response +} + +/// `GET /sync/done?ts_synced=1` or `GET /sync/done?ts_synced=0&ts_reason=no_consent` +/// +/// Callback from trusted-server after pixel sync completes. Returns a 1x1 pixel +/// so the browser redirect chain terminates cleanly. +#[action] +pub async fn handle_sync_done(ValidatedQuery(params): ValidatedQuery) -> Response { + let success = params.ts_synced == "1"; + let reason = params.ts_reason.as_deref().unwrap_or("none"); + if success { + log::info!("EC sync done: success"); + } else { + log::warn!( + "EC sync done: failed, reason={}", + sanitize_for_log(reason, 128) + ); + } + + // Return 1x1 transparent pixel + let mut response = build_response(StatusCode::OK, Body::from(PIXEL_GIF)); + { + let h = response.headers_mut(); + h.insert(header::CONTENT_TYPE, HeaderValue::from_static("image/gif")); + h.insert( + header::CACHE_CONTROL, + HeaderValue::from_static("no-store, no-cache, must-revalidate, max-age=0"), + ); + h.insert( + header::CONTENT_LENGTH, + HeaderValue::from_str(&PIXEL_GIF.len().to_string()).expect("length"), + ); + } + response +} + +/// `GET /resolve?ec_id={64-hex}.{6-alnum}&ip={ip_address}` +/// +/// Pull sync resolution endpoint. Trusted-server calls this S2S to resolve +/// an EC identifier + IP to a mocktioneer buyer UID. +/// +/// The `ec_id` is the full Edge Cookie value in `{64-hex}.{6-alnum}` format. +/// The 64-hex prefix (hash) is extracted internally and used with the IP to +/// derive a deterministic UID: `SHA-256(ec_hash | ip)` → `mtk-{hash[0:12]}`. +/// Always the same for the same `(ec_id, ip)` pair. +/// +/// Authentication: `Authorization: Bearer {token}` validated against +/// `MOCKTIONEER_PULL_TOKEN` env var (constant-time comparison). If the env +/// var is unset, auth is skipped. +/// +/// **WASM note:** `std::env::var` returns `Err` on Cloudflare Workers, +/// which means auth is silently disabled on that platform. See +/// `TS_ALLOWED_DOMAINS_ENV` for the same limitation. +#[action] +pub async fn handle_resolve( + Headers(headers): Headers, + ValidatedQuery(params): ValidatedQuery, +) -> Result { + // Check bearer token if configured + if let Ok(expected_token) = std::env::var(PULL_TOKEN_ENV) { + let auth_header = headers + .get(header::AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + + let provided_token = auth_header.strip_prefix("Bearer ").unwrap_or(""); + if !constant_time_token_eq(provided_token, &expected_token) { + log::warn!( + "Pull sync auth failed for ec_id={}", + sanitize_for_log(¶ms.ec_id, 72) + ); + return Ok(build_response(StatusCode::UNAUTHORIZED, Body::empty())); + } + } + + // Extract the 64-hex hash prefix from the full ec_id, then hash with IP. + // Validation already confirmed the format, so unwrap is safe here. + let ec_hash = extract_ec_hash(¶ms.ec_id).expect("validated ec_id format"); + + // Generate deterministic UID from ec_hash + IP: mtk-{sha256(ec_hash|ip)[0:12]} + let mut hasher = Sha256::new(); + hasher.update(ec_hash.as_bytes()); + hasher.update(b"|"); + hasher.update(params.ip.as_bytes()); + let hash = hasher.finalize(); + let hex = hex_encode(&hash); + let uid = format!("mtk-{}", &hex[..12]); + + log::info!( + "Pull sync resolve: ec_id={}..., ip={}, uid={}", + ¶ms.ec_id[..8], + params.ip, + uid + ); + + let body = Body::json(&ResolveResponse { uid: Some(uid) }).map_err(|e| { + log::error!("Failed to serialize resolve response: {}", e); + EdgeError::internal(e) + })?; + let mut response = build_response(StatusCode::OK, body); + response.headers_mut().insert( + header::CONTENT_TYPE, + HeaderValue::from_static("application/json"), + ); + Ok(response) +} + +/// Minimal percent-encoding for URL query parameter values. +fn urlencoding(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for b in s.bytes() { + match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + out.push(b as char); + } + _ => { + out.push('%'); + out.push(char::from(HEX_CHARS[(b >> 4) as usize])); + out.push(char::from(HEX_CHARS[(b & 0x0f) as usize])); + } + } + } + out +} + +const HEX_CHARS: [u8; 16] = *b"0123456789ABCDEF"; + +/// Encode bytes as lowercase hex string. +fn hex_encode(bytes: &[u8]) -> String { + let mut s = String::with_capacity(bytes.len() * 2); + for &b in bytes { + s.push(char::from(b"0123456789abcdef"[(b >> 4) as usize])); + s.push(char::from(b"0123456789abcdef"[(b & 0x0f) as usize])); + } + s +} + +/// Constant-time token comparison using `subtle::ConstantTimeEq`. +/// Compares SHA-256 digests to avoid leaking length information. +fn constant_time_token_eq(provided: &str, expected: &str) -> bool { + let hash_a = Sha256::digest(provided.as_bytes()); + let hash_b = Sha256::digest(expected.as_bytes()); + hash_a.ct_eq(&hash_b).into() +} + +/// Returns true if the host looks like a local development address. +fn is_local_host(host: &str) -> bool { + // Handle bracketed IPv6 with port: [::1]:8787 → ::1 + let hostname = if host.starts_with('[') { + host.split(']').next().map(|s| &s[1..]).unwrap_or(host) + } else { + host.split(':').next().unwrap_or(host) + }; + hostname == "localhost" + || hostname == "127.0.0.1" + || hostname == "::1" + || hostname.ends_with(".localhost") +} + +/// Sanitize a user-supplied string for safe logging. +/// Strips control characters and truncates to `max_len`. +fn sanitize_for_log(s: &str, max_len: usize) -> String { + s.chars() + .filter(|c| !c.is_control()) + .take(max_len) + .collect() +} + #[cfg(test)] mod tests { use super::*; @@ -1004,4 +1357,377 @@ mod tests { assert!(first["height"].is_i64()); assert!(first["cpm"].is_f64()); } + + // ----------------------------------------------------------------------- + // Edge Cookie (EC) sync endpoint tests + // ----------------------------------------------------------------------- + + #[test] + fn handle_sync_start_redirects_with_new_mtkid() { + let ctx = ctx( + Method::GET, + "/sync/start?ts_domain=ts.publisher.com", + Body::empty(), + &[], + ); + let response = response_from(block_on(handle_sync_start(ctx))); + assert_eq!( + response.status(), + StatusCode::FOUND, + "should redirect to TS /sync" + ); + let location = response + .headers() + .get(header::LOCATION) + .expect("should have Location header") + .to_str() + .unwrap(); + assert!( + location.starts_with("https://ts.publisher.com/sync?"), + "should redirect to TS domain" + ); + assert!( + location.contains("partner=mocktioneer"), + "should include partner=mocktioneer" + ); + assert!( + location.contains("uid="), + "should include uid= with generated mtkid" + ); + assert!( + location.contains("return="), + "should include return= callback URL" + ); + // Should set mtkid cookie + let cookies = response.headers().get_all("set-cookie"); + assert!( + cookies + .iter() + .any(|c| c.to_str().unwrap_or_default().starts_with("mtkid=")), + "should set mtkid cookie" + ); + } + + #[test] + fn handle_sync_start_reuses_existing_mtkid() { + let mut builder = request_builder(); + builder = builder + .method(Method::GET) + .uri("/sync/start?ts_domain=ts.publisher.com") + .header("Cookie", "mtkid=existing-id-123"); + let request = builder.body(Body::empty()).expect("request"); + let ctx = RequestContext::new(request, PathParams::default()); + let response = response_from(block_on(handle_sync_start(ctx))); + assert_eq!(response.status(), StatusCode::FOUND); + let location = response + .headers() + .get(header::LOCATION) + .unwrap() + .to_str() + .unwrap(); + assert!( + location.contains("uid=existing-id-123"), + "should use existing mtkid in redirect" + ); + // Should NOT set a new cookie + assert!( + response.headers().get("set-cookie").is_none(), + "should not reset existing cookie" + ); + } + + #[test] + fn handle_sync_start_missing_ts_domain() { + let ctx = ctx(Method::GET, "/sync/start", Body::empty(), &[]); + let response = response_from(block_on(handle_sync_start(ctx))); + assert_eq!( + response.status(), + StatusCode::BAD_REQUEST, + "should reject missing ts_domain" + ); + } + + #[test] + fn handle_sync_done_success() { + let ctx = ctx(Method::GET, "/sync/done?ts_synced=1", Body::empty(), &[]); + let response = response_from(block_on(handle_sync_done(ctx))); + assert_eq!(response.status(), StatusCode::OK); + let ct = response + .headers() + .get(header::CONTENT_TYPE) + .unwrap() + .to_str() + .unwrap(); + assert_eq!(ct, "image/gif", "should return a pixel"); + } + + #[test] + fn handle_sync_done_failure() { + let ctx = ctx( + Method::GET, + "/sync/done?ts_synced=0&ts_reason=no_consent", + Body::empty(), + &[], + ); + let response = response_from(block_on(handle_sync_done(ctx))); + assert_eq!( + response.status(), + StatusCode::OK, + "should still return pixel even on sync failure" + ); + } + + #[test] + fn handle_resolve_returns_deterministic_uid() { + // Ensure no auth token is set (tests may run concurrently) + std::env::remove_var(PULL_TOKEN_ENV); + + let ec_id = format!("{}.AbC123", "a".repeat(64)); + let uri = format!("/resolve?ec_id={}&ip=203.0.113.1", ec_id); + let rctx = ctx(Method::GET, &uri, Body::empty(), &[]); + let response = response_from(block_on(handle_resolve(rctx))); + assert_eq!(response.status(), StatusCode::OK); + let body = String::from_utf8(response.into_body().into_bytes().to_vec()).unwrap(); + let json: serde_json::Value = serde_json::from_str(&body).unwrap(); + let uid = json["uid"].as_str().expect("should have uid").to_string(); + assert!(uid.starts_with("mtk-"), "uid should start with mtk-"); + assert_eq!(uid.len(), 16, "uid should be mtk- + 12 hex chars"); + + // Same IP should produce the same UID (deterministic) + let rctx2 = ctx(Method::GET, &uri, Body::empty(), &[]); + let response2 = response_from(block_on(handle_resolve(rctx2))); + let body2 = String::from_utf8(response2.into_body().into_bytes().to_vec()).unwrap(); + let json2: serde_json::Value = serde_json::from_str(&body2).unwrap(); + assert_eq!( + json2["uid"].as_str().unwrap(), + &uid, + "should be deterministic" + ); + } + + #[test] + fn handle_resolve_different_ips_produce_different_uids() { + // Ensure no auth token is set + std::env::remove_var(PULL_TOKEN_ENV); + + let ec_id = format!("{}.XyZ789", "b".repeat(64)); + + let uri1 = format!("/resolve?ec_id={}&ip=203.0.113.1", ec_id); + let ctx1 = ctx(Method::GET, &uri1, Body::empty(), &[]); + let resp1 = response_from(block_on(handle_resolve(ctx1))); + let body1 = String::from_utf8(resp1.into_body().into_bytes().to_vec()).unwrap(); + let uid1 = serde_json::from_str::(&body1).unwrap()["uid"] + .as_str() + .unwrap() + .to_string(); + + let uri2 = format!("/resolve?ec_id={}&ip=198.51.100.1", ec_id); + let ctx2 = ctx(Method::GET, &uri2, Body::empty(), &[]); + let resp2 = response_from(block_on(handle_resolve(ctx2))); + let body2 = String::from_utf8(resp2.into_body().into_bytes().to_vec()).unwrap(); + let uid2 = serde_json::from_str::(&body2).unwrap()["uid"] + .as_str() + .unwrap() + .to_string(); + + assert_ne!(uid1, uid2, "different IPs should produce different UIDs"); + } + + #[test] + fn handle_resolve_rejects_invalid_ec_id() { + // Ensure no auth token is set + std::env::remove_var(PULL_TOKEN_ENV); + + let ctx = ctx( + Method::GET, + "/resolve?ec_id=tooshort&ip=1.2.3.4", + Body::empty(), + &[], + ); + let response = response_from(block_on(handle_resolve(ctx))); + assert!( + response.status() == StatusCode::BAD_REQUEST + || response.status() == StatusCode::UNPROCESSABLE_ENTITY, + "should reject invalid ec_id format" + ); + } + + /// Auth test is run with `--ignored` because it uses env vars that conflict + /// with parallel test execution. Run: `cargo test -p mocktioneer-core -- --ignored` + #[test] + #[ignore = "uses env vars that race with parallel tests"] + fn handle_resolve_rejects_when_auth_fails() { + std::env::set_var(PULL_TOKEN_ENV, "correct-token"); + + let ec_id = format!("{}.TsT456", "c".repeat(64)); + let uri = format!("/resolve?ec_id={}&ip=1.2.3.4", ec_id); + + // Request with wrong token + let mut builder = request_builder(); + builder = builder + .method(Method::GET) + .uri(&uri) + .header("Authorization", "Bearer wrong-token"); + let request = builder.body(Body::empty()).expect("request"); + let rctx = RequestContext::new(request, PathParams::default()); + let response = response_from(block_on(handle_resolve(rctx))); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + + // Request with correct token should succeed + let mut builder2 = request_builder(); + builder2 = builder2 + .method(Method::GET) + .uri(&uri) + .header("Authorization", "Bearer correct-token"); + let request2 = builder2.body(Body::empty()).expect("request"); + let rctx2 = RequestContext::new(request2, PathParams::default()); + let response2 = response_from(block_on(handle_resolve(rctx2))); + assert_eq!(response2.status(), StatusCode::OK); + + // Clean up env var + std::env::remove_var(PULL_TOKEN_ENV); + } + + #[test] + fn urlencoding_encodes_special_chars() { + assert_eq!(urlencoding("hello world"), "hello%20world"); + assert_eq!(urlencoding("a=b&c=d"), "a%3Db%26c%3Dd"); + assert_eq!(urlencoding("plain"), "plain"); + assert_eq!( + urlencoding("https://example.com/path"), + "https%3A%2F%2Fexample.com%2Fpath" + ); + } + + #[test] + fn hex_encode_produces_lowercase_hex() { + assert_eq!(hex_encode(&[0x00, 0xff, 0xab]), "00ffab"); + assert_eq!(hex_encode(&[0xde, 0xad, 0xbe, 0xef]), "deadbeef"); + } + + #[test] + fn is_valid_hostname_accepts_valid_domains() { + assert!(is_valid_hostname("ts.publisher.com")); + assert!(is_valid_hostname("localhost")); + assert!(is_valid_hostname("my-server.example.org")); + } + + #[test] + fn is_valid_hostname_rejects_path_injection() { + assert!(!is_valid_hostname("evil.com/path")); + assert!(!is_valid_hostname("user@evil.com")); + assert!(!is_valid_hostname("evil.com:8080")); + assert!(!is_valid_hostname("evil.com?query")); + assert!(!is_valid_hostname("evil.com#fragment")); + assert!(!is_valid_hostname("evil.com foo")); + assert!(!is_valid_hostname("")); + } + + #[test] + fn is_local_host_detects_local_addresses() { + assert!(is_local_host("localhost")); + assert!(is_local_host("localhost:8787")); + assert!(is_local_host("127.0.0.1")); + assert!(is_local_host("127.0.0.1:7676")); + assert!(is_local_host("[::1]")); + assert!(is_local_host("[::1]:8787")); + assert!(is_local_host("foo.localhost")); + assert!(!is_local_host("example.com")); + assert!(!is_local_host("notlocalhost.com")); + } + + #[test] + fn sanitize_for_log_strips_control_chars() { + assert_eq!(sanitize_for_log("normal text", 128), "normal text"); + assert_eq!(sanitize_for_log("has\nnewline", 128), "hasnewline"); + assert_eq!(sanitize_for_log("has\ttab", 128), "hastab"); + assert_eq!(sanitize_for_log("a\x00b\x1fc", 128), "abc"); + } + + #[test] + fn sanitize_for_log_truncates() { + assert_eq!(sanitize_for_log("abcdefgh", 4), "abcd"); + } + + #[test] + fn constant_time_token_eq_works() { + assert!(constant_time_token_eq("secret", "secret")); + assert!(!constant_time_token_eq("secret", "wrong")); + assert!(!constant_time_token_eq("short", "different-length")); + assert!(!constant_time_token_eq("", "notempty")); + assert!(constant_time_token_eq("", "")); + } + + #[test] + fn handle_sync_start_rejects_path_injection() { + let ctx = ctx( + Method::GET, + "/sync/start?ts_domain=evil.com%2Fpath", + Body::empty(), + &[], + ); + let response = response_from(block_on(handle_sync_start(ctx))); + assert_eq!( + response.status(), + StatusCode::BAD_REQUEST, + "should reject ts_domain with path component" + ); + } + + #[test] + fn handle_sync_start_rejects_auth_injection() { + let ctx = ctx( + Method::GET, + "/sync/start?ts_domain=user%40evil.com", + Body::empty(), + &[], + ); + let response = response_from(block_on(handle_sync_start(ctx))); + assert_eq!( + response.status(), + StatusCode::BAD_REQUEST, + "should reject ts_domain with @ (basic auth syntax)" + ); + } + + #[test] + fn handle_resolve_rejects_non_hex_ec_id() { + std::env::remove_var(PULL_TOKEN_ENV); + + // 64 chars but not hex, plus valid suffix + let ec_id = format!("{}.AbC123", "z".repeat(64)); + let uri = format!("/resolve?ec_id={}&ip=1.2.3.4", ec_id); + let ctx = ctx(Method::GET, &uri, Body::empty(), &[]); + let response = response_from(block_on(handle_resolve(ctx))); + assert!( + response.status() == StatusCode::BAD_REQUEST + || response.status() == StatusCode::UNPROCESSABLE_ENTITY, + "should reject non-hex ec_id" + ); + } + + #[test] + fn handle_pixel_produces_deterministic_mtkid() { + let ctx1 = ctx(Method::GET, "/pixel?pid=test", Body::empty(), &[]); + let response1 = response_from(block_on(handle_pixel(ctx1))); + let cookie1 = response1 + .headers() + .get("set-cookie") + .unwrap() + .to_str() + .unwrap() + .to_string(); + + let ctx2 = ctx(Method::GET, "/pixel?pid=test", Body::empty(), &[]); + let response2 = response_from(block_on(handle_pixel(ctx2))); + let cookie2 = response2 + .headers() + .get("set-cookie") + .unwrap() + .to_str() + .unwrap() + .to_string(); + + assert_eq!(cookie1, cookie2, "same host should produce same mtkid"); + } } diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 1580429..b6e18c3 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -46,6 +46,8 @@ export default defineConfig({ { text: 'APS TAM Bid', link: '/api/aps-bid' }, { text: 'Creatives & Assets', link: '/api/creatives' }, { text: 'Tracking', link: '/api/tracking' }, + { text: 'Edge Cookie Sync', link: '/api/sync' }, + { text: 'Pull Sync (Resolve)', link: '/api/resolve' }, { text: 'Mediation', link: '/api/mediation' }, { text: 'APS Win Notification', link: '/api/aps-win' }, ], @@ -56,6 +58,7 @@ export default defineConfig({ { text: 'Overview', link: '/integrations/' }, { text: 'Prebid.js', link: '/integrations/prebidjs' }, { text: 'Prebid Server', link: '/integrations/prebid-server' }, + { text: 'Trusted Server (EC)', link: '/integrations/trusted-server' }, ], }, ], diff --git a/docs/api/creatives.md b/docs/api/creatives.md index e16b340..723f832 100644 --- a/docs/api/creatives.md +++ b/docs/api/creatives.md @@ -118,6 +118,34 @@ curl "http://127.0.0.1:8787/static/img/300x250.svg?bid=2.50" curl http://127.0.0.1:8787/static/img/728x90.svg ``` +## Creative Metadata {#creative-metadata} + +When a creative is generated from an OpenRTB auction response, the HTML includes a hidden comment with JSON metadata containing signature data, request/response details, and Edge Cookie identity information. + +If the bid request includes [Edge Cookie fields](/integrations/trusted-server#bidstream-identity-decoration) (`user.id`, `user.eids`, `user.buyeruid`), the metadata includes an `edge_cookie` section: + +```json +{ + "edge_cookie": { + "ec_id": "a1b2c3...64hex.AbC123", + "buyer_uid": "mtk-a1b2c3d4e5f6", + "consent": null, + "eids_count": 1, + "mocktioneer_matched": true + } +} +``` + +| Field | Description | +| --------------------- | ------------------------------------------------------------ | +| `ec_id` | Full EC identifier from `user.id` (`{64-hex}.{6-alnum}`) | +| `buyer_uid` | Mocktioneer's synced UID from `user.buyeruid` or `user.eids` | +| `consent` | TCF consent string from `user.consent`, if present | +| `eids_count` | Number of EID sources in the bid request | +| `mocktioneer_matched` | Whether Mocktioneer's own UID appeared in `user.eids` | + +This is useful for debugging the full EC pipeline — you can inspect the rendered creative's source to verify which identity fields the bidder received. + ## Embedding Creatives ### In iframe (from auction response) diff --git a/docs/api/index.md b/docs/api/index.md index 0999d82..9ec04db 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -36,6 +36,14 @@ Mocktioneer exposes several HTTP endpoints for bid requests, creative serving, a | GET | [`/click`](./tracking) | Click landing page | | GET | [`/aps/win`](./aps-win) | APS win notification | +### Edge Cookie Sync Endpoints + +| Method | Path | Description | +| ------ | ----------------------- | ----------------------------------- | +| GET | [`/sync/start`](./sync) | Initiate pixel sync redirect | +| GET | [`/sync/done`](./sync) | Pixel sync callback (returns pixel) | +| GET | [`/resolve`](./resolve) | Pull sync identity resolution (S2S) | + ### Utility Endpoints | Method | Path | Description | @@ -101,14 +109,17 @@ Errors are returned as JSON: ### HTTP Status Codes -| Code | Meaning | -| ---- | ---------------------------- | -| 200 | Success | -| 204 | Success (no content) | -| 400 | Bad request (malformed JSON) | -| 404 | Not found | -| 422 | Validation error | -| 500 | Internal server error | +| Code | Meaning | +| ---- | ------------------------------ | +| 200 | Success | +| 204 | Success (no content) | +| 302 | Redirect (sync flow) | +| 400 | Bad request (malformed JSON) | +| 401 | Unauthorized (invalid token) | +| 403 | Forbidden (domain not allowed) | +| 404 | Not found | +| 422 | Validation error | +| 500 | Internal server error | ## CORS Preflight {#cors-preflight} diff --git a/docs/api/resolve.md b/docs/api/resolve.md new file mode 100644 index 0000000..b20679c --- /dev/null +++ b/docs/api/resolve.md @@ -0,0 +1,132 @@ +# Pull Sync (Resolve) + +The `/resolve` endpoint provides server-to-server identity resolution for the [Edge Cookie (EC)](../integrations/trusted-server) protocol. Trusted-server calls this endpoint to look up a buyer UID for a given EC identifier and client IP address. + +## Endpoint + +``` +GET /resolve?ec_id={64-hex}.{6-alnum}&ip={ip} +``` + +Returns a deterministic buyer UID derived from the EC identifier and IP combination. + +## Parameters + +| Parameter | Location | Type | Required | Description | +| --------- | -------- | ------ | -------- | ------------------------------------------------- | +| `ec_id` | Query | string | Yes | Full EC identifier in `{64-hex}.{6-alnum}` format | +| `ip` | Query | string | Yes | Client IP address (1-45 characters) | + +## Authentication + +When `MOCKTIONEER_PULL_TOKEN` is set, the endpoint requires a Bearer token in the `Authorization` header. The token is compared using constant-time comparison (SHA-256 digest) to prevent timing attacks. + +```bash +curl "http://127.0.0.1:8787/resolve?ec_id=a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2.AbC123&ip=1.2.3.4" \ + -H "Authorization: Bearer mtk-pull-token-change-me" +``` + +When `MOCKTIONEER_PULL_TOKEN` is not set, authentication is disabled and any request is accepted. + +::: warning WASM Note +On Cloudflare Workers, `std::env::var` returns `Err`, so authentication is effectively disabled in that environment. Use Cloudflare's built-in access controls (e.g., Service Auth tokens) instead. +::: + +## Response Format + +```json +{ + "uid": "mtk-a1b2c3d4e5f6" +} +``` + +| Field | Type | Description | +| ----- | ------ | ----------------------------------------------- | +| `uid` | string | Deterministic UID: `mtk-` prefix + 12 hex chars | + +::: tip Deterministic UIDs +The 64-hex hash prefix is extracted from the `ec_id`, then hashed with the IP: `SHA-256(ec_hash || ip)` truncated to 12 hex characters, prefixed with `mtk-`. The same `(ec_id, ip)` pair always produces the same UID. +::: + +## Examples + +```bash +# Basic resolve request +curl "http://127.0.0.1:8787/resolve?ec_id=a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2.AbC123&ip=192.168.1.1" | jq . + +# With authentication +curl "http://127.0.0.1:8787/resolve?ec_id=a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2.AbC123&ip=192.168.1.1" \ + -H "Authorization: Bearer mtk-pull-token-change-me" | jq . +``` + +```json +{ + "uid": "mtk-a1b2c3d4e5f6" +} +``` + +### Different IPs produce different UIDs + +```bash +# Same ec_id, different IPs +curl "http://127.0.0.1:8787/resolve?ec_id=a1b2c3d4...64hex...AbC123&ip=1.2.3.4" | jq .uid +# "mtk-abc123def456" + +curl "http://127.0.0.1:8787/resolve?ec_id=a1b2c3d4...64hex...AbC123&ip=5.6.7.8" | jq .uid +# "mtk-789012345678" (different) +``` + +## Error Responses + +### Missing or invalid ec_id (400) + +The `ec_id` must be in `{64-hex}.{6-alnum}` format: + +```bash +curl "http://127.0.0.1:8787/resolve?ec_id=tooshort&ip=1.2.3.4" +# Returns 400 Bad Request +``` + +```json +{ + "error": { + "code": "VALIDATION_ERROR", + "message": "ec_id: must be in {64-hex}.{6-alnum} format" + } +} +``` + +### Non-hex characters in ec_id (400) + +```bash +curl "http://127.0.0.1:8787/resolve?ec_id=zzzz...64chars....AbC123&ip=1.2.3.4" +# Returns 400 Bad Request +``` + +### Missing ip (400) + +```bash +curl "http://127.0.0.1:8787/resolve?ec_id=a1b2...64hex.AbC123" +# Returns 400 Bad Request +``` + +### Unauthorized (401) + +When `MOCKTIONEER_PULL_TOKEN` is set and the token is missing or incorrect: + +```bash +curl "http://127.0.0.1:8787/resolve?ec_id=a1b2...64hex.AbC123&ip=1.2.3.4" \ + -H "Authorization: Bearer wrong-token" +# Returns 401 Unauthorized +``` + +## Environment Variables + +| Variable | Description | Default | +| ------------------------ | -------------------------------------------------------------------------------------- | ------- | +| `MOCKTIONEER_PULL_TOKEN` | Bearer token required for `/resolve` requests. When unset, authentication is disabled. | Unset | + +## Next Steps + +- [Pixel Sync](./sync) — browser-based redirect sync flow +- [Trusted Server Integration](../integrations/trusted-server) — full setup guide diff --git a/docs/api/sync.md b/docs/api/sync.md new file mode 100644 index 0000000..696f690 --- /dev/null +++ b/docs/api/sync.md @@ -0,0 +1,199 @@ +# Edge Cookie Sync + +Mocktioneer supports [Edge Cookie (EC)](../integrations/trusted-server) pixel sync — a browser-based redirect flow that associates Mocktioneer's `mtkid` cookie with the publisher's Edge Cookie via a trusted-server instance. + +Two endpoints make up the pixel sync flow: `/sync/start` initiates the redirect chain and `/sync/done` receives the callback. + +## Sync Flow + +``` +Browser Mocktioneer Trusted Server + │ │ │ + │ GET /sync/start │ │ + │ ?ts_domain=ts.pub.com │ │ + │────────────────────────>│ │ + │ │ │ + │ 302 → ts.pub.com/sync │ │ + │ Set-Cookie: mtkid=... │ │ + │<────────────────────────│ │ + │ │ │ + │ GET /sync?partner=mocktioneer&uid=...&return=... │ + │────────────────────────────────────────────────────>│ + │ │ │ + │ 302 → mocktioneer/sync/done?ts_synced=1 │ + │<────────────────────────────────────────────────────│ + │ │ │ + │ GET /sync/done │ │ + │ ?ts_synced=1 │ │ + │────────────────────────>│ │ + │ │ │ + │ 200 (1x1 GIF) │ │ + │<────────────────────────│ │ +``` + +## Start Sync {#sync-start} + +### Endpoint + +``` +GET /sync/start?ts_domain={hostname} +``` + +Initiates the pixel sync by redirecting the browser to the trusted-server's `/sync` endpoint, passing Mocktioneer's `mtkid` cookie value. + +### Parameters + +| Parameter | Location | Type | Required | Description | +| ----------- | -------- | ------ | -------- | -------------------------------------------------- | +| `ts_domain` | Query | string | Yes | Trusted-server hostname (e.g., `ts.publisher.com`) | + +### Behavior + +1. Validates `ts_domain` as a clean hostname (no paths, ports, auth, or query strings) +2. If `MOCKTIONEER_TS_DOMAINS` is set, checks `ts_domain` against the allowlist +3. Reads existing `mtkid` cookie or creates a new deterministic one +4. Redirects to `https://{ts_domain}/sync?partner=mocktioneer&uid={mtkid}&return={self}/sync/done` + +### Response + +Returns `302 Found` with a `Location` header pointing to the trusted-server. + +``` +HTTP/1.1 302 Found +Location: https://ts.publisher.com/sync?partner=mocktioneer&uid=abc123...&return=https%3A%2F%2Fmocktioneer.example.com%2Fsync%2Fdone +Cache-Control: no-store, no-cache, must-revalidate, max-age=0 +Set-Cookie: mtkid=abc123...; Path=/; Max-Age=31536000; SameSite=None; Secure; HttpOnly +``` + +The `Set-Cookie` header is only present when creating a new cookie. + +### Cookie Details + +| Property | Value | +| -------- | ----------------------------------------- | +| Name | `mtkid` | +| Value | Deterministic SHA-256 hash (32 hex chars) | +| Path | `/` | +| Max-Age | 31536000 (1 year) | +| SameSite | None | +| Secure | Yes | +| HttpOnly | Yes | + +::: tip Deterministic IDs +The `mtkid` value is derived from `SHA-256("mtkid:" || host)` and truncated to 32 hex characters. The same host always produces the same `mtkid` — there is no randomness. +::: + +### Examples + +```bash +# Initiate sync with trusted-server +curl -v "http://127.0.0.1:8787/sync/start?ts_domain=ts.publisher.com" +# Returns 302 redirect to ts.publisher.com/sync?partner=mocktioneer&uid=... + +# With existing mtkid cookie +curl -v "http://127.0.0.1:8787/sync/start?ts_domain=ts.publisher.com" \ + -H "Cookie: mtkid=existing-value" +``` + +### Error Responses + +#### Missing ts_domain (400) + +```bash +curl http://127.0.0.1:8787/sync/start +# Returns 400 Bad Request +``` + +```json +{ + "error": { + "code": "VALIDATION_ERROR", + "message": "ts_domain: missing required field" + } +} +``` + +#### Invalid hostname (400) + +Characters like `/`, `@`, `:`, `?`, `#`, or whitespace in `ts_domain` are rejected: + +```bash +curl "http://127.0.0.1:8787/sync/start?ts_domain=evil.com/redirect" +# Returns 400 Bad Request +``` + +```json +{ + "error": { + "code": "VALIDATION_ERROR", + "message": "ts_domain: not a valid hostname" + } +} +``` + +#### Domain not allowed (403) + +When `MOCKTIONEER_TS_DOMAINS` is set and `ts_domain` is not in the allowlist: + +```bash +curl "http://127.0.0.1:8787/sync/start?ts_domain=unknown.com" +# Returns 403 Forbidden +``` + +--- + +## Sync Done {#sync-done} + +### Endpoint + +``` +GET /sync/done?ts_synced={0|1} +``` + +Receives the callback from trusted-server after the sync completes. Returns a 1x1 transparent GIF regardless of outcome. + +### Parameters + +| Parameter | Location | Type | Required | Description | +| ----------- | -------- | ------ | -------- | ------------------------------------- | +| `ts_synced` | Query | string | Yes | `"1"` for success, `"0"` for failure | +| `ts_reason` | Query | string | No | Failure reason (e.g., `"no_consent"`) | + +### Response + +Always returns a 1x1 transparent GIF: + +``` +HTTP/1.1 200 OK +Content-Type: image/gif +Content-Length: 43 +Cache-Control: no-store +``` + +### Examples + +```bash +# Successful sync callback +curl -v "http://127.0.0.1:8787/sync/done?ts_synced=1" + +# Failed sync callback (e.g., no consent) +curl -v "http://127.0.0.1:8787/sync/done?ts_synced=0&ts_reason=no_consent" +``` + +--- + +## Environment Variables + +| Variable | Description | Default | +| ------------------------ | ----------------------------------------------------------------------------------------------------------------------- | --------------------------- | +| `MOCKTIONEER_TS_DOMAINS` | Comma-separated allowlist of trusted-server hostnames. When set, `/sync/start` rejects any `ts_domain` not in the list. | Unset (all domains allowed) | + +```bash +# Allow only specific trusted-server instances +export MOCKTIONEER_TS_DOMAINS="ts.publisher.com,ts.staging.publisher.com" +``` + +## Next Steps + +- [Pull Sync (Resolve)](./resolve) — server-to-server identity resolution +- [Trusted Server Integration](../integrations/trusted-server) — full setup guide diff --git a/docs/api/tracking.md b/docs/api/tracking.md index ef6d97c..a0a618c 100644 --- a/docs/api/tracking.md +++ b/docs/api/tracking.md @@ -20,10 +20,14 @@ Returns a 1x1 transparent GIF and optionally sets a tracking cookie. ### Behavior -1. If no `mtkid` cookie exists, sets one with a UUIDv7 value +1. If no `mtkid` cookie exists, sets one with a deterministic SHA-256-based value 2. Returns a 1x1 transparent GIF 3. Sets cache-control headers to prevent caching +::: tip Deterministic IDs +The `mtkid` value is derived from `SHA-256("mtkid:" || host)` and truncated to 32 hex characters. The same host always produces the same `mtkid` — there is no randomness. This cookie is shared with the [pixel sync](/api/sync) flow. +::: + ### Response Headers ``` @@ -31,22 +35,22 @@ Content-Type: image/gif Content-Length: 43 Cache-Control: no-store, no-cache, must-revalidate, max-age=0 Pragma: no-cache -Set-Cookie: mtkid=019abc123...; Path=/; Max-Age=31536000; SameSite=None; Secure; HttpOnly +Set-Cookie: mtkid=a1b2c3d4e5f6...; Path=/; Max-Age=31536000; SameSite=None; Secure; HttpOnly ``` The `Set-Cookie` header is only present when creating a new cookie. ### Cookie Details -| Property | Value | -| -------- | ----------------- | -| Name | `mtkid` | -| Value | UUIDv7 | -| Path | `/` | -| Max-Age | 31536000 (1 year) | -| SameSite | None | -| Secure | Yes | -| HttpOnly | Yes | +| Property | Value | +| -------- | ----------------------------------------- | +| Name | `mtkid` | +| Value | Deterministic SHA-256 hash (32 hex chars) | +| Path | `/` | +| Max-Age | 31536000 (1 year) | +| SameSite | None | +| Secure | Yes | +| HttpOnly | Yes | ### Examples diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 62ee54e..d0ee23f 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -49,18 +49,21 @@ adapters = ["axum", "cloudflare", "fastly"] ### Available Routes -| Path | Methods | Handler | Description | -| -------------------------- | ------- | ------------------------- | ----------------------- | -| `/` | GET | `handle_root` | Service info page | -| `/openrtb2/auction` | POST | `handle_openrtb_auction` | OpenRTB 2.x bid request | -| `/e/dtb/bid` | POST | `handle_aps_bid` | APS TAM bid request | -| `/static/img/{size}` | GET | `handle_static_img` | SVG creative image | -| `/static/creatives/{size}` | GET | `handle_static_creatives` | HTML creative wrapper | -| `/click` | GET | `handle_click` | Click landing page | -| `/pixel` | GET | `handle_pixel` | Tracking pixel | -| `/aps/win` | GET | `handle_aps_win` | APS win notification | -| `/adserver/mediate` | POST | `handle_adserver_mediate` | Auction mediation | -| `/_/sizes` | GET | `handle_sizes` | Supported sizes as JSON | +| Path | Methods | Handler | Description | +| -------------------------- | ------- | ------------------------- | ------------------------ | +| `/` | GET | `handle_root` | Service info page | +| `/openrtb2/auction` | POST | `handle_openrtb_auction` | OpenRTB 2.x bid request | +| `/e/dtb/bid` | POST | `handle_aps_bid` | APS TAM bid request | +| `/static/img/{size}` | GET | `handle_static_img` | SVG creative image | +| `/static/creatives/{size}` | GET | `handle_static_creatives` | HTML creative wrapper | +| `/click` | GET | `handle_click` | Click landing page | +| `/pixel` | GET | `handle_pixel` | Tracking pixel | +| `/aps/win` | GET | `handle_aps_win` | APS win notification | +| `/adserver/mediate` | POST | `handle_adserver_mediate` | Auction mediation | +| `/_/sizes` | GET | `handle_sizes` | Supported sizes as JSON | +| `/sync/start` | GET | `handle_sync_start` | EC pixel sync initiation | +| `/sync/done` | GET | `handle_sync_done` | EC pixel sync callback | +| `/resolve` | GET | `handle_resolve` | EC pull sync resolution | All routes also have OPTIONS handlers for CORS preflight. @@ -142,6 +145,23 @@ echo_stdout = true | `level` | Log level: `trace`, `debug`, `info`, `warn`, `error` | | `echo_stdout` | Whether to print logs to stdout | +## Environment Variables + +Mocktioneer reads these optional environment variables at runtime for Edge Cookie sync configuration: + +| Variable | Description | Default | +| ------------------------ | ----------------------------------------------------------------------- | --------------------- | +| `MOCKTIONEER_TS_DOMAINS` | Comma-separated allowlist of trusted-server hostnames for `/sync/start` | Unset (all allowed) | +| `MOCKTIONEER_PULL_TOKEN` | Bearer token required for `/resolve` authentication | Unset (auth disabled) | + +```bash +# Example: restrict sync to specific trusted-server instances +export MOCKTIONEER_TS_DOMAINS="ts.publisher.com,ts.staging.publisher.com" +export MOCKTIONEER_PULL_TOKEN="mtk-pull-token-change-me" +``` + +See the [Trusted Server integration guide](/integrations/trusted-server) for full setup details. + ## Rebuilding After Changes Since `edgezero.toml` is embedded at compile time via `include_str!`, you must rebuild the adapter after making changes: diff --git a/docs/integrations/index.md b/docs/integrations/index.md index b450423..3cd22b9 100644 --- a/docs/integrations/index.md +++ b/docs/integrations/index.md @@ -11,35 +11,37 @@ The Mocktioneer adapters are not yet merged upstream. Use the Stackpop forks: ## Supported Integrations -| Platform | Type | Description | -| -------------------------------- | ----------- | ---------------------------- | -| [Prebid.js](./prebidjs) | Client-side | Browser-based header bidding | -| [Prebid Server](./prebid-server) | Server-side | Server-to-server bidding | +| Platform | Type | Description | +| --------------------------------------- | ----------- | ----------------------------------- | +| [Prebid.js](./prebidjs) | Client-side | Browser-based header bidding | +| [Prebid Server](./prebid-server) | Server-side | Server-to-server bidding | +| [Trusted Server (EC)](./trusted-server) | Identity | Edge Cookie sync and identity graph | ## How Integration Works Mocktioneer acts as a drop-in replacement for real bidders during development and testing: ``` -┌─────────────────┐ ┌─────────────────┐ -│ Prebid.js │ │ Prebid Server │ -│ (Browser) │ │ (Server) │ -└────────┬────────┘ └────────┬────────┘ - │ │ - │ OpenRTB 2.x │ - │ Bid Request │ - ▼ ▼ -┌─────────────────────────────────────────┐ -│ Mocktioneer │ -│ (Edge or Local) │ -└─────────────────────────────────────────┘ +┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ +│ Prebid.js │ │ Prebid Server │ │ Trusted Server │ +│ (Browser) │ │ (Server) │ │ (Identity) │ +└────────┬────────┘ └────────┬────────┘ └────────┬────────┘ + │ │ │ + │ OpenRTB 2.x │ │ + │ Bid Request │ EC Sync (Pixel │ + │ │ + Pull + Bidstream) │ + ▼ ▼ ▼ +┌──────────────────────────────────────────────────────────────────┐ +│ Mocktioneer │ +│ (Edge or Local) │ +└──────────────────────────────────────────────────────────────────┘ │ │ OpenRTB 2.x │ Bid Response ▼ -┌─────────────────────────────────────────┐ -│ Ad Server (GAM) │ -└─────────────────────────────────────────┘ +┌──────────────────────────────────────────────────────────────────┐ +│ Ad Server (GAM) │ +└──────────────────────────────────────────────────────────────────┘ ``` ## Benefits @@ -143,6 +145,18 @@ params: { } ``` +### Identity Pipeline Testing + +Test Edge Cookie sync and identity decoration: + +1. Register Mocktioneer with trusted-server +2. Trigger pixel sync via `/sync/start` +3. Verify pull sync via `/resolve` +4. Send decorated bid requests with `user.eids` +5. Inspect creative metadata for EC info + +See the [Trusted Server integration guide](./trusted-server) for details. + ### Error Handling Test error scenarios: diff --git a/docs/integrations/trusted-server.md b/docs/integrations/trusted-server.md new file mode 100644 index 0000000..892954d --- /dev/null +++ b/docs/integrations/trusted-server.md @@ -0,0 +1,283 @@ +# Trusted Server (Edge Cookie) + +Mocktioneer integrates with [trusted-server](https://github.com/ABTechLab/trusted-server) to support the Edge Cookie (EC) identity protocol. This enables testing of the full EC pipeline — pixel sync, pull sync, and bidstream identity decoration — without a production DSP. + +## What is Edge Cookie? + +Edge Cookie is a publisher-side identity mechanism managed by trusted-server. It works by: + +1. Setting a first-party cookie (the "Edge Cookie") on the publisher's domain +2. Syncing partner IDs (like Mocktioneer's `mtkid`) with the EC via pixel sync or pull sync +3. Decorating OpenRTB bid requests with the synced identity data (`user.id`, `user.eids`, `user.buyeruid`) + +Mocktioneer acts as a mock DSP partner, implementing all three integration points so you can test the EC pipeline end-to-end. + +## Architecture + +``` +Publisher Page Trusted Server Mocktioneer + │ │ │ + │ 1. Page load sets │ │ + │ Edge Cookie │ │ + │<──────────────────────>│ │ + │ │ │ + │ 2. Pixel sync │ │ + │ redirect chain │ /sync/start │ + │───────────────────────────────────────────────────>│ + │ │ 302 → TS /sync │ + │<───────────────────────────────────────────────────│ + │ │ │ + │ 3. TS stores mapping │ │ + │──────────────────────>│ │ + │ │ 4. Pull sync │ + │ │ /resolve │ + │ │─────────────────────────>│ + │ │ { "uid": "mtk-..." } │ + │ │<─────────────────────────│ + │ │ │ + │ 5. Bid request with │ │ + │ user.eids │ OpenRTB auction │ + │──────────────────────>│──────────────────────────>│ + │ │ Bid response │ + │ │<─────────────────────────│ +``` + +## Prerequisites + +- A running trusted-server instance +- Mocktioneer deployed (local or edge) +- Admin credentials for the trusted-server `/_ts/admin/*` API + +## Setup + +### 1. Register Mocktioneer as a Partner {#partner-registration} + +Before any sync or identity decoration works, Mocktioneer must be registered as an EC partner with your trusted-server instance. Use the included registration script: + +```bash +export TS_BASE_URL="https://ts.publisher.com" +export TS_ADMIN_USER="admin" +export TS_ADMIN_PASS="your-password" +export MOCKTIONEER_BASE_URL="https://mocktioneer.example.com" + +./examples/register_partner.sh +``` + +This registers Mocktioneer with the following capabilities: + +| Capability | Value | +| -------------------- | ----------------------------------------- | +| Partner ID | `mocktioneer` | +| Source domain | `mocktioneer.dev` | +| OpenRTB atype | `3` (partner-defined) | +| Pixel sync | Enabled (via `/sync/start` redirect flow) | +| Pull sync | Enabled (via `/resolve` endpoint) | +| Bidstream decoration | Enabled (`user.eids`, `user.buyeruid`) | + +::: details Full registration payload + +```json +{ + "id": "mocktioneer", + "name": "Mocktioneer Mock DSP", + "allowed_return_domains": ["mocktioneer.example.com"], + "api_key": "mtk-demo-key-change-me", + "bidstream_enabled": true, + "source_domain": "mocktioneer.dev", + "openrtb_atype": 3, + "sync_rate_limit": 100, + "batch_rate_limit": 60, + "pull_sync_enabled": true, + "pull_sync_url": "https://mocktioneer.example.com/resolve", + "pull_sync_allowed_domains": ["mocktioneer.example.com"], + "pull_sync_ttl_sec": 86400, + "pull_sync_rate_limit": 10, + "ts_pull_token": "mtk-pull-token-change-me" +} +``` + +::: + +#### Registration Environment Variables + +| Variable | Description | Default | +| ------------------------ | ---------------------------------------------- | ---------------------------------------- | +| `TS_BASE_URL` | Trusted-server base URL | `https://cdintel.com` | +| `TS_ADMIN_USER` | Basic Auth username for admin API | **Required** | +| `TS_ADMIN_PASS` | Basic Auth password for admin API | **Required** | +| `MOCKTIONEER_BASE_URL` | Mocktioneer's public base URL | `https://origin-mocktioneer.cdintel.com` | +| `MOCKTIONEER_API_KEY` | API key for batch sync authentication | `mtk-demo-key-change-me` | +| `MOCKTIONEER_PULL_TOKEN` | Bearer token trusted-server sends on pull sync | `mtk-pull-token-change-me` | + +::: warning Change Default Tokens +The default `MOCKTIONEER_API_KEY` and `MOCKTIONEER_PULL_TOKEN` values are placeholders. Set real values in production. +::: + +### 2. Configure Mocktioneer Environment + +Set these environment variables on your Mocktioneer deployment: + +```bash +# Optional: restrict which trusted-server domains can initiate sync +export MOCKTIONEER_TS_DOMAINS="ts.publisher.com,ts.staging.publisher.com" + +# Optional: require authentication on /resolve +export MOCKTIONEER_PULL_TOKEN="mtk-pull-token-change-me" +``` + +| Variable | Description | Default | +| ------------------------ | ----------------------------------------------------------------------- | --------------------- | +| `MOCKTIONEER_TS_DOMAINS` | Comma-separated allowlist of trusted-server hostnames for `/sync/start` | Unset (all allowed) | +| `MOCKTIONEER_PULL_TOKEN` | Bearer token for `/resolve` authentication | Unset (auth disabled) | + +## Sync Methods + +### Pixel Sync (Browser-Based) + +Pixel sync uses a browser redirect chain to associate Mocktioneer's `mtkid` cookie with the publisher's Edge Cookie. This is the primary sync method for browser-based environments. + +**Flow:** + +1. Publisher page loads a sync pixel pointing to `/sync/start?ts_domain=ts.publisher.com` +2. Mocktioneer sets (or reads) the `mtkid` cookie and redirects to trusted-server +3. Trusted-server stores the `mtkid` → EC mapping and redirects back to `/sync/done` +4. Mocktioneer returns a 1x1 transparent GIF + +```html + + +``` + +See the [Sync API reference](/api/sync) for full endpoint details. + +### Pull Sync (Server-to-Server) + +Pull sync allows trusted-server to resolve a buyer UID on demand by calling Mocktioneer's `/resolve` endpoint directly. This is used when the browser-based sync hasn't happened yet or as a fallback. + +**Flow:** + +1. Trusted-server receives a bid request with an EC identifier +2. Trusted-server calls `GET /resolve?ec_id={ec_id}&ip={client_ip}` on Mocktioneer +3. Mocktioneer returns a deterministic UID (`mtk-{12 hex chars}`) +4. Trusted-server includes this UID in the OpenRTB bid request's `user.eids` + +See the [Resolve API reference](/api/resolve) for full endpoint details. + +## Bidstream Identity Decoration + +After sync, trusted-server decorates OpenRTB bid requests with EC identity data. Mocktioneer parses these fields and reflects them in creative metadata for visual debugging. + +### OpenRTB Fields Used + +| Field | Description | Example | +| --------------- | -------------------------------------------------- | ---------------------------------- | +| `user.id` | Full EC value (`{64-hex}.{6-alnum}`) | `a1b2c3...d4e5.AbC123` | +| `user.buyeruid` | Mocktioneer's synced UID | `mtk-a1b2c3d4e5f6` | +| `user.consent` | TCF consent string | `CPx...` | +| `user.eids` | Extended Identifiers (OpenRTB 2.6) | See below | +| `user.ext.eids` | Extended Identifiers (Prebid Server / OpenRTB 2.5) | Fallback when `user.eids` is empty | + +### EID Format + +Trusted-server adds Mocktioneer's UID to the `user.eids` array: + +```json +{ + "user": { + "id": "a1b2c3d4e5f6...64hex...chars.AbC123", + "buyeruid": "mtk-a1b2c3d4e5f6", + "eids": [ + { + "source": "mocktioneer.dev", + "uids": [ + { + "id": "mtk-a1b2c3d4e5f6", + "atype": 3 + } + ] + } + ] + } +} +``` + +### EC Info in Creative Metadata + +When EC data is present in a bid request, Mocktioneer includes it in the creative's HTML comment metadata alongside the existing signature and request/response data: + +```json +{ + "edge_cookie": { + "ec_id": "a1b2c3...64hex.AbC123", + "buyer_uid": "mtk-a1b2c3d4e5f6", + "consent": "CPx...", + "eids_count": 1, + "mocktioneer_matched": true + } +} +``` + +This allows you to inspect the rendered creative and verify which EC fields were received by the bidder. + +## Testing the Full Flow + +### 1. Register and start Mocktioneer + +```bash +# Register with trusted-server (one-time) +export TS_BASE_URL="https://ts.publisher.com" +export TS_ADMIN_USER="admin" +export TS_ADMIN_PASS="password" +./examples/register_partner.sh + +# Start Mocktioneer locally +cargo run -p mocktioneer-adapter-axum +``` + +### 2. Test pixel sync + +```bash +# Initiate sync (follow redirects with -L) +curl -v -L "http://127.0.0.1:8787/sync/start?ts_domain=ts.publisher.com" +``` + +### 3. Test pull sync + +```bash +# Resolve a UID from an EC identifier +curl "http://127.0.0.1:8787/resolve?ec_id=a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2.AbC123&ip=192.168.1.1" | jq . +``` + +### 4. Test bidstream decoration + +Send an OpenRTB request with EC identity fields and inspect the creative metadata: + +```bash +curl -s -X POST http://127.0.0.1:8787/openrtb2/auction \ + -H 'Content-Type: application/json' \ + -d '{ + "id": "test-ec", + "imp": [{"id": "1", "banner": {"w": 300, "h": 250}}], + "user": { + "id": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2.AbC123", + "buyeruid": "mtk-a1b2c3d4e5f6", + "eids": [{ + "source": "mocktioneer.dev", + "uids": [{"id": "mtk-a1b2c3d4e5f6", "atype": 3}] + }] + } + }' | jq . +``` + +## Security Considerations + +- **Open-redirect protection**: `/sync/start` validates `ts_domain` as a clean hostname, rejecting paths, ports, auth strings, and query parameters +- **Domain allowlist**: Set `MOCKTIONEER_TS_DOMAINS` to restrict which trusted-server instances can initiate sync +- **Constant-time auth**: `/resolve` uses SHA-256 digest comparison to prevent timing attacks on the Bearer token +- **Input sanitization**: User-supplied values are sanitized before logging (control characters stripped, length truncated) +- **Deterministic IDs**: No randomness — all generated IDs use SHA-256 hashing for reproducibility diff --git a/edgezero.toml b/edgezero.toml index 720d62d..e8f7e8c 100644 --- a/edgezero.toml +++ b/edgezero.toml @@ -146,6 +146,50 @@ methods = ["OPTIONS"] handler = "mocktioneer_core::routes::handle_options" adapters = ["axum", "cloudflare", "fastly"] +# Edge Cookie (EC) sync endpoints + +[[triggers.http]] +id = "sync_start" +path = "/sync/start" +methods = ["GET"] +handler = "mocktioneer_core::routes::handle_sync_start" +adapters = ["axum", "cloudflare", "fastly"] + +[[triggers.http]] +id = "sync_start_options" +path = "/sync/start" +methods = ["OPTIONS"] +handler = "mocktioneer_core::routes::handle_options" +adapters = ["axum", "cloudflare", "fastly"] + +[[triggers.http]] +id = "sync_done" +path = "/sync/done" +methods = ["GET"] +handler = "mocktioneer_core::routes::handle_sync_done" +adapters = ["axum", "cloudflare", "fastly"] + +[[triggers.http]] +id = "sync_done_options" +path = "/sync/done" +methods = ["OPTIONS"] +handler = "mocktioneer_core::routes::handle_options" +adapters = ["axum", "cloudflare", "fastly"] + +[[triggers.http]] +id = "resolve" +path = "/resolve" +methods = ["GET"] +handler = "mocktioneer_core::routes::handle_resolve" +adapters = ["axum", "cloudflare", "fastly"] + +[[triggers.http]] +id = "resolve_options" +path = "/resolve" +methods = ["OPTIONS"] +handler = "mocktioneer_core::routes::handle_options" +adapters = ["axum", "cloudflare", "fastly"] + [adapters.axum.adapter] crate = "crates/mocktioneer-adapter-axum" manifest = "crates/mocktioneer-adapter-axum/axum.toml" diff --git a/examples/register_partner.sh b/examples/register_partner.sh new file mode 100755 index 0000000..93e0cc2 --- /dev/null +++ b/examples/register_partner.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Register mocktioneer as an EC sync partner in trusted-server. +# +# This is the setup step required before pixel sync, pull sync, or batch sync +# will work. Run it once against your trusted-server instance. +# +# Environment variables: +# TS_BASE_URL Trusted-server base URL (default: https://cdintel.com) +# TS_ADMIN_USER Basic Auth username for /_ts/admin/* routes +# TS_ADMIN_PASS Basic Auth password for /_ts/admin/* routes +# MOCKTIONEER_BASE_URL Mocktioneer base URL (default: https://origin-mocktioneer.cdintel.com) +# MOCKTIONEER_API_KEY API key for batch sync auth (default: mtk-demo-key-change-me) +# MOCKTIONEER_PULL_TOKEN Bearer token TS sends on pull sync calls (default: mtk-pull-token-change-me) + +TS_BASE_URL="${TS_BASE_URL:-https://cdintel.com}" +TS_ADMIN_USER="${TS_ADMIN_USER:?Set TS_ADMIN_USER to the Basic Auth username}" +TS_ADMIN_PASS="${TS_ADMIN_PASS:?Set TS_ADMIN_PASS to the Basic Auth password}" +MOCKTIONEER_BASE_URL="${MOCKTIONEER_BASE_URL:-https://origin-mocktioneer.cdintel.com}" +MOCKTIONEER_API_KEY="${MOCKTIONEER_API_KEY:-mtk-demo-key-change-me}" +MOCKTIONEER_PULL_TOKEN="${MOCKTIONEER_PULL_TOKEN:-mtk-pull-token-change-me}" + +# Extract hostname from mocktioneer URL for allowed_return_domains +MOCKTIONEER_HOST=$(echo "${MOCKTIONEER_BASE_URL}" | sed -E 's|https?://||' | sed -E 's|/.*||') + +# Extract hostname from pull sync URL for pull_sync_allowed_domains +RESOLVE_URL="${MOCKTIONEER_BASE_URL}/resolve" +RESOLVE_HOST=$(echo "${RESOLVE_URL}" | sed -E 's|https?://||' | sed -E 's|/.*||' | sed -E 's|:.*||') + +echo "Registering mocktioneer as EC partner at ${TS_BASE_URL}/_ts/admin/partners/register" +echo " Mocktioneer host: ${MOCKTIONEER_HOST}" +echo " Pull sync URL: ${RESOLVE_URL}" +echo "" + +curl -sS -w "\nHTTP %{http_code}\n" \ + -X POST "${TS_BASE_URL}/_ts/admin/partners/register" \ + -u "${TS_ADMIN_USER}:${TS_ADMIN_PASS}" \ + -H "Content-Type: application/json" \ + -d @- <