diff --git a/src/handler/mod.rs b/src/handler/mod.rs index 19e5627..5cf3f87 100644 --- a/src/handler/mod.rs +++ b/src/handler/mod.rs @@ -1,6 +1,7 @@ pub mod domains; mod health; mod host_meta; +pub mod tokens; mod webfinger; use axum::Router; @@ -11,6 +12,7 @@ pub fn router(state: AppState) -> Router { .merge(webfinger::router()) .merge(host_meta::router()) .merge(domains::router()) + .merge(tokens::router()) .merge(health::router()) .with_state(state) } diff --git a/src/handler/tokens.rs b/src/handler/tokens.rs new file mode 100644 index 0000000..752a5eb --- /dev/null +++ b/src/handler/tokens.rs @@ -0,0 +1,170 @@ +use axum::extract::{Path, State}; +use axum::http::StatusCode; +use axum::routing::{delete, post}; +use axum::{Json, Router}; +use sea_orm::*; +use serde::Deserialize; +use serde_json::json; + +use crate::auth; +use crate::entity::{links, resources, service_tokens}; +use crate::error::{AppError, AppResult}; +use crate::handler::domains::authenticate_owner; +use crate::state::AppState; + +fn validate_resource_pattern(pattern: &str, domain: &str) -> Result<(), String> { + if !pattern.contains('@') { + return Err("resource_pattern must contain '@'".into()); + } + if pattern == "*" { + return Err("resource_pattern '*' is too broad".into()); + } + // Must end with the domain + let domain_suffix = format!("@{domain}"); + if !pattern.ends_with(&domain_suffix) { + return Err(format!("resource_pattern must end with @{domain}")); + } + Ok(()) +} + +#[derive(Deserialize)] +pub struct CreateTokenRequest { + name: String, + allowed_rels: Vec, + resource_pattern: String, +} + +async fn create_token( + State(state): State, + Path(domain_id): Path, + headers: axum::http::HeaderMap, + Json(req): Json, +) -> AppResult<(StatusCode, Json)> { + let auth_header = headers.get("authorization").and_then(|v| v.to_str().ok()); + let domain = authenticate_owner(&state.db, &domain_id, auth_header).await?; + + validate_resource_pattern(&req.resource_pattern, &domain.domain) + .map_err(|e| AppError::BadRequest(e))?; + + if req.allowed_rels.is_empty() { + return Err(AppError::BadRequest("allowed_rels cannot be empty".into())); + } + + let id = uuid::Uuid::new_v4().to_string(); + let token = auth::generate_token(&id); + let token_hash = auth::hash_token(&token) + .map_err(|e| AppError::Internal(format!("hash error: {e}")))?; + + let service_token = service_tokens::ActiveModel { + id: Set(id.clone()), + domain_id: Set(domain_id), + name: Set(req.name.clone()), + token_hash: Set(token_hash), + allowed_rels: Set(serde_json::to_string(&req.allowed_rels).unwrap()), + resource_pattern: Set(req.resource_pattern.clone()), + created_at: Set(chrono::Utc::now().naive_utc()), + revoked_at: Set(None), + }; + + service_token.insert(&state.db).await?; + + Ok(( + StatusCode::CREATED, + Json(json!({ + "id": id, + "name": req.name, + "token": token, + "allowed_rels": req.allowed_rels, + "resource_pattern": req.resource_pattern, + })), + )) +} + +async fn list_tokens( + State(state): State, + Path(domain_id): Path, + headers: axum::http::HeaderMap, +) -> AppResult> { + let auth_header = headers.get("authorization").and_then(|v| v.to_str().ok()); + authenticate_owner(&state.db, &domain_id, auth_header).await?; + + let tokens = service_tokens::Entity::find() + .filter(service_tokens::Column::DomainId.eq(&domain_id)) + .filter(service_tokens::Column::RevokedAt.is_null()) + .all(&state.db) + .await?; + + let result: Vec = tokens + .into_iter() + .map(|t| { + json!({ + "id": t.id, + "name": t.name, + "allowed_rels": serde_json::from_str::(&t.allowed_rels).unwrap_or_default(), + "resource_pattern": t.resource_pattern, + "created_at": t.created_at.to_string(), + }) + }) + .collect(); + + Ok(Json(json!(result))) +} + +async fn revoke_token( + State(state): State, + Path((domain_id, token_id)): Path<(String, String)>, + headers: axum::http::HeaderMap, +) -> AppResult { + let auth_header = headers.get("authorization").and_then(|v| v.to_str().ok()); + authenticate_owner(&state.db, &domain_id, auth_header).await?; + + let token = service_tokens::Entity::find_by_id(&token_id) + .filter(service_tokens::Column::DomainId.eq(&domain_id)) + .one(&state.db) + .await? + .ok_or(AppError::NotFound)?; + + // Find all resource URIs affected by links from this token + let affected_links = links::Entity::find() + .filter(links::Column::ServiceTokenId.eq(&token_id)) + .find_also_related(resources::Entity) + .all(&state.db) + .await?; + + let affected_resource_uris: Vec = affected_links + .iter() + .filter_map(|(_, resource)| resource.as_ref().map(|r| r.resource_uri.clone())) + .collect::>() + .into_iter() + .collect(); + + // Delete all links for this token + links::Entity::delete_many() + .filter(links::Column::ServiceTokenId.eq(&token_id)) + .exec(&state.db) + .await?; + + // Mark token as revoked + let mut active: service_tokens::ActiveModel = token.into(); + active.revoked_at = Set(Some(chrono::Utc::now().naive_utc())); + active.update(&state.db).await?; + + // Refresh cache for affected resources + for uri in affected_resource_uris { + state.cache.refresh_resource(&state.db, &uri).await?; + } + + Ok(StatusCode::NO_CONTENT) +} + +pub fn router() -> Router { + Router::new() + .route( + "/api/v1/domains/{id}/tokens", + post(create_token).get(list_tokens), + ) + .route( + "/api/v1/domains/{id}/tokens/{tid}", + delete(revoke_token), + ) +} diff --git a/tests/test_tokens.rs b/tests/test_tokens.rs new file mode 100644 index 0000000..8ec3cc0 --- /dev/null +++ b/tests/test_tokens.rs @@ -0,0 +1,154 @@ +mod common; + +use axum_test::TestServer; +use serde_json::json; +use webfingerd::handler; + +/// Helper: register a verified domain and return (id, owner_token). +/// Uses MockChallengeVerifier (injected in test state) so no manual DB manipulation needed. +async fn setup_verified_domain( + server: &TestServer, + _state: &webfingerd::state::AppState, + domain_name: &str, +) -> (String, String) { + let create_resp = server + .post("/api/v1/domains") + .json(&json!({"domain": domain_name, "challenge_type": "dns-01"})) + .await; + let body: serde_json::Value = create_resp.json(); + let id = body["id"].as_str().unwrap().to_string(); + let reg_secret = body["registration_secret"].as_str().unwrap().to_string(); + + // MockChallengeVerifier always succeeds + let verify_resp = server + .post(&format!("/api/v1/domains/{id}/verify")) + .json(&json!({"registration_secret": reg_secret})) + .await; + let owner_token = verify_resp.json::()["owner_token"] + .as_str() + .unwrap() + .to_string(); + + (id, owner_token) +} + +#[tokio::test] +async fn test_create_service_token() { + let state = common::test_state().await; + let app = handler::router(state.clone()); + let server = TestServer::new(app); + + let (id, owner_token) = setup_verified_domain(&server, &state, "example.com").await; + + let response = server + .post(&format!("/api/v1/domains/{id}/tokens")) + .add_header("Authorization", format!("Bearer {owner_token}")) + .json(&json!({ + "name": "oxifed", + "allowed_rels": ["self"], + "resource_pattern": "acct:*@example.com" + })) + .await; + + response.assert_status(axum::http::StatusCode::CREATED); + let body: serde_json::Value = response.json(); + assert!(body["id"].is_string()); + assert!(body["token"].is_string()); + assert_eq!(body["name"], "oxifed"); +} + +#[tokio::test] +async fn test_create_service_token_rejects_bad_pattern() { + let state = common::test_state().await; + let app = handler::router(state.clone()); + let server = TestServer::new(app); + + let (id, owner_token) = setup_verified_domain(&server, &state, "example.com").await; + + // Pattern without @ or wrong domain + let response = server + .post(&format!("/api/v1/domains/{id}/tokens")) + .add_header("Authorization", format!("Bearer {owner_token}")) + .json(&json!({ + "name": "evil", + "allowed_rels": ["self"], + "resource_pattern": "*" + })) + .await; + + response.assert_status_bad_request(); +} + +#[tokio::test] +async fn test_list_service_tokens() { + let state = common::test_state().await; + let app = handler::router(state.clone()); + let server = TestServer::new(app); + + let (id, owner_token) = setup_verified_domain(&server, &state, "example.com").await; + + server + .post(&format!("/api/v1/domains/{id}/tokens")) + .add_header("Authorization", format!("Bearer {owner_token}")) + .json(&json!({ + "name": "oxifed", + "allowed_rels": ["self"], + "resource_pattern": "acct:*@example.com" + })) + .await; + + let response = server + .get(&format!("/api/v1/domains/{id}/tokens")) + .add_header("Authorization", format!("Bearer {owner_token}")) + .await; + + response.assert_status_ok(); + let body: serde_json::Value = response.json(); + let tokens = body.as_array().unwrap(); + assert_eq!(tokens.len(), 1); + assert_eq!(tokens[0]["name"], "oxifed"); + // Token hash should NOT be exposed + assert!(tokens[0].get("token_hash").is_none()); + assert!(tokens[0].get("token").is_none()); +} + +// NOTE: test_revoke_service_token_deletes_links is in tests/test_links.rs (Task 10) +// because it depends on the link registration endpoint. It is tested there as part +// of the full link lifecycle, not here where the endpoint doesn't exist yet. + +#[tokio::test] +async fn test_revoke_service_token() { + let state = common::test_state().await; + let app = handler::router(state.clone()); + let server = TestServer::new(app); + + let (id, owner_token) = setup_verified_domain(&server, &state, "example.com").await; + + let create_resp = server + .post(&format!("/api/v1/domains/{id}/tokens")) + .add_header("Authorization", format!("Bearer {owner_token}")) + .json(&json!({ + "name": "oxifed", + "allowed_rels": ["self"], + "resource_pattern": "acct:*@example.com" + })) + .await; + let body: serde_json::Value = create_resp.json(); + let token_id = body["id"].as_str().unwrap().to_string(); + + // Revoke the token + let response = server + .delete(&format!("/api/v1/domains/{id}/tokens/{token_id}")) + .add_header("Authorization", format!("Bearer {owner_token}")) + .await; + response.assert_status(axum::http::StatusCode::NO_CONTENT); + + // Token should no longer appear in list + let list_resp = server + .get(&format!("/api/v1/domains/{id}/tokens")) + .add_header("Authorization", format!("Bearer {owner_token}")) + .await; + let tokens = list_resp.json::(); + let tokens = tokens.as_array().unwrap(); + assert!(tokens.is_empty()); +}