use super::store::{RefreshTokenStore, RotationOutcome};
use super::types::*;
use crate::authn::ids::{DeviceId, TenantId, UserId};
use crate::session::data::{AuthState, SessionData};
use axess_rng::SecureRng;
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use chrono::{DateTime, Utc};
use sha2::{Digest, Sha256};
use subtle::ConstantTimeEq;
pub(super) fn hash_token(plaintext: &str, pepper: Option<&[u8]>) -> String {
match pepper {
Some(pep) if !pep.is_empty() => {
use hmac::Mac;
let mut mac = crate::hmac::new_signer(pep);
mac.update(plaintext.as_bytes());
URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes())
}
_ => {
let digest = Sha256::digest(plaintext.as_bytes());
URL_SAFE_NO_PAD.encode(digest)
}
}
}
pub(super) fn generate_token_value(rng: &impl SecureRng) -> String {
let mut bytes = [0u8; 32];
rng.fill_bytes(&mut bytes);
URL_SAFE_NO_PAD.encode(bytes)
}
pub struct IssueRequest<'a> {
pub user_id: &'a UserId,
pub tenant_id: &'a TenantId,
pub device_info: Option<String>,
pub family_id: Option<TokenFamilyId>,
pub device_id: Option<DeviceId>,
}
pub async fn issue_refresh_token<S: RefreshTokenStore>(
req: IssueRequest<'_>,
config: &RefreshTokenConfig,
store: &S,
rng: &impl SecureRng,
now: DateTime<Utc>,
) -> Result<(String, RefreshToken), RefreshError<S::Error>> {
let active = store
.active_tokens(req.user_id)
.await
.map_err(RefreshError::Store)?;
let evict_ids: Vec<RefreshTokenId> = if active.len() >= config.max_per_user {
let to_revoke = active.len() - config.max_per_user + 1;
active.iter().take(to_revoke).map(|t| t.id).collect()
} else {
Vec::new()
};
let (plaintext, record) = build_refresh_token(req, config, rng, now, None);
store
.issue_with_eviction(&evict_ids, &record)
.await
.map_err(RefreshError::Store)?;
Ok((plaintext, record))
}
pub async fn refresh_session<S: RefreshTokenStore>(
plaintext: &str,
store: &S,
config: &RefreshTokenConfig,
rng: &impl SecureRng,
now: DateTime<Utc>,
device_info: Option<&str>,
) -> Result<(SessionData, Option<(String, RefreshToken)>), RefreshError<S::Error>> {
let token_hash = hash_token(plaintext, config.pepper());
let record = store
.find_token(&token_hash)
.await
.map_err(RefreshError::Store)?
.ok_or(RefreshError::NotFound)?;
refresh_session_from_record(record, store, config, rng, now, device_info).await
}
async fn refresh_session_from_record<S: RefreshTokenStore>(
record: RefreshToken,
store: &S,
config: &RefreshTokenConfig,
rng: &impl SecureRng,
now: DateTime<Utc>,
device_info: Option<&str>,
) -> Result<(SessionData, Option<(String, RefreshToken)>), RefreshError<S::Error>> {
if record.revoked {
if let Some(ref fid) = record.family_id {
tracing::warn!(
family_id = %fid,
user_id = %record.user_id,
"revoked refresh token reused; revoking entire family (compromise signal)"
);
let compromised_devices = collect_family_device_targets(store, fid, &record).await;
store
.revoke_family(&record.user_id, fid)
.await
.map_err(RefreshError::Store)?;
store
.on_token_compromise(&record.user_id, fid, &compromised_devices)
.await;
}
return Err(RefreshError::Revoked);
}
if now >= record.expires_at {
return Err(RefreshError::Expired);
}
if let Some(ref stored_device) = record.device_info {
let Some(current) = device_info else {
return Err(RefreshError::DeviceMismatch);
};
let stored_hash = Sha256::digest(stored_device.as_bytes());
let current_hash = Sha256::digest(current.as_bytes());
if !bool::from(stored_hash.ct_eq(¤t_hash)) {
return Err(RefreshError::DeviceMismatch);
}
}
let session = SessionData {
version: crate::session::data::SESSION_DATA_VERSION,
auth_state: AuthState::Authenticated {
user_id: record.user_id,
tenant_id: record.tenant_id,
authn_time: record.authenticated_at(),
factors_completed: Vec::new(),
},
fingerprint: None,
device_id: record.device_id,
custom: serde_json::Value::default(),
};
let new_token = if config.rotation {
let (new_plaintext, new_record) = build_refresh_token(
IssueRequest {
user_id: &record.user_id,
tenant_id: &record.tenant_id,
device_info: record.device_info.clone(),
family_id: record.family_id, device_id: record.device_id, },
config,
rng,
now,
Some(record.authenticated_at()),
);
match store
.rotate_token(&record.id, &new_record)
.await
.map_err(RefreshError::Store)?
{
RotationOutcome::Rotated => {}
RotationOutcome::ParentAlreadyRevoked => {
if let Some(ref fid) = record.family_id {
tracing::warn!(
family_id = %fid,
user_id = %record.user_id,
"refresh token rotated concurrently; revoking entire family (compromise signal)"
);
let compromised_devices =
collect_family_device_targets(store, fid, &record).await;
store
.revoke_family(&record.user_id, fid)
.await
.map_err(RefreshError::Store)?;
store
.on_token_compromise(&record.user_id, fid, &compromised_devices)
.await;
}
return Err(RefreshError::Revoked);
}
}
Some((new_plaintext, new_record))
} else {
None
};
Ok((session, new_token))
}
pub async fn refresh_session_with_status_check<S, FStat, Fut>(
plaintext: &str,
store: &S,
config: &RefreshTokenConfig,
rng: &impl SecureRng,
now: DateTime<Utc>,
device_info: Option<&str>,
status_check: FStat,
) -> Result<(SessionData, Option<(String, RefreshToken)>), RefreshError<S::Error>>
where
S: RefreshTokenStore,
FStat: FnOnce(&UserId) -> Fut,
Fut: std::future::Future<Output = bool>,
{
let token_hash = hash_token(plaintext, config.pepper());
let record = store
.find_token(&token_hash)
.await
.map_err(RefreshError::Store)?
.ok_or(RefreshError::NotFound)?;
if record.revoked {
return refresh_session_from_record(record, store, config, rng, now, device_info).await;
}
if now >= record.expires_at {
return Err(RefreshError::Expired);
}
if !status_check(&record.user_id).await {
tracing::warn!(
user_id = %record.user_id,
"refresh refused; caller-supplied status check returned false"
);
return Err(RefreshError::AccountInactive);
}
refresh_session_from_record(record, store, config, rng, now, device_info).await
}
pub(super) async fn collect_family_device_targets<S: RefreshTokenStore>(
store: &S,
family_id: &TokenFamilyId,
seen_record: &RefreshToken,
) -> Vec<(TenantId, DeviceId)> {
let mut targets: Vec<(TenantId, DeviceId)> = Vec::new();
if let Some(did) = seen_record.device_id.as_ref() {
targets.push((seen_record.tenant_id, *did));
}
match store.active_tokens(&seen_record.user_id).await {
Ok(active) => {
for token in active {
if token.family_id.as_ref() != Some(family_id) {
continue;
}
let Some(did) = token.device_id else { continue };
let pair = (token.tenant_id, did);
if !targets.contains(&pair) {
targets.push(pair);
}
}
}
Err(e) => {
tracing::warn!(
error = %e,
family_id = %family_id,
user_id = %seen_record.user_id,
"failed to enumerate active siblings for device cascade; \
family revocation will proceed with the seen-record device only"
);
}
}
targets
}
pub(super) fn build_refresh_token(
req: IssueRequest<'_>,
config: &RefreshTokenConfig,
rng: &impl SecureRng,
now: DateTime<Utc>,
authn_time: Option<DateTime<Utc>>,
) -> (String, RefreshToken) {
let plaintext = generate_token_value(rng);
let token_hash = hash_token(&plaintext, config.pepper());
let mut id_bytes = [0u8; 16];
rng.fill_bytes(&mut id_bytes);
let id = RefreshTokenId::try_new(uuid::Uuid::from_bytes(id_bytes).to_string())
.expect("UUID is non-empty");
let effective_family = req.family_id.unwrap_or_else(|| {
TokenFamilyId::try_new(id.to_string()).expect("UUID-derived family id is non-empty")
});
let record = RefreshToken {
id,
user_id: *req.user_id,
tenant_id: *req.tenant_id,
token_hash,
issued_at: now,
expires_at: now + config.ttl,
revoked: false,
device_info: req.device_info,
family_id: Some(effective_family),
device_id: req.device_id,
authn_time,
};
(plaintext, record)
}
pub async fn revoke_refresh_token<S: RefreshTokenStore>(
plaintext: &str,
store: &S,
config: &RefreshTokenConfig,
) -> Result<(), RefreshError<S::Error>> {
let token_hash = hash_token(plaintext, config.pepper());
let record = store
.find_token(&token_hash)
.await
.map_err(RefreshError::Store)?
.ok_or(RefreshError::NotFound)?;
store
.revoke_token(&record.id)
.await
.map_err(RefreshError::Store)?;
Ok(())
}