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 11 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
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
6 changes: 6 additions & 0 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 @@ -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
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))
}
1 change: 1 addition & 0 deletions src/test_utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,5 +69,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()));
}
}
37 changes: 35 additions & 2 deletions src/web/handlers/status_list/get_status_list.rs
Original file line number Diff line number Diff line change
Expand Up @@ -131,8 +131,8 @@ async fn build_response_from_record(
};

let token_bytes = match accept {
ACCEPT_STATUS_LISTS_HEADER_CWT => issue_cwt(status_record, &keypair, certs_parts)?,
_ => issue_jwt(status_record, &keypair, certs_parts)?.into_bytes(),
ACCEPT_STATUS_LISTS_HEADER_CWT => issue_cwt(status_record, &keypair, certs_parts, state)?,
_ => issue_jwt(status_record, &keypair, certs_parts, state)?.into_bytes(),
};

Ok((
Expand All @@ -151,6 +151,7 @@ fn issue_cwt(
status_record: &StatusListRecord,
keypair: &Keypair,
cert_chain: Vec<String>,
state: &AppState,
) -> Result<Vec<u8>, StatusListError> {
let mut claims = vec![];

Expand Down Expand Up @@ -191,6 +192,13 @@ fn issue_cwt(
CborValue::Map(status_list),
));

if let Some(aggregation_uri) = &state.aggregation_uri {
claims.push((
CborValue::Text("aggregation_uri".into()),
CborValue::Text(aggregation_uri.clone()),
));
}

let payload = CborValue::Map(claims).to_vec().map_err(|err| {
tracing::error!("Failed to serialize claims: {err:?}");
StatusListError::InternalServerError
Expand Down Expand Up @@ -253,12 +261,15 @@ pub struct StatusListToken {
pub status_list: StatusList,
pub sub: String,
pub ttl: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub aggregation_uri: Option<String>,
}

fn issue_jwt(
status_record: &StatusListRecord,
keypair: &Keypair,
cert_chain: Vec<String>,
state: &AppState,
) -> Result<String, StatusListError> {
let iat = Utc::now().timestamp();
let ttl = TOKEN_TTL;
Expand All @@ -270,6 +281,7 @@ fn issue_jwt(
status_list: status_record.status_list.clone(),
sub: status_record.sub.to_owned(),
ttl: Some(ttl),
aggregation_uri: state.aggregation_uri.clone(),
};
// Building the header
let mut header = Header::new(jsonwebtoken::Algorithm::ES256);
Expand Down Expand Up @@ -382,6 +394,13 @@ mod tests {
token_data.claims.status_list.lst,
encode_compressed(&[0, 0, 0]).unwrap()
);
assert_eq!(
token_data.claims.aggregation_uri,
Some(format!(
"https://{}/statuslists/aggregation",
app_state.server_domain
))
);
}

#[tokio::test]
Expand Down Expand Up @@ -496,6 +515,20 @@ mod tests {
.1
.clone();
assert_eq!(ttl, CborValue::Integer(300.into()));

let aggregation_uri = claims
.iter()
.find(|(k, _)| k == &CborValue::Text("aggregation_uri".to_string()))
.unwrap()
.1
.clone();
assert_eq!(
aggregation_uri,
CborValue::Text(format!(
"https://{}/statuslists/aggregation",
app_state.server_domain
))
);
}

#[tokio::test]
Expand Down
1 change: 1 addition & 0 deletions src/web/handlers/status_list/mod.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
use crate::models::StatusEntry;
use serde::Deserialize;

pub(crate) mod aggregation;
pub(super) mod constants;
pub(super) mod error;
pub(crate) mod get_status_list;
Expand Down
Loading