src/http/oauth.rs 2.1 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 crate::model::{AuthStyle, OAuthConfig};
9
10
/// Clock skew so tokens are refreshed slightly before their stated expiry.
11
const EXPIRY_SKEW_SECS: u64 = 30;
12
13
#[derive(Debug, Clone)]
14
pub struct OAuthToken {
15
    pub access_token: String,
16
    pub expires_at: Instant,
17
}
18
19
pub fn token_valid(token: &OAuthToken) -> bool {
20
    Instant::now() < token.expires_at
21
}
22
23
/// Request a new access token using the client-credentials grant.
24
pub async fn fetch_token(client: &reqwest::Client, cfg: &OAuthConfig) -> Result<OAuthToken> {
25
    if cfg.token_url.is_empty() {
26
        bail!("OAuth token URL is not configured (press A to configure auth)");
27
    }
28
29
    let mut form: Vec<(&str, String)> = vec![("grant_type", "client_credentials".into())];
30
    if !cfg.scopes.is_empty() {
31
        form.push(("scope", cfg.scopes.join(" ")));
32
    }
33
34
    let mut rb = client.post(&cfg.token_url);
35
    match cfg.auth_style {
36
        AuthStyle::Basic => {
37
            rb = rb.basic_auth(cfg.client_id.clone(), Some(cfg.client_secret.clone()));
38
        }
39
        AuthStyle::Post => {
40
            form.push(("client_id", cfg.client_id.clone()));
41
            form.push(("client_secret", cfg.client_secret.clone()));
42
        }
43
    }
44
45
    let resp = rb
46
        .form(&form)
47
        .send()
48
        .await
49
        .context("token request failed")?;
50
    let status = resp.status();
51
    let text = resp.text().await.context("reading token response")?;
52
    if !status.is_success() {
53
        bail!("token request returned {status}: {text}");
54
    }
55
56
    let v: Value = serde_json::from_str(&text).context("token response is not JSON")?;
57
    let access_token = v
58
        .get("access_token")
59
        .and_then(Value::as_str)
60
        .ok_or_else(|| anyhow!("token response missing access_token"))?
61
        .to_string();
62
    let expires_in = v.get("expires_in").and_then(Value::as_u64).unwrap_or(3600);
63
64
    Ok(OAuthToken {
65
        access_token,
66
        expires_at: Instant::now()
67
            + Duration::from_secs(expires_in.saturating_sub(EXPIRY_SKEW_SECS)),
68
    })
69
}