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 #[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}