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
28 changes: 14 additions & 14 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
```
src/
main.rs Entry point, CLI argument parsing (clap), command dispatch
auth.rs Authentication config (load/save/resolve) + auth command
auth.rs Authentication config (load/save/resolve) + auth command (device flow via /v1/cli-auth/*)
whoami.rs Whoami command (auth check, team info)
apps.rs Apps command (fetch, DB write)
consumers.rs Consumers command (paginated fetch, DB write)
Expand Down Expand Up @@ -41,18 +41,18 @@ skills/

## CLI Subcommands

| Subcommand | Data source |
| ----------------- | --------------------------------------------------- |
| `auth` | — |
| `whoami` | `GET /v1/team` |
| `apps` | `GET /v1/apps` |
| `consumers` | `GET /v1/apps/{app_id}/consumers` |
| `endpoints` | `GET /v1/apps/{app_id}/endpoints` |
| `metrics` | `POST /v1/apps/{app_id}/metrics` |
| `request-logs` | `POST /v1/apps/{app_id}/request-logs` |
| `traces` | `POST /v1/apps/{app_id}/traces` |
| `request-details` | `GET /v1/apps/{app_id}/request-logs/{request_uuid}` |
| `sql` | Local DuckDB |
| Subcommand | Data source |
| ----------------- | ------------------------------------------------------- |
| `auth` | `POST /v1/cli-auth/sessions`, `POST /v1/cli-auth/token` |
| `whoami` | `GET /v1/team` |
| `apps` | `GET /v1/apps` |
| `consumers` | `GET /v1/apps/{app_id}/consumers` |
| `endpoints` | `GET /v1/apps/{app_id}/endpoints` |
| `metrics` | `POST /v1/apps/{app_id}/metrics` |
| `request-logs` | `POST /v1/apps/{app_id}/request-logs` |
| `traces` | `POST /v1/apps/{app_id}/traces` |
| `request-details` | `GET /v1/apps/{app_id}/request-logs/{request_uuid}` |
| `sql` | Local DuckDB |

## Authentication with API

Expand Down Expand Up @@ -94,7 +94,7 @@ Triggered by publishing a GitHub release. Two jobs:

### Module `run()` functions

Each command module exposes a `pub fn run(...)` that does all the work: resolve auth, call the API, write output. The last parameter is `impl Write` (stdout in production, `Vec<u8>` in tests), except `auth::run` which takes `&mut impl Read` (stdin) instead. Data goes to the writer (stdout), human messages and progress go to stderr.
Each command module exposes a `pub fn run(...)` that does all the work: resolve auth, call the API, write output. The last parameter is `impl Write` (stdout in production, `Vec<u8>` in tests), except `auth::run`, which writes only to stderr and takes the `auth.json` path instead. Data goes to the writer (stdout), human messages and progress go to stderr.

### Error handling and exit codes

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ Run the `auth` command to authenticate the CLI:
npx @apitally/cli auth
```

This opens a browser-based auth flow where you log in to the Apitally dashboard and select a team. A newly created API key is then passed back to the CLI and saved to `~/.apitally/auth.json`.
This opens the Apitally dashboard in your browser, where you confirm the code shown in the terminal, select a team and choose the API key's scope. Once you approve the login, the CLI fetches a newly created API key and saves it to `~/.apitally/auth.json`.

If you already have an API key, you can provide it directly:

