From e186c6d916928ad61747d79cd0f61603ad747ff0 Mon Sep 17 00:00:00 2001 From: Bradley Axen Date: Tue, 10 Mar 2026 14:21:03 -0700 Subject: [PATCH] Add `just goose` for one-command agent setup + fix API token auth Adds an idempotent setup script and justfile recipe so that connecting a Goose agent to a local Sprout relay is a single command: just goose What this does: - scripts/setup-goose-agent.sh: mints a Nostr keypair + API token on first run, persists to .sprout-agent.env (gitignored, chmod 600). Subsequent runs are a no-op. - justfile: 'just goose' sources the agent env and launches goose with the sprout-mcp-server extension. - .gitignore: excludes .sprout-agent.env. Also fixes a bug where API token authentication was not wired up in the relay's NIP-42 auth handler. The AuthService intentionally has no DB access, so sprout_ tokens need to be intercepted in handle_auth before calling verify_auth_event. The handler now: 1. Extracts the auth_token tag from the NIP-42 event 2. If it starts with sprout_, hashes it, looks it up via Db::get_api_token_by_hash, and delegates to AuthService::verify_api_token_against_hash 3. Updates last_used_at on success 4. Falls through to the existing JWT/no-token paths otherwise --- .gitignore | 1 + crates/sprout-relay/src/handlers/auth.rs | 90 ++++++++++++++++++-- justfile | 13 +++ scripts/setup-goose-agent.sh | 102 +++++++++++++++++++++++ 4 files changed, 200 insertions(+), 6 deletions(-) create mode 100755 scripts/setup-goose-agent.sh diff --git a/.gitignore b/.gitignore index f04f168..4b7d91e 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ .env .env.local .env.*.local +.sprout-agent.env # Editor / IDE .idea/ diff --git a/crates/sprout-relay/src/handlers/auth.rs b/crates/sprout-relay/src/handlers/auth.rs index c870cae..ec7c124 100644 --- a/crates/sprout-relay/src/handlers/auth.rs +++ b/crates/sprout-relay/src/handlers/auth.rs @@ -1,9 +1,15 @@ //! NIP-42 AUTH handler — verify challenge response, transition auth state. +//! +//! API token authentication (`sprout_*` tokens) is intercepted here before +//! reaching [`AuthService::verify_auth_event`], because token verification +//! requires a database lookup that `sprout-auth` intentionally does not own. use std::sync::Arc; use tracing::{debug, info, warn}; +use sprout_auth::{hash_token, verify_nip42_event, AuthContext, AuthMethod}; + use crate::connection::{AuthState, ConnectionState}; use crate::protocol::RelayMessage; use crate::state::AppState; @@ -37,16 +43,33 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: }; let relay_url = state.config.relay_url.clone(); - let auth_svc = Arc::clone(&state.auth); let event_id_hex = event.id.to_hex(); - match auth_svc - .verify_auth_event(event, &challenge, &relay_url) - .await - { + // ── Check for a `sprout_` API token in the auth event ──────────────── + // API tokens require a DB lookup, so the relay intercepts them here + // rather than inside AuthService (which has no database access). + let api_token = event + .tags + .iter() + .find(|t| t.kind().to_string() == "auth_token") + .and_then(|t| t.content()) + .filter(|v| v.starts_with("sprout_")) + .map(|s| s.to_string()); + + let result = if let Some(raw_token) = api_token { + verify_api_token_auth(&event, &challenge, &relay_url, &raw_token, &state).await + } else { + // JWT or no-token path — delegate entirely to AuthService. + let auth_svc = Arc::clone(&state.auth); + auth_svc + .verify_auth_event(event, &challenge, &relay_url) + .await + }; + + match result { Ok(auth_ctx) => { let pubkey = auth_ctx.pubkey; - info!(conn_id = %conn_id, pubkey = %pubkey.to_hex(), "NIP-42 auth successful"); + info!(conn_id = %conn_id, pubkey = %pubkey.to_hex(), method = ?auth_ctx.auth_method, "NIP-42 auth successful"); *conn.auth_state.write().await = AuthState::Authenticated(auth_ctx); conn.send(RelayMessage::ok(&event_id_hex, true, "")); } @@ -61,3 +84,58 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: } } } + +/// Verify a NIP-42 AUTH event that carries a `sprout_` API token. +/// +/// 1. Verify the NIP-42 event structure + Schnorr signature. +/// 2. Hash the raw token and look it up in the database. +/// 3. Delegate to [`AuthService::verify_api_token_against_hash`] for +/// constant-time hash comparison, expiry, pubkey, and scope resolution. +/// 4. Update `last_used_at` on success. +async fn verify_api_token_auth( + event: &nostr::Event, + challenge: &str, + relay_url: &str, + raw_token: &str, + state: &AppState, +) -> Result { + // Step 1: verify NIP-42 signature, challenge, relay URL, timestamp. + let event_clone = event.clone(); + let challenge_owned = challenge.to_string(); + let relay_owned = relay_url.to_string(); + tokio::task::spawn_blocking(move || { + verify_nip42_event(&event_clone, &challenge_owned, &relay_owned) + }) + .await + .map_err(|_| sprout_auth::AuthError::Internal("spawn_blocking panicked".into()))??; + + // Step 2: look up the token in the database by its SHA-256 hash. + let token_hash = hash_token(raw_token); + let record = state + .db + .get_api_token_by_hash(&token_hash) + .await + .map_err(|_| sprout_auth::AuthError::TokenInvalid)?; + + // Step 3: constant-time verify + expiry + pubkey + scope resolution. + let owner_pubkey = nostr::PublicKey::from_slice(&record.owner_pubkey) + .map_err(|_| sprout_auth::AuthError::TokenInvalid)?; + + let (pubkey, scopes) = state.auth.verify_api_token_against_hash( + raw_token, + &record.token_hash, + &owner_pubkey, + &event.pubkey, + record.expires_at, + &record.scopes, + )?; + + // Step 4: update last_used_at (best-effort, don't fail auth on this). + let _ = state.db.update_token_last_used(&token_hash).await; + + Ok(AuthContext { + pubkey, + scopes, + auth_method: AuthMethod::Nip42ApiToken, + }) +} diff --git a/justfile b/justfile index 8b2fccf..91ce038 100644 --- a/justfile +++ b/justfile @@ -153,6 +153,19 @@ migrate: echo "Migrations applied." fi +# ─── Goose (AI agent) ───────────────────────────────────────────────────────── + +# Set up agent identity and start a Goose session with Sprout tools +goose: + #!/usr/bin/env bash + set -euo pipefail + ./scripts/setup-goose-agent.sh + set -o allexport + source .sprout-agent.env + set +o allexport + exec goose session \ + --with-extension "SPROUT_RELAY_URL=$SPROUT_RELAY_URL SPROUT_PRIVATE_KEY=$SPROUT_PRIVATE_KEY SPROUT_API_TOKEN=$SPROUT_API_TOKEN $(pwd)/target/debug/sprout-mcp-server" + # ─── Utilities ──────────────────────────────────────────────────────────────── # Remove build artifacts diff --git a/scripts/setup-goose-agent.sh b/scripts/setup-goose-agent.sh new file mode 100755 index 0000000..e29d61f --- /dev/null +++ b/scripts/setup-goose-agent.sh @@ -0,0 +1,102 @@ +#!/usr/bin/env bash +# ============================================================================= +# setup-goose-agent.sh — Idempotent setup for a Goose agent identity +# ============================================================================= +# Creates .sprout-agent.env with a minted API token and Nostr keypair. +# If the file already exists, does nothing. Safe to run repeatedly. +# +# Usage: ./scripts/setup-goose-agent.sh +# ============================================================================= +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +AGENT_ENV="${REPO_ROOT}/.sprout-agent.env" + +GREEN='\033[0;32m' +BLUE='\033[0;34m' +YELLOW='\033[1;33m' +RED='\033[0;31m' +NC='\033[0m' + +log() { echo -e "${BLUE}[setup-agent]${NC} $*"; } +success(){ echo -e "${GREEN}[setup-agent]${NC} ✅ $*"; } +warn() { echo -e "${YELLOW}[setup-agent]${NC} ⚠️ $*"; } +error() { echo -e "${RED}[setup-agent]${NC} ❌ $*" >&2; } + +# ---- Already set up? -------------------------------------------------------- + +if [[ -f "${AGENT_ENV}" ]]; then + success "Agent identity already exists at .sprout-agent.env — skipping." + exit 0 +fi + +# ---- Preflight --------------------------------------------------------------- + +# Need the relay DB to mint a token +if ! docker inspect --format='{{.State.Health.Status}}' sprout-mysql 2>/dev/null | grep -q healthy; then + error "MySQL is not running. Run 'just setup' first." + exit 1 +fi + +# Build sprout-admin if needed +ADMIN_BIN="${REPO_ROOT}/target/debug/sprout-admin" +if [[ ! -x "${ADMIN_BIN}" ]]; then + log "Building sprout-admin..." + cargo build -p sprout-admin 2>&1 | tail -3 +fi + +# Build sprout-mcp-server if needed +MCP_BIN="${REPO_ROOT}/target/debug/sprout-mcp-server" +if [[ ! -x "${MCP_BIN}" ]]; then + log "Building sprout-mcp-server..." + cargo build -p sprout-mcp 2>&1 | tail -3 +fi + +# ---- Mint token -------------------------------------------------------------- + +log "Minting agent token..." + +# Source .env for DATABASE_URL +if [[ -f "${REPO_ROOT}/.env" ]]; then + set -o allexport + source "${REPO_ROOT}/.env" + set +o allexport +fi +export DATABASE_URL="${DATABASE_URL:-mysql://sprout:sprout_dev@localhost:3306/sprout}" + +OUTPUT=$("${ADMIN_BIN}" mint-token \ + --name "goose-agent" \ + --scopes "messages:read,messages:write,channels:read,channels:write" \ + 2>&1) + +# Parse the nsec and token from the box-drawing output +NSEC=$(echo "${OUTPUT}" | grep -oE 'nsec1[a-z0-9]+') +TOKEN=$(echo "${OUTPUT}" | grep -oE 'sprout_[0-9a-f]+') + +if [[ -z "${NSEC}" || -z "${TOKEN}" ]]; then + error "Failed to parse credentials from mint-token output:" + echo "${OUTPUT}" + exit 1 +fi + +# ---- Write .sprout-agent.env ------------------------------------------------- + +cat > "${AGENT_ENV}" <