#![allow(clippy::doc_markdown)]
use core::fmt;
#[derive(Debug, Clone, Copy)]
pub struct FederatedClaims<'a> {
pub subject: &'a str,
pub email: Option<&'a str>,
pub email_verified: bool,
}
#[derive(Debug, Clone, Copy)]
pub struct FederationContext {
pub already_linked: bool,
pub local_user_with_verified_email: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum FederationDenied {
NoSubject,
NoEmail,
EmailNotVerified,
}
impl FederationDenied {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::NoSubject => "the identity provider returned no subject",
Self::NoEmail => "the identity provider returned no email",
Self::EmailNotVerified => "the identity provider did not verify the email",
}
}
}
impl fmt::Display for FederationDenied {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "federated login denied: {}", self.as_str())
}
}
impl std::error::Error for FederationDenied {}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum FederationOutcome {
ExistingLink,
LinkByEmail,
Provision,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum FederationVerdict {
Allow(FederationOutcome),
Deny(FederationDenied),
}
#[must_use]
pub fn evaluate_federation(
claims: &FederatedClaims<'_>,
ctx: &FederationContext,
) -> FederationVerdict {
use FederationDenied as D;
use FederationOutcome as O;
use FederationVerdict::{Allow, Deny};
if claims.subject.is_empty() {
return Deny(D::NoSubject);
}
if ctx.already_linked {
return Allow(O::ExistingLink);
}
if claims.email.is_none_or(str::is_empty) {
return Deny(D::NoEmail);
}
if !claims.email_verified {
return Deny(D::EmailNotVerified);
}
if ctx.local_user_with_verified_email {
Allow(O::LinkByEmail)
} else {
Allow(O::Provision)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn claims(email: Option<&'static str>, verified: bool) -> FederatedClaims<'static> {
FederatedClaims {
subject: "ext-sub-1",
email,
email_verified: verified,
}
}
fn ctx(linked: bool, local: bool) -> FederationContext {
FederationContext {
already_linked: linked,
local_user_with_verified_email: local,
}
}
#[test]
fn existing_link_signs_in() {
assert_eq!(
evaluate_federation(&claims(Some("a@b.com"), true), &ctx(true, false)),
FederationVerdict::Allow(FederationOutcome::ExistingLink)
);
assert_eq!(
evaluate_federation(&claims(None, false), &ctx(true, false)),
FederationVerdict::Allow(FederationOutcome::ExistingLink)
);
}
#[test]
fn verified_email_match_links() {
assert_eq!(
evaluate_federation(&claims(Some("a@b.com"), true), &ctx(false, true)),
FederationVerdict::Allow(FederationOutcome::LinkByEmail)
);
}
#[test]
fn no_match_provisions() {
assert_eq!(
evaluate_federation(&claims(Some("a@b.com"), true), &ctx(false, false)),
FederationVerdict::Allow(FederationOutcome::Provision)
);
}
#[test]
fn unverified_email_is_refused_even_with_a_local_match() {
assert_eq!(
evaluate_federation(&claims(Some("victim@b.com"), false), &ctx(false, true)),
FederationVerdict::Deny(FederationDenied::EmailNotVerified)
);
}
#[test]
fn missing_pieces_refused() {
assert_eq!(
evaluate_federation(&claims(None, true), &ctx(false, false)),
FederationVerdict::Deny(FederationDenied::NoEmail)
);
let no_sub = FederatedClaims {
subject: "",
email: Some("a@b.com"),
email_verified: true,
};
assert_eq!(
evaluate_federation(&no_sub, &ctx(false, true)),
FederationVerdict::Deny(FederationDenied::NoSubject)
);
}
}