use std::sync::Arc;
use super::core::{
CiphertextFeed, PasRefreshFeed, PersistFeed, RefetchFeed, SvCore, SvDecision, SvStep,
UserinfoFeed, classify_userinfo,
};
use super::super::session::SessionResolution;
use super::super::sv_cache::{CheckResult, SV_CACHE_TTL, SvCachePort};
use super::super::traits::SessionStore;
use crate::pas_port::{PasAuthPort, PasRefreshOutcome, pas_refresh};
use crate::session_liveness::TokenCipher;
use crate::types::SessionId;
pub(crate) struct SvDriverPorts<'a, S, P, B>
where
S: SessionStore,
P: PasAuthPort,
B: SvCachePort,
{
pub store: &'a Arc<S>,
pub pas: &'a Arc<P>,
pub cache: &'a Arc<B>,
pub cipher: Option<&'a TokenCipher>,
}
fn classify_cache(token_sv: i64, cached: Option<i64>) -> CheckResult {
match cached {
Some(cached_sv) if token_sv >= cached_sv => CheckResult::Fresh,
Some(_) => CheckResult::Stale,
None => CheckResult::Unknown,
}
}
fn cache_key(ppnum_id: &str) -> String {
ppoppo_token::sv_cache_key(ppnum_id)
}
pub(crate) async fn drive<S, P, B>(
ports: SvDriverPorts<'_, S, P, B>,
session_id: SessionId,
ppnum_id: String,
token_sv: i64,
base_session: S::AuthContext,
) -> Result<SessionResolution<S::AuthContext>, S::Error>
where
S: SessionStore,
P: PasAuthPort,
B: SvCachePort,
{
let mut refreshed_ctx: Option<S::AuthContext> = None;
let (mut core, mut step) = SvCore::start();
loop {
step = match step {
SvStep::QueryCache => {
let cached = ports.cache.load(&cache_key(&ppnum_id)).await;
let result = classify_cache(token_sv, cached);
if !matches!(result, CheckResult::Fresh) {
tracing::debug!(
session_id = %session_id,
sv_cache_outcome = ?result,
"sv refresh entered (cache outcome that triggered the path)"
);
}
core.feed_check(result)
}
SvStep::LoadCiphertext => {
let ct_outcome = match ports.store.get_refresh_ciphertext(&session_id).await {
Ok(Some(ct)) => match ports.cipher {
Some(_) => CiphertextFeed::Available { ciphertext: ct },
None => {
tracing::error!(
session_id = %session_id,
"session ciphertext present but no TokenCipher configured \
(PasAuthConfig::with_refresh_token_cipher missing) — Expired"
);
CiphertextFeed::NoCipherConfigured
}
},
Ok(None) => {
tracing::debug!(
session_id = %session_id,
"no refresh-token ciphertext available — surface as Expired"
);
CiphertextFeed::Absent
}
Err(e) => {
tracing::warn!(
session_id = %session_id,
error = %e,
"ciphertext lookup failed — surface as Expired"
);
CiphertextFeed::LookupFailed
}
};
core.feed_ciphertext(ct_outcome)
}
SvStep::PasRefresh { ciphertext } => {
let Some(cipher) = ports.cipher else {
tracing::error!(
session_id = %session_id,
"internal invariant violated: PasRefresh reached with cipher=None — Expired"
);
return Ok(SessionResolution::Expired);
};
let feed = match pas_refresh(cipher, &**ports.pas, &ciphertext).await {
Ok(PasRefreshOutcome::Refreshed { tokens }) => {
PasRefreshFeed::Refreshed { access_token: tokens.access_token }
}
Ok(PasRefreshOutcome::Rejected { status, detail }) => {
tracing::info!(
session_id = %session_id,
status = status,
detail = %detail,
"PAS refresh rejected — Expired (likely revoked)"
);
PasRefreshFeed::Rejected
}
Ok(PasRefreshOutcome::Transient { detail }) => {
tracing::warn!(
session_id = %session_id,
detail = %detail,
"PAS refresh failed (transient) — Expired (S-L6 fail-closed)"
);
PasRefreshFeed::Transient
}
Err(_cipher_failure) => {
tracing::error!(
session_id = %session_id,
"ciphertext decrypt failed — Expired (S-L6 fail-closed; \
stored ciphertext unrecoverable)"
);
PasRefreshFeed::CipherFailed
}
};
core.feed_pas_refresh(feed)
}
SvStep::UserInfo { access_token } => {
let feed = match ports.pas.userinfo(&access_token).await {
Ok(info) => match info.session_version {
Some(sv) => UserinfoFeed::Ok { new_sv: sv },
None => {
tracing::error!(
session_id = %session_id,
"Human session refreshed but \
userinfo.session_version=None — Expired"
);
UserinfoFeed::MissingSv
}
},
Err(ref e) => {
let cls = classify_userinfo(e);
match &cls {
UserinfoFeed::Rejected => tracing::info!(
session_id = %session_id,
error = ?e,
"post-refresh userinfo rejected — Expired \
(revoked between /token and /userinfo)"
),
UserinfoFeed::Transient => tracing::warn!(
session_id = %session_id,
error = ?e,
"post-refresh userinfo failed (transient) — \
Expired (S-L6 fail-closed)"
),
_ => {}
}
cls
}
};
core.feed_userinfo(feed)
}
SvStep::PersistSv { new_sv } => {
let feed = match ports.store.update_sv(&session_id, new_sv).await {
Ok(()) => PersistFeed::Ok,
Err(e) => {
tracing::warn!(
session_id = %session_id,
new_sv = new_sv,
error = %e,
"update_sv failed after refresh — Expired",
);
PersistFeed::Failed
}
};
core.feed_persist(feed)
}
SvStep::RecordCache { sv } => {
ports.cache.store(&cache_key(&ppnum_id), sv, SV_CACHE_TTL).await;
core.feed_record()
}
SvStep::ReFetch => {
let feed = match ports.store.find(&session_id).await? {
Some(ctx) => {
refreshed_ctx = Some(ctx);
RefetchFeed::Found
}
None => RefetchFeed::Missing,
};
core.feed_refetch(feed)
}
SvStep::Done(decision) => {
return Ok(fold_decision(&session_id, decision, base_session, refreshed_ctx));
}
};
}
}
fn fold_decision<A>(
session_id: &SessionId,
decision: SvDecision,
base_session: A,
refreshed: Option<A>,
) -> SessionResolution<A> {
match decision {
SvDecision::FreshFromCache => SessionResolution::Authenticated(base_session),
SvDecision::Refreshed => match refreshed {
Some(ctx) => SessionResolution::Authenticated(ctx),
None => {
tracing::error!(
session_id = %session_id,
"internal invariant violated: SvCore emitted Refreshed without refreshed_ctx — Expired"
);
SessionResolution::Expired
}
},
SvDecision::Expired(_cause) => SessionResolution::Expired,
}
}
#[cfg(test)]
mod tests {
use super::{CheckResult, cache_key, classify_cache};
#[test]
fn cache_key_uses_canonical_namespace() {
assert_eq!(cache_key("01HXYZ"), "sv:01HXYZ");
}
#[test]
fn classify_fresh_when_token_sv_ge_cached() {
assert_eq!(classify_cache(5, Some(5)), CheckResult::Fresh);
assert_eq!(classify_cache(6, Some(5)), CheckResult::Fresh);
}
#[test]
fn classify_stale_when_token_sv_lt_cached() {
assert_eq!(classify_cache(9, Some(10)), CheckResult::Stale);
}
#[test]
fn classify_unknown_on_miss() {
assert_eq!(classify_cache(1, None), CheckResult::Unknown);
}
}