use uuid::Uuid;
use crate::server::acl::Role;
use crate::server::auth::jwt::JwtKeys;
use crate::server::error::AppError;
use crate::server::store::KeyspaceHandle;
use serde::Serialize;
use std::time::{SystemTime, UNIX_EPOCH};
use tracing::{debug, warn};
pub use ::vti_common::auth::session::{Session, SessionState};
fn session_key(session_id: &str) -> String {
format!("session:{session_id}")
}
fn refresh_key(token: &str) -> String {
format!("refresh:{token}")
}
pub async fn store_session(sessions: &KeyspaceHandle, session: &Session) -> Result<(), AppError> {
sessions
.insert(session_key(&session.session_id), session)
.await?;
debug!(session_id = %session.session_id, did = %session.did, "session stored");
Ok(())
}
pub async fn get_session(
sessions: &KeyspaceHandle,
session_id: &str,
) -> Result<Option<Session>, AppError> {
sessions.get(session_key(session_id)).await
}
pub async fn store_refresh_index(
sessions: &KeyspaceHandle,
token: &str,
session_id: &str,
) -> Result<(), AppError> {
sessions
.insert_raw(refresh_key(token), session_id.as_bytes().to_vec())
.await
}
pub async fn get_session_by_refresh(
sessions: &KeyspaceHandle,
token: &str,
) -> Result<Option<String>, AppError> {
match sessions.get_raw(refresh_key(token)).await? {
Some(bytes) => {
let session_id = String::from_utf8(bytes)
.map_err(|e| AppError::Internal(format!("invalid session_id bytes: {e}")))?;
Ok(Some(session_id))
}
None => Ok(None),
}
}
pub async fn take_session_id_by_refresh(
sessions: &KeyspaceHandle,
token: &str,
) -> Result<Option<String>, AppError> {
match sessions.take_raw(refresh_key(token)).await? {
Some(bytes) => {
let session_id = String::from_utf8(bytes)
.map_err(|e| AppError::Internal(format!("invalid session_id bytes: {e}")))?;
Ok(Some(session_id))
}
None => Ok(None),
}
}
pub fn now_epoch() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
pub async fn delete_session(sessions: &KeyspaceHandle, session_id: &str) -> Result<(), AppError> {
let session: Option<Session> = sessions.get(session_key(session_id)).await?;
if let Some(session) = session {
if let Some(ref token) = session.refresh_token {
sessions.remove(refresh_key(token)).await?;
}
sessions.remove(session_key(session_id)).await?;
debug!(session_id, "session deleted");
}
Ok(())
}
pub async fn cleanup_expired_sessions(
sessions: &KeyspaceHandle,
challenge_ttl: u64,
) -> Result<(), AppError> {
let entries = sessions.prefix_iter_raw("session:").await?;
let now = now_epoch();
let mut removed = 0u64;
for (key, value) in entries {
let session: Session = match serde_json::from_slice(&value) {
Ok(s) => s,
Err(e) => {
warn!("skipping malformed session record: {e}");
continue;
}
};
let expired = match session.state {
SessionState::ChallengeSent => now.saturating_sub(session.created_at) > challenge_ttl,
SessionState::Authenticated => session
.refresh_expires_at
.is_none_or(|expires| now > expires),
};
if expired {
sessions.remove(key).await?;
if let Some(ref token) = session.refresh_token {
sessions.remove(refresh_key(token)).await?;
}
removed += 1;
}
}
let enrollments = sessions.prefix_iter_raw("enroll:").await?;
for (key, value) in enrollments {
#[derive(serde::Deserialize)]
struct EnrollmentExpiry {
expires_at: u64,
}
if let Ok(e) = serde_json::from_slice::<EnrollmentExpiry>(&value)
&& now > e.expires_at
{
sessions.remove(key).await?;
removed += 1;
}
}
debug!(removed, "session cleanup complete");
Ok(())
}
#[derive(Debug, Serialize)]
pub struct TokenResponse {
pub session_id: String,
pub subject: String,
pub access_token: String,
pub access_expires_at: u64,
pub refresh_token: String,
pub refresh_expires_at: u64,
pub issued_at: u64,
pub amr: Vec<String>,
pub acr: String,
}
impl TokenResponse {
pub fn into_canonical(self) -> crate::AuthenticateResponse {
let session_expires_at_epoch = self.refresh_expires_at.max(self.access_expires_at);
crate::AuthenticateResponse {
session: crate::Session {
id: self.session_id,
subject: self.subject,
issued_at: crate::epoch_to_rfc3339(self.issued_at),
expires_at: crate::epoch_to_rfc3339(session_expires_at_epoch),
amr: self.amr,
acr: self.acr,
},
tokens: crate::TokenBundle {
access_token: self.access_token,
refresh_token: Some(self.refresh_token),
token_type: "Bearer".to_string(),
expires_in: self.access_expires_at.saturating_sub(self.issued_at),
refresh_expires_in: Some(self.refresh_expires_at.saturating_sub(self.issued_at)),
scope: Vec::new(),
},
}
}
}
fn generate_tokens(
jwt_keys: &JwtKeys,
did: &str,
session_id: &str,
role: &Role,
access_expiry: u64,
refresh_expiry: u64,
amr_acr_override: Option<(Vec<String>, String)>,
) -> Result<(String, u64, String, u64, String), AppError> {
let mut claims = JwtKeys::new_claims(
did.to_string(),
session_id.to_string(),
role.to_string(),
access_expiry,
);
if let Some((amr, acr)) = amr_acr_override {
claims.amr = amr;
claims.acr = acr;
}
let access_expires_at = claims.exp;
let token_id = claims.jti.clone();
let access_token = jwt_keys.encode(&claims)?;
let refresh_token = Uuid::new_v4().to_string();
let refresh_expires_at = now_epoch() + refresh_expiry;
Ok((
access_token,
access_expires_at,
refresh_token,
refresh_expires_at,
token_id,
))
}
pub async fn finalize_challenge_session(
sessions: &KeyspaceHandle,
jwt_keys: &JwtKeys,
session: &mut Session,
role: &Role,
access_expiry: u64,
refresh_expiry: u64,
) -> Result<TokenResponse, AppError> {
let (access_token, access_expires_at, refresh_token, refresh_expires_at, token_id) =
generate_tokens(
jwt_keys,
&session.did,
&session.session_id,
role,
access_expiry,
refresh_expiry,
None,
)?;
session.state = SessionState::Authenticated;
session.refresh_token = Some(refresh_token.clone());
session.refresh_expires_at = Some(refresh_expires_at);
session.token_id = Some(token_id);
session.amr = vec!["did".to_string()];
session.acr = "aal1".to_string();
store_session(sessions, session).await?;
store_refresh_index(sessions, &refresh_token, &session.session_id).await?;
Ok(TokenResponse {
session_id: session.session_id.clone(),
subject: session.did.clone(),
access_token,
access_expires_at,
refresh_token,
refresh_expires_at,
issued_at: now_epoch(),
amr: vec!["did".to_string()],
acr: "aal1".to_string(),
})
}
#[allow(clippy::too_many_arguments)]
pub async fn create_authenticated_session(
sessions: &KeyspaceHandle,
jwt_keys: &JwtKeys,
did: &str,
role: &Role,
access_expiry: u64,
refresh_expiry: u64,
session_pubkey_b58btc: Option<String>,
amr_acr_override: Option<(Vec<String>, String)>,
) -> Result<TokenResponse, AppError> {
let session_id = Uuid::new_v4().to_string();
let (access_token, access_expires_at, refresh_token, refresh_expires_at, token_id) =
generate_tokens(
jwt_keys,
did,
&session_id,
role,
access_expiry,
refresh_expiry,
amr_acr_override.clone(),
)?;
let (amr, acr) =
amr_acr_override.unwrap_or_else(|| (vec!["did".to_string()], "aal1".to_string()));
let session = Session {
session_id: session_id.clone(),
did: did.to_string(),
challenge: String::new(),
state: SessionState::Authenticated,
created_at: now_epoch(),
last_seen: now_epoch(),
refresh_token: Some(refresh_token.clone()),
refresh_expires_at: Some(refresh_expires_at),
tee_attested: false,
token_id: Some(token_id),
session_pubkey_b58btc,
amr: amr.clone(),
acr: acr.clone(),
acr_expires_at: None,
};
store_session(sessions, &session).await?;
store_refresh_index(sessions, &refresh_token, &session_id).await?;
Ok(TokenResponse {
session_id,
subject: did.to_string(),
access_token,
access_expires_at,
refresh_token,
refresh_expires_at,
issued_at: now_epoch(),
amr,
acr,
})
}
#[allow(clippy::too_many_arguments)]
pub async fn elevate_session(
sessions: &KeyspaceHandle,
jwt_keys: &JwtKeys,
session_id: &str,
role: &Role,
amr: Vec<String>,
acr: &str,
access_expiry: u64,
refresh_expiry: u64,
) -> Result<TokenResponse, AppError> {
let mut session = get_session(sessions, session_id)
.await?
.ok_or_else(|| AppError::Authentication("session not found".into()))?;
if session.state != SessionState::Authenticated {
return Err(AppError::Authentication("session not authenticated".into()));
}
let mut claims = JwtKeys::new_claims(
session.did.clone(),
session_id.to_string(),
role.to_string(),
access_expiry,
);
claims.amr = amr.clone();
claims.acr = acr.to_string();
let access_expires_at = claims.exp;
let token_id = claims.jti.clone();
let access_token = jwt_keys.encode(&claims)?;
let refresh_token = Uuid::new_v4().to_string();
let refresh_expires_at = now_epoch() + refresh_expiry;
session.token_id = Some(token_id);
session.refresh_token = Some(refresh_token.clone());
session.refresh_expires_at = Some(refresh_expires_at);
session.amr = amr.clone();
session.acr = acr.to_string();
store_session(sessions, &session).await?;
store_refresh_index(sessions, &refresh_token, session_id).await?;
Ok(TokenResponse {
session_id: session_id.to_string(),
subject: session.did.clone(),
access_token,
access_expires_at,
refresh_token,
refresh_expires_at,
issued_at: now_epoch(),
amr,
acr: acr.to_string(),
})
}
#[cfg(all(test, feature = "store-fjall"))]
mod refresh_token_invariant {
use super::*;
use crate::server::config::StoreConfig;
use crate::server::store::{KS_SESSIONS, Store};
use std::path::PathBuf;
async fn make_ks() -> (KeyspaceHandle, JwtKeys, tempfile::TempDir) {
let dir = tempfile::tempdir().unwrap();
let store = Store::open(&StoreConfig {
data_dir: PathBuf::from(dir.path()),
..StoreConfig::default()
})
.await
.unwrap();
let ks = store.keyspace(KS_SESSIONS).unwrap();
let keys = JwtKeys::from_ed25519_bytes(&[11u8; 32]).unwrap();
(ks, keys, dir)
}
#[tokio::test]
async fn sessions_we_create_carry_a_refresh_token() {
let (ks, keys, _dir) = make_ks().await;
let created = create_authenticated_session(
&ks,
&keys,
"did:example:alice",
&Role::Owner,
60,
900,
None,
None,
)
.await
.expect("create session");
let stored = get_session(&ks, &created.session_id)
.await
.expect("load")
.expect("session exists");
assert!(
stored.refresh_token.is_some() && stored.refresh_expires_at.is_some(),
"create_authenticated_session must leave a refresh token: cleanup_expired_sessions \
reaps refresh-less Authenticated sessions on the next pass"
);
elevate_session(
&ks,
&keys,
&created.session_id,
&Role::Owner,
vec!["did".to_string(), "passkey".to_string()],
"aal2",
60,
900,
)
.await
.expect("elevate");
let elevated = get_session(&ks, &created.session_id)
.await
.expect("load")
.expect("session exists");
assert!(
elevated.refresh_token.is_some() && elevated.refresh_expires_at.is_some(),
"elevate_session must leave a refresh token on the row"
);
}
#[tokio::test]
async fn sweeper_reaps_a_refreshless_authenticated_session_immediately() {
let (ks, _keys, _dir) = make_ks().await;
let intrinsic = Session {
session_id: "did:example:tsp-caller".to_string(),
did: "did:example:tsp-caller".to_string(),
challenge: String::new(),
state: SessionState::Authenticated,
created_at: now_epoch(),
last_seen: now_epoch(),
refresh_token: None,
refresh_expires_at: None,
tee_attested: false,
token_id: None,
session_pubkey_b58btc: None,
amr: vec!["did".to_string()],
acr: "aal1".to_string(),
acr_expires_at: None,
};
store_session(&ks, &intrinsic).await.expect("store");
cleanup_expired_sessions(&ks, 300).await.expect("sweep");
assert!(
get_session(&ks, &intrinsic.session_id)
.await
.expect("load")
.is_none(),
"a refresh-less Authenticated session is reaped on the next sweep, however recently \
it was seen — teach cleanup_expired_sessions about last_seen before creating one"
);
}
}