bitbucket_cli/auth/
oauth.rs

1use anyhow::{Context, Result};
2use oauth2::basic::BasicClient;
3use oauth2::{
4    AuthUrl, AuthorizationCode, ClientId, ClientSecret, CsrfToken, PkceCodeChallenge, RedirectUrl,
5    RefreshToken, Scope, TokenResponse, TokenUrl,
6};
7use std::io::{BufRead, BufReader, Write};
8use std::net::TcpListener;
9
10use super::{AuthManager, Credential};
11
12/// Async HTTP client for OAuth2 token exchange
13async fn async_http_client(
14    request: oauth2::HttpRequest,
15) -> Result<oauth2::HttpResponse, reqwest::Error> {
16    let client = reqwest::Client::builder()
17        .redirect(reqwest::redirect::Policy::none())
18        .build()?;
19
20    let mut request_builder = client
21        .request(request.method().clone(), request.uri().to_string())
22        .body(request.body().clone());
23
24    for (name, value) in request.headers() {
25        request_builder = request_builder.header(name.as_str(), value.as_bytes());
26    }
27
28    let response = request_builder.send().await?;
29
30    let status_code = response.status();
31    let headers = response.headers().to_owned();
32    let body = response.bytes().await?.to_vec();
33
34    let mut builder = oauth2::http::Response::builder().status(status_code);
35
36    for (name, value) in headers.iter() {
37        builder = builder.header(name, value);
38    }
39
40    // Build the response - this should never fail with valid HTTP data
41    Ok(builder.body(body).expect("Failed to build HTTP response"))
42}
43
44const BITBUCKET_AUTH_URL: &str = "https://bitbucket.org/site/oauth2/authorize";
45const BITBUCKET_TOKEN_URL: &str = "https://bitbucket.org/site/oauth2/access_token";
46
47/// OAuth 2.0 authentication flow
48pub struct OAuthFlow {
49    client_id: String,
50    client_secret: String,
51}
52
53impl OAuthFlow {
54    pub fn new(client_id: String, client_secret: String) -> Self {
55        Self {
56            client_id,
57            client_secret,
58        }
59    }
60
61    /// Try to bind to one of the preferred ports
62    fn bind_to_available_port(ports: &[u16]) -> Result<(TcpListener, u16)> {
63        for &port in ports {
64            match TcpListener::bind(format!("127.0.0.1:{}", port)) {
65                Ok(listener) => {
66                    return Ok((listener, port));
67                }
68                Err(_) => continue,
69            }
70        }
71
72        anyhow::bail!(
73            "Could not bind to any preferred port. Tried: {:?}\n\n\
74            Please ensure at least one of these ports is available:\n\
75            - Close any applications using these ports\n\
76            - Or use API key authentication: bitbucket auth login --api-key",
77            ports
78        )
79    }
80
81    /// Run the OAuth 2.0 authentication flow
82    pub async fn authenticate(&self, auth_manager: &AuthManager) -> Result<Credential> {
83        println!("\nšŸ” Bitbucket OAuth Authentication");
84        println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
85        println!();
86
87        // Use a static port for OAuth callback (required by Bitbucket)
88        // Try common ports in order: 8080, 3000, 8888, 9000
89        const PREFERRED_PORTS: &[u16] = &[8080, 3000, 8888, 9000];
90
91        let (listener, port) = Self::bind_to_available_port(PREFERRED_PORTS)
92            .context("Failed to bind callback server. Please ensure one of these ports is available: 8080, 3000, 8888, or 9000")?;
93
94        let redirect_url = format!("http://127.0.0.1:{}/callback", port);
95
96        println!("šŸ“” Callback server listening on port {}", port);
97        println!("   Make sure your OAuth consumer callback URL is set to:");
98        println!("   {}", redirect_url);
99        println!();
100
101        // Create OAuth client
102        let client = BasicClient::new(ClientId::new(self.client_id.clone()))
103            .set_client_secret(ClientSecret::new(self.client_secret.clone()))
104            .set_auth_uri(AuthUrl::new(BITBUCKET_AUTH_URL.to_string())?)
105            .set_token_uri(TokenUrl::new(BITBUCKET_TOKEN_URL.to_string())?)
106            .set_redirect_uri(RedirectUrl::new(redirect_url.clone())?);
107
108        // Generate PKCE challenge
109        let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();
110
111        // Generate authorization URL
112        let (auth_url, csrf_token) = client
113            .authorize_url(CsrfToken::new_random)
114            .add_scope(Scope::new("repository".to_string()))
115            .add_scope(Scope::new("pullrequest".to_string()))
116            .add_scope(Scope::new("issue".to_string()))
117            .add_scope(Scope::new("pipeline".to_string()))
118            .add_scope(Scope::new("account".to_string()))
119            .set_pkce_challenge(pkce_challenge)
120            .url();
121
122        println!("Opening browser for authentication...");
123        println!();
124
125        // Try to open browser
126        if open::that(auth_url.as_str()).is_err() {
127            println!("Could not open browser automatically.");
128            println!("Please open this URL in your browser:");
129            println!();
130            println!("  {}", auth_url);
131            println!();
132        }
133
134        println!("Waiting for authorization...");
135
136        // Wait for callback
137        let code = Self::wait_for_callback(listener, csrf_token)?;
138
139        println!("Authorization received, exchanging for token...");
140
141        // Exchange code for token
142        let token_response = client
143            .exchange_code(code)
144            .set_pkce_verifier(pkce_verifier)
145            .request_async(&async_http_client)
146            .await
147            .context("Failed to exchange authorization code for token")?;
148
149        let access_token = token_response.access_token().secret().to_string();
150        let refresh_token = token_response
151            .refresh_token()
152            .map(|t| t.secret().to_string());
153        let expires_at = token_response
154            .expires_in()
155            .map(|d| chrono::Utc::now().timestamp() + d.as_secs() as i64);
156
157        let credential = Credential::OAuth {
158            access_token,
159            refresh_token,
160            expires_at,
161        };
162
163        // Store credentials
164        auth_manager.store_credentials(&credential)?;
165
166        println!("\nāœ… Successfully authenticated via OAuth");
167
168        Ok(credential)
169    }
170
171    /// Wait for the OAuth callback and extract the authorization code
172    fn wait_for_callback(
173        listener: TcpListener,
174        expected_csrf: CsrfToken,
175    ) -> Result<AuthorizationCode> {
176        for stream in listener.incoming() {
177            let mut stream = match stream {
178                Ok(s) => s,
179                Err(_) => continue,
180            };
181
182            let mut reader = BufReader::new(&stream);
183            let mut request_line = String::new();
184            if reader.read_line(&mut request_line).is_err() {
185                continue;
186            }
187
188            // Parse the request URL
189            let Some(redirect_url) = request_line.split_whitespace().nth(1) else {
190                continue;
191            };
192
193            let Ok(url) = url::Url::parse(&format!("http://localhost{}", redirect_url)) else {
194                continue;
195            };
196
197            let mut code = None;
198            let mut state = None;
199
200            for (key, value) in url.query_pairs() {
201                match key.as_ref() {
202                    "code" => code = Some(AuthorizationCode::new(value.to_string())),
203                    "state" => state = Some(CsrfToken::new(value.to_string())),
204                    _ => {}
205                }
206            }
207
208            // Verify CSRF token
209            if let Some(ref state) = state {
210                if state.secret() != expected_csrf.secret() {
211                    let response = "HTTP/1.1 400 Bad Request\r\n\r\nCSRF token mismatch";
212                    let _ = stream.write_all(response.as_bytes());
213                    continue;
214                }
215            }
216
217            // Send success response
218            let response = r#"HTTP/1.1 200 OK
219Content-Type: text/html
220
221<!DOCTYPE html>
222<html>
223<head><title>Bitbucket CLI</title></head>
224<body style="font-family: system-ui; text-align: center; padding: 50px;">
225<h1>āœ… Authentication Successful</h1>
226<p>You can close this window and return to the terminal.</p>
227</body>
228</html>"#;
229            let _ = stream.write_all(response.as_bytes());
230
231            if let Some(code) = code {
232                return Ok(code);
233            }
234        }
235
236        anyhow::bail!("Callback server closed unexpectedly")
237    }
238
239    /// Refresh an expired OAuth token
240    pub async fn refresh_token(
241        &self,
242        auth_manager: &AuthManager,
243        refresh_token: &str,
244    ) -> Result<Credential> {
245        let client = BasicClient::new(ClientId::new(self.client_id.clone()))
246            .set_client_secret(ClientSecret::new(self.client_secret.clone()))
247            .set_auth_uri(AuthUrl::new(BITBUCKET_AUTH_URL.to_string())?)
248            .set_token_uri(TokenUrl::new(BITBUCKET_TOKEN_URL.to_string())?);
249
250        let token_response = client
251            .exchange_refresh_token(&RefreshToken::new(refresh_token.to_string()))
252            .request_async(&async_http_client)
253            .await
254            .context("Failed to refresh token")?;
255
256        let access_token = token_response.access_token().secret().to_string();
257        let new_refresh_token = token_response
258            .refresh_token()
259            .map(|t| t.secret().to_string())
260            .unwrap_or_else(|| refresh_token.to_string());
261        let expires_at = token_response
262            .expires_in()
263            .map(|d| chrono::Utc::now().timestamp() + d.as_secs() as i64);
264
265        let credential = Credential::OAuth {
266            access_token,
267            refresh_token: Some(new_refresh_token),
268            expires_at,
269        };
270
271        auth_manager.store_credentials(&credential)?;
272
273        Ok(credential)
274    }
275}