Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,7 @@ Optional:
- `SQLX_POOL_SIZE`: Database connection pool size (should match Cloud Run concurrency, default: `50`)
- `VITE_ALLOWED_PUBKEYS`: Comma-separated pubkeys for whitelist access (web frontend)
- `ENABLE_EXAMPLES`: Enable `/examples` directory serving (default: `false`, set to `true` for development)
- `KEYCAST_SERVICE_TOKEN`: Bearer token for service-to-service admin API calls (relay-manager, COOP). Required for `/api/admin/users/:pubkey/status` endpoints.

Development (`.env` in `/web`):
- `VITE_ALLOWED_PUBKEYS`: Comma-separated pubkeys for dev access
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions api/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ serde = { workspace = true }
serde_json = { workspace = true }
serde_yaml = "0.9"
sha256 = "1.5"
subtle = "2.6"
sqlx = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true }
Expand Down
130 changes: 130 additions & 0 deletions api/src/api/http/admin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// ABOUTME: Used for Vine import and support workflows

use axum::extract::{Path, Query, State};
use axum::http::HeaderMap;
use axum::Json;
use chrono::{Duration, Utc};
use nostr_sdk::{FromBech32, Keys};
Expand All @@ -17,6 +18,7 @@ use keycast_core::repositories::{
RegisteredClientRepository, RepositoryError, UserRepository,
};
use keycast_core::types::claim_token::generate_claim_token;
use keycast_core::types::user::UserStatus;

/// Admin token expiry in days (30 days for long-lived admin tokens)
const ADMIN_TOKEN_EXPIRY_DAYS: i64 = 30;
Expand Down Expand Up @@ -898,6 +900,9 @@ pub struct UserLookupDetails {
pub display_name: Option<String>,
pub vine_id: Option<String>,
pub has_personal_key: bool,
pub status: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub suspended_reason: Option<String>,
pub active_sessions: i64,
pub created_at: String,
pub last_active: Option<String>,
Expand Down Expand Up @@ -964,6 +969,8 @@ pub async fn get_user_lookup(
display_name: details.display_name,
vine_id: details.vine_id,
has_personal_key: details.has_personal_key,
status: details.status.as_str().to_string(),
suspended_reason: details.suspended_reason,
active_sessions: sessions.len() as i64,
created_at: details.created_at.to_rfc3339(),
last_active,
Expand Down Expand Up @@ -1798,3 +1805,126 @@ pub async fn test_registered_client_pattern(
matches: test_redirect_pattern(&req.pattern, &req.uri),
}))
}

// --- Service-token-authenticated admin endpoints (for relay-manager, COOP) ---

fn authorize_service_token(headers: &HeaderMap) -> Result<(), ApiError> {
use subtle::ConstantTimeEq;

let expected = std::env::var("KEYCAST_SERVICE_TOKEN")
.ok()
.map(|token| token.trim().to_string())
.filter(|token| !token.is_empty())
.ok_or_else(|| ApiError::internal("KEYCAST_SERVICE_TOKEN not configured"))?;

let actual = headers
.get("authorization")
.and_then(|v| v.to_str().ok())
.ok_or_else(|| ApiError::auth("Missing Authorization header"))?;

// Hash both to fixed length to avoid leaking expected token length via timing
let expected_hash = blake3::hash(format!("Bearer {expected}").as_bytes());
let actual_hash = blake3::hash(actual.as_bytes());
if expected_hash
.as_bytes()
.ct_eq(actual_hash.as_bytes())
.into()
{
Ok(())
} else {
Err(ApiError::auth("Invalid service token"))
}
}

#[derive(Debug, Serialize, Deserialize)]
pub struct UserStatusResponse {
pub pubkey: String,
pub status: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub suspended_reason: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub suspended_at: Option<chrono::DateTime<chrono::Utc>>,
}

pub async fn get_user_status_admin(
tenant: crate::api::tenant::TenantExtractor,
State(auth_state): State<AuthState>,
headers: HeaderMap,
Path(pubkey): Path<String>,
) -> ApiResult<Json<UserStatusResponse>> {
authorize_service_token(&headers)?;
let tenant_id = tenant.0.id;
let user_repo = UserRepository::new(auth_state.state.db.clone());

let (status, suspended_reason, suspended_at) = user_repo
.get_user_status(&pubkey, tenant_id)
.await?
.ok_or_else(|| ApiError::not_found("User not found"))?;

Ok(Json(UserStatusResponse {
pubkey,
status: status.as_str().to_string(),
suspended_reason,
suspended_at,
}))
}

