polyc-web-session 2026.9.0

Browser session establishment (ADR 0007): one-time login challenges, the shared session cookie, and both the persona-passkey and wallet-passkey ceremonies that mint a polyc_crypto::session token. Consumed by polyc-control-plane; carries no ceremony-consumer naming of its own.
//! The credential-agnostic half of a `WebAuthn`-rooted login ceremony: mint
//! a one-time login challenge, and redeem it single-use.
//! [`passkey_login`](crate::passkey_login) builds directly on this;
//! [`wallet_login`](crate::wallet_login) does not
//! (see that module's doc for why it has no `rp_id`/`origin` to check).

use std::sync::Arc;

use polyc_ceremony::{
    CeremonyAuthority, CeremonyClassification, CeremonyError, CeremonyKind, CeremonyPayload,
    CeremonyToken, MAX_TTL_MS, MintCeremony,
};
#[cfg(any(test, feature = "test-util"))]
use polyc_crypto::session::RevokedTokens;

const LOGIN_CHALLENGE_SCHEMA: &[u8] = b"\x01";

/// `WebAuthn` relying-party id + origin for a login PAGE.
///
/// Independent of any credential's own enrollment `rp_id`/`origin`. A login
/// ceremony verifies a posted assertion against THIS config and the resolved
/// credential's stored public key, never against the enrollment record's own
/// fields.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WebAuthnLoginConfig {
    /// The relying-party id (the login page's registrable host, no scheme,
    /// no port).
    pub rp_id: String,
    /// The full login-page origin (scheme, host, optional port).
    pub origin: String,
}

impl WebAuthnLoginConfig {
    /// Derive the `WebAuthn` config from a login page's public `base_url`,
    /// optionally overriding `rp_id` with `rp_id_override`.
    ///
    /// `origin` is ALWAYS the login page's own tuple origin in ASCII
    /// serialization regardless of `rp_id_override`: only the relying-party
    /// id widens, never the origin a login assertion must have been signed
    /// under.
    ///
    /// `rp_id_override`:
    ///
    /// - `None`: `rp_id` is the URL's host as [`url::Url::host_str`]
    ///   serializes it.
    /// - `Some(rp_id)`: accepted ONLY when it is a **registrable suffix** of
    ///   the URL's host — the host equals `rp_id` outright, or the host ends
    ///   with `.` followed by `rp_id` (e.g. host `explore.polychrome.sh`
    ///   accepts override `polychrome.sh`, the typical "share one passkey
    ///   across every subdomain" case). A non-suffix override is REJECTED —
    ///   this function returns `None` rather than silently falling back to
    ///   the un-overridden host, so a bad `rp_id` fails closed instead of
    ///   quietly reverting to a posture the operator did not ask for.
    ///
    /// Returns `None` (independent of `rp_id_override`) when the URL does not
    /// parse, has no host, or has only an opaque (non-web-scheme) origin.
    #[must_use]
    pub fn from_base_url(base_url: &str, rp_id_override: Option<&str>) -> Option<Self> {
        let url = url::Url::parse(base_url).ok()?;
        let host = url.host_str()?;
        if host.is_empty() {
            return None;
        }
        let origin = url.origin();
        if !origin.is_tuple() {
            return None;
        }
        let rp_id = match rp_id_override {
            Some(rp_id) if host == rp_id || host.ends_with(&format!(".{rp_id}")) => {
                rp_id.to_owned()
            }
            // A configured-but-non-suffix override is a misconfiguration,
            // fail closed — see the doc above. `None` (unset) falls through
            // to the same `_` arm, which is exactly the "no override" path.
            Some(_) => return None,
            None => host.to_owned(),
        };
        Some(Self {
            rp_id,
            origin: origin.ascii_serialization(),
        })
    }
}

/// A freshly minted login challenge: the opaque one-time `token` the browser
/// posts back to redeem, and the `WebAuthn` `challenge_b64` string it passes
/// to `navigator.credentials.get`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LoginChallenge {
    /// Opaque one-time session token, posted back with the assertion.
    pub token: String,
    /// The `WebAuthn` challenge string for `navigator.credentials.get`.
    pub challenge_b64: String,
}

