Skip to main content

sui_id_shared/
auth_method.rs

1//! Which authentication factors a session was established with.
2//!
3//! Recorded once at session creation and read back when issuing an
4//! ID token, so that the `acr` and `amr` claims (OpenID Connect Core
5//! §2; AMR values are RFC 8176) accurately describe how the user
6//! proved who they are.
7//!
8//! The list of methods on a session is the historical record of
9//! *that* sign-in. It is not refreshed on subsequent token-endpoint
10//! calls — a session that started as password-only does not become
11//! MFA later just because the user enrols TOTP afterwards.
12
13use serde::{Deserialize, Serialize};
14
15/// Authentication method that contributed to a session's
16/// authentication. The variants map 1:1 to RFC 8176 AMR values.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
18#[serde(rename_all = "snake_case")]
19pub enum AuthMethod {
20    /// Username + password. Always present in a sui-id session.
21    Pwd,
22    /// TOTP authenticator app code.
23    Totp,
24    /// One of the user's pre-issued recovery codes.
25    RecoveryCode,
26    /// WebAuthn assertion (passkey / hardware key).
27    Webauthn,
28}
29
30impl AuthMethod {
31    /// The RFC 8176 AMR token for this method. These strings appear
32    /// verbatim in the `amr` array of issued ID tokens; relying
33    /// parties match on them.
34    pub fn as_amr(self) -> &'static str {
35        match self {
36            // RFC 8176 §2 — values are case-sensitive lowercase.
37            Self::Pwd => "pwd",
38            // TOTP and recovery codes are both one-time passwords from
39            // the perspective of relying parties; RFC 8176 has a
40            // single `otp` token for both.
41            Self::Totp | Self::RecoveryCode => "otp",
42            // RFC 8176 §2 — `hwk` is "proof of possession of a
43            // hardware-secured key". This is what a passkey or
44            // security key stored in a TPM/Secure Enclave is.
45            Self::Webauthn => "hwk",
46        }
47    }
48
49    /// Whether this method counts as a *second factor* — i.e. it is
50    /// neither password nor a knowledge-only credential. Used to
51    /// derive the ACR level.
52    pub fn is_second_factor(self) -> bool {
53        match self {
54            Self::Pwd => false,
55            Self::Totp | Self::RecoveryCode | Self::Webauthn => true,
56        }
57    }
58
59    /// Whether this method is *phishing-resistant* in the sense that
60    /// the credential cannot be replayed by an attacker who has
61    /// captured the user's keystrokes. WebAuthn is; TOTP is not (the
62    /// 6-digit code is interceptable inside its 30-second window).
63    pub fn is_phishing_resistant(self) -> bool {
64        matches!(self, Self::Webauthn)
65    }
66}
67
68/// OIDC Authentication Context Class Reference value derived from
69/// the methods used in a session.
70///
71/// We follow the ISO/IEC 29115 four-level Level-of-Assurance scheme
72/// (also referenced from OpenID Connect Core §2 as the reference
73/// example for `acr`), encoded as the bare numeric strings `"1"`,
74/// `"2"`, `"3"` that Keycloak and most other off-the-shelf IdPs
75/// produce. Numeric strings are the most widely understood form
76/// across RP libraries; the longer URI variants used by NIST AAL
77/// or eIDAS LoA fit those specific contexts and are needlessly
78/// verbose for a general-purpose IdP.
79///
80/// Mapping:
81///
82/// - `"1"` — single factor. Password only. Equivalent to ISO 29115
83///   LoA 1 / NIST AAL 1.
84/// - `"2"` — multi-factor with a software second factor (TOTP,
85///   recovery code). Equivalent to ISO 29115 LoA 2.
86/// - `"3"` — multi-factor with a phishing-resistant hardware
87///   second factor (WebAuthn). Equivalent to ISO 29115 LoA 3.
88///
89/// LoA 4 (in-person identity proofing) is not something an IdP can
90/// assert from authentication alone, so sui-id never produces it.
91pub fn acr_from_methods(methods: &[AuthMethod]) -> &'static str {
92    if methods.iter().any(|m| m.is_phishing_resistant()) {
93        "3"
94    } else if methods.iter().any(|m| m.is_second_factor()) {
95        "2"
96    } else {
97        "1"
98    }
99}
100
101/// Build the `amr` claim array. Includes `"mfa"` (RFC 8176) as the
102/// umbrella signal when **two or more distinct factor types** were
103/// used — i.e. when the sign-in was genuinely multi-factor. A
104/// single-factor sign-in, even one with a hardware key, does not
105/// claim `mfa`.
106///
107/// Deduplicates while preserving order so that `["pwd", "otp", "mfa"]`
108/// is the canonical shape.
109pub fn amr_from_methods(methods: &[AuthMethod]) -> Vec<String> {
110    let mut out: Vec<String> = Vec::with_capacity(methods.len() + 1);
111    for m in methods {
112        let v = m.as_amr().to_string();
113        if !out.contains(&v) {
114            out.push(v);
115        }
116    }
117    // RFC 8176 `mfa`: claim only if two or more *distinct* factor
118    // tokens are present. WebAuthn alone, or password alone, does
119    // not earn `mfa` even though WebAuthn is phishing-resistant.
120    if out.len() >= 2 && !out.contains(&"mfa".to_string()) {
121        out.push("mfa".to_string());
122    }
123    out
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129
130    #[test]
131    fn password_only_is_loa_1() {
132        assert_eq!(acr_from_methods(&[AuthMethod::Pwd]), "1");
133        assert_eq!(amr_from_methods(&[AuthMethod::Pwd]), vec!["pwd"]);
134    }
135
136    #[test]
137    fn password_plus_totp_is_loa_2_with_mfa() {
138        let m = [AuthMethod::Pwd, AuthMethod::Totp];
139        assert_eq!(acr_from_methods(&m), "2");
140        assert_eq!(amr_from_methods(&m), vec!["pwd", "otp", "mfa"]);
141    }
142
143    #[test]
144    fn password_plus_recovery_is_loa_2_with_otp_amr() {
145        // Recovery codes share the `otp` AMR with TOTP — both are
146        // one-time codes from the RP's perspective.
147        let m = [AuthMethod::Pwd, AuthMethod::RecoveryCode];
148        assert_eq!(acr_from_methods(&m), "2");
149        assert_eq!(amr_from_methods(&m), vec!["pwd", "otp", "mfa"]);
150    }
151
152    #[test]
153    fn password_plus_webauthn_is_loa_3() {
154        let m = [AuthMethod::Pwd, AuthMethod::Webauthn];
155        assert_eq!(acr_from_methods(&m), "3");
156        assert_eq!(amr_from_methods(&m), vec!["pwd", "hwk", "mfa"]);
157    }
158
159    #[test]
160    fn duplicates_are_deduped_and_mfa_isnt_added_twice() {
161        let m = [AuthMethod::Pwd, AuthMethod::Pwd, AuthMethod::Totp, AuthMethod::Totp];
162        assert_eq!(amr_from_methods(&m), vec!["pwd", "otp", "mfa"]);
163    }
164
165    #[test]
166    fn empty_methods_falls_back_to_loa_1() {
167        // An empty slice is a corruption case in practice — any
168        // sui-id session has at least Pwd. Be conservative: the
169        // lowest LoA, no `mfa`, no second factor.
170        assert_eq!(acr_from_methods(&[]), "1");
171        assert!(amr_from_methods(&[]).is_empty());
172    }
173
174    #[test]
175    fn webauthn_alone_is_phishing_resistant_so_loa_3_but_not_mfa() {
176        // Hypothetical future: passwordless WebAuthn-only sign-in.
177        // The hardware-bound key assertion clears LoA 3 by itself,
178        // but it is *not* multi-factor — just one phishing-
179        // resistant factor.
180        let m = [AuthMethod::Webauthn];
181        assert_eq!(acr_from_methods(&m), "3");
182        assert_eq!(amr_from_methods(&m), vec!["hwk"]);
183    }
184}