mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
feat(ws-client): send custom headers on the WebSocket upgrade (#8159)
## Summary NIP-FI relays verify the `Nostr-Federated-Identity` assertion on the WebSocket upgrade request (#7224) and pair it with the NIP-42 AUTH key, but `NostrWsConnection` can only connect from a bare URL, so no `buzz-ws-client` consumer can present an assertion today. This adds two additive constructors: - `NostrWsConnection::connect_with_headers(url, headers)` - `NostrWsConnection::connect_authenticated_with_headers(url, keys, auth_tag, headers)` The existing `connect` and `connect_authenticated` delegate with an empty `HeaderMap`, so their behavior is unchanged (`connect_async(&str)` already went through `into_client_request`). `HeaderMap`, `HeaderName`, and `HeaderValue` are re-exported so callers don't need a matching `tungstenite`/`http` dependency. Caller headers are added after the handshake headers; the doc comment notes that supplying a handshake header such as `Sec-WebSocket-Key` replaces it. tungstenite doesn't follow redirects, so upgrade credentials can't be forwarded to another origin. Follow-up (not in this PR): renewing assertions on long-lived connections. The relay ends the session at the assertion's deadline, so clients reconnect with a fresh assertion; that's caller policy rather than a transport concern. ### Related issue NIP-FI spec: #1485. Relay enforcement: #7224, #7264. No existing client-side issue or PR found. ### Testing - New `connect_with_headers_sends_headers_on_upgrade` test runs a local `accept_hdr_async` server and asserts the header arrives on the upgrade. Confirmed it fails when the headers are dropped. - `cargo test -p buzz-ws-client` (4 passed) - `cargo clippy -p buzz-ws-client -p buzz-test-client -p buzz-cli --all-targets -- -D warnings` - `cargo fmt --all -- --check` - Not run: full `just ci` (desktop/mobile toolchains unavailable in my environment) and an end-to-end connect against a local relay with NIP-FI enforcement on. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: Jason Holmes <holmes@squareup.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,8 @@ use futures_util::{SinkExt, StreamExt};
|
||||
use nostr::{Event, Keys, Tag};
|
||||
use serde_json::{json, Value};
|
||||
use tokio::time::timeout;
|
||||
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
|
||||
use tokio_tungstenite::tungstenite::http::HeaderMap;
|
||||
use tokio_tungstenite::{connect_async, tungstenite::Message, MaybeTlsStream, WebSocketStream};
|
||||
use tracing::debug;
|
||||
|
||||
@@ -39,18 +41,45 @@ impl NostrWsConnection {
|
||||
keys: &Keys,
|
||||
auth_tag: Option<&Tag>,
|
||||
) -> Result<Self, WsClientError> {
|
||||
let mut conn = Self::connect(url).await?;
|
||||
Self::connect_authenticated_with_headers(url, keys, auth_tag, HeaderMap::new()).await
|
||||
}
|
||||
|
||||
/// [`Self::connect_authenticated`], sending `headers` on the WebSocket upgrade request.
|
||||
///
|
||||
/// Use this for credentials the relay checks at upgrade time, such as a NIP-FI
|
||||
/// `Nostr-Federated-Identity` assertion that must pair with the NIP-42 AUTH key.
|
||||
pub async fn connect_authenticated_with_headers(
|
||||
url: &str,
|
||||
keys: &Keys,
|
||||
auth_tag: Option<&Tag>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Self, WsClientError> {
|
||||
let mut conn = Self::connect_with_headers(url, headers).await?;
|
||||
conn.authenticate(keys, auth_tag).await?;
|
||||
Ok(conn)
|
||||
}
|
||||
|
||||
/// Connects to the relay at `url` without performing authentication.
|
||||
pub async fn connect(url: &str) -> Result<Self, WsClientError> {
|
||||
Self::connect_with_headers(url, HeaderMap::new()).await
|
||||
}
|
||||
|
||||
/// [`Self::connect`], sending `headers` on the WebSocket upgrade request.
|
||||
///
|
||||
/// The headers are added after the handshake headers, so supplying a handshake
|
||||
/// header such as `Sec-WebSocket-Key` replaces it and breaks the upgrade.
|
||||
pub async fn connect_with_headers(
|
||||
url: &str,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Self, WsClientError> {
|
||||
let parsed = url
|
||||
.parse::<url::Url>()
|
||||
.map_err(|e| WsClientError::Url(e.to_string()))?;
|
||||
|
||||
let (ws, _response) = connect_async(parsed.as_str())
|
||||
let mut request = parsed.as_str().into_client_request()?;
|
||||
request.headers_mut().extend(headers);
|
||||
|
||||
let (ws, _response) = connect_async(request)
|
||||
.await
|
||||
.map_err(WsClientError::WebSocket)?;
|
||||
|
||||
@@ -311,4 +340,43 @@ mod tests {
|
||||
fn publish_ok_timeout_meets_floor() {
|
||||
const { assert!(PUBLISH_OK_TIMEOUT_SECS >= 30) };
|
||||
}
|
||||
|
||||
// The handshake callback's `Result<Response, ErrorResponse>` is fixed by tungstenite.
|
||||
#[allow(clippy::result_large_err)]
|
||||
#[tokio::test]
|
||||
async fn connect_with_headers_sends_headers_on_upgrade() {
|
||||
use tokio_tungstenite::tungstenite::handshake::server::{Request, Response};
|
||||
use tokio_tungstenite::tungstenite::http::HeaderValue;
|
||||
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let url = format!("ws://{}", listener.local_addr().unwrap());
|
||||
let server = tokio::spawn(async move {
|
||||
let (stream, _) = listener.accept().await.unwrap();
|
||||
let mut received = None;
|
||||
let _ws = tokio_tungstenite::accept_hdr_async(
|
||||
stream,
|
||||
|request: &Request, response: Response| {
|
||||
received = request.headers().get("nostr-federated-identity").cloned();
|
||||
Ok(response)
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
received
|
||||
});
|
||||
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
"Nostr-Federated-Identity",
|
||||
HeaderValue::from_static("Bearer header.payload.signature"),
|
||||
);
|
||||
let _conn = NostrWsConnection::connect_with_headers(&url, headers)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
server.await.unwrap(),
|
||||
Some(HeaderValue::from_static("Bearer header.payload.signature"))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,3 +7,4 @@ pub mod message;
|
||||
pub use connection::{publish_event, NostrWsConnection};
|
||||
pub use error::WsClientError;
|
||||
pub use message::{build_auth_event, parse_relay_message, OkResponse, RelayMessage};
|
||||
pub use tokio_tungstenite::tungstenite::http::{HeaderMap, HeaderName, HeaderValue};
|
||||
|
||||
Reference in New Issue
Block a user