Factors and methods
A factor is a single credential check: a password, a TOTP code, a WebAuthn assertion, an LDAP bind, an OAuth token exchange. A method is a sequence of factors that together count as a successful login. Composing factors into methods, and scoping methods to apply per-user or per-tenant rather than globally, is the day-to-day surface adopters work with. This chapter explains the vocabulary, the types that carry it, and the pattern for adding a factor that axess does not ship.
Vocabulary
The four words that recur are factor, step, method, and scope. They sound interchangeable in casual writing, and they are not in the code.
A factor is one credential verifier, identified by a FactorKind
variant: Password, Totp, Hotp, EmailOtp, Fido2, LdapBind,
or Federated(FederatedProvider). Each factor has a config struct
(PasswordConfig, TotpConfig, and so on) that the relevant adopter
seeds at provisioning time and the service reads at verification time.
A step is one node in a method. A step is either a Required(kind)
demand for a specific factor, or an AnyOf(vec![kind1, kind2, ...])
disjunction that lets the user choose among several factors at that
position. The step is the unit of authoring; a method is a sequence
of steps.
A method is an ordered sequence of steps with a stable name. Examples
in the wild: "password-only" (one step, Required(Password)),
"password-then-TOTP" (two steps, Required(Password) then
Required(Totp)), "password-then-second-factor"
(two steps, Required(Password) then AnyOf(vec![Totp, Fido2, EmailOtp])). The name matters because the session records which
method is in progress, and the audit trail names the method when
recording success or failure.
A scope is the tier at which a method is configured. There are three tiers (Global, Tenant, and User), covered in detail in Scope hierarchy. The short version: a global default applies everywhere; a tenant can override it; a user can override the tenant. Resolution is the simple inversion of authority: user override beats tenant override beats global default.
The factor list
The current FactorKind enum and its companion config sum-type live in
axess-core/src/authn/factor.rs.
pub enum FactorKind {
Password,
Totp,
Hotp,
EmailOtp,
Fido2,
LdapBind,
Federated(FederatedProvider),
}
pub enum FederatedProvider {
Github,
Google,
Microsoft,
Custom(String),
}
pub enum FactorConfig {
Password(PasswordConfig),
Totp(TotpConfig),
Hotp(HotpConfig),
EmailOtp(EmailOtpConfig),
Fido2(Fido2Config),
LdapBind(LdapBindFactorConfig),
// Federated configs live with their provider's verifier crate.
}
FactorKind is the discriminator the state machine carries.
FactorConfig is the data the verifier needs. They mirror each other
because the verifier-versus-orchestrator split (see Architecture at
a glance) puts the algorithm and its config in axess-factors and
puts the discriminator and the composition machinery in axess-core.
A new factor lands as a new FactorKind variant, a new FactorConfig
variant, and a new verifier crate (or module) under axess-factors.
The federated case is intentionally a parameterised variant rather than a flat list. Each federated provider has its own configuration shape (Google's audience claim differs from GitHub's; Microsoft adds tenant directory parameters), and the wire formats are different enough that flattening them into one enum would require a discriminator inside the config. Parameterising the kind itself makes the config sum-type smaller and the type system honest about the variation.
Custom(String) is the extension point for IdPs the upstream library
does not name explicitly. Adopters who federate against Okta, Auth0,
Azure AD as a generic OIDC provider, or an in-house IdP plug in with
the OAuth-RS resolver and a custom string identifier; the workload
identity chapter (Workload identity overview) describes the same
pattern from the inbound-resolver side.
How factors compose
The composition primitives are FactorStep and Method. A FactorStep
is one node in a method. A Method is a vector of steps plus a name.
pub enum FactorStep {
Required(FactorKind),
AnyOf(Vec<FactorKind>),
}
pub struct Method {
pub name: Arc<str>,
pub steps: Vec<FactorStep>,
}
The two-step Required(Password) then AnyOf(vec![Totp, Fido2])
method handles a common shape: the user must enter their password,
then must complete one of two second factors, and the choice of second
factor is theirs (perhaps because they have not registered a passkey
yet, or perhaps because their phone is at home and they only have
their hardware key with them). The state machine's
Authenticating::remaining field carries the residue of steps yet to
complete: after the password step, remaining looks like
[AnyOf(vec![Totp, Fido2])] and the application's login page renders
the choice between them.
Required(kind) is shorthand for a one-element AnyOf(vec![kind]),
but the distinction matters for audit clarity. A successful login that
went password + totp reads cleanly when the audit log records
"completed Required(Totp)"; the same login through an AnyOf step
records "completed AnyOf::Totp" and a reviewer asks why the choice
was offered at all. Use Required when there is no choice.
The orchestrator does not support arbitrary expression trees of factors (you cannot say "two of these three" with a single step). The omission is on purpose. Real authentication methods are short sequences with at most one decision point per step, and admitting arbitrary expressions would invite policies that pass formal review but defeat operational understanding.
The verify_factor path
Application code drives factor verification through
AuthnService::verify_factor. The signature is
pub async fn verify_factor(
&self,
credential: &FactorCredential,
session: &AuthSession,
) -> Result<FactorOutcome, AuthnError<I::Error>>;
with FactorCredential the runtime credential value:
pub enum FactorCredential {
Password(ZeroizedString),
OtpCode(Arc<str>),
Fido2Assertion(serde_json::Value),
}
and FactorOutcome the result of the call:
pub enum FactorOutcome {
Authenticated,
FactorRequired(FactorKind),
InvalidCredential,
Locked { until: Option<DateTime<Utc>> },
}
The handler in your application takes the credential off the request
(form body, JSON, header, whatever), wraps it in the right
FactorCredential variant, and calls verify_factor. Three things
then happen inside the service.
First, the service acquires the session's write lock and reads its
current state. If the state is not Authenticating, the call returns
an AuthnError. If the state is Authenticating, the service inspects
remaining to determine which factor is expected next. A mismatch
between the credential the client supplied and the factor the method
expects returns FactorOutcome::InvalidCredential without engaging
the verifier, which keeps the cryptographic cost of failed attempts
predictable.
Second, the service dispatches to the appropriate verifier in
axess-factors. The password case calls Argon2id. The TOTP case calls
the RFC 6238 verifier with the user's stored secret and the current
window. The FIDO2 case calls the WebAuthn ceremony, which is itself
stateful and threads through the session's challenge field. Federated
cases dispatch to their respective OAuth or OIDC handlers.
Third, the service translates the verifier's result into a
FactorOutcome and an AdvanceOutcome from the state machine. A
successful verification calls AuthState::advance_factor, which
returns Completed if no factors remain (the orchestrator promotes
the session to Authenticated) or StillAuthenticating if more
factors are required (the orchestrator leaves the state in
Authenticating and returns FactorOutcome::FactorRequired(kind)
with the next expected kind). A failed verification increments
attempt_count, updates last_attempt, and returns
FactorOutcome::InvalidCredential or Locked depending on the
attempt policy.
The Locked outcome is the lockout decision in band. The
until: Option<DateTime<Utc>> field carries the unlock time when one
is scheduled (a five-minute exponential backoff after three attempts,
for instance) or None when the lockout requires administrative
intervention. The application surfaces this to the user with the right
copy; the audit log records the lockout regardless.
Begin and complete
verify_factor is the verb that drives a method forward, but a login
also has a start and an end. The start is AuthnService::begin_login,
which transitions a Guest session into Authenticating. The end is
the orchestrator's promotion of Authenticating to Authenticated
when the last factor completes (or to PendingWorkflow when a workflow
is in progress).
begin_login does three things that are worth naming explicitly.
First, it looks up the user in the configured identity store, in the
tenant that the caller named, and returns UserNotFound if no user
matches. Second, it loads the method that applies to that user under
the scope hierarchy (covered in Scope hierarchy); the result is the
specific sequence of steps the user will walk. Third, it transitions
the session into Authenticating with the loaded method's
remaining set to the method's full step list.
complete_signup is the corresponding verb for the PendingWorkflow
case. After a signup ceremony completes (email verified, KYC checks
passed, terms accepted), the orchestrator transitions the session
from PendingWorkflow { kind: Signup, ... } to Authenticated. The
factor list on the resulting Authenticated variant is the list that
was used during the signup, which is what the audit trail wants and
what subsequent policy evaluation reads.
Adding a custom factor
The pattern for adding a factor that axess does not ship is the same pattern that produced the factors that axess does ship. There are four moving parts.
The first part is the verifier itself. It lives in axess-factors
(or in a separate crate that depends on axess-factors) and exposes
a function or trait that takes the stored config plus the runtime
credential and returns a verifier-side result. For a hash-based
factor this is straightforward (compute the hash, constant-time
compare); for a ceremony-based factor (FIDO2, OAuth) the verifier
threads through the session-side challenge and the response.
The second part is the FactorKind variant. Adding a variant is a
breaking change to the public surface, which is what you want: any
match on FactorKind in adopter code now flags a missing arm, and the
adopter chooses to handle the new factor or to reject it with an
explicit pattern. There is no "add a variant silently" mechanism in
axess, and that omission is intentional.
The third part is the FactorConfig variant and the storage adapter
that loads it. The factor config goes into the configured factor
store; the load path resolves the scope (Global, Tenant, User) and
returns the right config for the user being authenticated. Adopters
implement the factor store, so the storage decision is theirs.
The fourth part is the credential type. A new factor that requires a
new shape of input adds a variant to FactorCredential. A factor
that maps to one of the existing variants (a password-like factor
reuses Password, a code-based factor reuses OtpCode) avoids the
addition.
The work is small. The factors that axess ships today each take fewer than a thousand lines of Rust including tests. The reason the work stays small is that the orchestration and the state machine do not change; the verifier is doing one job, behind a fixed contract.
Step-up authentication
Step-up is the pattern where an already-Authenticated session is
asked to re-prove identity (or to prove with a stronger factor) before
performing a sensitive action. Axess models this by transitioning the
state from Authenticated back to Authenticating with a non-empty
remaining list. The orchestrator method that drives this is
AuthnService::require_step_up, which takes the session and the
factor or factors the caller demands.
The state-machine view is uniform. The session is Authenticating
again; the factor list contains the stepped-up factors; the session
remembers (in completed) which factors it already cleared.
verify_factor works the same way it did during the original login,
and on the final Completed outcome the session transitions back to
Authenticated with a fresh authn_time and an updated
factors_completed.
The application controls when step-up is required. The Cedar policy
engine can express "this action requires Fido2 in
factors_completed" (see Cedar policy fundamentals), or the
handler can demand it directly. The state machine does not impose a
policy; it provides the shape that lets the policy be enforced.
What this enables
A method composed of a Required(Password) followed by an
AnyOf(vec![Totp, Fido2, EmailOtp]) covers an enormous share of real
deployments without any further structure. A per-tenant override for
a specific tenant that requires Required(Fido2) instead of the
disjunction covers the rare case where one tenant must be stricter.
A per-user override that adds Required(EmailOtp) for a flagged user
covers the regulatory case where one user is on a watch list.
None of these require new code beyond an entry in the method store. The state machine, the verifier dispatch, and the audit pipeline all read the method out of the configured scope and execute it. The next chapter, Scope hierarchy, covers the configuration tier in detail.
Further reading
Scope hierarchy covers Global, Tenant, and User configuration tiers
and how begin_login resolves them at runtime. Cedar policy
fundamentals covers how the policy engine reads
factors_completed and authorises against it. Part III, Factor
cookbooks, has a chapter per real-world factor (Password and TOTP,
FIDO2 and WebAuthn passkeys, OAuth 2.0 and OIDC, and so on) that
walks through the integration details one factor at a time.