mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
feat: self-service token minting via NIP-98 HTTP Auth (#37)
This commit is contained in:
52
Cargo.lock
generated
52
Cargo.lock
generated
@@ -604,6 +604,24 @@ dependencies = [
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-channel"
|
||||
version = "0.5.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-epoch"
|
||||
version = "0.9.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-queue"
|
||||
version = "0.3.12"
|
||||
@@ -1593,6 +1611,23 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "moka"
|
||||
version = "0.12.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85f8024e1c8e71c778968af91d43700ce1d11b219d127d79fb2934153b82b42b"
|
||||
dependencies = [
|
||||
"crossbeam-channel",
|
||||
"crossbeam-epoch",
|
||||
"crossbeam-utils",
|
||||
"equivalent",
|
||||
"parking_lot",
|
||||
"portable-atomic",
|
||||
"smallvec",
|
||||
"tagptr",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "negentropy"
|
||||
version = "0.3.1"
|
||||
@@ -1883,6 +1918,12 @@ dependencies = [
|
||||
"universal-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "portable-atomic"
|
||||
version = "1.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
|
||||
|
||||
[[package]]
|
||||
name = "potential_utf"
|
||||
version = "0.1.4"
|
||||
@@ -2803,6 +2844,7 @@ dependencies = [
|
||||
"deadpool-redis",
|
||||
"futures-util",
|
||||
"hex",
|
||||
"moka",
|
||||
"nostr",
|
||||
"redis",
|
||||
"serde",
|
||||
@@ -2849,11 +2891,15 @@ name = "sprout-test-client"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
"chrono",
|
||||
"futures-util",
|
||||
"hex",
|
||||
"nostr",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"sprout-core",
|
||||
"sprout-mcp",
|
||||
"thiserror",
|
||||
@@ -3143,6 +3189,12 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tagptr"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417"
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.18"
|
||||
|
||||
@@ -78,6 +78,7 @@ rand = "0.8"
|
||||
|
||||
# Concurrent data structures
|
||||
dashmap = "6"
|
||||
moka = { version = "0.12", features = ["sync"] }
|
||||
|
||||
# JWT validation (Okta JWKS)
|
||||
jsonwebtoken = "9"
|
||||
|
||||
@@ -34,6 +34,27 @@ pub enum AuthError {
|
||||
#[error("api token invalid or expired")]
|
||||
TokenInvalid,
|
||||
|
||||
/// The API token was found in the database but has been revoked.
|
||||
///
|
||||
/// Distinct from [`TokenInvalid`] so the relay can return `401 token_revoked`
|
||||
/// rather than the generic `invalid_token` error code.
|
||||
#[error("api token has been revoked")]
|
||||
TokenRevoked,
|
||||
|
||||
/// The API token was found in the database but has passed its expiry timestamp.
|
||||
///
|
||||
/// Distinct from [`TokenInvalid`] so the relay can return `401 token_expired`
|
||||
/// rather than the generic `invalid_token` error code.
|
||||
#[error("api token has expired")]
|
||||
TokenExpired,
|
||||
|
||||
/// NIP-98 HTTP Auth event (kind:27235) failed verification.
|
||||
///
|
||||
/// The inner string describes the specific failure (signature, timestamp, URL, etc.)
|
||||
/// and is safe to include in server logs. Do **not** forward raw event content to clients.
|
||||
#[error("NIP-98 HTTP Auth verification failed: {0}")]
|
||||
Nip98Invalid(String),
|
||||
|
||||
/// The pubkey in the NIP-42 event does not match the identity in the JWT or API token.
|
||||
#[error("pubkey mismatch: event pubkey does not match authenticated identity")]
|
||||
PubkeyMismatch,
|
||||
|
||||
@@ -21,6 +21,8 @@ pub mod access;
|
||||
pub mod error;
|
||||
/// NIP-42 challenge–response authentication.
|
||||
pub mod nip42;
|
||||
/// NIP-98 HTTP Auth verification (kind:27235).
|
||||
pub mod nip98;
|
||||
/// Okta OIDC integration and JWKS validation.
|
||||
pub mod okta;
|
||||
/// Per-connection rate limiting.
|
||||
@@ -33,11 +35,12 @@ pub mod token;
|
||||
pub use access::{check_read_access, check_write_access, require_scope, ChannelAccessChecker};
|
||||
pub use error::AuthError;
|
||||
pub use nip42::{generate_challenge, verify_nip42_event};
|
||||
pub use nip98::verify_nip98_event;
|
||||
pub use okta::{CachedJwks, Jwks, JwksCache, OktaConfig};
|
||||
pub use rate_limit::{
|
||||
ip_rate_limit_key, rate_limit_key, LimitType, RateLimitConfig, RateLimitResult, RateLimiter,
|
||||
};
|
||||
pub use scope::{parse_scopes, Scope};
|
||||
pub use scope::{is_self_mintable, parse_scopes, Scope, SELF_MINTABLE_SCOPES};
|
||||
pub use token::{generate_token, hash_token, verify_token_hash};
|
||||
|
||||
#[cfg(any(test, feature = "test-utils"))]
|
||||
|
||||
326
crates/sprout-auth/src/nip98.rs
Normal file
326
crates/sprout-auth/src/nip98.rs
Normal file
@@ -0,0 +1,326 @@
|
||||
//! NIP-98 HTTP Auth verification (kind:27235).
|
||||
//!
|
||||
//! NIP-98 is the standard Nostr HTTP Auth pattern used by Nostr.build, Blossom, and
|
||||
//! other Nostr HTTP services. It is **stateless** — no WebSocket session required.
|
||||
//!
|
||||
//! The client signs a short-lived kind:27235 event containing the target URL, HTTP method,
|
||||
//! and an optional SHA-256 hash of the request body, then sends it as:
|
||||
//!
|
||||
//! ```text
|
||||
//! Authorization: Nostr <base64(JSON-serialized-event)>
|
||||
//! ```
|
||||
//!
|
||||
//! ## Verification steps
|
||||
//!
|
||||
//! 1. Parse JSON into a `nostr::Event`
|
||||
//! 2. Verify `kind == 27235` (`Kind::HttpAuth`)
|
||||
//! 3. Verify Schnorr signature via `sprout_core::verify_event`
|
||||
//! 4. Verify `created_at` within ±60 seconds of server time
|
||||
//! 5. Verify `["u", <url>]` tag matches `expected_url` (normalised: case-insensitive
|
||||
//! scheme/host, trailing slash stripped)
|
||||
//! 6. Verify `["method", <method>]` tag matches `expected_method` (case-insensitive)
|
||||
//! 7. If `["payload", <hash>]` tag is present **and** `body` is `Some`: verify
|
||||
//! `SHA-256(body) == hex(payload_tag)`. This prevents body-substitution attacks.
|
||||
//! 8. Return `event.pubkey` on success.
|
||||
|
||||
use nostr::{Alphabet, Event, Kind, SingleLetterTag, TagKind, Timestamp};
|
||||
use sha2::{Digest, Sha256};
|
||||
use url::Url;
|
||||
|
||||
use crate::error::AuthError;
|
||||
|
||||
const TIMESTAMP_TOLERANCE_SECS: u64 = 60;
|
||||
|
||||
/// Verify a NIP-98 HTTP Auth event (kind:27235).
|
||||
///
|
||||
/// # Parameters
|
||||
///
|
||||
/// - `event_json` — the raw JSON string of the Nostr event (decoded from base64 by the caller).
|
||||
/// - `expected_url` — the canonical URL of the request being authenticated.
|
||||
/// For reverse-proxy deployments, reconstruct from `X-Forwarded-Proto` / `X-Forwarded-Host`
|
||||
/// before passing here.
|
||||
/// - `expected_method` — the HTTP method (e.g. `"POST"`). Compared case-insensitively.
|
||||
/// - `body` — raw request body bytes. If `Some` and a `payload` tag is present in the event,
|
||||
/// the SHA-256 hash of `body` must match the tag value. If `None`, the `payload` tag is
|
||||
/// ignored (clients SHOULD include it for POST requests, but it is not required).
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The authenticated `nostr::PublicKey` on success.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`AuthError::Nip98Invalid`] with a descriptive message for any verification failure.
|
||||
/// The message is safe for server logs but should not be forwarded verbatim to clients.
|
||||
pub fn verify_nip98_event(
|
||||
event_json: &str,
|
||||
expected_url: &str,
|
||||
expected_method: &str,
|
||||
body: Option<&[u8]>,
|
||||
) -> Result<nostr::PublicKey, AuthError> {
|
||||
// 1. Parse JSON.
|
||||
let event: Event = serde_json::from_str(event_json)
|
||||
.map_err(|e| AuthError::Nip98Invalid(format!("event JSON parse error: {e}")))?;
|
||||
|
||||
// 2. Verify kind == 27235.
|
||||
if event.kind != Kind::HttpAuth {
|
||||
return Err(AuthError::Nip98Invalid(format!(
|
||||
"expected kind 27235, got {}",
|
||||
event.kind.as_u16()
|
||||
)));
|
||||
}
|
||||
|
||||
// 3. Verify Schnorr signature (also verifies event ID hash).
|
||||
sprout_core::verify_event(&event)
|
||||
.map_err(|_| AuthError::Nip98Invalid("invalid Schnorr signature".to_string()))?;
|
||||
|
||||
// 4. Verify created_at within ±60 seconds of now.
|
||||
let now = Timestamp::now().as_u64();
|
||||
let event_ts = event.created_at.as_u64();
|
||||
let delta = now.abs_diff(event_ts);
|
||||
if delta > TIMESTAMP_TOLERANCE_SECS {
|
||||
return Err(AuthError::Nip98Invalid(format!(
|
||||
"event timestamp outside ±{TIMESTAMP_TOLERANCE_SECS}s window (delta: {delta}s)"
|
||||
)));
|
||||
}
|
||||
|
||||
// 5. Verify `u` tag matches expected_url (normalised).
|
||||
// NIP-98 uses the single-letter "u" tag, not the multi-letter "url" tag.
|
||||
let u_tag = event
|
||||
.tags
|
||||
.find(TagKind::SingleLetter(SingleLetterTag::lowercase(
|
||||
Alphabet::U,
|
||||
)))
|
||||
.and_then(|t| t.content())
|
||||
.ok_or_else(|| AuthError::Nip98Invalid("missing `u` tag".to_string()))?;
|
||||
|
||||
if normalize_url(u_tag) != normalize_url(expected_url) {
|
||||
return Err(AuthError::Nip98Invalid(format!(
|
||||
"URL mismatch: event has `{u_tag}`, expected `{expected_url}`"
|
||||
)));
|
||||
}
|
||||
|
||||
// 6. Verify `method` tag matches expected_method (case-insensitive).
|
||||
let method_tag = event
|
||||
.tags
|
||||
.find(TagKind::Method)
|
||||
.and_then(|t| t.content())
|
||||
.ok_or_else(|| AuthError::Nip98Invalid("missing `method` tag".to_string()))?;
|
||||
|
||||
if !method_tag.eq_ignore_ascii_case(expected_method) {
|
||||
return Err(AuthError::Nip98Invalid(format!(
|
||||
"method mismatch: event has `{method_tag}`, expected `{expected_method}`"
|
||||
)));
|
||||
}
|
||||
|
||||
// 7. If `payload` tag present AND body is Some: verify SHA-256(body) == payload hex.
|
||||
let payload_tag = event.tags.find(TagKind::Payload).and_then(|t| t.content());
|
||||
|
||||
if let (Some(payload_hex), Some(body_bytes)) = (payload_tag, body) {
|
||||
let computed: [u8; 32] = Sha256::digest(body_bytes).into();
|
||||
let computed_hex = hex::encode(computed);
|
||||
if computed_hex != payload_hex {
|
||||
return Err(AuthError::Nip98Invalid(
|
||||
"payload tag SHA-256 mismatch: request body does not match signed hash".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// 8. Return the authenticated pubkey.
|
||||
Ok(event.pubkey)
|
||||
}
|
||||
|
||||
/// Normalize a URL for comparison.
|
||||
///
|
||||
/// - Lowercases scheme and host (already done by the `url` crate).
|
||||
/// - Strips trailing slash from path.
|
||||
/// - Treats `localhost` and `::1` as equivalent to `127.0.0.1`.
|
||||
fn normalize_url(raw: &str) -> String {
|
||||
let mut parsed = match Url::parse(raw) {
|
||||
Ok(u) => u,
|
||||
Err(_) => return raw.to_lowercase(),
|
||||
};
|
||||
if let Some(host) = parsed.host_str() {
|
||||
if host == "localhost" || host == "::1" {
|
||||
let _ = parsed.set_host(Some("127.0.0.1"));
|
||||
}
|
||||
}
|
||||
let path = parsed.path().trim_end_matches('/').to_string();
|
||||
parsed.set_path(&path);
|
||||
parsed.to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use nostr::{EventBuilder, Keys, Kind, Timestamp};
|
||||
|
||||
const TEST_URL: &str = "https://relay.example.com/api/tokens";
|
||||
const TEST_METHOD: &str = "POST";
|
||||
|
||||
fn make_nip98_event(
|
||||
keys: &Keys,
|
||||
url: &str,
|
||||
method: &str,
|
||||
payload_hex: Option<&str>,
|
||||
created_at: Option<Timestamp>,
|
||||
) -> String {
|
||||
use nostr::Tag;
|
||||
|
||||
let mut tags = vec![
|
||||
Tag::parse(&["u", url]).unwrap(),
|
||||
Tag::parse(&["method", method]).unwrap(),
|
||||
];
|
||||
if let Some(hex) = payload_hex {
|
||||
tags.push(Tag::parse(&["payload", hex]).unwrap());
|
||||
}
|
||||
|
||||
let mut builder = EventBuilder::new(Kind::HttpAuth, "", tags);
|
||||
if let Some(ts) = created_at {
|
||||
builder = builder.custom_created_at(ts);
|
||||
}
|
||||
let event = builder.sign_with_keys(keys).expect("sign");
|
||||
serde_json::to_string(&event).expect("serialize")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_event_returns_pubkey() {
|
||||
let keys = Keys::generate();
|
||||
let json = make_nip98_event(&keys, TEST_URL, TEST_METHOD, None, None);
|
||||
let result = verify_nip98_event(&json, TEST_URL, TEST_METHOD, None);
|
||||
assert!(result.is_ok(), "verify failed: {:?}", result.err());
|
||||
assert_eq!(result.unwrap(), keys.public_key());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrong_kind_rejected() {
|
||||
let keys = Keys::generate();
|
||||
let event = EventBuilder::new(Kind::TextNote, "", [])
|
||||
.sign_with_keys(&keys)
|
||||
.expect("sign");
|
||||
let json = serde_json::to_string(&event).unwrap();
|
||||
let result = verify_nip98_event(&json, TEST_URL, TEST_METHOD, None);
|
||||
assert!(matches!(result, Err(AuthError::Nip98Invalid(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expired_timestamp_rejected() {
|
||||
let keys = Keys::generate();
|
||||
let old_ts = Timestamp::from(Timestamp::now().as_u64().saturating_sub(120));
|
||||
let json = make_nip98_event(&keys, TEST_URL, TEST_METHOD, None, Some(old_ts));
|
||||
let result = verify_nip98_event(&json, TEST_URL, TEST_METHOD, None);
|
||||
assert!(matches!(result, Err(AuthError::Nip98Invalid(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_mismatch_rejected() {
|
||||
let keys = Keys::generate();
|
||||
let json = make_nip98_event(
|
||||
&keys,
|
||||
"https://other.example.com/api/tokens",
|
||||
TEST_METHOD,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let result = verify_nip98_event(&json, TEST_URL, TEST_METHOD, None);
|
||||
assert!(matches!(result, Err(AuthError::Nip98Invalid(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn method_mismatch_rejected() {
|
||||
let keys = Keys::generate();
|
||||
let json = make_nip98_event(&keys, TEST_URL, "GET", None, None);
|
||||
let result = verify_nip98_event(&json, TEST_URL, TEST_METHOD, None);
|
||||
assert!(matches!(result, Err(AuthError::Nip98Invalid(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn method_case_insensitive() {
|
||||
let keys = Keys::generate();
|
||||
let json = make_nip98_event(&keys, TEST_URL, "post", None, None);
|
||||
let result = verify_nip98_event(&json, TEST_URL, "POST", None);
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn payload_tag_correct_hash_passes() {
|
||||
let keys = Keys::generate();
|
||||
let body = b"hello world";
|
||||
let hash: [u8; 32] = Sha256::digest(body).into();
|
||||
let hash_hex = hex::encode(hash);
|
||||
let json = make_nip98_event(&keys, TEST_URL, TEST_METHOD, Some(&hash_hex), None);
|
||||
let result = verify_nip98_event(&json, TEST_URL, TEST_METHOD, Some(body));
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn payload_tag_wrong_hash_rejected() {
|
||||
let keys = Keys::generate();
|
||||
let body = b"hello world";
|
||||
let wrong_hex = "deadbeef".repeat(8); // 64 hex chars but wrong hash
|
||||
let json = make_nip98_event(&keys, TEST_URL, TEST_METHOD, Some(&wrong_hex), None);
|
||||
let result = verify_nip98_event(&json, TEST_URL, TEST_METHOD, Some(body));
|
||||
assert!(matches!(result, Err(AuthError::Nip98Invalid(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn payload_tag_absent_with_body_passes() {
|
||||
// payload tag is optional per spec; clients SHOULD include it but it's not required
|
||||
let keys = Keys::generate();
|
||||
let json = make_nip98_event(&keys, TEST_URL, TEST_METHOD, None, None);
|
||||
let result = verify_nip98_event(&json, TEST_URL, TEST_METHOD, Some(b"some body"));
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trailing_slash_normalized() {
|
||||
let keys = Keys::generate();
|
||||
let url_with_slash = "https://relay.example.com/api/tokens/";
|
||||
let json = make_nip98_event(&keys, url_with_slash, TEST_METHOD, None, None);
|
||||
// expected_url without trailing slash — should still match
|
||||
let result = verify_nip98_event(&json, TEST_URL, TEST_METHOD, None);
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn localhost_normalized() {
|
||||
let keys = Keys::generate();
|
||||
let localhost_url = "http://localhost:3000/api/tokens";
|
||||
let loopback_url = "http://127.0.0.1:3000/api/tokens";
|
||||
let json = make_nip98_event(&keys, localhost_url, TEST_METHOD, None, None);
|
||||
let result = verify_nip98_event(&json, loopback_url, TEST_METHOD, None);
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_self_mintable_all_seven() {
|
||||
use crate::scope::{is_self_mintable, Scope};
|
||||
assert!(is_self_mintable(&Scope::MessagesRead));
|
||||
assert!(is_self_mintable(&Scope::MessagesWrite));
|
||||
assert!(is_self_mintable(&Scope::ChannelsRead));
|
||||
assert!(is_self_mintable(&Scope::ChannelsWrite));
|
||||
assert!(is_self_mintable(&Scope::UsersRead));
|
||||
assert!(is_self_mintable(&Scope::FilesRead));
|
||||
assert!(is_self_mintable(&Scope::FilesWrite));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_self_mintable_admin_scopes_false() {
|
||||
use crate::scope::{is_self_mintable, Scope};
|
||||
assert!(!is_self_mintable(&Scope::AdminChannels));
|
||||
assert!(!is_self_mintable(&Scope::UsersWrite));
|
||||
assert!(!is_self_mintable(&Scope::AdminUsers));
|
||||
assert!(!is_self_mintable(&Scope::JobsRead));
|
||||
assert!(!is_self_mintable(&Scope::JobsWrite));
|
||||
assert!(!is_self_mintable(&Scope::SubscriptionsRead));
|
||||
assert!(!is_self_mintable(&Scope::SubscriptionsWrite));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_self_mintable_unknown_false() {
|
||||
use crate::scope::{is_self_mintable, Scope};
|
||||
assert!(!is_self_mintable(&Scope::Unknown(
|
||||
"future:scope".to_string()
|
||||
)));
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,51 @@ pub enum Scope {
|
||||
}
|
||||
|
||||
impl Scope {
|
||||
/// Return a `Vec` containing every known scope variant.
|
||||
///
|
||||
/// Used in dev mode (`require_auth_token=false`) where `X-Pubkey` header
|
||||
/// auth grants unrestricted access — there is no token to derive scopes from.
|
||||
pub fn all_known() -> Vec<Scope> {
|
||||
vec![
|
||||
Self::MessagesRead,
|
||||
Self::MessagesWrite,
|
||||
Self::ChannelsRead,
|
||||
Self::ChannelsWrite,
|
||||
Self::AdminChannels,
|
||||
Self::UsersRead,
|
||||
Self::UsersWrite,
|
||||
Self::AdminUsers,
|
||||
Self::JobsRead,
|
||||
Self::JobsWrite,
|
||||
Self::SubscriptionsRead,
|
||||
Self::SubscriptionsWrite,
|
||||
Self::FilesRead,
|
||||
Self::FilesWrite,
|
||||
]
|
||||
}
|
||||
|
||||
/// Return a `Vec` containing every known scope variant except admin scopes.
|
||||
///
|
||||
/// Used in dev mode (`require_auth_token=false`) where `X-Pubkey` header auth grants
|
||||
/// access without a real token. Admin operations (`AdminChannels`, `AdminUsers`) require
|
||||
/// a real token even in dev mode, so they are excluded here.
|
||||
pub fn all_non_admin() -> Vec<Scope> {
|
||||
vec![
|
||||
Self::MessagesRead,
|
||||
Self::MessagesWrite,
|
||||
Self::ChannelsRead,
|
||||
Self::ChannelsWrite,
|
||||
Self::UsersRead,
|
||||
Self::UsersWrite,
|
||||
Self::JobsRead,
|
||||
Self::JobsWrite,
|
||||
Self::SubscriptionsRead,
|
||||
Self::SubscriptionsWrite,
|
||||
Self::FilesRead,
|
||||
Self::FilesWrite,
|
||||
]
|
||||
}
|
||||
|
||||
/// Return the canonical wire-format string for this scope (e.g. `"messages:read"`).
|
||||
pub fn as_str(&self) -> &str {
|
||||
match self {
|
||||
@@ -99,6 +144,39 @@ impl FromStr for Scope {
|
||||
}
|
||||
}
|
||||
|
||||
/// Scopes that can be self-minted via `POST /api/tokens`.
|
||||
///
|
||||
/// Admin-only scopes (`AdminChannels`, `UsersWrite`, `AdminUsers`, `JobsRead`, `JobsWrite`,
|
||||
/// `SubscriptionsRead`, `SubscriptionsWrite`) are intentionally excluded — they require
|
||||
/// `sprout-admin mint-token`.
|
||||
pub const SELF_MINTABLE_SCOPES: &[Scope] = &[
|
||||
Scope::MessagesRead,
|
||||
Scope::MessagesWrite,
|
||||
Scope::ChannelsRead,
|
||||
Scope::ChannelsWrite,
|
||||
Scope::UsersRead,
|
||||
Scope::FilesRead,
|
||||
Scope::FilesWrite,
|
||||
];
|
||||
|
||||
/// Returns `true` if the given scope may be requested via `POST /api/tokens`.
|
||||
///
|
||||
/// Admin-only scopes and `Scope::Unknown` always return `false`.
|
||||
/// Unknown scope strings are rejected at mint time rather than silently accepted —
|
||||
/// a client sending an unrecognised scope string likely has a bug.
|
||||
pub fn is_self_mintable(scope: &Scope) -> bool {
|
||||
matches!(
|
||||
scope,
|
||||
Scope::MessagesRead
|
||||
| Scope::MessagesWrite
|
||||
| Scope::ChannelsRead
|
||||
| Scope::ChannelsWrite
|
||||
| Scope::UsersRead
|
||||
| Scope::FilesRead
|
||||
| Scope::FilesWrite
|
||||
)
|
||||
}
|
||||
|
||||
/// Parse a slice of scope strings into `Vec<Scope>`.
|
||||
pub fn parse_scopes(raw: &[impl AsRef<str>]) -> Vec<Scope> {
|
||||
raw.iter()
|
||||
@@ -134,4 +212,49 @@ mod tests {
|
||||
let scopes = parse_scopes(&["messages:read", "channels:write"]);
|
||||
assert_eq!(scopes, vec![Scope::MessagesRead, Scope::ChannelsWrite]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_non_admin_excludes_admin_scopes() {
|
||||
let scopes = Scope::all_non_admin();
|
||||
assert_eq!(scopes.len(), 12, "expected 12 non-admin scope variants");
|
||||
// Verify no duplicates
|
||||
let unique: std::collections::HashSet<_> = scopes.iter().map(|s| s.as_str()).collect();
|
||||
assert_eq!(
|
||||
unique.len(),
|
||||
12,
|
||||
"all_non_admin() must not contain duplicates"
|
||||
);
|
||||
// Verify no Unknown variants
|
||||
for scope in &scopes {
|
||||
assert!(
|
||||
!matches!(scope, Scope::Unknown(_)),
|
||||
"all_non_admin() must not contain Unknown variants"
|
||||
);
|
||||
}
|
||||
// Verify admin scopes are excluded
|
||||
assert!(
|
||||
!scopes.contains(&Scope::AdminChannels),
|
||||
"all_non_admin() must not contain AdminChannels"
|
||||
);
|
||||
assert!(
|
||||
!scopes.contains(&Scope::AdminUsers),
|
||||
"all_non_admin() must not contain AdminUsers"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_known_returns_all_14_variants() {
|
||||
let all = Scope::all_known();
|
||||
assert_eq!(all.len(), 14, "expected 14 known scope variants");
|
||||
// Verify no duplicates
|
||||
let unique: std::collections::HashSet<_> = all.iter().map(|s| s.as_str()).collect();
|
||||
assert_eq!(unique.len(), 14, "all_known() must not contain duplicates");
|
||||
// Verify no Unknown variants
|
||||
for scope in &all {
|
||||
assert!(
|
||||
!matches!(scope, Scope::Unknown(_)),
|
||||
"all_known() must not contain Unknown variants"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
//! API token CRUD operations.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use sqlx::MySqlPool;
|
||||
use sqlx::{MySqlPool, Row};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::{DbError, Result};
|
||||
use crate::event::uuid_from_bytes;
|
||||
|
||||
/// Create a new API token record. The caller is responsible for generating
|
||||
/// the raw token and computing its SHA-256 hash.
|
||||
@@ -49,3 +50,248 @@ pub async fn create_api_token(
|
||||
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Atomic conditional INSERT: create a token only if the owner has fewer than 10 active tokens.
|
||||
///
|
||||
/// Uses a `SELECT ... WHERE COUNT < 10` subquery so the check and insert are atomic —
|
||||
/// no TOCTOU race between a separate count query and the insert.
|
||||
///
|
||||
/// Returns `Ok(Some(uuid))` on success, `Ok(None)` if the 10-token limit is exceeded.
|
||||
pub async fn create_api_token_if_under_limit(
|
||||
pool: &MySqlPool,
|
||||
token_hash: &[u8],
|
||||
owner_pubkey: &[u8],
|
||||
name: &str,
|
||||
scopes: &[String],
|
||||
channel_ids: Option<&[Uuid]>,
|
||||
expires_at: Option<DateTime<Utc>>,
|
||||
) -> Result<Option<Uuid>> {
|
||||
let id = Uuid::new_v4();
|
||||
let id_bytes = id.as_bytes().as_slice();
|
||||
|
||||
let scopes_json =
|
||||
serde_json::to_value(scopes).map_err(|e| DbError::InvalidData(e.to_string()))?;
|
||||
|
||||
let channel_ids_json: Option<serde_json::Value> = channel_ids
|
||||
.map(|ids| {
|
||||
serde_json::to_value(ids.iter().map(|id| id.to_string()).collect::<Vec<_>>())
|
||||
.map_err(|e| DbError::InvalidData(format!("channel_ids serialization: {e}")))
|
||||
})
|
||||
.transpose()?;
|
||||
|
||||
// Conditional INSERT: only inserts if active (non-revoked, non-expired) token count < 10.
|
||||
// The subquery and insert execute atomically — no separate count + insert race.
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
INSERT INTO api_tokens
|
||||
(id, token_hash, owner_pubkey, name, scopes, channel_ids, expires_at, created_by_self_mint)
|
||||
SELECT ?, ?, ?, ?, ?, ?, ?, TRUE
|
||||
FROM DUAL
|
||||
WHERE (
|
||||
SELECT COUNT(*)
|
||||
FROM api_tokens
|
||||
WHERE owner_pubkey = ?
|
||||
AND revoked_at IS NULL
|
||||
AND (expires_at IS NULL OR expires_at > NOW())
|
||||
) < 10
|
||||
"#,
|
||||
)
|
||||
.bind(id_bytes)
|
||||
.bind(token_hash)
|
||||
.bind(owner_pubkey)
|
||||
.bind(name)
|
||||
.bind(&scopes_json)
|
||||
.bind(&channel_ids_json)
|
||||
.bind(expires_at)
|
||||
.bind(owner_pubkey)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
// Limit exceeded — the WHERE clause prevented the INSERT.
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
Ok(Some(id))
|
||||
}
|
||||
|
||||
/// Look up an API token by its SHA-256 hash, **including revoked tokens**.
|
||||
///
|
||||
/// Unlike [`crate::Db::get_api_token_by_hash`] (which filters `revoked_at IS NULL`),
|
||||
/// this function returns the full record regardless of revocation status.
|
||||
/// The relay layer uses this to return distinct `token_revoked` vs `invalid_token`
|
||||
/// error responses rather than treating both as "not found".
|
||||
pub async fn get_api_token_by_hash_including_revoked(
|
||||
pool: &MySqlPool,
|
||||
hash: &[u8],
|
||||
) -> Result<Option<crate::ApiTokenRecord>> {
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
SELECT id, token_hash, owner_pubkey, name, scopes, channel_ids,
|
||||
created_at, expires_at, last_used_at, revoked_at
|
||||
FROM api_tokens
|
||||
WHERE token_hash = ?
|
||||
"#,
|
||||
)
|
||||
.bind(hash)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
let row = match row {
|
||||
None => return Ok(None),
|
||||
Some(r) => r,
|
||||
};
|
||||
|
||||
let id_bytes: Vec<u8> = row.try_get("id")?;
|
||||
let id = uuid_from_bytes(&id_bytes)?;
|
||||
|
||||
let scopes_json: serde_json::Value = row.try_get("scopes")?;
|
||||
let scopes: Vec<String> = serde_json::from_value(scopes_json)
|
||||
.map_err(|e| DbError::InvalidData(format!("scopes JSON: {e}")))?;
|
||||
|
||||
let channel_ids: Option<Vec<Uuid>> = {
|
||||
let raw: Option<serde_json::Value> = row.try_get("channel_ids")?;
|
||||
match raw {
|
||||
None => None,
|
||||
Some(v) => {
|
||||
let strings: Vec<String> = serde_json::from_value(v)
|
||||
.map_err(|e| DbError::InvalidData(format!("channel_ids JSON: {e}")))?;
|
||||
let uuids: std::result::Result<Vec<Uuid>, _> =
|
||||
strings.iter().map(|s| s.parse::<Uuid>()).collect();
|
||||
Some(uuids.map_err(|e| DbError::InvalidData(format!("channel_ids UUID: {e}")))?)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Some(crate::ApiTokenRecord {
|
||||
id,
|
||||
token_hash: row.try_get("token_hash")?,
|
||||
owner_pubkey: row.try_get("owner_pubkey")?,
|
||||
name: row.try_get("name")?,
|
||||
scopes,
|
||||
channel_ids,
|
||||
created_at: row.try_get("created_at")?,
|
||||
expires_at: row.try_get("expires_at")?,
|
||||
last_used_at: row.try_get("last_used_at")?,
|
||||
revoked_at: row.try_get("revoked_at")?,
|
||||
}))
|
||||
}
|
||||
|
||||
/// List all tokens (including revoked) for a pubkey, ordered by creation time descending.
|
||||
///
|
||||
/// Returns the full [`crate::ApiTokenRecord`] including `token_hash`. Callers are
|
||||
/// responsible for stripping `token_hash` before returning data to clients — the
|
||||
/// raw token value is never exposed after the initial mint response.
|
||||
/// Used by `GET /api/tokens` to show a user their full token history.
|
||||
pub async fn list_tokens_by_owner(
|
||||
pool: &MySqlPool,
|
||||
pubkey: &[u8],
|
||||
) -> Result<Vec<crate::ApiTokenRecord>> {
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT id, token_hash, owner_pubkey, name, scopes, channel_ids,
|
||||
created_at, expires_at, last_used_at, revoked_at
|
||||
FROM api_tokens
|
||||
WHERE owner_pubkey = ?
|
||||
ORDER BY created_at DESC
|
||||
"#,
|
||||
)
|
||||
.bind(pubkey)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
let mut out = Vec::with_capacity(rows.len());
|
||||
for row in rows {
|
||||
let id_bytes: Vec<u8> = row.try_get("id")?;
|
||||
let id = uuid_from_bytes(&id_bytes)?;
|
||||
|
||||
let scopes_json: serde_json::Value = row.try_get("scopes")?;
|
||||
let scopes: Vec<String> = serde_json::from_value(scopes_json)
|
||||
.map_err(|e| DbError::InvalidData(format!("scopes JSON: {e}")))?;
|
||||
|
||||
let channel_ids: Option<Vec<Uuid>> = {
|
||||
let raw: Option<serde_json::Value> = row.try_get("channel_ids")?;
|
||||
match raw {
|
||||
None => None,
|
||||
Some(v) => {
|
||||
let strings: Vec<String> = serde_json::from_value(v)
|
||||
.map_err(|e| DbError::InvalidData(format!("channel_ids JSON: {e}")))?;
|
||||
let uuids: std::result::Result<Vec<Uuid>, _> =
|
||||
strings.iter().map(|s| s.parse::<Uuid>()).collect();
|
||||
Some(
|
||||
uuids
|
||||
.map_err(|e| DbError::InvalidData(format!("channel_ids UUID: {e}")))?,
|
||||
)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
out.push(crate::ApiTokenRecord {
|
||||
id,
|
||||
token_hash: row.try_get("token_hash")?,
|
||||
owner_pubkey: row.try_get("owner_pubkey")?,
|
||||
name: row.try_get("name")?,
|
||||
scopes,
|
||||
channel_ids,
|
||||
created_at: row.try_get("created_at")?,
|
||||
expires_at: row.try_get("expires_at")?,
|
||||
last_used_at: row.try_get("last_used_at")?,
|
||||
revoked_at: row.try_get("revoked_at")?,
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Revoke a single token by ID, scoped to the owner.
|
||||
///
|
||||
/// Only revokes if the token is owned by `owner_pubkey` and not already revoked.
|
||||
/// Returns `true` if the token was revoked, `false` if not found, not owned, or already revoked.
|
||||
pub async fn revoke_token(
|
||||
pool: &MySqlPool,
|
||||
id: Uuid,
|
||||
owner_pubkey: &[u8],
|
||||
revoked_by: &[u8],
|
||||
) -> Result<bool> {
|
||||
let id_bytes = id.as_bytes().as_slice();
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
UPDATE api_tokens
|
||||
SET revoked_at = NOW(6), revoked_by = ?
|
||||
WHERE id = ?
|
||||
AND owner_pubkey = ?
|
||||
AND revoked_at IS NULL
|
||||
"#,
|
||||
)
|
||||
.bind(revoked_by)
|
||||
.bind(id_bytes)
|
||||
.bind(owner_pubkey)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(result.rows_affected() > 0)
|
||||
}
|
||||
|
||||
/// Revoke all active tokens for a pubkey.
|
||||
///
|
||||
/// Skips already-revoked tokens (idempotent). Returns the count of newly revoked tokens.
|
||||
/// If all tokens are already revoked, returns 0 with no error.
|
||||
pub async fn revoke_all_tokens(
|
||||
pool: &MySqlPool,
|
||||
owner_pubkey: &[u8],
|
||||
revoked_by: &[u8],
|
||||
) -> Result<u64> {
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
UPDATE api_tokens
|
||||
SET revoked_at = NOW(6), revoked_by = ?
|
||||
WHERE owner_pubkey = ?
|
||||
AND revoked_at IS NULL
|
||||
"#,
|
||||
)
|
||||
.bind(revoked_by)
|
||||
.bind(owner_pubkey)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
|
||||
@@ -694,6 +694,68 @@ impl Db {
|
||||
.await
|
||||
}
|
||||
|
||||
/// Atomic conditional INSERT: create a self-minted token only if the owner has
|
||||
/// fewer than 10 active (non-revoked, non-expired) tokens.
|
||||
///
|
||||
/// Returns `Ok(Some(uuid))` on success, `Ok(None)` if the limit is exceeded.
|
||||
pub async fn create_api_token_if_under_limit(
|
||||
&self,
|
||||
token_hash: &[u8],
|
||||
owner_pubkey: &[u8],
|
||||
name: &str,
|
||||
scopes: &[String],
|
||||
channel_ids: Option<&[Uuid]>,
|
||||
expires_at: Option<DateTime<Utc>>,
|
||||
) -> Result<Option<Uuid>> {
|
||||
api_token::create_api_token_if_under_limit(
|
||||
&self.pool,
|
||||
token_hash,
|
||||
owner_pubkey,
|
||||
name,
|
||||
scopes,
|
||||
channel_ids,
|
||||
expires_at,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Look up an API token by its SHA-256 hash, **including revoked tokens**.
|
||||
///
|
||||
/// Unlike [`Db::get_api_token_by_hash`], this does not filter on `revoked_at IS NULL`.
|
||||
/// Used by the relay to return distinct `token_revoked` vs `invalid_token` errors.
|
||||
pub async fn get_api_token_by_hash_including_revoked(
|
||||
&self,
|
||||
hash: &[u8],
|
||||
) -> Result<Option<ApiTokenRecord>> {
|
||||
api_token::get_api_token_by_hash_including_revoked(&self.pool, hash).await
|
||||
}
|
||||
|
||||
/// List all tokens (including revoked) for a pubkey, ordered by `created_at DESC`.
|
||||
///
|
||||
/// Does not return `token_hash`. Used by `GET /api/tokens`.
|
||||
pub async fn list_tokens_by_owner(&self, pubkey: &[u8]) -> Result<Vec<ApiTokenRecord>> {
|
||||
api_token::list_tokens_by_owner(&self.pool, pubkey).await
|
||||
}
|
||||
|
||||
/// Revoke a single token by ID, scoped to the owner.
|
||||
///
|
||||
/// Returns `true` if revoked, `false` if not found, not owned by caller, or already revoked.
|
||||
pub async fn revoke_token(
|
||||
&self,
|
||||
id: Uuid,
|
||||
owner_pubkey: &[u8],
|
||||
revoked_by: &[u8],
|
||||
) -> Result<bool> {
|
||||
api_token::revoke_token(&self.pool, id, owner_pubkey, revoked_by).await
|
||||
}
|
||||
|
||||
/// Revoke all active tokens for a pubkey. Skips already-revoked tokens (idempotent).
|
||||
///
|
||||
/// Returns the count of newly revoked tokens (0 if all already revoked).
|
||||
pub async fn revoke_all_tokens(&self, owner_pubkey: &[u8], revoked_by: &[u8]) -> Result<u64> {
|
||||
api_token::revoke_all_tokens(&self.pool, owner_pubkey, revoked_by).await
|
||||
}
|
||||
|
||||
/// List all non-revoked, non-expired API tokens.
|
||||
///
|
||||
/// Returns a summary view — does not expose raw token hashes.
|
||||
|
||||
@@ -43,6 +43,7 @@ serde_yaml = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
hex = { workspace = true }
|
||||
url = { workspace = true }
|
||||
moka = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
sprout-core = { workspace = true, features = ["test-utils"] }
|
||||
|
||||
@@ -13,7 +13,7 @@ use nostr::util::hex as nostr_hex;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
use super::{extract_auth_pubkey, internal_error};
|
||||
use super::{extract_auth_context, internal_error};
|
||||
|
||||
/// Returns all bot/agent members visible to the authenticated user, with presence status.
|
||||
///
|
||||
@@ -22,7 +22,10 @@ pub async fn agents_handler(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
|
||||
let (_pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
|
||||
let ctx = extract_auth_context(&headers, &state).await?;
|
||||
sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::ChannelsRead)
|
||||
.map_err(super::scope_error)?;
|
||||
let pubkey_bytes = ctx.pubkey_bytes.clone();
|
||||
|
||||
// Get requester's accessible channels to filter bot channel visibility.
|
||||
let accessible_channels = state
|
||||
|
||||
@@ -16,7 +16,7 @@ use serde::Deserialize;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
use super::{api_error, extract_auth_pubkey, forbidden, internal_error, not_found};
|
||||
use super::{api_error, extract_auth_context, forbidden, internal_error, not_found, scope_error};
|
||||
|
||||
// ── Request body ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -178,7 +178,10 @@ pub async fn grant_approval(
|
||||
Path(token): Path<String>,
|
||||
body: Option<Json<ApprovalBody>>,
|
||||
) -> Result<(StatusCode, Json<serde_json::Value>), (StatusCode, Json<serde_json::Value>)> {
|
||||
let (pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
|
||||
let ctx = extract_auth_context(&headers, &state).await?;
|
||||
sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::ChannelsWrite)
|
||||
.map_err(scope_error)?;
|
||||
let pubkey_bytes = ctx.pubkey_bytes.clone();
|
||||
|
||||
let approval = state
|
||||
.db
|
||||
@@ -197,7 +200,7 @@ pub async fn grant_approval(
|
||||
return Err(api_error(StatusCode::GONE, "approval token has expired"));
|
||||
}
|
||||
|
||||
check_approver_spec(&approval.approver_spec, &pubkey.to_hex())?;
|
||||
check_approver_spec(&approval.approver_spec, &ctx.pubkey.to_hex())?;
|
||||
|
||||
let note = body.as_ref().and_then(|b| b.note.as_deref());
|
||||
|
||||
@@ -250,7 +253,10 @@ pub async fn deny_approval(
|
||||
Path(token): Path<String>,
|
||||
body: Option<Json<ApprovalBody>>,
|
||||
) -> Result<(StatusCode, Json<serde_json::Value>), (StatusCode, Json<serde_json::Value>)> {
|
||||
let (pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
|
||||
let ctx = extract_auth_context(&headers, &state).await?;
|
||||
sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::ChannelsWrite)
|
||||
.map_err(scope_error)?;
|
||||
let pubkey_bytes = ctx.pubkey_bytes.clone();
|
||||
|
||||
let approval = state
|
||||
.db
|
||||
@@ -269,7 +275,7 @@ pub async fn deny_approval(
|
||||
return Err(api_error(StatusCode::GONE, "approval token has expired"));
|
||||
}
|
||||
|
||||
check_approver_spec(&approval.approver_spec, &pubkey.to_hex())?;
|
||||
check_approver_spec(&approval.approver_spec, &ctx.pubkey.to_hex())?;
|
||||
|
||||
let note = body.as_ref().and_then(|b| b.note.as_deref());
|
||||
|
||||
@@ -292,7 +298,7 @@ pub async fn deny_approval(
|
||||
// A run that has already transitioned to Failed/Completed/Cancelled through
|
||||
// another path (e.g., timeout, manual cancel) must not be overwritten.
|
||||
let run_id = approval.run_id;
|
||||
let pubkey_for_msg = pubkey.to_hex();
|
||||
let pubkey_for_msg = ctx.pubkey.to_hex();
|
||||
let db = state.db.clone();
|
||||
tokio::spawn(async move {
|
||||
let run = match db.get_workflow_run(run_id).await {
|
||||
|
||||
@@ -24,7 +24,10 @@ use sprout_db::event::EventQuery;
|
||||
use crate::handlers::event::dispatch_persistent_event;
|
||||
use crate::state::AppState;
|
||||
|
||||
use super::{api_error, check_channel_access, extract_auth_pubkey, internal_error};
|
||||
use super::{
|
||||
api_error, check_channel_access, check_token_channel_access, extract_auth_context,
|
||||
internal_error,
|
||||
};
|
||||
|
||||
// ── GET /api/channels/:channel_id/canvas ─────────────────────────────────────
|
||||
|
||||
@@ -37,11 +40,15 @@ pub async fn get_canvas(
|
||||
headers: HeaderMap,
|
||||
Path(channel_id_str): Path<String>,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
|
||||
let (_pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
|
||||
let ctx = extract_auth_context(&headers, &state).await?;
|
||||
sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::ChannelsRead)
|
||||
.map_err(super::scope_error)?;
|
||||
let pubkey_bytes = ctx.pubkey_bytes.clone();
|
||||
|
||||
let channel_id = uuid::Uuid::parse_str(&channel_id_str)
|
||||
.map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid channel UUID"))?;
|
||||
|
||||
check_token_channel_access(&ctx, &channel_id)?;
|
||||
check_channel_access(&state, channel_id, &pubkey_bytes).await?;
|
||||
|
||||
// Query the events table for the most recent KIND_CANVAS event scoped to
|
||||
@@ -105,11 +112,15 @@ pub async fn set_canvas(
|
||||
Path(channel_id_str): Path<String>,
|
||||
Json(body): Json<SetCanvasBody>,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
|
||||
let (_pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
|
||||
let ctx = extract_auth_context(&headers, &state).await?;
|
||||
sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::ChannelsWrite)
|
||||
.map_err(super::scope_error)?;
|
||||
let pubkey_bytes = ctx.pubkey_bytes.clone();
|
||||
|
||||
let channel_id = uuid::Uuid::parse_str(&channel_id_str)
|
||||
.map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid channel UUID"))?;
|
||||
|
||||
check_token_channel_access(&ctx, &channel_id)?;
|
||||
check_channel_access(&state, channel_id, &pubkey_bytes).await?;
|
||||
|
||||
// Reject writes to archived channels (consistent with messages, metadata, etc.).
|
||||
|
||||
@@ -19,7 +19,7 @@ use sprout_db::channel::{ChannelRecord, ChannelType, ChannelVisibility};
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
use super::{api_error, extract_auth_pubkey, internal_error};
|
||||
use super::{api_error, extract_auth_context, internal_error};
|
||||
|
||||
/// Query parameters for `GET /api/channels`.
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -36,7 +36,10 @@ pub async fn channels_handler(
|
||||
headers: HeaderMap,
|
||||
Query(params): Query<ListChannelsParams>,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
|
||||
let (_pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
|
||||
let ctx = extract_auth_context(&headers, &state).await?;
|
||||
sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::ChannelsRead)
|
||||
.map_err(super::scope_error)?;
|
||||
let pubkey_bytes = ctx.pubkey_bytes.clone();
|
||||
|
||||
let channels = state
|
||||
.db
|
||||
@@ -101,7 +104,10 @@ pub async fn create_channel(
|
||||
headers: HeaderMap,
|
||||
ExtractJson(body): ExtractJson<CreateChannelBody>,
|
||||
) -> Result<(StatusCode, Json<serde_json::Value>), (StatusCode, Json<serde_json::Value>)> {
|
||||
let (_pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
|
||||
let ctx = extract_auth_context(&headers, &state).await?;
|
||||
sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::ChannelsWrite)
|
||||
.map_err(super::scope_error)?;
|
||||
let pubkey_bytes = ctx.pubkey_bytes.clone();
|
||||
|
||||
let name = body.name.trim();
|
||||
if name.is_empty() {
|
||||
|
||||
@@ -34,7 +34,8 @@ use crate::handlers::side_effects::emit_system_message;
|
||||
use crate::state::AppState;
|
||||
|
||||
use super::{
|
||||
api_error, check_channel_access, extract_auth_pubkey, forbidden, internal_error, not_found,
|
||||
api_error, check_channel_access, check_token_channel_access, extract_auth_context, forbidden,
|
||||
internal_error, not_found, scope_error,
|
||||
};
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
@@ -99,8 +100,12 @@ pub async fn get_channel_handler(
|
||||
headers: HeaderMap,
|
||||
Path(channel_id_str): Path<String>,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
|
||||
let (_pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
|
||||
let ctx = extract_auth_context(&headers, &state).await?;
|
||||
sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::ChannelsRead)
|
||||
.map_err(scope_error)?;
|
||||
let pubkey_bytes = ctx.pubkey_bytes.clone();
|
||||
let channel_id = parse_channel_id(&channel_id_str)?;
|
||||
check_token_channel_access(&ctx, &channel_id)?;
|
||||
|
||||
check_channel_access(&state, channel_id, &pubkey_bytes).await?;
|
||||
|
||||
@@ -143,8 +148,12 @@ pub async fn update_channel_handler(
|
||||
Path(channel_id_str): Path<String>,
|
||||
ExtractJson(body): ExtractJson<UpdateChannelBody>,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
|
||||
let (_pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
|
||||
let ctx = extract_auth_context(&headers, &state).await?;
|
||||
sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::ChannelsWrite)
|
||||
.map_err(scope_error)?;
|
||||
let pubkey_bytes = ctx.pubkey_bytes.clone();
|
||||
let channel_id = parse_channel_id(&channel_id_str)?;
|
||||
check_token_channel_access(&ctx, &channel_id)?;
|
||||
|
||||
require_owner_or_admin(&state, channel_id, &pubkey_bytes).await?;
|
||||
|
||||
@@ -237,8 +246,12 @@ pub async fn set_topic_handler(
|
||||
Path(channel_id_str): Path<String>,
|
||||
ExtractJson(body): ExtractJson<SetTopicBody>,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
|
||||
let (_pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
|
||||
let ctx = extract_auth_context(&headers, &state).await?;
|
||||
sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::ChannelsWrite)
|
||||
.map_err(scope_error)?;
|
||||
let pubkey_bytes = ctx.pubkey_bytes.clone();
|
||||
let channel_id = parse_channel_id(&channel_id_str)?;
|
||||
check_token_channel_access(&ctx, &channel_id)?;
|
||||
|
||||
check_channel_access(&state, channel_id, &pubkey_bytes).await?;
|
||||
|
||||
@@ -300,8 +313,12 @@ pub async fn set_purpose_handler(
|
||||
Path(channel_id_str): Path<String>,
|
||||
ExtractJson(body): ExtractJson<SetPurposeBody>,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
|
||||
let (_pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
|
||||
let ctx = extract_auth_context(&headers, &state).await?;
|
||||
sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::ChannelsWrite)
|
||||
.map_err(scope_error)?;
|
||||
let pubkey_bytes = ctx.pubkey_bytes.clone();
|
||||
let channel_id = parse_channel_id(&channel_id_str)?;
|
||||
check_token_channel_access(&ctx, &channel_id)?;
|
||||
|
||||
check_channel_access(&state, channel_id, &pubkey_bytes).await?;
|
||||
|
||||
@@ -359,8 +376,12 @@ pub async fn archive_channel_handler(
|
||||
headers: HeaderMap,
|
||||
Path(channel_id_str): Path<String>,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
|
||||
let (_pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
|
||||
let ctx = extract_auth_context(&headers, &state).await?;
|
||||
sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::AdminChannels)
|
||||
.map_err(scope_error)?;
|
||||
let pubkey_bytes = ctx.pubkey_bytes.clone();
|
||||
let channel_id = parse_channel_id(&channel_id_str)?;
|
||||
check_token_channel_access(&ctx, &channel_id)?;
|
||||
|
||||
require_owner_or_admin(&state, channel_id, &pubkey_bytes).await?;
|
||||
|
||||
@@ -400,8 +421,12 @@ pub async fn unarchive_channel_handler(
|
||||
headers: HeaderMap,
|
||||
Path(channel_id_str): Path<String>,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
|
||||
let (_pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
|
||||
let ctx = extract_auth_context(&headers, &state).await?;
|
||||
sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::AdminChannels)
|
||||
.map_err(scope_error)?;
|
||||
let pubkey_bytes = ctx.pubkey_bytes.clone();
|
||||
let channel_id = parse_channel_id(&channel_id_str)?;
|
||||
check_token_channel_access(&ctx, &channel_id)?;
|
||||
|
||||
require_owner_or_admin(&state, channel_id, &pubkey_bytes).await?;
|
||||
|
||||
@@ -441,8 +466,12 @@ pub async fn delete_channel_handler(
|
||||
headers: HeaderMap,
|
||||
Path(channel_id_str): Path<String>,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
|
||||
let (_pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
|
||||
let ctx = extract_auth_context(&headers, &state).await?;
|
||||
sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::AdminChannels)
|
||||
.map_err(scope_error)?;
|
||||
let pubkey_bytes = ctx.pubkey_bytes.clone();
|
||||
let channel_id = parse_channel_id(&channel_id_str)?;
|
||||
check_token_channel_access(&ctx, &channel_id)?;
|
||||
|
||||
// Only channel owners may delete.
|
||||
let role = state
|
||||
|
||||
@@ -19,7 +19,7 @@ use uuid::Uuid;
|
||||
use crate::handlers::side_effects::emit_system_message;
|
||||
use crate::state::AppState;
|
||||
|
||||
use super::{api_error, extract_auth_pubkey, internal_error};
|
||||
use super::{api_error, extract_auth_context, internal_error};
|
||||
|
||||
// ── Request / query types ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -58,7 +58,10 @@ pub async fn open_dm_handler(
|
||||
headers: HeaderMap,
|
||||
ExtractJson(body): ExtractJson<OpenDmBody>,
|
||||
) -> Result<(StatusCode, Json<serde_json::Value>), (StatusCode, Json<serde_json::Value>)> {
|
||||
let (_pubkey, self_bytes) = extract_auth_pubkey(&headers, &state).await?;
|
||||
let ctx = extract_auth_context(&headers, &state).await?;
|
||||
sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::MessagesWrite)
|
||||
.map_err(super::scope_error)?;
|
||||
let self_bytes = ctx.pubkey_bytes.clone();
|
||||
|
||||
if body.pubkeys.is_empty() {
|
||||
return Err(api_error(
|
||||
@@ -154,7 +157,10 @@ pub async fn add_dm_member_handler(
|
||||
Path(channel_id_str): Path<String>,
|
||||
ExtractJson(body): ExtractJson<AddDmMemberBody>,
|
||||
) -> Result<(StatusCode, Json<serde_json::Value>), (StatusCode, Json<serde_json::Value>)> {
|
||||
let (_pubkey, self_bytes) = extract_auth_pubkey(&headers, &state).await?;
|
||||
let ctx = extract_auth_context(&headers, &state).await?;
|
||||
sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::MessagesWrite)
|
||||
.map_err(super::scope_error)?;
|
||||
let self_bytes = ctx.pubkey_bytes.clone();
|
||||
|
||||
let channel_id = Uuid::parse_str(&channel_id_str)
|
||||
.map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid channel_id format"))?;
|
||||
@@ -258,7 +264,10 @@ pub async fn list_dms_handler(
|
||||
headers: HeaderMap,
|
||||
Query(params): Query<ListDmsQuery>,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
|
||||
let (_pubkey, self_bytes) = extract_auth_pubkey(&headers, &state).await?;
|
||||
let ctx = extract_auth_context(&headers, &state).await?;
|
||||
sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::MessagesRead)
|
||||
.map_err(super::scope_error)?;
|
||||
let self_bytes = ctx.pubkey_bytes.clone();
|
||||
|
||||
let limit = params.limit.unwrap_or(50).min(200);
|
||||
|
||||
|
||||
@@ -13,7 +13,10 @@ use axum::{
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
use super::{api_error, check_channel_access, extract_auth_pubkey, internal_error, not_found};
|
||||
use super::{
|
||||
api_error, check_channel_access, check_token_channel_access, extract_auth_context,
|
||||
internal_error, not_found,
|
||||
};
|
||||
|
||||
/// Fetch a single stored event by its 64-char hex ID.
|
||||
pub async fn get_event(
|
||||
@@ -21,7 +24,10 @@ pub async fn get_event(
|
||||
headers: HeaderMap,
|
||||
Path(event_id): Path<String>,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
|
||||
let (_pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
|
||||
let ctx = extract_auth_context(&headers, &state).await?;
|
||||
sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::MessagesRead)
|
||||
.map_err(super::scope_error)?;
|
||||
let pubkey_bytes = ctx.pubkey_bytes.clone();
|
||||
|
||||
let id_bytes = hex::decode(&event_id)
|
||||
.map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid event ID"))?;
|
||||
@@ -37,6 +43,9 @@ pub async fn get_event(
|
||||
.ok_or_else(|| not_found("event not found"))?;
|
||||
|
||||
if let Some(channel_id) = stored_event.channel_id {
|
||||
// Token-level channel restriction check (in addition to membership check).
|
||||
// channel_id is obtained from the event's stored metadata — no extra lookup needed.
|
||||
check_token_channel_access(&ctx, &channel_id)?;
|
||||
check_channel_access(&state, channel_id, &pubkey_bytes).await?;
|
||||
} else {
|
||||
return Err(not_found("event not found"));
|
||||
|
||||
@@ -21,7 +21,7 @@ use sprout_core::kind::{self, event_kind_u32};
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
use super::{extract_auth_pubkey, internal_error};
|
||||
use super::{extract_auth_context, internal_error};
|
||||
|
||||
/// Agent activity kind set — used to partition activity into agent vs channel activity.
|
||||
const AGENT_KINDS: &[u32] = &[
|
||||
@@ -54,7 +54,10 @@ pub async fn feed_handler(
|
||||
headers: HeaderMap,
|
||||
Query(params): Query<FeedParams>,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
|
||||
let (_pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
|
||||
let ctx = extract_auth_context(&headers, &state).await?;
|
||||
sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::MessagesRead)
|
||||
.map_err(super::scope_error)?;
|
||||
let pubkey_bytes = ctx.pubkey_bytes.clone();
|
||||
|
||||
let limit = params.limit.unwrap_or(20).min(50) as i64;
|
||||
let since: DateTime<Utc> = params
|
||||
|
||||
@@ -24,7 +24,10 @@ use uuid::Uuid;
|
||||
use crate::handlers::side_effects::emit_system_message;
|
||||
use crate::state::AppState;
|
||||
|
||||
use super::{api_error, check_channel_access, extract_auth_pubkey, forbidden, internal_error};
|
||||
use super::{
|
||||
api_error, check_channel_access, check_token_channel_access, extract_auth_context, forbidden,
|
||||
internal_error, scope_error,
|
||||
};
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -72,10 +75,14 @@ pub async fn add_members(
|
||||
Path(channel_id): Path<String>,
|
||||
ExtractJson(body): ExtractJson<AddMembersBody>,
|
||||
) -> Result<(StatusCode, Json<serde_json::Value>), (StatusCode, Json<serde_json::Value>)> {
|
||||
let (_pubkey, actor_bytes) = extract_auth_pubkey(&headers, &state).await?;
|
||||
let ctx = extract_auth_context(&headers, &state).await?;
|
||||
sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::AdminChannels)
|
||||
.map_err(scope_error)?;
|
||||
let actor_bytes = ctx.pubkey_bytes.clone();
|
||||
|
||||
let channel_id = Uuid::parse_str(&channel_id)
|
||||
.map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid channel_id"))?;
|
||||
check_token_channel_access(&ctx, &channel_id)?;
|
||||
|
||||
// Private channels require owner/admin to add members.
|
||||
let channel = state
|
||||
@@ -245,10 +252,14 @@ pub async fn remove_member(
|
||||
headers: HeaderMap,
|
||||
Path((channel_id, pubkey)): Path<(String, String)>,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
|
||||
let (_actor_pk, actor_bytes) = extract_auth_pubkey(&headers, &state).await?;
|
||||
let ctx = extract_auth_context(&headers, &state).await?;
|
||||
sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::AdminChannels)
|
||||
.map_err(scope_error)?;
|
||||
let actor_bytes = ctx.pubkey_bytes.clone();
|
||||
|
||||
let channel_id = Uuid::parse_str(&channel_id)
|
||||
.map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid channel_id"))?;
|
||||
check_token_channel_access(&ctx, &channel_id)?;
|
||||
|
||||
let target_bytes = hex::decode(&pubkey)
|
||||
.ok()
|
||||
@@ -330,10 +341,14 @@ pub async fn list_members(
|
||||
headers: HeaderMap,
|
||||
Path(channel_id): Path<String>,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
|
||||
let (_pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
|
||||
let ctx = extract_auth_context(&headers, &state).await?;
|
||||
sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::ChannelsRead)
|
||||
.map_err(scope_error)?;
|
||||
let pubkey_bytes = ctx.pubkey_bytes.clone();
|
||||
|
||||
let channel_id = Uuid::parse_str(&channel_id)
|
||||
.map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid channel_id"))?;
|
||||
check_token_channel_access(&ctx, &channel_id)?;
|
||||
|
||||
check_channel_access(&state, channel_id, &pubkey_bytes).await?;
|
||||
|
||||
@@ -390,7 +405,10 @@ pub async fn join_channel(
|
||||
headers: HeaderMap,
|
||||
Path(channel_id): Path<String>,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
|
||||
let (_pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
|
||||
let ctx = extract_auth_context(&headers, &state).await?;
|
||||
sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::ChannelsRead)
|
||||
.map_err(scope_error)?;
|
||||
let pubkey_bytes = ctx.pubkey_bytes.clone();
|
||||
|
||||
let channel_id = Uuid::parse_str(&channel_id)
|
||||
.map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid channel_id"))?;
|
||||
@@ -449,7 +467,10 @@ pub async fn leave_channel(
|
||||
headers: HeaderMap,
|
||||
Path(channel_id): Path<String>,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
|
||||
let (_pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
|
||||
let ctx = extract_auth_context(&headers, &state).await?;
|
||||
sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::ChannelsRead)
|
||||
.map_err(scope_error)?;
|
||||
let pubkey_bytes = ctx.pubkey_bytes.clone();
|
||||
|
||||
let channel_id = Uuid::parse_str(&channel_id)
|
||||
.map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid channel_id"))?;
|
||||
|
||||
@@ -31,7 +31,8 @@ use crate::handlers::event::dispatch_persistent_event;
|
||||
use crate::state::AppState;
|
||||
|
||||
use super::{
|
||||
api_error, check_channel_access, extract_auth_pubkey, forbidden, internal_error, not_found,
|
||||
api_error, check_channel_access, check_token_channel_access, extract_auth_context, forbidden,
|
||||
internal_error, not_found,
|
||||
};
|
||||
|
||||
/// Extract the effective message author from a stored event.
|
||||
@@ -281,11 +282,15 @@ pub async fn send_message(
|
||||
Path(channel_id_str): Path<String>,
|
||||
Json(body): Json<SendMessageBody>,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
|
||||
let (_pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
|
||||
let ctx = extract_auth_context(&headers, &state).await?;
|
||||
sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::MessagesWrite)
|
||||
.map_err(super::scope_error)?;
|
||||
let pubkey_bytes = ctx.pubkey_bytes.clone();
|
||||
|
||||
let channel_id = uuid::Uuid::parse_str(&channel_id_str)
|
||||
.map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid channel UUID"))?;
|
||||
|
||||
check_token_channel_access(&ctx, &channel_id)?;
|
||||
check_channel_access(&state, channel_id, &pubkey_bytes).await?;
|
||||
|
||||
let channel = state
|
||||
@@ -499,7 +504,10 @@ pub async fn delete_message(
|
||||
headers: HeaderMap,
|
||||
Path(event_id_hex): Path<String>,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
|
||||
let (_pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
|
||||
let ctx = extract_auth_context(&headers, &state).await?;
|
||||
sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::MessagesWrite)
|
||||
.map_err(super::scope_error)?;
|
||||
let pubkey_bytes = ctx.pubkey_bytes.clone();
|
||||
|
||||
let event_id_bytes = nostr_hex::decode(&event_id_hex)
|
||||
.map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid event_id hex"))?;
|
||||
@@ -516,6 +524,9 @@ pub async fn delete_message(
|
||||
.channel_id
|
||||
.ok_or_else(|| api_error(StatusCode::BAD_REQUEST, "event has no channel"))?;
|
||||
|
||||
// Token-level channel restriction check (channel_id from event lookup).
|
||||
check_token_channel_access(&ctx, &channel_id)?;
|
||||
|
||||
// Auth: must be the message author OR an owner/admin of the channel.
|
||||
// For relay-signed REST messages, the real author is in the p tag.
|
||||
let author_bytes = effective_author(&stored.event, &state.relay_keypair.public_key());
|
||||
@@ -589,11 +600,15 @@ pub async fn list_messages(
|
||||
Path(channel_id_str): Path<String>,
|
||||
Query(params): Query<ListMessagesParams>,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
|
||||
let (_pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
|
||||
let ctx = extract_auth_context(&headers, &state).await?;
|
||||
sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::MessagesRead)
|
||||
.map_err(super::scope_error)?;
|
||||
let pubkey_bytes = ctx.pubkey_bytes.clone();
|
||||
|
||||
let channel_id = uuid::Uuid::parse_str(&channel_id_str)
|
||||
.map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid channel UUID"))?;
|
||||
|
||||
check_token_channel_access(&ctx, &channel_id)?;
|
||||
check_channel_access(&state, channel_id, &pubkey_bytes).await?;
|
||||
|
||||
let limit = params.limit.unwrap_or(50).min(200);
|
||||
@@ -704,11 +719,15 @@ pub async fn get_thread(
|
||||
Path((channel_id_str, event_id_hex)): Path<(String, String)>,
|
||||
Query(params): Query<GetThreadParams>,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
|
||||
let (_pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
|
||||
let ctx = extract_auth_context(&headers, &state).await?;
|
||||
sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::MessagesRead)
|
||||
.map_err(super::scope_error)?;
|
||||
let pubkey_bytes = ctx.pubkey_bytes.clone();
|
||||
|
||||
let channel_id = uuid::Uuid::parse_str(&channel_id_str)
|
||||
.map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid channel UUID"))?;
|
||||
|
||||
check_token_channel_access(&ctx, &channel_id)?;
|
||||
check_channel_access(&state, channel_id, &pubkey_bytes).await?;
|
||||
|
||||
let root_id_bytes = nostr_hex::decode(&event_id_hex)
|
||||
|
||||
@@ -38,6 +38,8 @@ pub mod presence;
|
||||
pub mod reactions;
|
||||
/// Full-text search endpoint.
|
||||
pub mod search;
|
||||
/// Self-service API token minting, listing, and revocation endpoints.
|
||||
pub mod tokens;
|
||||
/// User profile endpoints.
|
||||
pub mod users;
|
||||
/// Shared helpers for workflow API handlers.
|
||||
@@ -73,15 +75,366 @@ pub use workflows::{
|
||||
// ── Shared helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use axum::{
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::Json,
|
||||
};
|
||||
use sha2::{Digest, Sha256};
|
||||
use uuid::Uuid;
|
||||
|
||||
use sprout_auth::Scope;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
// ── Auth context types ────────────────────────────────────────────────────────
|
||||
|
||||
/// How the REST request was authenticated.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum RestAuthMethod {
|
||||
/// `Authorization: Bearer sprout_*` — API token verified against DB hash.
|
||||
ApiToken,
|
||||
/// `Authorization: Bearer eyJ*` — Okta JWT validated via JWKS.
|
||||
OktaJwt,
|
||||
/// `Authorization: Nostr <base64>` — NIP-98 HTTP Auth (bootstrap path only).
|
||||
Nip98,
|
||||
/// `X-Pubkey: <hex>` — dev mode only (`require_auth_token=false`).
|
||||
DevPubkey,
|
||||
}
|
||||
|
||||
/// Full authentication context returned to REST handlers.
|
||||
///
|
||||
/// Replaces the old `(pubkey, pubkey_bytes)` tuple from `extract_auth_pubkey`.
|
||||
/// The `pubkey` and `pubkey_bytes` fields are identical to the old return value,
|
||||
/// so existing handler logic is unchanged; scope and channel checks can now be
|
||||
/// layered on top.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RestAuthContext {
|
||||
/// The authenticated Nostr public key.
|
||||
pub pubkey: nostr::PublicKey,
|
||||
/// Compressed (32-byte) serialisation of `pubkey`.
|
||||
pub pubkey_bytes: Vec<u8>,
|
||||
/// Permission scopes granted to this request.
|
||||
///
|
||||
/// Empty for the NIP-98 bootstrap path — the caller must be `POST /api/tokens`.
|
||||
pub scopes: Vec<Scope>,
|
||||
/// How the request was authenticated.
|
||||
pub auth_method: RestAuthMethod,
|
||||
/// The UUID of the API token used, if auth_method is `ApiToken`.
|
||||
pub token_id: Option<Uuid>,
|
||||
/// Token-level channel restriction, if any.
|
||||
///
|
||||
/// `None` means unrestricted (all channels the pubkey is a member of).
|
||||
/// `Some([])` means no channels are permitted.
|
||||
pub channel_ids: Option<Vec<Uuid>>,
|
||||
}
|
||||
|
||||
/// Extract the full auth context from request headers.
|
||||
///
|
||||
/// Auth resolution order:
|
||||
/// 1. `Authorization: Bearer sprout_*` — API token; revocation + expiry checked here
|
||||
/// 2. `Authorization: Bearer eyJ*` — Okta JWT
|
||||
/// 3. `X-Pubkey: <hex>` — dev mode only
|
||||
///
|
||||
/// NIP-98 (`Authorization: Nostr <base64>`) is **not** handled here — it is only
|
||||
/// valid for `POST /api/tokens` and is verified directly in that handler. Any
|
||||
/// request that sends a `Nostr` auth header to a non-token endpoint will receive
|
||||
/// a 401 with `"nip98_not_supported"`.
|
||||
///
|
||||
/// Returns a populated [`RestAuthContext`] on success, or a 401 response on failure.
|
||||
pub(crate) async fn extract_auth_context(
|
||||
headers: &HeaderMap,
|
||||
state: &AppState,
|
||||
) -> Result<RestAuthContext, (StatusCode, Json<serde_json::Value>)> {
|
||||
let require_auth = state.config.require_auth_token;
|
||||
|
||||
if let Some(auth_header) = headers.get("authorization").and_then(|v| v.to_str().ok()) {
|
||||
// ── 1. Reject NIP-98 on non-token endpoints ───────────────────────────
|
||||
// NIP-98 auth is only valid for POST /api/tokens (handled directly in
|
||||
// post_tokens). Sending it here is a client error — reject explicitly
|
||||
// rather than falling through to a confusing "authentication failed".
|
||||
if auth_header.starts_with("Nostr ") {
|
||||
tracing::warn!("auth: NIP-98 auth header sent to non-token endpoint");
|
||||
return Err((
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(serde_json::json!({
|
||||
"error": "nip98_not_supported",
|
||||
"message": "NIP-98 auth is only supported for POST /api/tokens"
|
||||
})),
|
||||
));
|
||||
}
|
||||
|
||||
// ── 2. Bearer token path ──────────────────────────────────────────────
|
||||
if let Some(token) = auth_header.strip_prefix("Bearer ") {
|
||||
// ── 2a. API token (sprout_*) ──────────────────────────────────────
|
||||
if token.starts_with("sprout_") {
|
||||
let hash: [u8; 32] = Sha256::digest(token.as_bytes()).into();
|
||||
|
||||
// Use the new including-revoked query so we can return distinct
|
||||
// token_revoked vs invalid_token errors.
|
||||
let record = match state
|
||||
.db
|
||||
.get_api_token_by_hash_including_revoked(&hash)
|
||||
.await
|
||||
{
|
||||
Ok(Some(r)) => r,
|
||||
Ok(None) => {
|
||||
tracing::warn!("auth: API token not found");
|
||||
return Err((
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(serde_json::json!({ "error": "invalid_token" })),
|
||||
));
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("auth: API token lookup failed: {e}");
|
||||
return Err((
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(serde_json::json!({ "error": "invalid_token" })),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
// Relay-layer revocation check (before hash verification).
|
||||
if record.revoked_at.is_some() {
|
||||
tracing::warn!("auth: API token is revoked");
|
||||
return Err((
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(serde_json::json!({ "error": "token_revoked" })),
|
||||
));
|
||||
}
|
||||
|
||||
// Relay-layer expiry check (before hash verification).
|
||||
if let Some(exp) = record.expires_at {
|
||||
if exp < chrono::Utc::now() {
|
||||
tracing::warn!("auth: API token is expired");
|
||||
return Err((
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(serde_json::json!({ "error": "token_expired" })),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let owner_pubkey = match nostr::PublicKey::from_slice(&record.owner_pubkey) {
|
||||
Ok(pk) => pk,
|
||||
Err(e) => {
|
||||
tracing::warn!("auth: API token owner pubkey invalid: {e}");
|
||||
return Err((
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(serde_json::json!({ "error": "invalid_token" })),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
match state.auth.verify_api_token_against_hash(
|
||||
token,
|
||||
&record.token_hash,
|
||||
&owner_pubkey,
|
||||
&owner_pubkey,
|
||||
record.expires_at,
|
||||
&record.scopes,
|
||||
) {
|
||||
Ok((pubkey, scopes)) => {
|
||||
let pubkey_bytes = pubkey.serialize().to_vec();
|
||||
if let Err(e) = state.db.ensure_user(&pubkey_bytes).await {
|
||||
tracing::warn!("ensure_user failed: {e}");
|
||||
}
|
||||
|
||||
// Debounced last_used_at update — at most once per 5 min per token.
|
||||
let should_update = state
|
||||
.last_used_cache
|
||||
.get(&record.id)
|
||||
.map(|t| t.elapsed() > Duration::from_secs(300))
|
||||
.unwrap_or(true);
|
||||
if should_update {
|
||||
state.last_used_cache.insert(record.id, Instant::now());
|
||||
let db = state.db.clone();
|
||||
let hash_copy = hash;
|
||||
tokio::spawn(async move {
|
||||
let _ = db.update_token_last_used(&hash_copy).await;
|
||||
});
|
||||
}
|
||||
|
||||
return Ok(RestAuthContext {
|
||||
pubkey,
|
||||
pubkey_bytes,
|
||||
scopes,
|
||||
auth_method: RestAuthMethod::ApiToken,
|
||||
token_id: Some(record.id),
|
||||
channel_ids: record.channel_ids,
|
||||
});
|
||||
}
|
||||
Err(_) => {
|
||||
tracing::warn!("auth: API token hash verification failed");
|
||||
return Err((
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(serde_json::json!({ "error": "invalid_token" })),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 2b. Okta JWT (eyJ*) ───────────────────────────────────────────
|
||||
if require_auth {
|
||||
match state.auth.validate_bearer_jwt(token).await {
|
||||
Ok((pubkey, scopes)) => {
|
||||
let pubkey_bytes = pubkey.serialize().to_vec();
|
||||
if let Err(e) = state.db.ensure_user(&pubkey_bytes).await {
|
||||
tracing::warn!("ensure_user failed: {e}");
|
||||
}
|
||||
return Ok(RestAuthContext {
|
||||
pubkey,
|
||||
pubkey_bytes,
|
||||
scopes,
|
||||
auth_method: RestAuthMethod::OktaJwt,
|
||||
token_id: None,
|
||||
channel_ids: None,
|
||||
});
|
||||
}
|
||||
Err(_) => {
|
||||
tracing::warn!("auth: JWT validation failed");
|
||||
return Err((
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(serde_json::json!({ "error": "authentication failed" })),
|
||||
));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Dev mode: decode JWT payload without JWKS validation.
|
||||
match decode_jwt_payload_unverified(token) {
|
||||
Ok(claims) => {
|
||||
if let Some(username) =
|
||||
claims.get("preferred_username").and_then(|v| v.as_str())
|
||||
{
|
||||
match sprout_auth::derive_pubkey_from_username(username) {
|
||||
Ok(pubkey) => {
|
||||
let pubkey_bytes = pubkey.serialize().to_vec();
|
||||
if let Err(e) = state.db.ensure_user(&pubkey_bytes).await {
|
||||
tracing::warn!("ensure_user failed: {e}");
|
||||
}
|
||||
return Ok(RestAuthContext {
|
||||
pubkey,
|
||||
pubkey_bytes,
|
||||
scopes: vec![Scope::MessagesRead, Scope::MessagesWrite],
|
||||
auth_method: RestAuthMethod::OktaJwt,
|
||||
token_id: None,
|
||||
channel_ids: None,
|
||||
});
|
||||
}
|
||||
Err(_) => {
|
||||
tracing::warn!("auth: key derivation failed for username");
|
||||
return Err((
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(
|
||||
serde_json::json!({ "error": "authentication failed" }),
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
tracing::warn!("auth: JWT missing preferred_username claim");
|
||||
return Err((
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(serde_json::json!({ "error": "authentication failed" })),
|
||||
));
|
||||
}
|
||||
Err(_) => {
|
||||
tracing::warn!("auth: malformed JWT");
|
||||
return Err((
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(serde_json::json!({ "error": "authentication failed" })),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 4. Dev fallback: X-Pubkey ─────────────────────────────────────────────
|
||||
if !require_auth {
|
||||
if let Some(hex_val) = headers.get("x-pubkey").and_then(|v| v.to_str().ok()) {
|
||||
match nostr::PublicKey::from_hex(hex_val) {
|
||||
Ok(pubkey) => {
|
||||
let pubkey_bytes = pubkey.serialize().to_vec();
|
||||
if let Err(e) = state.db.ensure_user(&pubkey_bytes).await {
|
||||
tracing::warn!("ensure_user failed: {e}");
|
||||
}
|
||||
// Dev mode grants all scopes (including admin) — it's a development convenience.
|
||||
// Production deployments MUST set SPROUT_REQUIRE_AUTH_TOKEN=true.
|
||||
return Ok(RestAuthContext {
|
||||
pubkey,
|
||||
pubkey_bytes,
|
||||
scopes: Scope::all_known(),
|
||||
auth_method: RestAuthMethod::DevPubkey,
|
||||
token_id: None,
|
||||
channel_ids: None,
|
||||
});
|
||||
}
|
||||
Err(_) => {
|
||||
tracing::warn!("auth: invalid X-Pubkey header value");
|
||||
return Err((
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(serde_json::json!({ "error": "authentication failed" })),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err((
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(serde_json::json!({ "error": "authentication required" })),
|
||||
))
|
||||
}
|
||||
|
||||
// ── Step 8: Token-level channel access enforcement ────────────────────────────
|
||||
|
||||
/// Check whether a token is permitted to access the given channel.
|
||||
///
|
||||
/// This is a **token-level** check — it verifies that `channel_id` is in the
|
||||
/// token's `channel_ids` restriction list. It is **in addition to** the
|
||||
/// membership check (`check_channel_access`) and scope check (`require_scope`);
|
||||
/// all three must pass.
|
||||
///
|
||||
/// Tokens with `channel_ids = None` (no restriction) always pass this check.
|
||||
/// Tokens with `channel_ids = Some([])` (empty list) deny all channels.
|
||||
pub fn check_token_channel_access(
|
||||
ctx: &RestAuthContext,
|
||||
channel_id: &Uuid,
|
||||
) -> Result<(), (StatusCode, Json<serde_json::Value>)> {
|
||||
if let Some(ref allowed) = ctx.channel_ids {
|
||||
if !allowed.contains(channel_id) {
|
||||
return Err((
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(serde_json::json!({
|
||||
"error": "channel_not_permitted",
|
||||
"message": "Token does not have access to this channel"
|
||||
})),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Convert a scope-check failure into a 403 Forbidden response.
|
||||
///
|
||||
/// Used by handlers to propagate `require_scope` errors via `?`.
|
||||
pub(crate) fn scope_error(e: sprout_auth::AuthError) -> (StatusCode, Json<serde_json::Value>) {
|
||||
match e {
|
||||
sprout_auth::AuthError::InsufficientScope { required, .. } => (
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(serde_json::json!({
|
||||
"error": "insufficient_scope",
|
||||
"message": format!("token missing required scope: {required}"),
|
||||
})),
|
||||
),
|
||||
other => {
|
||||
tracing::warn!("scope_error: unexpected auth error: {other}");
|
||||
api_error(StatusCode::FORBIDDEN, "insufficient_scope")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Standard error envelope.
|
||||
pub(crate) fn api_error(status: StatusCode, msg: &str) -> (StatusCode, Json<serde_json::Value>) {
|
||||
(status, Json(serde_json::json!({ "error": msg })))
|
||||
@@ -116,161 +469,16 @@ fn decode_jwt_payload_unverified(
|
||||
serde_json::from_slice(&decoded).map_err(|_| "authentication failed".to_string())
|
||||
}
|
||||
|
||||
/// Extract an authenticated pubkey from the request headers.
|
||||
/// Check channel membership access: member OR open-visibility channel.
|
||||
///
|
||||
/// Auth resolution order:
|
||||
/// 1. `Authorization: Bearer sprout_*` — API token; hashed, looked up in DB.
|
||||
/// 2. `Authorization: Bearer eyJ*` — Okta JWT; validated via JWKS when
|
||||
/// `require_auth_token=true`, or decoded unverified (username → derived key) when
|
||||
/// `require_auth_token=false`.
|
||||
/// 3. `X-Pubkey: <hex>` — accepted only when `require_auth_token=false` (dev mode).
|
||||
///
|
||||
/// Returns `(nostr::PublicKey, pubkey_bytes)` on success, or a 401 response on failure.
|
||||
pub(crate) async fn extract_auth_pubkey(
|
||||
headers: &HeaderMap,
|
||||
state: &AppState,
|
||||
) -> Result<(nostr::PublicKey, Vec<u8>), (StatusCode, Json<serde_json::Value>)> {
|
||||
let require_auth = state.config.require_auth_token;
|
||||
|
||||
// Try Authorization: Bearer <token>
|
||||
if let Some(auth_header) = headers.get("authorization").and_then(|v| v.to_str().ok()) {
|
||||
if let Some(token) = auth_header.strip_prefix("Bearer ") {
|
||||
// ── API token path (sprout_*) ─────────────────────────────────
|
||||
// Must be checked before the Okta JWT path: verify_auth_event()
|
||||
// (and validate_bearer_jwt) would reject sprout_ tokens immediately.
|
||||
if token.starts_with("sprout_") {
|
||||
let hash: [u8; 32] = Sha256::digest(token.as_bytes()).into();
|
||||
let record = match state.db.get_api_token_by_hash(&hash).await {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
tracing::warn!("auth: API token lookup failed: {e}");
|
||||
return Err(api_error(StatusCode::UNAUTHORIZED, "authentication failed"));
|
||||
}
|
||||
};
|
||||
let owner_pubkey = match nostr::PublicKey::from_slice(&record.owner_pubkey) {
|
||||
Ok(pk) => pk,
|
||||
Err(e) => {
|
||||
tracing::warn!("auth: API token owner pubkey invalid: {e}");
|
||||
return Err(api_error(StatusCode::UNAUTHORIZED, "authentication failed"));
|
||||
}
|
||||
};
|
||||
// verify_api_token_against_hash checks hash match, expiry, and
|
||||
// pubkey equality. For the REST path there is no "claimed" pubkey
|
||||
// from a Nostr event, so we pass the owner pubkey for both
|
||||
// arguments — we are asserting identity from the DB record itself.
|
||||
match state.auth.verify_api_token_against_hash(
|
||||
token,
|
||||
&record.token_hash,
|
||||
&owner_pubkey,
|
||||
&owner_pubkey,
|
||||
record.expires_at,
|
||||
&record.scopes,
|
||||
) {
|
||||
Ok((pubkey, _scopes)) => {
|
||||
let bytes = pubkey.serialize().to_vec();
|
||||
if let Err(e) = state.db.ensure_user(&bytes).await {
|
||||
tracing::warn!("ensure_user failed: {e}");
|
||||
}
|
||||
// Update last_used_at — non-fatal.
|
||||
let _ = state.db.update_token_last_used(&hash).await;
|
||||
return Ok((pubkey, bytes));
|
||||
}
|
||||
Err(_) => {
|
||||
tracing::warn!("auth: API token verification failed");
|
||||
return Err(api_error(StatusCode::UNAUTHORIZED, "authentication failed"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if require_auth {
|
||||
// Production: validate JWT against JWKS
|
||||
match state.auth.validate_bearer_jwt(token).await {
|
||||
// NOTE: Scope enforcement is deferred to a future milestone.
|
||||
// Currently all authenticated users get full API access.
|
||||
Ok((pubkey, _scopes)) => {
|
||||
let bytes = pubkey.serialize().to_vec();
|
||||
// Auto-register user on first authentication (INSERT IGNORE — no-op if exists).
|
||||
if let Err(e) = state.db.ensure_user(&bytes).await {
|
||||
tracing::warn!("ensure_user failed: {e}");
|
||||
// Non-fatal — don't block auth if user creation fails
|
||||
}
|
||||
return Ok((pubkey, bytes));
|
||||
}
|
||||
Err(_) => {
|
||||
tracing::warn!("auth: JWT validation failed");
|
||||
return Err(api_error(StatusCode::UNAUTHORIZED, "authentication failed"));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Dev mode: decode JWT payload without JWKS validation.
|
||||
match decode_jwt_payload_unverified(token) {
|
||||
Ok(claims) => {
|
||||
if let Some(username) =
|
||||
claims.get("preferred_username").and_then(|v| v.as_str())
|
||||
{
|
||||
match sprout_auth::derive_pubkey_from_username(username) {
|
||||
Ok(pubkey) => {
|
||||
let bytes = pubkey.serialize().to_vec();
|
||||
// Auto-register user on first authentication (INSERT IGNORE — no-op if exists).
|
||||
if let Err(e) = state.db.ensure_user(&bytes).await {
|
||||
tracing::warn!("ensure_user failed: {e}");
|
||||
// Non-fatal — don't block auth if user creation fails
|
||||
}
|
||||
return Ok((pubkey, bytes));
|
||||
}
|
||||
Err(_) => {
|
||||
tracing::warn!("auth: key derivation failed for username");
|
||||
return Err(api_error(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"authentication failed",
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
// JWT present but no preferred_username — fail, don't silently downgrade
|
||||
tracing::warn!("auth: JWT missing preferred_username claim");
|
||||
return Err(api_error(StatusCode::UNAUTHORIZED, "authentication failed"));
|
||||
}
|
||||
Err(_) => {
|
||||
// Malformed JWT — fail, don't silently downgrade to X-Pubkey
|
||||
tracing::warn!("auth: malformed JWT");
|
||||
return Err(api_error(StatusCode::UNAUTHORIZED, "authentication failed"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Dev fallback: X-Pubkey header (only when require_auth_token=false)
|
||||
if !require_auth {
|
||||
if let Some(hex_val) = headers.get("x-pubkey").and_then(|v| v.to_str().ok()) {
|
||||
match nostr::PublicKey::from_hex(hex_val) {
|
||||
Ok(pubkey) => {
|
||||
let bytes = pubkey.serialize().to_vec();
|
||||
// Auto-register user on first authentication (INSERT IGNORE — no-op if exists).
|
||||
if let Err(e) = state.db.ensure_user(&bytes).await {
|
||||
tracing::warn!("ensure_user failed: {e}");
|
||||
// Non-fatal — don't block auth if user creation fails
|
||||
}
|
||||
return Ok((pubkey, bytes));
|
||||
}
|
||||
Err(_) => {
|
||||
tracing::warn!("auth: invalid X-Pubkey header value");
|
||||
return Err(api_error(StatusCode::UNAUTHORIZED, "authentication failed"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(api_error(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"authentication required",
|
||||
))
|
||||
}
|
||||
|
||||
/// Check channel access: member OR open-visibility channel.
|
||||
/// Open channels (visibility = "open") allow any authenticated user to read/write.
|
||||
pub(crate) async fn check_channel_access(
|
||||
/// This is the **membership** check — separate from the token-level channel restriction
|
||||
/// check ([`check_token_channel_access`]).
|
||||
///
|
||||
/// # Note
|
||||
/// This function is also exported as [`check_channel_access`] for backward compatibility
|
||||
/// while Step 7 migrates all handlers to use [`extract_auth_context`].
|
||||
pub(crate) async fn check_channel_membership(
|
||||
state: &AppState,
|
||||
channel_id: uuid::Uuid,
|
||||
pubkey_bytes: &[u8],
|
||||
@@ -296,6 +504,18 @@ pub(crate) async fn check_channel_access(
|
||||
}
|
||||
}
|
||||
|
||||
/// Backward-compatible alias for [`check_channel_membership`].
|
||||
///
|
||||
/// Step 7 will replace all call sites with `check_channel_membership` and remove this alias.
|
||||
#[allow(dead_code)]
|
||||
pub(crate) async fn check_channel_access(
|
||||
state: &AppState,
|
||||
channel_id: uuid::Uuid,
|
||||
pubkey_bytes: &[u8],
|
||||
) -> Result<(), (StatusCode, Json<serde_json::Value>)> {
|
||||
check_channel_membership(state, channel_id, pubkey_bytes).await
|
||||
}
|
||||
|
||||
// ── Custom JSON extractor ─────────────────────────────────────────────────────
|
||||
|
||||
use axum::extract::{rejection::JsonRejection, FromRequest, Request};
|
||||
|
||||
@@ -13,7 +13,7 @@ use sprout_pubsub::presence::PRESENCE_TTL_SECS;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
use super::extract_auth_pubkey;
|
||||
use super::extract_auth_context;
|
||||
|
||||
/// Query parameters for the presence endpoint.
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -31,7 +31,9 @@ pub async fn presence_handler(
|
||||
headers: HeaderMap,
|
||||
Query(params): Query<PresenceParams>,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
|
||||
let (_pubkey, _pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
|
||||
let ctx = extract_auth_context(&headers, &state).await?;
|
||||
sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::ChannelsRead)
|
||||
.map_err(super::scope_error)?;
|
||||
|
||||
let pubkeys_param = params.pubkeys.unwrap_or_default();
|
||||
|
||||
@@ -91,7 +93,10 @@ pub async fn set_presence_handler(
|
||||
headers: HeaderMap,
|
||||
super::ApiJson(body): super::ApiJson<SetPresenceBody>,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
|
||||
let (pubkey, _pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
|
||||
let ctx = extract_auth_context(&headers, &state).await?;
|
||||
sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::ChannelsRead)
|
||||
.map_err(super::scope_error)?;
|
||||
let pubkey = ctx.pubkey;
|
||||
|
||||
match body.status {
|
||||
PresenceStatus::Online | PresenceStatus::Away => {
|
||||
|
||||
@@ -26,7 +26,10 @@ use serde::Deserialize;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
use super::{api_error, check_channel_access, extract_auth_pubkey, internal_error, not_found};
|
||||
use super::{
|
||||
api_error, check_channel_access, check_token_channel_access, extract_auth_context,
|
||||
internal_error, not_found,
|
||||
};
|
||||
|
||||
// ── Request / query types ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -78,7 +81,10 @@ pub async fn add_reaction_handler(
|
||||
Path(event_id_hex): Path<String>,
|
||||
axum::extract::Json(body): axum::extract::Json<AddReactionBody>,
|
||||
) -> Result<(StatusCode, Json<serde_json::Value>), (StatusCode, Json<serde_json::Value>)> {
|
||||
let (_pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
|
||||
let ctx = extract_auth_context(&headers, &state).await?;
|
||||
sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::MessagesWrite)
|
||||
.map_err(super::scope_error)?;
|
||||
let pubkey_bytes = ctx.pubkey_bytes.clone();
|
||||
|
||||
let emoji = body.emoji.trim().to_string();
|
||||
if emoji.is_empty() {
|
||||
@@ -97,6 +103,8 @@ pub async fn add_reaction_handler(
|
||||
|
||||
// Verify channel access if the event belongs to a channel.
|
||||
if let Some(channel_id) = stored.channel_id {
|
||||
// Token-level channel restriction check (channel_id from event lookup).
|
||||
check_token_channel_access(&ctx, &channel_id)?;
|
||||
check_channel_access(&state, channel_id, &pubkey_bytes).await?;
|
||||
let channel = state
|
||||
.db
|
||||
@@ -142,7 +150,10 @@ pub async fn remove_reaction_handler(
|
||||
headers: HeaderMap,
|
||||
Path((event_id_hex, emoji)): Path<(String, String)>,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
|
||||
let (_pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
|
||||
let ctx = extract_auth_context(&headers, &state).await?;
|
||||
sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::MessagesWrite)
|
||||
.map_err(super::scope_error)?;
|
||||
let pubkey_bytes = ctx.pubkey_bytes.clone();
|
||||
|
||||
let event_id_bytes = decode_event_id(&event_id_hex)?;
|
||||
|
||||
@@ -156,6 +167,8 @@ pub async fn remove_reaction_handler(
|
||||
|
||||
// Verify channel access if the event belongs to a channel.
|
||||
if let Some(channel_id) = stored.channel_id {
|
||||
// Token-level channel restriction check (channel_id from event lookup).
|
||||
check_token_channel_access(&ctx, &channel_id)?;
|
||||
check_channel_access(&state, channel_id, &pubkey_bytes).await?;
|
||||
let channel = state
|
||||
.db
|
||||
@@ -197,7 +210,10 @@ pub async fn list_reactions_handler(
|
||||
Path(event_id_hex): Path<String>,
|
||||
Query(params): Query<ListReactionsParams>,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
|
||||
let (_pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
|
||||
let ctx = extract_auth_context(&headers, &state).await?;
|
||||
sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::MessagesRead)
|
||||
.map_err(super::scope_error)?;
|
||||
let pubkey_bytes = ctx.pubkey_bytes.clone();
|
||||
|
||||
let limit = params.limit.unwrap_or(50).min(200);
|
||||
let cursor = params.cursor.as_deref();
|
||||
@@ -214,6 +230,8 @@ pub async fn list_reactions_handler(
|
||||
|
||||
// Verify channel access if the event belongs to a channel.
|
||||
if let Some(channel_id) = stored.channel_id {
|
||||
// Token-level channel restriction check (channel_id from event lookup).
|
||||
check_token_channel_access(&ctx, &channel_id)?;
|
||||
check_channel_access(&state, channel_id, &pubkey_bytes).await?;
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ use sprout_search::SearchQuery;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
use super::extract_auth_pubkey;
|
||||
use super::extract_auth_context;
|
||||
|
||||
/// Query parameters for the search endpoint.
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -34,7 +34,10 @@ pub async fn search_handler(
|
||||
headers: HeaderMap,
|
||||
Query(params): Query<SearchParams>,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
|
||||
let (_pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
|
||||
let ctx = extract_auth_context(&headers, &state).await?;
|
||||
sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::MessagesRead)
|
||||
.map_err(super::scope_error)?;
|
||||
let pubkey_bytes = ctx.pubkey_bytes.clone();
|
||||
|
||||
let query_str = params.q.unwrap_or_default();
|
||||
let per_page = params.limit.unwrap_or(20).min(100);
|
||||
|
||||
792
crates/sprout-relay/src/api/tokens.rs
Normal file
792
crates/sprout-relay/src/api/tokens.rs
Normal file
@@ -0,0 +1,792 @@
|
||||
//! Self-service API token minting, listing, and revocation endpoints.
|
||||
//!
|
||||
//! ## Routes
|
||||
//! - `POST /api/tokens` — mint a new token (NIP-98 bootstrap or Bearer)
|
||||
//! - `GET /api/tokens` — list own tokens (Bearer required)
|
||||
//! - `DELETE /api/tokens/{id}` — revoke one token by UUID (Bearer required)
|
||||
//! - `DELETE /api/tokens` — revoke all tokens / panic button (Bearer required)
|
||||
//!
|
||||
//! ## Rate Limiting
|
||||
//! [`MintRateLimiter`] enforces a configurable per-pubkey-per-hour limit
|
||||
//! (default 50, override with `SPROUT_MINT_RATE_LIMIT`) using a bounded
|
||||
//! in-memory rolling-window cache (moka). Resets on restart — acceptable since
|
||||
//! the limit is a DoS guard, not a hard security cap.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::Json,
|
||||
};
|
||||
use chrono::Utc;
|
||||
use moka::sync::Cache;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use uuid::Uuid;
|
||||
|
||||
use nostr::JsonUtil;
|
||||
use sprout_auth::{is_self_mintable, Scope};
|
||||
|
||||
use super::{api_error, extract_auth_context, internal_error, RestAuthMethod};
|
||||
use crate::state::AppState;
|
||||
|
||||
// ── Rate limiter ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// Default: 50 mints per pubkey per hour. Override with `SPROUT_MINT_RATE_LIMIT`.
|
||||
const DEFAULT_MINT_LIMIT: usize = 50;
|
||||
const MINT_WINDOW: Duration = Duration::from_secs(3600);
|
||||
const RATE_LIMITER_MAX_ENTRIES: u64 = 100_000;
|
||||
|
||||
/// Per-pubkey rolling-window rate limiter for `POST /api/tokens`.
|
||||
///
|
||||
/// Uses a bounded moka cache (max 100,000 entries) to prevent OOM from an
|
||||
/// attacker flooding with unique pubkeys. Evicted entries lose their window
|
||||
/// history — worst case is a few extra mints, not a security bypass.
|
||||
pub struct MintRateLimiter {
|
||||
cache: Cache<[u8; 32], Arc<std::sync::Mutex<VecDeque<Instant>>>>,
|
||||
limit: usize,
|
||||
}
|
||||
|
||||
impl MintRateLimiter {
|
||||
/// Create a new rate limiter.
|
||||
///
|
||||
/// `limit` is the max mints per pubkey per hour. Reads `SPROUT_MINT_RATE_LIMIT`
|
||||
/// env var at construction; falls back to [`DEFAULT_MINT_LIMIT`] (50).
|
||||
pub fn new() -> Self {
|
||||
let limit = std::env::var("SPROUT_MINT_RATE_LIMIT")
|
||||
.ok()
|
||||
.and_then(|v| v.parse::<usize>().ok())
|
||||
.unwrap_or(DEFAULT_MINT_LIMIT);
|
||||
tracing::info!("mint rate limiter: {limit} mints/hr/pubkey");
|
||||
Self {
|
||||
cache: Cache::builder()
|
||||
.max_capacity(RATE_LIMITER_MAX_ENTRIES)
|
||||
.time_to_idle(Duration::from_secs(3600))
|
||||
.build(),
|
||||
limit,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check whether `pubkey_bytes` is within the rate limit and record the attempt.
|
||||
///
|
||||
/// Returns `Ok(())` if the mint is allowed, or `Err(retry_after)` with the
|
||||
/// duration until the oldest entry in the window expires.
|
||||
pub fn check_and_record(&self, pubkey_bytes: &[u8; 32]) -> Result<(), Duration> {
|
||||
let now = Instant::now();
|
||||
let entry = self
|
||||
.cache
|
||||
.entry(*pubkey_bytes)
|
||||
.or_insert_with(|| Arc::new(std::sync::Mutex::new(VecDeque::new())));
|
||||
let mut timestamps = entry.value().lock().unwrap();
|
||||
|
||||
// Evict timestamps that have fallen outside the rolling window.
|
||||
while timestamps
|
||||
.front()
|
||||
.map(|t| now.duration_since(*t) > MINT_WINDOW)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
timestamps.pop_front();
|
||||
}
|
||||
|
||||
if timestamps.len() >= self.limit {
|
||||
let retry_after = MINT_WINDOW - now.duration_since(*timestamps.front().unwrap());
|
||||
return Err(retry_after);
|
||||
}
|
||||
|
||||
timestamps.push_back(now);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The configured limit (for error messages).
|
||||
pub fn limit(&self) -> usize {
|
||||
self.limit
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for MintRateLimiter {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
// ── Request / response types ──────────────────────────────────────────────────
|
||||
|
||||
/// Request body for `POST /api/tokens`.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct MintTokenRequest {
|
||||
/// Human-readable label for the token (1–100 chars).
|
||||
pub name: String,
|
||||
/// Scope strings to grant (e.g. `["messages:read", "channels:read"]`).
|
||||
pub scopes: Vec<String>,
|
||||
/// Optional channel UUIDs to restrict the token to.
|
||||
/// Absent means unrestricted (unless caller's token is channel-restricted, in which case
|
||||
/// channel_ids is required). Empty array is rejected for restricted callers.
|
||||
pub channel_ids: Option<Vec<String>>,
|
||||
/// Optional expiry in days (1–365). Omit for no expiry.
|
||||
pub expires_in_days: Option<u32>,
|
||||
}
|
||||
|
||||
/// Response body for `POST /api/tokens` (token shown once only).
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct MintTokenResponse {
|
||||
/// Unique token identifier (UUID).
|
||||
pub id: Uuid,
|
||||
/// Raw token value — shown **once only**. Only the SHA-256 hash is stored.
|
||||
pub token: String,
|
||||
/// Human-readable label.
|
||||
pub name: String,
|
||||
/// Scope strings granted to this token.
|
||||
pub scopes: Vec<String>,
|
||||
/// Channel UUIDs this token is restricted to (empty = unrestricted).
|
||||
pub channel_ids: Vec<String>,
|
||||
/// ISO 8601 creation timestamp.
|
||||
pub created_at: String,
|
||||
/// ISO 8601 expiry timestamp, or `null` if no expiry.
|
||||
pub expires_at: Option<String>,
|
||||
}
|
||||
|
||||
/// A single token entry in the `GET /api/tokens` response.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct TokenListItem {
|
||||
/// Unique token identifier (UUID).
|
||||
pub id: Uuid,
|
||||
/// Human-readable label.
|
||||
pub name: String,
|
||||
/// Scope strings granted to this token.
|
||||
pub scopes: Vec<String>,
|
||||
/// Channel UUIDs this token is restricted to (empty = unrestricted).
|
||||
pub channel_ids: Vec<String>,
|
||||
/// ISO 8601 creation timestamp.
|
||||
pub created_at: String,
|
||||
/// ISO 8601 expiry timestamp, or `null` if no expiry.
|
||||
pub expires_at: Option<String>,
|
||||
/// ISO 8601 timestamp of last use, or `null` if never used.
|
||||
pub last_used_at: Option<String>,
|
||||
/// ISO 8601 revocation timestamp, or `null` if not revoked.
|
||||
pub revoked_at: Option<String>,
|
||||
}
|
||||
|
||||
/// Response body for `GET /api/tokens`.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct TokenListResponse {
|
||||
/// All tokens owned by the authenticated pubkey (including revoked).
|
||||
pub tokens: Vec<TokenListItem>,
|
||||
}
|
||||
|
||||
/// Response body for `DELETE /api/tokens` (panic button).
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct RevokeAllResponse {
|
||||
/// Number of tokens that were newly revoked (0 if all already revoked).
|
||||
pub revoked_count: u64,
|
||||
}
|
||||
|
||||
// ── Handlers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// `POST /api/tokens` — mint a new API token.
|
||||
///
|
||||
/// Accepts NIP-98 HTTP Auth (bootstrap — no existing token required) or an
|
||||
/// existing Bearer API token. Validates scopes, rate-limits, checks channel
|
||||
/// membership if `channel_ids` is provided, then inserts via the conditional
|
||||
/// INSERT that enforces the 10-token-per-pubkey limit atomically.
|
||||
pub async fn post_tokens(
|
||||
State(state): State<std::sync::Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
body: axum::body::Bytes,
|
||||
) -> Result<(StatusCode, Json<serde_json::Value>), (StatusCode, Json<serde_json::Value>)> {
|
||||
// Parse the request body as JSON.
|
||||
let req: MintTokenRequest = serde_json::from_slice(&body).map_err(|e| {
|
||||
api_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
&format!("invalid request body: {e}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
// Extract auth context — NIP-98 or Bearer.
|
||||
// For NIP-98 we re-verify here with the raw body for payload hash checking.
|
||||
let ctx = if let Some(auth_header) = headers.get("authorization").and_then(|v| v.to_str().ok())
|
||||
{
|
||||
if let Some(encoded) = auth_header.strip_prefix("Nostr ") {
|
||||
// Reconstruct canonical URL for NIP-98 verification.
|
||||
let canonical_url = reconstruct_canonical_url_for_tokens(&headers, &state);
|
||||
|
||||
// The Authorization: Nostr header value is base64-encoded JSON.
|
||||
// Decode it before passing to verify_nip98_event which expects JSON.
|
||||
let decoded_bytes = {
|
||||
use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
|
||||
BASE64.decode(encoded).map_err(|_| {
|
||||
tracing::warn!("post_tokens: NIP-98 base64 decode failed");
|
||||
(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(serde_json::json!({
|
||||
"error": "invalid_auth",
|
||||
"message": "NIP-98 verification failed"
|
||||
})),
|
||||
)
|
||||
})?
|
||||
};
|
||||
let event_json = String::from_utf8(decoded_bytes).map_err(|_| {
|
||||
tracing::warn!("post_tokens: NIP-98 decoded bytes are not valid UTF-8");
|
||||
(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(serde_json::json!({
|
||||
"error": "invalid_auth",
|
||||
"message": "NIP-98 verification failed"
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
|
||||
match sprout_auth::verify_nip98_event(&event_json, &canonical_url, "POST", Some(&body))
|
||||
{
|
||||
Ok(pubkey) => {
|
||||
// POST /api/tokens requires the payload tag — body must be
|
||||
// cryptographically bound to the signed event.
|
||||
let event: nostr::Event =
|
||||
nostr::Event::from_json(&event_json).expect("already verified");
|
||||
let has_payload = event.tags.find(nostr::TagKind::Payload).is_some();
|
||||
if !has_payload {
|
||||
tracing::warn!("post_tokens: NIP-98 event missing required payload tag");
|
||||
return Err((
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(serde_json::json!({
|
||||
"error": "invalid_auth",
|
||||
"message": "NIP-98 payload tag required for POST /api/tokens"
|
||||
})),
|
||||
));
|
||||
}
|
||||
let pubkey_bytes = pubkey.to_bytes().to_vec();
|
||||
if let Err(e) = state.db.ensure_user(&pubkey_bytes).await {
|
||||
tracing::warn!("ensure_user failed for NIP-98 pubkey: {e}");
|
||||
}
|
||||
super::RestAuthContext {
|
||||
pubkey,
|
||||
pubkey_bytes,
|
||||
scopes: vec![],
|
||||
auth_method: RestAuthMethod::Nip98,
|
||||
token_id: None,
|
||||
channel_ids: None,
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("post_tokens: NIP-98 verification failed: {e}");
|
||||
return Err((
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(serde_json::json!({
|
||||
"error": "invalid_auth",
|
||||
"message": "NIP-98 verification failed"
|
||||
})),
|
||||
));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
extract_auth_context(&headers, &state).await?
|
||||
}
|
||||
} else {
|
||||
extract_auth_context(&headers, &state).await?
|
||||
};
|
||||
|
||||
// ── Validate name ─────────────────────────────────────────────────────────
|
||||
if req.name.is_empty() || req.name.len() > 100 {
|
||||
return Err(api_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"invalid_name: name must be 1–100 characters",
|
||||
));
|
||||
}
|
||||
|
||||
// ── Validate scopes ───────────────────────────────────────────────────────
|
||||
if req.scopes.is_empty() {
|
||||
return Err(api_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"invalid_scopes: scopes must not be empty",
|
||||
));
|
||||
}
|
||||
|
||||
let mut parsed_scopes: Vec<Scope> = Vec::with_capacity(req.scopes.len());
|
||||
for s in &req.scopes {
|
||||
let scope: Scope = s.parse().expect("infallible");
|
||||
match &scope {
|
||||
Scope::Unknown(_) => {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({
|
||||
"error": "invalid_scopes",
|
||||
"message": format!("unknown scope: {s}")
|
||||
})),
|
||||
));
|
||||
}
|
||||
_ => {
|
||||
if !is_self_mintable(&scope) {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({
|
||||
"error": "invalid_scopes",
|
||||
"message": format!("scope requires admin: {s}")
|
||||
})),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
parsed_scopes.push(scope);
|
||||
}
|
||||
|
||||
// Deduplicate scopes (order-preserving).
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
parsed_scopes.retain(|s| seen.insert(s.clone()));
|
||||
|
||||
// ── Scope escalation prevention (Bearer-authenticated callers) ───────────
|
||||
// If the caller authenticated via an API token, the requested scopes must be
|
||||
// a subset of the caller's own scopes, and channel_ids must be a subset too.
|
||||
// NIP-98 and Okta JWT callers are unrestricted (they authenticate the identity
|
||||
// directly, not via a scoped token).
|
||||
if matches!(ctx.auth_method, RestAuthMethod::ApiToken) {
|
||||
for scope in &parsed_scopes {
|
||||
if !ctx.scopes.contains(scope) {
|
||||
return Err((
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(serde_json::json!({
|
||||
"error": "scope_escalation",
|
||||
"message": format!("Cannot mint scope '{}' — not in your token's scopes", scope)
|
||||
})),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// If caller has channel_ids restriction, child must also be restricted
|
||||
// to a subset of those channels.
|
||||
if let Some(ref caller_channels) = ctx.channel_ids {
|
||||
match &req.channel_ids {
|
||||
None => {
|
||||
return Err((
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(serde_json::json!({
|
||||
"error": "channel_escalation",
|
||||
"message": "Your token is channel-restricted; minted tokens must also specify channel_ids (subset of yours)"
|
||||
})),
|
||||
));
|
||||
}
|
||||
Some(requested_raw) => {
|
||||
// Parse requested channel IDs (already validated above, but we need UUIDs here).
|
||||
for raw in requested_raw {
|
||||
if let Ok(cid) = raw.parse::<uuid::Uuid>() {
|
||||
if !caller_channels.contains(&cid) {
|
||||
return Err((
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(serde_json::json!({
|
||||
"error": "channel_escalation",
|
||||
"message": format!("Cannot mint access to channel {} — not in your token's channel_ids", cid)
|
||||
})),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Rate limit ────────────────────────────────────────────────────────────
|
||||
let pubkey_bytes_arr: [u8; 32] = ctx
|
||||
.pubkey_bytes
|
||||
.as_slice()
|
||||
.try_into()
|
||||
.map_err(|_| internal_error("pubkey bytes length mismatch"))?;
|
||||
|
||||
if let Err(retry_after) = state.mint_rate_limiter.check_and_record(&pubkey_bytes_arr) {
|
||||
let retry_secs = retry_after.as_secs();
|
||||
return Err((
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
Json(serde_json::json!({
|
||||
"error": "rate_limited",
|
||||
"message": format!(
|
||||
"Mint limit exceeded: {} per hour. Try again in {} seconds.",
|
||||
state.mint_rate_limiter.limit(), retry_secs
|
||||
),
|
||||
"retry_after_seconds": retry_secs
|
||||
})),
|
||||
));
|
||||
}
|
||||
|
||||
// ── Validate and verify channel_ids ──────────────────────────────────────
|
||||
let validated_channel_ids: Option<Vec<Uuid>> = if let Some(ref raw_ids) = req.channel_ids {
|
||||
if raw_ids.is_empty() {
|
||||
// Empty array is treated as "no restriction" — but if the caller's
|
||||
// token is channel-restricted, this would be an escalation. The subset
|
||||
// check above already rejects `None` for restricted callers, so we
|
||||
// must also reject empty arrays here.
|
||||
if matches!(ctx.auth_method, RestAuthMethod::ApiToken) && ctx.channel_ids.is_some() {
|
||||
return Err((
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(serde_json::json!({
|
||||
"error": "channel_escalation",
|
||||
"message": "Your token is channel-restricted; minted tokens must specify non-empty channel_ids (subset of yours)"
|
||||
})),
|
||||
));
|
||||
}
|
||||
None
|
||||
} else {
|
||||
let mut uuids = Vec::with_capacity(raw_ids.len());
|
||||
for raw in raw_ids {
|
||||
let cid: Uuid = raw.parse().map_err(|_| {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({
|
||||
"error": "invalid_channel_ids",
|
||||
"message": format!("malformed UUID: {raw}")
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
|
||||
// Verify channel exists.
|
||||
let channel = state.db.get_channel(cid).await.map_err(|_| {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({
|
||||
"error": "invalid_channel_ids",
|
||||
"message": format!("channel not found: {cid}")
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
let _ = channel; // existence confirmed
|
||||
|
||||
// Verify caller is a member of the channel.
|
||||
let is_member = state
|
||||
.db
|
||||
.is_member(cid, &ctx.pubkey_bytes)
|
||||
.await
|
||||
.map_err(|e| internal_error(&format!("db error: {e}")))?;
|
||||
if !is_member {
|
||||
return Err((
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(serde_json::json!({
|
||||
"error": "not_channel_member",
|
||||
"message": format!("not a member of channel: {cid}")
|
||||
})),
|
||||
));
|
||||
}
|
||||
|
||||
uuids.push(cid);
|
||||
}
|
||||
// Deduplicate channel_ids (sorted; UUIDs have no meaningful order).
|
||||
uuids.sort();
|
||||
uuids.dedup();
|
||||
Some(uuids)
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// ── Validate expires_in_days ──────────────────────────────────────────────
|
||||
if let Some(days) = req.expires_in_days {
|
||||
if days == 0 || days > 365 {
|
||||
return Err(api_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"invalid expires_in_days: must be 1–365",
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let expires_at = req
|
||||
.expires_in_days
|
||||
.map(|days| Utc::now() + chrono::Duration::days(days as i64));
|
||||
|
||||
// ── Generate token ────────────────────────────────────────────────────────
|
||||
let raw_token = sprout_auth::generate_token();
|
||||
let token_hash: Vec<u8> = Sha256::digest(raw_token.as_bytes()).to_vec();
|
||||
let scope_strings: Vec<String> = parsed_scopes.iter().map(|s| s.to_string()).collect();
|
||||
|
||||
// ── Conditional INSERT (enforces 10-token limit atomically) ──────────────
|
||||
let channel_ids_slice = validated_channel_ids.as_deref();
|
||||
let token_id = state
|
||||
.db
|
||||
.create_api_token_if_under_limit(
|
||||
&token_hash,
|
||||
&ctx.pubkey_bytes,
|
||||
&req.name,
|
||||
&scope_strings,
|
||||
channel_ids_slice,
|
||||
expires_at,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| internal_error(&format!("db error creating token: {e}")))?;
|
||||
|
||||
let token_id = match token_id {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
return Err((
|
||||
StatusCode::CONFLICT,
|
||||
Json(serde_json::json!({
|
||||
"error": "token_limit_exceeded",
|
||||
"message": "Maximum of 10 active tokens per pubkey"
|
||||
})),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
// ── Set agent owner ─────────────────────────────────────────────────────
|
||||
// Self-minted tokens always set the caller as the agent owner. This is the
|
||||
// same field that `sprout-admin mint-token --owner-pubkey` sets, ensuring
|
||||
// self-minted agents have the same ownership semantics as admin-minted ones.
|
||||
if let Err(e) = state
|
||||
.db
|
||||
.set_agent_owner(&ctx.pubkey_bytes, &ctx.pubkey_bytes)
|
||||
.await
|
||||
{
|
||||
tracing::warn!("set_agent_owner failed for self-mint: {e}");
|
||||
// Non-fatal — token was already created. Owner field is a convenience,
|
||||
// not a security gate. Log and continue.
|
||||
}
|
||||
|
||||
// ── Build response ────────────────────────────────────────────────────────
|
||||
let channel_ids_response: Vec<String> = validated_channel_ids
|
||||
.as_deref()
|
||||
.unwrap_or(&[])
|
||||
.iter()
|
||||
.map(|id| id.to_string())
|
||||
.collect();
|
||||
|
||||
let resp = MintTokenResponse {
|
||||
id: token_id,
|
||||
token: raw_token,
|
||||
name: req.name,
|
||||
scopes: scope_strings,
|
||||
channel_ids: channel_ids_response,
|
||||
created_at: Utc::now().to_rfc3339(),
|
||||
expires_at: expires_at.map(|t| t.to_rfc3339()),
|
||||
};
|
||||
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
Json(serde_json::to_value(resp).unwrap()),
|
||||
))
|
||||
}
|
||||
|
||||
/// `GET /api/tokens` — list all tokens owned by the authenticated pubkey.
|
||||
///
|
||||
/// Returns all tokens including revoked ones (for audit). Token values are
|
||||
/// **never** returned. Requires Bearer token or Okta JWT (not NIP-98 bootstrap).
|
||||
pub async fn get_tokens(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
|
||||
let ctx = extract_auth_context(&headers, &state).await?;
|
||||
|
||||
// NIP-98 bootstrap is not allowed for listing — caller must have a real token.
|
||||
if ctx.auth_method == RestAuthMethod::Nip98 {
|
||||
return Err(api_error(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"Bearer token required to list tokens",
|
||||
));
|
||||
}
|
||||
|
||||
let records = state
|
||||
.db
|
||||
.list_tokens_by_owner(&ctx.pubkey_bytes)
|
||||
.await
|
||||
.map_err(|e| internal_error(&format!("db error: {e}")))?;
|
||||
|
||||
let tokens: Vec<TokenListItem> = records
|
||||
.into_iter()
|
||||
.map(|r| {
|
||||
let channel_ids: Vec<String> = r
|
||||
.channel_ids
|
||||
.as_deref()
|
||||
.unwrap_or(&[])
|
||||
.iter()
|
||||
.map(|id| id.to_string())
|
||||
.collect();
|
||||
TokenListItem {
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
scopes: r.scopes,
|
||||
channel_ids,
|
||||
created_at: r.created_at.to_rfc3339(),
|
||||
expires_at: r.expires_at.map(|t| t.to_rfc3339()),
|
||||
last_used_at: r.last_used_at.map(|t| t.to_rfc3339()),
|
||||
revoked_at: r.revoked_at.map(|t| t.to_rfc3339()),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(serde_json::json!({ "tokens": tokens })))
|
||||
}
|
||||
|
||||
/// `DELETE /api/tokens/{id}` — revoke a single token by UUID.
|
||||
///
|
||||
/// The caller must own the token. Returns 204 on success, 404 if not found
|
||||
/// or not owned by the caller, 409 if already revoked.
|
||||
pub async fn delete_token(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<StatusCode, (StatusCode, Json<serde_json::Value>)> {
|
||||
let ctx = extract_auth_context(&headers, &state).await?;
|
||||
|
||||
if ctx.auth_method == RestAuthMethod::Nip98 {
|
||||
return Err(api_error(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"Bearer token required to revoke tokens",
|
||||
));
|
||||
}
|
||||
|
||||
// First, check if the token exists and is owned by the caller — to distinguish
|
||||
// "not found / not owned" (404) from "already revoked" (409).
|
||||
// We use get_api_token_by_hash_including_revoked is not applicable here (we have
|
||||
// the UUID, not the hash). Instead, we attempt the revoke and then check existence.
|
||||
let revoked = state
|
||||
.db
|
||||
.revoke_token(id, &ctx.pubkey_bytes, &ctx.pubkey_bytes)
|
||||
.await
|
||||
.map_err(|e| internal_error(&format!("db error: {e}")))?;
|
||||
|
||||
if revoked {
|
||||
return Ok(StatusCode::NO_CONTENT);
|
||||
}
|
||||
|
||||
// rows_affected == 0: either not found, not owned, or already revoked.
|
||||
// Check if the token exists at all (including revoked) to distinguish the cases.
|
||||
let all_tokens = state
|
||||
.db
|
||||
.list_tokens_by_owner(&ctx.pubkey_bytes)
|
||||
.await
|
||||
.map_err(|e| internal_error(&format!("db error: {e}")))?;
|
||||
|
||||
let found = all_tokens.iter().find(|t| t.id == id);
|
||||
match found {
|
||||
Some(t) if t.revoked_at.is_some() => Err((
|
||||
StatusCode::CONFLICT,
|
||||
Json(serde_json::json!({
|
||||
"error": "already_revoked",
|
||||
"message": "Token is already revoked"
|
||||
})),
|
||||
)),
|
||||
_ => Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({
|
||||
"error": "not_found",
|
||||
"message": "Token not found or not owned by caller"
|
||||
})),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// `DELETE /api/tokens` — revoke all tokens (panic button).
|
||||
///
|
||||
/// Revokes all active tokens for the authenticated pubkey, including the token
|
||||
/// used to make this call. Skips already-revoked tokens (idempotent).
|
||||
/// Returns `{ "revoked_count": N }` with 200.
|
||||
pub async fn delete_all_tokens(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
|
||||
let ctx = extract_auth_context(&headers, &state).await?;
|
||||
|
||||
if ctx.auth_method == RestAuthMethod::Nip98 {
|
||||
return Err(api_error(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"Bearer token required to revoke tokens",
|
||||
));
|
||||
}
|
||||
|
||||
let revoked_count = state
|
||||
.db
|
||||
.revoke_all_tokens(&ctx.pubkey_bytes, &ctx.pubkey_bytes)
|
||||
.await
|
||||
.map_err(|e| internal_error(&format!("db error: {e}")))?;
|
||||
|
||||
Ok(Json(serde_json::json!({ "revoked_count": revoked_count })))
|
||||
}
|
||||
|
||||
// ── Internal helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
/// Reconstruct the canonical URL for NIP-98 verification on the token mint endpoint.
|
||||
fn reconstruct_canonical_url_for_tokens(headers: &HeaderMap, state: &AppState) -> String {
|
||||
let proto = headers
|
||||
.get("x-forwarded-proto")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("https");
|
||||
let host = headers
|
||||
.get("x-forwarded-host")
|
||||
.or_else(|| headers.get("host"))
|
||||
.and_then(|v| v.to_str().ok());
|
||||
|
||||
if let Some(host) = host {
|
||||
format!("{proto}://{host}/api/tokens")
|
||||
} else {
|
||||
let base = state
|
||||
.config
|
||||
.relay_url
|
||||
.replace("wss://", "https://")
|
||||
.replace("ws://", "http://");
|
||||
format!("{base}/api/tokens")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rate_limiter_allows_up_to_limit() {
|
||||
let limiter = MintRateLimiter::new();
|
||||
let key = [0u8; 32];
|
||||
for _ in 0..DEFAULT_MINT_LIMIT {
|
||||
assert!(limiter.check_and_record(&key).is_ok());
|
||||
}
|
||||
// 6th call should be denied.
|
||||
assert!(limiter.check_and_record(&key).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rate_limiter_different_pubkeys_are_independent() {
|
||||
let limiter = MintRateLimiter::new();
|
||||
let key_a = [1u8; 32];
|
||||
let key_b = [2u8; 32];
|
||||
for _ in 0..DEFAULT_MINT_LIMIT {
|
||||
assert!(limiter.check_and_record(&key_a).is_ok());
|
||||
}
|
||||
// key_b should still be allowed.
|
||||
assert!(limiter.check_and_record(&key_b).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rate_limiter_returns_retry_after_duration() {
|
||||
let limiter = MintRateLimiter::new();
|
||||
let key = [3u8; 32];
|
||||
for _ in 0..DEFAULT_MINT_LIMIT {
|
||||
let _ = limiter.check_and_record(&key);
|
||||
}
|
||||
let err = limiter.check_and_record(&key).unwrap_err();
|
||||
// retry_after should be ≤ MINT_WINDOW and > 0.
|
||||
assert!(err > Duration::ZERO);
|
||||
assert!(err <= MINT_WINDOW);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mint_token_request_deserializes() {
|
||||
let json = r#"{
|
||||
"name": "my-agent",
|
||||
"scopes": ["messages:read", "messages:write"],
|
||||
"expires_in_days": 30
|
||||
}"#;
|
||||
let req: MintTokenRequest = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(req.name, "my-agent");
|
||||
assert_eq!(req.scopes.len(), 2);
|
||||
assert_eq!(req.expires_in_days, Some(30));
|
||||
assert!(req.channel_ids.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mint_token_request_with_channel_ids() {
|
||||
let json = r#"{
|
||||
"name": "channel-agent",
|
||||
"scopes": ["messages:read"],
|
||||
"channel_ids": ["01950a3b-c000-7000-8000-000000000001"]
|
||||
}"#;
|
||||
let req: MintTokenRequest = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(req.channel_ids.as_ref().unwrap().len(), 1);
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ use serde::Deserialize;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
use super::{api_error, extract_auth_pubkey, internal_error};
|
||||
use super::{api_error, extract_auth_context, internal_error, scope_error};
|
||||
|
||||
/// Request body for updating a user's profile.
|
||||
/// All fields are optional — at least one must be present.
|
||||
@@ -43,7 +43,9 @@ pub async fn update_profile(
|
||||
headers: HeaderMap,
|
||||
ExtractJson(body): ExtractJson<UpdateProfileBody>,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
|
||||
let (_pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
|
||||
let ctx = extract_auth_context(&headers, &state).await?;
|
||||
sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::UsersWrite).map_err(scope_error)?;
|
||||
let pubkey_bytes = ctx.pubkey_bytes.clone();
|
||||
|
||||
let display_name = body
|
||||
.display_name
|
||||
@@ -108,7 +110,9 @@ pub async fn get_profile(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
|
||||
let (_pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
|
||||
let ctx = extract_auth_context(&headers, &state).await?;
|
||||
sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::UsersRead).map_err(scope_error)?;
|
||||
let pubkey_bytes = ctx.pubkey_bytes.clone();
|
||||
|
||||
let profile = state
|
||||
.db
|
||||
@@ -134,7 +138,8 @@ pub async fn get_user_profile(
|
||||
headers: HeaderMap,
|
||||
Path(pubkey_hex): Path<String>,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
|
||||
let _ = extract_auth_pubkey(&headers, &state).await?;
|
||||
let ctx = extract_auth_context(&headers, &state).await?;
|
||||
sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::UsersRead).map_err(scope_error)?;
|
||||
|
||||
let pubkey_bytes = nostr_hex::decode(&pubkey_hex)
|
||||
.map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid pubkey hex"))?;
|
||||
@@ -174,7 +179,8 @@ pub async fn get_users_batch(
|
||||
headers: HeaderMap,
|
||||
ExtractJson(body): ExtractJson<BatchProfilesRequest>,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
|
||||
let _ = extract_auth_pubkey(&headers, &state).await?;
|
||||
let ctx = extract_auth_context(&headers, &state).await?;
|
||||
sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::UsersRead).map_err(scope_error)?;
|
||||
|
||||
if body.pubkeys.len() > 200 {
|
||||
return Err(api_error(
|
||||
@@ -262,7 +268,9 @@ pub async fn put_channel_add_policy(
|
||||
headers: HeaderMap,
|
||||
ExtractJson(body): ExtractJson<UpdateChannelAddPolicyBody>,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
|
||||
let (_pubkey_hex, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
|
||||
let ctx = extract_auth_context(&headers, &state).await?;
|
||||
sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::UsersWrite).map_err(scope_error)?;
|
||||
let pubkey_bytes = ctx.pubkey_bytes.clone();
|
||||
|
||||
let policy = body.channel_add_policy.as_str();
|
||||
if !matches!(policy, "anyone" | "owner_only" | "nobody") {
|
||||
|
||||
@@ -26,7 +26,8 @@ use super::workflow_helpers::{
|
||||
validate_webhook_urls, workflow_record_to_json,
|
||||
};
|
||||
use super::{
|
||||
api_error, check_channel_access, extract_auth_pubkey, forbidden, internal_error, not_found,
|
||||
api_error, check_channel_access, check_token_channel_access, extract_auth_context, forbidden,
|
||||
internal_error, not_found, scope_error,
|
||||
};
|
||||
|
||||
// ── GET /api/channels/:channel_id/workflows ───────────────────────────────────
|
||||
@@ -37,11 +38,15 @@ pub async fn list_channel_workflows(
|
||||
headers: HeaderMap,
|
||||
Path(channel_id_str): Path<String>,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
|
||||
let (_pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
|
||||
let ctx = extract_auth_context(&headers, &state).await?;
|
||||
sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::ChannelsRead)
|
||||
.map_err(scope_error)?;
|
||||
let pubkey_bytes = ctx.pubkey_bytes.clone();
|
||||
|
||||
let channel_id = uuid::Uuid::parse_str(&channel_id_str)
|
||||
.map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid channel UUID"))?;
|
||||
|
||||
check_token_channel_access(&ctx, &channel_id)?;
|
||||
check_channel_access(&state, channel_id, &pubkey_bytes).await?;
|
||||
|
||||
let workflows = state
|
||||
@@ -73,11 +78,15 @@ pub async fn create_workflow(
|
||||
Path(channel_id_str): Path<String>,
|
||||
Json(body): Json<CreateWorkflowBody>,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
|
||||
let (_pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
|
||||
let ctx = extract_auth_context(&headers, &state).await?;
|
||||
sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::ChannelsWrite)
|
||||
.map_err(scope_error)?;
|
||||
let pubkey_bytes = ctx.pubkey_bytes.clone();
|
||||
|
||||
let channel_id = uuid::Uuid::parse_str(&channel_id_str)
|
||||
.map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid channel UUID"))?;
|
||||
|
||||
check_token_channel_access(&ctx, &channel_id)?;
|
||||
check_channel_access(&state, channel_id, &pubkey_bytes).await?;
|
||||
|
||||
let (def, definition_json_str) =
|
||||
@@ -150,7 +159,10 @@ pub async fn get_workflow(
|
||||
headers: HeaderMap,
|
||||
Path(id_str): Path<String>,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
|
||||
let (_pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
|
||||
let ctx = extract_auth_context(&headers, &state).await?;
|
||||
sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::ChannelsRead)
|
||||
.map_err(scope_error)?;
|
||||
let pubkey_bytes = ctx.pubkey_bytes.clone();
|
||||
|
||||
let id = uuid::Uuid::parse_str(&id_str)
|
||||
.map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid workflow UUID"))?;
|
||||
@@ -162,6 +174,7 @@ pub async fn get_workflow(
|
||||
.map_err(|_| not_found("workflow not found"))?;
|
||||
|
||||
if let Some(channel_id) = workflow.channel_id {
|
||||
check_token_channel_access(&ctx, &channel_id)?;
|
||||
check_channel_access(&state, channel_id, &pubkey_bytes).await?;
|
||||
} else if workflow.owner_pubkey != pubkey_bytes {
|
||||
return Err(forbidden("not authorized to access this workflow"));
|
||||
@@ -189,7 +202,10 @@ pub async fn update_workflow(
|
||||
Path(id_str): Path<String>,
|
||||
Json(body): Json<UpdateWorkflowBody>,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
|
||||
let (_pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
|
||||
let ctx = extract_auth_context(&headers, &state).await?;
|
||||
sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::ChannelsWrite)
|
||||
.map_err(scope_error)?;
|
||||
let pubkey_bytes = ctx.pubkey_bytes.clone();
|
||||
|
||||
let id = uuid::Uuid::parse_str(&id_str)
|
||||
.map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid workflow UUID"))?;
|
||||
@@ -201,6 +217,7 @@ pub async fn update_workflow(
|
||||
.map_err(|_| not_found("workflow not found"))?;
|
||||
|
||||
if let Some(channel_id) = existing.channel_id {
|
||||
check_token_channel_access(&ctx, &channel_id)?;
|
||||
check_channel_access(&state, channel_id, &pubkey_bytes).await?;
|
||||
} else if existing.owner_pubkey != pubkey_bytes {
|
||||
return Err(forbidden("not authorized to access this workflow"));
|
||||
@@ -278,7 +295,10 @@ pub async fn delete_workflow(
|
||||
headers: HeaderMap,
|
||||
Path(id_str): Path<String>,
|
||||
) -> Result<axum::response::Response, (StatusCode, Json<serde_json::Value>)> {
|
||||
let (_pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
|
||||
let ctx = extract_auth_context(&headers, &state).await?;
|
||||
sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::ChannelsWrite)
|
||||
.map_err(scope_error)?;
|
||||
let pubkey_bytes = ctx.pubkey_bytes.clone();
|
||||
|
||||
let id = uuid::Uuid::parse_str(&id_str)
|
||||
.map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid workflow UUID"))?;
|
||||
@@ -291,6 +311,7 @@ pub async fn delete_workflow(
|
||||
|
||||
if workflow.owner_pubkey != pubkey_bytes {
|
||||
if let Some(channel_id) = workflow.channel_id {
|
||||
check_token_channel_access(&ctx, &channel_id)?;
|
||||
check_channel_access(&state, channel_id, &pubkey_bytes)
|
||||
.await
|
||||
.map_err(|_| forbidden("not authorized to delete this workflow"))?;
|
||||
@@ -325,7 +346,10 @@ pub async fn list_workflow_runs(
|
||||
Path(id_str): Path<String>,
|
||||
Query(params): Query<RunsParams>,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
|
||||
let (_pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
|
||||
let ctx = extract_auth_context(&headers, &state).await?;
|
||||
sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::ChannelsRead)
|
||||
.map_err(scope_error)?;
|
||||
let pubkey_bytes = ctx.pubkey_bytes.clone();
|
||||
|
||||
let id = uuid::Uuid::parse_str(&id_str)
|
||||
.map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid workflow UUID"))?;
|
||||
@@ -337,6 +361,7 @@ pub async fn list_workflow_runs(
|
||||
.map_err(|_| not_found("workflow not found"))?;
|
||||
|
||||
if let Some(channel_id) = workflow.channel_id {
|
||||
check_token_channel_access(&ctx, &channel_id)?;
|
||||
check_channel_access(&state, channel_id, &pubkey_bytes).await?;
|
||||
}
|
||||
|
||||
@@ -359,7 +384,10 @@ pub async fn trigger_workflow(
|
||||
headers: HeaderMap,
|
||||
Path(id_str): Path<String>,
|
||||
) -> Result<(StatusCode, Json<serde_json::Value>), (StatusCode, Json<serde_json::Value>)> {
|
||||
let (_pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
|
||||
let ctx = extract_auth_context(&headers, &state).await?;
|
||||
sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::ChannelsWrite)
|
||||
.map_err(scope_error)?;
|
||||
let pubkey_bytes = ctx.pubkey_bytes.clone();
|
||||
|
||||
let id = uuid::Uuid::parse_str(&id_str)
|
||||
.map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid workflow UUID"))?;
|
||||
@@ -371,6 +399,7 @@ pub async fn trigger_workflow(
|
||||
.map_err(|_| not_found("workflow not found"))?;
|
||||
|
||||
if let Some(channel_id) = workflow.channel_id {
|
||||
check_token_channel_access(&ctx, &channel_id)?;
|
||||
check_channel_access(&state, channel_id, &pubkey_bytes).await?;
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ use tower_http::limit::RequestBodyLimitLayer;
|
||||
use tower_http::trace::TraceLayer;
|
||||
|
||||
use crate::api;
|
||||
use crate::api::tokens;
|
||||
use crate::connection::handle_connection;
|
||||
use crate::nip11::{relay_info_handler, RelayInfo};
|
||||
use crate::state::AppState;
|
||||
@@ -25,6 +26,14 @@ pub fn build_router(state: Arc<AppState>) -> Router {
|
||||
.route("/info", get(relay_info_handler))
|
||||
.route("/.well-known/nostr.json", get(api::nip05::nostr_nip05))
|
||||
.route("/health", get(health_handler))
|
||||
// Token self-service routes
|
||||
.route(
|
||||
"/api/tokens",
|
||||
post(tokens::post_tokens)
|
||||
.get(tokens::get_tokens)
|
||||
.delete(tokens::delete_all_tokens),
|
||||
)
|
||||
.route("/api/tokens/{id}", delete(tokens::delete_token))
|
||||
.route(
|
||||
"/api/channels",
|
||||
get(api::channels_handler).post(api::create_channel),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Shared application state — Arc-wrapped, shared across all connections.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
use axum::extract::ws::Message as WsMessage;
|
||||
use dashmap::DashMap;
|
||||
@@ -14,6 +15,7 @@ use sprout_pubsub::PubSubManager;
|
||||
use sprout_search::SearchService;
|
||||
use sprout_workflow::WorkflowEngine;
|
||||
|
||||
use crate::api::tokens::MintRateLimiter;
|
||||
use crate::config::Config;
|
||||
use crate::subscription::SubscriptionRegistry;
|
||||
|
||||
@@ -84,6 +86,12 @@ pub struct AppState {
|
||||
pub workflow_engine: Arc<WorkflowEngine>,
|
||||
/// Relay signing keypair — used to sign system messages (kind 40099).
|
||||
pub relay_keypair: nostr::Keys,
|
||||
/// Rate limiter for `POST /api/tokens` — 5 mints per pubkey per hour.
|
||||
pub mint_rate_limiter: Arc<MintRateLimiter>,
|
||||
/// Debounce cache for `last_used_at` token updates — avoids a DB write on every request.
|
||||
/// Entries map token UUID → last time we wrote `last_used_at` to the DB.
|
||||
/// Resets on restart (acceptable — `last_used_at` is informational, not security-critical).
|
||||
pub last_used_cache: Arc<DashMap<Uuid, Instant>>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
@@ -114,6 +122,8 @@ impl AppState {
|
||||
handler_semaphore: Arc::new(Semaphore::new(max_concurrent_handlers)),
|
||||
workflow_engine,
|
||||
relay_keypair,
|
||||
mint_rate_limiter: Arc::new(MintRateLimiter::new()),
|
||||
last_used_cache: Arc::new(DashMap::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,10 @@ tracing-subscriber = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
futures-util = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
base64 = "0.22"
|
||||
sha2 = "0.10"
|
||||
chrono = { workspace = true }
|
||||
hex = "0.4"
|
||||
|
||||
[[bin]]
|
||||
name = "sprout-test-cli"
|
||||
|
||||
@@ -295,8 +295,8 @@ async fn test_mcp_initialize_and_list_tools() {
|
||||
|
||||
assert_eq!(
|
||||
tools.len(),
|
||||
42,
|
||||
"expected exactly 42 tools, got {}. Tools: {:?}",
|
||||
43,
|
||||
"expected exactly 43 tools, got {}. Tools: {:?}",
|
||||
tools.len(),
|
||||
tools
|
||||
.iter()
|
||||
|
||||
1018
crates/sprout-test-client/tests/e2e_tokens.rs
Normal file
1018
crates/sprout-test-client/tests/e2e_tokens.rs
Normal file
File diff suppressed because it is too large
Load Diff
6
migrations/20260317000001_self_mint_token.sql
Normal file
6
migrations/20260317000001_self_mint_token.sql
Normal file
@@ -0,0 +1,6 @@
|
||||
-- Add created_by_self_mint column to api_tokens.
|
||||
-- Tracks whether a token was minted via POST /api/tokens (self-service)
|
||||
-- or via the sprout-admin CLI.
|
||||
ALTER TABLE api_tokens
|
||||
ADD COLUMN created_by_self_mint TINYINT(1) NOT NULL DEFAULT 0
|
||||
COMMENT '1 = minted via POST /api/tokens, 0 = minted via sprout-admin CLI';
|
||||
Reference in New Issue
Block a user