1use std::time::Duration;
41
42use base64::Engine;
43use base64::engine::general_purpose::URL_SAFE_NO_PAD;
44use rand::distr::Alphanumeric;
45use rand::{Rng, RngExt, rng};
46use serde::Deserialize;
47use sha2::{Digest, Sha256};
48use tokio::io::{AsyncReadExt, AsyncWriteExt};
49use tokio::net::TcpListener;
50
51use crate::auth::tokens::TokenSet;
52use crate::error::ClientError;
53
54pub struct AuthSuccess {
56 pub tokens: TokenSet,
57 pub id_token: Option<String>,
60}
61
62pub async fn run<F: FnOnce(&str)>(
68 http: &reqwest::Client,
69 authority_base: &str,
70 client_id: &str,
71 scope: &str,
72 on_open: F,
73) -> Result<AuthSuccess, ClientError> {
74 let listener = TcpListener::bind("127.0.0.1:0")
76 .await
77 .map_err(ClientError::Io)?;
78 let port = listener.local_addr().map_err(ClientError::Io)?.port();
79 let redirect_uri = format!("http://localhost:{port}");
80
81 let verifier = make_code_verifier();
82 let challenge = make_code_challenge(&verifier);
83 let state = make_random(32);
84
85 let authorize_url = build_authorize_url(
86 authority_base,
87 client_id,
88 &redirect_uri,
89 scope,
90 &challenge,
91 &state,
92 );
93 on_open(&authorize_url);
94
95 let CallbackParams {
96 code,
97 state: returned_state,
98 } = wait_for_callback(listener, Duration::from_secs(300), "Microsoft sign-in").await?;
99 if returned_state != state {
100 return Err(ClientError::Graph {
101 status: 400,
102 message: "OAuth state mismatch: possible CSRF or stale request".to_string(),
103 });
104 }
105
106 exchange_code_to_success(
107 http,
108 authority_base,
109 client_id,
110 &code,
111 &verifier,
112 &redirect_uri,
113 )
114 .await
115}
116
117pub(crate) async fn exchange_code_to_success(
120 http: &reqwest::Client,
121 authority_base: &str,
122 client_id: &str,
123 code: &str,
124 code_verifier: &str,
125 redirect_uri: &str,
126) -> Result<AuthSuccess, ClientError> {
127 let tokens_response = exchange_code(
128 http,
129 authority_base,
130 client_id,
131 code,
132 code_verifier,
133 redirect_uri,
134 )
135 .await?;
136
137 Ok(AuthSuccess {
138 tokens: TokenSet {
139 access_token: tokens_response.access_token,
140 refresh_token: tokens_response.refresh_token.unwrap_or_default(),
141 expires_at: chrono::Utc::now()
142 + chrono::Duration::seconds(tokens_response.expires_in.unwrap_or(3600)),
143 scope: tokens_response.scope,
144 },
145 id_token: tokens_response.id_token,
146 })
147}
148
149pub(crate) fn build_authorize_url(
150 authority_base: &str,
151 client_id: &str,
152 redirect_uri: &str,
153 scope: &str,
154 challenge: &str,
155 state: &str,
156) -> String {
157 build_authorize_url_with_hint(
158 authority_base,
159 client_id,
160 redirect_uri,
161 scope,
162 challenge,
163 state,
164 None,
165 )
166}
167
168pub(crate) fn build_authorize_url_with_hint(
172 authority_base: &str,
173 client_id: &str,
174 redirect_uri: &str,
175 scope: &str,
176 challenge: &str,
177 state: &str,
178 login_hint: Option<&str>,
179) -> String {
180 let mut url = url::Url::parse(&format!("{authority_base}/oauth2/v2.0/authorize"))
181 .expect("authority_base is a valid URL");
182 {
183 let mut q = url.query_pairs_mut();
184 q.append_pair("client_id", client_id)
185 .append_pair("response_type", "code")
186 .append_pair("redirect_uri", redirect_uri)
187 .append_pair("response_mode", "query")
188 .append_pair("scope", scope)
189 .append_pair("state", state)
190 .append_pair("code_challenge", challenge)
191 .append_pair("code_challenge_method", "S256")
192 .append_pair("prompt", "select_account");
197 if let Some(hint) = login_hint.map(str::trim).filter(|h| !h.is_empty()) {
198 q.append_pair("login_hint", hint);
199 }
200 }
201 url.into()
202}
203
204pub(crate) struct CallbackParams {
205 pub(crate) code: String,
206 pub(crate) state: String,
207}
208
209fn timeout_label(timeout: Duration) -> String {
226 let secs = timeout.as_secs();
227 if secs >= 60 && secs.is_multiple_of(60) {
228 format!("{} min", secs / 60)
229 } else {
230 format!("{secs}s")
231 }
232}
233
234pub(crate) async fn wait_for_callback(
235 listener: TcpListener,
236 timeout: Duration,
237 label: &str,
238) -> Result<CallbackParams, ClientError> {
239 let deadline = tokio::time::Instant::now() + timeout;
240 let timed_out = || ClientError::Graph {
241 status: 408,
242 message: format!(
243 "timed out waiting for browser sign-in ({})",
244 timeout_label(timeout)
245 ),
246 };
247
248 let (mut stream, _) = tokio::time::timeout_at(deadline, listener.accept())
249 .await
250 .map_err(|_| timed_out())?
251 .map_err(ClientError::Io)?;
252
253 let mut buf = [0u8; 2048];
255 let n = tokio::time::timeout_at(deadline, stream.read(&mut buf))
256 .await
257 .map_err(|_| timed_out())?
258 .map_err(ClientError::Io)?;
259 let request = std::str::from_utf8(&buf[..n]).unwrap_or("");
260 let first_line = request.lines().next().unwrap_or("");
261 let path_and_query =
262 first_line
263 .split_whitespace()
264 .nth(1)
265 .ok_or_else(|| ClientError::Graph {
266 status: 400,
267 message: "malformed browser callback request".to_string(),
268 })?;
269
270 let query_start = path_and_query.find('?').unwrap_or(path_and_query.len());
272 let query = &path_and_query[query_start.saturating_add(1)..];
273 let pairs: Vec<(String, String)> = url::form_urlencoded::parse(query.as_bytes())
274 .map(|(k, v)| (k.into_owned(), v.into_owned()))
275 .collect();
276
277 let mut code: Option<String> = None;
278 let mut state: Option<String> = None;
279 let mut err: Option<String> = None;
280 let mut err_description: Option<String> = None;
281 for (k, v) in pairs {
282 match k.as_str() {
283 "code" => code = Some(v),
284 "state" => state = Some(v),
285 "error" => err = Some(v),
286 "error_description" => err_description = Some(v),
287 _ => {}
288 }
289 }
290
291 if let Some(e) = err {
292 let detail = err_description.unwrap_or_default();
293 write_html(&mut stream, &error_page_html(&e, &detail), "Sign-in failed")
294 .await
295 .ok();
296 return Err(ClientError::Graph {
297 status: 400,
298 message: format!("{label}: {e} ({detail})"),
299 });
300 }
301
302 let code = code.ok_or_else(|| ClientError::Graph {
303 status: 400,
304 message: "browser callback missing `code` parameter".to_string(),
305 })?;
306 let state = state.unwrap_or_default();
307
308 write_html(&mut stream, SUCCESS_HTML, "Signed in")
309 .await
310 .ok();
311
312 Ok(CallbackParams { code, state })
313}
314
315async fn write_html(
316 stream: &mut tokio::net::TcpStream,
317 body: &str,
318 title: &str,
319) -> std::io::Result<()> {
320 let body_bytes = body.as_bytes();
321 let response = format!(
322 "HTTP/1.1 200 OK\r\n\
323 Content-Type: text/html; charset=utf-8\r\n\
324 Content-Length: {}\r\n\
325 Connection: close\r\n\
326 X-Title: {}\r\n\
327 \r\n",
328 body_bytes.len(),
329 title,
330 );
331 stream.write_all(response.as_bytes()).await?;
332 stream.write_all(body_bytes).await?;
333 stream.shutdown().await
334}
335
336const SUCCESS_HTML: &str = r#"<!doctype html><html><head><meta charset="utf-8"><title>Signed in</title>
337<style>
338body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
339 max-width: 480px; margin: 80px auto; text-align: center; color: #1d1d1f; }
340.check { font-size: 48px; color: #34c759; }
341h1 { font-size: 24px; margin: 16px 0 8px; }
342p { color: #6e6e73; }
343</style></head>
344<body>
345 <div class="check">✓</div>
346 <h1>Signed in to pidge</h1>
347 <p>You can close this window and return to the terminal.</p>
348</body></html>"#;
349
350fn error_page_html(err: &str, description: &str) -> String {
351 let err = html_escape(err);
352 let description = html_escape(description);
353 format!(
354 r#"<!doctype html><html><head><meta charset="utf-8"><title>Sign-in failed</title>
355<style>
356body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
357 max-width: 540px; margin: 80px auto; color: #1d1d1f; }}
358.x {{ font-size: 48px; color: #ff3b30; text-align: center; }}
359h1 {{ font-size: 22px; margin: 16px 0 8px; text-align: center; }}
360.detail {{ background: #f5f5f7; padding: 16px; border-radius: 8px; font-family: ui-monospace, monospace;
361 font-size: 13px; white-space: pre-wrap; word-break: break-word; }}
362</style></head>
363<body>
364 <div class="x">✕</div>
365 <h1>Sign-in failed</h1>
366 <p class="detail"><strong>{err}</strong>
367{description}</p>
368 <p>You can close this window. Return to the terminal for next steps.</p>
369</body></html>"#
370 )
371}
372
373fn html_escape(s: &str) -> String {
378 s.chars()
379 .fold(String::with_capacity(s.len()), |mut acc, c| {
380 match c {
381 '&' => acc.push_str("&"),
382 '<' => acc.push_str("<"),
383 '>' => acc.push_str(">"),
384 '"' => acc.push_str("""),
385 '\'' => acc.push_str("'"),
386 _ => acc.push(c),
387 }
388 acc
389 })
390}
391
392pub(crate) fn make_code_verifier() -> String {
399 let mut rng = rng();
400 (0..64).map(|_| rng.sample(Alphanumeric) as char).collect()
401}
402
403pub(crate) fn make_code_challenge(verifier: &str) -> String {
406 let mut hasher = Sha256::new();
407 hasher.update(verifier.as_bytes());
408 URL_SAFE_NO_PAD.encode(hasher.finalize())
409}
410
411pub(crate) fn make_random(byte_len: usize) -> String {
413 let mut buf = vec![0u8; byte_len];
414 rng().fill_bytes(&mut buf);
415 URL_SAFE_NO_PAD.encode(&buf)
416}
417
418#[derive(Debug, Deserialize)]
421struct TokenResponse {
422 access_token: String,
423 refresh_token: Option<String>,
424 expires_in: Option<i64>,
425 id_token: Option<String>,
426 #[serde(default)]
427 scope: Option<String>,
428}
429
430async fn exchange_code(
431 http: &reqwest::Client,
432 authority_base: &str,
433 client_id: &str,
434 code: &str,
435 code_verifier: &str,
436 redirect_uri: &str,
437) -> Result<TokenResponse, ClientError> {
438 let url = format!("{authority_base}/oauth2/v2.0/token");
439 let params = [
440 ("client_id", client_id),
441 ("grant_type", "authorization_code"),
442 ("code", code),
443 ("code_verifier", code_verifier),
444 ("redirect_uri", redirect_uri),
445 ];
446 let resp = http.post(&url).form(¶ms).send().await?;
447 let status = resp.status();
448 if !status.is_success() {
449 let text = resp.text().await.unwrap_or_default();
450 return Err(ClientError::Graph {
451 status: status.as_u16(),
452 message: text,
453 });
454 }
455 Ok(resp.json().await?)
456}
457
458#[cfg(test)]
459mod tests {
460 use super::*;
461
462 #[test]
463 fn timeout_label_uses_minutes_when_whole() {
464 assert_eq!(timeout_label(Duration::from_secs(300)), "5 min");
465 assert_eq!(timeout_label(Duration::from_secs(600)), "10 min");
466 assert_eq!(timeout_label(Duration::from_secs(45)), "45s");
467 }
468
469 #[test]
470 fn code_verifier_is_64_alphanumerics() {
471 let v = make_code_verifier();
472 assert_eq!(v.len(), 64);
473 assert!(v.chars().all(|c| c.is_ascii_alphanumeric()));
474 }
475
476 #[test]
477 fn challenge_matches_rfc_7636_example() {
478 let verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
482 assert_eq!(
483 make_code_challenge(verifier),
484 "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
485 );
486 }
487
488 #[test]
489 fn authorize_url_contains_required_params() {
490 let url = build_authorize_url(
491 "https://login.microsoftonline.com/common",
492 "client-id-here",
493 "http://localhost:47821",
494 "User.Read offline_access",
495 "challenge-here",
496 "state-here",
497 );
498 assert!(url.contains("client_id=client-id-here"));
499 assert!(url.contains("response_type=code"));
500 assert!(url.contains("code_challenge=challenge-here"));
501 assert!(url.contains("code_challenge_method=S256"));
502 assert!(url.contains("state=state-here"));
503 assert!(url.contains("redirect_uri=http%3A%2F%2Flocalhost%3A47821"));
505 assert!(url.contains("prompt=select_account"));
506 assert!(!url.contains("login_hint"));
507 }
508
509 #[test]
510 fn authorize_url_carries_the_login_hint_when_given() {
511 let url = build_authorize_url_with_hint(
512 "https://login.microsoftonline.com/common",
513 "client-id-here",
514 "http://localhost:47821",
515 "scope-here",
516 "challenge-here",
517 "state-here",
518 Some("jane.doe@example.com"),
519 );
520 assert!(url.contains("login_hint=jane.doe%40example.com"), "{url}");
521 assert!(url.contains("prompt=select_account"));
522 let blank = build_authorize_url_with_hint(
523 "https://login.microsoftonline.com/common",
524 "c",
525 "http://localhost:1",
526 "s",
527 "ch",
528 "st",
529 Some(" "),
530 );
531 assert!(!blank.contains("login_hint"));
532 }
533
534 #[test]
535 fn random_state_is_unique_per_call() {
536 let a = make_random(32);
537 let b = make_random(32);
538 assert_ne!(a, b);
539 assert_eq!(URL_SAFE_NO_PAD.decode(&a).unwrap().len(), 32);
540 }
541
542 #[test]
543 fn error_page_html_escapes_html_special_characters() {
544 let page = error_page_html("<script>alert(1)</script>", "quote\" apostrophe' amp&");
545 assert!(!page.contains("<script>"));
546 assert!(page.contains("<script>alert(1)</script>"));
547 assert!(page.contains("quote" apostrophe' amp&"));
548 }
549}