diff --git a/README.md b/README.md index 983b48e9..eb78b515 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,38 @@ $ 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 + +# 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 +``` + +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. + ## 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..90d97ec8 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) @@ -58,6 +62,8 @@ Command line interface is intended for quick interaction with Screenly through t * `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 +* `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,7 +91,11 @@ 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 @@ -93,7 +103,52 @@ Logs in with the provided token and stores it for further use if valid. You can Logs out and removes the stored token -**Usage:** `screenly logout` +**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:** + +* `` — 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..b91b2870 100644 --- a/src/authentication.rs +++ b/src/authentication.rs @@ -1,7 +1,10 @@ +use std::collections::HashMap; +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,8 @@ pub enum AuthenticationError { WrongCredentials, #[error("no credentials error")] NoCredentials, + #[error("profile not found: {0}")] + ProfileNotFound(String), #[error("request error")] Request(#[from] reqwest::Error), #[error("i/o error")] @@ -29,10 +34,28 @@ 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, + tokens: HashMap, +} + pub struct Authentication { pub config: Config, pub token: String, @@ -57,6 +80,96 @@ impl Config { } } +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') +} + +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())); + let mut file = create_private_file(&tmp_path)?; + file.write_all(contents.as_bytes())?; + file.sync_all()?; + drop(file); + fs::rename(&tmp_path, &path)?; + 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. Permissions are a no-op on non-Unix platforms. +#[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 +178,69 @@ 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 Ok(token) = env::var("API_TOKEN") { return Ok(token); } + let store = read_store()?; + let active = store.active.ok_or(AuthenticationError::NoCredentials)?; + store + .tokens + .get(&active) + .cloned() + .ok_or_else(|| AuthenticationError::ProfileNotFound(active)) + } - match dirs::home_dir() { - Some(path) => { - fs::read_to_string(path.join(".screenly")).map_err(AuthenticationError::Io) - } - None => Err(AuthenticationError::NoCredentials), + /// Removes a profile. When `name` is `None` the active profile is removed. + /// If the removed profile was active, the new active profile is chosen + /// deterministically (the alphabetically first remaining profile). + /// Returns the name of the profile that is active after removal, if any. + pub fn remove_token(name: Option<&str>) -> Result, AuthenticationError> { + 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) { + let mut remaining: Vec<&String> = store.tokens.keys().collect(); + remaining.sort(); + store.active = remaining.first().map(|n| n.to_string()); + } + write_store(&store)?; + Ok(store.active) + } + + /// 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()?; + let mut profiles: Vec = store + .tokens + .keys() + .map(|name| ProfileSummary { + is_active: store.active.as_deref() == Some(name.as_str()), + name: name.clone(), + }) + .collect(); + profiles.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(profiles) + } + + 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 +252,112 @@ 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) + } +} + +/// Builds a blocking client that sends the auth token and the standard +/// `screenly-cli {version}` User-Agent on every request. +fn authenticated_client(token: &str) -> 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()?, + ); + + reqwest::blocking::Client::builder() + .default_headers(default_headers) + .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, +} + +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 mut names: Vec = store.tokens.keys().cloned().collect(); + names.sort(); + 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)?; + + let user_response = client.get(format!("{api_url}/v4.1/users/me")).send()?; - reqwest::blocking::Client::builder() - .default_headers(default_headers) - .build() - .map_err(AuthenticationError::Request) + 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 +412,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 +474,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,8 +486,17 @@ 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"); } @@ -224,20 +505,337 @@ mod tests { 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(); - + 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"), "legacy_token").unwrap(); + assert_eq!(Authentication::read_token().unwrap(), "legacy_token"); + } + + #[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()); - fs::write(tmp_dir.path().join(".screenly").to_str().unwrap(), "token").unwrap(); + let path = tmp_dir.path().join(".screenly"); - Authentication::remove_token().unwrap(); - assert!(!tmp_dir.path().join(".screenly").exists()); + 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("/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 new_active = Authentication::remove_token(None).unwrap(); + assert!(new_active.is_none()); + 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] + #[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 new_active = Authentication::remove_token(Some("stage")).unwrap(); + assert_eq!(new_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_picks_deterministic_new_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 picks the alphabetically first remaining one. + let new_active = Authentication::remove_token(None).unwrap(); + assert_eq!(new_active.as_deref(), Some("alpha")); + let store: TokenStore = + serde_yaml::from_str(&fs::read_to_string(tmp_dir.path().join(".screenly")).unwrap()) + .unwrap(); + assert_eq!(store.active.as_deref(), Some("alpha")); + } + + #[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 +855,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("/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("/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..71d3818f 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -7,9 +7,13 @@ use http_auth_basic::Credentials; use log::{error, info}; 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, 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,16 +28,173 @@ 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::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(), + } +} + +/// Renders the stored profiles as an aligned table, marking the active +/// profile with `*`. Returns a `String` so it can be unit-tested and reused +/// across output formats. Column widths account for the header labels and for +/// the `(unavailable)` placeholder shown when a profile's info can't be +/// fetched. +fn format_profiles_table(entries: &[ProfileEntry]) -> String { + // Resolve each row's cells first so the widths cover placeholders too. + let rows: Vec<(&str, String, String, bool)> = entries + .iter() + .map(|e| match &e.info { + Some(info) => ( + e.name.as_str(), + info.email.clone(), + info.workspace.clone(), + e.is_active, + ), + None => ( + e.name.as_str(), + "(unavailable)".to_string(), + "(unavailable)".to_string(), + e.is_active, + ), + }) + .collect(); + + let name_w = rows + .iter() + .map(|r| r.0.len()) + .max() + .unwrap_or(0) + .max("Profile".len()); + let email_w = rows + .iter() + .map(|r| r.1.len()) + .max() + .unwrap_or(0) + .max("Email".len()); + + let mut lines = vec![ + format!(" {: 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 { + 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() + } + } + } +} + /// Creates an Authentication instance or exits with a user-friendly error message. fn get_authentication() -> Authentication { match Authentication::new() { @@ -90,9 +251,23 @@ 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 {}, + Login { + /// Profile name to store the token under. Defaults to the active + /// profile, or "default" on a fresh install. + #[arg(long)] + name: Option, + }, /// Logs out and removes the stored token. - Logout {}, + 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 +287,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 +736,15 @@ pub fn handle_cli(cli: &Cli) { }; match &cli.command { - Commands::Login {} => { + Commands::Login { name } => { + let active = active_profile_name(); + let resolved_name = resolve_login_name(name.as_deref(), active.as_deref()); print!("Enter your API Token: "); std::io::stdout().flush().unwrap(); let token = read_password().unwrap(); - match verify_and_store_token(&token, &Config::default().url) { + 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); } @@ -565,7 +754,7 @@ pub fn handle_cli(cli: &Cli) { std::process::exit(1); } _ => { - error!("Error occurred: {e:?}"); + error!("{e}"); std::process::exit(1); } }, @@ -575,11 +764,106 @@ 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 env::var("API_TOKEN").is_ok() { + "(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(new_active) => { + info!("Logout successful."); + match new_active { + Some(profile) => info!("Active profile is now '{profile}'."), + None => info!("No profiles remain."), + } + 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) => { + error!("Error occurred: {e}"); + std::process::exit(1); + } + }, + Commands::Auth(auth_command) => match auth_command { + AuthCommands::List {} => match fetch_profiles_with_info(&Config::default().url) { + Ok(entries) if entries.is_empty() => { + info!("No profiles stored. Run `screenly login` to add one."); + } + Ok(entries) => { + handle_command_execution_result( + Ok::<_, CommandError>(ProfilesTable(entries)), + output, + ); + } + Err(e) => { + error!("Error occurred: {e}"); + std::process::exit(1); + } + }, + 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. + error!("No profile name given. Specify one of the profiles below:"); + match Authentication::list_profiles() { + Ok(profiles) => { + for profile in profiles { + let marker = if profile.is_active { "*" } else { " " }; + println!("{marker} {}", profile.name); + } + } + Err(e) => error!("Could not read profiles: {e}"), + } + 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) => { + error!("Error occurred: {e:?}"); + std::process::exit(1); + } + }, + }, + }, Commands::Mcp {} => { handle_cli_mcp_command(); } @@ -1240,6 +1524,66 @@ mod tests { use super::*; use crate::authentication::Config; + #[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_format_profiles_table_aligns_headers_and_placeholders() { + use crate::authentication::{ProfileEntry, ProfileInfo}; + + // A short name/email (shorter than the headers) and a profile with no + // info at all -- both used to break alignment. + 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, + }, + ]; + + let table = format_profiles_table(&entries); + let lines: Vec<&str> = table.lines().collect(); + + // Header column is at least as wide as "Profile" even though the + // widest name ("staging") is exactly 7 chars. + assert!(lines[0].starts_with(" Profile ")); + + // The Email and Workspace columns start at the same offset on the + // header and on every data row (lines[0] header, [1] rule, [2..] data). + let email_col = lines[0].find("Email").unwrap(); + let ws_col = lines[0].find("Workspace").unwrap(); + assert_eq!(lines[2].find("x@y.z"), Some(email_col)); + assert_eq!(lines[2].find("Team"), Some(ws_col)); + // Row with missing info renders a placeholder in the same columns + // rather than dropping them. + assert_eq!(lines[3].find("(unavailable)"), Some(email_col)); + + // Active profile is marked. + assert!(lines[2].starts_with("* a")); + } + #[test] fn test_get_screen_name_should_return_correct_screen_name() { let _tmp_dir = tempdir().unwrap();