mod memory;
mod model;
pub use memory::{MemorySessionStore, SessionCommit, SessionPrune, SessionPruned};
pub use model::{Replay, Session, SessionLimits, SessionPolicy};
use crate::{
admission_budget::{AdmissionBudgetError, SessionAdmissionBudget},
canonical::CanonicalAuthError,
portable::{
DecodedPortableClaims, LoginRevocationChange, LoginRevocationError, MemoryLoginRevocations,
PortableClaimsContext, PortableClaimsError, PortableEncodingError, VerifiedPortableClaims,
validate_claims_context,
},
session_request::{SessionRequest, SessionRequestError},
};
use ic_auth_protocol_types::{AuthRole, LogoutResult, LogoutUnavailable, Principal};
use thiserror::Error;
pub struct SessionContext<'a> {
pub authority: PortableClaimsContext<'a>,
pub role: &'a AuthRole,
pub required_scopes: &'a [String],
}
impl SessionContext<'_> {
fn validate(
&self,
policy: SessionPolicy,
ledger: &MemoryLoginRevocations,
) -> Result<(), SessionError> {
policy.validate()?;
if !policy.enabled || !policy.subject_admissible {
return Err(SessionError::Disabled);
}
if ledger.audience() != *self.authority.audience
|| self.authority.issuer == Principal::anonymous()
|| self.authority.authority_not_before_ns >= self.authority.authority_expires_at_ns
|| crate::certificate_issuance::validate_grants(
self.authority.grants,
crate::limits::MAX_GRANTS,
crate::limits::MAX_SCOPES_PER_GRANT,
)
.is_err()
|| self.required_scopes.len() > crate::limits::MAX_SESSION_SCOPES
{
return Err(SessionError::InvalidPolicyOrRecord);
}
for scope in self.required_scopes {
crate::canonical::validate_scope_label(scope)?;
}
Ok(())
}
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct SessionOccupancy {
pub sessions: usize,
pub replays: usize,
pub replays_for_caller: usize,
}
pub trait SessionTransaction {
fn generation(&self) -> u64;
fn limits(&self) -> SessionLimits;
fn session(&self, caller: Principal) -> Result<Option<Session>, SessionError>;
fn replay_exists(&self, fingerprint: [u8; 32]) -> Result<bool, SessionError>;
fn occupancy(&self, caller: Principal) -> Result<SessionOccupancy, SessionError>;
fn stage(&mut self, session: Session, replay: Replay) -> Result<(), SessionError>;
fn remove(&mut self, caller: Principal) -> Result<(), SessionError>;
}
pub trait SessionStore {
fn transaction<T>(
&mut self,
login_change: Option<&LoginRevocationChange>,
operation: impl FnOnce(&mut dyn SessionTransaction) -> Result<T, SessionError>,
) -> Result<T, SessionError>;
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum SessionEstablished {
Created(Session),
Replaced(Session),
ExactRetry(Session),
}
#[derive(Debug, Error, Eq, PartialEq)]
pub enum SessionError {
#[error("fresh session verification budget is exhausted")]
AdmissionThrottled,
#[error("session admission clock moved backwards")]
AdmissionClockRollback,
#[error("session capability is disabled or subject is inadmissible")]
Disabled,
#[error("session policy or stored record is invalid")]
InvalidPolicyOrRecord,
#[error("session authority is no longer current")]
StaleAuthority,
#[error("session is missing or expired")]
MissingOrExpired,
#[error("proof was already consumed or retry request differs")]
ReplayConflict,
#[error("requested or required scope is not granted")]
ScopeRejected,
#[error("session/replay capacity is exhausted")]
Capacity,
#[error("encoded record budget is exhausted")]
ByteCapacity,
#[error("authority generation is exhausted")]
GenerationExhausted,
#[error("host transaction could not commit")]
StorageFailure,
#[error(transparent)]
Canonical(#[from] CanonicalAuthError),
#[cfg(any(
feature = "portable-signature-verification",
feature = "certified-proof-verification"
))]
#[error(transparent)]
Credential(#[from] crate::portable_credential::PortableCredentialError),
#[error(transparent)]
Claims(#[from] PortableClaimsError),
#[error(transparent)]
Encoding(#[from] PortableEncodingError),
#[error(transparent)]
Revocation(#[from] LoginRevocationError),
#[cfg(feature = "portable-signature-verification")]
#[error(transparent)]
Signature(#[from] crate::portable_signature::PortableSignatureError),
#[cfg(feature = "certified-proof-verification")]
#[error(transparent)]
Certified(#[from] crate::portable_certified::PortableCertifiedError),
}
impl From<AdmissionBudgetError> for SessionError {
fn from(error: AdmissionBudgetError) -> Self {
match error {
AdmissionBudgetError::InvalidPolicy => Self::InvalidPolicyOrRecord,
AdmissionBudgetError::Exhausted => Self::AdmissionThrottled,
AdmissionBudgetError::ClockRollback => Self::AdmissionClockRollback,
}
}
}
impl From<SessionRequestError> for SessionError {
fn from(error: SessionRequestError) -> Self {
match error {
SessionRequestError::InvalidRequest | SessionRequestError::InvalidLifetime => {
Self::InvalidPolicyOrRecord
}
SessionRequestError::ScopeRejected => Self::ScopeRejected,
SessionRequestError::Canonical(error) => Self::Canonical(error),
}
}
}
#[allow(
clippy::too_many_arguments,
reason = "Keep independently host-owned state and protected inputs explicit at the admission boundary"
)]
pub fn establish_session(
store: &mut impl SessionStore,
ledger: &mut MemoryLoginRevocations,
budget: &mut SessionAdmissionBudget,
encoded_claims: &[u8],
request: &SessionRequest,
context: &SessionContext<'_>,
policy: SessionPolicy,
verify: impl FnOnce(
&mut SessionAdmissionBudget,
DecodedPortableClaims,
) -> Result<VerifiedPortableClaims, SessionError>,
) -> Result<SessionEstablished, SessionError> {
context.validate(policy, ledger)?;
let authority = &context.authority;
let decoded = DecodedPortableClaims::decode(encoded_claims)?;
let claims = &decoded.claims;
let fingerprint = decoded.fingerprint;
if ledger.is_revoked(authority.caller, authority.now_ns)? {
return Err(LoginRevocationError::Revoked.into());
}
let retry = store.transaction(None, |tx| {
if tx.generation() != policy.generation {
return Err(SessionError::StaleAuthority);
}
let previous = tx.session(authority.caller)?;
if let Some(session) = &previous
&& session.fingerprint() == fingerprint
{
if session.request_hash() != request.hash() {
return Err(SessionError::ReplayConflict);
}
session.check_live(context, policy)?;
session.check_revocation(ledger, authority.now_ns)?;
return Ok(Some(session.clone()));
}
if tx.replay_exists(fingerprint)? {
return Err(SessionError::ReplayConflict);
}
validate_claims_context(claims, authority)?;
let grant = claims
.grants
.iter()
.find(|grant| &grant.target == context.role)
.ok_or(SessionError::ScopeRejected)?;
request.check_granted_scopes(&grant.scopes)?;
request.expires_at_ns(
authority.now_ns,
policy.default_ttl_secs,
policy.max_ttl_secs,
)?;
let limits = tx.limits();
limits.validate()?;
limits.check_capacity(tx.occupancy(authority.caller)?, previous.is_some())?;
ledger.check_reservation(authority.caller, claims, authority.now_ns)?;
Ok(None)
})?;
if let Some(session) = retry {
return Ok(SessionEstablished::ExactRetry(session));
}
let admission_expires_at_ns = claims.admission_expires_at_ns;
let verified = verify(budget, decoded)?;
if verified.fingerprint() != fingerprint || verified.verified_at_ns() != authority.now_ns {
return Err(SessionError::InvalidPolicyOrRecord);
}
let session = Session::admit(&verified, request, context, policy)?;
let replay = Replay::new(&session, admission_expires_at_ns);
let mut storage_error = None;
let result = ledger.with_reservation(authority.caller, &verified, authority.now_ns, |change| {
store
.transaction(Some(change), |tx| {
if tx.generation() != policy.generation {
return Err(SessionError::StaleAuthority);
}
let replacing = tx.session(authority.caller)?.is_some();
tx.stage(session.clone(), replay)?;
Ok(if replacing {
SessionEstablished::Replaced(session)
} else {
SessionEstablished::Created(session)
})
})
.map_err(|error| {
storage_error = Some(error);
LoginRevocationError::Storage
})
});
match storage_error {
Some(error) => Err(error),
None => result.map_err(SessionError::from),
}
}
pub fn authorize_session(
store: &mut impl SessionStore,
ledger: &mut MemoryLoginRevocations,
context: &SessionContext<'_>,
policy: SessionPolicy,
) -> Result<Session, SessionError> {
context.validate(policy, ledger)?;
if ledger.is_revoked(context.authority.caller, context.authority.now_ns)? {
return Err(LoginRevocationError::Revoked.into());
}
store.transaction(None, |tx| {
if tx.generation() != policy.generation {
return Err(SessionError::StaleAuthority);
}
let session = tx
.session(context.authority.caller)?
.ok_or(SessionError::MissingOrExpired)?;
session.check_live(context, policy)?;
session.check_revocation(ledger, context.authority.now_ns)?;
if context
.required_scopes
.iter()
.any(|scope| !session.scopes().contains(scope))
{
return Err(SessionError::ScopeRejected);
}
Ok(session)
})
}
pub fn logout_session(
store: &mut impl SessionStore,
ledger: &mut MemoryLoginRevocations,
unknown_budget: &mut SessionAdmissionBudget,
caller: Principal,
now_ns: u64,
) -> LogoutResult {
let mut storage_error = None;
let result = ledger.with_revocation(unknown_budget, caller, now_ns, |change| {
store
.transaction(Some(change), |tx| tx.remove(caller))
.map_err(|error| {
storage_error = Some(error);
LoginRevocationError::Storage
})
});
if storage_error.is_some() {
return LogoutResult::Unavailable(LogoutUnavailable::Storage);
}
match result {
Ok(deny_until_ns) => LogoutResult::Revoked { deny_until_ns },
Err(LoginRevocationError::InvalidCaller) => LogoutResult::InvalidCaller,
Err(LoginRevocationError::Capacity) => {
LogoutResult::Unavailable(LogoutUnavailable::Capacity)
}
Err(LoginRevocationError::Clock) => LogoutResult::Unavailable(LogoutUnavailable::Clock),
Err(
LoginRevocationError::InvalidPolicy
| LoginRevocationError::Revoked
| LoginRevocationError::Storage,
) => LogoutResult::Unavailable(LogoutUnavailable::Storage),
}
}