Skip to main content

pharos_native/runtime/social_connections/
settings.rs

1//! App-scoped provisioning in the encrypted vault, separate from each owner account grant.
2use super::*;
3use pharos_runtime_security::credentials::{CredentialBackend, CredentialError};
4
5/// Derives a private configuration reference from trusted application, namespace, owner and provider bindings.
6pub(super) fn reference(scope: &ConnectionScope, provider: SocialProvider) -> String {
7    let value = format!(
8        "{}\0{}\0{}\0{}",
9        scope.application(),
10        scope.namespace(),
11        scope.owner(),
12        provider.key()
13    );
14    format!(
15        "social.registration.{}",
16        dynamic::hex_digest(&Sha256::digest(value.as_bytes()))
17    )
18}
19
20/// Reads owner configuration without borrowing another owner's registration or returning any secret to the browser.
21pub(super) fn load(
22    app: &AppRuntime,
23    scope: &ConnectionScope,
24    provider: SocialProvider,
25) -> Result<Option<Registration>, SocialError> {
26    if let Some(value) = load_shared(app, scope, provider)? {
27        return Ok(Some(value));
28    }
29    let vault = vault(app)?;
30    let bytes = match vault.get_secret(&reference(scope, provider)) {
31        Ok(bytes) => zeroize::Zeroizing::new(bytes),
32        Err(CredentialError::CredentialUnavailable) => return Ok(None),
33        Err(_) => return Err(SocialError::Configuration),
34    };
35    let value: Registration =
36        serde_json::from_slice(&bytes).map_err(|_| SocialError::Configuration)?;
37    if value.application != scope.application()
38        || value.namespace != scope.namespace()
39        || value.provider != provider.key()
40        || value.client_secret_env.is_some()
41    {
42        return Err(SocialError::Configuration);
43    }
44    Ok(Some(value))
45}
46
47/// Names deployment configuration independently of any local user's account connection.
48fn shared_reference(scope: &ConnectionScope, provider: SocialProvider) -> String {
49    let value = format!(
50        "{}\0{}\0{}",
51        scope.application(),
52        scope.namespace(),
53        provider.key()
54    );
55    format!(
56        "social.application.{}",
57        dynamic::hex_digest(&Sha256::digest(value.as_bytes()))
58    )
59}
60
61/// Restores the shared application registration without exposing its secret through status rendering.
62fn load_shared(
63    app: &AppRuntime,
64    scope: &ConnectionScope,
65    provider: SocialProvider,
66) -> Result<Option<Registration>, SocialError> {
67    let bytes = match vault(app)?.get_secret(&shared_reference(scope, provider)) {
68        Ok(bytes) => zeroize::Zeroizing::new(bytes),
69        Err(CredentialError::CredentialUnavailable) => return Ok(None),
70        Err(_) => return Err(SocialError::Configuration),
71    };
72    let value: Registration =
73        serde_json::from_slice(&bytes).map_err(|_| SocialError::Configuration)?;
74    if value.application != scope.application()
75        || value.namespace != scope.namespace()
76        || value.provider != provider.key()
77        || value.client_secret_env.is_some()
78    {
79        return Err(SocialError::Configuration);
80    }
81    Ok(Some(value))
82}
83
84/// Requires explicit app opt-in and a real administrative capability; ordinary connection rights are insufficient.
85pub(super) fn can_setup(
86    app: &AppRuntime,
87    declaration: &Value,
88    owner: i64,
89) -> Result<bool, SocialError> {
90    let Some(capability) = declaration.get("setup_capability").and_then(Value::as_str) else {
91        return Ok(false);
92    };
93    if capability.is_empty()
94        || matches!(
95            capability,
96            "public" | "customer" | "authenticated_user" | "account_login" | "account_logout"
97        )
98    {
99        return Err(SocialError::Configuration);
100    }
101    actor_has_route_policy(app, owner, capability).map_err(|_| SocialError::Transport)
102}
103
104/// Builds a callback from the host-selected route and this browser origin, never a posted redirect URL.
105pub(super) fn callback_url(
106    app: &AppRuntime,
107    request: &http::HttpRequest,
108    provider: SocialProvider,
109    preview_path: Option<&str>,
110) -> Result<String, SocialError> {
111    let hosts = request
112        .headers
113        .lines()
114        .filter_map(|line| {
115            let (name, value) = line.split_once(':')?;
116            name.trim()
117                .eq_ignore_ascii_case("host")
118                .then_some(value.trim())
119        })
120        .collect::<Vec<_>>();
121    if hosts.len() != 1
122        || hosts[0].contains(['/', '\\', '?', '#', '@'])
123        || hosts[0].chars().any(char::is_whitespace)
124    {
125        return Err(SocialError::Configuration);
126    }
127    let scheme = dynamic::request_origin_scheme(
128        Some(request),
129        super::super::connection::NativeTransportType::Tcp,
130        None,
131        None,
132    );
133    let secure_referrer = request
134        .headers
135        .lines()
136        .filter_map(|line| line.split_once(':'))
137        .any(|(name, value)| {
138            name.trim().eq_ignore_ascii_case("referer")
139                && Url::parse(value.trim()).is_ok_and(|url| {
140                    url.scheme() == "https" && url.authority().eq_ignore_ascii_case(hosts[0])
141                })
142        });
143    let scheme = if secure_referrer { "https" } else { scheme };
144    let path = preview_path
145        .map(|path| path.replace("{provider}", provider.key()))
146        .unwrap_or_else(|| {
147            app_scope_path(
148                app,
149                &format!("/__pharos/social/{}/callback", provider.key()),
150            )
151        });
152    let value = format!("{scheme}://{}{path}", hosts[0]);
153    let parsed = Url::parse(&value).map_err(|_| SocialError::Configuration)?;
154    if value.len() > 4096
155        || parsed.fragment().is_some()
156        || !parsed.username().is_empty()
157        || parsed.password().is_some()
158        || (parsed.scheme() != "https"
159            && !(parsed.scheme() == "http"
160                && matches!(parsed.host_str(), Some("localhost" | "127.0.0.1" | "[::1]"))))
161    {
162        return Err(SocialError::Configuration);
163    }
164    Ok(value)
165}
166
167/// Installs one immutable app registration; future replacements require an explicit credential-rotation operation.
168pub(super) fn save(
169    app: &AppRuntime,
170    scope: &ConnectionScope,
171    provider: SocialProvider,
172    client_id: &str,
173    client_secret: &str,
174    callback: &str,
175) -> Result<(), SocialError> {
176    if client_id.is_empty()
177        || client_id.len() > 512
178        || !client_id.bytes().all(|byte| byte.is_ascii_graphic())
179        || client_secret.len() > 8192
180        || client_secret.chars().any(char::is_control)
181    {
182        return Err(SocialError::Configuration);
183    }
184    let secret = if client_secret.is_empty() {
185        None
186    } else {
187        Some(SecretValue::new(client_secret.to_owned()).map_err(|_| SocialError::Configuration)?)
188    };
189    let revision = pharos_runtime_social::repository::random_revision()?;
190    SocialClient::new(provider, client_id, callback, &revision, secret.as_ref())?;
191    // Serialize initial setup with local callback promotion and refuse silent registration replacement.
192    let _pending = pending_registry().lock().map_err(|_| SocialError::Busy)?;
193    if registration(app, scope, provider)?.is_some() {
194        return Err(SocialError::Busy);
195    }
196    let vault = vault(app)?;
197    let secret_ref = secret.as_ref().map(|_| {
198        format!(
199            "social.client.{}",
200            dynamic::hex_digest(&Sha256::digest(
201                format!("{}\0{revision}", shared_reference(scope, provider)).as_bytes()
202            ))
203        )
204    });
205    if let Some(key) = &secret_ref {
206        vault
207            .put_secret(key, client_secret.as_bytes())
208            .map_err(|_| SocialError::Transport)?;
209    }
210    let registration = Registration {
211        application: scope.application().into(),
212        namespace: scope.namespace().into(),
213        provider: provider.key().into(),
214        client_id: client_id.into(),
215        callback: callback.into(),
216        revision,
217        client_secret_env: None,
218        client_secret_ref: secret_ref.clone(),
219        instagram_account_id: None,
220    };
221    let bytes = zeroize::Zeroizing::new(
222        serde_json::to_vec(&registration).map_err(|_| SocialError::Configuration)?,
223    );
224    if vault
225        .put_secret(&shared_reference(scope, provider), &bytes)
226        .is_err()
227    {
228        if let Some(key) = &secret_ref {
229            let _ = vault.unset_secret(key);
230        }
231        return Err(SocialError::Transport);
232    }
233    Ok(())
234}