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
//! Single Logout — OIDC **back-channel logout** ingestion (RFC: OpenID Connect
//! Back-Channel Logout 1.0).
//!
//! When a user logs out at the upstream IdP (or the IdP force-terminates the
//! session), the IdP POSTs a signed `logout_token` to the app's back-channel
//! endpoint. This handler verifies that token and revokes the corresponding
//! local sessions + refresh tokens, so an SSO logout actually ends the app
//! session instead of leaving a live token behind.
//!
//! The `logout_token` is a JWT much like an `id_token` but carrying the
//! `http://schemas.openid.net/event/backchannel-logout` event and a `sid`
//! (session id) and/or `sub`. We reuse the same [`IdTokenVerifier`] seam for
//! signature/`iss`/`aud` checks.

use std::sync::Arc;

use crate::error::{IdentityError, Result};
use crate::federation::IdTokenVerifier;
use crate::session::{RefreshStore, SessionIndex};

/// Handles inbound back-channel logout notifications from an upstream IdP.
pub struct BackChannelLogout {
    verifier: Arc<dyn IdTokenVerifier>,
    refresh: Arc<dyn RefreshStore>,
    sessions: Option<Arc<dyn SessionIndex>>,
}

impl BackChannelLogout {
    pub fn new(verifier: Arc<dyn IdTokenVerifier>, refresh: Arc<dyn RefreshStore>) -> Self {
        Self {
            verifier,
            refresh,
            sessions: None,
        }
    }

    /// Also revoke rows in the session/device index (so "active sessions" UIs
    /// reflect the IdP-initiated logout).
    pub fn with_sessions(mut self, sessions: Arc<dyn SessionIndex>) -> Self {
        self.sessions = Some(sessions);
        self
    }

    /// Process a `logout_token`. Verifies it (no nonce — logout tokens must not
    /// carry one per spec), confirms it is a back-channel logout event, and
    /// revokes every refresh token (and session) for the subject.
    ///
    /// Returns the `sub` that was logged out. Idempotent: replays simply revoke
    /// an already-empty set.
    pub async fn handle(&self, logout_token: &str) -> Result<String> {
        let verified = self.verifier.verify(logout_token, None).await?;

        // Must assert the back-channel logout event; a plain id_token must never
        // be accepted here (that would let a login token trigger a logout).
        let is_logout_event = verified
            .extra
            .get("events")
            .and_then(|v| v.as_object())
            .map(|m| m.contains_key("http://schemas.openid.net/event/backchannel-logout"))
            .unwrap_or(false);
        if !is_logout_event {
            return Err(IdentityError::InvalidToken);
        }
        // A logout_token must NOT contain a nonce.
        if verified.nonce.is_some() {
            return Err(IdentityError::InvalidToken);
        }

        let sub = verified.sub;
        self.refresh.revoke_all(&sub).await?;
        if let Some(sessions) = &self.sessions {
            sessions.revoke_all(&sub).await?;
        }
        Ok(sub)
    }
}