fix: Fixes web clipper hardware key auth for Firefox (#3040)

* fix: Fixes web clipper hardware key auth for Firefox

* chore: fix ts errors
This commit is contained in:
Antonella Sgarlatta
2026-08-25 19:46:19 -03:00
committed by GitHub
parent 6a0a404114
commit 369b1f001f
15 changed files with 314 additions and 28 deletions
@@ -5,6 +5,20 @@ import packageInfo from '../../package.json'
const isFirefox = navigator.userAgent.indexOf('Firefox/') !== -1
const openClipperAuthPanel = async (pane: 'sign-in' | 'register') => {
const popupPath = await browserAction.getPopup({})
const url = new URL(popupPath, runtime.getURL('/'))
url.searchParams.set('route', 'extension')
url.searchParams.set('pane', pane)
await windows.create({
type: 'detached_panel',
url: url.toString(),
width: 350,
height: 300,
})
}
const openPopupAndClipSelection = async (payload: ClipPayload) => {
await storage.local.set({ clip: payload })
@@ -33,6 +47,8 @@ runtime.onMessage.addListener(async (message: RuntimeMessage) => {
return
}
void openPopupAndClipSelection(message.payload)
} else if (message.type === RuntimeMessageTypes.OpenClipperAuthPanel) {
await openClipperAuthPanel(message.pane)
} else if (message.type === RuntimeMessageTypes.CaptureVisibleTab) {
return await tabs.captureVisibleTab(undefined, {
format: 'png',
+1 -1
View File
@@ -27,7 +27,7 @@
"browser_specific_settings": {
"gecko": {
"id": "{9f917dfe-accd-4d3a-9685-33c3ac0ca643}",
"strict_min_version": "48.0"
"strict_min_version": "150.0"
}
}
}
+1 -1
View File
@@ -28,7 +28,7 @@
"browser_specific_settings": {
"gecko": {
"id": "{9f917dfe-accd-4d3a-9685-33c3ac0ca643}",
"strict_min_version": "48.0"
"strict_min_version": "150.0"
}
}
}
+7
View File
@@ -10,6 +10,7 @@ export const RuntimeMessageTypes = {
ToggleScreenshotMode: 'toggle-screenshot-mode',
CaptureVisibleTab: 'capture-visible-tab',
RunHttpRequest: 'run-http-request',
OpenClipperAuthPanel: 'open-clipper-auth-panel',
} as const
export type RuntimeMessageType = (typeof RuntimeMessageTypes)[keyof typeof RuntimeMessageTypes]
@@ -33,6 +34,7 @@ export type RuntimeMessageReturnTypes = {
[RuntimeMessageTypes.StartNodeSelection]: void
[RuntimeMessageTypes.ToggleScreenshotMode]: void
[RuntimeMessageTypes.RunHttpRequest]: void
[RuntimeMessageTypes.OpenClipperAuthPanel]: void
}
export type RuntimeMessage =
@@ -48,11 +50,16 @@ export type RuntimeMessage =
type: typeof RuntimeMessageTypes.ToggleScreenshotMode
enabled: boolean
}
| {
type: typeof RuntimeMessageTypes.OpenClipperAuthPanel
pane: 'sign-in' | 'register'
}
| {
type: Exclude<
RuntimeMessageType,
| MessagesWithClipPayload
| typeof RuntimeMessageTypes.ToggleScreenshotMode
| typeof RuntimeMessageTypes.RunHttpRequest
| typeof RuntimeMessageTypes.OpenClipperAuthPanel
>
}
@@ -4,11 +4,15 @@ import { RuntimeMessage, RuntimeMessageReturnTypes } from '../types/message'
export default async function sendMessageToActiveTab<T extends RuntimeMessage>(
message: T,
): Promise<RuntimeMessageReturnTypes[T['type']] | undefined> {
const [activeTab] = await tabs.query({ active: true, currentWindow: true, windowType: 'normal' })
const [activeTab] = await tabs.query({ active: true, lastFocusedWindow: true })
if (!activeTab || !activeTab.id) {
if (!activeTab?.id) {
return
}
return await tabs.sendMessage(activeTab.id, message)
try {
return await tabs.sendMessage(activeTab.id, message)
} catch {
return
}
}
@@ -82,6 +82,7 @@ import {
CreateEncryptedBackupFile,
WebSocketsService,
PreferencesServiceEvent,
AuthenticatorManager,
} from '@standardnotes/services'
import {
SNNote,
@@ -955,6 +956,10 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
return this.dependencies.get<AddAuthenticator>(TYPES.AddAuthenticator)
}
fetchAuthenticatorRegistrationOptions(): Promise<Record<string, unknown> | null> {
return this.dependencies.get<AuthenticatorManager>(TYPES.AuthenticatorManager).generateRegistrationOptions()
}
get listAuthenticators(): ListAuthenticators {
return this.dependencies.get<ListAuthenticators>(TYPES.ListAuthenticators)
}
@@ -31,7 +31,8 @@ export class AddAuthenticator implements UseCaseInterface<void> {
)
}
const registrationOptions = await this.authenticatorClient.generateRegistrationOptions()
const registrationOptions =
dto.registrationOptions ?? (await this.authenticatorClient.generateRegistrationOptions())
if (registrationOptions === null) {
return Result.fail('Could not generate authenticator registration options')
}
@@ -1,4 +1,6 @@
export interface AddAuthenticatorDTO {
userUuid: string
authenticatorName: string
/** When provided, skips the server round-trip so WebAuthn can run in the same user gesture. */
registrationOptions?: Record<string, unknown>
}
@@ -29,6 +29,7 @@ import { action, computed, makeObservable, observable } from 'mobx'
import { startAuthentication, startRegistration } from '@simplewebauthn/browser'
import { PanelResizedData } from '@/Types/PanelResizedData'
import { getBlobFromBase64, isDesktopApplication, isDev } from '@/Utils'
import { prepareWebAuthnRegistrationOptions } from '@/Utils/prepareWebAuthnRegistrationOptions'
import {
ArchiveManager,
AutolockService,
@@ -128,9 +129,10 @@ export class WebApplication extends SNApplication implements WebApplicationInter
deviceInterface.environment === Environment.Mobile ? 250 : ApplicationOptionsDefaults.sleepBetweenBatches,
allowMultipleSelection: deviceInterface.environment !== Environment.Mobile,
allowNoteSelectionStatePersistence: deviceInterface.environment !== Environment.Mobile,
u2fAuthenticatorRegistrationPromptFunction: startRegistration as unknown as (
registrationOptions: Record<string, unknown>,
) => Promise<Record<string, unknown>>,
u2fAuthenticatorRegistrationPromptFunction: ((registrationOptions: Record<string, unknown>) =>
startRegistration(
prepareWebAuthnRegistrationOptions(registrationOptions) as unknown as Parameters<typeof startRegistration>[0],
)) as unknown as (registrationOptions: Record<string, unknown>) => Promise<Record<string, unknown>>,
u2fAuthenticatorVerificationPromptFunction: startAuthentication as unknown as (
authenticationOptions: Record<string, unknown>,
) => Promise<Record<string, unknown>>,
@@ -1,15 +1,17 @@
import { Username } from '@standardnotes/snjs'
import { Environment, Username } from '@standardnotes/snjs'
import { ChallengePrompt } from '@standardnotes/services'
import { RefObject, useState } from 'react'
import { RefObject, useCallback, useState } from 'react'
import { c } from 'ttag'
import { WebApplication } from '@/Application/WebApplication'
import { IS_FIREFOX } from '@/Components/SuperEditor/Lexical/Shared/environment'
import Button from '../Button/Button'
import Icon from '../Icon/Icon'
import { InputValue } from './InputValue'
import U2FPromptIframeContainer from './U2FPromptIframeContainer'
import U2FPromptFirefoxNative from './U2FPromptFirefoxNative'
import { isAndroid } from '@standardnotes/ui-services'
type Props = {
@@ -24,11 +26,31 @@ const U2FPrompt = ({ application, onValueChange, prompt, buttonRef, contextData
const [authenticatorResponse, setAuthenticatorResponse] = useState<Record<string, unknown> | null>(null)
const [error, setError] = useState('')
const handleNativeResponse = useCallback(
(response: Record<string, unknown>) => {
onValueChange(response, prompt)
},
[onValueChange, prompt],
)
if (!application.isFullU2FClient && !isAndroid()) {
const apiHost = application.getHost.execute().getValue() || window.defaultSyncServer
if (application.environment === Environment.Clipper && IS_FIREFOX) {
return (
<U2FPromptFirefoxNative
contextData={contextData}
apiHost={apiHost}
buttonRef={buttonRef}
onResponse={handleNativeResponse}
/>
)
}
return (
<U2FPromptIframeContainer
contextData={contextData}
apiHost={application.getHost.execute().getValue() || window.defaultSyncServer}
apiHost={apiHost}
onResponse={(response) => {
onValueChange(response, prompt)
}}
@@ -0,0 +1,81 @@
import { log, LoggingDomain } from '@/Logging'
import { getU2fRelyingPartyId } from '@/Constants/U2FConstants'
import { startAuthentication } from '@simplewebauthn/browser'
import type { PublicKeyCredentialRequestOptionsJSON } from '@simplewebauthn/typescript-types'
import { RefObject, useCallback, useState } from 'react'
import { c } from 'ttag'
import Button from '../Button/Button'
type Props = {
contextData?: Record<string, unknown>
onResponse: (response: Record<string, unknown>) => void
apiHost: string
buttonRef: RefObject<HTMLButtonElement>
}
const U2FPromptFirefoxNative = ({ contextData, onResponse, apiHost, buttonRef }: Props) => {
const [pending, setPending] = useState(false)
const [error, setError] = useState('')
const authenticateWithSecurityKey = useCallback(async () => {
const username = (contextData as { username: string } | undefined)?.username
if (!username) {
setError(c('B1.Account.SignIn.Error').t`No username provided`)
return
}
setPending(true)
setError('')
try {
const response = await fetch(`${apiHost}/v1/authenticators/generate-authentication-options`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username }),
})
const jsonResponse = await response.json()
if (!jsonResponse.data?.options) {
throw new Error(c('B1.Account.SignIn.Error').t`No options returned from server`)
}
const options = jsonResponse.data.options as PublicKeyCredentialRequestOptionsJSON
const rpId = getU2fRelyingPartyId(apiHost, options)
log(LoggingDomain.U2F, 'Starting native WebAuthn authentication', {
username,
apiHost,
rpId,
})
const assertionResponse = await startAuthentication({
...options,
rpId,
})
log(LoggingDomain.U2F, 'Native WebAuthn authentication completed', { id: assertionResponse.id })
onResponse(assertionResponse as unknown as Record<string, unknown>)
} catch (authError) {
setError(authError instanceof Error ? authError.message : String(authError))
console.error(authError)
} finally {
setPending(false)
}
}, [contextData, apiHost, onResponse])
return (
<div className="min-w-76">
{error && <div className="text-danger">{error}</div>}
<Button primary fullWidth onClick={authenticateWithSecurityKey} disabled={pending} ref={buttonRef}>
{pending
? c('B1.Account.SignIn.Status').t`Waiting for security key...`
: c('B5.SecuritySync.Challenge.Action').t`Authenticate Device`}
</Button>
</div>
)
}
export default U2FPromptFirefoxNative
@@ -39,6 +39,18 @@ import LinkedItemBubble from '../LinkedItems/LinkedItemBubble'
import StyledTooltip from '../StyledTooltip/StyledTooltip'
import MenuSwitchButtonItem from '../Menu/MenuSwitchButtonItem'
import Spinner from '../Spinner/Spinner'
import { IS_FIREFOX } from '@/Components/SuperEditor/Lexical/Shared/environment'
const getAuthPaneFromUrl = () => {
const pane = new URLSearchParams(window.location.search).get('pane')
if (pane === 'sign-in') {
return AccountMenuPane.SignIn
}
if (pane === 'register') {
return AccountMenuPane.Register
}
return undefined
}
const ClipperView = ({ applicationGroup }: { applicationGroup: WebApplicationGroup }) => {
const application = useApplication()
@@ -54,6 +66,8 @@ const ClipperView = ({ applicationGroup }: { applicationGroup: WebApplicationGro
})
.catch(console.error)
}, [])
const isDetachedPanel = (currentWindow?.type as string | undefined) === 'detached_panel'
const isFirefoxAuthPanel = IS_FIREFOX && getAuthPaneFromUrl() !== undefined
const isFirefoxPopup = !!currentWindow && currentWindow.type === 'popup' && currentWindow.incognito === false
const [user, setUser] = useState(() => application.sessions.getUser())
@@ -74,6 +88,16 @@ const ClipperView = ({ applicationGroup }: { applicationGroup: WebApplicationGro
return application.addEventObserver(async (event) => {
switch (event) {
case ApplicationEvent.SignedIn:
setUser(application.sessions.getUser())
setIsEntitled(
application.features.getFeatureStatus(
NativeFeatureIdentifier.create(NativeFeatureIdentifier.TYPES.Clipper).getValue(),
) === FeatureStatus.Entitled,
)
if (isFirefoxAuthPanel) {
window.close()
}
break
case ApplicationEvent.SignedOut:
case ApplicationEvent.UserRolesChanged:
setUser(application.sessions.getUser())
@@ -126,15 +150,37 @@ const ClipperView = ({ applicationGroup }: { applicationGroup: WebApplicationGro
void application.setPreference(PrefKey.ClipperDefaultTagUuid, undefined)
}, [application])
const [menuPane, setMenuPane] = useState<AccountMenuPane>()
const [menuPane, setMenuPane] = useState<AccountMenuPane | undefined>(getAuthPaneFromUrl)
const openFirefoxAuthPanel = useCallback(async (pane: 'sign-in' | 'register') => {
try {
await runtime.sendMessage({ type: RuntimeMessageTypes.OpenClipperAuthPanel, pane })
} catch (error) {
console.error(error)
setMenuPane(pane === 'sign-in' ? AccountMenuPane.SignIn : AccountMenuPane.Register)
return
}
window.close()
}, [])
const activateRegisterPane = useCallback(() => {
if (IS_FIREFOX && !isFirefoxAuthPanel && !isDetachedPanel) {
void openFirefoxAuthPanel('register')
return
}
setMenuPane(AccountMenuPane.Register)
}, [setMenuPane])
}, [isDetachedPanel, isFirefoxAuthPanel, openFirefoxAuthPanel])
const activateSignInPane = useCallback(() => {
if (IS_FIREFOX && !isFirefoxAuthPanel && !isDetachedPanel) {
void openFirefoxAuthPanel('sign-in')
return
}
setMenuPane(AccountMenuPane.SignIn)
}, [setMenuPane])
}, [isDetachedPanel, isFirefoxAuthPanel, openFirefoxAuthPanel])
const showSignOutConfirmation = useCallback(async () => {
if (
@@ -155,7 +201,7 @@ const ClipperView = ({ applicationGroup }: { applicationGroup: WebApplicationGro
void sendMessageToActiveTab({
type: RuntimeMessageTypes.ToggleScreenshotMode,
enabled: isScreenshotMode,
})
}).catch(() => undefined)
}, [isScreenshotMode])
const [hasSelection, setHasSelection] = useState(false)
@@ -323,16 +369,26 @@ const ClipperView = ({ applicationGroup }: { applicationGroup: WebApplicationGro
}
if (!user) {
return menuPane ? (
<div className="py-1">
<MenuPaneSelector
mainApplicationGroup={applicationGroup}
menuPane={menuPane}
setMenuPane={setMenuPane}
closeMenu={() => setMenuPane(undefined)}
/>
</div>
) : (
if (menuPane || isFirefoxAuthPanel) {
return (
<div className="py-1">
<MenuPaneSelector
mainApplicationGroup={applicationGroup}
menuPane={menuPane ?? AccountMenuPane.SignIn}
setMenuPane={(pane) => {
if (isFirefoxAuthPanel && pane === AccountMenuPane.GeneralMenu) {
window.close()
return
}
setMenuPane(pane)
}}
closeMenu={() => (isFirefoxAuthPanel ? window.close() : setMenuPane(undefined))}
/>
</div>
)
}
return (
<Menu a11yLabel={c('B1.Account.Session.Label').t`User account menu`}>
<MenuItem onClick={activateRegisterPane}>
<Icon type="user" className="mr-2 h-6 w-6 text-neutral md:h-5 md:w-5" />
@@ -1,4 +1,4 @@
import { FunctionComponent, useCallback, useState } from 'react'
import { FunctionComponent, useCallback, useEffect, useState } from 'react'
import { observer } from 'mobx-react-lite'
import { AddAuthenticator } from '@standardnotes/snjs'
@@ -19,6 +19,27 @@ const U2FAddDeviceView: FunctionComponent<Props> = ({ addAuthenticator, onDevice
const [deviceName, setDeviceName] = useState('')
const [errorMessage, setErrorMessage] = useState('')
const [registrationOptions, setRegistrationOptions] = useState<Record<string, unknown> | null>(null)
const [isLoadingOptions, setIsLoadingOptions] = useState(true)
useEffect(() => {
let isActive = true
void application.fetchAuthenticatorRegistrationOptions().then((options) => {
if (!isActive) {
return
}
setRegistrationOptions(options)
setIsLoadingOptions(false)
if (options === null) {
setErrorMessage(c('B6.Preferences.Security.Error').t`Could not prepare security key registration`)
}
})
return () => {
isActive = false
}
}, [application])
const handleDeviceNameChange = useCallback((deviceName: string) => {
setDeviceName(deviceName)
@@ -30,6 +51,11 @@ const U2FAddDeviceView: FunctionComponent<Props> = ({ addAuthenticator, onDevice
return
}
if (!registrationOptions) {
setErrorMessage(c('B6.Preferences.Security.Error').t`Could not prepare security key registration`)
return
}
const user = application.sessions.getUser()
if (user === undefined) {
setErrorMessage(c('B6.Preferences.Security.Error').t`User not found`)
@@ -39,6 +65,7 @@ const U2FAddDeviceView: FunctionComponent<Props> = ({ addAuthenticator, onDevice
const authenticatorAddedOrError = await addAuthenticator.execute({
userUuid: user.uuid,
authenticatorName: deviceName,
registrationOptions,
})
if (authenticatorAddedOrError.isFailed()) {
setErrorMessage(authenticatorAddedOrError.getError())
@@ -47,7 +74,15 @@ const U2FAddDeviceView: FunctionComponent<Props> = ({ addAuthenticator, onDevice
onDeviceAddingModalToggle(false)
await onDeviceAdded()
}, [deviceName, setErrorMessage, application, addAuthenticator, onDeviceAddingModalToggle, onDeviceAdded])
}, [
deviceName,
registrationOptions,
setErrorMessage,
application,
addAuthenticator,
onDeviceAddingModalToggle,
onDeviceAdded,
])
const closeModal = () => {
onDeviceAddingModalToggle(false)
@@ -77,6 +112,7 @@ const U2FAddDeviceView: FunctionComponent<Props> = ({ addAuthenticator, onDevice
type: 'primary',
onClick: handleAddDeviceClick,
mobileSlot: 'right',
disabled: isLoadingOptions || registrationOptions === null,
},
]}
>
@@ -0,0 +1,28 @@
/** Relying party ID used when registering and authenticating hardware keys for Standard Notes. */
export const U2F_RELYING_PARTY_ID = 'app.standardnotes.com'
/**
* Stable WebAuthn origin for the Firefox clipper extension (SHA-256 of gecko.id, a–p encoded).
* Must be included in the auth server's U2F_EXPECTED_ORIGIN allowlist.
* @see https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Use_the_web_authn_api
*/
export const FIREFOX_CLIPPER_WEBAUTHN_ORIGIN =
'moz-extension://mbmeiccjaghdhkomcnlbapkpmhjgcjdonnphcmpgbgnlpbepegcaeomhoinfmflc'
export function isLocalU2fApiHost(apiHost: string): boolean {
try {
const hostname = new URL(apiHost).hostname
return hostname === 'localhost' || hostname === '127.0.0.1'
} catch {
return false
}
}
/** Prod clipper always uses app.standardnotes.com; local servers use their configured rpId. */
export function getU2fRelyingPartyId(apiHost: string, serverOptions?: { rpId?: string }): string {
if (isLocalU2fApiHost(apiHost)) {
return serverOptions?.rpId ?? new URL(apiHost).hostname
}
return U2F_RELYING_PARTY_ID
}
@@ -0,0 +1,26 @@
import { isDev } from '@/Utils'
/** Align rpId with the page hostname for local WebAuthn development. */
export function prepareWebAuthnRegistrationOptions(options: Record<string, unknown>): Record<string, unknown> {
if (!isDev) {
return options
}
const hostname = window.location.hostname
if (hostname !== 'localhost' && hostname !== '127.0.0.1') {
return options
}
const rp = options.rp as { id?: string; name?: string } | undefined
if (!rp) {
return options
}
return {
...options,
rp: {
...rp,
id: hostname,
},
}
}