From c848260b1d65e6350b1c54111db47000339709e5 Mon Sep 17 00:00:00 2001 From: Navan Chauhan Date: Tue, 25 Aug 2026 14:16:59 -0600 Subject: [PATCH 1/6] feat(server): add bounded trace and batch storage paths --- server/Cargo.toml | 1 + server/src/blob_store/mod.rs | 183 +++++++++++- server/src/http/mod.rs | 419 +++++++++++++++++++--------- server/src/projection/fast.rs | 31 ++ server/src/projection/mod.rs | 301 +++++++++++++++++--- server/src/store.rs | 82 ++++-- server/src/turn_store/mod.rs | 125 ++++++++- server/tests/registry_projection.rs | 83 +++++- server/tests/store_basic.rs | 126 +++++++++ 9 files changed, 1147 insertions(+), 204 deletions(-) create mode 100644 server/src/projection/fast.rs diff --git a/server/Cargo.toml b/server/Cargo.toml index 1e7b3d4..aec9eb4 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -31,6 +31,7 @@ sysinfo = "0.30" libc = "0.2" regex = "1.10" tracing = "0.1" +rayon = "1.10" # AWS SDK for S3 sync (optional feature for production deployments) aws-config = { version = "1.5", features = ["behavior-version-latest"] } diff --git a/server/src/blob_store/mod.rs b/server/src/blob_store/mod.rs index 1ab1dd3..819e24d 100644 --- a/server/src/blob_store/mod.rs +++ b/server/src/blob_store/mod.rs @@ -1,15 +1,16 @@ // Copyright 2025 StrongDM Inc // SPDX-License-Identifier: Apache-2.0 -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::fs::{File, OpenOptions}; -use std::io::{Read, Seek, SeekFrom, Write}; +use std::io::{Cursor, Read, Seek, SeekFrom, Write}; #[cfg(unix)] use std::os::unix::fs::FileExt; use std::path::{Path, PathBuf}; use byteorder::{LittleEndian, ReadBytesExt, WriteBytesExt}; use crc32fast::Hasher; +use rayon::prelude::*; use crate::error::{Result, StoreError}; @@ -296,10 +297,110 @@ impl BlobStore { if raw_bytes.len() as u32 != raw_len { return Err(StoreError::Corrupt("blob length mismatch".into())); } + if blake3::hash(&raw_bytes).as_bytes() != hash { + return Err(StoreError::Corrupt("blob content hash mismatch".into())); + } Ok(raw_bytes) } + /// Read several blobs with bounded, coalesced pread operations. + /// + /// Records are decoded independently after the range read. The returned + /// vector has exactly the same order and duplicates as `hashes`. + pub fn get_many(&self, hashes: &[[u8; 32]]) -> Result>> { + const HEADER_SIZE: u64 = 48; + const CRC_SIZE: u64 = 4; + const MAX_GAP: u64 = 64 * 1024; + const MAX_RANGE: u64 = 16 * 1024 * 1024; + + if hashes.is_empty() { + return Ok(Vec::new()); + } + let mut unique = Vec::with_capacity(hashes.len()); + let mut seen = HashSet::with_capacity(hashes.len()); + for hash in hashes { + if seen.insert(*hash) { + let entry = self + .index + .get(hash) + .ok_or_else(|| StoreError::NotFound("blob".into()))? + .clone(); + let end = entry + .offset + .checked_add(HEADER_SIZE) + .and_then(|v| v.checked_add(u64::from(entry.stored_len))) + .and_then(|v| v.checked_add(CRC_SIZE)) + .ok_or_else(|| StoreError::Corrupt("blob record offset overflow".into()))?; + unique.push((*hash, entry, end)); + } + } + unique.sort_unstable_by_key(|(_, entry, _)| entry.offset); + + let pack_len = self.pack_read.metadata()?.len(); + let mut decoded = HashMap::with_capacity(unique.len()); + let mut first = 0; + while first < unique.len() { + let range_start = unique[first].1.offset; + let mut range_end = unique[first].2; + let mut last = first + 1; + while last < unique.len() { + let next = &unique[last]; + if next.1.offset < range_end { + return Err(StoreError::Corrupt("overlapping blob index entries".into())); + } + let gap = next.1.offset - range_end; + let span = next + .2 + .checked_sub(range_start) + .ok_or_else(|| StoreError::Corrupt("invalid blob index range".into()))?; + if gap > MAX_GAP || span > MAX_RANGE { + break; + } + range_end = next.2; + last += 1; + } + if range_end > pack_len { + return Err(StoreError::Corrupt( + "blob index points past pack end".into(), + )); + } + let range_len = usize::try_from(range_end - range_start) + .map_err(|_| StoreError::Corrupt("blob read range exceeds address space".into()))?; + let mut range = vec![0u8; range_len]; + self.read_at_exact(range_start, &mut range)?; + let group: Result)>> = unique[first..last] + .par_iter() + .map(|(hash, entry, end)| { + let start = usize::try_from(entry.offset - range_start).map_err(|_| { + StoreError::Corrupt("blob offset exceeds address space".into()) + })?; + let end = usize::try_from(*end - range_start).map_err(|_| { + StoreError::Corrupt("blob record exceeds address space".into()) + })?; + let record = range.get(start..end).ok_or_else(|| { + StoreError::Corrupt("blob record outside read range".into()) + })?; + Ok((*hash, decode_blob_record_slice(record, hash, entry)?)) + }) + .collect(); + for (hash, payload) in group? { + decoded.insert(hash, payload); + } + first = last; + } + + hashes + .iter() + .map(|hash| { + decoded + .get(hash) + .cloned() + .ok_or_else(|| StoreError::NotFound("blob".into())) + }) + .collect() + } + /// Read exactly buf.len() bytes from the read handle at the given offset using pread. fn read_at_exact(&self, offset: u64, buf: &mut [u8]) -> Result<()> { let mut total_read = 0usize; @@ -335,6 +436,59 @@ impl BlobStore { } } +fn decode_blob_record_slice( + record: &[u8], + expected_hash: &[u8; 32], + expected_entry: &BlobIndexEntry, +) -> Result> { + const HEADER_SIZE: usize = 48; + let expected_len = HEADER_SIZE + .checked_add(expected_entry.stored_len as usize) + .and_then(|v| v.checked_add(4)) + .ok_or_else(|| StoreError::Corrupt("blob record length overflow".into()))?; + if record.len() != expected_len { + return Err(StoreError::Corrupt("blob record length mismatch".into())); + } + let mut header = Cursor::new(&record[..HEADER_SIZE]); + let magic = header.read_u32::()?; + let version = header.read_u16::()?; + let codec_raw = header.read_u16::()?; + let raw_len = header.read_u32::()?; + let stored_len = header.read_u32::()?; + let mut stored_hash = [0u8; 32]; + header.read_exact(&mut stored_hash)?; + if magic != BLOB_MAGIC || version != BLOB_VERSION { + return Err(StoreError::Corrupt("invalid blob header".into())); + } + if &stored_hash != expected_hash + || raw_len != expected_entry.raw_len + || stored_len != expected_entry.stored_len + || codec_raw != expected_entry.codec as u16 + { + return Err(StoreError::Corrupt("blob index/header mismatch".into())); + } + let stored_end = HEADER_SIZE + stored_len as usize; + let stored = &record[HEADER_SIZE..stored_end]; + let crc = Cursor::new(&record[stored_end..]).read_u32::()?; + let mut hasher = Hasher::new(); + hasher.update(&record[..stored_end]); + if crc != hasher.finalize() { + return Err(StoreError::Corrupt("blob crc mismatch".into())); + } + let raw = match expected_entry.codec { + BlobCodec::None => stored.to_vec(), + BlobCodec::Zstd => zstd::decode_all(stored) + .map_err(|e| StoreError::Corrupt(format!("zstd decode failed: {e}")))?, + }; + if raw.len() != raw_len as usize { + return Err(StoreError::Corrupt("blob length mismatch".into())); + } + if blake3::hash(&raw).as_bytes() != expected_hash { + return Err(StoreError::Corrupt("blob content hash mismatch".into())); + } + Ok(raw) +} + #[derive(Debug, Clone)] pub struct BlobStoreStats { pub blobs_total: usize, @@ -345,3 +499,28 @@ pub struct BlobStoreStats { fn file_len(path: &PathBuf) -> u64 { std::fs::metadata(path).map(|m| m.len()).unwrap_or(0) } + +#[cfg(test)] +mod tests { + use super::BlobStore; + use tempfile::tempdir; + + #[test] + fn get_many_preserves_order_and_duplicates() { + let dir = tempdir().expect("tempdir"); + let mut store = BlobStore::open(dir.path()).expect("open"); + let first = b"first payload"; + let second = b"second payload"; + let first_hash = *blake3::hash(first).as_bytes(); + let second_hash = *blake3::hash(second).as_bytes(); + store.put_if_absent(first_hash, first).expect("first"); + store.put_if_absent(second_hash, second).expect("second"); + let values = store + .get_many(&[second_hash, first_hash, second_hash]) + .expect("batch read"); + assert_eq!( + values, + vec![second.to_vec(), first.to_vec(), second.to_vec()] + ); + } +} diff --git a/server/src/http/mod.rs b/server/src/http/mod.rs index 91a304b..58cdf5c 100644 --- a/server/src/http/mod.rs +++ b/server/src/http/mod.rs @@ -17,13 +17,17 @@ use crate::error::{Result, StoreError}; use crate::events::{EventBus, StoreEvent}; use crate::fs_store::EntryKind; use crate::metrics::{Metrics, SessionTracker}; -use crate::projection::{BytesRender, EnumRender, RenderOptions, TimeRender, U64Format}; +use crate::projection::{ + assemble_turn_page_json, serialize_turn_page, BytesRender, EnumRender, RenderOptions, + TimeRender, TurnProjectionOptions, U64Format, +}; use crate::registry::{ FieldSpec, ItemsSpec, PutOutcome, Registry, RegistryBundle, RendererSpec, TypeVersionSpec, }; use crate::store::Store; type HttpResponse = (u16, Response>>); +const MAX_HTTP_PAYLOAD_BYTES: usize = 4 * 1024 * 1024; pub fn start_http( bind_addr: String, @@ -660,14 +664,19 @@ fn handle_request( let type_id = get_required_string(&body, "type_id")?; let type_version = get_required_u32(&body, "type_version")?; let parent_turn_id = get_optional_u64(&body, "parent_turn_id")?.unwrap_or(0); - let payload_json = body - .get("data") - .or_else(|| body.get("payload")) - .ok_or_else(|| { - StoreError::InvalidInput("missing required field: data or payload".into()) - })?; - - let payload_bytes = { + let payload_bytes = if let Some(encoded) = + body.get("payload_base64").and_then(JsonValue::as_str) + { + decode_http_payload_base64(encoded)? + } else { + let payload_json = body + .get("data") + .or_else(|| body.get("payload")) + .ok_or_else(|| { + StoreError::InvalidInput( + "missing required field: payload_base64, data, or payload".into(), + ) + })?; let registry = registry.lock().unwrap(); encode_http_payload(payload_json, &type_id, type_version, ®istry)? }; @@ -736,6 +745,112 @@ fn handle_request( ), )) } + (Method::Post, ["v1", "contexts", context_id, "append-batch"]) => { + let context_id: u64 = context_id + .parse() + .map_err(|_| StoreError::InvalidInput("invalid context_id".into()))?; + let body = parse_json_body(&mut request)?; + let items = body + .get("turns") + .or_else(|| body.get("items")) + .and_then(JsonValue::as_array) + .ok_or_else(|| { + StoreError::InvalidInput("missing required field: turns".into()) + })?; + let registry_guard = registry.lock().unwrap(); + let mut prepared = Vec::with_capacity(items.len()); + for (index, item) in items.iter().enumerate() { + match prepare_batch_item(item, ®istry_guard) { + Ok(prepared_item) => prepared.push(prepared_item), + Err(error) => { + return batch_failure_response(context_id, &[], index, &error) + } + } + } + drop(registry_guard); + + // Hold the writer lock for the complete batch. This prevents an + // unrelated append from being inserted between two batch items. + // Storage is append-only, so a later I/O or parent error cannot + // roll back earlier records. Such a failure returns the exact + // successful prefix and failed index. + let mut store_guard = store.write().unwrap(); + let mut appended = Vec::with_capacity(prepared.len()); + let mut current_head = store_guard.get_head(context_id)?.head_turn_id; + for (index, (type_id, type_version, payload, requested_parent)) in + prepared.into_iter().enumerate() + { + let parent = requested_parent.unwrap_or(current_head); + let hash = blake3::hash(&payload); + let (record, metadata) = match store_guard.append_turn( + context_id, + parent, + type_id.clone(), + type_version, + 1, + 0, + payload.len() as u32, + *hash.as_bytes(), + &payload, + ) { + Ok(result) => result, + Err(error) => { + drop(store_guard); + return batch_failure_response(context_id, &appended, index, &error); + } + }; + current_head = record.turn_id; + event_bus.publish(StoreEvent::TurnAppended { + context_id: context_id.to_string(), + turn_id: record.turn_id.to_string(), + parent_turn_id: record.parent_turn_id.to_string(), + depth: record.depth, + declared_type_id: Some(type_id), + declared_type_version: Some(type_version), + }); + if let Some(meta) = metadata { + event_bus.publish(StoreEvent::ContextMetadataUpdated { + context_id: context_id.to_string(), + client_tag: meta.client_tag, + title: meta.title, + labels: meta.labels, + has_provenance: meta.provenance.is_some(), + }); + if let Some(prov) = meta.provenance { + if let Some(parent_context_id) = prov.parent_context_id { + event_bus.publish(StoreEvent::ContextLinked { + child_context_id: context_id.to_string(), + parent_context_id: parent_context_id.to_string(), + root_context_id: prov.root_context_id.map(|v| v.to_string()), + spawn_reason: prov.spawn_reason, + }); + } + } + } + appended.push(json!({ + "turn_id": format_id(record.turn_id, &U64Format::Number), + "parent_turn_id": format_id(record.parent_turn_id, &U64Format::Number), + "depth": record.depth, + "content_hash": hex::encode(hash.as_bytes()), + })); + } + drop(store_guard); + let bytes = serde_json::to_vec(&json!({ + "context_id": format_id(context_id, &U64Format::Number), + "turns": appended, + "partial": false, + })) + .map_err(|e| StoreError::InvalidInput(format!("json encode error: {e}")))?; + Ok(( + 201, + Response::from_data(bytes) + .with_status_code(StatusCode(201)) + .with_header( + Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..]) + .unwrap(), + ), + )) + } (Method::Get, ["v1", "contexts", context_id, "turns"]) => { let context_id: u64 = context_id .parse() @@ -747,8 +862,21 @@ fn handle_request( .unwrap_or(64); let before_turn_id = params .get("before_turn_id") - .and_then(|v| v.parse::().ok()) + .map(|value| { + value + .parse::() + .map_err(|_| StoreError::InvalidInput("invalid before_turn_id".into())) + }) + .transpose()? .unwrap_or(0); + let exact_turn_id = params + .get("turn_id") + .map(|value| { + value + .parse::() + .map_err(|_| StoreError::InvalidInput("invalid turn_id".into())) + }) + .transpose()?; let view = params.get("view").map(|v| v.as_str()).unwrap_or("typed"); let type_hint_mode = params .get("type_hint_mode") @@ -777,6 +905,20 @@ fn handle_request( .get("include_unknown") .map(|v| v == "1") .unwrap_or(false); + let string_limit = match params.get("string_limit") { + Some(value) => { + let value = value + .parse::() + .map_err(|_| StoreError::InvalidInput("invalid string_limit".into()))?; + if value > 64 * 1024 { + return Err(StoreError::InvalidInput( + "string_limit exceeds 65536".into(), + )); + } + Some(value) + } + None => None, + }; let as_type_id = params.get("as_type_id").cloned(); let as_type_version = params @@ -789,12 +931,19 @@ fn handle_request( enum_render, time_render, include_unknown, + string_limit: if exact_turn_id.is_some() { + None + } else { + string_limit + }, }; let store = store.read().unwrap(); let head = store.get_head(context_id)?; let t0 = Instant::now(); - let turns = if before_turn_id == 0 { + let turns = if let Some(turn_id) = exact_turn_id { + vec![store.get_context_turn(context_id, turn_id, true)?] + } else if before_turn_id == 0 { store.get_last(context_id, limit, true)? } else { store.get_before(context_id, before_turn_id, limit, true)? @@ -802,135 +951,33 @@ fn handle_request( metrics.record_get_last(t0.elapsed()); let registry = registry.lock().unwrap(); - let mut out_turns = Vec::new(); - for item in turns.iter() { - let declared_type_id = item.meta.declared_type_id.clone(); - let declared_type_version = item.meta.declared_type_version; - - let (decoded_type_id, decoded_type_version) = match type_hint_mode { - "explicit" => { - let id = as_type_id.clone().ok_or_else(|| { - StoreError::InvalidInput("as_type_id required".into()) - })?; - let ver = as_type_version.ok_or_else(|| { - StoreError::InvalidInput("as_type_version required".into()) - })?; - (id, ver) - } - "latest" => { - let latest = registry - .get_latest_type_version(&declared_type_id) - .ok_or_else(|| StoreError::NotFound("type descriptor".into()))?; - (declared_type_id.clone(), latest.version) - } - _ => (declared_type_id.clone(), declared_type_version), - }; - - let mut turn_obj = Map::new(); - turn_obj.insert( - "turn_id".into(), - format_id(item.record.turn_id, &u64_format), - ); - turn_obj.insert( - "parent_turn_id".into(), - format_id(item.record.parent_turn_id, &u64_format), - ); - turn_obj.insert("depth".into(), JsonValue::Number(item.record.depth.into())); - turn_obj.insert( - "declared_type".into(), - json!({ - "type_id": declared_type_id, - "type_version": declared_type_version, - }), - ); - - if view == "typed" || view == "both" { - let desc = registry - .get_type_version(&decoded_type_id, decoded_type_version) - .ok_or_else(|| StoreError::NotFound("type descriptor".into()))?; - let payload = item - .payload - .as_ref() - .ok_or_else(|| StoreError::InvalidInput("payload not loaded".into()))?; - let projected = - crate::projection::project_msgpack(payload, desc, ®istry, &options)?; - turn_obj.insert( - "decoded_as".into(), - json!({ - "type_id": decoded_type_id, - "type_version": decoded_type_version, - }), - ); - turn_obj.insert("data".into(), projected.data); - if let Some(unknown) = projected.unknown { - turn_obj.insert("unknown".into(), unknown); - } - } - - if view == "raw" || view == "both" { - let raw_payload = item - .payload - .as_ref() - .ok_or_else(|| StoreError::InvalidInput("payload not loaded".into()))?; - turn_obj.insert( - "content_hash_b3".into(), - JsonValue::String(hex::encode(item.record.payload_hash)), - ); - turn_obj.insert( - "encoding".into(), - JsonValue::Number(item.meta.encoding.into()), - ); - turn_obj.insert("compression".into(), JsonValue::Number(0u32.into())); - turn_obj.insert( - "uncompressed_len".into(), - JsonValue::Number((raw_payload.len() as u32).into()), - ); - match bytes_render { - BytesRender::Base64 => { - turn_obj.insert( - "bytes_b64".into(), - JsonValue::String( - base64::engine::general_purpose::STANDARD - .encode(raw_payload), - ), - ); - } - BytesRender::Hex => { - turn_obj.insert( - "bytes_hex".into(), - JsonValue::String(hex::encode(raw_payload)), - ); - } - BytesRender::LenOnly => { - turn_obj.insert( - "bytes_len".into(), - JsonValue::Number((raw_payload.len() as u64).into()), - ); - } - } - } - - out_turns.push(JsonValue::Object(turn_obj)); - } - - let next_before = turns - .first() - .map(|t| format_id(t.record.turn_id, &u64_format)); - let meta = json!({ + let projection_options = TurnProjectionOptions { + view, + type_hint_mode, + as_type_id: as_type_id.as_deref(), + as_type_version, + render: &options, + }; + let serialized_turns = serialize_turn_page(&turns, ®istry, &projection_options)?; + let next_before = if exact_turn_id.is_some() { + None + } else { + turns + .first() + .map(|t| format_id(t.record.turn_id, &u64_format)) + }; + let mut meta = json!({ "context_id": format_id(context_id, &u64_format), "head_turn_id": format_id(head.head_turn_id, &u64_format), "head_depth": head.head_depth, "registry_bundle_id": registry.last_bundle_id(), }); - - let resp = json!({ - "meta": meta, - "turns": out_turns, - "next_before_turn_id": next_before, - }); - - let bytes = serde_json::to_vec(&resp) - .map_err(|e| StoreError::InvalidInput(format!("json encode error: {e}")))?; + if exact_turn_id.is_none() { + if let Some(string_limit) = string_limit { + meta["string_limit"] = json!(string_limit); + } + } + let bytes = assemble_turn_page_json(&meta, next_before, &serialized_turns)?; Ok(( 200, Response::from_data(bytes) @@ -1442,6 +1489,69 @@ fn parse_json_u64(value: &JsonValue, field_name: &str) -> Result { } } +fn decode_http_payload_base64(encoded: &str) -> Result> { + let payload = base64::engine::general_purpose::STANDARD + .decode(encoded) + .map_err(|e| StoreError::InvalidInput(format!("invalid payload_base64: {e}")))?; + if payload.len() > MAX_HTTP_PAYLOAD_BYTES { + return Err(StoreError::InvalidInput( + "payload_base64 must contain at most 4 MiB".into(), + )); + } + Ok(payload) +} + +fn prepare_batch_item( + item: &JsonValue, + registry: &Registry, +) -> Result<(String, u32, Vec, Option)> { + let type_id = get_required_string(item, "type_id")?; + let type_version = get_required_u32(item, "type_version")?; + let payload = if let Some(encoded) = item.get("payload_base64").and_then(JsonValue::as_str) { + decode_http_payload_base64(encoded)? + } else { + let value = item + .get("data") + .or_else(|| item.get("payload")) + .ok_or_else(|| { + StoreError::InvalidInput( + "missing required field: payload_base64, data, or payload".into(), + ) + })?; + encode_http_payload(value, &type_id, type_version, registry)? + }; + let parent = get_optional_u64(item, "parent_turn_id")?; + Ok((type_id, type_version, payload, parent)) +} + +fn batch_failure_response( + context_id: u64, + appended: &[JsonValue], + failed_index: usize, + error: &StoreError, +) -> Result { + let (status, message) = map_error(error); + let bytes = serde_json::to_vec(&json!({ + "context_id": format_id(context_id, &U64Format::Number), + "turns": appended, + "partial": !appended.is_empty(), + "failed_index": failed_index, + "error": { + "code": status, + "message": message, + }, + })) + .map_err(|e| StoreError::InvalidInput(format!("json encode error: {e}")))?; + Ok(( + status, + Response::from_data(bytes) + .with_status_code(StatusCode(status)) + .with_header( + Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..]).unwrap(), + ), + )) +} + fn get_required_string(body: &JsonValue, key: &str) -> Result { body.get(key) .and_then(|v| v.as_str()) @@ -1968,4 +2078,43 @@ mod tests { *k == MsgpackValue::from("text") && *v == MsgpackValue::from("hello") })); } + + #[test] + fn decode_http_payload_base64_accepts_mcp_payloads() { + let encoded = base64::engine::general_purpose::STANDARD.encode(b"mcp payload"); + assert_eq!( + decode_http_payload_base64(&encoded).expect("decode payload"), + b"mcp payload" + ); + } + + #[test] + fn decode_http_payload_base64_rejects_oversized_payloads() { + let encoded = + base64::engine::general_purpose::STANDARD + .encode(vec![0_u8; MAX_HTTP_PAYLOAD_BYTES + 1]); + let error = decode_http_payload_base64(&encoded).expect_err("oversized payload"); + assert!(error.to_string().contains("at most 4 MiB")); + } + + #[test] + fn batch_failures_preserve_route_status_semantics() { + let (conflict_status, _) = batch_failure_response( + 1, + &[json!({"turn_id": 1})], + 1, + &StoreError::NotFound("parent turn".into()), + ) + .expect("conflict response"); + assert_eq!(conflict_status, 409); + + let (storage_status, _) = batch_failure_response( + 1, + &[], + 0, + &StoreError::Corrupt("blob content hash mismatch".into()), + ) + .expect("storage response"); + assert_eq!(storage_status, 500); + } } diff --git a/server/src/projection/fast.rs b/server/src/projection/fast.rs new file mode 100644 index 0000000..7e11563 --- /dev/null +++ b/server/src/projection/fast.rs @@ -0,0 +1,31 @@ +// Copyright 2025 StrongDM Inc +// SPDX-License-Identifier: Apache-2.0 + +//! Streaming page serializer entry point. +//! +//! The page scheduler owns parallelism. This serializer keeps the wire shape +//! in one place and delegates field semantics to the compatibility projector, +//! so named and numeric key handling cannot drift between paths. + +use serde::Serialize; + +use super::{project_turn, TurnProjectionOptions}; +use crate::error::{Result, StoreError}; +use crate::registry::Registry; +use crate::store::TurnWithMeta; + +pub(super) fn serialize_turn( + item: &TurnWithMeta, + registry: &Registry, + options: &TurnProjectionOptions<'_>, +) -> Result> { + let projected = project_turn(item, registry, options)?; + serde_json::to_vec(&projected) + .map_err(|error| StoreError::InvalidInput(format!("json encode error: {error}"))) +} + +#[allow(dead_code)] +fn _serialize_json(value: &T) -> Result> { + serde_json::to_vec(value) + .map_err(|error| StoreError::InvalidInput(format!("json encode error: {error}"))) +} diff --git a/server/src/projection/mod.rs b/server/src/projection/mod.rs index 36997ad..d7cbe15 100644 --- a/server/src/projection/mod.rs +++ b/server/src/projection/mod.rs @@ -1,6 +1,8 @@ // Copyright 2025 StrongDM Inc // SPDX-License-Identifier: Apache-2.0 +use rayon::prelude::*; +use std::borrow::Cow; use std::collections::HashMap; use base64::Engine; @@ -44,13 +46,212 @@ pub struct RenderOptions { pub enum_render: EnumRender, pub time_render: TimeRender, pub include_unknown: bool, + pub string_limit: Option, } +pub struct TurnProjectionOptions<'a> { + pub view: &'a str, + pub type_hint_mode: &'a str, + pub as_type_id: Option<&'a str>, + pub as_type_version: Option, + pub render: &'a RenderOptions, +} + +pub fn project_turn_page( + turns: &[crate::store::TurnWithMeta], + registry: &Registry, + options: &TurnProjectionOptions<'_>, +) -> Result> { + if turns.len() < 8 { + turns + .iter() + .map(|turn| project_turn(turn, registry, options)) + .collect() + } else { + turns + .par_iter() + .map(|turn| project_turn(turn, registry, options)) + .collect() + } +} + +pub fn serialize_turn_page( + turns: &[crate::store::TurnWithMeta], + registry: &Registry, + options: &TurnProjectionOptions<'_>, +) -> Result>> { + if turns.len() < 8 { + turns + .iter() + .map(|turn| fast::serialize_turn(turn, registry, options)) + .collect() + } else { + turns + .par_iter() + .map(|turn| fast::serialize_turn(turn, registry, options)) + .collect() + } +} + +pub fn assemble_turn_page_json( + meta: &JsonValue, + next_before_turn_id: Option, + turns: &[Vec], +) -> Result> { + let meta = serde_json::to_vec(meta) + .map_err(|e| StoreError::InvalidInput(format!("json encode error: {e}")))?; + let next = serde_json::to_vec(&next_before_turn_id) + .map_err(|e| StoreError::InvalidInput(format!("json encode error: {e}")))?; + let mut out = Vec::with_capacity(meta.len() + turns.iter().map(Vec::len).sum::() + 64); + out.extend_from_slice(br#"{"meta":"#); + out.extend_from_slice(&meta); + out.extend_from_slice(br#","turns":["#); + for (index, turn) in turns.iter().enumerate() { + if index != 0 { + out.push(b','); + } + out.extend_from_slice(turn); + } + out.extend_from_slice(br#"],"next_before_turn_id":"#); + out.extend_from_slice(&next); + out.push(b'}'); + Ok(out) +} + +#[cfg(test)] +mod page_assembly_tests { + use super::assemble_turn_page_json; + use serde_json::json; + + #[test] + fn assembles_empty_and_populated_pages_as_json() { + let empty = assemble_turn_page_json(&json!({"context_id": 1}), None, &[]) + .expect("assemble empty page"); + assert_eq!( + serde_json::from_slice::(&empty).expect("parse empty page"), + json!({"meta": {"context_id": 1}, "turns": [], "next_before_turn_id": null}) + ); + + let turns = vec![br#"{"turn_id":1}"#.to_vec(), br#"{"turn_id":2}"#.to_vec()]; + let populated = assemble_turn_page_json(&json!({"context_id": 1}), Some(json!(2)), &turns) + .expect("assemble populated page"); + assert_eq!( + serde_json::from_slice::(&populated).expect("parse populated page"), + json!({ + "meta": {"context_id": 1}, + "turns": [{"turn_id": 1}, {"turn_id": 2}], + "next_before_turn_id": 2 + }) + ); + } +} + +mod fast; + pub struct ProjectionResult { pub data: JsonValue, pub unknown: Option, } +pub fn project_turn( + item: &crate::store::TurnWithMeta, + registry: &Registry, + options: &TurnProjectionOptions<'_>, +) -> Result { + let declared_type_id = item.meta.declared_type_id.clone(); + let (decoded_type_id, decoded_type_version) = match options.type_hint_mode { + "explicit" => ( + options + .as_type_id + .ok_or_else(|| StoreError::InvalidInput("as_type_id required".into()))? + .to_string(), + options + .as_type_version + .ok_or_else(|| StoreError::InvalidInput("as_type_version required".into()))?, + ), + "latest" => { + let latest = registry + .get_latest_type_version(&declared_type_id) + .ok_or_else(|| StoreError::NotFound("type descriptor".into()))?; + (declared_type_id.clone(), latest.version) + } + _ => (declared_type_id.clone(), item.meta.declared_type_version), + }; + let mut turn = Map::new(); + turn.insert( + "turn_id".into(), + render_id(item.record.turn_id, options.render.u64_format), + ); + turn.insert( + "parent_turn_id".into(), + render_id(item.record.parent_turn_id, options.render.u64_format), + ); + turn.insert("depth".into(), JsonValue::Number(item.record.depth.into())); + turn.insert("declared_type".into(), serde_json::json!({"type_id": declared_type_id, "type_version": item.meta.declared_type_version})); + if options.view == "typed" || options.view == "both" { + let descriptor = registry + .get_type_version(&decoded_type_id, decoded_type_version) + .ok_or_else(|| StoreError::NotFound("type descriptor".into()))?; + let payload = item + .payload + .as_ref() + .ok_or_else(|| StoreError::InvalidInput("payload not loaded".into()))?; + let projected = project_msgpack(payload, descriptor, registry, options.render)?; + turn.insert( + "decoded_as".into(), + serde_json::json!({"type_id": decoded_type_id, "type_version": decoded_type_version}), + ); + turn.insert("data".into(), projected.data); + if let Some(unknown) = projected.unknown { + turn.insert("unknown".into(), unknown); + } + } + if options.view == "raw" || options.view == "both" { + let payload = item + .payload + .as_ref() + .ok_or_else(|| StoreError::InvalidInput("payload not loaded".into()))?; + turn.insert( + "content_hash_b3".into(), + JsonValue::String(hex::encode(item.record.payload_hash)), + ); + turn.insert( + "encoding".into(), + JsonValue::Number(item.meta.encoding.into()), + ); + turn.insert("compression".into(), JsonValue::Number(0u32.into())); + turn.insert( + "uncompressed_len".into(), + JsonValue::Number((payload.len() as u32).into()), + ); + match options.render.bytes_render { + BytesRender::Base64 => { + turn.insert( + "bytes_b64".into(), + JsonValue::String(base64::engine::general_purpose::STANDARD.encode(payload)), + ); + } + BytesRender::Hex => { + turn.insert("bytes_hex".into(), JsonValue::String(hex::encode(payload))); + } + BytesRender::LenOnly => { + turn.insert( + "bytes_len".into(), + JsonValue::Number((payload.len() as u64).into()), + ); + } + } + } + Ok(JsonValue::Object(turn)) +} + +fn render_id(value: u64, format: U64Format) -> JsonValue { + match format { + U64Format::String => JsonValue::String(value.to_string()), + U64Format::Number => JsonValue::Number(value.into()), + } +} + pub fn project_msgpack( payload: &[u8], descriptor: &TypeVersionSpec, @@ -60,24 +261,24 @@ pub fn project_msgpack( let mut cursor = std::io::Cursor::new(payload); let value = rmpv::decode::read_value(&mut cursor) .map_err(|e| StoreError::InvalidInput(format!("msgpack decode error: {e}")))?; + if !matches!(value, Value::Map(_)) { + return Err(StoreError::InvalidInput("payload is not a map".into())); + } - let map = normalize_tags(&value)?; + let map = normalize_fields(&value, descriptor); let mut data = Map::new(); let mut unknown = Map::new(); for (tag, field) in descriptor.fields.iter() { - if let Some(val) = map.get(tag) { + if let Some(val) = map.known.get(tag) { let rendered = render_field_value(val, field, registry, options); data.insert(field.name.clone(), rendered); } } if options.include_unknown { - for (tag, val) in map.iter() { - if descriptor.fields.contains_key(tag) { - continue; - } - unknown.insert(tag.to_string(), render_value(val, options)); + for (tag, val) in map.unknown.iter() { + unknown.insert(tag.clone(), render_value(val, options)); } } @@ -91,20 +292,44 @@ pub fn project_msgpack( }) } -fn normalize_tags(value: &Value) -> Result> { - let mut out = HashMap::new(); +struct NormalizedFields { + known: HashMap, + unknown: HashMap, +} + +fn normalize_fields(value: &Value, descriptor: &TypeVersionSpec) -> NormalizedFields { + let mut known = HashMap::new(); + let mut unknown = HashMap::new(); let map = match value { Value::Map(map) => map, - _ => return Err(StoreError::InvalidInput("payload is not a map".into())), + _ => return NormalizedFields { known, unknown }, }; for (k, v) in map.iter() { - if let Some(tag) = key_to_tag(k) { - out.insert(tag, v.clone()); + let named = match k { + Value::String(name) => name.as_str().and_then(|name| { + descriptor + .fields + .iter() + .find_map(|(tag, field)| (field.name == name).then_some(*tag)) + }), + _ => None, + }; + if let Some(tag) = key_to_tag(k).or(named) { + if matches!(k, Value::Integer(_)) || !known.contains_key(&tag) { + known.insert(tag, v.clone()); + } + if !descriptor.fields.contains_key(&tag) { + let name = tag.to_string(); + if matches!(k, Value::Integer(_)) || !unknown.contains_key(&name) { + unknown.insert(name, v.clone()); + } + } + } else if let Value::String(name) = k { + unknown.insert(name.as_str().unwrap_or("").to_string(), v.clone()); } } - - Ok(out) + NormalizedFields { known, unknown } } fn key_to_tag(key: &Value) -> Option { @@ -157,7 +382,7 @@ fn render_field_value( match field_type { "u64" | "uint64" | "i64" | "int64" => render_u64(value, options), "u32" | "uint32" | "u8" | "uint8" | "int32" => render_int(value), - "string" => render_string(value), + "string" => render_string(value, options), "bool" => render_bool(value), "bytes" | "typed_blob" => render_bytes(value, options), "array" => render_array(value, field.items.as_ref(), registry, options), @@ -187,34 +412,24 @@ fn render_type_ref( }; // Normalize the value to a tag map - let Ok(map) = normalize_tags(value) else { - return render_value(value, options); - }; + let map = normalize_fields(value, type_spec); // Project using the type descriptor let mut data = Map::new(); for (tag, field) in type_spec.fields.iter() { - if let Some(val) = map.get(tag) { + if let Some(val) = map.known.get(tag) { let rendered = render_field_value(val, field, registry, options); data.insert(field.name.clone(), rendered); } } - // Propagate include_unknown into nested types — collect tags that the - // descriptor doesn't know about so they surface through the HTTP API. - if options.include_unknown { + if options.include_unknown && !map.unknown.is_empty() { let mut unknown = Map::new(); - for (tag, val) in map.iter() { - if type_spec.fields.contains_key(tag) { - continue; - } - unknown.insert(tag.to_string(), render_value(val, options)); - } - if !unknown.is_empty() { - data.insert("_unknown".into(), JsonValue::Object(unknown)); + for (name, value) in map.unknown { + unknown.insert(name, render_value(&value, options)); } + data.insert("_unknown".into(), JsonValue::Object(unknown)); } - JsonValue::Object(data) } @@ -233,7 +448,9 @@ fn render_value(value: &Value, options: &RenderOptions) -> JsonValue { } Value::F32(f) => JsonValue::Number(Number::from_f64(*f as f64).unwrap_or(Number::from(0))), Value::F64(f) => JsonValue::Number(Number::from_f64(*f).unwrap_or(Number::from(0))), - Value::String(s) => JsonValue::String(s.as_str().unwrap_or("").to_string()), + Value::String(s) => JsonValue::String( + limit_string(s.as_str().unwrap_or(""), options.string_limit).into_owned(), + ), Value::Binary(_) => render_bytes(value, options), Value::Array(arr) => { let items = arr.iter().map(|v| render_value(v, options)).collect(); @@ -258,13 +475,29 @@ fn render_value(value: &Value, options: &RenderOptions) -> JsonValue { } } -fn render_string(value: &Value) -> JsonValue { +fn render_string(value: &Value, options: &RenderOptions) -> JsonValue { match value { - Value::String(s) => JsonValue::String(s.as_str().unwrap_or("").to_string()), + Value::String(s) => JsonValue::String( + limit_string(s.as_str().unwrap_or(""), options.string_limit).into_owned(), + ), _ => JsonValue::Null, } } +pub fn limit_string(value: &str, limit: Option) -> Cow<'_, str> { + let Some(limit) = limit else { + return Cow::Borrowed(value); + }; + if value.len() <= limit { + return Cow::Borrowed(value); + } + let mut end = limit; + while end > 0 && !value.is_char_boundary(end) { + end -= 1; + } + Cow::Owned(value[..end].to_string()) +} + fn render_bool(value: &Value) -> JsonValue { match value { Value::Boolean(b) => JsonValue::Bool(*b), diff --git a/server/src/store.rs b/server/src/store.rs index 0dbec2f..9fd668d 100644 --- a/server/src/store.rs +++ b/server/src/store.rs @@ -266,23 +266,61 @@ impl Store { include_payload: bool, ) -> Result> { let turns = self.turn_store.get_last(context_id, limit)?; - let mut out = Vec::with_capacity(turns.len()); - for record in turns { - let meta = self.turn_store.get_turn_meta(record.turn_id)?; - let payload = if include_payload { - Some(self.blob_store.get(&record.payload_hash)?) - } else { - None - }; - out.push(TurnWithMeta { - record, - meta, - payload, - }); + self.hydrate_turns(turns, include_payload) + } + + /// Hydrate turn metadata and payloads in page order. Blob reads are + /// coalesced by the CAS while duplicate payload hashes remain duplicated + /// in the returned page. + pub fn hydrate_turns( + &self, + turns: Vec, + include_payload: bool, + ) -> Result> { + let payloads = if include_payload { + let hashes: Vec<_> = turns.iter().map(|turn| turn.payload_hash).collect(); + Some(self.blob_store.get_many(&hashes)?) + } else { + None + }; + turns + .into_iter() + .enumerate() + .map(|(index, record)| { + let meta = self.turn_store.get_turn_meta(record.turn_id)?; + Ok(TurnWithMeta { + record, + meta, + payload: payloads.as_ref().map(|items| items[index].clone()), + }) + }) + .collect() + } + + /// Return one turn only when it belongs to the requested context. + pub fn get_turn(&self, turn_id: u64, include_payload: bool) -> Result { + let record = self.turn_store.get_turn(turn_id)?; + self.hydrate_turns(vec![record], include_payload)? + .into_iter() + .next() + .ok_or_else(|| StoreError::NotFound("turn".into())) + } + + /// Return an exact turn after validating context membership. + pub fn get_context_turn( + &self, + context_id: u64, + turn_id: u64, + include_payload: bool, + ) -> Result { + if !self.turn_store.context_contains_turn(context_id, turn_id)? { + return Err(StoreError::NotFound("turn in context".into())); } - Ok(out) + self.get_turn(turn_id, include_payload) } + /// Fetch a page before a cursor. Context membership is validated by the + /// turn store before the parent chain is traversed. pub fn get_before( &self, context_id: u64, @@ -293,21 +331,7 @@ impl Store { let turns = self .turn_store .get_before(context_id, before_turn_id, limit)?; - let mut out = Vec::with_capacity(turns.len()); - for record in turns { - let meta = self.turn_store.get_turn_meta(record.turn_id)?; - let payload = if include_payload { - Some(self.blob_store.get(&record.payload_hash)?) - } else { - None - }; - out.push(TurnWithMeta { - record, - meta, - payload, - }); - } - Ok(out) + self.hydrate_turns(turns, include_payload) } pub fn get_blob(&self, hash: &[u8; 32]) -> Result> { diff --git a/server/src/turn_store/mod.rs b/server/src/turn_store/mod.rs index 8a840be..850fb21 100644 --- a/server/src/turn_store/mod.rs +++ b/server/src/turn_store/mod.rs @@ -12,6 +12,8 @@ use crc32fast::Hasher; use crate::error::{Result, StoreError}; +const ANCESTRY_BLOCK_SIZE: u32 = 256; + #[derive(Debug, Clone)] pub struct TurnRecord { pub turn_id: u64, @@ -57,6 +59,11 @@ pub struct TurnStore { turn_index: HashMap, turn_meta: HashMap, heads: HashMap, + /// Nearest block boundary ancestor for each turn. A map avoids allocating + /// a sparse vector when imported turn IDs are large. + ancestry_checkpoints: HashMap, + /// The head inherited when each context was created. + context_base_turns: HashMap, next_turn_id: u64, next_context_id: u64, @@ -108,11 +115,14 @@ impl TurnStore { turn_index: HashMap::new(), turn_meta: HashMap::new(), heads: HashMap::new(), + ancestry_checkpoints: HashMap::new(), + context_base_turns: HashMap::new(), next_turn_id: 1, next_context_id: 1, }; store.load_turns()?; + store.rebuild_ancestry_checkpoints()?; store.load_meta()?; store.load_heads()?; store.rebuild_index()?; @@ -163,6 +173,38 @@ impl TurnStore { Ok(()) } + fn rebuild_ancestry_checkpoints(&mut self) -> Result<()> { + self.ancestry_checkpoints.clear(); + let mut turn_ids: Vec<_> = self.turns.keys().copied().collect(); + turn_ids.sort_unstable(); + for turn_id in turn_ids { + let record = self + .turns + .get(&turn_id) + .ok_or_else(|| StoreError::Corrupt("turn disappeared during index build".into()))?; + let checkpoint = if record.depth % ANCESTRY_BLOCK_SIZE == 0 { + record.turn_id + } else if record.parent_turn_id == 0 { + return Err(StoreError::Corrupt( + "turn ancestry checkpoint is incomplete".into(), + )); + } else { + *self + .ancestry_checkpoints + .get(&record.parent_turn_id) + .ok_or_else(|| { + StoreError::Corrupt("turn ancestry checkpoint is incomplete".into()) + })? + }; + self.ancestry_checkpoints.insert(turn_id, checkpoint); + } + Ok(()) + } + + fn checkpoint(&self, turn_id: u64) -> Option { + self.ancestry_checkpoints.get(&turn_id).copied() + } + fn load_meta(&mut self) -> Result<()> { self.turn_meta.clear(); self.turns_meta.seek(SeekFrom::Start(0))?; @@ -234,6 +276,7 @@ impl TurnStore { fn load_heads(&mut self) -> Result<()> { self.heads.clear(); + self.context_base_turns.clear(); self.heads_tbl.seek(SeekFrom::Start(0))?; loop { let start = self.heads_tbl.stream_position()?; @@ -302,6 +345,9 @@ impl TurnStore { flags, }, ); + self.context_base_turns + .entry(context_id) + .or_insert(head_turn_id); } Ok(()) } @@ -357,6 +403,7 @@ impl TurnStore { self.write_head(&head)?; self.heads.insert(context_id, head.clone()); + self.context_base_turns.insert(context_id, head_turn_id); Ok(head) } @@ -454,6 +501,14 @@ impl TurnStore { ); self.turns.insert(turn_id, record.clone()); self.turn_index.insert(turn_id, offset); + let checkpoint = if depth % ANCESTRY_BLOCK_SIZE == 0 { + turn_id + } else { + self.checkpoint(parent_id).ok_or_else(|| { + StoreError::Corrupt("parent ancestry checkpoint is missing".into()) + })? + }; + self.ancestry_checkpoints.insert(turn_id, checkpoint); // update head let head = ContextHead { @@ -493,6 +548,44 @@ impl TurnStore { .ok_or_else(|| StoreError::NotFound("turn".into())) } + /// Check membership without walking from the head one edge at a time for + /// every block of a deep ancestry chain. + pub fn context_contains_turn(&self, context_id: u64, turn_id: u64) -> Result { + let head = self + .heads + .get(&context_id) + .ok_or_else(|| StoreError::NotFound("context".into()))?; + let target = self + .turns + .get(&turn_id) + .ok_or_else(|| StoreError::NotFound("turn".into()))?; + if target.depth > head.head_depth { + return Ok(false); + } + let mut current_id = head.head_turn_id; + let mut current_depth = head.head_depth; + while current_depth > target.depth { + let distance = current_depth - target.depth; + let within_block = current_depth % ANCESTRY_BLOCK_SIZE; + if within_block > 0 && within_block <= distance { + if let Some(checkpoint) = self.checkpoint(current_id) { + if checkpoint != current_id { + current_id = checkpoint; + current_depth -= within_block; + continue; + } + } + } + let current = self + .turns + .get(¤t_id) + .ok_or_else(|| StoreError::Corrupt("context ancestry is incomplete".into()))?; + current_id = current.parent_turn_id; + current_depth -= 1; + } + Ok(current_id == turn_id) + } + pub fn get_turn_meta(&self, turn_id: u64) -> Result { self.turn_meta .get(&turn_id) @@ -532,7 +625,14 @@ impl TurnStore { .get(&context_id) .ok_or_else(|| StoreError::NotFound("context".into()))?; - if before_turn_id == 0 || head.head_turn_id == 0 { + if before_turn_id == 0 { + return self.get_last(context_id, limit); + } + + if !self.context_contains_turn(context_id, before_turn_id)? { + return Err(StoreError::NotFound("turn in context".into())); + } + if head.head_turn_id == 0 { return self.get_last(context_id, limit); } @@ -562,14 +662,33 @@ impl TurnStore { .get(&context_id) .ok_or_else(|| StoreError::NotFound("context".into()))?; - // Walk back from head to find the turn with depth=0 + let base_turn_id = self + .context_base_turns + .get(&context_id) + .copied() + .ok_or_else(|| StoreError::NotFound("context base".into()))?; + if head.head_turn_id == base_turn_id { + return Err(StoreError::NotFound("first turn".into())); + } + + // Walk back to the first turn owned by this context. let mut current = head.head_turn_id; while current != 0 { let rec = self .turns .get(¤t) .ok_or_else(|| StoreError::NotFound("turn".into()))?; - if rec.depth == 0 { + if base_turn_id == 0 && rec.depth == 0 { + return Ok(rec.clone()); + } + if rec.turn_id == base_turn_id { + return Err(StoreError::NotFound("first turn".into())); + } + let parent = self.turns.get(&rec.parent_turn_id); + if parent + .map(|record| record.turn_id == base_turn_id) + .unwrap_or(false) + { return Ok(rec.clone()); } current = rec.parent_turn_id; diff --git a/server/tests/registry_projection.rs b/server/tests/registry_projection.rs index 09843cb..359459d 100644 --- a/server/tests/registry_projection.rs +++ b/server/tests/registry_projection.rs @@ -1,9 +1,11 @@ // Copyright 2025 StrongDM Inc // SPDX-License-Identifier: Apache-2.0 -use cxdb_server::projection::project_msgpack; +use cxdb_server::projection::{project_msgpack, project_turn_page, serialize_turn_page}; use cxdb_server::projection::{BytesRender, EnumRender, RenderOptions, TimeRender, U64Format}; use cxdb_server::registry::Registry; +use cxdb_server::store::TurnWithMeta; +use cxdb_server::turn_store::{TurnMeta, TurnRecord}; use rmpv::Value; use tempfile::tempdir; @@ -14,6 +16,7 @@ fn default_options() -> RenderOptions { enum_render: EnumRender::Label, time_render: TimeRender::Iso, include_unknown: true, + string_limit: None, } } @@ -68,6 +71,7 @@ fn registry_ingest_and_project() { enum_render: EnumRender::Label, time_render: TimeRender::Iso, include_unknown: true, + string_limit: None, }; let projection = project_msgpack(&buf, desc, ®istry, &options).expect("project"); @@ -733,3 +737,80 @@ fn array_ref_items_include_unknown_tags() { "_unknown should not appear when there are no unknown tags" ); } + +#[test] +fn named_keys_are_recursive_numeric_priority_and_utf8_bounded() { + let dir = tempdir().expect("tempdir"); + let mut registry = Registry::open(dir.path()).expect("open registry"); + let bundle = r#"{ + "registry_version": 1, "bundle_id": "named#test", + "types": {"test:Message": {"versions": {"1": {"fields": { + "1": {"name": "text", "type": "string"}, + "2": {"name": "count", "type": "u64"} + }}}}} + }"#; + registry + .put_bundle("named#test", bundle.as_bytes()) + .expect("bundle"); + let desc = registry.get_type_version("test:Message", 1).expect("desc"); + let value = Value::Map(vec![ + (Value::String("text".into()), Value::String("named".into())), + (Value::Integer(1.into()), Value::String("numeric".into())), + (Value::String("1".into()), Value::String("alias".into())), + ( + Value::String("extra".into()), + Value::String("éclair".into()), + ), + (Value::String("count".into()), Value::Integer(7.into())), + ]); + let mut payload = Vec::new(); + rmpv::encode::write_value(&mut payload, &value).expect("encode"); + + let bounded = RenderOptions { + string_limit: Some(4), + ..default_options() + }; + let projected = project_msgpack(&payload, desc, ®istry, &bounded).expect("project"); + assert_eq!(projected.data["text"], "nume"); + assert_eq!(projected.data["count"], "7"); + assert_eq!(projected.unknown.as_ref().unwrap()["extra"], "écl"); + + let record = TurnRecord { + turn_id: 9, + parent_turn_id: 0, + depth: 0, + codec: 1, + type_tag: 0, + payload_hash: *blake3::hash(&payload).as_bytes(), + flags: 0, + created_at_unix_ms: 0, + }; + let item = TurnWithMeta { + record, + meta: TurnMeta { + declared_type_id: "test:Message".into(), + declared_type_version: 1, + encoding: 1, + compression: 0, + uncompressed_len: payload.len() as u32, + }, + payload: Some(payload), + }; + let numeric = RenderOptions { + u64_format: U64Format::Number, + ..bounded.clone() + }; + let options = cxdb_server::projection::TurnProjectionOptions { + view: "typed", + type_hint_mode: "inherit", + as_type_id: None, + as_type_version: None, + render: &numeric, + }; + let normal = + project_turn_page(std::slice::from_ref(&item), ®istry, &options).expect("normal"); + let fast = serialize_turn_page(std::slice::from_ref(&item), ®istry, &options).expect("fast"); + let fast_json: serde_json::Value = serde_json::from_slice(&fast[0]).expect("fast json"); + assert_eq!(fast_json, normal[0]); + assert_eq!(fast_json["turn_id"], 9); +} diff --git a/server/tests/store_basic.rs b/server/tests/store_basic.rs index 40e44c9..f1847a3 100644 --- a/server/tests/store_basic.rs +++ b/server/tests/store_basic.rs @@ -157,6 +157,132 @@ fn indexes_parent_child_context_lineage() { assert_eq!(descendants, vec![grandchild.context_id, child.context_id]); } +#[test] +fn exact_turn_and_cursor_are_context_scoped() { + let dir = tempdir().expect("tempdir"); + let mut store = Store::open(dir.path()).expect("open store"); + let root = store.create_context(0).expect("root"); + let payload = b"root"; + let hash = blake3::hash(payload); + let (root_turn, _) = store + .append_turn( + root.context_id, + 0, + "test:Message".into(), + 1, + 1, + 0, + payload.len() as u32, + *hash.as_bytes(), + payload, + ) + .expect("root turn"); + let child = store.fork_context(root_turn.turn_id).expect("fork"); + let child_payload = b"child"; + let child_hash = blake3::hash(child_payload); + let (child_turn, _) = store + .append_turn( + child.context_id, + 0, + "test:Message".into(), + 1, + 1, + 0, + child_payload.len() as u32, + *child_hash.as_bytes(), + child_payload, + ) + .expect("child turn"); + + assert!(store + .get_context_turn(child.context_id, child_turn.turn_id, true) + .is_ok()); + assert!(store + .get_context_turn(root.context_id, child_turn.turn_id, true) + .is_err()); + assert!(store + .get_before(root.context_id, child_turn.turn_id, 10, true) + .is_err()); + assert_eq!( + store + .get_context_turn(child.context_id, root_turn.turn_id, true) + .expect("inherited turn") + .payload + .as_deref(), + Some(payload.as_slice()) + ); +} + +#[test] +fn deep_ancestry_membership_uses_checkpoints() { + let dir = tempdir().expect("tempdir"); + let mut store = Store::open(dir.path()).expect("open store"); + let context = store.create_context(0).expect("context"); + let payload = b"same"; + let hash = blake3::hash(payload); + let mut ids = Vec::new(); + for _ in 0..600 { + let (turn, _) = store + .append_turn( + context.context_id, + 0, + "test:Message".into(), + 1, + 1, + 0, + payload.len() as u32, + *hash.as_bytes(), + payload, + ) + .expect("append"); + ids.push(turn.turn_id); + } + assert!(store + .get_context_turn(context.context_id, ids[0], false) + .is_ok()); + assert!(store + .get_context_turn(context.context_id, ids[599], false) + .is_ok()); +} + +#[test] +fn sequential_append_failure_keeps_prior_success() { + let dir = tempdir().expect("tempdir"); + let mut store = Store::open(dir.path()).expect("open store"); + let context = store.create_context(0).expect("context"); + let payload = b"first"; + let hash = blake3::hash(payload); + store + .append_turn( + context.context_id, + 0, + "test:Message".into(), + 1, + 1, + 0, + payload.len() as u32, + *hash.as_bytes(), + payload, + ) + .expect("first append"); + let failed = store.append_turn( + context.context_id, + 0, + "test:Message".into(), + 1, + 1, + 0, + payload.len() as u32, + [0; 32], + payload, + ); + assert!(failed.is_err()); + assert_eq!( + store.get_last(context.context_id, 10, true).unwrap().len(), + 1 + ); +} + fn encode_context_metadata_payload( parent_context_id: Option, root_context_id: Option, From eac8248c5ad5a3e0f2465178042c4644ec932cb2 Mon Sep 17 00:00:00 2001 From: Navan Chauhan Date: Tue, 25 Aug 2026 14:17:00 -0600 Subject: [PATCH 2/6] feat(gateway): add scoped OAuth tokens and MCP --- gateway/.env.example | 11 +- gateway/Dockerfile | 2 +- gateway/cmd/server/main.go | 25 +- gateway/go.mod | 32 +- gateway/go.sum | 66 ++- gateway/internal/config/config.go | 75 +++- gateway/internal/config/config_test.go | 42 ++ gateway/pkg/auth/api_token.go | 359 +++++++++++++++ gateway/pkg/auth/api_token_test.go | 100 +++++ gateway/pkg/auth/aws_iam.go | 51 ++- gateway/pkg/auth/aws_iam_test.go | 38 ++ gateway/pkg/auth/browser_oidc.go | 223 +++++++++ gateway/pkg/auth/browser_oidc_test.go | 172 +++++++ gateway/pkg/auth/google.go | 57 +-- gateway/pkg/auth/google_security_test.go | 31 ++ gateway/pkg/auth/k8s_oidc.go | 34 +- gateway/pkg/auth/k8s_oidc_test.go | 24 + gateway/pkg/auth/middleware.go | 211 ++++++--- gateway/pkg/auth/middleware_test.go | 167 +++++++ gateway/pkg/auth/oauth_server.go | 500 +++++++++++++++++++++ gateway/pkg/auth/oauth_server_test.go | 214 +++++++++ gateway/pkg/auth/session.go | 117 ++++- gateway/pkg/auth/session_test.go | 38 ++ gateway/pkg/mcpserver/server.go | 316 +++++++++++++ gateway/pkg/mcpserver/server_test.go | 228 ++++++++++ gateway/pkg/proxy/peer.go | 28 ++ gateway/pkg/proxy/reverse.go | 82 ++-- gateway/pkg/proxy/reverse_security_test.go | 64 +++ gateway/pkg/proxy/server.go | 227 ++++++++-- gateway/pkg/proxy/server_security_test.go | 24 + gateway/pkg/proxy/server_tokens_test.go | 187 ++++++++ 31 files changed, 3477 insertions(+), 268 deletions(-) create mode 100644 gateway/internal/config/config_test.go create mode 100644 gateway/pkg/auth/api_token.go create mode 100644 gateway/pkg/auth/api_token_test.go create mode 100644 gateway/pkg/auth/aws_iam_test.go create mode 100644 gateway/pkg/auth/browser_oidc.go create mode 100644 gateway/pkg/auth/browser_oidc_test.go create mode 100644 gateway/pkg/auth/google_security_test.go create mode 100644 gateway/pkg/auth/k8s_oidc_test.go create mode 100644 gateway/pkg/auth/middleware_test.go create mode 100644 gateway/pkg/auth/oauth_server.go create mode 100644 gateway/pkg/auth/oauth_server_test.go create mode 100644 gateway/pkg/auth/session_test.go create mode 100644 gateway/pkg/mcpserver/server.go create mode 100644 gateway/pkg/mcpserver/server_test.go create mode 100644 gateway/pkg/proxy/peer.go create mode 100644 gateway/pkg/proxy/reverse_security_test.go create mode 100644 gateway/pkg/proxy/server_security_test.go create mode 100644 gateway/pkg/proxy/server_tokens_test.go diff --git a/gateway/.env.example b/gateway/.env.example index 5066c28..b4a25b2 100644 --- a/gateway/.env.example +++ b/gateway/.env.example @@ -9,7 +9,16 @@ GOOGLE_CLIENT_SECRET=GOCSPX-your-client-secret # For production: use your organization's domain GOOGLE_ALLOWED_DOMAIN=example.com -# Session secret for HMAC cookie signing (generate with: openssl rand -hex 32) +# Generic browser OIDC (alternative to Google OAuth). +# Discovery and ID-token validation require an HTTPS issuer. +# OIDC_ENABLED=true +# OIDC_ISSUER_URL=https://id.example.com +# OIDC_CLIENT_ID=cxdb +# OIDC_CLIENT_SECRET=replace-me +# OIDC_ALLOWED_DOMAINS=example.com + +# Session secret for HMAC cookie signing (generate with: openssl rand -hex 32). +# The gateway requires at least 32 bytes. SESSION_SECRET=your-64-character-hex-secret-here # Public URL where this gateway is accessible (used for OAuth redirect) diff --git a/gateway/Dockerfile b/gateway/Dockerfile index e3e1071..eeeaa7e 100644 --- a/gateway/Dockerfile +++ b/gateway/Dockerfile @@ -26,7 +26,7 @@ RUN pnpm build # ============================================ # Stage 2: Build Go binary with CGO for SQLite # ============================================ -FROM --platform=linux/amd64 golang:1.23-bookworm AS builder +FROM --platform=linux/amd64 golang:1.25-bookworm AS builder WORKDIR /app # Copy go mod files first for layer caching diff --git a/gateway/cmd/server/main.go b/gateway/cmd/server/main.go index d194132..c2c3753 100644 --- a/gateway/cmd/server/main.go +++ b/gateway/cmd/server/main.go @@ -18,8 +18,8 @@ import ( ) // Entry point for the cxdb Gateway server. -// This gateway provides Google OAuth authentication for reads while -// forwarding writes directly to the cxdb backend. +// This gateway provides browser OIDC, scoped bearer-token, and MCP OAuth +// authentication while proxying the CXDB HTTP API. func main() { logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ Level: slog.LevelInfo, @@ -46,16 +46,19 @@ func main() { logger.Error("session store init failed", "err", err) os.Exit(1) } - defer func() { _ = sessionStore.Close() }() + defer sessionStore.Close() - googleAuth := auth.NewGoogleAuth( - cfg.PublicBaseURL, - cfg.GoogleClientID, - cfg.GoogleClientSecret, - cfg.GoogleAllowedDomain, - cfg.PublicAllowedHosts, - sessionStore, - ) + var googleAuth *auth.GoogleAuth + if cfg.GoogleClientID != "" { + googleAuth = auth.NewGoogleAuth( + cfg.PublicBaseURL, + cfg.GoogleClientID, + cfg.GoogleClientSecret, + cfg.GoogleAllowedDomain, + cfg.PublicAllowedHosts, + sessionStore, + ) + } reverseProxy, err := proxy.NewReverseProxy(cfg.CXDBBackendURL, logger) if err != nil { diff --git a/gateway/go.mod b/gateway/go.mod index 96b3026..7d0ec26 100644 --- a/gateway/go.mod +++ b/gateway/go.mod @@ -1,39 +1,33 @@ module github.com/strongdm/cxdb/gateway -go 1.23.0 +go 1.25.0 require ( + github.com/coreos/go-oidc/v3 v3.20.0 github.com/joho/godotenv v1.5.1 github.com/lestrrat-go/jwx/v2 v2.1.6 github.com/mattn/go-sqlite3 v1.14.24 - golang.org/x/oauth2 v0.24.0 - golang.org/x/time v0.8.0 + github.com/modelcontextprotocol/go-sdk v1.7.0 + github.com/vmihailenco/msgpack/v5 v5.4.1 + golang.org/x/oauth2 v0.36.0 + golang.org/x/time v0.15.0 ) - require ( cloud.google.com/go/compute/metadata v0.3.0 // indirect - github.com/aws/aws-sdk-go-v2 v1.41.0 // indirect - github.com/aws/aws-sdk-go-v2/config v1.32.6 // indirect - github.com/aws/aws-sdk-go-v2/credentials v1.19.6 // indirect - github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 // indirect - github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 // indirect - github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 // indirect - github.com/aws/aws-sdk-go-v2/service/sso v1.30.8 // indirect - github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 // indirect - github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 // indirect - github.com/aws/smithy-go v1.24.0 // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect + github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/goccy/go-json v0.10.3 // indirect + github.com/google/jsonschema-go v0.4.3 // indirect github.com/lestrrat-go/blackmagic v1.0.3 // indirect github.com/lestrrat-go/httpcc v1.0.1 // indirect github.com/lestrrat-go/httprc v1.0.6 // indirect github.com/lestrrat-go/iter v1.0.2 // indirect github.com/lestrrat-go/option v1.0.1 // indirect github.com/segmentio/asm v1.2.0 // indirect + github.com/segmentio/encoding v0.5.4 // indirect + github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect golang.org/x/crypto v0.32.0 // indirect - golang.org/x/sys v0.31.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.41.0 // indirect ) diff --git a/gateway/go.sum b/gateway/go.sum index 121bc94..9d766b4 100644 --- a/gateway/go.sum +++ b/gateway/go.sum @@ -1,42 +1,22 @@ cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc= cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= -github.com/aws/aws-sdk-go-v2 v1.41.0 h1:tNvqh1s+v0vFYdA1xq0aOJH+Y5cRyZ5upu6roPgPKd4= -github.com/aws/aws-sdk-go-v2 v1.41.0/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0= -github.com/aws/aws-sdk-go-v2/config v1.32.6 h1:hFLBGUKjmLAekvi1evLi5hVvFQtSo3GYwi+Bx4lpJf8= -github.com/aws/aws-sdk-go-v2/config v1.32.6/go.mod h1:lcUL/gcd8WyjCrMnxez5OXkO3/rwcNmvfno62tnXNcI= -github.com/aws/aws-sdk-go-v2/credentials v1.19.6 h1:F9vWao2TwjV2MyiyVS+duza0NIRtAslgLUM0vTA1ZaE= -github.com/aws/aws-sdk-go-v2/credentials v1.19.6/go.mod h1:SgHzKjEVsdQr6Opor0ihgWtkWdfRAIwxYzSJ8O85VHY= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 h1:80+uETIWS1BqjnN9uJ0dBUaETh+P1XwFy5vwHwK5r9k= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16/go.mod h1:wOOsYuxYuB/7FlnVtzeBYRcjSRtQpAW0hCP7tIULMwo= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 h1:rgGwPzb82iBYSvHMHXc8h9mRoOUBZIGFgKb9qniaZZc= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16/go.mod h1:L/UxsGeKpGoIj6DxfhOWHWQ/kGKcd4I1VncE4++IyKA= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 h1:1jtGzuV7c82xnqOVfx2F0xmJcOw5374L7N6juGW6x6U= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16/go.mod h1:M2E5OQf+XLe+SZGmmpaI2yy+J326aFf6/+54PoxSANc= -github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk= -github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 h1:oHjJHeUy0ImIV0bsrX0X91GkV5nJAyv1l1CC9lnO0TI= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16/go.mod h1:iRSNGgOYmiYwSCXxXaKb9HfOEj40+oTKn8pTxMlYkRM= -github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 h1:HpI7aMmJ+mm1wkSHIA2t5EaFFv5EFYXePW30p1EIrbQ= -github.com/aws/aws-sdk-go-v2/service/signin v1.0.4/go.mod h1:C5RdGMYGlfM0gYq/tifqgn4EbyX99V15P2V3R+VHbQU= -github.com/aws/aws-sdk-go-v2/service/sso v1.30.8 h1:aM/Q24rIlS3bRAhTyFurowU8A0SMyGDtEOY/l/s/1Uw= -github.com/aws/aws-sdk-go-v2/service/sso v1.30.8/go.mod h1:+fWt2UHSb4kS7Pu8y+BMBvJF0EWx+4H0hzNwtDNRTrg= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 h1:AHDr0DaHIAo8c9t1emrzAlVDFp+iMMKnPdYy6XO4MCE= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12/go.mod h1:GQ73XawFFiWxyWXMHWfhiomvP3tXtdNar/fi8z18sx0= -github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 h1:SciGFVNZ4mHdm7gpD1dgZYnCuVdX1s+lFTg4+4DOy70= -github.com/aws/aws-sdk-go-v2/service/sts v1.41.5/go.mod h1:iW40X4QBmUxdP+fZNOpfmkdMZqsovezbAeO+Ubiv2pk= -github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk= -github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= +github.com/coreos/go-oidc/v3 v3.20.0 h1:EtE0WIBHk03N+DqGkY4+UONzzZHk7amKt6IyNd7OsZE= +github.com/coreos/go-oidc/v3 v3.20.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= +github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= +github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA= github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= -github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= -github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0= +github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/lestrrat-go/blackmagic v1.0.3 h1:94HXkVLxkZO9vJI/w2u1T0DAoprShFd13xtnSINtDWs= @@ -53,23 +33,37 @@ github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNB github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM= github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/modelcontextprotocol/go-sdk v1.7.0 h1:yqjY2dsbKAC0LSuWZVBMrHgiG8ukXv6NRo0JiALay44= +github.com/modelcontextprotocol/go-sdk v1.7.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= +github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0= +github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= +github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= +github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= +github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= -golang.org/x/oauth2 v0.24.0 h1:KTBBxWqUa0ykRPLtV69rRto9TLXcqYkeswu48x/gvNE= -golang.org/x/oauth2 v0.24.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= -golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= -golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg= -golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/gateway/internal/config/config.go b/gateway/internal/config/config.go index d5b6605..3e8f902 100644 --- a/gateway/internal/config/config.go +++ b/gateway/internal/config/config.go @@ -20,9 +20,14 @@ import ( // Values are sourced from environment variables so they can // be injected locally via a .env file or via platform secrets. type Config struct { - GoogleClientID string - GoogleClientSecret string + GoogleClientID string + GoogleClientSecret string GoogleAllowedDomain string + OIDCEnabled bool + OIDCIssuerURL string + OIDCClientID string + OIDCClientSecret string + OIDCAllowedDomains []string PublicBaseURL string PublicAllowedHosts []string @@ -94,6 +99,11 @@ func Load() (Config, error) { SessionTTL: defaultSessionTTL, CXDBBackendURL: firstNonEmpty(os.Getenv("CXDB_BACKEND_URL"), defaultCXDBBackendURL), } + cfg.OIDCEnabled = parseBoolEnv("OIDC_ENABLED") + cfg.OIDCIssuerURL = strings.TrimSuffix(strings.TrimSpace(os.Getenv("OIDC_ISSUER_URL")), "/") + cfg.OIDCClientID = strings.TrimSpace(os.Getenv("OIDC_CLIENT_ID")) + cfg.OIDCClientSecret = strings.TrimSpace(os.Getenv("OIDC_CLIENT_SECRET")) + cfg.OIDCAllowedDomains = splitAndTrim(os.Getenv("OIDC_ALLOWED_DOMAINS")) if ttlStr := strings.TrimSpace(os.Getenv("SESSION_TTL_HOURS")); ttlStr != "" { if hours, err := strconv.Atoi(ttlStr); err == nil && hours > 0 { @@ -134,7 +144,6 @@ func Load() (Config, error) { // Renderer origin allowlist for CSP script-src directive // Defaults to common public CDNs if not specified - // For self-hosted renderers, set ALLOWED_RENDERER_ORIGINS to your CDN origin cfg.AllowedRendererOrigins = splitAndTrimPreserveCase(os.Getenv("ALLOWED_RENDERER_ORIGINS")) if len(cfg.AllowedRendererOrigins) == 0 { cfg.AllowedRendererOrigins = []string{ @@ -156,17 +165,37 @@ func Load() (Config, error) { func (c Config) validate() error { var missing []string - if c.GoogleClientID == "" { - missing = append(missing, "GOOGLE_CLIENT_ID") + if len(strings.TrimSpace(c.SessionSecret)) < 32 { + missing = append(missing, "SESSION_SECRET (minimum 32 bytes)") } - if c.GoogleClientSecret == "" { - missing = append(missing, "GOOGLE_CLIENT_SECRET") + googleConfigured := c.GoogleClientID != "" || c.GoogleClientSecret != "" || c.GoogleAllowedDomain != "" + if googleConfigured { + if c.GoogleClientID == "" { + missing = append(missing, "GOOGLE_CLIENT_ID") + } + if c.GoogleClientSecret == "" { + missing = append(missing, "GOOGLE_CLIENT_SECRET") + } + if c.GoogleAllowedDomain == "" { + missing = append(missing, "GOOGLE_ALLOWED_DOMAIN") + } } - if c.SessionSecret == "" { - missing = append(missing, "SESSION_SECRET") + if c.OIDCEnabled { + if c.OIDCIssuerURL == "" { + missing = append(missing, "OIDC_ISSUER_URL") + } + if c.OIDCClientID == "" { + missing = append(missing, "OIDC_CLIENT_ID") + } + if c.OIDCClientSecret == "" { + missing = append(missing, "OIDC_CLIENT_SECRET") + } + if len(c.OIDCAllowedDomains) == 0 { + missing = append(missing, "OIDC_ALLOWED_DOMAINS") + } } - if c.GoogleAllowedDomain == "" { - missing = append(missing, "GOOGLE_ALLOWED_DOMAIN") + if !googleConfigured && !c.OIDCEnabled && !c.DevMode { + missing = append(missing, "GOOGLE_CLIENT_ID or OIDC_ENABLED=true") } // Conditional validation for K8s OIDC @@ -225,6 +254,15 @@ func firstNonEmpty(values ...string) string { return "" } +func containsExact(values []string, want string) bool { + for _, v := range values { + if v == want { + return true + } + } + return false +} + func parseBoolEnv(key string) bool { raw := strings.TrimSpace(os.Getenv(key)) if raw == "" { @@ -238,8 +276,19 @@ func parseBoolEnv(key string) bool { } func isLocalhostURL(raw string) bool { - lower := strings.ToLower(raw) - return strings.Contains(lower, "localhost") || strings.Contains(lower, "127.0.0.1") + u, err := url.Parse(strings.TrimSpace(raw)) + if err != nil || u.User != nil || u.Host == "" { + return false + } + if u.Scheme != "http" && u.Scheme != "https" { + return false + } + switch strings.ToLower(u.Hostname()) { + case "localhost", "127.0.0.1", "::1": + return true + default: + return false + } } func hostnameFromURL(raw string) string { diff --git a/gateway/internal/config/config_test.go b/gateway/internal/config/config_test.go new file mode 100644 index 0000000..71a720a --- /dev/null +++ b/gateway/internal/config/config_test.go @@ -0,0 +1,42 @@ +// Copyright 2025 StrongDM Inc +// SPDX-License-Identifier: Apache-2.0 + +package config + +import "testing" + +func TestValidateRequiresStrongSessionSecret(t *testing.T) { + cfg := Config{SessionSecret: "short", CXDBBackendURL: "http://127.0.0.1:9010"} + if err := cfg.validate(); err == nil { + t.Fatal("short session secret was accepted") + } +} + +func TestValidateAcceptsStrongSessionSecretWithoutGoogleWhenDevMode(t *testing.T) { + cfg := Config{ + SessionSecret: "01234567890123456789012345678901", + CXDBBackendURL: "http://127.0.0.1:9010", + DevMode: true, + } + if err := cfg.validate(); err != nil { + t.Fatalf("strong development configuration rejected: %v", err) + } +} + +func TestIsLocalhostURLRequiresExactHost(t *testing.T) { + tests := map[string]bool{ + "http://localhost:8080": true, + "https://127.0.0.1:8080": true, + "http://[::1]:8080": true, + "http://LOCALHOST/": true, + "http://localhost.attacker.example": false, + "http://127.0.0.1.attacker.example": false, + "http://user@localhost:8080": false, + "localhost:8080": false, + } + for raw, want := range tests { + if got := isLocalhostURL(raw); got != want { + t.Errorf("isLocalhostURL(%q) = %v, want %v", raw, got, want) + } + } +} diff --git a/gateway/pkg/auth/api_token.go b/gateway/pkg/auth/api_token.go new file mode 100644 index 0000000..7d86127 --- /dev/null +++ b/gateway/pkg/auth/api_token.go @@ -0,0 +1,359 @@ +// Copyright 2025 StrongDM Inc +// SPDX-License-Identifier: Apache-2.0 + +package auth + +import ( + "context" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "database/sql" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + "time" +) + +const ( + APITokenAuthMethod = "api_token" + apiTokenPrefix = "cxpat_" +) + +var ( + ErrAPITokenNotFound = errors.New("api token not found") + ErrAPITokenExpired = errors.New("api token expired") + ErrAPITokenRevoked = errors.New("api token revoked") +) + +// APIToken is the non-secret representation of a personal API token. It is +// safe to return from list and revoke APIs. The token plaintext and its hash +// are intentionally not fields on this type. +type APIToken struct { + ID string `json:"id"` + Prefix string `json:"prefix"` // Public identifier, also equal to ID. + Name string `json:"name"` + Issuer string `json:"issuer"` + Subject string `json:"subject"` + Scopes []string `json:"scopes"` + CreatedAt time.Time `json:"created_at"` + ExpiresAt time.Time `json:"expires_at"` + RevokedAt *time.Time `json:"revoked_at,omitempty"` + LastUsedAt *time.Time `json:"last_used_at,omitempty"` +} + +// APITokenCreateRequest describes a token and its stable owner. Issuer and +// Subject together identify the owner; neither email nor display name does. +type APITokenCreateRequest struct { + Name string + Issuer string + Subject string + Scopes []string + ExpiresAt time.Time +} + +// PersonalAPIToken and CreateAPITokenRequest are descriptive aliases for +// callers that use the personal-token terminology. +type PersonalAPIToken = APIToken +type CreateAPITokenRequest = APITokenCreateRequest + +// CreateAPIToken creates a token and returns its metadata and plaintext. The +// plaintext is returned only by this call and is never persisted. +func (s *SessionStore) CreateAPIToken(ctx context.Context, req APITokenCreateRequest) (*APIToken, string, error) { + if len(s.secret) == 0 { + return nil, "", errors.New("token hashing secret is required") + } + issuer := strings.TrimSpace(req.Issuer) + subject := strings.TrimSpace(req.Subject) + if issuer == "" || subject == "" { + return nil, "", errors.New("token issuer and subject are required") + } + name := strings.TrimSpace(req.Name) + if name == "" { + return nil, "", errors.New("token name is required") + } + scopes, err := validateAPITokenScopes(req.Scopes) + if err != nil { + return nil, "", err + } + + // A zero expiry uses the store's configured lifetime. An explicitly past + // expiry is retained so that callers can consistently test and inspect the + // expiry/revocation lifecycle; verification rejects it. + expiresAt := req.ExpiresAt + if expiresAt.IsZero() { + expiresAt = time.Now().UTC().Add(s.ttl) + } + now := time.Now().UTC() + publicID, err := randomTokenPublicID() + if err != nil { + return nil, "", err + } + secret := make([]byte, 32) // 256 bits of entropy, encoded below. + if _, err := rand.Read(secret); err != nil { + return nil, "", fmt.Errorf("generate token secret: %w", err) + } + plaintext := publicID + "." + base64.RawURLEncoding.EncodeToString(secret) + hash := s.apiTokenHash(plaintext) + scopesJSON, err := marshalScopes(scopes) + if err != nil { + return nil, "", err + } + _, err = s.db.ExecContext(ctx, ` + INSERT INTO api_tokens + (id, name, issuer, subject, scopes, token_hash, created_at, expires_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `, publicID, name, issuer, subject, scopesJSON, hash, now, expiresAt.UTC()) + if err != nil { + return nil, "", fmt.Errorf("insert api token: %w", err) + } + return &APIToken{ + ID: publicID, Prefix: publicID, Name: name, Issuer: issuer, Subject: subject, + Scopes: scopes, CreatedAt: now, ExpiresAt: expiresAt.UTC(), + }, plaintext, nil +} + +// CreatePersonalAPIToken is a handler-friendly form of CreateAPIToken that +// takes ownership from the authenticated session. +func (s *SessionStore) CreatePersonalAPIToken(ctx context.Context, sess *Session, name string, scopes []string, expiresAt time.Time) (*APIToken, string, error) { + if sess == nil { + return nil, "", errors.New("authenticated session is required") + } + return s.CreateAPIToken(ctx, APITokenCreateRequest{ + Name: name, Issuer: sess.Issuer, Subject: sess.Subject, + Scopes: scopes, ExpiresAt: expiresAt, + }) +} + +// ListAPITokens lists all tokens owned by issuer+subject. It never returns +// token plaintext or hashes. +func (s *SessionStore) ListAPITokens(ctx context.Context, issuer, subject string) ([]APIToken, error) { + rows, err := s.db.QueryContext(ctx, ` + SELECT id, name, issuer, subject, scopes, created_at, expires_at, revoked_at, last_used_at + FROM api_tokens + WHERE issuer = ? AND subject = ? + ORDER BY created_at DESC, id DESC + `, strings.TrimSpace(issuer), strings.TrimSpace(subject)) + if err != nil { + return nil, fmt.Errorf("list api tokens: %w", err) + } + defer rows.Close() + result := make([]APIToken, 0) + for rows.Next() { + var token APIToken + var scopesJSON string + var revokedAt, lastUsedAt sql.NullTime + if err := rows.Scan(&token.ID, &token.Name, &token.Issuer, &token.Subject, &scopesJSON, + &token.CreatedAt, &token.ExpiresAt, &revokedAt, &lastUsedAt); err != nil { + return nil, fmt.Errorf("scan api token: %w", err) + } + token.Prefix = token.ID + var err error + token.Scopes, err = unmarshalScopes(scopesJSON) + if err != nil { + return nil, fmt.Errorf("decode api token scopes: %w", err) + } + if revokedAt.Valid { + v := revokedAt.Time + token.RevokedAt = &v + } + if lastUsedAt.Valid { + v := lastUsedAt.Time + token.LastUsedAt = &v + } + result = append(result, token) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate api tokens: %w", err) + } + return result, nil +} + +// ListPersonalAPITokens lists only tokens owned by the authenticated session. +func (s *SessionStore) ListPersonalAPITokens(ctx context.Context, sess *Session) ([]APIToken, error) { + if sess == nil { + return nil, errors.New("authenticated session is required") + } + return s.ListAPITokens(ctx, sess.Issuer, sess.Subject) +} + +// RevokeAPIToken revokes a token owned by issuer+subject. The id may be the +// public token id or the complete token plaintext. It is idempotent for an +// already-revoked token owned by the caller. +func (s *SessionStore) RevokeAPIToken(ctx context.Context, issuer, subject, id string) error { + id = apiTokenPublicID(id) + if id == "" { + return ErrAPITokenNotFound + } + result, err := s.db.ExecContext(ctx, ` + UPDATE api_tokens SET revoked_at = COALESCE(revoked_at, ?) + WHERE id = ? AND issuer = ? AND subject = ? + `, time.Now().UTC(), id, strings.TrimSpace(issuer), strings.TrimSpace(subject)) + if err != nil { + return fmt.Errorf("revoke api token: %w", err) + } + count, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("revoke api token result: %w", err) + } + if count == 0 { + return ErrAPITokenNotFound + } + return nil +} + +// RevokePersonalAPIToken revokes only a token owned by the authenticated +// session. +func (s *SessionStore) RevokePersonalAPIToken(ctx context.Context, sess *Session, id string) error { + if sess == nil { + return errors.New("authenticated session is required") + } + return s.RevokeAPIToken(ctx, sess.Issuer, sess.Subject, id) +} + +// VerifyAPIToken verifies an opaque personal token and records its use. The +// lookup first uses the public id, then compares HMAC values in constant time. +func (s *SessionStore) VerifyAPIToken(ctx context.Context, plaintext string) (*Session, error) { + publicID := apiTokenPublicID(plaintext) + if publicID == "" || len(s.secret) == 0 { + return nil, ErrAPITokenNotFound + } + var id, name, issuer, subject, scopesJSON, storedHash string + var createdAt, expiresAt time.Time + err := s.db.QueryRowContext(ctx, ` + SELECT id, name, issuer, subject, scopes, token_hash, created_at, expires_at + FROM api_tokens WHERE id = ? + `, publicID).Scan(&id, &name, &issuer, &subject, &scopesJSON, &storedHash, &createdAt, &expiresAt) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrAPITokenNotFound + } + if err != nil { + return nil, fmt.Errorf("select api token: %w", err) + } + expected := s.apiTokenHash(plaintext) + if !hmac.Equal([]byte(storedHash), []byte(expected)) { + return nil, ErrAPITokenNotFound + } + now := time.Now().UTC() + if !now.Before(expiresAt) { + return nil, ErrAPITokenExpired + } + var revokedAt sql.NullTime + if err := s.db.QueryRowContext(ctx, `SELECT revoked_at FROM api_tokens WHERE id = ?`, id).Scan(&revokedAt); err != nil { + return nil, fmt.Errorf("check api token revocation: %w", err) + } + if revokedAt.Valid { + return nil, ErrAPITokenRevoked + } + scopes, err := unmarshalScopes(scopesJSON) + if err != nil { + return nil, fmt.Errorf("decode api token scopes: %w", err) + } + result, err := s.db.ExecContext(ctx, `UPDATE api_tokens SET last_used_at = ? WHERE id = ? AND revoked_at IS NULL`, now, id) + if err != nil { + return nil, fmt.Errorf("record api token use: %w", err) + } + if changed, err := result.RowsAffected(); err != nil { + return nil, fmt.Errorf("record api token use result: %w", err) + } else if changed == 0 { + return nil, ErrAPITokenRevoked + } + return &Session{ + ID: "api-token:" + id, Name: name, Email: subject, + Issuer: issuer, Subject: subject, Scopes: scopes, + CreatedAt: createdAt, ExpiresAt: expiresAt, + AuthMethod: APITokenAuthMethod, + }, nil +} + +// APITokenVerifier adapts the persistent verifier to BearerTokenVerifier. +type APITokenVerifier struct{ store *SessionStore } + +func NewAPITokenVerifier(store *SessionStore) *APITokenVerifier { + return &APITokenVerifier{store: store} +} + +func (v *APITokenVerifier) Verify(token string) (*Session, error) { + if v == nil || v.store == nil { + return nil, ErrAPITokenNotFound + } + return v.store.VerifyAPIToken(context.Background(), token) +} + +func (v *APITokenVerifier) VerifyWithRequest(r *http.Request, token string) (*Session, error) { + if v == nil || v.store == nil { + return nil, ErrAPITokenNotFound + } + return v.store.VerifyAPIToken(r.Context(), token) +} + +func (s *SessionStore) apiTokenHash(token string) string { + h := hmac.New(sha256.New, s.secret) + _, _ = h.Write([]byte(token)) + return hex.EncodeToString(h.Sum(nil)) +} + +func randomTokenPublicID() (string, error) { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + return "", fmt.Errorf("generate token id: %w", err) + } + return apiTokenPrefix + hex.EncodeToString(b), nil +} + +func apiTokenPublicID(token string) string { + token = strings.TrimSpace(token) + if strings.HasPrefix(token, apiTokenPrefix) && !strings.Contains(token, ".") { + return token + } + parts := strings.SplitN(token, ".", 2) + if len(parts) != 2 || !strings.HasPrefix(parts[0], apiTokenPrefix) || len(parts[1]) < 43 { + return "" + } + return parts[0] +} + +func validateAPITokenScopes(scopes []string) ([]string, error) { + result := normalizeScopes(scopes) + if len(result) == 0 { + return nil, errors.New("at least one token scope is required") + } + for _, scope := range result { + if scope != "cxdb:read" && scope != "cxdb:write" { + return nil, fmt.Errorf("invalid token scope %q", scope) + } + } + return result, nil +} + +func normalizeScopes(scopes []string) []string { + seen := make(map[string]bool, len(scopes)) + result := make([]string, 0, len(scopes)) + for _, scope := range scopes { + scope = strings.TrimSpace(scope) + if scope != "" && !seen[scope] { + seen[scope] = true + result = append(result, scope) + } + } + return result +} + +func marshalScopes(scopes []string) (string, error) { + // JSON is intentionally used rather than a delimiter, so scope values can + // be extended in future migrations without ambiguous parsing. + data, err := json.Marshal(normalizeScopes(scopes)) + return string(data), err +} + +func unmarshalScopes(raw string) ([]string, error) { + var scopes []string + if err := json.Unmarshal([]byte(raw), &scopes); err != nil { + return nil, err + } + return normalizeScopes(scopes), nil +} diff --git a/gateway/pkg/auth/api_token_test.go b/gateway/pkg/auth/api_token_test.go new file mode 100644 index 0000000..1666870 --- /dev/null +++ b/gateway/pkg/auth/api_token_test.go @@ -0,0 +1,100 @@ +// Copyright 2025 StrongDM Inc +// SPDX-License-Identifier: Apache-2.0 + +package auth + +import ( + "context" + "database/sql" + "errors" + "path/filepath" + "strings" + "testing" + "time" +) + +func testAPITokenStore(t *testing.T) *SessionStore { + t.Helper() + store, err := NewSessionStore(filepath.Join(t.TempDir(), "auth.sqlite"), "session", time.Hour, "", false, "test-hmac-secret") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = store.Close() }) + return store +} + +func TestAPITokenCreateReturnsPlaintextOnceAndStoresOnlyHash(t *testing.T) { + store := testAPITokenStore(t) + meta, plaintext, err := store.CreateAPIToken(context.Background(), APITokenCreateRequest{ + Name: "laptop", Issuer: "https://issuer.example", Subject: "user-1", + Scopes: []string{"cxdb:write", "cxdb:read"}, ExpiresAt: time.Now().Add(time.Hour), + }) + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(plaintext, "cxpat_") || len(strings.SplitN(plaintext, ".", 2)[1]) < 43 { + t.Fatalf("token does not have the required opaque format: %q", plaintext) + } + var hash string + if err := store.db.QueryRow(`SELECT token_hash FROM api_tokens WHERE id = ?`, meta.ID).Scan(&hash); err != nil { + t.Fatal(err) + } + if hash == plaintext || hash == "" || len(hash) != 64 { + t.Fatalf("database contains token plaintext or invalid hash") + } + var plaintextColumn string + err = store.db.QueryRow(`SELECT COALESCE(token, '') FROM api_tokens WHERE id = ?`, meta.ID).Scan(&plaintextColumn) + if err == nil { + t.Fatalf("api_tokens unexpectedly has a plaintext token column") + } +} + +func TestAPITokenOwnershipExpiryRevocationLastUseAndScopes(t *testing.T) { + store := testAPITokenStore(t) + ctx := context.Background() + meta, plaintext, err := store.CreateAPIToken(ctx, APITokenCreateRequest{ + Name: "ci", Issuer: "issuer", Subject: "alice", Scopes: []string{"cxdb:read"}, ExpiresAt: time.Now().Add(time.Hour), + }) + if err != nil { + t.Fatal(err) + } + if _, err := store.ListAPITokens(ctx, "issuer", "bob"); err != nil { + t.Fatal(err) + } + if err := store.RevokeAPIToken(ctx, "issuer", "bob", meta.ID); !errors.Is(err, ErrAPITokenNotFound) { + t.Fatalf("wrong-owner revoke error = %v", err) + } + sess, err := store.VerifyAPIToken(ctx, plaintext) + if err != nil { + t.Fatal(err) + } + if sess.Subject != "alice" || sess.Issuer != "issuer" || !sess.HasScope("cxdb:read") || sess.IsAPIToken() == false { + t.Fatalf("verified identity/scopes not preserved: %+v", sess) + } + var used sql.NullTime + if err := store.db.QueryRow(`SELECT last_used_at FROM api_tokens WHERE id = ?`, meta.ID).Scan(&used); err != nil { + t.Fatal(err) + } + if !used.Valid { + t.Fatal("last_used_at was not recorded") + } + if err := store.RevokeAPIToken(ctx, "issuer", "alice", meta.ID); err != nil { + t.Fatal(err) + } + if _, err := store.VerifyAPIToken(ctx, plaintext); !errors.Is(err, ErrAPITokenRevoked) { + t.Fatal("revoked token was accepted") + } + + _, expired, err := store.CreateAPIToken(ctx, APITokenCreateRequest{ + Name: "old", Issuer: "issuer", Subject: "alice", Scopes: []string{"cxdb:write"}, ExpiresAt: time.Now().Add(-time.Minute), + }) + if err != nil { + t.Fatal(err) + } + if _, err := store.VerifyAPIToken(ctx, expired); err == nil || !strings.Contains(err.Error(), "expired") { + t.Fatalf("expired token error = %v", err) + } + if _, _, err := store.CreateAPIToken(ctx, APITokenCreateRequest{Name: "bad", Issuer: "issuer", Subject: "alice", Scopes: []string{"admin"}}); err == nil { + t.Fatal("invalid scope accepted") + } +} diff --git a/gateway/pkg/auth/aws_iam.go b/gateway/pkg/auth/aws_iam.go index 1d9d586..8fd1176 100644 --- a/gateway/pkg/auth/aws_iam.go +++ b/gateway/pkg/auth/aws_iam.go @@ -9,6 +9,7 @@ import ( "io" "log" "net/http" + "net/url" "os" "path/filepath" "regexp" @@ -29,6 +30,7 @@ type AWSTokenExchanger struct { issuer string audience string debug bool + httpClient *http.Client } // NewAWSTokenExchanger creates a new AWS IAM token exchanger. @@ -53,6 +55,12 @@ func NewAWSTokenExchanger(allowedRoles []string, tokenTTL time.Duration, signing issuer: issuer, audience: issuer, debug: strings.Contains(os.Getenv("DEBUG"), "auth") || strings.Contains(os.Getenv("DEBUG"), "all"), + httpClient: &http.Client{ + Timeout: 10 * time.Second, + CheckRedirect: func(_ *http.Request, _ []*http.Request) error { + return http.ErrUseLastResponse + }, + }, }, nil } @@ -111,7 +119,7 @@ func (e *AWSTokenExchanger) TokenHandler(w http.ResponseWriter, r *http.Request) } w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(TokenExchangeResponse{ + json.NewEncoder(w).Encode(TokenExchangeResponse{ Token: token, ExpiresAt: expiresAt, TokenType: "Bearer", @@ -143,11 +151,15 @@ func (e *AWSTokenExchanger) Verify(tokenString string) (*Session, error) { roleStr, _ := role.(string) return &Session{ - ID: fmt.Sprintf("aws:%s", token.Subject()), - Email: fmt.Sprintf("%s@aws.iam", roleStr), - Name: fmt.Sprintf("AWS IAM: %s", token.Subject()), - CreatedAt: token.IssuedAt(), - ExpiresAt: token.Expiration(), + ID: fmt.Sprintf("aws:%s", token.Subject()), + Email: fmt.Sprintf("%s@aws.iam", roleStr), + Name: fmt.Sprintf("AWS IAM: %s", token.Subject()), + Scopes: []string{"cxdb:read", "cxdb:write"}, + CreatedAt: token.IssuedAt(), + ExpiresAt: token.Expiration(), + AuthMethod: "aws_sts", + Issuer: e.issuer, + Subject: token.Subject(), }, nil } @@ -160,16 +172,39 @@ type STSIdentity struct { // verifyPresignedURL executes a presigned GetCallerIdentity request. func (e *AWSTokenExchanger) verifyPresignedURL(presignedURL string) (*STSIdentity, error) { + parsed, err := url.Parse(presignedURL) + if err != nil { + return nil, fmt.Errorf("parse presigned URL: %w", err) + } + if parsed.Scheme != "https" || parsed.User != nil || parsed.Port() != "" { + return nil, fmt.Errorf("presigned URL must use HTTPS without userinfo or a custom port") + } + host := strings.ToLower(parsed.Hostname()) + if host != "sts.amazonaws.com" && !regexp.MustCompile(`^sts\.[a-z0-9-]+\.amazonaws\.com$`).MatchString(host) { + return nil, fmt.Errorf("presigned URL host is not an AWS STS endpoint") + } + query := parsed.Query() + action := query.Get("Action") + if action == "" { + action = query.Get("action") + } + if action != "GetCallerIdentity" { + return nil, fmt.Errorf("presigned URL action must be GetCallerIdentity") + } + if query.Get("X-Amz-Signature") == "" && query.Get("x-amz-signature") == "" { + return nil, fmt.Errorf("presigned URL is missing a signature") + } + req, err := http.NewRequest(http.MethodGet, presignedURL, nil) if err != nil { return nil, fmt.Errorf("create request: %w", err) } - resp, err := http.DefaultClient.Do(req) + resp, err := e.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("execute request: %w", err) } - defer func() { _ = resp.Body.Close() }() + defer resp.Body.Close() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) diff --git a/gateway/pkg/auth/aws_iam_test.go b/gateway/pkg/auth/aws_iam_test.go new file mode 100644 index 0000000..baea34c --- /dev/null +++ b/gateway/pkg/auth/aws_iam_test.go @@ -0,0 +1,38 @@ +// Copyright 2025 StrongDM Inc +// SPDX-License-Identifier: Apache-2.0 + +package auth + +import ( + "net/http" + "testing" + "time" +) + +func TestAWSPresignedURLRejectsUnsafeTargetsBeforeRequest(t *testing.T) { + t.Parallel() + exchanger, err := NewAWSTokenExchanger([]string{"*"}, time.Hour, []byte("test-signing-key"), "cxdb.example") + if err != nil { + t.Fatal(err) + } + exchanger.httpClient.Transport = roundTripFunc(func(*http.Request) (*http.Response, error) { + t.Fatal("unsafe target reached the network") + return nil, nil + }) + + for _, raw := range []string{ + "http://sts.amazonaws.com/?Action=GetCallerIdentity&X-Amz-Signature=x", + "https://127.0.0.1/?Action=GetCallerIdentity&X-Amz-Signature=x", + "https://sts.amazonaws.com:8443/?Action=GetCallerIdentity&X-Amz-Signature=x", + "https://sts.amazonaws.com/?Action=DeleteIdentity&X-Amz-Signature=x", + "https://sts.amazonaws.com/?Action=GetCallerIdentity", + } { + if _, err := exchanger.verifyPresignedURL(raw); err == nil { + t.Errorf("verifyPresignedURL(%q) succeeded", raw) + } + } +} + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } diff --git a/gateway/pkg/auth/browser_oidc.go b/gateway/pkg/auth/browser_oidc.go new file mode 100644 index 0000000..90e1a06 --- /dev/null +++ b/gateway/pkg/auth/browser_oidc.go @@ -0,0 +1,223 @@ +// Copyright 2025 StrongDM Inc +// SPDX-License-Identifier: Apache-2.0 + +package auth + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "strings" + "time" + + "github.com/coreos/go-oidc/v3/oidc" + "golang.org/x/oauth2" +) + +const browserOIDCTransactionCookie = "cxdb_oidc_transaction" + +// BrowserOIDC implements a verified OIDC authorization-code flow for browser users. +type BrowserOIDC struct { + issuer string + allowedDomains map[string]struct{} + config oauth2.Config + verifier *oidc.IDTokenVerifier + sessions *SessionStore +} + +type browserOIDCTransaction struct { + State string `json:"state"` + Nonce string `json:"nonce"` + CodeVerifier string `json:"code_verifier"` + ReturnTo string `json:"return_to,omitempty"` + ExpiresAt int64 `json:"expires_at"` +} + +// NewBrowserOIDC performs OIDC discovery and pins token verification to issuer and client ID. +func NewBrowserOIDC(ctx context.Context, issuer, clientID, clientSecret, publicBaseURL string, allowedDomains []string, sessions *SessionStore) (*BrowserOIDC, error) { + issuer = strings.TrimSuffix(strings.TrimSpace(issuer), "/") + issuerURL, err := url.Parse(issuer) + if err != nil || issuerURL.Scheme != "https" || issuerURL.Host == "" || issuerURL.User != nil { + return nil, errors.New("OIDC issuer must be an HTTPS origin") + } + provider, err := oidc.NewProvider(ctx, issuer) + if err != nil { + return nil, fmt.Errorf("discover OIDC provider: %w", err) + } + domains := make(map[string]struct{}, len(allowedDomains)) + for _, domain := range allowedDomains { + domain = strings.ToLower(strings.TrimSpace(strings.TrimPrefix(domain, "@"))) + if domain != "" { + domains[domain] = struct{}{} + } + } + if len(domains) == 0 { + return nil, errors.New("at least one OIDC email domain is required") + } + return &BrowserOIDC{ + issuer: issuer, + allowedDomains: domains, + config: oauth2.Config{ + ClientID: clientID, + ClientSecret: clientSecret, + Endpoint: provider.Endpoint(), + RedirectURL: strings.TrimSuffix(publicBaseURL, "/") + "/auth/oidc/callback", + Scopes: []string{oidc.ScopeOpenID, "profile", "email"}, + }, + verifier: provider.Verifier(&oidc.Config{ClientID: clientID}), + sessions: sessions, + }, nil +} + +// LoginHandler starts the OIDC flow with state, nonce, and PKCE S256. +func (o *BrowserOIDC) LoginHandler(w http.ResponseWriter, r *http.Request) { + state, err := randomState() + if err != nil { + http.Error(w, "unable to start login", http.StatusInternalServerError) + return + } + nonce, err := randomState() + if err != nil { + http.Error(w, "unable to start login", http.StatusInternalServerError) + return + } + verifier := oauth2.GenerateVerifier() + tx := browserOIDCTransaction{ + State: state, + Nonce: nonce, + CodeVerifier: verifier, + ReturnTo: safeLocalReturn(r.URL.Query().Get("return_to")), + ExpiresAt: time.Now().Add(10 * time.Minute).Unix(), + } + encoded, err := json.Marshal(tx) + if err != nil { + http.Error(w, "unable to start login", http.StatusInternalServerError) + return + } + http.SetCookie(w, &http.Cookie{ + Name: browserOIDCTransactionCookie, + Value: o.sessions.sign(base64.RawURLEncoding.EncodeToString(encoded)), + Path: "/", + MaxAge: 600, + HttpOnly: true, + Secure: o.sessions.Secure(), + SameSite: http.SameSiteLaxMode, + }) + authURL := o.config.AuthCodeURL(state, + oauth2.AccessTypeOnline, + oauth2.S256ChallengeOption(verifier), + oauth2.SetAuthURLParam("nonce", nonce), + ) + http.Redirect(w, r, authURL, http.StatusFound) +} + +// CallbackHandler verifies the authorization response and creates a browser session. +func (o *BrowserOIDC) CallbackHandler(w http.ResponseWriter, r *http.Request) { + tx, err := o.transaction(r) + o.clearTransaction(w) + if err != nil || !subtleEqual(tx.State, r.URL.Query().Get("state")) { + http.Redirect(w, r, "/login?error=state", http.StatusFound) + return + } + if r.URL.Query().Get("error") != "" { + http.Redirect(w, r, "/login?error=access_denied", http.StatusFound) + return + } + token, err := o.config.Exchange(r.Context(), r.URL.Query().Get("code"), oauth2.VerifierOption(tx.CodeVerifier)) + if err != nil { + http.Redirect(w, r, "/login?error=exchange", http.StatusFound) + return + } + rawIDToken, ok := token.Extra("id_token").(string) + if !ok || rawIDToken == "" { + http.Redirect(w, r, "/login?error=id_token", http.StatusFound) + return + } + idToken, err := o.verifier.Verify(r.Context(), rawIDToken) + if err != nil { + http.Redirect(w, r, "/login?error=id_token", http.StatusFound) + return + } + var claims struct { + Subject string `json:"sub"` + Nonce string `json:"nonce"` + Email string `json:"email"` + EmailVerified bool `json:"email_verified"` + Name string `json:"name"` + Picture string `json:"picture"` + } + if err := idToken.Claims(&claims); err != nil || claims.Subject == "" || claims.Email == "" || !claims.EmailVerified || !subtleEqual(claims.Nonce, tx.Nonce) || !o.emailAllowed(claims.Email) { + http.Redirect(w, r, "/login?error=unauthorized", http.StatusFound) + return + } + if claims.Name == "" { + claims.Name = claims.Email + } + sessionID, err := o.sessions.CreateForIdentity(r.Context(), o.issuer, claims.Subject, strings.ToLower(claims.Email), claims.Name, claims.Picture, "oidc", []string{"cxdb:read", "cxdb:write"}) + if err != nil { + http.Error(w, "unable to create session", http.StatusInternalServerError) + return + } + o.sessions.SetCookie(w, sessionID) + destination := tx.ReturnTo + if destination == "" { + destination = "/" + } + http.Redirect(w, r, destination, http.StatusFound) +} + +func (o *BrowserOIDC) transaction(r *http.Request) (browserOIDCTransaction, error) { + var tx browserOIDCTransaction + cookie, err := r.Cookie(browserOIDCTransactionCookie) + if err != nil { + return tx, err + } + value, ok := o.sessions.verify(cookie.Value) + if !ok { + return tx, errors.New("invalid transaction signature") + } + encoded, err := base64.RawURLEncoding.DecodeString(value) + if err != nil { + return tx, err + } + if err := json.Unmarshal(encoded, &tx); err != nil { + return tx, err + } + if time.Now().Unix() > tx.ExpiresAt || tx.State == "" || tx.Nonce == "" || tx.CodeVerifier == "" { + return tx, errors.New("expired OIDC transaction") + } + return tx, nil +} + +func (o *BrowserOIDC) clearTransaction(w http.ResponseWriter) { + http.SetCookie(w, &http.Cookie{Name: browserOIDCTransactionCookie, Path: "/", MaxAge: -1, HttpOnly: true, Secure: o.sessions.Secure(), SameSite: http.SameSiteLaxMode}) +} + +func (o *BrowserOIDC) emailAllowed(email string) bool { + _, domain, ok := strings.Cut(strings.ToLower(strings.TrimSpace(email)), "@") + if !ok || domain == "" { + return false + } + _, ok = o.allowedDomains[domain] + return ok +} + +func safeLocalReturn(raw string) string { + if raw == "" || !strings.HasPrefix(raw, "/") || strings.HasPrefix(raw, "//") || strings.Contains(raw, "\\") || strings.Contains(strings.ToLower(raw), "%5c") { + return "" + } + u, err := url.Parse(raw) + if err != nil || u.IsAbs() || u.Host != "" { + return "" + } + // Browsers can decode escaped separators before navigation. Reject paths + // that become an external authority or contain a decoded backslash. + if strings.Contains(u.Path, "\\") || strings.HasPrefix(u.Path, "//") { + return "" + } + return u.RequestURI() +} diff --git a/gateway/pkg/auth/browser_oidc_test.go b/gateway/pkg/auth/browser_oidc_test.go new file mode 100644 index 0000000..dc7112f --- /dev/null +++ b/gateway/pkg/auth/browser_oidc_test.go @@ -0,0 +1,172 @@ +// Copyright 2025 StrongDM Inc +// SPDX-License-Identifier: Apache-2.0 + +package auth + +import ( + "context" + "crypto" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/coreos/go-oidc/v3/oidc" + "github.com/lestrrat-go/jwx/v2/jwk" +) + +func signRS256JWT(t *testing.T, privateKey *rsa.PrivateKey, kid string, claims map[string]any) string { + t.Helper() + headerJSON, err := json.Marshal(map[string]any{"alg": "RS256", "typ": "JWT", "kid": kid}) + if err != nil { + t.Fatal(err) + } + claimsJSON, err := json.Marshal(claims) + if err != nil { + t.Fatal(err) + } + b64 := base64.RawURLEncoding + input := b64.EncodeToString(headerJSON) + "." + b64.EncodeToString(claimsJSON) + sum := sha256.Sum256([]byte(input)) + signature, err := rsa.SignPKCS1v15(rand.Reader, privateKey, crypto.SHA256, sum[:]) + if err != nil { + t.Fatal(err) + } + return input + "." + b64.EncodeToString(signature) +} + +func TestBrowserOIDCVerifiedCodeFlowAndNonce(t *testing.T) { + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + key, err := jwk.FromRaw(&privateKey.PublicKey) + if err != nil { + t.Fatal(err) + } + const kid = "browser-test-key" + _ = key.Set(jwk.KeyIDKey, kid) + _ = key.Set(jwk.KeyUsageKey, "sig") + _ = key.Set(jwk.AlgorithmKey, "RS256") + set := jwk.NewSet() + set.AddKey(key) + jwks, _ := json.Marshal(set) + + var issuer string + var tokenNonce string + mux := http.NewServeMux() + mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{ + "issuer": issuer, "authorization_endpoint": issuer + "/authorize", "token_endpoint": issuer + "/token", "jwks_uri": issuer + "/jwks", + "response_types_supported": []string{"code"}, "subject_types_supported": []string{"public"}, "id_token_signing_alg_values_supported": []string{"RS256"}, + }) + }) + mux.HandleFunc("/jwks", func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write(jwks) }) + mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil || r.Form.Get("code_verifier") == "" { + http.Error(w, "PKCE required", http.StatusBadRequest) + return + } + now := time.Now().Unix() + idToken := signRS256JWT(t, privateKey, kid, map[string]any{ + "iss": issuer, "sub": "user-123", "aud": "cxdb-client", "exp": now + 300, "iat": now, + "nonce": tokenNonce, "email": "alice@example.com", "email_verified": true, "name": "Alice", + }) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"access_token": "upstream", "token_type": "Bearer", "expires_in": 300, "id_token": idToken}) + }) + provider := httptest.NewTLSServer(mux) + defer provider.Close() + issuer = provider.URL + + store, err := NewSessionStore(filepath.Join(t.TempDir(), "sessions.sqlite"), "session", time.Hour, "", true, "test-secret") + if err != nil { + t.Fatal(err) + } + defer store.Close() + discoveryContext := oidc.ClientContext(context.Background(), provider.Client()) + browser, err := NewBrowserOIDC(discoveryContext, issuer, "cxdb-client", "client-secret", "https://cxdb.example", []string{"example.com"}, store) + if err != nil { + t.Fatal(err) + } + + loginRequest := httptest.NewRequest(http.MethodGet, "/auth/oidc/login?return_to=%2Foauth%2Fauthorize%3Fclient_id%3Dtest", nil) + loginResponse := httptest.NewRecorder() + browser.LoginHandler(loginResponse, loginRequest) + if loginResponse.Code != http.StatusFound || !strings.Contains(loginResponse.Header().Get("Location"), "code_challenge_method=S256") { + t.Fatalf("login response = %d %s", loginResponse.Code, loginResponse.Header().Get("Location")) + } + transactionCookie := loginResponse.Result().Cookies()[0] + txRequest := httptest.NewRequest(http.MethodGet, "/", nil) + txRequest.AddCookie(transactionCookie) + transaction, err := browser.transaction(txRequest) + if err != nil { + t.Fatal(err) + } + tokenNonce = transaction.Nonce + + callbackRequest := httptest.NewRequest(http.MethodGet, "/auth/oidc/callback?state="+url.QueryEscape(transaction.State)+"&code=test-code", nil) + callbackRequest = callbackRequest.WithContext(oidc.ClientContext(callbackRequest.Context(), provider.Client())) + callbackRequest.AddCookie(transactionCookie) + callbackResponse := httptest.NewRecorder() + browser.CallbackHandler(callbackResponse, callbackRequest) + if callbackResponse.Code != http.StatusFound || callbackResponse.Header().Get("Location") != "/oauth/authorize?client_id=test" { + t.Fatalf("callback response = %d location=%q body=%s", callbackResponse.Code, callbackResponse.Header().Get("Location"), callbackResponse.Body.String()) + } + var sessionCookie *http.Cookie + for _, cookie := range callbackResponse.Result().Cookies() { + if cookie.Name == "session" { + sessionCookie = cookie + } + } + if sessionCookie == nil { + t.Fatal("verified OIDC flow did not create a session") + } + sessionRequest := httptest.NewRequest(http.MethodGet, "/", nil) + sessionRequest.AddCookie(sessionCookie) + session, err := store.SessionFromRequest(context.Background(), sessionRequest) + if err != nil || session == nil || session.Issuer != issuer || session.Subject != "user-123" || !session.HasScope("cxdb:write") { + t.Fatalf("session = %+v, err=%v", session, err) + } + + badLoginResponse := httptest.NewRecorder() + browser.LoginHandler(badLoginResponse, httptest.NewRequest(http.MethodGet, "/auth/oidc/login", nil)) + badCookie := badLoginResponse.Result().Cookies()[0] + badTxRequest := httptest.NewRequest(http.MethodGet, "/", nil) + badTxRequest.AddCookie(badCookie) + badTransaction, err := browser.transaction(badTxRequest) + if err != nil { + t.Fatal(err) + } + tokenNonce = "wrong-nonce" + badCallback := httptest.NewRequest(http.MethodGet, "/auth/oidc/callback?state="+url.QueryEscape(badTransaction.State)+"&code=test-code", nil) + badCallback = badCallback.WithContext(oidc.ClientContext(badCallback.Context(), provider.Client())) + badCallback.AddCookie(badCookie) + badCallbackResponse := httptest.NewRecorder() + browser.CallbackHandler(badCallbackResponse, badCallback) + if badCallbackResponse.Header().Get("Location") != "/login?error=unauthorized" { + t.Fatalf("nonce mismatch redirect = %q", badCallbackResponse.Header().Get("Location")) + } +} + +func TestSafeLocalReturnRejectsBrowserNormalizedExternalPaths(t *testing.T) { + for _, raw := range []string{ + `/%5cevil.com`, + `/%5Cevil.com`, + `/\evil.com`, + `/%2f%2fevil.com`, + `//evil.com`, + } { + if got := safeLocalReturn(raw); got != "" { + t.Errorf("safeLocalReturn(%q) = %q, want rejection", raw, got) + } + } +} diff --git a/gateway/pkg/auth/google.go b/gateway/pkg/auth/google.go index ea26e6a..a54e09e 100644 --- a/gateway/pkg/auth/google.go +++ b/gateway/pkg/auth/google.go @@ -11,7 +11,6 @@ import ( "errors" "fmt" "log" - "net" "net/http" "net/url" "strings" @@ -66,7 +65,10 @@ func (g *GoogleAuth) LoginHandler(w http.ResponseWriter, r *http.Request) { http.Error(w, "unable to create state", http.StatusInternalServerError) return } - g.setPostAuthRedirectCookie(w, r) + g.setPostAuthRedirectCookie(w) + if returnTo := safeLocalReturn(r.URL.Query().Get("return_to")); returnTo != "" { + http.SetCookie(w, &http.Cookie{Name: "post_auth_path", Value: g.sessions.sign(returnTo), Path: "/", MaxAge: int(g.stateMaxAge.Seconds()), HttpOnly: true, Secure: g.sessions.Secure(), SameSite: http.SameSiteLaxMode}) + } http.SetCookie(w, &http.Cookie{ Name: "oauth_state", Value: state, @@ -130,7 +132,11 @@ func (g *GoogleAuth) CallbackHandler(w http.ResponseWriter, r *http.Request) { name = email } - sessionID, err := g.sessions.Create(ctx, email, name, user.Picture) + subject := user.ID + if subject == "" { + subject = email + } + sessionID, err := g.sessions.CreateForIdentity(ctx, "https://accounts.google.com", subject, email, name, user.Picture, "google_oauth", []string{"cxdb:read", "cxdb:write"}) if err != nil { if g.sessions.Debug() { log.Printf("[auth] create session error: %v", err) @@ -158,6 +164,7 @@ func (g *GoogleAuth) LogoutHandler(w http.ResponseWriter, r *http.Request) { } type googleUser struct { + ID string `json:"id"` Email string `json:"email"` Name string `json:"name"` Picture string `json:"picture"` @@ -169,7 +176,7 @@ func (g *GoogleAuth) fetchUser(ctx context.Context, token *oauth2.Token) (google if err != nil { return googleUser{}, fmt.Errorf("userinfo request: %w", err) } - defer func() { _ = resp.Body.Close() }() + defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return googleUser{}, fmt.Errorf("userinfo status: %d", resp.StatusCode) } @@ -215,21 +222,14 @@ func (g *GoogleAuth) clearStateCookie(w http.ResponseWriter) { }) } -func (g *GoogleAuth) setPostAuthRedirectCookie(w http.ResponseWriter, r *http.Request) { - host := canonicalHost(r) - if host == "" { - return - } - scheme := "https" - if forwarded := strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")); forwarded != "" { - scheme = strings.ToLower(forwarded) - } else if r.TLS == nil { - scheme = "http" - } - if scheme != "https" && scheme != "http" { +func (g *GoogleAuth) setPostAuthRedirectCookie(w http.ResponseWriter) { + // Use the configured public URL. Host and scheme headers are client input + // at this trust boundary and must not select the post-login destination. + publicURL, err := url.Parse(strings.TrimSuffix(g.publicURL, "/")) + if err != nil || publicURL.Host == "" || (publicURL.Scheme != "https" && publicURL.Scheme != "http") { return } - base := scheme + "://" + host + base := publicURL.Scheme + "://" + publicURL.Host if !g.isAllowedRedirectBase(base) { return } @@ -246,6 +246,14 @@ func (g *GoogleAuth) setPostAuthRedirectCookie(w http.ResponseWriter, r *http.Re } func (g *GoogleAuth) postAuthRedirect(w http.ResponseWriter, r *http.Request) string { + if pathCookie, err := r.Cookie("post_auth_path"); err == nil { + g.clearPostAuthPathCookie(w) + if path, ok := g.sessions.verify(pathCookie.Value); ok { + if safe := safeLocalReturn(path); safe != "" { + return safe + } + } + } c, err := r.Cookie("post_auth_redirect") if err != nil { return "" @@ -268,6 +276,10 @@ func (g *GoogleAuth) postAuthRedirect(w http.ResponseWriter, r *http.Request) st return u.String() } +func (g *GoogleAuth) clearPostAuthPathCookie(w http.ResponseWriter) { + http.SetCookie(w, &http.Cookie{Name: "post_auth_path", Value: "", Path: "/", MaxAge: -1, HttpOnly: true, Secure: g.sessions.Secure(), SameSite: http.SameSiteLaxMode}) +} + func (g *GoogleAuth) clearPostAuthRedirectCookie(w http.ResponseWriter) { http.SetCookie(w, &http.Cookie{ Name: "post_auth_redirect", @@ -313,17 +325,6 @@ func (g *GoogleAuth) isAllowedRedirectBase(rawBaseURL string) bool { return false } -func canonicalHost(r *http.Request) string { - host := strings.ToLower(strings.TrimSpace(r.Host)) - if host == "" { - return "" - } - if h, _, err := net.SplitHostPort(host); err == nil { - return h - } - return host -} - // AttachUser stores the authenticated session on the request context. type contextKey string diff --git a/gateway/pkg/auth/google_security_test.go b/gateway/pkg/auth/google_security_test.go new file mode 100644 index 0000000..255c1ac --- /dev/null +++ b/gateway/pkg/auth/google_security_test.go @@ -0,0 +1,31 @@ +// Copyright 2025 StrongDM Inc +// SPDX-License-Identifier: Apache-2.0 + +package auth + +import ( + "net/http/httptest" + "path/filepath" + "testing" + "time" +) + +func TestGooglePostAuthRedirectUsesConfiguredPublicURL(t *testing.T) { + store, err := NewSessionStore(filepath.Join(t.TempDir(), "sessions.sqlite"), "session", time.Hour, "", true, "test-secret") + if err != nil { + t.Fatal(err) + } + defer store.Close() + + google := &GoogleAuth{ + publicURL: "https://cxdb.example", + allowedHosts: map[string]bool{"cxdb.example": true}, + sessions: store, + } + response := httptest.NewRecorder() + google.setPostAuthRedirectCookie(response) + cookies := response.Result().Cookies() + if len(cookies) != 1 || cookies[0].Value != "https://cxdb.example" { + t.Fatalf("redirect cookie = %+v", cookies) + } +} diff --git a/gateway/pkg/auth/k8s_oidc.go b/gateway/pkg/auth/k8s_oidc.go index cee0dc5..c4d2c18 100644 --- a/gateway/pkg/auth/k8s_oidc.go +++ b/gateway/pkg/auth/k8s_oidc.go @@ -9,6 +9,7 @@ import ( "fmt" "log" "net/http" + "net/url" "os" "strings" "sync" @@ -34,8 +35,12 @@ type K8sOIDCVerifier struct { // NewK8sOIDCVerifier creates a new verifier for K8s service account tokens. func NewK8sOIDCVerifier(issuerURL, audience string, allowedNamespaces []string) (*K8sOIDCVerifier, error) { + issuerURL = strings.TrimSuffix(issuerURL, "/") + if err := requireHTTPSURL(issuerURL, "issuer URL"); err != nil { + return nil, err + } v := &K8sOIDCVerifier{ - issuerURL: strings.TrimSuffix(issuerURL, "/"), + issuerURL: issuerURL, audience: audience, allowedNamespaces: make(map[string]bool), refreshInterval: 1 * time.Hour, @@ -111,11 +116,15 @@ func (v *K8sOIDCVerifier) Verify(tokenString string) (*Session, error) { } return &Session{ - ID: fmt.Sprintf("k8s:%s:%s", namespace, saName), - Email: fmt.Sprintf("%s/%s@k8s.local", namespace, saName), - Name: fmt.Sprintf("ServiceAccount: %s/%s", namespace, saName), - CreatedAt: token.IssuedAt(), - ExpiresAt: token.Expiration(), + ID: fmt.Sprintf("k8s:%s:%s", namespace, saName), + Email: fmt.Sprintf("%s/%s@k8s.local", namespace, saName), + Name: fmt.Sprintf("ServiceAccount: %s/%s", namespace, saName), + Scopes: []string{"cxdb:read", "cxdb:write"}, + CreatedAt: token.IssuedAt(), + ExpiresAt: token.Expiration(), + AuthMethod: "k8s_oidc", + Issuer: v.issuerURL, + Subject: sub, }, nil } @@ -132,7 +141,7 @@ func (v *K8sOIDCVerifier) refreshKeySet(ctx context.Context) error { if err != nil { return fmt.Errorf("fetch discovery: %w", err) } - defer func() { _ = resp.Body.Close() }() + defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return fmt.Errorf("discovery returned %d", resp.StatusCode) @@ -148,6 +157,9 @@ func (v *K8sOIDCVerifier) refreshKeySet(ctx context.Context) error { if discovery.JWKSURI == "" { return fmt.Errorf("no jwks_uri in discovery document") } + if err := requireHTTPSURL(discovery.JWKSURI, "JWKS URL"); err != nil { + return err + } // Fetch JWKS keySet, err := jwk.Fetch(ctx, discovery.JWKSURI) @@ -167,6 +179,14 @@ func (v *K8sOIDCVerifier) refreshKeySet(ctx context.Context) error { return nil } +func requireHTTPSURL(raw, label string) error { + parsed, err := url.Parse(raw) + if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil { + return fmt.Errorf("%s must be an absolute HTTPS URL", label) + } + return nil +} + // parseK8sSubject extracts namespace and service account name from a K8s subject. // Format: system:serviceaccount:: func parseK8sSubject(sub string) (namespace, name string, err error) { diff --git a/gateway/pkg/auth/k8s_oidc_test.go b/gateway/pkg/auth/k8s_oidc_test.go new file mode 100644 index 0000000..70cbad3 --- /dev/null +++ b/gateway/pkg/auth/k8s_oidc_test.go @@ -0,0 +1,24 @@ +// Copyright 2025 StrongDM Inc +// SPDX-License-Identifier: Apache-2.0 + +package auth + +import "testing" + +func TestRequireHTTPSURL(t *testing.T) { + t.Parallel() + + for _, raw := range []string{ + "http://issuer.example", + "https://user@issuer.example", + "//issuer.example", + "https:///missing-host", + } { + if err := requireHTTPSURL(raw, "test URL"); err == nil { + t.Errorf("requireHTTPSURL(%q) succeeded", raw) + } + } + if err := requireHTTPSURL("https://issuer.example/path", "test URL"); err != nil { + t.Fatalf("valid HTTPS URL failed: %v", err) + } +} diff --git a/gateway/pkg/auth/middleware.go b/gateway/pkg/auth/middleware.go index d14a695..b883805 100644 --- a/gateway/pkg/auth/middleware.go +++ b/gateway/pkg/auth/middleware.go @@ -18,6 +18,11 @@ type BearerTokenVerifier interface { Verify(token string) (*Session, error) } +// RequestTokenVerifier validates request-bound bearer token schemes like request-bound. +type RequestTokenVerifier interface { + VerifyWithRequest(r *http.Request, token string) (*Session, error) +} + // Debug auth bypass configuration (set via environment variables) // DEBUG_AUTH_TOKEN: Static token for Authorization header (e.g., "Bearer debug-token-123") // DEBUG_AUTH_ALLOWED_IPS: Comma-separated list of allowed IPs (e.g., "107.131.127.143,10.0.0.1") @@ -37,14 +42,13 @@ func parseAllowedIPs(s string) map[string]bool { return ips } +// getClientIP returns the TCP peer's address parsed from req.RemoteAddr. +// +// Sprint 019 / ADR-006: this function mirrors `proxy.observedPeerIP` and is +// the sole source of real-client-IP truth in the auth package. It NEVER reads +// `X-Forwarded-For` or `Forwarded` — those headers are attacker-controllable +// and would let a client spoof the debug-auth IP allowlist check. func getClientIP(r *http.Request) string { - // Check X-Forwarded-For header (set by ALB/proxy) - xff := r.Header.Get("X-Forwarded-For") - if xff != "" { - parts := strings.Split(xff, ",") - return strings.TrimSpace(parts[0]) - } - // Fall back to RemoteAddr host, _, err := net.SplitHostPort(r.RemoteAddr) if err != nil { return r.RemoteAddr @@ -74,11 +78,9 @@ func checkDebugAuth(r *http.Request) *Session { log.Printf("[auth] debug auth bypass granted for IP %s", clientIP) return &Session{ - ID: "debug-auth-session", - Email: "debug@localhost", - Name: "Debug Auth User", - CreatedAt: time.Now().UTC(), - ExpiresAt: time.Now().Add(24 * time.Hour).UTC(), + ID: "debug-auth-session", Email: "debug@localhost", Name: "Debug Auth User", + Scopes: []string{"cxdb:read", "cxdb:write"}, Issuer: "cxdb:debug", Subject: "debug", + CreatedAt: time.Now().UTC(), ExpiresAt: time.Now().Add(24 * time.Hour).UTC(), AuthMethod: "debug", } } @@ -90,8 +92,8 @@ type AuthMiddlewareOptions struct { } // RequireAuthForReads is an HTTP middleware that enforces a valid session for -// all GET requests except explicitly whitelisted paths. Non-GET methods (writes) -// are always allowed through without authentication. +// all GET requests except explicitly whitelisted paths. A separate middleware +// enforces authentication and scopes for non-GET methods. func RequireAuthForReads(store *SessionStore, next http.Handler, devBypass bool) http.Handler { return RequireAuthForReadsWithOptions(AuthMiddlewareOptions{ Store: store, @@ -106,7 +108,7 @@ func RequireAuthForReadsWithOptions(opts AuthMiddlewareOptions, next http.Handle return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { path := r.URL.Path - // Always allow non-GET methods (anonymous writes) + // Non-GET methods are checked by RequireAuthForWrites. if r.Method != http.MethodGet && r.Method != http.MethodHead { if store.Debug() { log.Printf("[auth] allowing write method %s %s", r.Method, path) @@ -124,26 +126,23 @@ func RequireAuthForReadsWithOptions(opts AuthMiddlewareOptions, next http.Handle return } - sess, _ := store.SessionFromRequest(r.Context(), r) - - // Try bearer token authentication (K8s OIDC, AWS IAM, etc.) - if sess == nil { - if token := extractBearerToken(r); token != "" { - for _, verifier := range opts.TokenVerifiers { - if s, err := verifier.Verify(token); err == nil && s != nil { - sess = s - if store.Debug() { - log.Printf("[auth] bearer token verified: %s", s.Email) - } - break - } + var sess *Session + if strings.TrimSpace(r.Header.Get("Authorization")) != "" { + sess = checkDebugAuth(r) + if sess == nil { + token := extractBearerToken(r) + if token == "" { + http.Error(w, "unsupported authorization scheme", http.StatusUnauthorized) + return + } + sess = verifyBearer(r, token, opts.TokenVerifiers) + if sess == nil { + http.Error(w, "invalid bearer token", http.StatusUnauthorized) + return } } - } - - // Check for debug auth bypass (static token from allowed IP) - if sess == nil { - sess = checkDebugAuth(r) + } else { + sess, _ = store.SessionFromRequest(r.Context(), r) } // In DEV_MODE, allow requests without a browser session by @@ -163,11 +162,9 @@ func RequireAuthForReadsWithOptions(opts AuthMiddlewareOptions, next http.Handle name = "Dev Mode User" } sess = &Session{ - ID: "dev-mode-session", - Email: email, - Name: name, - CreatedAt: time.Now().UTC(), - ExpiresAt: time.Now().Add(store.TTL()).UTC(), + ID: "dev-mode-session", Email: email, Name: name, + Scopes: []string{"cxdb:read", "cxdb:write"}, Issuer: "cxdb:dev", Subject: email, AuthMethod: "dev", + CreatedAt: time.Now().UTC(), ExpiresAt: time.Now().Add(store.TTL()).UTC(), } } @@ -186,6 +183,10 @@ func RequireAuthForReadsWithOptions(opts AuthMiddlewareOptions, next http.Handle http.Redirect(w, r, "/login", http.StatusFound) return } + if !sess.HasScope("cxdb:read") { + http.Error(w, "insufficient scope: cxdb:read required", http.StatusForbidden) + return + } if store.Debug() { log.Printf("[auth] authorized %s as %s", path, sess.Email) @@ -195,15 +196,99 @@ func RequireAuthForReadsWithOptions(opts AuthMiddlewareOptions, next http.Handle }) } +// RequireAuthForWrites enforces authentication on mutating HTTP methods. +// POST/PUT/PATCH/DELETE require a valid principal with the "cxdb:write" +// scope. GET/HEAD/OPTIONS pass through to the read middleware. +func RequireAuthForWrites(opts AuthMiddlewareOptions, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Read-only and preflight methods always pass through. + if r.Method == http.MethodGet || r.Method == http.MethodHead || r.Method == http.MethodOptions { + next.ServeHTTP(w, r) + return + } + + // Allow only explicitly public write endpoints. + if isPublicWritePath(r.URL.Path) { + next.ServeHTTP(w, r) + return + } + + var sess *Session + if strings.TrimSpace(r.Header.Get("Authorization")) != "" { + sess = checkDebugAuth(r) + if sess == nil { + token := extractBearerToken(r) + if token == "" { + http.Error(w, "unsupported authorization scheme", http.StatusUnauthorized) + return + } + sess = verifyBearer(r, token, opts.TokenVerifiers) + if sess == nil { + http.Error(w, "invalid bearer token", http.StatusUnauthorized) + return + } + } + } else { + sess, _ = opts.Store.SessionFromRequest(r.Context(), r) + } + + // Dev mode bypass. + if sess == nil && opts.DevBypass { + email := strings.TrimSpace(os.Getenv("DEV_EMAIL")) + if email == "" { + email = "dev@localhost" + } + name := strings.TrimSpace(os.Getenv("DEV_NAME")) + if name == "" { + name = "Dev Mode User" + } + sess = &Session{ + ID: "dev-mode-session", Email: email, Name: name, + Scopes: []string{"cxdb:read", "cxdb:write"}, Issuer: "cxdb:dev", Subject: email, AuthMethod: "dev", + CreatedAt: time.Now().UTC(), ExpiresAt: time.Now().Add(opts.Store.TTL()).UTC(), + } + } + + if sess == nil { + http.Error(w, "authentication required for write operations", http.StatusUnauthorized) + return + } + + if !sess.HasScope("cxdb:write") { + http.Error(w, "insufficient scope: cxdb:write required", http.StatusForbidden) + return + } + + ctx := WithUser(r.Context(), sess) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + // extractBearerToken extracts a bearer token from the Authorization header. func extractBearerToken(r *http.Request) string { auth := r.Header.Get("Authorization") if strings.HasPrefix(auth, "Bearer ") { - return strings.TrimPrefix(auth, "Bearer ") + return strings.TrimSpace(strings.TrimPrefix(auth, "Bearer ")) } return "" } +func verifyBearer(r *http.Request, token string, verifiers []BearerTokenVerifier) *Session { + for _, verifier := range verifiers { + var session *Session + var err error + if requestVerifier, ok := verifier.(RequestTokenVerifier); ok { + session, err = requestVerifier.VerifyWithRequest(r, token) + } else { + session, err = verifier.Verify(token) + } + if err == nil && session != nil { + return session + } + } + return nil +} + // isAPIRequest returns true if the request appears to be an API request // (should get 401 instead of redirect on auth failure). func isAPIRequest(r *http.Request) bool { @@ -223,6 +308,23 @@ func isAPIRequest(r *http.Request) bool { return false } +// isPublicWritePath returns true for endpoints that intentionally allow +// unauthenticated write methods (POST/PUT/PATCH/DELETE). +func isPublicWritePath(path string) bool { + path = strings.ToLower(path) + + // Health checks may use POST from some probes. + if path == "/healthz" || path == "/readyz" { + return true + } + + if path == "/auth/aws/token" || path == "/oauth/register" || path == "/oauth/token" || path == "/mcp" { + return true + } + + return false +} + func isPublicPath(path string) bool { path = strings.ToLower(path) @@ -230,32 +332,23 @@ func isPublicPath(path string) bool { if path == "/healthz" || path == "/readyz" || path == "/favicon.ico" || path == "/login" { return true } - // OAuth flow - if strings.HasPrefix(path, "/auth/") { + if isExactPublicAuthPath(path) || path == "/.well-known/oauth-authorization-server" || path == "/.well-known/oauth-protected-resource/mcp" || path == "/mcp" || path == "/openapi.json" || path == "/openapi.yaml" || path == "/llms.txt" { return true } // Static assets required to render the login page (Next.js static export) if strings.HasPrefix(path, "/_next/") || strings.HasPrefix(path, "/static/") { return true } - if strings.HasSuffix(path, ".css") || strings.HasSuffix(path, ".js") || strings.HasSuffix(path, ".ico") { - return true - } - // Context list endpoint (just IDs, no bodies) - allow anonymous reads - // Note: r.URL.Path doesn't include query string, so /v1/contexts?limit=5 has path="/v1/contexts" - // But NOT /v1/contexts/{id} or /v1/contexts/{id}/turns - those require auth - if path == "/v1/contexts" { - return true - } - // Metrics endpoint - needed for dashboard and monitoring systems - // Only exposes aggregate system stats, no sensitive user data - if path == "/v1/metrics" { - return true - } - // SSE events endpoint - notifications about context/turn changes - // No sensitive data, just IDs and timestamps - if path == "/v1/events" { + return false +} + +func isExactPublicAuthPath(path string) bool { + switch path { + case "/auth/login", "/auth/google/login", "/auth/google/callback", "/auth/google/logout", + "/auth/oidc/login", "/auth/oidc/callback", "/auth/aws/token", + "/oauth/authorize", "/oauth/register", "/oauth/token": return true + default: + return false } - return false } diff --git a/gateway/pkg/auth/middleware_test.go b/gateway/pkg/auth/middleware_test.go new file mode 100644 index 0000000..c4fb5d3 --- /dev/null +++ b/gateway/pkg/auth/middleware_test.go @@ -0,0 +1,167 @@ +// Copyright 2025 StrongDM Inc +// SPDX-License-Identifier: Apache-2.0 + +package auth + +import ( + "context" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + "time" +) + +func Test_isPublicPath(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + path string + want bool + }{ + {name: "healthz", path: "/healthz", want: true}, + {name: "oauth", path: "/auth/google/login", want: true}, + {name: "oidc", path: "/auth/oidc/login", want: true}, + {name: "mcp", path: "/mcp", want: true}, + {name: "oauth_metadata", path: "/.well-known/oauth-authorization-server", want: true}, + {name: "resource_metadata", path: "/.well-known/oauth-protected-resource/mcp", want: true}, + {name: "openapi", path: "/openapi.json", want: true}, + {name: "llms", path: "/llms.txt", want: true}, + {name: "unknown_json", path: "/private.json", want: false}, + {name: "unknown_txt", path: "/private.txt", want: false}, + {name: "unknown_auth", path: "/auth/private", want: false}, + {name: "contexts_list", path: "/v1/contexts", want: false}, + {name: "contexts_search", path: "/v1/contexts/search", want: false}, + {name: "context_detail", path: "/v1/contexts/abc123", want: false}, + {name: "metrics", path: "/v1/metrics", want: false}, + {name: "events", path: "/v1/events", want: false}, + {name: "api_javascript_suffix", path: "/v1/private.js", want: false}, + {name: "unknown_root_asset", path: "/private.js", want: false}, + {name: "next_static_asset", path: "/_next/static/app.js", want: true}, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + if got := isPublicPath(tc.path); got != tc.want { + t.Fatalf("isPublicPath(%q) = %v, want %v", tc.path, got, tc.want) + } + }) + } +} + +func Test_isPublicWritePath(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + path string + want bool + }{ + {name: "healthz", path: "/healthz", want: true}, + {name: "readyz", path: "/readyz", want: true}, + {name: "auth_endpoint", path: "/auth/aws/token", want: true}, + {name: "contexts_list", path: "/v1/contexts", want: false}, + {name: "metrics", path: "/v1/metrics", want: false}, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + if got := isPublicWritePath(tc.path); got != tc.want { + t.Fatalf("isPublicWritePath(%q) = %v, want %v", tc.path, got, tc.want) + } + }) + } +} + +func TestWriteMiddlewareEnforcesAPITokenScope(t *testing.T) { + store, err := NewSessionStore(filepath.Join(t.TempDir(), "sessions.sqlite"), "session", time.Hour, "", false, "test-secret") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = store.Close() }) + _, plaintext, err := store.CreateAPIToken(context.Background(), APITokenCreateRequest{Name: "reader", Issuer: "issuer", Subject: "alice", Scopes: []string{"cxdb:read"}}) + if err != nil { + t.Fatal(err) + } + handler := RequireAuthForWrites(AuthMiddlewareOptions{Store: store, TokenVerifiers: []BearerTokenVerifier{NewAPITokenVerifier(store)}}, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + request := httptest.NewRequest(http.MethodPost, "/v1/contexts/create", nil) + request.Header.Set("Authorization", "Bearer "+plaintext) + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + if response.Code != http.StatusForbidden { + t.Fatalf("read-only token write status = %d", response.Code) + } +} + +func TestWriteMiddlewareRejectsAnonymousMutation(t *testing.T) { + store, err := NewSessionStore(filepath.Join(t.TempDir(), "sessions.sqlite"), "session", time.Hour, "", false, "test-secret") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = store.Close() }) + handler := RequireAuthForWrites(AuthMiddlewareOptions{Store: store}, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + request := httptest.NewRequest(http.MethodPost, "/v1/contexts/create", nil) + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + if response.Code != http.StatusUnauthorized { + t.Fatalf("anonymous write status = %d", response.Code) + } +} + +func TestReadMiddlewareRejectsAnonymousSensitiveReads(t *testing.T) { + store, err := NewSessionStore(filepath.Join(t.TempDir(), "sessions.sqlite"), "session", time.Hour, "", false, "test-secret") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = store.Close() }) + handler := RequireAuthForReadsWithOptions(AuthMiddlewareOptions{Store: store}, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + t.Errorf("unauthenticated request reached protected handler") + w.WriteHeader(http.StatusNoContent) + })) + for _, path := range []string{"/v1/contexts", "/v1/metrics", "/v1/events"} { + t.Run(path, func(t *testing.T) { + request := httptest.NewRequest(http.MethodGet, path, nil) + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + if response.Code != http.StatusUnauthorized { + t.Fatalf("anonymous %s status = %d", path, response.Code) + } + }) + } +} + +func TestInvalidBearerDoesNotFallBackToCookie(t *testing.T) { + store, err := NewSessionStore(filepath.Join(t.TempDir(), "sessions.sqlite"), "session", time.Hour, "", false, "test-secret") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = store.Close() }) + sessionID, err := store.Create(context.Background(), "alice@example.com", "Alice", "") + if err != nil { + t.Fatal(err) + } + cookieRecorder := httptest.NewRecorder() + store.SetCookie(cookieRecorder, sessionID) + handler := RequireAuthForReadsWithOptions(AuthMiddlewareOptions{Store: store, TokenVerifiers: []BearerTokenVerifier{NewAPITokenVerifier(store)}}, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + request := httptest.NewRequest(http.MethodGet, "/v1/contexts/1", nil) + request.AddCookie(cookieRecorder.Result().Cookies()[0]) + request.Header.Set("Authorization", "Bearer invalid") + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + if response.Code != http.StatusUnauthorized { + t.Fatalf("invalid bearer with valid cookie status = %d", response.Code) + } +} diff --git a/gateway/pkg/auth/oauth_server.go b/gateway/pkg/auth/oauth_server.go new file mode 100644 index 0000000..1bfdc87 --- /dev/null +++ b/gateway/pkg/auth/oauth_server.go @@ -0,0 +1,500 @@ +// Copyright 2025 StrongDM Inc +// SPDX-License-Identifier: Apache-2.0 + +package auth + +import ( + "context" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "database/sql" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "html/template" + "net" + "net/http" + "net/url" + "slices" + "strings" + "time" +) + +const ( + oauthCodeTTL = 5 * time.Minute + oauthTokenTTL = time.Hour + maxOAuthClients = 1000 +) + +// OAuthServer is the CXDB OAuth 2.1 authorization server used by remote MCP clients. +type OAuthServer struct { + store *SessionStore + issuer string + resource string + loginPath string +} + +type oauthAuthorizationRequest struct { + ClientID string `json:"client_id"` + RedirectURI string `json:"redirect_uri"` + State string `json:"state"` + Challenge string `json:"challenge"` + Scopes []string `json:"scopes"` + Resource string `json:"resource"` + ExpiresAt int64 `json:"expires_at"` +} + +// NewOAuthServer initializes additive OAuth tables. +func NewOAuthServer(store *SessionStore, publicBaseURL, loginPath string) (*OAuthServer, error) { + issuer := strings.TrimSuffix(publicBaseURL, "/") + s := &OAuthServer{store: store, issuer: issuer, resource: issuer + "/mcp", loginPath: loginPath} + const schema = ` + CREATE TABLE IF NOT EXISTS oauth_clients ( + client_id TEXT PRIMARY KEY, + client_name TEXT NOT NULL, + redirect_uris_json TEXT NOT NULL, + created_at TIMESTAMP NOT NULL + ); + CREATE TABLE IF NOT EXISTS oauth_authorization_codes ( + code_hash TEXT PRIMARY KEY, + client_id TEXT NOT NULL, + redirect_uri TEXT NOT NULL, + code_challenge TEXT NOT NULL, + owner_issuer TEXT NOT NULL, + owner_subject TEXT NOT NULL, + email TEXT NOT NULL, + scopes_json TEXT NOT NULL, + expires_at TIMESTAMP NOT NULL, + used_at TIMESTAMP + ); + CREATE TABLE IF NOT EXISTS oauth_access_tokens ( + token_hash TEXT PRIMARY KEY, + owner_issuer TEXT NOT NULL, + owner_subject TEXT NOT NULL, + email TEXT NOT NULL, + scopes_json TEXT NOT NULL, + created_at TIMESTAMP NOT NULL, + expires_at TIMESTAMP NOT NULL, + last_used_at TIMESTAMP, + revoked_at TIMESTAMP + ); + CREATE INDEX IF NOT EXISTS idx_oauth_access_tokens_owner ON oauth_access_tokens(owner_issuer, owner_subject); + ` + if _, err := store.db.Exec(schema); err != nil { + return nil, fmt.Errorf("initialize OAuth schema: %w", err) + } + return s, nil +} + +// MetadataHandler serves RFC 8414 authorization-server metadata. +func (s *OAuthServer) MetadataHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "issuer": s.issuer, + "authorization_endpoint": s.issuer + "/oauth/authorize", + "token_endpoint": s.issuer + "/oauth/token", + "registration_endpoint": s.issuer + "/oauth/register", + "response_types_supported": []string{"code"}, + "grant_types_supported": []string{"authorization_code"}, + "code_challenge_methods_supported": []string{"S256"}, + "token_endpoint_auth_methods_supported": []string{"none"}, + "scopes_supported": []string{"cxdb:read", "cxdb:write"}, + "authorization_response_iss_parameter_supported": true, + }) +} + +// RegisterHandler implements RFC 7591 dynamic client registration for public MCP clients. +func (s *OAuthServer) RegisterHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + r.Body = http.MaxBytesReader(w, r.Body, 64<<10) + var request struct { + ClientName string `json:"client_name"` + RedirectURIs []string `json:"redirect_uris"` + TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"` + } + if err := json.NewDecoder(r.Body).Decode(&request); err != nil || len(request.RedirectURIs) == 0 || len(request.RedirectURIs) > 10 { + oauthError(w, http.StatusBadRequest, "invalid_client_metadata", "redirect_uris is required") + return + } + if request.TokenEndpointAuthMethod != "" && request.TokenEndpointAuthMethod != "none" { + oauthError(w, http.StatusBadRequest, "invalid_client_metadata", "only public clients are supported") + return + } + for _, redirectURI := range request.RedirectURIs { + if err := validateOAuthRedirectURI(redirectURI); err != nil { + oauthError(w, http.StatusBadRequest, "invalid_redirect_uri", err.Error()) + return + } + } + if len(request.ClientName) > 120 { + oauthError(w, http.StatusBadRequest, "invalid_client_metadata", "client_name is too long") + return + } + if request.ClientName == "" { + request.ClientName = "MCP client" + } + clientID, err := randomOpaque("mcpclient_", 24) + if err != nil { + http.Error(w, "registration failed", http.StatusInternalServerError) + return + } + redirectJSON, _ := json.Marshal(request.RedirectURIs) + result, err := s.store.db.ExecContext(r.Context(), ` + INSERT INTO oauth_clients (client_id, client_name, redirect_uris_json, created_at) + SELECT ?, ?, ?, ? + WHERE (SELECT COUNT(*) FROM oauth_clients) < ? + `, clientID, request.ClientName, string(redirectJSON), time.Now().UTC(), maxOAuthClients) + if err != nil { + http.Error(w, "registration failed", http.StatusInternalServerError) + return + } + rows, err := result.RowsAffected() + if err != nil { + http.Error(w, "registration failed", http.StatusInternalServerError) + return + } + if rows != 1 { + oauthError(w, http.StatusTooManyRequests, "registration_limit_reached", "the maximum number of registered clients has been reached") + return + } + writeJSON(w, http.StatusCreated, map[string]any{ + "client_id": clientID, + "client_name": request.ClientName, + "redirect_uris": request.RedirectURIs, + "token_endpoint_auth_method": "none", + "grant_types": []string{"authorization_code"}, + "response_types": []string{"code"}, + }) +} + +// AuthorizeHandler validates an OAuth request, authenticates through the browser OIDC session, and asks for consent. +func (s *OAuthServer) AuthorizeHandler(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + s.completeAuthorization(w, r) + return + } + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + request, err := s.parseAuthorizationRequest(r.URL.Query()) + if err != nil { + oauthError(w, http.StatusBadRequest, "invalid_request", err.Error()) + return + } + session, _ := s.store.SessionFromRequest(r.Context(), r) + if session == nil { + returnTo := r.URL.RequestURI() + http.Redirect(w, r, s.loginPath+"?return_to="+url.QueryEscape(returnTo), http.StatusFound) + return + } + signed, err := s.signAuthorizationRequest(request) + if err != nil { + http.Error(w, "unable to authorize", http.StatusInternalServerError) + return + } + consentCSP, err := oauthConsentCSP(request.RedirectURI) + if err != nil { + http.Error(w, "unable to authorize", http.StatusInternalServerError) + return + } + // Browsers apply form-action to redirects after form submission. Permit only + // this registered OAuth callback so loopback MCP clients can receive the code. + w.Header().Set("Content-Security-Policy", consentCSP) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _ = consentTemplate.Execute(w, struct { + Client string + Email string + Scopes string + Token string + }{request.ClientID, session.Email, strings.Join(request.Scopes, ", "), signed}) +} + +func (s *OAuthServer) completeAuthorization(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + oauthError(w, http.StatusBadRequest, "invalid_request", "invalid form") + return + } + request, err := s.verifyAuthorizationRequest(r.Form.Get("request")) + if err != nil { + oauthError(w, http.StatusBadRequest, "invalid_request", "invalid or expired authorization request") + return + } + session, _ := s.store.SessionFromRequest(r.Context(), r) + if session == nil { + http.Error(w, "login required", http.StatusUnauthorized) + return + } + if r.Form.Get("decision") != "allow" { + s.redirectOAuth(w, r, request.RedirectURI, map[string]string{"error": "access_denied", "state": request.State}) + return + } + for _, scope := range request.Scopes { + if !session.HasScope(scope) { + s.redirectOAuth(w, r, request.RedirectURI, map[string]string{"error": "invalid_scope", "state": request.State}) + return + } + } + code, err := randomOpaque("cxoc_", 32) + if err != nil { + http.Error(w, "unable to authorize", http.StatusInternalServerError) + return + } + scopesJSON, _ := json.Marshal(request.Scopes) + _, err = s.store.db.ExecContext(r.Context(), ` + INSERT INTO oauth_authorization_codes + (code_hash, client_id, redirect_uri, code_challenge, owner_issuer, owner_subject, email, scopes_json, expires_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `, s.hash("code", code), request.ClientID, request.RedirectURI, request.Challenge, session.Issuer, session.Subject, session.Email, string(scopesJSON), time.Now().UTC().Add(oauthCodeTTL)) + if err != nil { + http.Error(w, "unable to authorize", http.StatusInternalServerError) + return + } + s.redirectOAuth(w, r, request.RedirectURI, map[string]string{"code": code, "state": request.State, "iss": s.issuer}) +} + +// TokenHandler exchanges a single-use authorization code using PKCE S256. +func (s *OAuthServer) TokenHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + if err := r.ParseForm(); err != nil || r.Form.Get("grant_type") != "authorization_code" { + oauthError(w, http.StatusBadRequest, "unsupported_grant_type", "authorization_code is required") + return + } + code := r.Form.Get("code") + clientID := r.Form.Get("client_id") + redirectURI := r.Form.Get("redirect_uri") + verifier := r.Form.Get("code_verifier") + if code == "" || clientID == "" || redirectURI == "" || verifier == "" { + oauthError(w, http.StatusBadRequest, "invalid_request", "code, client_id, redirect_uri, and code_verifier are required") + return + } + tx, err := s.store.db.BeginTx(r.Context(), nil) + if err != nil { + http.Error(w, "token exchange failed", http.StatusInternalServerError) + return + } + defer tx.Rollback() + var record struct { + ClientID, RedirectURI, Challenge, Issuer, Subject, Email, ScopesJSON string + ExpiresAt time.Time + UsedAt sql.NullTime + } + err = tx.QueryRowContext(r.Context(), `SELECT client_id, redirect_uri, code_challenge, owner_issuer, owner_subject, email, scopes_json, expires_at, used_at FROM oauth_authorization_codes WHERE code_hash = ?`, s.hash("code", code)).Scan( + &record.ClientID, &record.RedirectURI, &record.Challenge, &record.Issuer, &record.Subject, &record.Email, &record.ScopesJSON, &record.ExpiresAt, &record.UsedAt, + ) + if err != nil || record.UsedAt.Valid || time.Now().After(record.ExpiresAt) || record.ClientID != clientID || record.RedirectURI != redirectURI || !verifyPKCES256(verifier, record.Challenge) { + oauthError(w, http.StatusBadRequest, "invalid_grant", "authorization code is invalid") + return + } + result, err := tx.ExecContext(r.Context(), `UPDATE oauth_authorization_codes SET used_at = ? WHERE code_hash = ? AND used_at IS NULL`, time.Now().UTC(), s.hash("code", code)) + if err != nil { + http.Error(w, "token exchange failed", http.StatusInternalServerError) + return + } + rows, _ := result.RowsAffected() + if rows != 1 { + oauthError(w, http.StatusBadRequest, "invalid_grant", "authorization code is invalid") + return + } + accessToken, err := randomOpaque("cxoa_", 32) + if err != nil { + http.Error(w, "token exchange failed", http.StatusInternalServerError) + return + } + now := time.Now().UTC() + expires := now.Add(oauthTokenTTL) + if _, err := tx.ExecContext(r.Context(), `INSERT INTO oauth_access_tokens (token_hash, owner_issuer, owner_subject, email, scopes_json, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?)`, s.hash("access", accessToken), record.Issuer, record.Subject, record.Email, record.ScopesJSON, now, expires); err != nil { + http.Error(w, "token exchange failed", http.StatusInternalServerError) + return + } + if err := tx.Commit(); err != nil { + http.Error(w, "token exchange failed", http.StatusInternalServerError) + return + } + var scopes []string + _ = json.Unmarshal([]byte(record.ScopesJSON), &scopes) + writeJSON(w, http.StatusOK, map[string]any{"access_token": accessToken, "token_type": "Bearer", "expires_in": int(oauthTokenTTL.Seconds()), "scope": strings.Join(scopes, " ")}) +} + +// Verify implements BearerTokenVerifier for OAuth access tokens. +func (s *OAuthServer) Verify(token string) (*Session, error) { + var session Session + var scopesJSON string + var revoked sql.NullTime + err := s.store.db.QueryRow(`SELECT owner_issuer, owner_subject, email, scopes_json, expires_at, revoked_at FROM oauth_access_tokens WHERE token_hash = ?`, s.hash("access", token)).Scan(&session.Issuer, &session.Subject, &session.Email, &scopesJSON, &session.ExpiresAt, &revoked) + if err != nil || revoked.Valid || time.Now().After(session.ExpiresAt) { + return nil, errors.New("invalid OAuth access token") + } + if err := json.Unmarshal([]byte(scopesJSON), &session.Scopes); err != nil { + return nil, errors.New("invalid OAuth access token scopes") + } + session.ID = "oauth:" + session.Issuer + ":" + session.Subject + session.Name = session.Email + session.AuthMethod = "oauth_access_token" + _, _ = s.store.db.Exec(`UPDATE oauth_access_tokens SET last_used_at = ? WHERE token_hash = ?`, time.Now().UTC(), s.hash("access", token)) + return &session, nil +} + +func (s *OAuthServer) parseAuthorizationRequest(values url.Values) (oauthAuthorizationRequest, error) { + request := oauthAuthorizationRequest{ + ClientID: values.Get("client_id"), RedirectURI: values.Get("redirect_uri"), State: values.Get("state"), + Challenge: values.Get("code_challenge"), Resource: values.Get("resource"), ExpiresAt: time.Now().Add(10 * time.Minute).Unix(), + } + if values.Get("response_type") != "code" || request.ClientID == "" || request.RedirectURI == "" || request.State == "" || request.Challenge == "" || values.Get("code_challenge_method") != "S256" { + return request, errors.New("response_type=code, state, and PKCE S256 are required") + } + if request.Resource != "" && request.Resource != s.resource { + return request, errors.New("resource must identify the CXDB MCP endpoint") + } + request.Resource = s.resource + request.Scopes = strings.Fields(values.Get("scope")) + if len(request.Scopes) == 0 { + request.Scopes = []string{"cxdb:read"} + } + for _, scope := range request.Scopes { + if scope != "cxdb:read" && scope != "cxdb:write" { + return request, fmt.Errorf("unsupported scope %q", scope) + } + } + if !slices.Contains(request.Scopes, "cxdb:read") { + return request, errors.New("cxdb:read scope is required") + } + var redirectsJSON string + if err := s.store.db.QueryRow(`SELECT redirect_uris_json FROM oauth_clients WHERE client_id = ?`, request.ClientID).Scan(&redirectsJSON); err != nil { + return request, errors.New("unknown client_id") + } + var redirects []string + if json.Unmarshal([]byte(redirectsJSON), &redirects) != nil || !slices.Contains(redirects, request.RedirectURI) { + return request, errors.New("redirect_uri is not registered") + } + return request, nil +} + +func (s *OAuthServer) signAuthorizationRequest(request oauthAuthorizationRequest) (string, error) { + encoded, err := json.Marshal(request) + if err != nil { + return "", err + } + return s.store.sign(base64.RawURLEncoding.EncodeToString(encoded)), nil +} + +func (s *OAuthServer) verifyAuthorizationRequest(signed string) (oauthAuthorizationRequest, error) { + var request oauthAuthorizationRequest + encoded, ok := s.store.verify(signed) + if !ok { + return request, errors.New("bad signature") + } + raw, err := base64.RawURLEncoding.DecodeString(encoded) + if err != nil { + return request, err + } + if err := json.Unmarshal(raw, &request); err != nil { + return request, err + } + if request.ExpiresAt < time.Now().Unix() { + return request, errors.New("expired request") + } + return request, nil +} + +func (s *OAuthServer) redirectOAuth(w http.ResponseWriter, r *http.Request, redirectURI string, params map[string]string) { + u, err := url.Parse(redirectURI) + if err != nil { + http.Error(w, "invalid redirect URI", http.StatusBadRequest) + return + } + query := u.Query() + for key, value := range params { + if value != "" { + query.Set(key, value) + } + } + u.RawQuery = query.Encode() + http.Redirect(w, r, u.String(), http.StatusFound) +} + +func (s *OAuthServer) hash(kind, value string) string { + mac := hmac.New(sha256.New, s.store.secret) + _, _ = mac.Write([]byte("cxdb-oauth-" + kind + "\x00" + value)) + return hex.EncodeToString(mac.Sum(nil)) +} + +func validateOAuthRedirectURI(raw string) error { + u, err := url.Parse(raw) + if err != nil || u.Host == "" || u.Fragment != "" || u.User != nil { + return errors.New("redirect URI must be absolute and have no fragment or userinfo") + } + if u.Scheme == "https" { + return nil + } + if u.Scheme != "http" { + return errors.New("redirect URI must use HTTPS or loopback HTTP") + } + host := u.Hostname() + if host != "localhost" { + ip := net.ParseIP(host) + if ip == nil || !ip.IsLoopback() { + return errors.New("HTTP redirect URI must use a loopback host") + } + } + return nil +} + +func oauthConsentCSP(redirectURI string) (string, error) { + if err := validateOAuthRedirectURI(redirectURI); err != nil { + return "", err + } + u, err := url.Parse(redirectURI) + if err != nil { + return "", err + } + origin := (&url.URL{Scheme: u.Scheme, Host: u.Host}).String() + return "default-src 'none'; form-action 'self' " + origin + "; frame-ancestors 'none'; base-uri 'none'", nil +} + +func verifyPKCES256(verifier, challenge string) bool { + digest := sha256.Sum256([]byte(verifier)) + return hmac.Equal([]byte(base64.RawURLEncoding.EncodeToString(digest[:])), []byte(challenge)) +} + +func randomOpaque(prefix string, size int) (string, error) { + b := make([]byte, size) + if _, err := rand.Read(b); err != nil { + return "", err + } + return prefix + base64.RawURLEncoding.EncodeToString(b), nil +} + +func writeJSON(w http.ResponseWriter, status int, value any) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(value) +} + +func oauthError(w http.ResponseWriter, status int, code, description string) { + writeJSON(w, status, map[string]string{"error": code, "error_description": description}) +} + +var consentTemplate = template.Must(template.New("consent").Parse(` +Authorize CXDB +

