soaprs-auth 0.5.0

Protocol-neutral authentication and authorization contracts for soaprs
Documentation
//! Session and token lifecycle ports implemented by storage and crypto packages.

use std::time::SystemTime;

use soaprs_core::{BoxFuture, SoapResult};

use crate::SessionId;

/// Server-side authenticated session value.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Session<P> {
    /// Opaque session identity.
    id: SessionId,
    /// Authenticated principal snapshot or application session principal.
    principal: P,
    /// Creation time supplied by the application clock.
    created_at: SystemTime,
    /// Absolute expiration time.
    expires_at: SystemTime,
}

impl<P> Session<P> {
    /// Creates a session with a strictly later expiration time.
    pub fn new(
        id: SessionId,
        principal: P,
        created_at: SystemTime,
        expires_at: SystemTime,
    ) -> SoapResult<Self> {
        if expires_at <= created_at {
            return Err(soaprs_core::SoapError::validation(
                "session expiration must be later than creation",
            ));
        }
        Ok(Self {
            id,
            principal,
            created_at,
            expires_at,
        })
    }

    /// Reports expiration against an externally supplied time.
    pub fn is_expired_at(&self, now: SystemTime) -> bool {
        now >= self.expires_at
    }

    /// Returns the opaque session identity.
    pub const fn id(&self) -> &SessionId {
        &self.id
    }

    /// Returns the authenticated principal snapshot.
    pub const fn principal(&self) -> &P {
        &self.principal
    }

    /// Returns the externally supplied creation time.
    pub const fn created_at(&self) -> SystemTime {
        self.created_at
    }

    /// Returns the absolute expiration time.
    pub const fn expires_at(&self) -> SystemTime {
        self.expires_at
    }

    /// Consumes the session into its principal.
    pub fn into_principal(self) -> P {
        self.principal
    }
}

/// Persistence port for server-side sessions.
pub trait SessionStore<P>: Send + Sync
where
    P: Send,
{
    /// Loads a session when it exists.
    fn load<'a>(&'a self, id: &'a SessionId) -> BoxFuture<'a, SoapResult<Option<Session<P>>>>;

    /// Inserts or replaces a session.
    fn save(&self, session: Session<P>) -> BoxFuture<'_, SoapResult<()>>;

    /// Removes a session. Missing sessions are successful no-ops.
    fn delete<'a>(&'a self, id: &'a SessionId) -> BoxFuture<'a, SoapResult<()>>;
}

/// Access and refresh credentials issued together.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TokenPair<A, R> {
    /// Short-lived credential used to access protected resources.
    pub access_token: A,
    /// Credential used for rotation without re-entering primary credentials.
    pub refresh_token: R,
}

/// Token issue, rotation, and revocation application port.
pub trait TokenService<P>: Send + Sync
where
    P: Send + Sync,
{
    /// Concrete access-token representation owned by an implementation package.
    type AccessToken: Send;
    /// Concrete refresh-token representation owned by an implementation package.
    type RefreshToken: Send;

    /// Issues a new access/refresh pair.
    fn issue(
        &self,
        principal: &P,
    ) -> BoxFuture<'_, SoapResult<TokenPair<Self::AccessToken, Self::RefreshToken>>>;

    /// Validates and rotates a refresh token.
    fn refresh(
        &self,
        refresh_token: Self::RefreshToken,
    ) -> BoxFuture<'_, SoapResult<TokenPair<Self::AccessToken, Self::RefreshToken>>>;

    /// Revokes a refresh token or its token family.
    fn revoke(&self, refresh_token: Self::RefreshToken) -> BoxFuture<'_, SoapResult<()>>;
}

#[cfg(test)]
mod tests {
    use std::time::{Duration, UNIX_EPOCH};

    use super::Session;
    use crate::SessionId;

    #[test]
    fn sessions_require_forward_expiration_and_use_explicit_time() {
        let Some(id) = SessionId::new("session-1").ok() else {
            panic!("valid session ID");
        };
        assert!(Session::new(id.clone(), (), UNIX_EPOCH, UNIX_EPOCH).is_err());
        let session = Session::new(id, (), UNIX_EPOCH, UNIX_EPOCH + Duration::from_secs(60));
        let Some(session) = session.ok() else {
            panic!("valid session");
        };
        assert!(!session.is_expired_at(UNIX_EPOCH + Duration::from_secs(59)));
        assert!(session.is_expired_at(UNIX_EPOCH + Duration::from_secs(60)));
    }
}