Skip to main content

runmat_server_client/
auth.rs

1use anyhow::{Context, Result};
2use base64::engine::general_purpose::URL_SAFE_NO_PAD;
3use base64::Engine;
4use chrono::{DateTime, Utc};
5use rand::RngCore;
6use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION};
7use serde::{Deserialize, Serialize};
8use sha2::{Digest, Sha256};
9use std::env;
10use std::fs;
11#[cfg(unix)]
12use std::io::Write;
13#[cfg(unix)]
14use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
15use std::path::{Path, PathBuf};
16use tokio::net::TcpListener;
17use tokio::time::{timeout, Duration};
18use url::Url;
19use uuid::Uuid;
20
21use crate::public_api;
22
23pub const DEFAULT_SERVER_URL: &str = "https://api.runmat.com";
24
25#[derive(Default, Serialize, Deserialize, Clone, Debug)]
26pub struct RemoteConfig {
27    pub server_url: Option<String>,
28    pub org_id: Option<Uuid>,
29    pub project_id: Option<Uuid>,
30    pub credential_store: Option<CredentialStoreMode>,
31    pub token_expires_at: Option<DateTime<Utc>>,
32    pub token_endpoint: Option<String>,
33    pub token_client_id: Option<String>,
34}
35
36#[derive(Serialize, Deserialize, Clone, Debug, Copy, PartialEq, Eq)]
37#[serde(rename_all = "snake_case")]
38pub enum CredentialStoreMode {
39    Auto,
40    Secure,
41    File,
42    Memory,
43}
44
45impl Default for CredentialStoreMode {
46    fn default() -> Self {
47        Self::Auto
48    }
49}
50
51impl std::str::FromStr for CredentialStoreMode {
52    type Err = anyhow::Error;
53
54    fn from_str(s: &str) -> Result<Self> {
55        match s {
56            "auto" => Ok(Self::Auto),
57            "secure" => Ok(Self::Secure),
58            "file" => Ok(Self::File),
59            "memory" => Ok(Self::Memory),
60            _ => anyhow::bail!("invalid credential store mode: {s}"),
61        }
62    }
63}
64
65#[derive(Clone, Debug)]
66pub struct AuthToken {
67    pub access_token: String,
68    pub refresh_token: Option<String>,
69    pub expires_at: Option<DateTime<Utc>>,
70    pub token_endpoint: Option<String>,
71    pub client_id: Option<String>,
72}
73
74impl RemoteConfig {
75    pub fn load() -> Result<Self> {
76        let path = config_path()?;
77        if !path.exists() {
78            return Ok(Self::default());
79        }
80        let contents = fs::read_to_string(&path)
81            .with_context(|| format!("Failed to read config {}", path.display()))?;
82        serde_json::from_str(&contents).context("Failed to parse remote config")
83    }
84
85    pub fn save(&self) -> Result<()> {
86        let path = config_path()?;
87        if let Some(parent) = path.parent() {
88            fs::create_dir_all(parent)
89                .with_context(|| format!("Failed to create config dir {}", parent.display()))?;
90        }
91        let contents = serde_json::to_string_pretty(self).context("Failed to serialize config")?;
92        fs::write(&path, contents)
93            .with_context(|| format!("Failed to write config {}", path.display()))?;
94        Ok(())
95    }
96}
97
98pub fn config_path() -> Result<PathBuf> {
99    if let Ok(dir) = env::var("RUNMAT_CLI_CONFIG_DIR") {
100        return Ok(PathBuf::from(dir).join("remote.json"));
101    }
102    let base = dirs::config_dir().context("Unable to locate config directory")?;
103    Ok(base.join("runmat").join("remote.json"))
104}
105
106pub async fn execute_login(
107    server: Option<String>,
108    api_key: Option<String>,
109    email: Option<String>,
110    org: Option<Uuid>,
111    project: Option<Uuid>,
112    credential_store: Option<CredentialStoreMode>,
113) -> Result<()> {
114    let mut config = RemoteConfig::load()?;
115    let server_url = resolve_server_url(&config, server)?;
116    let credential_store = credential_store
117        .or(config.credential_store)
118        .unwrap_or(CredentialStoreMode::Auto);
119    let auth = if let Some(api_key) = api_key {
120        AuthToken {
121            access_token: api_key,
122            refresh_token: None,
123            expires_at: None,
124            token_endpoint: None,
125            client_id: None,
126        }
127    } else if let Ok(env_token) = env::var("RUNMAT_API_KEY") {
128        AuthToken {
129            access_token: env_token,
130            refresh_token: None,
131            expires_at: None,
132            token_endpoint: None,
133            client_id: None,
134        }
135    } else {
136        interactive_login(&server_url, email).await?
137    };
138    store_token(&server_url, auth.access_token.trim(), credential_store)?;
139    if let Some(refresh) = auth.refresh_token.as_deref() {
140        store_refresh_token(&server_url, refresh, credential_store)?;
141    } else {
142        clear_refresh_token(&server_url, credential_store)?;
143    }
144    config.credential_store = Some(credential_store);
145    config.token_expires_at = auth.expires_at;
146    config.token_endpoint = auth.token_endpoint;
147    config.token_client_id = auth.client_id;
148    config.server_url = Some(server_url.clone());
149    if let Some(org) = org {
150        config.org_id = Some(org);
151    }
152    if let Some(project) = project {
153        config.project_id = Some(project);
154    }
155    config.save()?;
156    println!("Stored credentials for {server_url}");
157    Ok(())
158}
159
160pub fn resolve_server_url(config: &RemoteConfig, override_value: Option<String>) -> Result<String> {
161    if let Some(value) = override_value {
162        return Ok(value);
163    }
164    if let Ok(value) = env::var("RUNMAT_SERVER_URL") {
165        if !value.is_empty() {
166            return Ok(value);
167        }
168    }
169    if let Some(value) = &config.server_url {
170        return Ok(value.clone());
171    }
172    Ok(DEFAULT_SERVER_URL.to_string())
173}
174
175pub fn resolve_org_id(config: &RemoteConfig, override_value: Option<Uuid>) -> Result<Uuid> {
176    if let Some(value) = override_value {
177        return Ok(value);
178    }
179    if let Ok(value) = env::var("RUNMAT_ORG_ID") {
180        if !value.is_empty() {
181            return Uuid::parse_str(&value).context("RUNMAT_ORG_ID must be a UUID");
182        }
183    }
184    config.org_id.context(
185        "Organization id not configured. Use --org, set RUNMAT_ORG_ID, or run `runmat login --org <id>`."
186    )
187}
188
189pub fn resolve_project_id(config: &RemoteConfig, override_value: Option<Uuid>) -> Result<Uuid> {
190    if let Some(value) = override_value {
191        return Ok(value);
192    }
193    if let Ok(value) = env::var("RUNMAT_PROJECT_ID") {
194        if !value.is_empty() {
195            return Uuid::parse_str(&value).context("RUNMAT_PROJECT_ID must be a UUID");
196        }
197    }
198    config.project_id.context(
199        "Project id not configured. Use --project, set RUNMAT_PROJECT_ID, or run `runmat login --project <id>`."
200    )
201}
202
203pub async fn resolve_auth_token(config: &mut RemoteConfig, server_url: &str) -> Result<String> {
204    if let Ok(value) = env::var("RUNMAT_API_KEY") {
205        if !value.is_empty() {
206            return Ok(value);
207        }
208    }
209    let credential_store = config.credential_store.unwrap_or(CredentialStoreMode::Auto);
210    let token = load_token(server_url, credential_store)?;
211    let refresh = load_refresh_token(server_url, credential_store)?;
212    let expired = config
213        .token_expires_at
214        .map(|expiry| expiry <= Utc::now() + chrono::Duration::seconds(30))
215        .unwrap_or(false);
216    if let Some(token) = token.as_ref() {
217        if !expired {
218            return Ok(token.clone());
219        }
220    }
221    if let (Some(refresh), Some(endpoint), Some(client_id)) = (
222        refresh,
223        config.token_endpoint.clone(),
224        config.token_client_id.clone(),
225    ) {
226        let refreshed = refresh_access_token(&endpoint, &client_id, &refresh).await?;
227        store_token(server_url, &refreshed.access_token, credential_store)?;
228        if let Some(new_refresh) = refreshed.refresh_token.as_deref() {
229            store_refresh_token(server_url, new_refresh, credential_store)?;
230        }
231        config.token_expires_at = refreshed.expires_at;
232        config.save()?;
233        return Ok(refreshed.access_token);
234    }
235    token.context(
236        "No stored credentials. Run `runmat login --server <url> --project <id>` or set RUNMAT_API_KEY."
237    )
238}
239
240pub fn build_public_client(server_url: &str, token: &str) -> Result<public_api::Client> {
241    let mut headers = HeaderMap::new();
242    let mut value = HeaderValue::from_str(&format!("Bearer {token}"))
243        .context("Invalid authorization header")?;
244    value.set_sensitive(true);
245    headers.insert(AUTHORIZATION, value);
246    let client = reqwest::Client::builder()
247        .default_headers(headers)
248        .build()
249        .context("Failed to build HTTP client")?;
250    Ok(public_api::Client::new_with_client(server_url, client))
251}
252
253pub fn map_public_error<T: std::fmt::Debug>(err: public_api::Error<T>) -> anyhow::Error {
254    anyhow::anyhow!(err.to_string())
255}
256
257fn load_token(server_url: &str, mode: CredentialStoreMode) -> Result<Option<String>> {
258    match mode {
259        CredentialStoreMode::Auto => load_token_auto(server_url),
260        CredentialStoreMode::Secure => load_token_keyring(server_url),
261        CredentialStoreMode::File => load_token_file(server_url),
262        CredentialStoreMode::Memory => Ok(env::var(memory_token_key(server_url)).ok()),
263    }
264}
265
266fn load_refresh_token(server_url: &str, mode: CredentialStoreMode) -> Result<Option<String>> {
267    match mode {
268        CredentialStoreMode::Auto => load_refresh_token_auto(server_url),
269        CredentialStoreMode::Secure => load_refresh_token_keyring(server_url),
270        CredentialStoreMode::File => load_refresh_token_file(server_url),
271        CredentialStoreMode::Memory => Ok(env::var(memory_refresh_key(server_url)).ok()),
272    }
273}
274
275fn store_token(server_url: &str, token: &str, mode: CredentialStoreMode) -> Result<()> {
276    match mode {
277        CredentialStoreMode::Auto => store_token_auto(server_url, token),
278        CredentialStoreMode::Secure => store_token_keyring(server_url, token),
279        CredentialStoreMode::File => store_token_file(server_url, token),
280        CredentialStoreMode::Memory => {
281            unsafe { env::set_var(memory_token_key(server_url), token) };
282            Ok(())
283        }
284    }
285}
286
287fn store_refresh_token(server_url: &str, token: &str, mode: CredentialStoreMode) -> Result<()> {
288    match mode {
289        CredentialStoreMode::Auto => store_refresh_token_auto(server_url, token),
290        CredentialStoreMode::Secure => store_refresh_token_keyring(server_url, token),
291        CredentialStoreMode::File => store_refresh_token_file(server_url, token),
292        CredentialStoreMode::Memory => {
293            unsafe { env::set_var(memory_refresh_key(server_url), token) };
294            Ok(())
295        }
296    }
297}
298
299fn clear_refresh_token(server_url: &str, mode: CredentialStoreMode) -> Result<()> {
300    match mode {
301        CredentialStoreMode::Auto => clear_refresh_token_auto(server_url),
302        CredentialStoreMode::Secure => clear_refresh_token_keyring(server_url),
303        CredentialStoreMode::File => clear_refresh_token_file(server_url),
304        CredentialStoreMode::Memory => {
305            unsafe { env::remove_var(memory_refresh_key(server_url)) };
306            Ok(())
307        }
308    }
309}
310
311fn token_file_path(server_url: &str) -> Result<PathBuf> {
312    let path = config_path()?;
313    let parent = path.parent().context("Missing config directory")?;
314    Ok(parent.join(format!("token-{}.json", safe_server_key(server_url))))
315}
316
317#[derive(Serialize, Deserialize)]
318struct FileCredentialPayload {
319    access_token: Option<String>,
320    refresh_token: Option<String>,
321}
322
323fn load_token_file(server_url: &str) -> Result<Option<String>> {
324    let path = token_file_path(server_url)?;
325    if !path.exists() {
326        return Ok(None);
327    }
328    let payload: FileCredentialPayload =
329        serde_json::from_str(&fs::read_to_string(&path).context("Failed to read token file")?)
330            .context("Failed to parse token file")?;
331    Ok(payload.access_token)
332}
333
334fn load_refresh_token_file(server_url: &str) -> Result<Option<String>> {
335    let path = token_file_path(server_url)?;
336    if !path.exists() {
337        return Ok(None);
338    }
339    let payload: FileCredentialPayload =
340        serde_json::from_str(&fs::read_to_string(&path).context("Failed to read token file")?)
341            .context("Failed to parse token file")?;
342    Ok(payload.refresh_token)
343}
344
345fn store_token_file(server_url: &str, token: &str) -> Result<()> {
346    let path = token_file_path(server_url)?;
347    let refresh = load_refresh_token_file(server_url).ok().flatten();
348    if let Some(parent) = path.parent() {
349        fs::create_dir_all(parent).context("Failed to create token dir")?;
350    }
351    let payload = FileCredentialPayload {
352        access_token: Some(token.to_string()),
353        refresh_token: refresh,
354    };
355    write_token_payload_file(&path, &payload)?;
356    Ok(())
357}
358
359fn store_refresh_token_file(server_url: &str, token: &str) -> Result<()> {
360    let path = token_file_path(server_url)?;
361    let access = load_token_file(server_url).ok().flatten();
362    if let Some(parent) = path.parent() {
363        fs::create_dir_all(parent).context("Failed to create token dir")?;
364    }
365    let payload = FileCredentialPayload {
366        access_token: access,
367        refresh_token: Some(token.to_string()),
368    };
369    write_token_payload_file(&path, &payload)?;
370    Ok(())
371}
372
373fn clear_refresh_token_file(server_url: &str) -> Result<()> {
374    let path = token_file_path(server_url)?;
375    if !path.exists() {
376        return Ok(());
377    }
378    let access = load_token_file(server_url).ok().flatten();
379    let payload = FileCredentialPayload {
380        access_token: access,
381        refresh_token: None,
382    };
383    write_token_payload_file(&path, &payload)?;
384    Ok(())
385}
386
387fn load_token_auto(server_url: &str) -> Result<Option<String>> {
388    match load_token_keyring(server_url) {
389        Ok(Some(value)) => Ok(Some(value)),
390        Ok(None) | Err(_) => load_token_file(server_url),
391    }
392}
393
394fn load_refresh_token_auto(server_url: &str) -> Result<Option<String>> {
395    match load_refresh_token_keyring(server_url) {
396        Ok(Some(value)) => Ok(Some(value)),
397        Ok(None) | Err(_) => load_refresh_token_file(server_url),
398    }
399}
400
401fn store_token_auto(server_url: &str, token: &str) -> Result<()> {
402    match store_token_keyring(server_url, token) {
403        Ok(()) => Ok(()),
404        Err(_) => {
405            // Evict any stale keyring entry so load_token_auto won't return it on
406            // the next call (keyring is preferred over file in the load path).
407            let _ = clear_token_keyring(server_url);
408            store_token_file(server_url, token)
409        }
410    }
411}
412
413fn store_refresh_token_auto(server_url: &str, token: &str) -> Result<()> {
414    match store_refresh_token_keyring(server_url, token) {
415        Ok(()) => Ok(()),
416        Err(_) => {
417            let _ = clear_refresh_token_keyring(server_url);
418            store_refresh_token_file(server_url, token)
419        }
420    }
421}
422
423fn clear_refresh_token_auto(server_url: &str) -> Result<()> {
424    // Clear from both backends unconditionally: a previous store fallback may
425    // have left data in the file while the keyring still holds a stale entry,
426    // or vice-versa.  Treat keyring errors as non-fatal (keyring may be
427    // unavailable) and return the file result as the authoritative outcome.
428    let _ = clear_refresh_token_keyring(server_url);
429    clear_refresh_token_file(server_url)
430}
431
432fn write_token_payload_file(path: &Path, payload: &FileCredentialPayload) -> Result<()> {
433    let contents = serde_json::to_vec_pretty(payload)?;
434    #[cfg(unix)]
435    {
436        let mut file = fs::OpenOptions::new()
437            .create(true)
438            .truncate(true)
439            .write(true)
440            .mode(0o600)
441            .open(path)
442            .context("Failed to write token file")?;
443        file.write_all(&contents)
444            .context("Failed to write token file")?;
445        fs::set_permissions(path, fs::Permissions::from_mode(0o600))
446            .context("Failed to set token file permissions")?;
447    }
448    #[cfg(not(unix))]
449    {
450        fs::write(path, contents).context("Failed to write token file")?;
451    }
452    Ok(())
453}
454
455fn load_token_keyring(server_url: &str) -> Result<Option<String>> {
456    let entry = keyring_entry(server_url)?;
457    match entry.get_password() {
458        Ok(value) => Ok(Some(value)),
459        Err(keyring::Error::NoEntry) => Ok(None),
460        Err(err) => Err(err).context("Failed to access keyring"),
461    }
462}
463
464fn load_refresh_token_keyring(server_url: &str) -> Result<Option<String>> {
465    let entry = keyring_refresh_entry(server_url)?;
466    match entry.get_password() {
467        Ok(value) => Ok(Some(value)),
468        Err(keyring::Error::NoEntry) => Ok(None),
469        Err(err) => Err(err).context("Failed to access keyring"),
470    }
471}
472
473fn store_token_keyring(server_url: &str, token: &str) -> Result<()> {
474    let entry = keyring_entry(server_url)?;
475    entry
476        .set_password(token)
477        .context("Failed to store credentials")
478}
479
480fn store_refresh_token_keyring(server_url: &str, token: &str) -> Result<()> {
481    let entry = keyring_refresh_entry(server_url)?;
482    entry
483        .set_password(token)
484        .context("Failed to store refresh token")
485}
486
487fn clear_refresh_token_keyring(server_url: &str) -> Result<()> {
488    let entry = keyring_refresh_entry(server_url)?;
489    match entry.delete_password() {
490        Ok(_) => Ok(()),
491        Err(keyring::Error::NoEntry) => Ok(()),
492        Err(err) => Err(err).context("Failed to clear refresh token"),
493    }
494}
495
496fn clear_token_keyring(server_url: &str) -> Result<()> {
497    let entry = keyring_entry(server_url)?;
498    match entry.delete_password() {
499        Ok(_) => Ok(()),
500        Err(keyring::Error::NoEntry) => Ok(()),
501        Err(err) => Err(err).context("Failed to clear token"),
502    }
503}
504
505fn safe_server_key(server_url: &str) -> String {
506    server_url
507        .chars()
508        .map(|ch| if ch.is_ascii_alphanumeric() { ch } else { '_' })
509        .collect()
510}
511
512fn memory_token_key(server_url: &str) -> String {
513    format!("RUNMAT_MEMORY_TOKEN_{}", safe_server_key(server_url))
514}
515
516fn memory_refresh_key(server_url: &str) -> String {
517    format!("RUNMAT_MEMORY_REFRESH_{}", safe_server_key(server_url))
518}
519
520fn keyring_entry(server_url: &str) -> Result<keyring::Entry> {
521    let account = Url::parse(server_url)
522        .ok()
523        .and_then(|url| url.host_str().map(|host| host.to_string()))
524        .unwrap_or_else(|| server_url.to_string());
525    keyring::Entry::new("runmat", &account).context("Failed to open keyring")
526}
527
528fn keyring_refresh_entry(server_url: &str) -> Result<keyring::Entry> {
529    let account = Url::parse(server_url)
530        .ok()
531        .and_then(|url| url.host_str().map(|host| format!("{host}:refresh")))
532        .unwrap_or_else(|| format!("{}:refresh", server_url));
533    keyring::Entry::new("runmat", &account).context("Failed to open keyring")
534}
535
536async fn interactive_login(server_url: &str, email: Option<String>) -> Result<AuthToken> {
537    let client = public_api::Client::new(server_url);
538    let response = client
539        .auth_resolve(&public_api::types::AuthResolveRequest {
540            email,
541            client_kind: Some("cli".to_string()),
542        })
543        .await
544        .map_err(map_public_error)?
545        .into_inner();
546    let pkce = generate_pkce();
547    let redirect_uri = default_loopback_redirect_uri();
548    let client_id = response
549        .client_id
550        .clone()
551        .context("Missing client_id in auth resolve response")?;
552    let (authorize_url, expected_state) = build_pkce_authorize_url(
553        &response.redirect_url,
554        &client_id,
555        redirect_uri.as_str(),
556        response.audience.as_deref(),
557        response.scope.as_deref(),
558        &pkce,
559    )?;
560    let (host, port) = loopback_host_port(&redirect_uri)?;
561    let listener = TcpListener::bind((host.as_str(), port))
562        .await
563        .context("Failed to bind local callback listener")?;
564    let auth_url = authorize_url.as_str().to_string();
565    webbrowser::open(&auth_url).context("Failed to open browser")?;
566    let code = listen_for_auth_code(listener, &expected_state).await?;
567    let token_endpoint = discover_token_endpoint(&authorize_url).await?;
568    let token = exchange_code_for_token(
569        &token_endpoint,
570        &code,
571        &client_id,
572        redirect_uri.as_str(),
573        &pkce.verifier,
574    )
575    .await?;
576    Ok(AuthToken {
577        access_token: token.access_token,
578        refresh_token: token.refresh_token,
579        expires_at: token
580            .expires_in
581            .map(|value| Utc::now() + chrono::Duration::seconds(value)),
582        token_endpoint: Some(token_endpoint),
583        client_id: Some(client_id),
584    })
585}
586
587#[derive(Debug)]
588struct PkceState {
589    verifier: String,
590    challenge: String,
591    state: String,
592}
593
594fn generate_pkce() -> PkceState {
595    let mut verifier_bytes = [0u8; 32];
596    rand::rngs::OsRng.fill_bytes(&mut verifier_bytes);
597    let verifier = URL_SAFE_NO_PAD.encode(verifier_bytes);
598    let challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes()));
599    let mut state_bytes = [0u8; 16];
600    rand::rngs::OsRng.fill_bytes(&mut state_bytes);
601    let state = URL_SAFE_NO_PAD.encode(state_bytes);
602    PkceState {
603        verifier,
604        challenge,
605        state,
606    }
607}
608
609fn build_pkce_authorize_url(
610    authorize_base: &str,
611    client_id: &str,
612    redirect_uri: &str,
613    audience: Option<&str>,
614    scope: Option<&str>,
615    pkce: &PkceState,
616) -> Result<(Url, String)> {
617    let mut url = Url::parse(authorize_base).context("Invalid redirect URL")?;
618    let scope = scope.unwrap_or("openid profile email offline_access");
619    let mut scopes = scope.split_whitespace().collect::<Vec<_>>();
620    if !scopes.contains(&"offline_access") {
621        scopes.push("offline_access");
622    }
623    url.set_query(None);
624    {
625        let mut pairs = url.query_pairs_mut();
626        pairs.append_pair("client_id", client_id);
627        pairs.append_pair("redirect_uri", redirect_uri);
628        pairs.append_pair("response_type", "code");
629        pairs.append_pair("scope", &scopes.join(" "));
630        if let Some(audience) = audience.filter(|value| !value.is_empty()) {
631            pairs.append_pair("audience", audience);
632        }
633        pairs.append_pair("code_challenge", &pkce.challenge);
634        pairs.append_pair("code_challenge_method", "S256");
635        pairs.append_pair("state", &pkce.state);
636    }
637    Ok((url, pkce.state.clone()))
638}
639
640fn default_loopback_redirect_uri() -> Url {
641    Url::parse("http://127.0.0.1:7777/callback").expect("valid loopback url")
642}
643
644fn loopback_host_port(redirect_uri: &Url) -> Result<(String, u16)> {
645    if redirect_uri.scheme() != "http" {
646        anyhow::bail!(
647            "Interactive login requires http loopback redirect URIs; use --api-key instead."
648        );
649    }
650    let host = redirect_uri
651        .host_str()
652        .context("Missing redirect_uri host")?
653        .to_string();
654    if host != "127.0.0.1" && host != "localhost" {
655        anyhow::bail!("Interactive login requires a loopback redirect URI; use --api-key instead.");
656    }
657    let port = redirect_uri.port().unwrap_or(80);
658    Ok((host, port))
659}
660
661async fn listen_for_auth_code(listener: TcpListener, expected_state: &str) -> Result<String> {
662    let (stream, _) = timeout(Duration::from_secs(180), listener.accept())
663        .await
664        .context("Timed out waiting for login")?
665        .context("Failed to accept callback")?;
666    let mut buf = vec![0u8; 4096];
667    stream.readable().await.context("Failed to read callback")?;
668    let n = stream
669        .try_read(&mut buf)
670        .context("Failed to read callback")?;
671    let request = String::from_utf8_lossy(&buf[..n]);
672    let path = request
673        .lines()
674        .next()
675        .and_then(|line| line.split_whitespace().nth(1))
676        .unwrap_or("/");
677    let callback_url =
678        Url::parse(&format!("http://localhost{path}")).context("Invalid callback URL")?;
679    let code = callback_url
680        .query_pairs()
681        .find(|(key, _)| key == "code")
682        .map(|(_, value)| value.to_string())
683        .context("Missing authorization code")?;
684    if let Some(state) = callback_url
685        .query_pairs()
686        .find(|(key, _)| key == "state")
687        .map(|(_, value)| value.to_string())
688    {
689        if state != expected_state {
690            anyhow::bail!("Login state mismatch")
691        }
692    }
693    let response = "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\n\r\n<html><body><p>RunMat login complete. You can close this window.</p></body></html>";
694    let _ = stream.try_write(response.as_bytes());
695    Ok(code)
696}
697
698#[derive(Deserialize)]
699struct TokenResponse {
700    access_token: String,
701    #[serde(default)]
702    refresh_token: Option<String>,
703    #[serde(default)]
704    expires_in: Option<i64>,
705}
706
707#[derive(Deserialize)]
708struct OidcConfig {
709    token_endpoint: String,
710}
711
712async fn discover_token_endpoint(authorize_url: &Url) -> Result<String> {
713    let mut origin = authorize_url.clone();
714    origin.set_path("/");
715    origin.set_query(None);
716    origin.set_fragment(None);
717    let base = origin.as_str().trim_end_matches('/');
718    let discovery_url = format!("{base}/.well-known/openid-configuration");
719    let client = reqwest::Client::new();
720    if let Ok(response) = client.get(&discovery_url).send().await {
721        if response.status().is_success() {
722            if let Ok(config) = response.json::<OidcConfig>().await {
723                return Ok(config.token_endpoint);
724            }
725        }
726    }
727    Ok(format!("{base}/oauth/token"))
728}
729
730async fn exchange_code_for_token(
731    token_endpoint: &str,
732    code: &str,
733    client_id: &str,
734    redirect_uri: &str,
735    verifier: &str,
736) -> Result<TokenResponse> {
737    let client = reqwest::Client::new();
738    let response = client
739        .post(token_endpoint)
740        .form(&[
741            ("grant_type", "authorization_code"),
742            ("code", code),
743            ("client_id", client_id),
744            ("redirect_uri", redirect_uri),
745            ("code_verifier", verifier),
746        ])
747        .send()
748        .await
749        .context("Failed to request access token")?;
750    if !response.status().is_success() {
751        let status = response.status();
752        let text = response.text().await.unwrap_or_default();
753        anyhow::bail!("Token exchange failed ({status}): {text}")
754    }
755    let token = response
756        .json::<TokenResponse>()
757        .await
758        .context("Failed to parse access token")?;
759    Ok(token)
760}
761
762async fn refresh_access_token(
763    token_endpoint: &str,
764    client_id: &str,
765    refresh_token: &str,
766) -> Result<AuthToken> {
767    let client = reqwest::Client::new();
768    let response = client
769        .post(token_endpoint)
770        .form(&[
771            ("grant_type", "refresh_token"),
772            ("client_id", client_id),
773            ("refresh_token", refresh_token),
774        ])
775        .send()
776        .await
777        .context("Failed to refresh access token")?;
778    if !response.status().is_success() {
779        let status = response.status();
780        let text = response.text().await.unwrap_or_default();
781        anyhow::bail!("Token refresh failed ({status}): {text}")
782    }
783    let token = response
784        .json::<TokenResponse>()
785        .await
786        .context("Failed to parse refresh response")?;
787    Ok(AuthToken {
788        access_token: token.access_token,
789        refresh_token: token.refresh_token,
790        expires_at: token
791            .expires_in
792            .map(|value| Utc::now() + chrono::Duration::seconds(value)),
793        token_endpoint: Some(token_endpoint.to_string()),
794        client_id: Some(client_id.to_string()),
795    })
796}