bitbucket_cli/auth/
oauth.rs1use 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
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().status(status_code);
35
36 for (name, value) in headers.iter() {
37 builder = builder.header(name, value);
38 }
39
40 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
47pub 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 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 pub async fn authenticate(&self, auth_manager: &AuthManager) -> Result<Credential> {
83 println!("\nš Bitbucket OAuth Authentication");
84 println!("āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā");
85 println!();
86
87 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 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 let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();
110
111 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 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 let code = Self::wait_for_callback(listener, csrf_token)?;
138
139 println!("Authorization received, exchanging for token...");
140
141 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 client_id: Some(self.client_id.clone()),
162 client_secret: Some(self.client_secret.clone()),
163 };
164
165 auth_manager.store_credentials(&credential)?;
167
168 println!("\nā
Successfully authenticated via OAuth");
169
170 Ok(credential)
171 }
172
173 fn wait_for_callback(
175 listener: TcpListener,
176 expected_csrf: CsrfToken,
177 ) -> Result<AuthorizationCode> {
178 for stream in listener.incoming() {
179 let mut stream = match stream {
180 Ok(s) => s,
181 Err(_) => continue,
182 };
183
184 let mut reader = BufReader::new(&stream);
185 let mut request_line = String::new();
186 if reader.read_line(&mut request_line).is_err() {
187 continue;
188 }
189
190 let Some(redirect_url) = request_line.split_whitespace().nth(1) else {
192 continue;
193 };
194
195 let Ok(url) = url::Url::parse(&format!("http://localhost{}", redirect_url)) else {
196 continue;
197 };
198
199 let mut code = None;
200 let mut state = None;
201
202 for (key, value) in url.query_pairs() {
203 match key.as_ref() {
204 "code" => code = Some(AuthorizationCode::new(value.to_string())),
205 "state" => state = Some(CsrfToken::new(value.to_string())),
206 _ => {}
207 }
208 }
209
210 if let Some(ref state) = state {
212 if state.secret() != expected_csrf.secret() {
213 let response = "HTTP/1.1 400 Bad Request\r\n\r\nCSRF token mismatch";
214 let _ = stream.write_all(response.as_bytes());
215 continue;
216 }
217 }
218
219 let response = r#"HTTP/1.1 200 OK
221Content-Type: text/html
222
223<!DOCTYPE html>
224<html>
225<head><title>Bitbucket CLI</title></head>
226<body style="font-family: system-ui; text-align: center; padding: 50px;">
227<h1>ā
Authentication Successful</h1>
228<p>You can close this window and return to the terminal.</p>
229</body>
230</html>"#;
231 let _ = stream.write_all(response.as_bytes());
232
233 if let Some(code) = code {
234 return Ok(code);
235 }
236 }
237
238 anyhow::bail!("Callback server closed unexpectedly")
239 }
240
241 pub async fn refresh_token(
243 &self,
244 auth_manager: &AuthManager,
245 refresh_token: &str,
246 ) -> Result<Credential> {
247 let client = BasicClient::new(ClientId::new(self.client_id.clone()))
248 .set_client_secret(ClientSecret::new(self.client_secret.clone()))
249 .set_auth_uri(AuthUrl::new(BITBUCKET_AUTH_URL.to_string())?)
250 .set_token_uri(TokenUrl::new(BITBUCKET_TOKEN_URL.to_string())?);
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 client_id: Some(self.client_id.clone()),
272 client_secret: Some(self.client_secret.clone()),
273 };
274
275 auth_manager.store_credentials(&credential)?;
276
277 Ok(credential)
278 }
279}