arcly-http-identity 0.9.0

CIAM building-block engine for arcly-http: identity store traits, password hashing, MFA (TOTP), passwordless & account lifecycle, risk signals, and an OIDC provider — storage-agnostic, zero-lock, all I/O behind traits.
Documentation
//! Secure federated login ("Sign in with Google / Facebook / an enterprise
//! IdP"). This is the **relying-party** side — the app consumes an external
//! identity — as opposed to [`crate::oidc`], which *is* an identity provider.
//!
//! The design closes the two account-takeover holes a naive "find user by
//! email → mint token" callback opens:
//!
//! 1. **Untrusted provider email** — a provider that returns an *unverified*
//!    email must never be allowed to claim an existing account. We only ever
//!    consider `email_verified == true` for linking.
//! 2. **Pre-hijacking** — an attacker pre-registers a local (password) account
//!    with the victim's address, then the victim signs in with a federated
//!    provider. We refuse to auto-link into a local account whose *own* email
//!    is unverified, and — by default — require an explicit, logged-in linking
//!    step even when both sides are verified ([`LinkingConfig`]).
//!
//! Every decision flows through the **same minting path** as password login
//! (`IdentityService::mint`), so scopes, TTLs, tenant binding, and refresh
//! rotation can never diverge between login methods.

pub mod audit;
pub mod homerealm;
mod idtoken;
pub mod slo;

#[cfg(feature = "saml")]
pub mod saml;
#[cfg(feature = "scim")]
pub mod scim;

pub use audit::AuditPipelineFederationSink;
pub use homerealm::{IdpConfig, IdpKind, IdpRegistry};
pub use idtoken::{IdTokenVerifier, JwtIdTokenVerifier, VerifiedIdToken};
pub use slo::BackChannelLogout;

use std::collections::HashMap;
use std::sync::RwLock;

use async_trait::async_trait;
use serde::{Deserialize, Serialize};

use crate::error::Result;
use crate::model::TokenResponse;

/// A normalised profile from an external identity provider, ready to feed
/// [`IdentityService::login_federated`](crate::identity::IdentityService::login_federated).
///
/// Build it from your `OAuth2Provider`'s user-info **and** the provider's
/// verified-email signal (never assume verified), or from a
/// [`VerifiedIdToken`] via [`FederatedProfile::from_id_token`].
#[derive(Debug, Clone)]
pub struct FederatedProfile {
    pub provider: String,
    /// The provider's stable unique id for this account (Google `sub`, GitHub
    /// numeric id, …). **Primary** linking key — never the email.
    pub provider_id: String,
    pub email: Option<String>,
    /// Whether the *provider* asserts the email is verified. Gate all
    /// email-based linking on this.
    pub email_verified: bool,
    pub name: Option<String>,
    /// Extra claims to carry onto the identity's attributes (e.g. IdP groups).
    pub attributes: serde_json::Map<String, serde_json::Value>,
}

impl FederatedProfile {
    /// Build from the framework's OAuth2 user-info. `email_verified` must be
    /// supplied explicitly — the OAuth2 user-info type carries no such signal,
    /// and defaulting it to `true` is exactly the takeover bug.
    pub fn from_oauth(
        provider: impl Into<String>,
        provider_id: impl Into<String>,
        email: Option<String>,
        email_verified: bool,
    ) -> Self {
        Self {
            provider: provider.into(),
            provider_id: provider_id.into(),
            email,
            email_verified,
            name: None,
            attributes: Default::default(),
        }
    }

    /// Build from a verified inbound OIDC `id_token`. `email_verified` is taken
    /// from the token's `email_verified` claim.
    pub fn from_id_token(provider: impl Into<String>, tok: &VerifiedIdToken) -> Self {
        Self {
            provider: provider.into(),
            provider_id: tok.sub.clone(),
            email: tok.email.clone(),
            email_verified: tok.email_verified,
            name: tok.name.clone(),
            attributes: tok.extra.clone(),
        }
    }
}

/// A persisted link between an external identity and a local user.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FederatedIdentity {
    pub provider: String,
    pub provider_id: String,
    pub user_id: String,
    pub linked_at: u64,
}

/// Storage for federated-identity links. Back with a table carrying a unique
/// constraint on `(provider, provider_id)`.
#[async_trait]
pub trait FederatedIdentityStore: Send + Sync + 'static {
    /// Resolve the local `user_id` a given external identity is linked to.
    async fn find_user(&self, provider: &str, provider_id: &str) -> Result<Option<String>>;
    /// Create a link. Must be idempotent / reject duplicates at the store level.
    async fn link(&self, link: FederatedIdentity) -> Result<()>;
    /// All external identities linked to a user (for account-management UIs).
    async fn list_for_user(&self, user_id: &str) -> Result<Vec<FederatedIdentity>>;
    /// Remove a link (user unlinks a provider).
    async fn unlink(&self, provider: &str, provider_id: &str) -> Result<()>;
}

