use crate::util::now_ms;
use base64::Engine;
use serde_json::{json, Value};
use sha2::{Digest, Sha256};
use std::io::{Read, Write};
use std::time::{Duration, Instant};
type BErr = Box<dyn std::error::Error>;
pub fn provider_help() -> String {
let Ok(registry) = read_registry() else {
return format!(
"Provider defaults ship with the binary; overlay them at {}.",
crate::paths::providers_file().display()
);
};
let mut names: Vec<&String> = registry.keys().collect();
names.sort();
let width = names.iter().map(|n| n.len()).max().unwrap_or(0);
let mut out = String::from("Providers:\n");
for name in names {
let p = ®istry[name.as_str()];
let what = match p["auth"].as_str() {
Some("api_key") => "prompts you to paste an API key",
Some("smtp") => "prompts for SMTP host, port, username, password (e.g. Mailgun)",
Some("discord") => "prompts for a bot token and, optionally, your Discord user id",
Some("slack") => "prompts for a bot token (xoxb-) and a Socket Mode app token (xapp-)",
Some("oauth") => match p["login"]["flow"].as_str() {
Some("device_code") => {
"device-code login: open a URL, enter the shown code, approve"
}
Some("pkce") => {
"browser login: open the printed URL, authorize, paste the code back"
}
_ => "OAuth login (unrecognized flow)",
},
_ => "unrecognized auth kind",
};
out.push_str(&format!(" {name:width$} {what}\n"));
}
out.push_str("\nThe credential lands in the vault; tokens are refreshed automatically.");
out
}
pub fn list() -> Result<(), BErr> {
println!("{}", provider_help());
println!("\nusage: impyard server vault connect <provider>");
Ok(())
}
pub async fn run(name: &str) -> Result<(), BErr> {
let registry = read_registry()?;
let mut available: Vec<String> = registry.keys().cloned().collect();
available.sort();
let p = registry.get(name).ok_or_else(|| {
format!(
"unknown provider \"{name}\" — try one of: {}",
available.join(", ")
)
})?;
let cred = login(name, p).await?;
store(name, &cred)?;
println!("\nconnected: credential for \"{name}\" written to the vault");
Ok(())
}
pub async fn login(provider_name: &str, p: &Value) -> Result<Value, BErr> {
match p.get("auth").and_then(|v| v.as_str()) {
Some("api_key") => connect_api_key(),
Some("smtp") => connect_smtp(),
Some("discord") => connect_discord(),
Some("slack") => connect_slack(),
Some("oauth") => {
let login = p.get("login").cloned().unwrap_or_else(|| json!({}));
match login.get("flow").and_then(|v| v.as_str()) {
Some("device_code") => connect_device_code(p, &login).await,
Some("pkce") => connect_pkce(p, &login).await,
other => Err(
format!("provider \"{provider_name}\": unknown oauth flow {other:?}").into(),
),
}
}
other => Err(format!("provider \"{provider_name}\": unknown auth {other:?}").into()),
}
}
pub fn store(name: &str, cred: &Value) -> Result<(), BErr> {
write_vault(name, cred)
}
pub fn ask(question: &str) -> Result<String, BErr> {
prompt(question)
}
fn connect_api_key() -> Result<Value, BErr> {
let key = prompt("paste the API key: ")?;
if key.is_empty() {
return Err("no key entered".into());
}
Ok(json!({ "type": "api_key", "key": key }))
}
fn connect_smtp() -> Result<Value, BErr> {
let host = prompt_default("SMTP host [smtp.mailgun.org]: ", "smtp.mailgun.org")?;
let port: u16 = prompt_default("SMTP port [465 = TLS, 587 = STARTTLS] [465]: ", "465")?
.parse()
.map_err(|_| "port must be a number")?;
let user = prompt("SMTP username (e.g. postmaster@mg.example.com): ")?;
if user.is_empty() {
return Err("no username entered".into());
}
let pass = prompt_hidden("SMTP password: ")?;
if pass.is_empty() {
return Err("no password entered".into());
}
let from = prompt_default(&format!("From address [{user}]: "), &user)?;
Ok(
json!({ "type": "smtp", "host": host, "port": port, "user": user, "pass": pass, "from": from }),
)
}
fn connect_discord() -> Result<Value, BErr> {
let token = prompt_hidden("Discord bot token: ")?;
if token.is_empty() {
return Err("no token entered".into());
}
let owner_id = prompt("Owner Discord user id (for DMs; optional): ")?;
Ok(json!({ "type": "discord", "token": token, "owner_id": owner_id }))
}
fn connect_slack() -> Result<Value, BErr> {
let bot_token = prompt_hidden("Slack bot token (xoxb-…): ")?;
if bot_token.is_empty() {
return Err("no bot token entered".into());
}
if !bot_token.starts_with("xoxb-") {
eprintln!("note: bot tokens usually start with xoxb-");
}
let app_token = prompt_hidden("Slack app-level token (xapp-…, scope connections:write): ")?;
if app_token.is_empty() {
return Err("no app-level token entered — Socket Mode needs one (app settings → Basic Information → App-Level Tokens)".into());
}
let owner_id = prompt("Your lead's Slack member id (for DMs; optional): ")?;
Ok(
json!({ "type": "slack", "bot_token": bot_token, "app_token": app_token, "owner_id": owner_id }),
)
}
async fn connect_device_code(p: &Value, login: &Value) -> Result<Value, BErr> {
let client_id = p["client_id"].as_str().ok_or("provider has no client_id")?;
let http = reqwest::Client::new();
let start: Value = http
.post(str_field(login, "device_authorization_url")?)
.json(&json!({ "client_id": client_id }))
.send()
.await?
.json()
.await?;
let device_auth_id = start["device_auth_id"]
.as_str()
.ok_or_else(|| format!("device-code start failed: {start}"))?;
let user_code = start["user_code"]
.as_str()
.ok_or("device-code start had no user_code")?;
let mut wait = start["interval"].as_u64().unwrap_or(5);
println!(
"\n 1. open: {}",
login["verification_url"].as_str().unwrap_or("")
);
println!(" 2. enter code: {user_code}\n");
println!("waiting for you to authorize…");
let deadline = Instant::now() + Duration::from_secs(15 * 60);
let (auth_code, verifier) = loop {
if Instant::now() >= deadline {
return Err("device-code login timed out".into());
}
tokio::time::sleep(Duration::from_secs(wait)).await;
let res = http
.post(str_field(login, "device_token_url")?)
.json(&json!({ "device_auth_id": device_auth_id, "user_code": user_code }))
.send()
.await?;
let status = res.status().as_u16();
if res.status().is_success() {
let j: Value = res.json().await?;
if let (Some(c), Some(v)) = (
j["authorization_code"].as_str(),
j["code_verifier"].as_str(),
) {
break (c.to_string(), v.to_string());
}
} else if status == 403 || status == 404 {
continue;
} else {
let text = res.text().await.unwrap_or_default();
match error_code(&text).as_deref() {
Some("deviceauth_authorization_pending") => continue,
Some("slow_down") => {
wait += 2;
continue;
}
_ => return Err(format!("device-code poll failed ({status}): {text}").into()),
}
}
};
let tok = post_form(
str_field(p, "token_url")?,
&[
("grant_type", "authorization_code"),
("client_id", client_id),
("code", &auth_code),
("code_verifier", &verifier),
("redirect_uri", str_field(login, "exchange_redirect_uri")?),
],
)
.await?;
oauth_cred(p, &tok)
}
async fn connect_pkce(p: &Value, login: &Value) -> Result<Value, BErr> {
let client_id = p["client_id"].as_str().ok_or("provider has no client_id")?;
let redirect_uri = str_field(login, "redirect_uri")?;
let verifier = b64url(&random_bytes(32));
let challenge = b64url(&Sha256::digest(verifier.as_bytes()));
let sent_state = if login["state_source"].as_str() == Some("verifier") {
verifier.clone()
} else {
b64url(&random_bytes(16))
};
let mut url = reqwest::Url::parse(str_field(login, "authorize_url")?)?;
{
let mut q = url.query_pairs_mut();
q.append_pair("response_type", "code");
q.append_pair("client_id", client_id);
q.append_pair("redirect_uri", redirect_uri);
q.append_pair("scope", login["scope"].as_str().unwrap_or(""));
q.append_pair("code_challenge", &challenge);
q.append_pair("code_challenge_method", "S256");
q.append_pair("state", &sent_state);
if let Some(extra) = login["extra_authorize_params"].as_object() {
for (k, v) in extra {
if let Some(s) = v.as_str() {
q.append_pair(k, s);
}
}
}
}
println!("\n 1. open this URL and authorize:\n\n {url}\n");
println!(" 2. after authorizing, paste the code (or the full redirect URL) here:\n");
let (code, got_state) = parse_callback(&prompt("code / redirect URL: ")?);
if code.is_empty() {
return Err("no authorization code found in what you pasted".into());
}
if !got_state.is_empty() && got_state != sent_state {
return Err("state mismatch — aborting".into());
}
let exchange_state = if got_state.is_empty() {
sent_state
} else {
got_state
};
let tok = post_json(
str_field(p, "token_url")?,
&json!({
"grant_type": "authorization_code", "client_id": client_id, "code": code, "state": exchange_state,
"redirect_uri": redirect_uri, "code_verifier": verifier,
}),
)
.await?;
oauth_cred(p, &tok)
}
fn parse_callback(value: &str) -> (String, String) {
let value = value.trim();
if let Ok(url) = reqwest::Url::parse(value) {
let mut code = String::new();
let mut state = String::new();
for (k, v) in url.query_pairs() {
if k == "code" {
code = v.into_owned();
} else if k == "state" {
state = v.into_owned();
}
}
if !code.is_empty() {
return (code, state);
}
}
if let Some((code, state)) = value.split_once('#') {
return (code.to_string(), state.to_string());
}
(value.to_string(), String::new())
}
fn oauth_cred(p: &Value, tok: &Value) -> Result<Value, BErr> {
let access = tok["access_token"]
.as_str()
.ok_or_else(|| format!("token response missing access_token: {tok}"))?;
let refresh = tok["refresh_token"]
.as_str()
.ok_or("token response missing refresh_token")?;
let expires_in = tok["expires_in"]
.as_f64()
.ok_or("token response missing expires_in")?;
let skew = p["skew_ms"].as_i64().unwrap_or(0);
let mut cred = json!({
"type": "oauth",
"access": access,
"refresh": refresh,
"expires": now_ms() + (expires_in as i64) * 1000 - skew,
});
if let Some(path) = p["account_id_claim"].as_array() {
let claim: Vec<&str> = path.iter().filter_map(|v| v.as_str()).collect();
if let Some(id) = jwt_claim(access, &claim) {
cred["accountId"] = json!(id);
}
}
Ok(cred)
}
fn jwt_claim(jwt: &str, path: &[&str]) -> Option<String> {
let payload = jwt.split('.').nth(1)?;
let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(payload)
.ok()?;
let mut node: Value = serde_json::from_slice(&bytes).ok()?;
for key in path {
node = node.get(*key)?.clone();
}
node.as_str().map(String::from)
}
fn read_registry() -> Result<serde_json::Map<String, Value>, BErr> {
Ok(crate::credential::registry::registry_json())
}
fn write_vault(name: &str, cred: &Value) -> Result<(), BErr> {
let dir = crate::credential::vault::vault_dir();
std::fs::create_dir_all(&dir)?;
let path = dir.join(format!("{name}.json"));
std::fs::write(&path, format!("{}\n", serde_json::to_string_pretty(cred)?))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?;
}
Ok(())
}
async fn post_json(url: &str, body: &Value) -> Result<Value, BErr> {
let res = reqwest::Client::new().post(url).json(body).send().await?;
let status = res.status();
let text = res.text().await?;
if !status.is_success() {
return Err(format!("POST {url} → {status}: {}", &text[..text.len().min(300)]).into());
}
Ok(serde_json::from_str(&text)?)
}
async fn post_form(url: &str, form: &[(&str, &str)]) -> Result<Value, BErr> {
let res = reqwest::Client::new().post(url).form(form).send().await?;
let status = res.status();
let text = res.text().await?;
if !status.is_success() {
return Err(format!("POST {url} → {status}: {}", &text[..text.len().min(300)]).into());
}
Ok(serde_json::from_str(&text)?)
}
fn str_field<'a>(v: &'a Value, key: &str) -> Result<&'a str, BErr> {
v[key]
.as_str()
.ok_or_else(|| format!("provider config missing \"{key}\"").into())
}
fn error_code(text: &str) -> Option<String> {
let j: Value = serde_json::from_str(text).ok()?;
match &j["error"] {
Value::String(s) => Some(s.clone()),
Value::Object(o) => o.get("code").and_then(|v| v.as_str()).map(String::from),
_ => None,
}
}
fn b64url(bytes: &[u8]) -> String {
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
}
fn random_bytes(n: usize) -> Vec<u8> {
let mut buf = vec![0u8; n];
if let Ok(mut f) = std::fs::File::open("/dev/urandom") {
let _ = f.read_exact(&mut buf);
}
buf
}
fn prompt(question: &str) -> Result<String, BErr> {
print!("{question}");
std::io::stdout().flush()?;
let mut line = String::new();
std::io::stdin().read_line(&mut line)?;
Ok(line.trim().to_string())
}
fn prompt_default(question: &str, default: &str) -> Result<String, BErr> {
let v = prompt(question)?;
Ok(if v.is_empty() { default.to_string() } else { v })
}
fn prompt_hidden(question: &str) -> Result<String, BErr> {
print!("{question}");
std::io::stdout().flush()?;
let echo_off = set_echo(false);
let mut line = String::new();
let result = std::io::stdin().read_line(&mut line);
if echo_off {
set_echo(true);
println!(); }
result?;
Ok(line.trim().to_string())
}
fn set_echo(on: bool) -> bool {
std::process::Command::new("stty")
.arg(if on { "echo" } else { "-echo" })
.status()
.map(|s| s.success())
.unwrap_or(false)
}