1use anyhow::{bail, Context, Error, Result};
2use inquire::{Select, Text};
3use serde::{Deserialize, Serialize};
4use std::io::IsTerminal;
5use std::time::Duration;
6use std::{io, thread};
7use tracing::info;
8
9use crate::{
10 invocation_context::{context, init_context},
11 utils::auth::{
12 env_id_validator, host_validator, token_validator, CredentialProvider, HomeDirProvider,
13 Token,
14 },
15};
16
17#[derive(Debug, Deserialize)]
18struct DeviceCodeResponse {
19 device_code: String,
20 user_code: String,
21 verification_uri_complete: String,
22 expires_in: u64,
23 interval: u64,
24}
25
26#[derive(Debug, Serialize)]
27struct PollRequest {
28 device_code: String,
29}
30
31#[derive(Debug, Deserialize)]
32struct PollResponse {
33 status: String,
34 personal_api_key: Option<String>,
35 project_id: Option<String>,
36}
37
38pub fn login(host_override: Option<String>) -> Result<()> {
39 if !io::stdout().is_terminal() {
40 bail!("Failed to login. If you are running on a CI, skip this step and use POSTHOG_CLI_HOST, POSTHOG_CLI_ENV_ID, POSTHOG_CLI_TOKEN env variables when running commands")
41 }
42 login_with_use_cases(host_override, vec!["schema", "error_tracking"])
43}
44
45pub fn login_with_use_cases(host_override: Option<String>, use_cases: Vec<&str>) -> Result<()> {
46 let host = if let Some(override_host) = host_override {
47 override_host.trim_end_matches('/').to_string()
49 } else {
50 let options = vec!["US", "EU", "Manual"];
52 let selection = Select::new("Select your PostHog region:", options)
53 .with_help_message("Choose the region where your PostHog data is hosted, or 'Manual' to enter your own details")
54 .prompt()?;
55
56 match selection {
57 "US" => "https://us.posthog.com".to_string(),
58 "EU" => "https://eu.posthog.com".to_string(),
59 "Manual" => {
60 return manual_login();
61 }
62 _ => unreachable!(),
63 }
64 };
65
66 info!("🔐 Starting OAuth Device Flow authentication...");
67 info!("Connecting to: {}", host);
68
69 let device_data = request_device_code(&host)?;
71
72 let use_cases_param = use_cases.join(",");
74 let verification_url = if device_data.verification_uri_complete.contains('?') {
75 format!(
76 "{}&use_cases={}",
77 device_data.verification_uri_complete, use_cases_param
78 )
79 } else {
80 format!(
81 "{}?use_cases={}",
82 device_data.verification_uri_complete, use_cases_param
83 )
84 };
85
86 println!();
87 println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
88 println!(" 📱 Authorization Required");
89 println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
90 println!();
91 println!("To authenticate, visit this URL in your browser:");
92 println!(" {verification_url}");
93 println!();
94 println!("Your authorization code:");
95 println!(" ✨ {} ✨", device_data.user_code);
96 println!();
97 println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
98 println!();
99
100 if let Err(e) = open_browser(&verification_url) {
102 info!("Could not open browser automatically: {}", e);
103 info!("Please open the URL manually");
104 } else {
105 info!("✓ Opened browser for authorization");
106 }
107
108 info!("Waiting for authorization...");
110 let poll_response = poll_for_authorization(
111 &host,
112 &device_data.device_code,
113 device_data.interval,
114 device_data.expires_in,
115 )?;
116
117 info!("✓ Successfully authenticated!");
118
119 let token = Token {
121 host: Some(host),
122 token: poll_response.personal_api_key.unwrap(),
123 env_id: poll_response.project_id.unwrap(),
124 };
125 let provider = HomeDirProvider;
126 provider.store_credentials(token)?;
127
128 info!("Token saved to: {}", provider.report_location());
129
130 complete_login(&provider, "interactive_login")
131}
132
133fn request_device_code(host: &str) -> Result<DeviceCodeResponse, Error> {
134 let client = reqwest::blocking::Client::new();
135 let url = format!("{host}/api/cli-auth/device-code/");
136
137 let response = client
138 .post(&url)
139 .header("Content-Type", "application/json")
140 .send()
141 .context("Failed to request device code")?;
142
143 if !response.status().is_success() {
144 return Err(anyhow::anyhow!(
145 "Failed to request device code: HTTP {}",
146 response.status()
147 ));
148 }
149
150 let device_data: DeviceCodeResponse = response
151 .json()
152 .context("Failed to parse device code response")?;
153
154 Ok(device_data)
155}
156
157fn open_browser(url: &str) -> Result<(), Error> {
158 #[cfg(target_os = "macos")]
160 {
161 std::process::Command::new("open")
162 .arg(url)
163 .spawn()
164 .context("Failed to open browser")?;
165 }
166
167 #[cfg(target_os = "linux")]
168 {
169 std::process::Command::new("xdg-open")
170 .arg(url)
171 .spawn()
172 .context("Failed to open browser")?;
173 }
174
175 #[cfg(target_os = "windows")]
176 {
177 std::process::Command::new("cmd")
178 .args(&["/C", "start", url])
179 .spawn()
180 .context("Failed to open browser")?;
181 }
182
183 Ok(())
184}
185
186fn poll_for_authorization(
187 host: &str,
188 device_code: &str,
189 interval_seconds: u64,
190 expires_in_seconds: u64,
191) -> Result<PollResponse, Error> {
192 let client = reqwest::blocking::Client::new();
193 let url = format!("{host}/api/cli-auth/poll/");
194 let max_attempts = (expires_in_seconds / interval_seconds) + 1;
195 let poll_interval = Duration::from_secs(interval_seconds);
196
197 for attempt in 1..=max_attempts {
198 thread::sleep(poll_interval);
199
200 let request = PollRequest {
201 device_code: device_code.to_string(),
202 };
203
204 let response = client
205 .post(&url)
206 .header("Content-Type", "application/json")
207 .json(&request)
208 .send()
209 .context("Failed to poll for authorization")?;
210
211 let status_code = response.status();
212
213 if status_code.as_u16() == 202 {
214 if attempt % 3 == 0 {
216 info!(
217 "Still waiting for authorization... (attempt {}/{})",
218 attempt, max_attempts
219 );
220 }
221 continue;
222 }
223
224 let poll_response: PollResponse =
226 response.json().context("Failed to parse poll response")?;
227
228 if status_code.is_success() && poll_response.status == "authorized" {
229 return Ok(poll_response);
230 }
231
232 if status_code.as_u16() == 400 && poll_response.status == "expired" {
233 return Err(anyhow::anyhow!(
234 "Authorization code expired. Please try again."
235 ));
236 }
237
238 return Err(anyhow::anyhow!(
239 "Unexpected response during polling: HTTP {} - status: {}",
240 status_code,
241 poll_response.status
242 ));
243 }
244
245 Err(anyhow::anyhow!(
246 "Authorization timed out. Please try again."
247 ))
248}
249
250fn complete_login(provider: &HomeDirProvider, command_name: &str) -> Result<(), Error> {
251 init_context(None, false)?;
253 context().capture_command_invoked(command_name);
254
255 println!();
256 println!("🎉 Authentication complete!");
257 println!("Credentials saved to: {}", provider.report_location());
258 println!();
259 println!("You can now use the CLI:");
260 println!(" posthog-cli schema pull");
261 println!();
262
263 Ok(())
264}
265
266fn manual_login() -> Result<(), Error> {
267 info!("🔐 Manual login...");
268
269 let host = Text::new("Enter the PostHog host URL")
270 .with_default("https://us.posthog.com")
271 .with_validator(host_validator)
272 .prompt()?;
273
274 let env_id = Text::new("Enter your project ID (the number in your PostHog homepage URL)")
275 .with_validator(env_id_validator)
276 .prompt()?;
277
278 let token = Text::new(
279 "Enter your personal API token",
280 )
281 .with_validator(token_validator)
282 .with_help_message("See posthog.com/docs/api#private-endpoint-authentication. It will need to have the 'error tracking write' scope.")
283 .prompt()?;
284
285 let token = Token {
286 host: Some(host.trim_end_matches('/').to_string()),
287 token,
288 env_id,
289 };
290 let provider = HomeDirProvider;
291 provider.store_credentials(token)?;
292
293 info!("Token saved to: {}", provider.report_location());
294
295 complete_login(&provider, "manual_login")
296}