use std::net::IpAddr;
use std::sync::OnceLock;
use async_trait::async_trait;
use axum::http::{HeaderMap, header};
use did_hosting_common::server::auth::session::{create_authenticated_session, now_epoch};
use did_hosting_common::server::rate_limit::IpRateLimiter;
use did_hosting_common::server::trust_tasks::{BoundKeyError, TransportBoundVerifier};
use serde_json::Value;
use trust_tasks_proof::affinidi::{CryptoSuite, SignOptions, sign_trust_task};
use trust_tasks_rs::TrustTask;
use super::service::{
ApprovedSession, Connection, OOB_AMR, OobConfig, OobEnv, OobReply, OobService,
};
use super::types::{OobErrorCode, TYPE_REDEEM, TYPE_REQUEST};
use crate::control_tasks::SealedReply;
use crate::messaging::HttpsFacts;
use crate::server::AppState;
pub const SESSION_EXT_NAMESPACE: &str = "com.affinidi.did-hosting";
pub const MAX_OOB_DOCUMENT_BYTES: usize = 32 * 1024;
pub const REQUEST_MAX_PER_MINUTE: u64 = 20;
pub const OTHER_MAX_PER_MINUTE: u64 = 120;
pub const GLOBAL_REQUEST_MAX_PER_MINUTE: u64 = 600;
const GLOBAL_KEY: IpAddr = IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED);
pub struct OobRuntime {
service: OnceLock<Option<OobService>>,
request_limiter: IpRateLimiter,
global_request_limiter: IpRateLimiter,
other_limiter: IpRateLimiter,
}
impl Default for OobRuntime {
fn default() -> Self {
Self {
service: OnceLock::new(),
request_limiter: IpRateLimiter::new(
"auth-oob-request-per-ip",
REQUEST_MAX_PER_MINUTE,
60,
),
global_request_limiter: IpRateLimiter::new(
"auth-oob-request-global",
GLOBAL_REQUEST_MAX_PER_MINUTE,
60,
),
other_limiter: IpRateLimiter::new("auth-oob-per-ip", OTHER_MAX_PER_MINUTE, 60),
}
}
}
impl OobRuntime {
fn service(&self, state: &AppState) -> Option<&OobService> {
self.service
.get_or_init(|| {
let did = state.config.server_did.clone()?;
let public_url = state.config.public_url.as_deref()?;
let Some((origin, host)) = portal_origin(public_url) else {
tracing::warn!(
public_url,
"wallet sign-in (auth/oob) is off: the public URL is not an https \
origin, and a sign-in portal must be one"
);
return None;
};
Some(OobService::new(OobConfig::new(did, host, origin)))
})
.as_ref()
}
}
pub fn portal_origin(public_url: &str) -> Option<(String, String)> {
if !did_hosting_common::did::is_sign_in_portal_url(public_url) {
return None;
}
let url = url::Url::parse(public_url).ok()?;
let host = url.host_str()?.to_string();
let origin = url.origin();
if !origin.is_tuple() {
return None;
}
Some((origin.ascii_serialization(), host))
}
pub fn origin_is_portal(headers: &HeaderMap, portal: &str) -> bool {
let mut values = headers.get_all(header::ORIGIN).iter();
let (Some(value), None) = (values.next(), values.next()) else {
return false;
};
let Ok(sent) = value.to_str() else {
return false;
};
if sent == "null" {
return false;
}
let Ok(url) = url::Url::parse(sent) else {
return false;
};
if !matches!(url.scheme(), "http" | "https") {
return false;
}
let origin = url.origin();
if !origin.is_tuple() {
return false;
}
let canonical = origin.ascii_serialization();
canonical == sent && canonical == portal
}
pub fn sign_in_portal_endpoint(public_url: &str) -> Option<String> {
let (origin, _) = portal_origin(public_url)?;
Some(format!("{origin}/login"))
}
struct AppEnv<'a> {
state: &'a AppState,
}
#[async_trait]
impl OobEnv for AppEnv<'_> {
fn now(&self) -> u64 {
now_epoch()
}
fn member_verifier(&self) -> Option<&TransportBoundVerifier> {
self.state.trust_tasks_verifier.as_deref()
}
async fn is_allowed(&self, did: &str) -> bool {
did.len() <= 512
&& matches!(
crate::acl::get_acl_entry(&self.state.acl_ks, did).await,
Ok(Some(_))
)
}
async fn display_name(&self, did: &str) -> Option<String> {
crate::acl::get_acl_entry(&self.state.acl_ks, did)
.await
.ok()
.flatten()
.and_then(|e| e.label)
.filter(|l| !l.trim().is_empty())
}
async fn sign(&self, unsigned: Value, proof_purpose: &str) -> Result<Value, String> {
let did = self
.state
.config
.server_did
.as_deref()
.ok_or("server_did not configured")?;
let secret =
crate::signing::control_signing_secret(self.state, did).map_err(|e| e.to_string())?;
sign_trust_task(
&unsigned,
&secret,
SignOptions::new()
.with_proof_purpose(proof_purpose)
.with_cryptosuite(CryptoSuite::EddsaJcs2022),
)
.await
.map_err(|e| e.to_string())
}
}
pub use super::service::KEY_PURPOSES;
pub(crate) async fn admit(
state: &AppState,
https: &HttpsFacts<'_>,
doc: &TrustTask<Value>,
) -> Result<(), SealedReply> {
let Some(service) = state.oob.service(state) else {
return Err(SealedReply::Unavailable(
"wallet sign-in is not configured on this server",
));
};
let env = AppEnv { state };
let refuse = |code: OobErrorCode, msg: &str| {
let err = super::service::OobError::new(code, msg);
SealedReply::Document {
status: err.code.http_status(),
body: service.error_document(&env, &raw(doc), &err),
}
};
if https.body_len > MAX_OOB_DOCUMENT_BYTES {
return Err(refuse(OobErrorCode::MalformedRequest, "document too large"));
}
let ip = https.ip.unwrap_or(GLOBAL_KEY);
let type_uri = doc.type_uri.to_string();
let limiter = if type_uri == TYPE_REQUEST {
&state.oob.request_limiter
} else {
&state.oob.other_limiter
};
if limiter.try_consume(ip, now_epoch()).is_err() {
let type_prefix: String = type_uri.chars().take(64).collect();
tracing::warn!(%ip, type_uri = %type_prefix, "auth/oob rate limited");
return Err(refuse(OobErrorCode::RateLimited, "too many requests"));
}
if type_uri == TYPE_REQUEST {
if !origin_is_portal(https.headers, &service.config.origin) {
let origin: Vec<_> = https
.headers
.get_all(header::ORIGIN)
.iter()
.map(|v| {
String::from_utf8_lossy(v.as_bytes())
.chars()
.take(256)
.collect::<String>()
})
.take(4)
.collect();
tracing::warn!(
?origin,
"auth/oob/request refused: not from the portal origin"
);
return Err(refuse(
OobErrorCode::NotAuthorized,
"not from the sign-in portal",
));
}
if state
.oob
.global_request_limiter
.try_consume(GLOBAL_KEY, now_epoch())
.is_err()
{
tracing::warn!("auth/oob/request refused: global rate limit");
return Err(refuse(
OobErrorCode::RateLimited,
"too many sign-in requests",
));
}
}
tracing::debug!(target: "auth_oob", %ip, %type_uri, "auth/oob document admitted to its control_tasks row");
Ok(())
}
pub(crate) fn refuse(
state: &AppState,
doc: &TrustTask<Value>,
error: &BoundKeyError,
) -> SealedReply {
let Some(service) = state.oob.service(state) else {
return SealedReply::Unavailable("wallet sign-in is not configured on this server");
};
let err = match error {
BoundKeyError::KeyUnsupported => {
super::service::OobError::new(OobErrorCode::KeyUnsupported, "Ed25519 did:key only")
}
_ => super::service::OobError::new(OobErrorCode::NotAuthorized, "not authorized"),
};
SealedReply::Document {
status: err.code.http_status(),
body: service.error_document(&AppEnv { state }, &raw(doc), &err),
}
}
pub(crate) async fn exchange(
state: &AppState,
https: &HttpsFacts<'_>,
doc: TrustTask<Value>,
) -> SealedReply {
let Some(service) = state.oob.service(state) else {
return SealedReply::Unavailable("wallet sign-in is not configured on this server");
};
let env = AppEnv { state };
let raw = raw(&doc);
let ua = https
.headers
.get(header::USER_AGENT)
.and_then(|v| v.to_str().ok())
.unwrap_or_default();
let (browser, os) = parse_user_agent(ua);
let conn = Connection {
ip: https.ip.map(|ip| ip.to_string()),
location: None,
browser: Some(browser.to_string()),
os: Some(os.to_string()),
};
let error = |err: super::service::OobError| SealedReply::Document {
status: err.code.http_status(),
body: service.error_document(&env, &raw, &err),
};
match service.handle(&env, raw.clone(), &conn).await {
OobReply::Document { status, body } => SealedReply::Document { status, body },
OobReply::Approved { request, session } => {
debug_assert_eq!(request.type_uri.to_string(), TYPE_REDEEM);
match create_session(state, &session).await {
Ok(extra) => match service
.redeem_response(&env, &request, &session, Some(extra))
.await
{
Ok(body) => SealedReply::Document { status: 200, body },
Err(err) => error(err),
},
Err(e) => {
tracing::error!(error = %e, "auth/oob/redeem: session creation failed");
error(super::service::OobError::new(
OobErrorCode::NotAuthorized,
"not authorized",
))
}
}
}
}
}
fn raw(doc: &TrustTask<Value>) -> Value {
serde_json::to_value(doc).expect("a Trust Task document serialises")
}
async fn create_session(state: &AppState, session: &ApprovedSession) -> Result<Value, String> {
let role = crate::acl::check_acl(&state.acl_ks, &session.subject)
.await
.map_err(|e| e.to_string())?;
let jwt_keys = state.jwt_keys.as_ref().ok_or("JWT keys not configured")?;
let multikey = session
.session_key
.strip_prefix("did:key:")
.ok_or("session key is not a did:key")?
.to_string();
let now = now_epoch();
let ttl = session.not_after.saturating_sub(now).max(1);
let auth = &state.config.auth;
let tokens = create_authenticated_session(
&state.sessions_ks,
jwt_keys,
&session.subject,
&role,
auth.access_token_expiry.min(ttl),
auth.refresh_token_expiry.min(ttl),
Some(multikey),
Some((
OOB_AMR.iter().map(|s| s.to_string()).collect(),
"aal1".to_string(),
)),
)
.await
.map_err(|e| e.to_string())?;
let absolute = session.not_after.min(now + auth.absolute_session_lifetime);
crate::trust_tasks_auth::store_auth_proxy_meta(state, &tokens.session_id, None, absolute)
.await
.map_err(|e| e.to_string())?;
let canonical = tokens.into_canonical();
let tokens = serde_json::to_value(&canonical.tokens).map_err(|e| e.to_string())?;
Ok(serde_json::json!({ SESSION_EXT_NAMESPACE: { "tokens": tokens } }))
}
pub fn parse_user_agent(ua: &str) -> (&'static str, &'static str) {
let browser = if ua.contains("Edg/") || ua.contains("EdgA/") || ua.contains("EdgiOS/") {
"Edge"
} else if ua.contains("OPR/") || ua.contains("Opera") {
"Opera"
} else if ua.contains("Firefox/") || ua.contains("FxiOS/") {
"Firefox"
} else if ua.contains("Chrome/") || ua.contains("CriOS/") {
"Chrome"
} else if ua.contains("Safari/") {
"Safari"
} else {
"unknown"
};
let os = if ua.contains("iPhone") || ua.contains("iPad") || ua.contains("iPod") {
"iOS"
} else if ua.contains("Android") {
"Android"
} else if ua.contains("CrOS") {
"ChromeOS"
} else if ua.contains("Windows") {
"Windows"
} else if ua.contains("Mac OS X") || ua.contains("Macintosh") {
"macOS"
} else if ua.contains("Linux") {
"Linux"
} else {
"unknown"
};
(browser, os)
}
#[cfg(test)]
impl OobRuntime {
pub(crate) fn service_for_tests(&self, state: &AppState) -> &OobService {
self.service(state).expect("configured")
}
}
#[cfg(test)]
pub async fn maybe_handle(
state: &AppState,
ip: IpAddr,
headers: &HeaderMap,
body: &[u8],
) -> Option<axum::response::Response> {
let type_uri = serde_json::from_slice::<Value>(body)
.ok()?
.get("type")?
.as_str()?
.to_string();
if !matches!(
crate::control_tasks::proof_rule(&type_uri),
Some(did_hosting_common::server::trust_tasks::ProofRule::BoundKey(_))
) {
return None;
}
let response = crate::routes::trust_tasks::trust_tasks_endpoint(
None,
axum::extract::State(state.clone()),
Ok(axum::extract::ConnectInfo(std::net::SocketAddr::new(ip, 0))),
headers.clone(),
axum::body::Bytes::copy_from_slice(body),
)
.await;
Some(response.unwrap_or_else(axum::response::IntoResponse::into_response))
}