diff --git a/CLAUDE.md b/CLAUDE.md index 4f43c0f78..4252d8889 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,5 +23,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co Commands use a macro system in `main.rs`. The `commands!` macro generates routing for modules in `src/commands/`. ### Authentication -- Project tokens via `RAILWAY_TOKEN` environment variable -- User tokens via OAuth flow stored in config directory +- There are three auth sources: + - Scoped token via `RAILWAY_TOKEN` + - Global token via `RAILWAY_API_TOKEN` + - User token via OAuth from `railway login` (stored in config) diff --git a/src/client.rs b/src/client.rs index b77fb2694..56f0751ae 100644 --- a/src/client.rs +++ b/src/client.rs @@ -8,7 +8,7 @@ use reqwest::{ use crate::{ commands::Environment, - config::Configs, + config::{AuthScope, Configs, ResolvedAuth}, consts::{self, RAILWAY_API_TOKEN_ENV, RAILWAY_TOKEN_ENV}, errors::RailwayError, }; @@ -20,16 +20,24 @@ pub struct GQLClient; impl GQLClient { pub fn new_authorized(configs: &Configs) -> Result { + Self::new_authorized_with_scope(configs, AuthScope::PreferScoped) + } + + pub fn new_authorized_with_scope( + configs: &Configs, + scope: AuthScope, + ) -> Result { let mut headers = HeaderMap::new(); - if let Some(token) = &Configs::get_railway_token() { - headers.insert("project-access-token", HeaderValue::from_str(token)?); - } else if let Some(token) = configs.get_railway_auth_token() { - headers.insert( - "authorization", - HeaderValue::from_str(&format!("Bearer {token}"))?, - ); - } else { - return Err(RailwayError::Unauthorized); + match configs.resolve_auth(scope)? { + ResolvedAuth::ScopedToken(token) => { + headers.insert("project-access-token", HeaderValue::from_str(&token)?); + } + ResolvedAuth::Bearer(token) => { + headers.insert( + "authorization", + HeaderValue::from_str(&format!("Bearer {token}"))?, + ); + } } headers.insert( "x-source", @@ -40,8 +48,7 @@ impl GQLClient { .user_agent(consts::get_user_agent()) .default_headers(headers) .timeout(Duration::from_secs(30)) - .build() - .unwrap(); + .build()?; Ok(client) } diff --git a/src/commands/delete.rs b/src/commands/delete.rs index 826a06835..d6ce4ba59 100644 --- a/src/commands/delete.rs +++ b/src/commands/delete.rs @@ -37,7 +37,7 @@ pub struct Args { pub async fn command(args: Args) -> Result<()> { let configs = Configs::new()?; - let client = GQLClient::new_authorized(&configs)?; + let client = GQLClient::new_authorized_with_scope(&configs, AuthScope::GlobalOnly)?; let is_terminal = std::io::stdout().is_terminal(); let all_workspaces = workspaces().await?; diff --git a/src/commands/init.rs b/src/commands/init.rs index f90049887..1649bb7dd 100644 --- a/src/commands/init.rs +++ b/src/commands/init.rs @@ -26,7 +26,7 @@ pub struct Args { pub async fn command(args: Args) -> Result<()> { let mut configs = Configs::new()?; - let client = GQLClient::new_authorized(&configs)?; + let client = GQLClient::new_authorized_with_scope(&configs, AuthScope::GlobalOnly)?; let workspaces = workspaces().await?; let workspace = prompt_workspace(workspaces, args.workspace)?; diff --git a/src/commands/whoami.rs b/src/commands/whoami.rs index 9bc53ed47..52d583086 100644 --- a/src/commands/whoami.rs +++ b/src/commands/whoami.rs @@ -27,7 +27,7 @@ struct WorkspaceJson { pub async fn command(args: Args) -> Result<()> { let configs = Configs::new()?; - let client = GQLClient::new_authorized(&configs)?; + let client = GQLClient::new_authorized_with_scope(&configs, AuthScope::GlobalOnly)?; let user: RailwayUser = get_user(&client, &configs).await?; diff --git a/src/config.rs b/src/config.rs index 3f105d421..9271e40d1 100644 --- a/src/config.rs +++ b/src/config.rs @@ -59,6 +59,53 @@ pub enum Environment { Dev, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AuthScope { + GlobalOnly, + PreferScoped, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ResolvedAuth { + Bearer(String), + ScopedToken(String), +} + +fn resolve_auth_for_scope( + scope: AuthScope, + scoped_token: Option<&str>, + global_token: Option<&str>, + login_token: Option<&str>, +) -> Result { + let env_token_auth_active = scoped_token.is_some() || global_token.is_some(); + let global_auth_token = if env_token_auth_active { + global_token + } else { + global_token.or(login_token) + }; + + match scope { + AuthScope::GlobalOnly => { + if let Some(token) = global_auth_token { + Ok(ResolvedAuth::Bearer(token.to_owned())) + } else if scoped_token.is_some() { + Err(RailwayError::GlobalAuthRequired) + } else { + Err(RailwayError::Unauthorized) + } + } + AuthScope::PreferScoped => { + if let Some(token) = scoped_token { + Ok(ResolvedAuth::ScopedToken(token.to_owned())) + } else if let Some(token) = global_auth_token { + Ok(ResolvedAuth::Bearer(token.to_owned())) + } else { + Err(RailwayError::Unauthorized) + } + } + } +} + impl Configs { pub fn new() -> Result { let environment = Self::get_environment_id(); @@ -133,14 +180,30 @@ impl Configs { .unwrap_or(false) } - /// tries the environment variable and the config file + /// Tries global token env var, then login token from config. pub fn get_railway_auth_token(&self) -> Option { - Self::get_railway_api_token().or(self - .root_config + Self::get_railway_api_token().or(self.get_login_token()) + } + + fn get_login_token(&self) -> Option { + self.root_config .user .token .clone() - .filter(|t| !t.is_empty())) + .filter(|t| !t.is_empty()) + } + + pub fn resolve_auth(&self, scope: AuthScope) -> Result { + let scoped_token = Self::get_railway_token(); + let global_token = Self::get_railway_api_token(); + let login_token = self.get_login_token(); + + resolve_auth_for_scope( + scope, + scoped_token.as_deref(), + global_token.as_deref(), + login_token.as_deref(), + ) } pub fn get_environment_id() -> Environment { @@ -402,3 +465,61 @@ impl Configs { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn global_auth_prefers_global_token_when_both_env_tokens_are_set() { + let selected = resolve_auth_for_scope( + AuthScope::GlobalOnly, + Some("scoped-token"), + Some("global-token"), + None, + ); + + assert!(matches!( + selected, + Ok(ResolvedAuth::Bearer(token)) if token == "global-token" + )); + } + + #[test] + fn global_auth_errors_when_only_scoped_token_is_set() { + let selected = + resolve_auth_for_scope(AuthScope::GlobalOnly, Some("scoped-token"), None, None); + + assert!(matches!(selected, Err(RailwayError::GlobalAuthRequired))); + } + + #[test] + fn global_auth_errors_when_no_tokens_are_set() { + let selected = resolve_auth_for_scope(AuthScope::GlobalOnly, None, None, None); + + assert!(matches!(selected, Err(RailwayError::Unauthorized))); + } + + #[test] + fn global_auth_ignores_login_token_when_env_scoped_token_is_set() { + let selected = resolve_auth_for_scope( + AuthScope::GlobalOnly, + Some("scoped-token"), + None, + Some("login-token"), + ); + + assert!(matches!(selected, Err(RailwayError::GlobalAuthRequired))); + } + + #[test] + fn global_auth_uses_login_token_when_no_env_tokens_are_set() { + let selected = + resolve_auth_for_scope(AuthScope::GlobalOnly, None, None, Some("login-token")); + + assert!(matches!( + selected, + Ok(ResolvedAuth::Bearer(token)) if token == "login-token" + )); + } +} diff --git a/src/errors.rs b/src/errors.rs index 76e883fbc..b7c23ac2e 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -14,6 +14,11 @@ pub enum RailwayError { #[error("Unauthorized. Please run `railway login` again.")] UnauthorizedLogin, + #[error( + "This command requires global authentication. Set RAILWAY_API_TOKEN or run `railway login`." + )] + GlobalAuthRequired, + #[error( "Invalid {0}. Please check that it is valid and has access to the resource you're trying to use." )] diff --git a/src/workspace.rs b/src/workspace.rs index 50a4fa5af..4d76f9039 100644 --- a/src/workspace.rs +++ b/src/workspace.rs @@ -13,7 +13,7 @@ use super::{ pub async fn workspaces() -> Result> { let configs = Configs::new()?; let vars = queries::user_projects::Variables {}; - let client = GQLClient::new_authorized(&configs)?; + let client = GQLClient::new_authorized_with_scope(&configs, AuthScope::GlobalOnly)?; let response = post_graphql::(&client, configs.get_backboard(), vars).await?;