Skip to content
Open
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
6 changes: 4 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
31 changes: 19 additions & 12 deletions src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand All @@ -20,16 +20,24 @@ pub struct GQLClient;

impl GQLClient {
pub fn new_authorized(configs: &Configs) -> Result<Client, RailwayError> {
Self::new_authorized_with_scope(configs, AuthScope::PreferScoped)
}

pub fn new_authorized_with_scope(
configs: &Configs,
scope: AuthScope,
) -> Result<Client, RailwayError> {
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",
Expand All @@ -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)
}

Expand Down
2 changes: 1 addition & 1 deletion src/commands/delete.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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?;
Expand Down
2 changes: 1 addition & 1 deletion src/commands/init.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)?;
Expand Down
2 changes: 1 addition & 1 deletion src/commands/whoami.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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?;

Expand Down
129 changes: 125 additions & 4 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ResolvedAuth, RailwayError> {
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<Self> {
let environment = Self::get_environment_id();
Expand Down Expand Up @@ -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<String> {
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<String> {
self.root_config
.user
.token
.clone()
.filter(|t| !t.is_empty()))
.filter(|t| !t.is_empty())
}

pub fn resolve_auth(&self, scope: AuthScope) -> Result<ResolvedAuth, RailwayError> {
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 {
Expand Down Expand Up @@ -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"
));
}
}
5 changes: 5 additions & 0 deletions src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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."
)]
Expand Down
2 changes: 1 addition & 1 deletion src/workspace.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ use super::{
pub async fn workspaces() -> Result<Vec<Workspace>> {
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::<queries::UserProjects, _>(&client, configs.get_backboard(), vars).await?;

Expand Down