nythos-core 0.2.1

Infrastructure-free Rust core library for Nythos authentication and authorization.
Documentation
use std::time::{Duration, SystemTime};

use super::issuance::issue_session_auth;
use crate::{
    AccessToken, AuthError, Claims, DisplayName, Email, NewUser, NythosResult, Password,
    PasswordHasher, RefreshToken, Session, SessionStore, TenantAuthPolicy, TenantId,
    TenantPolicyPort, TokenSigner, User, UserRepository, Username,
};

/// Input for the register orchestration flow.
#[derive(Debug, Clone)]
pub struct RegisterInput {
    tenant_id: TenantId,
    email: String,
    password: String,
    username: Option<String>,
    display_name: Option<String>,
    issued_at: SystemTime,
    access_token_ttl: Duration,
    session_ttl: Duration,
    auto_sign_in: bool,
}

impl RegisterInput {
    pub fn new(
        tenant_id: TenantId,
        email: String,
        password: String,
        issued_at: SystemTime,
        access_token_ttl: Duration,
        session_ttl: Duration,
    ) -> Self {
        Self {
            tenant_id,
            email,
            password,
            username: None,
            display_name: None,
            issued_at,
            access_token_ttl,
            session_ttl,
            auto_sign_in: true,
        }
    }

    pub const fn tenant_id(&self) -> TenantId {
        self.tenant_id
    }

    pub fn email(&self) -> &str {
        &self.email
    }

    pub fn password(&self) -> &str {
        &self.password
    }

    pub fn username(&self) -> Option<&str> {
        self.username.as_deref()
    }

    pub fn display_name(&self) -> Option<&str> {
        self.display_name.as_deref()
    }

    pub const fn issued_at(&self) -> SystemTime {
        self.issued_at
    }

    pub const fn access_token_ttl(&self) -> Duration {
        self.access_token_ttl
    }

    pub const fn session_ttl(&self) -> Duration {
        self.session_ttl
    }

    pub const fn auto_sign_in(&self) -> bool {
        self.auto_sign_in
    }

    pub fn with_profile(mut self, username: Option<String>, display_name: Option<String>) -> Self {
        self.username = username;
        self.display_name = display_name;
        self
    }

    pub fn with_username(mut self, username: impl Into<String>) -> Self {
        self.username = Some(username.into());
        self
    }

    pub fn with_display_name(mut self, display_name: impl Into<String>) -> Self {
        self.display_name = Some(display_name.into());
        self
    }

    pub fn with_auto_sign_in(mut self, auto_sign_in: bool) -> Self {
        self.auto_sign_in = auto_sign_in;
        self
    }
}

/// Signed auth material returned when registration also creates a session.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RegisterAuthMaterial {
    user: User,
    session: Session,
    refresh_token: RefreshToken,
    access_token: AccessToken,
    claims: Claims,
}

impl RegisterAuthMaterial {
    pub fn new(
        user: User,
        session: Session,
        refresh_token: RefreshToken,
        access_token: AccessToken,
        claims: Claims,
    ) -> Self {
        Self {
            user,
            session,
            refresh_token,
            access_token,
            claims,
        }
    }

    pub fn user(&self) -> &User {
        &self.user
    }

    pub fn session(&self) -> &Session {
        &self.session
    }

    pub fn refresh_token(&self) -> &RefreshToken {
        &self.refresh_token
    }

    pub fn access_token(&self) -> &AccessToken {
        &self.access_token
    }

    pub fn claims(&self) -> &Claims {
        &self.claims
    }
}

/// Result of a register flow.
///
/// `auth` is present when the flow is configured to auto-sign-in.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RegisterResult {
    user: User,
    auth: Option<RegisterAuthMaterial>,
}

impl RegisterResult {
    pub fn new(user: User, auth: Option<RegisterAuthMaterial>) -> Self {
        Self { user, auth }
    }

    pub fn user(&self) -> &User {
        &self.user
    }

    pub fn auth(&self) -> Option<&RegisterAuthMaterial> {
        self.auth.as_ref()
    }
}

