use affinidi_tdk::messaging::ATM;
use serde_json::Value;
use vti_common::vault::{
RequestHeader, SessionBlob, SiteTarget, StoredVaultEntry, VaultSecret, put_stored_vault_entry,
};
use crate::error::AppError;
use crate::keys::seed_store::SeedStore;
use crate::store::KeyspaceHandle;
use crate::trust_tasks::wire_v0_2::{WireVersion, camelize_paths};
#[cfg(feature = "webvh")]
pub const PASSWORD_POST_TTL_CEILING_SECS: u64 = 900;
pub struct ProxyLoginOutput {
pub jwe: String,
pub session_id: String,
pub expires_at: String,
}
pub enum ProxyLoginError {
NoAudience { entry_targets: Vec<SiteTarget> },
NotProxyable,
NotImplemented { kind: &'static str },
#[cfg(feature = "webvh")]
PasswordPost(crate::operations::vault::password_post::PasswordPostError),
App(AppError),
}
impl From<AppError> for ProxyLoginError {
fn from(e: AppError) -> Self {
ProxyLoginError::App(e)
}
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn proxy_login(
atm: &ATM,
vault_ks: &KeyspaceHandle,
keys_ks: &KeyspaceHandle,
imported_ks: &KeyspaceHandle,
audit_ks: &KeyspaceHandle,
seed_store: &dyn SeedStore,
vta_did: &str,
holder_did: &str,
mut stored: StoredVaultEntry,
target: Option<SiteTarget>,
nonce: Option<String>,
ttl_hint: Option<u32>,
wire: WireVersion,
) -> Result<ProxyLoginOutput, ProxyLoginError> {
let (session_blob, session_id, expires_at) = match &stored.secret {
VaultSecret::DidSelfIssued {
did: siop_did,
signing_key_id,
..
} => {
let (audience, bind_origin) = resolve_siop_audience(&target, &stored.entry.targets)
.ok_or_else(|| ProxyLoginError::NoAudience {
entry_targets: stored.entry.targets.clone(),
})?;
let ttl_secs = ttl_hint
.map(|t| (t as u64).min(super::PROXY_LOGIN_ID_TOKEN_TTL_SECS))
.unwrap_or(super::PROXY_LOGIN_ID_TOKEN_TTL_SECS);
let signing_key = super::load_signing_key_by_id(
keys_ks,
imported_ks,
seed_store,
audit_ks,
signing_key_id,
)
.await?;
let iat = chrono::Utc::now().timestamp().max(0) as u64;
let id_token = super::build_siop_id_token(
siop_did,
signing_key_id,
&audience,
nonce.as_deref(),
iat,
ttl_secs,
&signing_key,
)?;
build_session_blob_with_bearer(id_token, bind_origin, ttl_secs)
}
VaultSecret::Password {
username,
password,
totp,
login_config: Some(login_config),
..
} => {
#[cfg(feature = "webvh")]
{
let cookies = crate::operations::vault::password_post::run_password_post(
login_config,
username.as_deref(),
password,
totp.as_ref(),
)
.await
.map_err(ProxyLoginError::PasswordPost)?;
let ttl_secs = ttl_hint
.map(|t| (t as u64).min(PASSWORD_POST_TTL_CEILING_SECS))
.unwrap_or(PASSWORD_POST_TTL_CEILING_SECS);
let bind_origin = first_web_origin(&stored.entry.targets).or_else(|| {
url::Url::parse(&login_config.login_url)
.ok()
.and_then(|u| u.origin().ascii_serialization().into())
});
build_session_blob_with_cookies(cookies, bind_origin, ttl_secs)
}
#[cfg(not(feature = "webvh"))]
{
let _ = (username, password, totp, login_config);
return Err(ProxyLoginError::NotImplemented { kind: "password" });
}
}
VaultSecret::Password {
login_config: None, ..
} => return Err(ProxyLoginError::NotProxyable),
other => {
return Err(ProxyLoginError::NotImplemented {
kind: super::secret_kind_label(other.kind()),
});
}
};
let session_body =
session_blob_cleartext_for_wire(&session_blob, wire).map_err(ProxyLoginError::App)?;
let jwe = super::authcrypt_to_holder(
atm,
vta_did,
holder_did,
super::PROXY_LOGIN_INNER_MSG_TYPE,
session_body,
)
.await?;
stored.entry.last_used_at = Some(chrono::Utc::now().to_rfc3339());
if let Err(e) = put_stored_vault_entry(vault_ks, &stored).await {
tracing::warn!(
entry_id = %stored.entry.id,
error = %e,
"vault/proxy-login: lastUsedAt update failed; session release proceeded"
);
}
Ok(ProxyLoginOutput {
jwe,
session_id,
expires_at,
})
}
fn build_session_blob_with_bearer(
bearer: String,
bind_origin: Option<String>,
ttl_secs: u64,
) -> (SessionBlob, String, String) {
let session_id = uuid::Uuid::new_v4().to_string();
let expires_at = (chrono::Utc::now() + chrono::Duration::seconds(ttl_secs as i64)).to_rfc3339();
let blob = SessionBlob {
session_id: session_id.clone(),
expires_at: expires_at.clone(),
cookies: Vec::new(),
headers: vec![RequestHeader {
name: "Authorization".to_string(),
value: format!("Bearer {bearer}"),
}],
local_storage: Vec::new(),
session_storage: Vec::new(),
bind_origin,
refresh_hint: None,
};
(blob, session_id, expires_at)
}
#[cfg(feature = "webvh")]
fn build_session_blob_with_cookies(
cookies: Vec<vti_common::vault::CookieJarEntry>,
bind_origin: Option<String>,
ttl_secs: u64,
) -> (SessionBlob, String, String) {
let session_id = uuid::Uuid::new_v4().to_string();
let expires_at = (chrono::Utc::now() + chrono::Duration::seconds(ttl_secs as i64)).to_rfc3339();
let blob = SessionBlob {
session_id: session_id.clone(),
expires_at: expires_at.clone(),
cookies,
headers: Vec::new(),
local_storage: Vec::new(),
session_storage: Vec::new(),
bind_origin,
refresh_hint: Some(vti_common::vault::RefreshHint::On401),
};
(blob, session_id, expires_at)
}
#[cfg(feature = "webvh")]
fn first_web_origin(targets: &[SiteTarget]) -> Option<String> {
targets.iter().find_map(|t| match t {
SiteTarget::WebOrigin { origin } => Some(origin.clone()),
_ => None,
})
}
fn resolve_siop_audience(
explicit: &Option<SiteTarget>,
entry_targets: &[SiteTarget],
) -> Option<(String, Option<String>)> {
let entry_origin: Option<String> = entry_targets.iter().find_map(|t| match t {
SiteTarget::WebOrigin { origin } => Some(origin.clone()),
_ => None,
});
if let Some(t) = explicit {
return match t {
SiteTarget::Did { did } => Some((did.clone(), entry_origin)),
SiteTarget::WebOrigin { origin } => Some((origin.clone(), Some(origin.clone()))),
_ => None,
};
}
let entry_did: Option<String> = entry_targets.iter().find_map(|t| match t {
SiteTarget::Did { did } => Some(did.clone()),
_ => None,
});
if let Some(did) = entry_did {
return Some((did, entry_origin));
}
entry_origin.clone().map(|o| (o, entry_origin))
}
fn session_blob_cleartext_for_wire(
blob: &SessionBlob,
wire: WireVersion,
) -> Result<Value, AppError> {
let mut body = serde_json::to_value(blob).map_err(|e| {
AppError::Internal(format!(
"vault/proxy-login: failed to serialise SessionBlob: {e}"
))
})?;
if wire == WireVersion::V0_2 {
camelize_paths(&mut body, &["refreshHint"]);
}
Ok(body)
}
#[cfg(test)]
mod tests {
use super::*;
use vti_common::vault::RefreshHint;
fn blob_with_hint(hint: Option<RefreshHint>) -> SessionBlob {
SessionBlob {
session_id: "s1".into(),
expires_at: "2026-06-17T00:00:00Z".into(),
cookies: vec![],
headers: vec![],
local_storage: vec![],
session_storage: vec![],
bind_origin: None,
refresh_hint: hint,
}
}
#[test]
fn v0_1_session_blob_keeps_kebab_refresh_hint() {
let body = session_blob_cleartext_for_wire(
&blob_with_hint(Some(RefreshHint::BeforeExpiry)),
WireVersion::V0_1,
)
.unwrap();
assert_eq!(body["refreshHint"], "before-expiry");
}
#[test]
fn v0_2_session_blob_camelizes_refresh_hint() {
let body = session_blob_cleartext_for_wire(
&blob_with_hint(Some(RefreshHint::BeforeExpiry)),
WireVersion::V0_2,
)
.unwrap();
assert_eq!(body["refreshHint"], "beforeExpiry");
let on401 = session_blob_cleartext_for_wire(
&blob_with_hint(Some(RefreshHint::On401)),
WireVersion::V0_2,
)
.unwrap();
assert_eq!(on401["refreshHint"], "on401");
let maint = session_blob_cleartext_for_wire(
&blob_with_hint(Some(RefreshHint::MaintainerOnly)),
WireVersion::V0_2,
)
.unwrap();
assert_eq!(maint["refreshHint"], "maintainerOnly");
}
#[test]
fn session_blob_without_hint_is_unaffected() {
let body =
session_blob_cleartext_for_wire(&blob_with_hint(None), WireVersion::V0_2).unwrap();
assert!(body.get("refreshHint").is_none());
}
fn web(o: &str) -> SiteTarget {
SiteTarget::WebOrigin {
origin: o.to_string(),
}
}
fn did(d: &str) -> SiteTarget {
SiteTarget::Did { did: d.to_string() }
}
fn ios() -> SiteTarget {
SiteTarget::IosApp {
bundle_id: "com.example.app".into(),
team_id: None,
}
}
#[test]
fn explicit_did_target_uses_did_as_audience_with_entry_origin_as_bind() {
let entry_targets = vec![did("did:web:rp.example"), web("https://rp.example")];
let (aud, bind) = resolve_siop_audience(&Some(did("did:web:rp.example")), &entry_targets)
.expect("audience");
assert_eq!(aud, "did:web:rp.example");
assert_eq!(bind.as_deref(), Some("https://rp.example"));
}
#[test]
fn explicit_web_origin_target_audience_equals_bind() {
let entry_targets = vec![web("https://rp.example")];
let (aud, bind) = resolve_siop_audience(&Some(web("https://rp.example")), &entry_targets)
.expect("audience");
assert_eq!(aud, "https://rp.example");
assert_eq!(bind.as_deref(), Some("https://rp.example"));
}
#[test]
fn explicit_app_target_rejects_for_siop() {
let entry_targets = vec![did("did:web:rp.example")];
assert!(
resolve_siop_audience(&Some(ios()), &entry_targets).is_none(),
"app targets aren't SIOP audiences"
);
}
#[test]
fn no_explicit_target_prefers_first_did_on_entry() {
let entry_targets = vec![
web("https://rp.example"),
did("did:web:rp.example"),
did("did:web:other"),
];
let (aud, bind) = resolve_siop_audience(&None, &entry_targets).expect("audience");
assert_eq!(aud, "did:web:rp.example", "first DID wins over later DIDs");
assert_eq!(bind.as_deref(), Some("https://rp.example"));
}
#[test]
fn no_explicit_target_falls_back_to_first_web_origin_when_no_did() {
let entry_targets = vec![web("https://rp.example")];
let (aud, bind) = resolve_siop_audience(&None, &entry_targets).expect("audience");
assert_eq!(aud, "https://rp.example");
assert_eq!(bind.as_deref(), Some("https://rp.example"));
}
#[test]
fn no_audience_when_entry_has_only_app_targets() {
let entry_targets = vec![ios()];
assert!(
resolve_siop_audience(&None, &entry_targets).is_none(),
"app-only entry yields no SIOP audience"
);
}
}