/// See the module doc.
pub struct LoginChallengeRegistry {
    /// Durable one-time authority. The challenge string itself is never
    /// stored; it is deterministically re-derived from the caller-held token.
    ceremonies: Arc<dyn CeremonyAuthority>,
    /// `WebAuthn` relying-party id + origin for the login page.
    config: WebAuthnLoginConfig,
    /// Legacy denylist retained only for compatibility fixtures. Production
    /// logout and authorization use State through `BrowserSessionAuthority`.
    #[cfg(any(test, feature = "test-util"))]
    revoked: Arc<RevokedTokens>,
}

impl LoginChallengeRegistry {
    /// Build the registry from a login page's public `base_url` and an
    /// optional `rp_id_override`, or `None` when
    /// [`WebAuthnLoginConfig::from_base_url`] fails closed.
    #[must_use]
    pub fn from_base_url(
        base_url: &str,
        rp_id_override: Option<&str>,
        ceremonies: Arc<dyn CeremonyAuthority>,
    ) -> Option<Self> {
        let config = WebAuthnLoginConfig::from_base_url(base_url, rp_id_override)?;
        Some(Self {
            ceremonies,
            config,
            #[cfg(any(test, feature = "test-util"))]
            revoked: Arc::new(RevokedTokens::new()),
        })
    }

    /// The `WebAuthn` config this registry's challenges are scoped to.
    #[must_use]
    pub const fn config(&self) -> &WebAuthnLoginConfig {
        &self.config
    }

    /// Mint a one-time login challenge.
    ///
    /// The `WebAuthn` challenge string is `polyc_passkey::challenge_b64` of
    /// the freshly minted one-time token's OWN bytes — no separate random
    /// canonical to generate or store: the challenge is structurally bound to
    /// the one-time session token that redeems it.
    ///
    /// # Errors
    ///
    /// Returns the durable authority failure when no token was safely minted.
    pub async fn challenge(&self, now_ms: u64) -> Result<LoginChallenge, CeremonyError> {
        let token = self
            .ceremonies
            .mint(MintCeremony {
                kind: CeremonyKind::PasskeyLogin,
                schema_version: 1,
                classification: CeremonyClassification::Internal,
                payload: CeremonyPayload::new(LOGIN_CHALLENGE_SCHEMA.to_vec()),
                now_ms,
                ttl_ms: MAX_TTL_MS,
            })
            .await?;
        let challenge_b64 = polyc_passkey::challenge_b64(token.expose().as_bytes());
        Ok(LoginChallenge {
            token: token.expose().to_owned(),
            challenge_b64,
        })
    }

    /// Redeem `challenge_token` single-use: `None` when it was never minted,
    /// already redeemed, or has aged out — the caller collapses this into its
    /// own "challenge expired" error variant.
    ///
    /// # Errors
    ///
    /// Returns the durable single-use verdict or an authority failure.
    pub async fn redeem(&self, challenge_token: &str, now_ms: u64) -> Result<(), CeremonyError> {
        let payload = self
            .ceremonies
            .redeem(
                CeremonyKind::PasskeyLogin,
                CeremonyToken::new(challenge_token),
                now_ms,
            )
            .await?;
        if payload.as_bytes() != LOGIN_CHALLENGE_SCHEMA {
            return Err(CeremonyError::Invalid);
        }
        Ok(())
    }

    /// Legacy fixture logout.
    #[cfg(any(test, feature = "test-util"))]
    pub fn logout(&self, session_token: &str) {
        self.revoked.revoke(session_token);
    }

    /// Legacy fixture denylist.
    #[cfg(any(test, feature = "test-util"))]
    #[must_use]
    pub const fn revoked(&self) -> &Arc<RevokedTokens> {
        &self.revoked
    }
}

#[cfg(test)]
mod tests {
    #![allow(clippy::pedantic, clippy::nursery, missing_docs)]

    use super::*;
    use polyc_ceremony::test_util::MemoryCeremonies;

    const NOW: u64 = 1_700_000_000_000;
    const BASE_URL: &str = "https://explore.polychrome.test";

    fn registry() -> LoginChallengeRegistry {
        LoginChallengeRegistry::from_base_url(BASE_URL, None, Arc::new(MemoryCeremonies::new()))
            .expect("valid config")
    }

    // ---- WebAuthnLoginConfig ------------------------------------------------

    #[test]
    fn config_from_base_url_derives_rp_id_and_origin() {
        let config = WebAuthnLoginConfig::from_base_url(BASE_URL, None)
            .expect("a well-formed https URL configures");
        assert_eq!(config.rp_id, "explore.polychrome.test");
        assert_eq!(config.origin, "https://explore.polychrome.test");
    }

