use std::time::Duration;
use anyhow::{bail, Context, Result};
use clap::Parser;
use crate::{auth, http, ui};
const CLIENT_ID: &str = "portaki-cli";
const BLIPS_TOLERATED: u32 = 3;
const SCOPES: [&str; 5] = [
"dev:read",
"dev:deploy",
"dev:dispatch",
"dev:stay:read",
"registry:publish",
];
const CLIENT_VERSION: &str = env!("CARGO_PKG_VERSION");
const SDK_VERSION: &str = portaki_sdk::VERSION;
#[derive(Debug, Parser)]
pub struct LoginArgs {
#[arg(long)]
pub url: Option<String>,
#[arg(long)]
pub no_browser: bool,
}
#[derive(Debug, Parser)]
pub struct LogoutArgs {
#[arg(long)]
pub url: Option<String>,
}
#[derive(Debug, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct DeviceCode {
device_code: String,
user_code: String,
verification_uri: String,
#[serde(default)]
verification_uri_complete: Option<String>,
expires_in: u64,
interval: u64,
}
#[derive(Debug, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct Granted {
access_token: String,
refresh_token: String,
#[serde(default)]
scopes: Vec<String>,
}
pub async fn run(args: LoginArgs) -> Result<()> {
ui::header(
"portaki login",
"Device grant — the code below ties this terminal to your Portaki account.",
);
let base = base_url(args.url.as_deref());
let client = http::client();
let code_url = format!("{base}/api/v1/auth/device/code");
let asking = ui::step("asking the platform for a code");
let sent = client
.post(&code_url)
.json(&serde_json::json!({
"clientId": CLIENT_ID,
"scopes": SCOPES,
"deviceLabel": device_label(),
"clientVersion": CLIENT_VERSION,
"sdkVersion": SDK_VERSION,
}))
.send()
.await;
let response = match sent {
Ok(response) => response,
Err(failure) => {
asking.abandon();
return Err(http::unreachable(&code_url, failure));
}
};
let status = response.status();
let body = response.text().await.unwrap_or_default();
if !status.is_success() {
asking.abandon();
bail!("{}", http::refused(&code_url, status.as_u16(), &body));
}
let started: DeviceCode = crate::api::unwrap(&body).map_err(|failure| {
asking.abandon();
failure
})?;
asking.done("got a code");
present(&started, args.no_browser);
let mut interval = Duration::from_secs(started.interval.max(1));
let deadline = std::time::Instant::now() + Duration::from_secs(started.expires_in);
let token_url = format!("{base}/api/v1/auth/device/token");
let waiting = ui::step("waiting for approval");
let mut blips: u32 = 0;
loop {
let now = std::time::Instant::now();
if now >= deadline {
waiting.abandon();
bail!("the code expired before it was approved — run `portaki login` again");
}
waiting.say(format!(
"waiting for approval — {} left",
ui::countdown(deadline - now)
));
tokio::time::sleep(interval).await;
let sent = client
.post(&token_url)
.json(&serde_json::json!({ "deviceCode": started.device_code }))
.timeout(poll_timeout(interval))
.send()
.await;
let response = match sent {
Ok(response) => response,
Err(failure) => {
blips += 1;
if give_up_after(blips) {
waiting.abandon();
return Err(http::unreachable(&token_url, failure)).context(format!(
"the platform stopped answering — {blips} polls in a row failed"
));
}
continue;
}
};
let status = response.status();
let body = response.text().await.unwrap_or_default();
if status.is_success() {
let granted: Granted = crate::api::unwrap(&body).map_err(|failure| {
waiting.abandon();
failure
})?;
auth::store(&granted.access_token, &granted.refresh_token)?;
waiting.done("approved");
ui::success("signed in — token stored in the system keychain");
if !granted.scopes.is_empty() {
ui::field("scopes", granted.scopes.join(" "));
}
ui::advice(
"the access token lasts minutes and renews itself — the session lives in the \
keychain until portaki logout",
);
ui::next(&[
(
"portaki dev --watch",
"build, deploy to the sandbox, redeploy on every save",
),
(
"portaki publish",
"push a release and announce it to the registry",
),
]);
ui::blank();
return Ok(());
}
match interpret(status.as_u16(), &body, &token_url) {
Pending::KeepWaiting => blips = 0,
Pending::SlowDown => {
blips = 0;
interval += Duration::from_secs(5);
}
Pending::Blip => {
blips += 1;
if give_up_after(blips) {
waiting.abandon();
bail!(
"{} — {blips} polls in a row failed",
http::refused(&token_url, status.as_u16(), &body)
);
}
}
Pending::GiveUp(reason) => {
waiting.abandon();
bail!(reason);
}
}
}
}
fn give_up_after(consecutive: u32) -> bool {
consecutive > BLIPS_TOLERATED
}
fn poll_timeout(interval: Duration) -> Duration {
(interval + Duration::from_secs(5)).clamp(Duration::from_secs(8), Duration::from_secs(20))
}
#[derive(Debug, PartialEq, Eq)]
enum Pending {
KeepWaiting,
SlowDown,
Blip,
GiveUp(String),
}
fn interpret(status: u16, body: &str, url: &str) -> Pending {
match crate::api::error_code(body).as_deref() {
Some("authorization_pending") => Pending::KeepWaiting,
Some("slow_down") => Pending::SlowDown,
Some("access_denied") => Pending::GiveUp("the request was denied".to_owned()),
Some("expired_token") => {
Pending::GiveUp("the code expired — run `portaki login` again".to_owned())
}
Some(other) => Pending::GiveUp(format!("the platform answered {other}")),
None if status >= 500 => Pending::Blip,
None => Pending::GiveUp(http::refused(url, status, body)),
}
}
fn present(started: &DeviceCode, no_browser: bool) {
let target = started
.verification_uri_complete
.as_deref()
.unwrap_or(&started.verification_uri);
ui::blank();
ui::code_block(&started.user_code);
ui::blank();
if no_browser || !ui::open_browser(target) {
ui::field("open", target);
ui::field("code", &started.user_code);
} else {
ui::success(if started.verification_uri_complete.is_some() {
"opened your browser — check the code above, then approve"
} else {
"opened your browser — paste the code above to approve"
});
ui::detail(target);
}
ui::blank();
}
pub async fn logout(args: LogoutArgs) -> Result<()> {
ui::header(
"portaki logout",
"End the session here, and on the platform.",
);
let stored = auth::refresh_token();
auth::forget()?;
ui::success("signed out here — credentials cleared");
let Some(refresh_token) = stored else {
ui::detail("no session was stored");
ui::blank();
return Ok(());
};
match revoke(&base_url(args.url.as_deref()), &refresh_token).await {
Ok(()) => ui::success("the platform revoked this session"),
Err(failure) => {
ui::warn("could not reach the platform — this session is still valid there");
ui::detail(format!("{failure:#}"));
ui::advice("run portaki logout again once you are online");
}
}
ui::blank();
Ok(())
}
async fn revoke(base: &str, refresh_token: &str) -> Result<()> {
let url = format!("{base}/api/v1/auth/logout");
let response = http::client()
.post(&url)
.json(&serde_json::json!({ "refreshToken": refresh_token }))
.timeout(std::time::Duration::from_secs(10))
.send()
.await
.map_err(|failure| http::unreachable(&url, failure))?;
let status = response.status();
if !status.is_success() {
let body = response.text().await.unwrap_or_default();
anyhow::bail!("{}", http::refused(&url, status.as_u16(), &body));
}
Ok(())
}
fn base_url(explicit: Option<&str>) -> String {
auth::api_base_url(explicit)
}
fn device_label() -> Option<String> {
if let Ok(name) = std::env::var("COMPUTERNAME") {
if let Some(name) = non_empty(name) {
return Some(name);
}
}
if let Ok(output) = std::process::Command::new("hostname").output() {
if output.status.success() {
if let Some(name) = non_empty(String::from_utf8_lossy(&output.stdout).into_owned()) {
return Some(name);
}
}
}
std::env::var("HOSTNAME")
.or_else(|_| std::env::var("HOST"))
.ok()
.and_then(non_empty)
}
fn non_empty(value: String) -> Option<String> {
let trimmed = value.trim();
(!trimmed.is_empty()).then(|| trimmed.to_owned())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_cli_never_asks_for_a_host_scope() {
assert!(!SCOPES.iter().any(|s| s.starts_with("host:")));
}
#[test]
fn sandbox_stays_are_asked_for_under_the_dev_domain() {
assert!(SCOPES.contains(&"dev:stay:read"));
assert!(!SCOPES.contains(&"stay:read"));
}
#[test]
fn a_device_label_is_trimmed_or_absent() {
assert_eq!(
non_empty(" my-laptop.local\n".to_owned()).as_deref(),
Some("my-laptop.local")
);
assert_eq!(non_empty(" ".to_owned()), None);
assert_eq!(non_empty(String::new()), None);
}
#[test]
fn a_missing_device_label_is_not_an_error() {
let label = device_label();
assert!(label
.as_deref()
.map(str::trim)
.map(|l| !l.is_empty())
.unwrap_or(true));
}
#[test]
fn the_versions_announced_are_the_ones_compiled_in() {
assert!(!CLIENT_VERSION.is_empty());
assert_eq!(SDK_VERSION, portaki_sdk::VERSION);
}
#[test]
fn an_explicit_url_wins_over_the_environment() {
std::env::set_var("PORTAKI_API_URL", "https://from-env.example");
assert_eq!(
base_url(Some("https://explicit.example/")),
"https://explicit.example"
);
std::env::remove_var("PORTAKI_API_URL");
}
#[test]
fn a_trailing_slash_never_doubles_in_the_path() {
assert_eq!(
base_url(Some("https://api.example/")),
"https://api.example"
);
}
const TOKEN_URL: &str = "https://api-staging.portaki.app/api/v1/auth/device/token";
#[test]
fn a_blip_does_not_end_the_login() {
for consecutive in 1..=BLIPS_TOLERATED {
assert!(!give_up_after(consecutive), "gave up after {consecutive}");
}
}
#[test]
fn a_run_of_failures_ends_the_login() {
assert!(give_up_after(BLIPS_TOLERATED + 1));
assert!(give_up_after(BLIPS_TOLERATED + 9));
}
#[test]
fn the_counter_is_a_run_and_not_a_total() {
let mut blips = 0_u32;
for outcome in [false, false, true, false, false] {
if outcome {
blips = 0;
} else {
blips += 1;
}
assert!(!give_up_after(blips));
}
}
#[test]
fn a_poll_never_outlives_the_code_it_asks_about() {
let expires_in = Duration::from_secs(600);
let interval = Duration::from_secs(5);
assert!(poll_timeout(interval) < expires_in / 10);
assert!(poll_timeout(Duration::from_secs(1)) >= Duration::from_secs(8));
assert_eq!(
poll_timeout(Duration::from_secs(600)),
Duration::from_secs(20)
);
}
#[test]
fn pending_and_slow_down_are_not_failures() {
assert_eq!(
interpret(400, r#"{"error_code":"authorization_pending"}"#, TOKEN_URL),
Pending::KeepWaiting
);
assert_eq!(
interpret(429, r#"{"error_code":"slow_down"}"#, TOKEN_URL),
Pending::SlowDown
);
}
#[test]
fn a_denial_stops_the_login_at_once() {
let denied = interpret(403, r#"{"error_code":"access_denied"}"#, TOKEN_URL);
let expired = interpret(400, r#"{"error_code":"expired_token"}"#, TOKEN_URL);
assert!(matches!(denied, Pending::GiveUp(said) if said.contains("denied")));
assert!(matches!(expired, Pending::GiveUp(said) if said.contains("expired")));
}
#[test]
fn a_status_without_an_oauth_code_names_the_url_that_was_polled() {
let said = match interpret(404, "<html>not found</html>", TOKEN_URL) {
Pending::GiveUp(said) => said,
other => panic!("{other:?}"),
};
assert!(said.contains("404"), "{said}");
assert!(said.contains(TOKEN_URL), "{said}");
}
#[test]
fn a_platform_hiccup_is_retried_rather_than_fatal() {
assert_eq!(
interpret(502, "<html>bad gateway</html>", TOKEN_URL),
Pending::Blip
);
assert_eq!(interpret(503, "", TOKEN_URL), Pending::Blip);
}
#[test]
fn an_unknown_oauth_code_is_reported_verbatim() {
let said = match interpret(400, r#"{"error_code":"invalid_client"}"#, TOKEN_URL) {
Pending::GiveUp(said) => said,
other => panic!("{other:?}"),
};
assert!(said.contains("invalid_client"), "{said}");
}
}