use std::env;
use std::time::Duration;
use secrecy::{ExposeSecret, SecretString};
use serde::Deserialize;
mod oauth;
mod store;
pub use oauth::{authenticate_oauth, logout, resolve_oauth_session, whoami};
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 ENDPOINT_ENV: &str = "INTEGRATES_ENDPOINT";
const DEFAULT_BASE: &str = "https://app.fluidattacks.com";
const ME_QUERY: &str = r#"{"query":"query{me{userEmail}}"}"#;
const GROUP_QUERY: &str = "query($groupName:String!){group(groupName:$groupName){name}}";
const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
#[derive(Debug)]
pub struct Session {
pub email: String,
pub token: SecretString,
}
impl Session {
#[must_use]
pub fn expose_token(&self) -> &str {
self.token.expose_secret()
}
}
#[derive(Debug)]
#[non_exhaustive]
pub enum AuthError {
NotAuthenticated,
Invalid,
Transport(String),
Local(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}")
}
Self::Local(detail) => {
write!(f, "a local step of the login flow failed: {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,
token: SecretString::new(token.into_boxed_str()),
})
}
pub fn authenticate_cli(group: Option<&str>) -> Result<Session, AuthError> {
let outcome = resolve_cli_identity(group);
if matches!(&outcome, Err(AuthError::NotAuthenticated)) {
tracing::warn!("no credential found; resolved as unauthenticated");
}
outcome
}
fn resolve_cli_identity(group: Option<&str>) -> Result<Session, AuthError> {
match authenticate() {
Ok(session) => finish_group(session, group, "PAT"),
Err(AuthError::NotAuthenticated) => resolve_after_pat(group),
Err(err) => Err(err),
}
}
fn resolve_after_pat(group: Option<&str>) -> Result<Session, AuthError> {
if oidc_source_available() {
return resolve_via_oidc(group);
}
match oauth::resolve_oauth_session() {
Ok(session) => finish_group(session, group, "stored OAuth token"),
Err(AuthError::NotAuthenticated | AuthError::Local(_)) => resolve_via_oidc(group),
Err(err) => Err(err),
}
}
fn oidc_source_available() -> bool {
oidc_source_present(
env::var(GH_TOKEN_URL_ENV).ok(),
env::var(GH_REQUEST_TOKEN_ENV).ok(),
env::var(OIDC_TOKEN_ENV).ok(),
)
}
fn oidc_source_present(
github_url: Option<String>,
github_request_token: Option<String>,
oidc_token: Option<String>,
) -> bool {
non_empty(oidc_token).is_some()
|| (non_empty(github_url).is_some() && non_empty(github_request_token).is_some())
}
fn resolve_via_oidc(group: Option<&str>) -> Result<Session, AuthError> {
let Some(group) = group else {
return Err(AuthError::NotAuthenticated);
};
let session = authenticate_oidc(group)?;
tracing::info!(group, "authenticated via CI OIDC; group is active");
Ok(session)
}
fn finish_group(session: Session, group: Option<&str>, method: &str) -> Result<Session, AuthError> {
if let Some(group) = group {
validate_group_access(session.expose_token(), group)?;
tracing::info!(
group,
method,
"authenticated; group is active and accessible"
);
} else {
tracing::info!(method, "authenticated");
}
Ok(session)
}
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 base_url() -> String {
env::var(ENDPOINT_ENV)
.ok()
.map(|value| value.trim().trim_end_matches('/').to_owned())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| DEFAULT_BASE.to_owned())
}
fn api_endpoint() -> String {
format!("{}/api", base_url())
}
fn audience() -> String {
base_url()
}
fn assume_endpoint() -> String {
format!("{}/auth/oidc/assume", base_url())
}
fn is_loopback(base: &str) -> bool {
reqwest::Url::parse(base)
.ok()
.and_then(|url| {
url.host_str()
.map(|host| matches!(host, "127.0.0.1" | "localhost" | "::1"))
})
.unwrap_or(false)
}
fn build_client() -> Result<reqwest::blocking::Client, AuthError> {
let mut builder = reqwest::blocking::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.timeout(REQUEST_TIMEOUT);
if is_loopback(&base_url()) {
builder = builder.danger_accept_invalid_certs(true);
}
builder
.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)
}
fn validate_group_access(token: &str, group: &str) -> Result<(), AuthError> {
let body = post_group(token, group)?;
parse_group_access(&body)
}
fn post_group(token: &str, group: &str) -> Result<String, AuthError> {
let payload = serde_json::to_string(&GroupRequest {
query: GROUP_QUERY,
variables: GroupVariables { group_name: group },
})
.map_err(|err| AuthError::Transport(err.to_string()))?;
let response = build_client()?
.post(api_endpoint())
.header("Authorization", format!("Bearer {token}"))
.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(AuthError::Transport(format!(
"platform returned HTTP {}",
status.as_u16()
)));
}
response
.text()
.map_err(|err| AuthError::Transport(err.without_url().to_string()))
}
#[derive(serde::Serialize)]
struct GroupRequest<'a> {
query: &'a str,
variables: GroupVariables<'a>,
}
#[derive(serde::Serialize)]
struct GroupVariables<'a> {
#[serde(rename = "groupName")]
group_name: &'a str,
}
#[derive(Deserialize)]
struct GroupResponse {
data: Option<GroupData>,
}
#[derive(Deserialize)]
struct GroupData {
group: Option<GroupNode>,
}
#[derive(Deserialize)]
struct GroupNode {
name: Option<String>,
}
fn parse_group_access(body: &str) -> Result<(), AuthError> {
let parsed: GroupResponse = serde_json::from_str(body)
.map_err(|_| AuthError::Transport("unexpected response from the platform".to_owned()))?;
parsed
.data
.and_then(|data| data.group)
.and_then(|group| group.name)
.filter(|name| !name.trim().is_empty())
.map(|_| ())
.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,
token: SecretString::new(service_token.into_boxed_str()),
})
}
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 oidc_source_present_detects_each_source() {
assert!(oidc_source_present(None, None, Some("tok".to_owned())));
assert!(oidc_source_present(
Some("url".to_owned()),
Some("req".to_owned()),
None
));
assert!(!oidc_source_present(Some("url".to_owned()), None, None));
assert!(!oidc_source_present(None, None, None));
assert!(!oidc_source_present(
Some(String::new()),
Some(" ".to_owned()),
Some(String::new())
));
}
#[test]
fn is_loopback_detects_local_hosts() {
assert!(is_loopback("https://127.0.0.1:8001"));
assert!(is_loopback("https://localhost:8001"));
assert!(!is_loopback("https://app.fluidattacks.com"));
assert!(!is_loopback("not a url"));
}
#[test]
fn base_url_defaults_to_prod_and_honours_override() {
std::env::remove_var(ENDPOINT_ENV);
assert_eq!(base_url(), "https://app.fluidattacks.com");
assert!(!is_loopback(&base_url()));
std::env::set_var(ENDPOINT_ENV, "https://localhost:8001/");
assert_eq!(base_url(), "https://localhost:8001");
assert_eq!(api_endpoint(), "https://localhost:8001/api");
assert_eq!(assume_endpoint(), "https://localhost:8001/auth/oidc/assume");
assert!(is_loopback(&base_url()));
std::env::remove_var(ENDPOINT_ENV);
}
#[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"));
}
#[test]
fn parse_group_access_ok_when_group_returned() {
assert!(parse_group_access(r#"{"data":{"group":{"name":"daimon"}}}"#).is_ok());
}
#[test]
fn parse_group_access_rejects_no_access_or_missing() {
assert!(matches!(
parse_group_access(r#"{"data":{"group":null},"errors":[{"message":"Access denied"}]}"#),
Err(AuthError::Invalid)
));
assert!(matches!(
parse_group_access(r#"{"data":null}"#),
Err(AuthError::Invalid)
));
assert!(matches!(
parse_group_access(r#"{"data":{"group":{"name":" "}}}"#),
Err(AuthError::Invalid)
));
}
#[test]
fn parse_group_access_non_json_is_transport() {
assert!(matches!(
parse_group_access("<html>502 Bad Gateway</html>"),
Err(AuthError::Transport(_))
));
}
}