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}