pas-external 0.18.0

Ppoppo Accounts System (PAS) external SDK — OAuth2 PKCE, JWT verification port, Axum middleware, session liveness
Documentation
//! Granted-vs-requested scope validation (RFC 6749 §5.1).
//!
//! A phantom scope marker types what the client **asks for**. What it
//! **gets** is a runtime fact the server decides, and the two can differ:
//! an authorization server may narrow a grant at the token endpoint, and
//! OAuth 2.1 §1.3.2 explicitly permits a *refresh* response to carry a
//! narrower scope than the token it renews. Without a check, a client whose
//! type says `Notify` can be running on a credential that lost `chat.ack`
//! — and the discrepancy surfaces as a puzzling 403 from the resource
//! server, arbitrarily far from the cause.
//!
//! [`ensure_covers`] is that check, in one place, used by every leg of the
//! SDK that receives a token response.

use ppoppo_sdk_core::scopes::ConsentScopes;

/// The server granted a scope that does not cover what was requested.
///
/// Terminal, never transient: retrying re-runs the same grant policy and
/// gets the same answer. The consumer's correct response is to treat the
/// credential as unusable at this tier — surface a signed-out state and
/// start a fresh authorization, or re-consent at a tier the user will grant.
#[derive(Debug, Clone, thiserror::Error)]
#[error(
    "granted scope does not cover the requested tier — missing [{}] (requested [{}], granted [{}])",
    missing.join(" "),
    requested.join(" "),
    granted.join(" ")
)]
pub struct ScopeNotCovered {
    /// The atoms the marker type requested.
    pub requested: Vec<String>,
    /// The atoms the server actually granted, as echoed.
    pub granted: Vec<String>,
    /// Requested atoms absent from the grant — the reason this failed.
    pub missing: Vec<String>,
}

/// Check that a token response's scope echo covers `S`'s atoms.
///
/// # Absence means success, per RFC 6749 §5.1
///
/// The `scope` parameter of a token response is **optional when the granted
/// scope is identical to the requested scope**. So `None` is the server
/// saying "you got exactly what you asked for", and must pass. Failing
/// closed on absence would reject spec-conformant servers outright — a real
/// risk for a published SDK, even though today's PAS always echoes.
///
/// # Containment, never equality
///
/// RFC 6749 §3.3 defines the scope parameter as a space-delimited,
/// **order-insignificant** list. Comparing strings would reject a grant that
/// merely echoes the atoms in a different order, or with different internal
/// whitespace. The check is therefore set containment — `granted ⊇
/// requested` — over whitespace-split atoms.
///
/// A *superset* passes: a server electing to grant more than was asked is
/// not an error, and the client simply never exercises the extra atoms (its
/// phantom type gates it to the tier it requested). Only a **narrowed**
/// grant — one missing a requested atom — fails.
///
/// # Errors
///
/// [`ScopeNotCovered`] when the echo is present and omits at least one atom
/// of `S::SCOPES`.
pub fn ensure_covers<S: ConsentScopes>(granted: Option<&str>) -> Result<(), ScopeNotCovered> {
    // RFC 6749 §5.1 — omitted echo means "granted as requested".
    let Some(granted) = granted else {
        return Ok(());
    };

    // §3.3 — space-delimited; treat any ASCII whitespace run as one
    // separator so a server padding its echo is not a false positive.
    let granted_atoms: Vec<&str> = granted.split_whitespace().collect();

    let missing: Vec<String> = S::SCOPES
        .iter()
        .filter(|requested| !granted_atoms.contains(*requested))
        .map(|s| (*s).to_owned())
        .collect();

    if missing.is_empty() {
        return Ok(());
    }

    Err(ScopeNotCovered {
        requested: S::SCOPES.iter().map(|s| (*s).to_owned()).collect(),
        granted: granted_atoms.into_iter().map(str::to_owned).collect(),
        missing,
    })
}

#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
    use super::*;

    struct Notify;
    impl ConsentScopes for Notify {
        const SCOPES: &'static [&'static str] = &["chat.read", "contact.read", "chat.ack"];
    }

    #[test]
    fn absent_echo_passes_as_granted_identically() {
        // RFC 6749 §5.1: the field is OPTIONAL when granted == requested.
        // Failing closed here would reject a spec-conformant server.
        assert!(ensure_covers::<Notify>(None).is_ok());
    }

    #[test]
    fn exact_echo_passes() {
        assert!(ensure_covers::<Notify>(Some("chat.read contact.read chat.ack")).is_ok());
    }

    #[test]
    fn reordered_echo_passes() {
        // §3.3 makes order insignificant — string equality would reject this.
        assert!(ensure_covers::<Notify>(Some("chat.ack chat.read contact.read")).is_ok());
    }

    #[test]
    fn irregular_whitespace_passes() {
        // Leading/trailing/repeated separators are still just separators.
        assert!(ensure_covers::<Notify>(Some("  chat.read   contact.read\tchat.ack ")).is_ok());
    }

    #[test]
    fn superset_grant_passes() {
        // A server granting more than asked is not an error; the phantom
        // type still gates the client to the tier it requested.
        assert!(
            ensure_covers::<Notify>(Some("chat.read contact.read chat.ack chat.send")).is_ok()
        );
    }

    #[test]
    fn narrowed_grant_is_a_typed_error_naming_what_is_missing() {
        let err = ensure_covers::<Notify>(Some("chat.read contact.read"))
            .expect_err("a grant missing chat.ack must not pass");
        assert_eq!(err.missing, ["chat.ack"]);
        assert_eq!(err.granted, ["chat.read", "contact.read"]);
        assert_eq!(err.requested, ["chat.read", "contact.read", "chat.ack"]);
        // The message must name the missing atom — this error lands in a
        // consumer's logs at the moment sign-in fails, and "scope mismatch"
        // alone would send them to the wrong place.
        assert!(err.to_string().contains("chat.ack"), "{err}");
    }

    #[test]
    fn empty_echo_fails_for_a_non_empty_request() {
        // An explicitly empty string is NOT the same as an absent field: the
        // server affirmatively said "nothing granted".
        let err = ensure_covers::<Notify>(Some("")).expect_err("empty grant covers nothing");
        assert_eq!(err.missing.len(), 3);
    }

    #[test]
    fn atoms_match_whole_not_by_prefix() {
        // `chat.read` must not be satisfied by `chat.readonly`. Substring
        // matching here would silently accept a strictly weaker grant.
        struct ReadOnly;
        impl ConsentScopes for ReadOnly {
            const SCOPES: &'static [&'static str] = &["chat.read"];
        }
        assert!(ensure_covers::<ReadOnly>(Some("chat.readonly")).is_err());
        assert!(ensure_covers::<ReadOnly>(Some("chat.read")).is_ok());
    }
}