/// Account-linking + provisioning policy. The defaults are the **enterprise-safe**
/// posture: JIT-provision brand-new users, but never silently merge a federated
/// login into a pre-existing local account.
#[derive(Debug, Clone)]
pub struct LinkingConfig {
    /// Create a new local user the first time an unknown external identity signs
    /// in (Just-In-Time provisioning). When `false`, unknown users are rejected
    /// (pre-provisioned / invited orgs).
    pub jit_provisioning: bool,
    /// Automatically link a first-time federated identity to an existing local
    /// account when **both** the provider email and the local account email are
    /// verified. **Default `false`**: instead return
    /// [`FederatedOutcome::LinkRequired`] so the app runs an explicit,
    /// authenticated "connect your account" step — the only takeover-proof flow.
    pub auto_link_verified_email: bool,
}

impl Default for LinkingConfig {
    fn default() -> Self {
        Self {
            jit_provisioning: true,
            auto_link_verified_email: false,
        }
    }
}

/// The outcome of a federated login attempt.
#[derive(Debug, Clone, Serialize)]
#[serde(untagged)]
pub enum FederatedOutcome {
    /// Fully authenticated — tokens issued.
    Authenticated(TokenResponse),
    /// A second factor (or risk step-up) is required; complete via
    /// `IdentityService::verify_mfa` with `challenge_id`.
    MfaChallenge {
        mfa_required: bool,
        challenge_id: String,
        methods: Vec<String>,
    },
    /// The verified email matches an existing local account but policy forbids
    /// silent linking. The app must have the user authenticate to that account
    /// (or verify ownership) and then call
    /// `IdentityService::link_federated` before this provider can sign them in.
    LinkRequired { email: String },
}

// ─── Audit ──────────────────────────────────────────────────────────────────

/// What the engine decided for one federated attempt — the audit-relevant fact.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FederationDecision {
    /// Known external identity signed in.
    ReturningLogin,
    /// New local user created and linked (JIT).
    Provisioned,
    /// Linked to an existing local account (only when policy allows).
    AutoLinked,
    /// Held for an explicit linking step.
    LinkRequired,
    /// Rejected (invite-only, unverified email with no JIT, risk deny).
    Denied,
}

/// One federated-login audit event. Bridge this to the framework's `AuditSink`
/// (hash-chained) in your app so every social/SSO login is on the trail.
#[derive(Debug, Clone)]
pub struct FederationEvent {
    pub provider: String,
    pub provider_id: String,
    pub user_id: Option<String>,
    pub email: Option<String>,
    pub tenant: Option<String>,
    pub decision: FederationDecision,
    pub ip: Option<String>,
    /// The originating request's W3C trace id (`ctx.trace_id_hex()`), when the
    /// caller can supply it, so the audit record correlates to the request span
    /// instead of abusing `trace_id` for something else. `None` off-request.
    pub trace_id: Option<String>,
}

/// Sink for [`FederationEvent`]s. Optional — provide via the builder to record
/// every federated decision.
#[async_trait]
pub trait FederationAudit: Send + Sync + 'static {
    async fn record(&self, event: FederationEvent);
}

// ─── In-memory store (tests / dev) ──────────────────────────────────────────

/// In-memory [`FederatedIdentityStore`] — tests / dev only.
#[derive(Default)]
pub struct MemoryFederatedStore {
    // (provider, provider_id) -> FederatedIdentity
    inner: RwLock<HashMap<(String, String), FederatedIdentity>>,
}

impl MemoryFederatedStore {
    pub fn new() -> Self {
        Self::default()
    }
}

#[async_trait]
impl FederatedIdentityStore for MemoryFederatedStore {
    async fn find_user(&self, provider: &str, provider_id: &str) -> Result<Option<String>> {
        Ok(self
            .inner
            .read()
            .unwrap()
            .get(&(provider.to_owned(), provider_id.to_owned()))
            .map(|l| l.user_id.clone()))
    }
    async fn link(&self, link: FederatedIdentity) -> Result<()> {
        self.inner
            .write()
            .unwrap()
            .insert((link.provider.clone(), link.provider_id.clone()), link);
        Ok(())
    }
    async fn list_for_user(&self, user_id: &str) -> Result<Vec<FederatedIdentity>> {
        Ok(self
            .inner
            .read()
            .unwrap()
            .values()
            .filter(|l| l.user_id == user_id)
            .cloned()
            .collect())
    }
    async fn unlink(&self, provider: &str, provider_id: &str) -> Result<()> {
        self.inner
            .write()
            .unwrap()
            .remove(&(provider.to_owned(), provider_id.to_owned()));
        Ok(())
    }
}