Skip to content

Factor Protocols

Authentication factors are the pluggable methods NeoZen uses to verify identity. Each factor implements one of three protocols depending on its authentication flow.

Protocol Hierarchy

FactorProtocol (base)
├── AuthenticatingFactorProtocol    -- single-step auth
├── ChallengingFactorProtocol       -- two-step challenge-response
└── EnrollableFactorProtocol        -- requires setup before use

FactorProtocol (Base)

All factors share these properties and methods:

class FactorProtocol(Protocol):
    @property
    def factor_id(self) -> str:
        """Unique ID: 'password', 'totp', 'magic_link', etc."""

    @property
    def display_name(self) -> str:
        """Human-readable: 'Password', 'Authenticator App', etc."""

    @property
    def factor_type(self) -> str:
        """'knowledge' | 'possession' | 'inherence'"""

    async def is_enrolled(self, subject_id: str) -> bool:
        """Whether the subject has this factor set up."""

    async def supports_subject(self, subject_id: str) -> bool:
        """Whether this factor can be used by the subject."""

AuthenticatingFactorProtocol

Single-step factors implement this protocol: the credential exists before sign-in.

class AuthenticatingFactorProtocol(FactorProtocol, Protocol):
    async def authenticate(
        self, subject_id: str, credential: Any, **kwargs
    ) -> bool:
        """Verify the credential. Returns True on success."""

Used by: PasswordFactor

ChallengingFactorProtocol

For two-step flows where a challenge must be issued before authentication can proceed.

class ChallengingFactorProtocol(FactorProtocol, Protocol):
    async def begin_authentication(
        self, subject_id: str, **kwargs
    ) -> dict[str, Any]:
        """Issue a challenge. Returns challenge data."""

    async def complete_authentication(
        self, subject_id: str, response: Any, **kwargs
    ) -> bool:
        """Verify the response to the challenge."""

Used by: MagicLinkFactor, WebAuthnFactor

EnrollableFactorProtocol

Factors that need explicit setup before use implement this protocol.

class EnrollableFactorProtocol(FactorProtocol, Protocol):
    async def begin_enrollment(
        self, subject_id: str, **kwargs
    ) -> dict[str, Any]:
        """Start enrollment. Returns setup data (QR code, etc.)."""

    async def complete_enrollment(
        self, subject_id: str, verification: Any, **kwargs
    ) -> bool:
        """Verify and finalize enrollment."""

    async def unenroll(self, subject_id: str) -> None:
        """Remove this factor from a subject."""

Used by: TOTPFactor, WebAuthnFactor

Multiple protocols

A factor can implement multiple protocols. WebAuthnFactor implements both EnrollableFactorProtocol (registration) and ChallengingFactorProtocol (authentication).

Built-in Factors

Factor Protocols Type Description
PasswordFactor Authenticating knowledge Argon2id password verification
TOTPFactor Enrollable possession Authenticator app codes
WebAuthnFactor Enrollable + Challenging possession Passkeys / security keys
MagicLinkFactor Challenging possession Passwordless email links

Factor Registry

Register factors for discovery:

from neozen.core.factors.registry import FactorRegistry

registry = FactorRegistry()
registry.register(password_factor)
registry.register(totp_factor)
registry.register(magic_link_factor)

# Look up by ID
factor = registry.get("password")
challenging = registry.get_challenging_factor("magic_link")

Creating a Custom Factor

from neozen.core.factors.protocol import AuthenticatingFactorProtocol

class PINFactor:
    @property
    def factor_id(self) -> str:
        return "pin"

    @property
    def display_name(self) -> str:
        return "PIN Code"

    @property
    def factor_type(self) -> str:
        return "knowledge"

    async def is_enrolled(self, subject_id: str) -> bool:
        return await self.pin_repo.exists(subject_id=subject_id)

    async def supports_subject(self, subject_id: str) -> bool:
        return True

    async def authenticate(
        self, subject_id: str, credential: str, **kwargs
    ) -> bool:
        stored = await self.pin_repo.get_one_by(subject_id=subject_id)
        return hmac.compare_digest(credential, stored.pin_hash)