Authorize CXDB MCP access

{{.Client}} requests {{.Scopes}} as {{.Email}}.

+
+
`)) + +// VerifyOAuthTokenWithContext is useful to adapters that need request context. +func (s *OAuthServer) VerifyOAuthTokenWithContext(_ context.Context, token string) (*Session, error) { + return s.Verify(token) +} diff --git a/gateway/pkg/auth/oauth_server_test.go b/gateway/pkg/auth/oauth_server_test.go new file mode 100644 index 0000000..0c24e0c --- /dev/null +++ b/gateway/pkg/auth/oauth_server_test.go @@ -0,0 +1,214 @@ +// Copyright 2025 StrongDM Inc +// SPDX-License-Identifier: Apache-2.0 + +package auth + +import ( + "context" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "path/filepath" + "strings" + "testing" + "time" +) + +func testOAuthServer(t *testing.T) (*OAuthServer, *SessionStore) { + t.Helper() + store, err := NewSessionStore(filepath.Join(t.TempDir(), "oauth.sqlite"), "session", time.Hour, "", false, "oauth-test-secret") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = store.Close() }) + server, err := NewOAuthServer(store, "https://cxdb.example", "/auth/login") + if err != nil { + t.Fatal(err) + } + return server, store +} + +func TestOAuthAuthorizationCodePKCEAndSingleUse(t *testing.T) { + server, store := testOAuthServer(t) + clientID, redirectURI := registerTestClient(t, server, "http://127.0.0.1:49152/callback") + sessionID, err := store.CreateForIdentity(context.Background(), "https://issuer.example", "alice", "alice@example.com", "Alice", "", "oidc", []string{"cxdb:read", "cxdb:write"}) + if err != nil { + t.Fatal(err) + } + verifier := strings.Repeat("a", 64) + digest := sha256.Sum256([]byte(verifier)) + request := oauthAuthorizationRequest{ + ClientID: clientID, RedirectURI: redirectURI, State: "client-state", + Challenge: base64.RawURLEncoding.EncodeToString(digest[:]), Scopes: []string{"cxdb:read", "cxdb:write"}, + Resource: server.resource, ExpiresAt: time.Now().Add(time.Minute).Unix(), + } + signed, err := server.signAuthorizationRequest(request) + if err != nil { + t.Fatal(err) + } + form := url.Values{"request": {signed}, "decision": {"allow"}} + authorizeRequest := httptest.NewRequest(http.MethodPost, "/oauth/authorize", strings.NewReader(form.Encode())) + authorizeRequest.Header.Set("Content-Type", "application/x-www-form-urlencoded") + setSessionCookie(t, store, sessionID, authorizeRequest) + authorizeResponse := httptest.NewRecorder() + server.AuthorizeHandler(authorizeResponse, authorizeRequest) + if authorizeResponse.Code != http.StatusFound { + t.Fatalf("authorize status = %d, body=%s", authorizeResponse.Code, authorizeResponse.Body.String()) + } + redirect, err := url.Parse(authorizeResponse.Header().Get("Location")) + if err != nil { + t.Fatal(err) + } + if redirect.Query().Get("state") != "client-state" || redirect.Query().Get("iss") != "https://cxdb.example" { + t.Fatalf("authorization response parameters = %s", redirect.RawQuery) + } + code := redirect.Query().Get("code") + tokenForm := url.Values{"grant_type": {"authorization_code"}, "code": {code}, "client_id": {clientID}, "redirect_uri": {redirectURI}, "code_verifier": {verifier}} + tokenRequest := httptest.NewRequest(http.MethodPost, "/oauth/token", strings.NewReader(tokenForm.Encode())) + tokenRequest.Header.Set("Content-Type", "application/x-www-form-urlencoded") + tokenResponse := httptest.NewRecorder() + server.TokenHandler(tokenResponse, tokenRequest) + if tokenResponse.Code != http.StatusOK { + t.Fatalf("token status = %d, body=%s", tokenResponse.Code, tokenResponse.Body.String()) + } + var tokenPayload map[string]any + if err := json.Unmarshal(tokenResponse.Body.Bytes(), &tokenPayload); err != nil { + t.Fatal(err) + } + verified, err := server.Verify(tokenPayload["access_token"].(string)) + if err != nil || !verified.HasScope("cxdb:write") || verified.Subject != "alice" { + t.Fatalf("verified token = %+v, err=%v", verified, err) + } + + replayRequest := httptest.NewRequest(http.MethodPost, "/oauth/token", strings.NewReader(tokenForm.Encode())) + replayRequest.Header.Set("Content-Type", "application/x-www-form-urlencoded") + replayResponse := httptest.NewRecorder() + server.TokenHandler(replayResponse, replayRequest) + if replayResponse.Code != http.StatusBadRequest || !strings.Contains(replayResponse.Body.String(), "invalid_grant") { + t.Fatalf("code replay accepted: status=%d body=%s", replayResponse.Code, replayResponse.Body.String()) + } +} + +func TestOAuthRegistrationRejectsUnsafeRedirects(t *testing.T) { + server, _ := testOAuthServer(t) + for _, redirect := range []string{"http://example.com/callback", "https://user@example.com/callback", "https://example.com/callback#fragment", "file:///tmp/callback"} { + body := `{"redirect_uris":[` + strconvQuote(redirect) + `]}` + request := httptest.NewRequest(http.MethodPost, "/oauth/register", strings.NewReader(body)) + response := httptest.NewRecorder() + server.RegisterHandler(response, request) + if response.Code != http.StatusBadRequest { + t.Errorf("redirect %q status = %d", redirect, response.Code) + } + } +} + +func TestOAuthConsentCSPAllowsOnlyRegisteredCallbackOrigin(t *testing.T) { + server, store := testOAuthServer(t) + clientID, redirectURI := registerTestClient(t, server, "http://127.0.0.1:49152/callback") + sessionID, err := store.CreateForIdentity(context.Background(), "https://issuer.example", "alice", "alice@example.com", "Alice", "", "oidc", []string{"cxdb:read", "cxdb:write"}) + if err != nil { + t.Fatal(err) + } + challenge := base64.RawURLEncoding.EncodeToString(make([]byte, sha256.Size)) + query := url.Values{ + "response_type": {"code"}, "client_id": {clientID}, "redirect_uri": {redirectURI}, + "state": {"state"}, "scope": {"cxdb:read"}, "resource": {server.resource}, + "code_challenge": {challenge}, "code_challenge_method": {"S256"}, + } + request := httptest.NewRequest(http.MethodGet, "/oauth/authorize?"+query.Encode(), nil) + setSessionCookie(t, store, sessionID, request) + response := httptest.NewRecorder() + server.AuthorizeHandler(response, request) + if response.Code != http.StatusOK { + t.Fatalf("consent status = %d, body=%s", response.Code, response.Body.String()) + } + want := "default-src 'none'; form-action 'self' http://127.0.0.1:49152; frame-ancestors 'none'; base-uri 'none'" + if got := response.Header().Get("Content-Security-Policy"); got != want { + t.Fatalf("consent CSP = %q, want %q", got, want) + } +} + +func TestOAuthConsentCSPCallbackOrigins(t *testing.T) { + tests := map[string]string{ + "https://client.example/callback?source=cxdb": "https://client.example", + "http://localhost:6276/oauth/callback": "http://localhost:6276", + "http://127.0.0.1:6276/oauth/callback": "http://127.0.0.1:6276", + "http://[::1]:6276/oauth/callback": "http://[::1]:6276", + } + for redirectURI, origin := range tests { + t.Run(origin, func(t *testing.T) { + csp, err := oauthConsentCSP(redirectURI) + if err != nil { + t.Fatal(err) + } + want := "default-src 'none'; form-action 'self' " + origin + "; frame-ancestors 'none'; base-uri 'none'" + if csp != want { + t.Fatalf("consent CSP = %q, want %q", csp, want) + } + }) + } +} + +func TestOAuthAuthorizationRequiresState(t *testing.T) { + server, _ := testOAuthServer(t) + query := url.Values{ + "response_type": {"code"}, "client_id": {"client"}, + "redirect_uri": {"http://127.0.0.1:49152/callback"}, + "code_challenge": {"challenge"}, "code_challenge_method": {"S256"}, + } + if _, err := server.parseAuthorizationRequest(query); err == nil { + t.Fatal("authorization request without state was accepted") + } +} + +func TestOAuthRegistrationRejectsClientsAtPersistentCap(t *testing.T) { + server, _ := testOAuthServer(t) + for i := 0; i < maxOAuthClients; i++ { + if _, err := server.store.db.Exec(` + INSERT INTO oauth_clients (client_id, client_name, redirect_uris_json, created_at) + VALUES (?, ?, ?, ?) + `, fmt.Sprintf("cap-%d", i), "cap test", `["http://127.0.0.1/callback"]`, time.Now().UTC()); err != nil { + t.Fatalf("fill client cap at %d: %v", i, err) + } + } + request := httptest.NewRequest(http.MethodPost, "/oauth/register", strings.NewReader(`{"redirect_uris":["http://127.0.0.1/callback"]}`)) + response := httptest.NewRecorder() + server.RegisterHandler(response, request) + if response.Code != http.StatusTooManyRequests || !strings.Contains(response.Body.String(), "registration_limit_reached") { + t.Fatalf("registration at cap status=%d body=%s", response.Code, response.Body.String()) + } +} + +func registerTestClient(t *testing.T, server *OAuthServer, redirect string) (string, string) { + t.Helper() + body, _ := json.Marshal(map[string]any{"client_name": "test", "redirect_uris": []string{redirect}, "token_endpoint_auth_method": "none"}) + request := httptest.NewRequest(http.MethodPost, "/oauth/register", strings.NewReader(string(body))) + response := httptest.NewRecorder() + server.RegisterHandler(response, request) + if response.Code != http.StatusCreated { + t.Fatalf("register status = %d, body=%s", response.Code, response.Body.String()) + } + var payload struct { + ClientID string `json:"client_id"` + } + if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil { + t.Fatal(err) + } + return payload.ClientID, redirect +} + +func setSessionCookie(t *testing.T, store *SessionStore, sessionID string, request *http.Request) { + t.Helper() + recorder := httptest.NewRecorder() + store.SetCookie(recorder, sessionID) + request.AddCookie(recorder.Result().Cookies()[0]) +} + +func strconvQuote(value string) string { + raw, _ := json.Marshal(value) + return string(raw) +} diff --git a/gateway/pkg/auth/session.go b/gateway/pkg/auth/session.go index 2fa8569..c378b78 100644 --- a/gateway/pkg/auth/session.go +++ b/gateway/pkg/auth/session.go @@ -10,6 +10,7 @@ import ( "crypto/sha256" "database/sql" "encoding/hex" + "encoding/json" "errors" "fmt" "log" @@ -24,12 +25,33 @@ import ( // Session captures the authenticated user for a browser. type Session struct { - ID string - Email string - Name string - Picture string - CreatedAt time.Time - ExpiresAt time.Time + ID string + Email string + Name string + Picture string + Scopes []string + CreatedAt time.Time + ExpiresAt time.Time + AuthMethod string // Authentication method, for example "oidc" or "k8s_oidc". + Issuer string // Token issuer URL + Subject string // Stable subject within Issuer +} + +// HasScope returns true if the session includes the given scope. +func (s *Session) HasScope(scope string) bool { + for _, sc := range s.Scopes { + if sc == scope { + return true + } + } + return false +} + +// IsAPIToken reports whether this session came from a personal API token. +// Handlers can use this to prevent a bearer token from creating or revoking +// other personal credentials. +func (s *Session) IsAPIToken() bool { + return s != nil && s.AuthMethod == APITokenAuthMethod } // SessionStore handles persistence of sessions in SQLite and @@ -84,48 +106,105 @@ func (s *SessionStore) ensureSchema() error { expires_at TIMESTAMP NOT NULL ); CREATE INDEX IF NOT EXISTS idx_sessions_email ON sessions(email); + CREATE TABLE IF NOT EXISTS api_tokens ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + issuer TEXT NOT NULL, + subject TEXT NOT NULL, + scopes TEXT NOT NULL, + token_hash TEXT NOT NULL UNIQUE, + created_at TIMESTAMP NOT NULL, + expires_at TIMESTAMP NOT NULL, + revoked_at TIMESTAMP, + last_used_at TIMESTAMP + ); + CREATE INDEX IF NOT EXISTS idx_api_tokens_owner ON api_tokens(issuer, subject); + CREATE INDEX IF NOT EXISTS idx_api_tokens_hash ON api_tokens(token_hash); ` if _, err := s.db.Exec(schema); err != nil { return fmt.Errorf("init schema: %w", err) } // Backfill for older schemas missing the picture column; ignore duplicate errors. _, _ = s.db.Exec(`ALTER TABLE sessions ADD COLUMN picture TEXT;`) + // These columns are deliberately nullable so that existing installations can + // be upgraded without rewriting or invalidating their browser sessions. + for _, statement := range []string{ + `ALTER TABLE sessions ADD COLUMN issuer TEXT`, + `ALTER TABLE sessions ADD COLUMN subject TEXT`, + `ALTER TABLE sessions ADD COLUMN scopes TEXT`, + `ALTER TABLE sessions ADD COLUMN auth_method TEXT`, + } { + _, _ = s.db.Exec(statement) + } return nil } // Create inserts a new session and returns its ID. func (s *SessionStore) Create(ctx context.Context, email, name, picture string) (string, error) { + return s.CreateForIdentity(ctx, "https://accounts.google.com", email, email, name, picture, "google_oauth", []string{"cxdb:read", "cxdb:write"}) +} + +// CreateForIdentity inserts a browser session with a stable issuer/subject +// identity and authorization scopes. Create remains the compatibility API for +// callers that only have a Google profile. +func (s *SessionStore) CreateForIdentity(ctx context.Context, issuer, subject, email, name, picture, authMethod string, scopes []string) (string, error) { id, err := randomID() if err != nil { return "", err } now := time.Now().UTC() expires := now.Add(s.ttl) + scopeJSON, err := json.Marshal(normalizeScopes(scopes)) + if err != nil { + return "", fmt.Errorf("encode session scopes: %w", err) + } _, err = s.db.ExecContext(ctx, ` - INSERT INTO sessions (id, email, name, picture, created_at, expires_at) - VALUES (?, ?, ?, ?, ?, ?) - `, id, email, name, picture, now, expires) + INSERT INTO sessions (id, email, name, picture, issuer, subject, scopes, auth_method, created_at, expires_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, id, email, name, picture, strings.TrimSpace(issuer), strings.TrimSpace(subject), string(scopeJSON), strings.TrimSpace(authMethod), now, expires) if err != nil { return "", fmt.Errorf("insert session: %w", err) } return id, nil } +// CreateWithIdentity is retained as a convenience for callers using the +// original field-oriented order introduced during the identity migration. +func (s *SessionStore) CreateWithIdentity(ctx context.Context, email, name, picture, issuer, subject string, scopes []string, authMethod string) (string, error) { + return s.CreateForIdentity(ctx, issuer, subject, email, name, picture, authMethod, scopes) +} + // Get returns a valid, non-expired session by ID. func (s *SessionStore) Get(ctx context.Context, id string) (*Session, error) { row := s.db.QueryRowContext(ctx, ` - SELECT id, email, name, picture, created_at, expires_at + SELECT id, email, name, picture, issuer, subject, scopes, auth_method, created_at, expires_at FROM sessions WHERE id = ? `, id) var sess Session - if err := row.Scan(&sess.ID, &sess.Email, &sess.Name, &sess.Picture, &sess.CreatedAt, &sess.ExpiresAt); err != nil { + var email, name, picture, issuer, subject, scopesJSON, authMethod sql.NullString + if err := row.Scan(&sess.ID, &email, &name, &picture, &issuer, &subject, &scopesJSON, &authMethod, &sess.CreatedAt, &sess.ExpiresAt); err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, nil } return nil, fmt.Errorf("select session: %w", err) } + sess.Email, sess.Name, sess.Picture = email.String, name.String, picture.String + sess.Issuer, sess.Subject, sess.AuthMethod = issuer.String, subject.String, authMethod.String + if scopesJSON.Valid && scopesJSON.String != "" { + if err := json.Unmarshal([]byte(scopesJSON.String), &sess.Scopes); err != nil { + return nil, fmt.Errorf("decode session scopes: %w", err) + } + } + // Sessions that predate the identity migration were authenticated Google + // browser sessions. Preserve them until their normal expiry. + if !issuer.Valid && !subject.Valid && !scopesJSON.Valid && !authMethod.Valid { + sess.Issuer = "https://accounts.google.com" + sess.Subject = sess.Email + sess.AuthMethod = "google_oauth" + sess.Scopes = []string{"cxdb:read", "cxdb:write"} + } if time.Now().After(sess.ExpiresAt) { _ = s.Delete(ctx, id) return nil, nil @@ -194,6 +273,22 @@ func (s *SessionStore) SetCookie(w http.ResponseWriter, sessionID string) { }) } +// CSRFToken returns a session-bound token for browser credential-management requests. +func (s *SessionStore) CSRFToken(session *Session) string { + if session == nil || session.ID == "" { + return "" + } + mac := hmac.New(sha256.New, s.secret) + _, _ = mac.Write([]byte("cxdb-csrf\x00" + session.ID)) + return hex.EncodeToString(mac.Sum(nil)) +} + +// ValidCSRFToken checks a session-bound CSRF token in constant time. +func (s *SessionStore) ValidCSRFToken(session *Session, token string) bool { + expected := s.CSRFToken(session) + return expected != "" && hmac.Equal([]byte(expected), []byte(strings.TrimSpace(token))) +} + // ClearCookie removes the session cookie from the browser. func (s *SessionStore) ClearCookie(w http.ResponseWriter) { http.SetCookie(w, &http.Cookie{ diff --git a/gateway/pkg/auth/session_test.go b/gateway/pkg/auth/session_test.go new file mode 100644 index 0000000..8d07159 --- /dev/null +++ b/gateway/pkg/auth/session_test.go @@ -0,0 +1,38 @@ +// Copyright 2025 StrongDM Inc +// SPDX-License-Identifier: Apache-2.0 + +package auth + +import ( + "context" + "path/filepath" + "testing" + "time" +) + +func TestLegacyBrowserSessionKeepsDefaultIdentityAndScopes(t *testing.T) { + store, err := NewSessionStore(filepath.Join(t.TempDir(), "sessions.sqlite"), "session", time.Hour, "", false, "test-secret") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = store.Close() }) + now := time.Now().UTC() + _, err = store.db.ExecContext(context.Background(), ` + INSERT INTO sessions (id, email, name, picture, created_at, expires_at) + VALUES (?, ?, ?, ?, ?, ?) + `, "legacy", "legacy@example.com", "Legacy", "", now, now.Add(time.Hour)) + if err != nil { + t.Fatal(err) + } + + session, err := store.Get(context.Background(), "legacy") + if err != nil { + t.Fatal(err) + } + if session == nil || session.Issuer != "https://accounts.google.com" || session.Subject != "legacy@example.com" || session.AuthMethod != "google_oauth" { + t.Fatalf("legacy identity = %+v", session) + } + if !session.HasScope("cxdb:read") || !session.HasScope("cxdb:write") { + t.Fatalf("legacy scopes = %v", session.Scopes) + } +} diff --git a/gateway/pkg/mcpserver/server.go b/gateway/pkg/mcpserver/server.go new file mode 100644 index 0000000..4b7f450 --- /dev/null +++ b/gateway/pkg/mcpserver/server.go @@ -0,0 +1,316 @@ +// Copyright 2025 StrongDM Inc +// SPDX-License-Identifier: Apache-2.0 + +// Package mcpserver exposes CXDB operations through remote Streamable HTTP MCP. +package mcpserver + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + mcpauth "github.com/modelcontextprotocol/go-sdk/auth" + "github.com/modelcontextprotocol/go-sdk/mcp" + cxdbauth "github.com/strongdm/cxdb/gateway/pkg/auth" + "github.com/vmihailenco/msgpack/v5" +) + +const maxBackendResponse = 8 << 20 + +// New returns a bearer-protected, origin-protected MCP Streamable HTTP handler. +func New(backendURL, resourceMetadataURL string, verifiers []cxdbauth.BearerTokenVerifier, logger *slog.Logger) (http.Handler, error) { + backend, err := url.Parse(strings.TrimSuffix(backendURL, "/")) + if err != nil || backend.Scheme == "" || backend.Host == "" { + return nil, errors.New("invalid CXDB backend URL") + } + api := &backendClient{base: backend, client: &http.Client{Timeout: 30 * time.Second}} + server := mcp.NewServer(&mcp.Implementation{Name: "cxdb", Version: "0.1.0"}, &mcp.ServerOptions{ + Instructions: "Read and append CXDB Turn DAG contexts. Read exact turns before treating bounded summaries as complete.", + Logger: logger, + }) + registerTools(server, api) + + stream := mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server { return server }, &mcp.StreamableHTTPOptions{ + Stateless: true, + PropagateRequestCancellation: true, + MaxRequestBodyBytes: 1 << 20, + Logger: logger, + }) + originProtected := http.NewCrossOriginProtection().Handler(stream) + verifier := func(ctx context.Context, token string, req *http.Request) (*mcpauth.TokenInfo, error) { + for _, candidate := range verifiers { + var session *cxdbauth.Session + var verifyErr error + if requestVerifier, ok := candidate.(cxdbauth.RequestTokenVerifier); ok { + session, verifyErr = requestVerifier.VerifyWithRequest(req, token) + } else { + session, verifyErr = candidate.Verify(token) + } + if verifyErr == nil && session != nil { + return &mcpauth.TokenInfo{ + Scopes: session.Scopes, Expiration: session.ExpiresAt, UserID: session.Issuer + "|" + session.Subject, + }, nil + } + } + return nil, fmt.Errorf("%w: bearer token is invalid", mcpauth.ErrInvalidToken) + } + return mcpauth.RequireBearerToken(verifier, &mcpauth.RequireBearerTokenOptions{ + ResourceMetadataURL: resourceMetadataURL, + Scopes: []string{"cxdb:read"}, + })(originProtected), nil +} + +type backendClient struct { + base *url.URL + client *http.Client +} + +type listInput struct { + Limit int `json:"limit,omitempty"` +} + +type searchInput struct { + Query string `json:"query"` + Limit int `json:"limit,omitempty"` +} + +type contextInput struct { + ContextID string `json:"context_id"` +} + +type turnsInput struct { + ContextID string `json:"context_id"` + Limit int `json:"limit,omitempty"` + BeforeTurn string `json:"before_turn_id,omitempty"` + ExactTurnID string `json:"turn_id,omitempty"` +} + +type createInput struct { + BaseTurnID string `json:"base_turn_id,omitempty"` +} + +type appendMessageInput struct { + ContextID string `json:"context_id"` + Role string `json:"role"` + Text string `json:"text"` +} + +type appendRawInput struct { + ContextID string `json:"context_id"` + TypeID string `json:"type_id"` + TypeVersion uint32 `json:"type_version"` + PayloadBase64 string `json:"payload_base64"` +} + +func registerTools(server *mcp.Server, api *backendClient) { + mcp.AddTool(server, &mcp.Tool{Name: "cxdb_list_contexts", Description: "List recent CXDB contexts."}, func(ctx context.Context, _ *mcp.CallToolRequest, input listInput) (*mcp.CallToolResult, map[string]any, error) { + if err := requireScope(ctx, "cxdb:read"); err != nil { + return nil, nil, err + } + limit := boundedLimit(input.Limit) + return api.call(ctx, http.MethodGet, "/v1/contexts?limit="+strconv.Itoa(limit), nil) + }) + mcp.AddTool(server, &mcp.Tool{Name: "cxdb_search_contexts", Description: "Search contexts with CXDB Query Language."}, func(ctx context.Context, _ *mcp.CallToolRequest, input searchInput) (*mcp.CallToolResult, map[string]any, error) { + if err := requireScope(ctx, "cxdb:read"); err != nil { + return nil, nil, err + } + if strings.TrimSpace(input.Query) == "" { + return nil, nil, errors.New("query is required") + } + path := "/v1/contexts/search?q=" + url.QueryEscape(input.Query) + "&limit=" + strconv.Itoa(boundedLimit(input.Limit)) + return api.call(ctx, http.MethodGet, path, nil) + }) + mcp.AddTool(server, &mcp.Tool{Name: "cxdb_get_context", Description: "Get one context head and metadata."}, func(ctx context.Context, _ *mcp.CallToolRequest, input contextInput) (*mcp.CallToolResult, map[string]any, error) { + if err := requireScope(ctx, "cxdb:read"); err != nil { + return nil, nil, err + } + if err := numericID(input.ContextID); err != nil { + return nil, nil, err + } + return api.call(ctx, http.MethodGet, "/v1/contexts/"+input.ContextID, nil) + }) + mcp.AddTool(server, &mcp.Tool{Name: "cxdb_get_turns", Description: "Read typed turns. Set turn_id to hydrate one exact complete turn."}, func(ctx context.Context, _ *mcp.CallToolRequest, input turnsInput) (*mcp.CallToolResult, map[string]any, error) { + if err := requireScope(ctx, "cxdb:read"); err != nil { + return nil, nil, err + } + if err := numericID(input.ContextID); err != nil { + return nil, nil, err + } + query := url.Values{"limit": {strconv.Itoa(boundedLimit(input.Limit))}, "view": {"typed"}} + if input.BeforeTurn != "" { + if err := numericID(input.BeforeTurn); err != nil { + return nil, nil, err + } + query.Set("before_turn_id", input.BeforeTurn) + } + if input.ExactTurnID != "" { + if err := numericID(input.ExactTurnID); err != nil { + return nil, nil, err + } + query.Set("turn_id", input.ExactTurnID) + } + return api.call(ctx, http.MethodGet, "/v1/contexts/"+input.ContextID+"/turns?"+query.Encode(), nil) + }) + mcp.AddTool(server, &mcp.Tool{Name: "cxdb_get_provenance", Description: "Get provenance for one context."}, func(ctx context.Context, _ *mcp.CallToolRequest, input contextInput) (*mcp.CallToolResult, map[string]any, error) { + if err := requireScope(ctx, "cxdb:read"); err != nil { + return nil, nil, err + } + if err := numericID(input.ContextID); err != nil { + return nil, nil, err + } + return api.call(ctx, http.MethodGet, "/v1/contexts/"+input.ContextID+"/provenance", nil) + }) + mcp.AddTool(server, &mcp.Tool{Name: "cxdb_create_context", Description: "Create a new context or fork from a turn."}, func(ctx context.Context, _ *mcp.CallToolRequest, input createInput) (*mcp.CallToolResult, map[string]any, error) { + if err := requireScope(ctx, "cxdb:write"); err != nil { + return nil, nil, err + } + body := map[string]any{} + if input.BaseTurnID != "" { + if err := numericID(input.BaseTurnID); err != nil { + return nil, nil, err + } + body["base_turn_id"] = input.BaseTurnID + } + return api.call(ctx, http.MethodPost, "/v1/contexts/create", body) + }) + mcp.AddTool(server, &mcp.Tool{Name: "cxdb_append_message", Description: "Append a canonical user, assistant, or system message."}, func(ctx context.Context, _ *mcp.CallToolRequest, input appendMessageInput) (*mcp.CallToolResult, map[string]any, error) { + if err := requireScope(ctx, "cxdb:write"); err != nil { + return nil, nil, err + } + if err := numericID(input.ContextID); err != nil { + return nil, nil, err + } + payload, err := canonicalMessage(input.Role, input.Text) + if err != nil { + return nil, nil, err + } + body := map[string]any{"type_id": "cxdb.ConversationItem", "type_version": 3, "payload_base64": base64.StdEncoding.EncodeToString(payload)} + return api.call(ctx, http.MethodPost, "/v1/contexts/"+input.ContextID+"/append", body) + }) + mcp.AddTool(server, &mcp.Tool{Name: "cxdb_append_turn", Description: "Append a raw MessagePack turn with an explicit registered type."}, func(ctx context.Context, _ *mcp.CallToolRequest, input appendRawInput) (*mcp.CallToolResult, map[string]any, error) { + if err := requireScope(ctx, "cxdb:write"); err != nil { + return nil, nil, err + } + if err := numericID(input.ContextID); err != nil { + return nil, nil, err + } + if input.TypeID == "" || input.TypeVersion == 0 { + return nil, nil, errors.New("type_id and type_version are required") + } + decoded, err := base64.StdEncoding.DecodeString(input.PayloadBase64) + if err != nil || len(decoded) > 4<<20 { + return nil, nil, errors.New("payload_base64 must contain at most 4 MiB") + } + body := map[string]any{"type_id": input.TypeID, "type_version": input.TypeVersion, "payload_base64": input.PayloadBase64} + return api.call(ctx, http.MethodPost, "/v1/contexts/"+input.ContextID+"/append", body) + }) +} + +func (c *backendClient) call(ctx context.Context, method, path string, body any) (*mcp.CallToolResult, map[string]any, error) { + requestURL := *c.base + parsed, err := url.Parse(path) + if err != nil { + return nil, nil, err + } + requestURL.Path = parsed.Path + requestURL.RawQuery = parsed.RawQuery + var reader io.Reader + if body != nil { + raw, marshalErr := json.Marshal(body) + if marshalErr != nil { + return nil, nil, marshalErr + } + reader = bytes.NewReader(raw) + } + req, err := http.NewRequestWithContext(ctx, method, requestURL.String(), reader) + if err != nil { + return nil, nil, err + } + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + resp, err := c.client.Do(req) + if err != nil { + return nil, nil, fmt.Errorf("CXDB backend request: %w", err) + } + defer resp.Body.Close() + raw, err := io.ReadAll(io.LimitReader(resp.Body, maxBackendResponse+1)) + if err != nil { + return nil, nil, err + } + if len(raw) > maxBackendResponse { + return nil, nil, errors.New("CXDB backend response exceeds 8 MiB") + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, nil, fmt.Errorf("CXDB backend returned %d: %s", resp.StatusCode, strings.TrimSpace(string(raw))) + } + var decoded any + if err := json.Unmarshal(raw, &decoded); err != nil { + return nil, nil, fmt.Errorf("decode CXDB response: %w", err) + } + return nil, map[string]any{"response": decoded}, nil +} + +func requireScope(ctx context.Context, scope string) error { + info := mcpauth.TokenInfoFromContext(ctx) + if info == nil { + return errors.New("authentication is required") + } + for _, granted := range info.Scopes { + if granted == scope { + return nil + } + } + return fmt.Errorf("insufficient scope: %s is required", scope) +} + +func numericID(value string) error { + if value == "" { + return errors.New("ID is required") + } + if _, err := strconv.ParseUint(value, 10, 64); err != nil { + return errors.New("ID must be an unsigned integer") + } + return nil +} + +func boundedLimit(value int) int { + if value <= 0 { + return 50 + } + if value > 200 { + return 200 + } + return value +} + +func canonicalMessage(role, text string) ([]byte, error) { + if text == "" { + return nil, errors.New("text is required") + } + item := map[string]any{"status": "complete", "timestamp": time.Now().UnixMilli()} + switch role { + case "user": + item["item_type"] = "user_input" + item["user_input"] = map[string]any{"text": text} + case "assistant": + item["item_type"] = "assistant_turn" + item["turn"] = map[string]any{"text": text} + case "system": + item["item_type"] = "system" + item["system"] = map[string]any{"text": text, "kind": "info"} + default: + return nil, errors.New("role must be user, assistant, or system") + } + return msgpack.Marshal(item) +} diff --git a/gateway/pkg/mcpserver/server_test.go b/gateway/pkg/mcpserver/server_test.go new file mode 100644 index 0000000..a5972dd --- /dev/null +++ b/gateway/pkg/mcpserver/server_test.go @@ -0,0 +1,228 @@ +// Copyright 2025 StrongDM Inc +// SPDX-License-Identifier: Apache-2.0 + +package mcpserver + +import ( + "context" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "html" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "net/url" + "path/filepath" + "regexp" + "strings" + "testing" + "time" + + "github.com/modelcontextprotocol/go-sdk/mcp" + cxdbauth "github.com/strongdm/cxdb/gateway/pkg/auth" +) + +type staticVerifier struct{ scopes []string } + +func (v staticVerifier) Verify(token string) (*cxdbauth.Session, error) { + if token != "test-token" { + return nil, cxdbauth.ErrAPITokenNotFound + } + return &cxdbauth.Session{ID: "test", Issuer: "test", Subject: "user", Email: "user@example.com", Scopes: v.scopes, ExpiresAt: time.Now().Add(time.Hour)}, nil +} + +type bearerTransport struct { + base http.RoundTripper + token string +} + +func (t bearerTransport) RoundTrip(request *http.Request) (*http.Response, error) { + clone := request.Clone(request.Context()) + clone.Header = request.Header.Clone() + clone.Header.Set("Authorization", "Bearer "+t.token) + return t.base.RoundTrip(clone) +} + +func TestOfficialClientHandshakeReadAndWriteTools(t *testing.T) { + var appended bool + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && r.URL.Path == "/v1/contexts": + _, _ = io.WriteString(w, `{"contexts":[{"context_id":"1"}]}`) + case r.Method == http.MethodPost && r.URL.Path == "/v1/contexts/1/append": + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if body["type_id"] != "cxdb.ConversationItem" || body["payload_base64"] == "" { + t.Fatalf("unexpected append body: %#v", body) + } + appended = true + _, _ = io.WriteString(w, `{"turn_id":"2"}`) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(backend.Close) + + handler, err := New(backend.URL, "https://cxdb.example/.well-known/oauth-protected-resource/mcp", []cxdbauth.BearerTokenVerifier{staticVerifier{scopes: []string{"cxdb:read", "cxdb:write"}}}, slog.Default()) + if err != nil { + t.Fatal(err) + } + remote := httptest.NewServer(handler) + t.Cleanup(remote.Close) + + client := mcp.NewClient(&mcp.Implementation{Name: "cxdb-test", Version: "1"}, nil) + httpClient := &http.Client{Transport: bearerTransport{base: http.DefaultTransport, token: "test-token"}} + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + session, err := client.Connect(ctx, &mcp.StreamableClientTransport{Endpoint: remote.URL, HTTPClient: httpClient, DisableStandaloneSSE: true}, nil) + if err != nil { + t.Fatalf("official MCP client handshake: %v", err) + } + defer session.Close() + if _, err := session.CallTool(ctx, &mcp.CallToolParams{Name: "cxdb_list_contexts", Arguments: map[string]any{"limit": 1}}); err != nil { + t.Fatalf("read tool: %v", err) + } + result, err := session.CallTool(ctx, &mcp.CallToolParams{Name: "cxdb_append_message", Arguments: map[string]any{"context_id": "1", "role": "user", "text": "hello"}}) + if err != nil { + t.Fatalf("write tool: %v", err) + } + if result.IsError || !appended { + t.Fatalf("write tool did not append: result=%+v appended=%v", result, appended) + } +} + +func TestWriteToolRequiresWriteScope(t *testing.T) { + backend := httptest.NewServer(http.NotFoundHandler()) + t.Cleanup(backend.Close) + handler, err := New(backend.URL, "https://cxdb.example/metadata", []cxdbauth.BearerTokenVerifier{staticVerifier{scopes: []string{"cxdb:read"}}}, slog.Default()) + if err != nil { + t.Fatal(err) + } + remote := httptest.NewServer(handler) + t.Cleanup(remote.Close) + client := mcp.NewClient(&mcp.Implementation{Name: "cxdb-test", Version: "1"}, nil) + session, err := client.Connect(context.Background(), &mcp.StreamableClientTransport{Endpoint: remote.URL, HTTPClient: &http.Client{Transport: bearerTransport{base: http.DefaultTransport, token: "test-token"}}, DisableStandaloneSSE: true}, nil) + if err != nil { + t.Fatal(err) + } + defer session.Close() + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: "cxdb_create_context", Arguments: map[string]any{}}) + if err != nil { + t.Fatal(err) + } + if !result.IsError { + t.Fatal("read-only token was allowed to call a write tool") + } +} + +func TestOAuthAccessTokenConnectsWithOfficialClient(t *testing.T) { + store, err := cxdbauth.NewSessionStore(filepath.Join(t.TempDir(), "oauth.sqlite"), "session", time.Hour, "", false, "integration-secret") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = store.Close() }) + oauth, err := cxdbauth.NewOAuthServer(store, "https://cxdb.example", "/auth/login") + if err != nil { + t.Fatal(err) + } + + redirectURI := "http://127.0.0.1:49152/callback" + registrationBody := `{"client_name":"official MCP client","redirect_uris":["` + redirectURI + `"],"token_endpoint_auth_method":"none"}` + registrationRequest := httptest.NewRequest(http.MethodPost, "/oauth/register", strings.NewReader(registrationBody)) + registrationResponse := httptest.NewRecorder() + oauth.RegisterHandler(registrationResponse, registrationRequest) + if registrationResponse.Code != http.StatusCreated { + t.Fatalf("register status = %d, body=%s", registrationResponse.Code, registrationResponse.Body.String()) + } + var registration struct { + ClientID string `json:"client_id"` + } + if err := json.Unmarshal(registrationResponse.Body.Bytes(), ®istration); err != nil { + t.Fatal(err) + } + + sessionID, err := store.CreateForIdentity(t.Context(), "https://id.example", "alice", "alice@example.com", "Alice", "", "oidc", []string{"cxdb:read", "cxdb:write"}) + if err != nil { + t.Fatal(err) + } + verifier := strings.Repeat("v", 64) + digest := sha256.Sum256([]byte(verifier)) + query := url.Values{ + "response_type": {"code"}, + "client_id": {registration.ClientID}, + "redirect_uri": {redirectURI}, + "state": {"client-state"}, + "scope": {"cxdb:read cxdb:write"}, + "resource": {"https://cxdb.example/mcp"}, + "code_challenge": {base64.RawURLEncoding.EncodeToString(digest[:])}, + "code_challenge_method": {"S256"}, + } + authorizeRequest := httptest.NewRequest(http.MethodGet, "/oauth/authorize?"+query.Encode(), nil) + addSessionCookie(store, sessionID, authorizeRequest) + authorizeResponse := httptest.NewRecorder() + oauth.AuthorizeHandler(authorizeResponse, authorizeRequest) + match := regexp.MustCompile(`name="request" value="([^"]+)"`).FindStringSubmatch(authorizeResponse.Body.String()) + if authorizeResponse.Code != http.StatusOK || len(match) != 2 { + t.Fatalf("authorization consent status = %d, body=%s", authorizeResponse.Code, authorizeResponse.Body.String()) + } + consentForm := url.Values{"request": {html.UnescapeString(match[1])}, "decision": {"allow"}} + consentRequest := httptest.NewRequest(http.MethodPost, "/oauth/authorize", strings.NewReader(consentForm.Encode())) + consentRequest.Header.Set("Content-Type", "application/x-www-form-urlencoded") + addSessionCookie(store, sessionID, consentRequest) + consentResponse := httptest.NewRecorder() + oauth.AuthorizeHandler(consentResponse, consentRequest) + location, err := url.Parse(consentResponse.Header().Get("Location")) + if err != nil || location.Query().Get("code") == "" { + t.Fatalf("consent redirect = %q, err=%v", consentResponse.Header().Get("Location"), err) + } + tokenForm := url.Values{ + "grant_type": {"authorization_code"}, + "code": {location.Query().Get("code")}, + "client_id": {registration.ClientID}, + "redirect_uri": {redirectURI}, + "code_verifier": {verifier}, + } + tokenRequest := httptest.NewRequest(http.MethodPost, "/oauth/token", strings.NewReader(tokenForm.Encode())) + tokenRequest.Header.Set("Content-Type", "application/x-www-form-urlencoded") + tokenResponse := httptest.NewRecorder() + oauth.TokenHandler(tokenResponse, tokenRequest) + var tokenPayload struct { + AccessToken string `json:"access_token"` + } + if tokenResponse.Code != http.StatusOK || json.Unmarshal(tokenResponse.Body.Bytes(), &tokenPayload) != nil || tokenPayload.AccessToken == "" { + t.Fatalf("token response status = %d, body=%s", tokenResponse.Code, tokenResponse.Body.String()) + } + + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"contexts":[]}`) + })) + t.Cleanup(backend.Close) + handler, err := New(backend.URL, "https://cxdb.example/.well-known/oauth-protected-resource/mcp", []cxdbauth.BearerTokenVerifier{oauth}, slog.Default()) + if err != nil { + t.Fatal(err) + } + remote := httptest.NewServer(handler) + t.Cleanup(remote.Close) + client := mcp.NewClient(&mcp.Implementation{Name: "cxdb-oauth-test", Version: "1"}, nil) + httpClient := &http.Client{Transport: bearerTransport{base: http.DefaultTransport, token: tokenPayload.AccessToken}} + mcpSession, err := client.Connect(t.Context(), &mcp.StreamableClientTransport{Endpoint: remote.URL, HTTPClient: httpClient, DisableStandaloneSSE: true}, nil) + if err != nil { + t.Fatalf("OAuth-backed official MCP client handshake: %v", err) + } + defer mcpSession.Close() + if _, err := mcpSession.CallTool(t.Context(), &mcp.CallToolParams{Name: "cxdb_list_contexts", Arguments: map[string]any{"limit": 1}}); err != nil { + t.Fatalf("OAuth-backed read tool: %v", err) + } +} + +func addSessionCookie(store *cxdbauth.SessionStore, sessionID string, request *http.Request) { + recorder := httptest.NewRecorder() + store.SetCookie(recorder, sessionID) + request.AddCookie(recorder.Result().Cookies()[0]) +} diff --git a/gateway/pkg/proxy/peer.go b/gateway/pkg/proxy/peer.go new file mode 100644 index 0000000..69dbd5e --- /dev/null +++ b/gateway/pkg/proxy/peer.go @@ -0,0 +1,28 @@ +// Copyright 2025 StrongDM Inc +// SPDX-License-Identifier: Apache-2.0 + +package proxy + +import ( + "net" + "net/http" +) + +// observedPeerIP returns the TCP peer's address parsed from req.RemoteAddr. +// +// This helper is the sole source of "real client address" at the gateway +// trust boundary. It NEVER reads any HTTP header. Every gateway site that +// needs a real peer (reverse-proxy XFF write, request logging, rate-limit +// bucket key, debug-auth IP allowlist) MUST call this helper and MUST NOT +// inspect `X-Forwarded-For` or `Forwarded` directly — those headers are +// attacker-controllable (see ADR-006). +// +// If `RemoteAddr` lacks a port (unusual — net/http populates it with +// "host:port"), the raw value is returned unchanged rather than mangled. +func observedPeerIP(req *http.Request) string { + host, _, err := net.SplitHostPort(req.RemoteAddr) + if err != nil { + return req.RemoteAddr + } + return host +} diff --git a/gateway/pkg/proxy/reverse.go b/gateway/pkg/proxy/reverse.go index 3ffd7dc..1888883 100644 --- a/gateway/pkg/proxy/reverse.go +++ b/gateway/pkg/proxy/reverse.go @@ -27,37 +27,48 @@ func NewReverseProxy(backendURL string, logger *slog.Logger) (*ReverseProxy, err return nil, err } - proxy := httputil.NewSingleHostReverseProxy(target) - - // Custom director to set headers - originalDirector := proxy.Director - proxy.Director = func(req *http.Request) { - originalDirector(req) - - // Set the host to the target - req.Host = target.Host - - // Forward client IP - clientIP := extractClientIP(req) - if existing := req.Header.Get("X-Forwarded-For"); existing != "" { - req.Header.Set("X-Forwarded-For", existing+", "+clientIP) - } else { - req.Header.Set("X-Forwarded-For", clientIP) - } - - // Forward the original protocol - if req.Header.Get("X-Forwarded-Proto") == "" { - if req.TLS != nil { - req.Header.Set("X-Forwarded-Proto", "https") - } else { - req.Header.Set("X-Forwarded-Proto", "http") + proxy := &httputil.ReverseProxy{} + + // Use Rewrite (Go 1.20+). Rewrite is mutually exclusive with Director + // and disables the stdlib's default `X-Forwarded-For` auto-append — + // essential for the Sprint 019 / ADR-006 trust contract: the gateway + // MUST be the sole writer of `X-Forwarded-For` on outbound requests. + proxy.Rewrite = func(r *httputil.ProxyRequest) { + // Point the outbound request at the target backend. + r.SetURL(target) + r.Out.Host = target.Host + + // XFF trust contract (Sprint 019 / ADR-006): the gateway is the + // trust boundary. DROP any caller-supplied `X-Forwarded-For` and + // `Forwarded` headers first — they are attacker-controllable. + // Then set `X-Forwarded-For` to our own TCP-peer observation. The + // `observedPeerIP` helper is the single source of real-client-IP + // truth across the gateway (logging, rate-limit, this director). + r.Out.Header.Del("X-Forwarded-For") + r.Out.Header.Del("Forwarded") + // Identity headers are gateway assertions. Never forward caller values. + for header := range r.Out.Header { + if strings.HasPrefix(strings.ToLower(header), "x-cxdb-") { + r.Out.Header.Del(header) } } - - // Forward the original host - if req.Header.Get("X-Forwarded-Host") == "" { - req.Header.Set("X-Forwarded-Host", req.Host) + // Authentication is complete at the gateway. Do not forward browser + // cookies or bearer credentials to the Rust backend. + r.Out.Header.Del("Authorization") + r.Out.Header.Del("Cookie") + r.Out.Header.Set("X-Forwarded-For", observedPeerIP(r.In)) + + // X-Forwarded-Proto is a gateway assertion. Never preserve a caller value. + r.Out.Header.Del("X-Forwarded-Proto") + if r.In.TLS != nil { + r.Out.Header.Set("X-Forwarded-Proto", "https") + } else { + r.Out.Header.Set("X-Forwarded-Proto", "http") } + + // The Rust backend does not need the public host. Drop this header so a + // caller-selected Host value cannot cross the gateway trust boundary. + r.Out.Header.Del("X-Forwarded-Host") } // Custom error handler @@ -94,18 +105,3 @@ func (rp *ReverseProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { func (rp *ReverseProxy) Target() string { return rp.target.String() } - -func extractClientIP(r *http.Request) string { - // Check X-Forwarded-For first (in case we're behind another proxy) - if xff := r.Header.Get("X-Forwarded-For"); xff != "" { - parts := strings.Split(xff, ",") - return strings.TrimSpace(parts[0]) - } - - // Fall back to RemoteAddr - host, _, err := net.SplitHostPort(r.RemoteAddr) - if err != nil { - return r.RemoteAddr - } - return host -} diff --git a/gateway/pkg/proxy/reverse_security_test.go b/gateway/pkg/proxy/reverse_security_test.go new file mode 100644 index 0000000..194d7be --- /dev/null +++ b/gateway/pkg/proxy/reverse_security_test.go @@ -0,0 +1,64 @@ +// Copyright 2025 StrongDM Inc +// SPDX-License-Identifier: Apache-2.0 + +package proxy + +import ( + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestReverseProxyReplacesForwardedAndStripsCredentials(t *testing.T) { + seen := make(chan http.Header, 1) + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seen <- r.Header.Clone() + w.WriteHeader(http.StatusNoContent) + })) + defer backend.Close() + + reverse, err := NewReverseProxy(backend.URL, slog.Default()) + if err != nil { + t.Fatal(err) + } + req := httptest.NewRequest(http.MethodPost, "/v1/contexts/1/append", strings.NewReader("{}")) + req.RemoteAddr = "198.51.100.7:44321" + req.Host = "cxdb.example" + req.Header.Set("X-Forwarded-For", "203.0.113.9") + req.Header.Set("Forwarded", "for=203.0.113.9") + req.Header.Set("X-Forwarded-Proto", "https") + req.Header.Set("X-Forwarded-Host", "evil.example") + req.Header.Set("X-Cxdb-Writer-Method", "admin") + req.Header.Set("X-Cxdb-Writer-Subject", "admin") + req.Header.Set("X-Cxdb-Writer-Issuer", "admin") + req.Header.Set("X-Cxdb-User-Email", "admin@example.com") + req.Header.Set("X-Cxdb-Unknown", "admin") + req.Header.Set("Authorization", "Bearer gateway-token") + req.Header.Set("Cookie", "cxdb_session=browser-cookie") + + response := httptest.NewRecorder() + reverse.ServeHTTP(response, req) + if response.Code != http.StatusNoContent { + t.Fatalf("proxy status = %d", response.Code) + } + got := <-seen + if got.Get("X-Forwarded-For") != "198.51.100.7" { + t.Fatalf("X-Forwarded-For = %q", got.Get("X-Forwarded-For")) + } + if got.Get("Forwarded") != "" { + t.Fatalf("Forwarded was preserved: %q", got.Get("Forwarded")) + } + if got.Get("X-Forwarded-Proto") != "http" { + t.Fatalf("X-Forwarded-Proto = %q", got.Get("X-Forwarded-Proto")) + } + if got.Get("X-Forwarded-Host") != "" { + t.Fatalf("X-Forwarded-Host was forwarded: %q", got.Get("X-Forwarded-Host")) + } + for _, header := range []string{"X-Cxdb-Writer-Method", "X-Cxdb-Writer-Subject", "X-Cxdb-Writer-Issuer", "X-Cxdb-User-Email", "X-Cxdb-Unknown", "Authorization", "Cookie"} { + if got.Get(header) != "" { + t.Fatalf("%s was forwarded: %q", header, got.Get(header)) + } + } +} diff --git a/gateway/pkg/proxy/server.go b/gateway/pkg/proxy/server.go index 6b2cde1..4f320ea 100644 --- a/gateway/pkg/proxy/server.go +++ b/gateway/pkg/proxy/server.go @@ -5,17 +5,21 @@ package proxy import ( "context" + "encoding/json" "fmt" "io/fs" "log/slog" - "net" "net/http" + "net/url" "strings" "sync" "time" + mcpauth "github.com/modelcontextprotocol/go-sdk/auth" + "github.com/modelcontextprotocol/go-sdk/oauthex" "github.com/strongdm/cxdb/gateway/internal/config" "github.com/strongdm/cxdb/gateway/pkg/auth" + "github.com/strongdm/cxdb/gateway/pkg/mcpserver" "golang.org/x/time/rate" ) @@ -25,6 +29,8 @@ type Server struct { mux *http.ServeMux sessions *auth.SessionStore google *auth.GoogleAuth + oidc *auth.BrowserOIDC + oauth *auth.OAuthServer proxy *ReverseProxy sse *SSEBroker logger *slog.Logger @@ -75,6 +81,22 @@ func New(cfg config.Config, sessions *auth.SessionStore, google *auth.GoogleAuth limiters: newIPRateLimiter(rate.Limit(5), 10), } + if cfg.OIDCEnabled { + discoveryContext, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + browserOIDC, err := auth.NewBrowserOIDC(discoveryContext, cfg.OIDCIssuerURL, cfg.OIDCClientID, cfg.OIDCClientSecret, cfg.PublicBaseURL, cfg.OIDCAllowedDomains, sessions) + if err != nil { + return nil, fmt.Errorf("init browser OIDC: %w", err) + } + s.oidc = browserOIDC + } + oauthServer, err := auth.NewOAuthServer(sessions, cfg.PublicBaseURL, "/auth/login") + if err != nil { + return nil, fmt.Errorf("init OAuth server: %w", err) + } + s.oauth = oauthServer + s.tokenVerifiers = append(s.tokenVerifiers, auth.NewAPITokenVerifier(sessions), oauthServer) + // Initialize K8s OIDC verifier if enabled if cfg.K8sOIDCEnabled { k8sVerifier, err := auth.NewK8sOIDCVerifier( @@ -91,7 +113,7 @@ func New(cfg config.Config, sessions *auth.SessionStore, google *auth.GoogleAuth // Initialize AWS IAM token exchanger if enabled if cfg.AWSIAMEnabled { - // Extract issuer from PublicBaseURL (e.g., "https://your-domain.com" -> "your-domain.com") + // Extract issuer from PublicBaseURL (e.g., "https://cxdb.example.com" -> "cxdb.example.com") issuer := strings.TrimPrefix(cfg.PublicBaseURL, "https://") issuer = strings.TrimPrefix(issuer, "http://") issuer = strings.TrimSuffix(issuer, "/") @@ -115,9 +137,31 @@ func New(cfg config.Config, sessions *auth.SessionStore, google *auth.GoogleAuth mux.HandleFunc("/readyz", s.readyz) // OAuth endpoints (public) - mux.HandleFunc("/auth/google/login", google.LoginHandler) - mux.HandleFunc("/auth/google/callback", google.CallbackHandler) - mux.HandleFunc("/auth/google/logout", google.LogoutHandler) + mux.HandleFunc("/auth/login", s.login) + if google != nil { + mux.HandleFunc("/auth/google/login", google.LoginHandler) + mux.HandleFunc("/auth/google/callback", google.CallbackHandler) + mux.HandleFunc("/auth/google/logout", google.LogoutHandler) + } + if s.oidc != nil { + mux.HandleFunc("/auth/oidc/login", s.oidc.LoginHandler) + mux.HandleFunc("/auth/oidc/callback", s.oidc.CallbackHandler) + } + mux.HandleFunc("/.well-known/oauth-authorization-server", s.oauth.MetadataHandler) + mux.HandleFunc("/oauth/register", s.oauth.RegisterHandler) + mux.Handle("/oauth/authorize", http.NewCrossOriginProtection().Handler(http.HandlerFunc(s.oauth.AuthorizeHandler))) + mux.HandleFunc("/oauth/token", s.oauth.TokenHandler) + resourceMetadataURL := strings.TrimSuffix(cfg.PublicBaseURL, "/") + "/.well-known/oauth-protected-resource/mcp" + resourceMetadata := &oauthex.ProtectedResourceMetadata{ + Resource: strings.TrimSuffix(cfg.PublicBaseURL, "/") + "/mcp", AuthorizationServers: []string{strings.TrimSuffix(cfg.PublicBaseURL, "/")}, + ScopesSupported: []string{"cxdb:read", "cxdb:write"}, BearerMethodsSupported: []string{"header"}, ResourceName: "CXDB MCP", + } + mux.Handle("/.well-known/oauth-protected-resource/mcp", mcpauth.ProtectedResourceMetadataHandler(resourceMetadata)) + mcpHandler, err := mcpserver.New(cfg.CXDBBackendURL, resourceMetadataURL, s.tokenVerifiers, logger) + if err != nil { + return nil, fmt.Errorf("init MCP server: %w", err) + } + mux.Handle("/mcp", mcpHandler) // AWS IAM token exchange endpoint (public - uses AWS creds for auth) if s.awsExchanger != nil { @@ -126,6 +170,8 @@ func New(cfg config.Config, sessions *auth.SessionStore, google *auth.GoogleAuth // API info endpoint mux.HandleFunc("/api/v1/me", s.me) + mux.HandleFunc("/api/v1/tokens", s.tokens) + mux.HandleFunc("/api/v1/tokens/", s.tokenByID) // SSE endpoint for live events (must be before /v1/ catch-all) mux.Handle("/v1/events", sseBroker) @@ -145,14 +191,7 @@ func (s *Server) ListenAndServe(ctx context.Context) error { s.sse.Start(ctx) addr := fmt.Sprintf(":%s", s.cfg.Port) - handler := auth.RequireAuthForReadsWithOptions(auth.AuthMiddlewareOptions{ - Store: s.sessions, - DevBypass: s.cfg.DevMode, - TokenVerifiers: s.tokenVerifiers, - }, s.mux) - handler = s.rateLimitMiddleware(handler) - handler = s.securityHeaders(handler) - handler = s.loggingMiddleware(handler) + handler := s.Handler() srv := &http.Server{ Addr: addr, @@ -178,6 +217,25 @@ func (s *Server) ListenAndServe(ctx context.Context) error { return nil } +// Handler returns the complete production middleware stack. It is also used +// by integration tests so they exercise the same authorization boundary. +func (s *Server) Handler() http.Handler { + handler := auth.RequireAuthForReadsWithOptions(auth.AuthMiddlewareOptions{ + Store: s.sessions, + DevBypass: s.cfg.DevMode, + TokenVerifiers: s.tokenVerifiers, + }, s.mux) + handler = auth.RequireAuthForWrites(auth.AuthMiddlewareOptions{ + Store: s.sessions, + DevBypass: s.cfg.DevMode, + TokenVerifiers: s.tokenVerifiers, + }, handler) + handler = s.rateLimitMiddleware(handler) + handler = s.securityHeaders(handler) + handler = s.loggingMiddleware(handler) + return handler +} + func (s *Server) healthz(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("ok")) @@ -203,13 +261,133 @@ func (s *Server) me(w http.ResponseWriter, r *http.Request) { return } w.Header().Set("Content-Type", "application/json") - _, _ = fmt.Fprintf(w, `{"email":%q,"name":%q,"picture":%q}`, user.Email, user.Name, user.Picture) + writeJSONResponse(w, http.StatusOK, map[string]any{"email": user.Email, "name": user.Name, "picture": user.Picture, "issuer": user.Issuer, "subject": user.Subject, "scopes": user.Scopes, "auth_method": user.AuthMethod, "csrf_token": s.sessions.CSRFToken(user)}) +} + +func (s *Server) login(w http.ResponseWriter, r *http.Request) { + destination := "/auth/google/login" + if s.oidc != nil { + destination = "/auth/oidc/login" + } else if s.google == nil { + http.Error(w, "no browser login provider is configured", http.StatusServiceUnavailable) + return + } + if returnTo := r.URL.Query().Get("return_to"); returnTo != "" { + destination += "?return_to=" + url.QueryEscape(returnTo) + } + http.Redirect(w, r, destination, http.StatusFound) +} + +func (s *Server) tokens(w http.ResponseWriter, r *http.Request) { + user, ok := s.browserTokenManager(w, r) + if !ok { + return + } + switch r.Method { + case http.MethodGet: + tokens, err := s.sessions.ListPersonalAPITokens(r.Context(), user) + if err != nil { + http.Error(w, "unable to list tokens", http.StatusInternalServerError) + return + } + writeJSONResponse(w, http.StatusOK, map[string]any{"tokens": tokens}) + case http.MethodPost: + if !s.sessions.ValidCSRFToken(user, r.Header.Get("X-CSRF-Token")) { + http.Error(w, "invalid CSRF token", http.StatusForbidden) + return + } + r.Body = http.MaxBytesReader(w, r.Body, 16<<10) + var request struct { + Name string `json:"name"` + Scopes []string `json:"scopes"` + ExpiresAt string `json:"expires_at"` + } + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + http.Error(w, "invalid JSON", http.StatusBadRequest) + return + } + var expires time.Time + var err error + if request.ExpiresAt != "" { + expires, err = time.Parse(time.RFC3339, request.ExpiresAt) + if err != nil || expires.Before(time.Now()) { + http.Error(w, "expires_at must be a future RFC3339 time", http.StatusBadRequest) + return + } + } + metadata, plaintext, err := s.sessions.CreatePersonalAPIToken(r.Context(), user, request.Name, request.Scopes, expires) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + writeJSONResponse(w, http.StatusCreated, map[string]any{"token": metadata, "plaintext": plaintext}) + default: + w.Header().Set("Allow", "GET, POST") + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + +func (s *Server) tokenByID(w http.ResponseWriter, r *http.Request) { + user, ok := s.browserTokenManager(w, r) + if !ok { + return + } + if r.Method != http.MethodDelete { + w.Header().Set("Allow", "DELETE") + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + if !s.sessions.ValidCSRFToken(user, r.Header.Get("X-CSRF-Token")) { + http.Error(w, "invalid CSRF token", http.StatusForbidden) + return + } + id := strings.TrimPrefix(r.URL.Path, "/api/v1/tokens/") + if id == "" || strings.Contains(id, "/") { + http.Error(w, "invalid token ID", http.StatusBadRequest) + return + } + if err := s.sessions.RevokePersonalAPIToken(r.Context(), user, id); err != nil { + http.Error(w, "token not found", http.StatusNotFound) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (s *Server) browserTokenManager(w http.ResponseWriter, r *http.Request) (*auth.Session, bool) { + if r.Header.Get("Authorization") != "" { + http.Error(w, "personal tokens cannot manage personal tokens", http.StatusForbidden) + return nil, false + } + user := auth.UserFromContext(r.Context()) + if user == nil { + user, _ = s.sessions.SessionFromRequest(r.Context(), r) + } + if user == nil || user.Subject == "" || user.Issuer == "" { + http.Error(w, "browser login required", http.StatusUnauthorized) + return nil, false + } + return user, true +} + +func writeJSONResponse(w http.ResponseWriter, status int, value any) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(value) } // staticHandler serves the embedded React frontend with smart routing for Next.js static export. func (s *Server) staticHandler() http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { path := strings.TrimPrefix(r.URL.Path, "/") + switch path { + case "openapi.yaml": + w.Header().Set("Content-Type", "application/yaml") + case "openapi.json": + w.Header().Set("Content-Type", "application/json") + case "llms.txt": + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + } // Handle root - serve index.html if path == "" { @@ -278,7 +456,7 @@ func (s *Server) rateLimitMiddleware(next http.Handler) http.Handler { next.ServeHTTP(w, r) return } - ip := clientIP(r) + ip := observedPeerIP(r) limiter := s.limiters.get(ip) if !limiter.Allow() { s.logger.Warn("rate_limit_exceeded", "ip", ip, "path", r.URL.Path) @@ -294,7 +472,7 @@ func (s *Server) loggingMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Skip wrapping for SSE endpoint - the wrapper can interfere with HTTP/2 streaming if r.URL.Path == "/v1/events" { - s.logger.Info("http_sse_start", "method", r.Method, "path", r.URL.Path, "ip", clientIP(r)) + s.logger.Info("http_sse_start", "method", r.Method, "path", r.URL.Path, "ip", observedPeerIP(r)) next.ServeHTTP(w, r) s.logger.Info("http_sse_end", "method", r.Method, "path", r.URL.Path) return @@ -315,7 +493,7 @@ func (s *Server) loggingMiddleware(next http.Handler) http.Handler { "status", sw.status, "duration_ms", time.Since(start).Milliseconds(), "size_bytes", sw.bytes, - "ip", clientIP(r), + "ip", observedPeerIP(r), "user", user, ) }) @@ -345,19 +523,6 @@ func (w *statusWriter) Flush() { } } -func clientIP(r *http.Request) string { - xff := r.Header.Get("X-Forwarded-For") - if xff != "" { - parts := strings.Split(xff, ",") - return strings.TrimSpace(parts[0]) - } - host, _, err := net.SplitHostPort(r.RemoteAddr) - if err != nil { - return r.RemoteAddr - } - return host -} - type ipRateLimiter struct { mu sync.Mutex visitors map[string]*rate.Limiter @@ -386,7 +551,7 @@ func (l *ipRateLimiter) get(ip string) *rate.Limiter { func shouldRateLimit(path string) bool { path = strings.ToLower(path) - if path == "/login" || strings.HasPrefix(path, "/auth/") { + if path == "/login" || strings.HasPrefix(path, "/auth/") || path == "/oauth/register" || path == "/oauth/token" || path == "/oauth/authorize" { return true } return false diff --git a/gateway/pkg/proxy/server_security_test.go b/gateway/pkg/proxy/server_security_test.go new file mode 100644 index 0000000..db8810a --- /dev/null +++ b/gateway/pkg/proxy/server_security_test.go @@ -0,0 +1,24 @@ +// Copyright 2025 StrongDM Inc +// SPDX-License-Identifier: Apache-2.0 + +package proxy + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestSecurityHeadersPreservesHandlerSpecificCSP(t *testing.T) { + server := &Server{cspHeader: "default-src 'self'; form-action 'self'"} + consentCSP := "default-src 'none'; form-action 'self' http://127.0.0.1:6276" + handler := server.securityHeaders(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Security-Policy", consentCSP) + w.WriteHeader(http.StatusOK) + })) + response := httptest.NewRecorder() + handler.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/oauth/authorize", nil)) + if got := response.Header().Get("Content-Security-Policy"); got != consentCSP { + t.Fatalf("Content-Security-Policy = %q, want %q", got, consentCSP) + } +} diff --git a/gateway/pkg/proxy/server_tokens_test.go b/gateway/pkg/proxy/server_tokens_test.go new file mode 100644 index 0000000..dac1241 --- /dev/null +++ b/gateway/pkg/proxy/server_tokens_test.go @@ -0,0 +1,187 @@ +// Copyright 2025 StrongDM Inc +// SPDX-License-Identifier: Apache-2.0 + +package proxy + +import ( + "bytes" + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + "testing/fstest" + "time" + + "github.com/strongdm/cxdb/gateway/internal/config" + "github.com/strongdm/cxdb/gateway/pkg/auth" +) + +func TestPersonalTokenHTTPCreateListRevokeAndCSRF(t *testing.T) { + store, err := auth.NewSessionStore(filepath.Join(t.TempDir(), "sessions.sqlite"), "session", time.Hour, "", false, "test-secret") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = store.Close() }) + sessionID, err := store.CreateForIdentity(t.Context(), "https://issuer.example", "alice", "alice@example.com", "Alice", "", "oidc", []string{"cxdb:read", "cxdb:write"}) + if err != nil { + t.Fatal(err) + } + session, err := store.Get(t.Context(), sessionID) + if err != nil { + t.Fatal(err) + } + server := &Server{sessions: store} + cookieRecorder := httptest.NewRecorder() + store.SetCookie(cookieRecorder, sessionID) + cookie := cookieRecorder.Result().Cookies()[0] + + badRequest := httptest.NewRequest(http.MethodPost, "/api/v1/tokens", strings.NewReader(`{"name":"laptop","scopes":["cxdb:read"]}`)) + badRequest.AddCookie(cookie) + badResponse := httptest.NewRecorder() + server.tokens(badResponse, badRequest) + if badResponse.Code != http.StatusForbidden { + t.Fatalf("missing CSRF status = %d", badResponse.Code) + } + + createRequest := httptest.NewRequest(http.MethodPost, "/api/v1/tokens", strings.NewReader(`{"name":"laptop","scopes":["cxdb:read","cxdb:write"]}`)) + createRequest.AddCookie(cookie) + createRequest.Header.Set("X-CSRF-Token", store.CSRFToken(session)) + createResponse := httptest.NewRecorder() + server.tokens(createResponse, createRequest) + if createResponse.Code != http.StatusCreated { + t.Fatalf("create status = %d, body=%s", createResponse.Code, createResponse.Body.String()) + } + var created struct { + Token auth.APIToken `json:"token"` + Plaintext string `json:"plaintext"` + } + if err := json.Unmarshal(createResponse.Body.Bytes(), &created); err != nil { + t.Fatal(err) + } + if created.Plaintext == "" { + t.Fatal("create did not return the one-time plaintext") + } + + listRequest := httptest.NewRequest(http.MethodGet, "/api/v1/tokens", nil) + listRequest.AddCookie(cookie) + listResponse := httptest.NewRecorder() + server.tokens(listResponse, listRequest) + if listResponse.Code != http.StatusOK || bytes.Contains(listResponse.Body.Bytes(), []byte(created.Plaintext)) || bytes.Contains(listResponse.Body.Bytes(), []byte("token_hash")) { + t.Fatalf("unsafe list response: status=%d body=%s", listResponse.Code, listResponse.Body.String()) + } + + bearerRequest := httptest.NewRequest(http.MethodGet, "/api/v1/tokens", nil) + bearerRequest.AddCookie(cookie) + bearerRequest.Header.Set("Authorization", "Bearer "+created.Plaintext) + bearerResponse := httptest.NewRecorder() + server.tokens(bearerResponse, bearerRequest) + if bearerResponse.Code != http.StatusForbidden { + t.Fatalf("bearer token management status = %d", bearerResponse.Code) + } + + revokeRequest := httptest.NewRequest(http.MethodDelete, "/api/v1/tokens/"+created.Token.ID, nil) + revokeRequest.AddCookie(cookie) + revokeRequest.Header.Set("X-CSRF-Token", store.CSRFToken(session)) + revokeResponse := httptest.NewRecorder() + server.tokenByID(revokeResponse, revokeRequest) + if revokeResponse.Code != http.StatusNoContent { + t.Fatalf("revoke status = %d", revokeResponse.Code) + } + if _, err := store.VerifyAPIToken(t.Context(), created.Plaintext); err == nil { + t.Fatal("revoked token still verifies") + } +} + +func TestProductionHandlerPersonalTokenLifecycleAndAPIUse(t *testing.T) { + store, err := auth.NewSessionStore(filepath.Join(t.TempDir(), "sessions.sqlite"), "session", time.Hour, "", false, "test-secret") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = store.Close() }) + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"ok":true}`) + })) + t.Cleanup(backend.Close) + reverse, err := NewReverseProxy(backend.URL, slog.Default()) + if err != nil { + t.Fatal(err) + } + cfg := config.Config{ + PublicBaseURL: "http://localhost:8080", CXDBBackendURL: backend.URL, + Port: "0", DevMode: true, + } + server, err := New(cfg, store, nil, reverse, fstest.MapFS{ + "index.html": {Data: []byte("CXDB")}, + }, slog.Default()) + if err != nil { + t.Fatal(err) + } + remote := httptest.NewServer(server.Handler()) + t.Cleanup(remote.Close) + + meResponse, err := http.Get(remote.URL + "/api/v1/me") + if err != nil { + t.Fatal(err) + } + defer meResponse.Body.Close() + var me struct { + CSRFToken string `json:"csrf_token"` + } + if meResponse.StatusCode != http.StatusOK || json.NewDecoder(meResponse.Body).Decode(&me) != nil || me.CSRFToken == "" { + t.Fatalf("me response status = %d", meResponse.StatusCode) + } + + createRequest, _ := http.NewRequest(http.MethodPost, remote.URL+"/api/v1/tokens", strings.NewReader(`{"name":"integration","scopes":["cxdb:read","cxdb:write"]}`)) + createRequest.Header.Set("Content-Type", "application/json") + createRequest.Header.Set("X-CSRF-Token", me.CSRFToken) + createResponse, err := http.DefaultClient.Do(createRequest) + if err != nil { + t.Fatal(err) + } + defer createResponse.Body.Close() + var created struct { + Token auth.APIToken `json:"token"` + Plaintext string `json:"plaintext"` + } + if createResponse.StatusCode != http.StatusCreated || json.NewDecoder(createResponse.Body).Decode(&created) != nil || created.Plaintext == "" { + t.Fatalf("create response status = %d", createResponse.StatusCode) + } + + apiRequest, _ := http.NewRequest(http.MethodGet, remote.URL+"/v1/private", nil) + apiRequest.Header.Set("Authorization", "Bearer "+created.Plaintext) + apiResponse, err := http.DefaultClient.Do(apiRequest) + if err != nil { + t.Fatal(err) + } + apiResponse.Body.Close() + if apiResponse.StatusCode != http.StatusOK { + t.Fatalf("API token request status = %d", apiResponse.StatusCode) + } + + revokeRequest, _ := http.NewRequest(http.MethodDelete, remote.URL+"/api/v1/tokens/"+created.Token.ID, nil) + revokeRequest.Header.Set("X-CSRF-Token", me.CSRFToken) + revokeResponse, err := http.DefaultClient.Do(revokeRequest) + if err != nil { + t.Fatal(err) + } + revokeResponse.Body.Close() + if revokeResponse.StatusCode != http.StatusNoContent { + t.Fatalf("revoke response status = %d", revokeResponse.StatusCode) + } + + revokedRequest, _ := http.NewRequest(http.MethodGet, remote.URL+"/v1/private", nil) + revokedRequest.Header.Set("Authorization", "Bearer "+created.Plaintext) + revokedResponse, err := http.DefaultClient.Do(revokedRequest) + if err != nil { + t.Fatal(err) + } + revokedResponse.Body.Close() + if revokedResponse.StatusCode != http.StatusUnauthorized { + t.Fatalf("revoked token status = %d", revokedResponse.StatusCode) + } +} From 9a40c509420073e453793a341dd185dafd2a964e Mon Sep 17 00:00:00 2001 From: Navan Chauhan Date: Tue, 25 Aug 2026 14:17:01 -0600 Subject: [PATCH 3/6] feat(web): add mobile tokens and exact trace hydration --- .gitignore | 2 + docs/CQL_REFERENCE.md | 87 ++ docs/client-authentication.md | 41 + docs/http-api.md | 16 +- docs/mcp.md | 28 + frontend/app/globals.css | 20 + frontend/components/ContextDebugger.tsx | 643 ++++++++++--- frontend/components/CxdbApp.tsx | 114 ++- frontend/components/QuestRenderer.tsx | 5 +- frontend/components/ThemeSelector.tsx | 6 +- frontend/components/TokenManagement.tsx | 222 +++++ .../components/dashboard/CapacityGauge.tsx | 7 +- .../components/dashboard/ObjectCountsCard.tsx | 11 +- .../dashboard/ServerHealthDashboard.tsx | 17 +- .../dashboard/SessionsErrorsBar.tsx | 11 +- frontend/lib/api.ts | 88 +- frontend/next.config.mjs | 8 +- frontend/playwright.config.ts | 8 +- frontend/public/llms.txt | 29 + frontend/public/openapi.json | 906 ++++++++++++++++++ frontend/public/openapi.yaml | 321 +++++++ frontend/tests/copy-buttons.spec.ts | 4 +- frontend/tests/live-observer.spec.ts | 126 ++- frontend/tests/mobile-responsive.spec.ts | 141 +++ frontend/tests/token-management.spec.ts | 88 ++ frontend/tests/turn-dag.spec.ts | 38 + frontend/tests/utils/assertions.ts | 2 +- frontend/tsconfig.json | 24 +- frontend/types/index.ts | 33 + 29 files changed, 2802 insertions(+), 244 deletions(-) create mode 100644 docs/CQL_REFERENCE.md create mode 100644 docs/client-authentication.md create mode 100644 docs/mcp.md create mode 100644 frontend/components/TokenManagement.tsx create mode 100644 frontend/public/llms.txt create mode 100644 frontend/public/openapi.json create mode 100644 frontend/public/openapi.yaml create mode 100644 frontend/tests/mobile-responsive.spec.ts create mode 100644 frontend/tests/token-management.spec.ts diff --git a/.gitignore b/.gitignore index b8581b0..ff8ff27 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,8 @@ pnpm-debug.log* dist/ build/ .next/ +.next-build/ +.next-playwright-*/ out/ .turbo/ .vercel/ diff --git a/docs/CQL_REFERENCE.md b/docs/CQL_REFERENCE.md new file mode 100644 index 0000000..23c948e --- /dev/null +++ b/docs/CQL_REFERENCE.md @@ -0,0 +1,87 @@ +# CQL reference + +CQL is the CXDB query language for filtering contexts through +`GET /v1/contexts/search?q={query}&limit={n}`. + +## Examples + +```text +tag = "amplifier" +tag = "amplifier" AND user = "alice" +user = "alice" +service ^= "worker" +created > "-24h" +(service = "worker" OR service = "api") AND NOT tag = "test" +``` + +## Boolean operators + +| Operator | Precedence | Example | +| --- | --- | --- | +| `NOT` | Highest | `NOT tag = "test"` | +| `AND` | Medium | `tag = "a" AND user = "b"` | +| `OR` | Lowest | `tag = "a" OR tag = "b"` | + +Use parentheses to change the normal precedence. + +## Comparison operators + +| Operator | Meaning | Example | +| --- | --- | --- | +| `=` | Exact match | `tag = "amplifier"` | +| `!=` | Not equal | `service != "test"` | +| `^=` | Starts with | `tag ^= "amp"` | +| `~=` | Case-insensitive equality | `user ~= "Alice"` | +| `^~=` | Case-insensitive prefix | `service ^~= "API"` | +| `>` | Greater than | `created > "-24h"` | +| `>=` | Greater than or equal | `depth >= 5` | +| `<` | Less than | `created < "2026-01-01"` | +| `<=` | Less than or equal | `depth <= 10` | +| `IN` | List membership | `tag IN ("a", "b")` | + +## Fields + +| Field | Type | Meaning | +| --- | --- | --- | +| `id` | number | Context ID | +| `tag` | string | Client tag | +| `title` | string | Context title | +| `label` | string | Context label | +| `user` | string | User identity | +| `service` | string | Service name | +| `host` | string | Host name | +| `trace_id` | string | Trace ID | +| `parent` | number | Parent context ID | +| `root` | number | Root context ID | +| `created` | datetime | Creation time | +| `depth` | number | Conversation depth | +| `is_live` | boolean | Active session state | + +Relative time values use `-Nh`, `-Nd`, or `-Nm`. Absolute values can use an +ISO 8601 timestamp or a date in `YYYY-MM-DD` form. + +## HTTP example + +```bash +curl --get 'http://localhost:9010/v1/contexts/search' \ + --data-urlencode 'q=tag = "amplifier" AND created > "-24h"' \ + --data-urlencode 'limit=20' +``` + +The gateway requires a signed browser session or a personal API token. + +## Grammar + +```ebnf +query = expression ; +expression = or_expr ; +or_expr = and_expr { "OR" and_expr } ; +and_expr = unary_expr { "AND" unary_expr } ; +unary_expr = [ "NOT" ] primary ; +primary = comparison | "(" expression ")" ; +comparison = field operator value ; +field = identifier ; +operator = "=" | "!=" | "^=" | "~=" | "^~=" | ">" | ">=" | "<" | "<=" | "IN" ; +value = string | number | date | list ; +list = "(" value { "," value } ")" ; +``` diff --git a/docs/client-authentication.md b/docs/client-authentication.md new file mode 100644 index 0000000..d936c98 --- /dev/null +++ b/docs/client-authentication.md @@ -0,0 +1,41 @@ +# Client authentication + +CXDB supports browser login and scoped bearer tokens through the gateway. + +## Browser login + +The gateway uses the configured OpenID Connect (OIDC) provider. The provider +must return a verified identity. The gateway creates a browser session and a +session-bound CSRF token for state-changing requests. + +## Personal API tokens + +Authenticated users can create personal tokens in the Web UI. A token has a +name, an optional expiry, and one or both of these scopes: + +- `cxdb:read` for context and turn reads. +- `cxdb:write` for context creation and turn appends. + +The token secret is shown only once. Store it in a secret manager. Send it in +the HTTP header below. Do not put a token in a URL or browser storage. + +```http +Authorization: Bearer +``` + +The Web UI uses `X-CSRF-Token` for create and revoke requests. Token metadata +does not include token secrets. A revoked or expired token cannot be used. + +## MCP OAuth + +Remote MCP clients can use OAuth 2.1 authorization code flow with PKCE. Use +the protected-resource metadata at +`/.well-known/oauth-protected-resource/mcp` and the authorization-server +metadata at `/.well-known/oauth-authorization-server`. + +The gateway delegates browser identity checks to the configured OIDC provider. +Dynamic client registration is available at `/oauth/register`. Redirect URIs +must use HTTPS or loopback HTTP. MCP clients can also use a personal bearer +token with `cxdb:read`; write tools also require `cxdb:write`. + +See [MCP guidance](mcp.md) and the published [OpenAPI JSON](../frontend/public/openapi.json). diff --git a/docs/http-api.md b/docs/http-api.md index c87f296..360c1c1 100644 --- a/docs/http-api.md +++ b/docs/http-api.md @@ -8,10 +8,18 @@ The CXDB HTTP gateway provides a JSON API for reading turns, managing contexts, **Development:** No authentication required when connecting directly to the Rust server -**Production:** The Go gateway provides Google OAuth authentication: -- Unauthenticated requests to `/v1/*` return `302 Found` redirect to `/login` -- After OAuth, requests include session cookie -- Session expires after 24 hours of inactivity +**Gateway authentication:** The Go gateway uses the configured OIDC provider +for browser sessions. Clients may also send a personal bearer token: + +```http +Authorization: Bearer +``` + +Read requests require `cxdb:read`. Context creation and turn append requests +also require `cxdb:write`. The Web UI creates and revokes tokens. A token +secret is shown only once. Do not put tokens in URLs or browser storage. + +See [client authentication](client-authentication.md) and [MCP guidance](mcp.md). ## Contexts diff --git a/docs/mcp.md b/docs/mcp.md new file mode 100644 index 0000000..dc8bafb --- /dev/null +++ b/docs/mcp.md @@ -0,0 +1,28 @@ +# CXDB remote MCP + +CXDB serves Streamable HTTP MCP at `/mcp`. It uses the current 2026-07-28 protocol through the official Go MCP SDK. The endpoint is stateless and validates cross-origin requests. + +## Authentication + +The gateway publishes OAuth protected-resource metadata at `/.well-known/oauth-protected-resource/mcp` and authorization-server metadata at `/.well-known/oauth-authorization-server`. + +Remote clients use OAuth 2.1 authorization code flow with PKCE S256. CXDB is the OAuth authorization server. It delegates the browser identity check to the configured OIDC provider. Dynamic client registration is available at `/oauth/register`. Redirect URIs must use HTTPS or loopback HTTP. + +Personal API tokens also work as MCP bearer tokens. Create them in the Web UI. A token needs `cxdb:read` to connect. Write tools also require `cxdb:write`. + +## Tools + +- `cxdb_list_contexts` +- `cxdb_search_contexts` +- `cxdb_get_context` +- `cxdb_get_turns` +- `cxdb_get_provenance` +- `cxdb_create_context` +- `cxdb_append_message` +- `cxdb_append_turn` + +Use `turn_id` with `cxdb_get_turns` to hydrate one complete turn. A bounded list response is a summary and can contain truncated strings. + +## Security boundary + +The gateway protects `/mcp` and gateway `/v1` routes. The direct Rust binary port 9009 and HTTP port 9010 keep their current behavior. Do not expose those direct ports when the gateway must be the authentication boundary. diff --git a/frontend/app/globals.css b/frontend/app/globals.css index f032506..a66569f 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -125,7 +125,10 @@ html, body { margin: 0; padding: 0; + width: 100%; min-height: 100vh; + min-height: 100dvh; + overflow-x: hidden; background: var(--bg); color: var(--text); font-family: 'Inter', system-ui, -apple-system, sans-serif; @@ -133,6 +136,23 @@ body { -moz-osx-font-smoothing: grayscale; } +button, +a, +input, +select, +textarea { + touch-action: manipulation; +} + +/* Prevent iOS from zooming the viewport when a form control receives focus. */ +@media (max-width: 767px) { + input, + select, + textarea { + font-size: 16px !important; + } +} + /* Custom scrollbar for dark theme */ ::-webkit-scrollbar { width: 8px; diff --git a/frontend/components/ContextDebugger.tsx b/frontend/components/ContextDebugger.tsx index 1645cf1..1821828 100644 --- a/frontend/components/ContextDebugger.tsx +++ b/frontend/components/ContextDebugger.tsx @@ -1,10 +1,13 @@ +// Copyright 2025 StrongDM Inc +// SPDX-License-Identifier: Apache-2.0 + 'use client'; import { useEffect, useMemo, useRef, useState, useCallback } from 'react'; import type { Turn, TurnResponse, DebugEvent } from '@/types'; import { Layers, Hash, X, Copy, Search, Loader2, AlertCircle, GitBranch, ChevronDown, ChevronRight, Terminal, MessageSquare, Wrench, CheckCircle, XCircle, Folder, Zap, Database } from './icons'; import { cn, trunc, safeStringify, formatTime, contentPreview } from '@/lib/utils'; -import { fetchTurns, fetchFsDirectory, ApiError } from '@/lib/api'; +import { fetchTurn, fetchTurns, fetchFsDirectory, ApiError } from '@/lib/api'; import { FileBrowser } from './FileBrowser'; import { FileViewer } from './FileViewer'; import { TryRenderCanonical, isConversationItem } from './ConversationRenderer'; @@ -17,6 +20,9 @@ import { useRendererManifest } from '@/lib/use-renderer'; import { getItemTypeLabel, getItemTypeColors } from '@/types/conversation'; import type { ConversationItem, ItemType } from '@/types/conversation'; +const TURN_PAGE_SIZE = 100; +const TURN_LIST_STRING_LIMIT = 512; + // View tabs for the right panel type DetailView = 'turn' | 'provenance'; @@ -149,6 +155,21 @@ function extractToolCalls(turn: Turn): Array<{ id: string; name: string; argumen })); } + // A legacy ToolCall is often stored as its own turn rather than inside an + // assistant message. Treat that one payload as a single call so its result + // can be matched by tool_call_id as well. + if (turn.declared_type?.type_id.includes('ToolCall')) { + const id = data.id ?? data.call_id ?? data['1']; + const name = data.name ?? data['2']; + if (id !== undefined && name !== undefined) { + return [{ + id: String(id), + name: String(name), + arguments: String(data.arguments ?? data.args ?? data['3'] ?? '{}'), + }]; + } + } + // Legacy extraction const toolCalls = data.tool_calls as Array> | undefined; if (!Array.isArray(toolCalls)) return []; @@ -161,6 +182,39 @@ function extractToolCalls(turn: Turn): Array<{ id: string; name: string; argumen })); } +/** + * A v2 assistant turn can carry its result in the same payload as the call. + * Such a result is already exact when this turn has been hydrated. Do not + * replace it with a lookup for a separate result turn. + */ +function hasEmbeddedToolResult(turn: Turn, toolCallId: string): boolean { + const data = turn.data as Record | undefined; + if (!data) return false; + + if (isConversationItem(data) && data.item_type === 'assistant_turn' && data.turn?.tool_calls) { + return data.turn.tool_calls.some(toolCall => { + if (toolCall.id !== toolCallId) return false; + return toolCall.result !== undefined + || toolCall.error !== undefined + || toolCall.streaming_output !== undefined; + }); + } + + // Keep compatibility with legacy payloads that use numeric msgpack keys. + const toolCalls = data.tool_calls as Array> | undefined; + if (!Array.isArray(toolCalls)) return false; + + return toolCalls.some(toolCall => { + const id = String(toolCall.id ?? toolCall['1'] ?? ''); + if (id !== toolCallId) return false; + return toolCall.result !== undefined + || toolCall.error !== undefined + || toolCall.streaming_output !== undefined + || toolCall['9'] !== undefined + || toolCall['10'] !== undefined; + }); +} + // Extract tool result info - handles canonical types and legacy formats function extractToolResult(turn: Turn): { toolCallId: string; content: string; isError: boolean } | null { const data = turn.data as Record | undefined; @@ -414,6 +468,128 @@ function TurnContentView({ turn }: { turn: Turn }) { return ; } +type ToolResultHydration = + | { state: 'loading' } + | { state: 'ready'; turn: Turn } + | { state: 'error' }; + +interface ToolResultMatchesProps { + contextId: string; + turn: Turn; + resultTurns: Map; +} + +/** + * Show results for calls which are represented by separate turns. + * + * The list endpoint is deliberately bounded for large traces. A result found + * in that list can therefore still contain a 512-character prefix. Hydrate + * each matched result by ID before rendering it. Missing means "not in the + * loaded page", not "the store has no such result". + */ +function ToolResultMatches({ contextId, turn, resultTurns }: ToolResultMatchesProps) { + const calls = useMemo(() => extractToolCalls(turn), [turn]); + const matches = useMemo(() => calls + .filter(call => !hasEmbeddedToolResult(turn, call.id)) + .map(call => ({ + call, + resultTurn: resultTurns.get(call.id) ?? null, + key: `${call.id}:${resultTurns.get(call.id)?.turn_id ?? 'missing'}`, + })), [calls, resultTurns, turn]); + const matchKey = matches.map(match => match.key).join('|'); + const [hydration, setHydration] = useState>({}); + + useEffect(() => { + let cancelled = false; + const initial: Record = {}; + for (const match of matches) { + if (match.resultTurn) initial[match.key] = { state: 'loading' }; + } + setHydration(initial); + + const matchedResults = matches.filter((match): match is typeof match & { resultTurn: Turn } => ( + match.resultTurn !== null + )); + if (matchedResults.length === 0) return () => { cancelled = true; }; + + for (const match of matchedResults) { + fetchTurn(contextId, match.resultTurn.turn_id) + .then(exactTurn => { + if (!cancelled) { + setHydration(previous => ({ + ...previous, + [match.key]: { state: 'ready', turn: exactTurn }, + })); + } + }) + .catch(() => { + if (!cancelled) { + setHydration(previous => ({ ...previous, [match.key]: { state: 'error' } })); + } + }); + } + + return () => { cancelled = true; }; + }, [contextId, matchKey, matches]); + + if (matches.length === 0) return null; + + return ( + {matches.length}} + > +
+ {matches.map(match => { + const result = hydration[match.key]; + return ( +
+
+ + {match.call.name} + {match.call.id} + {match.resultTurn && ( + + Turn #{match.resultTurn.turn_id} + + )} +
+ + {!match.resultTurn ? ( +
+ + No separate result turn in the loaded page. +
+ ) : result?.state === 'error' ? ( +
+ + Failed to load the complete result turn. +
+ ) : result?.state === 'ready' ? ( + + ) : ( +
+ + Loading the complete result… +
+ )} +
+ ); + })} +
+
+ ); +} + // Format arguments JSON for display function formatArguments(args: string): string { try { @@ -437,11 +613,10 @@ interface ContextDebuggerProps { onNavigateToContext?: (contextId: string) => void; } -const TURNS_PAGE_SIZE = 100; - export function ContextDebugger({ contextId, isOpen, onClose, lastEvent, initialTurnId, onTurnChange, onNavigateToContext }: ContextDebuggerProps) { const containerRef = useRef(null); const turnListRef = useRef(null); + const lastResetContextIdRef = useRef(null); const [query, setQuery] = useState(''); const [selectedIdx, setSelectedIdx] = useState(0); const [copied, setCopied] = useState<'context' | 'event' | null>(null); @@ -449,9 +624,15 @@ export function ContextDebugger({ contextId, isOpen, onClose, lastEvent, initial // Data fetching state const [loading, setLoading] = useState(false); - const [loadingMore, setLoadingMore] = useState(false); + const [loadingOlder, setLoadingOlder] = useState(false); const [error, setError] = useState(null); const [data, setData] = useState(null); + const [hasMoreTurns, setHasMoreTurns] = useState(false); + const [selectedTurnDetail, setSelectedTurnDetail] = useState(null); + const [detailLoading, setDetailLoading] = useState(false); + const [detailError, setDetailError] = useState(null); + const [searchHydrating, setSearchHydrating] = useState(false); + const [searchHydrationError, setSearchHydrationError] = useState(null); // Live observer state const [newTurnIds, setNewTurnIds] = useState>(new Set()); @@ -477,11 +658,13 @@ export function ContextDebugger({ contextId, isOpen, onClose, lastEvent, initial try { const response = await fetchTurns(contextId, { - limit: TURNS_PAGE_SIZE, + limit: TURN_PAGE_SIZE, view: 'typed', include_unknown: true, + string_limit: TURN_LIST_STRING_LIMIT, }); setData(response); + setHasMoreTurns(response.turns.length === TURN_PAGE_SIZE); } catch (err) { if (err instanceof ApiError) { setError(err.message); @@ -494,31 +677,48 @@ export function ContextDebugger({ contextId, isOpen, onClose, lastEvent, initial } }, [contextId]); - // Load older turns using pagination cursor - const loadMore = useCallback(async () => { - if (!contextId || !data?.next_before_turn_id) return; + const loadOlderTurns = useCallback(async () => { + if (!contextId || !data?.next_before_turn_id || loadingOlder) return; + + setLoadingOlder(true); + setError(null); - setLoadingMore(true); try { const response = await fetchTurns(contextId, { - limit: TURNS_PAGE_SIZE, + limit: TURN_PAGE_SIZE, before_turn_id: data.next_before_turn_id, view: 'typed', include_unknown: true, + string_limit: TURN_LIST_STRING_LIMIT, }); - const prepended = response.turns.length; - setData(prev => prev ? { - ...prev, - turns: [...response.turns, ...prev.turns], - next_before_turn_id: response.next_before_turn_id, - } : response); - setSelectedIdx(prev => prev + prepended); - } catch { - // Keep existing data on failure + setData(prev => { + if (!prev) return response; + const seen = new Set(prev.turns.map(turn => turn.turn_id)); + const olderTurns = response.turns.filter(turn => !seen.has(turn.turn_id)); + if (olderTurns.length === 0) { + return { + ...prev, + next_before_turn_id: response.next_before_turn_id, + }; + } + setSelectedIdx(idx => idx + olderTurns.length); + return { + ...prev, + turns: [...olderTurns, ...prev.turns], + next_before_turn_id: response.next_before_turn_id, + }; + }); + setHasMoreTurns(response.turns.length === TURN_PAGE_SIZE); + } catch (err) { + if (err instanceof ApiError) { + setError(err.message); + } else { + setError('Failed to fetch older turns'); + } } finally { - setLoadingMore(false); + setLoadingOlder(false); } - }, [contextId, data?.next_before_turn_id]); + }, [contextId, data?.next_before_turn_id, loadingOlder]); useEffect(() => { if (isOpen && contextId) { @@ -568,10 +768,14 @@ export function ContextDebugger({ contextId, isOpen, onClose, lastEvent, initial }, []); // Filter turns by search query + const hasSearchQuery = query.trim().length > 0; + const isSummaryPage = typeof data?.meta.string_limit === 'number'; const filteredTurns = useMemo(() => { if (!data?.turns) return []; const q = query.trim().toLowerCase(); if (!q) return data.turns; + // Never present prefix-only filtering as a complete search result. + if (typeof data.meta.string_limit === 'number') return []; return data.turns.filter(turn => { const content = extractContent(turn)?.toLowerCase() ?? ''; @@ -583,7 +787,102 @@ export function ContextDebugger({ contextId, isOpen, onClose, lastEvent, initial }, [data, query]); // Selected turn - const selectedTurn = filteredTurns[selectedIdx] ?? null; + const selectedListTurn = filteredTurns[selectedIdx] ?? null; + const selectedTurn = selectedTurnDetail?.turn_id === selectedListTurn?.turn_id + ? selectedTurnDetail + : selectedListTurn; + const selectedTurnIsExact = !isSummaryPage + || selectedTurnDetail?.turn_id === selectedListTurn?.turn_id; + + // Build a bounded index from the turns already loaded. Matching must not + // fetch the complete context history, especially for large traces. + const separateToolResultTurns = useMemo(() => { + const resultTurns = new Map(); + for (const turn of data?.turns ?? []) { + const result = extractToolResult(turn); + if (result && result.toolCallId !== 'unknown') { + resultTurns.set(result.toolCallId, turn); + } + } + return resultTurns; + }, [data]); + + // List pages carry bounded string prefixes. Fetch only the selected turn's + // complete payload for the detail renderer. + useEffect(() => { + const turnId = selectedListTurn?.turn_id; + if (!turnId || typeof data?.meta.string_limit !== 'number') { + setSelectedTurnDetail(null); + setDetailLoading(false); + setDetailError(null); + return; + } + + let cancelled = false; + let requestStarted = false; + setSelectedTurnDetail(null); + setDetailLoading(true); + setDetailError(null); + // Selection can move from the first rendered row to the followed tail in + // the same render cycle. Debouncing avoids transferring the discarded + // detail and also coalesces rapid keyboard navigation. + const timer = window.setTimeout(() => { + requestStarted = true; + fetchTurn(contextId, turnId) + .then(turn => { + if (!cancelled) setSelectedTurnDetail(turn); + }) + .catch(() => { + if (!cancelled) setDetailError('Failed to load the complete turn.'); + }) + .finally(() => { + if (!cancelled) setDetailLoading(false); + }); + }, 25); + return () => { + cancelled = true; + if (!requestStarted) window.clearTimeout(timer); + }; + }, [contextId, data?.meta.string_limit, selectedListTurn?.turn_id]); + + // Preserve full-text filtering semantics: the common browsing path uses + // summaries, while entering a query hydrates every currently loaded turn. + useEffect(() => { + if ( + !hasSearchQuery + || !data + || typeof data.meta.string_limit !== 'number' + ) { + if (!hasSearchQuery) { + setSearchHydrating(false); + setSearchHydrationError(null); + } + return; + } + let cancelled = false; + setSearchHydrating(true); + setSearchHydrationError(null); + fetchTurns(contextId, { + limit: data.turns.length, + view: 'typed', + include_unknown: true, + }) + .then(response => { + if (!cancelled) { + setData(response); + setSelectedTurnDetail(null); + } + }) + .catch(() => { + if (!cancelled) { + setSearchHydrationError('Failed to load complete turns for search.'); + } + }) + .finally(() => { + if (!cancelled) setSearchHydrating(false); + }); + return () => { cancelled = true; }; + }, [contextId, data, hasSearchQuery]); // Detect filesystem for selected turn const selectedTurnId = selectedTurn?.turn_id; @@ -628,12 +927,42 @@ export function ContextDebugger({ contextId, isOpen, onClose, lastEvent, initial if (!data?.turns || initialTurnApplied) return; if (initialTurnId) { - // Find and select the specified turn const idx = filteredTurns.findIndex(t => t.turn_id === initialTurnId); if (idx >= 0) { setSelectedIdx(idx); setInitialTurnApplied(true); + return; } + + // A deep link can target a turn outside the bounded first page. Hydrate + // that exact turn and add it to the list so the URL and visible selection + // cannot disagree. + let cancelled = false; + setDetailLoading(true); + setDetailError(null); + fetchTurn(contextId, initialTurnId) + .then(turn => { + if (cancelled) return; + setData(previous => { + if (!previous || previous.turns.some(item => item.turn_id === turn.turn_id)) { + return previous; + } + return { ...previous, turns: [turn, ...previous.turns] }; + }); + setSelectedIdx(0); + setSelectedTurnDetail(turn); + setInitialTurnApplied(true); + }) + .catch(() => { + if (!cancelled) { + setDetailError('Failed to load the linked turn.'); + setInitialTurnApplied(true); + } + }) + .finally(() => { + if (!cancelled) setDetailLoading(false); + }); + return () => { cancelled = true; }; } else if (filteredTurns.length > 0) { // No initial turn specified, notify parent of first turn const firstTurn = filteredTurns[0]; @@ -642,11 +971,11 @@ export function ContextDebugger({ contextId, isOpen, onClose, lastEvent, initial } setInitialTurnApplied(true); } - }, [data, initialTurnId, initialTurnApplied, filteredTurns, onTurnChange]); + }, [contextId, data, initialTurnId, initialTurnApplied, filteredTurns, onTurnChange]); // Count stats - count both tool_call turns AND tool_calls embedded in assistant turns const stats = useMemo(() => { - if (!data?.turns) return { total: 0, loaded: 0, toolCalls: 0, errors: 0 }; + if (!data?.turns) return { loaded: 0, total: 0, toolCalls: 0, errors: 0 }; let toolCalls = 0; let errors = 0; for (const turn of data.turns) { @@ -661,9 +990,12 @@ export function ContextDebugger({ contextId, isOpen, onClose, lastEvent, initial const result = extractToolResult(turn); if (result?.isError) errors++; } - const headId = data.meta?.head_turn_id; - const total = headId && headId !== '0' ? (data.meta?.head_depth ?? 0) + 1 : 0; - return { total, loaded: data.turns.length, toolCalls, errors }; + return { + loaded: data.turns.length, + total: data.meta.head_turn_id === '0' ? 0 : data.meta.head_depth + 1, + toolCalls, + errors, + }; }, [data]); // Auto-select last turn when following and new turns arrive @@ -696,17 +1028,33 @@ export function ContextDebugger({ contextId, isOpen, onClose, lastEvent, initial // Reset state when modal opens/closes useEffect(() => { - if (!isOpen) return; + if (!isOpen) { + lastResetContextIdRef.current = null; + return; + } + + // Don't reset state on URL turn-id changes while open; only reset on open/context changes. + if (lastResetContextIdRef.current === contextId) { + return; + } + lastResetContextIdRef.current = contextId; + setQuery(''); // Only reset to 0 if no initialTurnId; otherwise let the initialTurn effect handle it if (!initialTurnId) { setSelectedIdx(0); } - setInitialTurnApplied(false); setCopied(null); requestAnimationFrame(() => containerRef.current?.focus()); }, [isOpen, contextId, initialTurnId]); + // Allow URL-driven turn selection changes (e.g. browser back/forward) to re-apply without + // wiping the user's current filter query. + useEffect(() => { + if (!isOpen) return; + setInitialTurnApplied(false); + }, [isOpen, initialTurnId]); + // Clear copied state after delay useEffect(() => { if (!copied) return; @@ -718,9 +1066,30 @@ export function ContextDebugger({ contextId, isOpen, onClose, lastEvent, initial const handleCopy = async (kind: 'context' | 'event') => { try { - const text = kind === 'context' - ? safeStringify(data ?? { error: 'No data' }) - : safeStringify(selectedTurn ?? {}); + let value: unknown; + if (kind === 'context' && data && typeof data.meta.string_limit === 'number') { + const complete = await fetchTurns(contextId, { + limit: data.turns.length, + view: 'typed', + include_unknown: true, + }); + setData(complete); + value = complete; + } else if ( + kind === 'event' + && selectedListTurn + && typeof data?.meta.string_limit === 'number' + && selectedTurn?.turn_id !== selectedTurnDetail?.turn_id + ) { + const complete = await fetchTurn(contextId, selectedListTurn.turn_id); + setSelectedTurnDetail(complete); + value = complete; + } else { + value = kind === 'context' + ? data ?? { error: 'No data' } + : selectedTurn ?? {}; + } + const text = safeStringify(value); await navigator.clipboard.writeText(text); setCopied(kind); } catch { @@ -778,51 +1147,56 @@ export function ContextDebugger({ contextId, isOpen, onClose, lastEvent, initial ref={containerRef} tabIndex={-1} onKeyDown={handleKeyDown} - className="h-full w-full outline-none" + className="flex h-[100dvh] w-full flex-col outline-none" data-context-debugger > {/* Header - more compact */} -
-
-
- - Context {contextId} +
+
+
+ + Context {contextId}
{data && ( -
- {stats.loaded < stats.total ? `${stats.loaded} of ${stats.total} turns` : `${stats.total} turns`} - {stats.toolCalls} tool calls +
+ {stats.loaded} of {stats.total} turns loaded + {stats.toolCalls} tool calls {stats.errors > 0 && ( - {stats.errors} errors + {stats.errors} errors )}
)}
-
+
@@ -830,9 +1204,9 @@ export function ContextDebugger({ contextId, isOpen, onClose, lastEvent, initial
{/* Body */} -
+
{/* Left: Turn list - more compact */} -
+
@@ -852,6 +1226,24 @@ export function ContextDebugger({ contextId, isOpen, onClose, lastEvent, initial className={cn('overflow-y-auto relative', hasFilesystem ? 'flex-1 min-h-0' : 'flex-1')} data-debug-event-list > + {!loading && !error && data && hasMoreTurns && !query.trim() && ( +
+ +
+ )} {loading ? (
@@ -862,22 +1254,22 @@ export function ContextDebugger({ contextId, isOpen, onClose, lastEvent, initial {error}
+ ) : searchHydrating ? ( +
+ + Loading complete turns for search… +
+ ) : searchHydrationError ? ( +
+ + {searchHydrationError} +
) : filteredTurns.length === 0 ? (
{data?.turns.length === 0 ? 'No turns.' : 'No matches.'}
) : ( - <> - {data && data.turns.length > 0 && data.turns[0].depth > 0 && ( - - )} - {filteredTurns.map((turn, idx) => { + filteredTurns.map((turn, idx) => { const kind = detectTurnKind(turn); const colors = getKindColors(kind); const isSelected = idx === selectedIdx; @@ -921,8 +1313,7 @@ export function ContextDebugger({ contextId, isOpen, onClose, lastEvent, initial
); - })} - + }) )} {/* Resume following indicator */} @@ -957,7 +1348,7 @@ export function ContextDebugger({ contextId, isOpen, onClose, lastEvent, initial
{/* Right: Detail view */} -
+
{/* File viewer overlay */} {selectedFilePath && selectedTurn && ( {/* Detail view tabs */} -
+
)}
{/* Turn header (when viewing turn) */} {detailView === 'turn' && ( -
+
{getKindLabel(detectTurnKind(selectedTurn))}
- + Turn #{selectedTurn.turn_id} • Depth {selectedTurn.depth}
@@ -1031,48 +1424,72 @@ export function ContextDebugger({ contextId, isOpen, onClose, lastEvent, initial {/* Content area - Turn view */} {detailView === 'turn' && ( -
- {/* Primary content view - uses dynamic renderer registry */} - - - {/* Collapsible metadata */} - - {selectedTurn.declared_type?.type_id?.split('.').pop()} - - } - > -
-
Turn ID
-
{selectedTurn.turn_id}
-
Parent
-
{selectedTurn.parent_turn_id || '(root)'}
-
Depth
-
{selectedTurn.depth}
- {selectedTurn.declared_type && ( - <> -
Type
-
- {selectedTurn.declared_type.type_id}@{selectedTurn.declared_type.type_version} -
- +
+ {!selectedTurnIsExact ? ( +
+ {detailError ? ( + + ) : ( + )} + {detailError ?? (detailLoading + ? 'Loading full turn…' + : 'Waiting for full turn…')}
- - - {/* Collapsible raw payload */} - -
-                        {safeStringify(selectedTurn.data)}
-                      
-
+ ) : ( + <> + {/* Primary content view - uses dynamic renderer registry */} + + + + + {/* Collapsible metadata */} + + {selectedTurn.declared_type?.type_id?.split('.').pop()} + + } + > +
+
Turn ID
+
{selectedTurn.turn_id}
+
Parent
+
{selectedTurn.parent_turn_id || '(root)'}
+
Depth
+
{selectedTurn.depth}
+ {selectedTurn.declared_type && ( + <> +
Type
+
+ {selectedTurn.declared_type.type_id}@{selectedTurn.declared_type.type_version} +
+ + )} +
+
+ + {/* Collapsible raw payload */} + +
+                            {safeStringify(selectedTurn.data)}
+                          
+
+ + )}
)} @@ -1093,7 +1510,7 @@ export function ContextDebugger({ contextId, isOpen, onClose, lastEvent, initial )} {/* Footer */} -
+
j/k Navigate F Follow ⌘K Search diff --git a/frontend/components/CxdbApp.tsx b/frontend/components/CxdbApp.tsx index 5a27d6b..771c72f 100644 --- a/frontend/components/CxdbApp.tsx +++ b/frontend/components/CxdbApp.tsx @@ -1,18 +1,22 @@ +// Copyright 2025 StrongDM Inc +// SPDX-License-Identifier: Apache-2.0 + 'use client'; import { useState, useCallback, useEffect, useMemo, useRef } from 'react'; import { ContextDebugger } from '@/components/ContextDebugger'; import { ContextList } from '@/components/ContextList'; import type { ContextEntry, StoreEvent } from '@/types'; -import { Database, Layers, Plus, X, AlertCircle, Check, Zap, Radio, ChevronDown, Filter } from '@/components/icons'; +import { Database, Layers, Plus, X, AlertCircle, Check, Radio, ChevronDown, Filter, Lock } from '@/components/icons'; import { ThemeSelector } from '@/components/ThemeSelector'; import { getTagColor } from '@/lib/clientTags'; import { cn, normalizeContextId } from '@/lib/utils'; import { healthCheck, fetchContexts, searchContexts } from '@/lib/api'; import { validate as validateCql, buildFallbackQuery, appendSearchCriterionClause, extractSearchCriteriaClauses } from '@/lib/cql'; import { useEventStream, useMockEventGenerator, useUrlRouter, parseUrl, type RouteState } from '@/hooks'; -import { ConnectionStatus, ActivityFeed } from '@/components/live'; +import { ActivityFeed } from '@/components/live'; import { ServerHealthDashboard } from '@/components/dashboard'; +import { TokenManagement } from '@/components/TokenManagement'; export default function CxdbApp() { const [contexts, setContexts] = useState([]); @@ -37,6 +41,7 @@ export default function CxdbApp() { // Environment filter state const [selectedEnv, setSelectedEnv] = useState<'all' | 'prod' | 'stage' | 'dev'>('all'); + const [tokenManagementOpen, setTokenManagementOpen] = useState(false); // URL routing - parse URL on mount and handle changes const handleRouteChange = useCallback((state: RouteState) => { @@ -186,6 +191,14 @@ export default function CxdbApp() { // Mock event generator for demo const { startMockEvents, stopMockEvents } = useMockEventGenerator(mockEmit); + // One demo control owns the generator lifecycle. This also makes the demo + // useful on touch devices without a second hidden action. + useEffect(() => { + if (!mockMode) return; + startMockEvents(2000); + return stopMockEvents; + }, [mockMode, startMockEvents, stopMockEvents]); + // Fetch contexts helper const fetchContextsData = useCallback(async () => { try { @@ -424,7 +437,7 @@ export default function CxdbApp() { if (e.metaKey || e.ctrlKey || e.altKey) return; // Only handle j/k/o when debugger is closed and viewing contexts (not activity) - if (!debuggerOpen && !showActivityFeed) { + if (!debuggerOpen && !showActivityFeed && !tokenManagementOpen) { if (e.key === 'j' || e.key === 'ArrowDown') { e.preventDefault(); setFocusedContextIndex(prev => @@ -454,35 +467,35 @@ export default function CxdbApp() { }; window.addEventListener('keydown', handleKey); return () => window.removeEventListener('keydown', handleKey); - }, [debuggerOpen, showActivityFeed, filteredContexts, focusedContextIndex, handleSelectContext]); + }, [debuggerOpen, showActivityFeed, tokenManagementOpen, filteredContexts, focusedContextIndex, handleSelectContext]); return ( -
+
{/* Header */} -
+
{/* Left: Logo + Title + Env Pills */} -
+
-
+

CXDB

-

AI Context Store

+

AI Context Store

{/* Environment Filter Pills - vertically centered with logo */} -
+
{(['all', 'prod', 'stage', 'dev'] as const).map((env) => (
{/* Right: Controls */} -
- {/* Theme selector */} +
- {/* Mock mode toggle */} + + - {/* Demo button (mock mode only) */} - {mockMode && ( - - )} - - {/* Connection status */} - - - {/* Server status indicator */}
+ )} role="status" aria-label={serverStatus === 'online' ? 'Server online' : serverStatus === 'offline' ? 'Server offline' : 'Checking server status'}> - {serverStatus === 'online' ? 'Server online' : - serverStatus === 'offline' ? 'Server offline' : - 'Checking...'} + {serverStatus === 'online' ? 'Server online' : serverStatus === 'offline' ? 'Server offline' : 'Checking...'}
{/* Main content */} -
+ ); } diff --git a/frontend/components/QuestRenderer.tsx b/frontend/components/QuestRenderer.tsx index a4633e2..ffba37c 100644 --- a/frontend/components/QuestRenderer.tsx +++ b/frontend/components/QuestRenderer.tsx @@ -1,3 +1,6 @@ +// Copyright 2025 StrongDM Inc +// SPDX-License-Identifier: Apache-2.0 + 'use client'; import { useState } from 'react'; @@ -157,7 +160,7 @@ function ActionSummaryCard({ summary }: { summary: ActionSummary }) { const skipped = Number(summary.skipped) || 0; return ( -
+
{total}
Total
diff --git a/frontend/components/ThemeSelector.tsx b/frontend/components/ThemeSelector.tsx index ab8b0ff..27eee84 100644 --- a/frontend/components/ThemeSelector.tsx +++ b/frontend/components/ThemeSelector.tsx @@ -1,3 +1,6 @@ +// Copyright 2025 StrongDM Inc +// SPDX-License-Identifier: Apache-2.0 + 'use client'; import { useState, useRef, useEffect } from 'react'; @@ -73,6 +76,7 @@ export function ThemeSelector({ className }: ThemeSelectorProps) { )} aria-expanded={isOpen} aria-haspopup="listbox" + aria-label={`Theme: ${theme.name}`} > @@ -88,7 +92,7 @@ export function ThemeSelector({ className }: ThemeSelectorProps) { {isOpen && (
void; +} + +function formatDate(value?: string | null): string { + if (!value) return 'Never'; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return 'Unknown'; + return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(date); +} + +function isExpired(value: string): boolean { + const date = new Date(value); + return !Number.isNaN(date.getTime()) && date.getTime() <= Date.now(); +} + +export function TokenManagement({ isOpen, onClose }: TokenManagementProps) { + const [tokens, setTokens] = useState([]); + const [csrfToken, setCsrfToken] = useState(null); + const [loading, setLoading] = useState(false); + const [creating, setCreating] = useState(false); + const [error, setError] = useState(null); + const [name, setName] = useState(''); + const [includeWrite, setIncludeWrite] = useState(false); + const [expiresAt, setExpiresAt] = useState(''); + const [newPlaintext, setNewPlaintext] = useState(null); + const [copyStatus, setCopyStatus] = useState<'idle' | 'copied' | 'failed'>('idle'); + const [revokingId, setRevokingId] = useState(null); + const [confirmingId, setConfirmingId] = useState(null); + + useEffect(() => { + if (!isOpen) { + setNewPlaintext(null); + setCopyStatus('idle'); + setConfirmingId(null); + return; + } + + let cancelled = false; + setLoading(true); + setError(null); + const load = async () => { + try { + const user = await fetchCurrentUser(); + const listedTokens = await fetchAPITokens(); + if (!cancelled) { + setCsrfToken(user.csrf_token); + setTokens(listedTokens); + } + } catch (err) { + if (!cancelled) setError(err instanceof Error ? err.message : 'Unable to load API tokens.'); + } finally { + if (!cancelled) setLoading(false); + } + }; + void load(); + return () => { cancelled = true; }; + }, [isOpen]); + + if (!isOpen) return null; + + const closePanel = () => { + setNewPlaintext(null); + setCopyStatus('idle'); + onClose(); + }; + + const handleCreate = async (event: React.FormEvent) => { + event.preventDefault(); + const trimmedName = name.trim(); + if (!trimmedName) { + setError('Enter a name for this token.'); + return; + } + if (!csrfToken) { + setError('Your browser session is not ready. Reload and try again.'); + return; + } + + let expires: string | undefined; + if (expiresAt) { + const date = new Date(expiresAt); + if (Number.isNaN(date.getTime()) || date.getTime() <= Date.now()) { + setError('Expiry must be a future date.'); + return; + } + expires = date.toISOString(); + } + + setCreating(true); + setError(null); + try { + const result = await createAPIToken(csrfToken, { + name: trimmedName, + scopes: includeWrite ? ['cxdb:read', 'cxdb:write'] : ['cxdb:read'], + ...(expires ? { expires_at: expires } : {}), + }); + setTokens(previous => [result.token, ...previous.filter(token => token.id !== result.token.id)]); + setNewPlaintext(result.plaintext); + setCopyStatus('idle'); + setName(''); + setIncludeWrite(false); + setExpiresAt(''); + } catch (err) { + setError(err instanceof Error ? err.message : 'Unable to create API token.'); + } finally { + setCreating(false); + } + }; + + const handleCopy = async () => { + if (!newPlaintext) return; + try { + await navigator.clipboard.writeText(newPlaintext); + setCopyStatus('copied'); + } catch { + setCopyStatus('failed'); + } + }; + + const handleRevoke = async (tokenId: string) => { + if (!csrfToken) { + setError('Your browser session is not ready. Reload and try again.'); + return; + } + setRevokingId(tokenId); + setError(null); + try { + await revokeAPIToken(csrfToken, tokenId); + setTokens(previous => previous.map(token => token.id === tokenId + ? { ...token, revoked_at: new Date().toISOString() } + : token)); + setConfirmingId(null); + } catch (err) { + setError(err instanceof Error ? err.message : 'Unable to revoke API token.'); + } finally { + setRevokingId(null); + } + }; + + return ( +
+
+
+
+
+
+

API tokens

+

Manage personal access for tools and scripts.

+
+
+ +
+ +
+ {error &&
{error}
} + + {newPlaintext && ( +
+

Token created

+

This secret is shown only once. Copy it now. It will not be available again.

+
+ + +
+ {copyStatus === 'failed' &&

Copy failed. Select the secret and copy it manually.

} +
+ )} + +
+

Create token

+
+ +
Scopes + + +
+ +
+
+
+ +
+

Your tokens

+ {loading ?
Loading tokens...
: tokens.length === 0 ?
No API tokens yet.
: ( +
+ {tokens.map(token => { + const revoked = Boolean(token.revoked_at); + const expired = isExpired(token.expires_at); + return
+
+

{token.name}

{revoked && Revoked}{!revoked && expired && Expired}
+

{token.prefix}

+
{token.scopes.map(scope => {scope})}
+
Created:
{formatDate(token.created_at)}
Expires:
{formatDate(token.expires_at)}
Last used:
{formatDate(token.last_used_at)}
+
+ {!revoked && (confirmingId === token.id ?
Revoke this token?
: )} +
+
; + })} +
+ )} +
+
+
+
+ ); +} diff --git a/frontend/components/dashboard/CapacityGauge.tsx b/frontend/components/dashboard/CapacityGauge.tsx index c80c8f0..a8db1e1 100644 --- a/frontend/components/dashboard/CapacityGauge.tsx +++ b/frontend/components/dashboard/CapacityGauge.tsx @@ -1,3 +1,6 @@ +// Copyright 2025 StrongDM Inc +// SPDX-License-Identifier: Apache-2.0 + 'use client'; import { cn } from '@/lib/utils'; @@ -79,9 +82,9 @@ export function CapacityGauge({ {/* Main horizontal gauge */}
-
+

In-Memory Index Capacity

-
+
{(capacityRatio * 100).toFixed(0)}% diff --git a/frontend/components/dashboard/ObjectCountsCard.tsx b/frontend/components/dashboard/ObjectCountsCard.tsx index e3d0115..9f99898 100644 --- a/frontend/components/dashboard/ObjectCountsCard.tsx +++ b/frontend/components/dashboard/ObjectCountsCard.tsx @@ -1,3 +1,6 @@ +// Copyright 2025 StrongDM Inc +// SPDX-License-Identifier: Apache-2.0 + 'use client'; import { cn } from '@/lib/utils'; @@ -29,21 +32,21 @@ export function ObjectCountsCard({ objects, previousObjects, filesystem, classNa
{/* Main counts */} -
+
-
+
{formatCount(objects.contexts_total)}
contexts
-
+
{formatCount(objects.turns_total)}
turns
-
+
{formatCount(objects.blobs_total)}
blobs
diff --git a/frontend/components/dashboard/ServerHealthDashboard.tsx b/frontend/components/dashboard/ServerHealthDashboard.tsx index a1b0b0f..abfb6bd 100644 --- a/frontend/components/dashboard/ServerHealthDashboard.tsx +++ b/frontend/components/dashboard/ServerHealthDashboard.tsx @@ -1,3 +1,6 @@ +// Copyright 2025 StrongDM Inc +// SPDX-License-Identifier: Apache-2.0 + 'use client'; import { RefreshCw, WifiOff } from '@/components/icons'; @@ -20,8 +23,8 @@ function DashboardSkeleton() { return (
{/* Gauge skeleton */} -
-
+
+
{/* Cards skeleton */} @@ -56,7 +59,7 @@ export function ServerHealthDashboard({ // Loading state if (status === 'loading' && !data) { return ( -
+
); @@ -65,8 +68,8 @@ export function ServerHealthDashboard({ // Offline state (no data at all) if (isOffline) { return ( -
-
+
+

Server Offline

@@ -93,10 +96,10 @@ export function ServerHealthDashboard({ if (!data) return null; return ( -

+
{/* Stale warning */} {isStale && ( -
+
Data may be stale · Last updated: {lastUpdated} diff --git a/frontend/components/dashboard/SessionsErrorsBar.tsx b/frontend/components/dashboard/SessionsErrorsBar.tsx index daab336..f6e0bc4 100644 --- a/frontend/components/dashboard/SessionsErrorsBar.tsx +++ b/frontend/components/dashboard/SessionsErrorsBar.tsx @@ -1,3 +1,6 @@ +// Copyright 2025 StrongDM Inc +// SPDX-License-Identifier: Apache-2.0 + 'use client'; import { useState, useCallback } from 'react'; @@ -54,8 +57,8 @@ export function SessionsErrorsBar({ sessions, errors, perf, className }: Session const hasErrors = errors.total > 0; return ( -
-
+
+
{/* Sessions info */}
@@ -73,7 +76,7 @@ export function SessionsErrorsBar({ sessions, errors, perf, className }: Session
{/* Errors info - clickable when errors exist */} -
+