diff --git a/README.md b/README.md index c100baca..431b780b 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,47 @@ $ API_SERVER_NAME=local cargo build --release Explore available commands [here](https://developer.screenly.io/cli/#commands). +## Authentication and profiles + +Credentials are stored in `~/.screenly` as named profiles, with one profile active at a time. This lets you keep several tokens (for example one per workspace) and switch between them. + +```bash +# Log in. On a fresh install this creates the "default" profile; with a +# profile already active it updates that profile (e.g. after a token rotation). +$ screenly login + +# Log in under a specific profile name. +$ screenly login --name work + +# Log in without a prompt, for scripts and CI. --token-stdin is optional when +# stdin is already a pipe or a file, and required to skip the prompt on a +# terminal. +$ echo "$SCREENLY_TOKEN" | screenly login --token-stdin --name ci + +# Show the profile you are currently authenticated as. +$ screenly me + +# List stored profiles (the active one is marked with *). Honors --output. +$ screenly auth list + +# Switch the active profile. +$ screenly auth switch work + +# Remove a profile. Without --name, removes the active one. +$ screenly logout +$ screenly logout --name work +``` + +Removing the active profile leaves no profile active, even when others are still stored. The CLI will not pick a replacement for you, because doing so would silently point the next command at a different workspace. Run `screenly auth switch ` to choose one. + +The `API_TOKEN` environment variable overrides the stored profiles when set, so `me` and every other command authenticate with that token regardless of the active profile. + +Plain-text `~/.screenly` files from older versions are migrated to the profile format automatically on first write. + +If `~/.screenly` becomes malformed (for example from a hand-edit), the CLI reports the problem and leaves the file untouched rather than discarding credentials. Fix the file's YAML, or delete it and run `screenly login` to start fresh. + +The file holds every profile's token in plain text. On Linux and macOS it is created with `0600` permissions, so only your user can read it. On Windows it inherits the permissions of your home directory, so treat it the way you would any other credentials file. Avoid running two `login` commands at the same time: the CLI replaces the file atomically, but it does not lock it, so simultaneous writes can drop one of the two profiles. + ## Output Formats All list and get commands support three output formats via the global `--output` (`-o`) flag: diff --git a/docs/CommandLineHelp.md b/docs/CommandLineHelp.md index 13e9d01e..2b1388a6 100644 --- a/docs/CommandLineHelp.md +++ b/docs/CommandLineHelp.md @@ -7,6 +7,10 @@ This document contains the help content for the `screenly` command-line program. * [`screenly`↴](#screenly) * [`screenly login`↴](#screenly-login) * [`screenly logout`↴](#screenly-logout) +* [`screenly me`↴](#screenly-me) +* [`screenly auth`↴](#screenly-auth) +* [`screenly auth list`↴](#screenly-auth-list) +* [`screenly auth switch`↴](#screenly-auth-switch) * [`screenly screen`↴](#screenly-screen) * [`screenly screen list`↴](#screenly-screen-list) * [`screenly screen get`↴](#screenly-screen-get) @@ -57,7 +61,9 @@ Command line interface is intended for quick interaction with Screenly through t ###### **Subcommands:** * `login` — Logs in with the provided token and stores it for further use if valid. You can set the API_TOKEN environment variable to override the stored token -* `logout` — Logs out and removes the stored token +* `logout` — Removes a stored authentication profile. Removing the active profile leaves no profile active; other profiles are kept +* `me` — Show information about the currently authenticated profile +* `auth` — Manage stored authentication profiles * `screen` — Screen related commands * `asset` — Asset related commands * `playlist` — Playlist related commands @@ -85,15 +91,65 @@ Command line interface is intended for quick interaction with Screenly through t Logs in with the provided token and stores it for further use if valid. You can set the API_TOKEN environment variable to override the stored token -**Usage:** `screenly login` +**Usage:** `screenly login [OPTIONS]` + +###### **Options:** + +* `--name ` — Profile name to store the token under. Defaults to the active profile, or "default" on a fresh install +* `--token-stdin` — Read the token from stdin instead of prompting, for scripts: `echo "$TOKEN" | screenly login --token-stdin`. Implied when stdin is not a terminal ## `screenly logout` -Logs out and removes the stored token +Removes a stored authentication profile. Removing the active profile leaves no profile active; other profiles are kept + +**Usage:** `screenly logout [OPTIONS]` + +###### **Options:** + +* `--name ` — Profile name to remove. Removes the active profile if not specified + + + +## `screenly me` + +Show information about the currently authenticated profile + +**Usage:** `screenly me` + + + +## `screenly auth` + +Manage stored authentication profiles + +**Usage:** `screenly auth ` + +###### **Subcommands:** + +* `list` — List stored authentication profiles +* `switch` — Switch the active authentication profile + + + +## `screenly auth list` + +List stored authentication profiles + +**Usage:** `screenly auth list` + + + +## `screenly auth switch` + +Switch the active authentication profile + +**Usage:** `screenly auth switch [NAME]` + +###### **Arguments:** -**Usage:** `screenly logout` +* `` — Profile name to activate. If omitted, the available profiles are listed and the command exits with an error diff --git a/src/authentication.rs b/src/authentication.rs index ae14e673..6963fc32 100644 --- a/src/authentication.rs +++ b/src/authentication.rs @@ -1,7 +1,10 @@ +use std::collections::BTreeMap; +use std::io::Write; use std::{env, fs}; use reqwest::header::{HeaderMap, InvalidHeaderValue}; use reqwest::{header, StatusCode}; +use serde::{Deserialize, Serialize}; use thiserror::Error; // For compatability reasons - let's leave build env as well. @@ -19,6 +22,10 @@ pub enum AuthenticationError { WrongCredentials, #[error("no credentials error")] NoCredentials, + #[error("no active profile")] + NoActiveProfile, + #[error("profile not found: {0}")] + ProfileNotFound(String), #[error("request error")] Request(#[from] reqwest::Error), #[error("i/o error")] @@ -29,10 +36,31 @@ pub enum AuthenticationError { MissingHomeDir(), #[error("invalid header error")] InvalidHeader(#[from] InvalidHeaderValue), + #[error("yaml error: {0}")] + Yaml(#[from] serde_yaml::Error), + #[error( + "The credentials file at {path} could not be parsed ({source}).\n\ + Fix its contents, or delete it (`rm {path}`) and run `screenly login` to start fresh. \ + Your stored profiles are left untouched until you do." + )] + CorruptStore { + path: String, + #[source] + source: serde_yaml::Error, + }, #[error("unknown error")] Unknown, } +#[derive(Serialize, Deserialize, Default)] +struct TokenStore { + active: Option, + // A BTreeMap, not a HashMap: it keeps the serialized file in a stable key + // order (so rewriting the store doesn't reshuffle it) and it iterates + // sorted, which is the order every caller below wants to present. + tokens: BTreeMap, +} + pub struct Authentication { pub config: Config, pub token: String, @@ -57,6 +85,144 @@ impl Config { } } +/// The token from `API_TOKEN`, which overrides the stored profiles. +/// +/// An empty value counts as unset. Exporting an empty variable is what a shell +/// does when it interpolates a missing secret (the CLI's own GitHub Action does +/// exactly this when `screenly_api_token` is not configured), and authenticating +/// with an empty token only produces a confusing 401 far from the cause. +pub fn api_token_from_env() -> Option { + env::var("API_TOKEN").ok().filter(|t| !t.is_empty()) +} + +fn screenly_path() -> Result { + dirs::home_dir() + .map(|h| h.join(".screenly")) + .ok_or(AuthenticationError::MissingHomeDir()) +} + +fn read_store() -> Result { + let path = screenly_path()?; + if !path.exists() { + return Ok(TokenStore::default()); + } + let contents = fs::read_to_string(&path)?; + // An empty or whitespace-only file is treated like a missing one rather + // than a parse error, so it doesn't block `login` on a fresh/blank store. + if contents.trim().is_empty() { + return Ok(TokenStore::default()); + } + match serde_yaml::from_str::(&contents) { + Ok(store) => Ok(store), + Err(yaml_err) => { + // Backward compat: the original format was a single plain-text + // token. Only migrate when the file actually looks like one. + // Any other parse failure (a hand-edit typo, a truncated file, a + // future schema change) must surface as an error rather than be + // silently reinterpreted as a token, which would drop every stored + // profile on the next write. + let trimmed = contents.trim(); + if is_legacy_token(trimmed) { + let mut store = TokenStore::default(); + store + .tokens + .insert("default".to_string(), trimmed.to_string()); + store.active = Some("default".to_string()); + Ok(store) + } else { + Err(AuthenticationError::CorruptStore { + path: path.display().to_string(), + source: yaml_err, + }) + } + } + } +} + +/// A legacy `~/.screenly` holds exactly one plain-text token: a single +/// non-empty line with no YAML mapping punctuation. +fn is_legacy_token(contents: &str) -> bool { + !contents.is_empty() && !contents.contains(':') && !contents.contains('\n') +} + +/// Writes the store, replacing the file atomically. +/// +/// Not safe against a concurrent writer: two `login` processes racing here both +/// read the old store and the second rename wins, so one of the two profiles is +/// lost. The rename keeps any *reader* from seeing a torn file, which is the +/// case that would corrupt credentials; losing one of two simultaneous logins +/// needs file locking, which is not worth the portability cost for a CLI a +/// person drives by hand. +fn write_store(store: &TokenStore) -> Result<(), AuthenticationError> { + let path = screenly_path()?; + let contents = serde_yaml::to_string(store)?; + + // Write to a per-process temp file and rename over the target so a + // concurrent reader never observes a half-written store and a crash + // mid-write can't corrupt it. The pid suffix keeps two concurrent writers + // from sharing (and interleaving into) the same temp file. + let tmp_path = path.with_extension(format!("tmp.{}", std::process::id())); + + // The temp file holds every profile's token, so it must not survive a + // failed write. Any error below removes it before propagating. + let result = write_tmp_and_rename(&tmp_path, &path, contents.as_bytes()); + if result.is_err() { + let _ = fs::remove_file(&tmp_path); + } + result +} + +fn write_tmp_and_rename( + tmp_path: &std::path::Path, + path: &std::path::Path, + contents: &[u8], +) -> Result<(), AuthenticationError> { + let mut file = create_private_file(tmp_path)?; + file.write_all(contents)?; + file.sync_all()?; + drop(file); + fs::rename(tmp_path, path)?; + + // Renaming is atomic for a concurrent reader, but the directory entry + // itself is only durable once the directory is synced. Without this a + // crash right after the rename can leave the old file (or neither file) + // on disk. Best effort: some platforms refuse to open a directory for + // this, and a failure here does not make the store wrong. + if let Some(dir) = path.parent() { + if let Ok(dir_file) = fs::File::open(dir) { + let _ = dir_file.sync_all(); + } + } + Ok(()) +} + +/// Creates (or truncates) a file that the token store can be written to, +/// owner-readable only from the moment it exists so the token is never +/// briefly world-readable. +/// +/// On non-Unix platforms there is no equivalent here and the store inherits +/// whatever the directory's ACLs give it, which now covers every profile's +/// token rather than one. Documented in the README. +#[cfg(unix)] +fn create_private_file(path: &std::path::Path) -> Result { + use std::os::unix::fs::OpenOptionsExt; + Ok(fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(0o600) + .open(path)?) +} + +#[cfg(not(unix))] +fn create_private_file(path: &std::path::Path) -> Result { + Ok(fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .open(path)?) +} + impl Authentication { pub fn new() -> Result { Ok(Self { @@ -65,27 +231,79 @@ impl Authentication { }) } - pub fn remove_token() -> Result<(), AuthenticationError> { - match dirs::home_dir() { - Some(home) => { - fs::remove_file(home.join(".screenly"))?; - Ok(()) - } - None => Err(AuthenticationError::MissingHomeDir()), + fn read_token() -> Result { + if let Some(token) = api_token_from_env() { + return Ok(token); } + let store = read_store()?; + // Distinguish "nothing stored at all" from "profiles stored but none + // selected", which is the state `logout` leaves behind when it removes + // the active profile. The two need different advice. + let active = store.active.ok_or(if store.tokens.is_empty() { + AuthenticationError::NoCredentials + } else { + AuthenticationError::NoActiveProfile + })?; + store + .tokens + .get(&active) + .cloned() + .ok_or_else(|| AuthenticationError::ProfileNotFound(active)) } - fn read_token() -> Result { - if let Ok(token) = env::var("API_TOKEN") { - return Ok(token); + /// Removes a profile. When `name` is `None` the active profile is removed. + /// + /// Removing the active profile deliberately leaves *no* profile active + /// rather than promoting another one. Silently re-pointing the CLI at a + /// different account would make the next command talk to a different + /// workspace, so the user has to choose the next profile explicitly with + /// `auth switch`. + pub fn remove_token(name: Option<&str>) -> Result { + let mut store = read_store()?; + let target = match name { + Some(n) => n.to_string(), + None => store + .active + .clone() + .ok_or(AuthenticationError::NoCredentials)?, + }; + if !store.tokens.contains_key(&target) { + return Err(AuthenticationError::ProfileNotFound(target)); } + store.tokens.remove(&target); + if store.active.as_deref() == Some(&target) { + store.active = None; + } + write_store(&store)?; + Ok(Removal { + removed: target, + active: store.active.clone(), + remaining: store.tokens.keys().cloned().collect(), + }) + } - match dirs::home_dir() { - Some(path) => { - fs::read_to_string(path.join(".screenly")).map_err(AuthenticationError::Io) - } - None => Err(AuthenticationError::NoCredentials), + /// Returns the stored profiles sorted by name, without their tokens. + /// Tokens are intentionally not exposed here to avoid accidental prints; + /// use `fetch_profiles_with_info` when profile details are needed. + pub fn list_profiles() -> Result, AuthenticationError> { + let store = read_store()?; + Ok(store + .tokens + .keys() + .map(|name| ProfileSummary { + is_active: store.active.as_deref() == Some(name.as_str()), + name: name.clone(), + }) + .collect()) + } + + pub fn switch_profile(name: &str) -> Result<(), AuthenticationError> { + let mut store = read_store()?; + if !store.tokens.contains_key(name) { + return Err(AuthenticationError::ProfileNotFound(name.to_string())); } + store.active = Some(name.to_string()); + write_store(&store) } #[cfg(test)] @@ -97,35 +315,136 @@ impl Authentication { } pub fn build_client(&self) -> Result { - let token = self.token.clone(); - let secret = format!("Token {token}"); - let mut default_headers = HeaderMap::new(); - default_headers.insert(header::AUTHORIZATION, secret.parse()?); - default_headers.insert( - header::USER_AGENT, - format!("screenly-cli {}", env!("CARGO_PKG_VERSION")).parse()?, - ); + authenticated_client(&self.token, None) + } +} + +/// How long a single profile-info request may take. Without a cap, `auth list` +/// fans out one request per profile and a single black-holed connection hangs +/// the whole command, so the `(unavailable)` placeholder would never appear for +/// the failure most likely to produce it. +const PROFILE_REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); - reqwest::blocking::Client::builder() - .default_headers(default_headers) - .build() - .map_err(AuthenticationError::Request) +/// Builds a blocking client that sends the auth token and the standard +/// `screenly-cli {version}` User-Agent on every request. +/// +/// `timeout` is per-request. It is deliberately `None` for the general-purpose +/// client, whose requests include asset uploads that can legitimately run far +/// longer than any timeout worth setting here. +fn authenticated_client( + token: &str, + timeout: Option, +) -> Result { + let secret = format!("Token {token}"); + let mut default_headers = HeaderMap::new(); + default_headers.insert(header::AUTHORIZATION, secret.parse()?); + default_headers.insert( + header::USER_AGENT, + format!("screenly-cli {}", env!("CARGO_PKG_VERSION")).parse()?, + ); + + let mut builder = reqwest::blocking::Client::builder().default_headers(default_headers); + if let Some(timeout) = timeout { + builder = builder.timeout(timeout); } + builder.build().map_err(AuthenticationError::Request) +} + +pub struct ProfileInfo { + pub email: String, + pub workspace: String, +} + +/// A stored profile without its token, for listing profile names offline. +pub struct ProfileSummary { + pub name: String, + pub is_active: bool, +} + +/// The outcome of removing a profile, so `logout` can say what state the +/// store is in afterwards. +pub struct Removal { + pub removed: String, + /// The profile active after removal. `None` when the removed profile was + /// the active one, whether or not other profiles remain. + pub active: Option, + /// Profiles still stored after the removal, sorted by name. + pub remaining: Vec, +} + +pub struct ProfileEntry { + pub name: String, + pub is_active: bool, + /// `None` when the profile's token could not be resolved against the API. + pub info: Option, +} + +/// Returns every stored profile together with its email/workspace fetched +/// from the API. Tokens stay inside this module and are never returned. +/// The per-profile requests are issued in parallel so the total latency +/// does not grow linearly with the number of profiles. +pub fn fetch_profiles_with_info(api_url: &str) -> Result, AuthenticationError> { + use rayon::prelude::*; + + let store = read_store()?; + let names: Vec = store.tokens.keys().cloned().collect(); + let entries = names + .into_par_iter() + .map(|name| { + let is_active = store.active.as_deref() == Some(name.as_str()); + let info = fetch_profile_info(&store.tokens[&name], api_url).ok(); + ProfileEntry { + name, + is_active, + info, + } + }) + .collect(); + Ok(entries) +} + +pub fn fetch_profile_info(token: &str, api_url: &str) -> Result { + let client = authenticated_client(token, Some(PROFILE_REQUEST_TIMEOUT))?; + + let user_response = client.get(format!("{api_url}/v4.1/users/me")).send()?; + + if user_response.status() == StatusCode::UNAUTHORIZED { + return Err(AuthenticationError::WrongCredentials); + } + + let user: serde_json::Value = user_response.json()?; + + // The endpoint may return either a single object or a one-element array. + let user_obj = user.get(0).unwrap_or(&user); + let email = user_obj["email"].as_str().unwrap_or("unknown").to_string(); + + let teams: serde_json::Value = client.get(format!("{api_url}/v4.1/teams")).send()?.json()?; + + let workspace = teams + .as_array() + .and_then(|arr| arr.iter().find(|t| t["is_current"].as_bool() == Some(true))) + .and_then(|t| t["name"].as_str()) + .unwrap_or("unknown") + .to_string(); + + Ok(ProfileInfo { email, workspace }) +} + +pub fn active_profile_name() -> Option { + read_store().ok().and_then(|s| s.active) } pub fn verify_and_store_token( token: &str, + name: &str, api_url: &str, ) -> anyhow::Result<(), AuthenticationError> { verify_token(token, api_url)?; - match dirs::home_dir() { - Some(home) => { - fs::write(home.join(".screenly"), token)?; - Ok(()) - } - None => Err(AuthenticationError::MissingHomeDir()), - } + let mut store = read_store()?; + store.tokens.insert(name.to_string(), token.to_string()); + store.active = Some(name.to_string()); + write_store(&store) } fn verify_token(token: &str, api_url: &str) -> anyhow::Result<(), AuthenticationError> { @@ -180,11 +499,51 @@ mod tests { let config = Config::new(mock_server.base_url()); let authentication = Authentication::new_with_config(config, ""); - assert!(verify_and_store_token("correct_token", &authentication.config.url).is_ok()); + assert!( + verify_and_store_token("correct_token", "default", &authentication.config.url).is_ok() + ); let path = tmp_dir.path().join(".screenly"); assert!(path.exists()); - let contents = fs::read_to_string(path).unwrap(); - assert!(contents.eq("correct_token")); + let store: TokenStore = serde_yaml::from_str(&fs::read_to_string(path).unwrap()).unwrap(); + assert_eq!(store.tokens.get("default").unwrap(), "correct_token"); + assert_eq!(store.active.unwrap(), "default"); + } + + #[test] + fn test_verify_and_store_token_preserves_existing_profiles() { + let tmp_dir = tempdir().unwrap(); + let _lock = lock_test(); + let _test = set_env(OsString::from("HOME"), tmp_dir.path().to_str().unwrap()); + + let existing = TokenStore { + active: Some("prod".to_string()), + tokens: [("prod".to_string(), "prod_token".to_string())] + .into_iter() + .collect(), + }; + fs::write( + tmp_dir.path().join(".screenly"), + serde_yaml::to_string(&existing).unwrap(), + ) + .unwrap(); + + let mock_server = MockServer::start(); + mock_server.mock(|when, then| { + when.method(GET) + .path("/v3/groups/11CF9Z3GZR0005XXKH00F8V20R/"); + then.status(404); + }); + + let config = Config::new(mock_server.base_url()); + assert!(verify_and_store_token("stage_token", "stage", &config.url).is_ok()); + + let store: TokenStore = + serde_yaml::from_str(&fs::read_to_string(tmp_dir.path().join(".screenly")).unwrap()) + .unwrap(); + // The pre-existing profile is retained and the new one becomes active. + assert_eq!(store.tokens.get("prod").unwrap(), "prod_token"); + assert_eq!(store.tokens.get("stage").unwrap(), "stage_token"); + assert_eq!(store.active.as_deref(), Some("stage")); } #[test] @@ -202,7 +561,7 @@ mod tests { }); let config = Config::new(mock_server.base_url()); - assert!(verify_and_store_token("wrong_token", &config.url).is_err()); + assert!(verify_and_store_token("wrong_token", "default", &config.url).is_err()); let path = tmp_dir.path().join(".screenly"); assert!(!path.exists()); @@ -214,30 +573,454 @@ mod tests { let _lock = lock_test(); let _token = set_env(OsString::from("API_TOKEN"), "env_token"); let _test = set_env(OsString::from("HOME"), tmp_dir.path().to_str().unwrap()); - println!("{}", tmp_dir.path().join(".screenly").to_str().unwrap()); - fs::write(tmp_dir.path().join(".screenly").to_str().unwrap(), "token").unwrap(); + let store = TokenStore { + active: Some("default".to_string()), + tokens: [("default".to_string(), "token".to_string())] + .into_iter() + .collect(), + }; + fs::write( + tmp_dir.path().join(".screenly"), + serde_yaml::to_string(&store).unwrap(), + ) + .unwrap(); assert_eq!(Authentication::read_token().unwrap(), "env_token"); } #[test] - fn test_read_token_correct_token_is_returned() { + fn test_read_token_ignores_an_empty_env_token() { + // A shell interpolating a missing secret exports an empty value. That + // must fall back to the stored profile instead of authenticating with + // an empty token and failing with a distant 401. + let tmp_dir = tempdir().unwrap(); + let _lock = lock_test(); + let _token = set_env(OsString::from("API_TOKEN"), ""); + let _test = set_env(OsString::from("HOME"), tmp_dir.path().to_str().unwrap()); + let store = TokenStore { + active: Some("default".to_string()), + tokens: [("default".to_string(), "stored_token".to_string())] + .into_iter() + .collect(), + }; + fs::write( + tmp_dir.path().join(".screenly"), + serde_yaml::to_string(&store).unwrap(), + ) + .unwrap(); + assert_eq!(Authentication::read_token().unwrap(), "stored_token"); + } + + #[test] + fn test_read_token_with_empty_env_token_and_no_store_is_not_logged_in() { + // The CI case: empty API_TOKEN, nothing stored. Should say "not logged + // in" rather than authenticate as the empty token. let tmp_dir = tempdir().unwrap(); let _lock = lock_test(); + let _token = set_env(OsString::from("API_TOKEN"), ""); let _test = set_env(OsString::from("HOME"), tmp_dir.path().to_str().unwrap()); - fs::write(tmp_dir.path().join(".screenly").to_str().unwrap(), "token").unwrap(); + assert!(matches!( + Authentication::read_token(), + Err(AuthenticationError::NoCredentials) + )); + } + #[test] + fn test_read_token_correct_token_is_returned() { + let tmp_dir = tempdir().unwrap(); + let _lock = lock_test(); + let _test = set_env(OsString::from("HOME"), tmp_dir.path().to_str().unwrap()); + let store = TokenStore { + active: Some("default".to_string()), + tokens: [("default".to_string(), "token".to_string())] + .into_iter() + .collect(), + }; + fs::write( + tmp_dir.path().join(".screenly"), + serde_yaml::to_string(&store).unwrap(), + ) + .unwrap(); assert_eq!(Authentication::read_token().unwrap(), "token"); } #[test] - fn test_remove_token_should_remove_token_from_storage() { + fn test_read_token_backward_compat_plain_text() { let tmp_dir = tempdir().unwrap(); let _lock = lock_test(); let _test = set_env(OsString::from("HOME"), tmp_dir.path().to_str().unwrap()); - fs::write(tmp_dir.path().join(".screenly").to_str().unwrap(), "token").unwrap(); + fs::write(tmp_dir.path().join(".screenly"), "legacy_token").unwrap(); + assert_eq!(Authentication::read_token().unwrap(), "legacy_token"); + } - Authentication::remove_token().unwrap(); - assert!(!tmp_dir.path().join(".screenly").exists()); + #[test] + fn test_read_store_malformed_yaml_returns_error_not_token() { + // A store that fails to parse (here: the required `tokens` key is + // misspelled) must surface an error, not be silently reinterpreted as + // a plain-text token, which would drop the stored profiles. + let tmp_dir = tempdir().unwrap(); + let _lock = lock_test(); + let _test = set_env(OsString::from("HOME"), tmp_dir.path().to_str().unwrap()); + fs::write( + tmp_dir.path().join(".screenly"), + "active: prod\ntokenz:\n prod: prod_token\n", + ) + .unwrap(); + + match read_store() { + Err(e @ AuthenticationError::CorruptStore { .. }) => { + // The message tells the user where the file is and what to do. + let msg = e.to_string(); + assert!(msg.contains(".screenly")); + assert!(msg.contains("screenly login")); + } + _ => panic!("expected CorruptStore error"), + } + } + + #[test] + fn test_read_store_empty_file_is_treated_as_empty_store() { + // A zero-byte or whitespace-only file behaves like a missing one, so + // it doesn't take the CorruptStore path and block `login`. + let tmp_dir = tempdir().unwrap(); + let _lock = lock_test(); + let _test = set_env(OsString::from("HOME"), tmp_dir.path().to_str().unwrap()); + let path = tmp_dir.path().join(".screenly"); + + for contents in ["", " \n\t"] { + fs::write(&path, contents).unwrap(); + let store = read_store().unwrap(); + assert!(store.tokens.is_empty()); + assert!(store.active.is_none()); + } + } + + #[test] + fn test_legacy_plain_text_is_rewritten_as_yaml_on_first_write() { + let tmp_dir = tempdir().unwrap(); + let _lock = lock_test(); + let _test = set_env(OsString::from("HOME"), tmp_dir.path().to_str().unwrap()); + let path = tmp_dir.path().join(".screenly"); + fs::write(&path, "legacy_token").unwrap(); + + // Reading migrates the legacy token into a store; writing it back must + // persist YAML, not the original plain text. + let store = read_store().unwrap(); + write_store(&store).unwrap(); + + let contents = fs::read_to_string(&path).unwrap(); + let parsed: TokenStore = serde_yaml::from_str(&contents).unwrap(); + assert_eq!(parsed.tokens.get("default").unwrap(), "legacy_token"); + assert_eq!(parsed.active.as_deref(), Some("default")); + } + + #[test] + fn test_fetch_profiles_with_info_returns_details_per_profile() { + let tmp_dir = tempdir().unwrap(); + let _lock = lock_test(); + let _test = set_env(OsString::from("HOME"), tmp_dir.path().to_str().unwrap()); + let store = TokenStore { + active: Some("prod".to_string()), + tokens: [ + ("prod".to_string(), "prod_token".to_string()), + ("stage".to_string(), "stage_token".to_string()), + ] + .into_iter() + .collect(), + }; + fs::write( + tmp_dir.path().join(".screenly"), + serde_yaml::to_string(&store).unwrap(), + ) + .unwrap(); + + let mock_server = MockServer::start(); + mock_server.mock(|when, then| { + when.method(GET).path("/v4.1/users/me"); + then.status(200) + .json_body(serde_json::json!([{"email": "user@example.com"}])); + }); + mock_server.mock(|when, then| { + when.method(GET).path("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/v4.1/teams"); + then.status(200) + .json_body(serde_json::json!([{"name": "My Team", "is_current": true}])); + }); + + let entries = fetch_profiles_with_info(&mock_server.base_url()).unwrap(); + assert_eq!(entries.len(), 2); + // Sorted by name, so "prod" comes before "stage". + assert_eq!(entries[0].name, "prod"); + assert!(entries[0].is_active); + assert_eq!(entries[0].info.as_ref().unwrap().email, "user@example.com"); + assert_eq!(entries[0].info.as_ref().unwrap().workspace, "My Team"); + assert!(!entries[1].is_active); + } + + #[test] + fn test_remove_token_should_remove_active_profile() { + let tmp_dir = tempdir().unwrap(); + let _lock = lock_test(); + let _test = set_env(OsString::from("HOME"), tmp_dir.path().to_str().unwrap()); + let store = TokenStore { + active: Some("default".to_string()), + tokens: [("default".to_string(), "token".to_string())] + .into_iter() + .collect(), + }; + fs::write( + tmp_dir.path().join(".screenly"), + serde_yaml::to_string(&store).unwrap(), + ) + .unwrap(); + + let removal = Authentication::remove_token(None).unwrap(); + assert_eq!(removal.removed, "default"); + assert!(removal.active.is_none()); + assert!(removal.remaining.is_empty()); + let store: TokenStore = + serde_yaml::from_str(&fs::read_to_string(tmp_dir.path().join(".screenly")).unwrap()) + .unwrap(); + assert!(store.tokens.is_empty()); + assert!(store.active.is_none()); + } + + #[test] + fn test_write_store_removes_temp_file_when_the_write_fails() { + // The temp file holds every token, so a failed write must not leave it + // behind. A directory at the target path makes the rename fail after + // the temp file has already been created and written. + let tmp_dir = tempdir().unwrap(); + let _lock = lock_test(); + let _test = set_env(OsString::from("HOME"), tmp_dir.path().to_str().unwrap()); + fs::create_dir(tmp_dir.path().join(".screenly")).unwrap(); + + let mut store = TokenStore::default(); + store + .tokens + .insert("default".to_string(), "token".to_string()); + assert!(write_store(&store).is_err()); + + let leftovers: Vec<_> = fs::read_dir(tmp_dir.path()) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().to_string()) + .filter(|name| name.starts_with(".screenly.tmp")) + .collect(); + assert!( + leftovers.is_empty(), + "temp file with tokens left behind: {leftovers:?}" + ); + } + + #[test] + #[cfg(unix)] + fn test_write_store_restricts_permissions_to_owner() { + use std::os::unix::fs::PermissionsExt; + + let tmp_dir = tempdir().unwrap(); + let _lock = lock_test(); + let _test = set_env(OsString::from("HOME"), tmp_dir.path().to_str().unwrap()); + + let mut store = TokenStore::default(); + store + .tokens + .insert("default".to_string(), "token".to_string()); + store.active = Some("default".to_string()); + write_store(&store).unwrap(); + + let mode = fs::metadata(tmp_dir.path().join(".screenly")) + .unwrap() + .permissions() + .mode(); + assert_eq!(mode & 0o777, 0o600); + } + + #[test] + fn test_remove_token_with_explicit_name_keeps_active() { + let tmp_dir = tempdir().unwrap(); + let _lock = lock_test(); + let _test = set_env(OsString::from("HOME"), tmp_dir.path().to_str().unwrap()); + let store = TokenStore { + active: Some("prod".to_string()), + tokens: [ + ("prod".to_string(), "prod_token".to_string()), + ("stage".to_string(), "stage_token".to_string()), + ] + .into_iter() + .collect(), + }; + fs::write( + tmp_dir.path().join(".screenly"), + serde_yaml::to_string(&store).unwrap(), + ) + .unwrap(); + + // Removing a non-active profile by name leaves the active one intact. + let removal = Authentication::remove_token(Some("stage")).unwrap(); + assert_eq!(removal.active.as_deref(), Some("prod")); + let store: TokenStore = + serde_yaml::from_str(&fs::read_to_string(tmp_dir.path().join(".screenly")).unwrap()) + .unwrap(); + assert!(!store.tokens.contains_key("stage")); + assert_eq!(store.active.as_deref(), Some("prod")); + } + + #[test] + fn test_remove_active_profile_leaves_no_profile_active() { + let tmp_dir = tempdir().unwrap(); + let _lock = lock_test(); + let _test = set_env(OsString::from("HOME"), tmp_dir.path().to_str().unwrap()); + let store = TokenStore { + active: Some("prod".to_string()), + tokens: [ + ("prod".to_string(), "prod_token".to_string()), + ("alpha".to_string(), "alpha_token".to_string()), + ("stage".to_string(), "stage_token".to_string()), + ] + .into_iter() + .collect(), + }; + fs::write( + tmp_dir.path().join(".screenly"), + serde_yaml::to_string(&store).unwrap(), + ) + .unwrap(); + + // Removing the active profile must not promote another one: the CLI + // would silently start talking to a different workspace. + let removal = Authentication::remove_token(None).unwrap(); + assert_eq!(removal.removed, "prod"); + assert!(removal.active.is_none()); + assert_eq!(removal.remaining, vec!["alpha", "stage"]); + let store: TokenStore = + serde_yaml::from_str(&fs::read_to_string(tmp_dir.path().join(".screenly")).unwrap()) + .unwrap(); + assert!(store.active.is_none()); + // The other tokens are untouched, just unselected. + assert_eq!(store.tokens.len(), 2); + } + + #[test] + fn test_read_token_reports_no_active_profile_when_profiles_remain() { + // The state `logout` leaves behind when it removes the active profile: + // tokens are still stored, none is selected. That must not read as + // "not logged in", which would tell the user to log in again. + let tmp_dir = tempdir().unwrap(); + let _lock = lock_test(); + let _test = set_env(OsString::from("HOME"), tmp_dir.path().to_str().unwrap()); + let store = TokenStore { + active: None, + tokens: [("prod".to_string(), "prod_token".to_string())] + .into_iter() + .collect(), + }; + fs::write( + tmp_dir.path().join(".screenly"), + serde_yaml::to_string(&store).unwrap(), + ) + .unwrap(); + + assert!(matches!( + Authentication::read_token(), + Err(AuthenticationError::NoActiveProfile) + )); + } + + #[test] + fn test_remove_token_with_unknown_name_errors() { + let tmp_dir = tempdir().unwrap(); + let _lock = lock_test(); + let _test = set_env(OsString::from("HOME"), tmp_dir.path().to_str().unwrap()); + let store = TokenStore { + active: Some("prod".to_string()), + tokens: [("prod".to_string(), "prod_token".to_string())] + .into_iter() + .collect(), + }; + fs::write( + tmp_dir.path().join(".screenly"), + serde_yaml::to_string(&store).unwrap(), + ) + .unwrap(); + + assert!(matches!( + Authentication::remove_token(Some("ghost")), + Err(AuthenticationError::ProfileNotFound(_)) + )); + } + + #[test] + fn test_switch_profile_should_change_active() { + let tmp_dir = tempdir().unwrap(); + let _lock = lock_test(); + let _test = set_env(OsString::from("HOME"), tmp_dir.path().to_str().unwrap()); + let store = TokenStore { + active: Some("prod".to_string()), + tokens: [ + ("prod".to_string(), "prod_token".to_string()), + ("stage".to_string(), "stage_token".to_string()), + ] + .into_iter() + .collect(), + }; + fs::write( + tmp_dir.path().join(".screenly"), + serde_yaml::to_string(&store).unwrap(), + ) + .unwrap(); + + Authentication::switch_profile("stage").unwrap(); + let updated: TokenStore = + serde_yaml::from_str(&fs::read_to_string(tmp_dir.path().join(".screenly")).unwrap()) + .unwrap(); + assert_eq!(updated.active.unwrap(), "stage"); + } + + #[test] + fn test_switch_profile_to_nonexistent_should_fail() { + let tmp_dir = tempdir().unwrap(); + let _lock = lock_test(); + let _test = set_env(OsString::from("HOME"), tmp_dir.path().to_str().unwrap()); + let store = TokenStore { + active: Some("prod".to_string()), + tokens: [("prod".to_string(), "prod_token".to_string())] + .into_iter() + .collect(), + }; + fs::write( + tmp_dir.path().join(".screenly"), + serde_yaml::to_string(&store).unwrap(), + ) + .unwrap(); + + assert!(Authentication::switch_profile("ghost").is_err()); + } + + #[test] + fn test_list_profiles_should_return_profiles_with_active_marked() { + let tmp_dir = tempdir().unwrap(); + let _lock = lock_test(); + let _test = set_env(OsString::from("HOME"), tmp_dir.path().to_str().unwrap()); + let store = TokenStore { + active: Some("prod".to_string()), + tokens: [ + ("prod".to_string(), "prod_token".to_string()), + ("stage".to_string(), "stage_token".to_string()), + ] + .into_iter() + .collect(), + }; + fs::write( + tmp_dir.path().join(".screenly"), + serde_yaml::to_string(&store).unwrap(), + ) + .unwrap(); + + let profiles = Authentication::list_profiles().unwrap(); + assert_eq!(profiles.len(), 2); + let prod = profiles.iter().find(|p| p.name == "prod").unwrap(); + let stage = profiles.iter().find(|p| p.name == "stage").unwrap(); + assert!(prod.is_active); + assert!(!stage.is_active); } #[test] @@ -257,11 +1040,74 @@ mod tests { let config = Config::new(mock_server.base_url()); let authentication = Authentication::new_with_config(config, ""); - assert!(verify_and_store_token("correct_token", &authentication.config.url).is_ok()); + assert!( + verify_and_store_token("correct_token", "default", &authentication.config.url).is_ok() + ); let path = tmp_dir.path().join(".screenly"); assert!(path.exists()); - let contents = fs::read_to_string(path).unwrap(); + let store: TokenStore = serde_yaml::from_str(&fs::read_to_string(path).unwrap()).unwrap(); group_call_mock.assert(); - assert!(contents.eq("correct_token")); + assert_eq!(store.tokens.get("default").unwrap(), "correct_token"); + } + + #[test] + fn test_fetch_profile_info_returns_email_and_workspace() { + let mock_server = MockServer::start(); + mock_server.mock(|when, then| { + when.method(GET) + .path("/v4.1/users/me") + .header("Authorization", "Token valid_token"); + then.status(200) + .json_body(serde_json::json!([{"email": "user@example.com"}])); + }); + mock_server.mock(|when, then| { + when.method(GET) + .path("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/v4.1/teams") + .header("Authorization", "Token valid_token"); + then.status(200) + .json_body(serde_json::json!([{"name": "My Team", "is_current": true}])); + }); + + let result = fetch_profile_info("valid_token", &mock_server.base_url()); + assert!(result.is_ok()); + let info = result.unwrap(); + assert_eq!(info.email, "user@example.com"); + assert_eq!(info.workspace, "My Team"); + } + + #[test] + fn test_fetch_profile_info_accepts_object_response() { + let mock_server = MockServer::start(); + mock_server.mock(|when, then| { + when.method(GET) + .path("/v4.1/users/me") + .header("Authorization", "Token valid_token"); + // A single object, not wrapped in an array. + then.status(200) + .json_body(serde_json::json!({"email": "user@example.com"})); + }); + mock_server.mock(|when, then| { + when.method(GET) + .path("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/v4.1/teams") + .header("Authorization", "Token valid_token"); + then.status(200) + .json_body(serde_json::json!([{"name": "My Team", "is_current": true}])); + }); + + let info = fetch_profile_info("valid_token", &mock_server.base_url()).unwrap(); + assert_eq!(info.email, "user@example.com"); + assert_eq!(info.workspace, "My Team"); + } + + #[test] + fn test_fetch_profile_info_returns_wrong_credentials_on_401() { + let mock_server = MockServer::start(); + mock_server.mock(|when, then| { + when.method(GET).path("/v4.1/users/me"); + then.status(401); + }); + + let result = fetch_profile_info("bad_token", &mock_server.base_url()); + assert!(matches!(result, Err(AuthenticationError::WrongCredentials))); } } diff --git a/src/cli.rs b/src/cli.rs index 1509aa2d..d0707f32 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,15 +1,20 @@ -use std::io::{Read, Write}; +use std::io::{BufRead, IsTerminal, Read, Write}; use std::path::PathBuf; use std::{env, fs, io}; use clap::{Parser, Subcommand}; use http_auth_basic::Credentials; use log::{error, info}; +use prettytable::{Cell, Row}; use reqwest::StatusCode; use rpassword::read_password; +use serde_json::json; use thiserror::Error; -use crate::authentication::{verify_and_store_token, Authentication, AuthenticationError, Config}; +use crate::authentication::{ + active_profile_name, api_token_from_env, fetch_profile_info, fetch_profiles_with_info, + verify_and_store_token, Authentication, AuthenticationError, Config, ProfileEntry, +}; use crate::commands; use crate::commands::edge_app::instance_manifest::InstanceManifest; use crate::commands::edge_app::manifest::EdgeAppManifest; @@ -24,24 +29,201 @@ const DEFAULT_ASSET_DURATION: u32 = 15; /// Returns a user-friendly error message for authentication errors. fn get_authentication_error_message(e: &AuthenticationError) -> String { + let not_logged_in = "Not logged in. Please run `screenly login` first to authenticate."; match e { + // The logged-out state now leaves an empty store behind rather than + // deleting the file, so it surfaces as NoCredentials, not Io(NotFound). + AuthenticationError::NoCredentials => not_logged_in.to_string(), AuthenticationError::Io(io_err) if io_err.kind() == std::io::ErrorKind::NotFound => { - "Not logged in. Please run `screenly login` first to authenticate.".to_string() + not_logged_in.to_string() + } + AuthenticationError::NoActiveProfile => { + "No active profile. Run `screenly auth switch ` to choose one, or `screenly auth list` to see what is stored.".to_string() } + AuthenticationError::ProfileNotFound(name) => { + format!("Active profile '{name}' not found. Run `screenly auth switch` to pick a valid profile.") + } + // Already actionable and names the file; pass it through verbatim. + AuthenticationError::CorruptStore { .. } => e.to_string(), _ => { format!("Authentication error: {e}. Please run `screenly login` to authenticate.") } } } +/// Resolves the profile name a `login` should store under. +/// +/// An explicit `--name` is always honored. With no name given, `login` +/// updates the currently active profile (the common re-login-after-rotation +/// flow), falling back to `"default"` on a fresh install with no active +/// profile. +fn resolve_login_name(name: Option<&str>, active: Option<&str>) -> String { + match name { + Some(n) => n.to_string(), + None => active.unwrap_or("default").to_string(), + } +} + +/// Reads the API token for `login`. +/// +/// With stdin on a terminal, prompts and reads without echoing. With stdin +/// redirected, reads it as a plain line: there is no terminal to disable echo +/// on, and `read_password` fails outright rather than degrading, which used to +/// panic. `--token-stdin` forces the non-interactive path even on a terminal, +/// for scripts that want no prompt at all. +fn read_login_token(force_stdin: bool) -> io::Result { + if force_stdin || !io::stdin().is_terminal() { + return read_token_line(&mut io::stdin().lock()); + } + print!("Enter your API Token: "); + io::stdout().flush()?; + Ok(read_password()?.trim().to_string()) +} + +/// Reads one line as a token. +/// +/// Trimmed because a token never contains surrounding whitespace, and the +/// trailing newline from `echo` or a here-doc would otherwise be stored as part +/// of it and sent in the Authorization header. +fn read_token_line(reader: &mut impl BufRead) -> io::Result { + let mut line = String::new(); + reader.read_line(&mut line)?; + Ok(line.trim().to_string()) +} + +/// Shown for a profile whose token could not be resolved against the API. The +/// profile is stored, it just can't be described right now. +const PROFILE_INFO_UNAVAILABLE: &str = "(unavailable)"; + +/// Renders the stored profiles as a table, marking the active profile with `*`. +/// +/// Uses `prettytable` like every other list command, so `auth list` looks like +/// `screen list` and column widths (including non-ASCII names) are the table's +/// problem rather than this function's. +fn format_profiles_table(entries: &[ProfileEntry]) -> String { + let mut table = prettytable::Table::new(); + table.add_row(Row::from(vec!["Active", "Profile", "Email", "Workspace"])); + for entry in entries { + let (email, workspace) = match &entry.info { + Some(info) => (info.email.as_str(), info.workspace.as_str()), + None => (PROFILE_INFO_UNAVAILABLE, PROFILE_INFO_UNAVAILABLE), + }; + table.add_row(Row::new(vec![ + Cell::new(if entry.is_active { "*" } else { "" }), + Cell::new(&entry.name), + Cell::new(email), + Cell::new(workspace), + ])); + } + table.to_string() +} + +/// The current profile's details, rendered for the `me` command. +struct ProfileDetails { + profile: String, + email: String, + workspace: String, +} + +impl Formatter for ProfileDetails { + fn supports_csv() -> bool { + true + } + + fn format(&self, output_type: OutputType) -> String { + match output_type { + OutputType::Json => serde_json::to_string_pretty(&json!({ + "profile": self.profile, + "email": self.email, + "workspace": self.workspace, + })) + .unwrap(), + OutputType::Csv => { + let mut wtr = csv::WriterBuilder::new().from_writer(vec![]); + wtr.write_record(["Profile", "Email", "Workspace"]).unwrap(); + wtr.write_record([ + self.profile.as_str(), + self.email.as_str(), + self.workspace.as_str(), + ]) + .unwrap(); + String::from_utf8(wtr.into_inner().unwrap()).unwrap() + } + OutputType::HumanReadable => format!( + "Profile: {}\nEmail: {}\nWorkspace: {}", + self.profile, self.email, self.workspace + ), + } + } +} + +/// The stored profiles, rendered for the `auth list` command. +struct ProfilesTable(Vec); + +impl Formatter for ProfilesTable { + fn supports_csv() -> bool { + true + } + + fn format(&self, output_type: OutputType) -> String { + match output_type { + // The "no profiles" hint is only useful to a human. The machine + // formats render an empty array / a bare header instead, so a + // consumer piping `--output json` always gets parseable output. + OutputType::HumanReadable if self.0.is_empty() => { + "No profiles stored. Run `screenly login` to add one.".to_string() + } + OutputType::HumanReadable => format_profiles_table(&self.0), + OutputType::Json => { + let arr: Vec = self + .0 + .iter() + .map(|e| { + json!({ + "profile": e.name, + "active": e.is_active, + "email": e.info.as_ref().map(|i| i.email.clone()), + "workspace": e.info.as_ref().map(|i| i.workspace.clone()), + }) + }) + .collect(); + serde_json::to_string_pretty(&serde_json::Value::Array(arr)).unwrap() + } + OutputType::Csv => { + let mut wtr = csv::WriterBuilder::new().from_writer(vec![]); + wtr.write_record(["Profile", "Active", "Email", "Workspace"]) + .unwrap(); + for e in &self.0 { + let active = e.is_active.to_string(); + let (email, workspace) = match &e.info { + Some(i) => (i.email.as_str(), i.workspace.as_str()), + None => ("", ""), + }; + wtr.write_record([e.name.as_str(), active.as_str(), email, workspace]) + .unwrap(); + } + String::from_utf8(wtr.into_inner().unwrap()).unwrap() + } + } + } +} + +/// Reports an authentication error and exits. +/// +/// Every command that touches the credential store funnels its unhandled +/// authentication errors through here, so a corrupt store or a missing profile +/// reads the same whichever command hit it, and never surfaces as a `Debug` +/// dump. +fn exit_with_authentication_error(e: &AuthenticationError) -> ! { + error!("{}", get_authentication_error_message(e)); + std::process::exit(1); +} + /// Creates an Authentication instance or exits with a user-friendly error message. fn get_authentication() -> Authentication { match Authentication::new() { Ok(auth) => auth, - Err(e) => { - error!("{}", get_authentication_error_message(&e)); - std::process::exit(1); - } + Err(e) => exit_with_authentication_error(&e), } } @@ -90,9 +272,26 @@ pub struct Cli { #[derive(Subcommand)] pub enum Commands { /// Logs in with the provided token and stores it for further use if valid. You can set the API_TOKEN environment variable to override the stored token. - Login {}, - /// Logs out and removes the stored token. - Logout {}, + Login { + /// Profile name to store the token under. Defaults to the active + /// profile, or "default" on a fresh install. + #[arg(long)] + name: Option, + /// Read the token from stdin instead of prompting, for scripts: `echo "$TOKEN" | screenly login --token-stdin`. Implied when stdin is not a terminal. + #[arg(long)] + token_stdin: bool, + }, + /// Removes a stored authentication profile. Removing the active profile leaves no profile active; other profiles are kept. + Logout { + /// Profile name to remove. Removes the active profile if not specified. + #[arg(long)] + name: Option, + }, + /// Show information about the currently authenticated profile. + Me {}, + /// Manage stored authentication profiles. + #[command(subcommand)] + Auth(AuthCommands), /// Screen related commands. #[command(subcommand)] Screen(ScreenCommands), @@ -112,6 +311,18 @@ pub enum Commands { PrintHelpMarkdown {}, } +#[derive(Subcommand)] +pub enum AuthCommands { + /// List stored authentication profiles. + List {}, + /// Switch the active authentication profile. + Switch { + /// Profile name to activate. If omitted, the available profiles are + /// listed and the command exits with an error. + name: Option, + }, +} + #[derive(Subcommand, Clone, PartialEq, Eq, PartialOrd, Ord)] pub enum ScreenCommands { /// Lists your screens. @@ -549,13 +760,23 @@ pub fn handle_cli(cli: &Cli) { }; match &cli.command { - Commands::Login {} => { - print!("Enter your API Token: "); - std::io::stdout().flush().unwrap(); - let token = read_password().unwrap(); - match verify_and_store_token(&token, &Config::default().url) { + Commands::Login { name, token_stdin } => { + let active = active_profile_name(); + let resolved_name = resolve_login_name(name.as_deref(), active.as_deref()); + let token = match read_login_token(*token_stdin) { + Ok(token) => token, + Err(e) => { + error!("Could not read the API token: {e}"); + std::process::exit(1); + } + }; + if token.is_empty() { + error!("No API token provided. Pipe one in (`echo \"$TOKEN\" | screenly login --token-stdin`) or run `screenly login` on a terminal to be prompted."); + std::process::exit(1); + } + match verify_and_store_token(&token, &resolved_name, &Config::default().url) { Ok(()) => { - info!("Login credentials have been saved."); + info!("Login credentials have been saved under profile '{resolved_name}'."); std::process::exit(0); } @@ -564,10 +785,7 @@ pub fn handle_cli(cli: &Cli) { error!("Token verification failed."); std::process::exit(1); } - _ => { - error!("Error occurred: {e:?}"); - std::process::exit(1); - } + _ => exit_with_authentication_error(&e), }, } } @@ -575,11 +793,105 @@ pub fn handle_cli(cli: &Cli) { Commands::Asset(command) => handle_cli_asset_command(command, output), Commands::EdgeApp(command) => handle_cli_edge_app_command(command, output), Commands::Playlist(command) => handle_cli_playlist_command(command, output), - Commands::Logout {} => { - Authentication::remove_token().expect("Failed to remove token."); - info!("Logout successful."); - std::process::exit(0); + Commands::Me {} => { + let auth = get_authentication(); + match fetch_profile_info(&auth.token, &auth.config.url) { + Ok(info) => { + // read_token() prefers API_TOKEN over the stored profile, + // so the label must follow the same precedence, otherwise + // it names the wrong profile when both are present. + let profile = if api_token_from_env().is_some() { + "(from API_TOKEN env)".to_string() + } else { + active_profile_name().unwrap_or_else(|| "unknown".to_string()) + }; + let details = ProfileDetails { + profile, + email: info.email, + workspace: info.workspace, + }; + handle_command_execution_result(Ok::<_, CommandError>(details), output); + } + Err(AuthenticationError::WrongCredentials) => { + error!("Token is invalid. Run `screenly login` to update your credentials."); + std::process::exit(1); + } + Err(e) => { + error!("Failed to fetch profile info: {e}"); + std::process::exit(1); + } + } } + Commands::Logout { name } => match Authentication::remove_token(name.as_deref()) { + Ok(removal) => { + info!("Removed profile '{}'.", removal.removed); + match &removal.active { + // A non-active profile was removed, so the CLI still + // authenticates as before. + Some(profile) => info!("Active profile is still '{profile}'."), + None if removal.remaining.is_empty() => info!("No profiles remain."), + None => info!( + "No profile is active now. Run `screenly auth switch ` to pick one of: {}.", + removal.remaining.join(", ") + ), + } + std::process::exit(0); + } + Err(AuthenticationError::NoCredentials) => { + error!("Not logged in."); + std::process::exit(1); + } + Err(AuthenticationError::ProfileNotFound(profile)) => { + error!("Profile '{profile}' not found."); + std::process::exit(1); + } + Err(e) => exit_with_authentication_error(&e), + }, + Commands::Auth(auth_command) => match auth_command { + AuthCommands::List {} => match fetch_profiles_with_info(&Config::default().url) { + Ok(entries) => { + handle_command_execution_result( + Ok::<_, CommandError>(ProfilesTable(entries)), + output, + ); + } + Err(e) => exit_with_authentication_error(&e), + }, + AuthCommands::Switch { name } => match name { + None => { + // A missing argument is a usage error, so exit non-zero + // (scripts can detect it) but still print the available + // profile names as a hint. Names come from the local store, + // so this needs no network round-trips. Read them before + // reporting the usage error: if the store itself is + // unreadable, that is the only message worth printing. + let profiles = match Authentication::list_profiles() { + Ok(profiles) => profiles, + Err(e) => exit_with_authentication_error(&e), + }; + if profiles.is_empty() { + error!("No profiles stored. Run `screenly login` to add one."); + std::process::exit(1); + } + error!("No profile name given. Specify one of the profiles below:"); + for profile in profiles { + let marker = if profile.is_active { "*" } else { " " }; + println!("{marker} {}", profile.name); + } + std::process::exit(1); + } + Some(name) => match Authentication::switch_profile(name) { + Ok(()) => { + info!("Switched to profile '{name}'."); + } + Err(AuthenticationError::ProfileNotFound(_)) => { + error!("Profile '{name}' not found."); + std::process::exit(1); + } + Err(e) => exit_with_authentication_error(&e), + }, + }, + }, Commands::Mcp {} => { handle_cli_mcp_command(); } @@ -1240,6 +1552,124 @@ mod tests { use super::*; use crate::authentication::Config; + #[test] + fn test_read_token_line_trims_the_trailing_newline() { + // `echo "$TOKEN" |` appends a newline. Storing it would send it in the + // Authorization header. + let mut input = "tok_abc123\n".as_bytes(); + assert_eq!(read_token_line(&mut input).unwrap(), "tok_abc123"); + + // A here-doc or a Windows-authored file can add a CR too. + let mut crlf = "tok_abc123\r\n".as_bytes(); + assert_eq!(read_token_line(&mut crlf).unwrap(), "tok_abc123"); + + // Surrounding whitespace is not part of a token either. + let mut padded = " tok_abc123 \n".as_bytes(); + assert_eq!(read_token_line(&mut padded).unwrap(), "tok_abc123"); + } + + #[test] + fn test_read_token_line_takes_only_the_first_line() { + // Extra lines on stdin must not end up in the token. + let mut input = "tok_abc123\nnot_the_token\n".as_bytes(); + assert_eq!(read_token_line(&mut input).unwrap(), "tok_abc123"); + } + + #[test] + fn test_read_token_line_on_empty_input_is_empty_not_an_error() { + // Closed/empty stdin reads as empty, which the caller reports as + // "no token provided" rather than failing obscurely. + let mut input = "".as_bytes(); + assert_eq!(read_token_line(&mut input).unwrap(), ""); + } + + #[test] + fn test_resolve_login_name_defaults_to_default_on_fresh_install() { + assert_eq!(resolve_login_name(None, None), "default"); + } + + #[test] + fn test_resolve_login_name_honors_explicit_name() { + assert_eq!(resolve_login_name(Some("stage"), Some("prod")), "stage"); + } + + #[test] + fn test_resolve_login_name_defaults_to_active_profile() { + // Plain `login` with a profile already active updates that profile + // rather than failing (the re-login-after-rotation flow). + assert_eq!(resolve_login_name(None, Some("prod")), "prod"); + } + + #[test] + fn test_empty_profiles_table_still_renders_machine_formats() { + // With no profiles stored, `--output json` must stay parseable and + // `--output csv` must keep its header. Only the human-readable form + // switches to a hint. + let table = ProfilesTable(vec![]); + + assert_eq!(table.format(OutputType::Json), "[]"); + assert_eq!( + table.format(OutputType::Csv), + "Profile,Active,Email,Workspace\n" + ); + assert!(table + .format(OutputType::HumanReadable) + .contains("No profiles stored")); + } + + #[test] + fn test_format_profiles_table_marks_active_and_shows_placeholders() { + use crate::authentication::{ProfileEntry, ProfileInfo}; + + // A short name, a non-ASCII name (byte length != display width), and a + // profile whose info could not be fetched. + let entries = vec![ + ProfileEntry { + name: "a".to_string(), + is_active: true, + info: Some(ProfileInfo { + email: "x@y.z".to_string(), + workspace: "Team".to_string(), + }), + }, + ProfileEntry { + name: "staging".to_string(), + is_active: false, + info: None, + }, + ProfileEntry { + name: "работа".to_string(), + is_active: false, + info: Some(ProfileInfo { + email: "ru@y.z".to_string(), + workspace: "Команда".to_string(), + }), + }, + ]; + + let table = format_profiles_table(&entries); + let lines: Vec<&str> = table.lines().collect(); + + // prettytable draws the borders, so every line is the same display + // width regardless of how many bytes a name takes. + let widths: Vec = lines.iter().map(|l| l.chars().count()).collect(); + assert!( + widths.windows(2).all(|w| w[0] == w[1]), + "ragged table: {widths:?}\n{table}" + ); + + assert!(table.contains("Active")); + assert!(table.contains("Workspace")); + // The active profile is marked, the others are not. + let row_a = lines.iter().find(|l| l.contains(" a ")).unwrap(); + assert!(row_a.contains("*")); + let row_staging = lines.iter().find(|l| l.contains("staging")).unwrap(); + assert!(!row_staging.contains("*")); + // A profile with no info keeps its row and both columns. + assert_eq!(row_staging.matches("(unavailable)").count(), 2); + assert!(table.contains("работа")); + } + #[test] fn test_get_screen_name_should_return_correct_screen_name() { let _tmp_dir = tempdir().unwrap();