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);
#[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 {}
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>,
}
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"));
}
}