    #[test]
    fn config_from_base_url_fails_closed_on_an_opaque_or_unparseable_url() {
        assert!(WebAuthnLoginConfig::from_base_url("not a url", None).is_none());
        assert!(WebAuthnLoginConfig::from_base_url("data:text/plain,hi", None).is_none());
        assert!(WebAuthnLoginConfig::from_base_url("", None).is_none());
    }

    // ---- rp_id override (slice 3d, POLYCHROME_WEBAUTHN_RP_ID) -------------

    /// Unset override (`None`, today's behavior) → `rp_id` is the login
    /// host, unchanged.
    #[test]
    fn rp_id_override_unset_keeps_the_host_as_rp_id() {
        let config = WebAuthnLoginConfig::from_base_url(BASE_URL, None)
            .expect("a well-formed https URL with no override configures");
        assert_eq!(config.rp_id, "explore.polychrome.test");
        assert_eq!(config.origin, "https://explore.polychrome.test");
    }

    /// Override set to a registrable PARENT of the login host (the intended
    /// "share one passkey across every subdomain" case) → `rp_id` widens to
    /// the parent; `origin` stays the login page's own origin, unchanged.
    #[test]
    fn rp_id_override_set_to_a_registrable_parent_widens_rp_id_origin_unchanged() {
        let config = WebAuthnLoginConfig::from_base_url(BASE_URL, Some("polychrome.test"))
            .expect("a registrable-parent override configures");
        assert_eq!(config.rp_id, "polychrome.test");
        assert_eq!(
            config.origin, "https://explore.polychrome.test",
            "origin must stay the login page's own origin even when rp_id widens"
        );
    }

    /// Override set to the host itself (the degenerate "parent == host"
    /// case) is also an accepted registrable suffix.
    #[test]
    fn rp_id_override_equal_to_the_host_is_accepted() {
        let config = WebAuthnLoginConfig::from_base_url(BASE_URL, Some("explore.polychrome.test"))
            .expect("an override equal to the host configures");
        assert_eq!(config.rp_id, "explore.polychrome.test");
    }

    /// Override that is NOT a registrable suffix of the login host (neither
    /// equal nor a `.`-joined parent) fails closed — `None`, not a silent
    /// fallback to the un-overridden host and not a verification against a
    /// relying-party id the browser was never scoped to trust.
    #[test]
    fn rp_id_override_non_suffix_fails_closed() {
        assert!(WebAuthnLoginConfig::from_base_url(BASE_URL, Some("evil.example")).is_none());
        assert!(
            WebAuthnLoginConfig::from_base_url(
                BASE_URL,
                Some("explore.polychrome.test.evil.example")
            )
            .is_none()
        );
        assert!(WebAuthnLoginConfig::from_base_url(BASE_URL, Some("olychrome.test")).is_none());
    }

    // ---- LoginChallengeRegistry ---------------------------------------------

    #[tokio::test]
    async fn registry_mints_and_redeems_one_durable_challenge() {
        let registry = registry();
        let challenge = registry.challenge(NOW).await.expect("mint challenge");
        registry
            .redeem(&challenge.token, NOW + 1)
            .await
            .expect("redeem once");
        assert_eq!(
            registry.redeem(&challenge.token, NOW + 2).await,
            Err(CeremonyError::Spent)
        );
    }

    #[test]
    fn registry_logout_revokes_a_previously_minted_session_token() {
        use polyc_crypto::session::{SessionScope, SessionSubject, mint_session, verify_session};
        use polyc_crypto::signing_role::SessionSigner;

        let registry = registry();
        let signer =
            SessionSigner::from_key_bytes(&[7u8; 32]).expect("valid 32-byte ed25519 key material");
        let token = mint_session(
            &signer,
            &SessionSubject::Wallet {
                wallet_address: "0xabc".to_owned(),
                persona_id: None,
            },
            &[SessionScope::WalletManage],
            NOW,
            3_600_000,
        );

        assert!(
            verify_session(&signer.public_key_bytes(), &token, NOW, registry.revoked()).is_some(),
            "a freshly minted session verifies before logout"
        );

        registry.logout(&token);

        assert!(
            verify_session(&signer.public_key_bytes(), &token, NOW, registry.revoked()).is_none(),
            "the same session must be rejected once revoked"
        );
    }
}