#[derive(Debug, Deserialize)]
pub struct SetUserStatusRequest {
pub status: String,
pub reason: Option<String>,
}

pub async fn set_user_status_admin(
tenant: crate::api::tenant::TenantExtractor,
State(auth_state): State<AuthState>,
headers: HeaderMap,
Path(pubkey): Path<String>,
Json(req): Json<SetUserStatusRequest>,
) -> ApiResult<Json<UserStatusResponse>> {
authorize_service_token(&headers)?;
let tenant_id = tenant.0.id;
let user_repo = UserRepository::new(auth_state.state.db.clone());

let status = match req.status.as_str() {
"active" => UserStatus::Active,
"suspended" => UserStatus::Suspended,
"banned" => UserStatus::Banned,
_ => {
return Err(ApiError::bad_request(
"Invalid status. Must be: active, suspended, banned",
))
}
};

if !status.is_active() && req.reason.as_ref().is_none_or(|r| r.trim().is_empty()) {
return Err(ApiError::bad_request(
"reason is required when status is suspended or banned",
));
}

let reason = if status.is_active() {
None
} else {
req.reason.as_deref().map(str::trim)
};
let (old_status, updated_status, suspended_reason, suspended_at) = user_repo
.set_user_status(&pubkey, tenant_id, &status, reason)
.await?;

tracing::info!(
event = "user_status_changed",
pubkey = %pubkey,
old_status = %old_status.as_str(),
new_status = %updated_status.as_str(),
reason = ?req.reason,
"Admin changed user status"
);

Ok(Json(UserStatusResponse {
pubkey,
status: updated_status.as_str().to_string(),
suspended_reason,
suspended_at,
}))
}
104 changes: 92 additions & 12 deletions api/src/api/http/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@ pub(crate) async fn generate_ucan_token(
email: &str,
redirect_origin: &str,
relays: Option<&[String]>,
status: Option<&keycast_core::types::user::UserStatus>,
) -> Result<String, AuthError> {
use crate::ucan_auth::{nostr_pubkey_to_did, NostrKeyMaterial};
use serde_json::json;
Expand All @@ -204,6 +205,11 @@ pub(crate) async fn generate_ucan_token(
if let Some(relays) = relays {
facts_obj["relays"] = json!(relays);
}
if let Some(s) = status {
if !s.is_active() {
facts_obj["account_status"] = json!(s.as_str());
}
}

let facts = facts_obj;

Expand Down Expand Up @@ -238,6 +244,7 @@ pub async fn generate_server_signed_ucan(
server_keys: &Keys,
is_first_party: bool,
admin_role: Option<&str>,
status: Option<&keycast_core::types::user::UserStatus>,
) -> Result<String, AuthError> {
use crate::ucan_auth::{nostr_pubkey_to_did, NostrKeyMaterial};
use serde_json::json;
Expand All @@ -260,6 +267,11 @@ pub async fn generate_server_signed_ucan(
if let Some(role) = admin_role {
facts["admin_role"] = json!(role);
}
if let Some(s) = status {
if !s.is_active() {
facts["account_status"] = json!(s.as_str());
}
}

let ucan = UcanBuilder::default()
.issued_by(&server_key_material) // Server issues
Expand Down Expand Up @@ -358,6 +370,10 @@ pub struct AccountStatusResponse {
pub email: String,
pub email_verified: bool,
pub public_key: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub account_status: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub suspended_reason: Option<String>,
}

#[derive(Debug, Deserialize, Serialize)]
Expand Down Expand Up @@ -758,6 +774,7 @@ async fn nostr_auth_login(
&server_keys,
false, // NIP-98 admin login is not first-party OAuth
Some(admin_role),
None, // Admin login does not carry user account status
)
.await?;

Expand Down Expand Up @@ -1021,7 +1038,7 @@ pub async fn login(
let user_repo = UserRepository::new(pool.clone());
let user = user_repo.find_with_password(&req.email, tenant_id).await?;

let (public_key, password_hash, email_verified) = match user {
let (public_key, password_hash, email_verified, user_status) = match user {
Some(u) => u,
None => {
super::auth_observability::record_auth_event_and_log(
Expand Down Expand Up @@ -1140,8 +1157,20 @@ pub async fn login(
let keys = Keys::new(secret_key.into());

// Generate UCAN token for session cookie with redirect_origin
let ucan_token =
generate_ucan_token(&keys, tenant_id, &req.email, &redirect_origin, None).await?;
let status_ref = if user_status.is_active() {
None
} else {
Some(&user_status)
};
let ucan_token = generate_ucan_token(
&keys,
tenant_id,
&req.email,
&redirect_origin,
None,
status_ref,
)
.await?;

// Track successful login
METRICS.inc_login();
Expand Down Expand Up @@ -1798,8 +1827,15 @@ pub async fn verify_email(
// Mark email as verified (token kept for idempotent re-verification)
user_repo.verify_email(&public_key, tenant_id).await?;

// Get user's email for UCAN
// Get user's email and account status for UCAN
let email = user_repo.get_email(&public_key, tenant_id).await?;
// Best-effort: DB errors → None (no status fact). Hard enforcement is at signing time.
let user_status = user_repo
.get_user_status(&public_key, tenant_id)
.await
.ok()
.flatten()
.map(|(s, _, _)| s);

// Get user's keys to generate UCAN (tenant-scoped)
let personal_keys_repo = PersonalKeysRepository::new(pool.clone());
Expand All @@ -1823,7 +1859,15 @@ pub async fn verify_email(
.unwrap_or_else(|_| "http://localhost:3000".to_string());

// Generate UCAN token for session cookie
let ucan_token = generate_ucan_token(&keys, tenant_id, &email, &redirect_origin, None).await?;
let ucan_token = generate_ucan_token(
&keys,
tenant_id,
&email,
&redirect_origin,
None,
user_status.as_ref(),
)
.await?;

tracing::info!(
event = "email_verification",
Expand Down Expand Up @@ -2305,11 +2349,25 @@ pub async fn get_account_status(
.await?;

match user {
Some((email, email_verified)) => Ok(Json(AccountStatusResponse {
email: email.unwrap_or_default(),
email_verified: email_verified.unwrap_or(false),
public_key: user_pubkey,
})),
Some((email, email_verified, status, suspended_reason)) => {
let account_status = if status.is_active() {
None
} else {
Some(status.as_str().to_string())
};
let reason = if status.is_active() {
None
} else {
suspended_reason
};
Ok(Json(AccountStatusResponse {
email: email.unwrap_or_default(),
email_verified: email_verified.unwrap_or(false),
public_key: user_pubkey,
account_status,
suspended_reason: reason,
}))
}
None => Err(AuthError::UserNotFound),
}
}
Expand Down Expand Up @@ -3090,6 +3148,14 @@ pub async fn sign_event(
let pool = &auth_state.state.db;
let key_manager = auth_state.state.key_manager.as_ref();

// Check account status before either signing path (fast or slow)
let user_repo = UserRepository::new(pool.clone());
if let Some((status, _, _)) = user_repo.get_user_status(&user_pubkey, tenant_id).await? {
if !status.is_active() {
return Err(AuthError::Forbidden("Account restricted".to_string()));
}
}

// Parse unsigned event first for validation
let unsigned_event: UnsignedEvent = serde_json::from_value(req.event.clone())
.map_err(|e| AuthError::Internal(format!("Invalid event format: {}", e)))?;
Expand Down Expand Up @@ -3509,8 +3575,21 @@ pub async fn change_key(

// Issue new UCAN session cookie signed by the new key
let redirect_origin = extract_origin_from_headers(&headers)?;
let ucan_token =
generate_ucan_token(&new_keys, tenant_id, &email, &redirect_origin, None).await?;
let change_key_status = user_repo
.get_user_status(&new_pubkey, tenant_id)
.await
.ok()
.flatten()
.map(|(s, _, _)| s);
let ucan_token = generate_ucan_token(
&new_keys,
tenant_id,
&email,
&redirect_origin,
None,
change_key_status.as_ref(),
)
.await?;

let cookie = format!(
"keycast_session={}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=86400",
Expand Down Expand Up @@ -4161,6 +4240,7 @@ mod tests {
"second@example.com",
"http://localhost:3000",
None,
None,
)
.await
.unwrap();
Expand Down
Loading
Loading