use miette::Diagnostic;
use thiserror::Error;
use crate::GITHUB_COM;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TokenSource {
GitHubCli,
GitHubTokenEnv,
GhTokenEnv,
GhEnterpriseTokenEnv,
GitHubEnterpriseTokenEnv,
}
#[derive(Debug, Clone)]
pub struct AuthToken {
pub token: String,
#[cfg_attr(
not(test),
expect(
dead_code,
reason = "the resolution tests read it to pin that gh's answer beats the environment, \
and which token environment variable wins for a given host"
)
)]
pub source: TokenSource,
}
struct GhOutput {
success: bool,
stdout: String,
}
trait GhRunner: Send + Sync {
fn run_gh(
&self,
args: &[&str],
) -> impl std::future::Future<Output = Result<GhOutput, std::io::Error>> + Send;
}
struct RealGhRunner;
impl GhRunner for RealGhRunner {
async fn run_gh(&self, args: &[&str]) -> Result<GhOutput, std::io::Error> {
let output = tokio::process::Command::new("gh")
.args(args)
.output()
.await?;
Ok(GhOutput {
success: output.status.success(),
stdout: String::from_utf8_lossy(&output.stdout).to_string(),
})
}
}
#[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(
"repair the `gh` installation — a `gh` that cannot be started stops resolution before \
the token environment variables are read"
)
)]
GhCliError(std::io::Error),
}
fn env_sources(host: &str) -> &'static [(&'static str, TokenSource)] {
if host == GITHUB_COM {
&[
("GH_TOKEN", TokenSource::GhTokenEnv),
("GITHUB_TOKEN", TokenSource::GitHubTokenEnv),
]
} 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> {
resolve_token_with(host, &RealGhRunner, |name| std::env::var(name).ok()).await
}
async fn resolve_token_with(
host: &str,
gh: &impl GhRunner,
lookup: impl Fn(&str) -> Option<String>,
) -> Result<AuthToken, AuthError> {
if let Some(token) = try_gh_cli(host, gh).await? {
return Ok(AuthToken {
token,
source: TokenSource::GitHubCli,
});
}
token_from_env(host, lookup).ok_or_else(|| AuthError::NoAuthFound {
host: host.to_string(),
})
}
async fn try_gh_cli(host: &str, gh: &impl GhRunner) -> Result<Option<String>, AuthError> {
let result = gh.run_gh(&["auth", "token", "--hostname", host]).await;
match result {
Ok(output) if output.success => {
let token = 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 std::sync::Arc;
use std::sync::Mutex;
use super::*;
const ENTERPRISE: &str = "github.example.com";
struct RecordingGhRunner {
calls: Arc<Mutex<Vec<Vec<String>>>>,
success: bool,
stdout: String,
}
impl RecordingGhRunner {
fn new(success: bool, stdout: &str) -> Self {
Self {
calls: Arc::new(Mutex::new(Vec::new())),
success,
stdout: stdout.to_string(),
}
}
fn calls(&self) -> Vec<Vec<String>> {
self.calls.lock().unwrap().clone()
}
}
impl GhRunner for RecordingGhRunner {
fn run_gh(
&self,
args: &[&str],
) -> impl std::future::Future<Output = Result<GhOutput, std::io::Error>> + Send {
self.calls
.lock()
.unwrap()
.push(args.iter().map(|arg| (*arg).to_string()).collect());
let output = GhOutput {
success: self.success,
stdout: self.stdout.clone(),
};
async move { Ok(output) }
}
}
#[tokio::test]
async fn gh_cli_wins_over_the_environment() {
let gh = RecordingGhRunner::new(true, "gh-token\n");
let found = resolve_token_with(GITHUB_COM, &gh, env(&[("GH_TOKEN", "env-token")]))
.await
.expect("a token should be found");
assert_eq!(found.token, "gh-token");
assert_eq!(found.source, TokenSource::GitHubCli);
}
#[tokio::test]
async fn a_failed_gh_falls_back_to_the_environment() {
let gh = RecordingGhRunner::new(false, "gh-token\n");
let found = resolve_token_with(GITHUB_COM, &gh, env(&[("GH_TOKEN", "env-token")]))
.await
.expect("a token should be found");
assert_eq!(found.token, "env-token");
assert_eq!(found.source, TokenSource::GhTokenEnv);
}
#[tokio::test]
async fn gh_is_asked_about_the_host_being_resolved() {
let gh = RecordingGhRunner::new(true, "gh-token\n");
let _ = resolve_token_with(ENTERPRISE, &gh, env(&[])).await;
assert_eq!(
gh.calls(),
vec![vec!["auth", "token", "--hostname", ENTERPRISE]]
);
}
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 github_com_prefers_gh_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, "b");
assert_eq!(found.source, TokenSource::GhTokenEnv);
}
#[test]
fn github_com_falls_back_to_github_token() {
let found = token_from_env(GITHUB_COM, env(&[("GITHUB_TOKEN", "a")]))
.expect("a token should be found");
assert_eq!(found.token, "a");
assert_eq!(found.source, TokenSource::GitHubTokenEnv);
}
#[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(&[("GH_TOKEN", ""), ("GITHUB_TOKEN", "a")]))
.expect("a token should be found");
assert_eq!(found.token, "a");
assert_eq!(found.source, TokenSource::GitHubTokenEnv);
}
#[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("GH_TOKEN/GITHUB_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/GITHUB_ENTERPRISE_TOKEN"));
}
}