Expand Down
2 changes: 1 addition & 1 deletion skills/apitally-cli/references/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ Datetime flags (e.g. `--since`, `--until`, `--requests-since`) accept ISO 8601 s
npx @apitally/cli auth [--api-key <key>]
```

Opens a browser-based auth flow where the user logs in to the Apitally dashboard and selects a team. A newly created API key is then passed back to the CLI. The key is saved to `~/.apitally/auth.json` and used by all subsequent commands unless overridden by the `--api-key` flag.
Opens a browser-based auth flow where the user logs in to the Apitally dashboard, confirms the code shown in the terminal, and picks a team and a scope (read-only or read & write). The CLI then fetches the newly created API key. The key is saved to `~/.apitally/auth.json` and used by all subsequent commands unless overridden by the `--api-key` flag.

If `--api-key` is provided, the key is saved directly without opening the browser.

Expand Down
272 changes: 102 additions & 170 deletions src/auth.rs
Original file line number Diff line number Diff line change
@@ -1,17 +1,14 @@
use std::fs;
use std::io::{self, BufRead, Read, Write};
use std::net::{TcpListener, TcpStream};
use std::path::{Path, PathBuf};
use std::sync::mpsc;
use std::thread;
use std::time::Duration;
use std::time::{Duration, Instant};

use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};

use regex::Regex;

use crate::utils::{ansi, auth_err};
use crate::utils::{ansi, api_post_without_key, auth_err};

const DEFAULT_API_BASE_URL: &str = "https://api.apitally.io";

Expand All @@ -22,6 +19,20 @@ pub struct AuthConfig {
pub api_base_url: Option<String>,
}

#[derive(Deserialize)]
struct CliAuthSessionResponse {
device_code: String,
user_code: String,
verification_url: String,
expires_in: u64,
interval: u64,
}

#[derive(Deserialize)]
struct CliAuthTokenResponse {
api_key: String,
}

pub fn auth_file_path() -> Result<PathBuf> {
let home = dirs::home_dir().context("could not determine home directory")?;
Ok(home.join(".apitally").join("auth.json"))
Expand Down Expand Up @@ -105,13 +116,11 @@ pub fn resolve_api_base_url(api_base_url: Option<&str>) -> String {
pub fn run(
api_key: Option<String>,
api_base_url: Option<String>,
app_url: &str,
auth_file_path: &Path,
input: Option<Box<dyn Read + Send>>,
) -> Result<()> {
let api_key = match api_key {
Some(key) => key,
None => browser_auth(app_url, input)?,
None => browser_auth(&pick_api_base_url(api_base_url.as_deref(), None))?,
Comment thread
itssimon marked this conversation as resolved.
Resolved
};
validate_api_key(&api_key)?;
save_auth_file(
Expand All @@ -125,111 +134,52 @@ pub fn run(
Ok(())
}

fn browser_auth(app_url: &str, input: Option<Box<dyn Read + Send>>) -> Result<String> {
let listener =
TcpListener::bind("127.0.0.1:0").context("failed to start local callback server")?;
let port = listener.local_addr()?.port();
let url = format!("{app_url}/cli-auth?callback_port={port}");

#[cfg(not(test))]
let _ = open::that(&url);

eprintln!("Opening browser with URL: {url}\n");
eprintln!("Complete the auth flow in the browser.");
if input.is_some() {
eprint!("Or paste your API key and press Enter: ");
}
io::stderr().flush()?;

let (tx, rx) = mpsc::channel();

if let Some(input) = input {
let tx_stdin = tx.clone();
thread::spawn(move || read_stdin(tx_stdin, input));
}

let app_url = app_url.to_string();
thread::spawn(move || run_callback_server(listener, tx, &app_url));

let api_key = rx.recv_timeout(Duration::from_secs(300)).map_err(|_| {
eprintln!("\n");
auth_err("authentication timed out")
})?;
Ok(api_key)
}

fn read_stdin(tx: mpsc::Sender<String>, input: Box<dyn Read + Send>) {
let mut line = String::new();
if io::BufReader::new(input).read_line(&mut line).is_ok() {
let key = line.trim().to_string();
if !key.is_empty() {
eprintln!();
let _ = tx.send(key);
}
}
}

fn run_callback_server(listener: TcpListener, tx: mpsc::Sender<String>, app_url: &str) {
listener.set_nonblocking(false).ok();
while let Ok((mut stream, _)) = listener.accept() {
if let Some(api_key) = handle_callback_request(&mut stream, app_url) {
eprintln!("\n");
let _ = tx.send(api_key);
return;
}
}
}
fn browser_auth(api_base_url: &str) -> Result<String> {
let url = format!("{api_base_url}/v1/cli-auth/sessions");
let mut response = api_post_without_key(&url, &serde_json::json!({}))?;
let session: CliAuthSessionResponse = response.body_mut().read_json()?;

fn handle_callback_request(stream: &mut TcpStream, app_url: &str) -> Option<String> {
stream
.set_read_timeout(Some(std::time::Duration::from_secs(1)))
.ok();
let mut buf = [0u8; 4096];
let n = stream.read(&mut buf).ok()?;
let request = std::str::from_utf8(&buf[..n]).ok()?;

if request.starts_with("OPTIONS ") {
let response = format!(
"HTTP/1.1 204 No Content\r\n\
Access-Control-Allow-Origin: {app_url}\r\n\
Access-Control-Allow-Methods: POST\r\n\
Access-Control-Allow-Headers: Content-Type\r\n\
Content-Length: 0\r\n\
\r\n"
);
stream.write_all(response.as_bytes()).ok();
return None;
}
eprintln!("Opening browser with URL: {}\n", session.verification_url);
eprintln!(
"Confirm that the browser shows this code: {}\n",
session.user_code
);

if request.starts_with("POST ") {
let api_key = request
.split("\r\n\r\n")
.nth(1)
.and_then(|body| serde_json::from_str::<serde_json::Value>(body).ok())
.and_then(|parsed| parsed["api_key"].as_str().map(String::from));

if let Some(api_key) = api_key {
let response = format!(
"HTTP/1.1 200 OK\r\n\
Access-Control-Allow-Origin: {app_url}\r\n\
Content-Length: 0\r\n\
\r\n"
);
stream.write_all(response.as_bytes()).ok();
return Some(api_key);
#[cfg(not(test))]
let _ = open::that(&session.verification_url);

let token_url = format!("{api_base_url}/v1/cli-auth/token");
let body = serde_json::json!({ "device_code": session.device_code });
let deadline = Instant::now() + Duration::from_secs(session.expires_in);
while Instant::now() < deadline {
thread::sleep(Duration::from_secs(session.interval));
Comment thread
itssimon marked this conversation as resolved.
Resolved
let Ok(mut response) = ureq::post(&token_url)
.config()
.http_status_as_error(false)
.timeout_connect(Some(Duration::from_secs(5)))
.timeout_recv_response(Some(Duration::from_secs(15)))
.build()
.send_json(&body)
else {
continue;
};
match response.status().as_u16() {
200 => {
return Ok(response
.body_mut()
.read_json::<CliAuthTokenResponse>()?
.api_key);
}
429 => {}
400..=499 => {
return Err(auth_err(
"authentication expired, run `apitally auth` again",
));
}
_ => {}
}

let response = format!(
"HTTP/1.1 400 Bad Request\r\n\
Access-Control-Allow-Origin: {app_url}\r\n\
Content-Length: 0\r\n\
\r\n"
);
stream.write_all(response.as_bytes()).ok();
return None;
}

None
Err(auth_err("authentication timed out"))
}

#[cfg(test)]
Expand Down Expand Up @@ -295,9 +245,7 @@ mod tests {
run(
Some(TEST_API_KEY.into()),
Some("https://custom.api".into()),
"https://app.apitally.io",
&path,
None,
)
.unwrap();
let config = load_auth_file(&path).unwrap().unwrap();
Expand All @@ -306,69 +254,53 @@ mod tests {
}

#[test]
fn test_run_with_stdin() {
fn test_run_with_polling() {
let mut server = mockito::Server::new();
let sessions_mock = server
.mock("POST", "/v1/cli-auth/sessions")
.with_status(201)
.with_header("content-type", "application/json")
.with_body(
serde_json::json!({
"device_code": "test-device-code",
"user_code": "WDJB-MJHT",
"verification_url": "https://app.apitally.io/cli-auth?code=WDJB-MJHT",
"expires_in": 600,
"interval": 0,
})
.to_string(),
)
.create();
let pending_mock = server
.mock("POST", "/v1/cli-auth/token")
.match_body(mockito::Matcher::Json(
serde_json::json!({"device_code": "test-device-code"}),
))
.with_status(202)
.with_body("{}")
.expect(1)
.create();
let approved_mock = server
.mock("POST", "/v1/cli-auth/token")
.match_body(mockito::Matcher::Json(
serde_json::json!({"device_code": "test-device-code"}),
))
.with_status(200)
.with_header("content-type", "application/json")
.with_body(format!(r#"{{"api_key":"{TEST_API_KEY}"}}"#))
.expect(1)
.create();

let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("auth.json");
let input: Box<dyn Read + Send> =
Box::new(io::Cursor::new(format!("{TEST_API_KEY}\n").into_bytes()));
run(None, None, "https://app.apitally.io", &path, Some(input)).unwrap();
run(None, Some(server.url()), &path).unwrap();
sessions_mock.assert();
pending_mock.assert();
approved_mock.assert();

let config = load_auth_file(&path).unwrap().unwrap();
assert_eq!(config.api_key, TEST_API_KEY);
assert!(config.api_base_url.is_none());
}

#[test]
fn test_run_with_callback() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();

let (tx, rx) = mpsc::channel();
let app_url = "https://app.apitally.io";
let app_url_owned = app_url.to_string();
thread::spawn(move || run_callback_server(listener, tx, &app_url_owned));

// Send CORS preflight
let mut stream = TcpStream::connect(format!("127.0.0.1:{port}")).unwrap();
stream
.write_all(b"OPTIONS /callback HTTP/1.1\r\nOrigin: https://app.apitally.io\r\n\r\n")
.unwrap();
let mut response = vec![0u8; 1024];
let n = stream.read(&mut response).unwrap();
let response_str = std::str::from_utf8(&response[..n]).unwrap();
assert!(response_str.contains("204"));
assert!(response_str.contains(&format!("Access-Control-Allow-Origin: {app_url}")));

// Send callback POST with invalid data
let body = r#"{"invalid":"data"}"#;
let request = format!(
"POST /callback HTTP/1.1\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
body.len(),
body
);
let mut stream = TcpStream::connect(format!("127.0.0.1:{port}")).unwrap();
stream.write_all(request.as_bytes()).unwrap();
let mut response = vec![0u8; 1024];
let n = stream.read(&mut response).unwrap();
let response_str = std::str::from_utf8(&response[..n]).unwrap();
assert!(response_str.contains("400"));
assert!(response_str.contains(&format!("Access-Control-Allow-Origin: {app_url}")));

// Send callback POST with valid api_key
let body = format!(r#"{{"api_key":"{TEST_API_KEY}"}}"#);
let request = format!(
"POST /callback HTTP/1.1\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
body.len(),
body
);
let mut stream = TcpStream::connect(format!("127.0.0.1:{port}")).unwrap();
stream.write_all(request.as_bytes()).unwrap();
let mut response = vec![0u8; 1024];
let n = stream.read(&mut response).unwrap();
let response_str = std::str::from_utf8(&response[..n]).unwrap();
assert!(response_str.contains("200"));

let api_key = rx.recv_timeout(Duration::from_secs(5)).unwrap();
assert_eq!(api_key, TEST_API_KEY);
assert_eq!(config.api_base_url, Some(server.url()));
}

#[test]
Expand Down
Loading
Loading