diff --git a/credentialsd/src/credential_service/mod.rs b/credentialsd/src/credential_service/mod.rs index 7cc373f..200900b 100644 --- a/credentialsd/src/credential_service/mod.rs +++ b/credentialsd/src/credential_service/mod.rs @@ -392,7 +392,7 @@ mod test { credential_service::usb::InProcessUsbHandler, dbus::test::{DummyFlowServer, DummyUiServer}, model::CredentialRequest, - webauthn, + webauthn::{self, NavigationContext}, }; use credentialsd_common::model::Operation; @@ -452,18 +452,13 @@ mod test { fn create_credential_request() -> CredentialRequest { let challenge = "Ox0AXQz7WUER7BGQFzvVrQbReTkS3sepVGj26qfUhhrWSarkDbGF4T4NuCY1aAwHYzOzKMJJ2YRSatetl0D9bQ"; - let origin = "webauthn.io".to_string(); - let is_cross_origin = false; - let client_data_json = webauthn::format_client_data_json( - Operation::Create, - challenge, - &origin, - is_cross_origin, - ); + let origin = NavigationContext::SameOrigin("https://webauthn.io".parse().unwrap()); + let client_data_json = + webauthn::format_client_data_json(Operation::Create, challenge, &origin); let client_data_hash = webauthn::create_client_data_hash(&client_data_json); let make_request = MakeCredentialRequest { hash: client_data_hash, - origin: "webauthn.io".to_string(), + origin: "https://webauthn.io".to_string(), relying_party: Ctap2PublicKeyCredentialRpEntity { id: "webauthn.io".to_string(), name: Some("webauthn.io".to_string()), diff --git a/credentialsd/src/dbus/gateway.rs b/credentialsd/src/dbus/gateway.rs index 2b7c97c..513126f 100644 --- a/credentialsd/src/dbus/gateway.rs +++ b/credentialsd/src/dbus/gateway.rs @@ -26,7 +26,7 @@ use crate::{ CredentialRequestController, }, model::{CredentialRequest, CredentialResponse}, - webauthn::Origin, + webauthn::{AppId, NavigationContext, Origin}, }; pub const SERVICE_NAME: &str = "xyz.iinuwa.credentialsd.Credentials"; @@ -224,26 +224,49 @@ impl CredentialGateway, request: CreateCredentialRequest, ) -> Result { - let (_origin, is_same_origin, _top_origin) = - check_origin(request.origin.as_deref(), request.is_same_origin) - .await - .map_err(Error::from)?; + // TODO: Add authorization check for privileged client. + let top_origin = if request.is_same_origin.unwrap_or_default() { + None + } else { + // TODO: Once we modify the models to convey the top-origin in cross origin requests to the UI, we can remove this error message. + // We should still reject cross-origin requests for conditionally-mediated requests. + tracing::warn!("Client attempted to issue cross-origin request for credentials, which are not supported by this platform."); + return Err(WebAuthnError::NotAllowedError.into()); + }; + let Some(origin) = request + .origin + .as_ref() + .map(|o| { + o.parse::().map_err(|_| { + tracing::warn!("Invalid origin specified: {:?}", request.origin); + Error::SecurityError + }) + }) + .transpose()? + else { + tracing::warn!( + "Caller requested implicit origin, which is not yet implemented. Rejecting request." + ); + return Err(Error::SecurityError); + }; + let request_environment = check_origin_from_privileged_client(origin, top_origin)?; if let ("publicKey", Some(_)) = (request.r#type.as_ref(), &request.public_key) { - if !is_same_origin { - // TODO: Once we modify the models to convey the top-origin in cross origin requests to the UI, we can remove this error message. - // We should still reject cross-origin requests for conditionally-mediated requests. - tracing::warn!("Client attempted to issue cross-origin request for credentials, which are not supported by this platform."); - return Err(WebAuthnError::NotAllowedError.into()); - } - let (make_cred_request, client_data_json) = - create_credential_request_try_into_ctap2(&request).map_err(|e| { - if let WebAuthnError::TypeError = e { - tracing::error!( - "Could not parse passkey creation request. Rejecting request." - ); - } - e - })?; + // TODO: assert that RP ID is bound to origin: + // - if RP ID is not set, set the RP ID to the origin's effective domain + // - if RP ID is set, assert that it matches origin's effective domain + // - if RP ID is set, but origin's effective domain doesn't match + // - query for related origins, if supported + // - fail if not supported, or if RP ID doesn't match any related origins. + let (make_cred_request, client_data_json) = create_credential_request_try_into_ctap2( + &request, + &request_environment, + ) + .map_err(|e| { + if let WebAuthnError::TypeError = e { + tracing::error!("Could not parse passkey creation request. Rejecting request."); + } + e + })?; if make_cred_request.algorithms.is_empty() { tracing::info!("No supported algorithms given in request. Rejecting request."); return Err(Error::NotSupportedError); @@ -291,16 +314,33 @@ impl CredentialGateway, request: GetCredentialRequest, ) -> Result { - let (_origin, is_same_origin, _top_origin) = - check_origin(request.origin.as_deref(), request.is_same_origin) - .await - .map_err(Error::from)?; + // TODO: Add authorization check for privileged client. + let top_origin = if request.is_same_origin.unwrap_or_default() { + None + } else { + // TODO: Once we modify the models to convey the top-origin in cross origin requests to the UI, we can remove this error message. + // We should still reject cross-origin requests for conditionally-mediated requests. + tracing::warn!("Client attempted to issue cross-origin request for credentials, which are not supported by this platform."); + return Err(WebAuthnError::NotAllowedError.into()); + }; + let Some(origin) = request + .origin + .as_ref() + .map(|o| { + o.parse::().map_err(|_| { + tracing::warn!("Invalid origin specified: {:?}", request.origin); + Error::SecurityError + }) + }) + .transpose()? + else { + tracing::warn!( + "Caller requested implicit origin, which is not yet implemented. Rejecting request." + ); + return Err(Error::SecurityError); + }; + let request_env = check_origin_from_privileged_client(origin, top_origin)?; if let ("publicKey", Some(_)) = (request.r#type.as_ref(), &request.public_key) { - if !is_same_origin { - // TODO: Once we modify the models to convey the top-origin in cross origin requests to the UI, we can remove this error message. - tracing::warn!("Client attempted to issue cross-origin request for credentials, which are not supported by this platform."); - return Err(WebAuthnError::NotAllowedError.into()); - } // Setup request // TODO: assert that RP ID is bound to origin: @@ -310,7 +350,7 @@ impl CredentialGateway, claimed_origin: Option, claimed_top_origin: Option, -) -> Result<(RequestingApplication, Origin), Error> { +) -> Result<(RequestingApplication, NavigationContext), Error> { let Some(unique_name) = header.sender() else { return Err(Error::SecurityError); }; @@ -385,21 +425,40 @@ async fn validate_app_details( return Err(Error::SecurityError); } // Now we can trust these app detail parameters. - let app_id = format!("app:{claimed_app_id}"); + let Ok(app_id) = claimed_app_id.parse::() else { + tracing::warn!("Invalid app ID passed: {claimed_app_id}"); + return Err(Error::SecurityError); + }; let display_name = claimed_app_display_name.unwrap_or_default(); // Verify that the origin is valid for the given app ID. - let origin = check_origin_from_app( - &app_id, - claimed_origin.as_deref(), - claimed_top_origin.as_deref(), - )?; + let claimed_origin = claimed_origin + .map(|o| { + o.parse().map_err(|_| { + tracing::warn!("Invalid origin passed: {o}"); + Error::SecurityError + }) + }) + .transpose()?; + let request_env = if let Some(claimed_origin) = claimed_origin { + let claimed_top_origin = claimed_top_origin + .map(|o| { + o.parse().map_err(|_| { + tracing::warn!("Invalid origin passed: {o}"); + Error::SecurityError + }) + }) + .transpose()?; + check_origin_from_app(&app_id, claimed_origin, claimed_top_origin)? + } else { + NavigationContext::SameOrigin(Origin::AppId(app_id)) + }; let app_details = RequestingApplication { name: display_name, - path: app_id, + path: claimed_app_id, pid, }; - Ok((app_details, origin)) + Ok((app_details, request_env)) } async fn should_trust_app_id(pid: u32) -> bool { @@ -449,10 +508,10 @@ async fn should_trust_app_id(pid: u32) -> bool { } fn check_origin_from_app<'a>( - app_id: &str, - origin: Option<&str>, - top_origin: Option<&str>, -) -> Result { + app_id: &AppId, + origin: Origin, + top_origin: Option, +) -> Result { let trusted_clients = [ "org.mozilla.firefox", "xyz.iinuwa.credentialsd.DemoCredentialsUi", @@ -461,75 +520,28 @@ fn check_origin_from_app<'a>( if is_privileged_client { check_origin_from_privileged_client(origin, top_origin) } else { - Ok(Origin::AppId(app_id.to_string())) + Ok(NavigationContext::SameOrigin(Origin::AppId(app_id.clone()))) } } fn check_origin_from_privileged_client( - origin: Option<&str>, - top_origin: Option<&str>, -) -> Result { - let origin = match (origin, top_origin) { - (Some(origin), top_origin) => { - if !origin.starts_with("https://") { - tracing::warn!( - "Caller requested non-HTTPS schemed origin, which is not supported." - ); - return Err(WebAuthnError::SecurityError); - } - if let Some(top_origin) = top_origin { - if origin == top_origin { - Origin::SameOrigin(origin.to_string()) - } else { - Origin::CrossOrigin((origin.to_string(), top_origin.to_string())) - } + origin: Origin, + top_origin: Option, +) -> Result { + match (origin, top_origin) { + (origin @ Origin::Https { .. }, None) => Ok(NavigationContext::SameOrigin(origin)), + (origin @ Origin::Https { .. }, Some(top_origin @ Origin::Https { .. })) => { + if origin == top_origin { + Ok(NavigationContext::SameOrigin(origin)) } else { - Origin::SameOrigin(origin.to_string()) + Ok(NavigationContext::CrossOrigin((origin, top_origin))) } } - (None, Some(_)) => { - tracing::warn!("Top origin cannot be set if origin is not set."); + _ => { + tracing::warn!("Caller requested non-HTTPS schemed origin, which is not supported."); return Err(WebAuthnError::SecurityError); } - (None, None) => { - tracing::warn!("No origin given. Rejecting request."); - return Err(WebAuthnError::SecurityError); - } - }; - - if let Origin::CrossOrigin(_) = origin { - tracing::warn!("Client attempted to issue cross-origin request for credentials, which are not supported by this platform."); - return Err(WebAuthnError::NotAllowedError); - }; - Ok(origin) -} - -async fn check_origin( - origin: Option<&str>, - is_same_origin: Option, - // TODO: Replace is_same_origin with explicit top_origin - // top_origin: Option<&str>, -) -> Result<(String, bool, String), WebAuthnError> { - let origin = if let Some(origin) = origin { - origin.to_string() - } else { - tracing::warn!( - "Caller requested implicit origin, which is not yet implemented. Rejecting request." - ); - return Err(WebAuthnError::SecurityError); - }; - if !origin.starts_with("https://") { - tracing::warn!("Caller requested non-HTTPS schemed origin, which is not supported."); - return Err(WebAuthnError::SecurityError); } - let is_same_origin = is_same_origin.unwrap_or(false); - let top_origin = if is_same_origin { - origin.clone() - } else { - tracing::warn!("Client attempted to issue cross-origin request for credentials, which are not supported by this platform."); - return Err(WebAuthnError::NotAllowedError); - }; - Ok((origin, true, top_origin)) } #[allow(clippy::enum_variant_names)] @@ -593,17 +605,26 @@ impl From for Error { mod test { use credentialsd_common::model::WebAuthnError; - use crate::dbus::gateway::check_origin; + use crate::webauthn::{NavigationContext, Origin}; - #[tokio::test] - async fn test_only_https_origins() { - let check = |origin: &'static str| async { check_origin(Some(origin), Some(true)).await }; + use super::check_origin_from_privileged_client; + fn check_same_origin(origin: &str) -> Result { + let origin = origin.parse().unwrap(); + check_origin_from_privileged_client(origin, None) + } + + #[test] + fn test_only_https_origins() { assert!(matches!( - check("https://example.com").await, - Ok((o, ..)) if o == "https://example.com" - )); + check_same_origin("https://example.com"), + Ok(NavigationContext::SameOrigin(Origin::Https { host, .. })) if host == "example.com" + )) + } + + #[test] + fn test_privileged_client_cannot_set_http_origins() { assert!(matches!( - check("http://example.com").await, + check_same_origin("http://example.com"), Err(WebAuthnError::SecurityError) )); } diff --git a/credentialsd/src/dbus/model.rs b/credentialsd/src/dbus/model.rs index 4f77a5d..f98ccf5 100644 --- a/credentialsd/src/dbus/model.rs +++ b/credentialsd/src/dbus/model.rs @@ -23,13 +23,15 @@ use crate::{ GetAssertionHmacOrPrfInput, GetAssertionLargeBlobExtension, GetAssertionRequest, GetAssertionRequestExtensions, GetPublicKeyCredentialUnsignedExtensionsResponse, MakeCredentialHmacOrPrfInput, MakeCredentialRequest, MakeCredentialsRequestExtensions, - PublicKeyCredentialParameters, ResidentKeyRequirement, UserVerificationRequirement, + Origin, PublicKeyCredentialParameters, NavigationContext, ResidentKeyRequirement, + UserVerificationRequirement, }, }; // Helper functions for translating D-Bus types into internal types pub(super) fn create_credential_request_try_into_ctap2( request: &CreateCredentialRequest, + origin: &NavigationContext, ) -> std::result::Result<(MakeCredentialRequest, String), WebAuthnError> { if request.public_key.is_none() { return Err(WebAuthnError::NotSupportedError); @@ -181,24 +183,13 @@ pub(super) fn create_credential_request_try_into_ctap2( .filter_map(|e| e.ok()) .collect() }); - let (origin, is_cross_origin) = match (request.origin.as_ref(), request.is_same_origin.as_ref()) - { - (Some(origin), Some(is_same_origin)) => (origin.to_string(), !is_same_origin), - (Some(origin), None) => (origin.to_string(), true), - // origin should always be set on request either by client or D-Bus service, - // so this shouldn't be called - (None, _) => { - tracing::info!("Error reading origin from request."); - return Err(WebAuthnError::TypeError); - } - }; let client_data_json = - webauthn::format_client_data_json(Operation::Create, &challenge, &origin, is_cross_origin); + webauthn::format_client_data_json(Operation::Create, &challenge, &origin); let client_data_hash = webauthn::create_client_data_hash(&client_data_json); Ok(( MakeCredentialRequest { hash: client_data_hash, - origin, + origin: origin.origin().to_string(), relying_party: rp, user, @@ -256,6 +247,7 @@ pub(super) fn create_credential_response_try_from_ctap2( pub(super) fn get_credential_request_try_into_ctap2( request: &GetCredentialRequest, + request_env: &NavigationContext, ) -> std::result::Result<(GetAssertionRequest, String), WebAuthnError> { if request.public_key.is_none() { return Err(WebAuthnError::NotSupportedError); @@ -290,24 +282,9 @@ pub(super) fn get_credential_request_try_into_ctap2( for c in allow.iter_mut() { c.transports = None; } - let (origin, is_cross_origin) = match (request.origin.as_ref(), request.is_same_origin.as_ref()) - { - (Some(origin), Some(is_same_origin)) => (origin.to_string(), !is_same_origin), - (Some(origin), None) => (origin.to_string(), true), - // origin should always be set on request either by client or D-Bus service, - // so this shouldn't be called - (None, _) => { - tracing::info!("Error reading origin from client request."); - return Err(WebAuthnError::TypeError); - } - }; - let client_data_json = webauthn::format_client_data_json( - Operation::Get, - &options.challenge, - &origin, - is_cross_origin, - ); + let client_data_json = + webauthn::format_client_data_json(Operation::Get, &options.challenge, &request_env); let client_data_hash = webauthn::create_client_data_hash(&client_data_json); // TODO: actually calculate correct effective domain, and use fallback to related origin requests to fill this in. For now, just default to origin. let user_verification = match options @@ -323,12 +300,14 @@ pub(super) fn get_credential_request_try_into_ctap2( return Err(WebAuthnError::TypeError); } }; - let relying_party_id = options.rp_id.unwrap_or_else(|| { - // TODO: We're assuming that the origin is `://data`, which is - // currently checked by the caller, but we should encode this in a type. - let (_, effective_domain) = origin.rsplit_once('/').unwrap(); - effective_domain.to_string() - }); + let relying_party_id = match (options.rp_id, request_env.origin()) { + (Some(rp_id), _) => rp_id, + (None, Origin::Https { host, .. }) => host.to_string(), + (None, Origin::AppId(_)) => { + tracing::info!("RP ID required if using app ID as origin"); + return Err(WebAuthnError::SecurityError); + } + }; let extensions = if let Some(incoming_extensions) = options.extensions { let extensions = GetAssertionRequestExtensions { diff --git a/credentialsd/src/webauthn.rs b/credentialsd/src/webauthn.rs index cf4a82c..eeb6985 100644 --- a/credentialsd/src/webauthn.rs +++ b/credentialsd/src/webauthn.rs @@ -1,4 +1,4 @@ -use std::{collections::HashMap, time::Duration}; +use std::{collections::HashMap, fmt::Display, str::FromStr, time::Duration}; use base64::{self, engine::general_purpose::URL_SAFE_NO_PAD, Engine}; use libwebauthn::{ @@ -681,38 +681,265 @@ pub fn create_client_data_hash(json: &str) -> Vec { pub fn format_client_data_json( op: Operation, challenge: &str, - origin: &str, - is_cross_origin: bool, + origin: &NavigationContext, ) -> String { let op_str = match op { Operation::Create => "webauthn.create", Operation::Get => "webauthn.get", }; - let cross_origin_str = if is_cross_origin { "true" } else { "false" }; - format!("{{\"type\":\"{op_str}\",\"challenge\":\"{challenge}\",\"origin\":\"{origin}\",\"crossOrigin\":{cross_origin_str}}}") + let mut client_data_json = format!( + r#"{{"type":"{}","challenge":"{}","origin":"{}""#, + op_str, + challenge, + origin.origin() + ); + if let Some(top_origin) = origin.top_origin() { + client_data_json.push_str(&format!( + r#","crossOrigin":true,"topOrigin":"{top_origin}"}}"# + )); + } else { + client_data_json.push_str(r#","crossOrigin":false}"#); + } + client_data_json } -#[derive(Debug)] +/// An application ID conforming to the +/// [XDG desktop entry syntax][xdg-desktop-entry-name]. +/// +/// [xdg-desktop-entry-name]: https://specifications.freedesktop.org/desktop-entry/latest/file-naming.html +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct AppId(String); + +impl AsRef for AppId { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl FromStr for AppId { + type Err = (); + + fn from_str(s: &str) -> Result { + // This algorithm could be made more efficient, but this is fairly readable. + + // begins with a letter + match s.chars().nth(0) { + Some(c) if c.is_ascii_alphabetic() => {} + _ => return Err(()), + }; + + // alphanumeric and labels separated by dots + if !s + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_') + { + return Err(()); + } + + // All labels must be non-empty. + if s.contains("..") { + return Err(()); + } + + // ends with a valid label + if s.ends_with('.') { + return Err(()); + } + Ok(AppId(s.to_string())) + } +} + +/// The origin of the client for the request. +#[derive(Debug, PartialEq)] pub(crate) enum Origin { - AppId(String), - SameOrigin(String), - CrossOrigin((String, String)), + Https { host: String, port: Option }, + AppId(AppId), +} + +impl Display for Origin { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Origin::Https { host, port } => { + write!(f, "https://{}", host)?; + if let Some(port) = port { + write!(f, ":{port}")?; + } + } + Origin::AppId(app_id) => write!(f, "app:{}", app_id.0)?, + } + Ok(()) + } +} + +impl FromStr for Origin { + type Err = OriginParseError; + + fn from_str(s: &str) -> Result { + if let Some(rest) = s.strip_prefix("https://") { + let (host_candidate, port_candidate): (&str, Option<&str>) = rest + .split_once(':') + .map(|(h, p)| (h, Some(p))) + .unwrap_or((rest, None)); + + // begins with a letter + match host_candidate.chars().nth(0) { + Some(c) if c.is_ascii_alphabetic() => {} + _ => return Err(OriginParseError::InvalidHost), + }; + // alphanumeric with hyphens and labels separated by dots + if !host_candidate + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '.') + { + return Err(OriginParseError::InvalidHost); + } + // ends with a valid label + if host_candidate.ends_with('.') { + return Err(OriginParseError::InvalidHost); + } + let host = host_candidate.to_ascii_lowercase(); + + let Ok(port) = port_candidate.map(|p| p.parse()).transpose() else { + return Err(OriginParseError::InvalidPort); + }; + + Ok(Origin::Https { host, port }) + } else if let Some(app_id_candidate) = s.strip_prefix("app:") { + let app_id = app_id_candidate + .parse() + .map_err(|_| OriginParseError::InvalidHost)?; + Ok(Origin::AppId(app_id)) + } else { + Err(OriginParseError::InvalidScheme) + } + } +} + +/// The origin of the request, and its top-level origin, if it is cross-origin. +#[derive(Debug)] +pub(crate) enum NavigationContext { + /// Represents a client context with a single origin is presented to the user. + SameOrigin(Origin), + + /// Represents a client context where the origin of the request is nested within + /// another parent context with a different origin. + CrossOrigin((Origin, Origin)), } -impl Origin { - pub(crate) fn origin(&self) -> &str { - &match self { - Origin::AppId(app_id) => app_id, - Origin::SameOrigin(origin) => origin, - Origin::CrossOrigin((origin, _)) => origin, +impl NavigationContext { + /// Retrieve the origin from the context. + pub(crate) fn origin(&self) -> &Origin { + match self { + NavigationContext::SameOrigin(origin) => origin, + NavigationContext::CrossOrigin((origin, _)) => origin, } } - pub(crate) fn top_origin(&self) -> Option<&str> { + /// Retrieves the top origin from the context, if any. + pub(crate) fn top_origin(&self) -> Option<&Origin> { + match self { + NavigationContext::SameOrigin(_) => None, + NavigationContext::CrossOrigin((_, ref top_origin)) => Some(top_origin), + } + } +} + +#[derive(Debug)] +pub(crate) enum OriginParseError { + InvalidScheme, + InvalidHost, + InvalidPort, +} + +impl std::error::Error for OriginParseError {} + +impl Display for OriginParseError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - Origin::AppId(_) => None, - Origin::SameOrigin(_) => None, - Origin::CrossOrigin((_, ref top_origin)) => Some(top_origin), + Self::InvalidScheme => f.write_str("Invalid scheme"), + Self::InvalidHost => f.write_str("Invalid host"), + Self::InvalidPort => f.write_str("Invalid port"), } } } + +#[cfg(test)] +mod tests { + use crate::webauthn::{Origin, OriginParseError}; + + use super::{format_client_data_json, NavigationContext, Operation}; + #[test] + fn test_same_origin_client_data_json_str() { + let expected = r#"{"type":"webauthn.create","challenge":"abcd","origin":"https://example.com","crossOrigin":false}"#; + let json = format_client_data_json( + Operation::Create, + "abcd", + &NavigationContext::SameOrigin("https://example.com".parse().unwrap()), + ); + assert_eq!(expected, json); + } + + #[test] + fn test_cross_origin_client_data_json_str() { + let expected = r#"{"type":"webauthn.create","challenge":"abcd","origin":"https://example.com","crossOrigin":true,"topOrigin":"https://example.org"}"#; + let json = format_client_data_json( + Operation::Create, + "abcd", + &NavigationContext::CrossOrigin(( + "https://example.com".parse().unwrap(), + "https://example.org".parse().unwrap(), + )), + ); + assert_eq!(expected, json); + } + + fn check_https_origin(origin: &str, expected_host: &str, expected_port: Option) { + let Origin::Https { host, port }: Origin = origin.parse().unwrap() else { + panic!("Not an https origin"); + }; + assert_eq!(expected_host, host); + assert_eq!(expected_port, port); + } + + #[test] + fn test_origin_parse_when_http_fails() { + let err = "http://example.com".parse::().unwrap_err(); + assert!(matches!(err, OriginParseError::InvalidScheme)); + } + + #[test] + fn test_origin_parse_https_origin_without_port_succeeds() { + check_https_origin("https://example.com", "example.com", None); + } + + #[test] + fn test_origin_parse_https_with_port_succeeds() { + check_https_origin("https://example.org:8443", "example.org", Some(8443)); + } + + #[test] + fn test_origin_parse_with_trailing_slash_fails() { + let err = "https://example.org/".parse::().unwrap_err(); + assert!(matches!(err, OriginParseError::InvalidHost)); + } + + #[test] + fn test_origin_parse_with_port_and_path_fails() { + let err = "https://example.org:8443/".parse::().unwrap_err(); + assert!(matches!(err, OriginParseError::InvalidPort)); + } + + #[test] + fn test_origin_parse_with_invalid_characters_fails() { + let err = "https://😭.edu:1234".parse::().unwrap_err(); + assert!(matches!(err, OriginParseError::InvalidHost)); + } + + #[test] + fn test_origin_parse_app_id_succeeds() { + let Origin::AppId(app_id) = "app:com.example.ExampleApp".parse::().unwrap() else { + panic!("not an app origin"); + }; + assert_eq!("com.example.ExampleApp", app_id.0); + } +}