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.
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 (System, Tenant, User), covered in detail in Scope
hierarchy. The short version: a system 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 system 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.
Composing and verifying
How a method becomes a chain, and what one step does.
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 enters their password, then
completes one of two second factors, and which one is their choice.
They may not have registered a passkey yet, or their phone may be at
home and only the 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
RequestAuthnService::verify_factor, on the request-scoped handle that
AuthnService::with_audit_context returns. 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 RequestAuthnService::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:
- Looks up the user in the named tenant. An identifier matching
nothing returns
LoginOutcome::InvalidCredentials, the same outcome a wrong password gives, and runs the same store queries against a dummy id so the latency matches too. There is deliberately no "no such user" to branch on. - Loads the method that applies under the scope hierarchy (see Scope hierarchy): the sequence of steps this user will walk.
- Transitions the session to
Authenticating, withremainingset to that 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.
Extending it
Your own factor, and asking for another when risk rises.
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 (System, 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. There is no require_step_up orchestrator method.
What axess ships is the decision, not the transition: decide_step_up
is a free function over a Device and a StepUpPolicy, returning
Some(factors) when the device's trust level calls for them and None
when it does not. StepUpPolicy is a factor list per DeviceTrustLevel:
unknown and seen demand factors by default, trusted and
revoked demand none. begin_login surfaces the answer as
LoginOutcome::StepUpRequired { device_id, allowed_factors }.
Driving the session back into Authenticating with that list is the
caller's step.
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.
How far two steps get you
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.