entropy-auth 2026.7.31

Authentication and authorization for Entropy Softworks server and API projects
//! Inbound federation (external-IdP login) JIT-provisioning + account-linking
//! verdict.
//!
//! When a user returns from an external IdP ("Sign in with Google/…"), the
//! broker has an external `subject` + profile claims and must decide: sign in
//! an already-linked local account, link this identity to an existing local
//! account, provision a new one, or refuse. That decision is pure — it is this
//! verdict; the caller does the directory lookups (is this external subject
//! already linked? does a local user own this verified email?) and the writes.
//!
//! # Security — account-takeover safety
//!
//! The one rule that matters: NEVER link or provision on an **unverified**
//! email. An attacker who controls an external IdP account with an
//! unverified `email` claim set to a victim's address must not thereby seize
//! the victim's local account. So auto-linking by email requires
//! `email_verified == true` from the IdP; an already-established link (by
//! stable `subject`) is trusted, but a fresh email match is not unless the IdP
//! asserts the email is verified.
#![allow(clippy::doc_markdown)]

use core::fmt;

/// The external-IdP claims the broker extracted from the ID token.
#[derive(Debug, Clone, Copy)]
pub struct FederatedClaims<'a> {
    /// The IdP's stable subject identifier (`sub`) for this user.
    pub subject: &'a str,
    /// The `email` claim, if present.
    pub email: Option<&'a str>,
    /// The IdP's `email_verified` claim.
    pub email_verified: bool,
}

/// Directory context the caller resolves before evaluating.
#[derive(Debug, Clone, Copy)]
pub struct FederationContext {
    /// This external `(connector, subject)` is already linked to a local user.
    pub already_linked: bool,
    /// A local user owns this email AND the IdP asserts the email is verified
    /// (the caller ANDs its lookup with `claims.email_verified`).
    pub local_user_with_verified_email: bool,
}

/// Why a federated login is refused.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum FederationDenied {
    /// The IdP returned no stable subject.
    NoSubject,
    /// No email claim, so we can neither match nor safely provision.
    NoEmail,
    /// The email is not verified by the IdP — refusing to link/provision
    /// (account-takeover defence).
    EmailNotVerified,
}

impl FederationDenied {
    /// A short, stable diagnostic string.
    #[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 {}

/// What the caller should do with an accepted federated identity.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum FederationOutcome {
    /// The external identity is already linked — sign that local user in.
    ExistingLink,
    /// A local user owns this verified email — link the external identity to
    /// it, then sign them in.
    LinkByEmail,
    /// No local account — provision one (JIT) and link.
    Provision,
}

/// The verdict of evaluating a returning federated identity.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum FederationVerdict {
    /// Proceed with the given outcome.
    Allow(FederationOutcome),
    /// Refuse, with the reason.
    Deny(FederationDenied),
}

/// Decide how to handle a returning federated identity. Pure.
///
/// Order: an existing link (by stable subject) wins and is trusted; otherwise
/// a usable email is REQUIRED and must be IdP-verified before any link/provision
/// (takeover defence); a verified-email match links, else a new account is
/// provisioned.
#[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);
    }
    // From here a fresh identity: an email is required, and it MUST be verified
    // before we link it to — or provision — any local account.
    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)
        );
        // A link is trusted even if the email is (now) unverified.
        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() {
        // SECURITY: an attacker's unverified email claim matching a victim's
        // address must NOT link to the victim's account.
        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)
        );
    }
}