use lexe_common::time::TimestampMs;
#[cfg(feature = "crypto")]
use lexe_crypto::rng::Crng;
use lexe_uri::Uri;
use crate::request::{CredentialRequest, Delivery};
#[cfg(feature = "crypto")]
use crate::{
request::RequestError,
response::{
CredentialError, CredentialResponse, CredentialResult, ErrorCode, Grant,
},
seal::SealError,
};
#[cfg(feature = "crypto")]
#[derive(Debug, thiserror::Error)]
pub enum RespondError {
#[error(transparent)]
Request(#[from] RequestError),
#[error(transparent)]
Seal(#[from] SealError),
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum RequesterDisplay {
Verified { domain: String },
Unverified { scheme_host: Option<String> },
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum Outcome {
Approved {
credential: String,
expires_at: Option<TimestampMs>,
},
Rejected,
Failed(String),
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum DeliveryAction {
Redirect(String),
Http(HttpDelivery),
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct HttpDelivery {
pub url: String,
pub content_type: &'static str,
pub body: Vec<u8>,
}
#[cfg(feature = "crypto")]
impl HttpDelivery {
const CONTENT_TYPE_JSON: &str = "application/json";
const CONTENT_TYPE_OCTET_STREAM: &str = "application/octet-stream";
}
impl CredentialRequest {
pub fn requester_display(&self) -> RequesterDisplay {
let uri = match &self.params.delivery {
Delivery::Redirect(uri) | Delivery::Post(uri) => Uri::parse(uri),
Delivery::Mailbox(_) =>
return RequesterDisplay::Unverified { scheme_host: None },
};
let Ok(uri) = uri else {
return RequesterDisplay::Unverified { scheme_host: None };
};
let scheme = uri.scheme;
match uri.host() {
Some(host) if uri.is_https() => RequesterDisplay::Verified {
domain: host.into_owned(),
},
Some(host) => RequesterDisplay::Unverified {
scheme_host: Some(format!("{scheme}://{host}")),
},
None => RequesterDisplay::Unverified {
scheme_host: Some(match uri.authority {
true => format!("{scheme}://"),
false => format!("{scheme}:"),
}),
},
}
}
#[cfg(feature = "crypto")]
pub fn respond(
&self,
rng: &mut impl Crng,
outcome: Outcome,
) -> Result<DeliveryAction, RespondError> {
self.validate()?;
let result = match outcome {
Outcome::Approved {
credential,
expires_at,
} => CredentialResult::Granted(Grant {
credential,
scopes: self.params.scopes.clone(),
permissions: self.params.permissions.clone(),
expires_at,
}),
Outcome::Rejected => CredentialResult::Error(CredentialError {
code: ErrorCode::UserRejected,
message: None,
}),
Outcome::Failed(message) =>
CredentialResult::Error(CredentialError {
code: ErrorCode::Other,
message: Some(message),
}),
};
let response = CredentialResponse {
result,
one_time_secret: self.one_time_secret,
account: self.params.account.clone(),
metadata: self.params.metadata.clone(),
};
let action = match &self.params.delivery {
Delivery::Redirect(uri) => {
let blob = response.seal(rng, self)?.to_base64url();
let mut uri =
Uri::parse(uri).expect("validate() above parsed it");
uri.push_param("response", blob);
DeliveryAction::Redirect(uri.to_string())
}
Delivery::Post(url) if self.ephemeral_hpke_pubkey.is_some() =>
DeliveryAction::Http(HttpDelivery {
url: url.clone(),
content_type: HttpDelivery::CONTENT_TYPE_OCTET_STREAM,
body: response.seal(rng, self)?.0,
}),
Delivery::Post(url) => DeliveryAction::Http(HttpDelivery {
url: url.clone(),
content_type: HttpDelivery::CONTENT_TYPE_JSON,
body: response.to_json(),
}),
Delivery::Mailbox(url) => DeliveryAction::Http(HttpDelivery {
url: {
let mut url =
Uri::parse(url).expect("validate() above parsed it");
url.push_param(
"address",
self.mailbox_address().to_string(),
);
url.to_string()
},
content_type: HttpDelivery::CONTENT_TYPE_OCTET_STREAM,
body: response.seal(rng, self)?.0,
}),
};
Ok(action)
}
}
#[cfg(test)]
mod test {
use lexe_byte_array::ByteArray;
use lexe_crypto::rng::SysRng;
use super::*;
use crate::request::{CredentialRequestParams, OneTimeSecret};
fn request(delivery: Delivery) -> CredentialRequest {
CredentialRequest {
ephemeral_hpke_pubkey: None,
one_time_secret: OneTimeSecret::from_array([0; 16]),
params: CredentialRequestParams {
delivery,
account: None,
metadata: None,
scopes: ["read_info".to_owned()].into(),
permissions: Default::default(),
label: None,
expires_at: None,
},
}
}
#[test]
fn requester_display() {
let display =
|delivery: Delivery| request(delivery).requester_display();
let verified = |domain: &str| RequesterDisplay::Verified {
domain: domain.into(),
};
let unverified =
|scheme_host: Option<&str>| RequesterDisplay::Unverified {
scheme_host: scheme_host.map(String::from),
};
assert_eq!(
display(Delivery::Redirect("https://BillSplit.com/cb".into())),
verified("billsplit.com")
);
assert_eq!(
display(Delivery::Redirect("https://a@evil.com:1/".into())),
verified("evil.com")
);
assert_eq!(
display(Delivery::Post("https://paygate.com/x".into())),
verified("paygate.com")
);
assert_eq!(
display(Delivery::Redirect("myprotocol://".into())),
unverified(Some("myprotocol://"))
);
assert_eq!(
display(Delivery::Redirect("http://a.com/cb".into())),
unverified(Some("http://a.com"))
);
assert_eq!(
display(Delivery::Mailbox("https://lexe.app/mailbox".into())),
unverified(None)
);
}
#[test]
fn respond_validates() {
let mut rng = SysRng::new();
let insecure = request(Delivery::Post("http://paygate.com/x".into()));
assert!(matches!(
insecure.respond(&mut rng, Outcome::Rejected),
Err(RespondError::Request(RequestError::Invalid("post_url", _)))
));
}
}