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:
Jason Holmes
2026-10-20 20:24:35 -07:00
committed by GitHub
parent 0768397092
commit ce3fc78cbd
2 changed files with 71 additions and 2 deletions

View File

@@ -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"))
);
}
}

View File

@@ -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};