Skip to main content

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