Skip to content

Commit 276d72e

Browse files
committed
default auth url, api url to ~/.config/stacker/config.yml, device authorization
1 parent bd3c936 commit 276d72e

7 files changed

Lines changed: 498 additions & 14 deletions

File tree

install.sh

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,38 @@ download_and_install() {
105105
fi
106106

107107
ok "Installed stacker v${version} to ${INSTALL_DIR}/${BINARY_NAME}"
108+
109+
write_user_config
110+
}
111+
112+
# ── Write user config ────────────────────────────────
113+
114+
write_user_config() {
115+
local config_dir config_file
116+
117+
config_dir="${XDG_CONFIG_HOME:-${HOME}/.config}/stacker"
118+
config_file="${config_dir}/config.yml"
119+
120+
mkdir -p "$config_dir"
121+
122+
if [ -f "$config_file" ]; then
123+
info "User config already exists at ${config_file} — skipping"
124+
return
125+
fi
126+
127+
cat > "$config_file" <<'EOF'
128+
# Stacker CLI user configuration
129+
# Priority: CLI flag > environment variable > this file > built-in default
130+
131+
auth_url: https://try.direct/server/user
132+
server_url: https://try.direct/stacker
133+
134+
login:
135+
browser: true
136+
provider: gc # gc = Google, gh = GitHub
137+
EOF
138+
139+
ok "Wrote default config to ${config_file}"
108140
}
109141

110142
# ── Verify install ───────────────────────────────────

