feat: materialize agent_owner_pubkey on NIP-OA auth (#491)

This commit is contained in:
tlongwell-block
2026-05-06 16:08:21 +00:00
committed by GitHub
parent 2357c3ddb3
commit 1f1f8740e7
4 changed files with 128 additions and 34 deletions
+7
View File
@@ -64,6 +64,11 @@ pub struct AuthContext {
pub channel_ids: Option<Vec<uuid::Uuid>>,
/// How the connection was authenticated.
pub auth_method: AuthMethod,
/// NIP-OA verified owner pubkey (if authenticated via owner attestation).
///
/// `None` for direct relay members or non-NIP-OA auth paths.
/// Set by the relay membership gate when NIP-OA fallback succeeds.
pub agent_owner_pubkey: Option<nostr::PublicKey>,
}
impl AuthContext {
@@ -125,6 +130,7 @@ impl AuthService {
scopes: Scope::all_known(),
channel_ids: None,
auth_method: AuthMethod::Nip42,
agent_owner_pubkey: None, // Set later by relay membership gate if NIP-OA
})
}
}
@@ -176,6 +182,7 @@ mod tests {
scopes: vec![Scope::MessagesRead, Scope::ChannelsRead],
channel_ids: None,
auth_method: AuthMethod::Nip42,
agent_owner_pubkey: None,
};
assert!(ctx.has_scope(&Scope::MessagesRead));
assert!(!ctx.has_scope(&Scope::MessagesWrite));
+7 -4
View File
@@ -44,13 +44,16 @@ pub mod relay_members {
/// - If enabled → checks `relay_members` table for the pubkey.
/// - If not a direct member and NIP-OA is enabled → verifies the `auth_tag_header`
/// to check if the agent's owner is a relay member.
///
/// Returns `Ok(None)` for direct members, `Ok(Some(owner_pubkey))` when
/// access was granted via NIP-OA owner delegation.
pub async fn enforce_relay_membership(
state: &AppState,
pubkey_bytes: &[u8],
auth_tag_header: Option<&str>,
) -> Result<(), (StatusCode, Json<serde_json::Value>)> {
) -> Result<Option<nostr::PublicKey>, (StatusCode, Json<serde_json::Value>)> {
if !state.config.require_relay_membership {
return Ok(());
return Ok(None);
}
let pubkey_hex = hex::encode(pubkey_bytes);
@@ -60,7 +63,7 @@ pub mod relay_members {
})?;
if is_member {
return Ok(());
return Ok(None);
}
// NIP-OA fallback: check if agent's owner is a relay member.
@@ -86,7 +89,7 @@ pub mod relay_members {
owner = %owner_hex,
"NIP-OA membership granted via owner"
);
return Ok(());
return Ok(Some(owner_pubkey));
}
}
Err(e) => {
+82 -13
View File
@@ -77,7 +77,7 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc<ConnectionState>, state:
.verify_auth_event(event, &challenge, &relay_url)
.await
{
Ok(auth_ctx) => {
Ok(mut auth_ctx) => {
let pubkey = auth_ctx.pubkey;
// Pubkey allowlist gate — only for pubkey-only auth.
@@ -107,24 +107,93 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc<ConnectionState>, state:
}
// Relay membership gate — uses the shared helper with NIP-OA fallback.
if crate::api::relay_members::enforce_relay_membership(
let nip_oa_owner = match crate::api::relay_members::enforce_relay_membership(
&state,
&pubkey.serialize(),
auth_tag_json.as_deref(),
)
.await
.is_err()
{
warn!(conn_id = %conn_id, pubkey = %pubkey.to_hex(), "not a relay member");
metrics::counter!("sprout_auth_failures_total", "reason" => "not_relay_member")
.increment(1);
*conn.auth_state.write().await = AuthState::Failed;
conn.send(RelayMessage::ok(
&event_id_hex,
false,
"restricted: not a relay member",
));
return;
Ok(owner) => owner,
Err(_) => {
warn!(conn_id = %conn_id, pubkey = %pubkey.to_hex(), "not a relay member");
metrics::counter!("sprout_auth_failures_total", "reason" => "not_relay_member")
.increment(1);
*conn.auth_state.write().await = AuthState::Failed;
conn.send(RelayMessage::ok(
&event_id_hex,
false,
"restricted: not a relay member",
));
return;
}
};
// Stash NIP-OA owner on the auth context (session-scoped) only if
// the DB confirms this owner relationship (first-write-wins).
if let Some(owner) = nip_oa_owner {
// Ensure both agent and owner have users rows (BYO agents may not,
// and agent_owner_pubkey has a FK constraint to users.pubkey).
if let Err(e) = state.db.ensure_user(&pubkey.serialize()).await {
warn!(conn_id = %conn_id, error = %e, "ensure_user(agent) failed during NIP-OA backfill");
}
if let Err(e) = state.db.ensure_user(&owner.serialize()).await {
warn!(conn_id = %conn_id, error = %e, "ensure_user(owner) failed during NIP-OA backfill");
}
// Idempotent backfill: record agent→owner in DB so cross-connection
// features (observer frames, channel policy) work for BYO agents.
// Returns Ok(true) if written, Ok(false) if already owned by someone else.
match state
.db
.set_agent_owner(&pubkey.serialize(), &owner.serialize())
.await
{
Ok(true) => {
// Successfully materialized — this owner is authoritative.
auth_ctx.agent_owner_pubkey = Some(owner);
// Pre-warm the observer cache to avoid stale negatives.
let cache_key = (pubkey.serialize().to_vec(), owner.serialize().to_vec());
state.observer_owner_cache.insert(cache_key, true);
}
Ok(false) => {
// Agent already owned by someone else. Verify if this
// owner matches the existing DB record before trusting it.
match state
.db
.is_agent_owner(&pubkey.serialize(), &owner.serialize())
.await
{
Ok(true) => {
auth_ctx.agent_owner_pubkey = Some(owner);
}
Ok(false) => {
warn!(
conn_id = %conn_id,
agent = %pubkey.to_hex(),
nip_oa_owner = %owner.to_hex(),
"NIP-OA owner differs from DB owner — session will not get owner fast-path"
);
}
Err(e) => {
warn!(
conn_id = %conn_id,
error = %e,
"is_agent_owner check failed after set_agent_owner conflict"
);
}
}
}
Err(e) => {
warn!(
conn_id = %conn_id,
agent = %pubkey.to_hex(),
owner = %owner.to_hex(),
error = %e,
"failed to backfill agent_owner_pubkey"
);
}
}
}
info!(conn_id = %conn_id, pubkey = %pubkey.to_hex(), "NIP-42 auth successful");
+32 -17
View File
@@ -539,26 +539,41 @@ async fn handle_agent_observer_event(
}
};
// Fast path: if this connection authenticated via NIP-OA and the verified
// owner matches the observer frame's target owner, skip the DB lookup entirely.
let session_owner_match = {
let auth = conn.auth_state.read().await;
if let crate::connection::AuthState::Authenticated(ctx) = &*auth {
ctx.agent_owner_pubkey.as_ref() == Some(&route.owner)
} else {
false
}
};
let agent_bytes = route.agent.serialize().to_vec();
let owner_bytes = route.owner.serialize().to_vec();
let cache_key = (agent_bytes.clone(), owner_bytes.clone());
let is_owner = match state.observer_owner_cache.get(&cache_key) {
Some(cached) => cached,
None => {
let result = state.db.is_agent_owner(&agent_bytes, &owner_bytes).await;
match result {
Ok(v) => {
state.observer_owner_cache.insert(cache_key, v);
v
}
Err(e) => {
warn!(conn_id = %conn_id, event_id = %event_id_hex, "agent observer owner check failed: {e}");
conn.send(RelayMessage::ok(
event_id_hex,
false,
"error: internal server error",
));
return;
let is_owner = if session_owner_match {
true
} else {
match state.observer_owner_cache.get(&cache_key) {
Some(cached) => cached,
None => {
let result = state.db.is_agent_owner(&agent_bytes, &owner_bytes).await;
match result {
Ok(v) => {
state.observer_owner_cache.insert(cache_key, v);
v
}
Err(e) => {
warn!(conn_id = %conn_id, event_id = %event_id_hex, "agent observer owner check failed: {e}");
conn.send(RelayMessage::ok(
event_id_hex,
false,
"error: internal server error",
));
return;
}
}
}
}