agentmux_srv\identity/
oauth_client.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Service-account OAuth 2.0 client for the Trust Center.
5//!
6//! Distinct from the CLI-provider OAuth in `auth_session.rs` (which scrapes a
7//! spawned CLI's stdout): this drives the flow itself — opens the system
8//! browser, runs a transient loopback listener (or polls a device endpoint),
9//! exchanges the code, and stores the resulting tokens in the OS keychain.
10//!
11//! Security posture follows RFC 8252 (OAuth for Native Apps), RFC 7636
12//! (PKCE, S256), RFC 8628 (Device Grant), and RFC 9700 (Security BCP):
13//!   - Authorization Code + PKCE(S256) over a loopback `127.0.0.1:<ephemeral>`
14//!     redirect for code-flow providers (Google, Microsoft).
15//!   - Device Authorization Grant for GitHub (no client secret needed).
16//!   - High-entropy `state` generated and verified on the callback (CSRF).
17//!   - Public clients carry no secret; secret-mandatory providers (Slack) use
18//!     BYO credentials (the user supplies their own client_id/secret).
19//!   - Tokens are stored in the OS keychain (never the DB, never logged).
20//!
21//! **Scaffold status:** the per-provider `client_id`s are not yet provisioned
22//! (`client_id: None` below). Until a client id is supplied — either baked
23//! into the catalog or passed as BYO — `start` returns a clear
24//! "not configured" error and no flow runs. Dropping in the ids (and wiring
25//! the frontend Connect button) activates this end to end. See
26//! specs/SPEC_TRUST_CENTER_2026_06_15.md §4.2/§12.1.
27
28use std::collections::HashMap;
29use std::sync::{Arc, Mutex, OnceLock};
30use std::time::{Duration, Instant};
31
32use base64::Engine as _;
33use base64::engine::general_purpose::URL_SAFE_NO_PAD;
34use sha2::{Digest, Sha256};
35
36/// How a provider's OAuth flow is driven.
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum OAuthFlow {
39    /// Authorization Code + PKCE over a loopback redirect (Google, Microsoft).
40    AuthCodePkce,
41    /// Device Authorization Grant — RFC 8628 (GitHub, headless).
42    Device,
43}
44
45/// Static per-service OAuth configuration. `client_id` is `None` in the
46/// shipped scaffold — supply it (or BYO) to activate the provider.
47#[derive(Debug, Clone)]
48pub struct ServiceOAuthConfig {
49    pub provider: &'static str,
50    pub flow: OAuthFlow,
51    /// Public client id. `None` ⇒ provider not configured (BYO required).
52    pub client_id: Option<&'static str>,
53    pub auth_url: &'static str,
54    pub token_url: &'static str,
55    /// Device authorization endpoint (Device flow only).
56    pub device_url: Option<&'static str>,
57    pub scopes: &'static [&'static str],
58    /// True for providers whose token exchange mandates a client_secret
59    /// (Slack). Such providers require BYO credentials on a desktop client.
60    pub requires_secret: bool,
61}
62
63/// Per-provider catalog. Endpoints + flow choice follow each provider's
64/// native-app guidance (see SPEC §12.1). `client_id` intentionally `None`
65/// until provisioned.
66pub fn config_for(provider: &str) -> Option<ServiceOAuthConfig> {
67    match provider {
68        "google" => Some(ServiceOAuthConfig {
69            provider: "google",
70            flow: OAuthFlow::AuthCodePkce,
71            client_id: None, // TODO: provision public client id
72            auth_url: "https://accounts.google.com/o/oauth2/v2/auth",
73            token_url: "https://oauth2.googleapis.com/token",
74            device_url: None,
75            scopes: &["openid", "email", "profile"],
76            requires_secret: false,
77        }),
78        "microsoft" => Some(ServiceOAuthConfig {
79            provider: "microsoft",
80            flow: OAuthFlow::AuthCodePkce,
81            client_id: None, // TODO
82            auth_url: "https://login.microsoftonline.com/common/oauth2/v2.0/authorize",
83            token_url: "https://login.microsoftonline.com/common/oauth2/v2.0/token",
84            device_url: None,
85            scopes: &["openid", "profile", "email", "offline_access", "User.Read"],
86            requires_secret: false,
87        }),
88        "github" => Some(ServiceOAuthConfig {
89            provider: "github",
90            flow: OAuthFlow::Device, // no client secret needed
91            client_id: None, // TODO
92            auth_url: "https://github.com/login/oauth/authorize",
93            token_url: "https://github.com/login/oauth/access_token",
94            device_url: Some("https://github.com/login/device/code"),
95            scopes: &["repo", "read:org", "user:email"],
96            requires_secret: false,
97        }),
98        "slack" => Some(ServiceOAuthConfig {
99            provider: "slack",
100            flow: OAuthFlow::AuthCodePkce,
101            client_id: None, // BYO only
102            auth_url: "https://slack.com/oauth/v2/authorize",
103            token_url: "https://slack.com/api/oauth.v2.access",
104            device_url: None,
105            scopes: &["users:read"],
106            requires_secret: true,
107        }),
108        _ => None,
109    }
110}
111
112/// Whether a provider has an OAuth flow at all (frontend gating).
113pub fn supports_oauth(provider: &str) -> bool {
114    config_for(provider).is_some()
115}
116
117// ── PKCE + state helpers (RFC 7636 §4, RFC 8252 §8.9) ───────────────────────
118
119/// A PKCE verifier/challenge pair. `challenge = BASE64URL(SHA256(verifier))`.
120pub struct PkcePair {
121    pub verifier: String,
122    pub challenge: String,
123}
124
125/// Generate a PKCE pair. The verifier is 256 bits of CSPRNG entropy (two v4
126/// UUIDs, as `uuid` draws from the OS RNG) base64url-encoded — well within the
127/// 43–128 char range, charset-safe. Challenge uses S256 (never `plain`).
128pub fn pkce_pair() -> PkcePair {
129    let a = uuid::Uuid::new_v4();
130    let b = uuid::Uuid::new_v4();
131    let mut raw = [0u8; 32];
132    raw[..16].copy_from_slice(a.as_bytes());
133    raw[16..].copy_from_slice(b.as_bytes());
134    let verifier = URL_SAFE_NO_PAD.encode(raw);
135
136    let mut hasher = Sha256::new();
137    hasher.update(verifier.as_bytes());
138    let challenge = URL_SAFE_NO_PAD.encode(hasher.finalize());
139
140    PkcePair { verifier, challenge }
141}
142
143/// High-entropy CSRF `state` value. Verified byte-for-byte on the callback.
144pub fn random_state() -> String {
145    uuid::Uuid::new_v4().to_string()
146}
147
148// ── Session manager ─────────────────────────────────────────────────────────
149
150const SESSION_TIMEOUT: Duration = Duration::from_secs(300);
151
152/// Terminal/transient status surfaced to the frontend poll.
153#[derive(Debug, Clone)]
154pub enum OAuthStatus {
155    /// Seeded the instant a session is created, before the spawned task has
156    /// emitted its first real status. Non-terminal so an early poll doesn't
157    /// see a terminal value and abort the flow.
158    Pending,
159    /// Code flow: browser opened to `auth_url`; awaiting the loopback callback.
160    UrlAvailable { auth_url: String },
161    /// Device flow: show `user_code` + `verification_uri`; polling in progress.
162    CodeEmitted { user_code: String, verification_uri: String },
163    /// Done — the account row + keychain token are persisted.
164    Success { account_id: String },
165    Failed { error: String },
166}
167
168impl OAuthStatus {
169    pub fn is_terminal(&self) -> bool {
170        matches!(self, OAuthStatus::Success { .. } | OAuthStatus::Failed { .. })
171    }
172}
173
174struct OAuthSession {
175    status: OAuthStatus,
176    started_at: Instant,
177}
178
179#[derive(Default)]
180pub struct OAuthSessionManager {
181    sessions: Mutex<HashMap<String, OAuthSession>>,
182}
183
184/// How long a session entry is retained before it's pruned. Generous margin
185/// over the 5-min flow timeout so a slow frontend poll still finds its result.
186const SESSION_RETENTION: Duration = Duration::from_secs(900);
187
188impl OAuthSessionManager {
189    fn new_session(&self, status: OAuthStatus) -> String {
190        let id = format!("oauth-{}", uuid::Uuid::new_v4());
191        let mut guard = self.sessions.lock().unwrap();
192        // Prune stale entries so the global map can't grow unbounded over the
193        // process lifetime (terminal + timed-out sessions are GC'd here).
194        guard.retain(|_, s| s.started_at.elapsed() < SESSION_RETENTION);
195        guard.insert(id.clone(), OAuthSession { status, started_at: Instant::now() });
196        id
197    }
198
199    fn set_status(&self, id: &str, status: OAuthStatus) {
200        if let Some(s) = self.sessions.lock().unwrap().get_mut(id) {
201            s.status = status;
202        }
203    }
204
205    /// Current status, sweeping the 5-minute timeout to `Failed`.
206    pub fn poll(&self, id: &str) -> Option<OAuthStatus> {
207        let mut guard = self.sessions.lock().unwrap();
208        let s = guard.get_mut(id)?;
209        if !s.status.is_terminal() && s.started_at.elapsed() > SESSION_TIMEOUT {
210            s.status = OAuthStatus::Failed { error: "authorization timed out".into() };
211        }
212        Some(s.status.clone())
213    }
214
215    pub fn cancel(&self, id: &str) -> bool {
216        let mut guard = self.sessions.lock().unwrap();
217        if let Some(s) = guard.get_mut(id) {
218            if !s.status.is_terminal() {
219                s.status = OAuthStatus::Failed { error: "cancelled".into() };
220            }
221            return true;
222        }
223        false
224    }
225}
226
227/// Process-global manager. A dedicated singleton (rather than an AppState
228/// field) keeps this scaffold's blast radius small; service-OAuth sessions are
229/// short-lived and window-agnostic.
230pub fn manager() -> &'static OAuthSessionManager {
231    static M: OnceLock<OAuthSessionManager> = OnceLock::new();
232    M.get_or_init(OAuthSessionManager::default)
233}
234
235/// BYO OAuth-app credentials, supplied by the user for secret-mandatory
236/// providers (Slack) or to override the built-in public client.
237#[derive(Debug, Clone)]
238pub struct ByoCredentials {
239    pub client_id: String,
240    pub client_secret: Option<String>,
241}
242
243/// Resolve the effective client id: BYO overrides the built-in. Returns an
244/// error string when neither is available (the "not configured" gate) or when
245/// a secret-mandatory provider is missing its BYO secret.
246pub fn resolve_client(
247    cfg: &ServiceOAuthConfig,
248    byo: Option<&ByoCredentials>,
249) -> Result<(String, Option<String>), String> {
250    let client_id = byo
251        .map(|b| b.client_id.clone())
252        .or_else(|| cfg.client_id.map(|s| s.to_string()))
253        .ok_or_else(|| {
254            format!(
255                "{} OAuth is not configured yet — no client id available. \
256                 Supply your own OAuth app credentials (BYO) to continue.",
257                cfg.provider
258            )
259        })?;
260    if cfg.requires_secret && byo.and_then(|b| b.client_secret.as_ref()).is_none() {
261        return Err(format!(
262            "{} requires a client secret — register your own OAuth app and \
263             supply its client id + secret (BYO).",
264            cfg.provider
265        ));
266    }
267    Ok((client_id, byo.and_then(|b| b.client_secret.clone())))
268}
269
270// ── Flow execution ──────────────────────────────────────────────────────────
271
272use crate::backend::storage::store::{IdentityAccount, SecretRef, Store};
273
274fn http() -> &'static reqwest::Client {
275    static C: OnceLock<reqwest::Client> = OnceLock::new();
276    C.get_or_init(|| {
277        reqwest::Client::builder()
278            .timeout(Duration::from_secs(20))
279            .user_agent("AgentMux")
280            .build()
281            .expect("reqwest client build failed")
282    })
283}
284
285fn open_browser(url: &str) {
286    #[cfg(target_os = "windows")]
287    {
288        let _ = std::process::Command::new("cmd").args(["/C", "start", "", url]).spawn();
289    }
290    #[cfg(target_os = "macos")]
291    {
292        let _ = std::process::Command::new("open").arg(url).spawn();
293    }
294    #[cfg(not(any(target_os = "windows", target_os = "macos")))]
295    {
296        let _ = std::process::Command::new("xdg-open").arg(url).spawn();
297    }
298}
299
300/// Tokens returned by an exchange. Stored as a keychain JSON blob — never the
301/// DB, never logged.
302fn token_blob(json: &serde_json::Value, now: i64) -> serde_json::Value {
303    let expires_in = json.get("expires_in").and_then(|v| v.as_i64()).unwrap_or(0);
304    serde_json::json!({
305        "access_token": json.get("access_token").and_then(|v| v.as_str()).unwrap_or(""),
306        "refresh_token": json.get("refresh_token").and_then(|v| v.as_str()),
307        "expires_at": if expires_in > 0 { now + expires_in } else { 0 },
308    })
309}
310
311/// Persist the OAuth account: token blob → keychain, pointer + non-secret
312/// context → DB. Returns the account id.
313fn persist_oauth_account(
314    wstore: &Arc<Store>,
315    provider: &str,
316    name: &str,
317    tokens: &serde_json::Value,
318) -> Result<String, String> {
319    let account_id = uuid::Uuid::new_v4().to_string();
320    let blob = serde_json::to_string(tokens).map_err(|e| e.to_string())?;
321    crate::identity::secret_store::put(&account_id, &blob)?;
322    let now = chrono::Utc::now().timestamp_millis();
323    let account = IdentityAccount {
324        id: account_id.clone(),
325        name: name.to_string(),
326        provider: provider.to_string(),
327        kind: "oauth".to_string(),
328        display_name: String::new(),
329        secret_ref: SecretRef::Keychain {
330            service: crate::identity::secret_store::SERVICE.to_string(),
331            account: crate::identity::secret_store::account_key(&account_id),
332        },
333        context: serde_json::json!({ "oauth": true }),
334        status: "valid".to_string(),
335        created_at: now,
336        updated_at: now,
337    };
338    if let Err(e) = wstore.identity_upsert(&account) {
339        let _ = crate::identity::secret_store::delete(&account_id);
340        return Err(format!("persist failed: {e}"));
341    }
342    Ok(account_id)
343}
344
345/// URL-encode a query component (RFC 3986 unreserved kept).
346fn enc(s: &str) -> String {
347    let mut out = String::with_capacity(s.len());
348    for b in s.bytes() {
349        match b {
350            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => out.push(b as char),
351            _ => out.push_str(&format!("%{b:02X}")),
352        }
353    }
354    out
355}
356
357/// Start an OAuth flow. Resolves config + client (gating on "not configured"),
358/// creates a session, and spawns the background task that drives it. Returns
359/// `(session_id, initial_status)`. The frontend then polls `manager().poll`.
360pub fn start(
361    provider: &str,
362    name: String,
363    byo: Option<ByoCredentials>,
364    wstore: Arc<Store>,
365) -> Result<(String, OAuthStatus), String> {
366    let cfg = config_for(provider).ok_or_else(|| format!("unknown OAuth provider: {provider}"))?;
367    let (client_id, client_secret) = resolve_client(&cfg, byo.as_ref())?;
368
369    match cfg.flow {
370        OAuthFlow::AuthCodePkce => start_code_flow(cfg, client_id, client_secret, name, wstore),
371        OAuthFlow::Device => start_device_flow(cfg, client_id, name, wstore),
372    }
373}
374
375fn start_code_flow(
376    cfg: ServiceOAuthConfig,
377    client_id: String,
378    client_secret: Option<String>,
379    name: String,
380    wstore: Arc<Store>,
381) -> Result<(String, OAuthStatus), String> {
382    use tokio::io::{AsyncReadExt, AsyncWriteExt};
383    use tokio::net::TcpListener;
384
385    let pkce = pkce_pair();
386    let state = random_state();
387    let scopes = cfg.scopes.join(" ");
388
389    let session_id = manager().new_session(OAuthStatus::Pending);
390    let sid = session_id.clone();
391
392    tokio::spawn(async move {
393        // Transient loopback listener on an OS-assigned ephemeral port. Use the
394        // 127.0.0.1 literal (not "localhost") per RFC 8252 §7.3.
395        let listener = match TcpListener::bind("127.0.0.1:0").await {
396            Ok(l) => l,
397            Err(e) => {
398                manager().set_status(&sid, OAuthStatus::Failed { error: format!("bind failed: {e}") });
399                return;
400            }
401        };
402        let port = listener.local_addr().map(|a| a.port()).unwrap_or(0);
403        let redirect_uri = format!("http://127.0.0.1:{port}/callback");
404
405        let auth_url = format!(
406            "{}?response_type=code&client_id={}&redirect_uri={}&scope={}&state={}&code_challenge={}&code_challenge_method=S256",
407            cfg.auth_url,
408            enc(&client_id),
409            enc(&redirect_uri),
410            enc(&scopes),
411            enc(&state),
412            enc(&pkce.challenge),
413        );
414        manager().set_status(&sid, OAuthStatus::UrlAvailable { auth_url: auth_url.clone() });
415        open_browser(&auth_url);
416
417        // Await the single inbound callback (the session timeout sweep bounds
418        // total wait; this accept has its own guard too).
419        let accept = tokio::time::timeout(SESSION_TIMEOUT, listener.accept()).await;
420        let mut stream = match accept {
421            Ok(Ok((s, _))) => s,
422            _ => {
423                manager().set_status(&sid, OAuthStatus::Failed { error: "no callback received".into() });
424                return;
425            }
426        };
427        let mut buf = [0u8; 4096];
428        let n = stream.read(&mut buf).await.unwrap_or(0);
429        let req = String::from_utf8_lossy(&buf[..n]);
430        let (code, got_state) = parse_callback(&req);
431        let _ = stream
432            .write_all(b"HTTP/1.1 200 OK\r\nContent-Type: text/html\r\n\r\n<html><body>You can close this tab and return to AgentMux.</body></html>")
433            .await;
434
435        // CSRF: reject a callback whose state doesn't match (RFC 8252 §8.9).
436        if got_state.as_deref() != Some(state.as_str()) {
437            manager().set_status(&sid, OAuthStatus::Failed { error: "state mismatch — possible CSRF, aborted".into() });
438            return;
439        }
440        let code = match code {
441            Some(c) => c,
442            None => {
443                manager().set_status(&sid, OAuthStatus::Failed { error: "no authorization code in callback".into() });
444                return;
445            }
446        };
447
448        // Exchange the code (+ PKCE verifier) for tokens.
449        let mut params = vec![
450            ("grant_type", "authorization_code".to_string()),
451            ("client_id", client_id.clone()),
452            ("code", code),
453            ("redirect_uri", redirect_uri),
454            ("code_verifier", pkce.verifier.clone()),
455        ];
456        if let Some(secret) = client_secret.as_ref() {
457            params.push(("client_secret", secret.clone()));
458        }
459        match exchange(cfg.token_url, &params).await {
460            Ok(json) => {
461                let now = chrono::Utc::now().timestamp();
462                let tokens = token_blob(&json, now);
463                match persist_oauth_account(&wstore, cfg.provider, &name, &tokens) {
464                    Ok(account_id) => manager().set_status(&sid, OAuthStatus::Success { account_id }),
465                    Err(e) => manager().set_status(&sid, OAuthStatus::Failed { error: e }),
466                }
467            }
468            Err(e) => manager().set_status(&sid, OAuthStatus::Failed { error: e }),
469        }
470    });
471
472    Ok((session_id, OAuthStatus::UrlAvailable { auth_url: String::new() }))
473}
474
475fn start_device_flow(
476    cfg: ServiceOAuthConfig,
477    client_id: String,
478    name: String,
479    wstore: Arc<Store>,
480) -> Result<(String, OAuthStatus), String> {
481    let device_url = cfg.device_url.ok_or("provider has no device endpoint")?.to_string();
482    let scopes = cfg.scopes.join(" ");
483    let session_id = manager().new_session(OAuthStatus::Pending);
484    let sid = session_id.clone();
485
486    tokio::spawn(async move {
487        // 1. Device authorization request.
488        let params = [("client_id", client_id.clone()), ("scope", scopes)];
489        let resp = match http().post(&device_url).header("Accept", "application/json").form(&params).send().await {
490            Ok(r) => r,
491            Err(e) => {
492                manager().set_status(&sid, OAuthStatus::Failed { error: format!("device request failed: {e}") });
493                return;
494            }
495        };
496        let json: serde_json::Value = resp.json().await.unwrap_or_else(|_| serde_json::json!({}));
497        let device_code = json.get("device_code").and_then(|v| v.as_str()).unwrap_or("").to_string();
498        let user_code = json.get("user_code").and_then(|v| v.as_str()).unwrap_or("").to_string();
499        let verification_uri = json
500            .get("verification_uri")
501            .and_then(|v| v.as_str())
502            .unwrap_or("https://github.com/login/device")
503            .to_string();
504        let mut interval = json.get("interval").and_then(|v| v.as_u64()).unwrap_or(5);
505        if device_code.is_empty() {
506            manager().set_status(&sid, OAuthStatus::Failed { error: "no device_code in response".into() });
507            return;
508        }
509        manager().set_status(&sid, OAuthStatus::CodeEmitted { user_code, verification_uri });
510
511        // 2. Poll the token endpoint (RFC 8628 §3.4–3.5).
512        loop {
513            tokio::time::sleep(Duration::from_secs(interval)).await;
514            // Stop if the session was cancelled / timed out by the manager.
515            match manager().poll(&sid) {
516                Some(s) if s.is_terminal() => return,
517                None => return,
518                _ => {}
519            }
520            let p = [
521                ("client_id", client_id.clone()),
522                ("device_code", device_code.clone()),
523                ("grant_type", "urn:ietf:params:oauth:grant-type:device_code".to_string()),
524            ];
525            let r = match http().post(cfg.token_url).header("Accept", "application/json").form(&p).send().await {
526                Ok(r) => r,
527                Err(e) => {
528                    manager().set_status(&sid, OAuthStatus::Failed { error: format!("poll failed: {e}") });
529                    return;
530                }
531            };
532            let j: serde_json::Value = r.json().await.unwrap_or_else(|_| serde_json::json!({}));
533            if j.get("access_token").is_some() {
534                let now = chrono::Utc::now().timestamp();
535                let tokens = token_blob(&j, now);
536                match persist_oauth_account(&wstore, cfg.provider, &name, &tokens) {
537                    Ok(account_id) => manager().set_status(&sid, OAuthStatus::Success { account_id }),
538                    Err(e) => manager().set_status(&sid, OAuthStatus::Failed { error: e }),
539                }
540                return;
541            }
542            match j.get("error").and_then(|v| v.as_str()) {
543                Some("authorization_pending") => {}
544                Some("slow_down") => interval += 5, // RFC 8628 §3.5
545                Some("access_denied") => {
546                    manager().set_status(&sid, OAuthStatus::Failed { error: "access denied".into() });
547                    return;
548                }
549                Some("expired_token") | Some(_) => {
550                    manager().set_status(&sid, OAuthStatus::Failed { error: "device code expired".into() });
551                    return;
552                }
553                None => {}
554            }
555        }
556    });
557
558    Ok((session_id, OAuthStatus::CodeEmitted { user_code: String::new(), verification_uri: String::new() }))
559}
560
561async fn exchange(token_url: &str, params: &[(&str, String)]) -> Result<serde_json::Value, String> {
562    let resp = http()
563        .post(token_url)
564        .header("Accept", "application/json")
565        .form(params)
566        .send()
567        .await
568        .map_err(|e| format!("token exchange failed: {e}"))?;
569    if !resp.status().is_success() {
570        return Err(format!("token endpoint returned {}", resp.status()));
571    }
572    resp.json().await.map_err(|e| format!("token response parse failed: {e}"))
573}
574
575/// Extract `code` and `state` from a raw HTTP request line
576/// `GET /callback?code=...&state=... HTTP/1.1`.
577fn parse_callback(req: &str) -> (Option<String>, Option<String>) {
578    let first = req.lines().next().unwrap_or("");
579    let path = first.split_whitespace().nth(1).unwrap_or("");
580    let query = path.split('?').nth(1).unwrap_or("");
581    let mut code = None;
582    let mut state = None;
583    for pair in query.split('&') {
584        let mut it = pair.splitn(2, '=');
585        match (it.next(), it.next()) {
586            (Some("code"), Some(v)) => code = Some(percent_decode(v)),
587            (Some("state"), Some(v)) => state = Some(percent_decode(v)),
588            _ => {}
589        }
590    }
591    (code, state)
592}
593
594fn percent_decode(s: &str) -> String {
595    let bytes = s.replace('+', " ");
596    let bytes = bytes.as_bytes();
597    let mut out = Vec::with_capacity(bytes.len());
598    let mut i = 0;
599    while i < bytes.len() {
600        if bytes[i] == b'%' && i + 2 < bytes.len() {
601            if let Ok(b) = u8::from_str_radix(&String::from_utf8_lossy(&bytes[i + 1..i + 3]), 16) {
602                out.push(b);
603                i += 3;
604                continue;
605            }
606        }
607        out.push(bytes[i]);
608        i += 1;
609    }
610    String::from_utf8_lossy(&out).to_string()
611}
612
613#[cfg(test)]
614mod tests {
615    use super::*;
616
617    #[test]
618    fn pkce_challenge_is_s256_of_verifier() {
619        let p = pkce_pair();
620        // Recompute the challenge independently and compare.
621        let mut h = Sha256::new();
622        h.update(p.verifier.as_bytes());
623        let expected = URL_SAFE_NO_PAD.encode(h.finalize());
624        assert_eq!(p.challenge, expected);
625        // base64url, no padding.
626        assert!(!p.challenge.contains('='));
627        assert!(!p.challenge.contains('+'));
628        assert!(!p.challenge.contains('/'));
629        // 43–128 chars per RFC 7636 §4.1.
630        assert!(p.verifier.len() >= 43 && p.verifier.len() <= 128);
631    }
632
633    #[test]
634    fn verifiers_are_unique() {
635        assert_ne!(pkce_pair().verifier, pkce_pair().verifier);
636        assert_ne!(random_state(), random_state());
637    }
638
639    #[test]
640    fn catalog_flow_choices() {
641        assert_eq!(config_for("github").unwrap().flow, OAuthFlow::Device);
642        assert_eq!(config_for("google").unwrap().flow, OAuthFlow::AuthCodePkce);
643        assert!(config_for("slack").unwrap().requires_secret);
644        assert!(!config_for("google").unwrap().requires_secret);
645        assert!(config_for("nope").is_none());
646        assert!(supports_oauth("microsoft"));
647    }
648
649    #[test]
650    fn unconfigured_provider_is_gated() {
651        let cfg = config_for("google").unwrap();
652        // No built-in client id and no BYO → clear "not configured" error.
653        assert!(resolve_client(&cfg, None).is_err());
654        // BYO client id unblocks it.
655        let byo = ByoCredentials { client_id: "abc".into(), client_secret: None };
656        assert_eq!(resolve_client(&cfg, Some(&byo)).unwrap().0, "abc");
657    }
658
659    #[test]
660    fn parses_code_and_state_from_callback() {
661        let req = "GET /callback?code=abc123&state=xyz HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n";
662        let (code, state) = parse_callback(req);
663        assert_eq!(code.as_deref(), Some("abc123"));
664        assert_eq!(state.as_deref(), Some("xyz"));
665    }
666
667    #[test]
668    fn percent_decodes_callback_values() {
669        let req = "GET /callback?code=a%2Fb%2Bc&state=s HTTP/1.1\r\n\r\n";
670        let (code, _) = parse_callback(req);
671        assert_eq!(code.as_deref(), Some("a/b+c"));
672    }
673
674    #[test]
675    fn secret_mandatory_provider_requires_byo_secret() {
676        let cfg = config_for("slack").unwrap();
677        let id_only = ByoCredentials { client_id: "abc".into(), client_secret: None };
678        assert!(resolve_client(&cfg, Some(&id_only)).is_err());
679        let with_secret = ByoCredentials {
680            client_id: "abc".into(),
681            client_secret: Some("shh".into()),
682        };
683        assert!(resolve_client(&cfg, Some(&with_secret)).is_ok());
684    }
685}