diff --git a/agent_api_rest/src/issuance/credentials.rs b/agent_api_rest/src/issuance/credentials.rs index 6ac650c2..977745a9 100644 --- a/agent_api_rest/src/issuance/credentials.rs +++ b/agent_api_rest/src/issuance/credentials.rs @@ -43,7 +43,6 @@ pub struct CredentialsEndpointRequest { pub is_signed: bool, pub credential_configuration_id: String, pub expires_at: CredentialExpiry, - #[serde(default)] pub delivery_options: DeliveryOptions, } @@ -302,6 +301,9 @@ pub mod tests { }, "credentialConfigurationId": CREDENTIAL_CONFIGURATION_ID, "expiresAt": "never", + "deliveryOptions": { + "recipientEmail": null + } })) .unwrap(), )) diff --git a/agent_api_rest/src/issuance/offers/send.rs b/agent_api_rest/src/issuance/offers/send.rs index db5ed948..3d7e737d 100644 --- a/agent_api_rest/src/issuance/offers/send.rs +++ b/agent_api_rest/src/issuance/offers/send.rs @@ -1,5 +1,5 @@ use crate::handlers::command_handler; -use agent_issuance::{offer::command::OfferCommand, state::IssuanceState}; +use agent_issuance::{offer::aggregate::DeliveryMethod, offer::command::OfferCommand, state::IssuanceState}; use axum::{ extract::State, response::{IntoResponse, Response}, @@ -8,26 +8,29 @@ use axum::{ use http_api_problem::ApiError; use hyper::StatusCode; use serde::{Deserialize, Serialize}; -use url::Url; #[derive(Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct SendOfferEndpointRequest { pub offer_id: String, - pub target_url: Url, + #[serde(flatten)] + pub delivery_method: DeliveryMethod, } #[axum_macros::debug_handler] pub(crate) async fn send( State(state): State, - Json(SendOfferEndpointRequest { offer_id, target_url }): Json, + Json(SendOfferEndpointRequest { + offer_id, + delivery_method, + }): Json, ) -> Result { let command = OfferCommand::SendCredentialOffer { offer_id: offer_id.clone(), - target_url, + delivery_method, }; - // Send the Credential Offer to the `target_url`. + // Send the Credential Offer to the `target_url` or to the email recipient. command_handler(&offer_id, &state.command.offer, command).await?; Ok(StatusCode::OK.into_response()) diff --git a/agent_application/example.config.yaml b/agent_application/example.config.yaml index 85afb136..c4f16c38 100644 --- a/agent_application/example.config.yaml +++ b/agent_application/example.config.yaml @@ -42,7 +42,7 @@ event_publishers: subjects: - name: "email.commands" events: - offer: [TxCodeGenerated] + offer: [TxCodeGenerated, CredentialOfferEmailSent] # These display parameters are interpreted by identity wallets. display: diff --git a/agent_event_publisher_nats/README.md b/agent_event_publisher_nats/README.md index 6f3cd202..066f2037 100644 --- a/agent_event_publisher_nats/README.md +++ b/agent_event_publisher_nats/README.md @@ -11,10 +11,10 @@ event_publishers: nats: enabled: true nats_url: "nats://localhost:4222" # NATS server URL - subject: "email.commands" # NATS subject to publish to - events: - offer: - - "TxCodeGenerated" # Event types to publish + subjects: + - name: "email.commands" # NATS subject to publish to + events: + offer: [TxCodeGenerated, CredentialOfferEmailSent] # Event types to publish ``` ## Usage @@ -56,8 +56,6 @@ For example: ### Available events -Currently, the nats publisher only listens to one event: - #### `offer` -TxCodeGenerated +TxCodeGenerated, CredentialOfferEmailSent diff --git a/agent_holder/src/offer/aggregate.rs b/agent_holder/src/offer/aggregate.rs index 6e86b002..a7690598 100644 --- a/agent_holder/src/offer/aggregate.rs +++ b/agent_holder/src/offer/aggregate.rs @@ -377,6 +377,9 @@ pub mod tests { }}, "credentialConfigurationId": "001", "expiresAt": "never", + "deliveryOptions": { + "recipientEmail": null + } })) .unwrap(), )) @@ -392,9 +395,12 @@ pub mod tests { .header(http::header::CONTENT_TYPE, mime::APPLICATION_JSON.as_ref()) .body(Body::from( serde_json::to_vec(&json!({ - "offerId": received_offer_id, - "credentialConfigurationIds": ["001"], - })) + "offerId": received_offer_id, + "credentialConfigurationIds": ["001"], + "deliveryOptions": { + "recipientEmail": null + } + })) .unwrap(), )) .unwrap(), diff --git a/agent_issuance/src/offer/aggregate.rs b/agent_issuance/src/offer/aggregate.rs index 1bd773b5..9397fe66 100644 --- a/agent_issuance/src/offer/aggregate.rs +++ b/agent_issuance/src/offer/aggregate.rs @@ -13,6 +13,7 @@ use serde::{Deserialize, Serialize}; use std::collections::HashSet; use std::sync::Arc; use tracing::{debug, info}; +use url::Url; use crate::offer::command::OfferCommand; use crate::offer::error::OfferError::{self, *}; @@ -52,6 +53,21 @@ pub struct DeliveryOptions { pub recipient_email: Option, } +// Delivery methods for sending the credential offer. Not to be confused +// with the DeliveryOptions struct, which is used when creating the offer. +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(untagged)] +pub enum DeliveryMethod { + TargetUrl { + #[serde(rename = "targetUrl")] + target_url: Url, + }, + Email { + #[serde(rename = "recipientEmail")] + recipient_email: String, + }, +} + #[async_trait] impl Aggregate for Offer { type Command = OfferCommand; @@ -111,6 +127,9 @@ impl Aggregate for Offer { // If TxCode constraints are provided, generate a transaction code. let tx_code = tx_code_constraints.as_ref().map(generate_tx_code); + if tx_code.is_some() && delivery_options.recipient_email.is_none() { + return Err(MissingRecipientEmailError); + } let credential_offer = CredentialOffer::CredentialOffer(Box::new(CredentialOfferParameters { credential_issuer: credential_issuer.clone(), @@ -137,7 +156,7 @@ impl Aggregate for Offer { offer_id: offer_id.clone(), grant_types, credential_offer_uri, - credential_offer, + credential_offer: credential_offer.clone(), pre_authorized_code, access_token, status: Status::Created, @@ -146,7 +165,7 @@ impl Aggregate for Offer { }, FormUrlEncodedCredentialOfferCreated { offer_id: offer_id.clone(), - form_url_encoded_credential_offer, + form_url_encoded_credential_offer: form_url_encoded_credential_offer.clone(), status: Status::Pending, }, ]; @@ -205,28 +224,47 @@ impl Aggregate for Offer { Ok(events) } - SendCredentialOffer { offer_id, target_url } => { - let client = reqwest::Client::new(); - let target = self + SendCredentialOffer { + offer_id, + delivery_method, + } => { + let form_url_encoded_credential_offer = self .form_url_encoded_credential_offer .as_ref() .ok_or_else(|| MissingCredentialOfferError)? - .replace("openid-credential-offer://", target_url.as_str()); - - info!("Sending credential offer to: {}", target); - - client - .get(target) - .send() - .await - .and_then(|response| response.error_for_status()) - .map_err(SendCredentialOfferError)?; + .clone(); - Ok(vec![CredentialOfferSent { - offer_id, - target_url, - status: Status::Pending, - }]) + match delivery_method { + DeliveryMethod::TargetUrl { target_url } => { + let client = reqwest::Client::new(); + let target = form_url_encoded_credential_offer + .replace("openid-credential-offer://", target_url.as_str()); + + info!("Sending credential offer to: {}", target); + + client + .get(target) + .send() + .await + .and_then(|response| response.error_for_status()) + .map_err(SendCredentialOfferError)?; + + Ok(vec![CredentialOfferSent { + offer_id, + target_url, + status: Status::Pending, + }]) + } + DeliveryMethod::Email { recipient_email } => { + info!("Sending credential offer via email to: {}", recipient_email); + Ok(vec![CredentialOfferEmailSent { + offer_id, + recipient_email, + form_url_encoded_credential_offer, + status: Status::Pending, + }]) + } + } } CreateTokenResponse { offer_id, @@ -371,6 +409,7 @@ impl Aggregate for Offer { self.status = status; } CredentialOfferSent { .. } => {} + CredentialOfferEmailSent { .. } => {} CredentialRequestVerified { subject_id, .. } => { self.subject_id.replace(subject_id); } diff --git a/agent_issuance/src/offer/command.rs b/agent_issuance/src/offer/command.rs index 792b1086..c98fb90b 100644 --- a/agent_issuance/src/offer/command.rs +++ b/agent_issuance/src/offer/command.rs @@ -1,4 +1,4 @@ -use crate::offer::aggregate::DeliveryOptions; +use crate::offer::aggregate::{DeliveryMethod, DeliveryOptions}; use oid4vci::{ credential_issuer::{ authorization_server_metadata::AuthorizationServerMetadata, @@ -9,7 +9,6 @@ use oid4vci::{ token_request::TokenRequest, }; use serde::Deserialize; -use url::Url; #[derive(Debug, Deserialize)] #[serde(untagged)] @@ -28,7 +27,7 @@ pub enum OfferCommand { }, SendCredentialOffer { offer_id: String, - target_url: Url, + delivery_method: DeliveryMethod, }, // OpenID4VCI Pre-Authorized Code Flow diff --git a/agent_issuance/src/offer/event.rs b/agent_issuance/src/offer/event.rs index 8b897360..2e202323 100644 --- a/agent_issuance/src/offer/event.rs +++ b/agent_issuance/src/offer/event.rs @@ -38,6 +38,12 @@ pub enum OfferEvent { target_url: Url, status: Status, }, + CredentialOfferEmailSent { + offer_id: String, + recipient_email: String, + form_url_encoded_credential_offer: String, + status: Status, + }, TokenResponseCreated { offer_id: String, token_response: TokenResponse, diff --git a/agent_issuance/src/offer/views/mod.rs b/agent_issuance/src/offer/views/mod.rs index 4054fcf9..6a210c78 100644 --- a/agent_issuance/src/offer/views/mod.rs +++ b/agent_issuance/src/offer/views/mod.rs @@ -58,6 +58,15 @@ impl View for Offer { self.offer_id.clone_from(offer_id); self.status.clone_from(status); } + CredentialOfferEmailSent { + offer_id, + recipient_email: _recipient_email, + form_url_encoded_credential_offer: _form_url_encoded_credential_offer, + status, + } => { + self.offer_id.clone_from(offer_id); + self.status.clone_from(status); + } CredentialRequestVerified { offer_id, subject_id } => { self.offer_id.clone_from(offer_id); self.subject_id.replace(subject_id.clone()); diff --git a/agent_shared/src/config/mod.rs b/agent_shared/src/config/mod.rs index a385cdbd..e9dd6935 100644 --- a/agent_shared/src/config/mod.rs +++ b/agent_shared/src/config/mod.rs @@ -649,6 +649,7 @@ pub enum OfferEvent { CredentialRequestVerified, CredentialResponseCreated, TxCodeGenerated, + CredentialOfferEmailSent, } #[derive(Debug, Serialize, Deserialize, Clone, strum::Display)] @@ -1009,7 +1010,7 @@ mod tests { "sd-jwt_alg_values": ["ES256"], "kb-jwt_alg_values": ["ES256"] } - } + }, }) );