use std::env;
use std::time::Duration;
use serde::Deserialize;
const TOKEN_ENV: &str = "INTEGRATES_API_TOKEN";
const OIDC_TOKEN_ENV: &str = "INTEGRATES_OIDC_TOKEN";
const GH_TOKEN_URL_ENV: &str = "ACTIONS_ID_TOKEN_REQUEST_URL";
const GH_REQUEST_TOKEN_ENV: &str = "ACTIONS_ID_TOKEN_REQUEST_TOKEN";
const AUDIENCE: &str = "https://app.fluidattacks.com";
const API_ENDPOINT: &str = "https://app.fluidattacks.com/api";
const ASSUME_ENDPOINT: &str = "https://app.fluidattacks.com/auth/oidc/assume";
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}, or use CI OIDC \
({OIDC_TOKEN_ENV} or a GitHub id-token) with a group"
),
Self::Invalid => write!(
f,
"the credential is invalid, expired, or not authorized for the group"
),
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 build_client() -> Result<reqwest::blocking::Client, AuthError> {
reqwest::blocking::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.timeout(REQUEST_TIMEOUT)
.build()
.map_err(|err| AuthError::Transport(err.without_url().to_string()))
}
fn post_me(token: &str) -> Result<String, AuthError> {
let response = build_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)
}
pub fn authenticate_oidc(group: &str) -> Result<Session, AuthError> {
let id_token = acquire_id_token(
env::var(GH_TOKEN_URL_ENV).ok(),
env::var(GH_REQUEST_TOKEN_ENV).ok(),
env::var(OIDC_TOKEN_ENV).ok(),
)?;
let service_token = exchange(&id_token, group)?;
let email = validate(&service_token)?;
Ok(Session { email })
}
fn acquire_id_token(
github_url: Option<String>,
github_request_token: Option<String>,
oidc_token: Option<String>,
) -> Result<String, AuthError> {
match (non_empty(github_url), non_empty(github_request_token)) {
(Some(url), Some(request_token)) => fetch_github_id_token(&url, &request_token),
_ => resolve(oidc_token),
}
}
fn non_empty(value: Option<String>) -> Option<String> {
value
.map(|value| value.trim().to_owned())
.filter(|value| !value.is_empty())
}
fn classify_status(status: u16) -> AuthError {
if matches!(status, 400..=499) {
AuthError::Invalid
} else {
AuthError::Transport(format!("platform returned HTTP {status}"))
}
}
fn fetch_github_id_token(url: &str, request_token: &str) -> Result<String, AuthError> {
let mut request_url =
reqwest::Url::parse(url).map_err(|err| AuthError::Transport(err.to_string()))?;
request_url
.query_pairs_mut()
.append_pair("audience", AUDIENCE);
let response = build_client()?
.get(request_url)
.header("Authorization", format!("Bearer {request_token}"))
.send()
.map_err(|err| AuthError::Transport(err.without_url().to_string()))?;
let status = response.status();
if !status.is_success() {
return Err(classify_status(status.as_u16()));
}
let body = response
.text()
.map_err(|err| AuthError::Transport(err.without_url().to_string()))?;
parse_github_token(&body)
}
fn exchange(id_token: &str, group: &str) -> Result<String, AuthError> {
let payload = serde_json::to_string(&AssumeRequest {
token: id_token,
group_name: group,
})
.map_err(|err| AuthError::Transport(err.to_string()))?;
let response = build_client()?
.post(ASSUME_ENDPOINT)
.header("Content-Type", "application/json")
.body(payload)
.send()
.map_err(|err| AuthError::Transport(err.without_url().to_string()))?;
let status = response.status();
if !status.is_success() {
return Err(classify_status(status.as_u16()));
}
let body = response
.text()
.map_err(|err| AuthError::Transport(err.without_url().to_string()))?;
parse_assume_token(&body)
}
#[derive(serde::Serialize)]
struct AssumeRequest<'a> {
token: &'a str,
group_name: &'a str,
}
#[derive(Deserialize)]
struct AssumeResponse {
token: Option<String>,
}
fn parse_assume_token(body: &str) -> Result<String, AuthError> {
let parsed: AssumeResponse = serde_json::from_str(body)
.map_err(|_| AuthError::Transport("unexpected response from the platform".to_owned()))?;
parsed
.token
.map(|token| token.trim().to_owned())
.filter(|token| !token.is_empty())
.ok_or(AuthError::Invalid)
}
#[derive(Deserialize)]
struct GithubTokenResponse {
value: Option<String>,
}
fn parse_github_token(body: &str) -> Result<String, AuthError> {
let parsed: GithubTokenResponse = serde_json::from_str(body)
.map_err(|_| AuthError::Transport("unexpected response from GitHub".to_owned()))?;
parsed
.value
.map(|value| value.trim().to_owned())
.filter(|value| !value.is_empty())
.ok_or_else(|| AuthError::Transport("GitHub returned no id-token".to_owned()))
}
#[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"));
}
#[test]
fn acquire_reads_the_oidc_env_var_when_no_github() {
assert_eq!(
acquire_id_token(None, None, Some(" idtok\n".to_owned())).unwrap(),
"idtok"
);
}
#[test]
fn acquire_rejects_when_no_source() {
assert!(matches!(
acquire_id_token(None, None, None),
Err(AuthError::NotAuthenticated)
));
assert!(matches!(
acquire_id_token(Some(String::new()), Some(" ".to_owned()), None),
Err(AuthError::NotAuthenticated)
));
}
#[test]
fn parse_assume_token_extracts_and_trims() {
assert_eq!(
parse_assume_token(r#"{"token":" svc.tok "}"#).unwrap(),
"svc.tok"
);
}
#[test]
fn parse_assume_token_rejects_missing_or_blank() {
assert!(matches!(
parse_assume_token(r#"{"token":null}"#),
Err(AuthError::Invalid)
));
assert!(matches!(
parse_assume_token(r#"{"token":" "}"#),
Err(AuthError::Invalid)
));
}
#[test]
fn parse_assume_token_non_json_is_transport() {
assert!(matches!(
parse_assume_token("<html>500</html>"),
Err(AuthError::Transport(_))
));
}
#[test]
fn parse_github_token_extracts_and_trims() {
assert_eq!(
parse_github_token(r#"{"value":" gh.jwt "}"#).unwrap(),
"gh.jwt"
);
}
#[test]
fn parse_github_token_rejects_missing_or_non_json() {
assert!(matches!(
parse_github_token(r#"{"value":null}"#),
Err(AuthError::Transport(_))
));
assert!(matches!(
parse_github_token("not json"),
Err(AuthError::Transport(_))
));
}
#[test]
fn classify_status_maps_4xx_to_invalid_else_transport() {
assert!(matches!(classify_status(401), AuthError::Invalid));
assert!(matches!(classify_status(403), AuthError::Invalid));
assert!(matches!(classify_status(500), AuthError::Transport(_)));
}
#[test]
fn not_authenticated_message_mentions_oidc() {
assert!(AuthError::NotAuthenticated
.to_string()
.contains("INTEGRATES_OIDC_TOKEN"));
}
}