use serde_json::Value;
use trust_tasks_rs::TrustTask;
use trust_tasks_rs::specs::auth::authenticate::v0_1 as authenticate;
use trust_tasks_rs::specs::auth::refresh::v0_1 as refresh;
use crate::protocols::auth::AuthenticateResponse;
use crate::trust_task_sign::{self, TrustTaskSignError};
use crate::trust_tasks::{TASK_AUTH_AUTHENTICATE_0_1, TASK_AUTH_REFRESH_0_1};
#[derive(Debug)]
pub enum AuthDiError {
Payload(String),
Sign(TrustTaskSignError),
Response(String),
}
impl From<TrustTaskSignError> for AuthDiError {
fn from(e: TrustTaskSignError) -> Self {
Self::Sign(e)
}
}
impl std::fmt::Display for AuthDiError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Payload(e) => write!(f, "invalid auth payload: {e}"),
Self::Sign(e) => write!(f, "{e}"),
Self::Response(e) => write!(f, "unexpected auth response from VTA: {e}"),
}
}
}
impl std::error::Error for AuthDiError {}
pub async fn sign_authenticate_doc(
client_did: &str,
private_key_multibase: &str,
vta_did: &str,
challenge: &str,
session_id: &str,
) -> Result<String, AuthDiError> {
let payload = authenticate::Payload {
challenge: authenticate::PayloadChallenge::try_from(challenge.to_string())
.map_err(|e| AuthDiError::Payload(format!("challenge: {e}")))?,
session_id: authenticate::PayloadSessionId::try_from(session_id.to_string())
.map_err(|e| AuthDiError::Payload(format!("sessionId: {e}")))?,
scope: Vec::new(),
ext: None,
};
Ok(trust_task_sign::build_signed(
TASK_AUTH_AUTHENTICATE_0_1,
serde_json::to_value(&payload).map_err(|e| AuthDiError::Payload(e.to_string()))?,
client_did,
private_key_multibase,
vta_did,
)
.await?)
}
pub fn build_refresh_doc(
client_did: &str,
vta_did: &str,
refresh_token: &str,
) -> Result<String, AuthDiError> {
let payload = refresh::Payload {
refresh_token: refresh::PayloadRefreshToken::try_from(refresh_token.to_string())
.map_err(|e| AuthDiError::Payload(format!("refreshToken: {e}")))?,
scope: Vec::new(),
ext: None,
};
let doc = trust_task_sign::build_unsigned(
TASK_AUTH_REFRESH_0_1,
serde_json::to_value(&payload).map_err(|e| AuthDiError::Payload(e.to_string()))?,
client_did,
vta_did,
)?;
serde_json::to_string(&doc).map_err(|e| AuthDiError::Payload(e.to_string()))
}
pub fn parse_auth_response(body: &str) -> Result<AuthenticateResponse, AuthDiError> {
if let Ok(flat) = serde_json::from_str::<AuthenticateResponse>(body) {
return Ok(flat);
}
let doc: TrustTask<Value> = serde_json::from_str(body)
.map_err(|e| AuthDiError::Response(format!("{e} (is this a VTA?)")))?;
serde_json::from_value(doc.payload)
.map_err(|e| AuthDiError::Response(format!("payload is not an AuthenticateResponse: {e}")))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
const CHALLENGE: &str = "a3f1c09b7e2d4856a3f1c09b7e2d4856";
fn did_key_from_seed(seed_byte: u8) -> (String, String) {
let seed = [seed_byte; 32];
let sk = ed25519_dalek::SigningKey::from_bytes(&seed);
let did = format!(
"did:key:{}",
crate::did_key::ed25519_multibase_pubkey(&sk.verifying_key().to_bytes())
);
let mut buf = vec![0x80, 0x26];
buf.extend_from_slice(&seed);
(did, multibase::encode(multibase::Base::Base58Btc, &buf))
}
#[tokio::test]
async fn authenticate_doc_is_signed_and_camel_cased() {
let (did, pk) = did_key_from_seed(0x11);
let body = sign_authenticate_doc(&did, &pk, "did:key:z6MkVta", CHALLENGE, "sess-1")
.await
.expect("sign");
let v: Value = serde_json::from_str(&body).unwrap();
assert_eq!(v["type"], TASK_AUTH_AUTHENTICATE_0_1);
assert_eq!(v["payload"]["challenge"], CHALLENGE);
assert_eq!(v["payload"]["sessionId"], "sess-1");
assert_eq!(v["issuer"], did);
assert_eq!(v["recipient"], "did:key:z6MkVta");
assert_eq!(v["proof"]["cryptosuite"], "eddsa-jcs-2022");
assert_eq!(
v["proof"]["verificationMethod"],
format!("{did}#{}", did.strip_prefix("did:key:").unwrap())
);
assert!(
v["proof"]["proofValue"]
.as_str()
.is_some_and(|s| !s.is_empty()),
"proof carries no proofValue"
);
}
#[tokio::test]
async fn non_did_key_holder_is_refused() {
let (_, pk) = did_key_from_seed(0x44);
let err = sign_authenticate_doc(
"did:web:example.com",
&pk,
"did:key:z6MkVta",
CHALLENGE,
"sess-1",
)
.await
.expect_err("did:web holder must be refused");
assert!(
matches!(
err,
AuthDiError::Sign(crate::trust_task_sign::TrustTaskSignError::NotDidKey(_))
),
"got {err:?}"
);
assert!(
err.to_string().contains("did:web:example.com"),
"the rendered error must name the offending DID: {err}"
);
}
#[test]
fn refresh_doc_is_unsigned_and_camel_cased() {
let body = build_refresh_doc("did:key:z6MkHolder", "did:key:z6MkVta", "tok-1").unwrap();
let v: Value = serde_json::from_str(&body).unwrap();
assert_eq!(v["type"], TASK_AUTH_REFRESH_0_1);
assert_eq!(v["payload"]["refreshToken"], "tok-1");
assert!(
v.get("proof").is_none_or(Value::is_null),
"refresh must not be signed"
);
}
#[test]
fn parses_flat_and_trust_task_responses() {
let tokens = json!({
"session": {
"id": "sess", "subject": "did:key:z6MkHolder",
"issuedAt": "1970-01-01T00:00:00Z", "expiresAt": "2099-12-31T23:59:59Z",
"amr": ["did"], "acr": "aal1"
},
"tokens": {
"accessToken": "acc", "tokenType": "Bearer", "expiresIn": 900_u64
}
});
let flat = parse_auth_response(&tokens.to_string()).expect("flat");
assert_eq!(flat.tokens.access_token, "acc");
let wrapped = json!({
"id": "urn:uuid:1",
"type": "https://trusttasks.org/spec/auth/authenticate/0.1#response",
"payload": tokens,
});
let unwrapped = parse_auth_response(&wrapped.to_string()).expect("trust-task");
assert_eq!(unwrapped.tokens.access_token, "acc");
}
#[test]
fn rejects_unrecognisable_response() {
assert!(matches!(
parse_auth_response("<html>not a VTA</html>"),
Err(AuthDiError::Response(_))
));
}
}