use serde::Deserialize;
use serde_json::{Value, json};
use crate::{MAX_DOCUMENT_RESPONSE_BYTES, VtcClient, VtcError};
pub const SESSION_EXT: &str = "org.openvtc.session";
pub mod task {
pub const REQUEST: &str = "https://trusttasks.org/spec/auth/oob/request/0.2";
pub const REDEEM: &str = "https://trusttasks.org/spec/auth/oob/redeem/0.2";
pub const CANCEL: &str = "https://trusttasks.org/spec/auth/oob/cancel/0.1";
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SignInAudience {
Member,
Admin,
}
impl SignInAudience {
pub fn as_str(self) -> &'static str {
match self {
Self::Member => "member",
Self::Admin => "admin",
}
}
pub fn parse(s: &str) -> Option<Self> {
match s {
"member" => Some(Self::Member),
"admin" => Some(Self::Admin),
_ => None,
}
}
fn of_ext(ext: Option<&Value>) -> Self {
match ext
.and_then(|e| e.get(SESSION_EXT))
.and_then(|ns| ns.get("audience"))
.and_then(Value::as_str)
{
Some("admin") => Self::Admin,
_ => Self::Member,
}
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct SignInConfig {
pub vtc_did: String,
pub link_host: String,
pub flow: String,
#[serde(default)]
pub did_document: Value,
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct SignInRequest {
pub request_id: String,
pub audience: SignInAudience,
pub claim_closes_at: String,
pub trigger_link: String,
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct SignedIn {
pub subject: String,
pub display_name: Option<String>,
pub session_ends_at: Option<String>,
pub amr: Vec<String>,
pub audience: SignInAudience,
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub enum SignInPoll {
Pending {
state: String,
match_number: Option<String>,
},
SignedIn(SignedIn),
Declined { reason: Option<String> },
Expired,
}
pub(crate) fn encode_from(did: &str) -> String {
let mut out = String::with_capacity(did.len());
for c in did.chars() {
match c {
'&' | '=' | '#' | '%' => out.push_str(&format!("%{:02X}", c as u32)),
other => out.push(other),
}
}
out
}
pub fn trigger_link(
cfg: &SignInConfig,
request_id: &str,
claim_deadline_epoch: i64,
) -> Result<String, VtcError> {
let link = format!(
"https://{}/t#_from={}&_id={request_id}&_exp={claim_deadline_epoch}&_type={}",
cfg.link_host,
encode_from(&cfg.vtc_did),
cfg.flow
);
if !link.is_ascii() || link.len() > 251 {
return Err(VtcError::SignIn {
code: "linkTooLong".into(),
message: "this community's sign-in link is too long to show as a code".into(),
details: Value::Null,
});
}
Ok(link)
}
fn epoch_of(v: &Value) -> Option<i64> {
v.as_str()
.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
.map(|t| t.timestamp())
}
fn local_code(code: &str) -> &str {
code.rsplit(':').next().unwrap_or(code)
}
impl VtcClient {
fn portal_origin(&self) -> Result<String, VtcError> {
if self.base_url.is_empty() {
return Err(VtcError::NoRestTransport("wallet sign-in"));
}
let parsed = url::Url::parse(&self.base_url).map_err(|e| VtcError::Url(e.to_string()))?;
Ok(parsed.origin().ascii_serialization())
}
pub async fn sign_in_config(&self) -> Result<SignInConfig, VtcError> {
if self.base_url.is_empty() {
return Err(VtcError::NoRestTransport("wallet sign-in"));
}
let resp = self
.http
.get(format!("{}/member/sign-in/config", self.base_url))
.send()
.await?;
let status = resp.status().as_u16();
let bytes = vta_sdk::http::read_body_capped(resp, MAX_DOCUMENT_RESPONSE_BYTES)
.await
.map_err(|e| VtcError::Http {
status,
body: e.to_string(),
})?;
if !(200..300).contains(&status) {
return Err(VtcError::Http {
status,
body: String::from_utf8_lossy(&bytes).into_owned(),
});
}
let cfg: SignInConfig = serde_json::from_slice(&bytes).map_err(|e| VtcError::Http {
status,
body: format!("sign-in config has an unexpected shape: {e}"),
})?;
if cfg.vtc_did != self.vtc_did {
return Err(VtcError::SignIn {
code: "wrongCommunity".into(),
message: format!(
"the portal at {} names {} and this client is bound to {}",
self.base_url, cfg.vtc_did, self.vtc_did
),
details: Value::Null,
});
}
Ok(cfg)
}
async fn oob_post(
&self,
type_uri: &str,
payload: Value,
starter: &str,
) -> Result<Result<Value, (String, String, Value)>, VtcError> {
let Some(key) = &self.signer else {
return Err(VtcError::NotAuthenticated);
};
let origin = self.portal_origin()?;
let doc =
vta_sdk::trust_task_sign::build_signed_with(type_uri, payload, key, &self.vtc_did)
.await
.map_err(|e| VtcError::Signing(e.to_string()))?;
let resp = self
.http
.post(format!("{}/trust-tasks", self.base_url))
.header("content-type", "application/json")
.header("origin", origin)
.header("user-agent", starter)
.body(doc)
.send()
.await?;
let status = resp.status().as_u16();
let bytes = vta_sdk::http::read_body_capped(resp, MAX_DOCUMENT_RESPONSE_BYTES)
.await
.map_err(|e| VtcError::Http {
status,
body: e.to_string(),
})?;
let text = String::from_utf8_lossy(&bytes).into_owned();
let body: Value = serde_json::from_str(&text).map_err(|_| VtcError::Http {
status,
body: text.clone(),
})?;
let is_error = body["type"]
.as_str()
.is_some_and(|t| t.contains("/trust-task-error/"));
if (200..300).contains(&status) && !is_error {
return Ok(Ok(body["payload"].clone()));
}
let payload = &body["payload"];
let code = payload["code"]
.as_str()
.map(str::to_string)
.unwrap_or_else(|| format!("http{status}"));
let message = payload["message"]
.as_str()
.map(str::to_string)
.unwrap_or_else(|| format!("the community refused the sign-in (HTTP {status})"));
Ok(Err((code, message, payload["details"].clone())))
}
pub async fn sign_in_request(
&self,
audience: SignInAudience,
starter: &str,
) -> Result<SignInRequest, VtcError> {
let cfg = self.sign_in_config().await?;
let mut payload = json!({ "purpose": "login", "mode": "scan" });
if audience == SignInAudience::Admin {
payload["ext"] = json!({ SESSION_EXT: { "audience": "admin" } });
}
let answer = match self.oob_post(task::REQUEST, payload, starter).await? {
Ok(p) => p,
Err((code, message, details)) => {
return Err(VtcError::SignIn {
code,
message,
details,
});
}
};
if SignInAudience::of_ext(answer.get("ext")) != audience {
return Err(VtcError::SignIn {
code: "audienceNotServed".into(),
message: "this community does not offer wallet sign-in to the operator console"
.into(),
details: Value::Null,
});
}
let request_id = answer["requestId"]
.as_str()
.filter(|id| {
id.len() == 22
&& id
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
})
.ok_or_else(|| VtcError::SignIn {
code: "unexpectedShape".into(),
message:
"the community answered the sign-in request in a shape this client cannot read"
.into(),
details: answer.clone(),
})?
.to_string();
let claim_closes_at = answer["claimClosesAt"]
.as_str()
.map(str::to_string)
.unwrap_or_default();
let deadline = epoch_of(&answer["claimClosesAt"]).ok_or_else(|| VtcError::SignIn {
code: "unexpectedShape".into(),
message: "the community's claimClosesAt is not an RFC 3339 instant".into(),
details: answer.clone(),
})?;
let trigger_link = trigger_link(&cfg, &request_id, deadline)?;
Ok(SignInRequest {
request_id,
audience,
claim_closes_at,
trigger_link,
})
}
pub async fn sign_in_redeem(
&self,
request_id: &str,
starter: &str,
) -> Result<SignInPoll, VtcError> {
match self
.oob_post(task::REDEEM, json!({ "requestId": request_id }), starter)
.await?
{
Ok(p) => Ok(SignInPoll::SignedIn(SignedIn {
subject: p["subject"].as_str().unwrap_or_default().to_string(),
display_name: p["displayName"].as_str().map(str::to_string),
session_ends_at: p["sessionEndsAt"].as_str().map(str::to_string),
amr: p["amr"]
.as_array()
.map(|a| {
a.iter()
.filter_map(Value::as_str)
.map(str::to_string)
.collect()
})
.unwrap_or_default(),
audience: SignInAudience::of_ext(p.get("ext")),
})),
Err((code, message, details)) => match local_code(&code) {
"pending" => Ok(SignInPoll::Pending {
state: details["state"].as_str().unwrap_or("pending").to_string(),
match_number: details["matchNumber"].as_str().map(str::to_string),
}),
"declined" => Ok(SignInPoll::Declined {
reason: details["reason"].as_str().map(str::to_string),
}),
"requestExpired" | "expired" => Ok(SignInPoll::Expired),
_ => Err(VtcError::SignIn {
code,
message,
details,
}),
},
}
}
pub async fn sign_in_cancel(&self, request_id: &str, starter: &str) -> Result<(), VtcError> {
match self
.oob_post(task::CANCEL, json!({ "requestId": request_id }), starter)
.await?
{
Ok(_) => Ok(()),
Err((code, message, details)) => Err(VtcError::SignIn {
code,
message,
details,
}),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_trigger_link_is_the_portals() {
let cfg = SignInConfig {
vtc_did: "did:webvh:Qm1:vtc.example.com:first".into(),
link_host: "link.trustoverip.org".into(),
flow: "vtc-sign-in".into(),
did_document: Value::Null,
};
let link = trigger_link(&cfg, "AAAAAAAAAAAAAAAAAAAAAA", 1_900_000_000).unwrap();
assert_eq!(
link,
"https://link.trustoverip.org/t#_from=did:webvh:Qm1:vtc.example.com:first\
&_id=AAAAAAAAAAAAAAAAAAAAAA&_exp=1900000000&_type=vtc-sign-in"
);
assert_eq!(encode_from("did:x:a&b=c#d%e"), "did:x:a%26b%3Dc%23d%25e");
let long = SignInConfig {
link_host: "x".repeat(240),
..cfg
};
assert!(matches!(
trigger_link(&long, "AAAAAAAAAAAAAAAAAAAAAA", 1),
Err(VtcError::SignIn { ref code, .. }) if code == "linkTooLong"
));
}
#[test]
fn audiences_and_codes_read_as_the_portal_reads_them() {
assert_eq!(SignInAudience::of_ext(None), SignInAudience::Member);
assert_eq!(
SignInAudience::of_ext(Some(&json!({ SESSION_EXT: { "audience": "admin" } }))),
SignInAudience::Admin
);
assert_eq!(
SignInAudience::of_ext(Some(&json!({ SESSION_EXT: { "audience": "root" } }))),
SignInAudience::Member
);
assert_eq!(local_code("auth/oob/redeem:pending"), "pending");
assert_eq!(local_code("http403"), "http403");
assert_eq!(
epoch_of(&json!("2030-03-17T17:46:40Z")),
Some(1_900_000_000)
);
assert_eq!(epoch_of(&json!(1_900_000_800)), None);
}
}