#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum AuthnContextComparison {
Exact,
Minimum,
Maximum,
Better,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum AuthnContextClassRef {
Password,
PasswordProtectedTransport,
TlsClient,
Smartcard,
SmartcardPki,
Kerberos,
PreviousSession,
Unspecified,
MultiFactorAuth,
TimeSyncToken,
Custom(String),
}
impl AuthnContextClassRef {
pub fn as_uri(&self) -> &str {
match self {
AuthnContextClassRef::Password => "urn:oasis:names:tc:SAML:2.0:ac:classes:Password",
AuthnContextClassRef::PasswordProtectedTransport => {
"urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport"
}
AuthnContextClassRef::TlsClient => "urn:oasis:names:tc:SAML:2.0:ac:classes:TLSClient",
AuthnContextClassRef::Smartcard => "urn:oasis:names:tc:SAML:2.0:ac:classes:Smartcard",
AuthnContextClassRef::SmartcardPki => {
"urn:oasis:names:tc:SAML:2.0:ac:classes:SmartcardPKI"
}
AuthnContextClassRef::Kerberos => "urn:oasis:names:tc:SAML:2.0:ac:classes:Kerberos",
AuthnContextClassRef::PreviousSession => {
"urn:oasis:names:tc:SAML:2.0:ac:classes:PreviousSession"
}
AuthnContextClassRef::Unspecified => {
"urn:oasis:names:tc:SAML:2.0:ac:classes:unspecified"
}
AuthnContextClassRef::MultiFactorAuth => {
"urn:oasis:names:tc:SAML:2.0:ac:classes:MultiFactorAuthentication"
}
AuthnContextClassRef::TimeSyncToken => {
"urn:oasis:names:tc:SAML:2.0:ac:classes:TimeSyncToken"
}
AuthnContextClassRef::Custom(s) => s.as_str(),
}
}
pub fn from_uri(uri: &str) -> Self {
match uri {
"urn:oasis:names:tc:SAML:2.0:ac:classes:Password" => AuthnContextClassRef::Password,
"urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport" => {
AuthnContextClassRef::PasswordProtectedTransport
}
"urn:oasis:names:tc:SAML:2.0:ac:classes:TLSClient" => AuthnContextClassRef::TlsClient,
"urn:oasis:names:tc:SAML:2.0:ac:classes:Smartcard" => AuthnContextClassRef::Smartcard,
"urn:oasis:names:tc:SAML:2.0:ac:classes:SmartcardPKI" => {
AuthnContextClassRef::SmartcardPki
}
"urn:oasis:names:tc:SAML:2.0:ac:classes:Kerberos" => AuthnContextClassRef::Kerberos,
"urn:oasis:names:tc:SAML:2.0:ac:classes:PreviousSession" => {
AuthnContextClassRef::PreviousSession
}
"urn:oasis:names:tc:SAML:2.0:ac:classes:unspecified" => {
AuthnContextClassRef::Unspecified
}
"urn:oasis:names:tc:SAML:2.0:ac:classes:MultiFactorAuthentication" => {
AuthnContextClassRef::MultiFactorAuth
}
"urn:oasis:names:tc:SAML:2.0:ac:classes:TimeSyncToken" => {
AuthnContextClassRef::TimeSyncToken
}
other => AuthnContextClassRef::Custom(other.to_string()),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct RequestedAuthnContext {
pub class_refs: Vec<AuthnContextClassRef>,
pub comparison: AuthnContextComparison,
}
#[must_use]
pub fn class_ref_strength(cr: &AuthnContextClassRef) -> Option<u8> {
match cr {
AuthnContextClassRef::Unspecified => Some(0),
AuthnContextClassRef::PreviousSession => Some(1),
AuthnContextClassRef::Password => Some(2),
AuthnContextClassRef::PasswordProtectedTransport => Some(3),
AuthnContextClassRef::TimeSyncToken => Some(4),
AuthnContextClassRef::Kerberos | AuthnContextClassRef::TlsClient => Some(5),
AuthnContextClassRef::Smartcard => Some(6),
AuthnContextClassRef::SmartcardPki => Some(7),
AuthnContextClassRef::MultiFactorAuth => Some(8),
AuthnContextClassRef::Custom(_) => None,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ComparatorOutcome {
Satisfied,
NotSatisfied,
NotComparable,
}
#[derive(Debug, Clone, Copy, Default)]
pub struct StandardComparator;
impl StandardComparator {
#[must_use]
pub fn evaluate(
self,
requested: &RequestedAuthnContext,
actual_uri: &str,
) -> ComparatorOutcome {
if matches!(requested.comparison, AuthnContextComparison::Exact) {
if requested.class_refs.is_empty() {
return ComparatorOutcome::NotSatisfied;
}
return if requested
.class_refs
.iter()
.any(|c| c.as_uri() == actual_uri)
{
ComparatorOutcome::Satisfied
} else {
ComparatorOutcome::NotSatisfied
};
}
let actual = AuthnContextClassRef::from_uri(actual_uri);
let Some(actual_strength) = class_ref_strength(&actual) else {
return ComparatorOutcome::NotComparable;
};
let mut requested_strengths = requested.class_refs.iter().filter_map(class_ref_strength);
let Some(first) = requested_strengths.next() else {
return ComparatorOutcome::NotComparable;
};
let (mut min_req, mut max_req) = (first, first);
for s in requested_strengths {
if s < min_req {
min_req = s;
}
if s > max_req {
max_req = s;
}
}
let satisfied = match requested.comparison {
AuthnContextComparison::Exact => false,
AuthnContextComparison::Minimum => actual_strength >= min_req,
AuthnContextComparison::Maximum => actual_strength <= max_req,
AuthnContextComparison::Better => actual_strength > max_req,
};
if satisfied {
ComparatorOutcome::Satisfied
} else {
ComparatorOutcome::NotSatisfied
}
}
#[must_use]
pub fn is_satisfied(self, requested: &RequestedAuthnContext, actual_uri: &str) -> bool {
matches!(
self.evaluate(requested, actual_uri),
ComparatorOutcome::Satisfied
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn round_trip_known_class_refs() {
for cr in [
AuthnContextClassRef::Password,
AuthnContextClassRef::PasswordProtectedTransport,
AuthnContextClassRef::TlsClient,
AuthnContextClassRef::Smartcard,
AuthnContextClassRef::SmartcardPki,
AuthnContextClassRef::Kerberos,
AuthnContextClassRef::PreviousSession,
AuthnContextClassRef::Unspecified,
AuthnContextClassRef::MultiFactorAuth,
AuthnContextClassRef::TimeSyncToken,
] {
let uri = cr.as_uri().to_string();
assert_eq!(AuthnContextClassRef::from_uri(&uri), cr);
}
}
#[test]
fn mfa_uri_is_well_known() {
assert_eq!(
AuthnContextClassRef::MultiFactorAuth.as_uri(),
"urn:oasis:names:tc:SAML:2.0:ac:classes:MultiFactorAuthentication"
);
}
#[test]
fn unrecognized_uri_becomes_custom() {
let uri = "urn:example:com:custom:strong-auth";
let v = AuthnContextClassRef::from_uri(uri);
assert_eq!(v, AuthnContextClassRef::Custom(uri.to_string()));
assert_eq!(v.as_uri(), uri);
}
#[test]
fn requested_authn_context_holds_multiple_class_refs() {
let r = RequestedAuthnContext {
class_refs: vec![
AuthnContextClassRef::PasswordProtectedTransport,
AuthnContextClassRef::MultiFactorAuth,
],
comparison: AuthnContextComparison::Minimum,
};
assert_eq!(r.class_refs.len(), 2);
assert_eq!(r.comparison, AuthnContextComparison::Minimum);
}
#[test]
fn comparison_variants_distinct() {
assert_ne!(
AuthnContextComparison::Exact,
AuthnContextComparison::Minimum
);
assert_ne!(
AuthnContextComparison::Maximum,
AuthnContextComparison::Better
);
}
#[test]
fn serde_round_trip_compiles() {
fn assert_serde<T: serde::Serialize + serde::de::DeserializeOwned>() {}
assert_serde::<AuthnContextClassRef>();
assert_serde::<AuthnContextComparison>();
assert_serde::<RequestedAuthnContext>();
}
fn requested(
comparison: AuthnContextComparison,
refs: &[AuthnContextClassRef],
) -> RequestedAuthnContext {
RequestedAuthnContext {
class_refs: refs.to_vec(),
comparison,
}
}
#[test]
fn exact_matches_one_of_the_requested_uris() {
let req = requested(
AuthnContextComparison::Exact,
&[
AuthnContextClassRef::Password,
AuthnContextClassRef::MultiFactorAuth,
],
);
let c = StandardComparator;
assert_eq!(
c.evaluate(&req, AuthnContextClassRef::MultiFactorAuth.as_uri()),
ComparatorOutcome::Satisfied,
);
assert!(c.is_satisfied(&req, AuthnContextClassRef::Password.as_uri()));
}
#[test]
fn exact_rejects_non_matching_uri() {
let req = requested(
AuthnContextComparison::Exact,
&[AuthnContextClassRef::Password],
);
let c = StandardComparator;
assert_eq!(
c.evaluate(&req, AuthnContextClassRef::MultiFactorAuth.as_uri()),
ComparatorOutcome::NotSatisfied,
);
}
#[test]
fn exact_works_for_custom_uris() {
let custom = "urn:example:vendor:strong";
let req = requested(
AuthnContextComparison::Exact,
&[AuthnContextClassRef::Custom(custom.into())],
);
let c = StandardComparator;
assert_eq!(c.evaluate(&req, custom), ComparatorOutcome::Satisfied);
assert_eq!(
c.evaluate(&req, "urn:example:vendor:other"),
ComparatorOutcome::NotSatisfied,
);
}
#[test]
fn exact_with_empty_requested_set_fails() {
let req = requested(AuthnContextComparison::Exact, &[]);
let c = StandardComparator;
assert_eq!(
c.evaluate(&req, AuthnContextClassRef::Password.as_uri()),
ComparatorOutcome::NotSatisfied,
);
}
#[test]
fn minimum_satisfied_when_actual_is_equal() {
let req = requested(
AuthnContextComparison::Minimum,
&[AuthnContextClassRef::PasswordProtectedTransport],
);
let c = StandardComparator;
assert_eq!(
c.evaluate(
&req,
AuthnContextClassRef::PasswordProtectedTransport.as_uri()
),
ComparatorOutcome::Satisfied,
);
}
#[test]
fn minimum_satisfied_when_actual_is_stronger() {
let req = requested(
AuthnContextComparison::Minimum,
&[AuthnContextClassRef::PasswordProtectedTransport],
);
let c = StandardComparator;
assert_eq!(
c.evaluate(&req, AuthnContextClassRef::MultiFactorAuth.as_uri()),
ComparatorOutcome::Satisfied,
);
}
#[test]
fn minimum_rejects_weaker_actual() {
let req = requested(
AuthnContextComparison::Minimum,
&[AuthnContextClassRef::PasswordProtectedTransport],
);
let c = StandardComparator;
assert_eq!(
c.evaluate(&req, AuthnContextClassRef::Password.as_uri()),
ComparatorOutcome::NotSatisfied,
);
}
#[test]
fn minimum_with_multiple_requested_uses_weakest_floor() {
let req = requested(
AuthnContextComparison::Minimum,
&[
AuthnContextClassRef::Password,
AuthnContextClassRef::Smartcard,
],
);
let c = StandardComparator;
assert_eq!(
c.evaluate(
&req,
AuthnContextClassRef::PasswordProtectedTransport.as_uri()
),
ComparatorOutcome::Satisfied,
);
}
#[test]
fn maximum_satisfied_when_actual_is_weaker_or_equal() {
let req = requested(
AuthnContextComparison::Maximum,
&[AuthnContextClassRef::Smartcard],
);
let c = StandardComparator;
assert_eq!(
c.evaluate(&req, AuthnContextClassRef::Password.as_uri()),
ComparatorOutcome::Satisfied,
);
assert_eq!(
c.evaluate(&req, AuthnContextClassRef::Smartcard.as_uri()),
ComparatorOutcome::Satisfied,
);
}
#[test]
fn maximum_rejects_stronger_actual() {
let req = requested(
AuthnContextComparison::Maximum,
&[AuthnContextClassRef::Smartcard],
);
let c = StandardComparator;
assert_eq!(
c.evaluate(&req, AuthnContextClassRef::MultiFactorAuth.as_uri()),
ComparatorOutcome::NotSatisfied,
);
}
#[test]
fn maximum_with_multiple_requested_uses_strongest_ceiling() {
let req = requested(
AuthnContextComparison::Maximum,
&[
AuthnContextClassRef::Password,
AuthnContextClassRef::Smartcard,
],
);
let c = StandardComparator;
assert_eq!(
c.evaluate(&req, AuthnContextClassRef::Kerberos.as_uri()),
ComparatorOutcome::Satisfied,
);
}
#[test]
fn better_rejects_equal_strength() {
let req = requested(
AuthnContextComparison::Better,
&[AuthnContextClassRef::PasswordProtectedTransport],
);
let c = StandardComparator;
assert_eq!(
c.evaluate(
&req,
AuthnContextClassRef::PasswordProtectedTransport.as_uri()
),
ComparatorOutcome::NotSatisfied,
);
}
#[test]
fn better_accepts_strictly_stronger() {
let req = requested(
AuthnContextComparison::Better,
&[AuthnContextClassRef::PasswordProtectedTransport],
);
let c = StandardComparator;
assert_eq!(
c.evaluate(&req, AuthnContextClassRef::Smartcard.as_uri()),
ComparatorOutcome::Satisfied,
);
}
#[test]
fn better_must_exceed_every_requested_not_just_one() {
let req = requested(
AuthnContextComparison::Better,
&[
AuthnContextClassRef::Password,
AuthnContextClassRef::Smartcard,
],
);
let c = StandardComparator;
assert_eq!(
c.evaluate(&req, AuthnContextClassRef::Kerberos.as_uri()),
ComparatorOutcome::NotSatisfied,
);
assert_eq!(
c.evaluate(&req, AuthnContextClassRef::MultiFactorAuth.as_uri()),
ComparatorOutcome::Satisfied,
);
}
#[test]
fn ordered_comparison_with_custom_actual_is_not_comparable() {
let req = requested(
AuthnContextComparison::Minimum,
&[AuthnContextClassRef::PasswordProtectedTransport],
);
let c = StandardComparator;
assert_eq!(
c.evaluate(&req, "urn:example:vendor:strong"),
ComparatorOutcome::NotComparable,
);
assert!(!c.is_satisfied(&req, "urn:example:vendor:strong"));
}
#[test]
fn ordered_comparison_with_only_custom_requested_is_not_comparable() {
let req = requested(
AuthnContextComparison::Maximum,
&[AuthnContextClassRef::Custom(
"urn:example:vendor:weak".into(),
)],
);
let c = StandardComparator;
assert_eq!(
c.evaluate(&req, AuthnContextClassRef::Password.as_uri()),
ComparatorOutcome::NotComparable,
);
}
#[test]
fn ordered_comparison_skips_custom_among_known_requested() {
let req = requested(
AuthnContextComparison::Minimum,
&[
AuthnContextClassRef::Custom("urn:example:vendor:weak".into()),
AuthnContextClassRef::Password,
],
);
let c = StandardComparator;
assert_eq!(
c.evaluate(
&req,
AuthnContextClassRef::PasswordProtectedTransport.as_uri()
),
ComparatorOutcome::Satisfied,
);
}
#[test]
fn strength_ladder_monotonic_through_known_refs() {
let ranks = [
class_ref_strength(&AuthnContextClassRef::Unspecified).unwrap(),
class_ref_strength(&AuthnContextClassRef::PreviousSession).unwrap(),
class_ref_strength(&AuthnContextClassRef::Password).unwrap(),
class_ref_strength(&AuthnContextClassRef::PasswordProtectedTransport).unwrap(),
class_ref_strength(&AuthnContextClassRef::TimeSyncToken).unwrap(),
class_ref_strength(&AuthnContextClassRef::Kerberos).unwrap(),
class_ref_strength(&AuthnContextClassRef::Smartcard).unwrap(),
class_ref_strength(&AuthnContextClassRef::SmartcardPki).unwrap(),
class_ref_strength(&AuthnContextClassRef::MultiFactorAuth).unwrap(),
];
for w in ranks.windows(2) {
assert!(w[0] < w[1], "ranks not strictly ascending: {ranks:?}");
}
assert_eq!(
class_ref_strength(&AuthnContextClassRef::TlsClient),
class_ref_strength(&AuthnContextClassRef::Kerberos),
);
}
#[test]
fn strength_ladder_custom_is_none() {
assert_eq!(
class_ref_strength(&AuthnContextClassRef::Custom("urn:x".into())),
None,
);
}
}