mirror of
https://github.com/lexmount/moli.git
synced 2026-10-30 16:07:24 +00:00
feat(credentials): bind native CredentialsContainer entry points
This commit is contained in:
@@ -2,6 +2,7 @@ use anyhow::{Context, Result, anyhow};
|
||||
|
||||
use crate::context_bootstrap::{
|
||||
crypto::finalize_crypto_realm_bindings, events::finalize_pointer_event_realm_bindings,
|
||||
navigator_runtime::finalize_navigator_credentials_realm_bindings,
|
||||
notification_runtime::finalize_notification_realm_bindings,
|
||||
performance_runtime::finalize_performance_observer_realm_bindings,
|
||||
web_audio_runtime::finalize_base_audio_context_realm_bindings,
|
||||
@@ -13,6 +14,7 @@ enum RealmDependentFinalizer {
|
||||
DocumentRealmBindings,
|
||||
NodeMixinUnscopables,
|
||||
GlobalEventHandlersSecureContextSurface,
|
||||
NavigatorCredentialsSecureContextSurface,
|
||||
CryptoSecureContextSurface,
|
||||
BaseAudioContextSecureContextSurface,
|
||||
XmlHttpRequestEventTargetState,
|
||||
@@ -40,6 +42,10 @@ const REALM_DEPENDENT_FINALIZER_ALLOWLIST: &[(&str, RealmDependentFinalizer)] =
|
||||
),
|
||||
// These entries install private realm state, context-conditional surface,
|
||||
// or constructor data whose JavaScript identity must be realm-local.
|
||||
(
|
||||
"Navigator",
|
||||
RealmDependentFinalizer::NavigatorCredentialsSecureContextSurface,
|
||||
),
|
||||
(
|
||||
"BaseAudioContext",
|
||||
RealmDependentFinalizer::BaseAudioContextSecureContextSurface,
|
||||
@@ -113,6 +119,9 @@ pub(super) fn finalize_materialized_interface<'s>(
|
||||
RealmDependentFinalizer::BaseAudioContextSecureContextSurface => {
|
||||
finalize_base_audio_context_realm_bindings(scope, prototype)
|
||||
}
|
||||
RealmDependentFinalizer::NavigatorCredentialsSecureContextSurface => {
|
||||
finalize_navigator_credentials_realm_bindings(scope, prototype)
|
||||
}
|
||||
RealmDependentFinalizer::GlobalEventHandlersSecureContextSurface => {
|
||||
crate::context_bootstrap::window_events::finalize_secure_global_event_handler_realm_bindings(
|
||||
scope, prototype,
|
||||
@@ -171,6 +180,7 @@ mod tests {
|
||||
"Element",
|
||||
"DocumentType",
|
||||
"CharacterData",
|
||||
"Navigator",
|
||||
"BaseAudioContext",
|
||||
"Crypto",
|
||||
"XMLHttpRequestEventTarget",
|
||||
|
||||
@@ -21,7 +21,7 @@ pub(in crate::context_bootstrap) use self::navigator::{
|
||||
SERVICE_WORKER_OWNER_TOKEN_SLOT, STORAGE_BUCKET_MANAGER_CHILD_HANDLE_SLOT,
|
||||
STORAGE_BUCKET_MANAGER_POPUP_ID_SLOT, STORAGE_MANAGER_CHILD_HANDLE_SLOT,
|
||||
STORAGE_MANAGER_POPUP_ID_SLOT, build_storage_manager_worker_template,
|
||||
current_protocol_user_gesture_activation,
|
||||
current_protocol_user_gesture_activation, finalize_navigator_credentials_realm_bindings,
|
||||
install_storage_manager_constructor_template_bindings, navigator_identity_profile,
|
||||
navigator_receiver_branded, service_worker_owner_token_value, set_navigator_identity_profile,
|
||||
};
|
||||
|
||||
@@ -7,9 +7,11 @@
|
||||
use moli_webapi_declare::WebApiFunctionTemplate;
|
||||
|
||||
mod base64url;
|
||||
mod container;
|
||||
mod json_options;
|
||||
mod schema;
|
||||
mod value;
|
||||
pub(super) use container::build as build_container;
|
||||
|
||||
use crate::{native_bridge::throw_dom_exception, util::v8str, web_api_interfaces};
|
||||
|
||||
@@ -90,6 +92,7 @@ pub(super) fn install<'s>(
|
||||
// Static members are installed on the constructor template by the derive.
|
||||
let prototype = template.prototype_template(scope);
|
||||
match interface_name {
|
||||
"CredentialsContainer" => container::install(scope, prototype),
|
||||
"Credential" => {
|
||||
CredentialDeclaration::initialize_template(scope, template);
|
||||
CredentialDeclaration::initialize_prototype_template(scope, prototype);
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
//! Native credential-manager entry points without a credential-store backend.
|
||||
//!
|
||||
//! WebIDL conversion and fully-active/pre-aborted checks still apply when no
|
||||
//! credential can be produced. The generated Promise adapter handles receiver
|
||||
//! and conversion errors in the function realm. Do not manufacture credentials
|
||||
//! or claim to persist them.
|
||||
|
||||
mod options;
|
||||
|
||||
use anyhow::Result;
|
||||
use moli_webapi_declare::{WebApiFunctionTemplate, WebApiObject};
|
||||
|
||||
use crate::{
|
||||
native_bridge::{OwnerDispatchScope, throw_dom_exception},
|
||||
util::context_host_ptr_from_context_slot,
|
||||
web_api_interfaces, webidl,
|
||||
};
|
||||
|
||||
use options::{CreateArgs, GetArgs, StoreArgs};
|
||||
|
||||
#[derive(WebApiObject)]
|
||||
#[webapi(interface = web_api_interfaces::CredentialsContainer)]
|
||||
struct ContainerObject<'s> {
|
||||
#[webapi(prototype)]
|
||||
prototype: v8::Local<'s, v8::Object>,
|
||||
}
|
||||
|
||||
#[derive(WebApiFunctionTemplate)]
|
||||
#[webapi(interface = web_api_interfaces::CredentialsContainer, enumerable, receiver)]
|
||||
struct ContainerPrototype {
|
||||
#[webapi(method, returns_promise, length = 0, callback = get)]
|
||||
get: (),
|
||||
#[webapi(method, returns_promise, length = 1, callback = store)]
|
||||
store: (),
|
||||
#[webapi(method, returns_promise, length = 0, callback = create)]
|
||||
create: (),
|
||||
#[webapi(method, returns_promise, length = 0, callback = prevent_silent_access)]
|
||||
prevent_silent_access: (),
|
||||
}
|
||||
|
||||
pub(super) fn install<'s>(
|
||||
scope: &mut v8::PinScope<'s, '_, ()>,
|
||||
prototype: v8::Local<'s, v8::ObjectTemplate>,
|
||||
) {
|
||||
ContainerPrototype::initialize_prototype_template(scope, prototype);
|
||||
}
|
||||
|
||||
pub(in crate::context_bootstrap::navigator_runtime) fn build<'s>(
|
||||
scope: &mut v8::PinScope<'s, '_>,
|
||||
) -> Result<v8::Local<'s, v8::Object>> {
|
||||
let prototype = crate::context_bootstrap::ensure_intrinsic_interface_prototype(
|
||||
scope,
|
||||
"CredentialsContainer",
|
||||
)?;
|
||||
ContainerObject::new(prototype)
|
||||
.bind(scope)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
fn check_fully_active(scope: &mut v8::PinScope<'_, '_>) -> bool {
|
||||
// Current settings come from the function realm, never from an author
|
||||
// property such as window.document or a retained WindowProxy association.
|
||||
let context = scope.get_current_context();
|
||||
let fully_active = context_host_ptr_from_context_slot(context).is_some_and(|host_ptr| {
|
||||
let host = unsafe { &*host_ptr };
|
||||
let Some(identity) = host.window_execution_context_identity_for_access_check(context)
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
if !host.window_execution_context_identity_is_current(identity) {
|
||||
return false;
|
||||
}
|
||||
let document = match identity.dispatch_scope() {
|
||||
OwnerDispatchScope::Top => Some(host.document_handle()),
|
||||
OwnerDispatchScope::Child(frame) => host.child_browsing_context_document_handle(frame),
|
||||
OwnerDispatchScope::LightweightPopup(id) => host.lightweight_popup_document_handle(id),
|
||||
};
|
||||
document.is_some_and(|document| host.document_is_fully_active(document))
|
||||
});
|
||||
if !fully_active {
|
||||
throw_dom_exception(
|
||||
scope,
|
||||
"InvalidStateError",
|
||||
11,
|
||||
"The document is not fully active.",
|
||||
);
|
||||
}
|
||||
fully_active
|
||||
}
|
||||
|
||||
fn check_aborted<'s>(
|
||||
scope: &mut v8::PinScope<'s, '_>,
|
||||
signal: Option<options::Signal<'s>>,
|
||||
) -> bool {
|
||||
if let Some(signal) = signal
|
||||
&& signal.0.is_aborted(scope)
|
||||
{
|
||||
let reason = signal
|
||||
.0
|
||||
.reason(scope)
|
||||
.unwrap_or_else(|| v8::undefined(scope).into());
|
||||
scope.throw_exception(reason);
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn backend_unavailable(scope: &mut v8::PinScope<'_, '_>) {
|
||||
throw_dom_exception(
|
||||
scope,
|
||||
"NotSupportedError",
|
||||
9,
|
||||
"No supported credential backend is available.",
|
||||
);
|
||||
}
|
||||
|
||||
fn get<'s>(
|
||||
scope: &mut v8::PinScope<'s, '_>,
|
||||
args: v8::FunctionCallbackArguments<'s>,
|
||||
_rv: v8::ReturnValue<'s>,
|
||||
) {
|
||||
let Some(parsed) = webidl::parse_args::<GetArgs>(scope, &args) else {
|
||||
return;
|
||||
};
|
||||
if !check_fully_active(scope) || check_aborted(scope, parsed.options.0.signal) {
|
||||
return;
|
||||
}
|
||||
backend_unavailable(scope);
|
||||
}
|
||||
|
||||
fn create<'s>(
|
||||
scope: &mut v8::PinScope<'s, '_>,
|
||||
args: v8::FunctionCallbackArguments<'s>,
|
||||
_rv: v8::ReturnValue<'s>,
|
||||
) {
|
||||
let Some(parsed) = webidl::parse_args::<CreateArgs>(scope, &args) else {
|
||||
return;
|
||||
};
|
||||
if !check_fully_active(scope) {
|
||||
return;
|
||||
}
|
||||
let options = parsed.options.0;
|
||||
// Unlike get(), create() checks multiple credential types before abort.
|
||||
let types = usize::from(options.password.is_some())
|
||||
+ usize::from(options.federated.is_some())
|
||||
+ usize::from(options.public_key.is_some());
|
||||
if types > 1 {
|
||||
backend_unavailable(scope);
|
||||
return;
|
||||
}
|
||||
if !check_aborted(scope, options.signal) {
|
||||
backend_unavailable(scope);
|
||||
}
|
||||
}
|
||||
|
||||
fn store<'s>(
|
||||
scope: &mut v8::PinScope<'s, '_>,
|
||||
args: v8::FunctionCallbackArguments<'s>,
|
||||
_rv: v8::ReturnValue<'s>,
|
||||
) {
|
||||
if webidl::parse_args::<StoreArgs>(scope, &args).is_some() && check_fully_active(scope) {
|
||||
backend_unavailable(scope);
|
||||
}
|
||||
}
|
||||
|
||||
fn prevent_silent_access<'s>(
|
||||
scope: &mut v8::PinScope<'s, '_>,
|
||||
_args: v8::FunctionCallbackArguments<'s>,
|
||||
mut rv: v8::ReturnValue<'s>,
|
||||
) {
|
||||
if !check_fully_active(scope) {
|
||||
return;
|
||||
}
|
||||
// The default prevent-silent-access flag is true. With no credential store
|
||||
// or automatic sign-in path, this cannot enable silent access or persist a
|
||||
// credential; its idempotent operation can complete normally.
|
||||
let value = v8::undefined(scope).into();
|
||||
crate::context_bootstrap::stream_adapter::set_resolved_promise(scope, &mut rv, value);
|
||||
}
|
||||
+308
@@ -0,0 +1,308 @@
|
||||
//! Convert credential dictionaries before entering the backend algorithm.
|
||||
//!
|
||||
//! All dictionaries preserve WebIDL's inherited-then-lexical member order.
|
||||
//! Underscored fields are converted even though the unavailable backend cannot
|
||||
//! consume them. BufferSource validation uses native identity and never copies
|
||||
//! authenticator input or reads an author-visible `.buffer` property.
|
||||
|
||||
use crate::{abort_signal_route::ResolvedAbortSignal, web_api_interfaces, webidl};
|
||||
|
||||
use super::super::{
|
||||
schema::{Entity, Parameters, RelyingParty, Selection},
|
||||
value::{Dictionary, Text},
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy, webidl::WebIdlEnum)]
|
||||
#[webidl(name = "CredentialMediationRequirement")]
|
||||
enum Mediation {
|
||||
Silent,
|
||||
Optional,
|
||||
Conditional,
|
||||
Required,
|
||||
}
|
||||
|
||||
pub(super) struct Signal<'s>(pub(super) ResolvedAbortSignal<'s>);
|
||||
|
||||
impl<'s> webidl::WebIdlConverter<'s> for Signal<'s> {
|
||||
type Options = ();
|
||||
|
||||
fn convert(
|
||||
scope: &mut v8::PinScope<'s, '_>,
|
||||
value: v8::Local<'s, v8::Value>,
|
||||
context: webidl::Context,
|
||||
_options: &Self::Options,
|
||||
) -> Result<Self, webidl::WebIdlError> {
|
||||
let signal = v8::Local::<v8::Object>::try_from(value)
|
||||
.ok()
|
||||
.filter(|object| web_api_interfaces::AbortSignal::is_instance(scope, *object))
|
||||
.and_then(|object| moli_webapi_declare::web_api_object_target(scope, object))
|
||||
.and_then(|object| ResolvedAbortSignal::resolve(scope, object));
|
||||
signal
|
||||
.map(Self)
|
||||
.ok_or_else(|| webidl::WebIdlError::cannot_convert(context, "AbortSignal"))
|
||||
}
|
||||
}
|
||||
|
||||
// This is validation for WebAuthn's BufferSource without AllowShared or
|
||||
// AllowResizable. The existing byte-copy converter also accepts shared input,
|
||||
// so it cannot be used for these dictionaries.
|
||||
struct Buffer;
|
||||
|
||||
impl<'s> webidl::WebIdlConverter<'s> for Buffer {
|
||||
type Options = ();
|
||||
|
||||
fn convert(
|
||||
_scope: &mut v8::PinScope<'s, '_>,
|
||||
value: v8::Local<'s, v8::Value>,
|
||||
context: webidl::Context,
|
||||
_options: &Self::Options,
|
||||
) -> Result<Self, webidl::WebIdlError> {
|
||||
let binary = value.is_array_buffer() || value.is_array_buffer_view();
|
||||
if binary && !crate::blob::buffer_source_has_shared_or_resizable_backing_store(value) {
|
||||
Ok(Self)
|
||||
} else {
|
||||
Err(webidl::WebIdlError::cannot_convert(context, "BufferSource"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct PasswordInit;
|
||||
|
||||
impl<'s> webidl::WebIdlConverter<'s> for PasswordInit {
|
||||
type Options = ();
|
||||
|
||||
fn convert(
|
||||
scope: &mut v8::PinScope<'s, '_>,
|
||||
value: v8::Local<'s, v8::Value>,
|
||||
context: webidl::Context,
|
||||
_options: &Self::Options,
|
||||
) -> Result<Self, webidl::WebIdlError> {
|
||||
if let Ok(object) = v8::Local::<v8::Object>::try_from(value)
|
||||
&& web_api_interfaces::HTMLFormElement::is_instance(scope, object)
|
||||
{
|
||||
return Ok(Self);
|
||||
}
|
||||
webidl::convert::<Dictionary<PasswordData>>(scope, value, context)?;
|
||||
Ok(Self)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(webidl::WebIdlArgs)]
|
||||
#[webidl(prefix = "CredentialsContainer.get")]
|
||||
pub(super) struct GetArgs<'s> {
|
||||
#[webidl(converter = "raw", default = Dictionary::empty(scope)?)]
|
||||
pub(super) options: Dictionary<Request<'s>>,
|
||||
}
|
||||
|
||||
#[derive(webidl::WebIdlArgs)]
|
||||
#[webidl(prefix = "CredentialsContainer.create")]
|
||||
pub(super) struct CreateArgs<'s> {
|
||||
#[webidl(converter = "raw", default = Dictionary::empty(scope)?)]
|
||||
pub(super) options: Dictionary<Creation<'s>>,
|
||||
}
|
||||
|
||||
#[derive(webidl::WebIdlArgs)]
|
||||
#[webidl(prefix = "CredentialsContainer.store")]
|
||||
pub(super) struct StoreArgs<'s> {
|
||||
#[webidl(required, interface = web_api_interfaces::Credential)]
|
||||
_credential: v8::Local<'s, v8::Object>,
|
||||
}
|
||||
|
||||
#[derive(webidl::WebIdlDictionary)]
|
||||
#[webidl(prefix = "CredentialRequestOptions")]
|
||||
pub(super) struct Request<'s> {
|
||||
#[webidl(converter = "raw")]
|
||||
_federated: Option<Dictionary<FederatedRequest>>,
|
||||
#[webidl(converter = "enum", default = Mediation::Optional)]
|
||||
_mediation: Mediation,
|
||||
#[webidl(default = false)]
|
||||
_password: bool,
|
||||
#[webidl(converter = "raw")]
|
||||
_public_key: Option<Dictionary<PublicKeyRequest>>,
|
||||
#[webidl(converter = "raw")]
|
||||
pub(super) signal: Option<Signal<'s>>,
|
||||
#[webidl(converter = "raw")]
|
||||
_ui_mode: Option<Text>,
|
||||
}
|
||||
|
||||
#[derive(webidl::WebIdlDictionary)]
|
||||
#[webidl(prefix = "CredentialCreationOptions")]
|
||||
pub(super) struct Creation<'s> {
|
||||
#[webidl(converter = "raw")]
|
||||
pub(super) federated: Option<Dictionary<FederatedInit>>,
|
||||
#[webidl(converter = "enum", default = Mediation::Optional)]
|
||||
_mediation: Mediation,
|
||||
#[webidl(converter = "raw")]
|
||||
pub(super) password: Option<PasswordInit>,
|
||||
#[webidl(converter = "raw")]
|
||||
pub(super) public_key: Option<Dictionary<PublicKeyCreation>>,
|
||||
#[webidl(converter = "raw")]
|
||||
pub(super) signal: Option<Signal<'s>>,
|
||||
}
|
||||
|
||||
#[derive(webidl::WebIdlDictionary)]
|
||||
#[webidl(prefix = "FederatedCredentialRequestOptions")]
|
||||
struct FederatedRequest {
|
||||
#[webidl(converter = "raw")]
|
||||
_protocols: Option<webidl::Sequence<Text>>,
|
||||
#[webidl(converter = "raw")]
|
||||
_providers: Option<webidl::Sequence<webidl::UsvString>>,
|
||||
}
|
||||
|
||||
#[derive(webidl::WebIdlDictionary)]
|
||||
#[webidl(prefix = "CredentialData")]
|
||||
struct CredentialData {
|
||||
#[webidl(required, converter = "usv_string")]
|
||||
_id: String,
|
||||
}
|
||||
|
||||
#[derive(webidl::WebIdlDictionary)]
|
||||
#[webidl(prefix = "FederatedCredentialInit")]
|
||||
pub(super) struct FederatedInit {
|
||||
#[webidl(inherit)]
|
||||
_base: CredentialData,
|
||||
#[webidl(name = "iconURL", converter = "usv_string")]
|
||||
_icon_url: Option<String>,
|
||||
#[webidl(converter = "usv_string")]
|
||||
_name: Option<String>,
|
||||
#[webidl(required, converter = "usv_string")]
|
||||
_origin: String,
|
||||
#[webidl(converter = "raw")]
|
||||
_protocol: Option<Text>,
|
||||
#[webidl(required, converter = "usv_string")]
|
||||
_provider: String,
|
||||
}
|
||||
|
||||
#[derive(webidl::WebIdlDictionary)]
|
||||
#[webidl(prefix = "PasswordCredentialData")]
|
||||
struct PasswordData {
|
||||
#[webidl(inherit)]
|
||||
_base: CredentialData,
|
||||
#[webidl(name = "iconURL", converter = "usv_string")]
|
||||
_icon_url: Option<String>,
|
||||
#[webidl(converter = "usv_string")]
|
||||
_name: Option<String>,
|
||||
#[webidl(required, converter = "usv_string")]
|
||||
_origin: String,
|
||||
#[webidl(required, converter = "usv_string")]
|
||||
_password: String,
|
||||
}
|
||||
|
||||
#[derive(webidl::WebIdlDictionary)]
|
||||
#[webidl(prefix = "PublicKeyCredentialCreationOptions")]
|
||||
pub(super) struct PublicKeyCreation {
|
||||
#[webidl(converter = "raw", default = Text::from("none"))]
|
||||
_attestation: Text,
|
||||
#[webidl(converter = "raw", default = webidl::Sequence(Vec::new()))]
|
||||
_attestation_formats: webidl::Sequence<Text>,
|
||||
#[webidl(converter = "raw")]
|
||||
_authenticator_selection: Option<Dictionary<Selection>>,
|
||||
#[webidl(required, converter = "raw")]
|
||||
_challenge: Buffer,
|
||||
#[webidl(converter = "raw", default = webidl::Sequence(Vec::new()))]
|
||||
_exclude_credentials: webidl::Sequence<Dictionary<Descriptor>>,
|
||||
#[webidl(converter = "raw")]
|
||||
_extensions: Option<Dictionary<Extensions>>,
|
||||
#[webidl(converter = "raw", default = webidl::Sequence(Vec::new()))]
|
||||
_hints: webidl::Sequence<Text>,
|
||||
#[webidl(required, converter = "raw")]
|
||||
_pub_key_cred_params: webidl::Sequence<Dictionary<Parameters>>,
|
||||
#[webidl(required, converter = "raw")]
|
||||
_rp: Dictionary<RelyingParty>,
|
||||
_timeout: Option<u32>,
|
||||
#[webidl(required, converter = "raw")]
|
||||
_user: Dictionary<User>,
|
||||
}
|
||||
|
||||
#[derive(webidl::WebIdlDictionary)]
|
||||
#[webidl(prefix = "PublicKeyCredentialRequestOptions")]
|
||||
struct PublicKeyRequest {
|
||||
#[webidl(converter = "raw", default = webidl::Sequence(Vec::new()))]
|
||||
_allow_credentials: webidl::Sequence<Dictionary<Descriptor>>,
|
||||
#[webidl(required, converter = "raw")]
|
||||
_challenge: Buffer,
|
||||
#[webidl(converter = "raw")]
|
||||
_extensions: Option<Dictionary<Extensions>>,
|
||||
#[webidl(converter = "raw", default = webidl::Sequence(Vec::new()))]
|
||||
_hints: webidl::Sequence<Text>,
|
||||
#[webidl(converter = "raw")]
|
||||
_rp_id: Option<Text>,
|
||||
_timeout: Option<u32>,
|
||||
#[webidl(converter = "raw", default = Text::from("preferred"))]
|
||||
_user_verification: Text,
|
||||
}
|
||||
|
||||
#[derive(webidl::WebIdlDictionary)]
|
||||
#[webidl(prefix = "PublicKeyCredentialUserEntity")]
|
||||
struct User {
|
||||
#[webidl(inherit)]
|
||||
_base: Entity,
|
||||
#[webidl(required, converter = "raw")]
|
||||
_display_name: Text,
|
||||
#[webidl(required, converter = "raw")]
|
||||
_id: Buffer,
|
||||
}
|
||||
|
||||
#[derive(webidl::WebIdlDictionary)]
|
||||
#[webidl(prefix = "PublicKeyCredentialDescriptor")]
|
||||
struct Descriptor {
|
||||
#[webidl(required, converter = "raw")]
|
||||
_id: Buffer,
|
||||
#[webidl(converter = "raw")]
|
||||
_transports: Option<webidl::Sequence<Text>>,
|
||||
#[webidl(required, name = "type", converter = "raw")]
|
||||
_credential_type: Text,
|
||||
}
|
||||
|
||||
#[derive(webidl::WebIdlDictionary)]
|
||||
#[webidl(prefix = "AuthenticationExtensionsClientInputs")]
|
||||
struct Extensions {
|
||||
#[webidl(converter = "raw")]
|
||||
_appid: Option<Text>,
|
||||
#[webidl(converter = "raw")]
|
||||
_appid_exclude: Option<Text>,
|
||||
#[webidl(converter = "raw")]
|
||||
_cred_blob: Option<Buffer>,
|
||||
_cred_props: Option<bool>,
|
||||
#[webidl(converter = "usv_string")]
|
||||
_credential_protection_policy: Option<String>,
|
||||
#[webidl(default = false)]
|
||||
_enforce_credential_protection_policy: bool,
|
||||
_get_cred_blob: Option<bool>,
|
||||
_hmac_create_secret: Option<bool>,
|
||||
#[webidl(converter = "raw")]
|
||||
_large_blob: Option<Dictionary<LargeBlob>>,
|
||||
_min_pin_length: Option<bool>,
|
||||
#[webidl(converter = "raw")]
|
||||
_prf: Option<Dictionary<Prf>>,
|
||||
#[webidl(name = "remoteClientDataJSON", converter = "raw")]
|
||||
_remote_client_data_json: Option<Text>,
|
||||
}
|
||||
|
||||
#[derive(webidl::WebIdlDictionary)]
|
||||
#[webidl(prefix = "AuthenticationExtensionsLargeBlobInputs")]
|
||||
struct LargeBlob {
|
||||
_read: Option<bool>,
|
||||
#[webidl(converter = "raw")]
|
||||
_support: Option<Text>,
|
||||
#[webidl(converter = "raw")]
|
||||
_write: Option<Buffer>,
|
||||
}
|
||||
|
||||
#[derive(webidl::WebIdlDictionary)]
|
||||
#[webidl(prefix = "AuthenticationExtensionsPRFInputs")]
|
||||
struct Prf {
|
||||
#[webidl(converter = "raw")]
|
||||
_eval: Option<Dictionary<PrfValues>>,
|
||||
#[webidl(converter = "raw")]
|
||||
_eval_by_credential: Option<webidl::Record<Text, Dictionary<PrfValues>>>,
|
||||
}
|
||||
|
||||
#[derive(webidl::WebIdlDictionary)]
|
||||
#[webidl(prefix = "AuthenticationExtensionsPRFValues")]
|
||||
struct PrfValues {
|
||||
#[webidl(required, converter = "raw")]
|
||||
_first: Buffer,
|
||||
#[webidl(converter = "raw")]
|
||||
_second: Option<Buffer>,
|
||||
}
|
||||
@@ -74,6 +74,7 @@ const NAVIGATOR_RUNTIME_DATA_KEYS: &[&str] = &[
|
||||
"storageBuckets",
|
||||
"geolocation",
|
||||
"mediaCapabilities",
|
||||
"credentials",
|
||||
];
|
||||
const WORKER_NAVIGATOR_INSTALLED_SLOT: &str = "__moliWorkerNavigatorInstalled";
|
||||
const WORKER_NAVIGATOR_MATERIALIZING_SLOT: &str = "__moliWorkerNavigatorMaterializing";
|
||||
@@ -197,6 +198,13 @@ struct NavigatorRuntimeDataPrototypeDeclaration {
|
||||
cookie_enabled: (),
|
||||
}
|
||||
|
||||
#[derive(Default, WebApiObject)]
|
||||
#[webapi(fragment, prototype = "Navigator")]
|
||||
struct NavigatorCredentialsPrototypeDeclaration {
|
||||
#[webapi(accessor_property, enumerable, receiver = web_api_interfaces::Navigator::is_instance, getter = navigator_runtime_data_getter_callback, data = callback_data_index_value(scope, 33))]
|
||||
credentials: (),
|
||||
}
|
||||
|
||||
#[derive(Default, WebApiFunctionTemplate)]
|
||||
#[webapi(interface = web_api_interfaces::Navigator)]
|
||||
struct NavigatorPrototypeMethodsDeclaration {
|
||||
@@ -591,6 +599,8 @@ struct WindowNavigatorBackingDeclaration<'scope, 'profile> {
|
||||
|
||||
#[webapi(data_property, enumerable)]
|
||||
media_capabilities: v8::Local<'scope, v8::Value>,
|
||||
#[webapi(data_property, enumerable)]
|
||||
credentials: v8::Local<'scope, v8::Value>,
|
||||
}
|
||||
|
||||
#[derive(WebApiObject)]
|
||||
@@ -927,6 +937,19 @@ pub(in crate::context_bootstrap) fn install_navigator_template_bindings<'s>(
|
||||
}
|
||||
}
|
||||
|
||||
pub(in crate::context_bootstrap) fn finalize_navigator_credentials_realm_bindings<'s>(
|
||||
scope: &mut v8::PinScope<'s, '_>,
|
||||
prototype: v8::Local<'s, v8::Object>,
|
||||
) -> Result<()> {
|
||||
let global = scope.get_current_context().global(scope);
|
||||
if super::super::runtime_state::window_realm_secure_context_available(scope, global) {
|
||||
NavigatorCredentialsPrototypeDeclaration::default()
|
||||
.initialize(scope, prototype)
|
||||
.map_err(|error| anyhow!("failed to initialize Navigator credentials: {error}"))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn filter_navigator_secure_context_exposure<'s>(
|
||||
scope: &mut v8::PinScope<'s, '_>,
|
||||
prototype: v8::Local<'s, v8::Object>,
|
||||
@@ -934,6 +957,7 @@ fn filter_navigator_secure_context_exposure<'s>(
|
||||
) -> Result<()> {
|
||||
if !secure_context {
|
||||
delete_object_property(scope, prototype, "clipboard")?;
|
||||
delete_object_property(scope, prototype, "credentials")?;
|
||||
delete_object_property(scope, prototype, "mediaDevices")?;
|
||||
delete_object_property(scope, prototype, "storage")?;
|
||||
delete_object_property(scope, prototype, "storageBuckets")?;
|
||||
@@ -1086,6 +1110,7 @@ pub(super) fn build_lazy_navigator_subobject_in_current_realm<'s>(
|
||||
build_service_worker_container(scope, owner_child, owner_popup)?.into()
|
||||
}
|
||||
NavigatorSubobject::Clipboard => build_clipboard_object(scope)?.into(),
|
||||
NavigatorSubobject::Credentials => super::credentials::build_container(scope)?.into(),
|
||||
NavigatorSubobject::UserActivation => build_user_activation(scope)?.into(),
|
||||
NavigatorSubobject::StorageBuckets => {
|
||||
build_storage_bucket_manager(scope, owner_child, owner_popup)?.into()
|
||||
@@ -1275,6 +1300,7 @@ fn build_window_navigator_backing_for_owner<'s>(
|
||||
storage_buckets: v8::undefined(scope).into(),
|
||||
geolocation: v8::undefined(scope).into(),
|
||||
media_capabilities: v8::undefined(scope).into(),
|
||||
credentials: v8::undefined(scope).into(),
|
||||
}
|
||||
.bind(scope)
|
||||
.map_err(|error| anyhow!("failed to bind Navigator backing object: {error}"))
|
||||
@@ -1685,6 +1711,7 @@ fn navigator_runtime_data_object<'s>(
|
||||
scope: &mut v8::PinScope<'s, '_>,
|
||||
navigator: v8::Local<'s, v8::Object>,
|
||||
) -> Option<v8::Local<'s, v8::Object>> {
|
||||
let navigator = moli_webapi_declare::web_api_object_target(scope, navigator)?;
|
||||
get_private_value(scope, navigator, NAVIGATOR_RUNTIME_DATA_SLOT)
|
||||
.and_then(|value| v8::Local::<v8::Object>::try_from(value).ok())
|
||||
}
|
||||
|
||||
@@ -22,11 +22,12 @@ pub(super) enum NavigatorSubobject {
|
||||
StorageBuckets,
|
||||
Geolocation,
|
||||
MediaCapabilities,
|
||||
Credentials,
|
||||
}
|
||||
|
||||
impl NavigatorSubobject {
|
||||
#[cfg(test)]
|
||||
pub(super) const ALL: [Self; 16] = [
|
||||
pub(super) const ALL: [Self; 17] = [
|
||||
Self::Languages,
|
||||
Self::MimeTypes,
|
||||
Self::Plugins,
|
||||
@@ -43,6 +44,7 @@ impl NavigatorSubobject {
|
||||
Self::StorageBuckets,
|
||||
Self::Geolocation,
|
||||
Self::MediaCapabilities,
|
||||
Self::Credentials,
|
||||
];
|
||||
|
||||
pub(super) fn from_key(key: &str) -> Option<Self> {
|
||||
@@ -63,6 +65,7 @@ impl NavigatorSubobject {
|
||||
"storageBuckets" => Some(Self::StorageBuckets),
|
||||
"geolocation" => Some(Self::Geolocation),
|
||||
"mediaCapabilities" => Some(Self::MediaCapabilities),
|
||||
"credentials" => Some(Self::Credentials),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -85,6 +88,7 @@ impl NavigatorSubobject {
|
||||
Self::StorageBuckets => "storageBuckets",
|
||||
Self::Geolocation => "geolocation",
|
||||
Self::MediaCapabilities => "mediaCapabilities",
|
||||
Self::Credentials => "credentials",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use super::workers::WorkerExecutionState;
|
||||
use super::*;
|
||||
use crate::dom::native::Node;
|
||||
|
||||
impl JsContextHost {
|
||||
pub(crate) fn document_has_browsing_context(&self, document: DomHandle) -> bool {
|
||||
@@ -14,6 +15,35 @@ impl JsContextHost {
|
||||
.is_some()
|
||||
}
|
||||
|
||||
pub(crate) fn document_is_fully_active(&self, document: DomHandle) -> bool {
|
||||
if self.page_context_resources_closed {
|
||||
return false;
|
||||
}
|
||||
// A retained Window association is insufficient: the Document and each
|
||||
// ancestor must still be current in a live browsing context.
|
||||
let mut document = document;
|
||||
let mut visited = std::collections::HashSet::new();
|
||||
loop {
|
||||
if document == self.document_handle() {
|
||||
return true;
|
||||
}
|
||||
if let Some(popup_id) = self.lightweight_popup_id_for_document_handle(document) {
|
||||
return self.lightweight_popup_is_open(popup_id);
|
||||
}
|
||||
let Some(frame) = self.child_browsing_context_host_for_document_handle(document) else {
|
||||
return false;
|
||||
};
|
||||
if !visited.insert(frame) || !self.dom_host().is_connected(frame) {
|
||||
return false;
|
||||
}
|
||||
let Some(parent_document) = self.dom_host().node(frame).and_then(Node::owner_document)
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
document = parent_document;
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn document_activity(&self) -> moli_page_types::DocumentActivity {
|
||||
self.document_activity
|
||||
}
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
(async () => {
|
||||
const facts = {complete: false, checks: [], traces: []};
|
||||
globalThis.__uiEventResults = facts;
|
||||
const assert = (ok, message) => { if (!ok) throw Error(message); };
|
||||
const same = (actual, expected) => assert(JSON.stringify(actual) === JSON.stringify(expected), JSON.stringify({actual, expected}));
|
||||
const foreign = document.getElementById('child').contentWindow;
|
||||
for (const [realm, w] of [['main', globalThis], ['iframe', foreign]]) {
|
||||
const run = async (name, fn) => {
|
||||
try { await fn(); facts.checks.push({realm, name, passed: true}); }
|
||||
catch (error) { facts.checks.push({realm, name, passed: false, error: error.name, message: error.message}); }
|
||||
};
|
||||
if (!w.navigator.credentials) {
|
||||
facts.checks.push({realm, name: 'navigator.credentials producer', passed: false, message: 'Missing producer'});
|
||||
continue;
|
||||
}
|
||||
const container = w.navigator.credentials;
|
||||
const prototype = w.CredentialsContainer.prototype;
|
||||
const PromiseCtor = w.Promise, TypeErrorCtor = w.TypeError, DOMExceptionCtor = w.DOMException;
|
||||
const methods = Object.fromEntries(['get', 'create', 'store', 'preventSilentAccess'].map(name => [name, prototype[name]]));
|
||||
const invoke = (name, receiver, args = []) => {
|
||||
let result;
|
||||
try { result = methods[name].apply(receiver, args); }
|
||||
catch (error) { throw Error('Synchronous exception: ' + error); }
|
||||
assert(result instanceof PromiseCtor, 'Promise must belong to function realm');
|
||||
return result;
|
||||
};
|
||||
const rejection = async (name, receiver, args, Constructor, exceptionName) => {
|
||||
let error, rejected = false;
|
||||
try { await invoke(name, receiver, args); } catch (caught) { rejected = true; error = caught; }
|
||||
assert(rejected, 'Expected a rejected Promise');
|
||||
if (Constructor) assert(error instanceof Constructor, 'Wrong exception constructor/realm: ' + String(error));
|
||||
if (exceptionName) {
|
||||
assert(error.name === exceptionName, 'Wrong exception name: ' + String(error));
|
||||
if (exceptionName === 'NotSupportedError') assert(error.code === 9, 'NotSupportedError code');
|
||||
if (exceptionName === 'InvalidStateError') assert(error.code === 11, 'InvalidStateError code');
|
||||
}
|
||||
return error;
|
||||
};
|
||||
const controller = reason => { const value = new w.AbortController(); value.abort(reason); return value; };
|
||||
const validCreation = () => ({rp: {name: 'Site'}, user: {name: 'user', displayName: 'User', id: new Uint8Array([1])}, challenge: new Uint8Array([2]), pubKeyCredParams: [{type: 'public-key', alg: -7}]});
|
||||
const validRequest = () => ({challenge: new Uint8Array([3])});
|
||||
const getters = (values, trace, prefix = '') => Object.defineProperties({}, Object.fromEntries(Object.entries(values).map(([key, value]) => [key, {enumerable: true, get() { trace.push(prefix + key); return value; }}])));
|
||||
await run('secure SameObject producer and descriptor', async () => {
|
||||
assert(w.isSecureContext, 'Fixture must run in a secure context');
|
||||
const descriptor = Object.getOwnPropertyDescriptor(w.Navigator.prototype, 'credentials');
|
||||
assert(descriptor.enumerable && descriptor.configurable && descriptor.set === undefined, 'Readonly enumerable accessor');
|
||||
assert(container === w.navigator.credentials && descriptor.get.call(w.navigator) === container, 'SameObject');
|
||||
assert(Object.getPrototypeOf(container) === prototype && container instanceof w.CredentialsContainer, 'Relevant-realm prototype');
|
||||
assert(Object.prototype.toString.call(container) === '[object CredentialsContainer]', 'Interface tag');
|
||||
let error; try { new w.CredentialsContainer(); } catch (caught) { error = caught; }
|
||||
assert(error instanceof TypeErrorCtor, 'Illegal constructor');
|
||||
const other = w === globalThis ? foreign : globalThis;
|
||||
assert(descriptor.get.call(other.navigator) === other.navigator.credentials, 'Borrowed Navigator getter uses receiver realm/cache');
|
||||
assert(Object.getPrototypeOf(descriptor.get.call(other.navigator)) === other.CredentialsContainer.prototype, 'Borrowed getter materializes in receiver realm');
|
||||
});
|
||||
await run('Navigator getter uses native brand without author traps', async () => {
|
||||
const getter = Object.getOwnPropertyDescriptor(w.Navigator.prototype, 'credentials').get;
|
||||
let reads = 0;
|
||||
const trap = () => { reads++; throw Error('Getter trap'); };
|
||||
const revoked = Proxy.revocable(w.navigator, {}); revoked.revoke();
|
||||
for (const fake of [{}, Object.create(w.navigator), Object.create(w.Navigator.prototype), new Proxy(w.navigator, {get: trap, getPrototypeOf: trap}), revoked.proxy, new EventTarget()]) {
|
||||
let error; try { getter.call(fake); } catch (caught) { error = caught; }
|
||||
assert(error instanceof TypeErrorCtor, 'Navigator getter accepted a forged receiver');
|
||||
}
|
||||
assert(reads === 0, 'Receiver check ran author traps');
|
||||
});
|
||||
const trap = () => { throw Error('Unexpected author trap'); };
|
||||
const revoked = Proxy.revocable(container, {}); revoked.revoke();
|
||||
const badReceivers = [null, undefined, {}, Object.create(container), Object.create(prototype), new Proxy(container, {get: trap, getPrototypeOf: trap}), revoked.proxy, new EventTarget()];
|
||||
for (const [name, length] of [['get', 0], ['create', 0], ['store', 1], ['preventSilentAccess', 0]]) {
|
||||
await run(name + '/native descriptor and arity', async () => {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(prototype, name);
|
||||
assert(descriptor.enumerable && descriptor.configurable && descriptor.writable, 'Writable enumerable method');
|
||||
assert(methods[name].name === name && methods[name].length === length, 'Name and arity');
|
||||
assert(Function.prototype.toString.call(methods[name]).includes('[native code]'), 'Native function');
|
||||
});
|
||||
for (const [index, receiver] of badReceivers.entries()) {
|
||||
await run(name + '/brand before argument conversion/' + index, async () => {
|
||||
let reads = 0;
|
||||
const argument = new Proxy({}, {get() { reads++; throw Error('Argument conversion ran'); }});
|
||||
await rejection(name, receiver, [argument], TypeErrorCtor);
|
||||
assert(reads === 0, 'Invalid receiver must fail before any argument conversion');
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const name of ['get', 'create']) {
|
||||
for (const [label, args] of [['omitted', []], ['undefined', [undefined]], ['null', [null]], ['empty', [{}]], ['unknown', [{get unknown() { throw Error('Unknown member read'); }}]]]) {
|
||||
await run(name + '/empty dictionary/' + label, () => rejection(name, container, args, DOMExceptionCtor, 'NotSupportedError'));
|
||||
}
|
||||
for (const [index, value] of [true, 1, 'string', Symbol('dictionary'), 1n].entries()) {
|
||||
await run(name + '/primitive dictionary/' + index, () => rejection(name, container, [value], TypeErrorCtor));
|
||||
}
|
||||
for (const mediation of ['silent', 'optional', 'conditional', 'required']) {
|
||||
await run(name + '/valid mediation/' + mediation, async () => {
|
||||
const marker = {}, signal = controller(marker).signal;
|
||||
assert(await rejection(name, container, [{mediation, signal}]) === marker, 'Pre-abort reason identity');
|
||||
});
|
||||
}
|
||||
for (const [index, mediation] of ['', 'OPTIONAL', null, Symbol('mediation')].entries()) {
|
||||
await run(name + '/invalid mediation/' + index, () => rejection(name, container, [{mediation, signal: controller({}).signal}], TypeErrorCtor));
|
||||
}
|
||||
for (const [index, reason] of ['reason', {}, [], new Error('reason'), null, 42, 1n, Symbol('reason')].entries()) {
|
||||
await run(name + '/pre-abort exact reason/' + index, async () => {
|
||||
const signal = controller(reason).signal;
|
||||
assert(await rejection(name, container, [{signal}]) === reason, 'Abort reason was replaced');
|
||||
});
|
||||
}
|
||||
await run(name + '/default abort reason and foreign signal', async () => {
|
||||
const other = w === globalThis ? foreign : globalThis;
|
||||
const abort = new other.AbortController(); abort.abort();
|
||||
assert(await rejection(name, container, [{signal: abort.signal}]) === abort.signal.reason, 'Cross-realm native signal/default reason');
|
||||
});
|
||||
const signal = new w.AbortController().signal;
|
||||
const revokedSignal = Proxy.revocable(signal, {}); revokedSignal.revoke();
|
||||
for (const [index, invalid] of [null, {}, Object.create(signal), Object.create(w.AbortSignal.prototype), new Proxy(signal, {get: trap, getPrototypeOf: trap}), revokedSignal.proxy, new EventTarget()].entries()) {
|
||||
await run(name + '/AbortSignal native brand/' + index, () => rejection(name, container, [{signal: invalid}], TypeErrorCtor));
|
||||
}
|
||||
await run(name + '/abort state ignores public getters', async () => {
|
||||
const marker = {}, signal = controller(marker).signal;
|
||||
Object.defineProperties(signal, {aborted: {get: trap}, reason: {get: trap}});
|
||||
assert(await rejection(name, container, [{signal}]) === marker, 'Internal abort state/reason');
|
||||
});
|
||||
await run(name + '/conversion exception before pre-abort', async () => {
|
||||
const marker = {}, signal = controller({}).signal;
|
||||
assert(await rejection(name, container, [{get mediation() { throw marker; }, signal}]) === marker, 'Conversion exception was replaced');
|
||||
});
|
||||
await run(name + '/all dictionary getters precede native algorithm', async () => {
|
||||
const trace = [], marker = {}, signal = controller(marker).signal;
|
||||
const values = name === 'get' ? {federated: undefined, mediation: 'optional', password: false, publicKey: undefined, signal, uiMode: undefined} : {federated: undefined, mediation: 'optional', password: undefined, publicKey: undefined, signal};
|
||||
const options = getters(values, trace);
|
||||
assert(await rejection(name, container, [options]) === marker, 'Abort must follow conversion');
|
||||
same(trace, Object.keys(values)); facts.traces.push({realm, name, trace});
|
||||
});
|
||||
await run(name + '/missing defaults avoid Object.prototype getters', async () => {
|
||||
let reads = 0;
|
||||
const get = () => { reads++; throw Error('Inherited default read'); };
|
||||
for (const key of ['federated', 'mediation', 'password', 'publicKey', 'signal']) Object.defineProperty(w.Object.prototype, key, {configurable: true, get});
|
||||
let error;
|
||||
try { error = await rejection(name, container, [], DOMExceptionCtor, 'NotSupportedError'); }
|
||||
finally { for (const key of ['federated', 'mediation', 'password', 'publicKey', 'signal']) delete w.Object.prototype[key]; }
|
||||
assert(reads === 0 && error.name === 'NotSupportedError', 'Empty default is not an ordinary JS object');
|
||||
});
|
||||
for (const [index, binary] of [null, [], {}, 'bytes', new Proxy(new Uint8Array([1]), {})].entries()) {
|
||||
await run(name + '/native BufferSource/' + index, async () => {
|
||||
const publicKey = name === 'create' ? validCreation() : validRequest();
|
||||
publicKey.challenge = binary;
|
||||
await rejection(name, container, [{publicKey, signal: controller({}).signal}], TypeErrorCtor);
|
||||
});
|
||||
}
|
||||
for (const [index, binary] of [new ArrayBuffer(2), new Uint8Array([1, 2]), new DataView(new ArrayBuffer(3))].entries()) {
|
||||
await run(name + '/valid BufferSource before abort/' + index, async () => {
|
||||
const marker = {}, publicKey = name === 'create' ? validCreation() : validRequest();
|
||||
publicKey.challenge = binary;
|
||||
assert(await rejection(name, container, [{publicKey, signal: controller(marker).signal}]) === marker, 'Native ArrayBuffer/view conversion');
|
||||
});
|
||||
}
|
||||
for (const [index, binary] of [new ArrayBuffer(2, {maxByteLength: 4}), new Uint8Array(new ArrayBuffer(2, {maxByteLength: 4})), new DataView(new ArrayBuffer(2, {maxByteLength: 4}))].entries()) {
|
||||
await run(name + '/reject resizable BufferSource/' + index, async () => {
|
||||
const publicKey = name === 'create' ? validCreation() : validRequest(); publicKey.challenge = binary;
|
||||
await rejection(name, container, [{publicKey, signal: controller({}).signal}], TypeErrorCtor);
|
||||
});
|
||||
}
|
||||
if (typeof w.SharedArrayBuffer === 'function') {
|
||||
for (const [index, binary] of [new w.SharedArrayBuffer(2), new Uint8Array(new w.SharedArrayBuffer(2)), new DataView(new w.SharedArrayBuffer(2))].entries()) {
|
||||
await run(name + '/reject shared BufferSource/' + index, async () => {
|
||||
const publicKey = name === 'create' ? validCreation() : validRequest(); publicKey.challenge = binary;
|
||||
await rejection(name, container, [{publicKey, signal: controller({}).signal}], TypeErrorCtor);
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const extension of [{credBlob: {}}, {largeBlob: {write: {}}}, {prf: {eval: {first: {}}}}, {prf: {evalByCredential: {AA: {first: {}}}}}]) {
|
||||
await run(name + '/nested extension buffers/' + JSON.stringify(extension), async () => {
|
||||
const publicKey = name === 'create' ? validCreation() : validRequest(); publicKey.extensions = extension;
|
||||
await rejection(name, container, [{publicKey, signal: controller({}).signal}], TypeErrorCtor);
|
||||
});
|
||||
}
|
||||
await run(name + '/credential descriptor required members', async () => {
|
||||
const publicKey = name === 'create' ? validCreation() : validRequest();
|
||||
publicKey[name === 'create' ? 'excludeCredentials' : 'allowCredentials'] = [{type: 'public-key'}];
|
||||
await rejection(name, container, [{publicKey, signal: controller({}).signal}], TypeErrorCtor);
|
||||
});
|
||||
await run(name + '/PRF record key getter exception identity', async () => {
|
||||
const marker = {}, publicKey = name === 'create' ? validCreation() : validRequest();
|
||||
publicKey.extensions = {prf: {evalByCredential: {get AA() { throw marker; }}}};
|
||||
assert(await rejection(name, container, [{publicKey, signal: controller({}).signal}]) === marker, 'Record getter exception identity');
|
||||
});
|
||||
}
|
||||
await run('create/inherited dictionaries convert before derived members', async () => {
|
||||
const marker = {}, trace = [], publicKey = validCreation();
|
||||
publicKey.rp = getters({name: 'Site', id: location.hostname}, trace, 'rp.');
|
||||
publicKey.user = getters({name: 'user', displayName: 'User', id: new Uint8Array([1])}, trace, 'user.');
|
||||
assert(await rejection('create', container, [{publicKey, signal: controller(marker).signal}]) === marker, 'Valid nested conversion');
|
||||
same(trace, ['rp.name', 'rp.id', 'user.name', 'user.displayName', 'user.id']);
|
||||
});
|
||||
for (const kind of ['password', 'federated']) {
|
||||
await run('create/' + kind + '/inherited dictionary member order', async () => {
|
||||
const marker = {}, trace = [];
|
||||
const values = kind === 'password' ? {id: 'user', iconURL: '', name: 'User', origin: location.origin, password: 'secret'} : {id: 'user', iconURL: '', name: 'User', origin: location.origin, protocol: undefined, provider: 'https://idp.example'};
|
||||
const options = {[kind]: getters(values, trace), signal: controller(marker).signal};
|
||||
assert(await rejection('create', container, [options]) === marker, 'Abort after credential conversion');
|
||||
same(trace, Object.keys(values));
|
||||
});
|
||||
await run('create/' + kind + '/required origin', async () => {
|
||||
const data = kind === 'password' ? {id: 'user', password: 'secret'} : {id: 'user', provider: 'https://idp.example'};
|
||||
await rejection('create', container, [{[kind]: data, signal: controller({}).signal}], TypeErrorCtor);
|
||||
});
|
||||
}
|
||||
await run('create/native form union precedes dictionary getters', async () => {
|
||||
const marker = {}, form = document.createElement('form');
|
||||
Object.defineProperty(form, 'password', {get: trap});
|
||||
assert(await rejection('create', container, [{password: form, signal: controller(marker).signal}]) === marker, 'Native form union branch');
|
||||
});
|
||||
await run('create/multiple credential types before pre-abort', async () => {
|
||||
const password = {id: 'user', origin: location.origin, password: 'secret'}, publicKey = validCreation();
|
||||
await rejection('create', container, [{password, publicKey, signal: controller({}).signal}], DOMExceptionCtor, 'NotSupportedError');
|
||||
});
|
||||
for (const [index, args] of [[], [undefined], [null], [{}], [Object.create(w.Credential.prototype)]].entries()) {
|
||||
await run('store/required native Credential/' + index, () => rejection('store', container, args, TypeErrorCtor));
|
||||
}
|
||||
await run('preventSilentAccess/idempotent undefined without argument conversion', async () => {
|
||||
assert(await invoke('preventSilentAccess', container, [new Proxy({}, {get: trap})]) === undefined, 'No extra argument conversion');
|
||||
assert(await invoke('preventSilentAccess', container) === undefined, 'Idempotent result');
|
||||
});
|
||||
await run('native brand remains after prototype replacement', async () => {
|
||||
const saved = Object.getPrototypeOf(container);
|
||||
Object.setPrototypeOf(container, null);
|
||||
try { assert(await invoke('preventSilentAccess', container) === undefined, 'Native brand survives prototype change'); }
|
||||
finally { Object.setPrototypeOf(container, saved); }
|
||||
});
|
||||
await run('native Promise and errors ignore replaced public constructors', async () => {
|
||||
const saved = [w.Promise, w.TypeError, w.DOMException, w.CredentialsContainer];
|
||||
try {
|
||||
w.Promise = w.TypeError = w.DOMException = w.CredentialsContainer = function() { throw Error('Public constructor called'); };
|
||||
await rejection('store', container, [{}], TypeErrorCtor);
|
||||
await rejection('create', container, [], DOMExceptionCtor, 'NotSupportedError');
|
||||
assert(await invoke('preventSilentAccess', container) === undefined, 'Native Promise resolver');
|
||||
} finally { [w.Promise, w.TypeError, w.DOMException, w.CredentialsContainer] = saved; }
|
||||
});
|
||||
}
|
||||
for (const ancestor of [false, true]) {
|
||||
const name = ancestor ? 'inactive ancestor' : 'removed iframe';
|
||||
try {
|
||||
const outer = document.createElement('iframe'); document.body.appendChild(outer);
|
||||
let frame = outer;
|
||||
if (ancestor) { frame = outer.contentDocument.createElement('iframe'); outer.contentDocument.body.appendChild(frame); }
|
||||
const w = frame.contentWindow, container = w.navigator.credentials, PromiseCtor = w.Promise, DOMExceptionCtor = w.DOMException, TypeErrorCtor = w.TypeError;
|
||||
const marker = {}, abort = new w.AbortController(); abort.abort(marker);
|
||||
const get = container.get, create = container.create, prevent = container.preventSilentAccess, store = container.store;
|
||||
outer.remove();
|
||||
for (const [method, args] of [[get, [{signal: abort.signal}]], [create, [{signal: abort.signal}]], [prevent, []]]) {
|
||||
let error; const promise = method.apply(container, args); assert(promise instanceof PromiseCtor, 'Inactive function Promise realm');
|
||||
try { await promise; } catch (caught) { error = caught; }
|
||||
assert(error instanceof DOMExceptionCtor && error.name === 'InvalidStateError', 'Fully active check precedes pre-abort');
|
||||
}
|
||||
let error; try { await store.call(container, {}); } catch (caught) { error = caught; }
|
||||
assert(error instanceof TypeErrorCtor, 'WebIDL conversion precedes native activity check');
|
||||
facts.checks.push({realm: 'lifecycle', name, passed: true});
|
||||
} catch (error) { facts.checks.push({realm: 'lifecycle', name, passed: false, error: error.name, message: error.message}); }
|
||||
}
|
||||
facts.passed = facts.checks.filter(row => row.passed).length; facts.total = facts.checks.length; facts.complete = true;
|
||||
return true;
|
||||
})()
|
||||
@@ -0,0 +1,123 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn credentials_container_preserves_native_brands_conversion_order_abort_and_inactive_realms() {
|
||||
let mut vm = new_storage_page_task_executor_test_vm("https://credentials-container.test/");
|
||||
vm.eval("document.body.innerHTML = '<iframe id=child></iframe>'")
|
||||
.unwrap();
|
||||
vm.eval(include_str!("credential_container.js")).unwrap();
|
||||
assert_eq!(vm.eval("__uiEventResults.complete").unwrap(), "true");
|
||||
assert_eq!(
|
||||
vm.eval("JSON.stringify(__uiEventResults.checks.filter(row => !row.passed))")
|
||||
.unwrap(),
|
||||
"[]"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn credentials_container_exposes_navigator_property_in_secure_contexts() {
|
||||
for (url, exposed) in [
|
||||
("https://credentials-container.test/", true),
|
||||
("http://localhost/", true),
|
||||
("http://credentials-container.test/", false),
|
||||
] {
|
||||
let mut vm = new_storage_page_task_executor_test_vm(url);
|
||||
assert_eq!(vm.eval("isSecureContext").unwrap(), exposed.to_string());
|
||||
vm.eval(&format!(
|
||||
"Object.defineProperty(globalThis, 'isSecureContext', {{value: {}}})",
|
||||
!exposed
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
vm.eval("Object.hasOwn(Navigator.prototype, 'credentials')")
|
||||
.unwrap(),
|
||||
exposed.to_string(),
|
||||
"{url}"
|
||||
);
|
||||
assert_eq!(
|
||||
vm.eval("'credentials' in navigator").unwrap(),
|
||||
exposed.to_string(),
|
||||
"{url} after Navigator materialization"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn credentials_container_accepts_registered_native_proxies_and_native_credential_arguments() {
|
||||
let mut vm = new_storage_page_task_executor_test_vm("https://credentials-container.test/");
|
||||
vm.eval("globalThis.nativeSignal = AbortSignal.abort({})")
|
||||
.unwrap();
|
||||
let context_ptr = &vm.page_default_runtime.context as *const _;
|
||||
vm.with_context_scope_by_ptr_and_checkpoint_for_test(context_ptr, |scope, _| {
|
||||
let global = scope.get_current_context().global(scope);
|
||||
let fixtures = v8::Object::new(scope);
|
||||
for name in ["CredentialsContainer", "Credential", "PublicKeyCredential"] {
|
||||
let object = v8::Object::new(scope);
|
||||
// Identity-only fixtures, not produced credentials or saved secrets.
|
||||
moli_webapi_declare::initialize_web_api_object(scope, object, name).unwrap();
|
||||
let handler = crate::util::new_null_prototype_object(scope);
|
||||
let proxy = v8::Proxy::new(scope, object, handler).unwrap();
|
||||
moli_webapi_declare::register_web_api_proxy(scope, proxy).unwrap();
|
||||
let key = crate::util::v8str(scope, name);
|
||||
assert_eq!(
|
||||
fixtures.create_data_property(scope, key.into(), proxy.into()),
|
||||
Some(true)
|
||||
);
|
||||
}
|
||||
let key = crate::util::v8str(scope, "navigator");
|
||||
let navigator = global.get(scope, key.into()).unwrap();
|
||||
let navigator = v8::Local::<v8::Object>::try_from(navigator).unwrap();
|
||||
let handler = crate::util::new_null_prototype_object(scope);
|
||||
let proxy = v8::Proxy::new(scope, navigator, handler).unwrap();
|
||||
moli_webapi_declare::register_web_api_proxy(scope, proxy).unwrap();
|
||||
let key = crate::util::v8str(scope, "Navigator");
|
||||
assert_eq!(
|
||||
fixtures.create_data_property(scope, key.into(), proxy.into()),
|
||||
Some(true)
|
||||
);
|
||||
let key = crate::util::v8str(scope, "nativeSignal");
|
||||
let signal = global.get(scope, key.into()).unwrap();
|
||||
let signal = v8::Local::<v8::Object>::try_from(signal).unwrap();
|
||||
let handler = crate::util::new_null_prototype_object(scope);
|
||||
let proxy = v8::Proxy::new(scope, signal, handler).unwrap();
|
||||
moli_webapi_declare::register_web_api_proxy(scope, proxy).unwrap();
|
||||
let key = crate::util::v8str(scope, "AbortSignal");
|
||||
assert_eq!(
|
||||
fixtures.create_data_property(scope, key.into(), proxy.into()),
|
||||
Some(true)
|
||||
);
|
||||
let key = crate::util::v8str(scope, "nativeFixtures");
|
||||
assert_eq!(
|
||||
global.create_data_property(scope, key.into(), fixtures.into()),
|
||||
Some(true)
|
||||
);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
vm.eval(r#"(async () => {
|
||||
const prototype = CredentialsContainer.prototype, container = nativeFixtures.CredentialsContainer;
|
||||
const getter = Object.getOwnPropertyDescriptor(Navigator.prototype, 'credentials').get;
|
||||
if (getter.call(nativeFixtures.Navigator) !== navigator.credentials) throw Error('Native Navigator proxy rejected');
|
||||
if (await prototype.preventSilentAccess.call(container) !== undefined) throw Error('Native proxy receiver rejected');
|
||||
for (const method of ['get', 'create']) {
|
||||
let reason; try { await prototype[method].call(container, {signal: nativeFixtures.AbortSignal}); } catch (caught) { reason = caught; }
|
||||
if (reason !== nativeSignal.reason) throw Error('Native AbortSignal proxy rejected');
|
||||
}
|
||||
for (const credential of [nativeFixtures.Credential, nativeFixtures.PublicKeyCredential]) {
|
||||
let error; try { await prototype.store.call(container, credential); } catch (caught) { error = caught; }
|
||||
if (!(error instanceof DOMException) || error.name !== 'NotSupportedError') throw Error('Native Credential brand rejected');
|
||||
let conversions = 0;
|
||||
const revoked = Proxy.revocable(credential, {}); revoked.revoke();
|
||||
for (const fake of [Object.create(credential), new Proxy(credential, {get() { conversions++; throw Error('Trap'); }}), revoked.proxy]) {
|
||||
let error; try { await prototype.store.call(container, fake); } catch (caught) { error = caught; }
|
||||
if (!(error instanceof TypeError)) throw Error('Author receiver accepted');
|
||||
}
|
||||
if (conversions) throw Error('Native interface conversion ran author trap');
|
||||
}
|
||||
globalThis.nativeCredentialTestDone = true;
|
||||
})().catch(error => globalThis.nativeCredentialTestDone = String(error))"#).unwrap();
|
||||
assert_eq!(
|
||||
vm.eval("globalThis.nativeCredentialTestDone").unwrap(),
|
||||
"true"
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
mod credential_container;
|
||||
mod credential_json_options;
|
||||
mod writable_stream_native_receivers;
|
||||
use super::post_parse::dynamic_script_execute_is_runnable_before_dom_content_loaded;
|
||||
|
||||
Reference in New Issue
Block a user