/// Register orchestration service.
///
/// This flow:
/// - validates email and password through core value objects
/// - loads tenant auth policy through `TenantPolicyPort`
/// - validates and policy-gates optional profile fields
/// - enforces tenant-scoped uniqueness through `UserRepository`
/// - hashes the password through `PasswordHasher`
/// - persists the user through `UserRepository`
/// - optionally creates a session and signed access token through `SessionStore` and `TokenSigner`
pub struct RegisterService<'a, U, P, S, H, T> {
    user_repository: &'a U,
    tenant_policy_port: &'a P,
    session_store: &'a S,
    password_hasher: &'a H,
    token_signer: &'a T,
}

impl<'a, U, P, S, H, T> RegisterService<'a, U, P, S, H, T>
where
    U: UserRepository,
    P: TenantPolicyPort,
    S: SessionStore,
    H: PasswordHasher,
    T: TokenSigner,
{
    pub fn new(
        user_repository: &'a U,
        tenant_policy_port: &'a P,
        session_store: &'a S,
        password_hasher: &'a H,
        token_signer: &'a T,
    ) -> Self {
        Self {
            user_repository,
            tenant_policy_port,
            session_store,
            password_hasher,
            token_signer,
        }
    }

    pub async fn register(&self, input: RegisterInput) -> NythosResult<RegisterResult> {
        let email = Email::parse(input.email())?;
        let password = Password::new(input.password())?;
        let policy = self
            .tenant_policy_port
            .load_auth_policy(input.tenant_id())
            .await?;

        let username = self.parse_username(input.username(), &policy)?;
        let display_name = self.parse_display_name(input.display_name(), &policy)?;

        self.ensure_email_available(input.tenant_id(), &email)
            .await?;

        if let Some(username) = &username {
            self.ensure_username_available(input.tenant_id(), username)
                .await?;
        }

        let password_hash = self.password_hasher.hash(&password).await?;
        let user = self
            .user_repository
            .create(
                input.tenant_id(),
                NewUser::with_profile(email, username, display_name),
                password_hash,
            )
            .await?;

        let auth = if input.auto_sign_in() {
            Some(self.create_auth_material(&input, &user).await?)
        } else {
            None
        };

        Ok(RegisterResult::new(user, auth))
    }

    fn parse_username(
        &self,
        username: Option<&str>,
        policy: &TenantAuthPolicy,
    ) -> NythosResult<Option<Username>> {
        let Some(username) = username else {
            return Ok(None);
        };

        if !policy.username_registration_enabled() {
            return Err(AuthError::ValidationError(
                "username registration is disabled for tenant".to_owned(),
            ));
        }

        Username::parse(username).map(Some)
    }

    fn parse_display_name(
        &self,
        display_name: Option<&str>,
        policy: &TenantAuthPolicy,
    ) -> NythosResult<Option<DisplayName>> {
        let Some(display_name) = display_name else {
            return Ok(None);
        };

        if !policy.display_name_registration_enabled() {
            return Err(AuthError::ValidationError(
                "display name registration is disabled for tenant".to_owned(),
            ));
        }

        DisplayName::parse(display_name).map(Some)
    }

    async fn ensure_email_available(&self, tenant_id: TenantId, email: &Email) -> NythosResult<()> {
        if self
            .user_repository
            .find_by_email(tenant_id, email)
            .await?
            .is_some()
        {
            return Err(AuthError::ValidationError(
                "user with email already exists in tenant".to_owned(),
            ));
        }

        Ok(())
    }

    async fn ensure_username_available(
        &self,
        tenant_id: TenantId,
        username: &Username,
    ) -> NythosResult<()> {
        if self
            .user_repository
            .find_by_username(tenant_id, username)
            .await?
            .is_some()
        {
            return Err(AuthError::ValidationError(
                "user with username already exists in tenant".to_owned(),
            ));
        }

        Ok(())
    }

    async fn create_auth_material(
        &self,
        input: &RegisterInput,
        user: &User,
    ) -> NythosResult<RegisterAuthMaterial> {
        let issued = issue_session_auth(
            self.session_store,
            self.token_signer,
            user.id(),
            input.tenant_id(),
            input.issued_at(),
            input.access_token_ttl(),
            input.session_ttl(),
        )
        .await?;

        Ok(RegisterAuthMaterial::new(
            user.clone(),
            issued.session,
            issued.refresh_token,
            issued.access_token,
            issued.claims,
        ))
    }
}