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