diff --git a/.env.example b/.env.example index b0a488c7..9602d06e 100644 --- a/.env.example +++ b/.env.example @@ -1,9 +1,12 @@ UNICORE__LOG_FORMAT=text -UNICORE__EVENT_STORE__TYPE=postgres -UNICORE__EVENT_STORE__CONNECTION_STRING="postgresql://demo_user:demo_pass@cqrs-postgres-db:5432/demo" +UNICORE__EVENT_STORE__TYPE=in_memory -UNICORE__SECRET_MANAGER__STRONGHOLD_PATH="agent_secret_manager/tests/res/test.stronghold" +UNICORE__URL="http://localhost:3033" + +UNICORE__SECRET_MANAGER__STRONGHOLD_PATH="agent_secret_manager/tests/res/temp.stronghold" UNICORE__SECRET_MANAGER__STRONGHOLD_PASSWORD="secure_password" -UNICORE__SECRET_MANAGER__ISSUER_EDDSA_KEY_ID="9O66nzWqYYy1LmmiOudOlh2SMIaUWoTS" -UNICORE__SECRET_MANAGER__ISSUER_DID="did:iota:rms:0x42ad588322e58b3c07aa39e4948d021ee17ecb5747915e9e1f35f028d7ecaf90" -UNICORE__SECRET_MANAGER__ISSUER_FRAGMENT="bQKQRzaop7CgEvqVq8UlgLGsdF-R-hnLFkKFZqW2VN0" +# Uncomment to enable DID IOTA for testing purposes +# UNICORE__SECRET_MANAGER__STRONGHOLD_PATH="agent_secret_manager/tests/res/test.stronghold" +# UNICORE__SECRET_MANAGER__ISSUER_EDDSA_KEY_ID="9O66nzWqYYy1LmmiOudOlh2SMIaUWoTS" +# UNICORE__SECRET_MANAGER__ISSUER_DID="did:iota:rms:0x42ad588322e58b3c07aa39e4948d021ee17ecb5747915e9e1f35f028d7ecaf90" +# UNICORE__SECRET_MANAGER__ISSUER_FRAGMENT="bQKQRzaop7CgEvqVq8UlgLGsdF-R-hnLFkKFZqW2VN0" diff --git a/.gitignore b/.gitignore index d43a0f1e..a2fcc431 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,5 @@ **/*.env !**/.env.example + +.DS_Store diff --git a/Cargo.lock b/Cargo.lock index a4ff67e2..79046a59 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -64,6 +64,7 @@ version = "0.1.0" dependencies = [ "agent_event_publisher_http", "agent_holder", + "agent_identity", "agent_issuance", "agent_secret_manager", "agent_shared", @@ -72,9 +73,11 @@ dependencies = [ "axum 0.7.5", "axum-auth 0.7.0", "axum-macros", + "did_manager", "futures", "http-api-problem", "hyper 1.4.1", + "identity_credential", "jsonwebtoken", "lazy_static", "mime", @@ -93,7 +96,6 @@ dependencies = [ "tower", "tower-http 0.5.2", "tracing", - "tracing-subscriber", "tracing-test", "url", "uuid", @@ -107,21 +109,16 @@ dependencies = [ "agent_api_rest", "agent_event_publisher_http", "agent_holder", + "agent_identity", "agent_issuance", "agent_secret_manager", "agent_shared", "agent_store", "agent_verification", "axum 0.7.5", - "did_manager", - "identity_document", - "identity_verification", - "serde_json", "tokio", - "tower-http 0.5.2", "tracing", "tracing-subscriber", - "url", ] [[package]] @@ -142,7 +139,6 @@ dependencies = [ "serde", "serde_json", "serde_with 3.8.1", - "serde_yaml", "tokio", "tracing", "wiremock", @@ -161,8 +157,11 @@ dependencies = [ "async-std", "async-trait", "axum 0.7.5", + "base64 0.22.1", "cqrs-es", "did_manager", + "identity_core", + "identity_credential", "jsonwebtoken", "lazy_static", "mime", @@ -180,6 +179,48 @@ dependencies = [ "tower", "tracing", "tracing-test", + "uuid", +] + +[[package]] +name = "agent_identity" +version = "0.1.0" +dependencies = [ + "agent_api_rest", + "agent_holder", + "agent_identity", + "agent_issuance", + "agent_secret_manager", + "agent_shared", + "agent_store", + "async-std", + "async-trait", + "axum 0.7.5", + "base64 0.22.1", + "cqrs-es", + "derivative", + "did_manager", + "futures", + "identity_core", + "identity_credential", + "identity_did", + "identity_document", + "jsonwebtoken", + "lazy_static", + "mime", + "names", + "oid4vc-core", + "rand 0.8.5", + "reqwest 0.12.5", + "rstest", + "serde", + "serde_json", + "serial_test", + "thiserror", + "tokio", + "tower", + "tracing", + "tracing-test", ] [[package]] @@ -196,14 +237,11 @@ dependencies = [ "cqrs-es", "derivative", "did_manager", - "futures", "identity_core", "identity_credential", - "jsonschema", "jsonwebtoken", "lazy_static", "oid4vc-core", - "oid4vc-manager", "oid4vci", "once_cell", "reqwest 0.12.5", @@ -212,12 +250,10 @@ dependencies = [ "serde_json", "serial_test", "thiserror", - "tokio", "tracing", "tracing-test", "types-ob-v3", "url", - "uuid", ] [[package]] @@ -228,7 +264,6 @@ dependencies = [ "anyhow", "async-trait", "base64 0.22.1", - "cqrs-es", "did_manager", "futures", "identity_iota", @@ -238,7 +273,6 @@ dependencies = [ "oid4vc-core", "p256 0.13.2", "ring", - "serde", "serde_json", "tokio", "url", @@ -249,19 +283,10 @@ name = "agent_shared" version = "0.1.0" dependencies = [ "async-trait", - "base64 0.22.1", "config", "cqrs-es", - "did_manager", "dotenvy", - "identity_core", - "identity_credential", - "identity_did", - "identity_document", "identity_iota", - "identity_storage", - "identity_verification", - "is_empty", "jsonwebtoken", "oid4vc-core", "oid4vci", @@ -271,7 +296,6 @@ dependencies = [ "serde", "serde_json", "serde_with 3.8.1", - "serde_yaml", "strum 0.26.3", "thiserror", "time", @@ -284,6 +308,7 @@ name = "agent_store" version = "0.1.0" dependencies = [ "agent_holder", + "agent_identity", "agent_issuance", "agent_shared", "agent_verification", @@ -1408,7 +1433,7 @@ checksum = "f7144d30dcf0fafbce74250a3963025d8d52177934239851c917d29f1df280c2" [[package]] name = "consumer" version = "0.1.0" -source = "git+https://git@github.com/impierce/did-manager.git?tag=v1.0.0-beta.2#6880b7f81b23e6cf1bbdc76d1dea8d2c1f6ef559" +source = "git+https://git@github.com/impierce/did-manager.git?tag=v1.0.0-beta.3#3ad5e3dba7bc76df8d6cb4a4fd2df2238d88710b" dependencies = [ "did_iota", "did_jwk", @@ -1974,7 +1999,7 @@ dependencies = [ [[package]] name = "did_iota" version = "0.1.0" -source = "git+https://git@github.com/impierce/did-manager.git?tag=v1.0.0-beta.2#6880b7f81b23e6cf1bbdc76d1dea8d2c1f6ef559" +source = "git+https://git@github.com/impierce/did-manager.git?tag=v1.0.0-beta.3#3ad5e3dba7bc76df8d6cb4a4fd2df2238d88710b" dependencies = [ "bls12_381_plus 0.8.15", "identity_iota", @@ -1988,7 +2013,7 @@ dependencies = [ [[package]] name = "did_jwk" version = "0.1.0" -source = "git+https://git@github.com/impierce/did-manager.git?tag=v1.0.0-beta.2#6880b7f81b23e6cf1bbdc76d1dea8d2c1f6ef559" +source = "git+https://git@github.com/impierce/did-manager.git?tag=v1.0.0-beta.3#3ad5e3dba7bc76df8d6cb4a4fd2df2238d88710b" dependencies = [ "did-jwk", "identity_iota", @@ -2005,7 +2030,7 @@ dependencies = [ [[package]] name = "did_key" version = "0.1.0" -source = "git+https://git@github.com/impierce/did-manager.git?tag=v1.0.0-beta.2#6880b7f81b23e6cf1bbdc76d1dea8d2c1f6ef559" +source = "git+https://git@github.com/impierce/did-manager.git?tag=v1.0.0-beta.3#3ad5e3dba7bc76df8d6cb4a4fd2df2238d88710b" dependencies = [ "did-method-key", "identity_iota", @@ -2023,7 +2048,7 @@ dependencies = [ [[package]] name = "did_manager" version = "0.1.0" -source = "git+https://git@github.com/impierce/did-manager.git?tag=v1.0.0-beta.2#6880b7f81b23e6cf1bbdc76d1dea8d2c1f6ef559" +source = "git+https://git@github.com/impierce/did-manager.git?tag=v1.0.0-beta.3#3ad5e3dba7bc76df8d6cb4a4fd2df2238d88710b" dependencies = [ "consumer", "producer", @@ -2051,7 +2076,7 @@ dependencies = [ [[package]] name = "did_web" version = "0.1.0" -source = "git+https://git@github.com/impierce/did-manager.git?tag=v1.0.0-beta.2#6880b7f81b23e6cf1bbdc76d1dea8d2c1f6ef559" +source = "git+https://git@github.com/impierce/did-manager.git?tag=v1.0.0-beta.3#3ad5e3dba7bc76df8d6cb4a4fd2df2238d88710b" dependencies = [ "did-web", "identity_iota", @@ -2070,7 +2095,7 @@ dependencies = [ [[package]] name = "dif-presentation-exchange" version = "0.1.0" -source = "git+https://git@github.com/impierce/openid4vc.git?rev=23facd4#23facd4946e4b2e54ef87f42746c68ad952ae205" +source = "git+https://git@github.com/impierce/openid4vc.git?rev=12fed14#12fed1411ff3c0e1797090f386e44694f7a279b8" dependencies = [ "getset", "jsonpath_lib", @@ -2849,7 +2874,7 @@ dependencies = [ "futures-sink", "futures-util", "http 0.2.12", - "indexmap 2.2.6", + "indexmap 2.5.0", "slab", "tokio", "tokio-util", @@ -2868,7 +2893,7 @@ dependencies = [ "futures-core", "futures-sink", "http 1.1.0", - "indexmap 2.2.6", + "indexmap 2.5.0", "slab", "tokio", "tokio-util", @@ -3292,7 +3317,7 @@ dependencies = [ "identity_did", "identity_document", "identity_verification", - "indexmap 2.2.6", + "indexmap 2.5.0", "itertools 0.11.0", "once_cell", "roaring", @@ -3329,7 +3354,7 @@ dependencies = [ "identity_core", "identity_did", "identity_verification", - "indexmap 2.2.6", + "indexmap 2.5.0", "serde", "strum 0.25.0", "thiserror", @@ -3425,13 +3450,10 @@ dependencies = [ "identity_document", "identity_iota_core", "identity_verification", - "iota-crypto", - "rand 0.8.5", "seahash", "serde", "serde_json", "thiserror", - "tokio", ] [[package]] @@ -3454,7 +3476,7 @@ dependencies = [ [[package]] name = "identity_stronghold_ext" version = "0.1.0" -source = "git+https://git@github.com/impierce/did-manager.git?tag=v1.0.0-beta.2#6880b7f81b23e6cf1bbdc76d1dea8d2c1f6ef559" +source = "git+https://git@github.com/impierce/did-manager.git?tag=v1.0.0-beta.3#3ad5e3dba7bc76df8d6cb4a4fd2df2238d88710b" dependencies = [ "async-trait", "elliptic-curve 0.13.8", @@ -3537,9 +3559,9 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.2.6" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "168fb715dda47215e360912c096649d23d58bf392ac62f73919e831745e40f26" +checksum = "68b900aa2f7301e21c36462b170ee99994de34dff39a4a6a528e80e7376d07e5" dependencies = [ "equivalent", "hashbrown 0.14.5", @@ -3952,7 +3974,7 @@ checksum = "179551c27c512c948af1edaf4bd7e1d1486d223f8ec4fd41cd760f7645fd4197" dependencies = [ "cargo-license", "data-encoding", - "indexmap 2.2.6", + "indexmap 2.5.0", "json-unflattening", "serde", "serde_json", @@ -4727,7 +4749,7 @@ dependencies = [ [[package]] name = "oid4vc-core" version = "0.1.0" -source = "git+https://git@github.com/impierce/openid4vc.git?rev=23facd4#23facd4946e4b2e54ef87f42746c68ad952ae205" +source = "git+https://git@github.com/impierce/openid4vc.git?rev=12fed14#12fed1411ff3c0e1797090f386e44694f7a279b8" dependencies = [ "anyhow", "async-trait", @@ -4751,7 +4773,7 @@ dependencies = [ [[package]] name = "oid4vc-manager" version = "0.1.0" -source = "git+https://git@github.com/impierce/openid4vc.git?rev=23facd4#23facd4946e4b2e54ef87f42746c68ad952ae205" +source = "git+https://git@github.com/impierce/openid4vc.git?rev=12fed14#12fed1411ff3c0e1797090f386e44694f7a279b8" dependencies = [ "anyhow", "async-trait", @@ -4783,7 +4805,7 @@ dependencies = [ [[package]] name = "oid4vci" version = "0.1.0" -source = "git+https://git@github.com/impierce/openid4vc.git?rev=23facd4#23facd4946e4b2e54ef87f42746c68ad952ae205" +source = "git+https://git@github.com/impierce/openid4vc.git?rev=12fed14#12fed1411ff3c0e1797090f386e44694f7a279b8" dependencies = [ "anyhow", "derivative", @@ -4806,7 +4828,7 @@ dependencies = [ [[package]] name = "oid4vp" version = "0.1.0" -source = "git+https://git@github.com/impierce/openid4vc.git?rev=23facd4#23facd4946e4b2e54ef87f42746c68ad952ae205" +source = "git+https://git@github.com/impierce/openid4vc.git?rev=12fed14#12fed1411ff3c0e1797090f386e44694f7a279b8" dependencies = [ "anyhow", "chrono", @@ -5431,7 +5453,7 @@ dependencies = [ [[package]] name = "producer" version = "0.1.0" -source = "git+https://git@github.com/impierce/did-manager.git?tag=v1.0.0-beta.2#6880b7f81b23e6cf1bbdc76d1dea8d2c1f6ef559" +source = "git+https://git@github.com/impierce/did-manager.git?tag=v1.0.0-beta.3#3ad5e3dba7bc76df8d6cb4a4fd2df2238d88710b" dependencies = [ "did_iota", "did_jwk", @@ -6372,7 +6394,7 @@ version = "1.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "455182ea6142b14f93f4bc5320a2b31c1f266b66a4a5c858b013302a5d8cbfc3" dependencies = [ - "indexmap 2.2.6", + "indexmap 2.5.0", "itoa", "ryu", "serde", @@ -6456,7 +6478,7 @@ dependencies = [ "chrono", "hex", "indexmap 1.9.3", - "indexmap 2.2.6", + "indexmap 2.5.0", "serde", "serde_derive", "serde_json", @@ -6494,7 +6516,7 @@ version = "0.9.34+deprecated" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" dependencies = [ - "indexmap 2.2.6", + "indexmap 2.5.0", "itoa", "ryu", "serde", @@ -6593,7 +6615,7 @@ dependencies = [ [[package]] name = "shared" version = "0.1.0" -source = "git+https://git@github.com/impierce/did-manager.git?tag=v1.0.0-beta.2#6880b7f81b23e6cf1bbdc76d1dea8d2c1f6ef559" +source = "git+https://git@github.com/impierce/did-manager.git?tag=v1.0.0-beta.3#3ad5e3dba7bc76df8d6cb4a4fd2df2238d88710b" dependencies = [ "identity_iota", "identity_storage", @@ -6669,7 +6691,7 @@ dependencies = [ [[package]] name = "siopv2" version = "0.1.0" -source = "git+https://git@github.com/impierce/openid4vc.git?rev=23facd4#23facd4946e4b2e54ef87f42746c68ad952ae205" +source = "git+https://git@github.com/impierce/openid4vc.git?rev=12fed14#12fed1411ff3c0e1797090f386e44694f7a279b8" dependencies = [ "anyhow", "async-trait", @@ -6827,7 +6849,7 @@ dependencies = [ "futures-util", "hashlink", "hex", - "indexmap 2.2.6", + "indexmap 2.5.0", "log", "memchr", "once_cell", @@ -7703,7 +7725,7 @@ version = "0.19.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" dependencies = [ - "indexmap 2.2.6", + "indexmap 2.5.0", "toml_datetime", "winnow 0.5.40", ] @@ -7714,7 +7736,7 @@ version = "0.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a8534fd7f78b5405e860340ad6575217ce99f38d4d5c8f2442cb5ecb50090e1" dependencies = [ - "indexmap 2.2.6", + "indexmap 2.5.0", "toml_datetime", "winnow 0.5.40", ] @@ -7725,7 +7747,7 @@ version = "0.22.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f21c7aaf97f1bd9ca9d4f9e73b0a6c74bd5afef56f2bc931943a6e1c37e04e38" dependencies = [ - "indexmap 2.2.6", + "indexmap 2.5.0", "serde", "serde_spanned", "toml_datetime", @@ -8687,7 +8709,7 @@ dependencies = [ "crossbeam-utils", "displaydoc", "flate2", - "indexmap 2.2.6", + "indexmap 2.5.0", "memchr", "thiserror", "zopfli", diff --git a/Cargo.toml b/Cargo.toml index c913ede1..17bc2ae4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ members = [ "agent_application", "agent_event_publisher_http", "agent_holder", + "agent_identity", "agent_issuance", "agent_secret_manager", "agent_shared", @@ -18,18 +19,19 @@ edition = "2021" 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 = "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" } +did_manager = { git = "https://git@github.com/impierce/did-manager.git", tag = "v1.0.0-beta.3" } +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" } async-trait = "0.1" axum = { version = "0.7", features = ["tracing"] } base64 = "0.22" cqrs-es = "0.4.2" futures = "0.3" +identity_core = "1.3" identity_credential = { version = "1.3", default-features = false, features = [ "validator", "credential", diff --git a/agent_api_rest/Cargo.toml b/agent_api_rest/Cargo.toml index 73997c65..9390adbf 100644 --- a/agent_api_rest/Cargo.toml +++ b/agent_api_rest/Cargo.toml @@ -6,6 +6,7 @@ rust-version.workspace = true [dependencies] agent_holder = { path = "../agent_holder" } +agent_identity = { path = "../agent_identity" } agent_issuance = { path = "../agent_issuance" } agent_shared = { path = "../agent_shared" } agent_verification = { path = "../agent_verification" } @@ -13,8 +14,10 @@ agent_verification = { path = "../agent_verification" } axum.workspace = true axum-auth = "0.7" axum-macros = "0.4" +did_manager.workspace = true http-api-problem = "0.57" hyper = { version = "1.2" } +identity_credential.workspace = true oid4vc-core.workspace = true oid4vci.workspace = true oid4vp.workspace = true @@ -24,13 +27,13 @@ siopv2.workspace = true 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_identity = { path = "../agent_identity", 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"] } diff --git a/agent_api_rest/postman/ssi-agent.postman_collection.json b/agent_api_rest/postman/ssi-agent.postman_collection.json index 84802c04..8e134011 100644 --- a/agent_api_rest/postman/ssi-agent.postman_collection.json +++ b/agent_api_rest/postman/ssi-agent.postman_collection.json @@ -74,8 +74,11 @@ { "listen": "prerequest", "script": { - "exec": [], - "type": "text/javascript" + "exec": [ + "" + ], + "type": "text/javascript", + "packages": {} } } ], @@ -104,6 +107,60 @@ }, "response": [] }, + { + "name": "ACME Corp Credential", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "const location = pm.response.headers.get(\"LOCATION\");", + "", + "if(location){", + " pm.collectionVariables.set(\"CREDENTIAL_LOCATION\",location)", + "}", + "" + ], + "type": "text/javascript", + "packages": {} + } + }, + { + "listen": "prerequest", + "script": { + "exec": [ + "" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"offerId\":\"{{OFFER_ID}}\",\n \"credentialConfigurationId\": \"w3c_vc_credential\",\n \"credential\": {\n \"credentialSubject\": {\n \"id\": \"https://ecommerce.impierce.com/\",\n \"image\": \"https://static.wikia.nocookie.net/fictionalcompanies/images/c/c2/ACME_Corporation.png\",\n \"name\": \"VirtualVendors\",\n \"certificaat\": {\n \"type\": \"ACMECorpCredential\",\n \"certificeringsDatum\": \"2024-06-26\",\n \"geldigheidsPeriode\": \"1 jaar\",\n \"garanties\": [\n \"Het bedrijf is echt en bereikbaar.\",\n \"Voldoet aan de Thuiswinkel Algemene Voorwaarden.\",\n \"14 dagen bedenktijd.\",\n \"Veilige betaalmethoden.\",\n \"Duidelijke product/servicebeschrijvingen.\",\n \"Transparant bestelproces.\",\n \"Duidelijke prijzen.\",\n \"Veilige betaalomgeving.\",\n \"Veilige omgang met persoonlijke gegevens.\",\n \"Effectieve klachtenafhandeling en onafhankelijke geschillenbemiddeling.\"\n ]\n }\n }\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{HOST}}/v0/credentials", + "host": [ + "{{HOST}}" + ], + "path": [ + "v0", + "credentials" + ] + } + }, + "response": [] + }, { "name": "credentials", "request": { @@ -218,7 +275,7 @@ "response": [] }, { - "name": "all_offers", + "name": "All Offers", "event": [ { "listen": "test", @@ -258,13 +315,13 @@ "response": [] }, { - "name": "offers_send", + "name": "Send Offer", "request": { "method": "POST", "header": [], "body": { "mode": "raw", - "raw": "{\n \"offerId\": \"{{OFFER_ID}}\",\n \"targetUrl\": \"{{HOST}}/openid4vci/offers\"\n}", + "raw": "{\n \"offerId\": \"{{OFFER_ID}}\",\n \"targetUrl\": \"{{HOST}}\"\n}", "options": { "raw": { "language": "json" @@ -706,7 +763,7 @@ "name": "Holder", "item": [ { - "name": "offers", + "name": "All Received Offers", "event": [ { "listen": "test", @@ -729,8 +786,11 @@ { "listen": "prerequest", "script": { - "exec": [], - "type": "text/javascript" + "exec": [ + "" + ], + "type": "text/javascript", + "packages": {} } } ], @@ -752,31 +812,33 @@ "response": [] }, { - "name": "credentials", + "name": "Accept Received Offer", "request": { - "method": "GET", + "method": "POST", "header": [], "url": { - "raw": "{{HOST}}/v0/holder/credentials", + "raw": "{{HOST}}/v0/holder/offers/{{RECEIVED_OFFER_ID}}/accept", "host": [ "{{HOST}}" ], "path": [ "v0", "holder", - "credentials" + "offers", + "{{RECEIVED_OFFER_ID}}", + "accept" ] } }, "response": [] }, { - "name": "offers_accept", + "name": "Reject Received Offer", "request": { "method": "POST", "header": [], "url": { - "raw": "{{HOST}}/v0/holder/offers/{{RECEIVED_OFFER_ID}}/accept", + "raw": "{{HOST}}/v0/holder/offers/{{RECEIVED_OFFER_ID}}/reject", "host": [ "{{HOST}}" ], @@ -785,28 +847,234 @@ "holder", "offers", "{{RECEIVED_OFFER_ID}}", - "accept" + "reject" + ] + } + }, + "response": [] + }, + { + "name": "All Holder Credentials", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "const jsonData = JSON.parse(responseBody);", + "", + "if (jsonData && typeof jsonData === 'object') {", + " const holderCredentialId = Object.keys(jsonData)[0];", + "", + " if (holderCredentialId) {", + " pm.collectionVariables.set(\"HOLDER_CREDENTIAL_ID\", holderCredentialId);", + " }", + "}" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{HOST}}/v0/holder/credentials", + "host": [ + "{{HOST}}" + ], + "path": [ + "v0", + "holder", + "credentials" ] } }, "response": [] }, { - "name": "offers_reject", + "name": "All Presentations", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "const jsonData = JSON.parse(responseBody);", + "", + "if (jsonData && typeof jsonData === 'object') {", + " const presentationId = Object.keys(jsonData)[0];", + "", + " if (presentationId) {", + " pm.collectionVariables.set(\"PRESENTATION_ID\", presentationId);", + " }", + "}" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{HOST}}/v0/holder/presentations", + "host": [ + "{{HOST}}" + ], + "path": [ + "v0", + "holder", + "presentations" + ] + } + }, + "response": [] + }, + { + "name": "Create new Presentation", "request": { "method": "POST", "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"credentialIds\": [\"{{HOLDER_CREDENTIAL_ID}}\"]\n}", + "options": { + "raw": { + "language": "json" + } + } + }, "url": { - "raw": "{{HOST}}/v0/holder/offers/{{RECEIVED_OFFER_ID}}/reject", + "raw": "{{HOST}}/v0/holder/presentations", "host": [ "{{HOST}}" ], "path": [ "v0", "holder", - "offers", - "{{RECEIVED_OFFER_ID}}", - "reject" + "presentations" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "Identity", + "item": [ + { + "name": "Create new Linked Verifiable Presentation Service", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"presentationIds\": [\"{{PRESENTATION_ID}}\"]\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{HOST}}/v0/services/linked-vp", + "host": [ + "{{HOST}}" + ], + "path": [ + "v0", + "services", + "linked-vp" + ] + } + }, + "response": [] + }, + { + "name": "All Services", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "const jsonData = JSON.parse(responseBody);", + "", + "if (Array.isArray(jsonData) && jsonData.length > 0) {", + " const firstItem = jsonData[0];", + "", + " if (firstItem && typeof firstItem === 'object') {", + " const serviceId = firstItem.service_id;", + "", + " if (serviceId) {", + " pm.collectionVariables.set(\"SERVICE_ID\", serviceId);", + " }", + " }", + "}", + "" + ], + "type": "text/javascript", + "packages": {} + } + }, + { + "listen": "prerequest", + "script": { + "exec": [], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{HOST}}/v0/services", + "host": [ + "{{HOST}}" + ], + "path": [ + "v0", + "services" + ] + } + }, + "response": [] + }, + { + "name": "Service by ID", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{HOST}}/v0/services/{{SERVICE_ID}}", + "host": [ + "{{HOST}}" + ], + "path": [ + "v0", + "services", + "{{SERVICE_ID}}" + ] + } + }, + "response": [] + }, + { + "name": "Linked Verifiable Presentation Service", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{HOST}}/v0/services/linked-verifiable-presentation-service", + "host": [ + "{{HOST}}" + ], + "path": [ + "v0", + "services", + "linked-verifiable-presentation-service" ] } }, @@ -880,6 +1148,16 @@ "key": "RECEIVED_OFFER_ID", "value": "INITIAL_VALUE", "type": "string" + }, + { + "key": "HOLDER_CREDENTIAL_ID", + "value": "INITIAL_VALUE", + "type": "string" + }, + { + "key": "PRESENTATION_ID", + "value": "INITIAL_VALUE", + "type": "string" } ] } \ No newline at end of file diff --git a/agent_api_rest/src/holder/holder/mod.rs b/agent_api_rest/src/holder/holder/mod.rs index 1a09baa0..1992a65b 100644 --- a/agent_api_rest/src/holder/holder/mod.rs +++ b/agent_api_rest/src/holder/holder/mod.rs @@ -1,2 +1,3 @@ pub mod credentials; pub mod offers; +pub mod presentations; diff --git a/agent_api_rest/src/holder/holder/offers/accept.rs b/agent_api_rest/src/holder/holder/offers/accept.rs index 9022f14b..ead0e36a 100644 --- a/agent_api_rest/src/holder/holder/offers/accept.rs +++ b/agent_api_rest/src/holder/holder/offers/accept.rs @@ -1,12 +1,13 @@ use agent_holder::{ credential::command::CredentialCommand, - offer::{command::OfferCommand, queries::ReceivedOfferView}, + offer::{aggregate::OfferCredential, command::OfferCommand, queries::ReceivedOfferView}, state::HolderState, }; use agent_shared::handlers::{command_handler, query_handler}; use axum::{ extract::{Path, State}, response::{IntoResponse, Response}, + Json, }; use hyper::StatusCode; @@ -50,9 +51,11 @@ pub(crate) async fn accept(State(state): State, Path(offer_id): Pat _ => return StatusCode::INTERNAL_SERVER_ERROR.into_response(), }; - for credential in credentials { - let credential_id = uuid::Uuid::new_v4().to_string(); - + for OfferCredential { + credential_id, + credential, + } in credentials + { let command = CredentialCommand::AddCredential { credential_id: credential_id.clone(), offer_id: offer_id.clone(), @@ -68,6 +71,9 @@ pub(crate) async fn accept(State(state): State, Path(offer_id): Pat } } - // TODO: What do we return here? - StatusCode::OK.into_response() + match query_handler(&offer_id, &state.query.received_offer).await { + Ok(Some(received_offer_view)) => (StatusCode::OK, Json(received_offer_view)).into_response(), + Ok(None) => StatusCode::NOT_FOUND.into_response(), + _ => StatusCode::INTERNAL_SERVER_ERROR.into_response(), + } } diff --git a/agent_api_rest/src/holder/holder/presentations/mod.rs b/agent_api_rest/src/holder/holder/presentations/mod.rs new file mode 100644 index 00000000..1421a5ff --- /dev/null +++ b/agent_api_rest/src/holder/holder/presentations/mod.rs @@ -0,0 +1,75 @@ +pub mod presentation_signed; + +use agent_holder::{ + credential::queries::HolderCredentialView, presentation::command::PresentationCommand, state::HolderState, +}; +use agent_shared::handlers::{command_handler, query_handler}; +use axum::{ + extract::State, + response::{IntoResponse, Response}, + Json, +}; +use hyper::StatusCode; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use tracing::info; + +#[axum_macros::debug_handler] +pub(crate) async fn get_presentations(State(state): State) -> Response { + match query_handler("all_presentations", &state.query.all_presentations).await { + Ok(Some(all_presentations_view)) => (StatusCode::OK, Json(all_presentations_view)).into_response(), + Ok(None) => (StatusCode::OK, Json(json!({}))).into_response(), + _ => StatusCode::INTERNAL_SERVER_ERROR.into_response(), + } +} + +#[derive(Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PresentationsEndpointRequest { + pub credential_ids: Vec, +} + +#[axum_macros::debug_handler] +pub(crate) async fn post_presentations(State(state): State, Json(payload): Json) -> Response { + info!("Request Body: {}", payload); + + let Ok(PresentationsEndpointRequest { credential_ids }) = serde_json::from_value(payload) else { + return (StatusCode::BAD_REQUEST, "invalid payload").into_response(); + }; + + let mut credentials = vec![]; + + // Get all the credentials. + for credential_id in credential_ids { + match query_handler(&credential_id, &state.query.holder_credential).await { + Ok(Some(HolderCredentialView { + signed: Some(credential), + .. + })) => { + credentials.push(credential); + } + Ok(None) => return StatusCode::NOT_FOUND.into_response(), + _ => return StatusCode::INTERNAL_SERVER_ERROR.into_response(), + } + } + + let presentation_id = uuid::Uuid::new_v4().to_string(); + + let command = PresentationCommand::CreatePresentation { + presentation_id: presentation_id.clone(), + signed_credentials: credentials, + }; + + // Create the presentation. + if command_handler(&presentation_id, &state.command.presentation, command) + .await + .is_err() + { + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); + } + + match query_handler(&presentation_id, &state.query.presentation).await { + Ok(Some(presentation_view)) => (StatusCode::OK, Json(presentation_view)).into_response(), + _ => StatusCode::INTERNAL_SERVER_ERROR.into_response(), + } +} diff --git a/agent_api_rest/src/holder/holder/presentations/presentation_signed.rs b/agent_api_rest/src/holder/holder/presentations/presentation_signed.rs new file mode 100644 index 00000000..cc5dbc3a --- /dev/null +++ b/agent_api_rest/src/holder/holder/presentations/presentation_signed.rs @@ -0,0 +1,27 @@ +use agent_holder::{presentation::aggregate::Presentation, state::HolderState}; +use agent_shared::handlers::query_handler; +use axum::{ + extract::{Path, State}, + response::{IntoResponse, Response}, +}; +use hyper::{header, StatusCode}; + +#[axum_macros::debug_handler] +pub(crate) async fn presentation_signed( + State(state): State, + Path(presentation_id): Path, +) -> Response { + match query_handler(&presentation_id, &state.query.presentation).await { + Ok(Some(Presentation { + signed: Some(signed_presentation), + .. + })) => ( + StatusCode::OK, + [(header::CONTENT_TYPE, "application/jwt")], + signed_presentation.as_str().to_string(), + ) + .into_response(), + Ok(None) => (StatusCode::NOT_FOUND).into_response(), + _ => StatusCode::INTERNAL_SERVER_ERROR.into_response(), + } +} diff --git a/agent_api_rest/src/holder/mod.rs b/agent_api_rest/src/holder/mod.rs index 7ea56ad7..97f8a7fd 100644 --- a/agent_api_rest/src/holder/mod.rs +++ b/agent_api_rest/src/holder/mod.rs @@ -11,6 +11,7 @@ use crate::API_VERSION; use agent_holder::state::HolderState; use axum::routing::get; use axum::{routing::post, Router}; +use holder::presentations::{get_presentations, post_presentations, presentation_signed::presentation_signed}; pub fn router(holder_state: HolderState) -> Router { Router::new() @@ -18,10 +19,21 @@ pub fn router(holder_state: HolderState) -> Router { API_VERSION, Router::new() .route("/holder/credentials", get(credentials)) + .route("/holder/presentations", get(get_presentations).post(post_presentations)) + .route( + "/holder/presentations/:presentation_id/signed", + get(presentation_signed), + ) .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)) + // TODO: move these behind some sort of authentication? + .route("/credential_offer", get(openid4vci::offers_params)) + .route("/", get(openid4vci::offers_params)) + .route( + "/linked-verifiable-presentations/:presentation_id", + get(presentation_signed), + ) .with_state(holder_state) } diff --git a/agent_api_rest/src/holder/openid4vci/mod.rs b/agent_api_rest/src/holder/openid4vci/mod.rs index 95145b61..6cabf28a 100644 --- a/agent_api_rest/src/holder/openid4vci/mod.rs +++ b/agent_api_rest/src/holder/openid4vci/mod.rs @@ -1,41 +1,63 @@ use agent_holder::{offer::command::OfferCommand, state::HolderState}; -use agent_shared::handlers::command_handler; +use agent_shared::handlers::{command_handler, query_handler}; use axum::{ extract::State, response::{IntoResponse, Response}, - Json, + Form, Json, }; use hyper::StatusCode; use oid4vci::credential_offer::CredentialOffer; -use serde::{Deserialize, Serialize}; +use serde_json::Value; use tracing::info; -#[derive(Deserialize, Serialize)] -pub struct Oid4vciOfferEndpointRequest { - #[serde(flatten)] - pub credential_offer: CredentialOffer, +#[axum_macros::debug_handler] +pub(crate) async fn offers_params( + State(state): State, + Form(payload): Form, +) -> Response { + offers_inner(state, payload).await } -#[axum_macros::debug_handler] -pub(crate) async fn offers(State(state): State, Json(payload): Json) -> Response { +pub(crate) async fn offers_inner(state: HolderState, payload: serde_json::Value) -> 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 credential_offer_result: Result = + if let Some(credential_offer) = payload.get("credential_offer").and_then(Value::as_str) { + format!("openid-credential-offer://?credential_offer={credential_offer}") + } else if let Some(credential_offer_uri) = payload.get("credential_offer_uri").and_then(Value::as_str) { + format!("openid-credential-offer://?credential_offer_uri={credential_offer_uri}") + } else { + return (StatusCode::BAD_REQUEST, "invalid payload").into_response(); + } + .parse(); + + let credential_offer = match credential_offer_result { + Ok(credential_offer) => credential_offer, + Err(_) => return (StatusCode::BAD_REQUEST, "invalid payload").into_response(), }; - let offer_id = uuid::Uuid::new_v4().to_string(); + let received_offer_id = uuid::Uuid::new_v4().to_string(); + + info!("Credential Offer: {:#?}", credential_offer); let command = OfferCommand::ReceiveCredentialOffer { - offer_id: offer_id.clone(), + offer_id: received_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(), + if command_handler(&received_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. - Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(), + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); + } + + match query_handler(&received_offer_id, &state.query.received_offer).await { + // TODO: add Location header + Ok(Some(received_offer)) => (StatusCode::CREATED, Json(received_offer)).into_response(), + _ => StatusCode::INTERNAL_SERVER_ERROR.into_response(), } } diff --git a/agent_api_rest/src/identity/mod.rs b/agent_api_rest/src/identity/mod.rs new file mode 100644 index 00000000..90bbc1f0 --- /dev/null +++ b/agent_api_rest/src/identity/mod.rs @@ -0,0 +1,25 @@ +pub mod services; +pub mod well_known; + +use agent_identity::state::IdentityState; +use axum::{ + routing::{get, post}, + Router, +}; +use services::{linked_vp::linked_vp, services}; +use well_known::{did::did, did_configuration::did_configuration}; + +use crate::API_VERSION; + +pub fn router(identity_state: IdentityState) -> Router { + Router::new() + .nest( + API_VERSION, + Router::new() + .route("/services", get(services)) + .route("/services/linked-vp", post(linked_vp)), + ) + .route("/.well-known/did.json", get(did)) + .route("/.well-known/did-configuration.json", get(did_configuration)) + .with_state(identity_state) +} diff --git a/agent_api_rest/src/identity/services/linked_vp.rs b/agent_api_rest/src/identity/services/linked_vp.rs new file mode 100644 index 00000000..cee2f425 --- /dev/null +++ b/agent_api_rest/src/identity/services/linked_vp.rs @@ -0,0 +1,69 @@ +use agent_identity::{ + document::command::DocumentCommand, + service::{aggregate::Service, command::ServiceCommand}, + state::IdentityState, +}; +use agent_shared::handlers::{command_handler, query_handler}; +use axum::{ + extract::State, + response::{IntoResponse, Response}, + Json, +}; +use did_manager::DidMethod; +use hyper::StatusCode; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use tracing::info; + +#[derive(Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LinkedVPEndpointRequest { + pub presentation_ids: Vec, +} + +#[axum_macros::debug_handler] +pub(crate) async fn linked_vp(State(state): State, Json(payload): Json) -> Response { + info!("Request Body: {}", payload); + + let Ok(LinkedVPEndpointRequest { presentation_ids }) = serde_json::from_value(payload) else { + return (StatusCode::BAD_REQUEST, "invalid payload").into_response(); + }; + + let service_id = "linked-verifiable-presentation-service".to_string(); + let command = ServiceCommand::CreateLinkedVerifiablePresentationService { + service_id: service_id.clone(), + presentation_ids, + }; + + // Create a linked verifiable presentation service. + if command_handler(&service_id, &state.command.service, command) + .await + .is_err() + { + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); + } + + let linked_verifiable_presentation_service = match query_handler(&service_id, &state.query.service).await { + Ok(Some(Service { + service: Some(linked_verifiable_presentation_service), + .. + })) => linked_verifiable_presentation_service, + _ => return StatusCode::INTERNAL_SERVER_ERROR.into_response(), + }; + + let command = DocumentCommand::AddService { + service: linked_verifiable_presentation_service, + }; + + if command_handler(&DidMethod::Web.to_string(), &state.command.document, command) + .await + .is_err() + { + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); + } + + match query_handler(&DidMethod::Web.to_string(), &state.query.document).await { + Ok(Some(document)) => (StatusCode::OK, Json(document)).into_response(), + _ => StatusCode::INTERNAL_SERVER_ERROR.into_response(), + } +} diff --git a/agent_api_rest/src/identity/services/mod.rs b/agent_api_rest/src/identity/services/mod.rs new file mode 100644 index 00000000..d18000ff --- /dev/null +++ b/agent_api_rest/src/identity/services/mod.rs @@ -0,0 +1,20 @@ +pub mod linked_vp; + +use agent_identity::state::IdentityState; +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 services(State(state): State) -> Response { + match query_handler("all_services", &state.query.all_services).await { + Ok(Some(all_services_view)) => (StatusCode::OK, Json(all_services_view)).into_response(), + Ok(None) => (StatusCode::OK, Json(json!({}))).into_response(), + _ => StatusCode::INTERNAL_SERVER_ERROR.into_response(), + } +} diff --git a/agent_api_rest/src/identity/well_known/did.rs b/agent_api_rest/src/identity/well_known/did.rs new file mode 100644 index 00000000..416d2173 --- /dev/null +++ b/agent_api_rest/src/identity/well_known/did.rs @@ -0,0 +1,21 @@ +use agent_identity::{document::views::DocumentView, state::IdentityState}; +use agent_shared::handlers::query_handler; +use axum::{ + extract::State, + response::{IntoResponse, Response}, + Json, +}; +use did_manager::DidMethod; +use hyper::StatusCode; + +#[axum_macros::debug_handler] +pub(crate) async fn did(State(state): State) -> Response { + match query_handler(&DidMethod::Web.to_string(), &state.query.document).await { + Ok(Some(DocumentView { + document: Some(document), + .. + })) => (StatusCode::OK, Json(document)).into_response(), + Ok(None) => StatusCode::NOT_FOUND.into_response(), + _ => StatusCode::INTERNAL_SERVER_ERROR.into_response(), + } +} diff --git a/agent_api_rest/src/identity/well_known/did_configuration.rs b/agent_api_rest/src/identity/well_known/did_configuration.rs new file mode 100644 index 00000000..a70ed6e3 --- /dev/null +++ b/agent_api_rest/src/identity/well_known/did_configuration.rs @@ -0,0 +1,24 @@ +use agent_identity::{ + service::{aggregate::ServiceResource, views::ServiceView}, + state::{IdentityState, DOMAIN_LINKAGE_SERVICE_ID}, +}; +use agent_shared::handlers::query_handler; +use axum::{ + extract::State, + response::{IntoResponse, Response}, + Json, +}; +use hyper::StatusCode; + +#[axum_macros::debug_handler] +pub(crate) async fn did_configuration(State(state): State) -> Response { + // Get the DID Configuration Resource if it exists. + match query_handler(DOMAIN_LINKAGE_SERVICE_ID, &state.query.service).await { + Ok(Some(ServiceView { + resource: Some(ServiceResource::DomainLinkage(domain_linkage_configuration)), + .. + })) => (StatusCode::OK, Json(domain_linkage_configuration)).into_response(), + Ok(None) => StatusCode::NOT_FOUND.into_response(), + _ => StatusCode::INTERNAL_SERVER_ERROR.into_response(), + } +} diff --git a/agent_api_rest/src/identity/well_known/mod.rs b/agent_api_rest/src/identity/well_known/mod.rs new file mode 100644 index 00000000..225b8c40 --- /dev/null +++ b/agent_api_rest/src/identity/well_known/mod.rs @@ -0,0 +1,2 @@ +pub mod did; +pub mod did_configuration; diff --git a/agent_api_rest/src/lib.rs b/agent_api_rest/src/lib.rs index b282b706..25e6f81d 100644 --- a/agent_api_rest/src/lib.rs +++ b/agent_api_rest/src/lib.rs @@ -1,19 +1,23 @@ pub mod holder; +pub mod identity; pub mod issuance; pub mod verification; use agent_holder::state::HolderState; +use agent_identity::state::IdentityState; 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, Router}; -use tower_http::trace::TraceLayer; -use tracing::{info_span, Span}; +use std::time::Duration; +use tower_http::{cors::CorsLayer, trace::TraceLayer}; +use tracing::{info, info_span, Span}; pub const API_VERSION: &str = "/v0"; #[derive(Default)] pub struct ApplicationState { + pub identity_state: Option, pub issuance_state: Option, pub holder_state: Option, pub verification_state: Option, @@ -21,15 +25,17 @@ pub struct ApplicationState { pub fn app( ApplicationState { + identity_state, issuance_state, holder_state, verification_state, }: ApplicationState, ) -> Router { - Router::new() + let app = Router::new() .nest( &get_base_path().unwrap_or_default(), Router::new() + .merge(identity_state.map(identity::router).unwrap_or_default()) .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()), @@ -46,17 +52,25 @@ pub fn app( ) }) .on_request(|request: &Request<_>, _span: &Span| { - tracing::info!("Received request"); - tracing::info!("Request Headers: {:?}", request.headers()); + info!("Received request"); + info!("Request Headers: {:?}", request.headers()); }) - .on_response(|response: &Response, _latency: std::time::Duration, _span: &Span| { - tracing::info!("Returning {}", response.status()); - tracing::info!("Response Headers: {:?}", response.headers()); + .on_response(|response: &Response, _latency: Duration, _span: &Span| { + info!("Returning {}", response.status()); + info!("Response Headers: {:?}", response.headers()); }) - .on_body_chunk(|chunk: &Bytes, _latency: std::time::Duration, _span: &Span| { - tracing::info!("Response Body: {}", std::str::from_utf8(chunk).unwrap()); + .on_body_chunk(|chunk: &Bytes, _latency: Duration, _span: &Span| { + info!("Response Body: {}", std::str::from_utf8(chunk).unwrap()); }), - ) + ); + + // CORS + if config().cors_enabled.unwrap_or(false) { + info!("CORS (permissive) enabled for all routes"); + app.layer(CorsLayer::permissive()) + } else { + app + } } fn get_base_path() -> Result { @@ -77,7 +91,7 @@ fn get_base_path() -> Result { panic!("UNICORE__BASE_PATH can't be empty, remove or set path"); } - tracing::info!("Base path: {:?}", base_path); + info!("Base path: {:?}", base_path); format!("/{}", base_path) }) diff --git a/agent_application/Cargo.toml b/agent_application/Cargo.toml index a919c707..078f67cd 100644 --- a/agent_application/Cargo.toml +++ b/agent_application/Cargo.toml @@ -8,6 +8,7 @@ rust-version.workspace = true agent_api_rest = { path = "../agent_api_rest" } agent_event_publisher_http = { path = "../agent_event_publisher_http" } agent_holder = { path = "../agent_holder" } +agent_identity = { path = "../agent_identity" } agent_issuance = { path = "../agent_issuance" } agent_secret_manager = { path = "../agent_secret_manager" } agent_shared = { path = "../agent_shared" } @@ -15,12 +16,6 @@ agent_store = { path = "../agent_store" } agent_verification = { path = "../agent_verification" } axum.workspace = true -did_manager.workspace = true -identity_document = { version = "1.3" } -identity_verification.workspace = true -serde_json.workspace = true tokio.workspace = true -tower-http.workspace = true tracing.workspace = true tracing-subscriber.workspace = true -url.workspace = true diff --git a/agent_application/docker/db/init.sql b/agent_application/docker/db/init.sql index e8d2d537..99e11ff4 100644 --- a/agent_application/docker/db/init.sql +++ b/agent_application/docker/db/init.sql @@ -10,6 +10,30 @@ CREATE TABLE events PRIMARY KEY (aggregate_type, aggregate_id, sequence) ); +CREATE TABLE document +( + view_id text NOT NULL, + version bigint CHECK (version >= 0) NOT NULL, + payload json NOT NULL, + PRIMARY KEY (view_id) +); + +CREATE TABLE service +( + view_id text NOT NULL, + version bigint CHECK (version >= 0) NOT NULL, + payload json NOT NULL, + PRIMARY KEY (view_id) +); + +CREATE TABLE all_services +( + view_id text NOT NULL, + version bigint CHECK (version >= 0) NOT NULL, + payload json NOT NULL, + PRIMARY KEY (view_id) +); + CREATE TABLE offer ( view_id text NOT NULL, @@ -90,6 +114,21 @@ CREATE TABLE holder_credential PRIMARY KEY (view_id) ); +CREATE TABLE presentation +( + view_id text NOT NULL, + version bigint CHECK (version >= 0) NOT NULL, + payload json NOT NULL, + PRIMARY KEY (view_id) +); + +CREATE TABLE all_presentations +( + view_id text NOT NULL, + version bigint CHECK (version >= 0) NOT NULL, + payload json NOT NULL, + PRIMARY KEY (view_id) +); CREATE TABLE all_holder_credentials ( diff --git a/agent_application/src/main.rs b/agent_application/src/main.rs index 23933869..61932d1d 100644 --- a/agent_application/src/main.rs +++ b/agent_application/src/main.rs @@ -3,20 +3,14 @@ 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_identity::services::IdentityServices; +use agent_issuance::{services::IssuanceServices, startup_commands::startup_commands}; 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, - from_jsonwebtoken_algorithm_to_jwsalgorithm, -}; +use agent_shared::config::{config, LogFormat}; use agent_store::{in_memory, postgres, EventPublisher}; use agent_verification::services::VerificationServices; -use axum::{routing::get, Json}; -use identity_document::service::{Service, ServiceEndpoint}; use std::sync::Arc; use tokio::{fs, io}; -use tower_http::cors::CorsLayer; use tracing::info; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; @@ -35,6 +29,7 @@ async fn main() -> io::Result<()> { secret_manager: Arc::new(tokio::sync::Mutex::new(secret_manager().await)), }); + let identity_services = Arc::new(IdentityServices::new(subject.clone())); 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())); @@ -42,119 +37,44 @@ async fn main() -> io::Result<()> { // 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 identity_event_publishers: Vec> = vec![Box::new(EventPublisherHttp::load().unwrap())]; 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, 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, - ), - }; + let (identity_state, issuance_state, holder_state, verification_state) = + match agent_shared::config::config().event_store.type_ { + agent_shared::config::EventStoreType::Postgres => ( + postgres::identity_state(identity_services, identity_event_publishers).await, + 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::identity_state(identity_services, identity_event_publishers).await, + 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, + ), + }; info!("{:?}", config()); let url = &config().url; - info!("Application url: {:?}", url); + info!("Application url: {}", url); - let url = url::Url::parse(url).unwrap(); + agent_identity::state::initialize(&identity_state).await; + agent_issuance::state::initialize(&issuance_state, startup_commands(url.clone())).await; - initialize(&issuance_state, startup_commands(url.clone())).await; - - let mut app = app(ApplicationState { + let app = app(ApplicationState { + identity_state: Some(identity_state), issuance_state: Some(issuance_state), holder_state: Some(holder_state), verification_state: Some(verification_state), }); - // CORS - if config().cors_enabled.unwrap_or(false) { - info!("CORS (permissive) enabled for all routes"); - app = app.layer(CorsLayer::permissive()); - } - - // did:web - let enable_did_web = config() - .did_methods - .get(&SupportedDidMethod::Web) - .unwrap_or(&ToggleOptions::default()) - .enabled; - - let did_document = if enable_did_web { - let mut secret_manager = subject.secret_manager.lock().await; - - Some( - secret_manager - .produce_document( - did_manager::DidMethod::Web, - Some(did_manager::MethodSpecificParameters::Web { origin: url.origin() }), - from_jsonwebtoken_algorithm_to_jwsalgorithm( - &agent_shared::config::get_preferred_signing_algorithm(), - ), - ) - .await - .unwrap(), - ) - } else { - None - }; - // Domain Linkage - let did_configuration_resource = if config().domain_linkage_enabled { - let secret_manager = subject.secret_manager.lock().await; - - Some( - create_did_configuration_resource( - url.clone(), - did_document - .clone() - .expect("No DID document found to create a DID Configuration Resource for"), - &secret_manager, - ) - .await - .expect("Failed to create DID Configuration Resource"), - ) - } else { - None - }; - - if let Some(mut did_document) = did_document { - if let Some(did_configuration_resource) = did_configuration_resource { - // Create a new service and add it to the DID document. - let service = Service::builder(Default::default()) - .id(format!("{}#service-1", did_document.id()).parse().unwrap()) - .type_("LinkedDomains") - .service_endpoint( - serde_json::from_value::(serde_json::json!( - { - "origins": [url.origin().ascii_serialization()] - } - )) - .unwrap(), - ) - .build() - .expect("Failed to create DID Configuration Resource"); - did_document - .insert_service(service) - .expect("Service already exists in DID Document"); - - let path = "/.well-known/did-configuration.json"; - info!("Serving DID Configuration (Domain Linkage) at `{path}`"); - app = app.route(path, get(Json(did_configuration_resource))); - } - let path = "/.well-known/did.json"; - info!("Serving `did:web` document at `{path}`"); - app = app.route(path, get(Json(did_document))); - } - // This is used to indicate that the server accepts requests. // In a docker container this file can be searched to see if its ready. // A better solution can be made later (needed for impierce-demo) diff --git a/agent_event_publisher_http/Cargo.toml b/agent_event_publisher_http/Cargo.toml index a9a0f29d..a9811bf7 100644 --- a/agent_event_publisher_http/Cargo.toml +++ b/agent_event_publisher_http/Cargo.toml @@ -22,7 +22,6 @@ rustls = { version = "0.23", default-features = false, features = [ reqwest.workspace = true serde.workspace = true serde_with.workspace = true -serde_yaml.workspace = true tokio.workspace = true tracing.workspace = true diff --git a/agent_event_publisher_http/README.md b/agent_event_publisher_http/README.md index 2fa08dbf..cdc0a663 100644 --- a/agent_event_publisher_http/README.md +++ b/agent_event_publisher_http/README.md @@ -21,6 +21,20 @@ event_publishers: ### Available events +#### `document` + +``` +DocumentCreated +ServiceAdded +``` + +#### `service` + +``` +DomainLinkageServiceCreated +LinkedVerifiablePresentationServiceCreated +``` + #### `credential` ``` @@ -53,6 +67,12 @@ CredentialConfigurationAdded CredentialAdded ``` +#### `presentation` + +``` +PresentationCreated +``` + #### `received_offer` ``` diff --git a/agent_holder/Cargo.toml b/agent_holder/Cargo.toml index 974ada58..0ac7a60e 100644 --- a/agent_holder/Cargo.toml +++ b/agent_holder/Cargo.toml @@ -9,7 +9,10 @@ agent_shared = { path = "../agent_shared" } agent_secret_manager = { path = "../agent_secret_manager" } async-trait.workspace = true +base64.workspace = true cqrs-es.workspace = true +identity_core.workspace = true +identity_credential.workspace = true jsonwebtoken.workspace = true oid4vci.workspace = true oid4vc-core.workspace = true @@ -17,6 +20,7 @@ serde.workspace = true serde_json.workspace = true thiserror.workspace = true tracing.workspace = true +uuid.workspace = true # `test_utils` dependencies rstest = { workspace = true, optional = true } @@ -29,6 +33,7 @@ agent_secret_manager = { path = "../agent_secret_manager", features = ["test_uti agent_shared = { path = "../agent_shared", features = ["test_utils"] } agent_store = { path = "../agent_store" } +async-std = { version = "1.5", features = ["attributes", "tokio1"] } axum.workspace = true did_manager.workspace = true lazy_static.workspace = true @@ -40,7 +45,6 @@ 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/aggregate.rs b/agent_holder/src/credential/aggregate.rs index 5488ccf1..9981d3db 100644 --- a/agent_holder/src/credential/aggregate.rs +++ b/agent_holder/src/credential/aggregate.rs @@ -3,16 +3,24 @@ use crate::credential::error::CredentialError::{self}; use crate::credential::event::CredentialEvent; use crate::services::HolderServices; use async_trait::async_trait; +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; use cqrs_es::Aggregate; +use identity_credential::credential::Jwt; use serde::{Deserialize, Serialize}; use std::sync::Arc; use tracing::info; +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] +pub struct Data { + pub raw: serde_json::Value, +} + #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct Credential { pub credential_id: Option, pub offer_id: Option, - pub credential: Option, + pub signed: Option, + pub data: Option, } #[async_trait] @@ -32,6 +40,7 @@ impl Aggregate for Credential { _services: &Self::Services, ) -> Result, Self::Error> { use CredentialCommand::*; + use CredentialError::*; use CredentialEvent::*; info!("Handling command: {:?}", command); @@ -41,11 +50,19 @@ impl Aggregate for Credential { credential_id, offer_id, credential, - } => Ok(vec![CredentialAdded { - credential_id, - offer_id, - credential, - }]), + } => { + let raw = get_unverified_jwt_claims(&serde_json::json!(credential))? + .get("vc") + .cloned() + .ok_or(CredentialDecodingError)?; + + Ok(vec![CredentialAdded { + credential_id, + offer_id, + credential, + data: Data { raw }, + }]) + } } } @@ -59,15 +76,31 @@ impl Aggregate for Credential { credential_id, offer_id, credential, + data, } => { self.credential_id = Some(credential_id); self.offer_id = Some(offer_id); - self.credential = Some(credential); + self.signed = Some(credential); + self.data = Some(data); } } } } +// TODO: actually validate the JWT! +/// Get the claims from a JWT without performing validation. +pub fn get_unverified_jwt_claims(jwt: &serde_json::Value) -> Result { + jwt.as_str() + .and_then(|string| string.splitn(3, '.').collect::>().get(1).cloned()) + .and_then(|payload| { + URL_SAFE_NO_PAD + .decode(payload) + .ok() + .and_then(|payload_bytes| serde_json::from_slice::(&payload_bytes).ok()) + }) + .ok_or(CredentialError::CredentialDecodingError) +} + #[cfg(test)] pub mod credential_tests { use super::test_utils::*; @@ -79,7 +112,6 @@ pub mod credential_tests { use agent_secret_manager::service::Service; use cqrs_es::test::TestFramework; use rstest::rstest; - use serde_json::json; type CredentialTestFramework = TestFramework; @@ -91,12 +123,17 @@ pub mod credential_tests { .when(CredentialCommand::AddCredential { credential_id: credential_id.clone(), offer_id: offer_id.clone(), - credential: json!(OPENBADGE_VERIFIABLE_CREDENTIAL_JWT), + credential: Jwt::from(OPENBADGE_VERIFIABLE_CREDENTIAL_JWT.to_string()), }) .then_expect_events(vec![CredentialEvent::CredentialAdded { credential_id, offer_id, - credential: json!(OPENBADGE_VERIFIABLE_CREDENTIAL_JWT), + credential: Jwt::from(OPENBADGE_VERIFIABLE_CREDENTIAL_JWT.to_string()), + data: Data { + raw: get_unverified_jwt_claims(&serde_json::json!(OPENBADGE_VERIFIABLE_CREDENTIAL_JWT)).unwrap() + ["vc"] + .clone(), + }, }]) } } diff --git a/agent_holder/src/credential/command.rs b/agent_holder/src/credential/command.rs index af839527..97d83718 100644 --- a/agent_holder/src/credential/command.rs +++ b/agent_holder/src/credential/command.rs @@ -1,3 +1,4 @@ +use identity_credential::credential::Jwt; use serde::Deserialize; #[derive(Debug, Deserialize)] @@ -6,6 +7,6 @@ pub enum CredentialCommand { AddCredential { credential_id: String, offer_id: String, - credential: serde_json::Value, + credential: Jwt, }, } diff --git a/agent_holder/src/credential/error.rs b/agent_holder/src/credential/error.rs index df235841..314ec188 100644 --- a/agent_holder/src/credential/error.rs +++ b/agent_holder/src/credential/error.rs @@ -1,4 +1,7 @@ use thiserror::Error; #[derive(Error, Debug)] -pub enum CredentialError {} +pub enum CredentialError { + #[error("Failed to decode Credential JWT")] + CredentialDecodingError, +} diff --git a/agent_holder/src/credential/event.rs b/agent_holder/src/credential/event.rs index cbc50106..9a41dcb7 100644 --- a/agent_holder/src/credential/event.rs +++ b/agent_holder/src/credential/event.rs @@ -1,12 +1,16 @@ use cqrs_es::DomainEvent; +use identity_credential::credential::Jwt; use serde::{Deserialize, Serialize}; +use super::aggregate::Data; + #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] pub enum CredentialEvent { CredentialAdded { credential_id: String, offer_id: String, - credential: serde_json::Value, + credential: Jwt, + data: Data, }, } diff --git a/agent_holder/src/credential/queries/mod.rs b/agent_holder/src/credential/queries/mod.rs index 125054ea..fa5d5560 100644 --- a/agent_holder/src/credential/queries/mod.rs +++ b/agent_holder/src/credential/queries/mod.rs @@ -3,16 +3,10 @@ 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 HolderCredentialView { - pub credential_id: Option, - pub offer_id: Option, - pub credential: Option, -} +pub type HolderCredentialView = Credential; -impl View for HolderCredentialView { +impl View for Credential { fn update(&mut self, event: &EventEnvelope) { use CredentialEvent::*; @@ -21,10 +15,12 @@ impl View for HolderCredentialView { credential_id, offer_id, credential, + data, } => { self.credential_id.replace(credential_id.clone()); self.offer_id.replace(offer_id.clone()); - self.credential.replace(credential.clone()); + self.signed.replace(credential.clone()); + self.data.replace(data.clone()); } } } diff --git a/agent_holder/src/lib.rs b/agent_holder/src/lib.rs index 671c165e..2e9bc9a1 100644 --- a/agent_holder/src/lib.rs +++ b/agent_holder/src/lib.rs @@ -1,4 +1,5 @@ pub mod credential; pub mod offer; +pub mod presentation; pub mod services; pub mod state; diff --git a/agent_holder/src/offer/aggregate.rs b/agent_holder/src/offer/aggregate.rs index 32b27736..30d84c04 100644 --- a/agent_holder/src/offer/aggregate.rs +++ b/agent_holder/src/offer/aggregate.rs @@ -4,6 +4,7 @@ use crate::offer::event::OfferEvent; use crate::services::HolderServices; use async_trait::async_trait; use cqrs_es::Aggregate; +use identity_credential::credential::Jwt; use oid4vci::credential_issuer::credential_configurations_supported::CredentialConfigurationsSupportedObject; use oid4vci::credential_offer::{CredentialOffer, CredentialOfferParameters, Grants}; use oid4vci::credential_response::CredentialResponseType; @@ -19,10 +20,16 @@ pub enum Status { #[default] Pending, Accepted, - Received, + CredentialsReceived, Rejected, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct OfferCredential { + pub credential_id: String, + pub credential: Jwt, +} + #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct Offer { pub credential_offer: Option, @@ -32,7 +39,7 @@ pub struct Offer { // 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, + pub credentials: Vec, } #[async_trait] @@ -178,7 +185,7 @@ impl Aggregate for Offer { .as_ref() .ok_or(MissingCredentialConfigurationsError)?; - let credentials: Vec = match credential_configuration_ids.len() { + let credentials: Vec = match credential_configuration_ids.len() { 0 => vec![], 1 => { let credential_configuration_id = &credential_configuration_ids[0]; @@ -194,13 +201,23 @@ impl Aggregate for Offer { .map_err(|_| CredentialResponseError)?; let credential = match credential_response.credential { - CredentialResponseType::Immediate { credential, .. } => credential, + CredentialResponseType::Immediate { credential, .. } => { + Jwt::from(credential.as_str().ok_or(UnsupportedCredentialFormatError)?.to_string()) + } CredentialResponseType::Deferred { .. } => { return Err(UnsupportedDeferredCredentialResponseError) } }; - vec![credential] + #[cfg(not(feature = "test_utils"))] + let credential_id = uuid::Uuid::new_v4().to_string(); + #[cfg(feature = "test_utils")] + let credential_id = test_utils::credential_id(); + + vec![OfferCredential { + credential_id, + credential, + }] } _batch => { return Err(BatchCredentialRequestError); @@ -211,7 +228,7 @@ impl Aggregate for Offer { Ok(vec![CredentialResponseReceived { offer_id, - status: Status::Received, + status: Status::CredentialsReceived, credentials, }]) } @@ -423,6 +440,7 @@ pub mod tests { #[future(awt)] credential_offer_parameters: Box, #[future(awt)] token_response: TokenResponse, credential_configurations_supported: HashMap, + signed_credentials: Vec, ) { OfferTestFramework::with(Service::default()) .given(vec![ @@ -437,7 +455,7 @@ pub mod tests { }, OfferEvent::TokenResponseReceived { offer_id: offer_id.clone(), - token_response + token_response, }, ]) .when_async(OfferCommand::SendCredentialRequest { @@ -446,8 +464,8 @@ pub mod tests { .await .then_expect_events(vec![OfferEvent::CredentialResponseReceived { offer_id: offer_id.clone(), - status: Status::Received, - credentials: vec![json!("eyJ0eXAiOiJKV1QiLCJhbGciOiJFZERTQSIsImtpZCI6ImRpZDprZXk6ejZNa2dFODROQ01wTWVBeDlqSzljZjVXNEc4Z2NaOXh1d0p2RzFlN3dOazhLQ2d0I3o2TWtnRTg0TkNNcE1lQXg5aks5Y2Y1VzRHOGdjWjl4dXdKdkcxZTd3Tms4S0NndCJ9.eyJpc3MiOiJkaWQ6a2V5Ono2TWtnRTg0TkNNcE1lQXg5aks5Y2Y1VzRHOGdjWjl4dXdKdkcxZTd3Tms4S0NndCIsInN1YiI6ImRpZDprZXk6ejZNa2dFODROQ01wTWVBeDlqSzljZjVXNEc4Z2NaOXh1d0p2RzFlN3dOazhLQ2d0IiwiZXhwIjo5OTk5OTk5OTk5LCJpYXQiOjAsInZjIjp7IkBjb250ZXh0IjoiaHR0cHM6Ly93d3cudzMub3JnLzIwMTgvY3JlZGVudGlhbHMvdjEiLCJ0eXBlIjpbIlZlcmlmaWFibGVDcmVkZW50aWFsIl0sImNyZWRlbnRpYWxTdWJqZWN0Ijp7ImlkIjoiZGlkOmtleTp6Nk1rZ0U4NE5DTXBNZUF4OWpLOWNmNVc0RzhnY1o5eHV3SnZHMWU3d05rOEtDZ3QiLCJkZWdyZWUiOnsidHlwZSI6Ik1hc3RlckRlZ3JlZSIsIm5hbWUiOiJNYXN0ZXIgb2YgT2NlYW5vZ3JhcGh5In0sImZpcnN0X25hbWUiOiJGZXJyaXMiLCJsYXN0X25hbWUiOiJSdXN0YWNlYW4ifSwiaXNzdWVyIjoiZGlkOmtleTp6Nk1rZ0U4NE5DTXBNZUF4OWpLOWNmNVc0RzhnY1o5eHV3SnZHMWU3d05rOEtDZ3QiLCJpc3N1YW5jZURhdGUiOiIyMDEwLTAxLTAxVDAwOjAwOjAwWiJ9fQ.jQEpI7DhjOcmyhPEpfGARwcRyzor_fUvynb43-eqD9175FBoshENX0S-8qlloQ7vbT5gat8TjvcDlGDN720ZBw")], + status: Status::CredentialsReceived, + credentials: signed_credentials, }]); } @@ -478,11 +496,23 @@ pub mod tests { #[cfg(feature = "test_utils")] pub mod test_utils { + use super::*; use agent_shared::generate_random_string; + use identity_credential::credential::Jwt; use rstest::*; #[fixture] pub fn offer_id() -> String { generate_random_string() } + + #[fixture] + pub fn credential_id() -> String { + "credential_id".to_string() + } + + #[fixture] + pub fn signed_credentials(credential_id: String) -> Vec { + vec![OfferCredential { credential_id, credential: Jwt::from("eyJ0eXAiOiJKV1QiLCJhbGciOiJFZERTQSIsImtpZCI6ImRpZDprZXk6ejZNa2dFODROQ01wTWVBeDlqSzljZjVXNEc4Z2NaOXh1d0p2RzFlN3dOazhLQ2d0I3o2TWtnRTg0TkNNcE1lQXg5aks5Y2Y1VzRHOGdjWjl4dXdKdkcxZTd3Tms4S0NndCJ9.eyJpc3MiOiJkaWQ6a2V5Ono2TWtnRTg0TkNNcE1lQXg5aks5Y2Y1VzRHOGdjWjl4dXdKdkcxZTd3Tms4S0NndCIsInN1YiI6ImRpZDprZXk6ejZNa2dFODROQ01wTWVBeDlqSzljZjVXNEc4Z2NaOXh1d0p2RzFlN3dOazhLQ2d0IiwiZXhwIjo5OTk5OTk5OTk5LCJpYXQiOjAsInZjIjp7IkBjb250ZXh0IjoiaHR0cHM6Ly93d3cudzMub3JnLzIwMTgvY3JlZGVudGlhbHMvdjEiLCJ0eXBlIjpbIlZlcmlmaWFibGVDcmVkZW50aWFsIl0sImNyZWRlbnRpYWxTdWJqZWN0Ijp7ImlkIjoiZGlkOmtleTp6Nk1rZ0U4NE5DTXBNZUF4OWpLOWNmNVc0RzhnY1o5eHV3SnZHMWU3d05rOEtDZ3QiLCJkZWdyZWUiOnsidHlwZSI6Ik1hc3RlckRlZ3JlZSIsIm5hbWUiOiJNYXN0ZXIgb2YgT2NlYW5vZ3JhcGh5In0sImZpcnN0X25hbWUiOiJGZXJyaXMiLCJsYXN0X25hbWUiOiJSdXN0YWNlYW4ifSwiaXNzdWVyIjoiZGlkOmtleTp6Nk1rZ0U4NE5DTXBNZUF4OWpLOWNmNVc0RzhnY1o5eHV3SnZHMWU3d05rOEtDZ3QiLCJpc3N1YW5jZURhdGUiOiIyMDEwLTAxLTAxVDAwOjAwOjAwWiJ9fQ.jQEpI7DhjOcmyhPEpfGARwcRyzor_fUvynb43-eqD9175FBoshENX0S-8qlloQ7vbT5gat8TjvcDlGDN720ZBw".to_string())}] + } } diff --git a/agent_holder/src/offer/error.rs b/agent_holder/src/offer/error.rs index eabbfdd3..f65f0539 100644 --- a/agent_holder/src/offer/error.rs +++ b/agent_holder/src/offer/error.rs @@ -32,4 +32,6 @@ pub enum OfferError { UnsupportedDeferredCredentialResponseError, #[error("Batch Credential Request are not supported")] BatchCredentialRequestError, + #[error("Only JWT credentials are supported")] + UnsupportedCredentialFormatError, } diff --git a/agent_holder/src/offer/event.rs b/agent_holder/src/offer/event.rs index 4db40468..a3f9da8f 100644 --- a/agent_holder/src/offer/event.rs +++ b/agent_holder/src/offer/event.rs @@ -1,4 +1,4 @@ -use super::aggregate::Status; +use super::aggregate::{OfferCredential, Status}; use cqrs_es::DomainEvent; use oid4vci::{ credential_issuer::credential_configurations_supported::CredentialConfigurationsSupportedObject, @@ -25,7 +25,7 @@ pub enum OfferEvent { CredentialResponseReceived { offer_id: String, status: Status, - credentials: Vec, + credentials: Vec, }, CredentialOfferRejected { offer_id: String, diff --git a/agent_holder/src/offer/queries/mod.rs b/agent_holder/src/offer/queries/mod.rs index 05bae107..82f53fdf 100644 --- a/agent_holder/src/offer/queries/mod.rs +++ b/agent_holder/src/offer/queries/mod.rs @@ -1,25 +1,11 @@ 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 ReceivedOfferView { - pub credential_offer: Option, - pub status: Status, - pub credential_configurations: Option>, - pub token_response: Option, - pub credentials: Vec, -} +pub type ReceivedOfferView = Offer; -impl View for ReceivedOfferView { +impl View for Offer { fn update(&mut self, event: &EventEnvelope) { use crate::offer::event::OfferEvent::*; diff --git a/agent_holder/src/presentation/README.md b/agent_holder/src/presentation/README.md new file mode 100644 index 00000000..dc37302b --- /dev/null +++ b/agent_holder/src/presentation/README.md @@ -0,0 +1,5 @@ +# Presentation + +This aggregate holds everything related to a presentation: +- presentation_id +- signed (the signed Presentation) diff --git a/agent_holder/src/presentation/aggregate.rs b/agent_holder/src/presentation/aggregate.rs new file mode 100644 index 00000000..bcd2a167 --- /dev/null +++ b/agent_holder/src/presentation/aggregate.rs @@ -0,0 +1,180 @@ +use super::{command::PresentationCommand, error::PresentationError, event::PresentationEvent}; +use crate::services::HolderServices; +use agent_shared::config::{get_preferred_did_method, get_preferred_signing_algorithm}; +use async_trait::async_trait; +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; +use cqrs_es::Aggregate; +use identity_core::convert::ToJson; +use identity_credential::{credential::Jwt, presentation::JwtPresentationOptions}; +use jsonwebtoken::Header; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use tracing::info; + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct Presentation { + pub presentation_id: String, + pub signed: Option, +} + +#[async_trait] +impl Aggregate for Presentation { + type Command = PresentationCommand; + type Event = PresentationEvent; + type Error = PresentationError; + type Services = Arc; + + fn aggregate_type() -> String { + "presentation".to_string() + } + + async fn handle(&self, command: Self::Command, services: &Self::Services) -> Result, Self::Error> { + use PresentationCommand::*; + use PresentationError::*; + use PresentationEvent::*; + + info!("Handling command: {:?}", command); + + match command { + CreatePresentation { + presentation_id, + signed_credentials, + } => { + let holder = &services.holder; + let subject_did = holder + .identifier( + get_preferred_did_method().to_string().as_ref(), + get_preferred_signing_algorithm(), + ) + .await + .map_err(|err| MissingIdentifierError(err.to_string()))?; + + let mut presentation_builder = identity_credential::presentation::Presentation::builder( + subject_did + .parse::() + .map_err(|err| InvalidUrlError(err.to_string()))?, + Default::default(), + ); + for signed_credential in signed_credentials { + presentation_builder = presentation_builder.credential(signed_credential); + } + + #[cfg(feature = "test_utils")] + let options = JwtPresentationOptions::default() + .issuance_date(identity_core::common::Timestamp::from_unix(0).unwrap()); + #[cfg(not(feature = "test_utils"))] + let options = JwtPresentationOptions::default(); + + let verifiable_presentation: identity_credential::presentation::Presentation = + presentation_builder + .build() + .map_err(|err| PresentationBuilderError(err.to_string()))?; + + let payload = verifiable_presentation + .serialize_jwt(&options) + .map_err(|err| SerializationError(err.to_string()))?; + + // Compose JWT + let header = Header { + alg: get_preferred_signing_algorithm(), + typ: Some("JWT".to_string()), + // TODO: make dynamic + kid: Some(format!("{subject_did}#key-0")), + ..Default::default() + }; + + let message = [ + URL_SAFE_NO_PAD.encode( + header + .to_json_vec() + .map_err(|err| SerializationError(err.to_string()))?, + ), + URL_SAFE_NO_PAD.encode(payload.as_bytes()), + ] + .join("."); + + let proof_value = holder + .sign( + &message, + get_preferred_did_method().to_string().as_ref(), + get_preferred_signing_algorithm(), + ) + .await + .map_err(|err| SigningError(err.to_string()))?; + let signature = URL_SAFE_NO_PAD.encode(proof_value.as_slice()); + let message = [message, signature].join("."); + + Ok(vec![PresentationCreated { + presentation_id, + signed_presentation: Jwt::from(message), + }]) + } + } + } + + fn apply(&mut self, event: Self::Event) { + use PresentationEvent::*; + + info!("Applying event: {:?}", event); + + match event { + PresentationCreated { + presentation_id, + signed_presentation, + } => { + self.presentation_id = presentation_id; + self.signed.replace(signed_presentation); + } + } + } +} + +#[cfg(test)] +pub mod presentation_tests { + + use crate::offer::aggregate::test_utils::signed_credentials; + use crate::offer::aggregate::OfferCredential; + + use super::test_utils::*; + use super::*; + use agent_secret_manager::service::Service; + use cqrs_es::test::TestFramework; + use rstest::rstest; + + type PresentationTestFramework = TestFramework; + + #[rstest] + #[serial_test::serial] + async fn test_create_presentation( + presentation_id: String, + signed_credentials: Vec, + signed_presentation: Jwt, + ) { + PresentationTestFramework::with(Service::default()) + .given_no_previous_events() + .when(PresentationCommand::CreatePresentation { + presentation_id: presentation_id.clone(), + signed_credentials: signed_credentials.into_iter().map(|c| c.credential).collect(), + }) + .then_expect_events(vec![PresentationEvent::PresentationCreated { + presentation_id, + signed_presentation, + }]) + } +} + +#[cfg(feature = "test_utils")] +pub mod test_utils { + use super::*; + use rstest::*; + + #[fixture] + pub fn presentation_id() -> String { + "presentation-id".to_string() + } + + #[fixture] + pub fn signed_presentation() -> Jwt { + Jwt::from("eyJ0eXAiOiJKV1QiLCJhbGciOiJFZERTQSIsImtpZCI6ImRpZDprZXk6ejZNa2dFODROQ01wTWVBeDlqSzljZjVXNEc4Z2NaOXh1d0p2RzFlN3dOazhLQ2d0I2tleS0wIn0.eyJpc3MiOiJkaWQ6a2V5Ono2TWtnRTg0TkNNcE1lQXg5aks5Y2Y1VzRHOGdjWjl4dXdKdkcxZTd3Tms4S0NndCIsIm5iZiI6MCwidnAiOnsiQGNvbnRleHQiOiJodHRwczovL3d3dy53My5vcmcvMjAxOC9jcmVkZW50aWFscy92MSIsInR5cGUiOiJWZXJpZmlhYmxlUHJlc2VudGF0aW9uIiwidmVyaWZpYWJsZUNyZWRlbnRpYWwiOlsiZXlKMGVYQWlPaUpLVjFRaUxDSmhiR2NpT2lKRlpFUlRRU0lzSW10cFpDSTZJbVJwWkRwclpYazZlalpOYTJkRk9EUk9RMDF3VFdWQmVEbHFTemxqWmpWWE5FYzRaMk5hT1hoMWQwcDJSekZsTjNkT2F6aExRMmQwSTNvMlRXdG5SVGcwVGtOTmNFMWxRWGc1YWtzNVkyWTFWelJIT0dkaldqbDRkWGRLZGtjeFpUZDNUbXM0UzBObmRDSjkuZXlKcGMzTWlPaUprYVdRNmEyVjVPbm8yVFd0blJUZzBUa05OY0UxbFFYZzVha3M1WTJZMVZ6UkhPR2RqV2psNGRYZEtka2N4WlRkM1RtczRTME5uZENJc0luTjFZaUk2SW1ScFpEcHJaWGs2ZWpaTmEyZEZPRFJPUTAxd1RXVkJlRGxxU3psalpqVlhORWM0WjJOYU9YaDFkMHAyUnpGbE4zZE9hemhMUTJkMElpd2laWGh3SWpvNU9UazVPVGs1T1RrNUxDSnBZWFFpT2pBc0luWmpJanA3SWtCamIyNTBaWGgwSWpvaWFIUjBjSE02THk5M2QzY3Vkek11YjNKbkx6SXdNVGd2WTNKbFpHVnVkR2xoYkhNdmRqRWlMQ0owZVhCbElqcGJJbFpsY21sbWFXRmliR1ZEY21Wa1pXNTBhV0ZzSWwwc0ltTnlaV1JsYm5ScFlXeFRkV0pxWldOMElqcDdJbWxrSWpvaVpHbGtPbXRsZVRwNk5rMXJaMFU0TkU1RFRYQk5aVUY0T1dwTE9XTm1OVmMwUnpoblkxbzVlSFYzU25aSE1XVTNkMDVyT0V0RFozUWlMQ0prWldkeVpXVWlPbnNpZEhsd1pTSTZJazFoYzNSbGNrUmxaM0psWlNJc0ltNWhiV1VpT2lKTllYTjBaWElnYjJZZ1QyTmxZVzV2WjNKaGNHaDVJbjBzSW1acGNuTjBYMjVoYldVaU9pSkdaWEp5YVhNaUxDSnNZWE4wWDI1aGJXVWlPaUpTZFhOMFlXTmxZVzRpZlN3aWFYTnpkV1Z5SWpvaVpHbGtPbXRsZVRwNk5rMXJaMFU0TkU1RFRYQk5aVUY0T1dwTE9XTm1OVmMwUnpoblkxbzVlSFYzU25aSE1XVTNkMDVyT0V0RFozUWlMQ0pwYzNOMVlXNWpaVVJoZEdVaU9pSXlNREV3TFRBeExUQXhWREF3T2pBd09qQXdXaUo5ZlEualFFcEk3RGhqT2NteWhQRXBmR0FSd2NSeXpvcl9mVXZ5bmI0My1lcUQ5MTc1RkJvc2hFTlgwUy04cWxsb1E3dmJUNWdhdDhUanZjRGxHRE43MjBaQnciXX19.2iIO7zlcLsceC5P0X3p9yICrqRXj8A9VcTVJkUUiALufEm72urbJFRbkvrXGNWwYezFzAOz-4WrGpUNHWtTDCA".to_string()) + } +} diff --git a/agent_holder/src/presentation/command.rs b/agent_holder/src/presentation/command.rs new file mode 100644 index 00000000..367e0f92 --- /dev/null +++ b/agent_holder/src/presentation/command.rs @@ -0,0 +1,11 @@ +use identity_credential::credential::Jwt; +use serde::Deserialize; + +#[derive(Debug, Deserialize)] +#[serde(untagged)] +pub enum PresentationCommand { + CreatePresentation { + presentation_id: String, + signed_credentials: Vec, + }, +} diff --git a/agent_holder/src/presentation/error.rs b/agent_holder/src/presentation/error.rs new file mode 100644 index 00000000..c76dca36 --- /dev/null +++ b/agent_holder/src/presentation/error.rs @@ -0,0 +1,15 @@ +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum PresentationError { + #[error("Failed to serialize presentation: {0}")] + SerializationError(String), + #[error("Failed to build presentation: {0}")] + PresentationBuilderError(String), + #[error("Invalid URL: {0}")] + InvalidUrlError(String), + #[error("Missing identifier: {0}")] + MissingIdentifierError(String), + #[error("Failed to sign presentation: {0}")] + SigningError(String), +} diff --git a/agent_holder/src/presentation/event.rs b/agent_holder/src/presentation/event.rs new file mode 100644 index 00000000..05e552b8 --- /dev/null +++ b/agent_holder/src/presentation/event.rs @@ -0,0 +1,26 @@ +use cqrs_es::DomainEvent; +use identity_credential::credential::Jwt; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +pub enum PresentationEvent { + PresentationCreated { + presentation_id: String, + signed_presentation: Jwt, + }, +} + +impl DomainEvent for PresentationEvent { + fn event_type(&self) -> String { + use PresentationEvent::*; + + let event_type: &str = match self { + PresentationCreated { .. } => "PresentationCreated", + }; + event_type.to_string() + } + + fn event_version(&self) -> String { + "1".to_string() + } +} diff --git a/agent_holder/src/presentation/mod.rs b/agent_holder/src/presentation/mod.rs new file mode 100644 index 00000000..7cbc4ed7 --- /dev/null +++ b/agent_holder/src/presentation/mod.rs @@ -0,0 +1,5 @@ +pub mod aggregate; +pub mod command; +pub mod error; +pub mod event; +pub mod views; diff --git a/agent_holder/src/presentation/views/all_presentations.rs b/agent_holder/src/presentation/views/all_presentations.rs new file mode 100644 index 00000000..37a3382d --- /dev/null +++ b/agent_holder/src/presentation/views/all_presentations.rs @@ -0,0 +1,23 @@ +use super::PresentationView; +use crate::presentation::aggregate::Presentation; +use cqrs_es::{EventEnvelope, View}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +#[derive(Debug, Default, Serialize, Deserialize, Clone)] +pub struct AllPresentationsView { + #[serde(flatten)] + pub presentations: HashMap, +} + +impl View for AllPresentationsView { + fn update(&mut self, event: &EventEnvelope) { + self.presentations + // 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/presentation/views/mod.rs b/agent_holder/src/presentation/views/mod.rs new file mode 100644 index 00000000..d46b743c --- /dev/null +++ b/agent_holder/src/presentation/views/mod.rs @@ -0,0 +1,22 @@ +pub mod all_presentations; + +use super::aggregate::Presentation; +use cqrs_es::{EventEnvelope, View}; + +pub type PresentationView = Presentation; + +impl View for Presentation { + fn update(&mut self, event: &EventEnvelope) { + use crate::presentation::event::PresentationEvent::*; + + match &event.payload { + PresentationCreated { + presentation_id, + signed_presentation, + } => { + self.presentation_id.clone_from(presentation_id); + self.signed.replace(signed_presentation.clone()); + } + } + } +} diff --git a/agent_holder/src/state.rs b/agent_holder/src/state.rs index cef3ff26..d9ac3d8c 100644 --- a/agent_holder/src/state.rs +++ b/agent_holder/src/state.rs @@ -8,6 +8,9 @@ use crate::credential::queries::HolderCredentialView; use crate::offer::aggregate::Offer; use crate::offer::queries::all_offers::AllReceivedOffersView; use crate::offer::queries::ReceivedOfferView; +use crate::presentation::aggregate::Presentation; +use crate::presentation::views::all_presentations::AllPresentationsView; +use crate::presentation::views::PresentationView; #[derive(Clone)] pub struct HolderState { @@ -19,6 +22,7 @@ pub struct HolderState { #[derive(Clone)] pub struct CommandHandlers { pub credential: CommandHandler, + pub presentation: CommandHandler, pub offer: CommandHandler, } @@ -28,19 +32,25 @@ pub struct CommandHandlers { type Queries = ViewRepositories< dyn ViewRepository, dyn ViewRepository, + dyn ViewRepository, + dyn ViewRepository, dyn ViewRepository, dyn ViewRepository, >; -pub struct ViewRepositories +pub struct ViewRepositories where C1: ViewRepository + ?Sized, C2: ViewRepository + ?Sized, + P1: ViewRepository + ?Sized, + P2: ViewRepository + ?Sized, O1: ViewRepository + ?Sized, O2: ViewRepository + ?Sized, { pub holder_credential: Arc, pub all_holder_credentials: Arc, + pub presentation: Arc, + pub all_presentations: Arc, pub received_offer: Arc, pub all_received_offers: Arc, } @@ -50,6 +60,8 @@ impl Clone for Queries { ViewRepositories { holder_credential: self.holder_credential.clone(), all_holder_credentials: self.all_holder_credentials.clone(), + presentation: self.presentation.clone(), + all_presentations: self.all_presentations.clone(), received_offer: self.received_offer.clone(), all_received_offers: self.all_received_offers.clone(), } diff --git a/agent_identity/Cargo.toml b/agent_identity/Cargo.toml new file mode 100644 index 00000000..2436e921 --- /dev/null +++ b/agent_identity/Cargo.toml @@ -0,0 +1,59 @@ +[package] +name = "agent_identity" +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 +base64.workspace = true +cqrs-es.workspace = true +derivative = "2.2" +did_manager.workspace = true +identity_credential.workspace = true +identity_core.workspace = true +identity_did = { version = "1.3" } +identity_document = { version = "1.3" } +jsonwebtoken.workspace = true +oid4vc-core.workspace = true +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true +tracing.workspace = true + +# `test_utils` dependencies +futures = { workspace = true, optional = true } +rstest = { workspace = true, optional = true } +tokio = { workspace = true, optional = true } + +[dev-dependencies] +agent_api_rest = { path = "../agent_api_rest" } +agent_holder = { path = "../agent_holder", features = ["test_utils"] } +agent_identity = { 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:futures", + "dep:rstest", + "dep:tokio", +] diff --git a/agent_identity/src/document/README.md b/agent_identity/src/document/README.md new file mode 100644 index 00000000..5f057d11 --- /dev/null +++ b/agent_identity/src/document/README.md @@ -0,0 +1,4 @@ +# Document + +This aggregate holds everything related to a document: + - document (DID dcoument) diff --git a/agent_identity/src/document/aggregate.rs b/agent_identity/src/document/aggregate.rs new file mode 100644 index 00000000..c8f3e06d --- /dev/null +++ b/agent_identity/src/document/aggregate.rs @@ -0,0 +1,208 @@ +use std::sync::Arc; + +use agent_shared::{ + config::{config, get_preferred_signing_algorithm}, + from_jsonwebtoken_algorithm_to_jwsalgorithm, +}; +use async_trait::async_trait; +use cqrs_es::Aggregate; +use did_manager::{DidMethod, MethodSpecificParameters}; +use identity_document::document::CoreDocument; +use serde::{Deserialize, Serialize}; +use tracing::info; + +use crate::services::IdentityServices; + +use super::{command::DocumentCommand, error::DocumentError, event::DocumentEvent}; + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct Document { + pub document: Option, +} + +#[async_trait] +impl Aggregate for Document { + type Command = DocumentCommand; + type Event = DocumentEvent; + type Error = DocumentError; + type Services = Arc; + + fn aggregate_type() -> String { + "credential".to_string() + } + + async fn handle(&self, command: Self::Command, services: &Self::Services) -> Result, Self::Error> { + use DocumentCommand::*; + use DocumentError::*; + use DocumentEvent::*; + + info!("Handling command: {:?}", command); + + match command { + CreateDocument { did_method } => { + let mut secret_manager = services.subject.secret_manager.lock().await; + + let method_specific_parameters = + matches!(did_method, DidMethod::Web).then(|| MethodSpecificParameters::Web { + origin: config().url.origin(), + }); + + let document = secret_manager + .produce_document( + did_method, + method_specific_parameters, + // TODO: This way the Document can only support on single algorithm. We need to make sure that + // Documents can support multiple algorithms. + from_jsonwebtoken_algorithm_to_jwsalgorithm(&get_preferred_signing_algorithm()), + ) + .await + .map_err(|err| ProduceDocumentError(err.to_string()))?; + + Ok(vec![DocumentCreated { document }]) + } + AddService { service } => { + let mut document = self.document.clone().ok_or(MissingDocumentError)?; + + // Overwrite the service if it already exists. + document.remove_service(service.id()); + document + .insert_service(service) + .map_err(|err| AddServiceError(err.to_string()))?; + + Ok(vec![ServiceAdded { document }]) + } + } + } + + fn apply(&mut self, event: Self::Event) { + use DocumentEvent::*; + + info!("Applying event: {:?}", event); + + match event { + DocumentCreated { document } => { + self.document.replace(document); + } + ServiceAdded { document } => { + self.document.replace(document); + } + } + } +} + +#[cfg(test)] +pub mod document_tests { + use super::test_utils::*; + use super::*; + use cqrs_es::test::TestFramework; + use identity_document::service::Service; + use rstest::rstest; + + type DocumentTestFramework = TestFramework; + + #[rstest] + #[serial_test::serial] + async fn test_create_document(did_method: DidMethod, #[future(awt)] document: CoreDocument) { + DocumentTestFramework::with(IdentityServices::default()) + .given_no_previous_events() + .when(DocumentCommand::CreateDocument { did_method }) + .then_expect_events(vec![DocumentEvent::DocumentCreated { document }]) + } + + #[rstest] + #[serial_test::serial] + async fn test_add_service( + #[future(awt)] document: CoreDocument, + domain_linkage_service: Service, + #[future(awt)] document_with_domain_linkage_service: CoreDocument, + ) { + DocumentTestFramework::with(IdentityServices::default()) + .given(vec![DocumentEvent::DocumentCreated { document }]) + .when(DocumentCommand::AddService { + service: domain_linkage_service, + }) + .then_expect_events(vec![DocumentEvent::ServiceAdded { + document: document_with_domain_linkage_service, + }]) + } +} + +#[cfg(feature = "test_utils")] +pub mod test_utils { + use agent_secret_manager::secret_manager; + use agent_shared::{ + config::{config, get_preferred_signing_algorithm}, + from_jsonwebtoken_algorithm_to_jwsalgorithm, + }; + use did_manager::{DidMethod, MethodSpecificParameters}; + use identity_core::convert::FromJson; + use identity_document::{ + document::CoreDocument, + service::{Service, ServiceEndpoint}, + }; + use rstest::*; + use serde_json::json; + + #[fixture] + pub fn did_method() -> DidMethod { + DidMethod::Web + } + + #[fixture] + pub async fn document(did_method: DidMethod) -> CoreDocument { + let mut secret_manager = secret_manager().await; + + let method_specific_parameters = matches!(did_method, DidMethod::Web).then(|| MethodSpecificParameters::Web { + origin: config().url.origin(), + }); + + secret_manager + .produce_document( + did_method, + method_specific_parameters, + from_jsonwebtoken_algorithm_to_jwsalgorithm(&get_preferred_signing_algorithm()), + ) + .await + .unwrap() + } + + #[fixture] + pub fn domain_linkage_service() -> Service { + Service::builder(Default::default()) + .id("did:test:123#linked_domain-service".parse().unwrap()) + .type_("LinkedDomains") + .service_endpoint( + ServiceEndpoint::from_json_value(json!({ + "origins": [config().url], + })) + .unwrap(), + ) + .build() + .unwrap() + } + + #[fixture] + pub async fn document_with_domain_linkage_service( + did_method: DidMethod, + domain_linkage_service: Service, + ) -> CoreDocument { + let mut secret_manager = secret_manager().await; + + let method_specific_parameters = matches!(did_method, DidMethod::Web).then(|| MethodSpecificParameters::Web { + origin: config().url.origin(), + }); + + let mut document = secret_manager + .produce_document( + did_method, + method_specific_parameters, + from_jsonwebtoken_algorithm_to_jwsalgorithm(&get_preferred_signing_algorithm()), + ) + .await + .unwrap(); + + document.insert_service(domain_linkage_service).unwrap(); + + document + } +} diff --git a/agent_identity/src/document/command.rs b/agent_identity/src/document/command.rs new file mode 100644 index 00000000..afbc0852 --- /dev/null +++ b/agent_identity/src/document/command.rs @@ -0,0 +1,10 @@ +use did_manager::DidMethod; +use identity_document::service::Service as DocumentService; +use serde::Deserialize; + +#[derive(Debug, Deserialize)] +#[serde(untagged)] +pub enum DocumentCommand { + CreateDocument { did_method: DidMethod }, + AddService { service: DocumentService }, +} diff --git a/agent_identity/src/document/error.rs b/agent_identity/src/document/error.rs new file mode 100644 index 00000000..21a11515 --- /dev/null +++ b/agent_identity/src/document/error.rs @@ -0,0 +1,11 @@ +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum DocumentError { + #[error("Error while producing DID document: {0}")] + ProduceDocumentError(String), + #[error("Missing document")] + MissingDocumentError, + #[error("Error while adding service: {0}")] + AddServiceError(String), +} diff --git a/agent_identity/src/document/event.rs b/agent_identity/src/document/event.rs new file mode 100644 index 00000000..94e52fbb --- /dev/null +++ b/agent_identity/src/document/event.rs @@ -0,0 +1,25 @@ +use cqrs_es::DomainEvent; +use identity_document::document::CoreDocument; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +pub enum DocumentEvent { + DocumentCreated { document: CoreDocument }, + ServiceAdded { document: CoreDocument }, +} + +impl DomainEvent for DocumentEvent { + fn event_type(&self) -> String { + use DocumentEvent::*; + + let event_type: &str = match self { + DocumentCreated { .. } => "DocumentCreated", + ServiceAdded { .. } => "ServiceAdded", + }; + event_type.to_string() + } + + fn event_version(&self) -> String { + "1".to_string() + } +} diff --git a/agent_identity/src/document/mod.rs b/agent_identity/src/document/mod.rs new file mode 100644 index 00000000..7cbc4ed7 --- /dev/null +++ b/agent_identity/src/document/mod.rs @@ -0,0 +1,5 @@ +pub mod aggregate; +pub mod command; +pub mod error; +pub mod event; +pub mod views; diff --git a/agent_identity/src/document/views/mod.rs b/agent_identity/src/document/views/mod.rs new file mode 100644 index 00000000..ff550ab8 --- /dev/null +++ b/agent_identity/src/document/views/mod.rs @@ -0,0 +1,18 @@ +use super::aggregate::Document; +use cqrs_es::{EventEnvelope, View}; + +pub type DocumentView = Document; +impl View for Document { + fn update(&mut self, event: &EventEnvelope) { + use crate::document::event::DocumentEvent::*; + + match &event.payload { + DocumentCreated { document, .. } => { + self.document.replace(document.clone()); + } + ServiceAdded { document, .. } => { + self.document.replace(document.clone()); + } + } + } +} diff --git a/agent_identity/src/lib.rs b/agent_identity/src/lib.rs new file mode 100644 index 00000000..f2de33fd --- /dev/null +++ b/agent_identity/src/lib.rs @@ -0,0 +1,6 @@ +// Aggregates +pub mod document; +pub mod service; + +pub mod services; +pub mod state; diff --git a/agent_identity/src/service/README.md b/agent_identity/src/service/README.md new file mode 100644 index 00000000..3af85459 --- /dev/null +++ b/agent_identity/src/service/README.md @@ -0,0 +1,7 @@ +# Service + +This aggregate holds everything related to a service: +- id +- service (DID Document Service) +- resource (e.g. Domain Linkage resource) + diff --git a/agent_identity/src/service/aggregate.rs b/agent_identity/src/service/aggregate.rs new file mode 100644 index 00000000..2e35199e --- /dev/null +++ b/agent_identity/src/service/aggregate.rs @@ -0,0 +1,366 @@ +use super::{command::ServiceCommand, error::ServiceError, event::ServiceEvent}; +use crate::services::IdentityServices; +use agent_shared::{ + config::{config, get_preferred_did_method, get_preferred_signing_algorithm}, + from_jsonwebtoken_algorithm_to_jwsalgorithm, +}; +use async_trait::async_trait; +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; +use cqrs_es::Aggregate; +use did_manager::{DidMethod, MethodSpecificParameters}; +use identity_core::{ + common::{Duration, OrderedSet, Timestamp}, + convert::{FromJson, ToJson}, +}; +use identity_credential::{ + credential::Jwt, + domain_linkage::{DomainLinkageConfiguration, DomainLinkageCredentialBuilder}, +}; +use identity_did::{CoreDID, DIDUrl}; +use identity_document::service::{Service as DocumentService, ServiceEndpoint}; +use jsonwebtoken::Header; +use oid4vc_core::authentication::subject::Subject as _; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use std::sync::Arc; +use tracing::info; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum ServiceResource { + DomainLinkage(DomainLinkageConfiguration), +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct Service { + pub id: String, + pub service: Option, + pub resource: Option, +} + +#[async_trait] +impl Aggregate for Service { + type Command = ServiceCommand; + type Event = ServiceEvent; + type Error = ServiceError; + type Services = Arc; + + fn aggregate_type() -> String { + "service".to_string() + } + + async fn handle(&self, command: Self::Command, services: &Self::Services) -> Result, Self::Error> { + use ServiceCommand::*; + use ServiceError::*; + use ServiceEvent::*; + + info!("Handling command: {:?}", command); + + match command { + CreateDomainLinkageService { service_id } => { + let subject = &services.subject; + + let origin = config().url.origin(); + + let subject_did = subject + .identifier( + &get_preferred_did_method().to_string(), + get_preferred_signing_algorithm(), + ) + .await + .map_err(|err| MissingIdentifierError(err.to_string()))?; + + #[cfg(feature = "test_utils")] + let (issuance_date, expiration_date) = { + let issuance_date = test_utils::issuance_date(); + let expiration_date = test_utils::expiration_date(); + (issuance_date, expiration_date) + }; + #[cfg(not(feature = "test_utils"))] + let (issuance_date, expiration_date) = { + let issuance_date = Timestamp::now_utc(); + let expiration_date = issuance_date + // TODO: make this configurable + .checked_add(Duration::days(365)) + .ok_or(InvalidTimestampError)?; + + (issuance_date, expiration_date) + }; + + let origin = identity_core::common::Url::parse(origin.ascii_serialization()) + .map_err(|err| InvalidUrlError(err.to_string()))?; + let domain_linkage_credential = DomainLinkageCredentialBuilder::new() + .issuer( + subject_did + .parse::() + .map_err(|err| InvalidDidError(err.to_string()))?, + ) + .origin(origin.clone()) + .issuance_date(issuance_date) + .expiration_date(expiration_date) + .build() + .map_err(|err| DomainLinkageCredentialBuilderError(err.to_string()))? + .serialize_jwt(Default::default()) + .map_err(|err| SerializationError(err.to_string()))?; + + // Compose JWT + let header = Header { + alg: get_preferred_signing_algorithm(), + typ: None, + // TODO: make dynamic + kid: Some(format!("{subject_did}#key-0")), + ..Default::default() + }; + + let message = [ + URL_SAFE_NO_PAD.encode( + header + .to_json_vec() + .map_err(|err| SerializationError(err.to_string()))?, + ), + URL_SAFE_NO_PAD.encode(domain_linkage_credential.as_bytes()), + ] + .join("."); + + let secret_manager = subject.secret_manager.lock().await; + + let proof_value = secret_manager + .sign( + message.as_bytes(), + from_jsonwebtoken_algorithm_to_jwsalgorithm(&get_preferred_signing_algorithm()), + ) + .await + .map_err(|err| SigningError(err.to_string()))?; + let signature = URL_SAFE_NO_PAD.encode(proof_value.as_slice()); + let message = [message, signature].join("."); + + let domain_linkage_configuration = DomainLinkageConfiguration::new(vec![Jwt::from(message)]); + info!("Configuration Resource >>: {domain_linkage_configuration:#}"); + + // Create a new service and add it to the DID document. + let service = DocumentService::builder(Default::default()) + .id(format!("{subject_did}#{service_id}") + .parse::() + .map_err(|err| InvalidUrlError(err.to_string()))?) + .type_("LinkedDomains") + .service_endpoint( + ServiceEndpoint::from_json_value(json!({ + "origins": [origin] + })) + .map_err(|err| InvalidServiceEndpointError(err.to_string()))?, + ) + .build() + .expect("Failed to create DID Configuration Resource"); + + Ok(vec![DomainLinkageServiceCreated { + service_id, + service, + resource: ServiceResource::DomainLinkage(domain_linkage_configuration), + }]) + } + CreateLinkedVerifiablePresentationService { + service_id, + presentation_ids, + } => { + let mut secret_manager = services.subject.secret_manager.lock().await; + + let origin = config().url.origin(); + let method_specific_parameters = MethodSpecificParameters::Web { origin: origin.clone() }; + let origin = identity_core::common::Url::parse(origin.ascii_serialization()) + .map_err(|err| InvalidUrlError(err.to_string()))?; + + // TODO: implement for all non-deterministic methods and not just DID WEB + let document = secret_manager + .produce_document( + DidMethod::Web, + Some(method_specific_parameters), + // TODO: This way the Document can only support on single algorithm. We need to support multiple algorithms. + from_jsonwebtoken_algorithm_to_jwsalgorithm(&get_preferred_signing_algorithm()), + ) + .await + .map_err(|err| ProduceDocumentError(err.to_string()))?; + + let subject_did = document.id(); + + let service = DocumentService::builder(Default::default()) + .id(format!("{subject_did}#{service_id}") + .parse::() + .map_err(|err| InvalidUrlError(err.to_string()))?) + .type_("LinkedVerifiablePresentation") + .service_endpoint(ServiceEndpoint::from(OrderedSet::from_iter( + presentation_ids + .into_iter() + .map(|presentation_id| { + // TODO: Find a better way to construct the URL + format!("{origin}linked-verifiable-presentations/{presentation_id}") + .parse::() + }) + .collect::, _>>() + .map_err(|err| InvalidUrlError(err.to_string()))?, + ))) + .build() + .expect("Failed to create Linked Verifiable Presentation Resource"); + + Ok(vec![LinkedVerifiablePresentationServiceCreated { service_id, service }]) + } + } + } + + fn apply(&mut self, event: Self::Event) { + use ServiceEvent::*; + + info!("Applying event: {:?}", event); + + match event { + DomainLinkageServiceCreated { + service_id, + service, + resource, + } => { + self.id = service_id; + self.service.replace(service); + self.resource.replace(resource); + } + LinkedVerifiablePresentationServiceCreated { service_id, service } => { + self.id = service_id; + self.service.replace(service); + } + } + } +} + +#[cfg(test)] +pub mod service_tests { + use agent_shared::config::set_config; + use identity_document::service::Service as DocumentService; + + use super::test_utils::*; + use super::*; + use cqrs_es::test::TestFramework; + use rstest::rstest; + + type ServiceTestFramework = TestFramework; + + #[rstest] + #[serial_test::serial] + async fn test_create_domain_linkage_service( + domain_linkage_service_id: String, + domain_linkage_service: DocumentService, + domain_linkage_resource: ServiceResource, + ) { + set_config().set_preferred_did_method(agent_shared::config::SupportedDidMethod::Web); + + ServiceTestFramework::with(IdentityServices::default()) + .given_no_previous_events() + .when(ServiceCommand::CreateDomainLinkageService { + service_id: domain_linkage_service_id.clone(), + }) + .then_expect_events(vec![ServiceEvent::DomainLinkageServiceCreated { + service_id: domain_linkage_service_id, + service: domain_linkage_service, + resource: domain_linkage_resource, + }]) + } + + #[rstest] + #[serial_test::serial] + async fn test_create_linked_verifiable_presentation_service( + linked_verifiable_presentation_service_id: String, + linked_verifiable_presentation_service: DocumentService, + ) { + set_config().set_preferred_did_method(agent_shared::config::SupportedDidMethod::Web); + + ServiceTestFramework::with(IdentityServices::default()) + .given_no_previous_events() + .when(ServiceCommand::CreateLinkedVerifiablePresentationService { + service_id: linked_verifiable_presentation_service_id.clone(), + presentation_ids: vec!["presentation-1".to_string()], + }) + .then_expect_events(vec![ServiceEvent::LinkedVerifiablePresentationServiceCreated { + service_id: linked_verifiable_presentation_service_id, + service: linked_verifiable_presentation_service, + }]) + } +} + +#[cfg(feature = "test_utils")] +pub mod test_utils { + use super::*; + use crate::state::{DOMAIN_LINKAGE_SERVICE_ID, VERIFIABLE_PRESENTATION_SERVICE_ID}; + use agent_shared::config::config; + use identity_core::{common::Url, convert::FromJson}; + use identity_document::service::{Service, ServiceEndpoint}; + use rstest::*; + use serde_json::json; + + #[fixture] + pub fn domain_linkage_service_id() -> String { + DOMAIN_LINKAGE_SERVICE_ID.to_string() + } + + #[fixture] + pub fn linked_verifiable_presentation_service_id() -> String { + VERIFIABLE_PRESENTATION_SERVICE_ID.to_string() + } + + #[fixture] + pub fn domain_linkage_service(did_web_identifier: String, domain_linkage_service_id: String) -> DocumentService { + Service::builder(Default::default()) + .id(format!("{did_web_identifier}#{domain_linkage_service_id}") + .parse() + .unwrap()) + .type_("LinkedDomains") + .service_endpoint( + ServiceEndpoint::from_json_value(json!({ + "origins": [config().url], + })) + .unwrap(), + ) + .build() + .unwrap() + } + + #[fixture] + pub fn linked_verifiable_presentation_service( + did_web_identifier: String, + linked_verifiable_presentation_service_id: String, + ) -> DocumentService { + let origin = config().url.origin().ascii_serialization(); + + Service::builder(Default::default()) + .id( + format!("{did_web_identifier}#{linked_verifiable_presentation_service_id}") + .parse() + .unwrap(), + ) + .type_("LinkedVerifiablePresentation") + .service_endpoint(ServiceEndpoint::from(OrderedSet::from_iter(vec![format!( + "{origin}/linked-verifiable-presentations/presentation-1" + ) + .parse::() + .unwrap()]))) + .build() + .unwrap() + } + + #[fixture] + pub fn did_web_identifier() -> String { + let domain = config().url.domain().unwrap().to_string(); + + format!("did:web:{domain}") + } + + #[fixture] + pub fn domain_linkage_resource() -> ServiceResource { + let domain_linkage_configuration = DomainLinkageConfiguration::new(vec![Jwt::from("eyJhbGciOiJFZERTQSIsImtpZCI6ImRpZDp3ZWI6bXktZG9tYWluLmV4YW1wbGUub3JnI2tleS0wIn0.eyJleHAiOjMxNTM2MDAwLCJpc3MiOiJkaWQ6d2ViOm15LWRvbWFpbi5leGFtcGxlLm9yZyIsIm5iZiI6MCwic3ViIjoiZGlkOndlYjpteS1kb21haW4uZXhhbXBsZS5vcmciLCJ2YyI6eyJAY29udGV4dCI6WyJodHRwczovL3d3dy53My5vcmcvMjAxOC9jcmVkZW50aWFscy92MSIsImh0dHBzOi8vaWRlbnRpdHkuZm91bmRhdGlvbi8ud2VsbC1rbm93bi9kaWQtY29uZmlndXJhdGlvbi92MSJdLCJ0eXBlIjpbIlZlcmlmaWFibGVDcmVkZW50aWFsIiwiRG9tYWluTGlua2FnZUNyZWRlbnRpYWwiXSwiY3JlZGVudGlhbFN1YmplY3QiOnsib3JpZ2luIjoiaHR0cHM6Ly9teS1kb21haW4uZXhhbXBsZS5vcmcvIn19fQ.l7dEPioa-No5zBlDCthfXDcffRB7371OnLrrQQgeAdnvHhs5F8XqRtdAWKXB8z3Se00WtGxHrTepLKmH9OWJDQ".to_string())]); + + ServiceResource::DomainLinkage(domain_linkage_configuration) + } + + pub fn issuance_date() -> Timestamp { + Timestamp::from_unix(0).unwrap() + } + + pub fn expiration_date() -> Timestamp { + issuance_date().checked_add(Duration::days(365)).unwrap() + } +} diff --git a/agent_identity/src/service/command.rs b/agent_identity/src/service/command.rs new file mode 100644 index 00000000..5584ceea --- /dev/null +++ b/agent_identity/src/service/command.rs @@ -0,0 +1,13 @@ +use serde::Deserialize; + +#[derive(Debug, Deserialize)] +#[serde(untagged)] +pub enum ServiceCommand { + CreateDomainLinkageService { + service_id: String, + }, + CreateLinkedVerifiablePresentationService { + service_id: String, + presentation_ids: Vec, + }, +} diff --git a/agent_identity/src/service/error.rs b/agent_identity/src/service/error.rs new file mode 100644 index 00000000..530bc722 --- /dev/null +++ b/agent_identity/src/service/error.rs @@ -0,0 +1,23 @@ +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum ServiceError { + #[error("Missing identifier: {0}")] + MissingIdentifierError(String), + #[error("Invalid URL: {0}")] + InvalidUrlError(String), + #[error("Invalid DID: {0}")] + InvalidDidError(String), + #[error("Failed to build the Domain Linkage Credential: {0}")] + DomainLinkageCredentialBuilderError(String), + #[error("Failed to serialize credential: {0}")] + SerializationError(String), + #[error("Failed to sign proof: {0}")] + SigningError(String), + #[error("Invalid timestamp")] + InvalidTimestampError, + #[error("Invalid service endpoint: {0}")] + InvalidServiceEndpointError(String), + #[error("Error producing document: {0}")] + ProduceDocumentError(String), +} diff --git a/agent_identity/src/service/event.rs b/agent_identity/src/service/event.rs new file mode 100644 index 00000000..685c1cc1 --- /dev/null +++ b/agent_identity/src/service/event.rs @@ -0,0 +1,37 @@ +use cqrs_es::DomainEvent; +use derivative::Derivative; +use identity_document::service::Service as DocumentService; +use serde::{Deserialize, Serialize}; + +use super::aggregate::ServiceResource; + +#[derive(Clone, Debug, Deserialize, Serialize, Derivative)] +#[derivative(PartialEq)] +pub enum ServiceEvent { + DomainLinkageServiceCreated { + service_id: String, + service: DocumentService, + #[derivative(PartialEq = "ignore")] + resource: ServiceResource, + }, + LinkedVerifiablePresentationServiceCreated { + service_id: String, + service: DocumentService, + }, +} + +impl DomainEvent for ServiceEvent { + fn event_type(&self) -> String { + use ServiceEvent::*; + + let event_type: &str = match self { + DomainLinkageServiceCreated { .. } => "DomainLinkageServiceCreated", + LinkedVerifiablePresentationServiceCreated { .. } => "LinkedVerifiablePresentationServiceCreated", + }; + event_type.to_string() + } + + fn event_version(&self) -> String { + "1".to_string() + } +} diff --git a/agent_identity/src/service/mod.rs b/agent_identity/src/service/mod.rs new file mode 100644 index 00000000..7cbc4ed7 --- /dev/null +++ b/agent_identity/src/service/mod.rs @@ -0,0 +1,5 @@ +pub mod aggregate; +pub mod command; +pub mod error; +pub mod event; +pub mod views; diff --git a/agent_identity/src/service/views/all_services.rs b/agent_identity/src/service/views/all_services.rs new file mode 100644 index 00000000..58551d2f --- /dev/null +++ b/agent_identity/src/service/views/all_services.rs @@ -0,0 +1,23 @@ +use super::ServiceView; +use crate::service::aggregate::Service; +use cqrs_es::{EventEnvelope, View}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +#[derive(Debug, Default, Serialize, Deserialize, Clone)] +pub struct AllServicesView { + #[serde(flatten)] + pub services: HashMap, +} + +impl View for AllServicesView { + fn update(&mut self, event: &EventEnvelope) { + self.services + // 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_identity/src/service/views/mod.rs b/agent_identity/src/service/views/mod.rs new file mode 100644 index 00000000..ca6e6df0 --- /dev/null +++ b/agent_identity/src/service/views/mod.rs @@ -0,0 +1,27 @@ +pub mod all_services; + +use super::aggregate::Service; +use cqrs_es::{EventEnvelope, View}; + +pub type ServiceView = Service; +impl View for Service { + fn update(&mut self, event: &EventEnvelope) { + use crate::service::event::ServiceEvent::*; + + match &event.payload { + DomainLinkageServiceCreated { + service_id, + service, + resource, + } => { + self.id.clone_from(service_id); + self.service.replace(service.clone()); + self.resource.replace(resource.clone()); + } + LinkedVerifiablePresentationServiceCreated { service_id, service } => { + self.id.clone_from(service_id); + self.service.replace(service.clone()); + } + } + } +} diff --git a/agent_identity/src/services.rs b/agent_identity/src/services.rs new file mode 100644 index 00000000..abb9d7f2 --- /dev/null +++ b/agent_identity/src/services.rs @@ -0,0 +1,28 @@ +use agent_secret_manager::subject::Subject; +use std::sync::Arc; + +/// Identity services. +pub struct IdentityServices { + pub subject: Arc, +} + +impl IdentityServices { + pub fn new(subject: Arc) -> Self { + Self { subject } + } + + #[cfg(feature = "test_utils")] + #[allow(clippy::should_implement_trait)] + pub fn default() -> Arc + where + Self: Sized, + { + use agent_secret_manager::secret_manager; + + 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_identity/src/state.rs b/agent_identity/src/state.rs new file mode 100644 index 00000000..c6400198 --- /dev/null +++ b/agent_identity/src/state.rs @@ -0,0 +1,126 @@ +use agent_shared::config::{config, SupportedDidMethod, ToggleOptions}; +use agent_shared::handlers::command_handler; +use agent_shared::{application_state::CommandHandler, handlers::query_handler}; +use cqrs_es::persist::ViewRepository; +use did_manager::DidMethod; +use std::sync::Arc; +use tracing::{info, warn}; + +use crate::document::command::DocumentCommand; +use crate::service::views::all_services::AllServicesView; +use crate::{ + document::{aggregate::Document, views::DocumentView}, + service::{aggregate::Service, command::ServiceCommand, views::ServiceView}, +}; + +#[derive(Clone)] +pub struct IdentityState { + pub command: CommandHandlers, + pub query: Queries, +} + +/// The command handlers are used to execute commands on the aggregates. +#[derive(Clone)] +pub struct CommandHandlers { + pub document: CommandHandler, + pub service: 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, +>; + +pub struct ViewRepositories +where + D: ViewRepository + ?Sized, + S1: ViewRepository + ?Sized, + S2: ViewRepository + ?Sized, +{ + pub document: Arc, + pub service: Arc, + pub all_services: Arc, +} + +impl Clone for Queries { + fn clone(&self) -> Self { + ViewRepositories { + document: self.document.clone(), + service: self.service.clone(), + all_services: self.all_services.clone(), + } + } +} + +/// The unique identifier for the linked domain service. +pub const DOMAIN_LINKAGE_SERVICE_ID: &str = "linked-domain-service"; + +/// The unique identifier for the linked verifiable presentation service. +pub const VERIFIABLE_PRESENTATION_SERVICE_ID: &str = "linked-verifiable-presentation-service"; + +/// Initialize the identity state. +pub async fn initialize(state: &IdentityState) { + info!("Initializing ..."); + + let enable_did_web = config() + .did_methods + .get(&SupportedDidMethod::Web) + .unwrap_or(&ToggleOptions::default()) + .enabled; + + // If the did:web method is enabled, create a document + if enable_did_web { + let did_method = DidMethod::Web; + let command = DocumentCommand::CreateDocument { + did_method: did_method.clone(), + }; + + if command_handler(&did_method.to_string(), &state.command.document, command) + .await + .is_err() + { + warn!("Failed to create document"); + } + + // If domain linkage is enabled, create the domain linkage service and add it to the document. + // TODO: Support this for other (non-deterministic) DID methods. + if config().domain_linkage_enabled { + let command = ServiceCommand::CreateDomainLinkageService { + service_id: DOMAIN_LINKAGE_SERVICE_ID.to_string(), + }; + + if command_handler(DOMAIN_LINKAGE_SERVICE_ID, &state.command.service, command) + .await + .is_err() + { + warn!("Failed to create domain linkage service"); + } + + let linked_domains_service = match query_handler(DOMAIN_LINKAGE_SERVICE_ID, &state.query.service).await { + Ok(Some(Service { + service: Some(linked_domains_service), + .. + })) => linked_domains_service, + _ => { + warn!("Failed to retrieve linked domains service"); + return; + } + }; + + let command = DocumentCommand::AddService { + service: linked_domains_service, + }; + + if command_handler(&did_method.to_string(), &state.command.document, command) + .await + .is_err() + { + warn!("Failed to add service to document"); + } + } + } +} diff --git a/agent_issuance/Cargo.toml b/agent_issuance/Cargo.toml index 9f75c8f6..a6ba06fa 100644 --- a/agent_issuance/Cargo.toml +++ b/agent_issuance/Cargo.toml @@ -13,22 +13,17 @@ cqrs-es.workspace = true chrono = "0.4" types-ob-v3 = { git = "https://github.com/impierce/digital-credential-data-models.git", rev = "9f16c27" } derivative = "2.2" -futures.workspace = true -identity_core = "1.3" +identity_core.workspace = true identity_credential.workspace = true -jsonschema = "0.17" 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 -tokio.workspace = true tracing.workspace = true url.workspace = true -uuid.workspace = true # `test_utils` dependencies lazy_static = { workspace = true, optional = true } diff --git a/agent_issuance/src/credential/aggregate.rs b/agent_issuance/src/credential/aggregate.rs index a6ec1ac5..7db373c7 100644 --- a/agent_issuance/src/credential/aggregate.rs +++ b/agent_issuance/src/credential/aggregate.rs @@ -193,19 +193,31 @@ impl Aggregate for Credential { // Insert the rest of the fields for (key, value) in credential_subject { - new_credential_subject.insert(key, value); + if key != "id" { + new_credential_subject.insert(key, value); + } } + info!("Credential subject: {:?}", new_credential_subject); + // Replace the original credentialSubject with the new map credential.raw["credentialSubject"] = serde_json::Value::Object(new_credential_subject); + info!("Credential: {:?}", credential); + #[cfg(feature = "test_utils")] let iat = 0; #[cfg(not(feature = "test_utils"))] - let iat = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) + let iat = credential.raw["issuanceDate"] + .as_str() .unwrap() - .as_secs() as i64; + .parse::>() + .unwrap() + .timestamp(); + // let iat = std::time::SystemTime::now() + // .duration_since(std::time::UNIX_EPOCH) + // .unwrap() + // .as_secs() as i64; json!(jwt::encode( services.issuer.clone(), @@ -461,7 +473,7 @@ pub mod test_utils { "id": "http://example.com/credentials/3527", "type": ["VerifiableCredential", "OpenBadgeCredential"], "issuer": { - "id": "https://my-domain.example.org", + "id": "https://my-domain.example.org/", "type": "Profile", "name": "UniCore" }, diff --git a/agent_issuance/src/offer/aggregate.rs b/agent_issuance/src/offer/aggregate.rs index 3229b808..8105ab4e 100644 --- a/agent_issuance/src/offer/aggregate.rs +++ b/agent_issuance/src/offer/aggregate.rs @@ -103,9 +103,19 @@ impl Aggregate for Offer { // TODO: add to `service`? let client = reqwest::Client::new(); + let form_url_encoded_credential_offer = self + .credential_offer + .as_ref() + .ok_or(MissingCredentialOfferError)? + .to_string(); + + let target = + form_url_encoded_credential_offer.replace("openid-credential-offer://", target_url.as_str()); + + info!("Sending credential offer to: {}", target); + client - .get(target_url.clone()) - .json(self.credential_offer.as_ref().ok_or(MissingCredentialOfferError)?) + .get(target) .send() .await .map_err(|e| SendCredentialOfferError(e.to_string()))?; diff --git a/agent_secret_manager/Cargo.toml b/agent_secret_manager/Cargo.toml index 4c3f37be..3830af2b 100644 --- a/agent_secret_manager/Cargo.toml +++ b/agent_secret_manager/Cargo.toml @@ -10,7 +10,6 @@ agent_shared = { path = "../agent_shared" } anyhow = "1.0" async-trait = "0.1" base64.workspace = true -cqrs-es = "0.4.2" did_manager.workspace = true futures.workspace = true identity_iota.workspace = true @@ -18,7 +17,6 @@ jsonwebtoken = "9.3" log = "0.4" oid4vc-core.workspace = true p256 = { version = "0.13", features = ["jwk"] } -serde.workspace = true serde_json = "1.0" tokio.workspace = true url.workspace = true diff --git a/agent_secret_manager/src/subject.rs b/agent_secret_manager/src/subject.rs index 304aef9b..a96e5ae1 100644 --- a/agent_secret_manager/src/subject.rs +++ b/agent_secret_manager/src/subject.rs @@ -141,7 +141,7 @@ impl oid4vc_core::Subject for Subject { } fn origin() -> url::Origin { - config().url.parse::().unwrap().origin() + config().url.origin() } #[cfg(test)] diff --git a/agent_shared/Cargo.toml b/agent_shared/Cargo.toml index d018d2d1..2ff67319 100644 --- a/agent_shared/Cargo.toml +++ b/agent_shared/Cargo.toml @@ -6,21 +6,12 @@ rust-version.workspace = true [dependencies] async-trait.workspace = true -base64.workspace = true config = { version = "0.14" } cqrs-es.workspace = true -did_manager.workspace = true dotenvy = { version = "0.15" } -identity_core = { version = "1.3" } -identity_credential.workspace = true -identity_did = { version = "1.3" } -identity_document = { version = "1.3" } -identity_storage = { version = "1.3" } -identity_verification.workspace = true -is_empty = "0.2" -jsonwebtoken.workspace = true # TODO: replace all identity_* with identity_iota? identity_iota.workspace = true +jsonwebtoken.workspace = true oid4vc-core.workspace = true oid4vci.workspace = true oid4vp.workspace = true @@ -29,7 +20,6 @@ rand = "0.8" serde.workspace = true serde_json.workspace = true serde_with = "3.0" -serde_yaml.workspace = true strum = { version = "0.26", features = ["derive"] } thiserror.workspace = true time = { version = "0.3" } diff --git a/agent_shared/src/config.rs b/agent_shared/src/config.rs index 6fb64530..4eb1d2e3 100644 --- a/agent_shared/src/config.rs +++ b/agent_shared/src/config.rs @@ -18,7 +18,7 @@ use url::Url; pub struct ApplicationConfiguration { pub log_format: LogFormat, pub event_store: EventStoreConfig, - pub url: String, + pub url: Url, pub base_path: Option, pub cors_enabled: Option, pub did_methods: HashMap, @@ -116,6 +116,10 @@ pub struct EventPublisherHttp { #[derive(Debug, Deserialize, Clone, Default)] pub struct Events { + #[serde(default)] + pub document: Vec, + #[serde(default)] + pub service: Vec, #[serde(default)] pub server_config: Vec, #[serde(default)] @@ -132,6 +136,18 @@ pub struct Events { pub authorization_request: Vec, } +#[derive(Debug, Serialize, Deserialize, Clone, strum::Display)] +pub enum DocumentEvent { + DocumentCreated, + ServiceAdded, +} + +#[derive(Debug, Serialize, Deserialize, Clone, strum::Display)] +pub enum ServiceEvent { + DomainLinkageServiceCreated, + LinkedVerifiablePresentationServiceCreated, +} + #[derive(Debug, Serialize, Deserialize, Clone, strum::Display)] pub enum ServerConfigEvent { ServerMetadataInitialized, @@ -280,14 +296,22 @@ impl ApplicationConfiguration { options.preferred = Some(false); } - // Set the current preferred did_method to true if available. - self.did_methods + // Set the current preferred did_method to true. + let entry = self + .did_methods .entry(preferred_did_method) .or_insert_with(|| ToggleOptions { enabled: true, preferred: Some(true), - }) - .preferred = Some(true); + }); + entry.enabled = true; + entry.preferred = Some(true); + } + + pub fn disable_did_method(&mut self, did_method: SupportedDidMethod) { + if let Some(options) = self.did_methods.get_mut(&did_method) { + options.enabled = false; + } } // TODO: make generic: set_enabled(enabled: bool) diff --git a/agent_shared/src/domain_linkage/mod.rs b/agent_shared/src/domain_linkage/mod.rs deleted file mode 100644 index 7bd5d82c..00000000 --- a/agent_shared/src/domain_linkage/mod.rs +++ /dev/null @@ -1,123 +0,0 @@ -pub mod verifiable_credential_jwt; - -use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; -use std::time::{SystemTime, UNIX_EPOCH}; - -use crate::config::get_preferred_signing_algorithm; -use crate::error::SharedError; -use crate::from_jsonwebtoken_algorithm_to_jwsalgorithm; -use did_manager::SecretManager; -use identity_core::common::{Duration, Timestamp}; -use identity_credential::credential::{Credential, Jwt}; -use identity_credential::domain_linkage::{DomainLinkageConfiguration, DomainLinkageCredentialBuilder}; -use identity_did::DID; -use identity_document::document::CoreDocument; -use identity_storage::{JwkDocumentExt, JwsSignatureOptions, Storage}; -use jsonwebtoken::Header; -use tracing::info; -use verifiable_credential_jwt::VerifiableCredentialJwt; - -pub async fn create_did_configuration_resource( - url: url::Url, - did_document: CoreDocument, - secret_manager: &SecretManager, -) -> Result { - let url = if cfg!(feature = "local_development") { - url::Url::parse("http://local.example.org:8080").unwrap() - } else { - url - }; - - let origin = identity_core::common::Url::parse(url.origin().ascii_serialization()) - .map_err(|e| SharedError::Generic(e.to_string()))?; - let domain_linkage_credential: Credential = DomainLinkageCredentialBuilder::new() - .issuer(did_document.id().clone()) - .origin(origin) - .issuance_date(Timestamp::now_utc()) - // Expires after a year. - .expiration_date( - Timestamp::now_utc() - .checked_add(Duration::days(365)) - .ok_or_else(|| SharedError::Generic("calculation should not overflow".to_string()))?, - ) - .build() - .map_err(|e| SharedError::Generic(e.to_string()))?; - - info!("Domain Linkage Credential: {domain_linkage_credential:#}"); - - // Construct a `Storage` (identity_stronghold) for temporary usage: create JWS, etc. - let key_storage = secret_manager.stronghold_storage.clone(); - let key_id_storage = secret_manager.stronghold_storage.clone(); - - let storage = Storage::new(key_storage, key_id_storage); - - info!("DID Document: {did_document:#}"); - - // identity.rs currently doesn't know how to handle a `did:web` document in `create_credential_jwt()`. - - // Compose JWT and sign - let jwt: Jwt = match did_document.id().method() { - "iota" => did_document - .create_credential_jwt( - &domain_linkage_credential, - &storage, - // TODO: make this dynamic - "key-0", - &JwsSignatureOptions::default(), - None, - ) - .await - .map_err(|e| SharedError::Generic(e.to_string()))?, - "web" => { - let subject_did = did_document.id().to_string(); - let issuer_did = subject_did.clone(); - - let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() as i64; - let expires_in_secs = 60 * 60 * 24 * 365; - - // Create a new verifiable credential. - let payload = VerifiableCredentialJwt::builder() - .sub(&subject_did) - .iss(&issuer_did) - .nbf(now) - .exp(now + expires_in_secs) - .verifiable_credential(serde_json::json!(domain_linkage_credential)) - .build() - .unwrap(); - - // Compose JWT - let header = Header { - alg: get_preferred_signing_algorithm(), - typ: Some("JWT".to_string()), - kid: Some(format!("{subject_did}#key-0")), - ..Default::default() - }; - - let message = [ - URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).unwrap().as_slice()), - URL_SAFE_NO_PAD.encode(serde_json::to_vec(&payload).unwrap().as_slice()), - ] - .join("."); - - let proof_value = secret_manager - .sign( - message.as_bytes(), - from_jsonwebtoken_algorithm_to_jwsalgorithm(&crate::config::get_preferred_signing_algorithm()), - ) - .await - .unwrap(); - let signature = URL_SAFE_NO_PAD.encode(proof_value.as_slice()); - let message = [message, signature].join("."); - - Jwt::from(message) - } - _ => { - unimplemented!("Unsupported DID method: {}", did_document.id().method()); - } - }; - - let configuration_resource: DomainLinkageConfiguration = DomainLinkageConfiguration::new(vec![jwt]); - println!("Configuration Resource >>: {configuration_resource:#}"); - - Ok(configuration_resource) -} diff --git a/agent_shared/src/domain_linkage/verifiable_credential_jwt.rs b/agent_shared/src/domain_linkage/verifiable_credential_jwt.rs deleted file mode 100644 index a16aeb43..00000000 --- a/agent_shared/src/domain_linkage/verifiable_credential_jwt.rs +++ /dev/null @@ -1,82 +0,0 @@ -use is_empty::IsEmpty; -use serde::{Deserialize, Serialize}; -use serde_with::skip_serializing_none; - -use crate::error::SharedError; - -/// Set of IANA registered claims by the Internet Engineering Task Force (IETF) in -/// [RFC 7519](https://tools.ietf.org/html/rfc7519#section-4.1). -#[skip_serializing_none] -#[derive(Serialize, Deserialize, Debug, Default, PartialEq, Clone, IsEmpty)] -pub struct RFC7519Claims { - pub iss: Option, - pub sub: Option, - pub aud: Option, - pub exp: Option, - pub nbf: Option, - pub iat: Option, - pub jti: Option, -} - -// Macro that generates a builder function for a field. -#[macro_export] -macro_rules! builder_fn { - ($name:ident, $ty:ty) => { - #[allow(clippy::should_implement_trait)] - pub fn $name(mut self, value: impl Into<$ty>) -> Self { - self.$name.replace(value.into()); - self - } - }; - ($field:ident, $name:ident, $ty:ty) => { - #[allow(clippy::should_implement_trait)] - pub fn $name(mut self, value: impl Into<$ty>) -> Self { - self.$field.$name.replace(value.into()); - self - } - }; -} - -#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)] -pub struct VerifiableCredentialJwt { - #[serde(flatten)] - pub rfc7519_claims: RFC7519Claims, - #[serde(rename = "vc")] - pub verifiable_credential: serde_json::Value, -} - -impl VerifiableCredentialJwt { - pub fn builder() -> VerifiableCredentialJwtBuilder { - VerifiableCredentialJwtBuilder::new() - } -} - -#[derive(Default)] -pub struct VerifiableCredentialJwtBuilder { - rfc7519_claims: RFC7519Claims, - verifiable_credential: Option, -} - -impl VerifiableCredentialJwtBuilder { - pub fn new() -> Self { - VerifiableCredentialJwtBuilder::default() - } - - pub fn build(self) -> Result { - Ok(VerifiableCredentialJwt { - rfc7519_claims: self.rfc7519_claims, - verifiable_credential: self - .verifiable_credential - .ok_or(SharedError::Generic("`verifiable_credential` is required".to_string()))?, - }) - } - - builder_fn!(rfc7519_claims, iss, String); - builder_fn!(rfc7519_claims, sub, String); - builder_fn!(rfc7519_claims, aud, String); - builder_fn!(rfc7519_claims, exp, i64); - builder_fn!(rfc7519_claims, nbf, i64); - builder_fn!(rfc7519_claims, iat, i64); - builder_fn!(rfc7519_claims, jti, String); - builder_fn!(verifiable_credential, serde_json::Value); -} diff --git a/agent_shared/src/lib.rs b/agent_shared/src/lib.rs index 6a7d89ed..e183167c 100644 --- a/agent_shared/src/lib.rs +++ b/agent_shared/src/lib.rs @@ -1,7 +1,6 @@ pub mod application_state; pub mod config; pub mod custom_queries; -pub mod domain_linkage; pub mod error; pub mod generic_query; pub mod handlers; diff --git a/agent_store/Cargo.toml b/agent_store/Cargo.toml index 90b86f95..b48261ae 100644 --- a/agent_store/Cargo.toml +++ b/agent_store/Cargo.toml @@ -6,6 +6,7 @@ rust-version.workspace = true [dependencies] agent_holder = { path = "../agent_holder" } +agent_identity = { path = "../agent_identity" } 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 b95316dc..9880538c 100644 --- a/agent_store/src/in_memory.rs +++ b/agent_store/src/in_memory.rs @@ -1,5 +1,6 @@ -use crate::{partition_event_publishers, EventPublisher}; +use crate::{partition_event_publishers, EventPublisher, Partitions}; use agent_holder::{services::HolderServices, state::HolderState}; +use agent_identity::{services::IdentityServices, state::IdentityState}; use agent_issuance::{ offer::{ aggregate::Offer, @@ -9,7 +10,7 @@ use agent_issuance::{ }, }, services::IssuanceServices, - state::{IssuanceState, ViewRepositories}, + state::IssuanceState, SimpleLoggingQuery, }; use agent_shared::{application_state::Command, custom_queries::ListAllQuery, generic_query::generic_query}; @@ -115,6 +116,53 @@ where } } +pub async fn identity_state( + identity_services: Arc, + event_publishers: Vec>, +) -> IdentityState { + // Initialize the in-memory repositories. + let document = Arc::new(MemRepository::default()); + let service = Arc::new(MemRepository::default()); + let all_services = Arc::new(MemRepository::default()); + + // Create custom-queries for the offer aggregate. + let all_services_query = ListAllQuery::new(all_services.clone(), "all_services"); + + // Partition the event_publishers into the different aggregates. + let Partitions { + document_event_publishers, + service_event_publishers, + .. + } = partition_event_publishers(event_publishers); + + IdentityState { + command: agent_identity::state::CommandHandlers { + document: Arc::new( + document_event_publishers.into_iter().fold( + AggregateHandler::new(identity_services.clone()) + .append_query(SimpleLoggingQuery {}) + .append_query(generic_query(document.clone())), + |aggregate_handler, event_publisher| aggregate_handler.append_event_publisher(event_publisher), + ), + ), + service: Arc::new( + service_event_publishers.into_iter().fold( + AggregateHandler::new(identity_services) + .append_query(SimpleLoggingQuery {}) + .append_query(generic_query(service.clone())) + .append_query(all_services_query), + |aggregate_handler, event_publisher| aggregate_handler.append_event_publisher(event_publisher), + ), + ), + }, + query: agent_identity::state::ViewRepositories { + document, + service, + all_services, + }, + } +} + pub async fn issuance_state( issuance_services: Arc, event_publishers: Vec>, @@ -136,8 +184,12 @@ pub async fn issuance_state( let all_offers_query = ListAllQuery::new(all_offers.clone(), "all_offers"); // Partition the event_publishers into the different aggregates. - let (server_config_event_publishers, credential_event_publishers, offer_event_publishers, _, _, _, _) = - partition_event_publishers(event_publishers); + let Partitions { + server_config_event_publishers, + credential_event_publishers, + offer_event_publishers, + .. + } = partition_event_publishers(event_publishers); IssuanceState { command: agent_issuance::state::CommandHandlers { @@ -170,7 +222,7 @@ pub async fn issuance_state( ), ), }, - query: ViewRepositories { + query: agent_issuance::state::ViewRepositories { server_config, pre_authorized_code, access_token, @@ -188,22 +240,29 @@ pub async fn holder_state( ) -> HolderState { // Initialize the in-memory repositories. let holder_credential = Arc::new(MemRepository::default()); - let received_offer = Arc::new(MemRepository::default()); let all_holder_credentials = Arc::new(MemRepository::default()); + let presentation = Arc::new(MemRepository::default()); + let all_presentations = Arc::new(MemRepository::default()); + let received_offer = Arc::new(MemRepository::default()); let all_received_offers = Arc::new(MemRepository::default()); // Create custom-queries for the offer aggregate. let all_holder_credentials_query = ListAllQuery::new(all_holder_credentials.clone(), "all_holder_credentials"); + let all_presentations_query = ListAllQuery::new(all_presentations.clone(), "all_presentations"); let all_received_offers_query = ListAllQuery::new(all_received_offers.clone(), "all_received_offers"); // Partition the event_publishers into the different aggregates. - let (_, _, _, credential_event_publishers, offer_event_publishers, _, _) = - partition_event_publishers(event_publishers); + let Partitions { + holder_credential_event_publishers, + presentation_event_publishers, + received_offer_event_publishers, + .. + } = partition_event_publishers(event_publishers); HolderState { command: agent_holder::state::CommandHandlers { credential: Arc::new( - credential_event_publishers.into_iter().fold( + holder_credential_event_publishers.into_iter().fold( AggregateHandler::new(holder_services.clone()) .append_query(SimpleLoggingQuery {}) .append_query(generic_query(holder_credential.clone())) @@ -211,8 +270,17 @@ pub async fn holder_state( |aggregate_handler, event_publisher| aggregate_handler.append_event_publisher(event_publisher), ), ), + presentation: Arc::new( + presentation_event_publishers.into_iter().fold( + AggregateHandler::new(holder_services.clone()) + .append_query(SimpleLoggingQuery {}) + .append_query(generic_query(presentation.clone())) + .append_query(all_presentations_query), + |aggregate_handler, event_publisher| aggregate_handler.append_event_publisher(event_publisher), + ), + ), offer: Arc::new( - offer_event_publishers.into_iter().fold( + received_offer_event_publishers.into_iter().fold( AggregateHandler::new(holder_services.clone()) .append_query(SimpleLoggingQuery {}) .append_query(generic_query(received_offer.clone())) @@ -224,6 +292,8 @@ pub async fn holder_state( query: agent_holder::state::ViewRepositories { holder_credential, all_holder_credentials, + presentation, + all_presentations, received_offer, all_received_offers, }, @@ -239,8 +309,11 @@ 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) = - partition_event_publishers(event_publishers); + let Partitions { + authorization_request_event_publishers, + connection_event_publishers, + .. + } = partition_event_publishers(event_publishers); VerificationState { command: agent_verification::state::CommandHandlers { diff --git a/agent_store/src/lib.rs b/agent_store/src/lib.rs index b5baaf80..530dfcbb 100644 --- a/agent_store/src/lib.rs +++ b/agent_store/src/lib.rs @@ -1,3 +1,4 @@ +use agent_identity::{document::aggregate::Document, service::aggregate::Service}; use agent_issuance::{ credential::aggregate::Credential, offer::aggregate::Offer, server_config::aggregate::ServerConfig, }; @@ -7,30 +8,44 @@ use cqrs_es::Query; pub mod in_memory; pub mod postgres; +pub type DocumentEventPublisher = Box>; +pub type ServiceEventPublisher = Box>; pub type ServerConfigEventPublisher = Box>; pub type CredentialEventPublisher = Box>; pub type OfferEventPublisher = Box>; pub type HolderCredentialEventPublisher = Box>; +pub type PresentationEventPublisher = Box>; pub type ReceivedOfferEventPublisher = Box>; pub type AuthorizationRequestEventPublisher = Box>; pub type ConnectionEventPublisher = Box>; /// Contains all the event_publishers for each aggregate. -pub type Partitions = ( - Vec, - Vec, - Vec, - Vec, - Vec, - Vec, - Vec, -); +#[derive(Default)] +pub struct Partitions { + pub document_event_publishers: Vec, + pub service_event_publishers: Vec, + pub server_config_event_publishers: Vec, + pub credential_event_publishers: Vec, + pub offer_event_publishers: Vec, + pub holder_credential_event_publishers: Vec, + pub presentation_event_publishers: Vec, + pub received_offer_event_publishers: Vec, + pub authorization_request_event_publishers: Vec, + pub connection_event_publishers: Vec, +} /// An outbound event_publisher is a component that listens to events and dispatches them to the appropriate service. For each /// aggregate, by default, `None` is returned. If an event_publisher is interested in a specific aggregate, it should return a /// `Some` with the appropriate query. // TODO: move this to a separate crate that will include all the logic for event_publishers, i.e. `agent_event_publisher`. pub trait EventPublisher { + fn document(&mut self) -> Option { + None + } + fn service(&mut self) -> Option { + None + } + fn server_config(&mut self) -> Option { None } @@ -44,6 +59,9 @@ pub trait EventPublisher { fn holder_credential(&mut self) -> Option { None } + fn presentation(&mut self) -> Option { + None + } fn received_offer(&mut self) -> Option { None } @@ -57,35 +75,46 @@ 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![]), - |mut partitions, mut event_publisher| { + event_publishers + .into_iter() + .fold(Partitions::default(), |mut partitions, mut event_publisher| { + if let Some(document) = event_publisher.document() { + partitions.document_event_publishers.push(document); + } + if let Some(service) = event_publisher.service() { + partitions.service_event_publishers.push(service); + } + if let Some(server_config) = event_publisher.server_config() { - partitions.0.push(server_config); + partitions.server_config_event_publishers.push(server_config); } if let Some(credential) = event_publisher.credential() { - partitions.1.push(credential); + partitions.credential_event_publishers.push(credential); } if let Some(offer) = event_publisher.offer() { - partitions.2.push(offer); + partitions.offer_event_publishers.push(offer); } - if let Some(credential) = event_publisher.holder_credential() { - partitions.3.push(credential); + if let Some(holder_credential) = event_publisher.holder_credential() { + partitions.holder_credential_event_publishers.push(holder_credential); + } + if let Some(presentation) = event_publisher.presentation() { + partitions.presentation_event_publishers.push(presentation); } - if let Some(offer) = event_publisher.received_offer() { - partitions.4.push(offer); + if let Some(received_offer) = event_publisher.received_offer() { + partitions.received_offer_event_publishers.push(received_offer); } if let Some(authorization_request) = event_publisher.authorization_request() { - partitions.5.push(authorization_request); + partitions + .authorization_request_event_publishers + .push(authorization_request); } if let Some(connection) = event_publisher.connection() { - partitions.6.push(connection); + partitions.connection_event_publishers.push(connection); } partitions - }, - ) + }) } #[cfg(test)] @@ -140,20 +169,26 @@ mod test { let event_publishers: Vec> = vec![Box::new(FooEventPublisher), Box::new(BarEventPublisher)]; - let ( + let Partitions { + document_event_publishers, + service_event_publishers, server_config_event_publishers, credential_event_publishers, offer_event_publishers, holder_credential_event_publishers, + presentation_event_publishers, received_offer_event_publishers, authorization_request_event_publishers, connection_event_publishers, - ) = partition_event_publishers(event_publishers); + } = partition_event_publishers(event_publishers); + assert_eq!(document_event_publishers.len(), 0); + assert_eq!(service_event_publishers.len(), 0); assert_eq!(server_config_event_publishers.len(), 1); assert_eq!(credential_event_publishers.len(), 0); assert_eq!(offer_event_publishers.len(), 0); assert_eq!(holder_credential_event_publishers.len(), 0); + assert_eq!(presentation_event_publishers.len(), 0); assert_eq!(received_offer_event_publishers.len(), 0); assert_eq!(authorization_request_event_publishers.len(), 0); assert_eq!(connection_event_publishers.len(), 2); diff --git a/agent_store/src/postgres.rs b/agent_store/src/postgres.rs index 7b704f2e..ba97d37a 100644 --- a/agent_store/src/postgres.rs +++ b/agent_store/src/postgres.rs @@ -1,9 +1,10 @@ -use crate::{partition_event_publishers, EventPublisher}; +use crate::{partition_event_publishers, EventPublisher, Partitions}; use agent_holder::{services::HolderServices, state::HolderState}; +use agent_identity::{services::IdentityServices, state::IdentityState}; use agent_issuance::{ offer::queries::{access_token::AccessTokenQuery, pre_authorized_code::PreAuthorizedCodeQuery}, services::IssuanceServices, - state::{CommandHandlers, IssuanceState, ViewRepositories}, + state::IssuanceState, SimpleLoggingQuery, }; use agent_shared::{ @@ -65,6 +66,58 @@ where } } +pub async fn identity_state( + identity_services: Arc, + event_publishers: Vec>, +) -> IdentityState { + 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 document = Arc::new(PostgresViewRepository::new("document", pool.clone())); + let service = Arc::new(PostgresViewRepository::new("service", pool.clone())); + let all_services = Arc::new(PostgresViewRepository::new("all_services", pool.clone())); + + // Create custom-queries for the offer aggregate. + let all_services_query = ListAllQuery::new(all_services.clone(), "all_services"); + + // Partition the event_publishers into the different aggregates. + let Partitions { + document_event_publishers, + service_event_publishers, + .. + } = partition_event_publishers(event_publishers); + + IdentityState { + command: agent_identity::state::CommandHandlers { + document: Arc::new( + document_event_publishers.into_iter().fold( + AggregateHandler::new(pool.clone(), identity_services.clone()) + .append_query(SimpleLoggingQuery {}) + .append_query(generic_query(document.clone())), + |aggregate_handler, event_publisher| aggregate_handler.append_event_publisher(event_publisher), + ), + ), + service: Arc::new( + service_event_publishers.into_iter().fold( + AggregateHandler::new(pool.clone(), identity_services) + .append_query(SimpleLoggingQuery {}) + .append_query(generic_query(service.clone())) + .append_query(all_services_query), + |aggregate_handler, event_publisher| aggregate_handler.append_event_publisher(event_publisher), + ), + ), + }, + query: agent_identity::state::ViewRepositories { + document, + service, + all_services, + }, + } +} + pub async fn issuance_state( issuance_services: Arc, event_publishers: Vec>, @@ -88,15 +141,19 @@ 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, _, _, _, _) = - partition_event_publishers(event_publishers); + let Partitions { + server_config_event_publishers, + credential_event_publishers, + offer_event_publishers, + .. + } = partition_event_publishers(event_publishers); // 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"); IssuanceState { - command: CommandHandlers { + command: agent_issuance::state::CommandHandlers { server_config: Arc::new( server_config_event_publishers.into_iter().fold( AggregateHandler::new(pool.clone(), ()) @@ -126,7 +183,7 @@ pub async fn issuance_state( ), ), }, - query: ViewRepositories { + query: agent_issuance::state::ViewRepositories { server_config, pre_authorized_code, access_token, @@ -152,21 +209,28 @@ pub async fn holder_state( Arc::new(PostgresViewRepository::new("holder_credential", pool.clone())); let all_holder_credentials: Arc> = Arc::new(PostgresViewRepository::new("all_holder_credentials", pool.clone())); + let presentation = Arc::new(PostgresViewRepository::new("presentation", pool.clone())); + let all_presentations = Arc::new(PostgresViewRepository::new("all_presentations", pool.clone())); let received_offer = Arc::new(PostgresViewRepository::new("received_offer", pool.clone())); let all_received_offers = Arc::new(PostgresViewRepository::new("all_received_offers", pool.clone())); // Create custom-queries for the offer aggregate. let all_holder_credentials_query = ListAllQuery::new(all_holder_credentials.clone(), "all_holder_credentials"); + let all_presentations_query = ListAllQuery::new(all_presentations.clone(), "all_presentations"); let all_received_offers_query = ListAllQuery::new(all_received_offers.clone(), "all_received_offers"); // Partition the event_publishers into the different aggregates. - let (_, _, _, credential_event_publishers, offer_event_publishers, _, _) = - partition_event_publishers(event_publishers); + let Partitions { + holder_credential_event_publishers, + presentation_event_publishers, + received_offer_event_publishers, + .. + } = partition_event_publishers(event_publishers); HolderState { command: agent_holder::state::CommandHandlers { credential: Arc::new( - credential_event_publishers.into_iter().fold( + holder_credential_event_publishers.into_iter().fold( AggregateHandler::new(pool.clone(), holder_services.clone()) .append_query(SimpleLoggingQuery {}) .append_query(generic_query(holder_credential.clone())) @@ -174,8 +238,17 @@ pub async fn holder_state( |aggregate_handler, event_publisher| aggregate_handler.append_event_publisher(event_publisher), ), ), + presentation: Arc::new( + presentation_event_publishers.into_iter().fold( + AggregateHandler::new(pool.clone(), holder_services.clone()) + .append_query(SimpleLoggingQuery {}) + .append_query(generic_query(presentation.clone())) + .append_query(all_presentations_query), + |aggregate_handler, event_publisher| aggregate_handler.append_event_publisher(event_publisher), + ), + ), offer: Arc::new( - offer_event_publishers.into_iter().fold( + received_offer_event_publishers.into_iter().fold( AggregateHandler::new(pool, holder_services.clone()) .append_query(SimpleLoggingQuery {}) .append_query(generic_query(received_offer.clone())) @@ -187,6 +260,8 @@ pub async fn holder_state( query: agent_holder::state::ViewRepositories { holder_credential, all_holder_credentials, + presentation, + all_presentations, received_offer, all_received_offers, }, @@ -207,8 +282,11 @@ 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) = - partition_event_publishers(event_publishers); + let Partitions { + authorization_request_event_publishers, + connection_event_publishers, + .. + } = partition_event_publishers(event_publishers); VerificationState { command: agent_verification::state::CommandHandlers { diff --git a/agent_verification/Cargo.toml b/agent_verification/Cargo.toml index 57907114..86a092c8 100644 --- a/agent_verification/Cargo.toml +++ b/agent_verification/Cargo.toml @@ -30,7 +30,7 @@ agent_verification = { path = ".", features = ["test_utils"] } async-std = { version = "1.5", features = ["attributes", "tokio1"] } did_manager.workspace = true -identity_core = "1.2.0" +identity_core.workspace = true identity_credential.workspace = true lazy_static.workspace = true oid4vci.workspace = true diff --git a/agent_verification/src/authorization_request/aggregate.rs b/agent_verification/src/authorization_request/aggregate.rs index bb14c130..25cce297 100644 --- a/agent_verification/src/authorization_request/aggregate.rs +++ b/agent_verification/src/authorization_request/aggregate.rs @@ -51,8 +51,8 @@ impl Aggregate for AuthorizationRequest { .unwrap(); let url = &config().url; - let request_uri = format!("{url}/request/{state}").parse().unwrap(); - let redirect_uri = format!("{url}/redirect").parse::().unwrap(); + let request_uri = format!("{url}request/{state}").parse().unwrap(); + let redirect_uri = format!("{url}redirect").parse::().unwrap(); let authorization_request = Box::new(if let Some(presentation_definition) = presentation_definition { GenericAuthorizationRequest::OID4VP(Box::new(