Skip to main content

pidge_client/auth/
browser_flow.rs

1//! OAuth 2.0 authorization code + PKCE flow with a one-shot local HTTP server.
2//!
3//! Why this and not device-code:
4//!
5//! Device-code flow works perfectly for work/school M365 accounts, but personal
6//! Microsoft accounts (live.com / outlook.com / hotmail.com) consistently hit
7//! `invalid_request: response_type missing` errors deep inside Microsoft's MSA
8//! pipeline. After significant debugging, and trying multiple redirect-URI
9//! shapes (nativeclient, http://localhost, urn:ietf:wg:oauth:2.0:oob), the
10//! conclusion was that personal MSA's device-code support is fragile in ways
11//! that can't be papered over via app-registration tweaks.
12//!
13//! The auth-code + PKCE + local-server flow is what modern MSAL libraries use
14//! and what Microsoft itself recommends for desktop / CLI apps. Both account
15//! types route through the same `/oauth2/v2.0/authorize` endpoint and the
16//! same redirect-URI plumbing, so M365 and MSA behave identically.
17//!
18//! Flow:
19//!
20//! 1. Bind a `TcpListener` on `127.0.0.1:0`; the OS picks a free port.
21//! 2. Generate a 64-char random `code_verifier`, derive `code_challenge =
22//!    base64url(SHA256(code_verifier))`, and a random `state` for CSRF.
23//! 3. Open the user's browser to
24//!    `https://login.microsoftonline.com/common/oauth2/v2.0/authorize?
25//!     client_id=…&response_type=code&redirect_uri=http://localhost:{port}&
26//!     scope=…&code_challenge=…&code_challenge_method=S256&state=…`
27//! 4. The user signs in; Microsoft redirects the browser back to
28//!    `http://localhost:{port}/?code=…&state=…`.
29//! 5. Our local listener accepts exactly one connection, reads the first
30//!    request line, extracts the query params, writes a friendly HTML
31//!    response, and closes.
32//! 6. We POST `/oauth2/v2.0/token` with the auth code, code_verifier, and
33//!    redirect_uri to exchange for an access + refresh token.
34//!
35//! The local server is single-shot: one connection, one response, then close.
36//! No port collisions because we let the OS pick; no listening process left
37//! behind; no firewall surprises because nothing outside the loopback
38//! interface can reach it.
39
40use 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
54/// The result of a successful sign-in.
55pub struct AuthSuccess {
56    pub tokens: TokenSet,
57    /// The raw `id_token`, if Microsoft returned one. Caller uses this to
58    /// extract the tenant ID via the existing `jwt::extract_tenant_id`.
59    pub id_token: Option<String>,
60}
61
62/// Run the full browser flow to completion. Returns the access+refresh
63/// tokens and (optionally) the id_token for tenant extraction.
64///
65/// `on_open` is called once we know the authorize URL; the caller is
66/// expected to print it to the user and best-effort spawn their browser.
67pub 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    // Bind first so we know which port to embed in the redirect URI.
75    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
117/// Redeem an authorization code and shape the response as an [`AuthSuccess`].
118/// Shared by the local browser flow and hosted callbacks.
119pub(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
168/// [`build_authorize_url`] with an optional `login_hint`: the address
169/// Microsoft should preselect in its account picker, for flows where the
170/// caller already knows which mailbox the user is expected to sign in with.
171pub(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            // `prompt=select_account` forces Microsoft to show the account picker
193            // even if the user is already signed in to *some* account. This is
194            // what stops "browser is already signed in to my M365 account so
195            // pidge auto-grabs that one when I wanted my live.com account".
196            .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
209/// Accept a single connection on the listener, parse the request line for
210/// query parameters, write a success/error response, close. Single-shot.
211///
212/// `timeout` bounds how long we'll wait for the browser to hit the callback,
213/// covering both the initial connection *and* the request read that follows,
214/// so a client that opens the socket and never sends a request line still
215/// hits the deadline rather than hanging forever. The Microsoft flow uses 5
216/// minutes (matching Microsoft's own OAuth code TTL); the MCP sign-in flow
217/// (see `crate::mcp::oauth`) uses a longer window since it's a separate,
218/// often manually-triggered, sign-in step.
219///
220/// `label` identifies who this callback is for (e.g. `"Microsoft sign-in"`
221/// or `"MCP sign-in"`); it prefixes the error message when the remote party
222/// reports `error`/`error_description`, so an MCP-server error isn't
223/// misattributed to Microsoft.
224/// A timeout for the user to read: whole minutes as "5 min", else seconds.
225fn 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    // We only need the first ~1KB to parse the request line "GET /?...".
254    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    // Parse "/?code=...&state=..." (or "/?error=...&error_description=...").
271    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
373/// Escape the five HTML-significant characters. `err` and `error_description`
374/// are fixed strings from pidge's own server, but the MCP flow (see
375/// `crate::mcp::oauth`) can point this callback at any server the user
376/// configures, so this page must not trust its input to be markup-free.
377fn 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("&amp;"),
382                '<' => acc.push_str("&lt;"),
383                '>' => acc.push_str("&gt;"),
384                '"' => acc.push_str("&quot;"),
385                '\'' => acc.push_str("&#39;"),
386                _ => acc.push(c),
387            }
388            acc
389        })
390}
391
392// --- PKCE helpers ----------------------------------------------------------
393
394/// RFC 7636 says the code_verifier is "a high-entropy cryptographic random
395/// STRING, using the unreserved characters … with a minimum length of 43
396/// characters and a maximum length of 128 characters." 64 alphanumerics is
397/// comfortably inside the spec and gives ~380 bits of entropy.
398pub(crate) fn make_code_verifier() -> String {
399    let mut rng = rng();
400    (0..64).map(|_| rng.sample(Alphanumeric) as char).collect()
401}
402
403/// `base64url(SHA256(code_verifier))` per RFC 7636 §4.2. URL_SAFE_NO_PAD is
404/// the exact encoding the OAuth spec requires.
405pub(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
411/// 32 bytes of OS random → URL-safe base64. Used for the `state` CSRF nonce.
412pub(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// --- token exchange --------------------------------------------------------
419
420#[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(&params).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        // RFC 7636 §4 example:
479        //   verifier  = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
480        //   challenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
481        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        // redirect_uri is percent-encoded inside the query string.
504        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("&lt;script&gt;alert(1)&lt;/script&gt;"));
547        assert!(page.contains("quote&quot; apostrophe&#39; amp&amp;"));
548    }
549}