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 pub async fn authenticate(&self, auth_manager: &AuthManager) -> Result<Credential> {
64 println!("\nš Bitbucket OAuth Authentication");
65 println!("āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā");
66 println!();
67
68 let listener =
70 TcpListener::bind("127.0.0.1:0").context("Failed to bind callback server")?;
71 let port = listener.local_addr()?.port();
72 let redirect_url = format!("http://127.0.0.1:{}/callback", port);
73
74 let client = BasicClient::new(ClientId::new(self.client_id.clone()))
76 .set_client_secret(ClientSecret::new(self.client_secret.clone()))
77 .set_auth_uri(AuthUrl::new(BITBUCKET_AUTH_URL.to_string())?)
78 .set_token_uri(TokenUrl::new(BITBUCKET_TOKEN_URL.to_string())?)
79 .set_redirect_uri(RedirectUrl::new(redirect_url.clone())?);
80
81 let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();
83
84 let (auth_url, csrf_token) = client
86 .authorize_url(|| CsrfToken::new_random())
87 .add_scope(Scope::new("repository".to_string()))
88 .add_scope(Scope::new("pullrequest".to_string()))
89 .add_scope(Scope::new("issue".to_string()))
90 .add_scope(Scope::new("pipeline".to_string()))
91 .add_scope(Scope::new("account".to_string()))
92 .set_pkce_challenge(pkce_challenge)
93 .url();
94
95 println!("Opening browser for authentication...");
96 println!();
97
98 if open::that(auth_url.as_str()).is_err() {
100 println!("Could not open browser automatically.");
101 println!("Please open this URL in your browser:");
102 println!();
103 println!(" {}", auth_url);
104 println!();
105 }
106
107 println!("Waiting for authorization...");
108
109 let code = Self::wait_for_callback(listener, csrf_token)?;
111
112 println!("Authorization received, exchanging for token...");
113
114 let token_response = client
116 .exchange_code(code)
117 .set_pkce_verifier(pkce_verifier)
118 .request_async(&async_http_client)
119 .await
120 .context("Failed to exchange authorization code for token")?;
121
122 let access_token = token_response.access_token().secret().to_string();
123 let refresh_token = token_response
124 .refresh_token()
125 .map(|t| t.secret().to_string());
126 let expires_at = token_response
127 .expires_in()
128 .map(|d| chrono::Utc::now().timestamp() + d.as_secs() as i64);
129
130 let credential = Credential::OAuth {
131 access_token,
132 refresh_token,
133 expires_at,
134 };
135
136 auth_manager.store_credentials(&credential)?;
138
139 println!("\nā
Successfully authenticated via OAuth");
140
141 Ok(credential)
142 }
143
144 fn wait_for_callback(
146 listener: TcpListener,
147 expected_csrf: CsrfToken,
148 ) -> Result<AuthorizationCode> {
149 for stream in listener.incoming() {
150 let mut stream = match stream {
151 Ok(s) => s,
152 Err(_) => continue,
153 };
154
155 let mut reader = BufReader::new(&stream);
156 let mut request_line = String::new();
157 if reader.read_line(&mut request_line).is_err() {
158 continue;
159 }
160
161 let Some(redirect_url) = request_line.split_whitespace().nth(1) else {
163 continue;
164 };
165
166 let Ok(url) = url::Url::parse(&format!("http://localhost{}", redirect_url)) else {
167 continue;
168 };
169
170 let mut code = None;
171 let mut state = None;
172
173 for (key, value) in url.query_pairs() {
174 match key.as_ref() {
175 "code" => code = Some(AuthorizationCode::new(value.to_string())),
176 "state" => state = Some(CsrfToken::new(value.to_string())),
177 _ => {}
178 }
179 }
180
181 if let Some(ref state) = state {
183 if state.secret() != expected_csrf.secret() {
184 let response = "HTTP/1.1 400 Bad Request\r\n\r\nCSRF token mismatch";
185 let _ = stream.write_all(response.as_bytes());
186 continue;
187 }
188 }
189
190 let response = r#"HTTP/1.1 200 OK
192Content-Type: text/html
193
194<!DOCTYPE html>
195<html>
196<head><title>Bitbucket CLI</title></head>
197<body style="font-family: system-ui; text-align: center; padding: 50px;">
198<h1>ā
Authentication Successful</h1>
199<p>You can close this window and return to the terminal.</p>
200</body>
201</html>"#;
202 let _ = stream.write_all(response.as_bytes());
203
204 if let Some(code) = code {
205 return Ok(code);
206 }
207 }
208
209 anyhow::bail!("Callback server closed unexpectedly")
210 }
211
212 pub async fn refresh_token(
214 &self,
215 auth_manager: &AuthManager,
216 refresh_token: &str,
217 ) -> Result<Credential> {
218 let client = BasicClient::new(ClientId::new(self.client_id.clone()))
219 .set_client_secret(ClientSecret::new(self.client_secret.clone()))
220 .set_auth_uri(AuthUrl::new(BITBUCKET_AUTH_URL.to_string())?)
221 .set_token_uri(TokenUrl::new(BITBUCKET_TOKEN_URL.to_string())?);
222
223
224 let token_response = client
225 .exchange_refresh_token(&RefreshToken::new(refresh_token.to_string()))
226 .request_async(&async_http_client)
227 .await
228 .context("Failed to refresh token")?;
229
230 let access_token = token_response.access_token().secret().to_string();
231 let new_refresh_token = token_response
232 .refresh_token()
233 .map(|t| t.secret().to_string())
234 .unwrap_or_else(|| refresh_token.to_string());
235 let expires_at = token_response
236 .expires_in()
237 .map(|d| chrono::Utc::now().timestamp() + d.as_secs() as i64);
238
239 let credential = Credential::OAuth {
240 access_token,
241 refresh_token: Some(new_refresh_token),
242 expires_at,
243 };
244
245 auth_manager.store_credentials(&credential)?;
246
247 Ok(credential)
248 }
249}