tuitbot_core/x_api/auth/
oauth.rs1use std::io::Write;
4
5use oauth2::basic::BasicClient;
6use oauth2::{
7 AuthUrl, AuthorizationCode, ClientId, CsrfToken, PkceCodeChallenge, RedirectUrl, Scope,
8 TokenResponse, TokenUrl,
9};
10
11use crate::error::XApiError;
12use crate::x_api::scopes::REQUIRED_SCOPES;
13
14use super::{Tokens, AUTH_URL, TOKEN_URL};
15
16pub(crate) fn build_oauth_client(
18 client_id: &str,
19 redirect_uri: &str,
20) -> Result<BasicClient, XApiError> {
21 let auth_url = AuthUrl::new(AUTH_URL.to_string()).map_err(|e| XApiError::ApiError {
22 status: 0,
23 message: format!("Invalid auth URL: {e}"),
24 })?;
25
26 let token_url = TokenUrl::new(TOKEN_URL.to_string()).map_err(|e| XApiError::ApiError {
27 status: 0,
28 message: format!("Invalid token URL: {e}"),
29 })?;
30
31 let redirect = RedirectUrl::new(redirect_uri.to_string()).map_err(|e| XApiError::ApiError {
32 status: 0,
33 message: format!("Invalid redirect URI: {e}"),
34 })?;
35
36 let client = BasicClient::new(
37 ClientId::new(client_id.to_string()),
38 None,
39 auth_url,
40 Some(token_url),
41 )
42 .set_redirect_uri(redirect);
43
44 Ok(client)
45}
46
47pub async fn authenticate_manual(client_id: &str) -> Result<Tokens, XApiError> {
52 let redirect_uri = "http://localhost/callback";
53 let client = build_oauth_client(client_id, redirect_uri)?;
54
55 let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();
56
57 let mut auth_builder = client
58 .authorize_url(CsrfToken::new_random)
59 .set_pkce_challenge(pkce_challenge);
60 for scope in REQUIRED_SCOPES {
61 auth_builder = auth_builder.add_scope(Scope::new(scope.to_string()));
62 }
63 let (auth_url, csrf_state) = auth_builder.url();
64
65 println!("\n=== X API Authentication (Manual Mode) ===\n");
66 println!("1. Open this URL in your browser:\n");
67 println!(" {auth_url}\n");
68 println!("2. Authorize the application");
69 println!("3. Copy the authorization code from the callback URL");
70 println!(" (Look for ?code=XXXXX in the URL)\n");
71
72 let _ = csrf_state; print!("Paste the authorization code: ");
75 std::io::stdout().flush().map_err(|e| XApiError::ApiError {
76 status: 0,
77 message: format!("IO error: {e}"),
78 })?;
79
80 let mut code = String::new();
81 std::io::stdin()
82 .read_line(&mut code)
83 .map_err(|e| XApiError::ApiError {
84 status: 0,
85 message: format!("Failed to read input: {e}"),
86 })?;
87
88 let code = code.trim().to_string();
89 if code.is_empty() {
90 return Err(XApiError::ApiError {
91 status: 0,
92 message: "Authorization code cannot be empty".to_string(),
93 });
94 }
95
96 exchange_code(&client, &code, pkce_verifier).await
97}
98
99pub async fn authenticate_callback(
104 client_id: &str,
105 host: &str,
106 port: u16,
107) -> Result<Tokens, XApiError> {
108 let redirect_uri = format!("http://{host}:{port}/callback");
109 let client = build_oauth_client(client_id, &redirect_uri)?;
110
111 let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();
112
113 let mut auth_builder = client
114 .authorize_url(CsrfToken::new_random)
115 .set_pkce_challenge(pkce_challenge);
116 for scope in REQUIRED_SCOPES {
117 auth_builder = auth_builder.add_scope(Scope::new(scope.to_string()));
118 }
119 let (auth_url, csrf_state) = auth_builder.url();
120
121 let addr = format!("{host}:{port}");
123 let listener = tokio::net::TcpListener::bind(&addr)
124 .await
125 .map_err(|e| XApiError::ApiError {
126 status: 0,
127 message: format!(
128 "Failed to bind callback server on {addr}: {e}. Try changing auth.callback_port."
129 ),
130 })?;
131
132 tracing::info!("Callback server listening on {addr}");
133
134 let url_str = auth_url.to_string();
136 if let Err(e) = open::that(&url_str) {
137 tracing::warn!(error = %e, "Failed to open browser automatically");
138 println!("\nCould not open browser automatically.");
139 println!("Please open this URL manually:\n");
140 println!(" {url_str}\n");
141 } else {
142 println!("\nOpened authorization URL in your browser.");
143 println!("Waiting for callback...\n");
144 }
145
146 let callback_result = tokio::time::timeout(
148 std::time::Duration::from_secs(120),
149 accept_callback(&listener, csrf_state.secret()),
150 )
151 .await
152 .map_err(|_| XApiError::ApiError {
153 status: 0,
154 message: "Authentication timed out after 120 seconds".to_string(),
155 })??;
156
157 exchange_code(&client, &callback_result, pkce_verifier).await
158}
159
160async fn accept_callback(
162 listener: &tokio::net::TcpListener,
163 expected_state: &str,
164) -> Result<String, XApiError> {
165 let (mut stream, _addr) = listener.accept().await.map_err(|e| XApiError::ApiError {
166 status: 0,
167 message: format!("Failed to accept connection: {e}"),
168 })?;
169
170 use tokio::io::{AsyncReadExt, AsyncWriteExt};
171
172 let mut buf = vec![0u8; 4096];
173 let n = stream
174 .read(&mut buf)
175 .await
176 .map_err(|e| XApiError::ApiError {
177 status: 0,
178 message: format!("Failed to read request: {e}"),
179 })?;
180
181 let request = String::from_utf8_lossy(&buf[..n]);
182
183 let first_line = request.lines().next().unwrap_or("");
185 let path = first_line.split_whitespace().nth(1).unwrap_or("");
186
187 let query_start = path.find('?').map(|i| i + 1);
188 let query_string = query_start.map(|i| &path[i..]).unwrap_or("");
189
190 let mut code = None;
191 let mut state = None;
192
193 for param in query_string.split('&') {
194 if let Some((key, value)) = param.split_once('=') {
195 match key {
196 "code" => code = Some(value.to_string()),
197 "state" => state = Some(value.to_string()),
198 _ => {}
199 }
200 }
201 }
202
203 let received_state = state.ok_or_else(|| XApiError::ApiError {
205 status: 0,
206 message: "Missing OAuth state parameter in callback".to_string(),
207 })?;
208 if received_state != expected_state {
209 let error_html = "HTTP/1.1 400 Bad Request\r\nContent-Type: text/html\r\n\r\n\
210 <html><body><h1>Authentication Failed</h1>\
211 <p>State parameter mismatch. This may indicate a CSRF attack.</p>\
212 <p>Please try again.</p></body></html>";
213 let _ = stream.write_all(error_html.as_bytes()).await;
214 return Err(XApiError::ApiError {
215 status: 0,
216 message: "OAuth state parameter mismatch".to_string(),
217 });
218 }
219
220 let auth_code = code.ok_or_else(|| XApiError::ApiError {
221 status: 0,
222 message: "No authorization code in callback URL".to_string(),
223 })?;
224
225 let success_html = "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\n\r\n\
227 <html><body><h1>Authentication Successful!</h1>\
228 <p>You can close this tab and return to the terminal.</p></body></html>";
229 let _ = stream.write_all(success_html.as_bytes()).await;
230
231 Ok(auth_code)
232}
233
234async fn exchange_code(
236 client: &BasicClient,
237 code: &str,
238 pkce_verifier: oauth2::PkceCodeVerifier,
239) -> Result<Tokens, XApiError> {
240 let http_client = oauth2::reqwest::async_http_client;
241
242 let token_result = client
243 .exchange_code(AuthorizationCode::new(code.to_string()))
244 .set_pkce_verifier(pkce_verifier)
245 .request_async(http_client)
246 .await
247 .map_err(|e| XApiError::ApiError {
248 status: 0,
249 message: format!("Token exchange failed: {e}"),
250 })?;
251
252 let access_token = token_result.access_token().secret().to_string();
253 let refresh_token = token_result
254 .refresh_token()
255 .map(|rt| rt.secret().to_string())
256 .unwrap_or_default();
257
258 let expires_in = token_result
259 .expires_in()
260 .map(|d| d.as_secs() as i64)
261 .unwrap_or(7200);
262
263 let scopes: Vec<String> = token_result
264 .scopes()
265 .map(|s| s.iter().map(|scope| scope.to_string()).collect())
266 .unwrap_or_else(|| REQUIRED_SCOPES.iter().map(|s| s.to_string()).collect());
267
268 let tokens = Tokens {
269 access_token,
270 refresh_token,
271 expires_at: chrono::Utc::now() + chrono::Duration::seconds(expires_in),
272 scopes,
273 };
274
275 tracing::info!(
276 expires_at = %tokens.expires_at,
277 scopes = ?tokens.scopes,
278 "Authentication successful"
279 );
280
281 Ok(tokens)
282}