diff --git a/Cargo.lock b/Cargo.lock index 66e402766..17eb7250d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6611,11 +6611,13 @@ dependencies = [ "futures", "log", "reqwest 0.12.28", + "serde_json", "simple_logger", "temp-env", "tokio", "tower 0.4.13", "trusted-server-core", + "trusted-server-identity-providers", "web-time", ] diff --git a/crates/trusted-server-adapter-axum/Cargo.toml b/crates/trusted-server-adapter-axum/Cargo.toml index af707aa24..0d9ea7247 100644 --- a/crates/trusted-server-adapter-axum/Cargo.toml +++ b/crates/trusted-server-adapter-axum/Cargo.toml @@ -29,12 +29,14 @@ reqwest = { workspace = true } simple_logger = { workspace = true } tokio = { workspace = true, features = ["rt-multi-thread", "macros", "sync", "time"] } trusted-server-core = { workspace = true } +trusted-server-identity-providers = { workspace = true } web-time = { workspace = true } [dev-dependencies] trusted-server-core = { workspace = true, features = ["test-utils"] } axum = { workspace = true } base64 = { workspace = true } +serde_json = { workspace = true } temp-env = { workspace = true } tokio = { workspace = true, features = ["rt-multi-thread", "macros"] } tower = { workspace = true, features = ["util"] } diff --git a/crates/trusted-server-adapter-axum/src/app.rs b/crates/trusted-server-adapter-axum/src/app.rs index 585a9d24f..5b70ea906 100644 --- a/crates/trusted-server-adapter-axum/src/app.rs +++ b/crates/trusted-server-adapter-axum/src/app.rs @@ -9,18 +9,22 @@ use edgezero_core::http::{ HandlerFuture, HeaderValue, Method, Request, Response, StatusCode, header, }; use edgezero_core::router::RouterService; -use error_stack::Report; +use error_stack::{Report, ResultExt as _}; use trusted_server_core::auction::endpoints::handle_auction; use trusted_server_core::auction::{ AuctionOrchestrator, build_orchestrator_with_plan, compile_auction_plan, }; use trusted_server_core::cache_policy::EdgeCacheHeader; -use trusted_server_core::ec::EcContext; use trusted_server_core::ec::admin::{ admin_ec_lookup_not_supported, deny_admin_diagnostic_fallback, handle_admin_eids_lookup, }; +use trusted_server_core::ec::coordinator::ProviderCoordinator; +use trusted_server_core::ec::kv::KvIdentityGraph; +use trusted_server_core::ec::page_load::NavigationPermit; use trusted_server_core::ec::registry::PartnerRegistry; +use trusted_server_core::ec::{EcContext, EidSyncSource}; use trusted_server_core::error::{IntoHttpResponse as _, TrustedServerError}; +use trusted_server_core::http_util::is_navigation_request; use trusted_server_core::integrations::{IntegrationRegistry, ProxyDispatchInput}; use trusted_server_core::proxy::{ handle_first_party_click, handle_first_party_proxy, handle_first_party_proxy_rebuild, @@ -40,7 +44,15 @@ use trusted_server_core::settings_data::{ use trusted_server_core::platform::{ClientInfo, RuntimeServices}; use trusted_server_core::trace::{TraceMetadata, TracePreDispatchHook}; +use trusted_server_identity_providers::{ + build_after_response_coordinator, build_coordinator_with, partner_registry, +}; +use crate::ec_kv::{LocalEcKvStore, LocalRateLimiter}; +use crate::identity::{ + build_ec_request_state, device_signals_for, dispatch_page_lookups, finalize_navigation, + looks_like_browser, prepare_after_response_lookups, spawn_after_response_lookups, +}; use crate::middleware::{AuthMiddleware, FinalizeResponseMiddleware, SanitizeRequestMiddleware}; use crate::platform::{AxumPlatformConfigStore, AxumPlatformSecretStore, build_runtime_services}; @@ -54,14 +66,24 @@ pub struct AppState { orchestrator: Arc, registry: Arc, services: Option, + /// Partner registry, with the sources of enabled providers marked. + partner_registry: Arc, + /// Coordinator for providers collected before the response, if any. + page_identity: Option>, + /// Coordinator for providers collected after the response, if any. + after_response_identity: Option>, + /// Process-local EC identity store, present when `ec.ec_store` is set. + ec_store: Option, + rate_limiter: LocalRateLimiter, } /// Build the application state, loading settings and constructing all per-application components. /// /// # Errors /// -/// Returns an error when settings, the auction orchestrator, or the integration -/// registry fail to initialise. +/// Returns an error when settings, the auction orchestrator, the integration +/// registry, the partner registry or an enabled identity provider fail to +/// initialise. fn build_state() -> Result, Report> { let store_name = default_config_store_name(); let config_key = default_config_key(); @@ -87,25 +109,89 @@ fn build_state_with_settings( build_state_with_services(settings, None) } -fn build_state_with_services( +pub(crate) fn build_state_with_services( settings: Settings, services: Option, ) -> Result, Report> { - trusted_server_core::ec::provider::reject_enabled_providers(&settings.ec, "Axum")?; let plan = Arc::new(compile_auction_plan(&settings)?); plan.validate_for_target(trusted_server_core::platform::AuctionTargetId::Axum)?; let orchestrator = build_orchestrator_with_plan(Arc::clone(&plan), &settings)?; let registry = IntegrationRegistry::with_plan(&settings, plan)?; + // A provider that cannot run must stop startup: skipping it would quietly + // hand its sources back to browser ingest. + let partner_registry = Arc::new(partner_registry(&settings.ec)?); + let page_identity = build_coordinator_with(&settings, Arc::clone(&partner_registry)) + .change_context(TrustedServerError::Configuration { + message: "identity provider configuration is invalid".to_owned(), + })? + .map(Arc::new); + let after_response_identity = + build_after_response_coordinator(&settings, Arc::clone(&partner_registry)) + .change_context(TrustedServerError::Configuration { + message: "identity provider configuration is invalid".to_owned(), + })? + .map(Arc::new); + let ec_store = settings.ec.ec_store.as_deref().map(|store_name| { + log::warn!( + "Axum dev server: EC identity store `{store_name}` is process-local and is lost on exit" + ); + LocalEcKvStore::new(store_name) + }); + Ok(Arc::new(AppState { settings: Arc::new(settings), orchestrator: Arc::new(orchestrator), registry: Arc::new(registry), services, + partner_registry, + page_identity, + after_response_identity, + ec_store, + rate_limiter: LocalRateLimiter::default(), })) } impl AppState { + pub(crate) fn settings(&self) -> &Settings { + &self.settings + } + + pub(crate) fn partner_registry(&self) -> &PartnerRegistry { + &self.partner_registry + } + + pub(crate) fn page_identity(&self) -> Option<&Arc> { + self.page_identity.as_ref() + } + + pub(crate) fn after_response_identity(&self) -> Option<&Arc> { + self.after_response_identity.as_ref() + } + + pub(crate) fn rate_limiter(&self) -> &LocalRateLimiter { + &self.rate_limiter + } + + /// Builds an identity graph over the shared process-local store. + pub(crate) fn identity_graph(&self) -> Option { + self.ec_store.clone().map(KvIdentityGraph::new) + } + + /// Identity graph for read-only auction enrichment from a browser. + fn auction_identity_graph(&self, req: &Request) -> Option { + if looks_like_browser(&device_signals_for(req)) { + self.identity_graph() + } else { + None + } + } + + /// Partner registry for auction EID enrichment, `None` when empty. + fn auction_partner_registry(&self) -> Option<&PartnerRegistry> { + (!self.partner_registry.is_empty()).then_some(&*self.partner_registry) + } + fn services_for_request(&self, ctx: &RequestContext) -> RuntimeServices { self.services .clone() @@ -208,7 +294,7 @@ fn build_ec_context(state: &AppState, services: &RuntimeServices, req: &Request) // --------------------------------------------------------------------------- async fn dispatch_fallback( - state: &AppState, + state: &Arc, services: &RuntimeServices, mut req: Request, ) -> Result> { @@ -242,36 +328,93 @@ async fn dispatch_fallback( }); } + let mut ec = build_ec_request_state(state, services, &req); + + // Mint and look up only on document navigations by recognised browsers; + // subresource requests may lack consent signals such as Sec-GPC. + let is_navigation = is_navigation_request(&req); + let mut has_permit = false; + if is_navigation { + ec.ec_context.set_eid_sync_source(EidSyncSource::Navigation); + // The page-load write permit uses the stricter Fetch Metadata test; + // requests without it never write KV. + if let Some(permit) = NavigationPermit::classify(&req) { + ec.ec_context.set_navigation_permit(permit); + has_permit = true; + } + } + let is_publisher_navigation = ec.is_real_browser && is_navigation && has_permit; + // A new EC is minted only when this request may also write its row. + if is_publisher_navigation + && let Err(err) = ec + .ec_context + .generate_if_needed(&state.settings, ec.kv_graph.as_ref()) + { + log::warn!("EC generation failed for publisher proxy: {err:?}"); + } + + // Page-phase lookups start before the origin fetch so they overlap it. + let page_lookups = if is_publisher_navigation { + dispatch_page_lookups(state, services, &req, &mut ec).await + } else { + None + }; + let after_response = if is_publisher_navigation { + prepare_after_response_lookups(state, services, &req, &ec) + } else { + None + }; + // Run the server-side auction with the configured creative-opportunity // slots; `handle_publisher_request` matches them against the request path. - let mut ec_context = build_ec_context(state, services, &req); let auction = AuctionDispatch { integration_registry: &state.registry, orchestrator: &state.orchestrator, slots: state.settings.creative_opportunity_slots(), - registry: None, + registry: state.auction_partner_registry(), }; - let publisher_response = handle_publisher_request( + let result = match handle_publisher_request( &state.settings, services, - None, - &mut ec_context, + ec.kv_graph.as_ref(), + &mut ec.ec_context, auction, req, EdgeCacheHeader::SMaxageFallback, ) - .await?; - // Async finalize so the dispatched auction is collected and its bids are - // injected before `` (the sync buffer path would drop them). - buffer_publisher_response_async( - publisher_response, - &method, - &state.settings, - &state.registry, - &state.orchestrator, - services, - ) .await + { + Ok(publisher_response) => { + // Origin start succeeded: authorize orphan recovery only for + // real-browser document navigations. + ec.ec_context + .set_recovery_eligible(ec.is_real_browser && is_navigation); + // Async finalize so the dispatched auction is collected and its + // bids are injected before `` (the sync buffer path would + // drop them). + buffer_publisher_response_async( + publisher_response, + &method, + &state.settings, + &state.registry, + &state.orchestrator, + services, + ) + .await + } + Err(report) => Err(report), + }; + + let mut response = result.unwrap_or_else(|report| http_error(&report)); + finalize_navigation(state, services, &mut ec, page_lookups, &mut response).await; + // The task runs to completion on its own; its handle is not awaited. + drop(spawn_after_response_lookups( + Arc::clone(state), + services.clone(), + &mut ec, + after_response, + )); + Ok(response) } fn fallback_handler( @@ -497,8 +640,7 @@ fn named_route_handler( Ok(admin_ec_lookup_not_supported()) } NamedRouteHandler::AdminEidsLookup => { - let partner_registry = PartnerRegistry::from_ec(&state.settings.ec)?; - handle_admin_eids_lookup(&partner_registry, &req) + handle_admin_eids_lookup(&state.partner_registry, &req) } NamedRouteHandler::LegacyAdminDenied => Ok(legacy_admin_alias_denied()), NamedRouteHandler::Auction => { @@ -506,11 +648,13 @@ fn named_route_handler( // gate sees the caller's jurisdiction — `EcContext::default()` // fails it closed for consented users. let mut ec_context = build_ec_context(&state, &services, &req); + ec_context.set_eid_sync_source(EidSyncSource::Auction); + let kv_graph = state.auction_identity_graph(&req); handle_auction( &state.settings, &state.orchestrator, - None, - None, + kv_graph.as_ref(), + state.auction_partner_registry(), &mut ec_context, &services, req, @@ -529,12 +673,13 @@ fn named_route_handler( integration_registry: &state.registry, orchestrator: &state.orchestrator, slots: state.settings.creative_opportunity_slots(), - registry: None, + registry: state.auction_partner_registry(), }; + let kv_graph = state.auction_identity_graph(&req); handle_page_bids( &state.settings, &services, - None, + kv_graph.as_ref(), auction, &mut ec_context, req, @@ -672,7 +817,7 @@ impl TrustedServerApp { } } -fn build_router(state: &Arc) -> RouterService { +pub(crate) fn build_router(state: &Arc) -> RouterService { let fallback = fallback_handler(Arc::clone(state)); let trace_state = Arc::clone(state); @@ -754,19 +899,19 @@ mod trace_dispatch_tests { use trusted_server_core::trace::TraceTerminalResponse; #[test] - fn enabled_identity_provider_fails_startup() { + fn unregistered_identity_provider_kind_fails_startup() { let mut settings = trace_settings(false); settings.ec.identity_providers = vec![trusted_server_core::test_support::fixtures::enabled_identity_provider()]; let Err(error) = build_state_with_settings(settings) else { - panic!("should refuse to start with an enabled identity provider"); + panic!("should refuse to start with an unregistered provider kind"); }; let message = format!("{error:?}"); assert!( - message.contains("example-provider") && message.contains("Axum"), - "should name the provider and the adapter: {message}" + message.contains("example-provider") && message.contains("example-kind"), + "should name the provider and its kind: {message}" ); } diff --git a/crates/trusted-server-adapter-axum/src/ec_kv.rs b/crates/trusted-server-adapter-axum/src/ec_kv.rs new file mode 100644 index 000000000..efe433b12 --- /dev/null +++ b/crates/trusted-server-adapter-axum/src/ec_kv.rs @@ -0,0 +1,352 @@ +//! Process-local EC identity store and rate limiter for the Axum dev server. +//! +//! The Axum dev server has no platform KV store, so the EC identity graph is +//! kept in process memory. Every operation takes one mutex, which makes reads +//! strongly consistent and gives the generation-based compare-and-swap that +//! [`EcKvStore`] requires. Data is lost when the process exits, so this store +//! is only for local development and tests. + +use std::collections::{BTreeMap, HashMap}; +use std::sync::{Arc, Mutex, MutexGuard}; +use std::time::Duration; + +use error_stack::Report; +use trusted_server_core::ec::kv_backend::{ + EcKvLookup, EcKvStore, EcKvWrite, EcKvWriteMode, EcKvWriteOutcome, +}; +use trusted_server_core::ec::rate_limiter::RateLimiter; +use trusted_server_core::error::TrustedServerError; +use web_time::Instant; + +/// One stored row. +struct StoredEntry { + body: Vec, + metadata: Vec, + generation: u64, + expires_at: Option, +} + +impl StoredEntry { + fn is_live(&self, now: Instant) -> bool { + self.expires_at.is_none_or(|expires_at| now < expires_at) + } +} + +#[derive(Default)] +struct StoreState { + entries: BTreeMap, + /// Last generation handed out. Generations are never reused, even after + /// a delete, so a stale compare-and-swap marker can never match a newer + /// row for the same key. + last_generation: u64, +} + +impl StoreState { + /// Drops `key` when its TTL has passed, so reads never see expired rows. + fn purge_if_expired(&mut self, key: &str, now: Instant) { + if self + .entries + .get(key) + .is_some_and(|stored| !stored.is_live(now)) + { + self.entries.remove(key); + } + } +} + +/// Process-local [`EcKvStore`] for the Axum dev server. +/// +/// Cloning is cheap and every clone shares the same rows, so one store can +/// back the identity graph built for each request. +#[derive(Clone)] +pub struct LocalEcKvStore { + name: Arc, + state: Arc>, +} + +impl LocalEcKvStore { + /// Creates an empty store reported under `name` in logs and errors. + #[must_use] + pub fn new(name: &str) -> Self { + Self { + name: Arc::from(name), + state: Arc::new(Mutex::new(StoreState::default())), + } + } + + fn lock(&self) -> Result, Report> { + self.state.lock().map_err(|_| { + Report::new(TrustedServerError::KvStore { + store_name: self.name.to_string(), + message: "local EC store lock is poisoned".to_owned(), + }) + }) + } +} + +impl EcKvStore for LocalEcKvStore { + fn store_name(&self) -> &str { + &self.name + } + + fn lookup(&self, key: &str) -> Result, Report> { + let mut state = self.lock()?; + state.purge_if_expired(key, Instant::now()); + Ok(state.entries.get(key).map(|stored| EcKvLookup { + body: stored.body.clone(), + metadata: Some(stored.metadata.clone()), + generation: stored.generation, + })) + } + + fn key_exists(&self, key: &str) -> Result> { + // Reads and writes share one lock, so this answer is strongly + // consistent with every completed write. + let mut state = self.lock()?; + state.purge_if_expired(key, Instant::now()); + Ok(state.entries.contains_key(key)) + } + + fn insert( + &self, + key: &str, + write: EcKvWrite<'_>, + ) -> Result> { + let now = Instant::now(); + let mut state = self.lock()?; + state.purge_if_expired(key, now); + let existing_generation = state.entries.get(key).map(|stored| stored.generation); + + let precondition_holds = match write.mode { + EcKvWriteMode::Add => existing_generation.is_none(), + EcKvWriteMode::Overwrite => true, + EcKvWriteMode::IfGenerationMatch(expected) => existing_generation == Some(expected), + }; + if !precondition_holds { + return Ok(EcKvWriteOutcome::PreconditionFailed); + } + + state.last_generation += 1; + let generation = state.last_generation; + state.entries.insert( + key.to_owned(), + StoredEntry { + body: write.body.as_bytes().to_vec(), + metadata: write.metadata.as_bytes().to_vec(), + generation, + expires_at: now.checked_add(write.ttl), + }, + ); + Ok(EcKvWriteOutcome::Written) + } + + fn list_keys_with_prefix( + &self, + prefix: &str, + limit: u32, + ) -> Result, Report> { + let now = Instant::now(); + let state = self.lock()?; + Ok(state + .entries + .range(prefix.to_owned()..) + .take_while(|(key, _)| key.starts_with(prefix)) + .filter(|(_, stored)| stored.is_live(now)) + .take(usize::try_from(limit).unwrap_or(usize::MAX)) + .map(|(key, _)| key.clone()) + .collect()) + } + + fn delete(&self, key: &str) -> Result<(), Report> { + self.lock()?.entries.remove(key); + Ok(()) + } +} + +/// Length of one rate-limit window. +const RATE_WINDOW: Duration = Duration::from_secs(60); + +/// Process-local fixed-window [`RateLimiter`] for the Axum dev server. +/// +/// Mirrors the Fastly limiter: an hourly budget is enforced as a per-minute +/// budget rounded up to at least one request. +#[derive(Clone, Default)] +pub struct LocalRateLimiter { + windows: Arc>>, +} + +impl RateLimiter for LocalRateLimiter { + fn exceeded(&self, key: &str, hourly_limit: u32) -> Result> { + if hourly_limit == 0 { + return Ok(true); + } + let per_minute_limit = (hourly_limit.saturating_add(59) / 60).max(1); + let now = Instant::now(); + let mut windows = self.windows.lock().map_err(|_| { + Report::new(TrustedServerError::KvStore { + store_name: "local-rate-limiter".to_owned(), + message: "local rate limiter lock is poisoned".to_owned(), + }) + })?; + let window = windows.entry(key.to_owned()).or_insert((now, 0)); + if now.duration_since(window.0) >= RATE_WINDOW { + *window = (now, 0); + } + if window.1 >= per_minute_limit { + return Ok(true); + } + window.1 += 1; + Ok(false) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn write(body: &str, mode: EcKvWriteMode) -> EcKvWrite<'_> { + EcKvWrite { + body, + metadata: "{}", + ttl: Duration::from_secs(60), + mode, + } + } + + #[test] + fn add_fails_when_the_key_exists() { + let store = LocalEcKvStore::new("example-store"); + + let first = store + .insert("key", write("a", EcKvWriteMode::Add)) + .expect("should insert"); + let second = store + .insert("key", write("b", EcKvWriteMode::Add)) + .expect("should report the precondition"); + + assert_eq!(first, EcKvWriteOutcome::Written, "should create the key"); + assert_eq!( + second, + EcKvWriteOutcome::PreconditionFailed, + "should refuse to add an existing key" + ); + } + + #[test] + fn generation_match_applies_only_to_the_current_generation() { + let store = LocalEcKvStore::new("example-store"); + store + .insert("key", write("a", EcKvWriteMode::Add)) + .expect("should insert"); + let generation = store + .lookup("key") + .expect("should read") + .expect("should find the key") + .generation; + + let applied = store + .insert( + "key", + write("b", EcKvWriteMode::IfGenerationMatch(generation)), + ) + .expect("should write"); + let stale = store + .insert( + "key", + write("c", EcKvWriteMode::IfGenerationMatch(generation)), + ) + .expect("should report the precondition"); + + assert_eq!(applied, EcKvWriteOutcome::Written, "should apply the CAS"); + assert_eq!( + stale, + EcKvWriteOutcome::PreconditionFailed, + "should reject a stale generation" + ); + let body = store + .lookup("key") + .expect("should read") + .expect("should find the key") + .body; + assert_eq!(body, b"b", "should keep the CAS winner"); + } + + #[test] + fn generations_are_not_reused_after_delete() { + let store = LocalEcKvStore::new("example-store"); + store + .insert("key", write("a", EcKvWriteMode::Add)) + .expect("should insert"); + let old = store + .lookup("key") + .expect("should read") + .expect("should find the key") + .generation; + store.delete("key").expect("should delete"); + store + .insert("key", write("b", EcKvWriteMode::Add)) + .expect("should insert again"); + + let outcome = store + .insert("key", write("c", EcKvWriteMode::IfGenerationMatch(old))) + .expect("should report the precondition"); + + assert_eq!( + outcome, + EcKvWriteOutcome::PreconditionFailed, + "should not match a generation from a deleted row" + ); + } + + #[test] + fn expired_rows_are_absent() { + let store = LocalEcKvStore::new("example-store"); + store + .insert( + "key", + EcKvWrite { + body: "a", + metadata: "{}", + ttl: Duration::ZERO, + mode: EcKvWriteMode::Add, + }, + ) + .expect("should insert"); + + assert!( + !store.key_exists("key").expect("should check"), + "should treat an expired row as absent" + ); + assert!( + store.lookup("key").expect("should read").is_none(), + "should not return an expired row" + ); + } + + #[test] + fn prefix_listing_is_bounded() { + let store = LocalEcKvStore::new("example-store"); + for key in ["a.1", "a.2", "a.3", "b.1"] { + store + .insert(key, write("x", EcKvWriteMode::Overwrite)) + .expect("should insert"); + } + + let keys = store + .list_keys_with_prefix("a.", 2) + .expect("should list keys"); + + assert_eq!(keys, vec!["a.1", "a.2"], "should return one bounded page"); + } + + #[test] + fn rate_limiter_enforces_the_per_minute_budget() { + let limiter = LocalRateLimiter::default(); + + let first = limiter.exceeded("key", 60).expect("should check"); + let second = limiter.exceeded("key", 60).expect("should check"); + + assert!(!first, "should allow the first request"); + assert!(second, "should limit the second request in the window"); + } +} diff --git a/crates/trusted-server-adapter-axum/src/identity.rs b/crates/trusted-server-adapter-axum/src/identity.rs new file mode 100644 index 000000000..a3e73ce8e --- /dev/null +++ b/crates/trusted-server-adapter-axum/src/identity.rs @@ -0,0 +1,334 @@ +//! EC identity lifecycle for publisher navigations on the Axum dev server. +//! +//! Mirrors the Fastly adapter: the `ts-ec` cookie is read and minted, enabled +//! identity providers run on permitted navigations, the page-phase results +//! go into the single page-load write in EC finalization, and after-response +//! providers run once the response has been produced, with their own +//! follow-up compare-and-swap write. +//! +//! Differences from Fastly, all specific to the local dev server: +//! +//! - The identity graph is the process-local [`crate::ec_kv::LocalEcKvStore`]. +//! - The dev server sees no TLS or HTTP/2 fingerprint, so the bot gate +//! accepts any request whose `User-Agent` names a known platform. +//! - After-response lookups run on a blocking tokio task spawned when the +//! handler returns, rather than after the response bytes are sent. +//! - Partner pull sync is not run. + +use std::net::IpAddr; +use std::sync::Arc; + +use edgezero_core::http::{Request, Response, header}; +use trusted_server_core::constants::{COOKIE_SHAREDID, COOKIE_TS_EIDS}; +use trusted_server_core::cookies::extract_cookie_value; +use trusted_server_core::ec::after_response::{ + after_response_lookups_allowed, commit_after_response_lookups, +}; +use trusted_server_core::ec::consent::ec_consent_withdrawn; +use trusted_server_core::ec::coordinator::transport::PlatformLookupTransport; +use trusted_server_core::ec::coordinator::{ + IdentityCoordinator as _, LookupContext, PendingLookups, ProviderCoordinator, +}; +use trusted_server_core::ec::device::DeviceSignals; +use trusted_server_core::ec::eid_types::UnixSeconds; +use trusted_server_core::ec::finalize::ec_finalize_response; +use trusted_server_core::ec::kv::KvIdentityGraph; +use trusted_server_core::ec::page_load::NavigationPermit; +use trusted_server_core::ec::provider::{ClientSignals, RequestCookies}; +use trusted_server_core::ec::{EcContext, EcKvSnapshot, current_timestamp}; +use trusted_server_core::platform::{PlatformPendingRequest, RuntimeServices}; + +use crate::app::AppState; + +/// Per-request EC state for a publisher navigation. +pub(crate) struct EcRequestState { + pub(crate) ec_context: EcContext, + /// Graph for reads and the auction; `None` for a non-browser. + pub(crate) kv_graph: Option, + /// Graph for the finalize write; kept for a consent withdrawal even from + /// a non-browser so tombstones stay authoritative. + finalize_kv_graph: Option, + eids_cookie: Option, + sharedid_cookie: Option, + pub(crate) is_real_browser: bool, +} + +/// Identity lookups dispatched for a navigation, collected before finalize. +pub(crate) struct PageLookups { + coordinator: Arc, + pending: PendingLookups, +} + +/// Request signals captured for after-response lookups. +pub(crate) struct AfterResponseLookups { + ec_id: String, + client_ip: Option, + user_agent: Option, + cookie_header: String, +} + +/// Joins every `Cookie` header on the request into one `; `-separated value. +fn joined_cookie_header(req: &Request) -> String { + req.headers() + .get_all(header::COOKIE) + .iter() + .filter_map(|value| value.to_str().ok()) + .collect::>() + .join("; ") +} + +fn user_agent(req: &Request) -> Option<&str> { + req.headers() + .get(header::USER_AGENT) + .and_then(|value| value.to_str().ok()) +} + +/// Device signals for a request; the dev server only has the `User-Agent`. +pub(crate) fn device_signals_for(req: &Request) -> DeviceSignals { + DeviceSignals::derive(user_agent(req).unwrap_or(""), None, None) +} + +/// Dev-server bot gate. +/// +/// Fastly also requires a TLS fingerprint, which a plain-HTTP local server +/// never has, so only the `User-Agent` platform is checked here. +pub(crate) fn looks_like_browser(signals: &DeviceSignals) -> bool { + signals.platform_class.is_some() +} + +/// Builds the per-request EC state, mirroring the Fastly pre-routing prelude. +pub(crate) fn build_ec_request_state( + state: &AppState, + services: &RuntimeServices, + req: &Request, +) -> EcRequestState { + let device_signals = device_signals_for(req); + let is_real_browser = looks_like_browser(&device_signals); + if !is_real_browser { + log::info!("Bot gate: blocking EC operations for a request without a known UA platform"); + } + + let eids_cookie = extract_cookie_value(req, COOKIE_TS_EIDS); + let sharedid_cookie = extract_cookie_value(req, COOKIE_SHAREDID); + + let geo_info = services + .geo() + .lookup(services.client_info().client_ip) + .unwrap_or_else(|e| { + log::warn!("geo lookup failed during EC setup: {e}"); + None + }); + let (ec_context, setup_ok) = match EcContext::read_from_request_with_geo( + state.settings(), + req, + services, + geo_info.as_ref(), + ) { + Ok(mut context) => { + context.set_device_signals(device_signals); + (context, true) + } + Err(report) => { + // A malformed consent signal leaves a fail-closed default context + // with no identity-graph access. + log::warn!("EC context read failed: {report:?}"); + (EcContext::default(), false) + } + }; + + let kv_graph = setup_ok.then(|| state.identity_graph()).flatten(); + let finalize_kv_graph = if is_real_browser || ec_consent_withdrawn(ec_context.consent()) { + kv_graph.clone() + } else { + None + }; + let kv_graph = if is_real_browser { kv_graph } else { None }; + + EcRequestState { + ec_context, + kv_graph, + finalize_kv_graph, + eids_cookie, + sharedid_cookie, + is_real_browser, + } +} + +/// Starts page-phase identity lookups for a permitted publisher navigation. +/// +/// Returns `None` when no page-phase provider is enabled, the identity graph +/// is unavailable, or the EC was minted on this request (it has no row yet, +/// so a later navigation looks it up). +pub(crate) async fn dispatch_page_lookups( + state: &AppState, + services: &RuntimeServices, + req: &Request, + ec: &mut EcRequestState, +) -> Option { + let coordinator = Arc::clone(state.page_identity()?); + let graph = ec.kv_graph.as_ref()?; + if ec.ec_context.ec_generated() { + return None; + } + let ec_id = ec.ec_context.ec_value()?.to_owned(); + if matches!(ec.ec_context.kv_snapshot(), EcKvSnapshot::NotRead) { + let snapshot = graph.load_snapshot(&ec_id); + ec.ec_context.set_kv_snapshot(snapshot); + } + let transport = PlatformLookupTransport::new(services); + let cookie_header = joined_cookie_header(req); + let pending = coordinator + .dispatch( + &transport, + &LookupContext { + ec_id: &ec_id, + snapshot: ec.ec_context.kv_snapshot(), + consent: ec.ec_context.consent(), + client: ClientSignals { + ip: services.client_info().client_ip, + user_agent: user_agent(req), + }, + publisher_domain: &state.settings().publisher.domain, + cookies: RequestCookies::new(&cookie_header), + rate_limiter: state.rate_limiter(), + now: UnixSeconds::new(current_timestamp()), + }, + ) + .await; + Some(PageLookups { + coordinator, + pending, + }) +} + +/// Captures request signals for after-response lookups. +/// +/// Returns `None` when no after-response provider is enabled, the request +/// has no EC, or the identity graph is unavailable. +pub(crate) fn prepare_after_response_lookups( + state: &AppState, + services: &RuntimeServices, + req: &Request, + ec: &EcRequestState, +) -> Option { + state.after_response_identity()?; + ec.kv_graph.as_ref()?; + let ec_id = ec.ec_context.ec_value()?.to_owned(); + Some(AfterResponseLookups { + ec_id, + client_ip: services.client_info().client_ip, + user_agent: user_agent(req).map(str::to_owned), + cookie_header: joined_cookie_header(req), + }) +} + +/// Collects page-phase lookups and runs EC finalization on `response`. +/// +/// Finalization performs at most one page-load write to the identity graph. +pub(crate) async fn finalize_navigation( + state: &AppState, + services: &RuntimeServices, + ec: &mut EcRequestState, + page_lookups: Option, + response: &mut Response, +) { + // A redirect skips the write, so do not wait for lookups it would discard. + if let Some(PageLookups { + coordinator, + pending, + }) = page_lookups + { + if NavigationPermit::is_redirect(response.status()) { + log::debug!("Identity lookups dropped: redirect response skips the write"); + } else { + let transport = PlatformLookupTransport::new(services); + let results = coordinator.collect(&transport, pending).await; + ec.ec_context.set_lookup_results(results); + } + } + ec_finalize_response( + state.settings(), + &mut ec.ec_context, + ec.finalize_kv_graph.as_ref(), + state.partner_registry(), + ec.eids_cookie.as_deref(), + ec.sharedid_cookie.as_deref(), + response, + ); +} + +/// Spawns after-response lookups once the navigation response is ready. +/// +/// Runs only when finalization issued the follow-up permit. The lookups run +/// on a blocking tokio task so they never delay the response; returns `None` +/// when nothing was spawned. +pub(crate) fn spawn_after_response_lookups( + state: Arc, + services: RuntimeServices, + ec: &mut EcRequestState, + lookups: Option, +) -> Option> { + let lookups = lookups?; + let Some(permit) = ec.ec_context.take_after_response_permit() else { + log::debug!( + "After-response lookups skipped: no permit (consent, redirect or failed page-load write)" + ); + return None; + }; + if ec.ec_context.ec_value() != Some(lookups.ec_id.as_str()) { + log::debug!("After-response lookups skipped: EC changed during the request"); + return None; + } + let ec_generated = ec.ec_context.ec_generated(); + let consent = ec.ec_context.consent().clone(); + let Ok(runtime) = tokio::runtime::Handle::try_current() else { + log::warn!("After-response lookups skipped: no tokio runtime"); + return None; + }; + Some(tokio::task::spawn_blocking(move || { + runtime.block_on(async move { + let Some(coordinator) = state.after_response_identity() else { + return; + }; + let Some(graph) = state.identity_graph() else { + return; + }; + let snapshot = graph.load_snapshot(&lookups.ec_id); + if !after_response_lookups_allowed(&snapshot, &lookups.ec_id, ec_generated) { + log::debug!("After-response lookups skipped: new EC or no live row"); + return; + } + let transport = PlatformLookupTransport::new(&services); + let pending = coordinator + .dispatch( + &transport, + &LookupContext { + ec_id: &lookups.ec_id, + snapshot: &snapshot, + consent: &consent, + client: ClientSignals { + ip: lookups.client_ip, + user_agent: lookups.user_agent.as_deref(), + }, + publisher_domain: &state.settings().publisher.domain, + cookies: RequestCookies::new(&lookups.cookie_header), + rate_limiter: state.rate_limiter(), + now: UnixSeconds::new(current_timestamp()), + }, + ) + .await; + if pending.is_empty() { + return; + } + let results = coordinator.collect(&transport, pending).await; + let outcome = commit_after_response_lookups( + &graph, + permit, + state.partner_registry(), + &lookups.ec_id, + &results, + ); + log::info!("After-response lookups: write outcome={outcome}"); + }); + })) +} diff --git a/crates/trusted-server-adapter-axum/src/identity_tests.rs b/crates/trusted-server-adapter-axum/src/identity_tests.rs new file mode 100644 index 000000000..fd162c2d8 --- /dev/null +++ b/crates/trusted-server-adapter-axum/src/identity_tests.rs @@ -0,0 +1,508 @@ +//! End-to-end identity-provider tests for the Axum adapter. +//! +//! Each test drives publisher navigations through the full router against a +//! local stub server bound to `127.0.0.1:0`. Requests to the fictional +//! `example.com` hosts are rewritten to the stub, so lookups go through the +//! real [`AxumPlatformHttpClient`] with its deadline handling. + +use std::net::{IpAddr, Ipv4Addr}; +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use base64::Engine as _; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use edgezero_core::http::{Request, Uri, header}; +use edgezero_core::router::RouterService; +use error_stack::Report; +use trusted_server_core::ec::kv_types::KvEntry; +use trusted_server_core::geo::GeoInfo; +use trusted_server_core::platform::{ + ClientInfo, PlatformError, PlatformGeo, PlatformHttpClient, PlatformHttpRequest, + PlatformPendingRequest, PlatformResponse, PlatformSelectResult, RuntimeServices, + UnavailableKvStore, +}; +use trusted_server_core::settings::Settings; + +use crate::app::{AppState, build_router, build_state_with_services}; +use crate::platform::{ + AxumPlatformBackend, AxumPlatformConfigStore, AxumPlatformHttpClient, AxumPlatformSecretStore, +}; + +const PUBLISHER_URL: &str = "https://publisher.example.com/article"; +const BROWSER_UA: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36"; +const FIRST_PUBCID: &str = "11111111-1111-4111-8111-111111111111"; +const SECOND_PUBCID: &str = "22222222-2222-4222-8222-222222222222"; + +/// Local stub serving the publisher origin and the ID5 endpoint. +struct Stub { + address: String, + id5_requests: Arc, +} + +async fn start_stub() -> Stub { + let id5_requests = Arc::new(AtomicUsize::new(0)); + let counter = Arc::clone(&id5_requests); + let app = axum::Router::new().fallback(move |uri: axum::http::Uri| { + let counter = Arc::clone(&counter); + async move { + if uri.path().starts_with("/id5") { + counter.fetch_add(1, Ordering::SeqCst); + ( + [(axum::http::header::CONTENT_TYPE, "application/json")], + serde_json::json!({ + "universal_uid": "id5-example-uid", + "signature": "example-signature", + }) + .to_string(), + ) + } else { + ( + [(axum::http::header::CONTENT_TYPE, "text/html")], + "example".to_owned(), + ) + } + } + }); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("should bind the stub server"); + let address = listener + .local_addr() + .expect("should read the stub address") + .to_string(); + tokio::spawn(async move { + axum::serve(listener, app) + .await + .expect("should serve stub requests"); + }); + Stub { + address, + id5_requests, + } +} + +/// Sends every request to the stub, prefixing the path with `/id5` for the +/// ID5 host so the stub can tell the upstreams apart. +struct StubRoutingClient { + inner: AxumPlatformHttpClient, + address: String, +} + +impl StubRoutingClient { + fn rewrite(&self, mut request: PlatformHttpRequest) -> PlatformHttpRequest { + let uri = request.request.uri(); + let prefix = if uri.host() == Some("id5.example.com") { + "/id5" + } else { + "" + }; + let path = uri.path_and_query().map_or("/", |path| path.as_str()); + let rewritten: Uri = format!("http://{}{prefix}{path}", self.address) + .parse() + .expect("should build the stub URI"); + *request.request.uri_mut() = rewritten; + request + } +} + +#[async_trait::async_trait(?Send)] +impl PlatformHttpClient for StubRoutingClient { + async fn send( + &self, + request: PlatformHttpRequest, + ) -> Result> { + self.inner.send(self.rewrite(request)).await + } + + async fn send_async( + &self, + request: PlatformHttpRequest, + ) -> Result> { + self.inner.send_async(self.rewrite(request)).await + } + + async fn select( + &self, + pending_requests: Vec, + ) -> Result> { + self.inner.select(pending_requests).await + } + + async fn select_until( + &self, + pending_requests: Vec, + deadline: web_time::Instant, + ) -> Result, Report> { + self.inner.select_until(pending_requests, deadline).await + } +} + +struct FixedGeo { + country: &'static str, +} + +impl PlatformGeo for FixedGeo { + fn lookup(&self, _client_ip: Option) -> Result, Report> { + Ok(Some(GeoInfo { + country: self.country.to_owned(), + city: "Example City".to_owned(), + continent: "Example".to_owned(), + latitude: 0.0, + longitude: 0.0, + metro_code: 0, + region: None, + asn: None, + })) + } +} + +fn services(stub: &Stub, country: &'static str) -> RuntimeServices { + RuntimeServices::builder() + .config_store(Arc::new(AxumPlatformConfigStore)) + .secret_store(Arc::new(AxumPlatformSecretStore)) + .kv_store(Arc::new(UnavailableKvStore)) + .backend(Arc::new(AxumPlatformBackend)) + .http_client(Arc::new(StubRoutingClient { + inner: AxumPlatformHttpClient::new(), + address: stub.address.clone(), + })) + .geo(Arc::new(FixedGeo { country })) + .client_info(ClientInfo { + client_ip: Some(IpAddr::V4(Ipv4Addr::new(192, 0, 2, 10))), + ..ClientInfo::default() + }) + .build() +} + +fn settings(providers: &str) -> Settings { + Settings::from_toml(&format!( + r#" + [[handlers]] + path = "^/_ts/admin" + username = "example-user" + password = "example-password" + + [publisher] + domain = "publisher.example.com" + cookie_domain = ".publisher.example.com" + origin_url = "https://origin.publisher.example.com" + proxy_secret = "fictional-proxy-secret" + + [ec] + passphrase = "fictional-passphrase-at-least-32-bytes" + ec_store = "example_ec_store" + + [[ec.partners]] + name = "pubcid.org" + source_domain = "pubcid.org" + bidstream_enabled = true + + [[ec.partners]] + name = "id5.example.com" + source_domain = "id5.example.com" + bidstream_enabled = true + + {providers} + "# + )) + .expect("should parse identity test settings") +} + +const SHAREDID_PROVIDER: &str = r#" + [[ec.identity_providers]] + id = "sharedid" + kind = "sharedid" +"#; + +fn id5_provider(collect: &str) -> String { + format!( + r#" + [[ec.identity_providers]] + id = "id5" + kind = "id5" + endpoint = "https://id5.example.com/gs/v2" + allowed_domains = ["id5.example.com"] + sources = ["id5.example.com"] + collect = "{collect}" + [ec.identity_providers.settings] + partner_id = 1234 + "# + ) +} + +struct Harness { + state: Arc, + router: RouterService, + stub: Stub, +} + +async fn harness(providers: &str, country: &'static str) -> Harness { + let stub = start_stub().await; + let state = build_state_with_services(settings(providers), Some(services(&stub, country))) + .expect("should build state with identity providers"); + let router = build_router(&state); + Harness { + state, + router, + stub, + } +} + +impl Harness { + /// Sends a top-level document navigation and returns the `ts-ec` value + /// it set, if any. + async fn navigate(&self, cookie: &str) -> Option { + let request: Request = edgezero_core::http::request_builder() + .method("GET") + .uri(PUBLISHER_URL) + .header(header::HOST, "publisher.example.com") + .header(header::ACCEPT, "text/html") + .header(header::USER_AGENT, BROWSER_UA) + .header("sec-fetch-dest", "document") + .header("sec-fetch-mode", "navigate") + .header(header::COOKIE, cookie) + .body(edgezero_core::body::Body::empty()) + .expect("should build navigation"); + let response = self + .router + .oneshot(request) + .await + .expect("should serve navigation"); + assert_eq!(response.status(), 200, "should serve the origin page"); + response + .headers() + .get_all(header::SET_COOKIE) + .iter() + .filter_map(|value| value.to_str().ok()) + .find_map(|value| value.strip_prefix("ts-ec=")) + .and_then(|value| value.split(';').next()) + .filter(|value| !value.is_empty()) + .map(str::to_owned) + } + + /// Mints an EC on a first navigation and returns it. + async fn mint_ec(&self, consent_cookie: &str) -> String { + self.navigate(consent_cookie) + .await + .expect("should mint an EC on the first navigation") + } + + fn entry(&self, ec_id: &str) -> Option { + self.state + .identity_graph() + .expect("should have an identity graph") + .get(ec_id) + .expect("should read the identity graph") + .map(|(entry, _)| entry) + } + + fn generation(&self, ec_id: &str) -> u64 { + self.state + .identity_graph() + .expect("should have an identity graph") + .get(ec_id) + .expect("should read the identity graph") + .expect("should find the EC row") + .1 + } + + fn stored_uids(&self, ec_id: &str) -> Vec { + self.entry(ec_id) + .map(|entry| entry.ids.values().map(|id| id.uid.clone()).collect()) + .unwrap_or_default() + } +} + +/// Encodes a minimal TCF v2 TC string granting `purposes`. +fn tc_string(purposes: &[u8]) -> String { + fn write_bits(buf: &mut [u8], offset: usize, width: usize, value: u64) { + for i in 0..width { + if (value >> (width - 1 - i)) & 1 == 1 { + let bit = offset + i; + buf[bit / 8] |= 1 << (7 - bit % 8); + } + } + } + // Core segment, empty vendor consent and legitimate-interest sections, + // and no publisher restrictions. + let mut buf = vec![0_u8; (213 + 17 + 17 + 12_usize).div_ceil(8)]; + let deciseconds = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("should read the clock") + .as_millis() + / 100; + let deciseconds = u64::try_from(deciseconds).expect("should fit in u64"); + write_bits(&mut buf, 0, 6, 2); + write_bits(&mut buf, 6, 36, deciseconds); + write_bits(&mut buf, 42, 36, deciseconds); + write_bits(&mut buf, 78, 12, 7); + write_bits(&mut buf, 90, 12, 1); + write_bits(&mut buf, 102, 6, 1); + write_bits(&mut buf, 108, 6, u64::from(b'E' - b'A')); + write_bits(&mut buf, 114, 6, u64::from(b'N' - b'A')); + write_bits(&mut buf, 120, 12, 42); + write_bits(&mut buf, 132, 6, 2); + for purpose in purposes { + write_bits(&mut buf, 152 + usize::from(*purpose) - 1, 1, 1); + } + write_bits(&mut buf, 201, 6, u64::from(b'E' - b'A')); + write_bits(&mut buf, 207, 6, u64::from(b'N' - b'A')); + URL_SAFE_NO_PAD.encode(&buf) +} + +fn consented() -> String { + format!("euconsent-v2={}", tc_string(&[1, 2, 3, 4, 7, 9, 10])) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn sharedid_cookie_is_stored_and_kept_over_a_later_value() { + let harness = harness(SHAREDID_PROVIDER, "DE").await; + let consent = consented(); + let ec_id = harness.mint_ec(&consent).await; + + harness + .navigate(&format!("{consent}; ts-ec={ec_id}; _pubcid={FIRST_PUBCID}")) + .await; + let first = harness.stored_uids(&ec_id); + harness + .navigate(&format!( + "{consent}; ts-ec={ec_id}; _pubcid={SECOND_PUBCID}" + )) + .await; + let second = harness.stored_uids(&ec_id); + + assert_eq!(first, vec![FIRST_PUBCID], "should store the SharedID"); + assert_eq!( + second, + vec![FIRST_PUBCID], + "should keep the stored SharedID over a different cookie value" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn sharedid_opt_out_removes_the_stored_id() { + let harness = harness(SHAREDID_PROVIDER, "DE").await; + let consent = consented(); + let ec_id = harness.mint_ec(&consent).await; + harness + .navigate(&format!("{consent}; ts-ec={ec_id}; _pubcid={FIRST_PUBCID}")) + .await; + assert_eq!( + harness.stored_uids(&ec_id), + vec![FIRST_PUBCID], + "should store the SharedID before the opt-out" + ); + + harness + .navigate(&format!( + "{consent}; ts-ec={ec_id}; _pubcid={FIRST_PUBCID}; _pubcid_optout=1" + )) + .await; + + assert!( + harness.stored_uids(&ec_id).is_empty(), + "should remove the SharedID after an opt-out" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn sharedid_opt_out_prevents_storing() { + let harness = harness(SHAREDID_PROVIDER, "DE").await; + let consent = consented(); + let ec_id = harness.mint_ec(&consent).await; + + harness + .navigate(&format!( + "{consent}; ts-ec={ec_id}; _pubcid={FIRST_PUBCID}; _pubcid_optout=1" + )) + .await; + + assert!( + harness.entry(&ec_id).is_some(), + "should have written the EC row on the first navigation" + ); + assert!( + harness.stored_uids(&ec_id).is_empty(), + "should not store a SharedID while opted out" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn sharedid_is_not_stored_without_eid_consent() { + let harness = harness(SHAREDID_PROVIDER, "DE").await; + // Purpose 1 allows the EC; Purpose 4 is denied, so no EID may be kept. + let consent = format!("euconsent-v2={}", tc_string(&[1])); + let ec_id = harness.mint_ec(&consent).await; + + harness + .navigate(&format!("{consent}; ts-ec={ec_id}; _pubcid={FIRST_PUBCID}")) + .await; + + assert!( + harness.entry(&ec_id).is_some(), + "should have written the EC row on the first navigation" + ); + assert!( + harness.stored_uids(&ec_id).is_empty(), + "should store no SharedID without Purpose 4 consent" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn page_phase_http_lookup_stores_the_vendor_id() { + let harness = harness(&id5_provider("page"), "AU").await; + let ec_id = harness.mint_ec("").await; + assert_eq!( + harness.stub.id5_requests.load(Ordering::SeqCst), + 0, + "should not look up an EC minted on this request" + ); + let minted_generation = harness.generation(&ec_id); + + harness.navigate(&format!("ts-ec={ec_id}")).await; + + assert_eq!( + harness.stub.id5_requests.load(Ordering::SeqCst), + 1, + "should call the vendor endpoint once" + ); + assert_eq!( + harness.stored_uids(&ec_id), + vec!["id5-example-uid"], + "should store the vendor ID in the page-load write" + ); + // The store hands out one generation per write across all keys. + assert_eq!( + harness.generation(&ec_id), + minted_generation + 1, + "should write the row once for the page load" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn after_response_http_lookup_stores_the_vendor_id() { + let harness = harness(&id5_provider("after_response"), "AU").await; + let ec_id = harness.mint_ec("").await; + + harness.navigate(&format!("ts-ec={ec_id}")).await; + + let mut stored = Vec::new(); + for _ in 0..100 { + stored = harness.stored_uids(&ec_id); + if !stored.is_empty() { + break; + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + assert_eq!( + harness.stub.id5_requests.load(Ordering::SeqCst), + 1, + "should call the vendor endpoint once after the response" + ); + assert_eq!( + stored, + vec!["id5-example-uid"], + "should store the vendor ID in the follow-up write" + ); +} diff --git a/crates/trusted-server-adapter-axum/src/lib.rs b/crates/trusted-server-adapter-axum/src/lib.rs index 2f15e566d..bc571b8c3 100644 --- a/crates/trusted-server-adapter-axum/src/lib.rs +++ b/crates/trusted-server-adapter-axum/src/lib.rs @@ -6,6 +6,12 @@ /// Application routing and handler registration for the Axum dev server. pub mod app; +/// Process-local EC identity store and rate limiter for the Axum dev server. +pub mod ec_kv; +/// EC identity lifecycle for publisher navigations. +mod identity; +#[cfg(test)] +mod identity_tests; /// Request middleware (auth, response finalisation). pub mod middleware; /// Platform-trait implementations backed by env vars and `reqwest`. diff --git a/crates/trusted-server-core/src/config.rs b/crates/trusted-server-core/src/config.rs index f729a22f2..de2f152d0 100644 --- a/crates/trusted-server-core/src/config.rs +++ b/crates/trusted-server-core/src/config.rs @@ -344,30 +344,12 @@ pub fn validate_settings_for_deploy(settings: &Settings) -> Result<(), Report Result<(), Report> { - match ec.identity_providers.iter().find(|config| config.enabled) { - Some(config) => Err(Report::new(TrustedServerError::Configuration { - message: format!( - "ec.identity_providers: provider `{}` is enabled, but this release registers no \ - provider kinds; set `enabled = false`", - config.id - ), - })), - None => Ok(()), - } -} - /// Runs Trusted Server runtime validation after secret references are resolved. /// /// # Errors @@ -1630,16 +1612,11 @@ gam_network_id = "99999" } #[test] - fn deploy_validation_rejects_an_enabled_provider_while_no_kind_is_registered() { + fn deploy_validation_accepts_an_enabled_provider() { let settings = settings_with_identity_provider(serde_json::json!({})); - let err = validate_settings_for_deploy(&settings) - .expect_err("should reject an enabled provider at deploy time"); - - assert!( - err.to_string().contains("registers no provider kinds"), - "should explain why the provider is rejected: {err:?}" - ); + validate_settings_for_deploy(&settings) + .expect("should leave the registered-kind check to the provider crate"); } #[test] diff --git a/crates/trusted-server-core/src/ec/coordinator/mod.rs b/crates/trusted-server-core/src/ec/coordinator/mod.rs index 99821f569..f930532d1 100644 --- a/crates/trusted-server-core/src/ec/coordinator/mod.rs +++ b/crates/trusted-server-core/src/ec/coordinator/mod.rs @@ -587,6 +587,14 @@ impl IdentityCoordinator for ProviderCoordinator

{ }); recount_found(&mut found); } + // A first-party ID kept for the row's lifetime is not + // replaced by a different cookie value. + if registered.provider.keeps_stored_uids() { + found.entries.retain(|(source, _)| { + !Self::holds_live_uid(entry, source.as_str(), lookup.now) + }); + recount_found(&mut found); + } if matches!(found.status, LookupStatus::Found { .. }) { // An ID in the vendor's cookies means the visitor // opted back in, so a remembered opt-out is @@ -2931,6 +2939,82 @@ mod tests { assert!(transport.sent.borrow().is_empty(), "should send nothing"); } + struct StableCookieProvider(EchoProvider); + + impl IdentityProvider for StableCookieProvider { + fn capabilities(&self) -> &ProviderCapabilities { + self.0.capabilities() + } + + fn build_request( + &self, + lookup: &LookupInput<'_>, + ) -> Result> { + self.0.build_request(lookup) + } + + fn parse_response<'body>( + &self, + response: ProviderResponse<'body>, + ) -> Result, Report> { + self.0.parse_response(response) + } + + fn read_request_cookies<'cookie>( + &self, + cookies: RequestCookies<'cookie>, + ) -> CookieRead<'cookie> { + self.0.read_request_cookies(cookies) + } + + fn keeps_stored_uids(&self) -> bool { + true + } + } + + #[test] + fn a_different_cookie_does_not_replace_a_kept_stored_uid() { + // Arrange: the row holds a live UID; the cookie carries another, + // longer-lived one. + let coordinator = coordinator_with(RegisteredProvider::new( + provider_config(""), + StableCookieProvider(cookie_provider(SourceInput::Anonymous, Some("vendor_id"))), + )); + let transport = CannedTransport::new(ok_reply); + + // Act + let results = run( + &coordinator, + &transport, + &snapshot_holding(record_until("p-2", 4000)), + &granting(), + ); + + // Assert + assert_eq!( + only_status(&results), + LookupStatus::Skipped(SkipReason::NotDue), + "should keep the stored UID over a different cookie" + ); + assert!(transport.sent.borrow().is_empty(), "should send nothing"); + } + + #[test] + fn a_kept_source_without_a_stored_uid_takes_the_cookie() { + let coordinator = coordinator_with(RegisteredProvider::new( + provider_config(""), + StableCookieProvider(cookie_provider(SourceInput::Anonymous, Some("vendor_id"))), + )); + let transport = CannedTransport::new(ok_reply); + + let results = run(&coordinator, &transport, &live_row(), &granting()); + + assert!( + matches!(only_status(&results), LookupStatus::Found { kept: 1, .. }), + "should store the cookie UID when none is held" + ); + } + #[test] fn a_newer_cookie_replaces_an_older_stored_uid() { let coordinator = coordinator_with(RegisteredProvider::new( diff --git a/crates/trusted-server-core/src/ec/provider.rs b/crates/trusted-server-core/src/ec/provider.rs index e942de30b..9c8c40b08 100644 --- a/crates/trusted-server-core/src/ec/provider.rs +++ b/crates/trusted-server-core/src/ec/provider.rs @@ -116,6 +116,17 @@ pub trait IdentityProvider: Send + Sync { fn keeps_fresher_stored_uids(&self) -> bool { false } + + /// Whether a live UID the row already stores wins over a different one + /// in the provider's cookies. + /// + /// For a first-party ID that should stay stable for as long as the row + /// lives, such as Prebid's `SharedID`: when this returns `true`, a cookie + /// is taken only for a source with no live stored UID. A cookie opt-out + /// still wins. The default always takes the cookie. + fn keeps_stored_uids(&self) -> bool { + false + } } impl IdentityProvider for Box

{ @@ -155,6 +166,10 @@ impl IdentityProvider for Box

{ fn keeps_fresher_stored_uids(&self) -> bool { (**self).keeps_fresher_stored_uids() } + + fn keeps_stored_uids(&self) -> bool { + (**self).keeps_stored_uids() + } } /// What a provider found in the vendor's own first-party cookies. diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index b528bffca..78b94f09c 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -637,8 +637,11 @@ impl EcPartner { } /// One `[[ec.identity_providers]]` entry. +/// +/// A `sharedid` provider with no `sources` claims `pubcid.org`, the source +/// Prebid sends `SharedID` under. #[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(deny_unknown_fields)] +#[serde(remote = "Self", deny_unknown_fields)] pub struct IdentityProviderConfig { /// Unique provider ID; recorded as the owner of the UIDs it writes. pub id: String, @@ -686,6 +689,28 @@ pub struct IdentityProviderConfig { pub settings: serde_json::Map, } +impl<'de> Deserialize<'de> for IdentityProviderConfig { + fn deserialize>(deserializer: D) -> Result { + let mut config = Self::deserialize(deserializer)?; + if config.kind == SHAREDID_PROVIDER_KIND && config.sources.is_empty() { + config.sources = vec![SHAREDID_DEFAULT_SOURCE.to_owned()]; + } + Ok(config) + } +} + +impl Serialize for IdentityProviderConfig { + fn serialize(&self, serializer: S) -> Result { + Self::serialize(self, serializer) + } +} + +/// Provider kind of Prebid's `SharedID`. +const SHAREDID_PROVIDER_KIND: &str = "sharedid"; + +/// Source a `sharedid` provider claims when none is configured. +pub const SHAREDID_DEFAULT_SOURCE: &str = "pubcid.org"; + impl IdentityProviderConfig { const fn default_enabled() -> bool { true diff --git a/crates/trusted-server-identity-providers/src/lib.rs b/crates/trusted-server-identity-providers/src/lib.rs index c815ebaa2..1a2abc79a 100644 --- a/crates/trusted-server-identity-providers/src/lib.rs +++ b/crates/trusted-server-identity-providers/src/lib.rs @@ -21,6 +21,7 @@ pub mod sharedid; pub mod uid2; pub mod unifiedid; +use std::borrow::Cow; use std::sync::Arc; use std::time::Duration; @@ -32,6 +33,7 @@ use trusted_server_core::ec::provider::{ }; use trusted_server_core::ec::registry::PartnerRegistry; use trusted_server_core::error::TrustedServerError; +use trusted_server_core::integrations::prebid::{PrebidIntegrationConfig, PrebidUserIdStorageType}; use trusted_server_core::settings::{ CollectTiming, Ec, IdentityProviderConfig, ProviderSecrets, Settings, }; @@ -214,6 +216,57 @@ pub fn validate_providers(settings: &Settings) -> Result<(), Report Option { + let prebid = match settings.integration_config::("prebid") { + Ok(prebid) => prebid?, + Err(report) => { + log::warn!("Identity providers: Prebid config unreadable: {report}"); + return None; + } + }; + prebid + .managed_user_ids + .into_iter() + .filter(|entry| { + PREBID_SHAREDID_NAMES + .iter() + .any(|name| entry.name.eq_ignore_ascii_case(name)) + }) + .find_map(|entry| entry.storage) + .filter(|storage| storage.storage_type == PrebidUserIdStorageType::Cookie) + .map(|storage| storage.name) +} + +/// Fills a `sharedid` provider's `cookie_names` with the cookie Prebid's +/// `SharedID` module is configured to use, unless the provider sets its own. +fn with_prebid_sharedid_cookie<'config>( + config: &'config IdentityProviderConfig, + settings: &Settings, +) -> Cow<'config, IdentityProviderConfig> { + if config.kind() != sharedid::SharedId::KIND + || config.settings.contains_key(sharedid::COOKIE_NAMES_SETTING) + { + return Cow::Borrowed(config); + } + let Some(cookie) = prebid_sharedid_cookie(settings) else { + return Cow::Borrowed(config); + }; + let mut config = config.clone(); + config.settings.insert( + sharedid::COOKIE_NAMES_SETTING.to_owned(), + serde_json::Value::from(vec![cookie]), + ); + Cow::Owned(config) +} + fn build_phase_coordinator( settings: &Settings, partners: Arc, @@ -229,8 +282,9 @@ fn build_phase_coordinator( .iter() .filter(|config| config.enabled && config.collect == timing) .map(|config| { - build(config, &config.secrets) - .map(|provider| RegisteredProvider::new(config.clone(), provider)) + let config = with_prebid_sharedid_cookie(config, settings); + build(&config, &config.secrets) + .map(|provider| RegisteredProvider::new(config.into_owned(), provider)) }) .collect::, Report>>()?; if registered.is_empty() { @@ -425,6 +479,77 @@ mod tests { settings } + fn with_prebid_sharedid(settings: &mut Settings, storage: &serde_json::Value) { + settings + .integrations + .insert_config( + "prebid", + &json!({ + "enabled": true, + "external_bundle_url": "https://assets.example/prebid/trusted-prebid.js", + "bundle": { "modules": { "bidder": ["exampleBidderBidAdapter"] } }, + "managed_user_ids": [{ "name": "sharedId", "storage": storage }], + }), + ) + .expect("should configure Prebid"); + } + + #[test] + fn sharedid_reads_the_cookie_prebid_is_configured_to_use() { + // Arrange + let mut settings = settings_with_cookie_providers("page", "page"); + with_prebid_sharedid( + &mut settings, + &json!({ "type": "cookie", "name": "_pubcid" }), + ); + + // Act + let config = with_prebid_sharedid_cookie(&settings.ec.identity_providers[0], &settings); + + // Assert + assert_eq!( + config.settings.get("cookie_names"), + Some(&json!(["_pubcid"])), + "should read only Prebid's storage name" + ); + } + + #[test] + fn sharedid_keeps_its_defaults_without_a_prebid_cookie() { + let mut settings = settings_with_cookie_providers("page", "page"); + with_prebid_sharedid( + &mut settings, + &json!({ "type": "html5", "name": "_pubcid" }), + ); + + let config = with_prebid_sharedid_cookie(&settings.ec.identity_providers[0], &settings); + + assert!( + matches!(config, Cow::Borrowed(_)), + "should not take a local-storage key as a cookie name" + ); + } + + #[test] + fn sharedid_cookie_names_setting_wins_over_prebid() { + let mut settings = settings_with_cookie_providers("page", "page"); + with_prebid_sharedid( + &mut settings, + &json!({ "type": "cookie", "name": "_pubcid" }), + ); + settings.ec.identity_providers[0] + .settings + .insert("cookie_names".to_owned(), json!(["_example_sharedid"])); + + let config = with_prebid_sharedid_cookie(&settings.ec.identity_providers[0], &settings); + + assert_eq!( + config.settings.get("cookie_names"), + Some(&json!(["_example_sharedid"])), + "should keep the provider's own cookie names" + ); + } + fn liveramp_provider(collect: &str, settings: &str) -> IdentityProviderConfig { toml::from_str(&format!( "id = \"liveramp\"\nkind = \"liveramp\"\nsources = [\"liveramp.example.com\"]\n\ @@ -477,6 +602,23 @@ mod tests { } } + #[test] + fn validate_providers_rejects_an_enabled_unregistered_kind() { + let mut settings = settings_with_cookie_providers("page", "page"); + settings.ec.identity_providers.push( + toml::from_str("id = \"missing\"\nkind = \"missing\"\nsources = [\"criteo.com\"]") + .expect("should parse provider"), + ); + + let error = validate_providers(&settings) + .expect_err("should refuse an enabled provider whose kind is not registered"); + + assert!( + format!("{error:?}").contains("which this build does not include"), + "should name the unregistered kind: {error:?}" + ); + } + #[test] fn validate_providers_accepts_partner_tokens_that_are_still_key_names() { let mut settings = settings_with_cookie_providers("page", "page"); diff --git a/crates/trusted-server-identity-providers/src/sharedid.rs b/crates/trusted-server-identity-providers/src/sharedid.rs index d6a745bd9..bb0dc7ca7 100644 --- a/crates/trusted-server-identity-providers/src/sharedid.rs +++ b/crates/trusted-server-identity-providers/src/sharedid.rs @@ -1,19 +1,24 @@ //! Prebid `SharedID` identity provider: reads the publisher common ID from //! the first-party cookie Prebid's `sharedIdSystem` writes. //! -//! Prebid stores the ID as a bare UUID in `_sharedid` (the publisher's -//! configured name) or the legacy `_pubcid`. It is sent as a device-based -//! (agent type 1) EID. The opt-out cookie `_pubcid_optout` reports an -//! opt-out, which removes a stored `SharedID`. +//! Prebid stores the ID as a bare UUID in the cookie named by the +//! publisher's `storage.name`. The provider reads the cookie named in +//! `integrations.prebid.managed_user_ids[].storage.name` for the `sharedId` +//! (or `pubCommonId`) module; without one it reads `_sharedid`, then the +//! legacy `_pubcid`. It is sent as a device-based (agent type 1) EID under +//! `pubcid.org` unless another source is configured. The opt-out cookie +//! `_pubcid_optout` reports an opt-out, which removes a stored `SharedID`. //! No server call is made. //! +//! A stored `SharedID` lives as long as the row and wins over a different +//! cookie value, so the ID bidders see stays stable. +//! //! `SharedID` is a publisher first-party ID with no vendor server. With //! `generate = true`, when the cookies hold none and no live `SharedID` is //! stored, Trusted Server generates one itself: a random UUID v4, stored in -//! the row only. No cookie is set, so Prebid's own cookie is never touched; -//! when Prebid later writes one, the cookie's ID replaces the generated one. -//! An expired generated ID the row still holds is renewed with the same -//! value, so it stays stable for as long as the row lives. +//! the row only. No cookie is set, so Prebid's own cookie is never touched. +//! An expired generated ID an older row still holds is renewed with the same +//! value. //! Generation is off by default: Prebid may keep its opt-out in localStorage, //! which the server cannot see, so only a publisher who knows its opt-out //! lives in the `_pubcid_optout` cookie should turn it on. @@ -25,14 +30,15 @@ use serde::Deserialize; use trusted_server_core::ec::eid_types::SourceDomain; use trusted_server_core::ec::provider::{ CookieRead, IdentityProvider, LookupInput, LookupOutcome, ProviderCapabilities, ProviderCommon, - ProviderConfigError, ProviderEid, ProviderError, ProviderKind, ProviderRequest, + ProviderConfigError, ProviderEid, ProviderError, ProviderExpiry, ProviderKind, ProviderRequest, ProviderResponse, RequestCookies, }; use uuid::Uuid; -use crate::common::{ - CookieReader, MAX_TTL, bounded_expiry, cookie_capabilities, device_eid, is_usable_id, -}; +use crate::common::{CookieReader, cookie_capabilities, device_eid, is_usable_id}; + +/// Settings key naming the cookies that hold the `SharedID`. +pub const COOKIE_NAMES_SETTING: &str = "cookie_names"; /// Cookie Prebid sets when the user opts out of `SharedID`. const OPT_OUT_COOKIE: &str = "_pubcid_optout"; @@ -44,7 +50,8 @@ pub struct SharedId; #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] pub struct SharedIdSettings { - /// Cookies holding the `SharedID`, read in order. + /// Cookies holding the `SharedID`, read in order. Defaults to Prebid's + /// configured `storage.name`, else `_sharedid` then `_pubcid`. #[serde(default = "SharedIdSettings::default_cookie_names")] pub cookie_names: Vec, /// Whether to generate a `SharedID` when the cookies hold none. Off by @@ -123,13 +130,18 @@ impl IdentityProvider for SharedIdProvider { else { return CookieRead::Absent; }; + // No expiry: the ID lives as long as the row. CookieRead::Found(vec![device_eid( &self.source, Cow::Borrowed(id), - bounded_expiry(None), + ProviderExpiry::Unspecified, )]) } + fn keeps_stored_uids(&self) -> bool { + true + } + fn generate(&self, lookup: &LookupInput<'_>) -> Option>> { if !self.generate { return None; @@ -147,9 +159,8 @@ impl IdentityProvider for SharedIdProvider { Some(vec![device_eid( &self.source, Cow::Owned(id), - // A generated ID has no cookie to renew it, so it keeps the - // longest lifetime rather than changing every day. - bounded_expiry(Some(MAX_TTL)), + // A generated ID lives as long as the row. + ProviderExpiry::Unspecified, )]) } } @@ -161,7 +172,7 @@ mod tests { AgentType, EidFields, Expiry, ProviderId, UidValue, UnixSeconds, }; use trusted_server_core::ec::kv_types::{KvEid, KvEntry, KvPartnerId, KvUid}; - use trusted_server_core::ec::provider::{OwnedEntries, ProviderExpiry, build_kind}; + use trusted_server_core::ec::provider::{OwnedEntries, build_kind}; use trusted_server_core::settings::{IdentityProviderConfig, ProviderSecrets}; use super::*; @@ -208,6 +219,43 @@ mod tests { ); } + #[test] + fn keeps_a_cookie_id_as_long_as_the_row_and_over_a_different_cookie() { + let provider = provider(); + let eids = provider + .read_request_cookies(RequestCookies::new("_sharedid=sharedid-example")) + .into_found() + .expect("should read the cookie"); + + assert!( + matches!(eids[0].uids[0].expiry, ProviderExpiry::Unspecified), + "should state no expiry, so the ID lives as long as the row" + ); + assert!( + provider.keeps_stored_uids(), + "should keep a stored ID over a different cookie" + ); + } + + #[test] + fn defaults_the_source_to_pubcid_org() { + let config: IdentityProviderConfig = serde_json::from_value(json!({ + "id": "sharedid", + "kind": "sharedid", + })) + .expect("should parse provider config"); + + let provider = build_kind::(&config, &ProviderSecrets::default()) + .expect("should build provider"); + let eids = provider + .read_request_cookies(RequestCookies::new("_sharedid=sharedid-example")) + .into_found() + .expect("should read the cookie"); + + assert_eq!(eids[0].source, "pubcid.org", "should default to pubcid.org"); + assert_eq!(eids[0].uids[0].atype, Some(1), "should be device-based"); + } + #[test] fn honours_opt_out_and_ignores_blank_ids() { assert!( @@ -314,8 +362,8 @@ mod tests { ); assert_ne!(uid.id, second[0].uids[0].id, "should be random"); assert!( - matches!(uid.expiry, ProviderExpiry::After(ttl) if ttl == MAX_TTL), - "should keep a generated ID for the longest TTL" + matches!(uid.expiry, ProviderExpiry::Unspecified), + "should keep a generated ID as long as the row" ); } } diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 65f214f6d..78721cda7 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -923,12 +923,14 @@ stored UIDs may still be live. Without `tcf_vendor_id`, a provider is gated by the EID-persistence purposes alone, so set it for any provider that has a TCF vendor ID. -Identity providers run only on Fastly. This release registers no provider kind, -so `ts config validate`, `push` and `diff` reject an enabled provider; keep -`enabled = false` until a kind ships. The Axum, Cloudflare and Spin adapters -refuse to start when an enabled provider is configured. On Fastly, an enabled -provider whose `kind` this build does not include, or whose `settings` its kind -rejects, also fails startup. Lookups always verify the provider's TLS +Identity providers run on Fastly and on the Axum dev server. This release +registers no provider kind, so `ts config validate`, `push` and `diff` reject an +enabled provider; keep `enabled = false` until a kind ships. The Cloudflare and +Spin adapters refuse to start when an enabled provider is configured. On Fastly +and Axum, an enabled provider whose `kind` this build does not include, or whose +`settings` its kind rejects, also fails startup. The Axum dev server keeps the +identity graph named by `ec.ec_store` in process memory, so stored IDs are lost +when it exits. Lookups always verify the provider's TLS certificate, whatever `proxy.certificate_check` says. A provider owns its `sources`: its results replace whatever the row holds for diff --git a/trusted-server.example.toml b/trusted-server.example.toml index 81c2cb939..92033001b 100644 --- a/trusted-server.example.toml +++ b/trusted-server.example.toml @@ -295,21 +295,23 @@ pull_sync_concurrency = 3 # cookie_names = ["example_lockr_uid2"] # # Prebid SharedID (kind "sharedid"): no lookup and no endpoint. Reads the -# publisher common ID from Prebid's first-party `_sharedid` cookie, falling -# back to the legacy `_pubcid`, as a device-based (atype 1) ID. The -# `_pubcid_optout` cookie suppresses it. Takes exactly one source. With -# generate = true, when no cookie holds an ID and none is stored, Trusted Server -# generates a random UUID and stores it in the row only (no cookie is set); a -# later Prebid cookie replaces it. Leave it off unless Prebid keeps its opt-out -# in the `_pubcid_optout` cookie: a localStorage opt-out is invisible to the -# server. SharedID is the publisher's own ID, so it has no TCF vendor. +# publisher common ID from the cookie named in +# [[integrations.prebid.managed_user_ids]] `storage.name` for the `sharedId` +# module, or else from `_sharedid`, falling back to the legacy `_pubcid`, and +# sends it as a device-based (atype 1) ID. `sources` defaults to +# ["pubcid.org"], which needs a matching [[ec.partners]] entry. A stored +# SharedID lives as long as the row and wins over a different cookie value. +# The `_pubcid_optout` cookie suppresses it. With generate = true, when no +# cookie holds an ID and none is stored, Trusted Server generates a random UUID +# and stores it in the row only (no cookie is set). Leave it off unless Prebid +# keeps its opt-out in the `_pubcid_optout` cookie: a localStorage opt-out is +# invisible to the server. SharedID is the publisher's own ID, so it has no TCF +# vendor. # [[ec.identity_providers]] # id = "sharedid" # kind = "sharedid" # enabled = false -# sources = ["sharedid.example.com"] # [ec.identity_providers.settings] -# cookie_names = ["_sharedid", "_pubcid"] # generate = false # # Criteo (kind "criteo"): no lookup and no endpoint. Reads Criteo's bid ID