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