Skip to content

96 endpoint for aggregate status list as from the spec #97

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 14 commits into
base: main
Choose a base branch
from
Open
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
696 changes: 405 additions & 291 deletions Cargo.lock

Large diffs are not rendered by default.

8 changes: 4 additions & 4 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,12 @@ axum = { version = "0.8", features = ["macros"] }
tower-http = { version = "0.6", features = ["cors", "trace", "catch-panic"] }

# Database and ORM
sea-orm = { version = "1.1", features = [
sea-orm = { version = "0.12.15", features = [
"sqlx-postgres",
"runtime-tokio-rustls",
"macros",
] }
sea-orm-migration = { version = "1.1", features = [
sea-orm-migration = { version = "0.12.15", features = [
"sqlx-postgres",
"runtime-tokio-rustls",
] }
Expand All @@ -39,7 +39,7 @@ jsonwebtoken = "9.3"
rustls = "0.23"
webpki-roots = "1"
rustls-pki-types = "1.12"
secrecy = { version = "0.10", features = ["serde"] }
secrecy = { version = "0.8.0", features = ["serde"] }
rcgen = { version = "0.13", features = ["pem"] }

# Serialization
Expand Down Expand Up @@ -86,7 +86,7 @@ features = ["pkcs8", "ecdsa", "alloc", "pem"]

[dev-dependencies]
sealed_test = "1.1.0"
sea-orm = { version = "1.1", features = ["mock"] }
sea-orm = { version = "0.12.15", features = ["mock"] }

[target.'cfg(not(target_env = "msvc"))'.dependencies]
tikv-jemallocator = "0.6"
Expand Down
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,25 @@ By default, the server will listen on `http://localhost:8000`. You can modify th
- `404 NOT FOUND`: Status list not found
- `406 NOT ACCEPTABLE`: Requested format not supported

### Status List Aggregation

- **Endpoint:** `GET /statuslists`
- **Description:** Returns a JSON object containing an array of all status list URIs managed by the server. This allows Relying Parties to discover and cache all available status lists for offline or batch validation.
- **Response Example:**

```json
{
"status_lists": [
"https://example.com/statuslists/1",
"https://example.com/statuslists/2",
"https://example.com/statuslists/3"
]
}
```
- **Content-Type:** `application/json`
- **Authentication:** Not required (public endpoint)


## Authentication

The server uses JWT-based authentication with the following requirements:
Expand Down
22 changes: 21 additions & 1 deletion architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,8 @@
"lst": "eyJhbGciOiJFUzI1NiIsImtpZCI6IjEyIiwidHlwIjoic3RhdHVzbGlzdCtqd3QifQ..."
},
"exp": 2291720170,
"ttl": 43200
"ttl": 43200,
"aggregation_uri": "https://statuslist.example.com/statuslists/aggregation"
}
```
- **Status List Token (CWT Example)**:
Expand All @@ -72,6 +73,25 @@
646269747301636c73744a78dadbb918000217015d5840251d844ecc6541b8b2fd24
e681836c1a072cad61716fb174d57b162b4b392c1ea08b875a493ca8d1cf4328eee1
b14f33aa899e532844778ba2fff80b5c1e56e5
aggregation_uri: "https://statuslist.example.com/statuslists/aggregation"
```

---

### **Status List Aggregation**

- The Status List Server exposes an aggregation endpoint at `/statuslists/aggregation`.
- This endpoint returns a JSON object with a `status_lists` array containing all status list URIs managed by the server.
- Each status list token (JWT or CWT) includes an `aggregation_uri` claim pointing to this endpoint, enabling Relying Parties to discover and cache all available status lists for offline or batch validation.
- Example response:

```json
{
"status_lists": [
"https://statuslist.example.com/statuslists/1",
"https://statuslist.example.com/statuslists/2"
]
}
```

## **5. Application Design**
Expand Down
10 changes: 8 additions & 2 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ pub struct ServerConfig {
pub domain: String,
pub port: u16,
pub cert: CertConfig,
pub aggregation_uri: Option<String>,
}

#[derive(Debug, Clone, Deserialize)]
Expand Down Expand Up @@ -82,7 +83,7 @@ impl RedisConfig {
root_cert: Option<&str>,
) -> RedisResult<ConnectionManager> {
let client = if !self.require_tls {
RedisClient::open(self.uri.expose_secret())?
RedisClient::open(self.uri.expose_secret().to_string())?
} else {
let client_tls = match (cert_pem, key_pem) {
(Some(cert), Some(key)) => Some(ClientTlsConfig {
Expand All @@ -94,7 +95,7 @@ impl RedisConfig {
let root_cert = root_cert.map(|cert| cert.as_bytes().to_vec());

RedisClient::build_with_tls(
self.uri.expose_secret(),
self.uri.expose_secret().clone(),
TlsCertificates {
client_tls,
root_cert,
Expand Down Expand Up @@ -178,6 +179,7 @@ mod tests {
("APP_REDIS__REQUIRE_TLS", "true"),
("APP_SERVER__CERT__EMAIL", "[email protected]"),
("APP_SERVER__CERT__ACME_DIRECTORY_URL", "https://acme-v02.api.letsencrypt.org/directory"),
("APP_SERVER__AGGREGATION_URI", "https://example.com/aggregation"),
])]
fn test_env_config() {
// Test configuration overrides via environment variables
Expand All @@ -199,5 +201,9 @@ mod tests {
config.server.cert.acme_directory_url,
"https://acme-v02.api.letsencrypt.org/directory"
);
assert_eq!(
config.server.aggregation_uri,
Some("https://example.com/aggregation".to_string())
);
}
}
7 changes: 7 additions & 0 deletions src/database/queries.rs
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,13 @@ impl SeaOrmStore<StatusListRecord> {
.await
.map_err(|e| RepositoryError::FindError(e.to_string()))
}

pub async fn find_all(&self) -> Result<Vec<StatusListRecord>, RepositoryError> {
status_lists::Entity::find()
.all(&*self.db)
.await
.map_err(|e| RepositoryError::FindError(e.to_string()))
}
}

impl SeaOrmStore<Credentials> {
Expand Down
11 changes: 4 additions & 7 deletions src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,13 +1,10 @@
mod database;
#[cfg(test)]
mod test_resources;
#[cfg(test)]
mod test_utils;
mod utils;

pub mod config;
pub mod database;
pub mod models;
pub mod startup;
pub mod test_resources;
pub mod test_utils;
pub mod utils;
pub mod web;

pub use utils::{cert_manager, state};
2 changes: 1 addition & 1 deletion src/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ impl sea_orm::sea_query::ValueType for Alg {
}

fn column_type() -> sea_orm::sea_query::ColumnType {
sea_orm::sea_query::ColumnType::String(sea_orm::sea_query::StringLen::N(255))
sea_orm::sea_query::ColumnType::Text
}
}

Expand Down
5 changes: 4 additions & 1 deletion src/startup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ use crate::{
utils::state::AppState,
web::{
auth::auth,
handlers::{credential_handler, get_status_list, publish_status, update_status},
handlers::{
aggregation, credential_handler, get_status_list, publish_status, update_status,
},
},
};

Expand Down Expand Up @@ -76,5 +78,6 @@ fn status_list_routes(state: AppState) -> Router<AppState> {

Router::new()
.merge(protected_routes)
.route("/", get(aggregation))
.route("/{list_id}", get(get_status_list))
}
7 changes: 5 additions & 2 deletions src/test_utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ use crate::{
},
};
use async_trait::async_trait;
use sea_orm::{DbBackend, MockDatabase};
use sea_orm::Database as MockDatabase;
use std::{collections::HashMap, sync::Arc};

pub struct MockStorage {
Expand Down Expand Up @@ -40,7 +40,9 @@ pub async fn test_app_state(db_conn: Option<Arc<sea_orm::DatabaseConnection>>) -
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();

let db = db_conn.unwrap_or(Arc::new(
MockDatabase::new(DbBackend::Postgres).into_connection(),
MockDatabase::connect("postgres://username:password@localhost/test_db")
.await
.unwrap(),
));

let key_pem = include_str!("test_resources/ec-private.pem").to_string();
Expand Down Expand Up @@ -69,5 +71,6 @@ pub async fn test_app_state(db_conn: Option<Arc<sea_orm::DatabaseConnection>>) -
server_domain: "example.com".to_string(),
cert_manager: Arc::new(certificate_manager),
cache: Cache::new(5 * 60, 100),
aggregation_uri: Some("https://example.com/aggregation".to_string()),
}
}
8 changes: 4 additions & 4 deletions src/utils/cert_manager/challenge/dns01.rs
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ impl AwsRoute53DnsUpdater {
change_action: ChangeAction,
value: &str,
) -> Result<String, ChallengeError> {
let record_name = format!("_acme-challenge.{}", domain);
let record_name = format!("_acme-challenge.{domain}");
let hosted_zone_id = self.find_hosted_zone(domain).await?;

// Prepare the TXT record to change
Expand All @@ -195,7 +195,7 @@ impl AwsRoute53DnsUpdater {
.ttl(60)
.resource_records(
ResourceRecord::builder()
.value(format!("\"{}\"", value))
.value(format!("\"{value}\""))
.build()
.map_err(|e| ChallengeError::AwsSdk(e.into()))?,
)
Expand Down Expand Up @@ -279,7 +279,7 @@ impl PebbleDnsUpdater {
#[async_trait]
impl DnsUpdater for PebbleDnsUpdater {
async fn upsert_record(&self, domain: &str, value: &str) -> Result<(), ChallengeError> {
let record_name = format!("_acme-challenge.{}.", domain);
let record_name = format!("_acme-challenge.{domain}.");
let url = format!("{}/set-txt", self.addr);
let body = json!({"host": record_name, "value": value});

Expand All @@ -296,7 +296,7 @@ impl DnsUpdater for PebbleDnsUpdater {
}

async fn remove_record(&self, domain: &str, _value: &str) -> Result<(), ChallengeError> {
let record_name = format!("_acme-challenge.{}.", domain);
let record_name = format!("_acme-challenge.{domain}.");
let url = format!("{}/clear-txt", self.addr);
let body = json!({"host": record_name});

Expand Down
2 changes: 2 additions & 0 deletions src/utils/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ pub struct AppState {
pub server_domain: String,
pub cert_manager: Arc<CertManager>,
pub cache: Cache,
pub aggregation_uri: Option<String>,
}

pub async fn build_state(config: &AppConfig) -> EyeResult<AppState> {
Expand Down Expand Up @@ -98,5 +99,6 @@ pub async fn build_state(config: &AppConfig) -> EyeResult<AppState> {
server_domain: config.server.domain.clone(),
cert_manager: Arc::new(certificate_manager),
cache: Cache::new(config.cache.ttl, config.cache.max_capacity),
aggregation_uri: config.server.aggregation_uri.clone(),
})
}
2 changes: 1 addition & 1 deletion src/web/auth/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -320,7 +320,7 @@ mod tests {

let request = Request::builder()
.uri("/test")
.header(header::AUTHORIZATION, format!("Bearer {}", token))
.header(header::AUTHORIZATION, format!("Bearer {token}"))
.body(Body::empty())
.unwrap();

Expand Down
3 changes: 2 additions & 1 deletion src/web/handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@ pub mod status_list;

pub use issue_credential::credential_handler;
pub use status_list::{
get_status_list::get_status_list, publish_status::publish_status, update_status::update_status,
aggregation::aggregation, get_status_list::get_status_list, publish_status::publish_status,
update_status::update_status,
};
84 changes: 84 additions & 0 deletions src/web/handlers/status_list/aggregation.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
use crate::utils::state::AppState;
use crate::web::handlers::status_list::error::StatusListError;
use axum::{extract::State, response::IntoResponse, Json};
use serde::{Deserialize, Serialize};

#[derive(Serialize, Deserialize)]
pub struct AggregationResponse {
pub status_lists: Vec<String>,
}

pub async fn aggregation(
State(state): State<AppState>,
) -> Result<impl IntoResponse, StatusListError> {
let records = state.status_list_repo.find_all().await.map_err(|e| {
tracing::error!("Failed to fetch all status lists: {:?}", e);
StatusListError::InternalServerError
})?;
let status_lists = records.into_iter().map(|rec| rec.sub).collect();
Ok(Json(AggregationResponse { status_lists }))
}

#[cfg(test)]
mod tests {
use super::*;
use crate::{
models::{status_lists, StatusList, StatusListRecord},
test_utils::test_app_state,
};
use axum::{body::to_bytes, http::StatusCode, Router};
use sea_orm::{DatabaseBackend, MockDatabase};
use std::sync::Arc;
use tower::ServiceExt; // for .oneshot()

#[tokio::test]
async fn test_aggregation_returns_all_status_list_uris() {
let status_list1 = StatusListRecord {
list_id: "list1".to_string(),
issuer: "issuer1".to_string(),
status_list: StatusList {
bits: 1,
lst: "foo".to_string(),
},
sub: "https://example.com/statuslists/list1".to_string(),
};
let status_list2 = StatusListRecord {
list_id: "list2".to_string(),
issuer: "issuer2".to_string(),
status_list: StatusList {
bits: 1,
lst: "bar".to_string(),
},
sub: "https://example.com/statuslists/list2".to_string(),
};
let mock_db = MockDatabase::new(DatabaseBackend::Postgres)
.append_query_results::<status_lists::Model, Vec<_>, _>(vec![vec![
status_list1.clone(),
status_list2.clone(),
]])
.into_connection();
let app_state = test_app_state(Some(Arc::new(mock_db))).await;
let app = Router::new()
.route("/aggregation", axum::routing::get(aggregation))
.with_state(app_state);
let response = app
.oneshot(
axum::http::Request::builder()
.uri("/aggregation")
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), 1024 * 1024).await.unwrap();
let result: AggregationResponse = serde_json::from_slice(&body).unwrap();
assert_eq!(result.status_lists.len(), 2);
assert!(result
.status_lists
.contains(&"https://example.com/statuslists/list1".to_string()));
assert!(result
.status_lists
.contains(&"https://example.com/statuslists/list2".to_string()));
}
}
Loading
Loading