Skip to main content

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}