scout_rs 0.95.95

A Rust client for the synchornizing state with the Scout database
Documentation
//! Client abilities JWT minting (user-scoped, mirrors pubsub token minting).

use std::collections::HashMap;
use std::fmt;
use std::sync::{Arc, Mutex};

use anyhow::{anyhow, Context, Result};
use jsonwebtoken::{decode, decode_header, Algorithm, DecodingKey, Header, Validation};
use serde::Deserialize;

use crate::auth::UserAccessConfig;
use crate::constants::rpc;
use crate::db_client::DatabaseConfig;

pub const CLIENT_ABILITIES_JWT_AUDIENCE: &str = "scout-client";

/// Per-herd ability claim carried by a client abilities token.
#[derive(Debug, Clone, Deserialize)]
pub struct ClientAbilitiesHerdClaim {
    pub herd_id: i64,
    pub slug: String,
    #[serde(default)]
    pub abilities: Vec<String>,
}

#[derive(Debug, Clone, Deserialize)]
pub struct ClientAbilitiesTokenMint {
    pub token: String,
    #[serde(default)]
    pub platform_superadmin: bool,
    #[serde(default)]
    pub herds: Vec<ClientAbilitiesHerdClaim>,
    pub iat: i64,
    pub exp: i64,
}

#[derive(Debug, Clone, Deserialize)]
pub struct ClientAbilitiesJwkPublicKey {
    pub kid: String,
    pub jwk: serde_json::Value,
}

#[derive(Clone)]
pub struct ClientAbilitiesTokenConfig {
    cache: Arc<Mutex<HashMap<String, DecodingKey>>>,
}

impl ClientAbilitiesTokenConfig {
    /// Loads verifier keys from public ES256 JWK JSON.
    pub fn from_public_jwks(jwks: &[&str]) -> Result<Self> {
        let mut cache = HashMap::new();
        for jwk_json in jwks {
            let jwk: ClientAbilitiesJwk = serde_json::from_str(jwk_json)?;
            let kid = jwk
                .kid
                .as_deref()
                .ok_or_else(|| anyhow!("public JWK must include kid"))?;
            cache.insert(kid.to_string(), decoding_key_from_jwk(&jwk)?);
        }
        Ok(Self {
            cache: Arc::new(Mutex::new(cache)),
        })
    }

    pub fn from_public_keys(keys: &[ClientAbilitiesJwkPublicKey]) -> Result<Self> {
        let config = Self {
            cache: Arc::new(Mutex::new(HashMap::new())),
        };
        config.load_public_keys(keys)?;
        Ok(config)
    }

    pub fn sync_public_keys_from_database(&self, db: &DatabaseConfig) -> Result<()> {
        self.load_public_keys(&fetch_client_abilities_jwt_public_keys(db)?)
    }

    fn load_public_keys(&self, keys: &[ClientAbilitiesJwkPublicKey]) -> Result<()> {
        let mut loaded = HashMap::new();
        for key in keys {
            if !valid_kid(&key.kid) {
                return Err(anyhow!("invalid client abilities JWT kid: {}", key.kid));
            }
            let jwk: ClientAbilitiesJwk = serde_json::from_value(key.jwk.clone())?;
            loaded.insert(key.kid.clone(), decoding_key_from_jwk(&jwk)?);
        }
        *self.cache.lock().expect("client abilities key cache") = loaded;
        Ok(())
    }

    fn resolve_key(&self, header: &Header) -> Result<DecodingKey, ClientAbilitiesTokenError> {
        if header.alg != Algorithm::ES256 {
            return Err(ClientAbilitiesTokenError::Invalid);
        }
        let kid = header
            .kid
            .as_deref()
            .filter(|kid| valid_kid(kid))
            .ok_or(ClientAbilitiesTokenError::Invalid)?;
        self.cache
            .lock()
            .expect("client abilities key cache")
            .get(kid)
            .cloned()
            .ok_or(ClientAbilitiesTokenError::Invalid)
    }
}

#[derive(Debug, Clone, Deserialize)]
pub struct ClientAbilitiesTokenClaims {
    #[serde(rename = "sub")]
    pub subject: String,
    #[serde(default)]
    pub platform_superadmin: bool,
    #[serde(default)]
    pub herds: Vec<ClientAbilitiesHerdClaim>,
    pub iat: i64,
    pub exp: i64,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ClientAbilitiesTokenError {
    Invalid,
    Expired,
}

impl fmt::Display for ClientAbilitiesTokenError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            ClientAbilitiesTokenError::Invalid => write!(f, "invalid client abilities token"),
            ClientAbilitiesTokenError::Expired => write!(f, "expired client abilities token"),
        }
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MintClientAbilitiesTokenError {
    MissingUserAccessToken,
    MissingSupabaseApiKey,
    Unauthorized,
    Forbidden,
    Http { status: u16, body: String },
    InvalidResponse,
}

impl fmt::Display for MintClientAbilitiesTokenError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            MintClientAbilitiesTokenError::MissingUserAccessToken => {
                write!(
                    f,
                    "user access token required to mint client abilities token"
                )
            }
            MintClientAbilitiesTokenError::MissingSupabaseApiKey => {
                write!(
                    f,
                    "supabase API key required to mint client abilities token"
                )
            }
            MintClientAbilitiesTokenError::Unauthorized => write!(f, "unauthorized"),
            MintClientAbilitiesTokenError::Forbidden => write!(f, "forbidden"),
            MintClientAbilitiesTokenError::Http { status, body } => {
                write!(
                    f,
                    "mint-client-abilities-token failed: HTTP {status} - {body}"
                )
            }
            MintClientAbilitiesTokenError::InvalidResponse => {
                write!(f, "mint-client-abilities-token returned invalid response")
            }
        }
    }
}

