use miette::Diagnostic;
use thiserror::Error;
use crate::GITHUB_COM;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TokenSource {
GitHubCli,
GitHubTokenEnv,
GhTokenEnv,
GhEnterpriseTokenEnv,
GitHubEnterpriseTokenEnv,
}
impl std::fmt::Display for TokenSource {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::GitHubCli => write!(f, "GitHub CLI (gh auth token)"),
Self::GitHubTokenEnv => write!(f, "GITHUB_TOKEN environment variable"),
Self::GhTokenEnv => write!(f, "GH_TOKEN environment variable"),
Self::GhEnterpriseTokenEnv => write!(f, "GH_ENTERPRISE_TOKEN environment variable"),
Self::GitHubEnterpriseTokenEnv => {
write!(f, "GITHUB_ENTERPRISE_TOKEN environment variable")
}
}
}
}
#[derive(Debug, Clone)]
pub struct AuthToken {
pub token: String,
pub source: TokenSource,
}
#[derive(Debug, Error, Diagnostic)]
pub enum AuthError {
#[error("no GitHub authentication found for {host}")]
#[diagnostic(
code(stakk::auth::no_token),
help("run `gh auth login --hostname {host}`, or set {}", env_var_list(host))
)]
NoAuthFound { host: String },
#[error("failed to run `gh auth token`: {0}")]
#[diagnostic(
code(stakk::auth::gh_cli_error),
help("install the `gh` CLI, or set a token environment variable to skip it")
)]
GhCliError(std::io::Error),
}
fn env_sources(host: &str) -> &'static [(&'static str, TokenSource)] {
if host == GITHUB_COM {
&[
("GITHUB_TOKEN", TokenSource::GitHubTokenEnv),
("GH_TOKEN", TokenSource::GhTokenEnv),
]
} else {
&[
("GH_ENTERPRISE_TOKEN", TokenSource::GhEnterpriseTokenEnv),
(
"GITHUB_ENTERPRISE_TOKEN",
TokenSource::GitHubEnterpriseTokenEnv,
),
]
}
}
fn env_var_list(host: &str) -> String {
let names: Vec<&str> = env_sources(host).iter().map(|(name, _)| *name).collect();
names.join("/")
}
fn token_from_env(host: &str, lookup: impl Fn(&str) -> Option<String>) -> Option<AuthToken> {
env_sources(host).iter().find_map(|(name, source)| {
lookup(name)
.filter(|token| !token.is_empty())
.map(|token| AuthToken {
token,
source: *source,
})
})
}
pub async fn resolve_token(host: &str) -> Result<AuthToken, AuthError> {
if let Some(token) = try_gh_cli(host).await? {
return Ok(AuthToken {
token,
source: TokenSource::GitHubCli,
});
}
token_from_env(host, |name| std::env::var(name).ok()).ok_or_else(|| AuthError::NoAuthFound {
host: host.to_string(),
})
}
async fn try_gh_cli(host: &str) -> Result<Option<String>, AuthError> {
let result = tokio::process::Command::new("gh")
.args(["auth", "token", "--hostname", host])
.output()
.await;
match result {
Ok(output) if output.status.success() => {
let token = String::from_utf8_lossy(&output.stdout).trim().to_string();
if token.is_empty() {
Ok(None)
} else {
Ok(Some(token))
}
}
Ok(_) => Ok(None),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(AuthError::GhCliError(e)),
}
}
#[cfg(test)]
mod tests {
use super::*;
const ENTERPRISE: &str = "github.example.com";
fn env<'a>(pairs: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<String> + 'a {
move |name| {
pairs
.iter()
.find(|(key, _)| *key == name)
.map(|(_, value)| (*value).to_string())
}
}
#[test]
fn token_source_display_github_cli() {
assert_eq!(
TokenSource::GitHubCli.to_string(),
"GitHub CLI (gh auth token)"
);
}
#[test]
fn token_source_display_github_token_env() {
assert_eq!(
TokenSource::GitHubTokenEnv.to_string(),
"GITHUB_TOKEN environment variable"
);
}
#[test]
fn token_source_display_gh_token_env() {
assert_eq!(
TokenSource::GhTokenEnv.to_string(),
"GH_TOKEN environment variable"
);
}
#[test]
fn token_source_display_enterprise_env() {
assert_eq!(
TokenSource::GhEnterpriseTokenEnv.to_string(),
"GH_ENTERPRISE_TOKEN environment variable"
);
assert_eq!(
TokenSource::GitHubEnterpriseTokenEnv.to_string(),
"GITHUB_ENTERPRISE_TOKEN environment variable"
);
}
#[test]
fn github_com_prefers_github_token() {
let found = token_from_env(GITHUB_COM, env(&[("GITHUB_TOKEN", "a"), ("GH_TOKEN", "b")]))
.expect("a token should be found");
assert_eq!(found.token, "a");
assert_eq!(found.source, TokenSource::GitHubTokenEnv);
}
#[test]
fn github_com_falls_back_to_gh_token() {
let found =
token_from_env(GITHUB_COM, env(&[("GH_TOKEN", "b")])).expect("a token should be found");
assert_eq!(found.token, "b");
assert_eq!(found.source, TokenSource::GhTokenEnv);
}
#[test]
fn github_com_ignores_the_enterprise_variables() {
assert!(token_from_env(GITHUB_COM, env(&[("GH_ENTERPRISE_TOKEN", "e")])).is_none());
}
#[test]
fn enterprise_prefers_gh_enterprise_token() {
let found = token_from_env(
ENTERPRISE,
env(&[
("GH_ENTERPRISE_TOKEN", "e1"),
("GITHUB_ENTERPRISE_TOKEN", "e2"),
]),
)
.expect("a token should be found");
assert_eq!(found.token, "e1");
assert_eq!(found.source, TokenSource::GhEnterpriseTokenEnv);
}
#[test]
fn enterprise_falls_back_to_github_enterprise_token() {
let found = token_from_env(ENTERPRISE, env(&[("GITHUB_ENTERPRISE_TOKEN", "e2")]))
.expect("a token should be found");
assert_eq!(found.token, "e2");
assert_eq!(found.source, TokenSource::GitHubEnterpriseTokenEnv);
}
#[test]
fn enterprise_ignores_the_github_com_variables() {
assert!(
token_from_env(ENTERPRISE, env(&[("GITHUB_TOKEN", "a"), ("GH_TOKEN", "b")])).is_none()
);
}
#[test]
fn empty_values_are_skipped() {
let found = token_from_env(GITHUB_COM, env(&[("GITHUB_TOKEN", ""), ("GH_TOKEN", "b")]))
.expect("a token should be found");
assert_eq!(found.token, "b");
}
#[test]
fn auth_error_no_auth_found_is_actionable() {
let err = AuthError::NoAuthFound {
host: GITHUB_COM.to_string(),
};
let msg = err.to_string();
assert!(msg.contains("no GitHub authentication found"));
assert!(msg.contains(GITHUB_COM));
let help = miette::Diagnostic::help(&err).expect("NoAuthFound should have diagnostic help");
let help_text = help.to_string();
assert!(help_text.contains("gh auth login --hostname github.com"));
assert!(help_text.contains("GITHUB_TOKEN"));
assert!(help_text.contains("GH_TOKEN"));
}
#[test]
fn auth_error_names_the_enterprise_variables() {
let err = AuthError::NoAuthFound {
host: ENTERPRISE.to_string(),
};
let help = miette::Diagnostic::help(&err).expect("NoAuthFound should have diagnostic help");
let help_text = help.to_string();
assert!(help_text.contains("gh auth login --hostname github.example.com"));
assert!(help_text.contains("GH_ENTERPRISE_TOKEN"));
assert!(help_text.contains("GITHUB_ENTERPRISE_TOKEN"));
}
}