Skip to main content

greentic_ext_runtime/
oauth.rs

1//! OAuth broker HTTP client for the design-extension runtime.
2//!
3//! Ported from `greentic-runner` `greentic-runner-host/src/oauth.rs`. The broker
4//! service performs token refresh; this client just relays a `resource-token`
5//! request and returns the (already-fresh) access token.
6
7#![allow(dead_code)]
8
9use serde::{Deserialize, Serialize};
10use url::Url;
11
12/// Configuration the host supplies so the extension runtime can reach the OAuth broker.
13#[derive(Clone, Debug, Default)]
14pub struct OAuthBrokerConfig {
15    pub http_base_url: String,
16    pub env: String,
17    pub tenant: String,
18    pub team: Option<String>,
19    pub shared_secret: Option<String>,
20}
21
22#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
23pub struct ResourceTokenRequest {
24    pub http_base_url: String,
25    pub env: String,
26    pub tenant: String,
27    pub team: Option<String>,
28    pub resource_id: String,
29    pub scopes: Vec<String>,
30}
31
32#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
33pub struct ResourceTokenResponse {
34    pub access_token: String,
35    pub expires_at: u64,
36}
37
38/// POST `{http_base_url}/resource-token` and return the resolved access token.
39///
40/// The broker refreshes the token if needed; the shared secret (when present)
41/// authenticates this host→broker call and is never logged or returned.
42pub fn request_resource_token_blocking(
43    client: &reqwest::blocking::Client,
44    request: &ResourceTokenRequest,
45    shared_secret: Option<&str>,
46) -> anyhow::Result<ResourceTokenResponse> {
47    let base = Url::parse(&request.http_base_url)?;
48    let is_local = base.host_str() == Some("127.0.0.1") || base.host_str() == Some("localhost");
49    anyhow::ensure!(
50        base.scheme() == "https" || is_local,
51        "oauth broker http_base_url must be https"
52    );
53    anyhow::ensure!(
54        base.query().is_none(),
55        "oauth broker http_base_url must not carry a query"
56    );
57    let url = base.join("resource-token")?;
58    let mut rb = client.post(url).json(request);
59    if let Some(secret) = shared_secret {
60        rb = rb.bearer_auth(secret);
61    }
62    let response = rb.send()?.error_for_status()?;
63    Ok(response.json()?)
64}
65
66#[cfg(test)]
67mod tests {
68    use super::*;
69
70    #[test]
71    fn resource_token_request_serializes_expected_shape() {
72        let req = ResourceTokenRequest {
73            http_base_url: "https://oauth.example/".into(),
74            env: "dev".into(),
75            tenant: "acme".into(),
76            team: None,
77            resource_id: "hubspot".into(),
78            scopes: vec!["crm.objects.contacts.read".into()],
79        };
80        let v: serde_json::Value = serde_json::to_value(&req).unwrap();
81        assert_eq!(v["resource_id"], "hubspot");
82        assert_eq!(v["tenant"], "acme");
83        assert_eq!(v["scopes"][0], "crm.objects.contacts.read");
84    }
85
86    #[test]
87    fn resource_token_response_deserializes() {
88        let r: ResourceTokenResponse =
89            serde_json::from_str(r#"{"access_token":"tok","expires_at":123}"#).unwrap();
90        assert_eq!(r.access_token, "tok");
91        assert_eq!(r.expires_at, 123);
92    }
93}