use std::sync::Arc;
use axum_extra::extract::PrivateCookieJar;
use super::session::{SessionResolution, SessionResolver};
use super::traits::{SessionStore, SvAware};
use crate::pas_port::{CipherFailure, PasAuthPort, PasRefreshOutcome, pas_refresh};
use crate::session_liveness::TokenCipher;
use crate::session_version::{SV_CACHE_KEY_PREFIX, SV_CACHE_TTL, SessionVersionCache};
use crate::types::SessionId;
pub struct SvAwareSessionResolver<S, C, P>
where
S: SessionStore,
C: SessionVersionCache,
P: PasAuthPort,
{
base: SessionResolver<S>,
store: Arc<S>,
pas: Arc<P>,
cache: Arc<C>,
cipher: Option<Arc<TokenCipher>>,
}
impl<S, C, P> Clone for SvAwareSessionResolver<S, C, P>
where
S: SessionStore,
C: SessionVersionCache,
P: PasAuthPort,
{
fn clone(&self) -> Self {
Self {
base: self.base.clone(),
store: Arc::clone(&self.store),
pas: Arc::clone(&self.pas),
cache: Arc::clone(&self.cache),
cipher: self.cipher.as_ref().map(Arc::clone),
}
}
}
impl<S, C, P> SvAwareSessionResolver<S, C, P>
where
S: SessionStore,
C: SessionVersionCache,
P: PasAuthPort,
{
pub fn new(
base: SessionResolver<S>,
store: Arc<S>,
pas: Arc<P>,
cache: Arc<C>,
cipher: Option<Arc<TokenCipher>>,
) -> Self {
Self { base, store, pas, cache, cipher }
}
pub async fn resolve(
&self,
jar: &PrivateCookieJar,
) -> Result<SessionResolution<S::AuthContext>, S::Error> {
let resolution = self.base.resolve(jar).await?;
let session = match resolution {
SessionResolution::Authenticated(s) => s,
other => return Ok(other),
};
let Some(token_sv) = session.sv() else {
return Ok(SessionResolution::Authenticated(session));
};
let cache_key = format!("{SV_CACHE_KEY_PREFIX}{}", session.ppnum_id());
match self.cache.get(&cache_key).await {
Some(cached_sv) if token_sv >= cached_sv => {
Ok(SessionResolution::Authenticated(session))
}
_ => self.refresh_and_recheck(jar, &cache_key).await,
}
}
async fn refresh_and_recheck(
&self,
jar: &PrivateCookieJar,
cache_key: &str,
) -> Result<SessionResolution<S::AuthContext>, S::Error> {
let Some(cookie) = jar.get(self.base.cookie_name()) else {
return Ok(SessionResolution::Expired);
};
let session_id = SessionId(cookie.value().to_string());
let ct = match self.store.get_refresh_ciphertext(&session_id).await {
Ok(Some(ct)) => ct,
Ok(None) => {
tracing::debug!(
session_id = %session_id,
"no refresh-token ciphertext available — surface as Expired"
);
return Ok(SessionResolution::Expired);
}
Err(e) => {
tracing::warn!(
session_id = %session_id,
error = %e,
"ciphertext lookup failed — surface as Expired"
);
return Ok(SessionResolution::Expired);
}
};
let cipher = match self.cipher.as_ref() {
Some(c) => c.as_ref(),
None => {
tracing::error!(
session_id = %session_id,
"session ciphertext present but no TokenCipher configured \
(PasAuthConfig::with_refresh_token_cipher missing) — Expired"
);
return Ok(SessionResolution::Expired);
}
};
let token_response = match pas_refresh(cipher, &*self.pas, &ct).await {
Ok(PasRefreshOutcome::Refreshed { tokens }) => tokens,
Ok(PasRefreshOutcome::Rejected { status, detail }) => {
tracing::info!(
session_id = %session_id,
status = status,
detail = %detail,
"PAS refresh rejected — Expired (likely revoked)"
);
return Ok(SessionResolution::Expired);
}
Ok(PasRefreshOutcome::Transient { detail }) => {
tracing::warn!(
session_id = %session_id,
detail = %detail,
"PAS refresh failed (transient) — Expired (S-L6 fail-closed)"
);
return Ok(SessionResolution::Expired);
}
Err(CipherFailure) => {
tracing::error!(
session_id = %session_id,
"ciphertext decrypt failed — Expired (S-L6 fail-closed; stored ciphertext unrecoverable)"
);
return Ok(SessionResolution::Expired);
}
};
let new_sv = match self.pas.userinfo(&token_response.access_token).await {
Ok(info) => match info.session_version {
Some(sv) => sv,
None => {
tracing::error!(
session_id = %session_id,
"Human session refreshed but userinfo.session_version=None — Expired"
);
return Ok(SessionResolution::Expired);
}
},
Err(e) => {
use crate::pas_port::PasFailure;
match &e {
PasFailure::Rejected { .. } => tracing::info!(
session_id = %session_id,
error = ?e,
"post-refresh userinfo rejected — Expired (revoked between /token and /userinfo)"
),
PasFailure::ServerError { .. } | PasFailure::Transport { .. } => {
tracing::warn!(
session_id = %session_id,
error = ?e,
"post-refresh userinfo failed (transient) — Expired (S-L6 fail-closed)"
)
}
}
return Ok(SessionResolution::Expired);
}
};
if let Err(e) = self.store.update_sv(&session_id, new_sv).await {
tracing::warn!(
session_id = %session_id,
new_sv = new_sv,
error = %e,
"update_sv failed after refresh — Expired",
);
return Ok(SessionResolution::Expired);
}
self.cache.set(cache_key, new_sv, SV_CACHE_TTL).await;
match self.store.find(&session_id).await? {
Some(refreshed) => Ok(SessionResolution::Authenticated(refreshed)),
None => Ok(SessionResolution::Expired),
}
}
}