feat(relay): NIP-OA agent authentication for NIP-43 membership (WS + REST + git) (#471)

This commit is contained in:
tlongwell-block
2026-05-04 12:12:22 -04:00
committed by GitHub
parent f08d4f4034
commit df33818b5b
14 changed files with 419 additions and 68 deletions
Generated
+1
View File
@@ -3960,6 +3960,7 @@ dependencies = [
"sprout-db",
"sprout-media",
"sprout-pubsub",
"sprout-sdk",
"sprout-search",
"sprout-workflow",
"sqlx",
+96 -27
View File
@@ -62,7 +62,7 @@ const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
use std::time::Instant;
use futures_util::{SinkExt, StreamExt};
use nostr::{Event, EventBuilder, Keys, Kind, Tag, Url as NostrUrl};
use nostr::{Event, EventBuilder, Keys, Kind, Tag};
use serde_json::{json, Value};
use sprout_core::kind::{
KIND_AGENT_OBSERVER_FRAME, KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION,
@@ -95,6 +95,8 @@ pub struct RestClient {
pub base_url: String,
pub api_token: Option<String>,
pub keys: Keys,
/// NIP-OA auth tag sent as `X-Auth-Tag` header for relay membership.
pub auth_tag: Option<Tag>,
}
/// Whether an HTTP status code is retriable (transient server/rate-limit errors).
@@ -179,7 +181,12 @@ impl RestClient {
let url = format!("{}{}", self.base_url, path);
let resp = self
.request_with_retry("GET", path, || {
let builder = apply_auth(self.http.get(&url), &self.api_token, &self.keys);
let builder = apply_auth_with_tag(
self.http.get(&url),
&self.api_token,
&self.keys,
self.auth_tag.as_ref(),
);
builder.send()
})
.await?;
@@ -196,8 +203,12 @@ impl RestClient {
let body = body.clone();
let resp = self
.request_with_retry("PUT", path, || {
let builder =
apply_auth(self.http.put(&url).json(&body), &self.api_token, &self.keys);
let builder = apply_auth_with_tag(
self.http.put(&url).json(&body),
&self.api_token,
&self.keys,
self.auth_tag.as_ref(),
);
builder.send()
})
.await?;
@@ -219,10 +230,11 @@ impl RestClient {
let body = body.clone();
let resp = self
.request_with_retry("POST", path, || {
let builder = apply_auth(
let builder = apply_auth_with_tag(
self.http.post(&url).json(&body),
&self.api_token,
&self.keys,
self.auth_tag.as_ref(),
);
builder.send()
})
@@ -242,7 +254,12 @@ impl RestClient {
pub async fn delete(&self, path: &str) -> Result<(), RelayError> {
let url = format!("{}{}", self.base_url, path);
self.request_with_retry("DELETE", path, || {
let builder = apply_auth(self.http.delete(&url), &self.api_token, &self.keys);
let builder = apply_auth_with_tag(
self.http.delete(&url),
&self.api_token,
&self.keys,
self.auth_tag.as_ref(),
);
builder.send()
})
.await?;
@@ -383,6 +400,9 @@ pub struct HarnessRelay {
api_token: Option<String>,
/// Keys used for NIP-42 signing.
keys: Keys,
/// NIP-OA owner-attestation tag threaded into NIP-42 AUTH events.
#[allow(dead_code)]
auth_tag: Option<Tag>,
/// Agent public key (hex) used as the `#p` filter on subscriptions.
#[allow(dead_code)]
agent_pubkey_hex: String,
@@ -420,10 +440,22 @@ impl HarnessRelay {
api_token: Option<&str>,
agent_pubkey_hex: &str,
) -> Result<Self, RelayError> {
// Parse NIP-OA owner-attestation tag from env before connecting so it
// can be included in the initial NIP-42 AUTH event.
let auth_tag: Option<Tag> = std::env::var("SPROUT_AUTH_TAG")
.ok()
.filter(|s| !s.is_empty())
.and_then(|s| {
sprout_sdk::nip_oa::parse_auth_tag(&s)
.map_err(|e| tracing::warn!("invalid SPROUT_AUTH_TAG: {e}"))
.ok()
});
// Perform the initial connection and auth handshake.
// Finding #8: capture the handshake buffer and pass it to the background
// task so buffered messages aren't silently discarded.
let (ws, handshake_buffer) = do_connect(relay_url, keys, api_token).await?;
let (ws, handshake_buffer) =
do_connect(relay_url, keys, api_token, auth_tag.as_ref()).await?;
let (event_tx, event_rx) = mpsc::channel::<Option<SproutEvent>>(event_channel_capacity());
let (observer_control_tx, observer_control_rx) =
@@ -433,6 +465,7 @@ impl HarnessRelay {
let bg_keys = keys.clone();
let bg_relay_url = relay_url.to_string();
let bg_api_token = api_token.map(|t| t.to_string());
let bg_auth_tag = auth_tag.clone();
let bg_agent_pubkey_hex = agent_pubkey_hex.to_string();
let bg_handle = tokio::spawn(async move {
@@ -445,6 +478,7 @@ impl HarnessRelay {
bg_keys,
bg_relay_url,
bg_api_token,
bg_auth_tag,
bg_agent_pubkey_hex,
)
.await;
@@ -462,6 +496,7 @@ impl HarnessRelay {
relay_url: relay_url.to_string(),
api_token: api_token.map(|t| t.to_string()),
keys: keys.clone(),
auth_tag,
agent_pubkey_hex: agent_pubkey_hex.to_string(),
bg_handle: Some(bg_handle),
})
@@ -517,6 +552,7 @@ impl HarnessRelay {
base_url: relay_ws_to_http(&self.relay_url),
api_token: self.api_token.clone(),
keys: self.keys.clone(),
auth_tag: self.auth_tag.clone(),
}
}
@@ -1056,6 +1092,7 @@ async fn run_background_task(
keys: Keys,
relay_url: String,
api_token: Option<String>,
auth_tag: Option<Tag>,
agent_pubkey_hex: String,
) {
let mut state = BgState::new();
@@ -1071,6 +1108,7 @@ async fn run_background_task(
&keys,
&relay_url,
api_token.as_deref(),
auth_tag.as_ref(),
&agent_pubkey_hex,
)
.await;
@@ -1086,6 +1124,7 @@ async fn run_background_task(
&keys,
&relay_url,
api_token.as_deref(),
auth_tag.as_ref(),
&agent_pubkey_hex,
&event_tx,
&observer_control_tx,
@@ -1110,6 +1149,7 @@ async fn run_background_task(
&keys,
&relay_url,
api_token.as_deref(),
auth_tag.as_ref(),
&agent_pubkey_hex,
&event_tx,
&observer_control_tx,
@@ -1151,6 +1191,7 @@ async fn run_background_task(
&keys,
&relay_url,
api_token.as_deref(),
auth_tag.as_ref(),
&agent_pubkey_hex,
&event_tx,
&observer_control_tx,
@@ -1181,6 +1222,7 @@ async fn run_background_task(
&keys,
&relay_url,
api_token.as_deref(),
auth_tag.as_ref(),
&agent_pubkey_hex,
&event_tx,
&observer_control_tx,
@@ -1221,6 +1263,7 @@ async fn run_background_task(
&keys,
&relay_url,
api_token.as_deref(),
auth_tag.as_ref(),
&agent_pubkey_hex,
)
.await
@@ -1248,6 +1291,7 @@ async fn run_background_task(
&keys,
&relay_url,
api_token.as_deref(),
auth_tag.as_ref(),
&agent_pubkey_hex,
&event_tx,
&observer_control_tx,
@@ -1270,7 +1314,7 @@ async fn run_background_task(
if matches!(
wait_for_reconnect(
&mut ws, &mut cmd_rx, &mut state, &keys, &relay_url,
api_token.as_deref(), &agent_pubkey_hex, &event_tx, &observer_control_tx, true,
api_token.as_deref(), auth_tag.as_ref(), &agent_pubkey_hex, &event_tx, &observer_control_tx, true,
).await,
ReconnectOutcome::Shutdown
) { return; }
@@ -1290,7 +1334,7 @@ async fn run_background_task(
if matches!(
wait_for_reconnect(
&mut ws, &mut cmd_rx, &mut state, &keys, &relay_url,
api_token.as_deref(), &agent_pubkey_hex, &event_tx, &observer_control_tx, true,
api_token.as_deref(), auth_tag.as_ref(), &agent_pubkey_hex, &event_tx, &observer_control_tx, true,
).await,
ReconnectOutcome::Shutdown
) { return; }
@@ -1323,7 +1367,7 @@ async fn run_background_task(
let _ = event_tx.try_send(None);
match try_autonomous_reconnect(
&mut ws, &mut cmd_rx, &mut state, &keys, &relay_url,
api_token.as_deref(), &agent_pubkey_hex, &event_tx,
api_token.as_deref(), auth_tag.as_ref(), &agent_pubkey_hex, &event_tx,
&observer_control_tx,
).await {
ReconnectOutcome::Shutdown => return,
@@ -1337,7 +1381,7 @@ async fn run_background_task(
if matches!(
wait_for_reconnect(
&mut ws, &mut cmd_rx, &mut state, &keys, &relay_url,
api_token.as_deref(), &agent_pubkey_hex, &event_tx, &observer_control_tx, true,
api_token.as_deref(), auth_tag.as_ref(), &agent_pubkey_hex, &event_tx, &observer_control_tx, true,
).await,
ReconnectOutcome::Shutdown
) { return; }
@@ -1361,7 +1405,7 @@ async fn run_background_task(
let _ = event_tx.try_send(None);
match try_autonomous_reconnect(
&mut ws, &mut cmd_rx, &mut state, &keys, &relay_url,
api_token.as_deref(), &agent_pubkey_hex, &event_tx,
api_token.as_deref(), auth_tag.as_ref(), &agent_pubkey_hex, &event_tx,
&observer_control_tx,
).await {
ReconnectOutcome::Shutdown => return,
@@ -1375,7 +1419,7 @@ async fn run_background_task(
if matches!(
wait_for_reconnect(
&mut ws, &mut cmd_rx, &mut state, &keys, &relay_url,
api_token.as_deref(), &agent_pubkey_hex, &event_tx, &observer_control_tx, true,
api_token.as_deref(), auth_tag.as_ref(), &agent_pubkey_hex, &event_tx, &observer_control_tx, true,
).await,
ReconnectOutcome::Shutdown
) { return; }
@@ -1392,7 +1436,7 @@ async fn run_background_task(
let _ = event_tx.try_send(None);
match try_autonomous_reconnect(
&mut ws, &mut cmd_rx, &mut state, &keys, &relay_url,
api_token.as_deref(), &agent_pubkey_hex, &event_tx,
api_token.as_deref(), auth_tag.as_ref(), &agent_pubkey_hex, &event_tx,
&observer_control_tx,
).await {
ReconnectOutcome::Shutdown => return,
@@ -1406,7 +1450,7 @@ async fn run_background_task(
if matches!(
wait_for_reconnect(
&mut ws, &mut cmd_rx, &mut state, &keys, &relay_url,
api_token.as_deref(), &agent_pubkey_hex, &event_tx, &observer_control_tx, true,
api_token.as_deref(), auth_tag.as_ref(), &agent_pubkey_hex, &event_tx, &observer_control_tx, true,
).await,
ReconnectOutcome::Shutdown
) { return; }
@@ -1448,6 +1492,7 @@ async fn handle_ws_message(
keys: &Keys,
relay_url: &str,
api_token: Option<&str>,
auth_tag: Option<&Tag>,
agent_pubkey_hex: &str,
) -> bool {
match msg {
@@ -1699,7 +1744,8 @@ async fn handle_ws_message(
// Finding #18: AUTH send failure must trigger reconnect.
debug!("received mid-session AUTH challenge — re-authenticating");
if let Err(e) =
send_auth_response(ws, &challenge, relay_url, keys, api_token).await
send_auth_response(ws, &challenge, relay_url, keys, api_token, auth_tag)
.await
{
warn!("failed to respond to mid-session AUTH challenge: {e} — triggering reconnect");
return false;
@@ -1752,6 +1798,7 @@ async fn process_handshake_buffer(
keys: &Keys,
relay_url: &str,
api_token: Option<&str>,
auth_tag: Option<&Tag>,
agent_pubkey_hex: &str,
) -> bool {
if buffer.is_empty() {
@@ -1795,6 +1842,7 @@ async fn process_handshake_buffer(
keys,
relay_url,
api_token,
auth_tag,
agent_pubkey_hex,
)
.await;
@@ -1957,6 +2005,7 @@ async fn try_autonomous_reconnect(
keys: &Keys,
relay_url: &str,
api_token: Option<&str>,
auth_tag: Option<&Tag>,
agent_pubkey_hex: &str,
event_tx: &mpsc::Sender<Option<SproutEvent>>,
observer_control_tx: &mpsc::Sender<Event>,
@@ -1976,7 +2025,7 @@ async fn try_autonomous_reconnect(
attempt + 1,
backoffs.len()
);
match do_connect(relay_url, keys, api_token).await {
match do_connect(relay_url, keys, api_token, auth_tag).await {
Ok((new_ws, handshake_buffer)) => {
*ws = new_ws;
info!("autonomous reconnect succeeded (attempt {})", attempt + 1);
@@ -1990,6 +2039,7 @@ async fn try_autonomous_reconnect(
keys,
relay_url,
api_token,
auth_tag,
agent_pubkey_hex,
)
.await;
@@ -2060,6 +2110,7 @@ async fn wait_for_reconnect(
keys: &Keys,
relay_url: &str,
api_token: Option<&str>,
auth_tag: Option<&Tag>,
agent_pubkey_hex: &str,
event_tx: &mpsc::Sender<Option<SproutEvent>>,
observer_control_tx: &mpsc::Sender<Event>,
@@ -2091,7 +2142,7 @@ async fn wait_for_reconnect(
let mut delay = Duration::from_secs(1);
loop {
info!("attempting relay reconnect to {relay_url}…");
match do_connect(relay_url, keys, api_token).await {
match do_connect(relay_url, keys, api_token, auth_tag).await {
Ok((new_ws, handshake_buffer)) => {
*ws = new_ws;
info!("relay reconnected to {relay_url}");
@@ -2105,6 +2156,7 @@ async fn wait_for_reconnect(
keys,
relay_url,
api_token,
auth_tag,
agent_pubkey_hex,
)
.await;
@@ -2360,22 +2412,30 @@ async fn send_auth_response(
relay_url: &str,
keys: &Keys,
api_token: Option<&str>,
auth_tag: Option<&Tag>,
) -> Result<(), RelayError> {
let relay_nostr_url: NostrUrl = relay_url
.parse()
.map_err(|e: url::ParseError| RelayError::Http(format!("invalid relay URL: {e}")))?;
let auth_event = if let Some(token) = api_token {
let tags = vec![
let mut tags = vec![
Tag::parse(&["relay", relay_url]).map_err(|e| RelayError::AuthFailed(e.to_string()))?,
Tag::parse(&["challenge", challenge])
.map_err(|e| RelayError::AuthFailed(e.to_string()))?,
Tag::parse(&["auth_token", token])
.map_err(|e| RelayError::AuthFailed(e.to_string()))?,
];
if let Some(tag) = auth_tag {
tags.push(tag.clone());
}
EventBuilder::new(Kind::Authentication, "", tags).sign_with_keys(keys)?
} else {
EventBuilder::auth(challenge, relay_nostr_url).sign_with_keys(keys)?
let mut tags = vec![
Tag::parse(&["relay", relay_url]).map_err(|e| RelayError::AuthFailed(e.to_string()))?,
Tag::parse(&["challenge", challenge])
.map_err(|e| RelayError::AuthFailed(e.to_string()))?,
];
if let Some(tag) = auth_tag {
tags.push(tag.clone());
}
EventBuilder::new(Kind::Authentication, "", tags).sign_with_keys(keys)?
};
let auth_msg = serde_json::to_string(&json!(["AUTH", auth_event]))?;
@@ -2412,15 +2472,23 @@ fn channel_id_from_sub_id(sub_id: &str) -> Option<Uuid> {
}
/// Apply the appropriate auth header to a reqwest request builder.
fn apply_auth(
fn apply_auth_with_tag(
builder: reqwest::RequestBuilder,
api_token: &Option<String>,
keys: &Keys,
auth_tag: Option<&Tag>,
) -> reqwest::RequestBuilder {
if let Some(ref token) = api_token {
let builder = if let Some(ref token) = api_token {
builder.header("Authorization", format!("Bearer {token}"))
} else {
builder.header("X-Pubkey", keys.public_key().to_hex())
};
if let Some(tag) = auth_tag {
let slice = tag.as_slice();
let json = serde_json::json!([slice[0], slice[1], slice[2], slice[3]]).to_string();
builder.header("X-Auth-Tag", json)
} else {
builder
}
}
@@ -2526,6 +2594,7 @@ async fn do_connect(
relay_url: &str,
keys: &Keys,
api_token: Option<&str>,
auth_tag: Option<&Tag>,
) -> Result<(WsStream, VecDeque<RelayMessage>), RelayError> {
let parsed = relay_url
.parse::<url::Url>()
@@ -2544,7 +2613,7 @@ async fn do_connect(
let challenge = wait_for_auth_challenge(&mut ws, &mut buffer, AUTH_TIMEOUT).await?;
// ── Step 2: Build and send kind:22242 auth event ──────────────────────
send_auth_response(&mut ws, &challenge, relay_url, keys, api_token).await?;
send_auth_response(&mut ws, &challenge, relay_url, keys, api_token, auth_tag).await?;
// ── Step 3: Wait for OK ───────────────────────────────────────────────
let event_id = {
+13
View File
@@ -59,6 +59,11 @@ pub enum AuthMethod {
Nip42Okta,
/// NIP-42 with a `sprout_` API token in the `auth_token` tag.
Nip42ApiToken,
/// NIP-42 with a NIP-OA owner attestation `auth` tag.
///
/// The agent key signed the AUTH event; the `auth` tag proves an owner
/// key authorized it. Relay membership is checked against the owner.
Nip42OwnerAttestation,
}
/// The result of a successful authentication, bound to a WebSocket connection.
@@ -74,6 +79,12 @@ pub struct AuthContext {
pub channel_ids: Option<Vec<uuid::Uuid>>,
/// How the connection was authenticated.
pub auth_method: AuthMethod,
/// The owner pubkey from a NIP-OA `auth` tag, if present.
///
/// When an agent authenticates via owner attestation, this holds the
/// owner's pubkey. Relay membership is checked against this key.
/// `None` for all other auth methods.
pub owner_pubkey: Option<nostr::PublicKey>,
}
impl AuthContext {
@@ -193,6 +204,7 @@ impl AuthService {
scopes,
channel_ids: None,
auth_method,
owner_pubkey: None,
})
}
@@ -424,6 +436,7 @@ mod tests {
scopes: vec![Scope::MessagesRead, Scope::ChannelsRead],
channel_ids: None,
auth_method: AuthMethod::Nip42PubkeyOnly,
owner_pubkey: None,
};
assert!(ctx.has_scope(&Scope::MessagesRead));
assert!(!ctx.has_scope(&Scope::MessagesWrite));
+45 -21
View File
@@ -2,7 +2,7 @@ use std::collections::HashMap;
use std::time::Duration;
use futures_util::{SinkExt, StreamExt};
use nostr::{Event, EventBuilder, Filter, Keys, Kind, Tag, Url};
use nostr::{Event, EventBuilder, Filter, Keys, Kind, Tag};
use serde_json::{json, Value};
use thiserror::Error;
use tokio::sync::{mpsc, oneshot};
@@ -221,6 +221,7 @@ async fn do_connect(
relay_url: &str,
keys: &Keys,
api_token: Option<&str>,
auth_tag: Option<&nostr::Tag>,
) -> Result<WsStream, RelayClientError> {
let parsed = relay_url
.parse::<url::Url>()
@@ -236,7 +237,7 @@ async fn do_connect(
// Wait for AUTH challenge (5s timeout).
let challenge = wait_for_auth_challenge(&mut ws, Duration::from_secs(5)).await?;
let auth_event = build_auth_event(&challenge, relay_url, keys, api_token)?;
let auth_event = build_auth_event(&challenge, relay_url, keys, api_token, auth_tag)?;
let event_id = auth_event.id.to_hex();
debug!("sending AUTH event {event_id}");
let auth_msg = serde_json::to_string(&json!(["AUTH", auth_event]))?;
@@ -318,29 +319,38 @@ async fn wait_for_ok(
}
/// Build a NIP-42 AUTH event for the given challenge.
///
/// If `auth_tag` is provided (NIP-OA owner attestation), it is appended to the
/// event's tags so the relay can verify owner attestation at connection time.
#[allow(clippy::result_large_err)]
fn build_auth_event(
challenge: &str,
relay_url: &str,
keys: &Keys,
api_token: Option<&str>,
auth_tag: Option<&nostr::Tag>,
) -> Result<Event, RelayClientError> {
let relay_nostr_url: Url = relay_url
.parse()
.map_err(|e: url::ParseError| RelayClientError::Url(e.to_string()))?;
if let Some(token) = api_token {
let tags = vec![
let mut tags = if let Some(token) = api_token {
vec![
Tag::parse(&["relay", relay_url])
.map_err(|e| RelayClientError::EventBuilder(e.to_string()))?,
Tag::parse(&["challenge", challenge])
.map_err(|e| RelayClientError::EventBuilder(e.to_string()))?,
Tag::parse(&["auth_token", token])
.map_err(|e| RelayClientError::EventBuilder(e.to_string()))?,
];
Ok(EventBuilder::new(Kind::Authentication, "", tags).sign_with_keys(keys)?)
]
} else {
Ok(EventBuilder::auth(challenge, relay_nostr_url).sign_with_keys(keys)?)
vec![
Tag::parse(&["relay", relay_url])
.map_err(|e| RelayClientError::EventBuilder(e.to_string()))?,
Tag::parse(&["challenge", challenge])
.map_err(|e| RelayClientError::EventBuilder(e.to_string()))?,
]
};
if let Some(tag) = auth_tag {
tags.push(tag.clone());
}
Ok(EventBuilder::new(Kind::Authentication, "", tags).sign_with_keys(keys)?)
}
/// Send a NIP-42 AUTH response for a mid-session challenge.
@@ -353,9 +363,10 @@ async fn send_auth_response(
relay_url: &str,
keys: &Keys,
api_token: Option<&str>,
auth_tag: Option<&nostr::Tag>,
) {
let result: Result<(), RelayClientError> = async {
let auth_event = build_auth_event(challenge, relay_url, keys, api_token)?;
let auth_event = build_auth_event(challenge, relay_url, keys, api_token, auth_tag)?;
let msg = serde_json::to_string(&json!(["AUTH", auth_event]))?;
ws.send(Message::Text(msg.into())).await?;
debug!("sent AUTH response for mid-session challenge");
@@ -377,6 +388,7 @@ async fn handle_ws_message(
keys: &Keys,
relay_url: &str,
api_token: Option<&str>,
auth_tag: Option<&nostr::Tag>,
) -> bool {
match msg {
Message::Text(text) => {
@@ -429,7 +441,7 @@ async fn handle_ws_message(
}
RelayMessage::Auth { challenge } => {
debug!("received mid-session AUTH challenge — re-authenticating");
send_auth_response(ws, &challenge, relay_url, keys, api_token).await;
send_auth_response(ws, &challenge, relay_url, keys, api_token, auth_tag).await;
}
}
true
@@ -463,13 +475,14 @@ async fn do_reconnect(
keys: &Keys,
relay_url: &str,
api_token: Option<&str>,
auth_tag: Option<&nostr::Tag>,
) -> bool {
warn!("relay connection lost — reconnecting…");
state.cancel_pending();
let mut delay = Duration::from_secs(1);
loop {
match do_connect(relay_url, keys, api_token).await {
match do_connect(relay_url, keys, api_token, auth_tag).await {
Ok(new_ws) => {
tracing::info!("reconnected to relay at {relay_url}");
*ws = new_ws;
@@ -544,6 +557,7 @@ async fn run_background_task(
keys: Keys,
relay_url: String,
api_token: Option<String>,
auth_tag: Option<nostr::Tag>,
) {
let mut state = BgState::new();
// Ticker for expiring timed-out pending operations (~1s granularity).
@@ -558,13 +572,14 @@ async fn run_background_task(
Some(Ok(msg)) => {
!handle_ws_message(
msg, &mut ws, &mut state, &keys, &relay_url, api_token.as_deref(),
auth_tag.as_ref(),
).await
}
Some(Err(e)) => { warn!("WebSocket error: {e}"); true }
None => { debug!("WebSocket stream ended"); true }
};
if needs_reconnect
&& !do_reconnect(&mut ws, &mut state, &mut cmd_rx, &keys, &relay_url, api_token.as_deref()).await
&& !do_reconnect(&mut ws, &mut state, &mut cmd_rx, &keys, &relay_url, api_token.as_deref(), auth_tag.as_ref()).await
{
return; // Shutdown received during reconnect
}
@@ -581,7 +596,7 @@ async fn run_background_task(
};
if let Err(e) = ws.send(Message::Text(msg.into())).await {
let _ = reply.send(Err(RelayClientError::WebSocket(e)));
if !do_reconnect(&mut ws, &mut state, &mut cmd_rx, &keys, &relay_url, api_token.as_deref()).await {
if !do_reconnect(&mut ws, &mut state, &mut cmd_rx, &keys, &relay_url, api_token.as_deref(), auth_tag.as_ref()).await {
return;
}
continue;
@@ -611,7 +626,7 @@ async fn run_background_task(
};
if let Err(e) = ws.send(Message::Text(text.into())).await {
let _ = reply.send(Err(RelayClientError::WebSocket(e)));
if !do_reconnect(&mut ws, &mut state, &mut cmd_rx, &keys, &relay_url, api_token.as_deref()).await {
if !do_reconnect(&mut ws, &mut state, &mut cmd_rx, &keys, &relay_url, api_token.as_deref(), auth_tag.as_ref()).await {
return;
}
continue;
@@ -636,7 +651,7 @@ async fn run_background_task(
};
if let Err(e) = ws.send(Message::Text(msg.into())).await {
let _ = reply.send(Err(RelayClientError::WebSocket(e)));
if !do_reconnect(&mut ws, &mut state, &mut cmd_rx, &keys, &relay_url, api_token.as_deref()).await {
if !do_reconnect(&mut ws, &mut state, &mut cmd_rx, &keys, &relay_url, api_token.as_deref(), auth_tag.as_ref()).await {
return;
}
continue;
@@ -717,16 +732,17 @@ impl RelayClient {
api_token: Option<&str>,
auth_tag: Option<nostr::Tag>,
) -> Result<Self, RelayClientError> {
let ws = do_connect(relay_url, keys, api_token).await?;
let ws = do_connect(relay_url, keys, api_token, auth_tag.as_ref()).await?;
let (cmd_tx, cmd_rx) = mpsc::channel(CMD_CHANNEL_CAPACITY);
let bg_keys = keys.clone();
let bg_relay_url = relay_url.to_string();
let bg_api_token = api_token.map(|t| t.to_string());
let bg_auth_tag = auth_tag.clone();
let handle = tokio::spawn(async move {
run_background_task(ws, cmd_rx, bg_keys, bg_relay_url, bg_api_token).await;
run_background_task(ws, cmd_rx, bg_keys, bg_relay_url, bg_api_token, bg_auth_tag).await;
});
Ok(Self {
@@ -833,15 +849,23 @@ impl RelayClient {
}
}
/// Returns the appropriate auth header for REST requests.
/// Returns the appropriate auth headers for REST requests.
///
/// - If an API token is present: `Authorization: Bearer <token>` (production mode).
/// - Otherwise: `X-Pubkey: <hex>` (dev mode, relay has `require_auth_token=false`).
/// - If a NIP-OA auth tag is configured: adds `X-Auth-Tag` for relay membership.
fn apply_auth(&self, builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
if let Some(ref token) = self.api_token {
let builder = if let Some(ref token) = self.api_token {
builder.header("Authorization", format!("Bearer {}", token))
} else {
builder.header("X-Pubkey", self.pubkey_hex())
};
if let Some(ref tag) = self.auth_tag {
let slice = tag.as_slice();
let json = serde_json::json!([slice[0], slice[1], slice[2], slice[3]]).to_string();
builder.header("X-Auth-Tag", json)
} else {
builder
}
}
+1
View File
@@ -38,6 +38,7 @@ deadpool-redis = { workspace = true }
redis = { workspace = true }
sqlx = { workspace = true }
base64 = "0.22"
sprout-sdk = { workspace = true }
sprout-workflow = { workspace = true, features = ["reqwest"] }
sprout-media = { workspace = true }
bytes = "1"
+5 -1
View File
@@ -171,7 +171,11 @@ impl axum::extract::FromRequestParts<Arc<AppState>> for GitAuth {
// replay protection for v1. Per-request signing requires protocol changes.
// Relay membership gate (NIP-43).
if crate::api::relay_members::enforce_relay_membership(state, &pubkey.serialize())
let auth_tag = parts
.headers
.get("x-auth-tag")
.and_then(|v| v.to_str().ok());
if crate::api::relay_members::enforce_relay_membership(state, &pubkey.serialize(), auth_tag)
.await
.is_err()
{
+8 -3
View File
@@ -87,9 +87,14 @@ impl FromRequestParts<Arc<AppState>> for AuthenticatedUpload {
.map_err(|_| MediaError::InsufficientScope)?;
// 5. Relay membership gate (NIP-43).
crate::api::relay_members::enforce_relay_membership(state, &auth_event.pubkey.serialize())
.await
.map_err(|_| MediaError::RelayMembershipRequired)?;
let auth_tag = headers.get("x-auth-tag").and_then(|v| v.to_str().ok());
crate::api::relay_members::enforce_relay_membership(
state,
&auth_event.pubkey.serialize(),
auth_tag,
)
.await
.map_err(|_| MediaError::RelayMembershipRequired)?;
Ok(AuthenticatedUpload { auth_event, scopes })
}
+26 -1
View File
@@ -157,10 +157,35 @@ pub(crate) async fn extract_auth_context(
state: &AppState,
) -> Result<RestAuthContext, (StatusCode, Json<serde_json::Value>)> {
let ctx = extract_auth_context_inner(headers, state).await?;
relay_members::enforce_relay_membership(state, &ctx.pubkey_bytes).await?;
let auth_tag = extract_single_auth_tag(headers)?;
relay_members::enforce_relay_membership(state, &ctx.pubkey_bytes, auth_tag).await?;
Ok(ctx)
}
/// Extract the `X-Auth-Tag` header, rejecting requests with multiple values.
///
/// Mirrors the WS path's "exactly 0 or 1 auth tags" rule — duplicates are
/// rejected rather than silently using the first value.
pub(crate) fn extract_single_auth_tag(
headers: &HeaderMap,
) -> Result<Option<&str>, (StatusCode, Json<serde_json::Value>)> {
let mut iter = headers.get_all("x-auth-tag").iter();
let first = match iter.next() {
Some(v) => v,
None => return Ok(None),
};
if iter.next().is_some() {
return Err((
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "invalid_auth_tag",
"message": "multiple X-Auth-Tag headers not allowed"
})),
));
}
Ok(first.to_str().ok())
}
/// Inner auth extraction — no relay membership check.
///
/// Used by `extract_auth_context` (which layers the membership gate on top)
+57 -11
View File
@@ -4,6 +4,11 @@
//! [`enforce_relay_membership`] is the single gate — called at every authenticated
//! entry point. When `require_relay_membership` is disabled, it's a no-op.
//!
//! When `allow_nip_oa_auth` is enabled and the agent is not a direct member,
//! the `X-Auth-Tag` header is checked: if it contains a valid NIP-OA owner
//! attestation for the agent's pubkey, and the attesting owner IS a relay
//! member, access is granted.
//!
//! ## Routes
//! - `GET /api/relay/members` — list all relay members (any authenticated member)
//! - `GET /api/relay/members/me` — get own membership record (or 404)
@@ -17,22 +22,28 @@ use axum::{
};
use sprout_auth::Scope;
use sprout_sdk::nip_oa;
use tracing::debug;
use super::{extract_auth_context, extract_auth_context_inner, internal_error};
use crate::state::AppState;
// ── Enforcement ───────────────────────────────────────────────────────────────
/// Enforce relay membership for a pubkey.
/// Enforce relay membership for a pubkey, with optional NIP-OA fallback.
///
/// - If `config.require_relay_membership` is false → always Ok (no-op).
/// - If enabled → checks `relay_members` table. Returns 403 if not a member.
/// - If the pubkey is a direct relay member → Ok.
/// - If `config.allow_nip_oa_auth` is true and `auth_tag_header` contains a
/// valid NIP-OA tag proving an owner who IS a member → Ok.
/// - Otherwise → 403.
///
/// `pubkey_bytes` is the 32-byte compressed pubkey; it is hex-encoded before
/// the DB lookup (the `relay_members` table stores 64-char hex strings).
pub async fn enforce_relay_membership(
state: &AppState,
pubkey_bytes: &[u8],
auth_tag_header: Option<&str>,
) -> Result<(), (StatusCode, Json<serde_json::Value>)> {
if !state.config.require_relay_membership {
return Ok(());
@@ -46,16 +57,51 @@ pub async fn enforce_relay_membership(
.map_err(|e| internal_error(&format!("relay membership check failed: {e}")))?;
if is_member {
Ok(())
} else {
Err((
StatusCode::FORBIDDEN,
Json(serde_json::json!({
"error": "relay_membership_required",
"message": "You must be a relay member to access this relay"
})),
))
return Ok(());
}
// ── NIP-OA fallback: check owner attestation ──────────────────────────
if state.config.allow_nip_oa_auth {
if let Some(tag_json) = auth_tag_header {
let agent_pubkey = nostr::PublicKey::from_slice(pubkey_bytes).map_err(|e| {
internal_error(&format!("invalid agent pubkey for NIP-OA check: {e}"))
})?;
match nip_oa::verify_auth_tag(tag_json, &agent_pubkey) {
Ok(owner_pubkey) => {
let owner_hex = owner_pubkey.to_hex();
let owner_is_member =
state.db.is_relay_member(&owner_hex).await.map_err(|e| {
internal_error(&format!("relay membership check (owner) failed: {e}"))
})?;
if owner_is_member {
debug!(
agent = %pubkey_hex,
owner = %owner_hex,
"REST NIP-OA membership granted via owner"
);
return Ok(());
}
}
Err(e) => {
debug!(
agent = %pubkey_hex,
error = %e,
"REST NIP-OA auth tag verification failed"
);
}
}
}
}
Err((
StatusCode::FORBIDDEN,
Json(serde_json::json!({
"error": "relay_membership_required",
"message": "You must be a relay member to access this relay"
})),
))
}
// ── REST read handlers ────────────────────────────────────────────────────────
+3 -1
View File
@@ -292,7 +292,9 @@ pub async fn post_tokens(
}
// NIP-98 path builds auth context directly — enforce membership here.
// Bearer/JWT paths go through extract_auth_context which already checks.
super::relay_members::enforce_relay_membership(&state, &pubkey_bytes).await?;
let auth_tag = super::extract_single_auth_tag(&headers)?;
super::relay_members::enforce_relay_membership(&state, &pubkey_bytes, auth_tag)
.await?;
super::RestAuthContext {
pubkey,
pubkey_bytes,
+1 -1
View File
@@ -143,7 +143,7 @@ async fn handle_audio_connection(socket: WebSocket, state: Arc<AppState>, channe
let parent_channel_id = auth_msg.parent_channel_id;
// ── Relay membership gate (NIP-43) ────────────────────────────────────────
if crate::api::relay_members::enforce_relay_membership(&state, &pubkey.serialize())
if crate::api::relay_members::enforce_relay_membership(&state, &pubkey.serialize(), None)
.await
.is_err()
{
+18
View File
@@ -74,6 +74,15 @@ pub struct Config {
/// with the `owner` role on first startup.
pub relay_owner_pubkey: Option<String>,
/// Allow NIP-OA owner attestation for relay membership.
///
/// When `true` and `require_relay_membership` is also `true`, agents
/// bearing a valid NIP-OA `auth` tag can authenticate by proving their
/// owner is a relay member. The agent gets session-scoped access.
///
/// Default: `false`. Set via `SPROUT_ALLOW_NIP_OA_AUTH=true`.
pub allow_nip_oa_auth: bool,
/// Media storage configuration (S3/MinIO).
pub media: sprout_media::MediaConfig,
@@ -149,6 +158,10 @@ impl Config {
.map(|v| v == "true" || v == "1")
.unwrap_or(false);
let allow_nip_oa_auth = std::env::var("SPROUT_ALLOW_NIP_OA_AUTH")
.map(|v| v == "true" || v == "1")
.unwrap_or(false);
// Note: intentionally not prefixed with SPROUT_ — this is a relay-identity
// config that may be shared across multiple services (e.g., ACP agent).
let relay_owner_pubkey = std::env::var("RELAY_OWNER_PUBKEY")
@@ -322,6 +335,7 @@ impl Config {
pubkey_allowlist_enabled,
require_relay_membership,
relay_owner_pubkey,
allow_nip_oa_auth,
media,
ephemeral_ttl_override,
git_repo_path,
@@ -363,6 +377,10 @@ mod tests {
config.relay_owner_pubkey.is_none(),
"relay_owner_pubkey should default to None"
);
assert!(
!config.allow_nip_oa_auth,
"allow_nip_oa_auth should default to false"
);
}
#[test]
+116 -2
View File
@@ -3,6 +3,7 @@
use std::sync::Arc;
use sha2::{Digest, Sha256};
use sprout_sdk::nip_oa;
use tracing::{debug, info, warn};
use crate::connection::{AuthState, ConnectionState};
@@ -212,6 +213,7 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc<ConnectionState>, state:
scopes,
channel_ids: record.channel_ids,
auth_method: sprout_auth::AuthMethod::Nip42ApiToken,
owner_pubkey: None,
};
// API token users have already proven authorization via their token —
// the pubkey allowlist does not apply here.
@@ -246,6 +248,48 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc<ConnectionState>, state:
// ── Okta JWT / pubkey-only path ─────────────────────────────────────────
// Non-sprout_ tokens (eyJ* JWTs) and no-token (open-relay) fall through here.
// ── NIP-OA: extract auth tag before event is consumed ────────────────
// Only when the relay operator has opted in via SPROUT_ALLOW_NIP_OA_AUTH=true.
// NIP-OA spec: exactly 0 or 1 `auth` tags per event; 2+ is invalid.
let nip_oa_auth_tag: Option<String> = if state.config.allow_nip_oa_auth {
let auth_tags: Vec<_> = event
.tags
.iter()
.filter(|tag| {
let s = tag.as_slice();
s.len() == 4 && s[0] == "auth"
})
.collect();
match auth_tags.len() {
0 => None,
1 => {
let slice = auth_tags[0].as_slice();
Some(serde_json::json!([slice[0], slice[1], slice[2], slice[3]]).to_string())
}
n => {
warn!(
conn_id = %conn.conn_id,
count = n,
"AUTH event contains multiple auth tags, rejecting"
);
metrics::counter!("sprout_auth_failures_total", "reason" => "nip_oa_multiple_tags")
.increment(1);
*conn.auth_state.write().await = AuthState::Failed;
conn.send(RelayMessage::ok(
&event.id.to_hex(),
false,
"auth-required: multiple auth tags not allowed",
));
return;
}
}
} else {
None
};
let agent_pubkey = event.pubkey;
match auth_svc
.verify_auth_event(event, &challenge, &relay_url)
.await
@@ -253,6 +297,10 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc<ConnectionState>, state:
Ok(auth_ctx) => {
let pubkey = auth_ctx.pubkey;
// Pubkey allowlist gate — only for pubkey-only auth (no JWT/token).
// NOTE: The allowlist gates which keys may *connect*. For NIP-OA,
// the agent is the connecting party, so the allowlist correctly
// checks the agent's pubkey. The NIP-43 membership check (below)
// separately verifies the owner is a relay member.
// Users with valid API tokens or Okta JWTs bypass the allowlist.
if state.config.pubkey_allowlist_enabled
&& auth_ctx.auth_method == sprout_auth::AuthMethod::Nip42PubkeyOnly
@@ -278,8 +326,74 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc<ConnectionState>, state:
return;
}
}
// Relay membership gate (NIP-43) — applies to ALL auth methods.
if !enforce_ws_relay_membership(&state, &conn, conn_id, &pubkey, &event_id_hex).await {
// ── NIP-OA: verify owner attestation if present ──────────────────
let (auth_ctx, membership_pubkey) = if let Some(ref tag_json) = nip_oa_auth_tag {
// Defense-in-depth: verify_auth_event already checks pubkey match,
// but NIP-OA verification depends on the agent pubkey being the
// event signer, so assert the invariant explicitly.
if agent_pubkey != auth_ctx.pubkey {
warn!(
conn_id = %conn_id,
agent = %agent_pubkey.to_hex(),
authenticated = %auth_ctx.pubkey.to_hex(),
"NIP-OA: agent pubkey does not match authenticated pubkey"
);
metrics::counter!("sprout_auth_failures_total", "reason" => "nip_oa_pubkey_mismatch")
.increment(1);
*conn.auth_state.write().await = AuthState::Failed;
conn.send(RelayMessage::ok(
&event_id_hex,
false,
"auth-required: pubkey mismatch",
));
return;
}
match nip_oa::verify_auth_tag(tag_json, &agent_pubkey) {
Ok(owner_pubkey) => {
info!(
conn_id = %conn_id,
agent = %agent_pubkey.to_hex(),
owner = %owner_pubkey.to_hex(),
"NIP-OA owner attestation verified"
);
let mut ctx = auth_ctx;
ctx.auth_method = sprout_auth::AuthMethod::Nip42OwnerAttestation;
ctx.owner_pubkey = Some(owner_pubkey);
(ctx, owner_pubkey)
}
Err(e) => {
warn!(
conn_id = %conn_id,
agent = %agent_pubkey.to_hex(),
error = %e,
"NIP-OA auth tag verification failed"
);
metrics::counter!("sprout_auth_failures_total", "reason" => "nip_oa_invalid")
.increment(1);
*conn.auth_state.write().await = AuthState::Failed;
conn.send(RelayMessage::ok(
&event_id_hex,
false,
"auth-required: owner attestation verification failed",
));
return;
}
}
} else {
(auth_ctx, pubkey)
};
// Relay membership gate (NIP-43) — check owner for NIP-OA, agent otherwise.
if !enforce_ws_relay_membership(
&state,
&conn,
conn_id,
&membership_pubkey,
&event_id_hex,
)
.await
{
return;
}
@@ -0,0 +1,29 @@
//! Compute a NIP-OA auth tag for an agent keypair.
//!
//! Usage:
//! cargo run --release --example compute_auth_tag -- <owner_secret_hex> <agent_pubkey_hex> [conditions]
//!
//! Prints the JSON auth tag to stdout.
use nostr::{Keys, PublicKey};
use sprout_sdk::nip_oa;
fn main() {
let args: Vec<String> = std::env::args().collect();
if args.len() < 3 {
eprintln!(
"Usage: {} <owner_secret_hex> <agent_pubkey_hex> [conditions]",
args[0]
);
std::process::exit(1);
}
let owner_keys = Keys::parse(&args[1]).expect("invalid owner secret key");
let agent_pubkey = PublicKey::from_hex(&args[2]).expect("invalid agent pubkey hex");
let conditions = args.get(3).map(|s| s.as_str()).unwrap_or("");
let tag_json = nip_oa::compute_auth_tag(&owner_keys, &agent_pubkey, conditions)
.expect("failed to compute auth tag");
println!("{tag_json}");
}