Skip to main content

pharos_native/runtime/social_connections/
consent_ui.rs

1//! Bounded, actor-scoped OAuth progress and return-page feedback; never stores provider payloads.
2use super::*;
3use std::collections::BTreeMap;
4
5#[derive(Clone, Copy, Debug, PartialEq, Eq)]
6pub(super) enum Outcome {
7    Cancelled,
8    Failed,
9    SelectionRequired,
10}
11
12type ActorKey = (String, String, i64, String, String);
13type Feedback = BTreeMap<ActorKey, (Outcome, std::time::Instant)>;
14
15/// Binds UI feedback to the same immutable scope and hashed local session as consent.
16fn key(scope: &ConnectionScope, provider: SocialProvider, session: &str) -> ActorKey {
17    (
18        scope.application().into(),
19        scope.namespace().into(),
20        scope.owner(),
21        provider.key().into(),
22        session.into(),
23    )
24}
25
26/// Keeps transient UI state bounded and independent of durable credential authority.
27fn feedback() -> &'static std::sync::Mutex<Feedback> {
28    static FEEDBACK: std::sync::OnceLock<std::sync::Mutex<Feedback>> = std::sync::OnceLock::new();
29    FEEDBACK.get_or_init(|| std::sync::Mutex::new(BTreeMap::new()))
30}
31
32/// Replaces fixed feedback for one actor without retaining OAuth codes, states or error descriptions.
33pub(super) fn record(
34    scope: &ConnectionScope,
35    provider: SocialProvider,
36    session: &str,
37    outcome: Option<Outcome>,
38) {
39    if let Ok(mut entries) = feedback().lock() {
40        entries.retain(|_, (_, created)| created.elapsed() < Duration::from_secs(900));
41        let key = key(scope, provider, session);
42        entries.remove(&key);
43        if let Some(outcome) = outcome {
44            if entries.len() >= 128
45                && let Some(oldest) = entries
46                    .iter()
47                    .min_by_key(|(_, (_, time))| *time)
48                    .map(|(key, _)| key.clone())
49            {
50                entries.remove(&oldest);
51            }
52            entries.insert(key, (outcome, std::time::Instant::now()));
53        }
54    }
55}
56
57/// Reads feedback only for the authenticated owner who initiated the consent.
58pub(super) fn outcome(
59    scope: &ConnectionScope,
60    provider: SocialProvider,
61    session: &str,
62) -> Option<Outcome> {
63    feedback()
64        .lock()
65        .ok()?
66        .get(&key(scope, provider, session))
67        .filter(|(_, created)| created.elapsed() < Duration::from_secs(900))
68        .map(|(outcome, _)| *outcome)
69}
70
71/// Exposes pending progress without exposing the authorization URL or state secret.
72pub(super) fn pending(scope: &ConnectionScope, provider: SocialProvider, session: &str) -> bool {
73    pending_registry().lock().is_ok_and(|entries| {
74        entries.values().any(|attempt| {
75            attempt.scope == *scope
76                && attempt.provider == provider
77                && attempt.session == session
78                && attempt.created.elapsed() < Duration::from_secs(300)
79        })
80    })
81}
82
83/// Serializes local promotion with cancellation after network I/O, without holding a lock during HTTP.
84pub(super) fn claim_guard(
85    key: &str,
86    scope: &ConnectionScope,
87    provider: SocialProvider,
88    session: &str,
89) -> Result<std::sync::MutexGuard<'static, BTreeMap<String, Pending>>, SocialError> {
90    let registry = pending_registry().lock().map_err(|_| SocialError::Busy)?;
91    if !registry.get(key).is_some_and(|attempt| {
92        attempt.scope == *scope
93            && attempt.provider == provider
94            && attempt.session == session
95            && attempt.attempt.is_none()
96    }) {
97        return Err(SocialError::AuthorizationRequired);
98    }
99    Ok(registry)
100}
101
102/// Cancels only this session's consent, including claimed exchanges that have not begun promotion.
103pub(super) fn cancel(
104    scope: &ConnectionScope,
105    provider: SocialProvider,
106    session: &str,
107) -> Result<(), SocialError> {
108    let mut removed = false;
109    pending_registry()
110        .lock()
111        .map_err(|_| SocialError::Busy)?
112        .retain(|_, attempt| {
113            let keep = attempt.scope != *scope
114                || attempt.provider != provider
115                || attempt.session != session;
116            removed |= !keep;
117            keep
118        });
119    if removed {
120        record(scope, provider, session, Some(Outcome::Cancelled));
121    }
122    Ok(())
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128    /// Feedback and cancellation cannot cross owners, providers, deployments or initiating sessions.
129    #[test]
130    fn feedback_is_scoped_and_persists_until_the_next_attempt() {
131        let scope = ConnectionScope::new("consent-ui", "deployment", 7).unwrap();
132        record(
133            &scope,
134            SocialProvider::Facebook,
135            "session-a",
136            Some(Outcome::Failed),
137        );
138        assert_eq!(
139            outcome(&scope, SocialProvider::Facebook, "session-a"),
140            Some(Outcome::Failed)
141        );
142        for (scope, provider, session) in [
143            (&scope, SocialProvider::Facebook, "session-b"),
144            (&scope, SocialProvider::X, "session-a"),
145            (
146                &ConnectionScope::new("consent-ui", "another", 7).unwrap(),
147                SocialProvider::Facebook,
148                "session-a",
149            ),
150        ] {
151            assert!(outcome(scope, provider, session).is_none());
152        }
153        record(&scope, SocialProvider::Facebook, "session-a", None);
154        assert!(outcome(&scope, SocialProvider::Facebook, "session-a").is_none());
155    }
156}