From 1e20b9ffca72f23785f5cf681815c8cc7d3ee90d Mon Sep 17 00:00:00 2001 From: Christian Date: Thu, 20 Nov 2025 15:10:19 -0600 Subject: [PATCH] lockr integration --- crates/common/src/integrations/lockr.rs | 439 ++++++++++++++++++ crates/common/src/integrations/mod.rs | 3 +- crates/js/lib/src/integrations/lockr/index.ts | 109 +++++ trusted-server.toml | 8 + 4 files changed, 558 insertions(+), 1 deletion(-) create mode 100644 crates/common/src/integrations/lockr.rs create mode 100644 crates/js/lib/src/integrations/lockr/index.ts diff --git a/crates/common/src/integrations/lockr.rs b/crates/common/src/integrations/lockr.rs new file mode 100644 index 0000000..14714ab --- /dev/null +++ b/crates/common/src/integrations/lockr.rs @@ -0,0 +1,439 @@ +//! Lockr integration for identity resolution and advertising tokens. +//! +//! This module provides transparent proxying for Lockr's SDK and API, +//! enabling first-party identity resolution while maintaining privacy controls. + +use std::sync::Arc; + +use async_trait::async_trait; +use error_stack::{Report, ResultExt}; +use fastly::http::{header, Method, StatusCode}; +use fastly::{Request, Response}; +use serde::Deserialize; +use validator::Validate; + +use crate::backend::ensure_backend_from_url; +use crate::error::TrustedServerError; +use crate::integrations::{ + AttributeRewriteAction, IntegrationAttributeContext, IntegrationAttributeRewriter, + IntegrationEndpoint, IntegrationProxy, IntegrationRegistration, +}; +use crate::settings::{IntegrationConfig as IntegrationConfigTrait, Settings}; + +const LOCKR_INTEGRATION_ID: &str = "lockr"; + +/// Configuration for Lockr integration. +#[derive(Debug, Deserialize, Validate)] +pub struct LockrConfig { + /// Enable/disable the integration + #[serde(default = "default_enabled")] + pub enabled: bool, + + /// Lockr app ID (from meta tag lockr-signin-app_id) + #[validate(length(min = 1))] + pub app_id: String, + + /// Base URL for Lockr API (default: https://identity.lockr.kr) + #[serde(default = "default_api_endpoint")] + #[validate(url)] + pub api_endpoint: String, + + /// SDK URL (default: https://aim.loc.kr/identity-lockr-v1.0.js) + #[serde(default = "default_sdk_url")] + #[validate(url)] + pub sdk_url: String, + + /// Cache TTL for Lockr SDK in seconds (default: 3600 = 1 hour) + #[serde(default = "default_cache_ttl")] + #[validate(range(min = 60, max = 86400))] + pub cache_ttl_seconds: u32, + + /// Whether to rewrite Lockr SDK URLs in HTML + #[serde(default = "default_rewrite_sdk")] + pub rewrite_sdk: bool, +} + +impl IntegrationConfigTrait for LockrConfig { + fn is_enabled(&self) -> bool { + self.enabled + } +} + +/// Lockr integration implementation. +pub struct LockrIntegration { + config: LockrConfig, +} + +impl LockrIntegration { + fn new(config: LockrConfig) -> Arc { + Arc::new(Self { config }) + } + + fn error(message: impl Into) -> TrustedServerError { + TrustedServerError::Integration { + integration: LOCKR_INTEGRATION_ID.to_string(), + message: message.into(), + } + } + + /// Check if a URL is a Lockr SDK URL. + fn is_lockr_sdk_url(&self, url: &str) -> bool { + let lower = url.to_ascii_lowercase(); + lower.contains("aim.loc.kr") + || lower.contains("identity.loc.kr") + && lower.contains("identity-lockr") + && lower.ends_with(".js") + } + + /// Handle SDK serving - fetch from Lockr CDN and serve through first-party domain. + async fn handle_sdk_serving( + &self, + _settings: &Settings, + _req: Request, + ) -> Result> { + log::info!("Handling Lockr SDK request"); + + let sdk_url = &self.config.sdk_url; + log::info!("Fetching Lockr SDK from: {}", sdk_url); + + // TODO: Check KV store cache first (future enhancement) + + // Fetch SDK from Lockr CDN + let mut lockr_req = Request::new(Method::GET, sdk_url); + lockr_req.set_header(header::USER_AGENT, "TrustedServer/1.0"); + lockr_req.set_header(header::ACCEPT, "application/javascript, */*"); + + let backend_name = ensure_backend_from_url(sdk_url) + .change_context(Self::error("Failed to determine backend for SDK fetch"))?; + + let mut lockr_response = + lockr_req + .send(backend_name) + .change_context(Self::error(format!( + "Failed to fetch Lockr SDK from {}", + sdk_url + )))?; + + if !lockr_response.get_status().is_success() { + log::error!( + "Lockr SDK fetch failed with status: {}", + lockr_response.get_status() + ); + return Err(Report::new(Self::error(format!( + "Lockr SDK returned error status: {}", + lockr_response.get_status() + )))); + } + + let sdk_body = lockr_response.take_body_bytes(); + log::info!("Successfully fetched Lockr SDK: {} bytes", sdk_body.len()); + + // TODO: Cache in KV store (future enhancement) + + Ok(Response::from_status(StatusCode::OK) + .with_header( + header::CONTENT_TYPE, + "application/javascript; charset=utf-8", + ) + .with_header( + header::CACHE_CONTROL, + format!( + "public, max-age={}, immutable", + self.config.cache_ttl_seconds + ), + ) + .with_header("X-Lockr-SDK-Proxy", "true") + .with_header("X-SDK-Source", sdk_url) + .with_body(sdk_body)) + } + + /// Handle API proxy - forward requests to identity.lockr.kr. + async fn handle_api_proxy( + &self, + _settings: &Settings, + mut req: Request, + ) -> Result> { + let original_path = req.get_path(); + let method = req.get_method(); + + log::info!("Proxying Lockr API request: {} {}", method, original_path); + + // Extract path after /integrations/lockr/api + let api_path = original_path + .strip_prefix("/integrations/lockr/api") + .ok_or_else(|| Self::error(format!("Invalid Lockr API path: {}", original_path)))?; + + // Map to actual Lockr API paths + let target_path = match api_path { + "/settings" => "/publisher/app/v1/identitylockr/settings", + "/page-view" => "/publisher/app/v1/identitylockr/page-view", + "/generate-tokens" => "/publisher/app/v1/identitylockr/generate-tokens", + "/refresh-tokens" => "/publisher/app/v1/identitylockr/refresh-tokens", + "/sync-no-hem-ids" => "/publisher/app/v1/identitylockr/sync-no-hem-ids", + "/dataenrichment" => "/publisher/app/v1/identitylockr/dataenrichment", + "/revoke-consent" => "/publisher/app/v1/identitylockr/revoke-consent", + "/pending-token-request" => "/publisher/app/v1/identitylockr/pending-token-request", + "/incorrect-data-found" => "/publisher/app/v1/identitylockr/incorrect-data-found", + _ => { + return Err(Report::new(Self::error(format!( + "Unknown Lockr API endpoint: {}", + api_path + )))) + } + }; + + // Build full target URL with query parameters + let query = req + .get_url() + .query() + .map(|q| format!("?{}", q)) + .unwrap_or_default(); + let target_url = format!("{}{}{}", self.config.api_endpoint, target_path, query); + + log::info!("Forwarding to Lockr API: {}", target_url); + + // Create new request + let mut target_req = Request::new(method.clone(), &target_url); + + // Copy headers + self.copy_request_headers(&req, &mut target_req); + + // Copy body for POST/PUT/PATCH + if matches!(method, &Method::POST | &Method::PUT | &Method::PATCH) { + let body = req.take_body(); + target_req.set_body(body); + } + + // Get backend and forward + let backend_name = ensure_backend_from_url(&self.config.api_endpoint) + .change_context(Self::error("Failed to determine backend for API proxy"))?; + + let response = target_req + .send(backend_name) + .change_context(Self::error(format!( + "Failed to forward request to {}", + target_url + )))?; + + log::info!("Lockr API responded with status: {}", response.get_status()); + + Ok(response) + } + + /// Copy relevant request headers for proxying. + fn copy_request_headers(&self, from: &Request, to: &mut Request) { + let headers_to_copy = [ + header::CONTENT_TYPE, + header::ACCEPT, + header::USER_AGENT, + header::AUTHORIZATION, + header::ACCEPT_LANGUAGE, + header::ACCEPT_ENCODING, + header::COOKIE, + ]; + + for header_name in &headers_to_copy { + if let Some(value) = from.get_header(header_name) { + to.set_header(header_name, value); + } + } + + // Copy any X-* custom headers + for header_name in from.get_header_names() { + let name_str = header_name.as_str(); + if name_str.starts_with("x-") || name_str.starts_with("X-") { + if let Some(value) = from.get_header(header_name) { + to.set_header(header_name, value); + } + } + } + } +} + +fn build(settings: &Settings) -> Option> { + let config = match settings.integration_config::(LOCKR_INTEGRATION_ID) { + Ok(Some(config)) => config, + Ok(None) => return None, + Err(err) => { + log::error!("Failed to load Lockr integration config: {err:?}"); + return None; + } + }; + + Some(LockrIntegration::new(config)) +} + +/// Register the Lockr integration. +pub fn register(settings: &Settings) -> Option { + let integration = build(settings)?; + Some( + IntegrationRegistration::builder(LOCKR_INTEGRATION_ID) + .with_proxy(integration.clone()) + .with_attribute_rewriter(integration) + .build(), + ) +} + +#[async_trait(?Send)] +impl IntegrationProxy for LockrIntegration { + fn routes(&self) -> Vec { + vec![ + // SDK serving + IntegrationEndpoint::get("/integrations/lockr/sdk"), + // API proxy endpoints + IntegrationEndpoint::post("/integrations/lockr/api/*"), + IntegrationEndpoint::get("/integrations/lockr/api/*"), + ] + } + + async fn handle( + &self, + settings: &Settings, + req: Request, + ) -> Result> { + let path = req.get_path(); + + if path == "/integrations/lockr/sdk" { + self.handle_sdk_serving(settings, req).await + } else if path.starts_with("/integrations/lockr/api/") { + self.handle_api_proxy(settings, req).await + } else { + Err(Report::new(Self::error(format!( + "Unknown Lockr route: {}", + path + )))) + } + } +} + +impl IntegrationAttributeRewriter for LockrIntegration { + fn integration_id(&self) -> &'static str { + LOCKR_INTEGRATION_ID + } + + fn handles_attribute(&self, attribute: &str) -> bool { + self.config.rewrite_sdk && matches!(attribute, "src" | "href") + } + + fn rewrite( + &self, + _attr_name: &str, + attr_value: &str, + ctx: &IntegrationAttributeContext<'_>, + ) -> AttributeRewriteAction { + if !self.config.rewrite_sdk { + return AttributeRewriteAction::Keep; + } + + if self.is_lockr_sdk_url(attr_value) { + // Rewrite to first-party SDK endpoint + AttributeRewriteAction::Replace(format!( + "{}://{}/integrations/lockr/sdk", + ctx.request_scheme, ctx.request_host + )) + } else { + AttributeRewriteAction::Keep + } + } +} + +// Default value functions +fn default_enabled() -> bool { + true +} + +fn default_api_endpoint() -> String { + "https://identity.lockr.kr".to_string() +} + +fn default_sdk_url() -> String { + "https://aim.loc.kr/identity-lockr-v1.0.js".to_string() +} + +fn default_cache_ttl() -> u32 { + 3600 // 1 hour +} + +fn default_rewrite_sdk() -> bool { + true +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_lockr_sdk_url_detection() { + let config = LockrConfig { + enabled: true, + app_id: "test-app-id".to_string(), + api_endpoint: default_api_endpoint(), + sdk_url: default_sdk_url(), + cache_ttl_seconds: 3600, + rewrite_sdk: true, + }; + let integration = LockrIntegration::new(config); + + // Should match Lockr SDK URLs + assert!(integration.is_lockr_sdk_url("https://aim.loc.kr/identity-lockr-v1.0.js")); + assert!(integration.is_lockr_sdk_url("https://identity.loc.kr/identity-lockr-v2.0.js")); + + // Should not match other URLs + assert!(!integration.is_lockr_sdk_url("https://example.com/script.js")); + } + + #[test] + fn test_attribute_rewriter_rewrites_sdk_urls() { + let config = LockrConfig { + enabled: true, + app_id: "test-app-id".to_string(), + api_endpoint: default_api_endpoint(), + sdk_url: default_sdk_url(), + cache_ttl_seconds: 3600, + rewrite_sdk: true, + }; + let integration = LockrIntegration::new(config); + + let ctx = IntegrationAttributeContext { + attribute_name: "src", + request_host: "edge.example.com", + request_scheme: "https", + origin_host: "origin.example.com", + }; + + let rewritten = + integration.rewrite("src", "https://aim.loc.kr/identity-lockr-v1.0.js", &ctx); + + match rewritten { + AttributeRewriteAction::Replace(url) => { + assert_eq!(url, "https://edge.example.com/integrations/lockr/sdk"); + } + _ => panic!("Expected Replace action"), + } + } + + #[test] + fn test_attribute_rewriter_noop_when_disabled() { + let config = LockrConfig { + enabled: true, + app_id: "test-app-id".to_string(), + api_endpoint: default_api_endpoint(), + sdk_url: default_sdk_url(), + cache_ttl_seconds: 3600, + rewrite_sdk: false, // Disabled + }; + let integration = LockrIntegration::new(config); + + let ctx = IntegrationAttributeContext { + attribute_name: "src", + request_host: "edge.example.com", + request_scheme: "https", + origin_host: "origin.example.com", + }; + + let rewritten = + integration.rewrite("src", "https://aim.loc.kr/identity-lockr-v1.0.js", &ctx); + + assert_eq!(rewritten, AttributeRewriteAction::Keep); + } +} diff --git a/crates/common/src/integrations/mod.rs b/crates/common/src/integrations/mod.rs index e4577c4..bcc4548 100644 --- a/crates/common/src/integrations/mod.rs +++ b/crates/common/src/integrations/mod.rs @@ -2,6 +2,7 @@ use crate::settings::Settings; +pub mod lockr; mod registry; pub mod testlight; @@ -15,5 +16,5 @@ pub use registry::{ type IntegrationBuilder = fn(&Settings) -> Option; pub(crate) fn builders() -> &'static [IntegrationBuilder] { - &[testlight::register] + &[lockr::register, testlight::register] } diff --git a/crates/js/lib/src/integrations/lockr/index.ts b/crates/js/lib/src/integrations/lockr/index.ts new file mode 100644 index 0000000..9010707 --- /dev/null +++ b/crates/js/lib/src/integrations/lockr/index.ts @@ -0,0 +1,109 @@ +import { log } from '../../core/log'; + +// Type definition for Lockr global +declare global { + interface Window { + identityLockr?: IdentityLockr; + } +} + +interface IdentityLockr { + host: string; + app_id: string; + expiryDateKeys: string[]; + firstPartyCookies: string[]; + canRefreshToken: boolean; + macroDetectionEnabled: boolean; + iluiMacroDetection: boolean; + gdprApplies: boolean; + consentString: string; + gppString: string; + ccpaString: string; + isUTMTagsLoaded: boolean; + isFirstPartyCookiesLoaded: boolean; + allowedUTMTags: string[]; + lockrTrackingID: string; + panoramaClientId: string; + writeToDeviceConsentEUID: boolean; + id5JSEnabled: boolean; + firstIDPassHEM: boolean; + panoramaPassHEM: boolean; + firstIDEnabled: boolean; + panoramaEnabled: boolean; + isAdelphicEnabled: boolean; + os: string; + browser: string; + country: string; + city: string; + latitude: string; + longitude: string; + ip: string; + hashedUserAgent: string; + tokenMappings: Record; + tokenSourceMappings: Record; + identitProvidersType: Record; + identityIdEncryptionSalt: string; +} + +/** + * Install the Lockr shim to rewrite API endpoints to first-party domain. + * This function is called after the Lockr SDK has loaded and initialized. + */ +function installLockrShim() { + log.info('Installing Lockr shim - rewriting API host to first-party domain'); + + const identityLockr = window.identityLockr; + if (!identityLockr) { + log.warn('Lockr shim: identityLockr global not found'); + return; + } + + const host = window.location.host; + const protocol = window.location.protocol === 'https:' ? 'https' : 'http'; + + // Store original host for debugging + const originalHost = identityLockr.host; + + // Rewrite to first-party domain + // The Lockr SDK will now make all API calls through our proxy + identityLockr.host = `${protocol}://${host}/integrations/lockr/api`; + + log.info('Lockr shim installed', { + originalHost, + newHost: identityLockr.host, + appId: identityLockr.app_id, + }); +} + +/** + * Wait for Lockr SDK to be available before installing shim. + * Polls for SDK availability with a maximum number of attempts. + * + * @param callback - Function to call when SDK is available + * @param maxAttempts - Maximum number of polling attempts (default: 50) + */ +function waitForLockrSDK(callback: () => void, maxAttempts = 50) { + let attempts = 0; + + const check = () => { + attempts++; + + // Check if identityLockr global exists and is initialized with host + if (typeof window !== 'undefined' && window.identityLockr && window.identityLockr.host) { + log.info('Lockr SDK detected, installing shim'); + callback(); + } else if (attempts < maxAttempts) { + // Check again in 50ms + setTimeout(check, 50); + } else { + log.warn('Lockr SDK not detected after', maxAttempts * 50, 'ms'); + } + }; + + check(); +} + +// Auto-install when running in browser +if (typeof window !== 'undefined') { + waitForLockrSDK(() => installLockrShim()); +} diff --git a/trusted-server.toml b/trusted-server.toml index 6149565..8990fab 100644 --- a/trusted-server.toml +++ b/trusted-server.toml @@ -49,6 +49,14 @@ endpoint = "https://testlight.example/openrtb2/auction" timeout_ms = 1200 rewrite_scripts = true +[integrations.lockr] +enabled = false +app_id = "" +api_endpoint = "https://identity.lockr.kr" +sdk_url = "https://aim.loc.kr/identity-lockr-v1.0.js" +cache_ttl_seconds = 3600 +rewrite_sdk = true + # Rewrite configuration for creative HTML/CSS processing # [rewrite] # Domains to exclude from first-party rewriting (supports wildcards like "*.example.com")