src/http/oauth.rs 2.4 K raw
1
//! OAuth 2.0 client-credentials flow (RFC 6749 §4.4).
2
3
use std::time::{Duration, Instant};
4
5
use anyhow::{Context, Result, anyhow, bail};
6
use serde_json::Value;
7
8
use super::secret::resolve_secret;
9
use crate::model::{AuthStyle, OAuthConfig};
10
11
/// Clock skew so tokens are refreshed slightly before their stated expiry.
12
const EXPIRY_SKEW_SECS: u64 = 30;
13
14
#[derive(Debug, Clone)]
15
pub struct OAuthToken {
16
    pub access_token: String,
17
    pub expires_at: Instant,
18
}
19
20
pub fn token_valid(token: &OAuthToken) -> bool {
21
    Instant::now() < token.expires_at
22
}
23
24
/// Request a new access token using the client-credentials grant.
25
pub async fn fetch_token(client: &reqwest::Client, cfg: &OAuthConfig) -> Result<OAuthToken> {
26
    if cfg.token_url.is_empty() {
27
        bail!("OAuth token URL is not configured (press A to configure auth)");
28
    }
29
30
    let mut form: Vec<(&str, String)> = vec![("grant_type", "client_credentials".into())];
31
    if !cfg.scopes.is_empty() {
32
        form.push(("scope", cfg.scopes.join(" ")));
33
    }
34
35
    // The client secret may be a `$(…)` command (e.g. a password manager read);
36
    // resolve it just before the exchange so it never sits in memory longer.
37
    let client_secret =
38
        resolve_secret(&cfg.client_secret).context("resolving OAuth client secret")?;
39
40
    let mut rb = client.post(&cfg.token_url);
41
    match cfg.auth_style {
42
        AuthStyle::Basic => {
43
            rb = rb.basic_auth(cfg.client_id.clone(), Some(client_secret));
44
        }
45
        AuthStyle::Post => {
46
            form.push(("client_id", cfg.client_id.clone()));
47
            form.push(("client_secret", client_secret));
48
        }
49
    }
50
51
    let resp = rb
52
        .form(&form)
53
        .send()
54
        .await
55
        .context("token request failed")?;
56
    let status = resp.status();
57
    let text = resp.text().await.context("reading token response")?;
58
    if !status.is_success() {
59
        bail!("token request returned {status}: {text}");
60
    }
61
62
    let v: Value = serde_json::from_str(&text).context("token response is not JSON")?;
63
    let access_token = v
64
        .get("access_token")
65
        .and_then(Value::as_str)
66
        .ok_or_else(|| anyhow!("token response missing access_token"))?
67
        .to_string();
68
    let expires_in = v.get("expires_in").and_then(Value::as_u64).unwrap_or(3600);
69
70
    Ok(OAuthToken {
71
        access_token,
72
        expires_at: Instant::now()
73
            + Duration::from_secs(expires_in.saturating_sub(EXPIRY_SKEW_SECS)),
74
    })
75
}