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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion crates/config/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use tower_api::apis::configuration::Configuration;
use tower_api::apis::configuration::{ApiKey, Configuration};
use url::Url;

mod error;
Expand All @@ -22,6 +22,9 @@ pub struct Config {
#[serde(skip_serializing, skip_deserializing)]
pub session: Option<Session>,

#[serde(skip_serializing, skip_deserializing)]
pub api_key: Option<String>,

// cache_dir is the directory that we should cache uv artifacts within.
pub cache_dir: Option<PathBuf>,
}
Expand All @@ -33,6 +36,7 @@ impl Config {
tower_url: default_tower_url(),
json: false,
session: None,
api_key: None,
cache_dir: Some(default_cache_dir()),
}
}
Expand All @@ -44,12 +48,14 @@ impl Config {
} else {
default_tower_url()
};
let api_key = std::env::var("TOWER_API_KEY").ok();

Self {
debug,
tower_url,
json: false,
session: None,
api_key,
cache_dir: Some(default_cache_dir()),
}
}
Expand Down Expand Up @@ -78,6 +84,7 @@ impl Config {
tower_url: sess.tower_url.clone(),
json: self.json,
session: Some(sess),
api_key: self.api_key,
cache_dir: Some(default_cache_dir()),
}
}
Expand Down Expand Up @@ -181,6 +188,15 @@ impl Config {

configuration.base_path = base_path.to_string();

// API key takes precedence over session-based auth
if let Some(ref key) = self.api_key {
configuration.api_key = Some(ApiKey {
prefix: None,
key: key.clone(),
});
return configuration;
}

// Always read from disk to pick up team switches
if let Ok(session) = Session::from_config_dir() {
if let Some(active_team) = &session.active_team {
Expand Down
27 changes: 27 additions & 0 deletions crates/tower-cmd/src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,22 @@ pub async fn refresh_session(
.await
}

pub async fn list_teams(
config: &Config,
) -> Result<
tower_api::models::ListTeamsResponse,
Error<tower_api::apis::default_api::ListTeamsError>,
> {
let api_config = &config.into();

let params = tower_api::apis::default_api::ListTeamsParams {
page: None,
page_size: None,
};

unwrap_api_response(tower_api::apis::default_api::list_teams(api_config, params)).await
}

pub enum LogStreamEvent {
EventLog(tower_api::models::RunLogLine),
EventWarning(tower_api::models::EventWarning),
Expand Down Expand Up @@ -614,6 +630,17 @@ impl ResponseEntity for tower_api::apis::default_api::DescribeSecretsKeySuccess
}
}

impl ResponseEntity for tower_api::apis::default_api::ListTeamsSuccess {
type Data = tower_api::models::ListTeamsResponse;

fn extract_data(self) -> Option<Self::Data> {
match self {
Self::Status200(data) => Some(data),
Self::UnknownValue(_) => None,
}
}
}

impl ResponseEntity for tower_api::apis::default_api::ListAppsSuccess {
type Data = tower_api::models::ListAppsResponse;

Expand Down
10 changes: 6 additions & 4 deletions crates/tower-cmd/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,12 @@ impl App {
pub fn new() -> Self {
let cmd = root_cmd();

// In certain scenarios, we want to load a session from a JWT token supplied as an
// environment variable. This is for programmatic use cases where we want to test the CLI
// in automated environments, for instance.
let session = if let Ok(token) = std::env::var("TOWER_JWT") {
// When TOWER_API_KEY is set, skip session entirely — the API key is self-contained
// and authenticates via X-API-Key header rather than Bearer JWT.
let session = if std::env::var("TOWER_API_KEY").is_ok() {
None
} else if let Ok(token) = std::env::var("TOWER_JWT") {
// Load session from a JWT token for programmatic use cases
Session::from_jwt(&token).ok()
} else {
Session::from_config_dir().ok()
Expand Down
21 changes: 21 additions & 0 deletions crates/tower-cmd/src/mcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,23 @@ impl TowerService {
})
}

async fn list_teams_via_api(&self) -> Result<CallToolResult, McpError> {
let response = api::list_teams(&self.config).await.map_err(|e| {
McpError::internal_error(
"Failed to list teams",
Some(json!({"error": e.to_string()})),
)
})?;

let teams: Vec<Value> = response
.teams
.into_iter()
.map(|team| json!({"name": team.name}))
.collect();

Self::json_success(json!({"teams": teams}))
}

fn extract_api_error_message(error: &crate::Error) -> String {
let crate::Error::ApiRunError { source } = error else {
return error.to_string();
Expand Down Expand Up @@ -640,6 +657,10 @@ impl TowerService {

#[tool(description = "List teams you belong to")]
async fn tower_teams_list(&self) -> Result<CallToolResult, McpError> {
if self.config.api_key.is_some() {
return self.list_teams_via_api().await;
}

let response = api::refresh_session(&self.config).await.map_err(|e| {
McpError::internal_error(
"Failed to refresh session",
Expand Down
17 changes: 17 additions & 0 deletions crates/tower-cmd/src/session.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use crate::output;
use clap::{Arg, ArgMatches, Command};
use colored::Colorize;
use config::{Config, Session};
use tokio::{time, time::Duration};
use tower_api::models::CreateDeviceLoginTicketResponse;
Expand All @@ -22,6 +23,22 @@ pub fn login_cmd() -> Command {
pub async fn do_login(config: Config, args: &ArgMatches) {
output::banner();

if std::env::var("TOWER_API_KEY").is_ok() {
output::write(&format!(
"{} TOWER_API_KEY is set. As long as this environment variable is present, \
the CLI will authenticate using the API key and ignore the session \
created by this login flow.\n",
"Warning:".yellow(),
));

eprint!("Do you want to continue? [y/N] ");
let mut input = String::new();
if std::io::stdin().read_line(&mut input).is_err() || !input.trim().eq_ignore_ascii_case("y")
{
return;
}
}

// Open a browser by default, unless the --no-browser flag is set.
let open_browser = !args.get_flag("no-browser");

Expand Down
29 changes: 28 additions & 1 deletion crates/tower-cmd/src/teams.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,35 @@ async fn refresh_session(config: &Config) -> config::Session {
}

pub async fn do_list(config: Config) {
if config.api_key.is_some() {
do_list_via_api(&config).await;
} else {
do_list_via_session(&config).await;
}
}

async fn do_list_via_api(config: &Config) {
let resp = output::with_spinner("Fetching teams", api::list_teams(config)).await;

let headers = vec!["Name"]
.into_iter()
.map(|h| h.yellow().to_string())
.collect();

let teams_data: Vec<Vec<String>> = resp
.teams
.iter()
.map(|team| vec![team.name.clone()])
.collect();

output::newline();
output::table(headers, teams_data, None::<&Vec<config::Team>>);
output::newline();
}

async fn do_list_via_session(config: &Config) {
// Refresh the session and get the updated data
let session = refresh_session(&config).await;
let session = refresh_session(config).await;

// Get the current active team from the session
let active_team = session.active_team.clone();
Expand Down
18 changes: 18 additions & 0 deletions tests/integration/features/cli_api_key_auth.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
Feature: API Key Authentication
As a user authenticating with an API key
I want the CLI to use the X-API-Key header
So that I can interact with Tower without a session

Scenario: List apps using API key auth
When I run "tower --json apps list" via CLI with API key
Then the output should be valid JSON
And no session.json should exist in the temp home

Scenario: List teams using API key auth
When I run "tower teams list" via CLI with API key
Then no session.json should exist in the temp home

Scenario: Login warns when API key is set
When I run "tower login --no-browser" via CLI with API key
Then the output should show "TOWER_API_KEY is set"
And the output should show "ignore the session"
48 changes: 48 additions & 0 deletions tests/integration/features/steps/cli_steps.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,54 @@ def step_run_cli_command(context, command):
raise


@step('I run "{command}" via CLI with API key')
def step_run_cli_command_with_api_key(context, command):
"""Run a Tower CLI command authenticating via TOWER_API_KEY instead of session.json"""
cli_path = context.tower_binary

cmd_parts = shlex.split(command)
full_command = [cli_path] + cmd_parts[1:] # Skip 'tower' prefix

try:
test_env = os.environ.copy()
test_env["FORCE_COLOR"] = "1"
test_env["CLICOLOR_FORCE"] = "1"
test_env["TOWER_URL"] = context.tower_url
test_env["TOWER_API_KEY"] = "sk-test-api-key"

# Use a temp HOME with no session.json to prove API key auth works standalone
test_env["HOME"] = context.temp_dir

result = subprocess.run(
full_command,
capture_output=True,
text=True,
timeout=60,
env=test_env,
)
context.cli_output = result.stdout + result.stderr
context.cli_stdout = result.stdout
context.cli_return_code = result.returncode
except subprocess.TimeoutExpired:
context.cli_output = "Command timed out"
context.cli_stdout = ""
context.cli_return_code = 124
except Exception as e:
print(f"DEBUG: Exception in CLI command: {type(e).__name__}: {e}")
print(f"DEBUG: Command was: {full_command}")
print(f"DEBUG: Working directory: {os.getcwd()}")
raise


@step("no session.json should exist in the temp home")
def step_no_session_json(context):
"""Verify that API key auth did not create a session.json file"""
session_path = Path(context.temp_dir) / ".config" / "tower" / "session.json"
assert (
not session_path.exists()
), f"session.json should not exist but found at {session_path}"


@step('I run "{command}" via CLI using created app name')
def step_run_cli_command_with_app_name(context, command):
"""Run a Tower CLI command with the generated app name injected."""
Expand Down
Loading