mirror of
https://github.com/electron/electron.git
synced 2026-05-02 03:00:22 -04:00
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:
1
BUILD.gn
1
BUILD.gn
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`:
|
||||
|
||||
9
docs/api/structures/webauthn-account.md
Normal file
9
docs/api/structures/webauthn-account.md
Normal 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.
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
@@ -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_
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
237
spec/api-web-authn-spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user