diff --git a/Cargo.lock b/Cargo.lock index 10ebbca4..a4ff67e2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -63,6 +63,7 @@ name = "agent_api_rest" version = "0.1.0" dependencies = [ "agent_event_publisher_http", + "agent_holder", "agent_issuance", "agent_secret_manager", "agent_shared", @@ -73,7 +74,7 @@ dependencies = [ "axum-macros", "futures", "http-api-problem", - "hyper 1.3.1", + "hyper 1.4.1", "jsonwebtoken", "lazy_static", "mime", @@ -105,6 +106,7 @@ version = "0.1.0" dependencies = [ "agent_api_rest", "agent_event_publisher_http", + "agent_holder", "agent_issuance", "agent_secret_manager", "agent_shared", @@ -127,6 +129,7 @@ name = "agent_event_publisher_http" version = "0.1.0" dependencies = [ "agent_event_publisher_http", + "agent_holder", "agent_issuance", "agent_shared", "agent_store", @@ -146,15 +149,49 @@ dependencies = [ ] [[package]] -name = "agent_issuance" +name = "agent_holder" version = "0.1.0" dependencies = [ + "agent_api_rest", + "agent_holder", "agent_issuance", "agent_secret_manager", "agent_shared", + "agent_store", "async-std", "async-trait", "axum 0.7.5", + "cqrs-es", + "did_manager", + "jsonwebtoken", + "lazy_static", + "mime", + "names", + "oid4vc-core", + "oid4vci", + "rand 0.8.5", + "reqwest 0.12.5", + "rstest", + "serde", + "serde_json", + "serial_test", + "thiserror", + "tokio", + "tower", + "tracing", + "tracing-test", +] + +[[package]] +name = "agent_issuance" +version = "0.1.0" +dependencies = [ + "agent_holder", + "agent_issuance", + "agent_secret_manager", + "agent_shared", + "async-std", + "async-trait", "chrono", "cqrs-es", "derivative", @@ -168,6 +205,8 @@ dependencies = [ "oid4vc-core", "oid4vc-manager", "oid4vci", + "once_cell", + "reqwest 0.12.5", "rstest", "serde", "serde_json", @@ -244,6 +283,7 @@ dependencies = [ name = "agent_store" version = "0.1.0" dependencies = [ + "agent_holder", "agent_issuance", "agent_shared", "agent_verification", @@ -265,7 +305,6 @@ dependencies = [ "anyhow", "async-std", "async-trait", - "axum 0.7.5", "cqrs-es", "did_manager", "futures", @@ -723,7 +762,7 @@ dependencies = [ "http 1.1.0", "http-body 1.0.0", "http-body-util", - "hyper 1.3.1", + "hyper 1.4.1", "hyper-util", "itoa", "matchit", @@ -1084,9 +1123,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.6.0" +version = "1.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "514de17de45fdb8dc022b1a7975556c53c86f9f0aa5f534b98977b171857c2c9" +checksum = "8318a53db07bb3f8dca91a600466bdb3f2eaadeedfdbcf02e1accbad9271ba50" [[package]] name = "camino" @@ -2031,7 +2070,7 @@ dependencies = [ [[package]] name = "dif-presentation-exchange" version = "0.1.0" -source = "git+https://git@github.com/impierce/openid4vc.git?rev=12fed14#12fed1411ff3c0e1797090f386e44694f7a279b8" +source = "git+https://git@github.com/impierce/openid4vc.git?rev=23facd4#23facd4946e4b2e54ef87f42746c68ad952ae205" dependencies = [ "getset", "jsonpath_lib", @@ -3123,9 +3162,9 @@ dependencies = [ [[package]] name = "hyper" -version = "1.3.1" +version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe575dd17d0862a9a33781c8c4696a55c320909004a67a00fb286ba8b1bc496d" +checksum = "50dfd22e0e76d0f662d429a5f80fcaf3855009297eab6a0a9f8543834744ba05" dependencies = [ "bytes", "futures-channel", @@ -3164,7 +3203,7 @@ checksum = "5ee4be2c948921a1a5320b629c4193916ed787a7f7f293fd3f7f5a6c9de74155" dependencies = [ "futures-util", "http 1.1.0", - "hyper 1.3.1", + "hyper 1.4.1", "hyper-util", "rustls 0.23.10", "rustls-pki-types", @@ -3185,7 +3224,7 @@ dependencies = [ "futures-util", "http 1.1.0", "http-body 1.0.0", - "hyper 1.3.1", + "hyper 1.4.1", "pin-project-lite", "socket2 0.5.7", "tokio", @@ -4441,13 +4480,14 @@ dependencies = [ [[package]] name = "mio" -version = "0.8.11" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" +checksum = "80e04d1dcff3aae0704555fe5fee3bcfaf3d1fdf8a7e521d5b9d2b42acb52cec" dependencies = [ + "hermit-abi 0.3.9", "libc", "wasi 0.11.0+wasi-snapshot-preview1", - "windows-sys 0.48.0", + "windows-sys 0.52.0", ] [[package]] @@ -4499,6 +4539,15 @@ dependencies = [ "data-encoding-macro", ] +[[package]] +name = "names" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bddcd3bf5144b6392de80e04c347cd7fab2508f6df16a85fc496ecd5cec39bc" +dependencies = [ + "rand 0.8.5", +] + [[package]] name = "nix" version = "0.24.3" @@ -4678,7 +4727,7 @@ dependencies = [ [[package]] name = "oid4vc-core" version = "0.1.0" -source = "git+https://git@github.com/impierce/openid4vc.git?rev=12fed14#12fed1411ff3c0e1797090f386e44694f7a279b8" +source = "git+https://git@github.com/impierce/openid4vc.git?rev=23facd4#23facd4946e4b2e54ef87f42746c68ad952ae205" dependencies = [ "anyhow", "async-trait", @@ -4702,7 +4751,7 @@ dependencies = [ [[package]] name = "oid4vc-manager" version = "0.1.0" -source = "git+https://git@github.com/impierce/openid4vc.git?rev=12fed14#12fed1411ff3c0e1797090f386e44694f7a279b8" +source = "git+https://git@github.com/impierce/openid4vc.git?rev=23facd4#23facd4946e4b2e54ef87f42746c68ad952ae205" dependencies = [ "anyhow", "async-trait", @@ -4734,7 +4783,7 @@ dependencies = [ [[package]] name = "oid4vci" version = "0.1.0" -source = "git+https://git@github.com/impierce/openid4vc.git?rev=12fed14#12fed1411ff3c0e1797090f386e44694f7a279b8" +source = "git+https://git@github.com/impierce/openid4vc.git?rev=23facd4#23facd4946e4b2e54ef87f42746c68ad952ae205" dependencies = [ "anyhow", "derivative", @@ -4757,7 +4806,7 @@ dependencies = [ [[package]] name = "oid4vp" version = "0.1.0" -source = "git+https://git@github.com/impierce/openid4vc.git?rev=12fed14#12fed1411ff3c0e1797090f386e44694f7a279b8" +source = "git+https://git@github.com/impierce/openid4vc.git?rev=23facd4#23facd4946e4b2e54ef87f42746c68ad952ae205" dependencies = [ "anyhow", "chrono", @@ -5719,7 +5768,7 @@ dependencies = [ "http 1.1.0", "http-body 1.0.0", "http-body-util", - "hyper 1.3.1", + "hyper 1.4.1", "hyper-rustls 0.27.2", "hyper-util", "ipnet", @@ -5915,9 +5964,9 @@ dependencies = [ [[package]] name = "rstest" -version = "0.19.0" +version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d5316d2a1479eeef1ea21e7f9ddc67c191d497abc8fc3ba2467857abbb68330" +checksum = "7b423f0e62bdd61734b67cd21ff50871dfaeb9cc74f869dcd6af974fbcb19936" dependencies = [ "futures", "futures-timer", @@ -5927,12 +5976,13 @@ dependencies = [ [[package]] name = "rstest_macros" -version = "0.19.0" +version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04a9df72cc1f67020b0d63ad9bfe4a323e459ea7eb68e03bd9824db49f9a4c25" +checksum = "c5e1711e7d14f74b12a58411c542185ef7fb7f2e7f8ee6e2940a883628522b42" dependencies = [ "cfg-if", "glob", + "proc-macro-crate 3.1.0", "proc-macro2", "quote", "regex", @@ -6619,7 +6669,7 @@ dependencies = [ [[package]] name = "siopv2" version = "0.1.0" -source = "git+https://git@github.com/impierce/openid4vc.git?rev=12fed14#12fed1411ff3c0e1797090f386e44694f7a279b8" +source = "git+https://git@github.com/impierce/openid4vc.git?rev=23facd4#23facd4946e4b2e54ef87f42746c68ad952ae205" dependencies = [ "anyhow", "async-trait", @@ -7545,28 +7595,27 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.38.0" +version = "1.39.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba4f4a02a7a80d6f274636f0aa95c7e383b912d41fe721a31f29e29698585a4a" +checksum = "9babc99b9923bfa4804bd74722ff02c0381021eafa4db9949217e3be8e84fff5" dependencies = [ "backtrace", "bytes", "libc", "mio", - "num_cpus", "parking_lot 0.12.3", "pin-project-lite", "signal-hook-registry", "socket2 0.5.7", "tokio-macros", - "windows-sys 0.48.0", + "windows-sys 0.52.0", ] [[package]] name = "tokio-macros" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f5ae998a069d4b5aba8ee9dad856af7d520c3699e6159b185c2acd48155d39a" +checksum = "693d596312e88961bc67d7f1f97af8a70227d9f90c31bba5806eec004978d752" dependencies = [ "proc-macro2", "quote", @@ -8515,7 +8564,7 @@ dependencies = [ "futures", "http 1.1.0", "http-body-util", - "hyper 1.3.1", + "hyper 1.4.1", "hyper-util", "log", "once_cell", diff --git a/Cargo.toml b/Cargo.toml index b29a2252..c913ede1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ members = [ "agent_api_rest", "agent_application", "agent_event_publisher_http", + "agent_holder", "agent_issuance", "agent_secret_manager", "agent_shared", @@ -18,11 +19,11 @@ rust-version = "1.76.0" [workspace.dependencies] did_manager = { git = "https://git@github.com/impierce/did-manager.git", tag = "v1.0.0-beta.2" } -siopv2 = { git = "https://git@github.com/impierce/openid4vc.git", rev = "12fed14" } -oid4vci = { git = "https://git@github.com/impierce/openid4vc.git", rev = "12fed14" } -oid4vc-core = { git = "https://git@github.com/impierce/openid4vc.git", rev = "12fed14" } -oid4vc-manager = { git = "https://git@github.com/impierce/openid4vc.git", rev = "12fed14" } -oid4vp = { git = "https://git@github.com/impierce/openid4vc.git", rev = "12fed14" } +siopv2 = { git = "https://git@github.com/impierce/openid4vc.git", rev = "23facd4" } +oid4vci = { git = "https://git@github.com/impierce/openid4vc.git", rev = "23facd4" } +oid4vc-core = { git = "https://git@github.com/impierce/openid4vc.git", rev = "23facd4" } +oid4vc-manager = { git = "https://git@github.com/impierce/openid4vc.git", rev = "23facd4" } +oid4vp = { git = "https://git@github.com/impierce/openid4vc.git", rev = "23facd4" } async-trait = "0.1" axum = { version = "0.7", features = ["tracing"] } @@ -39,13 +40,17 @@ identity_iota = { version = "1.3" } identity_verification = { version = "1.3", default-features = false } jsonwebtoken = "9.3" lazy_static = "1.4" -rstest = "0.19" +mime = { version = "0.3" } +once_cell = { version = "1.19" } +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +rstest = "0.22" serde = { version = "1.0", default-features = false, features = ["derive"] } serde_json = { version = "1.0" } serde_with = "3.7" serde_yaml = "0.9" thiserror = "1.0" tokio = { version = "1", features = ["full"] } +tower = { version = "0.4" } tower-http = { version = "0.5", features = ["cors", "trace"] } tracing = { version = "0.1" } tracing-subscriber = { version = "0.3", features = ["json", "env-filter"] } diff --git a/agent_api_rest/Cargo.toml b/agent_api_rest/Cargo.toml index 88210ade..73997c65 100644 --- a/agent_api_rest/Cargo.toml +++ b/agent_api_rest/Cargo.toml @@ -5,6 +5,7 @@ edition.workspace = true rust-version.workspace = true [dependencies] +agent_holder = { path = "../agent_holder" } agent_issuance = { path = "../agent_issuance" } agent_shared = { path = "../agent_shared" } agent_verification = { path = "../agent_verification" } @@ -24,12 +25,14 @@ tokio.workspace = true tower-http.workspace = true tracing.workspace = true tracing-subscriber.workspace = true +url.workspace = true uuid.workspace = true [dev-dependencies] agent_event_publisher_http = { path = "../agent_event_publisher_http", features = ["test_utils"] } +agent_holder = { path = "../agent_holder", features = ["test_utils"] } agent_issuance = { path = "../agent_issuance", features = ["test_utils"] } -agent_secret_manager = { path = "../agent_secret_manager" } +agent_secret_manager = { path = "../agent_secret_manager", features = ["test_utils"] } agent_shared = { path = "../agent_shared", features = ["test_utils"] } agent_store = { path = "../agent_store" } agent_verification = { path = "../agent_verification", features = ["test_utils"] } @@ -37,14 +40,13 @@ agent_verification = { path = "../agent_verification", features = ["test_utils"] futures.workspace = true jsonwebtoken.workspace = true lazy_static.workspace = true -mime = { version = "0.3" } +mime.workspace = true oid4vc-core.workspace = true oid4vc-manager.workspace = true rstest.workspace = true serde_urlencoded = "0.7" serde_yaml.workspace = true serial_test = "3.0" -tower = { version = "0.4" } +tower.workspace = true tracing-test.workspace = true -url.workspace = true wiremock.workspace = true diff --git a/agent_api_rest/postman/ssi-agent.postman_collection.json b/agent_api_rest/postman/ssi-agent.postman_collection.json index b0fbd6ba..924b87ad 100644 --- a/agent_api_rest/postman/ssi-agent.postman_collection.json +++ b/agent_api_rest/postman/ssi-agent.postman_collection.json @@ -70,6 +70,13 @@ "type": "text/javascript", "packages": {} } + }, + { + "listen": "prerequest", + "script": { + "exec": [], + "type": "text/javascript" + } } ], "request": { @@ -136,6 +143,13 @@ "type": "text/javascript", "packages": {} } + }, + { + "listen": "prerequest", + "script": { + "exec": [], + "type": "text/javascript" + } } ], "request": { @@ -162,6 +176,34 @@ } }, "response": [] + }, + { + "name": "offers_send", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"offerId\": \"{{OFFER_ID}}\",\n \"targetUrl\": \"{{HOST}}/openid4vci/offers\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{HOST}}/v0/offers/send", + "host": [ + "{{HOST}}" + ], + "path": [ + "v0", + "offers", + "send" + ] + } + }, + "response": [] } ] }, @@ -579,6 +621,118 @@ "response": [] } ] + }, + { + "name": "Holder", + "item": [ + { + "name": "offers", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "const jsonData = JSON.parse(responseBody);", + "", + "if (jsonData && typeof jsonData === 'object') {", + " const receivedOfferId = Object.keys(jsonData)[0];", + "", + " if (receivedOfferId) {", + " pm.collectionVariables.set(\"RECEIVED_OFFER_ID\", receivedOfferId);", + " }", + "}" + ], + "type": "text/javascript", + "packages": {} + } + }, + { + "listen": "prerequest", + "script": { + "exec": [], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{HOST}}/v0/holder/offers", + "host": [ + "{{HOST}}" + ], + "path": [ + "v0", + "holder", + "offers" + ] + } + }, + "response": [] + }, + { + "name": "credentials", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{HOST}}/v0/holder/credentials", + "host": [ + "{{HOST}}" + ], + "path": [ + "v0", + "holder", + "credentials" + ] + } + }, + "response": [] + }, + { + "name": "offers_accept", + "request": { + "method": "POST", + "header": [], + "url": { + "raw": "{{HOST}}/v0/holder/offers/{{RECEIVED_OFFER_ID}}/accept", + "host": [ + "{{HOST}}" + ], + "path": [ + "v0", + "holder", + "offers", + "{{RECEIVED_OFFER_ID}}", + "accept" + ] + } + }, + "response": [] + }, + { + "name": "offers_reject", + "request": { + "method": "POST", + "header": [], + "url": { + "raw": "{{HOST}}/v0/holder/offers/{{RECEIVED_OFFER_ID}}/reject", + "host": [ + "{{HOST}}" + ], + "path": [ + "v0", + "holder", + "offers", + "{{RECEIVED_OFFER_ID}}", + "reject" + ] + } + }, + "response": [] + } + ] } ], "event": [ @@ -641,6 +795,11 @@ "key": "REQUEST_URI", "value": "INITIAL_VALUE", "type": "string" + }, + { + "key": "RECEIVED_OFFER_ID", + "value": "INITIAL_VALUE", + "type": "string" } ] } \ No newline at end of file diff --git a/agent_api_rest/src/holder/holder/credentials/mod.rs b/agent_api_rest/src/holder/holder/credentials/mod.rs new file mode 100644 index 00000000..5e91880c --- /dev/null +++ b/agent_api_rest/src/holder/holder/credentials/mod.rs @@ -0,0 +1,18 @@ +use agent_holder::state::HolderState; +use agent_shared::handlers::query_handler; +use axum::{ + extract::State, + response::{IntoResponse, Response}, + Json, +}; +use hyper::StatusCode; +use serde_json::json; + +#[axum_macros::debug_handler] +pub(crate) async fn credentials(State(state): State) -> Response { + match query_handler("all_credentials", &state.query.all_credentials).await { + Ok(Some(offer_view)) => (StatusCode::OK, Json(offer_view)).into_response(), + Ok(None) => (StatusCode::OK, Json(json!({}))).into_response(), + _ => StatusCode::INTERNAL_SERVER_ERROR.into_response(), + } +} diff --git a/agent_api_rest/src/holder/holder/mod.rs b/agent_api_rest/src/holder/holder/mod.rs new file mode 100644 index 00000000..1a09baa0 --- /dev/null +++ b/agent_api_rest/src/holder/holder/mod.rs @@ -0,0 +1,2 @@ +pub mod credentials; +pub mod offers; diff --git a/agent_api_rest/src/holder/holder/offers/accept.rs b/agent_api_rest/src/holder/holder/offers/accept.rs new file mode 100644 index 00000000..0cf1a0bb --- /dev/null +++ b/agent_api_rest/src/holder/holder/offers/accept.rs @@ -0,0 +1,73 @@ +use agent_holder::{ + credential::command::CredentialCommand, + offer::{command::OfferCommand, queries::OfferView}, + state::HolderState, +}; +use agent_shared::handlers::{command_handler, query_handler}; +use axum::{ + extract::{Path, State}, + response::{IntoResponse, Response}, +}; +use hyper::StatusCode; + +#[axum_macros::debug_handler] +pub(crate) async fn accept(State(state): State, Path(offer_id): Path) -> Response { + // TODO: General note that also applies to other endpoints. Currently we are using Application Layer logic in the + // REST API. This is not ideal and should be changed. The REST API should only be responsible for handling HTTP + // Requests and Responses. + // Furthermore, the to be implemented Application Layer should be kept very thin as well. See: https://github.com/impierce/ssi-agent/issues/114 + + // Accept the Credential Offer if it exists + match query_handler(&offer_id, &state.query.offer).await { + Ok(Some(OfferView { .. })) => { + let command = OfferCommand::AcceptCredentialOffer { + offer_id: offer_id.clone(), + }; + + if command_handler(&offer_id, &state.command.offer, command).await.is_err() { + // TODO: add better Error responses. This needs to be done properly in all endpoints once + // https://github.com/impierce/openid4vc/issues/78 is fixed. + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); + } + } + Ok(None) => return StatusCode::NOT_FOUND.into_response(), + _ => return StatusCode::INTERNAL_SERVER_ERROR.into_response(), + } + + let command = OfferCommand::SendCredentialRequest { + offer_id: offer_id.clone(), + }; + + // Send the Credential Request + if command_handler(&offer_id, &state.command.offer, command).await.is_err() { + // TODO: add better Error responses. This needs to be done properly in all endpoints once + // https://github.com/impierce/openid4vc/issues/78 is fixed. + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); + } + + let credentials = match query_handler(&offer_id, &state.query.offer).await { + Ok(Some(OfferView { credentials, .. })) => credentials, + _ => return StatusCode::INTERNAL_SERVER_ERROR.into_response(), + }; + + for credential in credentials { + let credential_id = uuid::Uuid::new_v4().to_string(); + + let command = CredentialCommand::AddCredential { + credential_id: credential_id.clone(), + offer_id: offer_id.clone(), + credential, + }; + + // Add the Credential to the state. + if command_handler(&credential_id, &state.command.credential, command) + .await + .is_err() + { + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); + } + } + + // TODO: What do we return here? + StatusCode::OK.into_response() +} diff --git a/agent_api_rest/src/holder/holder/offers/mod.rs b/agent_api_rest/src/holder/holder/offers/mod.rs new file mode 100644 index 00000000..c513aecd --- /dev/null +++ b/agent_api_rest/src/holder/holder/offers/mod.rs @@ -0,0 +1,21 @@ +pub mod accept; +pub mod reject; + +use agent_holder::state::HolderState; +use agent_shared::handlers::query_handler; +use axum::{ + extract::State, + response::{IntoResponse, Response}, + Json, +}; +use hyper::StatusCode; +use serde_json::json; + +#[axum_macros::debug_handler] +pub(crate) async fn offers(State(state): State) -> Response { + match query_handler("all_offers", &state.query.all_offers).await { + Ok(Some(offer_view)) => (StatusCode::OK, Json(offer_view)).into_response(), + Ok(None) => (StatusCode::OK, Json(json!({}))).into_response(), + _ => StatusCode::INTERNAL_SERVER_ERROR.into_response(), + } +} diff --git a/agent_api_rest/src/holder/holder/offers/reject.rs b/agent_api_rest/src/holder/holder/offers/reject.rs new file mode 100644 index 00000000..eb0ffe17 --- /dev/null +++ b/agent_api_rest/src/holder/holder/offers/reject.rs @@ -0,0 +1,24 @@ +use agent_holder::{offer::command::OfferCommand, state::HolderState}; +use agent_shared::handlers::command_handler; +use axum::{ + extract::{Path, State}, + response::{IntoResponse, Response}, +}; +use hyper::StatusCode; + +#[axum_macros::debug_handler] +pub(crate) async fn reject(State(state): State, Path(offer_id): Path) -> Response { + let command = OfferCommand::RejectCredentialOffer { + offer_id: offer_id.clone(), + }; + + // Remove the Credential Offer from the state. + if command_handler(&offer_id, &state.command.offer, command).await.is_err() { + // TODO: add better Error responses. This needs to be done properly in all endpoints once + // https://github.com/impierce/openid4vc/issues/78 is fixed. + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); + } + + // TODO: What do we return here? + StatusCode::OK.into_response() +} diff --git a/agent_api_rest/src/holder/mod.rs b/agent_api_rest/src/holder/mod.rs new file mode 100644 index 00000000..7ea56ad7 --- /dev/null +++ b/agent_api_rest/src/holder/mod.rs @@ -0,0 +1,27 @@ +// TODO: further refactor the API's folder structure to reflect the API's routes. +#[allow(clippy::module_inception)] +pub mod holder; +pub mod openid4vci; + +use crate::holder::holder::{ + credentials::credentials, + offers::{accept::accept, reject::reject, *}, +}; +use crate::API_VERSION; +use agent_holder::state::HolderState; +use axum::routing::get; +use axum::{routing::post, Router}; + +pub fn router(holder_state: HolderState) -> Router { + Router::new() + .nest( + API_VERSION, + Router::new() + .route("/holder/credentials", get(credentials)) + .route("/holder/offers", get(offers)) + .route("/holder/offers/:offer_id/accept", post(accept)) + .route("/holder/offers/:offer_id/reject", post(reject)), + ) + .route("/openid4vci/offers", get(openid4vci::offers)) + .with_state(holder_state) +} diff --git a/agent_api_rest/src/holder/openid4vci/mod.rs b/agent_api_rest/src/holder/openid4vci/mod.rs new file mode 100644 index 00000000..95145b61 --- /dev/null +++ b/agent_api_rest/src/holder/openid4vci/mod.rs @@ -0,0 +1,41 @@ +use agent_holder::{offer::command::OfferCommand, state::HolderState}; +use agent_shared::handlers::command_handler; +use axum::{ + extract::State, + response::{IntoResponse, Response}, + Json, +}; +use hyper::StatusCode; +use oid4vci::credential_offer::CredentialOffer; +use serde::{Deserialize, Serialize}; +use tracing::info; + +#[derive(Deserialize, Serialize)] +pub struct Oid4vciOfferEndpointRequest { + #[serde(flatten)] + pub credential_offer: CredentialOffer, +} + +#[axum_macros::debug_handler] +pub(crate) async fn offers(State(state): State, Json(payload): Json) -> Response { + info!("Request Body: {}", payload); + + let Ok(Oid4vciOfferEndpointRequest { credential_offer }) = serde_json::from_value(payload) else { + return (StatusCode::BAD_REQUEST, "invalid payload").into_response(); + }; + + let offer_id = uuid::Uuid::new_v4().to_string(); + + let command = OfferCommand::ReceiveCredentialOffer { + offer_id: offer_id.clone(), + credential_offer, + }; + + // Add the Credential Offer to the state. + match command_handler(&offer_id, &state.command.offer, command).await { + Ok(_) => StatusCode::OK.into_response(), + // TODO: add better Error responses. This needs to be done properly in all endpoints once + // https://github.com/impierce/openid4vc/issues/78 is fixed. + Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(), + } +} diff --git a/agent_api_rest/src/issuance/credential_issuer/credential.rs b/agent_api_rest/src/issuance/credential_issuer/credential.rs index 9cda7ee3..c0d43200 100644 --- a/agent_api_rest/src/issuance/credential_issuer/credential.rs +++ b/agent_api_rest/src/issuance/credential_issuer/credential.rs @@ -48,7 +48,10 @@ pub(crate) async fn credential( Ok(Some(ServerConfigView { credential_issuer_metadata: Some(credential_issuer_metadata), authorization_server_metadata, - })) => (credential_issuer_metadata, Box::new(authorization_server_metadata)), + })) => ( + Box::new(credential_issuer_metadata), + Box::new(authorization_server_metadata), + ), _ => return StatusCode::INTERNAL_SERVER_ERROR.into_response(), }; @@ -141,25 +144,21 @@ pub(crate) async fn credential( #[cfg(test)] mod tests { - use std::sync::Arc; - + use super::*; + use crate::issuance::credentials::tests::credentials; + use crate::issuance::router; + use crate::API_VERSION; use crate::{ - app, issuance::{ credential_issuer::token::tests::token, credentials::CredentialsEndpointRequest, offers::tests::offers, }, tests::{BASE_URL, CREDENTIAL_CONFIGURATION_ID, OFFER_ID}, }; - - use super::*; - use crate::issuance::credentials::tests::credentials; - use crate::API_VERSION; use agent_event_publisher_http::EventPublisherHttp; - use agent_issuance::services::test_utils::test_issuance_services; use agent_issuance::{offer::event::OfferEvent, startup_commands::startup_commands, state::initialize}; + use agent_secret_manager::service::Service; use agent_shared::config::{set_config, Events}; use agent_store::{in_memory, EventPublisher}; - use agent_verification::services::test_utils::test_verification_services; use axum::{ body::Body, http::{self, Request}, @@ -167,6 +166,7 @@ mod tests { }; use rstest::rstest; use serde_json::{json, Value}; + use std::sync::Arc; use tokio::sync::Mutex; use tower::ServiceExt; use wiremock::{ @@ -277,7 +277,7 @@ mod tests { #[case] is_self_signed: bool, #[case] delay: u64, ) { - let (external_server, issuance_event_publishers, verification_event_publishers) = if with_external_server { + let (external_server, issuance_event_publishers) = if with_external_server { let external_server = MockServer::start().await; let target_url = format!("{}/ssi-events-subscriber", &external_server.uri()); @@ -292,18 +292,15 @@ mod tests { ( Some(external_server), vec![Box::new(EventPublisherHttp::load().unwrap()) as Box], - vec![Box::new(EventPublisherHttp::load().unwrap()) as Box], ) } else { - (None, Default::default(), Default::default()) + (None, Default::default()) }; - let issuance_state = in_memory::issuance_state(test_issuance_services(), issuance_event_publishers).await; - let verification_state = - in_memory::verification_state(test_verification_services(), verification_event_publishers).await; + let issuance_state = in_memory::issuance_state(Service::default(), issuance_event_publishers).await; initialize(&issuance_state, startup_commands(BASE_URL.clone())).await; - let mut app = app((issuance_state, verification_state)); + let mut app = router(issuance_state); if let Some(external_server) = &external_server { external_server diff --git a/agent_api_rest/src/issuance/credential_issuer/token.rs b/agent_api_rest/src/issuance/credential_issuer/token.rs index 91ad4279..72728ad5 100644 --- a/agent_api_rest/src/issuance/credential_issuer/token.rs +++ b/agent_api_rest/src/issuance/credential_issuer/token.rs @@ -60,25 +60,21 @@ pub(crate) async fn token( #[cfg(test)] pub mod tests { + use super::*; use crate::{ - app, - issuance::{credentials::tests::credentials, offers::tests::offers}, + issuance::{credentials::tests::credentials, offers::tests::offers, router}, tests::BASE_URL, }; - - use super::*; - use agent_issuance::{ - services::test_utils::test_issuance_services, startup_commands::startup_commands, state::initialize, - }; + use agent_issuance::{startup_commands::startup_commands, state::initialize}; + use agent_secret_manager::service::Service; use agent_store::in_memory; - use agent_verification::services::test_utils::test_verification_services; use axum::{ body::Body, http::{self, Request}, Router, }; use oid4vci::token_response::TokenResponse; - use tower::Service; + use tower::Service as _; pub async fn token(app: &mut Router, pre_authorized_code: String) -> String { let response = app @@ -112,11 +108,10 @@ pub mod tests { #[tokio::test] async fn test_token_endpoint() { - let issuance_state = in_memory::issuance_state(test_issuance_services(), Default::default()).await; - let verification_state = in_memory::verification_state(test_verification_services(), Default::default()).await; + let issuance_state = in_memory::issuance_state(Service::default(), Default::default()).await; initialize(&issuance_state, startup_commands(BASE_URL.clone())).await; - let mut app = app((issuance_state, verification_state)); + let mut app = router(issuance_state); credentials(&mut app).await; let pre_authorized_code = offers(&mut app).await; diff --git a/agent_api_rest/src/issuance/credential_issuer/well_known/oauth_authorization_server.rs b/agent_api_rest/src/issuance/credential_issuer/well_known/oauth_authorization_server.rs index c690064d..73ab36c3 100644 --- a/agent_api_rest/src/issuance/credential_issuer/well_known/oauth_authorization_server.rs +++ b/agent_api_rest/src/issuance/credential_issuer/well_known/oauth_authorization_server.rs @@ -23,21 +23,18 @@ pub(crate) async fn oauth_authorization_server(State(state): State AuthorizationServerMetadata { let response = app @@ -72,11 +69,10 @@ mod tests { #[tokio::test] async fn test_oauth_authorization_server_endpoint() { - let issuance_state = in_memory::issuance_state(test_issuance_services(), Default::default()).await; - let verification_state = in_memory::verification_state(test_verification_services(), Default::default()).await; + let issuance_state = in_memory::issuance_state(Service::default(), Default::default()).await; initialize(&issuance_state, startup_commands(BASE_URL.clone())).await; - let mut app = app((issuance_state, verification_state)); + let mut app = router(issuance_state); let _authorization_server_metadata = oauth_authorization_server(&mut app).await; } diff --git a/agent_api_rest/src/issuance/credential_issuer/well_known/openid_credential_issuer.rs b/agent_api_rest/src/issuance/credential_issuer/well_known/openid_credential_issuer.rs index 2f93a878..92a9c3dd 100644 --- a/agent_api_rest/src/issuance/credential_issuer/well_known/openid_credential_issuer.rs +++ b/agent_api_rest/src/issuance/credential_issuer/well_known/openid_credential_issuer.rs @@ -23,17 +23,12 @@ pub(crate) async fn openid_credential_issuer(State(state): State) #[cfg(test)] mod tests { - use std::collections::HashMap; - - use crate::{app, tests::BASE_URL}; - use super::*; - use agent_issuance::{ - services::test_utils::test_issuance_services, startup_commands::startup_commands, state::initialize, - }; + use crate::{issuance::router, tests::BASE_URL}; + use agent_issuance::{startup_commands::startup_commands, state::initialize}; + use agent_secret_manager::service::Service; use agent_shared::UrlAppendHelpers; use agent_store::in_memory; - use agent_verification::services::test_utils::test_verification_services; use axum::{ body::Body, http::{self, Request}, @@ -52,7 +47,8 @@ mod tests { ProofType, }; use serde_json::json; - use tower::Service; + use std::collections::HashMap; + use tower::Service as _; pub async fn openid_credential_issuer(app: &mut Router) -> CredentialIssuerMetadata { let response = app @@ -133,11 +129,10 @@ mod tests { #[tokio::test] async fn test_openid_credential_issuer_endpoint() { - let issuance_state = in_memory::issuance_state(test_issuance_services(), Default::default()).await; - let verification_state = in_memory::verification_state(test_verification_services(), Default::default()).await; + let issuance_state = in_memory::issuance_state(Service::default(), Default::default()).await; initialize(&issuance_state, startup_commands(BASE_URL.clone())).await; - let mut app = app((issuance_state, verification_state)); + let mut app = router(issuance_state); let _credential_issuer_metadata = openid_credential_issuer(&mut app).await; } diff --git a/agent_api_rest/src/issuance/credentials.rs b/agent_api_rest/src/issuance/credentials.rs index 2689f27c..f8ea8a11 100644 --- a/agent_api_rest/src/issuance/credentials.rs +++ b/agent_api_rest/src/issuance/credentials.rs @@ -106,6 +106,15 @@ pub(crate) async fn credentials( return StatusCode::INTERNAL_SERVER_ERROR.into_response(); } + // Get the `CredentialIssuerMetadata` from the `ServerConfigView`. + let credential_issuer_metadata = match query_handler(SERVER_CONFIG_ID, &state.query.server_config).await { + Ok(Some(ServerConfigView { + credential_issuer_metadata: Some(credential_issuer_metadata), + .. + })) => Box::new(credential_issuer_metadata), + _ => return StatusCode::INTERNAL_SERVER_ERROR.into_response(), + }; + // Create an offer if it does not exist yet. match query_handler(&offer_id, &state.query.offer).await { Ok(Some(_)) => {} @@ -115,6 +124,7 @@ pub(crate) async fn credentials( &state.command.offer, OfferCommand::CreateCredentialOffer { offer_id: offer_id.clone(), + credential_issuer_metadata, }, ) .await @@ -153,15 +163,12 @@ pub(crate) async fn credentials( #[cfg(test)] pub mod tests { use super::*; + use crate::issuance::router; + use crate::tests::{BASE_URL, CREDENTIAL_CONFIGURATION_ID, OFFER_ID}; use crate::API_VERSION; - use crate::{ - app, - tests::{BASE_URL, CREDENTIAL_CONFIGURATION_ID, OFFER_ID}, - }; - use agent_issuance::services::test_utils::test_issuance_services; use agent_issuance::{startup_commands::startup_commands, state::initialize}; + use agent_secret_manager::service::Service; use agent_store::in_memory; - use agent_verification::services::test_utils::test_verification_services; use axum::{ body::Body, http::{self, Request}, @@ -169,7 +176,7 @@ pub mod tests { }; use lazy_static::lazy_static; use serde_json::json; - use tower::Service; + use tower::Service as _; lazy_static! { pub static ref CREDENTIAL_SUBJECT: serde_json::Value = json!({ @@ -253,12 +260,10 @@ pub mod tests { #[tokio::test] #[tracing_test::traced_test] async fn test_credentials_endpoint() { - let issuance_state = in_memory::issuance_state(test_issuance_services(), Default::default()).await; - let verification_state = in_memory::verification_state(test_verification_services(), Default::default()).await; + let issuance_state = in_memory::issuance_state(Service::default(), Default::default()).await; initialize(&issuance_state, startup_commands(BASE_URL.clone())).await; - let mut app = app((issuance_state, verification_state)); - + let mut app = router(issuance_state); credentials(&mut app).await; } } diff --git a/agent_api_rest/src/issuance/mod.rs b/agent_api_rest/src/issuance/mod.rs index 954f1c40..0bf4064e 100644 --- a/agent_api_rest/src/issuance/mod.rs +++ b/agent_api_rest/src/issuance/mod.rs @@ -1,3 +1,37 @@ pub mod credential_issuer; pub mod credentials; pub mod offers; + +use agent_issuance::state::IssuanceState; +use axum::routing::get; +use axum::{routing::post, Router}; + +use crate::issuance::{ + credential_issuer::{ + credential::credential, token::token, well_known::oauth_authorization_server::oauth_authorization_server, + well_known::openid_credential_issuer::openid_credential_issuer, + }, + credentials::{credentials, get_credentials}, + offers::{offers, send::send}, +}; +use crate::API_VERSION; + +pub fn router(issuance_state: IssuanceState) -> Router { + Router::new() + .nest( + API_VERSION, + Router::new() + .route("/credentials", post(credentials)) + .route("/credentials/:credential_id", get(get_credentials)) + .route("/offers", post(offers)) + .route("/offers/send", post(send)), + ) + .route( + "/.well-known/oauth-authorization-server", + get(oauth_authorization_server), + ) + .route("/.well-known/openid-credential-issuer", get(openid_credential_issuer)) + .route("/auth/token", post(token)) + .route("/openid4vci/credential", post(credential)) + .with_state(issuance_state) +} diff --git a/agent_api_rest/src/issuance/offers.rs b/agent_api_rest/src/issuance/offers/mod.rs similarity index 87% rename from agent_api_rest/src/issuance/offers.rs rename to agent_api_rest/src/issuance/offers/mod.rs index be3ea8d7..06900dfb 100644 --- a/agent_api_rest/src/issuance/offers.rs +++ b/agent_api_rest/src/issuance/offers/mod.rs @@ -1,3 +1,5 @@ +pub mod send; + use agent_issuance::{ offer::{command::OfferCommand, queries::OfferView}, server_config::queries::ServerConfigView, @@ -28,6 +30,15 @@ pub(crate) async fn offers(State(state): State, Json(payload): Js return (StatusCode::BAD_REQUEST, "invalid payload").into_response(); }; + // Get the `CredentialIssuerMetadata` from the `ServerConfigView`. + let credential_issuer_metadata = match query_handler(SERVER_CONFIG_ID, &state.query.server_config).await { + Ok(Some(ServerConfigView { + credential_issuer_metadata: Some(credential_issuer_metadata), + .. + })) => Box::new(credential_issuer_metadata), + _ => return StatusCode::INTERNAL_SERVER_ERROR.into_response(), + }; + // Create an offer if it does not exist yet. match query_handler(&offer_id, &state.query.offer).await { Ok(Some(_)) => {} @@ -37,6 +48,7 @@ pub(crate) async fn offers(State(state): State, Json(payload): Js &state.command.offer, OfferCommand::CreateCredentialOffer { offer_id: offer_id.clone(), + credential_issuer_metadata, }, ) .await @@ -47,18 +59,8 @@ pub(crate) async fn offers(State(state): State, Json(payload): Js } }; - // Get the `CredentialIssuerMetadata` from the `ServerConfigView`. - let credential_issuer_metadata = match query_handler(SERVER_CONFIG_ID, &state.query.server_config).await { - Ok(Some(ServerConfigView { - credential_issuer_metadata: Some(credential_issuer_metadata), - .. - })) => credential_issuer_metadata, - _ => return StatusCode::INTERNAL_SERVER_ERROR.into_response(), - }; - let command = OfferCommand::CreateFormUrlEncodedCredentialOffer { offer_id: offer_id.clone(), - credential_issuer_metadata, }; if command_handler(&offer_id, &state.command.offer, command).await.is_err() { @@ -81,21 +83,15 @@ pub(crate) async fn offers(State(state): State, Json(payload): Js #[cfg(test)] pub mod tests { - use std::str::FromStr; - - use crate::{ - app, - issuance::credentials::tests::credentials, - tests::{BASE_URL, OFFER_ID}, - }; - use super::*; use crate::API_VERSION; - use agent_issuance::{ - services::test_utils::test_issuance_services, startup_commands::startup_commands, state::initialize, + use crate::{ + issuance::{credentials::tests::credentials, router}, + tests::{BASE_URL, OFFER_ID}, }; + use agent_issuance::{startup_commands::startup_commands, state::initialize}; + use agent_secret_manager::service::Service; use agent_store::in_memory; - use agent_verification::services::test_utils::test_verification_services; use axum::{ body::Body, http::{self, Request}, @@ -103,7 +99,8 @@ pub mod tests { }; use oid4vci::credential_offer::{CredentialOffer, CredentialOfferParameters, Grants, PreAuthorizedCode}; use serde_json::json; - use tower::Service; + use std::str::FromStr; + use tower::Service as _; pub async fn offers(app: &mut Router) -> String { let response = app @@ -156,13 +153,10 @@ pub mod tests { #[tokio::test] #[tracing_test::traced_test] async fn test_offers_endpoint() { - let issuance_state = in_memory::issuance_state(test_issuance_services(), Default::default()).await; - - let verification_state = in_memory::verification_state(test_verification_services(), Default::default()).await; - + let issuance_state = in_memory::issuance_state(Service::default(), Default::default()).await; initialize(&issuance_state, startup_commands(BASE_URL.clone())).await; - let mut app = app((issuance_state, verification_state)); + let mut app = router(issuance_state); credentials(&mut app).await; let _pre_authorized_code = offers(&mut app).await; diff --git a/agent_api_rest/src/issuance/offers/send.rs b/agent_api_rest/src/issuance/offers/send.rs new file mode 100644 index 00000000..2e9a973a --- /dev/null +++ b/agent_api_rest/src/issuance/offers/send.rs @@ -0,0 +1,40 @@ +use agent_issuance::{offer::command::OfferCommand, state::IssuanceState}; +use agent_shared::handlers::command_handler; +use axum::{ + extract::State, + response::{IntoResponse, Response}, + Json, +}; +use hyper::StatusCode; +use serde::{Deserialize, Serialize}; +use tracing::info; +use url::Url; + +#[derive(Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SendOfferEndpointRequest { + pub offer_id: String, + pub target_url: Url, +} + +#[axum_macros::debug_handler] +pub(crate) async fn send(State(state): State, Json(payload): Json) -> Response { + info!("Request Body: {}", payload); + + let Ok(SendOfferEndpointRequest { offer_id, target_url }) = serde_json::from_value(payload) else { + return (StatusCode::BAD_REQUEST, "invalid payload").into_response(); + }; + + let command = OfferCommand::SendCredentialOffer { + offer_id: offer_id.clone(), + target_url, + }; + + // Send the Credential Offer to the `target_url`. + match command_handler(&offer_id, &state.command.offer, command).await { + Ok(_) => StatusCode::OK.into_response(), + // TODO: add better Error responses. This needs to be done properly in all endpoints once + // https://github.com/impierce/openid4vc/issues/78 is fixed. + Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(), + } +} diff --git a/agent_api_rest/src/lib.rs b/agent_api_rest/src/lib.rs index d2d72f1d..b282b706 100644 --- a/agent_api_rest/src/lib.rs +++ b/agent_api_rest/src/lib.rs @@ -1,77 +1,39 @@ -mod issuance; -mod verification; +pub mod holder; +pub mod issuance; +pub mod verification; +use agent_holder::state::HolderState; use agent_issuance::state::IssuanceState; use agent_shared::{config::config, ConfigError}; use agent_verification::state::VerificationState; -use axum::{ - body::Bytes, - extract::MatchedPath, - http::Request, - response::Response, - routing::{get, post}, - Router, -}; -use issuance::credential_issuer::{ - credential::credential, - token::token, - well_known::{ - oauth_authorization_server::oauth_authorization_server, openid_credential_issuer::openid_credential_issuer, - }, -}; -use issuance::credentials::{credentials, get_credentials}; -use issuance::offers::offers; +use axum::{body::Bytes, extract::MatchedPath, http::Request, response::Response, Router}; use tower_http::trace::TraceLayer; use tracing::{info_span, Span}; -use verification::{ - authorization_requests::{authorization_requests, get_authorization_requests}, - relying_party::{redirect::redirect, request::request}, -}; pub const API_VERSION: &str = "/v0"; -pub type ApplicationState = (IssuanceState, VerificationState); - -pub fn app(state: ApplicationState) -> Router { - let base_path = get_base_path(); - - let path = |suffix: &str| -> String { - if let Ok(base_path) = &base_path { - format!("/{}{}", base_path, suffix) - } else { - suffix.to_string() - } - }; +#[derive(Default)] +pub struct ApplicationState { + pub issuance_state: Option, + pub holder_state: Option, + pub verification_state: Option, +} +pub fn app( + ApplicationState { + issuance_state, + holder_state, + verification_state, + }: ApplicationState, +) -> Router { Router::new() .nest( - &path(API_VERSION), + &get_base_path().unwrap_or_default(), Router::new() - // Agent Issuance Preparations - .route("/credentials", post(credentials)) - .route("/credentials/:credential_id", get(get_credentials)) - .route("/offers", post(offers)) - // Agent Verification Preparations - .route("/authorization_requests", post(authorization_requests)) - .route( - "/authorization_requests/:authorization_request_id", - get(get_authorization_requests), - ), - ) - // OpenID4VCI Pre-Authorized Code Flow - .route( - &path("/.well-known/oauth-authorization-server"), - get(oauth_authorization_server), - ) - .route( - &path("/.well-known/openid-credential-issuer"), - get(openid_credential_issuer), + .merge(issuance_state.map(issuance::router).unwrap_or_default()) + .merge(holder_state.map(holder::router).unwrap_or_default()) + .merge(verification_state.map(verification::router).unwrap_or_default()), ) - .route(&path("/auth/token"), post(token)) - .route(&path("/openid4vci/credential"), post(credential)) - // SIOPv2 - .route(&path("/request/:request_id"), get(request)) - .route(&path("/redirect"), post(redirect)) // Trace layer .layer( TraceLayer::new_for_http() @@ -95,7 +57,6 @@ pub fn app(state: ApplicationState) -> Router { tracing::info!("Response Body: {}", std::str::from_utf8(chunk).unwrap()); }), ) - .with_state(state) } fn get_base_path() -> Result { @@ -118,25 +79,22 @@ fn get_base_path() -> Result { tracing::info!("Base path: {:?}", base_path); - base_path + format!("/{}", base_path) }) } #[cfg(test)] mod tests { - use std::collections::HashMap; - - use agent_issuance::services::test_utils::test_issuance_services; + use super::*; + use agent_secret_manager::service::Service; use agent_store::in_memory; - use agent_verification::services::test_utils::test_verification_services; use axum::routing::post; use oid4vci::credential_issuer::{ credential_configurations_supported::CredentialConfigurationsSupportedObject, credential_issuer_metadata::CredentialIssuerMetadata, }; use serde_json::json; - - use crate::app; + use std::collections::HashMap; pub const CREDENTIAL_CONFIGURATION_ID: &str = "badge"; pub const OFFER_ID: &str = "00000000-0000-0000-0000-000000000000"; @@ -183,10 +141,12 @@ mod tests { #[tokio::test] #[should_panic] async fn test_base_path_routes() { - let issuance_state = in_memory::issuance_state(test_issuance_services(), Default::default()).await; - let verification_state = in_memory::verification_state(test_verification_services(), Default::default()).await; + let issuance_state = in_memory::issuance_state(Service::default(), Default::default()).await; std::env::set_var("UNICORE__BASE_PATH", "unicore"); - let router = app((issuance_state, verification_state)); + let router = app(ApplicationState { + issuance_state: Some(issuance_state), + ..Default::default() + }); let _ = router.route("/auth/token", post(handler)); } diff --git a/agent_api_rest/src/verification/authorization_requests.rs b/agent_api_rest/src/verification/authorization_requests.rs index ad79ff15..e934bc3f 100644 --- a/agent_api_rest/src/verification/authorization_requests.rs +++ b/agent_api_rest/src/verification/authorization_requests.rs @@ -138,17 +138,16 @@ pub(crate) async fn authorization_requests( #[cfg(test)] pub mod tests { use super::*; - use crate::app; - use agent_issuance::services::test_utils::test_issuance_services; + use crate::verification::router; + use agent_secret_manager::service::Service; use agent_store::in_memory; - use agent_verification::services::test_utils::test_verification_services; use axum::{ body::Body, http::{self, Request}, Router, }; use rstest::rstest; - use tower::Service; + use tower::Service as _; pub async fn authorization_requests(app: &mut Router, by_value: bool) -> String { let request_body = AuthorizationRequestsEndpointRequest { @@ -221,9 +220,9 @@ pub mod tests { #[tokio::test] #[tracing_test::traced_test] async fn test_authorization_requests_endpoint(#[case] by_value: bool) { - let issuance_state = in_memory::issuance_state(test_issuance_services(), Default::default()).await; - let verification_state = in_memory::verification_state(test_verification_services(), Default::default()).await; - let mut app = app((issuance_state, verification_state)); + let verification_state = in_memory::verification_state(Service::default(), Default::default()).await; + + let mut app = router(verification_state); authorization_requests(&mut app, by_value).await; } diff --git a/agent_api_rest/src/verification/mod.rs b/agent_api_rest/src/verification/mod.rs index 7aa0c137..c7071971 100644 --- a/agent_api_rest/src/verification/mod.rs +++ b/agent_api_rest/src/verification/mod.rs @@ -1,2 +1,28 @@ pub mod authorization_requests; pub mod relying_party; + +use agent_verification::state::VerificationState; +use axum::routing::get; +use axum::{routing::post, Router}; + +use crate::verification::{ + authorization_requests::authorization_requests, authorization_requests::get_authorization_requests, + relying_party::redirect::redirect, relying_party::request::request, +}; +use crate::API_VERSION; + +pub fn router(verification_state: VerificationState) -> Router { + Router::new() + .nest( + API_VERSION, + Router::new() + .route("/authorization_requests", post(authorization_requests)) + .route( + "/authorization_requests/:authorization_request_id", + get(get_authorization_requests), + ), + ) + .route("/request/:request_id", get(request)) + .route("/redirect", post(redirect)) + .with_state(verification_state) +} diff --git a/agent_api_rest/src/verification/relying_party/redirect.rs b/agent_api_rest/src/verification/relying_party/redirect.rs index 778715ed..7e315071 100644 --- a/agent_api_rest/src/verification/relying_party/redirect.rs +++ b/agent_api_rest/src/verification/relying_party/redirect.rs @@ -57,16 +57,13 @@ pub mod tests { use std::{str::FromStr, sync::Arc}; use super::*; - use crate::{ - app, - verification::{authorization_requests::tests::authorization_requests, relying_party::request::tests::request}, + use crate::verification::{ + authorization_requests::tests::authorization_requests, relying_party::request::tests::request, router, }; use agent_event_publisher_http::EventPublisherHttp; - use agent_issuance::services::test_utils::test_issuance_services; - use agent_secret_manager::{secret_manager, subject::Subject}; + use agent_secret_manager::{secret_manager, service::Service, subject::Subject}; use agent_shared::config::{set_config, Events}; use agent_store::{in_memory, EventPublisher}; - use agent_verification::services::test_utils::test_verification_services; use axum::{ body::Body, http::{self, Request}, @@ -81,7 +78,7 @@ pub mod tests { }; use oid4vc_manager::ProviderManager; use siopv2::{authorization_request::ClientMetadataParameters, siopv2::SIOPv2}; - use tower::Service; + use tower::Service as _; use wiremock::{ matchers::{method, path}, Mock, MockServer, ResponseTemplate, @@ -162,10 +159,9 @@ pub mod tests { let event_publishers = vec![Box::new(EventPublisherHttp::load().unwrap()) as Box]; - let issuance_state = in_memory::issuance_state(test_issuance_services(), Default::default()).await; - let verification_state = in_memory::verification_state(test_verification_services(), event_publishers).await; + let verification_state = in_memory::verification_state(Service::default(), event_publishers).await; - let mut app = app((issuance_state, verification_state)); + let mut app = router(verification_state); let form_url_encoded_authorization_request = authorization_requests(&mut app, false).await; diff --git a/agent_api_rest/src/verification/relying_party/request.rs b/agent_api_rest/src/verification/relying_party/request.rs index 4ddcb24b..ff73e918 100644 --- a/agent_api_rest/src/verification/relying_party/request.rs +++ b/agent_api_rest/src/verification/relying_party/request.rs @@ -33,16 +33,15 @@ pub(crate) async fn request( #[cfg(test)] pub mod tests { use super::*; - use crate::{app, verification::authorization_requests::tests::authorization_requests}; - use agent_issuance::services::test_utils::test_issuance_services; + use crate::verification::{authorization_requests::tests::authorization_requests, router}; + use agent_secret_manager::service::Service; use agent_store::in_memory; - use agent_verification::services::test_utils::test_verification_services; use axum::{ body::Body, http::{self, Request}, Router, }; - use tower::Service; + use tower::Service as _; pub async fn request(app: &mut Router, state: String) { let response = app @@ -70,9 +69,9 @@ pub mod tests { #[tokio::test] #[tracing_test::traced_test] async fn test_request_endpoint() { - let issuance_state = in_memory::issuance_state(test_issuance_services(), Default::default()).await; - let verification_state = in_memory::verification_state(test_verification_services(), Default::default()).await; - let mut app = app((issuance_state, verification_state)); + let verification_state = in_memory::verification_state(Service::default(), Default::default()).await; + + let mut app = router(verification_state); let form_url_encoded_authorization_request = authorization_requests(&mut app, false).await; diff --git a/agent_application/Cargo.toml b/agent_application/Cargo.toml index ec51e3db..a919c707 100644 --- a/agent_application/Cargo.toml +++ b/agent_application/Cargo.toml @@ -7,6 +7,7 @@ rust-version.workspace = true [dependencies] agent_api_rest = { path = "../agent_api_rest" } agent_event_publisher_http = { path = "../agent_event_publisher_http" } +agent_holder = { path = "../agent_holder" } agent_issuance = { path = "../agent_issuance" } agent_secret_manager = { path = "../agent_secret_manager" } agent_shared = { path = "../agent_shared" } diff --git a/agent_application/docker/README.md b/agent_application/docker/README.md index e25559f2..6f493e0a 100644 --- a/agent_application/docker/README.md +++ b/agent_application/docker/README.md @@ -49,30 +49,3 @@ variables: UNICORE__SECRET_MANAGER__ISSUER_DID: UNICORE__SECRET_MANAGER__ISSUER_FRAGMENT: ``` - -## Leveraging Just-in-Time Data Request Events - -UniCore facilitates dynamic integration with external systems through just-in-time data request events, dispatched seamlessly via an HTTP Event Publisher. This enables real-time data retrieval and on-demand generation, enhancing flexibility and efficiency in your SSI ecosystem. - -### Example Scenarios - -**Custom Credential Signing** - -UniCore facilitates the utilization of just-in-time data request events for customized credential signing workflows. This approach enables users to manage the signing process independently, offering greater control over credential issuance. When UniCore verifies a Credential Request from a Wallet, it triggers the `CredentialRequestVerified` event. By utilizing the HTTP Event Publisher, this event, containing essential identifiers like `offer_id` and `subject_id`, can be dispatched to external systems. Subsequently, external systems leverage these identifiers to generate and sign credentials, which are then submitted to UniCore's `/v0/credentials` endpoint. - -To integrate just-in-time data request events into your workflow, adhere to the following steps: - -1. Configure the HTTP Event Publisher to listen for the `CredentialRequestVerified` event. Refer to the [HTTP Event Publisher documentation](../../agent_event_publisher_http/README.md) for detailed configuration instructions: - - ```yaml - target_url: &target_url "https://my-domain.example.org/ssi-event-subscriber" - - offer: - { target_url: *target_url, target_events: [CredentialRequestVerified] } - ``` - -2. Upon initiation of the OpenID4VCI flow by a Wallet, the CredentialRequestVerified event is triggered, containing relevant identifiers. -3. The HTTP Event Publisher dispatches the event to the external system. Leveraging the provided identifiers, the external system generates and signs the credential, then submits it to UniCore's `/v0/credentials` endpoint. Refer to the [API specification](../../agent_api_rest/README.md)) for additional details on endpoint usage. - -By default, UniCore will wait up to 1000 ms for the signed credential to arrive. This parameter can be changed by -setting the `AGENT_API_REST_EXTERNAL_SERVER_RESPONSE_TIMEOUT_MS` environment variable. diff --git a/agent_application/docker/db/init.sql b/agent_application/docker/db/init.sql index 0989bb4e..f333905c 100644 --- a/agent_application/docker/db/init.sql +++ b/agent_application/docker/db/init.sql @@ -50,6 +50,39 @@ CREATE TABLE server_config PRIMARY KEY (view_id) ); +CREATE TABLE received_offer +( + view_id text NOT NULL, + version bigint CHECK (version >= 0) NOT NULL, + payload json NOT NULL, + PRIMARY KEY (view_id) +); + +CREATE TABLE all_offers +( + view_id text NOT NULL, + version bigint CHECK (version >= 0) NOT NULL, + payload json NOT NULL, + PRIMARY KEY (view_id) +); + +CREATE TABLE holder_credential +( + view_id text NOT NULL, + version bigint CHECK (version >= 0) NOT NULL, + payload json NOT NULL, + PRIMARY KEY (view_id) +); + + +CREATE TABLE all_credentials +( + view_id text NOT NULL, + version bigint CHECK (version >= 0) NOT NULL, + payload json NOT NULL, + PRIMARY KEY (view_id) +); + CREATE TABLE authorization_request ( view_id text NOT NULL, diff --git a/agent_application/src/main.rs b/agent_application/src/main.rs index 24ed7bbb..23933869 100644 --- a/agent_application/src/main.rs +++ b/agent_application/src/main.rs @@ -1,9 +1,10 @@ #![allow(clippy::await_holding_lock)] -use agent_api_rest::app; +use agent_api_rest::{app, ApplicationState}; use agent_event_publisher_http::EventPublisherHttp; +use agent_holder::services::HolderServices; use agent_issuance::{services::IssuanceServices, startup_commands::startup_commands, state::initialize}; -use agent_secret_manager::{secret_manager, subject::Subject}; +use agent_secret_manager::{secret_manager, service::Service as _, subject::Subject}; use agent_shared::{ config::{config, LogFormat, SupportedDidMethod, ToggleOptions}, domain_linkage::create_did_configuration_resource, @@ -35,22 +36,26 @@ async fn main() -> io::Result<()> { }); let issuance_services = Arc::new(IssuanceServices::new(subject.clone())); + let holder_services = Arc::new(HolderServices::new(subject.clone())); let verification_services = Arc::new(VerificationServices::new(subject.clone())); - // TODO: Currently `issuance_event_publishers` and `verification_event_publishers` are exactly the same, which is - // weird. We need some sort of layer between `agent_application` and `agent_store` that will provide a cleaner way - // of initializing the event publishers and sending them over to `agent_store`. + // TODO: Currently `issuance_event_publishers`, `holder_event_publishers` and `verification_event_publishers` are + // exactly the same, which is weird. We need some sort of layer between `agent_application` and `agent_store` that + // will provide a cleaner way of initializing the event publishers and sending them over to `agent_store`. let issuance_event_publishers: Vec> = vec![Box::new(EventPublisherHttp::load().unwrap())]; + let holder_event_publishers: Vec> = vec![Box::new(EventPublisherHttp::load().unwrap())]; let verification_event_publishers: Vec> = vec![Box::new(EventPublisherHttp::load().unwrap())]; - let (issuance_state, verification_state) = match agent_shared::config::config().event_store.type_ { + let (issuance_state, holder_state, verification_state) = match agent_shared::config::config().event_store.type_ { agent_shared::config::EventStoreType::Postgres => ( postgres::issuance_state(issuance_services, issuance_event_publishers).await, + postgres::holder_state(holder_services, holder_event_publishers).await, postgres::verification_state(verification_services, verification_event_publishers).await, ), agent_shared::config::EventStoreType::InMemory => ( in_memory::issuance_state(issuance_services, issuance_event_publishers).await, + in_memory::holder_state(holder_services, holder_event_publishers).await, in_memory::verification_state(verification_services, verification_event_publishers).await, ), }; @@ -65,7 +70,11 @@ async fn main() -> io::Result<()> { initialize(&issuance_state, startup_commands(url.clone())).await; - let mut app = app((issuance_state, verification_state)); + let mut app = app(ApplicationState { + issuance_state: Some(issuance_state), + holder_state: Some(holder_state), + verification_state: Some(verification_state), + }); // CORS if config().cors_enabled.unwrap_or(false) { diff --git a/agent_event_publisher_http/Cargo.toml b/agent_event_publisher_http/Cargo.toml index a7e4f590..a9a0f29d 100644 --- a/agent_event_publisher_http/Cargo.toml +++ b/agent_event_publisher_http/Cargo.toml @@ -5,6 +5,7 @@ edition.workspace = true rust-version.workspace = true [dependencies] +agent_holder = { path = "../agent_holder" } agent_issuance = { path = "../agent_issuance" } agent_shared = { path = "../agent_shared" } agent_store = { path = "../agent_store" } @@ -18,7 +19,7 @@ rustls = { version = "0.23", default-features = false, features = [ "std", "tls12" ] } -reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +reqwest.workspace = true serde.workspace = true serde_with.workspace = true serde_yaml.workspace = true diff --git a/agent_event_publisher_http/README.md b/agent_event_publisher_http/README.md index 028d2308..82de1986 100644 --- a/agent_event_publisher_http/README.md +++ b/agent_event_publisher_http/README.md @@ -47,6 +47,22 @@ ServerMetadataLoaded CredentialConfigurationAdded ``` +#### `holder_credential` + +``` +CredentialAdded +``` + +#### `received_offer` + +``` +CredentialOfferReceived +CredentialOfferAccepted +TokenResponseReceived +CredentialResponseReceived +CredentialOfferRejected +``` + #### `authorization_request` ``` @@ -61,3 +77,60 @@ AuthorizationRequestObjectSigned SIOPv2AuthorizationResponseVerified OID4VPAuthorizationResponseVerified ``` + +## Leveraging Just-in-Time Data Request Events + +UniCore facilitates dynamic integration with external systems through just-in-time data request events, dispatched seamlessly via the HTTP Event Publisher. This enables real-time data retrieval and on-demand generation, enhancing flexibility and efficiency in your SSI ecosystem. + +### Example Scenarios + +**Custom Credential Signing** + +UniCore facilitates the utilization of just-in-time data request events for customized credential signing workflows. This approach enables users to manage the signing process independently, offering greater control over credential issuance. When UniCore verifies a Credential Request from a Wallet, it triggers the `CredentialRequestVerified` event. By utilizing the HTTP Event Publisher, this event, containing essential identifiers like `offer_id` and `subject_id`, can be dispatched to external systems. Subsequently, external systems leverage these identifiers to generate and sign credentials, which are then submitted to UniCore's `/v0/credentials` endpoint. + +To integrate just-in-time data request events into your workflow, adhere to the following steps: + +1. Configure the HTTP Event Publisher to listen for the `CredentialRequestVerified` event. The following configuration + can be added to your `config.yaml` file: + ```yaml + event_publishers: + http: + enabled: true + target_url: "https://your-server.org/event-subscriber" + events: + offer: [CredentialRequestVerified] + ``` +2. The above configuration makes sure that whenever a Wallet sends a Credential Request, the HTTP Event Publisher will + dispatch the `CredentialRequestVerified` event to the specified URL once it successfully verified the Credential + Request, e.g: + ```http + POST /event-subscriber HTTP/1.1 + Host: https://your-server.org + Content-Type: application/json + Content-Length: 328 + { + "CredentialRequestVerified": { + "offer_id": "001", + "subject_id": "did:jwk:eyJhbGciOiJFUzI1NiIsImNydiI6IlAtMjU2Iiwia2lkIjoieERDQVBRbHRVa2JZMnByTkdpT0ItNWJ2T0pnZnQ0NVJqYjM2RWNjSWNGdyIsImt0eSI6IkVDIiwieCI6Im02b3EySFF6NmluSk8xbzg1VUM5VVEyamxJRFJld0ROVS0ybUktVThKN1UiLCJ5Ijoia0NwbTcwbXpCT3Y0OWFPdHdmRUdxVW1fSkllWXlZeWdWSXpKaFpXY1ZnTSJ9" + } + } + ``` +3. Now your system can apply its own logic and create and sign a Credential based on the data received from the Event. + The signed Credential can then be submitted to UniCore's `/v0/credentials` endpoint, e.g: + ```http + POST /v0/credentials HTTP/1.1 + Host: https://unicore-server.org + Content-Type: application/json + Content-Length: 328 + { + "offerId": "001", + "credential": "", + "isSigned": true, + "credentialConfigurationId": "" + } + ``` +4. Once UniCore receives the signed Credential, it will finalize the issuance process by embedding the signed Credential + into the Credential Response to the Wallet. + +By default, UniCore will wait up to 1000 ms for the signed credential to arrive. This parameter can be changed by +setting the `AGENT_API_REST_EXTERNAL_SERVER_RESPONSE_TIMEOUT_MS` environment variable. diff --git a/agent_event_publisher_http/src/lib.rs b/agent_event_publisher_http/src/lib.rs index a8543298..e18be595 100644 --- a/agent_event_publisher_http/src/lib.rs +++ b/agent_event_publisher_http/src/lib.rs @@ -4,7 +4,7 @@ use agent_issuance::{ use agent_shared::config::config; use agent_store::{ AuthorizationRequestEventPublisher, ConnectionEventPublisher, CredentialEventPublisher, EventPublisher, - OfferEventPublisher, ServerConfigEventPublisher, + HolderCredentialEventPublisher, OfferEventPublisher, ReceivedOfferEventPublisher, ServerConfigEventPublisher, }; use agent_verification::{authorization_request::aggregate::AuthorizationRequest, connection::aggregate::Connection}; use async_trait::async_trait; @@ -15,13 +15,17 @@ use tracing::info; /// A struct that contains all the event publishers for the different aggregates. #[skip_serializing_none] -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, Default)] pub struct EventPublisherHttp { // Issuance pub server_config: Option>, pub credential: Option>, pub offer: Option>, + // Holder + pub holder_credential: Option>, + pub received_offer: Option>, + // Verification pub connection: Option>, pub authorization_request: Option>, @@ -33,13 +37,7 @@ impl EventPublisherHttp { // If it's not enabled, return an empty event publisher. if !event_publisher_http.enabled { - return Ok(EventPublisherHttp { - server_config: None, - credential: None, - offer: None, - connection: None, - authorization_request: None, - }); + return Ok(EventPublisherHttp::default()); } let server_config = (!event_publisher_http.events.server_config.is_empty()).then(|| { @@ -54,12 +52,12 @@ impl EventPublisherHttp { ) }); - let credential = (!event_publisher_http.events.offer.is_empty()).then(|| { + let credential = (!event_publisher_http.events.credential.is_empty()).then(|| { AggregateEventPublisherHttp::::new( event_publisher_http.target_url.clone(), event_publisher_http .events - .offer + .credential .iter() .map(ToString::to_string) .collect(), @@ -78,6 +76,30 @@ impl EventPublisherHttp { ) }); + let holder_credential = (!event_publisher_http.events.holder_credential.is_empty()).then(|| { + AggregateEventPublisherHttp::::new( + event_publisher_http.target_url.clone(), + event_publisher_http + .events + .holder_credential + .iter() + .map(ToString::to_string) + .collect(), + ) + }); + + let received_offer = (!event_publisher_http.events.received_offer.is_empty()).then(|| { + AggregateEventPublisherHttp::::new( + event_publisher_http.target_url.clone(), + event_publisher_http + .events + .received_offer + .iter() + .map(ToString::to_string) + .collect(), + ) + }); + let connection = (!event_publisher_http.events.connection.is_empty()).then(|| { AggregateEventPublisherHttp::::new( event_publisher_http.target_url.clone(), @@ -106,6 +128,8 @@ impl EventPublisherHttp { server_config, credential, offer, + holder_credential, + received_offer, connection, authorization_request, }; @@ -135,6 +159,18 @@ impl EventPublisher for EventPublisherHttp { .map(|publisher| Box::new(publisher) as OfferEventPublisher) } + fn holder_credential(&mut self) -> Option { + self.holder_credential + .take() + .map(|publisher| Box::new(publisher) as HolderCredentialEventPublisher) + } + + fn received_offer(&mut self) -> Option { + self.received_offer + .take() + .map(|publisher| Box::new(publisher) as ReceivedOfferEventPublisher) + } + fn connection(&mut self) -> Option { self.connection .take() diff --git a/agent_holder/Cargo.toml b/agent_holder/Cargo.toml new file mode 100644 index 00000000..974ada58 --- /dev/null +++ b/agent_holder/Cargo.toml @@ -0,0 +1,46 @@ +[package] +name = "agent_holder" +version.workspace = true +edition.workspace = true +rust-version.workspace = true + +[dependencies] +agent_shared = { path = "../agent_shared" } +agent_secret_manager = { path = "../agent_secret_manager" } + +async-trait.workspace = true +cqrs-es.workspace = true +jsonwebtoken.workspace = true +oid4vci.workspace = true +oid4vc-core.workspace = true +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true +tracing.workspace = true + +# `test_utils` dependencies +rstest = { workspace = true, optional = true } + +[dev-dependencies] +agent_api_rest = { path = "../agent_api_rest" } +agent_holder = { path = ".", features = ["test_utils"] } +agent_issuance = { path = "../agent_issuance", features = ["test_utils"] } +agent_secret_manager = { path = "../agent_secret_manager", features = ["test_utils"] } +agent_shared = { path = "../agent_shared", features = ["test_utils"] } +agent_store = { path = "../agent_store" } + +axum.workspace = true +did_manager.workspace = true +lazy_static.workspace = true +mime.workspace = true +names = { version = "0.14", default-features = false } +reqwest.workspace = true +rand = "0.8" +serial_test = "3.0" +tokio.workspace = true +tower.workspace = true +tracing-test.workspace = true +async-std = { version = "1.5", features = ["attributes", "tokio1"] } + +[features] +test_utils = ["dep:rstest"] diff --git a/agent_holder/src/credential/README.md b/agent_holder/src/credential/README.md new file mode 100644 index 00000000..78cc0876 --- /dev/null +++ b/agent_holder/src/credential/README.md @@ -0,0 +1,7 @@ +# Credential + +This aggregate is defined by: + +- credential_id +- offer_id +- credential diff --git a/agent_holder/src/credential/aggregate.rs b/agent_holder/src/credential/aggregate.rs new file mode 100644 index 00000000..5488ccf1 --- /dev/null +++ b/agent_holder/src/credential/aggregate.rs @@ -0,0 +1,113 @@ +use crate::credential::command::CredentialCommand; +use crate::credential::error::CredentialError::{self}; +use crate::credential::event::CredentialEvent; +use crate::services::HolderServices; +use async_trait::async_trait; +use cqrs_es::Aggregate; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use tracing::info; + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct Credential { + pub credential_id: Option, + pub offer_id: Option, + pub credential: Option, +} + +#[async_trait] +impl Aggregate for Credential { + type Command = CredentialCommand; + type Event = CredentialEvent; + type Error = CredentialError; + type Services = Arc; + + fn aggregate_type() -> String { + "credential".to_string() + } + + async fn handle( + &self, + command: Self::Command, + _services: &Self::Services, + ) -> Result, Self::Error> { + use CredentialCommand::*; + use CredentialEvent::*; + + info!("Handling command: {:?}", command); + + match command { + AddCredential { + credential_id, + offer_id, + credential, + } => Ok(vec![CredentialAdded { + credential_id, + offer_id, + credential, + }]), + } + } + + fn apply(&mut self, event: Self::Event) { + use CredentialEvent::*; + + info!("Applying event: {:?}", event); + + match event { + CredentialAdded { + credential_id, + offer_id, + credential, + } => { + self.credential_id = Some(credential_id); + self.offer_id = Some(offer_id); + self.credential = Some(credential); + } + } + } +} + +#[cfg(test)] +pub mod credential_tests { + use super::test_utils::*; + use super::*; + use crate::credential::aggregate::Credential; + use crate::credential::event::CredentialEvent; + use crate::offer::aggregate::test_utils::offer_id; + use agent_issuance::credential::aggregate::test_utils::OPENBADGE_VERIFIABLE_CREDENTIAL_JWT; + use agent_secret_manager::service::Service; + use cqrs_es::test::TestFramework; + use rstest::rstest; + use serde_json::json; + + type CredentialTestFramework = TestFramework; + + #[rstest] + #[serial_test::serial] + fn test_add_credential(credential_id: String, offer_id: String) { + CredentialTestFramework::with(Service::default()) + .given_no_previous_events() + .when(CredentialCommand::AddCredential { + credential_id: credential_id.clone(), + offer_id: offer_id.clone(), + credential: json!(OPENBADGE_VERIFIABLE_CREDENTIAL_JWT), + }) + .then_expect_events(vec![CredentialEvent::CredentialAdded { + credential_id, + offer_id, + credential: json!(OPENBADGE_VERIFIABLE_CREDENTIAL_JWT), + }]) + } +} + +#[cfg(feature = "test_utils")] +pub mod test_utils { + use agent_shared::generate_random_string; + use rstest::*; + + #[fixture] + pub fn credential_id() -> String { + generate_random_string() + } +} diff --git a/agent_holder/src/credential/command.rs b/agent_holder/src/credential/command.rs new file mode 100644 index 00000000..af839527 --- /dev/null +++ b/agent_holder/src/credential/command.rs @@ -0,0 +1,11 @@ +use serde::Deserialize; + +#[derive(Debug, Deserialize)] +#[serde(untagged)] +pub enum CredentialCommand { + AddCredential { + credential_id: String, + offer_id: String, + credential: serde_json::Value, + }, +} diff --git a/agent_holder/src/credential/error.rs b/agent_holder/src/credential/error.rs new file mode 100644 index 00000000..df235841 --- /dev/null +++ b/agent_holder/src/credential/error.rs @@ -0,0 +1,4 @@ +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum CredentialError {} diff --git a/agent_holder/src/credential/event.rs b/agent_holder/src/credential/event.rs new file mode 100644 index 00000000..cbc50106 --- /dev/null +++ b/agent_holder/src/credential/event.rs @@ -0,0 +1,26 @@ +use cqrs_es::DomainEvent; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +pub enum CredentialEvent { + CredentialAdded { + credential_id: String, + offer_id: String, + credential: serde_json::Value, + }, +} + +impl DomainEvent for CredentialEvent { + fn event_type(&self) -> String { + use CredentialEvent::*; + + let event_type: &str = match self { + CredentialAdded { .. } => "CredentialAdded", + }; + event_type.to_string() + } + + fn event_version(&self) -> String { + "1".to_string() + } +} diff --git a/agent_holder/src/credential/mod.rs b/agent_holder/src/credential/mod.rs new file mode 100644 index 00000000..7d8a943f --- /dev/null +++ b/agent_holder/src/credential/mod.rs @@ -0,0 +1,5 @@ +pub mod aggregate; +pub mod command; +pub mod error; +pub mod event; +pub mod queries; diff --git a/agent_holder/src/credential/queries/all_credentials.rs b/agent_holder/src/credential/queries/all_credentials.rs new file mode 100644 index 00000000..48000182 --- /dev/null +++ b/agent_holder/src/credential/queries/all_credentials.rs @@ -0,0 +1,23 @@ +use super::CredentialView; +use crate::credential::queries::Credential; +use cqrs_es::{EventEnvelope, View}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +#[derive(Debug, Default, Serialize, Deserialize, Clone)] +pub struct AllCredentialsView { + #[serde(flatten)] + pub credentials: HashMap, +} + +impl View for AllCredentialsView { + fn update(&mut self, event: &EventEnvelope) { + self.credentials + // Get the entry for the aggregate_id + .entry(event.aggregate_id.clone()) + // or insert a new one if it doesn't exist + .or_default() + // update the view with the event + .update(event); + } +} diff --git a/agent_holder/src/credential/queries/mod.rs b/agent_holder/src/credential/queries/mod.rs new file mode 100644 index 00000000..007f0255 --- /dev/null +++ b/agent_holder/src/credential/queries/mod.rs @@ -0,0 +1,31 @@ +pub mod all_credentials; + +use super::event::CredentialEvent; +use crate::credential::aggregate::Credential; +use cqrs_es::{EventEnvelope, View}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Default, Serialize, Deserialize, Clone)] +pub struct CredentialView { + pub credential_id: Option, + pub offer_id: Option, + pub credential: Option, +} + +impl View for CredentialView { + fn update(&mut self, event: &EventEnvelope) { + use CredentialEvent::*; + + match &event.payload { + CredentialAdded { + credential_id, + offer_id, + credential, + } => { + self.credential_id.replace(credential_id.clone()); + self.offer_id.replace(offer_id.clone()); + self.credential.replace(credential.clone()); + } + } + } +} diff --git a/agent_holder/src/lib.rs b/agent_holder/src/lib.rs new file mode 100644 index 00000000..671c165e --- /dev/null +++ b/agent_holder/src/lib.rs @@ -0,0 +1,4 @@ +pub mod credential; +pub mod offer; +pub mod services; +pub mod state; diff --git a/agent_holder/src/offer/README.md b/agent_holder/src/offer/README.md new file mode 100644 index 00000000..f8386aed --- /dev/null +++ b/agent_holder/src/offer/README.md @@ -0,0 +1,9 @@ +# Offer + +This aggregate holds everything related to a credential offer: + +- credential_offer +- status +- credential_configurations +- token_response +- credentials diff --git a/agent_holder/src/offer/aggregate.rs b/agent_holder/src/offer/aggregate.rs new file mode 100644 index 00000000..47239bdf --- /dev/null +++ b/agent_holder/src/offer/aggregate.rs @@ -0,0 +1,485 @@ +use crate::offer::command::OfferCommand; +use crate::offer::error::OfferError; +use crate::offer::event::OfferEvent; +use crate::services::HolderServices; +use async_trait::async_trait; +use cqrs_es::Aggregate; +use oid4vci::credential_issuer::credential_configurations_supported::CredentialConfigurationsSupportedObject; +use oid4vci::credential_offer::{CredentialOffer, CredentialOfferParameters, Grants}; +use oid4vci::credential_response::CredentialResponseType; +use oid4vci::token_request::TokenRequest; +use oid4vci::token_response::TokenResponse; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::sync::Arc; +use tracing::info; + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] +pub enum Status { + #[default] + Pending, + Accepted, + Received, + Rejected, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct Offer { + pub credential_offer: Option, + pub status: Status, + pub credential_configurations: Option>, + pub token_response: Option, + // TODO: These should not be part of this Aggregate. Instead, an Event Subscriber should be listening to the + // `CredentialResponseReceived` event and then trigger the `CredentialCommand::AddCredential` command. We can do + // this once we have a mechanism implemented that can both listen to events as well as trigger commands. + pub credentials: Vec, +} + +#[async_trait] +impl Aggregate for Offer { + type Command = OfferCommand; + type Event = OfferEvent; + type Error = OfferError; + type Services = Arc; + + fn aggregate_type() -> String { + "offer".to_string() + } + + async fn handle(&self, command: Self::Command, services: &Self::Services) -> Result, Self::Error> { + use OfferCommand::*; + use OfferError::*; + use OfferEvent::*; + + info!("Handling command: {:?}", command); + + match command { + ReceiveCredentialOffer { + offer_id, + credential_offer, + } => { + let wallet = &services.wallet; + + let credential_offer = match credential_offer { + CredentialOffer::CredentialOfferUri(credential_offer_uri) => services + .wallet + .get_credential_offer(credential_offer_uri) + .await + .map_err(|_| CredentialOfferByReferenceRetrievalError)?, + CredentialOffer::CredentialOffer(credential_offer) => *credential_offer, + }; + + // The credential offer contains a credential issuer url. + let credential_issuer_url = credential_offer.credential_issuer.clone(); + + // Get the credential issuer metadata. + let credential_issuer_metadata = wallet + .get_credential_issuer_metadata(credential_issuer_url.clone()) + .await + .map_err(|_| CredentialIssuerMetadataRetrievalError)?; + + let credential_configurations: HashMap = + credential_issuer_metadata + .credential_configurations_supported + .iter() + .filter(|(id, _)| credential_offer.credential_configuration_ids.contains(id)) + .map(|(id, credential_configuration)| (id.clone(), credential_configuration.clone())) + .collect(); + + Ok(vec![CredentialOfferReceived { + offer_id, + credential_offer: Box::new(credential_offer), + credential_configurations, + }]) + } + AcceptCredentialOffer { offer_id } => { + // TODO: should we 'do nothing' or log a `warn!` message instead of returning an error? + if self.status != Status::Pending { + return Err(CredentialOfferStatusNotPendingError); + } + + let wallet = &services.wallet; + + let credential_offer = self.credential_offer.as_ref().ok_or(MissingCredentialOfferError)?; + + let credential_issuer_url = credential_offer.credential_issuer.clone(); + + // Get the authorization server metadata. + let authorization_server_metadata = wallet + .get_authorization_server_metadata(credential_issuer_url.clone()) + .await + .map_err(|_| AuthorizationServerMetadataRetrievalError)?; + + // Create a token request with grant_type `pre_authorized_code`. + let token_request = match credential_offer.grants.clone() { + Some(Grants { + pre_authorized_code: Some(pre_authorized_code), + .. + }) => TokenRequest::PreAuthorizedCode { + pre_authorized_code: pre_authorized_code.pre_authorized_code, + tx_code: None, + }, + _ => return Err(MissingPreAuthorizedCodeError), + }; + + info!("token_request: {:?}", token_request); + + // Get an access token. + let token_response = wallet + .get_access_token( + authorization_server_metadata + .token_endpoint + .ok_or(MissingTokenEndpointError)?, + token_request, + ) + .await + .map_err(|_| TokenResponseError)?; + + info!("token_response: {:?}", token_response); + + Ok(vec![ + CredentialOfferAccepted { + offer_id: offer_id.clone(), + status: Status::Accepted, + }, + TokenResponseReceived { + offer_id, + token_response, + }, + ]) + } + SendCredentialRequest { offer_id } => { + if self.status != Status::Accepted { + return Err(CredentialOfferStatusNotAcceptedError); + } + + let wallet = &services.wallet; + + let credential_offer = self.credential_offer.as_ref().ok_or(MissingCredentialOfferError)?; + + let credential_issuer_url = credential_offer.credential_issuer.clone(); + + // Get an access token. + let token_response = self.token_response.as_ref().ok_or(MissingTokenResponseError)?.clone(); + + let credential_configuration_ids = credential_offer.credential_configuration_ids.clone(); + + // Get the credential issuer metadata. + let credential_issuer_metadata = wallet + .get_credential_issuer_metadata(credential_issuer_url.clone()) + .await + .map_err(|_| CredentialIssuerMetadataRetrievalError)?; + + let credential_configurations = self + .credential_configurations + .as_ref() + .ok_or(MissingCredentialConfigurationsError)?; + + let credentials: Vec = match credential_configuration_ids.len() { + 0 => vec![], + 1 => { + let credential_configuration_id = &credential_configuration_ids[0]; + + let credential_configuration = credential_configurations + .get(credential_configuration_id) + .ok_or(MissingCredentialConfigurationError)?; + + // Get the credential. + let credential_response = wallet + .get_credential(credential_issuer_metadata, &token_response, credential_configuration) + .await + .map_err(|_| CredentialResponseError)?; + + let credential = match credential_response.credential { + CredentialResponseType::Immediate { credential, .. } => credential, + CredentialResponseType::Deferred { .. } => { + return Err(UnsupportedDeferredCredentialResponseError) + } + }; + + vec![credential] + } + _batch => { + return Err(BatchCredentialRequestError); + } + }; + + info!("credentials: {:?}", credentials); + + Ok(vec![CredentialResponseReceived { + offer_id, + status: Status::Received, + credentials, + }]) + } + RejectCredentialOffer { offer_id } => { + // TODO: should we 'do nothing' or log a `warn!` message instead of returning an error? + if self.status != Status::Pending { + return Err(CredentialOfferStatusNotPendingError); + } + + Ok(vec![CredentialOfferRejected { + offer_id, + status: Status::Rejected, + }]) + } + } + } + + fn apply(&mut self, event: Self::Event) { + use OfferEvent::*; + + info!("Applying event: {:?}", event); + + match event { + CredentialOfferReceived { + credential_offer, + credential_configurations, + .. + } => { + self.credential_offer.replace(*credential_offer); + self.credential_configurations.replace(credential_configurations); + } + CredentialOfferAccepted { status, .. } => { + self.status = status; + } + TokenResponseReceived { token_response, .. } => { + self.token_response.replace(token_response); + } + CredentialResponseReceived { + status, credentials, .. + } => { + self.status = status; + self.credentials = credentials; + } + CredentialOfferRejected { status, .. } => { + self.status = status; + } + } + } +} + +#[cfg(test)] +pub mod tests { + use super::test_utils::*; + use super::*; + use agent_api_rest::issuance; + use agent_api_rest::API_VERSION; + use agent_issuance::offer::aggregate::test_utils::token_response; + use agent_issuance::server_config::aggregate::test_utils::credential_configurations_supported; + use agent_issuance::{startup_commands::startup_commands, state::initialize}; + use agent_secret_manager::service::Service; + use agent_shared::generate_random_string; + use agent_store::in_memory; + use axum::{ + body::Body, + http::{self, Request}, + }; + use cqrs_es::test::TestFramework; + use oid4vci::credential_offer::CredentialOffer; + use rstest::{fixture, rstest}; + use serde_json::json; + use tokio::net::TcpListener; + use tower::Service as _; + + type OfferTestFramework = TestFramework; + + async fn bootstrap_issuer_server() -> CredentialOffer { + let listener = TcpListener::bind("0.0.0.0:0").await.unwrap(); + let issuer_url = format!("http://{}", listener.local_addr().unwrap()); + + let issuance_state = in_memory::issuance_state(Service::default(), Default::default()).await; + initialize(&issuance_state, startup_commands(issuer_url.parse().unwrap())).await; + + let offer_id = generate_random_string(); + + let mut app = issuance::router(issuance_state); + + let _ = app + .call( + Request::builder() + .method(http::Method::POST) + .uri(&format!("{issuer_url}{API_VERSION}/credentials")) + .header(http::header::CONTENT_TYPE, mime::APPLICATION_JSON.as_ref()) + .body(Body::from( + serde_json::to_vec(&json!({ + "offerId": offer_id, + "credential": { + "credentialSubject": { + "first_name": "Ferris", + "last_name": "Rustacean", + "degree": { + "type": "MasterDegree", + "name": "Master of Oceanography" + } + }}, + "credentialConfigurationId": "badge" + })) + .unwrap(), + )) + .unwrap(), + ) + .await; + + let response = app + .call( + Request::builder() + .method(http::Method::POST) + .uri(&format!("{issuer_url}{API_VERSION}/offers")) + .header(http::header::CONTENT_TYPE, mime::APPLICATION_JSON.as_ref()) + .body(Body::from( + serde_json::to_vec(&json!({ + "offerId": offer_id + })) + .unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + + let body = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap(); + + let credential_offer: CredentialOffer = String::from_utf8(body.to_vec()).unwrap().parse().unwrap(); + + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + + credential_offer + } + + #[fixture] + async fn credential_offer_parameters() -> Box { + let credential_offer = bootstrap_issuer_server().await; + + match credential_offer { + CredentialOffer::CredentialOffer(credential_offer) => credential_offer, + _ => unreachable!(), + } + } + + #[rstest] + #[serial_test::serial] + #[tokio::test] + async fn test_receive_credential_offer( + offer_id: String, + #[future(awt)] credential_offer_parameters: Box, + credential_configurations_supported: HashMap, + ) { + OfferTestFramework::with(Service::default()) + .given_no_previous_events() + .when_async(OfferCommand::ReceiveCredentialOffer { + offer_id: offer_id.clone(), + credential_offer: CredentialOffer::CredentialOffer(credential_offer_parameters.clone()), + }) + .await + .then_expect_events(vec![OfferEvent::CredentialOfferReceived { + offer_id, + credential_offer: credential_offer_parameters, + credential_configurations: credential_configurations_supported, + }]); + } + + #[rstest] + #[serial_test::serial] + #[tokio::test] + async fn test_accept_credential_offer( + offer_id: String, + #[future(awt)] credential_offer_parameters: Box, + #[future(awt)] token_response: TokenResponse, + credential_configurations_supported: HashMap, + ) { + OfferTestFramework::with(Service::default()) + .given(vec![OfferEvent::CredentialOfferReceived { + offer_id: offer_id.clone(), + credential_offer: credential_offer_parameters, + credential_configurations: credential_configurations_supported, + }]) + .when_async(OfferCommand::AcceptCredentialOffer { + offer_id: offer_id.clone(), + }) + .await + .then_expect_events(vec![ + OfferEvent::CredentialOfferAccepted { + offer_id: offer_id.clone(), + status: Status::Accepted, + }, + OfferEvent::TokenResponseReceived { + offer_id, + token_response, + }, + ]); + } + + #[rstest] + #[serial_test::serial] + #[tokio::test] + async fn test_send_credential_request( + offer_id: String, + #[future(awt)] credential_offer_parameters: Box, + #[future(awt)] token_response: TokenResponse, + credential_configurations_supported: HashMap, + ) { + OfferTestFramework::with(Service::default()) + .given(vec![ + OfferEvent::CredentialOfferReceived { + offer_id: offer_id.clone(), + credential_offer: credential_offer_parameters, + credential_configurations: credential_configurations_supported, + }, + OfferEvent::CredentialOfferAccepted { + offer_id: offer_id.clone(), + status: Status::Accepted, + }, + OfferEvent::TokenResponseReceived { + offer_id: offer_id.clone(), + token_response + }, + ]) + .when_async(OfferCommand::SendCredentialRequest { + offer_id: offer_id.clone(), + }) + .await + .then_expect_events(vec![OfferEvent::CredentialResponseReceived { + offer_id: offer_id.clone(), + status: Status::Received, + credentials: vec![json!("eyJ0eXAiOiJKV1QiLCJhbGciOiJFZERTQSIsImtpZCI6ImRpZDprZXk6ejZNa2dFODROQ01wTWVBeDlqSzljZjVXNEc4Z2NaOXh1d0p2RzFlN3dOazhLQ2d0I3o2TWtnRTg0TkNNcE1lQXg5aks5Y2Y1VzRHOGdjWjl4dXdKdkcxZTd3Tms4S0NndCJ9.eyJpc3MiOiJkaWQ6a2V5Ono2TWtnRTg0TkNNcE1lQXg5aks5Y2Y1VzRHOGdjWjl4dXdKdkcxZTd3Tms4S0NndCIsInN1YiI6ImRpZDprZXk6ejZNa2dFODROQ01wTWVBeDlqSzljZjVXNEc4Z2NaOXh1d0p2RzFlN3dOazhLQ2d0IiwiZXhwIjo5OTk5OTk5OTk5LCJpYXQiOjAsInZjIjp7IkBjb250ZXh0IjoiaHR0cHM6Ly93d3cudzMub3JnLzIwMTgvY3JlZGVudGlhbHMvdjEiLCJ0eXBlIjpbIlZlcmlmaWFibGVDcmVkZW50aWFsIl0sImNyZWRlbnRpYWxTdWJqZWN0Ijp7ImlkIjoiZGlkOmtleTp6Nk1rZ0U4NE5DTXBNZUF4OWpLOWNmNVc0RzhnY1o5eHV3SnZHMWU3d05rOEtDZ3QiLCJkZWdyZWUiOnsidHlwZSI6Ik1hc3RlckRlZ3JlZSIsIm5hbWUiOiJNYXN0ZXIgb2YgT2NlYW5vZ3JhcGh5In0sImZpcnN0X25hbWUiOiJGZXJyaXMiLCJsYXN0X25hbWUiOiJSdXN0YWNlYW4ifSwiaXNzdWVyIjoiZGlkOmtleTp6Nk1rZ0U4NE5DTXBNZUF4OWpLOWNmNVc0RzhnY1o5eHV3SnZHMWU3d05rOEtDZ3QiLCJpc3N1YW5jZURhdGUiOiIyMDEwLTAxLTAxVDAwOjAwOjAwWiJ9fQ.jQEpI7DhjOcmyhPEpfGARwcRyzor_fUvynb43-eqD9175FBoshENX0S-8qlloQ7vbT5gat8TjvcDlGDN720ZBw")], + }]); + } + + #[rstest] + #[serial_test::serial] + #[tokio::test] + async fn test_reject_credential_offer( + offer_id: String, + #[future(awt)] credential_offer_parameters: Box, + credential_configurations_supported: HashMap, + ) { + OfferTestFramework::with(Service::default()) + .given(vec![OfferEvent::CredentialOfferReceived { + offer_id: offer_id.clone(), + credential_offer: credential_offer_parameters, + credential_configurations: credential_configurations_supported, + }]) + .when_async(OfferCommand::RejectCredentialOffer { + offer_id: offer_id.clone(), + }) + .await + .then_expect_events(vec![OfferEvent::CredentialOfferRejected { + offer_id: offer_id.clone(), + status: Status::Rejected, + }]); + } +} + +#[cfg(feature = "test_utils")] +pub mod test_utils { + use agent_shared::generate_random_string; + use rstest::*; + + #[fixture] + pub fn offer_id() -> String { + generate_random_string() + } +} diff --git a/agent_holder/src/offer/command.rs b/agent_holder/src/offer/command.rs new file mode 100644 index 00000000..b62b6d36 --- /dev/null +++ b/agent_holder/src/offer/command.rs @@ -0,0 +1,20 @@ +use oid4vci::credential_offer::CredentialOffer; +use serde::Deserialize; + +#[derive(Debug, Deserialize)] +#[serde(untagged)] +pub enum OfferCommand { + ReceiveCredentialOffer { + offer_id: String, + credential_offer: CredentialOffer, + }, + AcceptCredentialOffer { + offer_id: String, + }, + SendCredentialRequest { + offer_id: String, + }, + RejectCredentialOffer { + offer_id: String, + }, +} diff --git a/agent_holder/src/offer/error.rs b/agent_holder/src/offer/error.rs new file mode 100644 index 00000000..eabbfdd3 --- /dev/null +++ b/agent_holder/src/offer/error.rs @@ -0,0 +1,35 @@ +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum OfferError { + #[error("The Credential Offer could not be retrieved from the `credential_offer_uri`")] + CredentialOfferByReferenceRetrievalError, + #[error("The Credential Issuer Metadata could not be retrieved")] + CredentialIssuerMetadataRetrievalError, + #[error("The Credential Offer has already been accepted and cannot be rejected anymore")] + CredentialOfferStatusNotPendingError, + #[error("The Credential Offer is missing")] + MissingCredentialOfferError, + #[error("The Authorization Server Metadata could not be retrieved")] + AuthorizationServerMetadataRetrievalError, + #[error("The pre-authorized code is missing from the Credential Offer")] + MissingPreAuthorizedCodeError, + #[error("The Authorization Server Metadata is missing the `token_endpoint` parameter")] + MissingTokenEndpointError, + #[error("An error occurred while requesting the access token")] + TokenResponseError, + #[error("The Credential Offer has not been accepted yet")] + CredentialOfferStatusNotAcceptedError, + #[error("The Token Response is missing from the Credential Offer")] + MissingTokenResponseError, + #[error("The Credential Configurations are missing from the Credential Offer")] + MissingCredentialConfigurationsError, + #[error("The Credential Configuration is missing from the Credential Configurations")] + MissingCredentialConfigurationError, + #[error("An error occurred while requesting the credentials")] + CredentialResponseError, + #[error("Deferred Credential Responses are not supported")] + UnsupportedDeferredCredentialResponseError, + #[error("Batch Credential Request are not supported")] + BatchCredentialRequestError, +} diff --git a/agent_holder/src/offer/event.rs b/agent_holder/src/offer/event.rs new file mode 100644 index 00000000..4db40468 --- /dev/null +++ b/agent_holder/src/offer/event.rs @@ -0,0 +1,53 @@ +use super::aggregate::Status; +use cqrs_es::DomainEvent; +use oid4vci::{ + credential_issuer::credential_configurations_supported::CredentialConfigurationsSupportedObject, + credential_offer::CredentialOfferParameters, token_response::TokenResponse, +}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +pub enum OfferEvent { + CredentialOfferReceived { + offer_id: String, + credential_offer: Box, + credential_configurations: HashMap, + }, + CredentialOfferAccepted { + offer_id: String, + status: Status, + }, + TokenResponseReceived { + offer_id: String, + token_response: TokenResponse, + }, + CredentialResponseReceived { + offer_id: String, + status: Status, + credentials: Vec, + }, + CredentialOfferRejected { + offer_id: String, + status: Status, + }, +} + +impl DomainEvent for OfferEvent { + fn event_type(&self) -> String { + use OfferEvent::*; + + let event_type: &str = match self { + CredentialOfferReceived { .. } => "CredentialOfferReceived", + CredentialOfferAccepted { .. } => "CredentialOfferAccepted", + TokenResponseReceived { .. } => "AccessTokenReceived", + CredentialResponseReceived { .. } => "CredentialResponseReceived", + CredentialOfferRejected { .. } => "CredentialOfferRejected", + }; + event_type.to_string() + } + + fn event_version(&self) -> String { + "1".to_string() + } +} diff --git a/agent_holder/src/offer/mod.rs b/agent_holder/src/offer/mod.rs new file mode 100644 index 00000000..7d8a943f --- /dev/null +++ b/agent_holder/src/offer/mod.rs @@ -0,0 +1,5 @@ +pub mod aggregate; +pub mod command; +pub mod error; +pub mod event; +pub mod queries; diff --git a/agent_holder/src/offer/queries/all_offers.rs b/agent_holder/src/offer/queries/all_offers.rs new file mode 100644 index 00000000..b9696bba --- /dev/null +++ b/agent_holder/src/offer/queries/all_offers.rs @@ -0,0 +1,23 @@ +use super::OfferView; +use crate::offer::queries::Offer; +use cqrs_es::{EventEnvelope, View}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +#[derive(Debug, Default, Serialize, Deserialize, Clone)] +pub struct AllOffersView { + #[serde(flatten)] + pub offers: HashMap, +} + +impl View for AllOffersView { + fn update(&mut self, event: &EventEnvelope) { + self.offers + // Get the entry for the aggregate_id + .entry(event.aggregate_id.clone()) + // or insert a new one if it doesn't exist + .or_default() + // update the view with the event + .update(event); + } +} diff --git a/agent_holder/src/offer/queries/mod.rs b/agent_holder/src/offer/queries/mod.rs new file mode 100644 index 00000000..ad13bde1 --- /dev/null +++ b/agent_holder/src/offer/queries/mod.rs @@ -0,0 +1,53 @@ +pub mod all_offers; + +use super::aggregate::Status; +use crate::offer::aggregate::Offer; +use cqrs_es::{EventEnvelope, View}; +use oid4vci::{ + credential_issuer::credential_configurations_supported::CredentialConfigurationsSupportedObject, + credential_offer::CredentialOfferParameters, token_response::TokenResponse, +}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +#[derive(Debug, Default, Serialize, Deserialize, Clone)] +pub struct OfferView { + pub credential_offer: Option, + pub status: Status, + pub credential_configurations: Option>, + pub token_response: Option, + pub credentials: Vec, +} + +impl View for OfferView { + fn update(&mut self, event: &EventEnvelope) { + use crate::offer::event::OfferEvent::*; + + match &event.payload { + CredentialOfferReceived { + credential_offer, + credential_configurations, + .. + } => { + self.credential_offer.replace(*credential_offer.clone()); + self.credential_configurations + .replace(credential_configurations.clone()); + } + CredentialOfferAccepted { status, .. } => { + self.status.clone_from(status); + } + TokenResponseReceived { token_response, .. } => { + self.token_response.replace(token_response.clone()); + } + CredentialResponseReceived { + status, credentials, .. + } => { + self.status.clone_from(status); + self.credentials.clone_from(credentials); + } + CredentialOfferRejected { status, .. } => { + self.status.clone_from(status); + } + } + } +} diff --git a/agent_holder/src/services.rs b/agent_holder/src/services.rs new file mode 100644 index 00000000..17668375 --- /dev/null +++ b/agent_holder/src/services.rs @@ -0,0 +1,48 @@ +use agent_secret_manager::service::Service; +use agent_shared::config::{config, get_all_enabled_did_methods, get_preferred_did_method}; +use jsonwebtoken::Algorithm; +use oid4vc_core::{Subject, SubjectSyntaxType}; +use oid4vci::Wallet; +use std::sync::Arc; + +/// Holder services. This struct is used to sign credentials and validate credential requests. +pub struct HolderServices { + pub holder: Arc, + pub wallet: Wallet, +} + +impl Service for HolderServices { + fn new(holder: Arc) -> Self { + let signing_algorithms_supported: Vec = config() + .signing_algorithms_supported + .iter() + .filter(|(_, opts)| opts.enabled) + .map(|(alg, _)| *alg) + .collect(); + + let mut enabled_did_methods = get_all_enabled_did_methods(); + let preferred_did_method = get_preferred_did_method(); + enabled_did_methods.sort_by(|a, b| { + if *a == preferred_did_method { + std::cmp::Ordering::Less + } else if *b == preferred_did_method { + std::cmp::Ordering::Greater + } else { + std::cmp::Ordering::Equal + } + }); + + let supported_subject_syntax_types: Vec = + enabled_did_methods.into_iter().map(Into::into).collect(); + + let wallet = Wallet::new( + holder.clone(), + supported_subject_syntax_types, + signing_algorithms_supported, + ) + // TODO: make `Wallet::new` return `Wallet` instead of `Result` + .expect("Failed to create wallet"); + + Self { holder, wallet } + } +} diff --git a/agent_holder/src/state.rs b/agent_holder/src/state.rs new file mode 100644 index 00000000..2ebbddf4 --- /dev/null +++ b/agent_holder/src/state.rs @@ -0,0 +1,57 @@ +use agent_shared::application_state::CommandHandler; +use cqrs_es::persist::ViewRepository; +use std::sync::Arc; + +use crate::credential::aggregate::Credential; +use crate::credential::queries::all_credentials::AllCredentialsView; +use crate::credential::queries::CredentialView; +use crate::offer::aggregate::Offer; +use crate::offer::queries::all_offers::AllOffersView; +use crate::offer::queries::OfferView; + +#[derive(Clone)] +pub struct HolderState { + pub command: CommandHandlers, + pub query: Queries, +} + +/// The command handlers are used to execute commands on the aggregates. +#[derive(Clone)] +pub struct CommandHandlers { + pub credential: CommandHandler, + pub offer: CommandHandler, +} + +/// This type is used to define the queries that are used to query the view repositories. We make use of `dyn` here, so +/// that any type of repository that implements the `ViewRepository` trait can be used, but the corresponding `View` and +/// `Aggregate` types must be the same. +type Queries = ViewRepositories< + dyn ViewRepository, + dyn ViewRepository, + dyn ViewRepository, + dyn ViewRepository, +>; + +pub struct ViewRepositories +where + C1: ViewRepository + ?Sized, + C2: ViewRepository + ?Sized, + O1: ViewRepository + ?Sized, + O2: ViewRepository + ?Sized, +{ + pub credential: Arc, + pub all_credentials: Arc, + pub offer: Arc, + pub all_offers: Arc, +} + +impl Clone for Queries { + fn clone(&self) -> Self { + ViewRepositories { + credential: self.credential.clone(), + all_credentials: self.all_credentials.clone(), + offer: self.offer.clone(), + all_offers: self.all_offers.clone(), + } + } +} diff --git a/agent_issuance/Cargo.toml b/agent_issuance/Cargo.toml index ced77ee2..26be2835 100644 --- a/agent_issuance/Cargo.toml +++ b/agent_issuance/Cargo.toml @@ -9,7 +9,6 @@ agent_shared = { path = "../agent_shared" } agent_secret_manager = { path = "../agent_secret_manager" } async-trait.workspace = true -axum.workspace = true cqrs-es.workspace = true chrono = "0.4" types-ob-v3 = { git = "https://github.com/impierce/digital-credential-data-models.git", rev = "9f16c27" } @@ -22,6 +21,7 @@ jsonwebtoken.workspace = true oid4vci.workspace = true oid4vc-core.workspace = true oid4vc-manager.workspace = true +reqwest.workspace = true serde.workspace = true serde_json.workspace = true thiserror.workspace = true @@ -30,16 +30,25 @@ tracing.workspace = true url.workspace = true uuid.workspace = true +# `test_utils` dependencies +lazy_static = { workspace = true, optional = true } +once_cell = { workspace = true, optional = true } +rstest = { workspace = true, optional = true } + [dev-dependencies] +agent_holder = { path = "../agent_holder", features = ["test_utils"] } agent_issuance = { path = ".", features = ["test_utils"] } +agent_secret_manager = { path = "../agent_secret_manager", features = ["test_utils"] } agent_shared = { path = "../agent_shared", features = ["test_utils"] } did_manager.workspace = true -lazy_static.workspace = true serial_test = "3.0" tracing-test.workspace = true async-std = { version = "1.5", features = ["attributes", "tokio1"] } -rstest.workspace = true [features] -test_utils = [] +test_utils = [ + "dep:lazy_static", + "dep:rstest", + "dep:once_cell" +] diff --git a/agent_issuance/src/credential/aggregate.rs b/agent_issuance/src/credential/aggregate.rs index 9a9a5533..b64ee7da 100644 --- a/agent_issuance/src/credential/aggregate.rs +++ b/agent_issuance/src/credential/aggregate.rs @@ -1,3 +1,8 @@ +use super::entity::Data; +use crate::credential::command::CredentialCommand; +use crate::credential::error::CredentialError::{self}; +use crate::credential::event::CredentialEvent; +use crate::services::IssuanceServices; use agent_shared::config::{config, get_preferred_did_method, get_preferred_signing_algorithm}; use async_trait::async_trait; use cqrs_es::Aggregate; @@ -23,13 +28,6 @@ use types_ob_v3::prelude::{ ProfileBuilder, }; -use crate::credential::command::CredentialCommand; -use crate::credential::error::CredentialError::{self}; -use crate::credential::event::CredentialEvent; -use crate::services::IssuanceServices; - -use super::entity::Data; - #[derive(Debug, Clone, Serialize, Deserialize, Default, Derivative)] #[derivative(PartialEq)] pub struct Credential { @@ -257,14 +255,12 @@ impl Aggregate for Credential { #[cfg(test)] pub mod credential_tests { - use std::collections::HashMap; - + use super::test_utils::*; use super::*; + use agent_secret_manager::service::Service; use jsonwebtoken::Algorithm; - use lazy_static::lazy_static; - use oid4vci::proof::KeyProofMetadata; - use oid4vci::ProofType; + use rstest::rstest; use serde_json::json; @@ -272,8 +268,7 @@ pub mod credential_tests { use crate::credential::aggregate::Credential; use crate::credential::event::CredentialEvent; - use crate::offer::aggregate::tests::SUBJECT_KEY_DID; - use crate::services::test_utils::test_issuance_services; + use crate::offer::aggregate::test_utils::SUBJECT_KEY_DID; type CredentialTestFramework = TestFramework; @@ -294,7 +289,7 @@ pub mod credential_tests { #[case] credential_configuration: CredentialConfigurationsSupportedObject, #[case] unsigned_credential: serde_json::Value, ) { - CredentialTestFramework::with(test_issuance_services()) + CredentialTestFramework::with(Service::default()) .given_no_previous_events() .when(CredentialCommand::CreateUnsignedCredential { data: Data { @@ -327,7 +322,7 @@ pub mod credential_tests { #[case] credential_configuration: CredentialConfigurationsSupportedObject, #[case] verifiable_credential_jwt: String, ) { - CredentialTestFramework::with(test_issuance_services()) + CredentialTestFramework::with(Service::default()) .given(vec![CredentialEvent::UnsignedCredentialCreated { data: Data { raw: unsigned_credential, @@ -342,9 +337,29 @@ pub mod credential_tests { signed_credential: json!(verifiable_credential_jwt), }]) } +} + +#[cfg(feature = "test_utils")] +pub mod test_utils { + use super::*; + use jsonwebtoken::Algorithm; + use lazy_static::lazy_static; + use oid4vci::{ + credential_format_profiles::{ + w3c_verifiable_credentials::jwt_vc_json::CredentialDefinition, CredentialFormats, Parameters, + }, + proof::KeyProofMetadata, + ProofType, + }; + use serde_json::json; + use std::collections::HashMap; + + pub const OPENBADGE_VERIFIABLE_CREDENTIAL_JWT: &str = "eyJ0eXAiOiJKV1QiLCJhbGciOiJFZERTQSIsImtpZCI6ImRpZDprZXk6ejZNa2dFODROQ01wTWVBeDlqSzljZjVXNEc4Z2NaOXh1d0p2RzFlN3dOazhLQ2d0I3o2TWtnRTg0TkNNcE1lQXg5aks5Y2Y1VzRHOGdjWjl4dXdKdkcxZTd3Tms4S0NndCJ9.eyJpc3MiOiJkaWQ6a2V5Ono2TWtnRTg0TkNNcE1lQXg5aks5Y2Y1VzRHOGdjWjl4dXdKdkcxZTd3Tms4S0NndCIsInN1YiI6ImRpZDprZXk6ejZNa2dFODROQ01wTWVBeDlqSzljZjVXNEc4Z2NaOXh1d0p2RzFlN3dOazhLQ2d0IiwiZXhwIjo5OTk5OTk5OTk5LCJpYXQiOjAsInZjIjp7IkBjb250ZXh0IjpbImh0dHBzOi8vd3d3LnczLm9yZy8yMDE4L2NyZWRlbnRpYWxzL3YxIiwiaHR0cHM6Ly9wdXJsLmltc2dsb2JhbC5vcmcvc3BlYy9vYi92M3AwL2NvbnRleHQtMy4wLjIuanNvbiJdLCJpZCI6Imh0dHA6Ly9leGFtcGxlLmNvbS9jcmVkZW50aWFscy8zNTI3IiwidHlwZSI6WyJWZXJpZmlhYmxlQ3JlZGVudGlhbCIsIk9wZW5CYWRnZUNyZWRlbnRpYWwiXSwiaXNzdWVyIjoiZGlkOmtleTp6Nk1rZ0U4NE5DTXBNZUF4OWpLOWNmNVc0RzhnY1o5eHV3SnZHMWU3d05rOEtDZ3QiLCJpc3N1YW5jZURhdGUiOiIyMDEwLTAxLTAxVDAwOjAwOjAwWiIsIm5hbWUiOiJUZWFtd29yayBCYWRnZSIsImNyZWRlbnRpYWxTdWJqZWN0Ijp7ImlkIjoiZGlkOmtleTp6Nk1rZ0U4NE5DTXBNZUF4OWpLOWNmNVc0RzhnY1o5eHV3SnZHMWU3d05rOEtDZ3QiLCJ0eXBlIjpbIkFjaGlldmVtZW50U3ViamVjdCJdLCJhY2hpZXZlbWVudCI6eyJpZCI6Imh0dHBzOi8vZXhhbXBsZS5jb20vYWNoaWV2ZW1lbnRzLzIxc3QtY2VudHVyeS1za2lsbHMvdGVhbXdvcmsiLCJ0eXBlIjoiQWNoaWV2ZW1lbnQiLCJjcml0ZXJpYSI6eyJuYXJyYXRpdmUiOiJUZWFtIG1lbWJlcnMgYXJlIG5vbWluYXRlZCBmb3IgdGhpcyBiYWRnZSBieSB0aGVpciBwZWVycyBhbmQgcmVjb2duaXplZCB1cG9uIHJldmlldyBieSBFeGFtcGxlIENvcnAgbWFuYWdlbWVudC4ifSwiZGVzY3JpcHRpb24iOiJUaGlzIGJhZGdlIHJlY29nbml6ZXMgdGhlIGRldmVsb3BtZW50IG9mIHRoZSBjYXBhY2l0eSB0byBjb2xsYWJvcmF0ZSB3aXRoaW4gYSBncm91cCBlbnZpcm9ubWVudC4iLCJuYW1lIjoiVGVhbXdvcmsifX19fQ.SkC7IvpBGB9e98eobnE9qcLjs-yoZup3cieBla3DRTlcRezXEDPv4YRoUgffho9LJ0rkmfFPsPwb-owXMWyPAA"; + + pub const W3C_VC_VERIFIABLE_CREDENTIAL_JWT: &str = "eyJ0eXAiOiJKV1QiLCJhbGciOiJFZERTQSIsImtpZCI6ImRpZDprZXk6ejZNa2dFODROQ01wTWVBeDlqSzljZjVXNEc4Z2NaOXh1d0p2RzFlN3dOazhLQ2d0I3o2TWtnRTg0TkNNcE1lQXg5aks5Y2Y1VzRHOGdjWjl4dXdKdkcxZTd3Tms4S0NndCJ9.eyJpc3MiOiJkaWQ6a2V5Ono2TWtnRTg0TkNNcE1lQXg5aks5Y2Y1VzRHOGdjWjl4dXdKdkcxZTd3Tms4S0NndCIsInN1YiI6ImRpZDprZXk6ejZNa2dFODROQ01wTWVBeDlqSzljZjVXNEc4Z2NaOXh1d0p2RzFlN3dOazhLQ2d0IiwiZXhwIjo5OTk5OTk5OTk5LCJpYXQiOjAsInZjIjp7IkBjb250ZXh0IjoiaHR0cHM6Ly93d3cudzMub3JnLzIwMTgvY3JlZGVudGlhbHMvdjEiLCJ0eXBlIjpbIlZlcmlmaWFibGVDcmVkZW50aWFsIl0sImNyZWRlbnRpYWxTdWJqZWN0Ijp7ImlkIjoiZGlkOmtleTp6Nk1rZ0U4NE5DTXBNZUF4OWpLOWNmNVc0RzhnY1o5eHV3SnZHMWU3d05rOEtDZ3QiLCJmaXJzdF9uYW1lIjoiRmVycmlzIiwibGFzdF9uYW1lIjoiUnVzdGFjZWFuIiwiZGVncmVlIjp7InR5cGUiOiJNYXN0ZXJEZWdyZWUiLCJuYW1lIjoiTWFzdGVyIG9mIE9jZWFub2dyYXBoeSJ9fSwiaXNzdWVyIjoiZGlkOmtleTp6Nk1rZ0U4NE5DTXBNZUF4OWpLOWNmNVc0RzhnY1o5eHV3SnZHMWU3d05rOEtDZ3QiLCJpc3N1YW5jZURhdGUiOiIyMDEwLTAxLTAxVDAwOjAwOjAwWiJ9fQ.MUDBbPJfXe0G9sjVTF3RuR6ukRM0d4N57iMGNFcIKMFPIEdig12v-YFB0qfnSghGcQo8hUw3jzxZXTSJATEgBg"; lazy_static! { - static ref OPENBADGE_CREDENTIAL_CONFIGURATION: CredentialConfigurationsSupportedObject = + pub static ref OPENBADGE_CREDENTIAL_CONFIGURATION: CredentialConfigurationsSupportedObject = CredentialConfigurationsSupportedObject { credential_format: CredentialFormats::JwtVcJson(Parameters { parameters: ( @@ -357,7 +372,6 @@ pub mod credential_tests { .into(), }), cryptographic_binding_methods_supported: vec![ - "did:key".to_string(), "did:key".to_string(), "did:iota:rms".to_string(), "did:jwk".to_string(), @@ -377,7 +391,7 @@ pub mod credential_tests { })], ..Default::default() }; - static ref W3C_VC_CREDENTIAL_CONFIGURATION: CredentialConfigurationsSupportedObject = + pub static ref W3C_VC_CREDENTIAL_CONFIGURATION: CredentialConfigurationsSupportedObject = CredentialConfigurationsSupportedObject { credential_format: CredentialFormats::JwtVcJson(Parameters { parameters: ( @@ -390,10 +404,9 @@ pub mod credential_tests { .into(), }), cryptographic_binding_methods_supported: vec![ - "did:key".to_string(), - "did:key".to_string(), "did:iota:rms".to_string(), "did:jwk".to_string(), + "did:key".to_string(), ], credential_signing_alg_values_supported: vec!["EdDSA".to_string()], proof_types_supported: HashMap::from_iter(vec![( @@ -403,14 +416,16 @@ pub mod credential_tests { }, )]), display: vec![json!({ - "name": "Master Degree", + "locale": "en", + "name": "Verifiable Credential", "logo": { - "url": "https://example.com/logo.png" + "uri": "https://impierce.com/images/logo-blue.png", + "alt_text": "UniCore Logo", } })], ..Default::default() }; - static ref OPENBADGE_CREDENTIAL_SUBJECT: serde_json::Value = json!( + pub static ref OPENBADGE_CREDENTIAL_SUBJECT: serde_json::Value = json!( { "credentialSubject": { "type": [ "AchievementSubject" ], @@ -426,7 +441,7 @@ pub mod credential_tests { } } ); - static ref W3C_VC_CREDENTIAL_SUBJECT: serde_json::Value = json!( + pub static ref W3C_VC_CREDENTIAL_SUBJECT: serde_json::Value = json!( { "credentialSubject": { "first_name": "Ferris", @@ -438,7 +453,7 @@ pub mod credential_tests { } } ); - static ref UNSIGNED_OPENBADGE_CREDENTIAL: serde_json::Value = json!({ + pub static ref UNSIGNED_OPENBADGE_CREDENTIAL: serde_json::Value = json!({ "@context": [ "https://www.w3.org/2018/credentials/v1", "https://purl.imsglobal.org/spec/ob/v3p0/context-3.0.2.json" @@ -454,7 +469,7 @@ pub mod credential_tests { "name": "Teamwork Badge", "credentialSubject": OPENBADGE_CREDENTIAL_SUBJECT["credentialSubject"].clone(), }); - static ref UNSIGNED_W3C_VC_CREDENTIAL: serde_json::Value = json!({ + pub static ref UNSIGNED_W3C_VC_CREDENTIAL: serde_json::Value = json!({ "@context": "https://www.w3.org/2018/credentials/v1", "type": [ "VerifiableCredential" ], "credentialSubject": W3C_VC_CREDENTIAL_SUBJECT["credentialSubject"].clone(), @@ -465,8 +480,4 @@ pub mod credential_tests { "issuanceDate": "2010-01-01T00:00:00Z" }); } - - pub const OPENBADGE_VERIFIABLE_CREDENTIAL_JWT: &str = "eyJ0eXAiOiJKV1QiLCJhbGciOiJFZERTQSIsImtpZCI6ImRpZDprZXk6ejZNa2dFODROQ01wTWVBeDlqSzljZjVXNEc4Z2NaOXh1d0p2RzFlN3dOazhLQ2d0I3o2TWtnRTg0TkNNcE1lQXg5aks5Y2Y1VzRHOGdjWjl4dXdKdkcxZTd3Tms4S0NndCJ9.eyJpc3MiOiJkaWQ6a2V5Ono2TWtnRTg0TkNNcE1lQXg5aks5Y2Y1VzRHOGdjWjl4dXdKdkcxZTd3Tms4S0NndCIsInN1YiI6ImRpZDprZXk6ejZNa2dFODROQ01wTWVBeDlqSzljZjVXNEc4Z2NaOXh1d0p2RzFlN3dOazhLQ2d0IiwiZXhwIjo5OTk5OTk5OTk5LCJpYXQiOjAsInZjIjp7IkBjb250ZXh0IjpbImh0dHBzOi8vd3d3LnczLm9yZy8yMDE4L2NyZWRlbnRpYWxzL3YxIiwiaHR0cHM6Ly9wdXJsLmltc2dsb2JhbC5vcmcvc3BlYy9vYi92M3AwL2NvbnRleHQtMy4wLjIuanNvbiJdLCJpZCI6Imh0dHA6Ly9leGFtcGxlLmNvbS9jcmVkZW50aWFscy8zNTI3IiwidHlwZSI6WyJWZXJpZmlhYmxlQ3JlZGVudGlhbCIsIk9wZW5CYWRnZUNyZWRlbnRpYWwiXSwiaXNzdWVyIjoiZGlkOmtleTp6Nk1rZ0U4NE5DTXBNZUF4OWpLOWNmNVc0RzhnY1o5eHV3SnZHMWU3d05rOEtDZ3QiLCJpc3N1YW5jZURhdGUiOiIyMDEwLTAxLTAxVDAwOjAwOjAwWiIsIm5hbWUiOiJUZWFtd29yayBCYWRnZSIsImNyZWRlbnRpYWxTdWJqZWN0Ijp7ImlkIjoiZGlkOmtleTp6Nk1rZ0U4NE5DTXBNZUF4OWpLOWNmNVc0RzhnY1o5eHV3SnZHMWU3d05rOEtDZ3QiLCJ0eXBlIjpbIkFjaGlldmVtZW50U3ViamVjdCJdLCJhY2hpZXZlbWVudCI6eyJpZCI6Imh0dHBzOi8vZXhhbXBsZS5jb20vYWNoaWV2ZW1lbnRzLzIxc3QtY2VudHVyeS1za2lsbHMvdGVhbXdvcmsiLCJ0eXBlIjoiQWNoaWV2ZW1lbnQiLCJjcml0ZXJpYSI6eyJuYXJyYXRpdmUiOiJUZWFtIG1lbWJlcnMgYXJlIG5vbWluYXRlZCBmb3IgdGhpcyBiYWRnZSBieSB0aGVpciBwZWVycyBhbmQgcmVjb2duaXplZCB1cG9uIHJldmlldyBieSBFeGFtcGxlIENvcnAgbWFuYWdlbWVudC4ifSwiZGVzY3JpcHRpb24iOiJUaGlzIGJhZGdlIHJlY29nbml6ZXMgdGhlIGRldmVsb3BtZW50IG9mIHRoZSBjYXBhY2l0eSB0byBjb2xsYWJvcmF0ZSB3aXRoaW4gYSBncm91cCBlbnZpcm9ubWVudC4iLCJuYW1lIjoiVGVhbXdvcmsifX19fQ.SkC7IvpBGB9e98eobnE9qcLjs-yoZup3cieBla3DRTlcRezXEDPv4YRoUgffho9LJ0rkmfFPsPwb-owXMWyPAA"; - - pub const W3C_VC_VERIFIABLE_CREDENTIAL_JWT: &str = "eyJ0eXAiOiJKV1QiLCJhbGciOiJFZERTQSIsImtpZCI6ImRpZDprZXk6ejZNa2dFODROQ01wTWVBeDlqSzljZjVXNEc4Z2NaOXh1d0p2RzFlN3dOazhLQ2d0I3o2TWtnRTg0TkNNcE1lQXg5aks5Y2Y1VzRHOGdjWjl4dXdKdkcxZTd3Tms4S0NndCJ9.eyJpc3MiOiJkaWQ6a2V5Ono2TWtnRTg0TkNNcE1lQXg5aks5Y2Y1VzRHOGdjWjl4dXdKdkcxZTd3Tms4S0NndCIsInN1YiI6ImRpZDprZXk6ejZNa2dFODROQ01wTWVBeDlqSzljZjVXNEc4Z2NaOXh1d0p2RzFlN3dOazhLQ2d0IiwiZXhwIjo5OTk5OTk5OTk5LCJpYXQiOjAsInZjIjp7IkBjb250ZXh0IjoiaHR0cHM6Ly93d3cudzMub3JnLzIwMTgvY3JlZGVudGlhbHMvdjEiLCJ0eXBlIjpbIlZlcmlmaWFibGVDcmVkZW50aWFsIl0sImNyZWRlbnRpYWxTdWJqZWN0Ijp7ImlkIjoiZGlkOmtleTp6Nk1rZ0U4NE5DTXBNZUF4OWpLOWNmNVc0RzhnY1o5eHV3SnZHMWU3d05rOEtDZ3QiLCJmaXJzdF9uYW1lIjoiRmVycmlzIiwibGFzdF9uYW1lIjoiUnVzdGFjZWFuIiwiZGVncmVlIjp7InR5cGUiOiJNYXN0ZXJEZWdyZWUiLCJuYW1lIjoiTWFzdGVyIG9mIE9jZWFub2dyYXBoeSJ9fSwiaXNzdWVyIjoiZGlkOmtleTp6Nk1rZ0U4NE5DTXBNZUF4OWpLOWNmNVc0RzhnY1o5eHV3SnZHMWU3d05rOEtDZ3QiLCJpc3N1YW5jZURhdGUiOiIyMDEwLTAxLTAxVDAwOjAwOjAwWiJ9fQ.MUDBbPJfXe0G9sjVTF3RuR6ukRM0d4N57iMGNFcIKMFPIEdig12v-YFB0qfnSghGcQo8hUw3jzxZXTSJATEgBg"; } diff --git a/agent_issuance/src/offer/aggregate.rs b/agent_issuance/src/offer/aggregate.rs index ac669a82..3229b808 100644 --- a/agent_issuance/src/offer/aggregate.rs +++ b/agent_issuance/src/offer/aggregate.rs @@ -1,4 +1,3 @@ -use agent_shared::generate_random_string; use async_trait::async_trait; use cqrs_es::Aggregate; use oid4vc_core::Validator; @@ -18,6 +17,7 @@ use crate::services::IssuanceServices; #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct Offer { + pub credential_offer: Option, pub subject_id: Option, pub credential_ids: Vec, pub form_url_encoded_credential_offer: String, @@ -45,33 +45,24 @@ impl Aggregate for Offer { info!("Handling command: {:?}", command); match command { - CreateCredentialOffer { offer_id } => { - #[cfg(test)] + CreateCredentialOffer { + offer_id, + credential_issuer_metadata, + } => { + #[cfg(feature = "test_utils")] let (pre_authorized_code, access_token) = { - let pre_authorized_code = tests::PRE_AUTHORIZED_CODES.lock().unwrap().pop_front().unwrap(); - let access_token = tests::ACCESS_TOKENS.lock().unwrap().pop_front().unwrap(); + let pre_authorized_code = test_utils::pre_authorized_code().await; + let access_token = test_utils::access_token().await; (pre_authorized_code, access_token) }; - #[cfg(not(test))] - let (pre_authorized_code, access_token) = { (generate_random_string(), generate_random_string()) }; + #[cfg(not(feature = "test_utils"))] + let (pre_authorized_code, access_token) = { + ( + agent_shared::generate_random_string(), + agent_shared::generate_random_string(), + ) + }; - Ok(vec![CredentialOfferCreated { - offer_id, - pre_authorized_code, - access_token, - }]) - } - AddCredentials { - offer_id, - credential_ids, - } => Ok(vec![CredentialsAdded { - offer_id, - credential_ids, - }]), - CreateFormUrlEncodedCredentialOffer { - offer_id, - credential_issuer_metadata, - } => { // TODO: This needs to be fixed when we implement Batch credentials. let credentials_supported = credential_issuer_metadata.credential_configurations_supported.clone(); let credential_offer = CredentialOffer::CredentialOffer(Box::new(CredentialOfferParameters { @@ -80,24 +71,55 @@ impl Aggregate for Offer { grants: Some(Grants { authorization_code: None, pre_authorized_code: Some(PreAuthorizedCode { - pre_authorized_code: self.pre_authorized_code.clone(), + pre_authorized_code: pre_authorized_code.clone(), ..Default::default() }), }), })); - Ok(vec![FormUrlEncodedCredentialOfferCreated { + + Ok(vec![CredentialOfferCreated { offer_id, - form_url_encoded_credential_offer: credential_offer.to_string(), + credential_offer, + pre_authorized_code, + access_token, }]) } + AddCredentials { + offer_id, + credential_ids, + } => Ok(vec![CredentialsAdded { + offer_id, + credential_ids, + }]), + CreateFormUrlEncodedCredentialOffer { offer_id } => Ok(vec![FormUrlEncodedCredentialOfferCreated { + offer_id, + form_url_encoded_credential_offer: self + .credential_offer + .as_ref() + .ok_or(MissingCredentialOfferError)? + .to_string(), + }]), + SendCredentialOffer { offer_id, target_url } => { + // TODO: add to `service`? + let client = reqwest::Client::new(); + + client + .get(target_url.clone()) + .json(self.credential_offer.as_ref().ok_or(MissingCredentialOfferError)?) + .send() + .await + .map_err(|e| SendCredentialOfferError(e.to_string()))?; + + Ok(vec![CredentialOfferSent { offer_id, target_url }]) + } CreateTokenResponse { offer_id, token_request, } => { - #[cfg(test)] - let c_nonce = tests::C_NONCES.lock().unwrap().pop_front().unwrap(); - #[cfg(not(test))] - let c_nonce = generate_random_string(); + #[cfg(feature = "test_utils")] + let c_nonce = test_utils::c_nonce().await; + #[cfg(not(feature = "test_utils"))] + let c_nonce = agent_shared::generate_random_string(); match token_request { TokenRequest::PreAuthorizedCode { .. } => Ok(vec![TokenResponseCreated { @@ -123,7 +145,7 @@ impl Aggregate for Offer { } => { let credential_issuer = CredentialIssuer { subject: services.issuer.clone(), - metadata: credential_issuer_metadata, + metadata: *credential_issuer_metadata, authorization_server_metadata: *authorization_server_metadata, }; @@ -180,10 +202,12 @@ impl Aggregate for Offer { CredentialOfferCreated { pre_authorized_code, access_token, + credential_offer, .. } => { self.pre_authorized_code = pre_authorized_code; self.access_token = access_token; + self.credential_offer.replace(credential_offer); } CredentialsAdded { credential_ids, .. } => { self.credential_ids = credential_ids; @@ -194,6 +218,7 @@ impl Aggregate for Offer { } => { self.form_url_encoded_credential_offer = form_url_encoded_credential_offer; } + CredentialOfferSent { .. } => {} CredentialRequestVerified { subject_id, .. } => { self.subject_id.replace(subject_id); } @@ -211,63 +236,61 @@ impl Aggregate for Offer { #[cfg(test)] pub mod tests { - use super::*; - + use super::test_utils::*; + use crate::{ + credential::aggregate::test_utils::OPENBADGE_VERIFIABLE_CREDENTIAL_JWT, server_config::aggregate::test_utils::*, + }; + use agent_secret_manager::service::Service; use cqrs_es::test::TestFramework; use jsonwebtoken::Algorithm; - use lazy_static::lazy_static; + use oid4vc_core::Subject; use oid4vci::{ - credential_format_profiles::{ - w3c_verifiable_credentials::jwt_vc_json::CredentialDefinition, CredentialFormats, Parameters, + credential_issuer::{ + authorization_server_metadata::AuthorizationServerMetadata, + credential_issuer_metadata::CredentialIssuerMetadata, }, credential_request::CredentialRequest, - KeyProofType, ProofType, }; - use rstest::rstest; - use serde_json::json; - use std::{collections::VecDeque, sync::Mutex}; - use crate::{ - credential::aggregate::credential_tests::OPENBADGE_VERIFIABLE_CREDENTIAL_JWT, - server_config::aggregate::server_config_tests::{AUTHORIZATION_SERVER_METADATA, CREDENTIAL_ISSUER_METADATA}, - services::test_utils::test_issuance_services, - }; + use serde_json::json; type OfferTestFramework = TestFramework; - #[test] + #[rstest] #[serial_test::serial] - fn test_create_offer() { - *PRE_AUTHORIZED_CODES.lock().unwrap() = vec![generate_random_string()].into(); - *ACCESS_TOKENS.lock().unwrap() = vec![generate_random_string()].into(); - *C_NONCES.lock().unwrap() = vec![generate_random_string()].into(); - - let subject = test_subject(); - OfferTestFramework::with(test_issuance_services()) + async fn test_create_offer( + #[future(awt)] pre_authorized_code: String, + #[future(awt)] access_token: String, + credential_issuer_metadata: Box, + #[future(awt)] credential_offer: CredentialOffer, + ) { + OfferTestFramework::with(Service::default()) .given_no_previous_events() .when(OfferCommand::CreateCredentialOffer { offer_id: Default::default(), + credential_issuer_metadata, }) .then_expect_events(vec![OfferEvent::CredentialOfferCreated { offer_id: Default::default(), - pre_authorized_code: subject.pre_authorized_code, - access_token: subject.access_token, + credential_offer, + pre_authorized_code, + access_token, }]); } - #[test] + #[rstest] #[serial_test::serial] - fn test_add_credential() { - *PRE_AUTHORIZED_CODES.lock().unwrap() = vec![generate_random_string()].into(); - *ACCESS_TOKENS.lock().unwrap() = vec![generate_random_string()].into(); - *C_NONCES.lock().unwrap() = vec![generate_random_string()].into(); - - let subject = test_subject(); - OfferTestFramework::with(test_issuance_services()) + async fn test_add_credential( + #[future(awt)] pre_authorized_code: String, + #[future(awt)] access_token: String, + #[future(awt)] credential_offer: CredentialOffer, + ) { + OfferTestFramework::with(Service::default()) .given(vec![OfferEvent::CredentialOfferCreated { offer_id: Default::default(), - pre_authorized_code: subject.pre_authorized_code.clone(), - access_token: subject.access_token.clone(), + credential_offer, + pre_authorized_code, + access_token, }]) .when(OfferCommand::AddCredentials { offer_id: Default::default(), @@ -279,20 +302,21 @@ pub mod tests { }]); } - #[test] + #[rstest] #[serial_test::serial] - fn test_create_credential_offer() { - *PRE_AUTHORIZED_CODES.lock().unwrap() = vec![generate_random_string()].into(); - *ACCESS_TOKENS.lock().unwrap() = vec![generate_random_string()].into(); - *C_NONCES.lock().unwrap() = vec![generate_random_string()].into(); - - let subject = test_subject(); - OfferTestFramework::with(test_issuance_services()) + async fn test_create_credential_offer( + #[future(awt)] pre_authorized_code: String, + #[future(awt)] access_token: String, + #[future(awt)] credential_offer: CredentialOffer, + #[future(awt)] form_url_encoded_credential_offer: String, + ) { + OfferTestFramework::with(Service::default()) .given(vec![ OfferEvent::CredentialOfferCreated { offer_id: Default::default(), - pre_authorized_code: subject.pre_authorized_code, - access_token: subject.access_token, + credential_offer, + pre_authorized_code, + access_token, }, OfferEvent::CredentialsAdded { offer_id: Default::default(), @@ -301,28 +325,30 @@ pub mod tests { ]) .when(OfferCommand::CreateFormUrlEncodedCredentialOffer { offer_id: Default::default(), - credential_issuer_metadata: CREDENTIAL_ISSUER_METADATA.clone(), }) .then_expect_events(vec![OfferEvent::FormUrlEncodedCredentialOfferCreated { offer_id: Default::default(), - form_url_encoded_credential_offer: subject.form_url_encoded_credential_offer, + form_url_encoded_credential_offer, }]); } - #[test] + #[rstest] #[serial_test::serial] - fn test_create_token_response() { - *PRE_AUTHORIZED_CODES.lock().unwrap() = vec![generate_random_string()].into(); - *ACCESS_TOKENS.lock().unwrap() = vec![generate_random_string()].into(); - *C_NONCES.lock().unwrap() = vec![generate_random_string()].into(); - - let subject = test_subject(); - OfferTestFramework::with(test_issuance_services()) + async fn test_create_token_response( + #[future(awt)] pre_authorized_code: String, + #[future(awt)] access_token: String, + #[future(awt)] credential_offer: CredentialOffer, + #[future(awt)] form_url_encoded_credential_offer: String, + #[future(awt)] token_request: TokenRequest, + #[future(awt)] token_response: TokenResponse, + ) { + OfferTestFramework::with(Service::default()) .given(vec![ OfferEvent::CredentialOfferCreated { offer_id: Default::default(), - pre_authorized_code: subject.pre_authorized_code.clone(), - access_token: subject.access_token.clone(), + credential_offer, + pre_authorized_code, + access_token, }, OfferEvent::CredentialsAdded { offer_id: Default::default(), @@ -330,33 +356,40 @@ pub mod tests { }, OfferEvent::FormUrlEncodedCredentialOfferCreated { offer_id: Default::default(), - form_url_encoded_credential_offer: subject.form_url_encoded_credential_offer.clone(), + form_url_encoded_credential_offer, }, ]) .when(OfferCommand::CreateTokenResponse { offer_id: Default::default(), - token_request: token_request(subject.clone()), + token_request, }) .then_expect_events(vec![OfferEvent::TokenResponseCreated { offer_id: Default::default(), - token_response: token_response(subject), + token_response, }]); } + #[allow(clippy::too_many_arguments)] #[rstest] #[serial_test::serial] - async fn test_verify_credential_response() { - *PRE_AUTHORIZED_CODES.lock().unwrap() = vec![generate_random_string()].into(); - *ACCESS_TOKENS.lock().unwrap() = vec![generate_random_string()].into(); - *C_NONCES.lock().unwrap() = vec![generate_random_string()].into(); - - let subject = test_subject(); - OfferTestFramework::with(test_issuance_services()) + async fn test_verify_credential_response( + holder: &Arc, + #[future(awt)] pre_authorized_code: String, + #[future(awt)] access_token: String, + #[future(awt)] credential_offer: CredentialOffer, + #[future(awt)] form_url_encoded_credential_offer: String, + #[future(awt)] token_response: TokenResponse, + #[future(awt)] credential_request: CredentialRequest, + credential_issuer_metadata: Box, + authorization_server_metadata: Box, + ) { + OfferTestFramework::with(Service::default()) .given(vec![ OfferEvent::CredentialOfferCreated { offer_id: Default::default(), - pre_authorized_code: subject.pre_authorized_code.clone(), - access_token: subject.access_token.clone(), + credential_offer, + pre_authorized_code, + access_token, }, OfferEvent::CredentialsAdded { offer_id: Default::default(), @@ -364,39 +397,43 @@ pub mod tests { }, OfferEvent::FormUrlEncodedCredentialOfferCreated { offer_id: Default::default(), - form_url_encoded_credential_offer: subject.form_url_encoded_credential_offer.clone(), + form_url_encoded_credential_offer, }, OfferEvent::TokenResponseCreated { offer_id: Default::default(), - token_response: token_response(subject.clone()), + token_response, }, ]) .when(OfferCommand::VerifyCredentialRequest { offer_id: Default::default(), - credential_issuer_metadata: CREDENTIAL_ISSUER_METADATA.clone(), - authorization_server_metadata: AUTHORIZATION_SERVER_METADATA.clone(), - credential_request: credential_request(subject.clone()).await, + credential_issuer_metadata, + authorization_server_metadata, + credential_request, }) .then_expect_events(vec![OfferEvent::CredentialRequestVerified { offer_id: Default::default(), - subject_id: SUBJECT_KEY_DID.identifier("did:key", Algorithm::EdDSA).await.unwrap(), + subject_id: holder.identifier("did:key", Algorithm::EdDSA).await.unwrap(), }]); } #[rstest] #[serial_test::serial] - async fn test_create_credential_response() { - *PRE_AUTHORIZED_CODES.lock().unwrap() = vec![generate_random_string()].into(); - *ACCESS_TOKENS.lock().unwrap() = vec![generate_random_string()].into(); - *C_NONCES.lock().unwrap() = vec![generate_random_string()].into(); - - let subject = test_subject(); - OfferTestFramework::with(test_issuance_services()) + async fn test_create_credential_response( + holder: &Arc, + #[future(awt)] pre_authorized_code: String, + #[future(awt)] access_token: String, + #[future(awt)] credential_offer: CredentialOffer, + #[future(awt)] form_url_encoded_credential_offer: String, + #[future(awt)] token_response: TokenResponse, + credential_response: CredentialResponse, + ) { + OfferTestFramework::with(Service::default()) .given(vec![ OfferEvent::CredentialOfferCreated { offer_id: Default::default(), - pre_authorized_code: subject.pre_authorized_code.clone(), - access_token: subject.access_token.clone(), + credential_offer, + pre_authorized_code, + access_token, }, OfferEvent::CredentialsAdded { offer_id: Default::default(), @@ -404,15 +441,15 @@ pub mod tests { }, OfferEvent::FormUrlEncodedCredentialOfferCreated { offer_id: Default::default(), - form_url_encoded_credential_offer: subject.form_url_encoded_credential_offer.clone(), + form_url_encoded_credential_offer, }, OfferEvent::TokenResponseCreated { offer_id: Default::default(), - token_response: token_response(subject.clone()), + token_response, }, OfferEvent::CredentialRequestVerified { offer_id: Default::default(), - subject_id: SUBJECT_KEY_DID.identifier("did:key", Algorithm::EdDSA).await.unwrap(), + subject_id: holder.identifier("did:key", Algorithm::EdDSA).await.unwrap(), }, ]) .when(OfferCommand::CreateCredentialResponse { @@ -421,60 +458,137 @@ pub mod tests { }) .then_expect_events(vec![OfferEvent::CredentialResponseCreated { offer_id: Default::default(), - credential_response: credential_response(subject), + credential_response, }]); } +} - #[derive(Clone)] - struct TestSubject { - subject: Arc, - credential: String, - access_token: String, - pre_authorized_code: String, - form_url_encoded_credential_offer: String, - c_nonce: String, - } +#[cfg(feature = "test_utils")] +pub mod test_utils { + pub use super::*; + use crate::{ + credential::aggregate::test_utils::OPENBADGE_VERIFIABLE_CREDENTIAL_JWT, server_config::aggregate::test_utils::*, + }; + use agent_secret_manager::service::Service; + use agent_shared::generate_random_string; + use jsonwebtoken::Algorithm; + use lazy_static::lazy_static; + use oid4vc_core::Subject; + use oid4vci::{ + credential_format_profiles::{ + w3c_verifiable_credentials::jwt_vc_json::CredentialDefinition, CredentialFormats, Parameters, + }, + credential_issuer::credential_issuer_metadata::CredentialIssuerMetadata, + credential_request::CredentialRequest, + KeyProofType, ProofType, + }; + use once_cell::sync::OnceCell; + pub use rstest::*; + use serde_json::json; + use url::Url; lazy_static! { - pub static ref PRE_AUTHORIZED_CODES: Mutex> = Mutex::new(vec![].into()); - pub static ref ACCESS_TOKENS: Mutex> = Mutex::new(vec![].into()); - pub static ref C_NONCES: Mutex> = Mutex::new(vec![].into()); - pub static ref SUBJECT_KEY_DID: Arc = test_issuance_services().issuer.clone(); + pub static ref SUBJECT_KEY_DID: Arc = IssuanceServices::default().issuer.clone(); } - fn test_subject() -> TestSubject { - let pre_authorized_code = PRE_AUTHORIZED_CODES.lock().unwrap()[0].clone(); + static PRE_AUTHORIZED_CODE: OnceCell = OnceCell::new(); + static ACCESS_TOKEN: OnceCell = OnceCell::new(); + static C_NONCE: OnceCell = OnceCell::new(); - TestSubject { - subject: SUBJECT_KEY_DID.clone(), - credential: OPENBADGE_VERIFIABLE_CREDENTIAL_JWT.to_string(), - pre_authorized_code: pre_authorized_code.clone(), - access_token: ACCESS_TOKENS.lock().unwrap()[0].clone(), - form_url_encoded_credential_offer: format!("openid-credential-offer://?credential_offer=%7B%22credential_issuer%22%3A%22https%3A%2F%2Fexample.com%2F%22%2C%22credential_configuration_ids%22%3A%5B%220%22%5D%2C%22grants%22%3A%7B%22urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Apre-authorized_code%22%3A%7B%22pre-authorized_code%22%3A%22{pre_authorized_code}%22%7D%7D%7D"), - c_nonce: C_NONCES.lock().unwrap()[0].clone(), + #[fixture] + pub async fn pre_authorized_code() -> String { + PRE_AUTHORIZED_CODE.get_or_init(generate_random_string).clone() + } + + #[fixture] + pub async fn access_token() -> String { + ACCESS_TOKEN.get_or_init(generate_random_string).clone() + } + + #[fixture] + pub async fn c_nonce() -> String { + C_NONCE.get_or_init(generate_random_string).clone() + } + + pub struct TestAttributes { + pub pre_authorized_code: String, + pub access_token: String, + pub c_nonce: String, + } + + #[fixture] + pub async fn attributes( + #[future(awt)] pre_authorized_code: String, + #[future(awt)] access_token: String, + #[future(awt)] c_nonce: String, + ) -> TestAttributes { + TestAttributes { + pre_authorized_code, + access_token, + c_nonce, } } - fn token_request(subject: TestSubject) -> TokenRequest { + #[fixture] + #[once] + pub fn holder() -> Arc { + SUBJECT_KEY_DID.clone() + } + + #[fixture] + pub async fn credential_offer( + #[future(awt)] pre_authorized_code: String, + credential_issuer_metadata: Box, + ) -> CredentialOffer { + CredentialOffer::CredentialOffer(Box::new(CredentialOfferParameters { + credential_issuer: credential_issuer_metadata.credential_issuer.clone(), + credential_configuration_ids: credential_issuer_metadata + .credential_configurations_supported + .keys() + .cloned() + .collect(), + grants: Some(Grants { + authorization_code: None, + pre_authorized_code: Some(PreAuthorizedCode { + pre_authorized_code, + ..Default::default() + }), + }), + })) + } + + #[fixture] + pub async fn form_url_encoded_credential_offer(#[future(awt)] pre_authorized_code: String) -> String { + format!("openid-credential-offer://?credential_offer=%7B%22credential_issuer%22%3A%22https%3A%2F%2Fexample.com%2F%22%2C%22credential_configuration_ids%22%3A%5B%22badge%22%5D%2C%22grants%22%3A%7B%22urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Apre-authorized_code%22%3A%7B%22pre-authorized_code%22%3A%22{pre_authorized_code}%22%7D%7D%7D") + } + + #[fixture] + pub async fn token_request(#[future(awt)] pre_authorized_code: String) -> TokenRequest { TokenRequest::PreAuthorizedCode { - pre_authorized_code: subject.pre_authorized_code, + pre_authorized_code, tx_code: None, } } - fn token_response(subject: TestSubject) -> TokenResponse { + #[fixture] + pub async fn token_response(#[future(awt)] access_token: String, #[future(awt)] c_nonce: String) -> TokenResponse { TokenResponse { - access_token: subject.access_token.clone(), + access_token, token_type: "bearer".to_string(), expires_in: None, refresh_token: None, scope: None, - c_nonce: Some(subject.c_nonce.clone()), + c_nonce: Some(c_nonce), c_nonce_expires_in: None, } } - async fn credential_request(subject: TestSubject) -> CredentialRequest { + #[fixture] + pub async fn credential_request( + #[future(awt)] c_nonce: String, + holder: &Arc, + static_issuer_url: &Url, + ) -> CredentialRequest { CredentialRequest { credential_format: CredentialFormats::JwtVcJson(Parameters { parameters: ( @@ -490,11 +604,11 @@ pub mod tests { KeyProofType::builder() .proof_type(ProofType::Jwt) .algorithm(Algorithm::EdDSA) - .signer(subject.subject.clone()) - .iss(subject.subject.identifier("did:key", Algorithm::EdDSA).await.unwrap()) - .aud(CREDENTIAL_ISSUER_METADATA.credential_issuer.clone()) + .signer(holder.clone()) + .iss(holder.identifier("did:key", Algorithm::EdDSA).await.unwrap()) + .aud(static_issuer_url.to_string()) .iat(1571324800) - .nonce(subject.c_nonce.clone()) + .nonce(c_nonce) .subject_syntax_type("did:key") .build() .await @@ -503,10 +617,11 @@ pub mod tests { } } - fn credential_response(subject: TestSubject) -> CredentialResponse { + #[fixture] + pub fn credential_response() -> CredentialResponse { CredentialResponse { credential: CredentialResponseType::Immediate { - credential: json!(subject.credential.clone()), + credential: json!(OPENBADGE_VERIFIABLE_CREDENTIAL_JWT.to_string()), notification_id: None, }, c_nonce: None, diff --git a/agent_issuance/src/offer/command.rs b/agent_issuance/src/offer/command.rs index 8e4e8fc2..1dbb22fe 100644 --- a/agent_issuance/src/offer/command.rs +++ b/agent_issuance/src/offer/command.rs @@ -7,23 +7,28 @@ use oid4vci::{ token_request::TokenRequest, }; use serde::Deserialize; +use url::Url; #[derive(Debug, Deserialize)] #[serde(untagged)] pub enum OfferCommand { CreateCredentialOffer { offer_id: String, + credential_issuer_metadata: Box, }, AddCredentials { offer_id: String, credential_ids: Vec, }, + SendCredentialOffer { + offer_id: String, + target_url: Url, + }, // OpenID4VCI Pre-Authorized Code Flow // TODO: add option for credential_offer_uri (by reference) CreateFormUrlEncodedCredentialOffer { offer_id: String, - credential_issuer_metadata: CredentialIssuerMetadata, }, CreateTokenResponse { offer_id: String, @@ -31,7 +36,7 @@ pub enum OfferCommand { }, VerifyCredentialRequest { offer_id: String, - credential_issuer_metadata: CredentialIssuerMetadata, + credential_issuer_metadata: Box, authorization_server_metadata: Box, credential_request: CredentialRequest, }, diff --git a/agent_issuance/src/offer/error.rs b/agent_issuance/src/offer/error.rs index 3cd038e7..bdbd4528 100644 --- a/agent_issuance/src/offer/error.rs +++ b/agent_issuance/src/offer/error.rs @@ -2,6 +2,10 @@ use thiserror::Error; #[derive(Error, Debug)] pub enum OfferError { + #[error("Credential Offer is missing")] + MissingCredentialOfferError, + #[error("Something went wrong while trying to send the Credential Offer to the `target_url`: {0}")] + SendCredentialOfferError(String), #[error("Credential is missing")] MissingCredentialError, #[error("Missing `Proof` in Credential Request")] diff --git a/agent_issuance/src/offer/event.rs b/agent_issuance/src/offer/event.rs index 9fd2d03c..6e3fb1bf 100644 --- a/agent_issuance/src/offer/event.rs +++ b/agent_issuance/src/offer/event.rs @@ -1,11 +1,15 @@ use cqrs_es::DomainEvent; -use oid4vci::{credential_response::CredentialResponse, token_response::TokenResponse}; +use oid4vci::{ + credential_offer::CredentialOffer, credential_response::CredentialResponse, token_response::TokenResponse, +}; use serde::{Deserialize, Serialize}; +use url::Url; #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] pub enum OfferEvent { CredentialOfferCreated { offer_id: String, + credential_offer: CredentialOffer, pre_authorized_code: String, access_token: String, }, @@ -17,6 +21,10 @@ pub enum OfferEvent { offer_id: String, form_url_encoded_credential_offer: String, }, + CredentialOfferSent { + offer_id: String, + target_url: Url, + }, TokenResponseCreated { offer_id: String, token_response: TokenResponse, @@ -39,6 +47,7 @@ impl DomainEvent for OfferEvent { CredentialOfferCreated { .. } => "CredentialOfferCreated", CredentialsAdded { .. } => "CredentialsAdded", FormUrlEncodedCredentialOfferCreated { .. } => "FormUrlEncodedCredentialOfferCreated", + CredentialOfferSent { .. } => "CredentialOfferSent", TokenResponseCreated { .. } => "TokenResponseCreated", CredentialRequestVerified { .. } => "CredentialRequestVerified", CredentialResponseCreated { .. } => "CredentialResponseCreated", diff --git a/agent_issuance/src/offer/queries/access_token.rs b/agent_issuance/src/offer/queries/access_token.rs index d25935f5..0b33ed95 100644 --- a/agent_issuance/src/offer/queries/access_token.rs +++ b/agent_issuance/src/offer/queries/access_token.rs @@ -1,4 +1,5 @@ -use crate::offer::queries::{CustomQuery, Offer, OfferEvent, ViewRepository}; +use crate::offer::queries::{Offer, OfferEvent, ViewRepository}; +use agent_shared::custom_queries::CustomQuery; use async_trait::async_trait; use cqrs_es::{ persist::{PersistenceError, ViewContext}, @@ -43,7 +44,7 @@ where } #[async_trait] -impl CustomQuery for AccessTokenQuery +impl CustomQuery for AccessTokenQuery where R: ViewRepository, V: View, diff --git a/agent_issuance/src/offer/queries/mod.rs b/agent_issuance/src/offer/queries/mod.rs index 746447b4..806f7240 100644 --- a/agent_issuance/src/offer/queries/mod.rs +++ b/agent_issuance/src/offer/queries/mod.rs @@ -1,33 +1,17 @@ pub mod access_token; pub mod pre_authorized_code; -use async_trait::async_trait; -use cqrs_es::{ - persist::{PersistenceError, ViewContext, ViewRepository}, - EventEnvelope, Query, View, +use super::event::OfferEvent; +use crate::offer::aggregate::Offer; +use cqrs_es::{persist::ViewRepository, EventEnvelope, View}; +use oid4vci::{ + credential_offer::CredentialOffer, credential_response::CredentialResponse, token_response::TokenResponse, }; -use oid4vci::{credential_response::CredentialResponse, token_response::TokenResponse}; use serde::{Deserialize, Serialize}; -use crate::offer::aggregate::Offer; - -use super::event::OfferEvent; - -/// A custom query trait for the Offer aggregate. This trait is used to define custom queries for the Offer aggregate -/// that do not make use of `GenericQuery`. -#[async_trait] -pub trait CustomQuery: Query -where - R: ViewRepository, - V: View, -{ - async fn load_mut(&self, view_id: String) -> Result<(V, ViewContext), PersistenceError>; - - async fn apply_events(&self, view_id: &str, events: &[EventEnvelope]) -> Result<(), PersistenceError>; -} - #[derive(Debug, Default, Serialize, Deserialize, Clone)] pub struct OfferView { + pub credential_offer: Option, pub subject_id: Option, pub credential_ids: Vec, pub pre_authorized_code: String, @@ -62,6 +46,7 @@ impl View for OfferView { } => self .form_url_encoded_credential_offer .clone_from(form_url_encoded_credential_offer), + CredentialOfferSent { .. } => {} CredentialRequestVerified { subject_id, .. } => { self.subject_id.replace(subject_id.clone()); } diff --git a/agent_issuance/src/offer/queries/pre_authorized_code.rs b/agent_issuance/src/offer/queries/pre_authorized_code.rs index 2f96bd13..395f873e 100644 --- a/agent_issuance/src/offer/queries/pre_authorized_code.rs +++ b/agent_issuance/src/offer/queries/pre_authorized_code.rs @@ -1,4 +1,5 @@ -use crate::offer::queries::{CustomQuery, Offer, OfferEvent, ViewRepository}; +use crate::offer::queries::{Offer, OfferEvent, ViewRepository}; +use agent_shared::custom_queries::CustomQuery; use async_trait::async_trait; use cqrs_es::{ persist::{PersistenceError, ViewContext}, @@ -43,7 +44,7 @@ where } #[async_trait] -impl CustomQuery for PreAuthorizedCodeQuery +impl CustomQuery for PreAuthorizedCodeQuery where R: ViewRepository, V: View, diff --git a/agent_issuance/src/server_config/aggregate.rs b/agent_issuance/src/server_config/aggregate.rs index f4284d57..911dc1ae 100644 --- a/agent_issuance/src/server_config/aggregate.rs +++ b/agent_issuance/src/server_config/aggregate.rs @@ -124,7 +124,7 @@ impl Aggregate for ServerConfig { credential_issuer_metadata, } => { self.authorization_server_metadata = *authorization_server_metadata; - self.credential_issuer_metadata = credential_issuer_metadata; + self.credential_issuer_metadata = *credential_issuer_metadata; } CredentialConfigurationAdded { credential_configurations, @@ -135,110 +135,112 @@ impl Aggregate for ServerConfig { #[cfg(test)] pub mod server_config_tests { - use std::collections::HashMap; - + use super::test_utils::*; use super::*; - + use crate::server_config::aggregate::ServerConfig; + use crate::server_config::event::ServerConfigEvent; use agent_shared::config::CredentialConfiguration; - use lazy_static::lazy_static; + use cqrs_es::test::TestFramework; use oid4vci::credential_format_profiles::w3c_verifiable_credentials::jwt_vc_json::JwtVcJson; use oid4vci::credential_format_profiles::{w3c_verifiable_credentials, CredentialFormats, Parameters}; - use oid4vci::credential_issuer::credential_configurations_supported::CredentialConfigurationsSupportedObject; + use rstest::*; use serde_json::json; - use cqrs_es::test::TestFramework; - - use crate::server_config::aggregate::ServerConfig; - use crate::server_config::event::ServerConfigEvent; - type ServerConfigTestFramework = TestFramework; - #[test] - fn test_load_server_metadata() { + #[rstest] + fn test_load_server_metadata( + authorization_server_metadata: Box, + credential_issuer_metadata: Box, + ) { ServerConfigTestFramework::with(()) .given_no_previous_events() .when(ServerConfigCommand::InitializeServerMetadata { - authorization_server_metadata: AUTHORIZATION_SERVER_METADATA.clone(), - credential_issuer_metadata: CREDENTIAL_ISSUER_METADATA.clone(), + authorization_server_metadata: authorization_server_metadata.clone(), + credential_issuer_metadata: credential_issuer_metadata.clone(), }) .then_expect_events(vec![ServerConfigEvent::ServerMetadataInitialized { - authorization_server_metadata: AUTHORIZATION_SERVER_METADATA.clone(), - credential_issuer_metadata: CREDENTIAL_ISSUER_METADATA.clone(), + authorization_server_metadata, + credential_issuer_metadata, }]); } - #[test] - fn test_create_credentials_supported() { + #[rstest] + fn test_create_credentials_supported( + authorization_server_metadata: Box, + credential_issuer_metadata: Box, + ) { ServerConfigTestFramework::with(()) .given(vec![ServerConfigEvent::ServerMetadataInitialized { - authorization_server_metadata: AUTHORIZATION_SERVER_METADATA.clone(), - credential_issuer_metadata: CREDENTIAL_ISSUER_METADATA.clone(), + authorization_server_metadata, + credential_issuer_metadata: credential_issuer_metadata.clone(), }]) .when(ServerConfigCommand::AddCredentialConfiguration { credential_configuration: CredentialConfiguration { - credential_configuration_id: "0".to_string(), + credential_configuration_id: "badge".to_string(), credential_format_with_parameters: CredentialFormats::JwtVcJson(Parameters:: { parameters: w3c_verifiable_credentials::jwt_vc_json::JwtVcJsonParameters { credential_definition: w3c_verifiable_credentials::jwt_vc_json::CredentialDefinition { - type_: vec!["VerifiableCredential".to_string(), "OpenBadgeCredential".to_string()], + type_: vec!["VerifiableCredential".to_string()], credential_subject: Default::default(), }, order: None, }, }), - display: vec![], + display: vec![json!({ + "name": "Verifiable Credential", + "locale": "en", + "logo": { + "uri": "https://impierce.com/images/logo-blue.png", + "alt_text": "UniCore Logo" + } + })], }, }) .then_expect_events(vec![ServerConfigEvent::CredentialConfigurationAdded { - credential_configurations: CREDENTIAL_CONFIGURATIONS_SUPPORTED.clone(), + credential_configurations: credential_issuer_metadata.credential_configurations_supported, }]); } +} - lazy_static! { - static ref BASE_URL: url::Url = "https://example.com/".parse().unwrap(); - static ref CREDENTIAL_CONFIGURATIONS_SUPPORTED: HashMap = - vec![( - "0".to_string(), - serde_json::from_value(json!({ - "format": "jwt_vc_json", - "cryptographic_binding_methods_supported": [ - "did:iota:rms", - "did:jwk", - "did:key", - ], - "credential_signing_alg_values_supported": [ - "EdDSA" - ], - "proof_types_supported": { - "jwt": { - "proof_signing_alg_values_supported": [ - "EdDSA" - ] - } - }, - "credential_definition":{ - "type": [ - "VerifiableCredential", - "OpenBadgeCredential" - ] - } - } - )) - .unwrap() - )] - .into_iter() - .collect(); - pub static ref AUTHORIZATION_SERVER_METADATA: Box = - Box::new(AuthorizationServerMetadata { - issuer: BASE_URL.clone(), - token_endpoint: Some(BASE_URL.join("token").unwrap()), - ..Default::default() - }); - pub static ref CREDENTIAL_ISSUER_METADATA: CredentialIssuerMetadata = CredentialIssuerMetadata { - credential_issuer: BASE_URL.clone(), - credential_endpoint: BASE_URL.join("credential").unwrap(), - batch_credential_endpoint: Some(BASE_URL.join("batch_credential").unwrap()), - credential_configurations_supported: CREDENTIAL_CONFIGURATIONS_SUPPORTED.clone(), +#[cfg(feature = "test_utils")] +pub mod test_utils { + use super::*; + use crate::credential::aggregate::test_utils::W3C_VC_CREDENTIAL_CONFIGURATION; + use oid4vci::credential_issuer::credential_issuer_metadata::CredentialIssuerMetadata; + use rstest::*; + use url::Url; + + #[fixture] + #[once] + pub fn static_issuer_url() -> url::Url { + "https://example.com/".parse().unwrap() + } + + #[fixture] + pub fn credential_configurations_supported() -> HashMap { + HashMap::from_iter(vec![("badge".to_string(), W3C_VC_CREDENTIAL_CONFIGURATION.clone())]) + } + + #[fixture] + pub fn authorization_server_metadata(static_issuer_url: &Url) -> Box { + Box::new(AuthorizationServerMetadata { + issuer: static_issuer_url.clone(), + token_endpoint: Some(static_issuer_url.join("token").unwrap()), + ..Default::default() + }) + } + + #[fixture] + pub fn credential_issuer_metadata( + static_issuer_url: &Url, + credential_configurations_supported: HashMap, + ) -> Box { + Box::new(CredentialIssuerMetadata { + credential_issuer: static_issuer_url.clone(), + credential_endpoint: static_issuer_url.join("credential").unwrap(), + batch_credential_endpoint: Some(static_issuer_url.join("batch_credential").unwrap()), + credential_configurations_supported, ..Default::default() - }; + }) } } diff --git a/agent_issuance/src/server_config/command.rs b/agent_issuance/src/server_config/command.rs index d0367e4a..6f5a5c7f 100644 --- a/agent_issuance/src/server_config/command.rs +++ b/agent_issuance/src/server_config/command.rs @@ -9,7 +9,7 @@ use serde::Deserialize; pub enum ServerConfigCommand { InitializeServerMetadata { authorization_server_metadata: Box, - credential_issuer_metadata: CredentialIssuerMetadata, + credential_issuer_metadata: Box, }, AddCredentialConfiguration { credential_configuration: CredentialConfiguration, diff --git a/agent_issuance/src/server_config/event.rs b/agent_issuance/src/server_config/event.rs index 60583df6..56e6078d 100644 --- a/agent_issuance/src/server_config/event.rs +++ b/agent_issuance/src/server_config/event.rs @@ -12,7 +12,7 @@ use serde::{Deserialize, Serialize}; pub enum ServerConfigEvent { ServerMetadataInitialized { authorization_server_metadata: Box, - credential_issuer_metadata: CredentialIssuerMetadata, + credential_issuer_metadata: Box, }, CredentialConfigurationAdded { credential_configurations: HashMap, diff --git a/agent_issuance/src/server_config/queries.rs b/agent_issuance/src/server_config/queries.rs index 80cf8e1e..72f7177b 100644 --- a/agent_issuance/src/server_config/queries.rs +++ b/agent_issuance/src/server_config/queries.rs @@ -23,7 +23,7 @@ impl View for ServerConfigView { } => { self.authorization_server_metadata = *authorization_server_metadata.clone(); self.credential_issuer_metadata - .replace(credential_issuer_metadata.clone()); + .replace(*credential_issuer_metadata.clone()); } CredentialConfigurationAdded { credential_configurations, diff --git a/agent_issuance/src/services.rs b/agent_issuance/src/services.rs index 42fbc9fe..2e4b4353 100644 --- a/agent_issuance/src/services.rs +++ b/agent_issuance/src/services.rs @@ -1,3 +1,4 @@ +use agent_secret_manager::service::Service; use oid4vc_core::Subject; use std::sync::Arc; @@ -6,24 +7,8 @@ pub struct IssuanceServices { pub issuer: Arc, } -impl IssuanceServices { - pub fn new(issuer: Arc) -> Self { +impl Service for IssuanceServices { + fn new(issuer: Arc) -> Self { Self { issuer } } } - -#[cfg(feature = "test_utils")] -pub mod test_utils { - use agent_secret_manager::secret_manager; - use agent_secret_manager::subject::Subject; - - use super::*; - - pub fn test_issuance_services() -> Arc { - Arc::new(IssuanceServices::new(Arc::new(futures::executor::block_on(async { - Subject { - secret_manager: Arc::new(tokio::sync::Mutex::new(secret_manager().await)), - } - })))) - } -} diff --git a/agent_issuance/src/startup_commands.rs b/agent_issuance/src/startup_commands.rs index 2423cd50..6f1ae9a1 100644 --- a/agent_issuance/src/startup_commands.rs +++ b/agent_issuance/src/startup_commands.rs @@ -23,12 +23,12 @@ pub fn load_server_metadata(base_url: url::Url) -> ServerConfigCommand { token_endpoint: Some(base_url.append_path_segment("auth/token")), ..Default::default() }), - credential_issuer_metadata: CredentialIssuerMetadata { + credential_issuer_metadata: Box::new(CredentialIssuerMetadata { credential_issuer: base_url.clone(), credential_endpoint: base_url.append_path_segment("openid4vci/credential"), display, ..Default::default() - }, + }), } } diff --git a/agent_issuance/src/state.rs b/agent_issuance/src/state.rs index 7f372c42..4384deb6 100644 --- a/agent_issuance/src/state.rs +++ b/agent_issuance/src/state.rs @@ -13,7 +13,6 @@ use crate::offer::queries::OfferView; use crate::server_config::aggregate::ServerConfig; use crate::server_config::command::ServerConfigCommand; use crate::server_config::queries::ServerConfigView; -use axum::extract::FromRef; #[derive(Clone)] pub struct IssuanceState { @@ -21,12 +20,6 @@ pub struct IssuanceState { pub query: Queries, } -impl FromRef<(IssuanceState, V)> for IssuanceState { - fn from_ref(application_state: &(IssuanceState, V)) -> IssuanceState { - application_state.0.clone() - } -} - /// The command handlers are used to execute commands on the aggregates. #[derive(Clone)] pub struct CommandHandlers { diff --git a/agent_secret_manager/Cargo.toml b/agent_secret_manager/Cargo.toml index e257bc3e..4c3f37be 100644 --- a/agent_secret_manager/Cargo.toml +++ b/agent_secret_manager/Cargo.toml @@ -12,6 +12,7 @@ async-trait = "0.1" base64.workspace = true cqrs-es = "0.4.2" did_manager.workspace = true +futures.workspace = true identity_iota.workspace = true jsonwebtoken = "9.3" log = "0.4" @@ -28,3 +29,6 @@ agent_shared = { path = "../agent_shared", features = ["test_utils"] } futures.workspace = true lazy_static.workspace = true ring = "0.17.8" + +[features] +test_utils = [] diff --git a/agent_secret_manager/src/lib.rs b/agent_secret_manager/src/lib.rs index f622555f..2b0bcbba 100644 --- a/agent_secret_manager/src/lib.rs +++ b/agent_secret_manager/src/lib.rs @@ -2,6 +2,7 @@ use agent_shared::config::{config, get_all_enabled_did_methods, SecretManagerCon use did_manager::{InMemoryCache, SecretManager}; use log::info; +pub mod service; pub mod subject; // TODO: find better solution for this diff --git a/agent_secret_manager/src/service.rs b/agent_secret_manager/src/service.rs new file mode 100644 index 00000000..0bcecec4 --- /dev/null +++ b/agent_secret_manager/src/service.rs @@ -0,0 +1,20 @@ +use std::sync::Arc; + +/// Conventience trait for Services like `IssuanceServices`, `HolderServices`, and `VerifierServices`. +pub trait Service { + fn new(subject: Arc) -> Self; + + #[cfg(feature = "test_utils")] + fn default() -> Arc + where + Self: Sized, + { + use crate::{secret_manager, subject::Subject}; + + Arc::new(Self::new(Arc::new(futures::executor::block_on(async { + Subject { + secret_manager: Arc::new(tokio::sync::Mutex::new(secret_manager().await)), + } + })))) + } +} diff --git a/agent_shared/Cargo.toml b/agent_shared/Cargo.toml index 2ef6f8f1..d018d2d1 100644 --- a/agent_shared/Cargo.toml +++ b/agent_shared/Cargo.toml @@ -24,7 +24,7 @@ identity_iota.workspace = true oid4vc-core.workspace = true oid4vci.workspace = true oid4vp.workspace = true -once_cell = { version = "1.19" } +once_cell.workspace = true rand = "0.8" serde.workspace = true serde_json.workspace = true diff --git a/agent_shared/src/config.rs b/agent_shared/src/config.rs index 876e46c9..e08b3a4b 100644 --- a/agent_shared/src/config.rs +++ b/agent_shared/src/config.rs @@ -1,5 +1,6 @@ use config::ConfigError; use identity_iota::did::CoreDID; +use oid4vc_core::SubjectSyntaxType; use oid4vci::credential_format_profiles::{CredentialFormats, WithParameters}; use oid4vp::ClaimFormatDesignation; use once_cell::sync::Lazy; @@ -9,6 +10,7 @@ use std::{ collections::HashMap, sync::{RwLock, RwLockReadGuard}, }; +use strum::VariantArray; use tracing::{debug, info}; use url::Url; @@ -121,6 +123,10 @@ pub struct Events { #[serde(default)] pub offer: Vec, #[serde(default)] + pub holder_credential: Vec, + #[serde(default)] + pub received_offer: Vec, + #[serde(default)] pub connection: Vec, #[serde(default)] pub authorization_request: Vec, @@ -149,6 +155,20 @@ pub enum OfferEvent { CredentialResponseCreated, } +#[derive(Debug, Serialize, Deserialize, Clone, strum::Display)] +pub enum HolderCredentialEvent { + CredentialAdded, +} + +#[derive(Debug, Serialize, Deserialize, Clone, strum::Display)] +pub enum ReceivedOfferEvent { + CredentialOfferReceived, + CredentialOfferAccepted, + TokenResponseReceived, + CredentialResponseReceived, + CredentialOfferRejected, +} + #[derive(Debug, Serialize, Deserialize, Clone, strum::Display)] pub enum ConnectionEvent { SIOPv2AuthorizationResponseVerified, @@ -172,7 +192,18 @@ pub enum AuthorizationRequestEvent { /// assert_eq!(supported_did_method.to_string(), "did:jwk"); /// ``` #[derive( - Debug, Deserialize, Clone, Eq, PartialEq, Hash, strum::EnumString, strum::Display, SerializeDisplay, Ord, PartialOrd, + Debug, + Deserialize, + Clone, + Eq, + PartialEq, + Hash, + strum::EnumString, + strum::Display, + SerializeDisplay, + Ord, + PartialOrd, + VariantArray, )] pub enum SupportedDidMethod { #[serde(alias = "did_jwk", rename = "did_jwk")] @@ -195,6 +226,12 @@ pub enum SupportedDidMethod { IotaRms, } +impl From for SubjectSyntaxType { + fn from(val: SupportedDidMethod) -> Self { + SubjectSyntaxType::try_from(val.to_string().as_str()).expect("convertion into `SubjectSyntaxType` failed") + } +} + /// Generic options that add an "enabled" field and a "preferred" field (optional) to a configuration. #[derive(Debug, Deserialize, Default, Clone)] pub struct ToggleOptions { @@ -334,3 +371,15 @@ pub fn get_preferred_signing_algorithm() -> jsonwebtoken::Algorithm { .cloned() .expect("Please set a signing algorithm as `preferred` in the configuration") } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn all_supported_did_methods_can_be_converted_into_subject_syntax_type() { + for variant in SupportedDidMethod::VARIANTS { + let _subject_syntax_type: SubjectSyntaxType = variant.clone().into(); + } + } +} diff --git a/agent_shared/src/custom_queries.rs b/agent_shared/src/custom_queries.rs new file mode 100644 index 00000000..f327e0a5 --- /dev/null +++ b/agent_shared/src/custom_queries.rs @@ -0,0 +1,88 @@ +use async_trait::async_trait; +use cqrs_es::{ + persist::{PersistenceError, ViewContext, ViewRepository}, + Aggregate, EventEnvelope, Query, View, +}; +use std::marker::PhantomData; +use std::sync::Arc; + +/// A custom query trait. This trait is used to define custom queries for the Aggregates that do not make use of +/// `GenericQuery`. +#[async_trait] +pub trait CustomQuery: Query +where + R: ViewRepository, + V: View, + A: Aggregate, +{ + async fn load_mut(&self, view_id: String) -> Result<(V, ViewContext), PersistenceError>; + + async fn apply_events(&self, view_id: &str, events: &[EventEnvelope]) -> Result<(), PersistenceError>; +} + +/// A struct that lists all the instances of an `Aggregate`. +pub struct ListAllQuery +where + R: ViewRepository, + V: View, + A: Aggregate, +{ + view_id: String, + view_repository: Arc, + _phantom: PhantomData<(V, A)>, +} + +impl ListAllQuery +where + R: ViewRepository, + V: View, + A: Aggregate, +{ + pub fn new(view_repository: Arc, view_id: &str) -> Self { + ListAllQuery { + view_id: view_id.to_string(), + view_repository, + _phantom: PhantomData, + } + } +} + +#[async_trait] +impl Query for ListAllQuery +where + R: ViewRepository, + V: View, + A: Aggregate, +{ + async fn dispatch(&self, _view_id: &str, events: &[EventEnvelope]) { + self.apply_events(&self.view_id, events).await.ok(); + } +} + +#[async_trait] +impl CustomQuery for ListAllQuery +where + R: ViewRepository, + V: View, + A: Aggregate, +{ + async fn load_mut(&self, view_id: String) -> Result<(V, ViewContext), PersistenceError> { + match self.view_repository.load_with_context(&view_id).await? { + None => { + let view_context = ViewContext::new(view_id, 0); + Ok((Default::default(), view_context)) + } + Some((view, context)) => Ok((view, context)), + } + } + + async fn apply_events(&self, view_id: &str, events: &[EventEnvelope]) -> Result<(), PersistenceError> { + for event in events { + let (mut view, view_context) = self.load_mut(view_id.to_string()).await?; + + view.update(event); + self.view_repository.update_view(view, view_context).await?; + } + Ok(()) + } +} diff --git a/agent_shared/src/lib.rs b/agent_shared/src/lib.rs index e678f5bf..6a7d89ed 100644 --- a/agent_shared/src/lib.rs +++ b/agent_shared/src/lib.rs @@ -1,5 +1,6 @@ pub mod application_state; pub mod config; +pub mod custom_queries; pub mod domain_linkage; pub mod error; pub mod generic_query; diff --git a/agent_store/Cargo.toml b/agent_store/Cargo.toml index 05eafeae..90b86f95 100644 --- a/agent_store/Cargo.toml +++ b/agent_store/Cargo.toml @@ -5,6 +5,7 @@ edition.workspace = true rust-version.workspace = true [dependencies] +agent_holder = { path = "../agent_holder" } agent_issuance = { path = "../agent_issuance" } agent_shared = { path = "../agent_shared" } agent_verification = { path = "../agent_verification" } diff --git a/agent_store/src/in_memory.rs b/agent_store/src/in_memory.rs index 7b016c66..5e5fef7d 100644 --- a/agent_store/src/in_memory.rs +++ b/agent_store/src/in_memory.rs @@ -1,3 +1,5 @@ +use crate::{partition_event_publishers, EventPublisher}; +use agent_holder::{services::HolderServices, state::HolderState}; use agent_issuance::{ offer::{ aggregate::Offer, @@ -7,10 +9,10 @@ use agent_issuance::{ }, }, services::IssuanceServices, - state::{CommandHandlers, IssuanceState, ViewRepositories}, + state::{IssuanceState, ViewRepositories}, SimpleLoggingQuery, }; -use agent_shared::{application_state::Command, generic_query::generic_query}; +use agent_shared::{application_state::Command, custom_queries::ListAllQuery, generic_query::generic_query}; use agent_verification::{services::VerificationServices, state::VerificationState}; use async_trait::async_trait; use cqrs_es::{ @@ -21,8 +23,6 @@ use cqrs_es::{ use std::{collections::HashMap, sync::Arc}; use tokio::sync::Mutex; -use crate::{partition_event_publishers, EventPublisher}; - #[derive(Default)] struct MemRepository, A: Aggregate> { pub map: Mutex>, @@ -131,11 +131,11 @@ pub async fn issuance_state( let access_token_query = AccessTokenQuery::new(access_token.clone()); // Partition the event_publishers into the different aggregates. - let (server_config_event_publishers, credential_event_publishers, offer_event_publishers, _, _) = + let (server_config_event_publishers, credential_event_publishers, offer_event_publishers, _, _, _, _) = partition_event_publishers(event_publishers); IssuanceState { - command: CommandHandlers { + command: agent_issuance::state::CommandHandlers { server_config: Arc::new( server_config_event_publishers.into_iter().fold( AggregateHandler::new(()) @@ -173,6 +173,54 @@ pub async fn issuance_state( } } +pub async fn holder_state( + holder_services: Arc, + event_publishers: Vec>, +) -> HolderState { + // Initialize the in-memory repositories. + let credential = Arc::new(MemRepository::default()); + let offer = Arc::new(MemRepository::default()); + let all_credentials = Arc::new(MemRepository::default()); + let all_offers = Arc::new(MemRepository::default()); + + // Create custom-queries for the offer aggregate. + let all_credentials_query = ListAllQuery::new(all_credentials.clone(), "all_credentials"); + let all_offers_query = ListAllQuery::new(all_offers.clone(), "all_offers"); + + // Partition the event_publishers into the different aggregates. + let (_, _, _, credential_event_publishers, offer_event_publishers, _, _) = + partition_event_publishers(event_publishers); + + HolderState { + command: agent_holder::state::CommandHandlers { + credential: Arc::new( + credential_event_publishers.into_iter().fold( + AggregateHandler::new(holder_services.clone()) + .append_query(SimpleLoggingQuery {}) + .append_query(generic_query(credential.clone())) + .append_query(all_credentials_query), + |aggregate_handler, event_publisher| aggregate_handler.append_event_publisher(event_publisher), + ), + ), + offer: Arc::new( + offer_event_publishers.into_iter().fold( + AggregateHandler::new(holder_services.clone()) + .append_query(SimpleLoggingQuery {}) + .append_query(generic_query(offer.clone())) + .append_query(all_offers_query), + |aggregate_handler, event_publisher| aggregate_handler.append_event_publisher(event_publisher), + ), + ), + }, + query: agent_holder::state::ViewRepositories { + credential, + all_credentials, + offer, + all_offers, + }, + } +} + pub async fn verification_state( verification_services: Arc, event_publishers: Vec>, @@ -182,7 +230,7 @@ pub async fn verification_state( let connection = Arc::new(MemRepository::default()); // Partition the event_publishers into the different aggregates. - let (_, _, _, authorization_request_event_publishers, connection_event_publishers) = + let (_, _, _, _, _, authorization_request_event_publishers, connection_event_publishers) = partition_event_publishers(event_publishers); VerificationState { diff --git a/agent_store/src/lib.rs b/agent_store/src/lib.rs index f40f6623..b5baaf80 100644 --- a/agent_store/src/lib.rs +++ b/agent_store/src/lib.rs @@ -10,6 +10,8 @@ pub mod postgres; pub type ServerConfigEventPublisher = Box>; pub type CredentialEventPublisher = Box>; pub type OfferEventPublisher = Box>; +pub type HolderCredentialEventPublisher = Box>; +pub type ReceivedOfferEventPublisher = Box>; pub type AuthorizationRequestEventPublisher = Box>; pub type ConnectionEventPublisher = Box>; @@ -18,6 +20,8 @@ pub type Partitions = ( Vec, Vec, Vec, + Vec, + Vec, Vec, Vec, ); @@ -37,6 +41,13 @@ pub trait EventPublisher { None } + fn holder_credential(&mut self) -> Option { + None + } + fn received_offer(&mut self) -> Option { + None + } + fn connection(&mut self) -> Option { None } @@ -47,7 +58,7 @@ pub trait EventPublisher { pub(crate) fn partition_event_publishers(event_publishers: Vec>) -> Partitions { event_publishers.into_iter().fold( - (vec![], vec![], vec![], vec![], vec![]), + (vec![], vec![], vec![], vec![], vec![], vec![], vec![]), |mut partitions, mut event_publisher| { if let Some(server_config) = event_publisher.server_config() { partitions.0.push(server_config); @@ -59,11 +70,18 @@ pub(crate) fn partition_event_publishers(event_publishers: Vec where A: Aggregate, @@ -84,7 +86,7 @@ pub async fn issuance_state( let access_token_query = AccessTokenQuery::new(access_token.clone()); // Partition the event_publishers into the different aggregates. - let (server_config_event_publishers, credential_event_publishers, offer_event_publishers, _, _) = + let (server_config_event_publishers, credential_event_publishers, offer_event_publishers, _, _, _, _) = partition_event_publishers(event_publishers); IssuanceState { @@ -126,6 +128,61 @@ pub async fn issuance_state( } } +pub async fn holder_state( + holder_services: Arc, + event_publishers: Vec>, +) -> HolderState { + let connection_string = config().event_store.connection_string.clone().expect( + "Missing config parameter `event_store.connection_string` or `UNICORE__EVENT_STORE__CONNECTION_STRING`", + ); + let pool = default_postgress_pool(&connection_string).await; + + // Initialize the postgres repositories. + let credential: Arc> = + Arc::new(PostgresViewRepository::new("holder_credential", pool.clone())); + let all_credentials: Arc> = + Arc::new(PostgresViewRepository::new("all_credentials", pool.clone())); + let offer = Arc::new(PostgresViewRepository::new("received_offer", pool.clone())); + let all_offers = Arc::new(PostgresViewRepository::new("all_offers", pool.clone())); + + // Create custom-queries for the offer aggregate. + let all_credentials_query = ListAllQuery::new(all_credentials.clone(), "all_credentials"); + let all_offers_query = ListAllQuery::new(all_offers.clone(), "all_offers"); + + // Partition the event_publishers into the different aggregates. + let (_, _, _, credential_event_publishers, offer_event_publishers, _, _) = + partition_event_publishers(event_publishers); + + HolderState { + command: agent_holder::state::CommandHandlers { + credential: Arc::new( + credential_event_publishers.into_iter().fold( + AggregateHandler::new(pool.clone(), holder_services.clone()) + .append_query(SimpleLoggingQuery {}) + .append_query(generic_query(credential.clone())) + .append_query(all_credentials_query), + |aggregate_handler, event_publisher| aggregate_handler.append_event_publisher(event_publisher), + ), + ), + offer: Arc::new( + offer_event_publishers.into_iter().fold( + AggregateHandler::new(pool, holder_services.clone()) + .append_query(SimpleLoggingQuery {}) + .append_query(generic_query(offer.clone())) + .append_query(all_offers_query), + |aggregate_handler, event_publisher| aggregate_handler.append_event_publisher(event_publisher), + ), + ), + }, + query: agent_holder::state::ViewRepositories { + credential, + all_credentials, + offer, + all_offers, + }, + } +} + pub async fn verification_state( verification_services: Arc, event_publishers: Vec>, @@ -140,7 +197,7 @@ pub async fn verification_state( let connection = Arc::new(PostgresViewRepository::new("connection", pool.clone())); // Partition the event_publishers into the different aggregates. - let (_, _, _, authorization_request_event_publishers, connection_event_publishers) = + let (_, _, _, _, _, authorization_request_event_publishers, connection_event_publishers) = partition_event_publishers(event_publishers); VerificationState { diff --git a/agent_verification/Cargo.toml b/agent_verification/Cargo.toml index 863d4e71..57907114 100644 --- a/agent_verification/Cargo.toml +++ b/agent_verification/Cargo.toml @@ -10,7 +10,6 @@ agent_shared = { path = "../agent_shared" } anyhow = "1.0" async-trait.workspace = true -axum.workspace = true cqrs-es.workspace = true futures.workspace = true jsonwebtoken.workspace = true diff --git a/agent_verification/src/authorization_request/aggregate.rs b/agent_verification/src/authorization_request/aggregate.rs index e0e6718b..bb14c130 100644 --- a/agent_verification/src/authorization_request/aggregate.rs +++ b/agent_verification/src/authorization_request/aggregate.rs @@ -160,6 +160,7 @@ pub mod tests { use std::str::FromStr; use agent_secret_manager::secret_manager; + use agent_secret_manager::service::Service as _; use agent_secret_manager::subject::Subject; use agent_shared::config::set_config; use agent_shared::config::SupportedDidMethod; @@ -172,8 +173,6 @@ pub mod tests { use rstest::rstest; use serde_json::json; - use crate::services::test_utils::test_verification_services; - use super::*; type AuthorizationRequestTestFramework = TestFramework; @@ -186,7 +185,7 @@ pub mod tests { ) { set_config().set_preferred_did_method(verifier_did_method.clone()); - let verification_services = test_verification_services(); + let verification_services = VerificationServices::default(); let siopv2_client_metadata = verification_services.siopv2_client_metadata.clone(); let oid4vp_client_metadata = verification_services.oid4vp_client_metadata.clone(); @@ -225,7 +224,7 @@ pub mod tests { ) { set_config().set_preferred_did_method(verifier_did_method.clone()); - let verification_services = test_verification_services(); + let verification_services = VerificationServices::default(); let siopv2_client_metadata = verification_services.siopv2_client_metadata.clone(); let oid4vp_client_metadata = verification_services.oid4vp_client_metadata.clone(); diff --git a/agent_verification/src/connection/aggregate.rs b/agent_verification/src/connection/aggregate.rs index 471755fe..3a37b438 100644 --- a/agent_verification/src/connection/aggregate.rs +++ b/agent_verification/src/connection/aggregate.rs @@ -117,7 +117,7 @@ pub mod tests { authorization_request, verifier_did, PRESENTATION_DEFINITION, }; use crate::generic_oid4vc::GenericAuthorizationRequest; - use crate::services::test_utils::test_verification_services; + use agent_secret_manager::service::Service as _; use super::*; @@ -136,7 +136,7 @@ pub mod tests { ) { set_config().set_preferred_did_method(verifier_did_method.clone()); - let verification_services = test_verification_services(); + let verification_services = VerificationServices::default(); let siopv2_client_metadata = verification_services.siopv2_client_metadata.clone(); let oid4vp_client_metadata = verification_services.oid4vp_client_metadata.clone(); diff --git a/agent_verification/src/services.rs b/agent_verification/src/services.rs index 6c605c5e..613b79ec 100644 --- a/agent_verification/src/services.rs +++ b/agent_verification/src/services.rs @@ -1,3 +1,4 @@ +use agent_secret_manager::service::Service; use agent_shared::config::{config, get_all_enabled_did_methods, get_preferred_did_method}; use jsonwebtoken::Algorithm; use oid4vc_core::{client_metadata::ClientMetadataResource, Subject}; @@ -14,8 +15,8 @@ pub struct VerificationServices { pub oid4vp_client_metadata: ClientMetadataResource, } -impl VerificationServices { - pub fn new(verifier: Arc) -> Self { +impl Service for VerificationServices { + fn new(verifier: Arc) -> Self { let client_name = config().display.first().as_ref().map(|display| display.name.clone()); let logo_uri = config() @@ -83,21 +84,3 @@ impl VerificationServices { } } } - -#[cfg(feature = "test_utils")] -pub mod test_utils { - use agent_secret_manager::secret_manager; - use agent_secret_manager::subject::Subject; - - use super::*; - - pub fn test_verification_services() -> Arc { - Arc::new(VerificationServices::new(Arc::new(futures::executor::block_on( - async { - Subject { - secret_manager: Arc::new(tokio::sync::Mutex::new(secret_manager().await)), - } - }, - )))) - } -} diff --git a/agent_verification/src/state.rs b/agent_verification/src/state.rs index 21c7fe62..00acbddf 100644 --- a/agent_verification/src/state.rs +++ b/agent_verification/src/state.rs @@ -7,20 +7,12 @@ use crate::authorization_request::queries::AuthorizationRequestView; use crate::connection::aggregate::Connection; use crate::connection::queries::ConnectionView; -use axum::extract::FromRef; - #[derive(Clone)] pub struct VerificationState { pub command: CommandHandlers, pub query: Queries, } -impl FromRef<(I, VerificationState)> for VerificationState { - fn from_ref(application_state: &(I, VerificationState)) -> VerificationState { - application_state.1.clone() - } -} - /// The command handlers are used to execute commands on the aggregates. #[derive(Clone)] pub struct CommandHandlers {