Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ Pre-built releases (brew, scoop, deb) coming with the next milestone.

```bash
mirrorstack login # Sign in via OAuth (PKCE)
mirrorstack logout # Revoke the current session and wipe local credentials
mirrorstack whoami # Print the currently signed-in user
mirrorstack module init # Register a new module on the platform
mirrorstack --help # Show help
Expand Down
84 changes: 84 additions & 0 deletions src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,44 @@ pub fn create_module(
})
}

/// DELETE /v1/auth/sessions/current — revoke the supplied refresh token
/// (CLI flow: token in body, not cookie). The platform treats a missing
/// or already-revoked session as success, so callers can call this
/// idempotently. A 401 means the access token is gone but does NOT
/// necessarily mean the refresh token is — surface as Unauthenticated
/// and let the caller decide whether to still wipe local creds.
pub fn revoke_session(
http: &Client,
api_base: &str,
access_token: &str,
refresh_token: &str,
) -> Result<(), ApiError> {
#[derive(Serialize)]
struct Body<'a> {
refresh_token: &'a str,
}
let endpoint = format!(
"{}/v1/auth/sessions/current",
api_base.trim_end_matches('/')
);

let resp = http
.delete(&endpoint)
.bearer_auth(access_token)
.header("Accept", "application/json")
.json(&Body { refresh_token })
.send()?;

let status = resp.status();
if status.is_success() {
return Ok(());
}
if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
return Err(ApiError::Unauthenticated);
}
Err(unexpected_body_error(resp))
}

/// Common tail for unexpected (non-success, non-typed) responses: read
/// the body with [`http::read_capped`] and wrap as `ApiError::Unexpected`.
/// If reading fails, the io error is folded into the body for diagnosis.
Expand Down Expand Up @@ -380,6 +418,52 @@ mod tests {
}
}

#[test]
fn revoke_session_204_is_ok() {
let mut server = Server::new();
let _m = server
.mock("DELETE", "/v1/auth/sessions/current")
.match_header("authorization", "Bearer AT")
.match_body(mockito::Matcher::JsonString(
r#"{"refresh_token":"RT"}"#.into(),
))
.with_status(204)
.create();

revoke_session(&test_client(), &server.url(), "AT", "RT").expect("ok");
}

#[test]
fn revoke_session_401_is_unauthenticated() {
let mut server = Server::new();
let _m = server
.mock("DELETE", "/v1/auth/sessions/current")
.with_status(401)
.create();

let err = revoke_session(&test_client(), &server.url(), "expired", "RT").unwrap_err();
assert!(matches!(err, ApiError::Unauthenticated), "got {err:?}");
}

#[test]
fn revoke_session_5xx_is_unexpected() {
let mut server = Server::new();
let _m = server
.mock("DELETE", "/v1/auth/sessions/current")
.with_status(503)
.with_body("upstream timeout")
.create();

let err = revoke_session(&test_client(), &server.url(), "AT", "RT").unwrap_err();
match err {
ApiError::Unexpected { status, body } => {
assert_eq!(status, 503);
assert!(body.contains("upstream timeout"), "got body {body:?}");
}
other => panic!("expected Unexpected, got {other:?}"),
}
}

#[test]
fn create_module_4xx_without_envelope_is_unexpected() {
let mut server = Server::new();
Expand Down
65 changes: 65 additions & 0 deletions src/commands/logout.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
//! `mirrorstack logout` — revoke the current session and wipe local
//! credentials. Best-effort: a failed server revoke (network down,
//! 5xx) still wipes the local credentials and exits 0 with a warning.
//! The user's intent is "log me out"; refusing to clear local state
//! because the platform is unreachable would leave them stuck.

use std::time::Duration;

use anyhow::Result;
use clap::Args;
use console::style;

use crate::api::{self, ApiError};
use crate::credentials::{self, LoadError};
use crate::http;

use super::{DEFAULT_API_BASE, ENV_API_URL, ok_mark, resolve_base, warn_prefix};

#[derive(Args)]
pub struct LogoutArgs {}

pub fn run(_args: LogoutArgs) -> Result<()> {
let creds = match credentials::load() {
Ok(c) => c,
Err(LoadError::NotFound) => {
eprintln!("{} already signed out.", ok_mark());
return Ok(());
}
Err(e) => return Err(e.into()),
};

let api_base = resolve_base(ENV_API_URL, DEFAULT_API_BASE);
let client = http::client(Duration::from_secs(15))?;

// Server revoke is best-effort: we always wipe local creds afterwards.
// A 401 means the access token was already gone; the platform's
// Revoke handler is idempotent on the refresh token, so any non-401
// failure (network, 5xx) just means we couldn't tell the server.
let server_warn = match api::revoke_session(
&client,
&api_base,
&creds.access_token,
&creds.refresh_token,
) {
Ok(()) => None,
Err(ApiError::Unauthenticated) => None,
Err(e) => Some(format!("{e}")),
};

credentials::delete()?;

if let Some(reason) = server_warn {
eprintln!(
"{} server revoke failed ({reason}); local credentials wiped.",
warn_prefix(),
);
eprintln!(
" {}",
style("the refresh token may still be valid until it expires.").dim(),
);
} else {
eprintln!("{} signed out.", ok_mark());
}
Ok(())
}
15 changes: 15 additions & 0 deletions src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@

use anyhow::Result;
use clap::{Parser, Subcommand};
use console::{StyledObject, style};

mod login;
mod logout;
mod module;
mod whoami;

Expand All @@ -31,6 +33,16 @@ pub(crate) fn resolve_base(env_var: &str, default: &str) -> String {
std::env::var(env_var).unwrap_or_else(|_| default.into())
}

/// Green bold "✓" — shared status prefix for success lines across commands.
pub(crate) fn ok_mark() -> StyledObject<&'static str> {
style("✓").green().bold()
}

/// Yellow bold "warning:" — shared prefix for non-fatal advisory lines.
pub(crate) fn warn_prefix() -> StyledObject<&'static str> {
style("warning:").yellow().bold()
}

/// Official command-line tool for the MirrorStack platform.
#[derive(Parser)]
#[command(name = "mirrorstack", version, about, long_about = None)]
Expand All @@ -43,6 +55,8 @@ pub struct Cli {
enum Command {
/// Sign in to MirrorStack via OAuth.
Login(login::LoginArgs),
/// Sign out: revoke the current session and remove local credentials.
Logout(logout::LogoutArgs),
/// Print the currently signed-in user.
Whoami(whoami::WhoamiArgs),
/// Manage developer modules (the per-developer reusable units installed
Expand All @@ -54,6 +68,7 @@ impl Cli {
pub fn run(self) -> Result<()> {
match self.command {
Command::Login(args) => login::run(args),
Command::Logout(args) => logout::run(args),
Command::Whoami(args) => whoami::run(args),
Command::Module(args) => module::run(args),
}
Expand Down
6 changes: 3 additions & 3 deletions src/commands/module.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ use crate::http;

use super::{
DEFAULT_API_BASE, DEFAULT_APPS_API_BASE, DEFAULT_WEB_BASE, ENV_API_URL, ENV_APPS_API_URL,
ENV_WEB_URL, resolve_base,
ENV_WEB_URL, ok_mark, resolve_base,
};

#[derive(Args)]
Expand Down Expand Up @@ -239,7 +239,7 @@ where
fn print_created(username: &str, slug: &str, id: &str) {
eprintln!(
"{} created {}",
style("✓").green().bold(),
ok_mark(),
style(format!("@{username}/{slug}")).cyan().bold(),
);
eprintln!(" {} {}", style("id:").dim(), id);
Expand All @@ -248,7 +248,7 @@ fn print_created(username: &str, slug: &str, id: &str) {
fn print_already_exists(username: &str, slug: &str, id: Option<&str>) {
eprintln!(
"{} {} already exists; {} continuing.",
style("✓").green().bold(),
ok_mark(),
style(format!("@{username}/{slug}")).cyan().bold(),
style("--used set,").dim(),
);
Expand Down
14 changes: 13 additions & 1 deletion src/credentials/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ pub struct Credentials {
}

#[derive(Debug, Error)]
#[allow(dead_code)] // NotFound is matched in load(); load() is API for future commands
pub enum LoadError {
#[error("credentials: not found (run `mirrorstack login`)")]
NotFound,
Expand Down Expand Up @@ -90,6 +89,19 @@ pub fn load_or_login_hint() -> Result<Credentials> {
}
}

/// Wipe the credentials file. Idempotent — a missing file is treated as
/// success so callers don't need to special-case "already logged out".
/// On other errors (typically permissions) the path is included in the
/// message so the user can act manually.
pub fn delete() -> Result<()> {
let p = path().context("credentials: path")?;
match fs::remove_file(&p) {
Ok(()) => Ok(()),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(anyhow::anyhow!("credentials: remove {}: {e}", p.display())),
}
}

/// Encode `SystemTime` as RFC 3339 in JSON. Avoids pulling chrono just
/// for this; serde_json's default for SystemTime is a tagged struct
/// which is ugly on disk.
Expand Down
7 changes: 7 additions & 0 deletions src/credentials/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,4 +69,11 @@ fn credentials_lifecycle() {
let mode = std::fs::metadata(&p).expect("stat").permissions().mode() & 0o777;
assert_eq!(mode, 0o600, "got {mode:o}, want 0600");
}

// delete() wipes the file; subsequent load() returns NotFound.
delete().expect("delete");
assert!(matches!(load(), Err(LoadError::NotFound)));

// delete() is idempotent — no error when called against missing file.
delete().expect("delete idempotent");
}
Loading