use affinidi_tdk::didcomm::Message;
use crate::acl::check_acl_full;
use crate::auth::AuthClaims;
use crate::auth::session::{now_epoch, resolve_did_session};
use crate::error::AppError;
use crate::store::KeyspaceHandle;
pub async fn auth_from_message(
msg: &Message,
acl_ks: &KeyspaceHandle,
sessions_ks: &KeyspaceHandle,
) -> Result<AuthClaims, AppError> {
let did = msg
.from
.as_deref()
.ok_or_else(|| AppError::Authentication("message has no sender (from)".into()))?;
auth_from_did(did, acl_ks, sessions_ks).await
}
pub async fn auth_from_did(
did: &str,
acl_ks: &KeyspaceHandle,
sessions_ks: &KeyspaceHandle,
) -> Result<AuthClaims, AppError> {
let base_did = did.split('#').next().unwrap_or(did);
let (role, allowed_contexts) = check_acl_full(acl_ks, base_did).await?;
let session = resolve_did_session(sessions_ks, base_did, now_epoch()).await?;
Ok(AuthClaims {
did: base_did.to_string(),
role,
allowed_contexts,
session_id: session.session_id,
access_expires_at: 0,
amr: session.amr,
acr: session.acr,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::acl::{AclEntry, Role, store_acl_entry};
use crate::auth::session::now_epoch;
use crate::store::Store;
use vti_common::config::StoreConfig;
fn message_from(did: &str) -> Message {
Message::build(
"test-id".to_string(),
"https://example.com/test/1.0/ping".to_string(),
serde_json::json!({}),
)
.from(did.to_string())
.finalize()
}
async fn fresh_acl_ks() -> (Store, KeyspaceHandle, KeyspaceHandle, tempfile::TempDir) {
let dir = tempfile::tempdir().unwrap();
let store = Store::open(&StoreConfig {
data_dir: dir.path().into(),
})
.unwrap();
let acl_ks = store.keyspace(crate::keyspaces::ACL).unwrap();
let sessions_ks = store.keyspace(crate::keyspaces::SESSIONS).unwrap();
(store, acl_ks, sessions_ks, dir)
}
#[tokio::test]
async fn rejects_expired_entry() {
let (_store, acl_ks, sessions_ks, _dir) = fresh_acl_ks().await;
let did = "did:key:zExpired";
store_acl_entry(
&acl_ks,
&AclEntry::new(did, Role::Admin, "test")
.with_contexts(vec!["ctx-a".into()])
.with_created_at(now_epoch().saturating_sub(7200))
.with_expires_at(Some(now_epoch().saturating_sub(60))), )
.await
.unwrap();
let msg = message_from(did);
let err = auth_from_message(&msg, &acl_ks, &sessions_ks)
.await
.unwrap_err();
assert!(
matches!(err, AppError::Forbidden(ref m) if m.contains("expired")),
"expected Forbidden(expired), got {err:?}"
);
}
#[tokio::test]
async fn accepts_unexpired_entry_with_role_and_contexts() {
let (_store, acl_ks, sessions_ks, _dir) = fresh_acl_ks().await;
let did = "did:key:zLive";
store_acl_entry(
&acl_ks,
&AclEntry::new(did, Role::Admin, "test")
.with_contexts(vec!["ctx-a".into(), "ctx-b".into()])
.with_expires_at(Some(now_epoch() + 3600)),
)
.await
.unwrap();
let msg = message_from(did);
let claims = auth_from_message(&msg, &acl_ks, &sessions_ks)
.await
.unwrap();
assert_eq!(claims.did, did);
assert_eq!(claims.role, Role::Admin);
assert_eq!(claims.allowed_contexts, vec!["ctx-a", "ctx-b"]);
}
#[tokio::test]
async fn fragment_in_sender_collapses_to_base_did() {
let (_store, acl_ks, sessions_ks, _dir) = fresh_acl_ks().await;
let base = "did:key:zBase";
store_acl_entry(&acl_ks, &AclEntry::new(base, Role::Reader, "test"))
.await
.unwrap();
let msg = message_from(&format!("{base}#zBase"));
let claims = auth_from_message(&msg, &acl_ks, &sessions_ks)
.await
.unwrap();
assert_eq!(claims.did, base);
}
#[tokio::test]
async fn auth_from_did_resolves_role_and_contexts() {
let (_store, acl_ks, sessions_ks, _dir) = fresh_acl_ks().await;
let did = "did:key:zDidCore";
store_acl_entry(
&acl_ks,
&AclEntry::new(did, Role::Admin, "test")
.with_contexts(vec!["ctx-a".into(), "ctx-b".into()])
.with_expires_at(Some(now_epoch() + 3600)),
)
.await
.unwrap();
let claims = auth_from_did(did, &acl_ks, &sessions_ks).await.unwrap();
assert_eq!(claims.did, did);
assert_eq!(claims.role, Role::Admin);
assert_eq!(claims.allowed_contexts, vec!["ctx-a", "ctx-b"]);
}
#[tokio::test]
async fn auth_from_did_unknown_did_errors() {
let (_store, acl_ks, sessions_ks, _dir) = fresh_acl_ks().await;
let err = auth_from_did("did:key:zUnknownPeer", &acl_ks, &sessions_ks)
.await
.unwrap_err();
assert!(
matches!(err, AppError::Forbidden(_) | AppError::NotFound(_)),
"expected unauthorized-class error, got {err:?}"
);
}
#[tokio::test]
async fn auth_from_did_fragment_collapses() {
let (_store, acl_ks, sessions_ks, _dir) = fresh_acl_ks().await;
let base = "did:key:zCoreBase";
store_acl_entry(&acl_ks, &AclEntry::new(base, Role::Reader, "test"))
.await
.unwrap();
let claims = auth_from_did(&format!("{base}#zCoreBase"), &acl_ks, &sessions_ks)
.await
.unwrap();
assert_eq!(claims.did, base);
}
#[tokio::test]
async fn missing_sender_is_authentication_error() {
let (_store, acl_ks, sessions_ks, _dir) = fresh_acl_ks().await;
let mut msg = message_from("did:key:zAnything");
msg.from = None;
let err = auth_from_message(&msg, &acl_ks, &sessions_ks)
.await
.unwrap_err();
assert!(matches!(err, AppError::Authentication(_)), "got {err:?}");
}
#[tokio::test]
async fn auth_from_did_reports_persisted_elevated_acr() {
use crate::auth::session::{get_session, update_session};
let (_store, acl_ks, sessions_ks, _dir) = fresh_acl_ks().await;
let did = "did:key:zElevatedCaller";
store_acl_entry(
&acl_ks,
&AclEntry::new(did, Role::Admin, "test").with_expires_at(Some(now_epoch() + 3600)),
)
.await
.unwrap();
let first = auth_from_did(did, &acl_ks, &sessions_ks).await.unwrap();
assert_eq!(first.acr, "aal1");
assert_eq!(first.session_id, did);
let mut s = get_session(&sessions_ks, did).await.unwrap().unwrap();
s.acr = "aal2".into();
s.acr_expires_at = Some(now_epoch() + 900);
update_session(&sessions_ks, &s).await.unwrap();
let next = auth_from_did(did, &acl_ks, &sessions_ks).await.unwrap();
assert_eq!(next.acr, "aal2");
}
}