use serde::Serialize;
use serde_json::{Value, json};
use trust_tasks_rs::{RejectReason, StandardCode, TrustTask, TrustTaskCode};
use vti_common::audit::{AuditEvent, MemberWalletSignInData};
use vti_common::auth::session::{
Session, SessionState, now_epoch, store_refresh_index, store_session,
};
use super::JoinAuthCtx;
use super::helpers::{
TrustTaskOutcome, extended_code, parse_payload, reject_with, reject_with_code, success_response,
};
use crate::member_portal::oob::types::{
CANCEL_TYPE, CLAIM_TYPE, CancelPayload, CancelResponse, ClaimPayload, GRANT_TYPE,
GrantDecision, GrantPayload, IDENTIFY_TYPE, IdentifyPayload, PROVE_TYPE, ProvePayload,
REDEEM_TYPE, REQUEST_TYPE, RESPOND_TYPE, RedeemPayload, RedeemResponse, RequestPayload,
RequestResponse, RespondPayload, RespondResponse, Step1, Step2,
};
use crate::member_portal::oob::{
self, CLAIM_WINDOW_SECS, DECISION_WINDOW_SECS, HttpContext, MAX_PENDING_PER_ADDRESS,
NOT_AN_ADMIN, NOTIFIER, OobRequest, OobState, PollGuard, PollRefused, RequesterDetails,
SessionAudience, TransitionError,
};
use crate::member_portal::{MemberAuthBackend, active_member, cookies};
use crate::server::AppState;
pub(crate) const URIS: &[&str] = &[
REQUEST_TYPE,
CLAIM_TYPE,
PROVE_TYPE,
RESPOND_TYPE,
REDEEM_TYPE,
CANCEL_TYPE,
];
fn code(task: &str, local: &str) -> TrustTaskCode {
const TASK_CODES: &[(&str, &str)] = &[
("claim", "alreadyClaimed"),
("prove", "numberMismatch"),
("redeem", "declined"),
("redeem", "pending"),
("request", "modeUnsupported"),
("request", "purposeUnsupported"),
("respond", "contextMismatch"),
];
if TASK_CODES.contains(&(task, local)) {
extended_code(&format!("auth/oob/{task}:{local}"))
} else {
extended_code(&format!("auth/oob:{local}"))
}
}
fn task_of(type_uri: &str) -> &'static str {
match type_uri {
REQUEST_TYPE => "request",
CLAIM_TYPE => "claim",
PROVE_TYPE => "prove",
RESPOND_TYPE => "respond",
REDEEM_TYPE => "redeem",
_ => "cancel",
}
}
pub(super) fn precheck(doc: &TrustTask<Value>, type_uri: &str) -> Option<TrustTaskOutcome> {
if !URIS.contains(&type_uri) {
return None;
}
let task = task_of(type_uri);
if !doc.issuer.as_deref().is_some_and(oob::is_ed25519_did_key) {
return Some(reject_with_code(
doc,
code(task, "keyUnsupported"),
"the issuer must be an Ed25519 did:key generated for this exchange",
None,
));
}
None
}
pub(super) async fn dispatch(
state: &AppState,
ctx: &JoinAuthCtx,
doc: TrustTask<Value>,
type_uri: &str,
) -> Option<TrustTaskOutcome> {
let signer = ctx.verified_signer.clone().unwrap_or_default();
Some(match type_uri {
REQUEST_TYPE => handle_request(state, doc, signer).await,
CLAIM_TYPE => handle_claim(state, doc, signer).await,
PROVE_TYPE => handle_prove(state, doc, signer).await,
RESPOND_TYPE => handle_respond(state, doc, signer).await,
REDEEM_TYPE => handle_redeem(state, doc, signer).await,
CANCEL_TYPE => handle_cancel(state, doc, signer).await,
_ => return None,
})
}
async fn audit(
state: &AppState,
actor: &str,
stage: &str,
rec: &OobRequest,
member: Option<&str>,
reason: Option<&str>,
grant: Option<Value>,
) {
let Some(writer) = state.audit_writer.as_ref() else {
tracing::warn!(
stage,
"audit writer not configured; wallet sign-in step not audited"
);
return;
};
let data = MemberWalletSignInData {
stage: stage.into(),
request_id: rec.request_id.clone(),
member: member.map(str::to_string),
reason: reason.map(str::to_string),
location: Some(rec.requester.location.clone()),
grant,
};
let event = match rec.audience {
SessionAudience::Member => AuditEvent::MemberWalletSignIn(data),
SessionAudience::Admin => AuditEvent::AdminWalletSignIn(data),
};
if let Err(e) = writer.write(actor, member, event).await {
tracing::warn!(stage, error = %e, "could not audit a wallet sign-in step");
}
}
fn request_id_hint(doc: &TrustTask<Value>, carried: &Value) -> Option<String> {
doc.parent_thread_id.clone().or_else(|| {
carried
.get("payload")
.and_then(|p| p.get("requestId"))
.and_then(Value::as_str)
.map(str::to_string)
})
}
fn wire<T: serde::de::DeserializeOwned>(
doc: &TrustTask<Value>,
what: &str,
value: Value,
) -> Result<T, TrustTaskOutcome> {
serde_json::from_value(value).map_err(|e| {
reject_with(
doc,
RejectReason::InternalError {
reason: format!("{what} does not match its specification: {e}"),
},
)
})
}
async fn attested<R: Serialize>(
state: &AppState,
doc: &TrustTask<Value>,
payload: R,
) -> Result<(Value, TrustTaskOutcome), TrustTaskOutcome> {
let internal = |reason: String| reject_with(doc, RejectReason::InternalError { reason });
let Some(signer) = state.credential_signer.clone() else {
return Err(internal(
"this community has no signing key, so it cannot sign the sign-in response".into(),
));
};
let unsigned = success_response(doc, payload);
let mut value: Value =
serde_json::from_slice(&unsigned.body).map_err(|e| internal(e.to_string()))?;
signer
.sign_attested_response(&mut value)
.await
.map_err(|e| internal(e.to_string()))?;
let body = serde_json::to_vec(&value).map_err(|e| internal(e.to_string()))?;
Ok((
value,
TrustTaskOutcome {
status: axum::http::StatusCode::OK,
body,
},
))
}
fn not_found(doc: &TrustTask<Value>, task: &str) -> TrustTaskOutcome {
reject_with_code(
doc,
code(task, "requestNotFound"),
"no such sign-in request",
None,
)
}
fn expired(doc: &TrustTask<Value>, task: &str) -> TrustTaskOutcome {
reject_with_code(
doc,
code(task, "requestExpired"),
"this sign-in request has expired; refresh the code on the website and scan again",
None,
)
}
fn not_authorized(doc: &TrustTask<Value>, task: &str) -> TrustTaskOutcome {
reject_with_code(
doc,
code(task, "notAuthorized"),
"this sign-in was not authorised",
None,
)
}
const NOT_AN_ADMIN_MESSAGE: &str = "This identity isn't an administrator of this community.";
fn not_an_admin(doc: &TrustTask<Value>, task: &str) -> TrustTaskOutcome {
reject_with_code(
doc,
code(task, "notAuthorized"),
NOT_AN_ADMIN_MESSAGE,
Some(json!({ "reason": NOT_AN_ADMIN })),
)
}
async fn is_administrator(state: &AppState, did: &str) -> Result<bool, crate::error::AppError> {
match crate::acl::resolve_auth_role(&state.acl_ks, did).await {
Ok(_) => Ok(true),
Err(crate::error::AppError::Forbidden(_)) => Ok(false),
Err(e) => Err(e),
}
}
async fn decline(
state: &AppState,
rec: &OobRequest,
from: OobState,
member: Option<&str>,
reason: &str,
) {
decline_telling(state, rec, from, member, reason, None).await;
}
async fn decline_telling(
state: &AppState,
rec: &OobRequest,
from: OobState,
member: Option<&str>,
reason: &str,
starter_reason: Option<&str>,
) {
let now = now_epoch();
let lock = rec.approver_key.clone();
let res = oob::transition(&state.member_sessions_ks, &rec.request_id, |r| {
if r.state != from || r.approver_key != lock {
return Err(());
}
r.decline_reason = starter_reason.map(str::to_string);
r.end(OobState::Declined, now);
Ok(())
})
.await;
if let Ok((_, ended)) = res {
tracing::info!(
request_id = %rec.request_id,
stage = from.as_str(),
audience = ?rec.audience,
reason,
"wallet sign-in declined"
);
let actor = lock.as_deref().unwrap_or("unknown");
audit(
state,
actor,
"proofFailed",
&ended,
member,
Some(reason),
None,
)
.await;
}
}
async fn verify_carried(
state: &AppState,
received: &Value,
expected_type: &str,
issuer: &str,
purpose: vti_common::auth::ProofPurpose,
) -> Result<TrustTask<Value>, &'static str> {
let inner: TrustTask<Value> =
serde_json::from_value(received.clone()).map_err(|_| "not a Trust Task document")?;
if inner.type_uri.to_string() != expected_type {
return Err("wrong carried document type");
}
if inner.issuer.as_deref() != Some(issuer) {
return Err("carried document issuer mismatch");
}
let now = chrono::Utc::now();
let vtc_did = state.config.read().await.vtc_did.clone();
let Some(vtc_did) = vtc_did else {
return Err("community DID not configured");
};
if inner.recipient.as_deref() != Some(vtc_did.as_str()) {
return Err("carried document not addressed to this community");
}
inner
.validate_freshness(now, &super::freshness_policy())
.map_err(|_| "carried document is stale")?;
let declared = inner
.proof
.as_ref()
.map(|p| p.proof_purpose.clone())
.ok_or("carried document is unsigned")?;
if declared != purpose.as_str() {
return Err("carried document signed for the wrong purpose");
}
let signer = match purpose {
vti_common::auth::ProofPurpose::AssertionMethod => {
super::helpers::verify_received_approval_proof(state, received, expected_type).await
}
_ => super::helpers::verify_received_trust_task_proof(state, received).await,
}
.map_err(|_| "carried document proof does not verify")?;
if signer != issuer {
return Err("carried document signed by another DID");
}
match state
.accepted_ids()
.claim(&inner, super::retain_until(&inner, now), now)
.await
{
Ok(super::accepted_ids::Acceptance::Fresh(claim)) => {
claim.completed(None).await;
Ok(inner)
}
_ => Err("carried document replayed"),
}
}
async fn handle_request(
state: &AppState,
doc: TrustTask<Value>,
start_key: String,
) -> TrustTaskOutcome {
let Some(http) = HttpContext::current() else {
return reject_with_code(
&doc,
TrustTaskCode::Standard(StandardCode::PermissionDenied),
"auth/oob/request is accepted only over HTTPS from the member portal",
None,
);
};
let raw = |k: &str| {
doc.payload
.get(k)
.and_then(Value::as_str)
.map(str::to_string)
};
if let Some(purpose) = raw("purpose").filter(|p| p != "login") {
return reject_with_code(
&doc,
code("request", "purposeUnsupported"),
format!("purpose `{purpose}` is not served; v1 serves `login`"),
None,
);
}
if let Some(mode) = raw("mode").filter(|m| m != "scan") {
return reject_with_code(
&doc,
code("request", "modeUnsupported"),
format!("mode `{mode}` is not served; v1 serves `scan`"),
None,
);
}
let payload: RequestPayload = match parse_payload(&doc) {
Ok(p) => p,
Err(e) => return e,
};
let audience = match SessionAudience::from_ext(doc.payload.get("ext")) {
Ok(a) => a,
Err(message) => {
return reject_with_code(
&doc,
TrustTaskCode::Standard(StandardCode::MalformedRequest),
message,
None,
);
}
};
let (public_url, vtc_did) = {
let cfg = state.config.read().await;
(cfg.public_url.clone(), cfg.vtc_did.clone())
};
if vtc_did.is_none() {
return reject_with_code(
&doc,
TrustTaskCode::Standard(StandardCode::Unavailable),
"this community has not finished setting up",
None,
);
}
let origin = match (
oob::portal_origin(public_url.as_deref(), &http),
http.origin.as_deref(),
) {
(Some(portal), Some(sent)) if portal == sent => portal,
_ => {
return reject_with_code(
&doc,
TrustTaskCode::Standard(StandardCode::PermissionDenied),
"auth/oob/request is accepted only from this community's own origin \
(the member portal and operator console)",
None,
);
}
};
let address = http.client_ip.to_string();
match oob::open_requests_from(&state.member_sessions_ks, &address).await {
Ok(n) if n >= MAX_PENDING_PER_ADDRESS => {
return reject_with_code(
&doc,
code("request", "rateLimited"),
"too many sign-in codes are open from this network; use one or wait for it to expire",
None,
);
}
Ok(_) => {}
Err(e) => return super::helpers::app_error_to_reject(&doc, &e),
}
let now = now_epoch();
let (browser, os) = oob::browser_and_os(http.user_agent.as_deref());
let rec = OobRequest {
request_id: oob::new_request_id(),
state: OobState::Pending,
start_key: start_key.clone(),
start_network: Some(address),
approver_key: None,
purpose: payload.purpose.to_string(),
mode: payload.mode.to_string(),
origin,
match_number: None,
match_number_delivered: false,
requester: RequesterDetails {
location: state
.geoip
.locate(http.client_ip)
.unwrap_or_else(|| "unknown".into()),
browser,
os,
created_at: oob::rfc3339(now),
},
identified_did: None,
step2_digest: None,
step1: None,
created_at: now,
claim_deadline: now + CLAIM_WINDOW_SECS,
decision_deadline: None,
grant: None,
ended_at: None,
audience,
decline_reason: None,
};
if let Err(e) = oob::create(&state.member_sessions_ks, &rec).await {
return super::helpers::app_error_to_reject(&doc, &e);
}
audit(state, &start_key, "requested", &rec, None, None, None).await;
let response: RequestResponse = match wire(&doc, "the request response", {
let mut r =
serde_json::json!({ "requestId": rec.request_id, "claimDeadline": rec.claim_deadline });
if let Some(ext) = audience.to_ext() {
r["ext"] = ext;
}
r
}) {
Ok(r) => r,
Err(e) => return e,
};
success_response(&doc, response)
}
async fn handle_claim(
state: &AppState,
doc: TrustTask<Value>,
approver: String,
) -> TrustTaskOutcome {
let payload: ClaimPayload = match parse_payload(&doc) {
Ok(p) => p,
Err(e) => return e,
};
if doc.parent_thread_id.as_deref() != Some(payload.request_id.as_str()) {
return reject_with_code(
&doc,
TrustTaskCode::Standard(StandardCode::MalformedRequest),
"parentThreadId must equal payload.requestId (VTI-LNK-054)",
None,
);
}
let ks = &state.member_sessions_ks;
let rec = match oob::load(ks, &payload.request_id).await {
Ok(Some(r)) => r,
Ok(None) => return not_found(&doc, "claim"),
Err(e) => return super::helpers::app_error_to_reject(&doc, &e),
};
let now = now_epoch();
match rec.effective_state(now) {
OobState::Pending => {}
OobState::Expired => {
if let Ok(true) = oob::settle_expiry(ks, &rec.request_id).await {
audit(state, &approver, "expired", &rec, None, None, None).await;
}
return expired(&doc, "claim");
}
_ => {
return reject_with_code(
&doc,
code("claim", "alreadyClaimed"),
"this code was already used by another device",
None,
);
}
}
let (vtc_did, vtc_name, profile) = {
let cfg = state.config.read().await;
let did = cfg.vtc_did.clone().unwrap_or_default();
let name = cfg.vtc_name.clone();
drop(cfg);
let profile = crate::community::load_profile(&state.community_ks)
.await
.ok()
.flatten();
(did, name, profile)
};
let decision_deadline = now + DECISION_WINDOW_SECS;
let service_name: String = profile
.map(|p| p.name)
.filter(|n| !n.is_empty())
.or(vtc_name.filter(|n| !n.is_empty()))
.unwrap_or_else(|| vtc_did.chars().take(128).collect());
let step1_json = serde_json::json!({
"requestId": rec.request_id,
"service": { "did": vtc_did, "name": service_name },
"origin": rec.origin,
"purpose": rec.purpose,
"decisionDeadline": decision_deadline,
});
let mut step1_json = step1_json;
if let Some(ext) = rec.audience.to_ext() {
step1_json["ext"] = ext;
}
let step1: Step1 = match wire(&doc, "step 1", step1_json.clone()) {
Ok(s) => s,
Err(e) => return e,
};
let (_, outcome) = match attested(state, &doc, &step1).await {
Ok(signed) => signed,
Err(e) => return e,
};
let number = oob::new_match_number();
let res = oob::transition(ks, &rec.request_id, |r| {
if r.effective_state(now_epoch()) != OobState::Pending {
return Err(());
}
r.state = OobState::Claimed;
r.approver_key = Some(approver.clone());
r.match_number = Some(number.clone());
r.decision_deadline = Some(decision_deadline);
r.step1 = Some(step1_json.clone());
Ok(())
})
.await;
match res {
Ok((_, claimed)) => {
audit(state, &approver, "claimed", &claimed, None, None, None).await;
outcome
}
Err(TransitionError::Store(e)) => super::helpers::app_error_to_reject(&doc, &e),
Err(TransitionError::NotFound) => not_found(&doc, "claim"),
Err(TransitionError::Refused(())) => reject_with_code(
&doc,
code("claim", "alreadyClaimed"),
"this code was already used by another device",
None,
),
}
}
async fn handle_prove(
state: &AppState,
doc: TrustTask<Value>,
approver: String,
) -> TrustTaskOutcome {
let payload: ProvePayload = match parse_payload(&doc) {
Ok(p) => p,
Err(e) => return e,
};
let identify = Value::Object(payload.identify);
let Some(request_id) = request_id_hint(&doc, &identify) else {
return not_found(&doc, "prove");
};
let ks = &state.member_sessions_ks;
let rec = match oob::load(ks, &request_id).await {
Ok(Some(r)) => r,
Ok(None) => return not_found(&doc, "prove"),
Err(e) => return super::helpers::app_error_to_reject(&doc, &e),
};
if rec.approver_key.as_deref() != Some(approver.as_str()) {
return reject_with_code(
&doc,
code("prove", "notClaimant"),
"this device does not hold this sign-in request",
None,
);
}
match rec.effective_state(now_epoch()) {
OobState::Claimed => {}
OobState::Expired => {
if let Ok(true) = oob::settle_expiry(ks, &rec.request_id).await {
audit(state, &approver, "expired", &rec, None, None, None).await;
}
return expired(&doc, "prove");
}
_ => return not_authorized(&doc, "prove"),
}
let fail = |member: Option<String>, reason: &'static str| {
let rec = rec.clone();
async move {
decline(state, &rec, OobState::Claimed, member.as_deref(), reason).await;
}
};
let identify_issuer = identify
.get("issuer")
.and_then(Value::as_str)
.map(str::to_string);
let identify_payload: Option<IdentifyPayload> = identify
.get("payload")
.cloned()
.and_then(|p| serde_json::from_value(p).ok());
let (Some(member), Some(identify_payload)) = (identify_issuer, identify_payload) else {
fail(None, "identify is malformed").await;
return not_authorized(&doc, "prove");
};
if identify_payload.request_id.as_str() != rec.request_id
|| identify_payload.approver_key.as_str() != approver
{
fail(Some(member), "identify names another request or lock").await;
return not_authorized(&doc, "prove");
}
let known = match rec.audience {
SessionAudience::Member => active_member(state, &member).await.map(|m| m.is_some()),
SessionAudience::Admin => match is_administrator(state, &member).await {
Ok(true) => Ok(true),
Ok(false) => active_member(state, &member).await.map(|m| m.is_some()),
Err(e) => Err(e),
},
};
match known {
Ok(true) => {}
Ok(false) => {
fail(Some(member), "not an active member").await;
return not_authorized(&doc, "prove");
}
Err(e) => return super::helpers::app_error_to_reject(&doc, &e),
}
if let Err(reason) = verify_carried(
state,
&identify,
IDENTIFY_TYPE,
&member,
vti_common::auth::ProofPurpose::Authentication,
)
.await
{
tracing::info!(reason, "auth/oob/prove: identify refused");
fail(Some(member), reason).await;
return not_authorized(&doc, "prove");
}
if Some(identify_payload.entered_number.as_str()) != rec.match_number.as_deref() {
fail(Some(member), "wrong number").await;
return reject_with_code(
&doc,
code("prove", "numberMismatch"),
"that is not the number on the screen; refresh the code on the website and scan again",
None,
);
}
if rec.audience == SessionAudience::Admin {
match is_administrator(state, &member).await {
Ok(true) => {}
Ok(false) => {
decline_telling(
state,
&rec,
OobState::Claimed,
Some(&member),
"not an administrator",
Some(NOT_AN_ADMIN),
)
.await;
return not_an_admin(&doc, "prove");
}
Err(e) => return super::helpers::app_error_to_reject(&doc, &e),
}
}
let Some(step1) = rec.step1.clone() else {
return reject_with(
&doc,
RejectReason::InternalError {
reason: "claimed request has no step 1".into(),
},
);
};
let approver_ip = HttpContext::current().map(|h| h.client_ip);
let Value::Object(mut step2_json) = step1 else {
return reject_with(
&doc,
RejectReason::InternalError {
reason: "stored step 1 is not an object".into(),
},
);
};
step2_json.insert("sessionKey".into(), Value::String(rec.start_key.clone()));
step2_json.insert(
"requester".into(),
serde_json::json!({
"location": rec.requester.location,
"browser": rec.requester.browser,
"os": rec.requester.os,
"createdAt": rec.requester.created_at,
"sameNetwork": oob::same_network(rec.start_network.as_deref(), approver_ip),
}),
);
step2_json.insert("identifiedAs".into(), Value::String(member.clone()));
let step2: Step2 = match wire(&doc, "step 2", Value::Object(step2_json)) {
Ok(s) => s,
Err(e) => return e,
};
let (signed, outcome) = match attested(state, &doc, &step2).await {
Ok(s) => s,
Err(e) => return e,
};
let digest = match oob::context_digest(&signed) {
Ok(d) => d,
Err(e) => return super::helpers::app_error_to_reject(&doc, &e),
};
let res = oob::transition(ks, &rec.request_id, |r| {
if r.effective_state(now_epoch()) != OobState::Claimed
|| r.approver_key.as_deref() != Some(approver.as_str())
{
return Err(());
}
r.state = OobState::Identified;
r.identified_did = Some(member.clone());
r.step2_digest = Some(digest.clone());
Ok(())
})
.await;
match res {
Ok((_, identified)) => {
audit(
state,
&member,
"proved",
&identified,
Some(&member),
None,
None,
)
.await;
outcome
}
Err(TransitionError::Store(e)) => super::helpers::app_error_to_reject(&doc, &e),
Err(_) => expired(&doc, "prove"),
}
}
async fn handle_respond(
state: &AppState,
doc: TrustTask<Value>,
approver: String,
) -> TrustTaskOutcome {
let payload: RespondPayload = match parse_payload(&doc) {
Ok(p) => p,
Err(e) => return e,
};
let grant_doc = Value::Object(payload.grant);
let Some(request_id) = request_id_hint(&doc, &grant_doc) else {
return not_found(&doc, "respond");
};
let ks = &state.member_sessions_ks;
let rec = match oob::load(ks, &request_id).await {
Ok(Some(r)) => r,
Ok(None) => return not_found(&doc, "respond"),
Err(e) => return super::helpers::app_error_to_reject(&doc, &e),
};
if rec.approver_key.as_deref() != Some(approver.as_str()) {
return reject_with_code(
&doc,
code("respond", "notClaimant"),
"this device does not hold this sign-in request",
None,
);
}
match rec.effective_state(now_epoch()) {
OobState::Identified => {}
OobState::Expired => {
if let Ok(true) = oob::settle_expiry(ks, &rec.request_id).await {
audit(state, &approver, "expired", &rec, None, None, None).await;
}
return expired(&doc, "respond");
}
OobState::Claimed => return not_authorized(&doc, "respond"),
_ => {
return reject_with_code(
&doc,
code("respond", "alreadyDecided"),
"this sign-in request has already been decided",
None,
);
}
}
let Some(member) = rec.identified_did.clone() else {
return not_authorized(&doc, "respond");
};
let fail = |reason: &'static str| {
let rec = rec.clone();
let member = member.clone();
async move {
decline(state, &rec, OobState::Identified, Some(&member), reason).await;
}
};
if grant_doc.get("issuer").and_then(Value::as_str) != Some(member.as_str()) {
fail("grant from another identity").await;
return not_authorized(&doc, "respond");
}
if let Err(reason) = verify_carried(
state,
&grant_doc,
GRANT_TYPE,
&member,
vti_common::auth::ProofPurpose::AssertionMethod,
)
.await
{
tracing::info!(reason, "auth/oob/respond: grant refused");
fail(reason).await;
return not_authorized(&doc, "respond");
}
let grant: GrantPayload = match grant_doc
.get("payload")
.cloned()
.map(serde_json::from_value)
{
Some(Ok(g)) => g,
_ => {
fail("grant payload is malformed").await;
return not_authorized(&doc, "respond");
}
};
let context_ok = grant.request_id.as_str() == rec.request_id
&& grant.approver_key.as_str() == approver
&& grant.session_key.as_str() == rec.start_key
&& grant.origin.as_str() == rec.origin
&& rec
.step2_digest
.as_deref()
.is_some_and(|d| oob::same_digest(d, &grant.context_digest));
if !context_ok {
fail("grant context mismatch").await;
return reject_with_code(
&doc,
code("respond", "contextMismatch"),
"the grant does not match this sign-in request",
None,
);
}
let decision = match grant.decision {
GrantDecision::Approve => OobState::Approved,
GrantDecision::Decline => OobState::Declined,
_ => {
fail("unknown decision").await;
return not_authorized(&doc, "respond");
}
};
if decision == OobState::Approved {
let not_after = grant.not_after;
if not_after <= now_epoch() {
fail("grant notAfter has passed").await;
return not_authorized(&doc, "respond");
}
match rec.audience {
SessionAudience::Member => match active_member(state, &member).await {
Ok(Some(_)) => {}
Ok(None) => {
fail("no longer an active member").await;
return not_authorized(&doc, "respond");
}
Err(e) => return super::helpers::app_error_to_reject(&doc, &e),
},
SessionAudience::Admin => match is_administrator(state, &member).await {
Ok(true) => {}
Ok(false) => {
decline_telling(
state,
&rec,
OobState::Identified,
Some(&member),
"no longer an administrator",
Some(NOT_AN_ADMIN),
)
.await;
return not_an_admin(&doc, "respond");
}
Err(e) => return super::helpers::app_error_to_reject(&doc, &e),
},
}
}
let signed_grant = grant_doc.clone();
let now = now_epoch();
let res = oob::transition(ks, &rec.request_id, |r| {
if r.effective_state(now) != OobState::Identified
|| r.approver_key.as_deref() != Some(approver.as_str())
{
return Err(());
}
r.grant = Some(signed_grant.clone());
if decision == OobState::Declined {
r.end(OobState::Declined, now);
} else {
r.state = OobState::Approved;
}
Ok(())
})
.await;
match res {
Ok((_, decided)) => {
let stage = if decision == OobState::Approved {
"approved"
} else {
"declined"
};
audit(
state,
&member,
stage,
&decided,
Some(&member),
None,
Some(signed_grant),
)
.await;
let r: RespondResponse = match wire(
&doc,
"the respond response",
serde_json::json!({ "status": stage }),
) {
Ok(r) => r,
Err(e) => return e,
};
match attested(state, &doc, &r).await {
Ok((_, outcome)) => outcome,
Err(e) => e,
}
}
Err(TransitionError::Store(e)) => super::helpers::app_error_to_reject(&doc, &e),
Err(_) => reject_with_code(
&doc,
code("respond", "alreadyDecided"),
"this sign-in request has already been decided",
None,
),
}
}
async fn handle_cancel(
state: &AppState,
doc: TrustTask<Value>,
signer: String,
) -> TrustTaskOutcome {
let payload: CancelPayload = match parse_payload(&doc) {
Ok(p) => p,
Err(e) => return e,
};
let ks = &state.member_sessions_ks;
let now = now_epoch();
let res = oob::transition(ks, &payload.request_id, |r| {
if r.start_key != signer && r.approver_key.as_deref() != Some(signer.as_str()) {
return Err("notAuthorized");
}
if r.state.is_final() {
return Err("alreadyDecided");
}
if r.effective_state(now) == OobState::Expired {
r.end(OobState::Expired, now);
return Ok(OobState::Expired);
}
r.end(OobState::Cancelled, now);
Ok(OobState::Cancelled)
})
.await;
match res {
Ok((OobState::Cancelled, rec)) => {
audit(
state,
&signer,
"cancelled",
&rec,
rec.identified_did.as_deref(),
None,
None,
)
.await;
let r: CancelResponse = match wire(
&doc,
"the cancel response",
serde_json::json!({ "status": "cancelled" }),
) {
Ok(r) => r,
Err(e) => return e,
};
match attested(state, &doc, &r).await {
Ok((_, outcome)) => outcome,
Err(e) => e,
}
}
Ok((_, rec)) => {
audit(state, &signer, "expired", &rec, None, None, None).await;
expired(&doc, "cancel")
}
Err(TransitionError::NotFound) => not_found(&doc, "cancel"),
Err(TransitionError::Store(e)) => super::helpers::app_error_to_reject(&doc, &e),
Err(TransitionError::Refused("notAuthorized")) => not_authorized(&doc, "cancel"),
Err(TransitionError::Refused(local)) => reject_with_code(
&doc,
code("cancel", local),
"this sign-in request has already ended",
None,
),
}
}
fn pending(doc: &TrustTask<Value>, rec: &OobRequest, now: u64) -> TrustTaskOutcome {
let state = rec.effective_state(now);
let mut details = json!({ "state": state.as_str() });
if let Some(n) = &rec.match_number {
details["matchNumber"] = Value::String(n.clone());
}
reject_with_code(
doc,
code("redeem", "pending"),
"waiting for the wallet; poll again",
Some(details),
)
}
fn declined(doc: &TrustTask<Value>, reason: Option<&str>) -> TrustTaskOutcome {
let mut details = json!({ "state": "declined" });
let message = match reason {
Some(r) => {
details["reason"] = Value::String(r.to_string());
if r == NOT_AN_ADMIN {
NOT_AN_ADMIN_MESSAGE
} else {
"the sign-in was declined"
}
}
None => "the sign-in was declined",
};
reject_with_code(doc, code("redeem", "declined"), message, Some(details))
}
async fn handle_redeem(
state: &AppState,
doc: TrustTask<Value>,
starter: String,
) -> TrustTaskOutcome {
let Some(http) = HttpContext::current() else {
return reject_with_code(
&doc,
TrustTaskCode::Standard(StandardCode::PermissionDenied),
"auth/oob/redeem is served only over HTTPS, where the session is set as cookies",
None,
);
};
let payload: RedeemPayload = match parse_payload(&doc) {
Ok(p) => p,
Err(e) => return e,
};
let ks = &state.member_sessions_ks;
let rec = match oob::load(ks, &payload.request_id).await {
Ok(Some(r)) => r,
Ok(None) => return not_found(&doc, "redeem"),
Err(e) => return super::helpers::app_error_to_reject(&doc, &e),
};
if rec.start_key != starter {
return reject_with_code(
&doc,
code("redeem", "notStarter"),
"only the browser that started this sign-in can redeem it",
None,
);
}
let _poll = match PollGuard::open(&rec.request_id, http.client_ip) {
Ok(g) => Some(g),
Err(PollRefused::AlreadyOpen) => None,
Err(PollRefused::AddressCap) => {
return reject_with_code(
&doc,
code("redeem", "rateLimited"),
"too many sign-in polls are open from this network",
None,
);
}
};
let deadline = tokio::time::Instant::now() + oob::redeem_hold();
let entry_state = rec.effective_state(now_epoch());
loop {
let notify = NOTIFIER.handle(&rec.request_id);
let notified = notify.notified();
tokio::pin!(notified);
notified.as_mut().enable();
let current = match oob::load(ks, &rec.request_id).await {
Ok(Some(r)) => r,
Ok(None) => return not_found(&doc, "redeem"),
Err(e) => return super::helpers::app_error_to_reject(&doc, &e),
};
let now = now_epoch();
match current.effective_state(now) {
OobState::Approved => return redeem_approved(state, &doc, &http, ¤t).await,
OobState::Consumed => {
return reject_with_code(
&doc,
code("redeem", "alreadyDecided"),
"this sign-in has already been used",
None,
);
}
OobState::Declined => return declined(&doc, current.decline_reason.as_deref()),
OobState::Cancelled => {
return reject_with_code(
&doc,
code("redeem", "declined"),
"the sign-in was cancelled",
Some(json!({ "state": "cancelled" })),
);
}
OobState::Expired => {
if let Ok(true) = oob::settle_expiry(ks, ¤t.request_id).await {
audit(state, &starter, "expired", ¤t, None, None, None).await;
}
return expired(&doc, "redeem");
}
s @ (OobState::Pending | OobState::Claimed | OobState::Identified) => {
let number_owed = s == OobState::Claimed && !current.match_number_delivered;
if s != entry_state || number_owed || _poll.is_none() {
if current.match_number.is_some() && !current.match_number_delivered {
let _ = oob::transition(ks, ¤t.request_id, |r| {
r.match_number_delivered = true;
Ok::<_, ()>(())
})
.await;
}
return pending(&doc, ¤t, now);
}
}
}
if tokio::time::timeout_at(deadline, notified).await.is_err() {
let current = match oob::load(ks, &rec.request_id).await {
Ok(Some(r)) => r,
_ => return not_found(&doc, "redeem"),
};
return pending(&doc, ¤t, now_epoch());
}
}
}
async fn redeem_approved(
state: &AppState,
doc: &TrustTask<Value>,
http: &HttpContext,
rec: &OobRequest,
) -> TrustTaskOutcome {
let ks = &state.member_sessions_ks;
let now = now_epoch();
let res = oob::transition(ks, &rec.request_id, |r| {
if r.effective_state(now) != OobState::Approved {
return Err(());
}
r.end(OobState::Consumed, now);
Ok(())
})
.await;
let consumed = match res {
Ok((_, r)) => r,
Err(TransitionError::Store(e)) => return super::helpers::app_error_to_reject(doc, &e),
Err(_) => {
return reject_with_code(
doc,
code("redeem", "alreadyDecided"),
"this sign-in has already been used",
None,
);
}
};
let Some(subject) = consumed.identified_did.clone() else {
return not_authorized(doc, "redeem");
};
if consumed.signed_audience() != Some(consumed.audience) {
tracing::error!(
request_id = %consumed.request_id,
security_alert = true,
"auth/oob/redeem: the stored audience differs from the one the member approved"
);
audit(
state,
&subject,
"proofFailed",
&consumed,
Some(&subject),
Some("audience differs from the approved one"),
None,
)
.await;
return not_authorized(doc, "redeem");
}
let not_after = consumed
.grant
.as_ref()
.and_then(|g| g.get("payload"))
.and_then(|p| p.get("notAfter"))
.and_then(oob::epoch_of)
.unwrap_or(now);
if not_after <= now {
return not_authorized(doc, "redeem");
}
match consumed.audience {
SessionAudience::Member => {
redeem_member(state, doc, http, &consumed, subject, not_after).await
}
SessionAudience::Admin => {
redeem_admin(state, doc, http, &consumed, subject, not_after).await
}
}
}
async fn redeem_member(
state: &AppState,
doc: &TrustTask<Value>,
http: &HttpContext,
consumed: &OobRequest,
member_did: String,
not_after: u64,
) -> TrustTaskOutcome {
let member = match active_member(state, &member_did).await {
Ok(Some(m)) => m,
Ok(None) => return not_authorized(doc, "redeem"),
Err(e) => return super::helpers::app_error_to_reject(doc, &e),
};
match mint_session(state, &member_did, &consumed.start_key, not_after).await {
Ok((_session_id, session_end, set)) => {
http.set_cookies(set);
audit(
state,
&member_did,
"redeemed",
consumed,
Some(&member_did),
None,
None,
)
.await;
let display_name: String = member
.entry
.label
.clone()
.filter(|l| !l.is_empty())
.unwrap_or_else(|| member_did.chars().take(128).collect());
match wire::<RedeemResponse>(
doc,
"the redeem response",
serde_json::json!({
"subject": member_did,
"displayName": display_name,
"notAfter": session_end,
"amr": amr(),
}),
) {
Ok(r) => success_response(doc, r),
Err(e) => e,
}
}
Err(e) => super::helpers::app_error_to_reject(doc, &e),
}
}
async fn redeem_admin(
state: &AppState,
doc: &TrustTask<Value>,
http: &HttpContext,
consumed: &OobRequest,
admin_did: String,
not_after: u64,
) -> TrustTaskOutcome {
match is_administrator(state, &admin_did).await {
Ok(true) => {}
Ok(false) => {
audit(
state,
&admin_did,
"declined",
consumed,
Some(&admin_did),
Some(NOT_AN_ADMIN),
None,
)
.await;
return declined(doc, Some(NOT_AN_ADMIN));
}
Err(e) => return super::helpers::app_error_to_reject(doc, &e),
}
let session_key = oob::did_key_multikey(&consumed.start_key).map(str::to_string);
let minted = match crate::routes::auth::mint_admin_cookie_session(
state,
&admin_did,
&["did", "oob", "uv"],
session_key,
Some(not_after),
)
.await
{
Ok(m) => m,
Err(crate::error::AppError::Forbidden(_)) => return declined(doc, Some(NOT_AN_ADMIN)),
Err(e) => return super::helpers::app_error_to_reject(doc, &e),
};
http.set_cookies(minted.cookies);
audit(
state,
&admin_did,
"redeemed",
consumed,
Some(&admin_did),
None,
None,
)
.await;
let label = crate::acl::get_acl_entry(&state.acl_ks, &admin_did)
.await
.ok()
.flatten()
.and_then(|e| e.label)
.filter(|l| !l.is_empty());
let display_name = label.unwrap_or_else(|| admin_did.chars().take(128).collect());
match wire::<RedeemResponse>(
doc,
"the redeem response",
serde_json::json!({
"ext": SessionAudience::Admin.to_ext(),
"subject": admin_did,
"displayName": display_name,
"notAfter": minted.minted.refresh_expires_at,
"amr": amr(),
}),
) {
Ok(r) => success_response(doc, r),
Err(e) => e,
}
}
fn amr() -> Vec<String> {
vec!["did".into(), "oob".into(), "uv".into()]
}
async fn mint_session(
state: &AppState,
did: &str,
session_key: &str,
not_after: u64,
) -> Result<(String, u64, Vec<String>), crate::error::AppError> {
let backend = MemberAuthBackend::from_state(state).await?;
let session_id = uuid::Uuid::new_v4().to_string();
let amr = amr();
let acr = "aal2".to_string();
let minted = vti_common::auth::handlers::mint_session_tokens_until(
&backend,
did,
&session_id,
&crate::acl::Role::Reader,
&[],
&amr,
&acr,
false,
Some(not_after),
)
.await?;
let refresh_expires_at = minted.refresh_expires_at;
let access_expires_at = minted.access_expires_at;
store_session(
&state.member_sessions_ks,
&Session {
session_id: session_id.clone(),
did: did.to_string(),
challenge: String::new(),
state: SessionState::Authenticated,
created_at: minted.issued_at,
last_seen: minted.issued_at,
refresh_token: Some(minted.refresh_token.clone()),
refresh_expires_at: Some(refresh_expires_at),
tee_attested: false,
amr,
acr,
acr_expires_at: None,
token_id: Some(minted.token_id.clone()),
session_pubkey_b58btc: oob::did_key_multikey(session_key).map(str::to_string),
not_after: Some(not_after),
},
)
.await?;
store_refresh_index(
&state.member_sessions_ks,
&minted.refresh_token,
&session_id,
)
.await?;
let now = now_epoch();
let access_max_age = access_expires_at.saturating_sub(now).max(1);
let refresh_max_age = refresh_expires_at.saturating_sub(now).max(1);
Ok((
session_id,
refresh_expires_at,
vec![
cookies::session_cookie(&minted.access_token, access_max_age),
cookies::refresh_cookie(&minted.refresh_token, refresh_max_age),
cookies::csrf_cookie(&cookies::new_csrf(), refresh_max_age),
],
))
}