Skip to main content

rpi_extensions/
oauth.rs

1//! Host-side bridge for provider-owned OAuth flows.
2//!
3//! The host owns discovery and callback invocation, while platform-specific
4//! authorization remains in the plugin. Token-bearing JSON is returned to the
5//! caller only; this module never logs it or emits it as an agent event.
6
7use std::sync::Arc;
8
9use rpi_plugin_sdk::{StbString, StbStringRef};
10use serde_json::Value;
11
12use crate::registry::{RegisteredOAuthProvider, RegistrySnapshot};
13
14#[derive(Debug, thiserror::Error)]
15pub enum OAuthExtensionError {
16    #[error("OAuth provider `{0}` is not registered")]
17    NotFound(String),
18    #[error("OAuth response has unsupported version")]
19    UnsupportedVersion,
20    #[error("OAuth response has invalid kind")]
21    InvalidResponseKind,
22    #[error("OAuth action must be one of begin, exchange, refresh, revoke")]
23    InvalidAction,
24    #[error("OAuth plugin returned status {0}: {1}")]
25    PluginStatus(i32, String),
26    #[error("OAuth plugin returned invalid JSON: {0}")]
27    InvalidResponse(String),
28}
29
30/// Standard response kinds understood by the host. Payload contents remain
31/// provider-defined except for `credential`.
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum OAuthResponseKind {
34    Interaction,
35    Credential,
36    Success,
37    Error,
38}
39
40#[derive(Debug, Clone)]
41pub struct OAuthActionResponse {
42    pub kind: OAuthResponseKind,
43    pub payload: Value,
44}
45
46impl OAuthActionResponse {
47    pub fn parse(value: Value) -> Result<Self, OAuthExtensionError> {
48        let version = value.get("version").and_then(Value::as_u64).unwrap_or(1);
49        if version != 1 {
50            return Err(OAuthExtensionError::UnsupportedVersion);
51        }
52        let kind = match value.get("kind").and_then(Value::as_str) {
53            Some("interaction") => OAuthResponseKind::Interaction,
54            Some("credential") => OAuthResponseKind::Credential,
55            Some("success") => OAuthResponseKind::Success,
56            Some("error") => OAuthResponseKind::Error,
57            _ => return Err(OAuthExtensionError::InvalidResponseKind),
58        };
59        Ok(Self {
60            kind,
61            payload: value.get("payload").cloned().unwrap_or(Value::Null),
62        })
63    }
64
65    /// Parse the host-owned credential envelope. Provider-specific token
66    /// aliases must be converted by the extension before this point.
67    pub fn credential(&self) -> Result<Option<OAuthCredential>, OAuthExtensionError> {
68        if self.kind != OAuthResponseKind::Credential {
69            return Ok(None);
70        }
71        let object = self.payload.as_object().ok_or_else(|| {
72            OAuthExtensionError::InvalidResponse("credential payload must be an object".into())
73        })?;
74        let access = object
75            .get("access")
76            .and_then(Value::as_str)
77            .filter(|value| !value.is_empty())
78            .ok_or_else(|| {
79                OAuthExtensionError::InvalidResponse("missing credential access".into())
80            })?;
81        let refresh = object
82            .get("refresh")
83            .and_then(Value::as_str)
84            .unwrap_or_default();
85        let expires_at = object
86            .get("expires_at")
87            .and_then(Value::as_i64)
88            .unwrap_or(0);
89        Ok(Some(OAuthCredential {
90            access: access.to_string(),
91            refresh: refresh.to_string(),
92            expires_at,
93        }))
94    }
95}
96
97#[derive(Debug, Clone, PartialEq, Eq)]
98pub struct OAuthCredential {
99    pub access: String,
100    pub refresh: String,
101    pub expires_at: i64,
102}
103
104/// A non-secret view of a registered OAuth provider.
105#[derive(Debug, Clone, serde::Serialize)]
106pub struct OAuthProviderInfo {
107    pub plugin: String,
108    pub id: String,
109    #[serde(flatten)]
110    pub manifest: serde_json::Map<String, Value>,
111}
112
113/// List OAuth manifests without exposing callbacks or user data.
114pub fn list(snapshot: &RegistrySnapshot) -> Vec<OAuthProviderInfo> {
115    snapshot.oauth_providers().iter().filter_map(info).collect()
116}
117
118/// Invoke one provider-owned OAuth action.
119///
120/// `params` is action-specific and may contain transient authorization data.
121/// The returned value is intentionally not logged by this layer.
122pub fn request(
123    snapshot: &Arc<RegistrySnapshot>,
124    provider_id: &str,
125    action: &str,
126    params: Value,
127) -> Result<Value, OAuthExtensionError> {
128    if !matches!(action, "begin" | "exchange" | "refresh" | "revoke") {
129        return Err(OAuthExtensionError::InvalidAction);
130    }
131    let provider = snapshot
132        .oauth_provider(provider_id)
133        .ok_or_else(|| OAuthExtensionError::NotFound(provider_id.to_string()))?;
134    invoke(provider, action, params)
135}
136
137fn info(provider: &RegisteredOAuthProvider) -> Option<OAuthProviderInfo> {
138    let mut manifest = serde_json::from_str::<Value>(&provider.manifest_json)
139        .ok()?
140        .as_object()?
141        .clone();
142    let id = manifest.get("id").and_then(Value::as_str)?.to_string();
143    manifest.remove("id");
144    Some(OAuthProviderInfo {
145        plugin: provider.plugin.clone(),
146        id,
147        manifest,
148    })
149}
150
151fn invoke(
152    provider: &RegisteredOAuthProvider,
153    action: &str,
154    params: Value,
155) -> Result<Value, OAuthExtensionError> {
156    let request = serde_json::json!({
157        "action": action,
158        "providerId": provider_id(provider),
159        "input": params,
160    })
161    .to_string();
162    let mut out = StbString::empty();
163    let status = (provider.request_fn)(
164        StbStringRef::from_str(&request),
165        &mut out,
166        provider.user_data,
167    );
168    let response = out.to_string_lossy();
169    if !out.is_empty() {
170        (provider.plugin_free_string)(out);
171    }
172    if status != 0 {
173        return Err(OAuthExtensionError::PluginStatus(status, response));
174    }
175    serde_json::from_str(&response)
176        .map_err(|error| OAuthExtensionError::InvalidResponse(error.to_string()))
177}
178
179fn provider_id(provider: &RegisteredOAuthProvider) -> String {
180    // Registration validates this field, so a malformed value can only be
181    // present if a future host mutates the record. Return an empty id rather
182    // than panic across an extension boundary.
183    serde_json::from_str::<Value>(&provider.manifest_json)
184        .ok()
185        .and_then(|value| value.get("id").and_then(Value::as_str).map(str::to_owned))
186        .unwrap_or_default()
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192    use crate::registry::{ExtensionRegistry, RegisteredOAuthProvider};
193    use std::ffi::c_void;
194    use std::sync::atomic::{AtomicUsize, Ordering};
195
196    static FREED: AtomicUsize = AtomicUsize::new(0);
197
198    extern "C" fn free_output(value: StbString) {
199        FREED.fetch_add(1, Ordering::SeqCst);
200        if !value.is_empty() {
201            unsafe {
202                let bytes = std::slice::from_raw_parts(value.ptr as *const u8, value.len);
203                drop(Box::from_raw(bytes as *const [u8] as *mut [u8]));
204            }
205        }
206    }
207
208    extern "C" fn callback(
209        request: StbStringRef,
210        out: *mut StbString,
211        _user_data: *mut c_void,
212    ) -> i32 {
213        let request = unsafe { request.as_str() };
214        let value: Value = serde_json::from_str(request).unwrap();
215        if value.get("action").and_then(Value::as_str) == Some("refresh") {
216            unsafe {
217                *out = StbString::from_string(r#"{"access":"secret"}"#.to_string());
218            }
219            0
220        } else {
221            unsafe {
222                *out = StbString::from_string(r#"{"ok":true}"#.to_string());
223            }
224            0
225        }
226    }
227
228    fn snapshot() -> Arc<RegistrySnapshot> {
229        let mut registry = ExtensionRegistry::new();
230        registry.register_oauth_provider(RegisteredOAuthProvider {
231            plugin: "oauth-test".to_string(),
232            manifest_json: r#"{"id":"acme","displayName":"Acme"}"#.to_string(),
233            request_fn: callback,
234            plugin_free_string: free_output,
235            user_data: std::ptr::null_mut(),
236        });
237        Arc::new(registry.snapshot())
238    }
239
240    #[test]
241    fn lists_non_secret_manifest_info() {
242        let providers = list(&snapshot());
243        assert_eq!(providers[0].id, "acme");
244        assert_eq!(providers[0].plugin, "oauth-test");
245        assert!(!providers[0].manifest.contains_key("id"));
246    }
247
248    #[test]
249    fn invokes_action_and_reclaims_plugin_output() {
250        FREED.store(0, Ordering::SeqCst);
251        let result = request(&snapshot(), "acme", "refresh", serde_json::json!({"x": 1})).unwrap();
252        assert_eq!(result["access"], "secret");
253        assert_eq!(FREED.load(Ordering::SeqCst), 1);
254    }
255
256    #[test]
257    fn parses_standard_interaction_and_credential_responses() {
258        let interaction = OAuthActionResponse::parse(serde_json::json!({
259            "version": 1,
260            "kind": "interaction",
261            "payload": {"display": {"url": "opaque"}}
262        }))
263        .unwrap();
264        assert_eq!(interaction.kind, OAuthResponseKind::Interaction);
265
266        let credential = OAuthActionResponse::parse(serde_json::json!({
267            "version": 1,
268            "kind": "credential",
269            "payload": {"access": "a", "refresh": "r", "expires_at": 42}
270        }))
271        .unwrap();
272        assert_eq!(credential.credential().unwrap().unwrap().expires_at, 42);
273    }
274
275    #[test]
276    fn rejects_unknown_response_version_and_kind() {
277        assert!(matches!(
278            OAuthActionResponse::parse(serde_json::json!({"version": 2, "kind": "success"})),
279            Err(OAuthExtensionError::UnsupportedVersion)
280        ));
281        assert!(matches!(
282            OAuthActionResponse::parse(
283                serde_json::json!({"version": 1, "kind": "provider-specific"})
284            ),
285            Err(OAuthExtensionError::InvalidResponseKind)
286        ));
287    }
288
289    #[test]
290    fn rejects_unknown_action_and_provider() {
291        assert!(matches!(
292            request(&snapshot(), "acme", "unknown", Value::Null),
293            Err(OAuthExtensionError::InvalidAction)
294        ));
295        assert!(matches!(
296            request(&snapshot(), "missing", "begin", Value::Null),
297            Err(OAuthExtensionError::NotFound(_))
298        ));
299    }
300}