#![cfg(feature = "client")]
use std::time::{SystemTime, UNIX_EPOCH};
use ed25519_dalek::SigningKey;
use serde_json::json;
use vta_sdk::auth_light::{
authenticate_with_credential, challenge_response_light, refresh_token_light,
};
use vta_sdk::client::VtaClient;
use vta_sdk::credentials::CredentialBundle;
use vta_sdk::did_key::ed25519_multibase_pubkey;
use vta_sdk::error::VtaError;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
const CHALLENGE: &str = "a3f1c09b7e2d4856a3f1c09b7e2d4856";
fn did_key_from_seed(seed_byte: u8) -> (String, String) {
let seed = [seed_byte; 32];
let sk = SigningKey::from_bytes(&seed);
let pk = sk.verifying_key().to_bytes();
let did = format!("did:key:{}", ed25519_multibase_pubkey(&pk));
let mut buf = vec![0x80, 0x26];
buf.extend_from_slice(&seed);
let priv_mb = multibase::encode(multibase::Base::Base58Btc, &buf);
(did, priv_mb)
}
fn now_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs()
}
async fn mount_challenge(server: &MockServer) {
Mock::given(method("POST"))
.and(path("/auth/challenge"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"challenge": CHALLENGE,
"sessionId": "sess-test",
"expiresAt": "2026-12-31T23:59:59Z"
})))
.mount(server)
.await;
}
async fn mount_authenticate(server: &MockServer, expires_at: u64) {
Mock::given(method("POST"))
.and(path("/auth/"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"session": {
"id": "sess-test",
"subject": "did:example:caller",
"issuedAt": "1970-01-01T00:00:00Z",
"expiresAt": "2099-12-31T23:59:59Z",
"amr": ["did"],
"acr": "aal1"
},
"tokens": {
"accessToken": "access-jwt",
"tokenType": "Bearer",
"expiresIn": expires_at,
"refreshToken": "refresh-tok",
"refreshExpiresIn": expires_at + 3600
}
})))
.mount(server)
.await;
}
#[tokio::test]
async fn challenge_response_success_returns_tokens() {
let server = MockServer::start().await;
mount_challenge(&server).await;
mount_authenticate(&server, 1_700_001_000).await;
let (client_did, client_priv) = did_key_from_seed(0x11);
let (vta_did, _) = did_key_from_seed(0x22);
let http = reqwest::Client::new();
let result =
challenge_response_light(&http, &server.uri(), &client_did, &client_priv, &vta_did)
.await
.unwrap();
assert_eq!(result.access_token, "access-jwt");
assert_eq!(result.access_expires_at, 1_700_001_000);
assert_eq!(result.refresh_token.as_deref(), Some("refresh-tok"));
assert_eq!(result.refresh_expires_at, Some(1_700_001_000 + 3600));
}
#[tokio::test]
async fn challenge_endpoint_401_maps_to_auth() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/auth/challenge"))
.respond_with(ResponseTemplate::new(401).set_body_string("not authorized"))
.mount(&server)
.await;
let (client_did, client_priv) = did_key_from_seed(0x11);
let (vta_did, _) = did_key_from_seed(0x22);
let http = reqwest::Client::new();
let err = challenge_response_light(&http, &server.uri(), &client_did, &client_priv, &vta_did)
.await
.unwrap_err();
assert!(matches!(err, VtaError::Auth(_)), "got {err:?}");
}
#[tokio::test]
async fn challenge_endpoint_500_maps_to_server() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/auth/challenge"))
.respond_with(ResponseTemplate::new(500).set_body_string("boom"))
.mount(&server)
.await;
let (client_did, client_priv) = did_key_from_seed(0x11);
let (vta_did, _) = did_key_from_seed(0x22);
let http = reqwest::Client::new();
let err = challenge_response_light(&http, &server.uri(), &client_did, &client_priv, &vta_did)
.await
.unwrap_err();
match err {
VtaError::Server { status, .. } => assert_eq!(status, 500),
other => panic!("expected Server, got {other:?}"),
}
}
#[tokio::test]
async fn authenticate_endpoint_401_maps_to_auth() {
let server = MockServer::start().await;
mount_challenge(&server).await;
Mock::given(method("POST"))
.and(path("/auth/"))
.respond_with(ResponseTemplate::new(401).set_body_string("bad challenge"))
.mount(&server)
.await;
let (client_did, client_priv) = did_key_from_seed(0x11);
let (vta_did, _) = did_key_from_seed(0x22);
let http = reqwest::Client::new();
let err = challenge_response_light(&http, &server.uri(), &client_did, &client_priv, &vta_did)
.await
.unwrap_err();
assert!(matches!(err, VtaError::Auth(_)), "got {err:?}");
}
#[tokio::test]
async fn non_did_key_holder_maps_to_validation() {
let server = MockServer::start().await;
mount_challenge(&server).await;
let (_, client_priv) = did_key_from_seed(0x11);
let (vta_did, _) = did_key_from_seed(0x22);
let http = reqwest::Client::new();
let err = challenge_response_light(
&http,
&server.uri(),
"did:web:example.com",
&client_priv,
&vta_did,
)
.await
.unwrap_err();
assert!(matches!(err, VtaError::Validation(_)), "got {err:?}");
}
#[tokio::test]
async fn non_key_vta_did_still_authenticates() {
let server = MockServer::start().await;
mount_challenge(&server).await;
mount_authenticate(&server, 1_700_001_000).await;
let (client_did, client_priv) = did_key_from_seed(0x11);
let http = reqwest::Client::new();
let result = challenge_response_light(
&http,
&server.uri(),
&client_did,
&client_priv,
"did:web:not-a-key",
)
.await
.expect("the VTA DID is not resolved on this path");
assert_eq!(result.access_token, "access-jwt");
}
#[tokio::test]
async fn authenticate_body_is_a_signed_trust_task() {
let server = MockServer::start().await;
mount_challenge(&server).await;
mount_authenticate(&server, 1_700_001_000).await;
let (client_did, client_priv) = did_key_from_seed(0x11);
let (vta_did, _) = did_key_from_seed(0x22);
let http = reqwest::Client::new();
challenge_response_light(&http, &server.uri(), &client_did, &client_priv, &vta_did)
.await
.unwrap();
let req = server
.received_requests()
.await
.unwrap()
.into_iter()
.find(|r| r.url.path() == "/auth/")
.expect("an /auth/ request was made");
let body: serde_json::Value = serde_json::from_slice(&req.body).expect("body is JSON");
assert_eq!(
body["type"], "https://trusttasks.org/spec/auth/authenticate/0.1",
"body must be an authenticate Trust Task"
);
assert_eq!(body["payload"]["challenge"], CHALLENGE);
assert_eq!(body["payload"]["sessionId"], "sess-test");
assert_eq!(body["issuer"], client_did);
assert_eq!(body["proof"]["cryptosuite"], "eddsa-jcs-2022");
assert!(body.get("protected").is_none(), "must not be a JWE");
}
#[tokio::test]
async fn trust_task_wrapped_response_is_unwrapped() {
let server = MockServer::start().await;
mount_challenge(&server).await;
Mock::given(method("POST"))
.and(path("/auth/"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"id": "urn:uuid:resp-1",
"type": "https://trusttasks.org/spec/auth/authenticate/0.1#response",
"threadId": "urn:uuid:req-1",
"payload": {
"session": {
"id": "sess-test",
"subject": "did:example:caller",
"issuedAt": "1970-01-01T00:00:00Z",
"expiresAt": "2099-12-31T23:59:59Z",
"amr": ["did"],
"acr": "aal1"
},
"tokens": {
"accessToken": "wrapped-access",
"tokenType": "Bearer",
"expiresIn": 1_700_001_000_u64,
"refreshToken": "wrapped-refresh",
"refreshExpiresIn": 1_700_004_600_u64
}
}
})))
.mount(&server)
.await;
let (client_did, client_priv) = did_key_from_seed(0x11);
let (vta_did, _) = did_key_from_seed(0x22);
let http = reqwest::Client::new();
let result =
challenge_response_light(&http, &server.uri(), &client_did, &client_priv, &vta_did)
.await
.unwrap();
assert_eq!(result.access_token, "wrapped-access");
assert_eq!(result.refresh_token.as_deref(), Some("wrapped-refresh"));
}
#[tokio::test]
async fn refresh_token_success_rotates_tokens() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/auth/refresh"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"session": {
"id": "sess-test",
"subject": "did:example:caller",
"issuedAt": "1970-01-01T00:00:00Z",
"expiresAt": "2099-12-31T23:59:59Z",
"amr": ["did"],
"acr": "aal1"
},
"tokens": {
"accessToken": "new-access",
"tokenType": "Bearer",
"expiresIn": 2_000_000_000_u64,
"refreshToken": "new-refresh",
"refreshExpiresIn": 2_000_003_600_u64
}
})))
.mount(&server)
.await;
let (client_did, _) = did_key_from_seed(0x11);
let (vta_did, _) = did_key_from_seed(0x22);
let http = reqwest::Client::new();
let result = refresh_token_light(&http, &server.uri(), &client_did, &vta_did, "old-refresh")
.await
.unwrap();
assert_eq!(result.access_token, "new-access");
assert_eq!(result.refresh_token.as_deref(), Some("new-refresh"));
}
#[tokio::test]
async fn refresh_token_401_maps_to_auth() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/auth/refresh"))
.respond_with(ResponseTemplate::new(401).set_body_string("refresh token not found"))
.mount(&server)
.await;
let (client_did, _) = did_key_from_seed(0x11);
let (vta_did, _) = did_key_from_seed(0x22);
let http = reqwest::Client::new();
let err = refresh_token_light(&http, &server.uri(), &client_did, &vta_did, "old")
.await
.unwrap_err();
assert!(matches!(err, VtaError::Auth(_)));
}
#[tokio::test]
async fn refresh_body_is_an_unsigned_trust_task() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/auth/refresh"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"session": {
"id": "sess-test",
"subject": "did:example:caller",
"issuedAt": "1970-01-01T00:00:00Z",
"expiresAt": "2099-12-31T23:59:59Z",
"amr": ["did"],
"acr": "aal1"
},
"tokens": {
"accessToken": "a",
"tokenType": "Bearer",
"expiresIn": 2_000_000_000_u64
}
})))
.mount(&server)
.await;
let (client_did, _) = did_key_from_seed(0x11);
let http = reqwest::Client::new();
refresh_token_light(
&http,
&server.uri(),
&client_did,
"did:web:not-a-key",
"old-refresh",
)
.await
.expect("refresh needs no DID resolution");
let req = server
.received_requests()
.await
.unwrap()
.into_iter()
.find(|r| r.url.path() == "/auth/refresh")
.expect("a refresh request was made");
let body: serde_json::Value = serde_json::from_slice(&req.body).expect("body is JSON");
assert_eq!(body["type"], "https://trusttasks.org/spec/auth/refresh/0.1");
assert_eq!(body["payload"]["refreshToken"], "old-refresh");
assert!(body.get("proof").is_none(), "refresh must not be signed");
}
#[tokio::test]
async fn authenticate_with_credential_uses_url_override() {
let server = MockServer::start().await;
mount_challenge(&server).await;
mount_authenticate(&server, 1_700_001_000).await;
let (client_did, client_priv) = did_key_from_seed(0x11);
let (vta_did, _) = did_key_from_seed(0x22);
let cred = CredentialBundle::new(client_did, client_priv, vta_did);
let (result, returned_cred, _http) = authenticate_with_credential(&cred, Some(&server.uri()))
.await
.unwrap();
assert_eq!(result.access_token, "access-jwt");
assert_eq!(returned_cred.did, cred.did);
}
#[tokio::test]
async fn authenticate_with_credential_uses_bundle_url() {
let server = MockServer::start().await;
mount_challenge(&server).await;
mount_authenticate(&server, 1_700_001_000).await;
let (client_did, client_priv) = did_key_from_seed(0x11);
let (vta_did, _) = did_key_from_seed(0x22);
let cred = CredentialBundle::new(client_did, client_priv, vta_did).vta_url(server.uri());
let (result, _, _) = authenticate_with_credential(&cred, None).await.unwrap();
assert_eq!(result.access_token, "access-jwt");
}
#[tokio::test]
async fn authenticate_with_credential_no_url_anywhere_fails() {
let (client_did, client_priv) = did_key_from_seed(0x11);
let (vta_did, _) = did_key_from_seed(0x22);
let cred = CredentialBundle::new(client_did, client_priv, vta_did);
let err = authenticate_with_credential(&cred, None).await.unwrap_err();
match err {
VtaError::Validation(msg) => assert!(msg.contains("no VTA URL")),
other => panic!("expected Validation, got {other:?}"),
}
}
#[tokio::test]
async fn from_credential_authenticates_and_exposes_token() {
let server = MockServer::start().await;
mount_challenge(&server).await;
mount_authenticate(&server, 1_700_001_000).await;
let (client_did, client_priv) = did_key_from_seed(0x11);
let (vta_did, _) = did_key_from_seed(0x22);
let cred = CredentialBundle::new(client_did, client_priv, vta_did).vta_url(server.uri());
let client = VtaClient::from_credential(&cred, None).await.unwrap();
assert_eq!(client.token_expires_at().await, Some(1_700_001_000));
assert_eq!(client.rest_url(), Some(server.uri().trim_end_matches('/')));
}
#[tokio::test]
async fn from_credential_url_override_wins_over_bundle() {
let server = MockServer::start().await;
mount_challenge(&server).await;
mount_authenticate(&server, 1_700_001_000).await;
let (client_did, client_priv) = did_key_from_seed(0x11);
let (vta_did, _) = did_key_from_seed(0x22);
let cred = CredentialBundle::new(client_did, client_priv, vta_did)
.vta_url("https://stale.example.com");
let client = VtaClient::from_credential(&cred, Some(&server.uri()))
.await
.unwrap();
assert_eq!(client.rest_url(), Some(server.uri().trim_end_matches('/')));
}
#[tokio::test]
async fn ensure_token_valid_refreshes_expired_access_token() {
let server = MockServer::start().await;
mount_challenge(&server).await;
let future = now_secs() + 3600;
Mock::given(method("POST"))
.and(path("/auth/"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"session": {
"id": "sess",
"subject": "did:example:caller",
"issuedAt": "1970-01-01T00:00:00Z",
"expiresAt": "2099-12-31T23:59:59Z",
"amr": ["did"],
"acr": "aal1"
},
"tokens": {
"accessToken": "expired-access",
"tokenType": "Bearer",
"expiresIn": 100_u64,
"refreshToken": "live-refresh",
"refreshExpiresIn": future
}
})))
.expect(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/auth/refresh"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"session": {
"id": "sess",
"subject": "did:example:caller",
"issuedAt": "1970-01-01T00:00:00Z",
"expiresAt": "2099-12-31T23:59:59Z",
"amr": ["did"],
"acr": "aal1"
},
"tokens": {
"accessToken": "fresh-access",
"tokenType": "Bearer",
"expiresIn": future,
"refreshToken": "newer-refresh",
"refreshExpiresIn": future + 3600
}
})))
.expect(1)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/config"))
.and(wiremock::matchers::header(
"authorization",
"Bearer fresh-access",
))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"fields": []
})))
.expect(1)
.mount(&server)
.await;
let (client_did, client_priv) = did_key_from_seed(0x11);
let (vta_did, _) = did_key_from_seed(0x22);
let cred = CredentialBundle::new(client_did, client_priv, vta_did).vta_url(server.uri());
let client = VtaClient::from_credential(&cred, None).await.unwrap();
client.get_config().await.unwrap();
assert_eq!(client.token_expires_at().await, Some(future));
}
#[tokio::test]
async fn ensure_token_valid_full_reauth_when_refresh_expired() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/auth/challenge"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"challenge": CHALLENGE,
"sessionId": "sess",
"expiresAt": "2099-12-31T23:59:59Z"
})))
.expect(2)
.mount(&server)
.await;
let future = now_secs() + 3600;
Mock::given(method("POST"))
.and(path("/auth/"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"session": {
"id": "sess",
"subject": "did:example:caller",
"issuedAt": "1970-01-01T00:00:00Z",
"expiresAt": "2099-12-31T23:59:59Z",
"amr": ["did"],
"acr": "aal1"
},
"tokens": {
"accessToken": "stale-access",
"tokenType": "Bearer",
"expiresIn": 100_u64,
"refreshToken": "stale-refresh",
"refreshExpiresIn": 100_u64
}
})))
.up_to_n_times(1)
.expect(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/auth/"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"session": {
"id": "sess",
"subject": "did:example:caller",
"issuedAt": "1970-01-01T00:00:00Z",
"expiresAt": "2099-12-31T23:59:59Z",
"amr": ["did"],
"acr": "aal1"
},
"tokens": {
"accessToken": "reauth-access",
"tokenType": "Bearer",
"expiresIn": future,
"refreshToken": "reauth-refresh",
"refreshExpiresIn": future + 3600
}
})))
.expect(1)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/config"))
.and(wiremock::matchers::header(
"authorization",
"Bearer reauth-access",
))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"fields": []
})))
.expect(1)
.mount(&server)
.await;
let (client_did, client_priv) = did_key_from_seed(0x11);
let (vta_did, _) = did_key_from_seed(0x22);
let cred = CredentialBundle::new(client_did, client_priv, vta_did).vta_url(server.uri());
let client = VtaClient::from_credential(&cred, None).await.unwrap();
client.get_config().await.unwrap();
}
#[tokio::test]
async fn ensure_token_valid_falls_through_when_refresh_fails() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/auth/challenge"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"challenge": CHALLENGE,
"sessionId": "sess",
"expiresAt": "2099-12-31T23:59:59Z"
})))
.expect(2)
.mount(&server)
.await;
let future = now_secs() + 3600;
Mock::given(method("POST"))
.and(path("/auth/"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"session": {
"id": "sess",
"subject": "did:example:caller",
"issuedAt": "1970-01-01T00:00:00Z",
"expiresAt": "2099-12-31T23:59:59Z",
"amr": ["did"],
"acr": "aal1"
},
"tokens": {
"accessToken": "expired",
"tokenType": "Bearer",
"expiresIn": 100_u64,
"refreshToken": "live-but-server-rejects",
"refreshExpiresIn": future
}
})))
.up_to_n_times(1)
.expect(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/auth/"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"session": {
"id": "sess",
"subject": "did:example:caller",
"issuedAt": "1970-01-01T00:00:00Z",
"expiresAt": "2099-12-31T23:59:59Z",
"amr": ["did"],
"acr": "aal1"
},
"tokens": {
"accessToken": "fallback-access",
"tokenType": "Bearer",
"expiresIn": future,
"refreshToken": "fallback-refresh",
"refreshExpiresIn": future + 3600
}
})))
.expect(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/auth/refresh"))
.respond_with(ResponseTemplate::new(401).set_body_string("token reuse detected"))
.expect(1)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/config"))
.and(wiremock::matchers::header(
"authorization",
"Bearer fallback-access",
))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"fields": []
})))
.expect(1)
.mount(&server)
.await;
let (client_did, client_priv) = did_key_from_seed(0x11);
let (vta_did, _) = did_key_from_seed(0x22);
let cred = CredentialBundle::new(client_did, client_priv, vta_did).vta_url(server.uri());
let client = VtaClient::from_credential(&cred, None).await.unwrap();
client.get_config().await.unwrap();
}