Skip to main content

minco_interaction/
support_entry.rs

1use chrono::{DateTime, TimeDelta, Utc};
2use serde::{Deserialize, Serialize};
3use sha2::{Digest, Sha256};
4use std::{collections::BTreeMap, fmt};
5use subtle::ConstantTimeEq;
6use url::Url;
7use uuid::Uuid;
8use zeroize::Zeroizing;
9
10const MAX_HANDOFF_TTL_SECONDS: i64 = 15 * 60;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
13#[serde(rename_all = "snake_case")]
14pub enum SupportSurface {
15    Widget,
16    Portal,
17    Extension,
18    Api,
19    Mobile,
20}
21
22#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(deny_unknown_fields)]
24pub struct SupportResourceReference {
25    pub system: String,
26    pub resource_type: String,
27    pub resource_id: String,
28}
29
30#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
31#[serde(deny_unknown_fields)]
32pub struct SupportContext {
33    pub page_url: String,
34    #[serde(default, alias = "page_title", skip_serializing_if = "Option::is_none")]
35    pub optional_page_title: Option<String>,
36    #[serde(default, alias = "route_name", skip_serializing_if = "Option::is_none")]
37    pub optional_route_name: Option<String>,
38    #[serde(default, alias = "release_id", skip_serializing_if = "Option::is_none")]
39    pub optional_release_id: Option<String>,
40    #[serde(default, alias = "request_id", skip_serializing_if = "Option::is_none")]
41    pub optional_request_id: Option<String>,
42    #[serde(default, alias = "locale", skip_serializing_if = "Option::is_none")]
43    pub optional_locale: Option<String>,
44    #[serde(default, alias = "timezone", skip_serializing_if = "Option::is_none")]
45    pub optional_timezone: Option<String>,
46    #[serde(default, alias = "viewport", skip_serializing_if = "Option::is_none")]
47    pub optional_viewport: Option<String>,
48    #[serde(
49        default,
50        alias = "selected_text",
51        skip_serializing_if = "Option::is_none"
52    )]
53    pub optional_selected_text: Option<String>,
54    #[serde(default)]
55    pub resource_references: Vec<SupportResourceReference>,
56}
57
58#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
59#[serde(deny_unknown_fields)]
60pub struct SupportBootstrap {
61    pub schema_version: u32,
62    pub project_id: String,
63    pub portal_origin: String,
64    pub label: String,
65    pub brand: String,
66    pub enabled_surfaces: Vec<SupportSurface>,
67    pub screenshot_enabled: bool,
68    pub voice_enabled: bool,
69    pub file_enabled: bool,
70    pub attachment_limits: crate::AttachmentLimits,
71    pub recording_limit: u64,
72    pub privacy_notice: String,
73}
74
75#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
76#[serde(transparent)]
77pub struct SupportHandoffId(pub Uuid);
78
79impl SupportHandoffId {
80    #[must_use]
81    pub fn new() -> Self {
82        Self(Uuid::now_v7())
83    }
84}
85
86impl Default for SupportHandoffId {
87    fn default() -> Self {
88        Self::new()
89    }
90}
91
92impl fmt::Display for SupportHandoffId {
93    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
94        self.0.fmt(formatter)
95    }
96}
97
98#[derive(Clone, PartialEq, Eq)]
99pub struct SupportHandoffToken(Zeroizing<String>);
100
101impl SupportHandoffToken {
102    /// Generates a 244-bit opaque bearer from two independent `UUIDv4` values.
103    #[must_use]
104    pub fn generate() -> Self {
105        Self(Zeroizing::new(format!(
106            "{}{}",
107            Uuid::new_v4().simple(),
108            Uuid::new_v4().simple()
109        )))
110    }
111
112    pub fn parse(value: impl Into<String>) -> Result<Self, SupportEntryError> {
113        let value = value.into();
114        if value.len() != 64 || !value.bytes().all(|byte| byte.is_ascii_hexdigit()) {
115            return Err(SupportEntryError::InvalidToken);
116        }
117        Ok(Self(Zeroizing::new(value)))
118    }
119
120    #[must_use]
121    pub fn expose_sensitive(&self) -> &str {
122        self.0.as_str()
123    }
124
125    #[must_use]
126    pub fn digest(&self) -> SupportHandoffDigest {
127        SupportHandoffDigest(hex::encode(Sha256::digest(self.0.as_bytes())))
128    }
129}
130
131impl fmt::Debug for SupportHandoffToken {
132    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
133        formatter.write_str("SupportHandoffToken([REDACTED])")
134    }
135}
136
137#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
138#[serde(transparent)]
139pub struct SupportHandoffDigest(String);
140
141impl SupportHandoffDigest {
142    pub fn parse(value: impl Into<String>) -> Result<Self, SupportEntryError> {
143        let value = value.into().to_ascii_lowercase();
144        if value.len() != 64 || !value.bytes().all(|byte| byte.is_ascii_hexdigit()) {
145            return Err(SupportEntryError::InvalidDigest);
146        }
147        Ok(Self(value))
148    }
149
150    #[must_use]
151    pub fn as_str(&self) -> &str {
152        &self.0
153    }
154
155    #[must_use]
156    pub fn matches_token(&self, token: &SupportHandoffToken) -> bool {
157        let candidate = token.digest();
158        self.0.as_bytes().ct_eq(candidate.0.as_bytes()).into()
159    }
160}
161
162impl fmt::Debug for SupportHandoffDigest {
163    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
164        formatter.write_str("SupportHandoffDigest([REDACTED])")
165    }
166}
167
168#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
169pub struct SupportHandoff {
170    pub id: SupportHandoffId,
171    pub digest: SupportHandoffDigest,
172    pub project_id: String,
173    pub portal_origin: String,
174    pub return_location: String,
175    pub requester_subject: String,
176    pub requester_permissions: Vec<String>,
177    pub surface: SupportSurface,
178    pub context: SupportContext,
179    pub correlation_id: Uuid,
180    pub expires_at: DateTime<Utc>,
181    #[serde(default, skip_serializing_if = "Option::is_none")]
182    pub consumed_result: Option<SupportHandoffResult>,
183}
184
185impl fmt::Debug for SupportHandoff {
186    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
187        formatter
188            .debug_struct("SupportHandoff")
189            .field("id", &self.id)
190            .field("digest", &self.digest)
191            .field("project_id", &self.project_id)
192            .field("portal_origin", &self.portal_origin)
193            .field("return_location", &"[BOUNDED]")
194            .field("requester_subject", &"[TRUSTED]")
195            .field("permission_count", &self.requester_permissions.len())
196            .field("surface", &self.surface)
197            .field("context", &"[BOUNDED]")
198            .field("correlation_id", &self.correlation_id)
199            .field("expires_at", &self.expires_at)
200            .field("consumed", &self.consumed_result.is_some())
201            .finish()
202    }
203}
204
205#[derive(Clone, PartialEq, Eq)]
206pub struct SupportHandoffGrant {
207    pub id: SupportHandoffId,
208    pub token: SupportHandoffToken,
209    pub portal_origin: String,
210    pub expires_at: DateTime<Utc>,
211}
212
213impl SupportHandoffGrant {
214    #[must_use]
215    pub fn launch_url(&self) -> String {
216        format!(
217            "{}/#handoff={}",
218            self.portal_origin.trim_end_matches('/'),
219            self.token.expose_sensitive()
220        )
221    }
222}
223
224impl fmt::Debug for SupportHandoffGrant {
225    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
226        formatter
227            .debug_struct("SupportHandoffGrant")
228            .field("id", &self.id)
229            .field("token", &self.token)
230            .field("portal_origin", &self.portal_origin)
231            .field("expires_at", &self.expires_at)
232            .finish()
233    }
234}
235
236#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
237pub struct SupportHandoffResult {
238    pub ticket_id: Uuid,
239    pub requester_session_id: Uuid,
240}
241
242#[derive(Debug, Clone, PartialEq, Eq)]
243pub struct SupportLocationPolicy {
244    pub portal_origin: String,
245    pub allowed_return_paths: BTreeMap<String, Vec<String>>,
246}
247
248impl SupportLocationPolicy {
249    pub fn validate(&self) -> Result<(), SupportEntryError> {
250        exact_origin(&self.portal_origin)?;
251        for (origin, prefixes) in &self.allowed_return_paths {
252            exact_origin(origin)?;
253            if prefixes.is_empty()
254                || prefixes
255                    .iter()
256                    .any(|prefix| !prefix.starts_with('/') || prefix.contains(['?', '#']))
257            {
258                return Err(SupportEntryError::InvalidReturnPolicy);
259            }
260        }
261        Ok(())
262    }
263
264    pub fn validate_return_location(&self, value: &str) -> Result<String, SupportEntryError> {
265        self.validate()?;
266        let location = Url::parse(value).map_err(|_| SupportEntryError::InvalidReturnLocation)?;
267        if location.username() != ""
268            || location.password().is_some()
269            || location.query().is_some()
270            || location.fragment().is_some()
271        {
272            return Err(SupportEntryError::InvalidReturnLocation);
273        }
274        let origin = location.origin().ascii_serialization();
275        let allowed = self
276            .allowed_return_paths
277            .get(&origin)
278            .is_some_and(|prefixes| {
279                prefixes
280                    .iter()
281                    .any(|prefix| path_matches(location.path(), prefix))
282            });
283        allowed
284            .then(|| location.to_string())
285            .ok_or(SupportEntryError::ReturnLocationDenied)
286    }
287}
288
289#[allow(clippy::too_many_arguments)]
290pub fn issue_support_handoff(
291    project_id: impl Into<String>,
292    requester_subject: impl Into<String>,
293    requester_permissions: Vec<String>,
294    surface: SupportSurface,
295    context: SupportContext,
296    return_location: &str,
297    correlation_id: Uuid,
298    policy: &SupportLocationPolicy,
299    now: DateTime<Utc>,
300    ttl: TimeDelta,
301) -> Result<(SupportHandoff, SupportHandoffGrant), SupportEntryError> {
302    if ttl <= TimeDelta::zero() || ttl > TimeDelta::seconds(MAX_HANDOFF_TTL_SECONDS) {
303        return Err(SupportEntryError::InvalidTtl);
304    }
305    let portal_origin = exact_origin(&policy.portal_origin)?;
306    let return_location = policy.validate_return_location(return_location)?;
307    let project_id = bounded_required(project_id.into(), 100)?;
308    let requester_subject = bounded_required(requester_subject.into(), 300)?;
309    validate_context(&context)?;
310    if requester_permissions.len() > 64
311        || requester_permissions
312            .iter()
313            .any(|value| bounded_required(value.clone(), 160).is_err())
314    {
315        return Err(SupportEntryError::InvalidPermissions);
316    }
317    let id = SupportHandoffId::new();
318    let token = SupportHandoffToken::generate();
319    let expires_at = now + ttl;
320    let handoff = SupportHandoff {
321        id,
322        digest: token.digest(),
323        project_id,
324        portal_origin: portal_origin.clone(),
325        return_location,
326        requester_subject,
327        requester_permissions,
328        surface,
329        context,
330        correlation_id,
331        expires_at,
332        consumed_result: None,
333    };
334    let grant = SupportHandoffGrant {
335        id,
336        token,
337        portal_origin,
338        expires_at,
339    };
340    Ok((handoff, grant))
341}
342
343fn validate_context(context: &SupportContext) -> Result<(), SupportEntryError> {
344    let bounded_values = [
345        (context.optional_page_title.as_deref(), 2_000),
346        (context.optional_route_name.as_deref(), 2_000),
347        (context.optional_release_id.as_deref(), 2_000),
348        (context.optional_request_id.as_deref(), 2_000),
349        (context.optional_locale.as_deref(), 40),
350        (context.optional_timezone.as_deref(), 100),
351        (context.optional_viewport.as_deref(), 32),
352        (context.optional_selected_text.as_deref(), 2_000),
353    ];
354    if context.page_url.chars().count() > 4_096
355        || context.resource_references.len() > 8
356        || bounded_values.into_iter().any(|(value, maximum)| {
357            value.is_some_and(|value| {
358                value.trim().is_empty()
359                    || value.chars().count() > maximum
360                    || value.chars().any(char::is_control)
361            })
362        })
363        || context.resource_references.iter().any(|reference| {
364            invalid_bounded(&reference.system, 100)
365                || invalid_bounded(&reference.resource_type, 100)
366                || invalid_bounded(&reference.resource_id, 300)
367        })
368        || context.optional_viewport.as_ref().is_some_and(|viewport| {
369            let Some((width, height)) = viewport.split_once('x') else {
370                return true;
371            };
372            width.is_empty()
373                || height.is_empty()
374                || width.len() > 6
375                || height.len() > 6
376                || !width.bytes().all(|byte| byte.is_ascii_digit())
377                || !height.bytes().all(|byte| byte.is_ascii_digit())
378        })
379    {
380        return Err(SupportEntryError::InvalidContext);
381    }
382    let page = Url::parse(&context.page_url).map_err(|_| SupportEntryError::InvalidContext)?;
383    if !web_url_is_transport_safe(&page)
384        || page.username() != ""
385        || page.password().is_some()
386        || page.query().is_some()
387        || page.fragment().is_some()
388    {
389        return Err(SupportEntryError::InvalidContext);
390    }
391    Ok(())
392}
393
394fn exact_origin(value: &str) -> Result<String, SupportEntryError> {
395    let url = Url::parse(value).map_err(|_| SupportEntryError::InvalidPortalOrigin)?;
396    if !web_url_is_transport_safe(&url)
397        || url.username() != ""
398        || url.password().is_some()
399        || url.query().is_some()
400        || url.fragment().is_some()
401        || !matches!(url.path(), "" | "/")
402    {
403        return Err(SupportEntryError::InvalidPortalOrigin);
404    }
405    Ok(url.origin().ascii_serialization())
406}
407
408fn web_url_is_transport_safe(url: &Url) -> bool {
409    url.scheme() == "https"
410        || (url.scheme() == "http"
411            && matches!(
412                url.host_str(),
413                Some("localhost" | "127.0.0.1" | "[::1]" | "::1")
414            ))
415}
416
417fn path_matches(path: &str, prefix: &str) -> bool {
418    prefix == "/"
419        || path == prefix
420        || path
421            .strip_prefix(prefix)
422            .is_some_and(|rest| rest.starts_with('/'))
423}
424
425fn bounded_required(value: String, maximum: usize) -> Result<String, SupportEntryError> {
426    if invalid_bounded(&value, maximum) {
427        Err(SupportEntryError::InvalidTrustedValue)
428    } else {
429        Ok(value)
430    }
431}
432
433fn invalid_bounded(value: &str, maximum: usize) -> bool {
434    value.trim().is_empty()
435        || value.chars().count() > maximum
436        || value.chars().any(char::is_control)
437}
438
439#[derive(Debug, thiserror::Error, PartialEq, Eq)]
440pub enum SupportEntryError {
441    #[error("support handoff token is invalid")]
442    InvalidToken,
443    #[error("support handoff digest is invalid")]
444    InvalidDigest,
445    #[error("support handoff lifetime must be positive and no more than 15 minutes")]
446    InvalidTtl,
447    #[error("portal origin must be one exact HTTP or HTTPS origin")]
448    InvalidPortalOrigin,
449    #[error("return location policy is invalid")]
450    InvalidReturnPolicy,
451    #[error("return location is invalid")]
452    InvalidReturnLocation,
453    #[error("return location is not allowed")]
454    ReturnLocationDenied,
455    #[error("trusted handoff value is invalid")]
456    InvalidTrustedValue,
457    #[error("requester permissions are invalid")]
458    InvalidPermissions,
459    #[error("support context is invalid or exceeds its bounds")]
460    InvalidContext,
461}
462
463#[cfg(test)]
464mod tests {
465    use super::*;
466
467    fn policy() -> SupportLocationPolicy {
468        SupportLocationPolicy {
469            portal_origin: "https://support.example.test".into(),
470            allowed_return_paths: BTreeMap::from([(
471                "https://app.example.test".into(),
472                vec!["/orders".into()],
473            )]),
474        }
475    }
476
477    #[test]
478    fn bearer_is_redacted_digest_only_and_location_is_exact() {
479        let context = SupportContext {
480            page_url: "https://app.example.test/orders/7".into(),
481            ..SupportContext::default()
482        };
483        let (stored, grant) = issue_support_handoff(
484            "project",
485            "subject",
486            vec!["ticketing.create".into()],
487            SupportSurface::Widget,
488            context,
489            "https://app.example.test/orders/7",
490            Uuid::now_v7(),
491            &policy(),
492            Utc::now(),
493            TimeDelta::minutes(5),
494        )
495        .unwrap();
496        assert!(stored.digest.matches_token(&grant.token));
497        assert!(
498            !serde_json::to_string(&stored)
499                .unwrap()
500                .contains(grant.token.expose_sensitive())
501        );
502        assert!(!format!("{stored:?}{grant:?}").contains(grant.token.expose_sensitive()));
503        assert!(grant.launch_url().contains("#handoff="));
504        assert!(!grant.launch_url().contains('?'));
505    }
506
507    #[test]
508    fn path_prefix_is_segment_bounded() {
509        assert!(
510            policy()
511                .validate_return_location("https://app.example.test/orders/7")
512                .is_ok()
513        );
514        assert_eq!(
515            policy().validate_return_location("https://app.example.test/orders-admin"),
516            Err(SupportEntryError::ReturnLocationDenied)
517        );
518    }
519
520    #[test]
521    fn browser_context_aliases_are_accepted_and_all_context_is_bounded() {
522        let context: SupportContext = serde_json::from_value(serde_json::json!({
523            "page_url": "https://app.example.test/orders/7",
524            "page_title": "Order",
525            "locale": "en-AU",
526            "viewport": "1440x900"
527        }))
528        .unwrap();
529        assert_eq!(context.optional_page_title.as_deref(), Some("Order"));
530        assert!(validate_context(&context).is_ok());
531
532        let mut invalid = context;
533        invalid.resource_references.push(SupportResourceReference {
534            system: "orders".into(),
535            resource_type: "order".into(),
536            resource_id: "\n".into(),
537        });
538        assert_eq!(
539            validate_context(&invalid),
540            Err(SupportEntryError::InvalidContext)
541        );
542    }
543
544    #[test]
545    fn non_local_plain_http_origins_fail_closed() {
546        assert_eq!(
547            exact_origin("http://support.example.test"),
548            Err(SupportEntryError::InvalidPortalOrigin)
549        );
550        assert_eq!(
551            exact_origin("http://localhost:3000").unwrap(),
552            "http://localhost:3000"
553        );
554    }
555}