Skip to content

Commit 3756cbf

Browse files
committed
svix-cli: add interactive login
1 parent 068d479 commit 3756cbf

File tree

3 files changed

+137
-17
lines changed

3 files changed

+137
-17
lines changed

svix-cli/Cargo.toml

+1-1
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ idna_adapter = "=1.1.0"
4343
indoc = "2.0.5"
4444
open = "5.3.1"
4545
rand = "0.8.5"
46-
reqwest = { version = "0.12.9", features = ["rustls-tls", "charset", "http2", "macos-system-configuration"], default-features = false }
46+
reqwest = { version = "0.12.9", features = ["rustls-tls", "json", "charset", "http2", "macos-system-configuration"], default-features = false }
4747
serde = { version = "1.0.215", features = ["derive"] }
4848
serde_json = "1.0.133"
4949
svix = { path = "../rust" }

svix-cli/src/cmds/login.rs

+135-15
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,40 @@
1-
use anyhow::Result;
1+
use std::time::{Duration, Instant};
2+
3+
use anyhow::{Context, Result};
24
use dialoguer::Input;
5+
use reqwest::Client;
6+
use serde::Deserialize;
37

48
use crate::{config, config::Config};
59

6-
pub fn prompt() -> Result<()> {
10+
pub async fn prompt(_cfg: &Config) -> Result<()> {
711
print!("Welcome to the Svix CLI!\n\n");
812

9-
let auth_token = Input::new()
10-
.with_prompt("Auth Token")
11-
.validate_with({
12-
move |input: &String| -> Result<()> {
13-
if !input.trim().is_empty() {
14-
Ok(())
15-
} else {
16-
Err(anyhow::anyhow!("auth token cannot be empty"))
13+
let selections = &["Login in dashboard.svix.com", "Input token manually"];
14+
let selection = dialoguer::Select::new()
15+
.with_prompt("How would you like to authenticate?")
16+
.items(selections)
17+
.default(0)
18+
.interact()?;
19+
20+
let auth_token = if selection == 0 {
21+
dashboard_login().await?
22+
} else {
23+
Input::new()
24+
.with_prompt("Auth Token")
25+
.validate_with({
26+
move |input: &String| -> Result<()> {
27+
if !input.trim().is_empty() {
28+
Ok(())
29+
} else {
30+
Err(anyhow::anyhow!("auth token cannot be empty"))
31+
}
1732
}
18-
}
19-
})
20-
.interact_text()?
21-
.trim()
22-
.to_string();
33+
})
34+
.interact_text()?
35+
.trim()
36+
.to_string()
37+
};
2338

2439
// Load from disk and update the prompted fields.
2540
// There are other fields (not prompted for) related to "relay" for the `listen` command
@@ -45,3 +60,108 @@ pub fn prompt() -> Result<()> {
4560
);
4661
Ok(())
4762
}
63+
64+
#[derive(Debug, Deserialize)]
65+
#[serde(rename_all = "camelCase")]
66+
struct CliStartLoginSessionOut {
67+
session_id: String,
68+
}
69+
70+
#[derive(Debug, Deserialize)]
71+
#[serde(rename_all = "camelCase")]
72+
struct AuthTokenOut {
73+
token: String,
74+
}
75+
76+
#[derive(Debug, Deserialize)]
77+
#[serde(rename_all = "camelCase")]
78+
struct DiscoverySessionOut {
79+
pub region: String,
80+
}
81+
82+
const DASHBOARD_URL: &str = "https://dashboard.svix.com";
83+
const LOGIN_SERVER_URL: &str = "https://api.svix.com";
84+
85+
pub async fn dashboard_login() -> Result<String> {
86+
let client = reqwest::Client::new();
87+
88+
let start_session = client
89+
.post(format!("{LOGIN_SERVER_URL}/dashboard/cli/login/start"))
90+
.send()
91+
.await
92+
.context("Failed to get session ID. Could not connect to server.")?
93+
.json::<CliStartLoginSessionOut>()
94+
.await
95+
.context("Failed to get session ID. Invalid response.")?;
96+
97+
let session_id = start_session.session_id;
98+
let code = &session_id[0..4].to_uppercase();
99+
100+
let url = format!("{DASHBOARD_URL}/cli/login?sessionId={session_id}&code={code}");
101+
102+
println!("\nPlease approve the login in your browser, then return here.");
103+
println!("Verification code: \x1b[32m{}\x1b[0m\n", code);
104+
105+
if let Err(e) = open::that(&url) {
106+
eprintln!("Failed to open browser: {}", e);
107+
println!("Please manually open this URL in your browser: {}", url);
108+
}
109+
110+
println!("Waiting for approval...");
111+
112+
// First, poll the discovery endpoint to get the region
113+
let discovery_poll_url = format!("{LOGIN_SERVER_URL}/dashboard/cli/login/discovery/complete");
114+
let discovery_data: DiscoverySessionOut =
115+
poll_session(&client, &discovery_poll_url, &session_id).await?;
116+
117+
let region = discovery_data.region;
118+
let region_server_url = format!("https://api.{region}.svix.com");
119+
let token_poll_url = format!("{region_server_url}/dashboard/cli/login/token/complete");
120+
121+
// Then, poll the token endpoint to get the auth token
122+
let token_data: AuthTokenOut = poll_session(&client, &token_poll_url, &session_id).await?;
123+
124+
println!("Authentication successful!\n");
125+
Ok(token_data.token)
126+
}
127+
128+
const MAX_POLL_TIME: Duration = Duration::from_secs(5 * 60);
129+
130+
async fn poll_session<T>(client: &Client, poll_url: &str, session_id: &str) -> Result<T>
131+
where
132+
T: for<'de> serde::Deserialize<'de>,
133+
{
134+
let start_time: Instant = Instant::now();
135+
136+
while start_time.elapsed() < MAX_POLL_TIME {
137+
let response = client
138+
.post(poll_url)
139+
.json(&serde_json::json!({ "sessionId": session_id }))
140+
.send()
141+
.await
142+
.context("Failed to connect to authentication server")?;
143+
144+
if response.status().is_success() {
145+
return response
146+
.json::<T>()
147+
.await
148+
.context("Failed to parse authentication data");
149+
} else if response.status() != reqwest::StatusCode::NOT_FOUND {
150+
// Bail if session exists but has an error (is expired or something else)
151+
let error_message = match response.json::<serde_json::Value>().await {
152+
Ok(json) => json
153+
.get("detail")
154+
.and_then(|d| d.as_str())
155+
.unwrap_or("Unknown error")
156+
.to_string(),
157+
Err(_) => "Unknown error".to_string(),
158+
};
159+
160+
anyhow::bail!("Authentication failed: {error_message}");
161+
}
162+
163+
std::thread::sleep(std::time::Duration::from_secs(1));
164+
}
165+
166+
anyhow::bail!("Authentication failed.");
167+
}

svix-cli/src/main.rs

+1-1
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,7 @@ async fn main() -> Result<()> {
164164
}
165165

166166
RootCommands::Listen(args) => args.exec(&cfg?).await?,
167-
RootCommands::Login => cmds::login::prompt()?,
167+
RootCommands::Login => cmds::login::prompt(&cfg?).await?,
168168
RootCommands::Completion { shell } => cmds::completion::generate(&shell)?,
169169
}
170170

0 commit comments

Comments
 (0)