use axum::extract::FromRequestParts;
use axum::http::request::Parts;
use tracing::warn;
use vti_common::auth::session::{Session, SessionState, get_session, now_epoch, touch_last_seen};
use super::{ActiveMember, MEMBER_SESSION_COOKIE, member_jwt_keys, require_active_member};
use crate::error::AppError;
use crate::server::AppState;
#[derive(Debug, Clone)]
pub struct MemberAuth {
pub did: String,
pub session_id: String,
pub access_expires_at: u64,
pub amr: Vec<String>,
pub acr: String,
pub member: ActiveMember,
}
impl MemberAuth {
pub fn proved_did(&self) -> bool {
self.amr.iter().any(|f| f == "did")
}
pub fn require_proved_did(&self) -> Result<(), AppError> {
if self.proved_did() {
Ok(())
} else {
Err(AppError::Forbidden(
"sign in with your wallet to change your passkeys".into(),
))
}
}
}
fn presented_token(parts: &Parts) -> Option<(String, bool)> {
let bearer = parts
.headers
.get(axum::http::header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.map(|t| (t.trim().to_string(), false));
bearer.or_else(|| {
crate::member_portal::cookies::cookie_value(&parts.headers, MEMBER_SESSION_COOKIE)
.map(|t| (t, true))
})
}
pub(crate) async fn authenticate_member_token(
state: &AppState,
token: &str,
) -> Result<(vti_common::auth::jwt::Claims, Session), AppError> {
let claims = member_jwt_keys(state)?.decode(token)?;
let session = get_session(&state.member_sessions_ks, &claims.session_id)
.await?
.ok_or_else(|| AppError::Unauthorized("session not found".into()))?;
if session.state != SessionState::Authenticated {
return Err(AppError::Unauthorized("session not authenticated".into()));
}
if session.did != claims.sub {
return Err(AppError::Unauthorized("session subject mismatch".into()));
}
if let Some(pinned) = &session.token_id
&& claims.jti != *pinned
{
return Err(AppError::Unauthorized("token superseded".into()));
}
Ok((claims, session))
}
impl FromRequestParts<AppState> for MemberAuth {
type Rejection = AppError;
async fn from_request_parts(
parts: &mut Parts,
state: &AppState,
) -> Result<Self, Self::Rejection> {
let Some((token, from_cookie)) = presented_token(parts) else {
return Err(AppError::Unauthorized("not signed in".into()));
};
let (claims, session) = authenticate_member_token(state, &token).await?;
let member = require_active_member(state, &claims.sub).await?;
if from_cookie
&& let Err(e) = touch_last_seen(&state.member_sessions_ks, &session, now_epoch()).await
{
warn!(session_id = %session.session_id, error = %e, "failed to record member session activity");
}
Ok(Self {
did: claims.sub,
session_id: claims.session_id,
access_expires_at: claims.exp,
amr: claims.amr,
acr: claims.acr,
member,
})
}
}