pub fn fetch_client_abilities_jwt_public_keys(
    db: &DatabaseConfig,
) -> Result<Vec<ClientAbilitiesJwkPublicKey>> {
    let api_key = db
        .service_role_key
        .as_deref()
        .filter(|_| db.use_service_key)
        .unwrap_or_else(|| db.get_supabase_api_key());
    let url = format!(
        "{}/rpc/{}",
        db.get_rest_url().trim_end_matches('/'),
        rpc::GET_CLIENT_ABILITIES_JWT_PUBLIC_KEYS
    );
    let response = reqwest::blocking::Client::new()
        .post(url)
        .header("apikey", api_key)
        .header("Authorization", format!("Bearer {api_key}"))
        .header("Content-Type", "application/json")
        .body("{}")
        .send()
        .context("get_client_abilities_jwt_public_keys request")?;

    let status = response.status();
    let body = response
        .text()
        .context("get_client_abilities_jwt_public_keys response body")?;
    if !status.is_success() {
        return Err(anyhow!(
            "get_client_abilities_jwt_public_keys failed: HTTP {status} - {body}"
        ));
    }

    serde_json::from_str(&body).context("parse get_client_abilities_jwt_public_keys response")
}

pub fn verify_client_abilities_token(
    config: &ClientAbilitiesTokenConfig,
    token: &str,
) -> Result<ClientAbilitiesTokenClaims, ClientAbilitiesTokenError> {
    let token = token.trim();
    let header = decode_header(token).map_err(|_| ClientAbilitiesTokenError::Invalid)?;
    let key = config.resolve_key(&header)?;

    let mut validation = Validation::new(Algorithm::ES256);
    validation.set_audience(&[CLIENT_ABILITIES_JWT_AUDIENCE]);

    let data = decode::<ClientAbilitiesTokenClaims>(token, &key, &validation).map_err(|error| {
        if matches!(
            error.kind(),
            jsonwebtoken::errors::ErrorKind::ExpiredSignature
        ) {
            ClientAbilitiesTokenError::Expired
        } else {
            ClientAbilitiesTokenError::Invalid
        }
    })?;
    Ok(data.claims)
}

/// Mints a client abilities token for the calling user via the `mint-client-abilities-token`
/// edge function. Callers with no active herd membership receive `Forbidden`.
pub fn mint_client_abilities_token(
    config: &UserAccessConfig,
    user_access_token: &str,
) -> Result<ClientAbilitiesTokenMint, MintClientAbilitiesTokenError> {
    let token = user_access_token.trim();
    if token.is_empty() {
        return Err(MintClientAbilitiesTokenError::MissingUserAccessToken);
    }
    if config.supabase_anon_key.is_empty() {
        return Err(MintClientAbilitiesTokenError::MissingSupabaseApiKey);
    }

    let url = format!(
        "{}/mint-client-abilities-token",
        functions_url(&config.rest_url)
    );

    let response = reqwest::blocking::Client::new()
        .post(url)
        .header("apikey", &config.supabase_anon_key)
        .header("Authorization", format!("Bearer {token}"))
        .send()
        .map_err(|e| MintClientAbilitiesTokenError::Http {
            status: 0,
            body: e.to_string(),
        })?;

    let status = response.status();
    let body = response
        .text()
        .map_err(|e| MintClientAbilitiesTokenError::Http {
            status: status.as_u16(),
            body: e.to_string(),
        })?;

    match status.as_u16() {
        401 => Err(MintClientAbilitiesTokenError::Unauthorized),
        403 => Err(MintClientAbilitiesTokenError::Forbidden),
        _ if !status.is_success() => Err(MintClientAbilitiesTokenError::Http {
            status: status.as_u16(),
            body,
        }),
        _ => {
            let mint: ClientAbilitiesTokenMint = serde_json::from_str(&body)
                .map_err(|_| MintClientAbilitiesTokenError::InvalidResponse)?;
            if mint.token.is_empty() {
                Err(MintClientAbilitiesTokenError::InvalidResponse)
            } else {
                Ok(mint)
            }
        }
    }
}

/// Derives the edge functions base URL from a PostgREST `rest_url` ending in `/rest/v1`.
fn functions_url(rest_url: &str) -> String {
    let rest = rest_url.trim_end_matches('/');
    let base = rest.strip_suffix("/rest/v1").unwrap_or(rest);
    format!("{base}/functions/v1")
}

#[derive(Debug, Deserialize)]
struct ClientAbilitiesJwk {
    kid: Option<String>,
    kty: String,
    #[serde(rename = "crv")]
    curve: Option<String>,
    alg: Option<String>,
    x: Option<String>,
    y: Option<String>,
    #[serde(rename = "d")]
    private_scalar: Option<String>,
}

fn decoding_key_from_jwk(jwk: &ClientAbilitiesJwk) -> Result<DecodingKey> {
    if jwk.private_scalar.is_some() {
        return Err(anyhow!("public JWK must not include d"));
    }
    if jwk.kty != "EC"
        || jwk.curve.as_deref() != Some("P-256")
        || jwk.alg.as_deref().is_some_and(|alg| alg != "ES256")
    {
        return Err(anyhow!("client abilities JWK must use ES256"));
    }
    DecodingKey::from_ec_components(
        jwk.x.as_ref().context("EC JWK missing x")?,
        jwk.y.as_ref().context("EC JWK missing y")?,
    )
    .context("invalid client abilities JWK")
}

fn valid_kid(kid: &str) -> bool {
    !kid.is_empty()
        && kid
            .chars()
            .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
}