use std::env;
use std::sync::RwLock;
use std::time::Duration;
use secrecy::{ExposeSecret, SecretString};
use serde::Deserialize;
mod oauth;
mod store;
use store::StoredToken;
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";
pub(crate) const CLIENT_ID: &str = "fluidattacks-cli";
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);
const UNAUTHORIZED: u16 = 401;
const FORBIDDEN: u16 = 403;
const TOO_MANY_REQUESTS: u16 = 429;
pub const LOGIN_REQUIRED_CODE: &str = "LOGIN_REQUIRED";
static SETTINGS: RwLock<Option<Settings>> = RwLock::new(None);
static SIGNING_IN: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[derive(Clone, Debug, Default)]
pub struct Settings {
pub endpoint: Option<String>,
pub token: Option<SecretString>,
pub client: Option<(String, String)>,
}
pub fn configure(settings: Settings) {
if let Ok(mut slot) = SETTINGS.write() {
let current = slot.take().unwrap_or_default();
*slot = Some(Settings {
endpoint: settings.endpoint.or(current.endpoint),
token: settings.token.or(current.token),
client: settings.client.or(current.client),
});
}
}
#[derive(Clone, Debug)]
pub(crate) struct Platform {
pub(crate) base: String,
pub(crate) store_key: Option<String>,
pub(crate) loopback: bool,
}
#[cfg(test)]
impl Platform {
pub(crate) fn for_tests() -> Self {
Self {
base: DEFAULT_BASE.to_owned(),
store_key: None,
loopback: false,
}
}
}
pub(crate) fn platform() -> Platform {
let base = endpoint_from(
with_settings(|settings| settings.endpoint.clone()).flatten(),
env::var(ENDPOINT_ENV).ok(),
);
Platform {
store_key: store_key_of(&base),
loopback: base != DEFAULT_BASE && is_loopback(&base),
base,
}
}
fn with_settings<T>(read: impl FnOnce(&Settings) -> T) -> Option<T> {
SETTINGS
.read()
.ok()
.and_then(|slot| slot.as_ref().map(read))
}
#[cfg(test)]
pub(crate) static CONFIG_GUARD: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn configured_token() -> Option<String> {
with_settings(|settings| {
settings
.token
.as_ref()
.map(|token| token.expose_secret().to_owned())
})
.flatten()
}
fn user_agent() -> String {
with_settings(|settings| {
settings
.client
.as_ref()
.map(|(name, version)| format!("{name}/{version}"))
})
.flatten()
.unwrap_or_else(|| format!("{CLIENT_ID}/unknown"))
}
#[derive(Debug)]
pub struct Session {
pub email: String,
pub token: SecretString,
pub source: Credential,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Credential {
Pat,
Oauth,
Oidc,
}
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 login() -> Result<Session, AuthError> {
oauth::login(&platform())
}
pub fn logout() -> Result<bool, AuthError> {
oauth::logout(&platform())
}
pub fn recover(stale: &str, interactive: bool) -> Result<Option<Session>, AuthError> {
let platform = platform();
match current(&platform)? {
Current::Supplied(_) | Current::Federated => Ok(None),
Current::Stored(_) => match oauth::refresh_stale(&platform, stale) {
Ok(session) => Ok(Some(session)),
Err(AuthError::NotAuthenticated) if interactive => Ok(sign_in_once(&platform)),
Err(_) => Ok(None),
},
Current::Nothing => Ok(interactive.then(|| sign_in_once(&platform)).flatten()),
}
}
fn sign_in_once(platform: &Platform) -> Option<Session> {
let Ok(_guard) = SIGNING_IN.try_lock() else {
return None;
};
oauth::login(platform).ok()
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum Current {
Supplied(String),
Federated,
Stored(Box<StoredToken>),
Nothing,
}
pub(crate) fn current(platform: &Platform) -> Result<Current, AuthError> {
if let Ok(token) = resolve(configured_token().or_else(|| env::var(TOKEN_ENV).ok())) {
return Ok(Current::Supplied(token));
}
if oidc_source_available() {
return Ok(Current::Federated);
}
Ok(oauth::stored_session(platform)?
.map_or(Current::Nothing, |stored| Current::Stored(Box::new(stored))))
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum AuthState {
SignedIn(String),
TokenSupplied,
CiFederated,
Anonymous,
}
pub fn auth_state() -> Result<AuthState, AuthError> {
Ok(match current(&platform())? {
Current::Supplied(_) => AuthState::TokenSupplied,
Current::Federated => AuthState::CiFederated,
Current::Stored(stored) => AuthState::SignedIn(stored.email),
Current::Nothing => AuthState::Anonymous,
})
}
pub fn access_token() -> Result<SecretString, AuthError> {
let platform = platform();
match current(&platform)? {
Current::Supplied(token) => Ok(SecretString::from(token)),
Current::Federated | Current::Nothing => Err(AuthError::NotAuthenticated),
Current::Stored(stored) => Ok(oauth::token(&platform, *stored)?.token),
}
}
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> {
let platform = platform();
match current(&platform)? {
Current::Supplied(token) => {
finish_group(&platform, supplied_session(&platform, token)?, group, "PAT")
}
Current::Federated => resolve_via_oidc(&platform, group),
Current::Stored(stored) => finish_group(
&platform,
oauth::validated_token(&platform, *stored)?,
group,
"stored OAuth token",
),
Current::Nothing => Err(AuthError::NotAuthenticated),
}
}
fn supplied_session(platform: &Platform, token: String) -> Result<Session, AuthError> {
let email = validate(platform, &token)?;
Ok(Session {
email,
token: SecretString::from(token),
source: Credential::Pat,
})
}
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(platform: &Platform, group: Option<&str>) -> Result<Session, AuthError> {
let Some(group) = group else {
return Err(AuthError::NotAuthenticated);
};
let session = authenticate_oidc(platform, group)?;
tracing::info!(group, "authenticated via CI OIDC; group is active");
Ok(session)
}
fn finish_group(
platform: &Platform,
session: Session,
group: Option<&str>,
method: &str,
) -> Result<Session, AuthError> {
if let Some(group) = group {
validate_group_access(platform, 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(platform: &Platform, token: &str) -> Result<String, AuthError> {
let body = post_me(platform, token)?;
parse_me_email(&body)
}
fn endpoint_from(explicit: Option<String>, from_env: Option<String>) -> String {
explicit
.or(from_env)
.map(|value| value.trim().trim_end_matches('/').to_owned())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| DEFAULT_BASE.to_owned())
}
fn store_key_of(base: &str) -> Option<String> {
(base != DEFAULT_BASE).then(|| {
base.trim_start_matches("https://")
.trim_start_matches("http://")
.to_owned()
})
}
fn api_endpoint(platform: &Platform) -> String {
format!("{}/api", platform.base)
}
fn assume_endpoint(platform: &Platform) -> String {
format!("{}/auth/oidc/assume", platform.base)
}
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(platform: &Platform) -> Result<reqwest::blocking::Client, AuthError> {
let mut builder = reqwest::blocking::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.timeout(REQUEST_TIMEOUT);
if platform.loopback {
builder = builder.danger_accept_invalid_certs(true);
}
builder
.build()
.map_err(|err| AuthError::Transport(err.without_url().to_string()))
}
fn post_me(platform: &Platform, token: &str) -> Result<String, AuthError> {
let response = build_client(platform)?
.post(api_endpoint(platform))
.header("Authorization", format!("Bearer {token}"))
.header("Content-Type", "application/json")
.header("User-Agent", user_agent())
.body(ME_QUERY)
.send()
.map_err(|err| AuthError::Transport(err.without_url().to_string()))?;
let status = response.status();
let body = response
.text()
.map_err(|err| AuthError::Transport(err.without_url().to_string()))?;
if !status.is_success() {
return Err(classify_api_status(status.as_u16(), &body));
}
Ok(body)
}
#[derive(Deserialize)]
struct MeResponse {
data: Option<MeData>,
}
#[derive(Deserialize)]
struct ErrorsResponse {
errors: Option<Vec<GraphqlError>>,
}
#[derive(Deserialize)]
struct GraphqlError {
extensions: Option<ErrorExtensions>,
}
#[derive(Deserialize)]
struct ErrorExtensions {
code: Option<String>,
}
#[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(platform: &Platform, token: &str, group: &str) -> Result<(), AuthError> {
let body = post_group(platform, token, group)?;
parse_group_access(&body)
}
fn post_group(platform: &Platform, 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(platform)?
.post(api_endpoint(platform))
.header("Authorization", format!("Bearer {token}"))
.header("Content-Type", "application/json")
.header("User-Agent", user_agent())
.body(payload)
.send()
.map_err(|err| AuthError::Transport(err.without_url().to_string()))?;
let status = response.status();
let body = response
.text()
.map_err(|err| AuthError::Transport(err.without_url().to_string()))?;
if !status.is_success() {
return Err(classify_api_status(status.as_u16(), &body));
}
Ok(body)
}
#[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)
}
fn authenticate_oidc(platform: &Platform, group: &str) -> Result<Session, AuthError> {
let id_token = acquire_id_token(
platform,
env::var(GH_TOKEN_URL_ENV).ok(),
env::var(GH_REQUEST_TOKEN_ENV).ok(),
env::var(OIDC_TOKEN_ENV).ok(),
)?;
let service_token = exchange(platform, &id_token, group)?;
let email = validate(platform, &service_token)?;
Ok(Session {
email,
token: SecretString::from(service_token),
source: Credential::Oidc,
})
}
fn acquire_id_token(
platform: &Platform,
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(platform, &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) && status != TOO_MANY_REQUESTS {
AuthError::Invalid
} else {
AuthError::Transport(format!("platform returned HTTP {status}"))
}
}
fn classify_api_status(status: u16, body: &str) -> AuthError {
if says_login_required(body) || matches!(status, UNAUTHORIZED | FORBIDDEN) {
return AuthError::Invalid;
}
AuthError::Transport(format!("platform returned HTTP {status}"))
}
#[must_use]
pub fn says_login_required(body: &str) -> bool {
serde_json::from_str::<ErrorsResponse>(body)
.ok()
.and_then(|parsed| parsed.errors)
.is_some_and(|errors| {
errors.iter().any(|error| {
error
.extensions
.as_ref()
.and_then(|extensions| extensions.code.as_deref())
== Some(LOGIN_REQUIRED_CODE)
})
})
}
fn fetch_github_id_token(
platform: &Platform,
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", &platform.base);
let response = build_client(platform)?
.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(platform: &Platform, 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(platform)?
.post(assume_endpoint(platform))
.header("Content-Type", "application/json")
.header("User-Agent", user_agent())
.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 std::sync::PoisonError;
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(
&Platform::for_tests(),
None,
None,
Some(" idtok\n".to_owned())
)
.unwrap(),
"idtok"
);
}
#[test]
fn acquire_rejects_when_no_source() {
assert!(matches!(
acquire_id_token(&Platform::for_tests(), None, None, None),
Err(AuthError::NotAuthenticated)
));
assert!(matches!(
acquire_id_token(
&Platform::for_tests(),
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 the_platform_defaults_to_prod_and_honours_an_override() {
let _guard = CONFIG_GUARD.lock().unwrap_or_else(PoisonError::into_inner);
std::env::remove_var(ENDPOINT_ENV);
assert_eq!(platform().base, "https://app.fluidattacks.com");
assert!(!platform().loopback);
std::env::set_var(ENDPOINT_ENV, "https://localhost:8001/");
let local = platform();
assert_eq!(local.base, "https://localhost:8001");
assert_eq!(api_endpoint(&local), "https://localhost:8001/api");
assert_eq!(
assume_endpoint(&local),
"https://localhost:8001/auth/oidc/assume"
);
assert!(local.loopback);
assert!(local.store_key.is_some());
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 api_status_is_invalid_only_when_the_platform_says_so() {
let rejected =
r#"{"errors":[{"message":"Login required","extensions":{"code":"LOGIN_REQUIRED"}}]}"#;
assert!(matches!(
classify_api_status(400, rejected),
AuthError::Invalid
));
assert!(matches!(classify_api_status(401, ""), AuthError::Invalid));
assert!(matches!(classify_api_status(403, ""), AuthError::Invalid));
let other = r#"{"errors":[{"message":"Syntax Error"}]}"#;
assert!(matches!(
classify_api_status(400, other),
AuthError::Transport(_)
));
assert!(matches!(
classify_api_status(404, ""),
AuthError::Transport(_)
));
assert!(matches!(
classify_api_status(429, ""),
AuthError::Transport(_)
));
}
#[test]
fn a_rate_limit_is_never_a_rejected_credential() {
assert!(matches!(
classify_status(TOO_MANY_REQUESTS),
AuthError::Transport(_)
));
}
#[test]
fn classify_status_maps_4xx_to_invalid_else_transport() {
assert!(matches!(classify_status(400), AuthError::Invalid));
assert!(matches!(classify_status(401), AuthError::Invalid));
assert!(matches!(classify_status(403), AuthError::Invalid));
assert!(matches!(classify_status(500), AuthError::Transport(_)));
}
#[test]
fn one_ordering_decides_which_credential_is_in_force() {
let _guard = CONFIG_GUARD.lock().unwrap_or_else(PoisonError::into_inner);
let dir = tempfile::tempdir().expect("a temporary config directory");
let restore = std::env::var("XDG_CONFIG_HOME").ok();
std::env::set_var("XDG_CONFIG_HOME", dir.path());
std::env::remove_var(TOKEN_ENV);
std::env::remove_var(OIDC_TOKEN_ENV);
let platform = Platform::for_tests();
configure(Settings::default());
assert_eq!(current(&platform).unwrap(), Current::Nothing);
std::env::set_var(OIDC_TOKEN_ENV, "an-id-token");
assert_eq!(current(&platform).unwrap(), Current::Federated);
configure(Settings {
token: Some(SecretString::from("a-token")),
..Settings::default()
});
assert_eq!(
current(&platform).unwrap(),
Current::Supplied("a-token".to_owned())
);
configure(Settings::default());
std::env::remove_var(OIDC_TOKEN_ENV);
match restore {
Some(value) => std::env::set_var("XDG_CONFIG_HOME", value),
None => std::env::remove_var("XDG_CONFIG_HOME"),
}
}
#[test]
fn recovery_refuses_credentials_a_login_would_not_replace() {
let _guard = CONFIG_GUARD.lock().unwrap_or_else(PoisonError::into_inner);
let dir = tempfile::tempdir().expect("a temporary config directory");
let restore = std::env::var("XDG_CONFIG_HOME").ok();
std::env::set_var("XDG_CONFIG_HOME", dir.path());
std::env::remove_var(TOKEN_ENV);
std::env::remove_var(OIDC_TOKEN_ENV);
configure(Settings {
token: Some(SecretString::from("a-token")),
..Settings::default()
});
assert!(recover("stale", true).unwrap().is_none());
configure(Settings::default());
std::env::set_var(OIDC_TOKEN_ENV, "an-id-token");
assert!(recover("stale", true).unwrap().is_none());
std::env::remove_var(OIDC_TOKEN_ENV);
assert!(recover("stale", false).unwrap().is_none());
match restore {
Some(value) => std::env::set_var("XDG_CONFIG_HOME", value),
None => std::env::remove_var("XDG_CONFIG_HOME"),
}
}
#[test]
fn endpoint_precedence_and_normalisation() {
assert_eq!(
endpoint_from(Some("https://explicit.test".to_owned()), None),
"https://explicit.test"
);
assert_eq!(
endpoint_from(None, Some(" https://from-env.test/ ".to_owned())),
"https://from-env.test"
);
assert_eq!(endpoint_from(None, None), DEFAULT_BASE);
assert_eq!(endpoint_from(Some(String::new()), None), DEFAULT_BASE);
assert_eq!(endpoint_from(Some(" ".to_owned()), None), DEFAULT_BASE);
}
#[test]
fn store_key_is_none_only_for_the_default_platform() {
assert_eq!(store_key_of(DEFAULT_BASE), None);
assert_eq!(
store_key_of("https://localhost:8001").as_deref(),
Some("localhost:8001")
);
}
#[test]
fn access_token_refuses_to_stand_in_for_federation() {
let _guard = CONFIG_GUARD.lock().unwrap_or_else(PoisonError::into_inner);
let dir = tempfile::tempdir().expect("a temporary config directory");
let restore = std::env::var("XDG_CONFIG_HOME").ok();
std::env::set_var("XDG_CONFIG_HOME", dir.path());
std::env::remove_var(TOKEN_ENV);
std::env::set_var(OIDC_TOKEN_ENV, "an-id-token");
configure(Settings::default());
assert!(matches!(access_token(), Err(AuthError::NotAuthenticated)));
std::env::remove_var(OIDC_TOKEN_ENV);
match restore {
Some(value) => std::env::set_var("XDG_CONFIG_HOME", value),
None => std::env::remove_var("XDG_CONFIG_HOME"),
}
}
#[test]
fn client_identity_falls_back_to_the_shared_id() {
let _guard = CONFIG_GUARD.lock().unwrap_or_else(PoisonError::into_inner);
configure(Settings::default());
assert!(user_agent().starts_with(CLIENT_ID));
configure(Settings {
client: Some(("signals".to_owned(), "1.2.3".to_owned())),
..Settings::default()
});
assert_eq!(user_agent(), "signals/1.2.3");
assert_eq!(CLIENT_ID, "fluidattacks-cli");
configure(Settings::default());
}
#[test]
fn login_required_code_matches_the_platform() {
assert_eq!(LOGIN_REQUIRED_CODE, "LOGIN_REQUIRED");
}
#[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(_))
));
}
}