axess_core/authn/factor.rs
1//! Factor kinds, typed configurations, and credentials.
2//!
3//! Each factor kind has its own submodule with config types. This module
4//! re-exports everything for a flat import surface:
5//! `use crate::authn::factor::{FactorKind, PasswordConfig, TotpConfig, ...}`
6//!
7//! # Factor kind summary
8//!
9//! | `FactorKind` | `FactorConfig` variant | Stateful? | `FactorCredential` type | Notes |
10//! |---|---|---|---|---|
11//! | `Password` | `FactorConfig::Password(PasswordConfig)` | Yes (stored hash) | `FactorCredential::Password(ZeroizedString)` | Argon2id; constant-time comparison |
12//! | `Totp` | `FactorConfig::Totp(TotpConfig)` | Yes (shared secret) | `FactorCredential::OtpCode(Arc<str>)` | RFC 6238; secret is zeroized on drop |
13//! | `Hotp` | `FactorConfig::Hotp(HotpConfig)` | Yes (shared secret + counter) | `FactorCredential::OtpCode(Arc<str>)` | RFC 4226; counter increments on success |
14//! | `EmailOtp` | `FactorConfig::EmailOtp(EmailOtpConfig)` | No (code is transient) | `FactorCredential::OtpCode(Arc<str>)` | Server-generated code sent out-of-band |
15//! | `Fido2` | `FactorConfig::Fido2(Fido2Config)` | Yes (credential public key) | `FactorCredential::Fido2Assertion(Value)` | WebAuthn; requires `fido2` feature |
16//! | `LdapBind` | `FactorConfig::LdapBind(LdapBindFactorConfig)` | No (directory-side) | `FactorCredential::Password(ZeroizedString)` | Bind DN from config or provider template |
17//! | `Federated(_)` | N/A (OAuth flow) | No (IdP-side) | N/A (token exchange) | OAuth2/OIDC; handled by `OAuthService` |
18
19mod template;
20
21// ── Re-exports (flat surface) ────────────────────────────────────────────────
22//
23// The factor *configs* (PasswordConfig / PasswordRules / TotpConfig /
24// HotpConfig / EmailOtpConfig / OtpAlgorithm / ZeroizedString) live in
25// `axess-factors`; colocated with the verifiers
26// they pair 1:1 with. The flat `crate::authn::factor::{...}` import
27// surface is preserved via these re-exports so adopters and internal
28// call sites stay source-compatible. `Fido2Config` / `FactorTemplate` /
29// `default_catalog` still live in axess-core.
30
31pub use axess_factors::Fido2Config;
32pub use axess_factors::{
33 EmailOtpConfig, HotpConfig, OtpAlgorithm, PasswordConfig, PasswordRules, TotpConfig,
34 ZeroizedString,
35};
36pub use template::{FactorTemplate, default_catalog};
37
38#[cfg(feature = "fido2")]
39pub use axess_factors::{
40 AuthenticationResult, AuthenticatorAttachment, CredentialID, Fido2Credential, Fido2Options,
41};
42
43use serde::{Deserialize, Serialize};
44use std::{fmt, sync::Arc};
45
46// ── FactorKind ───────────────────────────────────────────────────────────────
47
48/// The kind of authentication factor.
49#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
50#[cfg_attr(
51 feature = "rkyv",
52 derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
53)]
54pub enum FactorKind {
55 /// Argon2id-hashed password verified locally.
56 Password,
57 /// Time-based one-time password (RFC 6238).
58 Totp,
59 /// HMAC-based one-time password with a counter (RFC 4226).
60 Hotp,
61 /// One-time code delivered out-of-band over email.
62 EmailOtp,
63 /// FIDO2/WebAuthn passkey assertion.
64 Fido2,
65 /// LDAP simple bind: password verified against an LDAP directory.
66 LdapBind,
67 /// OAuth 2.0 / OIDC federated login through an external identity provider.
68 Federated(FederatedProvider),
69}
70
71impl FactorKind {
72 /// Stable lower-case string tag used in audit logs and metrics.
73 pub fn as_str(&self) -> &str {
74 match self {
75 FactorKind::Password => "password",
76 FactorKind::Totp => "totp",
77 FactorKind::Hotp => "hotp",
78 FactorKind::EmailOtp => "email_otp",
79 FactorKind::Fido2 => "fido2",
80 FactorKind::LdapBind => "ldap_bind",
81 FactorKind::Federated(p) => p.as_str(),
82 }
83 }
84}
85
86impl fmt::Display for FactorKind {
87 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
88 write!(f, "{}", self.as_str())
89 }
90}
91
92// ── FactorStep ──────────────────────────────────────────────────────────────
93
94/// A step in an authentication method: either a required factor or a choice.
95///
96/// # Examples
97///
98/// Sequential MFA (password then TOTP):
99/// ```text
100/// vec![FactorStep::Required(Password), FactorStep::Required(Totp)]
101/// ```
102///
103/// Factor choice (FIDO2 or password+TOTP):
104/// ```text
105/// vec![FactorStep::AnyOf(vec![Fido2, Password]), FactorStep::Required(Totp)]
106/// ```
107#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
108pub enum FactorStep {
109 /// Exactly this factor must be verified.
110 Required(FactorKind),
111 /// The user may choose any one of these factors.
112 AnyOf(Vec<FactorKind>),
113}
114
115impl FactorStep {
116 /// Return the factor kind if this is a `Required` step, or `None` for `AnyOf`.
117 pub fn as_required(&self) -> Option<&FactorKind> {
118 match self {
119 FactorStep::Required(k) => Some(k),
120 FactorStep::AnyOf(_) => None,
121 }
122 }
123
124 /// Return `true` if the given kind satisfies this step.
125 pub fn accepts(&self, kind: &FactorKind) -> bool {
126 match self {
127 FactorStep::Required(k) => k == kind,
128 FactorStep::AnyOf(choices) => choices.contains(kind),
129 }
130 }
131}
132
133impl From<FactorKind> for FactorStep {
134 fn from(kind: FactorKind) -> Self {
135 FactorStep::Required(kind)
136 }
137}
138
139// ── FederatedProvider ────────────────────────────────────────────────────────
140
141/// A federated identity provider for OAuth2/OIDC-based authentication.
142#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
143#[cfg_attr(
144 feature = "rkyv",
145 derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
146)]
147pub enum FederatedProvider {
148 /// GitHub OAuth 2.0 (`https://github.com`).
149 Github,
150 /// Google OIDC (`https://accounts.google.com`).
151 Google,
152 /// Microsoft Entra ID / Azure AD OIDC.
153 Microsoft,
154 /// Custom OIDC/OAuth provider identified by a stable tag.
155 Custom(String),
156}
157
158impl FederatedProvider {
159 /// Stable lower-case tag used as the provider key in storage and logs.
160 pub fn as_str(&self) -> &str {
161 match self {
162 FederatedProvider::Github => "github",
163 FederatedProvider::Google => "google",
164 FederatedProvider::Microsoft => "microsoft",
165 FederatedProvider::Custom(s) => s.as_ref(),
166 }
167 }
168}
169
170// ── FactorConfig ─────────────────────────────────────────────────────────────
171
172/// Typed factor configuration: one variant per factor kind.
173#[derive(Debug, Clone, Serialize, Deserialize)]
174pub enum FactorConfig {
175 /// Argon2id password configuration (rules + stored hash).
176 Password(PasswordConfig),
177 /// TOTP configuration (RFC 6238): shared secret, period, digits.
178 Totp(TotpConfig),
179 /// HOTP configuration (RFC 4226): shared secret, counter, digits.
180 Hotp(HotpConfig),
181 /// Email OTP configuration: target address, code length, TTL, pending challenge.
182 EmailOtp(EmailOtpConfig),
183 /// FIDO2/WebAuthn passkey configuration with registered credentials.
184 Fido2(Fido2Config),
185 /// LDAP bind factor. Per-user config is optional; if absent, the bind DN
186 /// is constructed from the provider's template and the user's identifier.
187 LdapBind(LdapBindFactorConfig),
188}
189
190impl FactorConfig {
191 /// Return the [`FactorKind`] associated with this configuration variant.
192 pub fn kind(&self) -> FactorKind {
193 match self {
194 FactorConfig::Password(_) => FactorKind::Password,
195 FactorConfig::Totp(_) => FactorKind::Totp,
196 FactorConfig::Hotp(_) => FactorKind::Hotp,
197 FactorConfig::EmailOtp(_) => FactorKind::EmailOtp,
198 FactorConfig::Fido2(_) => FactorKind::Fido2,
199 FactorConfig::LdapBind(_) => FactorKind::LdapBind,
200 }
201 }
202}
203
204// ── LdapBindFactorConfig ────────────────────────────────────────────────────
205
206/// Per-user configuration for LDAP bind authentication.
207///
208/// If `bind_dn` is `None`, the bind DN is constructed from the provider's
209/// template and the user's login identifier. Set `bind_dn` explicitly when
210/// a user's directory DN differs from the template pattern.
211#[derive(Debug, Clone, Default, Serialize, Deserialize)]
212pub struct LdapBindFactorConfig {
213 /// Explicit bind DN for this user. Overrides the provider template.
214 pub bind_dn: Option<String>,
215}
216
217// ── FactorCredential ─────────────────────────────────────────────────────────
218
219/// A credential presented for factor verification.
220///
221/// The enum shape is stable regardless of feature flags. FIDO2 assertions
222/// are stored as `serde_json::Value` and deserialized on demand when the
223/// `fido2` feature is enabled.
224#[derive(Debug)]
225pub enum FactorCredential {
226 /// Plaintext password, zeroized on drop. Compared against a stored Argon2id hash.
227 Password(ZeroizedString),
228 /// One-time code as entered by the user (TOTP, HOTP, or email OTP).
229 OtpCode(Arc<str>),
230 /// FIDO2/WebAuthn authentication assertion (JSON-serialized `PublicKeyCredential`).
231 ///
232 /// Use [`FactorCredential::fido2_assertion`] to construct, and
233 /// [`FactorCredential::as_public_key_credential`] (requires `fido2` feature)
234 /// to deserialize.
235 Fido2Assertion(serde_json::Value),
236}
237
238impl FactorCredential {
239 /// Construct a `Fido2Assertion` from a `PublicKeyCredential`.
240 #[cfg(feature = "fido2")]
241 pub fn fido2_assertion(
242 cred: &webauthn_rs::prelude::PublicKeyCredential,
243 ) -> Result<Self, serde_json::Error> {
244 serde_json::to_value(cred).map(Self::Fido2Assertion)
245 }
246
247 /// Deserialize the stored JSON into a `PublicKeyCredential`.
248 ///
249 /// Returns `None` if this is not a `Fido2Assertion` or deserialization fails.
250 #[cfg(feature = "fido2")]
251 pub fn as_public_key_credential(&self) -> Option<webauthn_rs::prelude::PublicKeyCredential> {
252 match self {
253 Self::Fido2Assertion(v) => serde_json::from_value(v.clone()).ok(),
254 _ => None,
255 }
256 }
257}
258
259#[cfg(test)]
260mod factor_tests {
261 //! Pin pure-function bodies on `FactorKind`,
262 //! `FactorStep`, `FederatedProvider`, and `FactorCredential`.
263 use super::*;
264
265 /// Kills line 78 `FactorKind::fmt -> Ok(Default::default())`:
266 /// Display must emit the canonical `as_str()` tag, not an empty
267 /// formatter write.
268 #[test]
269 fn factor_kind_display_matches_as_str() {
270 assert_eq!(FactorKind::Password.to_string(), "password");
271 assert_eq!(FactorKind::Totp.to_string(), "totp");
272 assert_eq!(FactorKind::EmailOtp.to_string(), "email_otp");
273 // Display interpolates Federated tag through as_str.
274 assert_eq!(
275 FactorKind::Federated(FederatedProvider::Github).to_string(),
276 "github"
277 );
278 }
279
280 /// Kills line 108 `FactorStep::as_required -> None`: a Required
281 /// step must return `Some(&kind)`.
282 #[test]
283 fn factor_step_as_required_returns_some_for_required_variant() {
284 let step = FactorStep::Required(FactorKind::Totp);
285 assert_eq!(step.as_required(), Some(&FactorKind::Totp));
286 }
287
288 /// AnyOf must still return None: pins the match arm against
289 /// being swapped.
290 #[test]
291 fn factor_step_as_required_returns_none_for_anyof_variant() {
292 let step = FactorStep::AnyOf(vec![FactorKind::Password, FactorKind::Totp]);
293 assert!(step.as_required().is_none());
294 }
295
296 /// Kills line 116 `accepts -> true/false` and line 117 `== → !=`:
297 /// `accepts` must be true ONLY for the matching `Required` kind
298 /// or membership in an `AnyOf` choice list.
299 #[test]
300 fn factor_step_accepts_required_only_for_matching_kind() {
301 let step = FactorStep::Required(FactorKind::Totp);
302 assert!(step.accepts(&FactorKind::Totp));
303 assert!(!step.accepts(&FactorKind::Password));
304 }
305
306 /// AnyOf accepts any listed kind, rejects non-listed.
307 #[test]
308 fn factor_step_accepts_anyof_member_only() {
309 let step = FactorStep::AnyOf(vec![FactorKind::Password, FactorKind::Totp]);
310 assert!(step.accepts(&FactorKind::Password));
311 assert!(step.accepts(&FactorKind::Totp));
312 assert!(!step.accepts(&FactorKind::EmailOtp));
313 }
314
315 /// Kills line 147 `FederatedProvider::as_str -> ""` and
316 /// `-> "xyzzy"`: must return the canonical lower-case tag per
317 /// variant. Includes the Custom arm to defend the match against
318 /// arm reordering.
319 #[test]
320 fn federated_provider_as_str_per_variant() {
321 assert_eq!(FederatedProvider::Github.as_str(), "github");
322 assert_eq!(FederatedProvider::Google.as_str(), "google");
323 assert_eq!(FederatedProvider::Microsoft.as_str(), "microsoft");
324 assert_eq!(
325 FederatedProvider::Custom("okta-prod".to_string()).as_str(),
326 "okta-prod"
327 );
328 // Different Custom values must yield different tags; pins
329 // against `as_str() → constant` mutations that ignore the
330 // variant data.
331 assert_ne!(
332 FederatedProvider::Custom("a".to_string()).as_str(),
333 FederatedProvider::Custom("b".to_string()).as_str()
334 );
335 }
336
337 /// Pin `FactorCredential::as_public_key_credential` against the
338 /// `-> None` body replacement and against deleting the
339 /// `Self::Fido2Assertion(v)` match arm. A Fido2Assertion carrying a
340 /// valid PublicKeyCredential JSON must round-trip back to `Some`;
341 /// non-FIDO2 variants must yield `None`.
342 #[cfg(feature = "fido2")]
343 #[test]
344 fn as_public_key_credential_yields_some_for_fido2_assertion() {
345 let pkc_json = serde_json::json!({
346 "id": "AAAA",
347 "rawId": "AAAA",
348 "type": "public-key",
349 "response": {
350 "authenticatorData": "AAAA",
351 "clientDataJSON": "AAAA",
352 "signature": "AAAA",
353 "userHandle": null,
354 },
355 "extensions": {},
356 });
357 let cred = FactorCredential::Fido2Assertion(pkc_json);
358 assert!(
359 cred.as_public_key_credential().is_some(),
360 "Fido2Assertion with a valid PublicKeyCredential JSON must yield Some"
361 );
362
363 let non_fido2 = FactorCredential::OtpCode(Arc::from("123456"));
364 assert!(
365 non_fido2.as_public_key_credential().is_none(),
366 "non-Fido2 variants must yield None"
367 );
368 }
369}