posthog_cli/
login.rs

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        // Strip trailing slashes to avoid double slashes in URLs
48        override_host.trim_end_matches('/').to_string()
49    } else {
50        // Prompt user to select region or manual login
51        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    // Step 1: Request device code
70    let device_data = request_device_code(&host)?;
71
72    // Add use_cases parameter to the verification URL
73    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    // Step 2: Try to open browser
101    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    // Step 3: Poll for authorization
109    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    // Step 4: Save credentials
120    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    // Try to open browser using platform-specific commands
159    #[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            // Still pending
215            if attempt % 3 == 0 {
216                info!(
217                    "Still waiting for authorization... (attempt {}/{})",
218                    attempt, max_attempts
219                );
220            }
221            continue;
222        }
223
224        // Parse response body for both success and error cases
225        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    // Login is the only command that doesn't have a context coming in - because it modifies the context
252    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}