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
This commit is contained in:
Bradley Axen
2026-03-10 14:21:03 -07:00
parent 1be84cc315
commit e186c6d916
4 changed files with 200 additions and 6 deletions

1
.gitignore vendored
View File

@@ -5,6 +5,7 @@
.env
.env.local
.env.*.local
.sprout-agent.env
# Editor / IDE
.idea/

View File

@@ -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<ConnectionState>, 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<ConnectionState>, 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<AuthContext, sprout_auth::AuthError> {
// 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,
})
}

View File

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

102
scripts/setup-goose-agent.sh Executable file
View File

@@ -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}" <<EOF
# Sprout agent credentials — generated by setup-goose-agent.sh
# DO NOT commit this file.
SPROUT_RELAY_URL=ws://localhost:3000
SPROUT_PRIVATE_KEY=${NSEC}
SPROUT_API_TOKEN=${TOKEN}
EOF
chmod 600 "${AGENT_ENV}"
success "Agent identity created at .sprout-agent.env"
echo ""
echo -e " ${BLUE}Relay:${NC} ws://localhost:3000"
echo -e " ${BLUE}Private key:${NC} ${NSEC:0:20}..."
echo -e " ${BLUE}API token:${NC} ${TOKEN:0:20}..."
echo ""
echo -e " Run ${GREEN}just goose${NC} to start a Goose session with Sprout."
echo ""