feat: support WebAuthn Touch ID platform authenticator on macOS (#51411)

* feat: support WebAuthn Touch ID platform authenticator on macOS

Adds `app.configureWebAuthn({ touchID: { keychainAccessGroup } })` to enable
the Secure Enclave platform authenticator for `navigator.credentials`.
Credentials are stored under the app-supplied keychain access group with a
per-session metadata secret that is generated on first use and persisted in
prefs.

Also introduces `ElectronAuthenticatorRequestClientDelegate` and wires it via
`ContentBrowserClient::GetWebAuthenticationRequestDelegate()` so that
discoverable-credential `get()` calls with multiple matches emit a new
`select-webauthn-account` session event instead of DCHECK-failing in the base
delegate. If no listener is registered (or the callback is invoked with no
credential), the request is cancelled with NotAllowedError rather than
silently auto-selecting.

Tests use the DevTools virtual authenticator so the account-selection flow is
exercised in CI without entitlements or real hardware.

Co-authored-by: Samuel Attard <sattard@anthropic.com>

* fix: register request delegate as FidoRequestHandlerBase observer

The base AuthenticatorRequestClientDelegate::StartObserving() is a no-op, so
observer() on the request handler stayed null. MakeCredentialRequestHandler::
SpecializeRequestForAuthenticator dereferences observer()->SupportsPIN() when
residentKey is 'preferred', crashing with SEGV when a real FIDO2 HID key is
dispatched.

Override StartObserving/StopObserving to register via a ScopedObservation like
ChromeAuthenticatorRequestDelegate does. Added a virtual-authenticator
regression test for create() with residentKey: 'preferred'.

Co-authored-by: Samuel Attard <sattard@anthropic.com>

* chore: update copyright attribution for new webauthn files

Co-authored-by: Samuel Attard <sattard@anthropic.com>

* fix: address review feedback on webauthn account-select event

- Encode credentialId and userHandle as URL-safe base64 without padding so
  the values match PublicKeyCredential.id from navigator.credentials.get()
  byte-for-byte; tests now assert the equality rather than transcoding.
- Cancel the pending request when the listener invokes the callback with a
  credentialId that does not match any account, instead of leaving the
  request hanging while the listener retries. The TypeError still surfaces
  so the misuse remains visible to the developer.
- DCHECK that the Touch ID config helpers run on the UI thread, encoding
  the threading invariant the read-then-write metadata-secret pref relies
  on.

Co-authored-by: Samuel Attard <sattard@anthropic.com>

* fix: oxfmt formatting in webauthn spec

Co-authored-by: Samuel Attard <sattard@anthropic.com>

* fix: use out-param form of base::Base64UrlEncode

Co-authored-by: Samuel Attard <sattard@anthropic.com>

* fix: silently cancel webauthn account select on unknown credentialId

Throwing back into the listener bubbles up as an unhandled exception in
the main process. Match the no-args branch exactly so the listener sees a
single consistent failure mode (cancel + NotAllowedError) whether it
declines deliberately or by mistake.

Co-authored-by: Samuel Attard <sattard@anthropic.com>

* chore: node script/lint.js --js --fix

---------

Co-authored-by: trop[bot] <37223003+trop[bot]@users.noreply.github.com>
Co-authored-by: Samuel Attard <sattard@anthropic.com>
Co-authored-by: Charles Kerr <charles@charleskerr.com>
This commit is contained in:
trop[bot]
2026-05-01 11:15:13 -04:00
committed by GitHub
parent 930a5ccc97
commit 2d943ef610
17 changed files with 756 additions and 0 deletions

View File

@@ -509,6 +509,7 @@ source_set("electron_lib") {
"//content/public/utility",
"//device/bluetooth",
"//device/bluetooth/public/cpp",
"//device/fido",
"//gin",
"//gpu/ipc/client",
"//media/capture/mojom:video_capture",

View File

@@ -1233,6 +1233,51 @@ This API must be called after the `ready` event is emitted.
[doh-providers]: https://source.chromium.org/chromium/chromium/src/+/main:net/dns/public/doh_provider_entry.cc;l=31?q=%22DohProviderEntry::GetList()%22&ss=chromium%2Fchromium%2Fsrc
[RFC8484 § 3]: https://datatracker.ietf.org/doc/html/rfc8484#section-3
### `app.configureWebAuthn(options)` _macOS_
* `options` Object
* `touchID` Object (optional) - Enables the Touch ID / Secure Enclave platform
authenticator for [Web Authentication](https://www.w3.org/TR/webauthn-2/)
requests.
* `keychainAccessGroup` string - The keychain access group that WebAuthn
credentials will be stored under. This value **must** also be present in
your app's `keychain-access-groups` code-signing entitlement, and is
typically of the form `<TEAM_ID>.<BUNDLE_ID>.webauthn`.
Configures platform authenticators for the Web Authentication API
(`navigator.credentials.create()` / `navigator.credentials.get()`). Until this
is called, `PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable()`
resolves to `false` and platform-authenticator requests are not serviced.
When `touchID` is provided, WebAuthn credentials are stored in the macOS
keychain and bound to this device's Secure Enclave. Electron automatically
generates and persists a per-[`session`](session.md) metadata secret so that
credentials created in one partition are not visible to another.
```js
const { app } = require('electron')
app.configureWebAuthn({
touchID: {
keychainAccessGroup: 'A1B2C3D4E5.com.example.app.webauthn'
}
})
```
With the matching entitlement in your app's `entitlements.plist`:
```xml
<key>keychain-access-groups</key>
<array>
<string>A1B2C3D4E5.com.example.app.webauthn</string>
</array>
```
> [!NOTE]
> Touch ID WebAuthn credentials are device-bound and are not synced via iCloud
> Keychain. They are only available on Macs with a Secure Enclave (Apple
> silicon, or Intel Macs with a T2 chip).
### `app.disableHardwareAcceleration()`
Disables hardware acceleration for current app.

View File

@@ -629,6 +629,54 @@ Emitted after `USBDevice.forget()` has been called. This event can be used
to help maintain persistent storage of permissions when
`setDevicePermissionHandler` is used.
#### Event: 'select-webauthn-account'
Returns:
* `event` Event
* `details` Object
* `relyingPartyId` string - The relying party identifier from the WebAuthn request.
* `accounts` [WebAuthnAccount[]](structures/webauthn-account.md)
* `frame` [WebFrameMain](web-frame-main.md) | null - The frame initiating this event.
May be `null` if accessed after the frame has either navigated or been destroyed.
* `callback` Function
* `credentialId` string | null (optional)
Emitted when a call to `navigator.credentials.get()` resolves multiple
discoverable WebAuthn credentials and the user must choose one. `callback`
should be called with the `credentialId` of the selected account; passing no
arguments — or a `credentialId` that does not match one of the provided
accounts — will cancel the request and the page will receive a
`NotAllowedError`. If no listener is registered for this event, the request is
cancelled with the same error. The credential request remains pending until
the listener invokes the callback, so always invoke it exactly once — typically
from a `try { … } finally { callback(…) }` block.
On macOS, the Touch ID platform authenticator surfaces accounts via this event
once it has been configured with
[`app.configureWebAuthn`](app.md#appconfigurewebauthnoptions-macos). The event
may also fire on other platforms when a roaming FIDO2 authenticator returns
multiple discoverable credentials.
```js
const { app, BrowserWindow } = require('electron')
let win = null
app.whenReady().then(() => {
app.configureWebAuthn({
touchID: { keychainAccessGroup: 'A1B2C3D4E5.com.example.app.webauthn' }
})
win = new BrowserWindow()
win.webContents.session.on('select-webauthn-account', (event, details, callback) => {
const selected = details.accounts.find((a) => a.name === 'alice@example.com')
callback(selected?.credentialId)
})
})
```
### Instance Methods
The following methods are available on instances of `Session`:

View File

@@ -0,0 +1,9 @@
# WebAuthnAccount Object
* `credentialId` string - URL-safe base64-encoded (no padding) credential ID of
the discoverable credential. Matches `PublicKeyCredential.id` returned by
`navigator.credentials.get()` in the renderer.
* `userHandle` string (optional) - URL-safe base64-encoded (no padding) user
handle (`user.id`) that was provided when the credential was created.
* `name` string (optional) - Human-palatable identifier for the account (for example, an email address or username).
* `displayName` string (optional) - Human-palatable name for the account, intended for display.

View File

@@ -171,6 +171,7 @@ auto_filenames = {
"docs/api/structures/web-preferences.md",
"docs/api/structures/web-request-filter.md",
"docs/api/structures/web-source.md",
"docs/api/structures/webauthn-account.md",
"docs/api/structures/window-open-handler-response.md",
"docs/api/structures/window-session-end-event.md",
]

View File

@@ -566,6 +566,8 @@ filenames = {
"shell/browser/web_view_guest_delegate.h",
"shell/browser/web_view_manager.cc",
"shell/browser/web_view_manager.h",
"shell/browser/webauthn/electron_authenticator_request_client_delegate.cc",
"shell/browser/webauthn/electron_authenticator_request_client_delegate.h",
"shell/browser/webauthn/electron_authenticator_request_delegate.cc",
"shell/browser/webauthn/electron_authenticator_request_delegate.h",
"shell/browser/window_list.cc",

View File

@@ -99,6 +99,7 @@
#include "content/browser/mac_helpers.h"
#include "shell/browser/electron_child_process_host_flags.h"
#include "shell/browser/ui/cocoa/electron_bundle_mover.h"
#include "shell/browser/webauthn/electron_authenticator_request_delegate.h"
#include "shell/common/process_util.h"
#endif
@@ -1662,6 +1663,29 @@ bool App::IsInApplicationsFolder() {
return ElectronBundleMover::IsCurrentAppInApplicationsFolder();
}
void App::ConfigureWebAuthn(gin_helper::ErrorThrower thrower,
gin::Arguments* args) {
gin_helper::Dictionary options;
if (!args->GetNext(&options)) {
thrower.ThrowTypeError("configureWebAuthn requires an options object");
return;
}
gin_helper::Dictionary touch_id;
if (options.Get("touchID", &touch_id)) {
std::string keychain_access_group;
if (!touch_id.Get("keychainAccessGroup", &keychain_access_group) ||
keychain_access_group.empty()) {
thrower.ThrowTypeError(
"configureWebAuthn: 'touchID.keychainAccessGroup' must be a "
"non-empty string");
return;
}
ElectronWebAuthenticationDelegate::SetTouchIdKeychainAccessGroup(
std::move(keychain_access_group));
}
}
int DockBounce(gin::Arguments* args) {
int request_id = -1;
std::string type = "informational";
@@ -1891,6 +1915,7 @@ gin::ObjectTemplateBuilder App::GetObjectTemplateBuilder(v8::Isolate* isolate) {
.SetMethod("moveToApplicationsFolder", &App::MoveToApplicationsFolder)
.SetMethod("isInApplicationsFolder", &App::IsInApplicationsFolder)
.SetMethod("setActivationPolicy", &App::SetActivationPolicy)
.SetMethod("configureWebAuthn", &App::ConfigureWebAuthn)
#endif
.SetMethod("setAboutPanelOptions",
base::BindRepeating(&Browser::SetAboutPanelOptions, browser))

View File

@@ -244,6 +244,8 @@ class App final : public gin::Wrappable<App>,
#if BUILDFLAG(IS_MAC)
void SetActivationPolicy(gin_helper::ErrorThrower thrower,
const std::string& policy);
void ConfigureWebAuthn(gin_helper::ErrorThrower thrower,
gin::Arguments* args);
bool MoveToApplicationsFolder(gin_helper::ErrorThrower, gin::Arguments* args);
bool IsInApplicationsFolder();
v8::Local<v8::Value> GetDockAPI(v8::Isolate* isolate);

View File

@@ -115,6 +115,7 @@
#include "shell/browser/usb/electron_usb_delegate.h"
#include "shell/browser/web_contents_permission_helper.h"
#include "shell/browser/web_contents_preferences.h"
#include "shell/browser/webauthn/electron_authenticator_request_client_delegate.h"
#include "shell/browser/webauthn/electron_authenticator_request_delegate.h"
#include "shell/browser/window_list.h"
#include "shell/common/api/api.mojom.h"
@@ -1922,4 +1923,13 @@ ElectronBrowserClient::GetWebAuthenticationDelegate() {
return web_authentication_delegate_.get();
}
#if !BUILDFLAG(IS_ANDROID)
std::unique_ptr<content::AuthenticatorRequestClientDelegate>
ElectronBrowserClient::GetWebAuthenticationRequestDelegate(
content::RenderFrameHost* render_frame_host) {
return std::make_unique<ElectronAuthenticatorRequestClientDelegate>(
render_frame_host);
}
#endif
} // namespace electron

View File

@@ -127,6 +127,11 @@ class ElectronBrowserClient : public content::ContentBrowserClient,
content::UsbDelegate* GetUsbDelegate() override;
content::WebAuthenticationDelegate* GetWebAuthenticationDelegate() override;
#if !BUILDFLAG(IS_ANDROID)
std::unique_ptr<content::AuthenticatorRequestClientDelegate>
GetWebAuthenticationRequestDelegate(
content::RenderFrameHost* render_frame_host) override;
#endif
#if BUILDFLAG(IS_MAC)
std::string GetChildProcessSuffix(int child_flags) override;

View File

@@ -500,6 +500,11 @@ void ElectronBrowserContext::InitPrefs() {
// Unique uuid for global shortcuts.
registry->RegisterStringPref(electron::kElectronGlobalShortcutsUuid,
std::string());
#if BUILDFLAG(IS_MAC)
registry->RegisterStringPref(electron::kWebAuthnTouchIdMetadataSecretPrefName,
std::string());
#endif
}
void ElectronBrowserContext::SetUserAgent(const std::string& user_agent) {

View File

@@ -0,0 +1,204 @@
// Copyright (c) 2026 Anthropic, PBC.
// Use of this source code is governed by the MIT license that can be
// found in the LICENSE file.
#include "shell/browser/webauthn/electron_authenticator_request_client_delegate.h"
#include <string>
#include <utility>
#include "base/base64url.h"
#include "base/containers/span.h"
#include "base/functional/bind.h"
#include "content/public/browser/render_frame_host.h"
#include "content/public/browser/web_contents.h"
#include "device/fido/authenticator_get_assertion_response.h"
#include "device/fido/public/public_key_credential_descriptor.h"
#include "device/fido/public/public_key_credential_user_entity.h"
#include "gin/arguments.h"
#include "gin/data_object_builder.h"
#include "shell/browser/api/electron_api_session.h"
#include "shell/browser/javascript_environment.h"
#include "shell/common/gin_converters/callback_converter.h"
#include "shell/common/gin_converters/frame_converter.h"
#include "shell/common/gin_helper/event.h"
#include "shell/common/gin_helper/event_emitter_caller.h"
namespace electron {
namespace {
// WebAuthn's PublicKeyCredential.id is canonically URL-safe base64 with no
// padding, so encode credential IDs and user handles the same way to keep the
// event payload string-comparable to values returned by navigator.credentials.
std::string Base64UrlEncodeNoPad(base::span<const uint8_t> input) {
std::string out;
base::Base64UrlEncode(input, base::Base64UrlEncodePolicy::OMIT_PADDING, &out);
return out;
}
std::string CredentialIdFor(
const device::AuthenticatorGetAssertionResponse& response) {
if (response.credential) {
return Base64UrlEncodeNoPad(response.credential->id);
}
return {};
}
} // namespace
ElectronAuthenticatorRequestClientDelegate::
ElectronAuthenticatorRequestClientDelegate(
content::RenderFrameHost* render_frame_host)
: render_frame_host_id_(render_frame_host->GetGlobalId()) {}
ElectronAuthenticatorRequestClientDelegate::
~ElectronAuthenticatorRequestClientDelegate() = default;
void ElectronAuthenticatorRequestClientDelegate::SetRelyingPartyId(
const std::string& rp_id) {
relying_party_id_ = rp_id;
}
void ElectronAuthenticatorRequestClientDelegate::StartObserving(
device::FidoRequestHandlerBase* request_handler) {
request_handler_observation_.Observe(request_handler);
}
void ElectronAuthenticatorRequestClientDelegate::StopObserving(
device::FidoRequestHandlerBase* request_handler) {
request_handler_observation_.Reset();
}
void ElectronAuthenticatorRequestClientDelegate::RegisterActionCallbacks(
base::OnceClosure cancel_callback,
base::OnceClosure immediate_not_found_callback,
base::RepeatingClosure start_over_callback,
AccountPreselectedCallback account_preselected_callback,
PasswordSelectedCallback password_selected_callback,
device::FidoRequestHandlerBase::RequestCallback request_callback,
base::OnceClosure cancel_ui_timeout_callback,
base::RepeatingClosure bluetooth_adapter_power_on_callback,
base::RepeatingCallback<
void(device::FidoRequestHandlerBase::BlePermissionCallback)>
request_ble_permission_callback) {
cancel_callback_ = std::move(cancel_callback);
}
void ElectronAuthenticatorRequestClientDelegate::SelectAccount(
std::vector<device::AuthenticatorGetAssertionResponse> responses,
base::OnceCallback<void(device::AuthenticatorGetAssertionResponse)>
callback) {
DCHECK(!responses.empty());
content::RenderFrameHost* rfh =
content::RenderFrameHost::FromID(render_frame_host_id_);
content::WebContents* web_contents =
rfh ? content::WebContents::FromRenderFrameHost(rfh) : nullptr;
gin::WeakCell<api::Session>* session =
web_contents
? api::Session::FromBrowserContext(web_contents->GetBrowserContext())
: nullptr;
pending_responses_ = std::move(responses);
select_account_callback_ = std::move(callback);
if (!session || !session->Get()) {
CancelPendingAccountSelection();
return;
}
v8::Isolate* isolate = JavascriptEnvironment::GetIsolate();
v8::HandleScope scope(isolate);
v8::Local<v8::Array> accounts =
v8::Array::New(isolate, static_cast<int>(pending_responses_.size()));
for (size_t i = 0; i < pending_responses_.size(); ++i) {
const auto& response = pending_responses_[i];
gin::DataObjectBuilder account(isolate);
account.Set("credentialId", CredentialIdFor(response));
if (response.user_entity) {
account.Set("userHandle", Base64UrlEncodeNoPad(response.user_entity->id));
if (response.user_entity->name) {
account.Set("name", *response.user_entity->name);
}
if (response.user_entity->display_name) {
account.Set("displayName", *response.user_entity->display_name);
}
}
accounts
->CreateDataProperty(isolate->GetCurrentContext(),
static_cast<uint32_t>(i), account.Build())
.Check();
}
v8::Local<v8::Object> details = gin::DataObjectBuilder(isolate)
.Set("relyingPartyId", relying_party_id_)
.Set("accounts", accounts)
.Set("frame", rfh)
.Build();
v8::Local<v8::Object> session_wrapper;
if (!session->Get()->GetWrapper(isolate).ToLocal(&session_wrapper)) {
CancelPendingAccountSelection();
return;
}
v8::Local<v8::Object> event_object = gin_helper::internal::Event::New(isolate)
->GetWrapper(isolate)
.ToLocalChecked();
v8::Local<v8::Value> emit_result = gin_helper::EmitEvent(
isolate, session_wrapper, "select-webauthn-account", event_object,
details,
base::BindRepeating(
&ElectronAuthenticatorRequestClientDelegate::OnAccountSelected,
weak_factory_.GetWeakPtr()));
// EventEmitter.prototype.emit() returns true iff there was at least one
// listener. With no listener there is no way for the app to choose an
// account, so cancel rather than silently picking one.
bool had_listener = false;
if (!gin::ConvertFromV8(isolate, emit_result, &had_listener) ||
!had_listener) {
CancelPendingAccountSelection();
}
}
void ElectronAuthenticatorRequestClientDelegate::
CancelPendingAccountSelection() {
pending_responses_.clear();
select_account_callback_.Reset();
if (cancel_callback_) {
std::move(cancel_callback_).Run();
}
}
void ElectronAuthenticatorRequestClientDelegate::OnAccountSelected(
gin::Arguments* args) {
if (!select_account_callback_) {
return;
}
std::string credential_id;
if (!args->GetNext(&credential_id) || credential_id.empty()) {
CancelPendingAccountSelection();
return;
}
for (auto& response : pending_responses_) {
if (CredentialIdFor(response) == credential_id) {
auto selected = std::move(response);
pending_responses_.clear();
std::move(select_account_callback_).Run(std::move(selected));
return;
}
}
// Unknown credentialId: cancel the pending request rather than leaving it
// hanging. Matches the no-args branch above so the listener has a single,
// consistent failure mode whether it cancels deliberately or by mistake.
CancelPendingAccountSelection();
}
} // namespace electron

View File

@@ -0,0 +1,81 @@
// Copyright (c) 2026 Anthropic, PBC.
// Use of this source code is governed by the MIT license that can be
// found in the LICENSE file.
#ifndef ELECTRON_SHELL_BROWSER_WEBAUTHN_ELECTRON_AUTHENTICATOR_REQUEST_CLIENT_DELEGATE_H_
#define ELECTRON_SHELL_BROWSER_WEBAUTHN_ELECTRON_AUTHENTICATOR_REQUEST_CLIENT_DELEGATE_H_
#include <vector>
#include "base/memory/weak_ptr.h"
#include "base/scoped_observation.h"
#include "content/public/browser/authenticator_request_client_delegate.h"
#include "content/public/browser/global_routing_id.h"
namespace content {
class RenderFrameHost;
}
namespace gin {
class Arguments;
}
namespace electron {
class ElectronAuthenticatorRequestClientDelegate
: public content::AuthenticatorRequestClientDelegate {
public:
explicit ElectronAuthenticatorRequestClientDelegate(
content::RenderFrameHost* render_frame_host);
~ElectronAuthenticatorRequestClientDelegate() override;
// disable copy
ElectronAuthenticatorRequestClientDelegate(
const ElectronAuthenticatorRequestClientDelegate&) = delete;
ElectronAuthenticatorRequestClientDelegate& operator=(
const ElectronAuthenticatorRequestClientDelegate&) = delete;
// content::AuthenticatorRequestClientDelegate:
void SetRelyingPartyId(const std::string& rp_id) override;
void StartObserving(device::FidoRequestHandlerBase* request_handler) override;
void StopObserving(device::FidoRequestHandlerBase* request_handler) override;
void RegisterActionCallbacks(
base::OnceClosure cancel_callback,
base::OnceClosure immediate_not_found_callback,
base::RepeatingClosure start_over_callback,
AccountPreselectedCallback account_preselected_callback,
PasswordSelectedCallback password_selected_callback,
device::FidoRequestHandlerBase::RequestCallback request_callback,
base::OnceClosure cancel_ui_timeout_callback,
base::RepeatingClosure bluetooth_adapter_power_on_callback,
base::RepeatingCallback<
void(device::FidoRequestHandlerBase::BlePermissionCallback)>
request_ble_permission_callback) override;
void SelectAccount(
std::vector<device::AuthenticatorGetAssertionResponse> responses,
base::OnceCallback<void(device::AuthenticatorGetAssertionResponse)>
callback) override;
private:
void OnAccountSelected(gin::Arguments* args);
void CancelPendingAccountSelection();
const content::GlobalRenderFrameHostId render_frame_host_id_;
std::string relying_party_id_;
base::OnceClosure cancel_callback_;
base::ScopedObservation<device::FidoRequestHandlerBase,
device::FidoRequestHandlerBase::Observer>
request_handler_observation_{this};
std::vector<device::AuthenticatorGetAssertionResponse> pending_responses_;
base::OnceCallback<void(device::AuthenticatorGetAssertionResponse)>
select_account_callback_;
base::WeakPtrFactory<ElectronAuthenticatorRequestClientDelegate>
weak_factory_{this};
};
} // namespace electron
#endif // ELECTRON_SHELL_BROWSER_WEBAUTHN_ELECTRON_AUTHENTICATOR_REQUEST_CLIENT_DELEGATE_H_

View File

@@ -4,6 +4,17 @@
#include "shell/browser/webauthn/electron_authenticator_request_delegate.h"
#if BUILDFLAG(IS_MAC)
#include "base/base64.h"
#include "base/containers/span.h"
#include "base/no_destructor.h"
#include "components/prefs/pref_service.h"
#include "content/public/browser/browser_thread.h"
#include "device/fido/mac/credential_metadata.h"
#include "shell/browser/electron_browser_context.h"
#include "shell/common/electron_constants.h"
#endif
namespace electron {
ElectronWebAuthenticationDelegate::~ElectronWebAuthenticationDelegate() =
@@ -14,4 +25,48 @@ bool ElectronWebAuthenticationDelegate::SupportsResidentKeys(
return true;
}
#if BUILDFLAG(IS_MAC)
// static
std::string&
ElectronWebAuthenticationDelegate::touch_id_keychain_access_group() {
static base::NoDestructor<std::string> value;
return *value;
}
// static
void ElectronWebAuthenticationDelegate::SetTouchIdKeychainAccessGroup(
std::string access_group) {
DCHECK_CURRENTLY_ON(content::BrowserThread::UI);
touch_id_keychain_access_group() = std::move(access_group);
}
std::optional<content::WebAuthenticationDelegate::TouchIdAuthenticatorConfig>
ElectronWebAuthenticationDelegate::GetTouchIdAuthenticatorConfig(
content::BrowserContext* browser_context) {
// The metadata-secret pref is read-then-written; serialize on the UI thread
// to avoid two callers each generating a fresh secret and clobbering each
// other's credentials.
DCHECK_CURRENTLY_ON(content::BrowserThread::UI);
const std::string& access_group = touch_id_keychain_access_group();
if (access_group.empty()) {
return std::nullopt;
}
PrefService* prefs =
static_cast<ElectronBrowserContext*>(browser_context)->prefs();
std::string metadata_secret =
prefs->GetString(kWebAuthnTouchIdMetadataSecretPrefName);
if (metadata_secret.empty() ||
!base::Base64Decode(metadata_secret, &metadata_secret)) {
metadata_secret = device::fido::mac::GenerateCredentialMetadataSecret();
prefs->SetString(kWebAuthnTouchIdMetadataSecretPrefName,
base::Base64Encode(base::as_byte_span(metadata_secret)));
}
return TouchIdAuthenticatorConfig{
.keychain_access_group = access_group,
.metadata_secret = std::move(metadata_secret)};
}
#endif // BUILDFLAG(IS_MAC)
} // namespace electron

View File

@@ -5,6 +5,10 @@
#ifndef ELECTRON_SHELL_BROWSER_WEBAUTHN_ELECTRON_AUTHENTICATOR_REQUEST_DELEGATE_H_
#define ELECTRON_SHELL_BROWSER_WEBAUTHN_ELECTRON_AUTHENTICATOR_REQUEST_DELEGATE_H_
#include <optional>
#include <string>
#include "build/build_config.h"
#include "content/public/browser/web_authentication_delegate.h"
namespace electron {
@@ -15,9 +19,26 @@ class ElectronWebAuthenticationDelegate
public:
~ElectronWebAuthenticationDelegate() override;
#if BUILDFLAG(IS_MAC)
// Configures the keychain-access-group that the Touch ID platform
// authenticator stores credentials under. The app's entitlements must include
// this value in the `keychain-access-groups` array. Called from
// `app.configureWebAuthn()`.
static void SetTouchIdKeychainAccessGroup(std::string access_group);
#endif
// content::WebAuthenticationDelegate
bool SupportsResidentKeys(
content::RenderFrameHost* render_frame_host) override;
#if BUILDFLAG(IS_MAC)
std::optional<TouchIdAuthenticatorConfig> GetTouchIdAuthenticatorConfig(
content::BrowserContext* browser_context) override;
#endif
private:
#if BUILDFLAG(IS_MAC)
static std::string& touch_id_keychain_access_group();
#endif
};
} // namespace electron

View File

@@ -30,6 +30,11 @@ inline constexpr base::cstring_view kRunAsNode = "ELECTRON_RUN_AS_NODE";
inline constexpr char kElectronGlobalShortcutsUuid[] =
"electron.global_shortcuts.uuid";
// Per-profile secret used to encrypt Touch ID WebAuthn credential metadata
// stored in the macOS keychain.
inline constexpr char kWebAuthnTouchIdMetadataSecretPrefName[] =
"electron.webauthn.touchid.metadata_secret";
#if BUILDFLAG(ENABLE_PDF_VIEWER)
inline constexpr std::string_view kPDFExtensionPluginName =
"Chromium PDF Viewer";

237
spec/api-web-authn-spec.ts Normal file
View File

@@ -0,0 +1,237 @@
import { app, BrowserWindow, session } from 'electron/main';
import { expect } from 'chai';
import * as http from 'node:http';
import { AddressInfo } from 'node:net';
import { ifdescribe } from './lib/spec-helpers';
import { closeAllWindows } from './lib/window-helpers';
const configureWebAuthn = (app as any).configureWebAuthn?.bind(app) as (options?: unknown) => void;
ifdescribe(process.platform === 'darwin')('app.configureWebAuthn', () => {
it('throws when called without an options object', () => {
expect(() => configureWebAuthn()).to.throw(/configureWebAuthn requires an options object/);
});
it('throws when touchID.keychainAccessGroup is missing', () => {
expect(() => configureWebAuthn({ touchID: {} })).to.throw(/keychainAccessGroup/);
});
it('throws when touchID.keychainAccessGroup is empty', () => {
expect(() => configureWebAuthn({ touchID: { keychainAccessGroup: '' } })).to.throw(/keychainAccessGroup/);
});
it('accepts a valid touchID configuration', () => {
expect(() =>
configureWebAuthn({
touchID: { keychainAccessGroup: 'TESTTEAMID.org.electron.spec.webauthn' }
})
).to.not.throw();
});
});
describe("session 'select-webauthn-account' event", () => {
let server: http.Server;
let serverUrl: string;
let w: BrowserWindow;
let authenticatorId: string;
before(async () => {
server = http.createServer((req, res) => {
res.setHeader('Content-Type', 'text/html');
res.end('<!doctype html><title>webauthn</title>');
});
await new Promise<void>((resolve) => server.listen(0, 'localhost', resolve));
const { port } = server.address() as AddressInfo;
serverUrl = `http://localhost:${port}/`;
});
after(() => {
server.close();
});
beforeEach(async () => {
w = new BrowserWindow({ show: false });
await w.loadURL(serverUrl);
w.webContents.debugger.attach();
await w.webContents.debugger.sendCommand('WebAuthn.enable');
const result = await w.webContents.debugger.sendCommand('WebAuthn.addVirtualAuthenticator', {
options: {
protocol: 'ctap2',
transport: 'internal',
hasResidentKey: true,
hasUserVerification: true,
isUserVerified: true,
automaticPresenceSimulation: true
}
});
authenticatorId = result.authenticatorId;
});
afterEach(async () => {
session.defaultSession.removeAllListeners('select-webauthn-account');
try {
w.webContents.debugger.detach();
} catch {}
await closeAllWindows();
});
async function addCredential (opts: { id: string; userHandle: string; name: string; displayName: string }) {
const privateKey = await w.webContents.executeJavaScript(`
(async () => {
const k = await crypto.subtle.generateKey(
{ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign']);
const pkcs8 = await crypto.subtle.exportKey('pkcs8', k.privateKey);
return btoa(String.fromCharCode(...new Uint8Array(pkcs8)));
})()
`);
await w.webContents.debugger.sendCommand('WebAuthn.addCredential', {
authenticatorId,
credential: {
credentialId: Buffer.from(opts.id).toString('base64'),
isResidentCredential: true,
rpId: 'localhost',
privateKey,
userHandle: Buffer.from(opts.userHandle).toString('base64'),
userName: opts.name,
userDisplayName: opts.displayName,
signCount: 0
}
});
}
function getAssertion () {
return w.webContents.executeJavaScript(`
navigator.credentials.get({
publicKey: {
challenge: new Uint8Array(32),
rpId: 'localhost',
userVerification: 'required'
}
}).then(
c => {
const userHandle = c.response.userHandle
? new Uint8Array(c.response.userHandle)
.toBase64({ alphabet: 'base64url', omitPadding: true })
: null;
return { ok: true, id: c.id, userHandle };
},
e => ({ ok: false, name: e.name, message: e.message })
)
`);
}
// Regression test: MakeCredentialRequestHandler::SpecializeRequestForAuthenticator
// dereferences observer() for ResidentKeyRequirement::kPreferred, which crashed
// when the request delegate did not register itself as the handler's observer.
it('does not crash on navigator.credentials.create() with residentKey "preferred"', async () => {
const result = await w.webContents.executeJavaScript(`
navigator.credentials.create({
publicKey: {
rp: { id: 'localhost', name: 'Electron Spec' },
user: {
id: new TextEncoder().encode('user-1'),
name: 'alice@example.com',
displayName: 'Alice'
},
challenge: new Uint8Array(32),
pubKeyCredParams: [{ type: 'public-key', alg: -7 }],
authenticatorSelection: {
residentKey: 'preferred',
userVerification: 'preferred'
}
}
}).then(
c => ({ ok: true, id: c.id }),
e => ({ ok: false, name: e.name, message: e.message })
)
`);
expect(result.ok).to.be.true();
expect(result.id).to.be.a('string').and.not.be.empty();
});
// Pick byte sequences that exercise the URL-safe base64 alphabet — '?' and
// '>' both produce '+' / '/' / padding in standard base64, so a regression
// back to base::Base64Encode would surface here as a mismatch between the
// event's credentialId/userHandle and the values the renderer sees.
const BOB_ID = 'cred-bob??';
const BOB_USER_HANDLE = 'uh-bob>>';
const toBase64Url = (s: string) => Buffer.from(s).toString('base64url');
it('fires with discoverable credentials and resolves get() with the chosen one', async () => {
await addCredential({ id: 'cred-alice', userHandle: 'uh-alice', name: 'alice@example.com', displayName: 'Alice' });
await addCredential({ id: BOB_ID, userHandle: BOB_USER_HANDLE, name: 'bob@example.com', displayName: 'Bob' });
let received: any;
(w.webContents.session as NodeJS.EventEmitter).on('select-webauthn-account', (event, details, callback) => {
received = details;
const bob = details.accounts.find((a: any) => a.name === 'bob@example.com');
callback(bob.credentialId);
});
const result = await getAssertion();
expect(received).to.exist();
expect(received.relyingPartyId).to.equal('localhost');
expect(received.accounts).to.have.lengthOf(2);
const names = received.accounts.map((a: any) => a.name).sort();
expect(names).to.deep.equal(['alice@example.com', 'bob@example.com']);
const bob = received.accounts.find((a: any) => a.name === 'bob@example.com');
expect(bob.displayName).to.equal('Bob');
expect(bob.credentialId).to.be.a('string').and.not.be.empty();
// Both credentialId and userHandle must be URL-safe base64 (no '+', '/'
// or padding) so the values are byte-for-byte comparable to what the
// WebAuthn JS API exposes on PublicKeyCredential.
expect(bob.credentialId).to.equal(toBase64Url(BOB_ID));
expect(bob.userHandle).to.equal(toBase64Url(BOB_USER_HANDLE));
expect(bob.credentialId).to.not.match(/[+/=]/);
expect(bob.userHandle).to.not.match(/[+/=]/);
// The strong invariant: the credentialId surfaced via the main-process
// event is the same string the renderer sees as PublicKeyCredential.id.
expect(result.ok).to.be.true();
expect(result.id).to.equal(bob.credentialId);
expect(result.userHandle).to.equal(bob.userHandle);
});
it('cancels the request when the callback is invoked with an unknown credentialId', async () => {
await addCredential({ id: 'cred-alice', userHandle: 'uh-alice', name: 'alice@example.com', displayName: 'Alice' });
await addCredential({ id: 'cred-bob', userHandle: 'uh-bob', name: 'bob@example.com', displayName: 'Bob' });
(w.webContents.session as NodeJS.EventEmitter).on('select-webauthn-account', (event, details, callback) => {
callback('not-a-real-credential-id');
});
const result = await getAssertion();
expect(result.ok).to.be.false();
expect(result.name).to.equal('NotAllowedError');
});
it('cancels the request when the callback is invoked with no credential', async () => {
await addCredential({ id: 'cred-alice', userHandle: 'uh-alice', name: 'alice@example.com', displayName: 'Alice' });
await addCredential({ id: 'cred-bob', userHandle: 'uh-bob', name: 'bob@example.com', displayName: 'Bob' });
(w.webContents.session as NodeJS.EventEmitter).on('select-webauthn-account', (event, details, callback) => {
callback();
});
const result = await getAssertion();
expect(result.ok).to.be.false();
expect(result.name).to.equal('NotAllowedError');
});
it('cancels the request when no listener is registered', async () => {
await addCredential({ id: 'cred-alice', userHandle: 'uh-alice', name: 'alice@example.com', displayName: 'Alice' });
await addCredential({ id: 'cred-bob', userHandle: 'uh-bob', name: 'bob@example.com', displayName: 'Bob' });
expect(w.webContents.session.listenerCount('select-webauthn-account')).to.equal(0);
const result = await getAssertion();
expect(result.ok).to.be.false();
expect(result.name).to.equal('NotAllowedError');
});
});