fluidattacks-core 0.7.0

Fluid Attacks Core Library
Documentation
use std::env;
use std::time::Duration;

use serde::Deserialize;

const TOKEN_ENV: &str = "INTEGRATES_API_TOKEN";
const API_ENDPOINT: &str = "https://app.fluidattacks.com/api";
const ME_QUERY: &str = r#"{"query":"query{me{userEmail}}"}"#;
const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);

/// The authenticated stakeholder identity. The credential is used only to
/// validate and is never retained, returned, or printed.
#[derive(Debug, Clone)]
pub struct Session {
    pub email: String,
}

#[derive(Debug)]
pub enum AuthError {
    NotAuthenticated,
    Invalid,
    Transport(String),
}

impl std::fmt::Display for AuthError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::NotAuthenticated => write!(
                f,
                "not authenticated: set {TOKEN_ENV} to a valid Fluid Attacks API token"
            ),
            Self::Invalid => write!(f, "the provided {TOKEN_ENV} is invalid or expired"),
            Self::Transport(detail) => {
                write!(f, "could not reach the platform to authenticate: {detail}")
            }
        }
    }
}

impl std::error::Error for AuthError {}

/// Resolve a credential, validate it against the platform, and return the
/// authenticated identity.
///
/// CI OIDC support is added together with the backend federation endpoint
/// (issue #25806); for now only `INTEGRATES_API_TOKEN` is accepted.
///
/// # Errors
/// Returns [`AuthError::NotAuthenticated`] when no credential is set,
/// [`AuthError::Invalid`] when the platform rejects it, and
/// [`AuthError::Transport`] when the platform cannot be reached.
pub fn authenticate() -> Result<Session, AuthError> {
    let token = resolve(env::var(TOKEN_ENV).ok())?;
    let email = validate(&token)?;
    Ok(Session { email })
}

fn resolve(token: Option<String>) -> Result<String, AuthError> {
    match token {
        Some(token) if !token.trim().is_empty() => Ok(token.trim().to_owned()),
        _ => Err(AuthError::NotAuthenticated),
    }
}

fn validate(token: &str) -> Result<String, AuthError> {
    let body = post_me(token)?;
    parse_me_email(&body)
}

fn post_me(token: &str) -> Result<String, AuthError> {
    let client = reqwest::blocking::Client::builder()
        .redirect(reqwest::redirect::Policy::none())
        .timeout(REQUEST_TIMEOUT)
        .build()
        .map_err(|err| AuthError::Transport(err.without_url().to_string()))?;
    let response = client
        .post(API_ENDPOINT)
        .header("Authorization", format!("Bearer {token}"))
        .header("Content-Type", "application/json")
        .body(ME_QUERY)
        .send()
        .map_err(|err| AuthError::Transport(err.without_url().to_string()))?;
    let status = response.status();
    if !status.is_success() {
        return Err(AuthError::Transport(format!(
            "platform returned HTTP {}",
            status.as_u16()
        )));
    }
    response
        .text()
        .map_err(|err| AuthError::Transport(err.without_url().to_string()))
}

#[derive(Deserialize)]
struct MeResponse {
    data: Option<MeData>,
}

#[derive(Deserialize)]
struct MeData {
    me: Option<Me>,
}

#[derive(Deserialize)]
struct Me {
    #[serde(rename = "userEmail")]
    user_email: Option<String>,
}

// integrates answers a rejected token with HTTP 200 and a null `me`, so a
// parseable body with no email is an auth failure; a body we cannot parse is an
// unexpected (transport-level) response — e.g. a proxy/outage page — not a
// rejected token.
fn parse_me_email(body: &str) -> Result<String, AuthError> {
    let parsed: MeResponse = serde_json::from_str(body)
        .map_err(|_| AuthError::Transport("unexpected response from the platform".to_owned()))?;
    parsed
        .data
        .and_then(|data| data.me)
        .and_then(|me| me.user_email)
        .map(|email| email.trim().to_owned())
        .filter(|email| !email.is_empty())
        .ok_or(AuthError::Invalid)
}

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

    #[test]
    fn resolve_accepts_and_trims_a_non_empty_token() {
        assert_eq!(resolve(Some("tok".to_owned())).unwrap(), "tok");
        assert_eq!(resolve(Some("  tok\n".to_owned())).unwrap(), "tok");
    }

    #[test]
    fn resolve_rejects_empty_or_missing() {
        assert!(matches!(
            resolve(Some("   ".to_owned())),
            Err(AuthError::NotAuthenticated)
        ));
        assert!(matches!(resolve(None), Err(AuthError::NotAuthenticated)));
    }

    #[test]
    fn parse_me_email_extracts_and_trims_the_email() {
        let body = r#"{"data":{"me":{"userEmail":"u@fluidattacks.com"}}}"#;
        assert_eq!(parse_me_email(body).unwrap(), "u@fluidattacks.com");
        let padded = r#"{"data":{"me":{"userEmail":"  u@fluidattacks.com  "}}}"#;
        assert_eq!(parse_me_email(padded).unwrap(), "u@fluidattacks.com");
    }

    #[test]
    fn parse_me_email_rejects_unauthenticated_or_blank() {
        assert!(matches!(
            parse_me_email(r#"{"data":{"me":null}}"#),
            Err(AuthError::Invalid)
        ));
        assert!(matches!(
            parse_me_email(r#"{"data":null}"#),
            Err(AuthError::Invalid)
        ));
        assert!(matches!(
            parse_me_email(r#"{"data":{"me":{"userEmail":"   "}}}"#),
            Err(AuthError::Invalid)
        ));
    }

    #[test]
    fn parse_me_email_non_json_is_transport() {
        assert!(matches!(
            parse_me_email("<html>502 Bad Gateway</html>"),
            Err(AuthError::Transport(_))
        ));
    }

    #[test]
    fn not_authenticated_message_names_the_env_var() {
        assert!(AuthError::NotAuthenticated
            .to_string()
            .contains("INTEGRATES_API_TOKEN"));
    }
}