diff --git a/crates/maestro-cli/src/http.rs b/crates/maestro-cli/src/http.rs index 5ad291b..3290ad6 100644 --- a/crates/maestro-cli/src/http.rs +++ b/crates/maestro-cli/src/http.rs @@ -5,6 +5,7 @@ use std::path::PathBuf; use std::sync::{Arc, Mutex}; use std::time::Duration; +use maestro_protocol::{extract_cookie, COOKIE_NAME}; use reqwest::header::{HeaderMap, HeaderValue, COOKIE, SET_COOKIE}; use reqwest::{Method, Response, StatusCode}; use serde::de::DeserializeOwned; @@ -12,8 +13,6 @@ use serde::Serialize; use crate::config::{CliConfig, ConfigError}; -const COOKIE_NAME: &str = "maestro_session"; - /// Time budget for establishing a TCP+TLS connection to the registry/daemon. const CONNECT_TIMEOUT: Duration = Duration::from_secs(5); /// Total time budget for a single request (connect + send + receive). Bounds @@ -124,7 +123,7 @@ impl RegistryClient { let Some(raw) = headers.get(SET_COOKIE).and_then(|h| h.to_str().ok()) else { return Ok(()); }; - let Some(val) = extract_cookie_value(raw, COOKIE_NAME) else { + let Some(val) = extract_cookie(raw, COOKIE_NAME) else { return Ok(()); }; let mut guard = self.cookie.lock().unwrap(); @@ -178,32 +177,10 @@ async fn ok_or_body(resp: Response) -> Result { Err(HttpError::ServerError { status, body }) } -/// Extract `name=value` from a `Set-Cookie:` value. Ignores attributes. -pub fn extract_cookie_value<'a>(raw: &'a str, name: &str) -> Option<&'a str> { - let first = raw.split(';').next()?.trim(); - let (k, v) = first.split_once('=')?; - (k.trim() == name).then(|| v.trim()) -} - #[cfg(test)] mod tests { use super::*; - #[test] - fn extracts_named_cookie_value() { - let raw = "maestro_session=abc.def.ghi; HttpOnly; SameSite=Strict; Path=/; Max-Age=2592000"; - assert_eq!( - extract_cookie_value(raw, "maestro_session"), - Some("abc.def.ghi") - ); - } - - #[test] - fn returns_none_for_other_cookie_name() { - let raw = "other=1; HttpOnly"; - assert_eq!(extract_cookie_value(raw, "maestro_session"), None); - } - /// A peer that accepts the TCP connection but never sends a response must /// not hang the client forever: the request timeout has to fire (NAP-262). #[tokio::test] diff --git a/crates/maestro-protocol/src/lib.rs b/crates/maestro-protocol/src/lib.rs index cccc23f..073bb35 100644 --- a/crates/maestro-protocol/src/lib.rs +++ b/crates/maestro-protocol/src/lib.rs @@ -463,6 +463,29 @@ pub struct JwtClaims { /// secret (NAP-266). pub const REGISTRY_ISSUER: &str = "maestro-registry"; +/// Name of the auth cookie the registry sets and the CLI persists. Defined +/// once here so both ends of the wire agree (NAP-273). +pub const COOKIE_NAME: &str = "maestro_session"; + +/// Find a named cookie's value inside a `Cookie:` / `Set-Cookie:` header value. +/// +/// Iterates every `;`-separated `name=value` pair, so it works whether the +/// target pair is first or buried among other cookies or `Set-Cookie` +/// attributes (`HttpOnly`, `Path=/`, …). Returns the first match, or `None`. +/// Shared by the registry's inbound-cookie check and the CLI's `Set-Cookie` +/// capture (NAP-273). +pub fn extract_cookie<'a>(header_value: &'a str, name: &str) -> Option<&'a str> { + for part in header_value.split(';') { + let part = part.trim(); + if let Some((k, v)) = part.split_once('=') { + if k.trim() == name { + return Some(v.trim()); + } + } + } + None +} + /// Pair-scoped attach credential. Issued by the registry inside /// [`AttachCandidates::jwt`]; verified by the target daemon for direct /// transports (T1/T2) and gated by `allow_inbound` after JWT verification. @@ -488,6 +511,20 @@ pub struct PairAttachClaims { mod tests { use super::*; + #[test] + fn extract_cookie_finds_named_value_anywhere() { + // Cookie: header — pair may be buried among others. + let h = "foo=1; maestro_session=abc.def.ghi; bar=2"; + assert_eq!(extract_cookie(h, COOKIE_NAME), Some("abc.def.ghi")); + assert_eq!(extract_cookie(h, "foo"), Some("1")); + assert_eq!(extract_cookie(h, "missing"), None); + // Set-Cookie: header — cookie first, attributes (no `=` or other keys) + // after it must be skipped, not matched. + let sc = "maestro_session=xyz; HttpOnly; SameSite=Strict; Path=/; Max-Age=2592000"; + assert_eq!(extract_cookie(sc, COOKIE_NAME), Some("xyz")); + assert_eq!(extract_cookie("other=1; HttpOnly", COOKIE_NAME), None); + } + fn round_trip(value: &T) -> T where T: Serialize + for<'de> Deserialize<'de>, diff --git a/crates/maestro-registry/src/auth.rs b/crates/maestro-registry/src/auth.rs index 72a2534..dd770b6 100644 --- a/crates/maestro-registry/src/auth.rs +++ b/crates/maestro-registry/src/auth.rs @@ -28,8 +28,9 @@ use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, use maestro_protocol::REGISTRY_ISSUER; use serde::{Deserialize, Serialize}; -/// Name of the auth cookie surfaced to web clients. -pub const COOKIE_NAME: &str = "maestro_session"; +/// Name of the auth cookie surfaced to web clients. Defined in +/// `maestro-protocol` so the CLI agrees; re-exported here for local callers. +pub use maestro_protocol::{extract_cookie, COOKIE_NAME}; /// 30 days. Cookie is the second factor — long enough not to log users out; /// short enough to bound the blast radius if the secret rotates. @@ -88,21 +89,6 @@ fn set_cookie_header(value: &str) -> String { ) } -/// Find our cookie inside an inbound `Cookie:` header. Header may contain -/// many `name=value` pairs separated by `;`. -pub fn extract_cookie<'a>(header_value: &'a str, name: &str) -> Option<&'a str> { - for part in header_value.split(';') { - let part = part.trim(); - if let Some(eq) = part.find('=') { - let (k, v) = part.split_at(eq); - if k == name { - return Some(&v[1..]); - } - } - } - None -} - /// Shared state for the auth middleware: just the signing secret. #[derive(Debug, Clone)] pub struct AuthState { @@ -175,14 +161,6 @@ mod tests { ); } - #[test] - fn extract_cookie_finds_named_value() { - let h = "foo=1; maestro_session=abc.def.ghi; bar=2"; - assert_eq!(extract_cookie(h, "maestro_session"), Some("abc.def.ghi")); - assert_eq!(extract_cookie(h, "foo"), Some("1")); - assert_eq!(extract_cookie(h, "missing"), None); - } - #[test] fn set_cookie_header_includes_attributes() { let h = set_cookie_header("abc");