src/bin/stacker.rs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,15 @@ enum StackerCommands {
6767
/// Stacker API base URL (or set STACKER_URL)
6868
#[arg(long = "server-url", visible_alias = "api-url")]
6969
server_url: Option<String>,
70+
/// Authenticate via browser OAuth2 flow (opens a sign-in URL)
71+
#[arg(long)]
72+
browser: bool,
73+
/// OAuth provider code for browser login: gc (Google), gh (GitHub), … (default: gc)
74+
#[arg(long, value_name = "PROVIDER")]
75+
provider: Option<String>,
76+
/// Log in with username/password instead of browser OAuth (skips browser flow)
77+
#[arg(short = 'u', long, value_name = "EMAIL")]
78+
user: Option<String>,
7079
},
7180
/// Show the saved login and current project's recorded deploy identity
7281
Whoami {},
@@ -1704,8 +1713,11 @@ fn get_command(
17041713
domain,
17051714
auth_url,
17061715
server_url,
1716+
browser,
1717+
provider,
1718+
user,
17071719
} => Box::new(stacker::console::commands::cli::login::LoginCommand::new(
1708-
org, domain, auth_url, server_url,
1720+
org, domain, auth_url, server_url, browser, provider, user,
17091721
)),
17101722
StackerCommands::Whoami {} => {
17111723
Box::new(stacker::console::commands::cli::whoami::WhoamiCommand::new())

src/cli/credentials.rs

Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,7 @@ fn resolve_auth_url(request: &LoginRequest) -> Result<String, CliError> {
264264
.clone()
265265
.or_else(|| std::env::var("STACKER_AUTH_URL").ok())
266266
.or_else(|| std::env::var("STACKER_API_URL").ok())
267+
.or_else(|| crate::cli::user_config::UserConfig::load().auth_url)
267268
.ok_or_else(|| {
268269
CliError::ConfigValidation(
269270
"Missing auth URL. Pass `stacker login --auth-url <user-service-url> --server-url <stacker-api-url>` or set STACKER_AUTH_URL (or STACKER_API_URL) and STACKER_URL.".to_string(),
@@ -276,6 +277,7 @@ fn resolve_server_url(request: &LoginRequest) -> Result<String, CliError> {
276277
.server_url
277278
.clone()
278279
.or_else(|| std::env::var("STACKER_URL").ok())
280+
.or_else(|| crate::cli::user_config::UserConfig::load().server_url)
279281
.map(|value| crate::cli::install_runner::normalize_stacker_server_url(&value))
280282
.ok_or_else(|| {
281283
CliError::ConfigValidation(
@@ -404,6 +406,257 @@ impl fmt::Display for StoredCredentials {
404406
}
405407
}
406408

409+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
410+
// RFC 8628 Device Authorization Grant helpers
411+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
412+
413+
/// Strip a trailing `/auth/login` suffix so we always work from the user-service
414+
/// base URL (e.g. `https://try.direct/server/user`).
415+
fn oauth_base_url(auth_url: &str) -> String {
416+
let url = auth_url.trim_end_matches('/');
417+
for suffix in &["/auth/login", "/server/user/auth/login"] {
418+
if let Some(base) = url.strip_suffix(suffix) {
419+
return base.to_string();
420+
}
421+
}
422+
url.to_string()
423+
}
424+
425+
/// Response from `POST /oauth_client/device_authorization`.
426+
struct DeviceAuthResponse {
427+
device_code: String,
428+
user_code: String,
429+
verification_uri: String,
430+
verification_uri_complete: String,
431+
expires_in: u64,
432+
interval: u64,
433+
}
434+
435+
/// RFC 8628 §3.1 — Device Authorization Request.
436+
///
437+
/// POSTs `{"client_id": "stacker-cli", "provider": "<gc|gh|...>"}` and returns
438+
/// the server-generated codes the CLI needs to display and poll with.
439+
fn request_device_authorization(
440+
base_url: &str,
441+
provider: &str,
442+
) -> Result<DeviceAuthResponse, CliError> {
443+
let endpoint = format!("{base_url}/oauth_client/device_authorization");
444+
445+
let client = reqwest::blocking::Client::builder()
446+
.timeout(std::time::Duration::from_secs(30))
447+
.build()
448+
.map_err(|e| CliError::AuthFailed(format!("HTTP client error: {e}")))?;
449+
450+
let body = serde_json::json!({ "client_id": "stacker-cli", "provider": provider });
451+
let resp = client
452+
.post(&endpoint)
453+
.json(&body)
454+
.send()
455+
.map_err(|e| CliError::AuthFailed(format!("Network error: {e}")))?;
456+
457+
if !resp.status().is_success() {
458+
let status = resp.status();
459+
let preview: String = resp.text().unwrap_or_default().chars().take(200).collect();
460+
return Err(CliError::AuthFailed(format!(
461+
"Device authorization failed ({status}): {preview}"
462+
)));
463+
}
464+
465+
let data: serde_json::Value = resp
466+
.json()
467+
.map_err(|e| CliError::AuthFailed(format!("Invalid response: {e}")))?;
468+
let inner = data.get("data").unwrap_or(&data);
469+
470+
let field = |key: &str| -> Result<String, CliError> {
471+
inner
472+
.get(key)
473+
.and_then(|v| v.as_str())
474+
.map(|s| s.to_string())
475+
.ok_or_else(|| CliError::AuthFailed(format!("Response missing `{key}`: {data}")))
476+
};
477+
478+
Ok(DeviceAuthResponse {
479+
device_code: field("device_code")?,
480+
user_code: field("user_code")?,
481+
verification_uri: field("verification_uri")?,
482+
verification_uri_complete: field("verification_uri_complete")?,
483+
expires_in: inner.get("expires_in").and_then(|v| v.as_u64()).unwrap_or(300),
484+
interval: inner.get("interval").and_then(|v| v.as_u64()).unwrap_or(5),
485+
})
486+
}
487+
488+
/// RFC 8628 §3.4 — Device Access Token Request (polling).
489+
///
490+
/// Polls `POST /oauth_client/device_token` every `interval` seconds.
491+
/// Handles `authorization_pending` (keep waiting), `slow_down` (+5 s),
492+
/// `access_denied`, and `expired_token` per the spec.
493+
fn poll_device_token(
494+
base_url: &str,
495+
device_code: &str,
496+
interval_secs: u64,
497+
expires_in: u64,
498+
) -> Result<(String, Option<String>, Option<u64>), CliError> {
499+
let endpoint = format!("{base_url}/oauth_client/device_token");
500+
501+
let client = reqwest::blocking::Client::builder()
502+
.timeout(std::time::Duration::from_secs(15))
503+
.build()
504+
.map_err(|e| CliError::AuthFailed(format!("HTTP client error: {e}")))?;
505+
506+
let body = serde_json::json!({
507+
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
508+
"device_code": device_code,
509+
"client_id": "stacker-cli",
510+
});
511+
512+
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(expires_in);
513+
let mut interval = std::time::Duration::from_secs(interval_secs);
514+
515+
loop {
516+
std::thread::sleep(interval);
517+
518+
if std::time::Instant::now() >= deadline {
519+
return Err(CliError::AuthFailed(
520+
"Authentication timed out. Please try again.".to_string(),
521+
));
522+
}
523+
524+
let resp = match client.post(&endpoint).json(&body).send() {
525+
Ok(r) => r,
526+
Err(_) => continue, // transient network error — keep polling
527+
};
528+
529+
let status = resp.status();
530+
let data: serde_json::Value = resp.json().unwrap_or(serde_json::Value::Null);
531+
532+
if status.is_success() {
533+
let access_token = data
534+
.get("access_token")
535+
.and_then(|v| v.as_str())
536+
.ok_or_else(|| {
537+
CliError::AuthFailed("Token response missing access_token".to_string())
538+
})?
539+
.to_string();
540+
let refresh_token = data
541+
.get("refresh_token")
542+
.and_then(|v| v.as_str())
543+
.map(|s| s.to_string());
544+
let token_expires_in = data.get("expires_in").and_then(|v| v.as_u64());
545+
return Ok((access_token, refresh_token, token_expires_in));
546+
}
547+
548+
// RFC 8628 §3.5 error codes
549+
match data.get("error").and_then(|v| v.as_str()) {
550+
Some("authorization_pending") => {} // normal — keep polling
551+
Some("slow_down") => interval += std::time::Duration::from_secs(5),
552+
Some("access_denied") => {
553+
return Err(CliError::AuthFailed("Access denied by user.".to_string()))
554+
}
555+
Some("expired_token") => {
556+
return Err(CliError::AuthFailed(
557+
"Session expired. Please run `stacker login` again.".to_string(),
558+
))
559+
}
560+
Some(other) => {
561+
return Err(CliError::AuthFailed(format!("Auth error: {other}")))
562+
}
563+
None => {} // unexpected non-200 without error field — keep polling
564+
}
565+
}
566+
}
567+
568+
/// Retrieve the authenticated user's email from the user service.
569+
pub fn fetch_user_email(auth_url: &str, access_token: &str) -> Result<Option<String>, CliError> {
570+
let base = oauth_base_url(auth_url);
571+
let endpoint = format!("{base}/oauth_server/api/me");
572+
573+
let client = reqwest::blocking::Client::builder()
574+
.timeout(std::time::Duration::from_secs(15))
575+
.build()
576+
.map_err(|e| CliError::AuthFailed(format!("HTTP client error: {e}")))?;
577+
578+
let resp = client
579+
.get(&endpoint)
580+
.bearer_auth(access_token)
581+
.send()
582+
.map_err(|e| CliError::AuthFailed(format!("Network error fetching user profile: {e}")))?;
583+
584+
if !resp.status().is_success() {
585+
return Ok(None);
586+
}
587+
588+
let data: serde_json::Value = resp.json().unwrap_or(serde_json::Value::Null);
589+
// /oauth_server/api/me returns {"user": {"email": ...}}
590+
let email = data.get("email")
591+
.or_else(|| data.get("user").and_then(|u| u.get("email")))
592+
.and_then(|v| v.as_str())
593+
.map(|s| s.to_string());
594+
Ok(email)
595+
}
596+
597+
/// RFC 8628 Device Authorization Grant login flow.
598+
///
599+
/// 1. Requests device + user codes from the server.
600+
/// 2. Shows the user_code and opens the browser to the OAuth URL.
601+
/// 3. Polls until the user authenticates or the session expires.
602+
/// 4. Saves and returns the credentials.
603+
pub fn browser_login<S: CredentialStore>(
604+
store: &CredentialsManager<S>,
605+
auth_url: &str,
606+
server_url: &str,
607+
provider: &str,
608+
org: Option<&str>,
609+
domain: Option<&str>,
610+
) -> Result<StoredCredentials, CliError> {
611+
let base = oauth_base_url(auth_url);
612+
let device_auth = request_device_authorization(&base, provider)?;
613+
614+
eprintln!("\nTo sign in, open this URL in your browser:");
615+
eprintln!(" {}", device_auth.verification_uri_complete);
616+
eprintln!();
617+
618+
let opened = {
619+
#[cfg(target_os = "macos")]
620+
{ std::process::Command::new("open").arg(&device_auth.verification_uri_complete).status().is_ok() }
621+
#[cfg(target_os = "linux")]
622+
{ std::process::Command::new("xdg-open").arg(&device_auth.verification_uri_complete).status().is_ok() }
623+
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
624+
{ false }
625+
};
626+
if opened {
627+
eprintln!(" (Browser opened automatically)");
628+
}
629+
630+
eprintln!("Waiting for authentication...");
631+
632+
let (access_token, refresh_token, token_expires_in) = poll_device_token(
633+
&base,
634+
&device_auth.device_code,
635+
device_auth.interval,
636+
device_auth.expires_in,
637+
)?;
638+
639+
let email = fetch_user_email(auth_url, &access_token)?;
640+
641+
let min_ttl = session_ttl_secs();
642+
let ttl = token_expires_in.unwrap_or(min_ttl).max(min_ttl);
643+
let expires_at = chrono::Utc::now() + chrono::Duration::seconds(ttl as i64);
644+
645+
let creds = StoredCredentials {
646+
access_token,
647+
refresh_token,
648+
token_type: "Bearer".to_string(),
649+
expires_at,
650+
email,
651+
server_url: Some(crate::cli::install_runner::normalize_stacker_server_url(server_url)),
652+
org: org.map(|s| s.to_string()),
653+
domain: domain.map(|s| s.to_string()),
654+
};
655+
656+
store.save(&creds)?;
657+
Ok(creds)
658+
}
659+
407660
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
408661
// Tests
409662
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

src/cli/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,4 @@ pub mod runtime;
3232
pub mod service_catalog;
3333
pub mod service_import;
3434
pub mod stacker_client;
35+
pub mod user_config;

0 commit comments

Comments
 (0)