diff --git a/examples/storybook/package.json b/examples/storybook/package.json index 4e5e15b6..7d08cc3a 100644 --- a/examples/storybook/package.json +++ b/examples/storybook/package.json @@ -14,6 +14,7 @@ "@goodwidget/claim-widget-theme-demo": "workspace:*", "@goodwidget/citizen-claim-widget": "workspace:*", "@goodwidget/connect-a-wallet-widget": "workspace:*", + "@goodwidget/passkey-login-widget": "workspace:*", "@goodwidget/goodreserve-widget": "workspace:*", "@goodwidget/governance-widget": "workspace:*", "@goodwidget/streaming-widget": "workspace:*", diff --git a/examples/storybook/src/fixtures/passkeyMockStorage.ts b/examples/storybook/src/fixtures/passkeyMockStorage.ts new file mode 100644 index 00000000..03b8ba7d --- /dev/null +++ b/examples/storybook/src/fixtures/passkeyMockStorage.ts @@ -0,0 +1,55 @@ +import { + ProviderAuthError, + type PasskeyBackupStorage, + type PasskeyProviderId, +} from '@goodwidget/passkey-login-widget' + +/** Failure switches a Playwright spec flips from the page to exercise the D screens. */ +export interface MockBackupStorageControls { + /** File name -> backup JSON currently stored. */ + files: Map + /** Makes `authorize` fail like a closed or denied provider sign-in. */ + failAuthorization: boolean + /** Makes `writeBackup` fail like a full or unreachable provider. */ + failWrites: boolean +} + +export interface MockBackupStorage extends PasskeyBackupStorage { + controls: MockBackupStorageControls +} + +const PROVIDER_LABELS: Record = { + 'google-drive': 'Google Drive', + icloud: 'iCloud', +} + +/** + * In-memory stand-in for a Drive / iCloud adapter. It stores the opaque backup JSON the widget + * hands over, so the widget's real crypto and flow run end to end without a network. + */ +export function createMockBackupStorage(providerId: PasskeyProviderId): MockBackupStorage { + const controls: MockBackupStorageControls = { files: new Map(), failAuthorization: false, failWrites: false } + return { + id: providerId, + label: PROVIDER_LABELS[providerId], + controls, + async authorize() { + if (controls.failAuthorization) { + // Adapters signal sign-in problems with ProviderAuthError; the widget shows the provider-auth screen + throw new ProviderAuthError('Mock provider sign-in failed') + } + }, + async listBackupNames(prfTag) { + return [...controls.files.keys()].filter((fileName) => fileName.startsWith(`gwpk-${prfTag}-`)) + }, + async readBackup(fileName) { + return controls.files.get(fileName) ?? null + }, + async writeBackup(fileName, backupJson) { + if (controls.failWrites) { + throw new Error('Mock provider write failed') + } + controls.files.set(fileName, backupJson) + }, + } +} diff --git a/examples/storybook/src/globals.d.ts b/examples/storybook/src/globals.d.ts index acb66b11..886d820b 100644 --- a/examples/storybook/src/globals.d.ts +++ b/examples/storybook/src/globals.d.ts @@ -7,6 +7,10 @@ interface ImportMetaEnv { readonly VITE_AI_CREDITS_FUNDING_VAULT_ADDRESS?: string readonly VITE_AI_CREDITS_VAULT_ADDRESS?: string readonly VITE_AI_CREDITS_GOODID_ADDRESS?: string + readonly VITE_PASSKEY_GOOGLE_CLIENT_ID?: string + readonly VITE_PASSKEY_ICLOUD_CONTAINER_ID?: string + readonly VITE_PASSKEY_ICLOUD_API_TOKEN?: string + readonly VITE_PASSKEY_ICLOUD_ENVIRONMENT?: string } interface ImportMeta { diff --git a/examples/storybook/src/stories/design-system/Button.stories.tsx b/examples/storybook/src/stories/design-system/Button.stories.tsx new file mode 100644 index 00000000..5f3de3f6 --- /dev/null +++ b/examples/storybook/src/stories/design-system/Button.stories.tsx @@ -0,0 +1,63 @@ +/** + * Button — interactive primitive. + * + * Every variant must keep a readable label in both themes. The variants grid below + * is the regression fixture for the label-contrast smoke test in + * tests/design-system/smoke.spec.ts. + */ +import React from 'react' +import type { Decorator, Meta, StoryObj } from '@storybook/react' +import { GoodWidgetProvider } from '@goodwidget/core' +import { Button, ButtonText, XStack, YStack } from '@goodwidget/ui' + +/** Button variants that render a text label on a non-brand background. */ +const LABELLED_VARIANTS = ['primary', 'secondary', 'outline', 'ghost', 'text'] as const + +type ThemeMode = 'light' | 'dark' + +/** Renders the story inside the real provider using the theme chosen by the story parameters. */ +const withThemeModeProvider: Decorator = (Story, context) => { + const themeMode = (context.parameters.themeMode as ThemeMode | undefined) ?? 'light' + return ( + + + + + + ) +} + +const meta: Meta = { + title: 'Design System/Primitives/Button', + component: Button, + tags: ['autodocs', 'showcase'], + parameters: { layout: 'padded' }, + decorators: [withThemeModeProvider], +} +export default meta +type Story = StoryObj + +/** One button per labelled variant so label color can be asserted per variant. */ +function renderVariantsGrid() { + return ( + + {LABELLED_VARIANTS.map((variant) => ( + + ))} + + ) +} + +/** All labelled variants on the light theme. */ +export const VariantsLight: Story = { + parameters: { themeMode: 'light' }, + render: renderVariantsGrid, +} + +/** All labelled variants on the dark theme. */ +export const VariantsDark: Story = { + parameters: { themeMode: 'dark' }, + render: renderVariantsGrid, +} diff --git a/examples/storybook/src/stories/helpers/passkeyLoginWidgetStories.tsx b/examples/storybook/src/stories/helpers/passkeyLoginWidgetStories.tsx new file mode 100644 index 00000000..d0b9241b --- /dev/null +++ b/examples/storybook/src/stories/helpers/passkeyLoginWidgetStories.tsx @@ -0,0 +1,288 @@ +import React, { useMemo, useState } from 'react' +import { + PasskeyLoginWidget, + PasskeyLoginWidgetPreview, + createGoogleDriveStorage, + createICloudStorage, + createInitialFlowState, + type HexString, + type PasskeyBackupStorage, + type PasskeyFlowState, + type PasskeyLoginAdapterActions, + type PasskeyLoginAdapterResult, + type PasskeyProviderId, + type PasskeyScreenId, +} from '@goodwidget/passkey-login-widget' +import { createMockBackupStorage, type MockBackupStorage } from '../../fixtures/passkeyMockStorage' + +/** Width of the mobile-portrait card the designs were drawn at. */ +const WIDGET_WIDTH_PX = 400 + +/** Fixed demo private key of the logged-in user in the interaction stories; a throwaway, never used on a network. */ +const DEMO_PRIVATE_KEY: HexString = '0x4c0883a69102937d6231471b5dbb6204fe5129617082792ae468d01a3f362318' + +const PROVIDER_LABELS: Record = { 'google-drive': 'Google Drive', icloud: 'iCloud' } + +type ThemeMode = 'light' | 'dark' + +/** Frames a widget at the design width. */ +function WidgetFrame({ children }: { children: React.ReactNode }) { + return
{children}
+} + +// --------------------------------------------------------------------------- +// Screen fixtures: a fixed adapter result per screen, no WebAuthn and no storage +// --------------------------------------------------------------------------- + +const NOOP_ACTIONS: PasskeyLoginAdapterActions = { + startLogin: () => undefined, + requestNewAccount: () => undefined, + confirmNewAccount: () => undefined, + startBackup: () => undefined, + chooseProvider: () => undefined, + retry: () => undefined, + chooseOtherProvider: () => undefined, + goBack: () => undefined, + finish: () => undefined, + skip: () => undefined, +} + +export interface ScreenFixtureOptions { + screen: PasskeyScreenId + /** Existing (already logged-in) user: backup journey. */ + isExistingUser?: boolean + /** Both Google Drive and iCloud are offered. */ + hasMultipleProviders?: boolean + /** Fields of the flow state to override (provider, progress step, error code, failed screen). */ + flowState?: Partial + themeMode?: ThemeMode +} + +/** Renders one screen from a fixed adapter result. */ +export function ScreenFixtureStory({ + screen, + isExistingUser = false, + hasMultipleProviders = true, + flowState = {}, + themeMode = 'light', +}: ScreenFixtureOptions) { + const adapter: PasskeyLoginAdapterResult = { + flowState: { ...createInitialFlowState({ isExistingUser, hasMultipleProviders }), screen, ...flowState }, + providerOptions: [ + { id: 'google-drive', label: PROVIDER_LABELS['google-drive'] }, + ...(hasMultipleProviders ? [{ id: 'icloud' as const, label: PROVIDER_LABELS.icloud }] : []), + ], + preselectedProviderId: 'google-drive', + actions: NOOP_ACTIONS, + } + return ( + + + + ) +} + +// --------------------------------------------------------------------------- +// Event log shared by the interaction and live stories +// --------------------------------------------------------------------------- + +/** Window hook the Playwright specs read and drive. Only the mock-backed interaction stories create it. */ +export interface PasskeyStoryWindowHook { + storages: Record + /** Every host callback the widget fired, with the full payload (mock keys only). */ + events: { name: string; detail?: unknown }[] +} + +declare global { + interface Window { + __passkeyStory?: PasskeyStoryWindowHook + } +} + +interface EventLogLine { + name: string + text: string +} + +/** One line per host callback. The private key is never printed: only the address and a masked marker. */ +function describeEvent(name: string, detail?: Record): string { + if (name === 'login') { + return `login → address ${String(detail?.address)}, isNewAccount ${String(detail?.isNewAccount)}, privateKey ••••••••` + } + if (name === 'backup-complete') { + return `backup-complete → address ${String(detail?.address)}, provider ${String(detail?.provider)}` + } + if (name === 'error') { + return `error → ${String(detail?.code)}` + } + return name +} + +function EventLog({ lines }: { lines: EventLogLine[] }) { + return ( +
+ {lines.length === 0 ? 'No host callbacks yet' : lines.map((line, index) =>
{line.text}
)} +
+ ) +} + +// --------------------------------------------------------------------------- +// Interaction stories: the real widget with mock storage; WebAuthn is mocked by the spec +// --------------------------------------------------------------------------- + +export interface InteractionHarnessOptions { + providerIds: PasskeyProviderId[] + /** Existing (already logged-in) user: starts the backup journey. */ + isExistingUser?: boolean +} + +/** + * Runs the real widget against in-memory storage. "Restart widget" remounts it while keeping the + * stored backups, which is how a spec proves a backup written in one session restores in the next. + */ +export function InteractionHarnessStory({ providerIds, isExistingUser = false }: InteractionHarnessOptions) { + const [restartCount, setRestartCount] = useState(0) + const [isLoggedInUser, setIsLoggedInUser] = useState(isExistingUser) + const [logLines, setLogLines] = useState([]) + const mockStorages = useMemo(() => providerIds.map((providerId) => createMockBackupStorage(providerId)), [providerIds]) + + const windowHook = useMemo(() => { + const hook: PasskeyStoryWindowHook = { + storages: Object.fromEntries(mockStorages.map((mockStorage) => [mockStorage.id, mockStorage])), + events: [], + } + window.__passkeyStory = hook + return hook + }, [mockStorages]) + + function recordEvent(name: string, detail?: Record) { + windowHook.events.push({ name, detail }) + setLogLines((previousLines) => [...previousLines, { name, text: describeEvent(name, detail) }]) + } + + return ( + + recordEvent('login', { ...result })} + onBackupComplete={(result) => recordEvent('backup-complete', { ...result })} + onSkip={() => recordEvent('skip')} + onError={(error) => recordEvent('error', { ...error })} + /> + + {/* Switches between the login flow and the logged-in user's backup flow, keeping the stored backups */} + + + + ) +} + +// --------------------------------------------------------------------------- +// Live story: real passkey, real Google Drive / iCloud +// --------------------------------------------------------------------------- + +export interface LiveStoryArgs { + googleClientId: string + iCloudContainerId: string + iCloudApiToken: string + iCloudEnvironment: 'development' | 'production' + /** Run the existing-user flow with a throwaway account key instead of the login flow. */ + backUpThrowawayAccount: boolean +} + +/** A control value wins over the matching VITE_PASSKEY_* environment value. */ +function pickControlOrEnvironment(controlValue: string, environmentValue: string | undefined): string { + return controlValue.trim() !== '' ? controlValue.trim() : (environmentValue ?? '') +} + +/** Random 32-byte hex key for the throwaway account; lives only in memory of this story. */ +function createThrowawayPrivateKey(): HexString { + const keyBytes = crypto.getRandomValues(new Uint8Array(32)) + return `0x${Array.from(keyBytes, (byte) => byte.toString(16).padStart(2, '0')).join('')}` +} + +/** Real WebAuthn and real providers; each provider is offered only when its configuration is present. */ +export function LiveStory({ + googleClientId, + iCloudContainerId, + iCloudApiToken, + iCloudEnvironment, + backUpThrowawayAccount, +}: LiveStoryArgs) { + const [logLines, setLogLines] = useState([]) + const throwawayPrivateKey = useMemo(() => createThrowawayPrivateKey(), []) + + const resolvedGoogleClientId = pickControlOrEnvironment(googleClientId, import.meta.env.VITE_PASSKEY_GOOGLE_CLIENT_ID) + const resolvedContainerId = pickControlOrEnvironment(iCloudContainerId, import.meta.env.VITE_PASSKEY_ICLOUD_CONTAINER_ID) + const resolvedApiToken = pickControlOrEnvironment(iCloudApiToken, import.meta.env.VITE_PASSKEY_ICLOUD_API_TOKEN) + const resolvedEnvironment = + (import.meta.env.VITE_PASSKEY_ICLOUD_ENVIRONMENT as LiveStoryArgs['iCloudEnvironment'] | undefined) ?? + iCloudEnvironment + + const isGoogleConfigured = resolvedGoogleClientId !== '' + const isICloudConfigured = resolvedContainerId !== '' && resolvedApiToken !== '' + + const storage = useMemo( + () => [ + ...(isGoogleConfigured ? [createGoogleDriveStorage({ clientId: resolvedGoogleClientId })] : []), + ...(isICloudConfigured + ? [ + createICloudStorage({ + containerId: resolvedContainerId, + apiToken: resolvedApiToken, + environment: resolvedEnvironment, + }), + ] + : []), + ], + [isGoogleConfigured, isICloudConfigured, resolvedGoogleClientId, resolvedContainerId, resolvedApiToken, resolvedEnvironment], + ) + + function recordEvent(name: string, detail?: Record) { + setLogLines((previousLines) => [...previousLines, { name, text: describeEvent(name, detail) }]) + } + + return ( + +
+
Google Drive: {isGoogleConfigured ? 'configured' : 'not configured'}
+
iCloud: {isICloudConfigured ? 'configured' : 'not configured'}
+
+ {storage.length === 0 ? ( +
+ No backup provider is configured. Set the VITE_PASSKEY_* values or fill in the controls (see QA.md). +
+ ) : ( + provider.id).join('+')} + storage={storage} + rpId={window.location.hostname} + appName="GoodWidget Storybook" + privateKey={backUpThrowawayAccount ? throwawayPrivateKey : undefined} + defaultTheme="light" + onLogin={(result) => recordEvent('login', { ...result })} + onBackupComplete={(result) => recordEvent('backup-complete', { ...result })} + onSkip={() => recordEvent('skip')} + onError={(error) => recordEvent('error', { ...error })} + /> + )} + +
+ ) +} diff --git a/examples/storybook/src/stories/passkey-login-widget/PasskeyLoginWidget.mdx b/examples/storybook/src/stories/passkey-login-widget/PasskeyLoginWidget.mdx new file mode 100644 index 00000000..5cb5c1cc --- /dev/null +++ b/examples/storybook/src/stories/passkey-login-widget/PasskeyLoginWidget.mdx @@ -0,0 +1,96 @@ +import { Canvas, Meta } from '@storybook/blocks'; +import * as InteractionStories from './PasskeyLoginWidgetInteractions.stories'; +import { DocsCallout, DocsCard, DocsGrid, DocsPage, DocsSection, DocsList } from '../docs/DocsLayout'; + + + + + + +
  • + New users get a fresh passkey. Its PRF output is expanded with HKDF-SHA-256 into the account key (secp256k1), a + backup encryption key (AES-256-GCM) and a file tag (`prfTag`). +
  • +
  • + The backup file is named {'gwpk--
    .json'} and holds only `version`, `address`, `iv` and + `ciphertext`. The address is authenticated as AES-GCM additional data. +
  • +
  • + Logging in again lists the files with the passkey's `prfTag`, reads the single match and decrypts it. No match + shows "No backup found"; several matches show "More than one backup matches this passkey". +
  • +
  • + Existing users (the host passes `privateKey`) get a fresh passkey that backs up their current key, so accounts + created with another login method can be restored by passkey. +
  • +
    +
    + + + + ```tsx + import { + PasskeyLoginWidget, + createGoogleDriveStorage, + createICloudStorage, + } from '@goodwidget/passkey-login-widget' + + signIn(privateKey, address, isNewAccount)} + onBackupComplete={({ address, provider }) => markBackedUp(address, provider)} + onSkip={() => showOtherLoginMethods()} + onError={({ code }) => report(code)} + /> + ``` + + + Pass `privateKey` only for a logged-in user (it enables the backup flow). A single configured provider skips the + provider choice screens. The widget remembers the last used provider id in localStorage + (`goodwidget.passkey-login.provider`) and never stores keys. + + + + + + + + + + + One fixture per screen, rendered from a fixed adapter result, in light and dark. + + + Manual story for a real passkey with Google Drive / iCloud. Needs the VITE_PASSKEY_* values described in + `tests/widgets/passkey-login-widget/QA.md`. + + + +
    diff --git a/examples/storybook/src/stories/passkey-login-widget/PasskeyLoginWidgetInteractions.stories.tsx b/examples/storybook/src/stories/passkey-login-widget/PasskeyLoginWidgetInteractions.stories.tsx new file mode 100644 index 00000000..689ea895 --- /dev/null +++ b/examples/storybook/src/stories/passkey-login-widget/PasskeyLoginWidgetInteractions.stories.tsx @@ -0,0 +1,40 @@ +import type { Meta, StoryObj } from '@storybook/react' +import { PasskeyLoginWidget } from '@goodwidget/passkey-login-widget' +import { InteractionHarnessStory, type InteractionHarnessOptions } from '../helpers/passkeyLoginWidgetStories' + +/** + * The real widget (real crypto and flow state machine) on in-memory backup storage. The browser's WebAuthn + * is mocked by the Playwright specs (virtual authenticator); open these stories by hand only with a real + * authenticator that supports the PRF extension. The "Restart widget" button remounts the widget while the + * stored backups stay, which is the round trip from "create and back up" to "log in with the same passkey". + */ +const meta: Meta = { + title: 'QA/PasskeyLoginWidget/Interactions', + component: PasskeyLoginWidget as unknown as React.ComponentType, + tags: ['qa'], + parameters: { layout: 'padded' }, + render: (harnessOptions) => , +} + +export default meta +type Story = StoryObj + +export const OneProvider: Story = { + name: 'Login and create (Google Drive only)', + args: { providerIds: ['google-drive'] }, +} + +export const TwoProviders: Story = { + name: 'Login and create (Google Drive and iCloud)', + args: { providerIds: ['google-drive', 'icloud'] }, +} + +export const ExistingUserOneProvider: Story = { + name: 'Existing user backup (Google Drive only)', + args: { providerIds: ['google-drive'], isExistingUser: true }, +} + +export const ExistingUserTwoProviders: Story = { + name: 'Existing user backup (Google Drive and iCloud)', + args: { providerIds: ['google-drive', 'icloud'], isExistingUser: true }, +} diff --git a/examples/storybook/src/stories/passkey-login-widget/PasskeyLoginWidgetLive.stories.tsx b/examples/storybook/src/stories/passkey-login-widget/PasskeyLoginWidgetLive.stories.tsx new file mode 100644 index 00000000..ff047c46 --- /dev/null +++ b/examples/storybook/src/stories/passkey-login-widget/PasskeyLoginWidgetLive.stories.tsx @@ -0,0 +1,43 @@ +import type { Meta, StoryObj } from '@storybook/react' +import { PasskeyLoginWidget } from '@goodwidget/passkey-login-widget' +import { LiveStory, type LiveStoryArgs } from '../helpers/passkeyLoginWidgetStories' + +/** + * Manual QA with a real passkey and the real Google Drive / iCloud providers. Tagged `manual`, so it is hidden + * from the default Storybook filter and from the automated suites. Values come from the controls or from the + * VITE_PASSKEY_* environment variables (a control wins); a provider without values is not offered. + * Use a throwaway Google / Apple account: the story writes real backups. + */ +const meta: Meta = { + title: 'QA/PasskeyLoginWidget/Live (real passkey + real providers)', + component: PasskeyLoginWidget as unknown as React.ComponentType, + tags: ['manual'], + parameters: { layout: 'padded' }, + args: { + googleClientId: '', + iCloudContainerId: '', + iCloudApiToken: '', + iCloudEnvironment: 'development', + backUpThrowawayAccount: false, + }, + argTypes: { + googleClientId: { control: 'text', description: 'Google OAuth Web client ID (VITE_PASSKEY_GOOGLE_CLIENT_ID)' }, + iCloudContainerId: { control: 'text', description: 'CloudKit container (VITE_PASSKEY_ICLOUD_CONTAINER_ID)' }, + iCloudApiToken: { control: 'text', description: 'CloudKit API token (VITE_PASSKEY_ICLOUD_API_TOKEN)' }, + iCloudEnvironment: { + control: 'inline-radio', + options: ['development', 'production'], + description: 'CloudKit environment (VITE_PASSKEY_ICLOUD_ENVIRONMENT wins when set)', + }, + backUpThrowawayAccount: { + control: 'boolean', + description: 'Run the existing-user backup flow with a random throwaway key instead of the login flow', + }, + }, + render: (liveArgs) => , +} + +export default meta +type Story = StoryObj + +export const Live: Story = { name: 'Live' } diff --git a/examples/storybook/src/stories/passkey-login-widget/PasskeyLoginWidgetQA.stories.tsx b/examples/storybook/src/stories/passkey-login-widget/PasskeyLoginWidgetQA.stories.tsx new file mode 100644 index 00000000..08e6b282 --- /dev/null +++ b/examples/storybook/src/stories/passkey-login-widget/PasskeyLoginWidgetQA.stories.tsx @@ -0,0 +1,128 @@ +import type { Meta, StoryObj } from '@storybook/react' +import { PasskeyLoginWidgetPreview } from '@goodwidget/passkey-login-widget' +import { ScreenFixtureStory, type ScreenFixtureOptions } from '../helpers/passkeyLoginWidgetStories' + +/** + * One fixture per screen of the designs (A1-D6), rendered from a fixed adapter result: + * no WebAuthn, no storage. The theme is a control (`themeMode`), so the Playwright states spec + * opens every fixture in light and dark through the `args` URL parameter. + */ +const meta: Meta = { + title: 'QA/PasskeyLoginWidget/Screen Fixtures', + component: PasskeyLoginWidgetPreview as unknown as React.ComponentType, + tags: ['qa'], + parameters: { layout: 'padded' }, + args: { themeMode: 'light' }, + argTypes: { + themeMode: { control: 'inline-radio', options: ['light', 'dark'] }, + }, + render: (fixtureOptions) => , +} + +export default meta +type Story = StoryObj + +// ---- A: login / create account ---- +export const A1Entry: Story = { name: 'A1 Entry', args: { screen: 'A1_ENTRY' } } +export const A2Authenticating: Story = { name: 'A2 Authenticating', args: { screen: 'A2_AUTHENTICATING' } } +export const A3NewAccountWarning: Story = { + name: 'A3 New account warning', + args: { screen: 'A3_NEW_ACCOUNT_WARNING' }, +} +export const A4CreatePasskey: Story = { name: 'A4 Create passkey', args: { screen: 'A4_CREATE_PASSKEY' } } + +// ---- B: back up ---- +export const B1BackupPrompt: Story = { + name: 'B1 Backup prompt (existing user)', + args: { screen: 'B1_BACKUP_PROMPT', isExistingUser: true, flowState: { flowKind: 'backup' } }, +} +export const B2BackupProvider: Story = { + name: 'B2 Backup provider', + args: { screen: 'B2_BACKUP_PROVIDER', flowState: { flowKind: 'new-account' } }, +} +export const B3BackupProgress: Story = { + name: 'B3 Backup progress', + args: { + screen: 'B3_BACKUP_PROGRESS', + flowState: { flowKind: 'new-account', providerId: 'google-drive', progressStepIndex: 2 }, + }, +} +export const B4BackupSuccess: Story = { + name: 'B4 Backup success', + args: { screen: 'B4_BACKUP_SUCCESS', flowState: { flowKind: 'new-account', providerId: 'google-drive' } }, +} + +// ---- C: restore ---- +export const C1RestoreProvider: Story = { name: 'C1 Restore provider', args: { screen: 'C1_RESTORE_PROVIDER' } } +export const C2RestoreProgress: Story = { + name: 'C2 Restore progress', + args: { screen: 'C2_RESTORE_PROGRESS', flowState: { providerId: 'google-drive', progressStepIndex: 1 } }, +} +export const C3RestoreSuccess: Story = { + name: 'C3 Restore success', + args: { screen: 'C3_RESTORE_SUCCESS', flowState: { providerId: 'google-drive' } }, +} + +// ---- D: errors ---- +export const D1UnsupportedBrowser: Story = { + name: 'D1 Unsupported browser', + args: { screen: 'D1_UNSUPPORTED_BROWSER', flowState: { errorCode: 'UNSUPPORTED_BROWSER' } }, +} +export const D2PasskeyCancelled: Story = { + name: 'D2 Passkey cancelled (login)', + args: { + screen: 'D2_PASSKEY_CANCELLED', + flowState: { errorCode: 'PASSKEY_CANCELLED', failedScreen: 'A2_AUTHENTICATING' }, + }, +} +export const D3BackupNotFound: Story = { + name: 'D3 Backup not found (two providers)', + args: { + screen: 'D3_BACKUP_NOT_FOUND', + flowState: { errorCode: 'BACKUP_NOT_FOUND', providerId: 'google-drive', failedScreen: 'C2_RESTORE_PROGRESS' }, + }, +} +export const D3BackupNotFoundSingleProvider: Story = { + name: 'D3 Backup not found (one provider)', + args: { + screen: 'D3_BACKUP_NOT_FOUND', + hasMultipleProviders: false, + flowState: { errorCode: 'BACKUP_NOT_FOUND', providerId: 'google-drive', failedScreen: 'C2_RESTORE_PROGRESS' }, + }, +} +export const D4ProviderAuthFailed: Story = { + name: 'D4 Provider sign-in failed', + args: { + screen: 'D4_PROVIDER_AUTH_FAILED', + flowState: { errorCode: 'PROVIDER_AUTH_FAILED', providerId: 'google-drive', failedScreen: 'C2_RESTORE_PROGRESS' }, + }, +} +export const D5BackupFailedNewUser: Story = { + name: 'D5 Backup failed (new user)', + args: { + screen: 'D5_BACKUP_FAILED', + flowState: { errorCode: 'BACKUP_FAILED', flowKind: 'new-account', providerId: 'google-drive', failedScreen: 'B3_BACKUP_PROGRESS' }, + }, +} +export const D5BackupFailedExistingUser: Story = { + name: 'D5 Backup failed (existing user, can skip)', + args: { + screen: 'D5_BACKUP_FAILED', + isExistingUser: true, + flowState: { errorCode: 'BACKUP_FAILED', flowKind: 'backup', providerId: 'google-drive', failedScreen: 'B3_BACKUP_PROGRESS' }, + }, +} +export const D6DecryptFailed: Story = { + name: 'D6 Decrypt failed', + args: { + screen: 'D6_DECRYPT_FAILED', + flowState: { errorCode: 'DECRYPT_FAILED', providerId: 'google-drive', failedScreen: 'C2_RESTORE_PROGRESS' }, + }, +} +export const D6AmbiguousBackup: Story = { + name: 'D6 Ambiguous backup (D6 layout)', + args: { + screen: 'D6_DECRYPT_FAILED', + flowState: { errorCode: 'AMBIGUOUS_BACKUP', providerId: 'google-drive', failedScreen: 'C2_RESTORE_PROGRESS' }, + }, +} diff --git a/packages/passkey-login-widget/package.json b/packages/passkey-login-widget/package.json new file mode 100644 index 00000000..e53fb1fd --- /dev/null +++ b/packages/passkey-login-widget/package.json @@ -0,0 +1,52 @@ +{ + "name": "@goodwidget/passkey-login-widget", + "version": "0.1.0-beta", + "description": "GoodWidget for serverless passkey login: derives a GoodDollar account key from a WebAuthn PRF passkey and backs it up (encrypted) to Google Drive or iCloud", + "type": "module", + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + }, + "./element": { + "types": "./dist/element.d.ts", + "import": "./dist/element.js", + "require": "./dist/element.cjs" + }, + "./register": { + "types": "./dist/register.d.ts", + "import": "./dist/register.js", + "require": "./dist/register.cjs" + } + }, + "scripts": { + "build": "tsup", + "dev": "tsup --watch", + "lint": "eslint src/", + "test": "node --test --experimental-strip-types \"src/**/*.test.ts\"", + "clean": "rm -rf dist .turbo" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + }, + "dependencies": { + "@goodwidget/core": "workspace:*", + "@goodwidget/embed": "workspace:*", + "@goodwidget/ui": "workspace:*", + "viem": "^2.0.0" + }, + "devDependencies": { + "@types/node": "^22.10.0", + "@types/react": "^18.3.0", + "@types/react-dom": "^18.3.0", + "react": "^18.3.0", + "react-dom": "^18.3.0", + "tsup": "^8.4.0", + "typescript": "^5.7.0" + } +} diff --git a/packages/passkey-login-widget/src/PasskeyLoginWidget.tsx b/packages/passkey-login-widget/src/PasskeyLoginWidget.tsx new file mode 100644 index 00000000..8aa017c4 --- /dev/null +++ b/packages/passkey-login-widget/src/PasskeyLoginWidget.tsx @@ -0,0 +1,64 @@ +import React from 'react' +import { GoodWidgetProvider } from '@goodwidget/core' +import { GoodWidgetDialog, ToastContainer } from '@goodwidget/ui' +import { usePasskeyLoginAdapter, type PasskeyLoginAdapterResult } from './adapter.ts' +import { passkeyLoginWidgetConfig } from './config.ts' +import { PasskeyLoginWidgetView } from './components/PasskeyLoginWidgetView.tsx' +import type { PasskeyLoginWidgetProps } from './widgetRuntimeContract.ts' + +/** Runs the flow and renders it; must sit inside the provider. */ +function PasskeyLoginWidgetRuntime({ + 'data-testid': dataTestId, + ...adapterProps +}: Omit) { + const adapter = usePasskeyLoginAdapter(adapterProps) + return +} + +/** Widget-local component themes first, so a host `config` can still override them. */ +function useMergedConfig(hostConfig: PasskeyLoginWidgetProps['config']) { + return React.useMemo( + () => ({ ...hostConfig, themes: { ...passkeyLoginWidgetConfig.themes, ...hostConfig?.themes } }), + [hostConfig], + ) +} + +export interface PasskeyLoginWidgetPreviewProps + extends Pick { + adapter: PasskeyLoginAdapterResult + 'data-testid'?: string +} + +/** Deterministic render path for Storybook / test fixtures that provide a fixed adapter result. */ +export function PasskeyLoginWidgetPreview({ + adapter, + themeOverrides, + config, + defaultTheme = 'dark', + 'data-testid': dataTestId, +}: PasskeyLoginWidgetPreviewProps) { + const mergedConfig = useMergedConfig(config) + return ( + + + + + + ) +} + +export function PasskeyLoginWidget({ + themeOverrides, + config, + defaultTheme = 'dark', + ...runtimeProps +}: PasskeyLoginWidgetProps) { + const mergedConfig = useMergedConfig(config) + return ( + + + + + + ) +} diff --git a/packages/passkey-login-widget/src/adapter.ts b/packages/passkey-login-widget/src/adapter.ts new file mode 100644 index 00000000..450d6c92 --- /dev/null +++ b/packages/passkey-login-widget/src/adapter.ts @@ -0,0 +1,306 @@ +import { useCallback, useEffect, useReducer, useRef } from 'react' +import type { Bytes } from './crypto/encoding.ts' +import { deriveAccountKey } from './crypto/prfDerivation.ts' +import { createInitialFlowState, passkeyFlowReducer, type PasskeyFlowState } from './flow/passkeyFlowMachine.ts' +import { + prepareAccountBackup, + restoreAccountFromProvider, + uploadPreparedBackup, + type PreparedBackup, + type RestoredAccount, +} from './flow/passkeyOperations.ts' +import { readRememberedProviderId, rememberProviderId } from './storage/rememberedProvider.ts' +import { authenticateWithPasskey, createPasskey, isPasskeyPrfSupported } from './webauthn/passkeyClient.ts' +import { PASSKEY_ERROR_MESSAGES, PasskeyFlowError, toPasskeyFlowError } from './webauthn/passkeyErrors.ts' +import type { + HexString, + PasskeyBackupStorage, + PasskeyErrorCode, + PasskeyLoginWidgetProps, + PasskeyProviderId, +} from './widgetRuntimeContract.ts' + +/** A provider as shown on the B2 / C1 tiles. */ +export interface PasskeyProviderOption { + id: PasskeyProviderId + label: string +} + +export interface PasskeyLoginAdapterActions { + /** A1 "Continue with passkey". */ + startLogin: () => void + /** D2 "Create new account" -> A3. */ + requestNewAccount: () => void + /** A3 "Create new account" -> A4. */ + confirmNewAccount: () => void + /** B1 "Set up passkey backup" -> A4. */ + startBackup: () => void + /** B2 "Continue" / C1 tile press. */ + chooseProvider: (providerId: PasskeyProviderId) => void + /** D2 "Retry", D4 "Try again", D5 "Retry", D6 "Try again". */ + retry: () => void + /** D3 "Try the other provider", D4 "Choose another provider". */ + chooseOtherProvider: () => void + /** D3 / D6 "Back". */ + goBack: () => void + /** B4 / C3 "Continue": hands the result to the host. */ + finish: () => void + /** "Use another login method", "Back to login methods", "Not now", "Skip for now". */ + skip: () => void +} + +export interface PasskeyLoginAdapterResult { + flowState: PasskeyFlowState + providerOptions: PasskeyProviderOption[] + /** Provider preselected on B2: the remembered one, else the first offered. */ + preselectedProviderId: PasskeyProviderId | null + actions: PasskeyLoginAdapterActions +} + +/** + * Secrets of the running flow. They live in a ref, never in React state or props, and are + * dropped as soon as the user leaves the flow or hands the result to the host. + */ +interface PasskeyFlowSecrets { + prfOutput: Bytes | null + /** Account key created for a new user; only released through `onLogin` once the backup exists. */ + newAccountPrivateKey: HexString | null + /** Encrypted backup, kept so D5 "Retry" does not need a new passkey prompt. */ + preparedBackup: PreparedBackup | null + restoredAccount: RestoredAccount | null +} + +const EMPTY_SECRETS: PasskeyFlowSecrets = { + prfOutput: null, + newAccountPrivateKey: null, + preparedBackup: null, + restoredAccount: null, +} + +/** Provider that needs no choice screen: the only one offered, or else the remembered one. */ +function pickKnownProviderId( + providers: PasskeyBackupStorage[], + rememberedProviderId: PasskeyProviderId | null, + useRemembered: boolean, +): PasskeyProviderId | null { + if (providers.length === 1) { + return providers[0].id + } + return useRemembered ? rememberedProviderId : null +} + +/** + * Runs the passkey flow: owns the screen state machine, performs the WebAuthn / crypto / storage + * work of each screen, and reports results to the host through the callbacks. + */ +export function usePasskeyLoginAdapter(props: PasskeyLoginWidgetProps): PasskeyLoginAdapterResult { + const { storage, rpId, appName, privateKey: existingUserPrivateKey } = props + const isExistingUser = existingUserPrivateKey !== undefined + + const [flowState, dispatch] = useReducer( + passkeyFlowReducer, + { isExistingUser, hasMultipleProviders: storage.length > 1 }, + createInitialFlowState, + ) + + // Callbacks and inputs are read through refs so the screen work always sees the latest props + const latestPropsRef = useRef(props) + latestPropsRef.current = props + const secretsRef = useRef({ ...EMPTY_SECRETS }) + const startedAttemptRef = useRef(null) + const supportCheckStartedRef = useRef(false) + + const reportFailure = useCallback((error: unknown, fallbackCode: PasskeyErrorCode) => { + const flowError = toPasskeyFlowError(error, fallbackCode) + dispatch({ type: 'FAILED', errorCode: flowError.code }) + latestPropsRef.current.onError?.({ code: flowError.code, message: flowError.message }) + }, []) + + const findProvider = useCallback((providerId: PasskeyProviderId | null): PasskeyBackupStorage => { + const provider = latestPropsRef.current.storage.find((candidate) => candidate.id === providerId) + if (!provider) { + throw new Error('No backup provider selected') + } + return provider + }, []) + + /** A2: ask for an existing passkey, then restore from the known provider or ask which one. */ + const verifyExistingPasskey = useCallback(async () => { + try { + const { prfOutput } = await authenticateWithPasskey(rpId) + secretsRef.current.prfOutput = prfOutput + const knownProviderId = pickKnownProviderId( + latestPropsRef.current.storage, + readRememberedProviderId(latestPropsRef.current.storage), + true, + ) + dispatch({ type: 'PASSKEY_VERIFIED', providerId: knownProviderId }) + } catch (error) { + reportFailure(error, 'PASSKEY_CANCELLED') + } + }, [reportFailure, rpId]) + + /** C2: find, read and decrypt the backup of the verified passkey. */ + const restoreFromProvider = useCallback( + async (providerId: PasskeyProviderId | null) => { + try { + const provider = findProvider(providerId) + const { prfOutput } = secretsRef.current + if (!prfOutput) { + throw new Error('Passkey was not verified') + } + secretsRef.current.restoredAccount = await restoreAccountFromProvider({ + provider, + prfOutput, + rpId, + onBackupFound: () => dispatch({ type: 'PROGRESS_ADVANCED' }), + }) + rememberProviderId(provider.id) + dispatch({ type: 'RESTORE_SUCCEEDED' }) + } catch (error) { + reportFailure(error, 'PROVIDER_AUTH_FAILED') + } + }, + [findProvider, reportFailure, rpId], + ) + + /** A4: create a fresh passkey, make (or take) the account key and encrypt it for the backup. */ + const createPasskeyAndEncryptKey = useCallback( + async (isNewAccount: boolean) => { + try { + const { prfOutput } = await createPasskey({ rpId, appName }) + secretsRef.current.prfOutput = prfOutput + dispatch({ type: 'PROGRESS_ADVANCED' }) + + // Existing users keep their own key; only a new account gets a key derived from the passkey + const accountPrivateKey = isNewAccount + ? await deriveAccountKey(prfOutput) + : (latestPropsRef.current.privateKey as HexString) + secretsRef.current.newAccountPrivateKey = isNewAccount ? accountPrivateKey : null + dispatch({ type: 'PROGRESS_ADVANCED' }) + + secretsRef.current.preparedBackup = await prepareAccountBackup({ + privateKey: accountPrivateKey, + prfOutput, + rpId, + }) + dispatch({ type: 'PROGRESS_ADVANCED' }) + + const knownProviderId = pickKnownProviderId( + latestPropsRef.current.storage, + readRememberedProviderId(latestPropsRef.current.storage), + false, + ) + dispatch({ type: 'PASSKEY_CREATED', providerId: knownProviderId }) + } catch (error) { + reportFailure(error, 'BACKUP_FAILED') + } + }, + [appName, reportFailure, rpId], + ) + + /** B3: upload the prepared (already encrypted) backup. */ + const uploadBackup = useCallback( + async (providerId: PasskeyProviderId | null) => { + try { + const provider = findProvider(providerId) + const { preparedBackup } = secretsRef.current + if (!preparedBackup) { + throw new Error('No backup prepared') + } + await uploadPreparedBackup(provider, preparedBackup) + rememberProviderId(provider.id) + dispatch({ type: 'BACKUP_SUCCEEDED' }) + } catch (error) { + reportFailure(error, 'BACKUP_FAILED') + } + }, + [findProvider, reportFailure], + ) + + // The work of a screen starts once per screen entry (attempt), never on plain re-renders + useEffect(() => { + if (startedAttemptRef.current === flowState.attempt) { + return + } + startedAttemptRef.current = flowState.attempt + + switch (flowState.screen) { + case 'A1_ENTRY': + case 'B1_BACKUP_PROMPT': + // Back at the start of the journey: nothing of the previous attempt is kept + secretsRef.current = { ...EMPTY_SECRETS } + break + case 'A2_AUTHENTICATING': + void verifyExistingPasskey() + break + case 'C2_RESTORE_PROGRESS': + void restoreFromProvider(flowState.providerId) + break + case 'A4_CREATE_PASSKEY': + void createPasskeyAndEncryptKey(flowState.flowKind === 'new-account') + break + case 'B3_BACKUP_PROGRESS': + void uploadBackup(flowState.providerId) + break + default: + break + } + }, [createPasskeyAndEncryptKey, flowState, restoreFromProvider, uploadBackup, verifyExistingPasskey]) + + // Browsers without WebAuthn / PRF go straight to D1 + useEffect(() => { + if (supportCheckStartedRef.current) { + return + } + supportCheckStartedRef.current = true + void isPasskeyPrfSupported().then((isSupported) => { + if (!isSupported) { + reportFailure( + new PasskeyFlowError('UNSUPPORTED_BROWSER', PASSKEY_ERROR_MESSAGES.UNSUPPORTED_BROWSER), + 'UNSUPPORTED_BROWSER', + ) + } + }) + }, [reportFailure]) + + const finish = useCallback(() => { + const { onLogin, onBackupComplete } = latestPropsRef.current + const { restoredAccount, newAccountPrivateKey, preparedBackup } = secretsRef.current + + if (flowState.screen === 'C3_RESTORE_SUCCESS' && restoredAccount) { + onLogin?.({ ...restoredAccount, isNewAccount: false }) + } + if (flowState.screen === 'B4_BACKUP_SUCCESS' && preparedBackup && flowState.providerId) { + if (flowState.flowKind === 'new-account' && newAccountPrivateKey) { + onLogin?.({ privateKey: newAccountPrivateKey, address: preparedBackup.address, isNewAccount: true }) + } + onBackupComplete?.({ address: preparedBackup.address, provider: flowState.providerId }) + } + // The result has been handed over; nothing secret stays inside the widget + secretsRef.current = { ...EMPTY_SECRETS } + }, [flowState.flowKind, flowState.providerId, flowState.screen]) + + const skip = useCallback(() => { + secretsRef.current = { ...EMPTY_SECRETS } + latestPropsRef.current.onSkip?.() + }, []) + + const actions: PasskeyLoginAdapterActions = { + startLogin: () => dispatch({ type: 'LOGIN_STARTED' }), + requestNewAccount: () => dispatch({ type: 'NEW_ACCOUNT_REQUESTED' }), + confirmNewAccount: () => dispatch({ type: 'NEW_ACCOUNT_CONFIRMED' }), + startBackup: () => dispatch({ type: 'BACKUP_REQUESTED' }), + chooseProvider: (providerId) => dispatch({ type: 'PROVIDER_CHOSEN', providerId }), + retry: () => dispatch({ type: 'RETRY_REQUESTED' }), + chooseOtherProvider: () => dispatch({ type: 'OTHER_PROVIDER_REQUESTED' }), + goBack: () => dispatch({ type: 'BACK_REQUESTED' }), + finish, + skip, + } + + const providerOptions = storage.map(({ id, label }) => ({ id, label })) + const preselectedProviderId = readRememberedProviderId(storage) ?? storage[0]?.id ?? null + + return { flowState, providerOptions, preselectedProviderId, actions } +} diff --git a/packages/passkey-login-widget/src/components/BackupScreens.tsx b/packages/passkey-login-widget/src/components/BackupScreens.tsx new file mode 100644 index 00000000..5adef44c --- /dev/null +++ b/packages/passkey-login-widget/src/components/BackupScreens.tsx @@ -0,0 +1,90 @@ +import React, { useState } from 'react' +import { Text, YStack } from '@goodwidget/ui' +import type { PasskeyLoginAdapterActions, PasskeyProviderOption } from '../adapter.ts' +import type { PasskeyProviderId } from '../widgetRuntimeContract.ts' +import { ProgressSteps } from './ProgressSteps.tsx' +import { ProviderChoice } from './ProviderChoice.tsx' +import { ScreenAction, ScreenCard } from './ScreenCard.tsx' + +/** B1: offers an already logged-in user to back up their account with a passkey. */ +export function BackupPromptScreen({ actions }: { actions: PasskeyLoginAdapterActions }) { + return ( + + + + + + + ) +} + +export interface BackupProviderScreenProps { + providerOptions: PasskeyProviderOption[] + /** Tile that starts out selected: the remembered provider, else the first one offered. */ + preselectedProviderId: PasskeyProviderId | null + onContinue: (providerId: PasskeyProviderId) => void +} + +/** B2: choose where the backup is stored; the chosen tile is confirmed with Continue. */ +export function BackupProviderScreen({ providerOptions, preselectedProviderId, onContinue }: BackupProviderScreenProps) { + const [selectedProviderId, setSelectedProviderId] = useState(preselectedProviderId) + return ( + + + + Encrypted with your passkey. Only you can decrypt it. + + { + if (selectedProviderId) { + onContinue(selectedProviderId) + } + }} + /> + + ) +} + +export interface BackupProgressScreenProps { + providerLabel: string +} + +/** B3: key is encrypted and being uploaded to the chosen provider. */ +export function BackupProgressScreen({ providerLabel }: BackupProgressScreenProps) { + const stepLabels = ['Passkey verified', 'Key encrypted', `Uploading to ${providerLabel}`] + return ( + + + + ) +} + +/** B4: the backup exists. */ +export function BackupSuccessScreen({ + providerLabel, + actions, +}: { + providerLabel: string + actions: PasskeyLoginAdapterActions +}) { + return ( + + + + ) +} diff --git a/packages/passkey-login-widget/src/components/ErrorScreens.tsx b/packages/passkey-login-widget/src/components/ErrorScreens.tsx new file mode 100644 index 00000000..f6aea474 --- /dev/null +++ b/packages/passkey-login-widget/src/components/ErrorScreens.tsx @@ -0,0 +1,129 @@ +import React from 'react' +import { Alert, YStack } from '@goodwidget/ui' +import type { PasskeyLoginAdapterActions } from '../adapter.ts' +import { ScreenAction, ScreenCard } from './ScreenCard.tsx' +import type { ReactNode } from 'react' + +interface ErrorScreenProps { + screenId: string + title: string + message?: string + children: ReactNode +} + +/** Shared D-screen layout: red alert, then the recovery actions. */ +function ErrorScreen({ screenId, title, message, children }: ErrorScreenProps) { + return ( + + + {children} + + ) +} + +/** D1: no WebAuthn / PRF support. Only way out is another login method. */ +export function UnsupportedBrowserScreen({ actions }: { actions: PasskeyLoginAdapterActions }) { + return ( + + + + + ) +} + +/** + * D2: the passkey request failed or was cancelled. WebAuthn cannot tell "cancelled" from "no + * passkey on this device", so a failed login attempt also offers to create a new account. + */ +export function PasskeyCancelledScreen({ + actions, + offersNewAccount, +}: { + actions: PasskeyLoginAdapterActions + offersNewAccount: boolean +}) { + return ( + + + {offersNewAccount && ( + + )} + + ) +} + +/** D3: no backup under this passkey at the chosen provider. */ +export function BackupNotFoundScreen({ + providerLabel, + offersOtherProvider, + actions, +}: { + providerLabel: string + offersOtherProvider: boolean + actions: PasskeyLoginAdapterActions +}) { + return ( + + {offersOtherProvider && } + + + ) +} + +/** D4: sign-in or consent with the provider failed. */ +export function ProviderAuthFailedScreen({ + providerLabel, + offersOtherProvider, + actions, +}: { + providerLabel: string + offersOtherProvider: boolean + actions: PasskeyLoginAdapterActions +}) { + return ( + + + {offersOtherProvider && ( + + )} + + ) +} + +/** D5: the backup could not be written. Skipping is only offered to users who are already logged in. */ +export function BackupFailedScreen({ + offersSkip, + actions, +}: { + offersSkip: boolean + actions: PasskeyLoginAdapterActions +}) { + return ( + + + {offersSkip && } + + ) +} + +/** D6: the backup could not be unlocked, or several backups matched one passkey (`AMBIGUOUS_BACKUP`). */ +export function DecryptFailedScreen({ + isAmbiguousBackup, + actions, +}: { + isAmbiguousBackup: boolean + actions: PasskeyLoginAdapterActions +}) { + const title = isAmbiguousBackup + ? 'More than one backup matches this passkey' + : "Couldn't unlock this backup with your passkey" + const message = isAmbiguousBackup + ? "We can't tell which account to restore. Contact support before continuing." + : undefined + return ( + + + + + ) +} diff --git a/packages/passkey-login-widget/src/components/LoginScreens.tsx b/packages/passkey-login-widget/src/components/LoginScreens.tsx new file mode 100644 index 00000000..a0f87816 --- /dev/null +++ b/packages/passkey-login-widget/src/components/LoginScreens.tsx @@ -0,0 +1,55 @@ +import React from 'react' +import { Alert, YStack } from '@goodwidget/ui' +import type { PasskeyLoginAdapterActions } from '../adapter.ts' +import { ProgressSteps } from './ProgressSteps.tsx' +import { ScreenAction, ScreenCard, WaitingState } from './ScreenCard.tsx' + +/** Steps shown while a new passkey and account are created (A4). */ +export const CREATE_PASSKEY_STEP_LABELS = ['Create passkey', 'Generate account', 'Encrypt key', 'Back up'] + +/** A1: entry screen of the login flow. */ +export function EntryScreen({ actions }: { actions: PasskeyLoginAdapterActions }) { + return ( + + + + + + + ) +} + +/** A2: waiting for the passkey prompt of the browser. */ +export function AuthenticatingScreen() { + return ( + + + + ) +} + +/** A3: confirms that continuing creates a brand-new account, and points existing users to the backup route. */ +export function NewAccountWarningScreen({ actions }: { actions: PasskeyLoginAdapterActions }) { + return ( + + + + + + + + + ) +} + +/** A4: creating the passkey, the account and the first backup. */ +export function CreatePasskeyScreen({ activeStepIndex }: { activeStepIndex: number }) { + return ( + + + + ) +} diff --git a/packages/passkey-login-widget/src/components/PasskeyLoginWidgetView.tsx b/packages/passkey-login-widget/src/components/PasskeyLoginWidgetView.tsx new file mode 100644 index 00000000..a2fb6b98 --- /dev/null +++ b/packages/passkey-login-widget/src/components/PasskeyLoginWidgetView.tsx @@ -0,0 +1,97 @@ +import React from 'react' +import { WidgetVersion, YStack } from '@goodwidget/ui' +import { version } from '../../package.json' +import type { PasskeyLoginAdapterResult } from '../adapter.ts' +import { BackupFailedScreen, BackupNotFoundScreen, DecryptFailedScreen, PasskeyCancelledScreen, ProviderAuthFailedScreen, UnsupportedBrowserScreen } from './ErrorScreens.tsx' +import { BackupProgressScreen, BackupPromptScreen, BackupProviderScreen, BackupSuccessScreen } from './BackupScreens.tsx' +import { AuthenticatingScreen, CreatePasskeyScreen, EntryScreen, NewAccountWarningScreen } from './LoginScreens.tsx' +import { RestoreProgressScreen, RestoreProviderScreen, RestoreSuccessScreen } from './RestoreScreens.tsx' + +/** Used in provider-dependent copy when no provider was picked yet. */ +const FALLBACK_PROVIDER_LABEL = 'your backup provider' + +export interface PasskeyLoginWidgetViewProps { + adapter: PasskeyLoginAdapterResult + 'data-testid'?: string +} + +/** Renders the screen the flow is currently on (A1-D6). All behavior lives in the adapter. */ +export function PasskeyLoginWidgetView({ + adapter, + 'data-testid': dataTestId = 'passkey-login-widget', +}: PasskeyLoginWidgetViewProps) { + const { flowState, providerOptions, preselectedProviderId, actions } = adapter + const providerLabel = + providerOptions.find((providerOption) => providerOption.id === flowState.providerId)?.label ?? + FALLBACK_PROVIDER_LABEL + const { hasMultipleProviders, isExistingUser } = flowState + + function renderScreen() { + switch (flowState.screen) { + case 'A1_ENTRY': + return + case 'A2_AUTHENTICATING': + return + case 'A3_NEW_ACCOUNT_WARNING': + return + case 'A4_CREATE_PASSKEY': + return + case 'B1_BACKUP_PROMPT': + return + case 'B2_BACKUP_PROVIDER': + return ( + + ) + case 'B3_BACKUP_PROGRESS': + return + case 'B4_BACKUP_SUCCESS': + return + case 'C1_RESTORE_PROVIDER': + return + case 'C2_RESTORE_PROGRESS': + return + case 'C3_RESTORE_SUCCESS': + return + case 'D1_UNSUPPORTED_BROWSER': + return + case 'D2_PASSKEY_CANCELLED': + return ( + + ) + case 'D3_BACKUP_NOT_FOUND': + return ( + + ) + case 'D4_PROVIDER_AUTH_FAILED': + return ( + + ) + case 'D5_BACKUP_FAILED': + return + case 'D6_DECRYPT_FAILED': + return + } + } + + return ( + + {renderScreen()} + + + ) +} diff --git a/packages/passkey-login-widget/src/components/ProgressSteps.tsx b/packages/passkey-login-widget/src/components/ProgressSteps.tsx new file mode 100644 index 00000000..4397e3f7 --- /dev/null +++ b/packages/passkey-login-widget/src/components/ProgressSteps.tsx @@ -0,0 +1,18 @@ +import React from 'react' +import { Stepper, type StepperStepItem } from '@goodwidget/ui' + +export interface ProgressStepsProps { + stepLabels: string[] + activeStepIndex: number +} + +/** Stepper for the A4 / B3 / C2 progress screens: earlier steps done, the active one running, the rest pending. */ +export function ProgressSteps({ stepLabels, activeStepIndex }: ProgressStepsProps) { + const steps: StepperStepItem[] = stepLabels.map((title, index) => ({ + id: `step-${index}`, + title, + status: index < activeStepIndex ? 'completed' : index === activeStepIndex ? 'active' : 'pending', + })) + // activeStepId null: the stepper must not scroll the host page + return +} diff --git a/packages/passkey-login-widget/src/components/ProviderChoice.tsx b/packages/passkey-login-widget/src/components/ProviderChoice.tsx new file mode 100644 index 00000000..1cb9d5b2 --- /dev/null +++ b/packages/passkey-login-widget/src/components/ProviderChoice.tsx @@ -0,0 +1,28 @@ +import React from 'react' +import { XStack } from '@goodwidget/ui' +import type { PasskeyProviderOption } from '../adapter.ts' +import type { PasskeyProviderId } from '../widgetRuntimeContract.ts' +import { ProviderTile } from './ProviderTile.tsx' + +export interface ProviderChoiceProps { + providerOptions: PasskeyProviderOption[] + selectedProviderId: PasskeyProviderId | null + onSelect: (providerId: PasskeyProviderId) => void +} + +/** Row of provider tiles exposed as one radio group. */ +export function ProviderChoice({ providerOptions, selectedProviderId, onSelect }: ProviderChoiceProps) { + return ( + + {providerOptions.map((providerOption) => ( + + ))} + + ) +} diff --git a/packages/passkey-login-widget/src/components/ProviderLogo.tsx b/packages/passkey-login-widget/src/components/ProviderLogo.tsx new file mode 100644 index 00000000..71a1b393 --- /dev/null +++ b/packages/passkey-login-widget/src/components/ProviderLogo.tsx @@ -0,0 +1,44 @@ +import React from 'react' +import type { PasskeyProviderId } from '../widgetRuntimeContract.ts' + +const LOGO_SIZE_PX = 32 + +/** Google Drive triangle in the brand colors (official logo geometry, viewBox 87.3 x 78). */ +function GoogleDriveLogo() { + return ( + + ) +} + +/** iCloud cloud glyph in Apple's blue. */ +function ICloudLogo() { + return ( + + ) +} + +const LOGO_BY_PROVIDER_ID: Record React.JSX.Element> = { + 'google-drive': GoogleDriveLogo, + icloud: ICloudLogo, +} + +/** Brand logo of a backup provider, decorative (the provider name is always printed next to it). */ +export function ProviderLogo({ providerId }: { providerId: PasskeyProviderId }) { + const Logo = LOGO_BY_PROVIDER_ID[providerId] + return +} diff --git a/packages/passkey-login-widget/src/components/ProviderTile.tsx b/packages/passkey-login-widget/src/components/ProviderTile.tsx new file mode 100644 index 00000000..790d6388 --- /dev/null +++ b/packages/passkey-login-widget/src/components/ProviderTile.tsx @@ -0,0 +1,54 @@ +import React from 'react' +import { createComponent, Stack, Text } from '@goodwidget/ui' +import type { PasskeyProviderId } from '../widgetRuntimeContract.ts' +import { ProviderLogo } from './ProviderLogo.tsx' + +/** + * Selectable provider tile. Named so the `light_/dark_PasskeyProviderTile` component themes + * in `config.ts` drive its surface; the selected state uses the primary / info tokens. + */ +const ProviderTileFrame = createComponent(Stack, { + name: 'PasskeyProviderTile', + tag: 'button', + flex: 1, + alignItems: 'center', + paddingVertical: '$5', + gap: '$2', + borderWidth: 2, + borderRadius: '$3', + backgroundColor: '$background', + borderColor: '$borderColor', + cursor: 'pointer', + hoverStyle: { backgroundColor: '$backgroundHover' }, + pressStyle: { backgroundColor: '$backgroundPress' }, + focusStyle: { outlineStyle: 'solid', outlineWidth: 2, outlineColor: '$borderColorFocus' }, + variants: { + selected: { + true: { borderColor: '$primary', backgroundColor: '$infoMuted' }, + }, + } as const, +}) + +export interface ProviderTileProps { + providerId: PasskeyProviderId + label: string + selected: boolean + onSelect: (providerId: PasskeyProviderId) => void +} + +/** One radio option of a provider choice group (B2 / C1). Native button: Space/Enter select it. */ +export function ProviderTile({ providerId, label, selected, onSelect }: ProviderTileProps) { + return ( + onSelect(providerId)} + data-testid={`provider-tile-${providerId}`} + > + + {label} + + ) +} diff --git a/packages/passkey-login-widget/src/components/RestoreScreens.tsx b/packages/passkey-login-widget/src/components/RestoreScreens.tsx new file mode 100644 index 00000000..9f28f17b --- /dev/null +++ b/packages/passkey-login-widget/src/components/RestoreScreens.tsx @@ -0,0 +1,43 @@ +import React from 'react' +import type { PasskeyLoginAdapterActions } from '../adapter.ts' +import { ProgressSteps } from './ProgressSteps.tsx' +import { ProviderChoice } from './ProviderChoice.tsx' +import type { PasskeyProviderOption } from '../adapter.ts' +import type { PasskeyProviderId } from '../widgetRuntimeContract.ts' +import { ScreenAction, ScreenCard } from './ScreenCard.tsx' + +/** Steps shown while an account is restored from a backup (C2). */ +export const RESTORE_STEP_LABELS = ['Passkey verified', 'Backup found', 'Decrypting'] + +/** C1: asks where the backup lives when the widget cannot tell. Pressing a tile continues immediately. */ +export function RestoreProviderScreen({ + providerOptions, + onSelect, +}: { + providerOptions: PasskeyProviderOption[] + onSelect: (providerId: PasskeyProviderId) => void +}) { + return ( + + + + ) +} + +/** C2: looking up, then decrypting the backup. */ +export function RestoreProgressScreen({ activeStepIndex }: { activeStepIndex: number }) { + return ( + + + + ) +} + +/** C3: the account was restored; Continue hands the key to the host. */ +export function RestoreSuccessScreen({ actions }: { actions: PasskeyLoginAdapterActions }) { + return ( + + + + ) +} diff --git a/packages/passkey-login-widget/src/components/ScreenCard.tsx b/packages/passkey-login-widget/src/components/ScreenCard.tsx new file mode 100644 index 00000000..98cefaa9 --- /dev/null +++ b/packages/passkey-login-widget/src/components/ScreenCard.tsx @@ -0,0 +1,71 @@ +import React from 'react' +import type { ReactNode } from 'react' +import { Button, ButtonText, Card, Heading, Icon, Spinner, Text, YStack, type IconName } from '@goodwidget/ui' + +export interface ScreenCardProps { + icon?: IconName + title?: string + description?: string + 'data-testid'?: string + children?: ReactNode +} + +/** Shared card shell of every screen: optional icon, title and description, then the screen body. */ +export function ScreenCard({ icon, title, description, 'data-testid': dataTestId, children }: ScreenCardProps) { + return ( + + {title && ( + + {icon && } + + {title} + + {description && ( + + {description} + + )} + + )} + {children} + + ) +} + +export interface ScreenActionProps { + label: string + onPress: () => void + /** primary: solid; secondary: outlined; text: centered low-emphasis link style. */ + variant?: 'primary' | 'secondary' | 'text' + 'data-testid'?: string +} + +/** Action button of a screen in one of the three emphasis levels used by the designs. */ +export function ScreenAction({ label, onPress, variant = 'primary', 'data-testid': dataTestId }: ScreenActionProps) { + if (variant === 'text') { + return ( + + + + ) + } + return ( + + ) +} + +/** Body of an in-progress screen: spinner plus a status line. */ +export function WaitingState({ message }: { message: string }) { + return ( + + + + {message} + + + ) +} diff --git a/packages/passkey-login-widget/src/config.ts b/packages/passkey-login-widget/src/config.ts new file mode 100644 index 00000000..698a08b9 --- /dev/null +++ b/packages/passkey-login-widget/src/config.ts @@ -0,0 +1,30 @@ +import type { GoodWidgetConfig } from '@goodwidget/core' +import { defaultTokenPreset } from '@goodwidget/ui' + +const color = defaultTokenPreset.tokens.color + +/** + * Component themes of the passkey login widget. `PasskeyProviderTile` is the selectable + * Google Drive / iCloud tile; its surface sits on the card, so it uses the page background + * and the regular border colors of each mode. + */ +export const passkeyLoginWidgetConfig = { + themes: { + light_PasskeyProviderTile: { + background: color.governanceBackground, + backgroundHover: color.governanceBackgroundHover, + backgroundPress: color.governanceBackgroundPress, + backgroundFocus: color.governanceBackgroundFocus, + borderColor: color.governanceBorder, + borderColorFocus: color.governancePrimary, + }, + dark_PasskeyProviderTile: { + background: color.backgroundDark, + backgroundHover: color.surfaceAlt, + backgroundPress: color.backgroundInput, + backgroundFocus: color.surfaceAlt, + borderColor: color.borderDark, + borderColorFocus: color.primary, + }, + }, +} as const satisfies GoodWidgetConfig diff --git a/packages/passkey-login-widget/src/crypto/backupCipher.ts b/packages/passkey-login-widget/src/crypto/backupCipher.ts new file mode 100644 index 00000000..831a9802 --- /dev/null +++ b/packages/passkey-login-widget/src/crypto/backupCipher.ts @@ -0,0 +1,52 @@ +import { BackupDecryptError } from './backupErrors.ts' +import { bytesToHex, hexToBytes, type Bytes } from './encoding.ts' +import type { HexString } from '../widgetRuntimeContract.ts' + +/** AES-GCM nonce length in bytes (96 bit, as recommended for GCM). */ +export const BACKUP_IV_BYTE_LENGTH = 12 + +const textEncoder = new TextEncoder() + +export interface EncryptedPayload { + iv: Bytes + /** AES-GCM output including the 16-byte authentication tag. */ + ciphertext: Bytes +} + +/** The address is bound to the ciphertext as AES-GCM additional data, so the pair cannot be swapped. */ +function additionalDataForAddress(address: HexString): Bytes { + return textEncoder.encode(address.toLowerCase()) +} + +/** Encrypts the private key with a fresh random IV on every call. */ +export async function encryptPrivateKey( + privateKey: HexString, + address: HexString, + encryptionKey: CryptoKey, +): Promise { + const iv = crypto.getRandomValues(new Uint8Array(BACKUP_IV_BYTE_LENGTH)) + const ciphertextBuffer = await crypto.subtle.encrypt( + { name: 'AES-GCM', iv, additionalData: additionalDataForAddress(address) }, + encryptionKey, + hexToBytes(privateKey), + ) + return { iv, ciphertext: new Uint8Array(ciphertextBuffer) } +} + +/** Decrypts the private key; any wrong key, tampered data or wrong address surfaces as `BackupDecryptError`. */ +export async function decryptPrivateKey( + payload: EncryptedPayload, + address: HexString, + encryptionKey: CryptoKey, +): Promise { + try { + const plaintextBuffer = await crypto.subtle.decrypt( + { name: 'AES-GCM', iv: payload.iv, additionalData: additionalDataForAddress(address) }, + encryptionKey, + payload.ciphertext, + ) + return bytesToHex(new Uint8Array(plaintextBuffer)) + } catch { + throw new BackupDecryptError('Backup could not be decrypted with this passkey') + } +} diff --git a/packages/passkey-login-widget/src/crypto/backupErrors.ts b/packages/passkey-login-widget/src/crypto/backupErrors.ts new file mode 100644 index 00000000..e035d947 --- /dev/null +++ b/packages/passkey-login-widget/src/crypto/backupErrors.ts @@ -0,0 +1,15 @@ +/** The backup JSON is malformed, has an unknown version, or carries an invalid address. Maps to D6. */ +export class BackupFormatError extends Error { + constructor(message: string) { + super(message) + this.name = 'BackupFormatError' + } +} + +/** The backup could not be decrypted, or the decrypted key does not match the stored address. Maps to D6. */ +export class BackupDecryptError extends Error { + constructor(message: string) { + super(message) + this.name = 'BackupDecryptError' + } +} diff --git a/packages/passkey-login-widget/src/crypto/backupFileName.ts b/packages/passkey-login-widget/src/crypto/backupFileName.ts new file mode 100644 index 00000000..cc61870d --- /dev/null +++ b/packages/passkey-login-widget/src/crypto/backupFileName.ts @@ -0,0 +1,38 @@ +import type { HexString } from '../widgetRuntimeContract.ts' + +/** Every backup file written by this widget starts with this prefix. */ +export const BACKUP_FILE_PREFIX = 'gwpk-' + +/** `prfTag` is 16 bytes in unpadded base64url, which is always 22 characters. */ +const BACKUP_FILE_NAME_PATTERN = /^gwpk-([A-Za-z0-9_-]{22})-(0x[0-9a-f]{40})\.json$/ + +export interface ParsedBackupFileName { + prfTag: string + /** Lowercase public address of the backed-up account. */ + address: HexString +} + +/** + * Builds `gwpk--
    .json`. The address is lowercased so re-backing up + * the same account always produces the same name. Contains no secret material. + */ +export function buildBackupFileName(prfTag: string, address: HexString): string { + return `${BACKUP_FILE_PREFIX}${prfTag}-${address.toLowerCase()}.json` +} + +/** Parses a backup file name; returns null for anything this widget did not write. */ +export function parseBackupFileName(fileName: string): ParsedBackupFileName | null { + const match = BACKUP_FILE_NAME_PATTERN.exec(fileName) + if (!match) { + return null + } + return { prfTag: match[1], address: match[2] as HexString } +} + +/** + * Keeps only well-formed backup names that belong to the given passkey. + * Storage adapters use this so prefix filtering is identical for every provider. + */ +export function filterBackupNamesByTag(fileNames: string[], prfTag: string): string[] { + return fileNames.filter((fileName) => parseBackupFileName(fileName)?.prfTag === prfTag) +} diff --git a/packages/passkey-login-widget/src/crypto/backupFormat.test.ts b/packages/passkey-login-widget/src/crypto/backupFormat.test.ts new file mode 100644 index 00000000..121dca9c --- /dev/null +++ b/packages/passkey-login-widget/src/crypto/backupFormat.test.ts @@ -0,0 +1,127 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' +import { decryptPrivateKey } from './backupCipher.ts' +import { BackupDecryptError, BackupFormatError } from './backupErrors.ts' +import { buildBackupFileName, filterBackupNamesByTag, parseBackupFileName } from './backupFileName.ts' +import { createBackupJson, parseBackupJson, restorePrivateKeyFromBackupJson } from './backupFormat.ts' +import { decodeBase64, decodeBase64Url, encodeBase64, hexToBytes } from './encoding.ts' +import { addressOfPrivateKey, deriveBackupEncryptionKey } from './prfDerivation.ts' +import { createFakePrfOutput } from '../testFixtures.ts' + +const PRIVATE_KEY = `0x${'22'.repeat(32)}` as const +const OTHER_PRIVATE_KEY = `0x${'33'.repeat(32)}` as const + +describe('backup file names', () => { + const prfTag = 'abcdefghijklmnopqrstuv' + const address = addressOfPrivateKey(PRIVATE_KEY) + + it('builds and parses gwpk--
    .json with a lowercase address', () => { + const fileName = buildBackupFileName(prfTag, address.toUpperCase().replace('0X', '0x') as typeof address) + assert.equal(fileName, `gwpk-${prfTag}-${address}.json`) + assert.deepEqual(parseBackupFileName(fileName), { prfTag, address }) + }) + + it('rejects names this widget did not write', () => { + assert.equal(parseBackupFileName('notes.json'), null) + assert.equal(parseBackupFileName(`gwpk-short-${address}.json`), null) + assert.equal(parseBackupFileName(`gwpk-${prfTag}-${address}.txt`), null) + assert.equal(parseBackupFileName(`gwpk-${prfTag}-0xZZ.json`), null) + }) + + it('keeps only the names of the given passkey', () => { + const ownName = buildBackupFileName(prfTag, address) + const foreignName = buildBackupFileName('zzzzzzzzzzzzzzzzzzzzzz', address) + assert.deepEqual(filterBackupNamesByTag([ownName, foreignName, 'unrelated.json'], prfTag), [ownName]) + }) +}) + +describe('encoding', () => { + it('rejects non-canonical base64 and base64url', () => { + assert.throws(() => decodeBase64('AB==')) + assert.throws(() => decodeBase64('A')) + assert.throws(() => decodeBase64Url('a+b')) + assert.deepEqual(decodeBase64(encodeBase64(new Uint8Array([1, 2, 3]))), new Uint8Array([1, 2, 3])) + }) + + it('rejects malformed hex', () => { + assert.throws(() => hexToBytes('0x123')) + assert.throws(() => hexToBytes('12')) + }) +}) + +describe('backup JSON', () => { + async function createBackupFor(privateKey: typeof PRIVATE_KEY, passkeySeed: number) { + const encryptionKey = await deriveBackupEncryptionKey(createFakePrfOutput(passkeySeed)) + return { backupJson: await createBackupJson(privateKey, encryptionKey), encryptionKey } + } + + it('round-trips the private key and writes the documented v1 shape', async () => { + const { backupJson, encryptionKey } = await createBackupFor(PRIVATE_KEY, 1) + const content = JSON.parse(backupJson) + assert.deepEqual(Object.keys(content).sort(), ['address', 'ciphertext', 'iv', 'version']) + assert.equal(content.version, 1) + assert.equal(content.address, addressOfPrivateKey(PRIVATE_KEY)) + assert.equal(decodeBase64(content.iv).length, 12) + assert.ok(!backupJson.includes(PRIVATE_KEY.slice(2))) + + const restored = await restorePrivateKeyFromBackupJson(backupJson, encryptionKey) + assert.deepEqual(restored, { privateKey: PRIVATE_KEY, address: addressOfPrivateKey(PRIVATE_KEY) }) + }) + + it('uses a fresh IV for every backup of the same key', async () => { + const first = JSON.parse((await createBackupFor(PRIVATE_KEY, 1)).backupJson) + const second = JSON.parse((await createBackupFor(PRIVATE_KEY, 1)).backupJson) + assert.notEqual(first.iv, second.iv) + assert.notEqual(first.ciphertext, second.ciphertext) + }) + + it('fails to decrypt with another passkey', async () => { + const { backupJson } = await createBackupFor(PRIVATE_KEY, 1) + const wrongPasskeyKey = await deriveBackupEncryptionKey(createFakePrfOutput(2)) + await assert.rejects(restorePrivateKeyFromBackupJson(backupJson, wrongPasskeyKey), BackupDecryptError) + }) + + it('fails when the ciphertext was tampered with', async () => { + const { backupJson, encryptionKey } = await createBackupFor(PRIVATE_KEY, 1) + const content = JSON.parse(backupJson) + const tamperedBytes = decodeBase64(content.ciphertext) + tamperedBytes[0] ^= 1 + const tamperedJson = JSON.stringify({ ...content, ciphertext: encodeBase64(tamperedBytes) }) + await assert.rejects(restorePrivateKeyFromBackupJson(tamperedJson, encryptionKey), BackupDecryptError) + }) + + it('fails when the stored address was swapped (address is authenticated data)', async () => { + const { backupJson, encryptionKey } = await createBackupFor(PRIVATE_KEY, 1) + const swappedJson = JSON.stringify({ ...JSON.parse(backupJson), address: addressOfPrivateKey(OTHER_PRIVATE_KEY) }) + await assert.rejects(restorePrivateKeyFromBackupJson(swappedJson, encryptionKey), BackupDecryptError) + }) + + it('fails when the decrypted key does not belong to the stored address', async () => { + const encryptionKey = await deriveBackupEncryptionKey(createFakePrfOutput(1)) + const wrongAddress = addressOfPrivateKey(OTHER_PRIVATE_KEY) + // Encrypt PRIVATE_KEY but label it with another address: AAD matches, the key/address check must still fail + const { encryptPrivateKey } = await import('./backupCipher.ts') + const { iv, ciphertext } = await encryptPrivateKey(PRIVATE_KEY, wrongAddress, encryptionKey) + const mismatchedJson = JSON.stringify({ version: 1, address: wrongAddress, iv: encodeBase64(iv), ciphertext: encodeBase64(ciphertext) }) + assert.equal(await decryptPrivateKey({ iv, ciphertext }, wrongAddress, encryptionKey), PRIVATE_KEY) + await assert.rejects(restorePrivateKeyFromBackupJson(mismatchedJson, encryptionKey), BackupDecryptError) + }) + + it('rejects malformed backups', async () => { + const { backupJson } = await createBackupFor(PRIVATE_KEY, 1) + const content = JSON.parse(backupJson) + const malformedBackups = [ + 'not json', + '[]', + JSON.stringify({ ...content, version: 2 }), + JSON.stringify({ ...content, version: undefined }), + JSON.stringify({ ...content, address: '0x123' }), + JSON.stringify({ ...content, iv: encodeBase64(new Uint8Array(8)) }), + JSON.stringify({ ...content, ciphertext: encodeBase64(new Uint8Array(10)) }), + JSON.stringify({ ...content, ciphertext: 7 }), + ] + for (const malformedBackup of malformedBackups) { + assert.throws(() => parseBackupJson(malformedBackup), BackupFormatError) + } + }) +}) diff --git a/packages/passkey-login-widget/src/crypto/backupFormat.ts b/packages/passkey-login-widget/src/crypto/backupFormat.ts new file mode 100644 index 00000000..7ebc8dbb --- /dev/null +++ b/packages/passkey-login-widget/src/crypto/backupFormat.ts @@ -0,0 +1,90 @@ +import { BackupDecryptError, BackupFormatError } from './backupErrors.ts' +import { BACKUP_IV_BYTE_LENGTH, decryptPrivateKey, encryptPrivateKey } from './backupCipher.ts' +import { addressOfPrivateKey } from './prfDerivation.ts' +import { decodeBase64, encodeBase64 } from './encoding.ts' +import type { HexString } from '../widgetRuntimeContract.ts' + +/** The only backup format version. Older/newer versions are rejected, there are no compatibility shims. */ +export const BACKUP_FORMAT_VERSION = 1 + +/** 32-byte private key plus the 16-byte AES-GCM tag. */ +const MINIMUM_CIPHERTEXT_BYTE_LENGTH = 48 + +const ADDRESS_PATTERN = /^0x[0-9a-fA-F]{40}$/ + +/** Shape of the JSON file stored with the provider. */ +export interface BackupFileContent { + version: number + address: HexString + /** base64, 12 bytes */ + iv: string + /** base64, AES-GCM output including the tag */ + ciphertext: string +} + +/** Encrypts the account key and returns the versioned backup JSON to upload. */ +export async function createBackupJson(privateKey: HexString, encryptionKey: CryptoKey): Promise { + const address = addressOfPrivateKey(privateKey) + const { iv, ciphertext } = await encryptPrivateKey(privateKey, address, encryptionKey) + const content: BackupFileContent = { + version: BACKUP_FORMAT_VERSION, + address, + iv: encodeBase64(iv), + ciphertext: encodeBase64(ciphertext), + } + return JSON.stringify(content) +} + +/** Parses and validates backup JSON; throws `BackupFormatError` for anything that is not a valid v1 backup. */ +export function parseBackupJson(backupJson: string): BackupFileContent { + let parsed: unknown + try { + parsed = JSON.parse(backupJson) + } catch { + throw new BackupFormatError('Backup is not valid JSON') + } + if (typeof parsed !== 'object' || parsed === null) { + throw new BackupFormatError('Backup is not an object') + } + const { version, address, iv, ciphertext } = parsed as Record + if (version !== BACKUP_FORMAT_VERSION) { + throw new BackupFormatError(`Unsupported backup version: ${String(version)}`) + } + if (typeof address !== 'string' || !ADDRESS_PATTERN.test(address)) { + throw new BackupFormatError('Backup address is invalid') + } + if (typeof iv !== 'string' || typeof ciphertext !== 'string') { + throw new BackupFormatError('Backup is missing iv or ciphertext') + } + try { + if (decodeBase64(iv).length !== BACKUP_IV_BYTE_LENGTH) { + throw new Error('Wrong IV length') + } + if (decodeBase64(ciphertext).length < MINIMUM_CIPHERTEXT_BYTE_LENGTH) { + throw new Error('Ciphertext too short') + } + } catch { + throw new BackupFormatError('Backup iv or ciphertext is invalid') + } + return { version, address: address.toLowerCase() as HexString, iv, ciphertext } +} + +/** + * Decrypts a backup and verifies that the recovered key reproduces the stored address. + * Wrong passkey, corrupted data and address mismatch all throw (`BackupDecryptError`). + */ +export async function restorePrivateKeyFromBackupJson( + backupJson: string, + encryptionKey: CryptoKey, +): Promise<{ privateKey: HexString; address: HexString }> { + const content = parseBackupJson(backupJson) + const privateKey = await decryptPrivateKey( + { iv: decodeBase64(content.iv), ciphertext: decodeBase64(content.ciphertext) }, + content.address, + encryptionKey, + ) + if (addressOfPrivateKey(privateKey) !== content.address) { + throw new BackupDecryptError('Decrypted key does not match the backup address') + } + return { privateKey, address: content.address } +} diff --git a/packages/passkey-login-widget/src/crypto/encoding.ts b/packages/passkey-login-widget/src/crypto/encoding.ts new file mode 100644 index 00000000..4f7d1de0 --- /dev/null +++ b/packages/passkey-login-widget/src/crypto/encoding.ts @@ -0,0 +1,70 @@ +/** + * Byte <-> text encodings used by the backup format and file names. + * Decoders are strict: anything that is not canonical output of the matching encoder throws. + */ + +/** Byte array backed by a plain ArrayBuffer, which is what WebCrypto/WebAuthn accept as BufferSource. */ +export type Bytes = Uint8Array + +const BASE64_PATTERN = /^[A-Za-z0-9+/]*={0,2}$/ +const BASE64URL_PATTERN = /^[A-Za-z0-9_-]*$/ + +/** Standard base64 (with padding), used for the `iv` and `ciphertext` backup fields. */ +export function encodeBase64(bytes: Bytes): string { + let binary = '' + for (const byte of bytes) { + binary += String.fromCharCode(byte) + } + return btoa(binary) +} + +/** Strict inverse of `encodeBase64`; throws on invalid or non-canonical input. */ +export function decodeBase64(text: string): Bytes { + if (!BASE64_PATTERN.test(text) || text.length % 4 !== 0) { + throw new Error('Invalid base64') + } + const binary = atob(text) + const bytes = new Uint8Array(binary.length) + for (let index = 0; index < binary.length; index += 1) { + bytes[index] = binary.charCodeAt(index) + } + if (encodeBase64(bytes) !== text) { + throw new Error('Non-canonical base64') + } + return bytes +} + +/** URL-safe base64 without padding, used for the `prfTag` in backup file names. */ +export function encodeBase64Url(bytes: Bytes): string { + return encodeBase64(bytes).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') +} + +/** Strict inverse of `encodeBase64Url`. */ +export function decodeBase64Url(text: string): Bytes { + if (!BASE64URL_PATTERN.test(text)) { + throw new Error('Invalid base64url') + } + const padding = '='.repeat((4 - (text.length % 4)) % 4) + return decodeBase64(text.replace(/-/g, '+').replace(/_/g, '/') + padding) +} + +/** Lowercase 0x-prefixed hex of the given bytes. */ +export function bytesToHex(bytes: Bytes): `0x${string}` { + let hex = '0x' + for (const byte of bytes) { + hex += byte.toString(16).padStart(2, '0') + } + return hex as `0x${string}` +} + +/** Bytes of a 0x-prefixed hex string; throws when the string is not valid hex of even length. */ +export function hexToBytes(hex: string): Bytes { + if (!/^0x([0-9a-fA-F]{2})*$/.test(hex)) { + throw new Error('Invalid hex') + } + const bytes = new Uint8Array((hex.length - 2) / 2) + for (let index = 0; index < bytes.length; index += 1) { + bytes[index] = Number.parseInt(hex.slice(2 + index * 2, 4 + index * 2), 16) + } + return bytes +} diff --git a/packages/passkey-login-widget/src/crypto/prfDerivation.test.ts b/packages/passkey-login-widget/src/crypto/prfDerivation.test.ts new file mode 100644 index 00000000..87729c27 --- /dev/null +++ b/packages/passkey-login-widget/src/crypto/prfDerivation.test.ts @@ -0,0 +1,71 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' +import { bytesToHex } from './encoding.ts' +import { + addressOfPrivateKey, + deriveAccountKey, + deriveBackupEncryptionKey, + deriveBackupFileTag, + toValidAccountKey, +} from './prfDerivation.ts' +import { createFakePrfOutput } from '../testFixtures.ts' + +describe('PRF derivation', () => { + it('derives the same account key and file tag from the same passkey', async () => { + const prfOutput = createFakePrfOutput(1) + assert.equal(await deriveAccountKey(prfOutput), await deriveAccountKey(createFakePrfOutput(1))) + assert.equal( + await deriveBackupFileTag(prfOutput, 'example.org'), + await deriveBackupFileTag(createFakePrfOutput(1), 'example.org'), + ) + }) + + it('derives a 32-byte hex account key that is different for different passkeys', async () => { + const firstKey = await deriveAccountKey(createFakePrfOutput(1)) + const secondKey = await deriveAccountKey(createFakePrfOutput(2)) + assert.match(firstKey, /^0x[0-9a-f]{64}$/) + assert.notEqual(firstKey, secondKey) + }) + + it('separates the account key, the file tag and the encryption key by label', async () => { + const prfOutput = createFakePrfOutput(3) + const accountKey = await deriveAccountKey(prfOutput) + const fileTag = await deriveBackupFileTag(prfOutput, 'example.org') + assert.equal(fileTag.length, 22) + assert.ok(!accountKey.includes(fileTag)) + const encryptionKey = await deriveBackupEncryptionKey(prfOutput) + assert.equal(encryptionKey.extractable, false) + assert.deepEqual(encryptionKey.usages.sort(), ['decrypt', 'encrypt']) + }) + + it('makes the file tag specific to the rpId', async () => { + const prfOutput = createFakePrfOutput(4) + assert.notEqual(await deriveBackupFileTag(prfOutput, 'a.example.org'), await deriveBackupFileTag(prfOutput, 'b.example.org')) + }) + + it('produces a base64url file tag without padding', async () => { + assert.match(await deriveBackupFileTag(createFakePrfOutput(5), 'example.org'), /^[A-Za-z0-9_-]{22}$/) + }) + + it('rejects scalars that are zero or not below the curve order', () => { + assert.throws(() => toValidAccountKey(new Uint8Array(32))) + const curveOrderBytes = Uint8Array.from( + Buffer.from('fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141', 'hex'), + ) + assert.throws(() => toValidAccountKey(curveOrderBytes)) + assert.throws(() => toValidAccountKey(new Uint8Array(32).fill(255))) + assert.throws(() => toValidAccountKey(new Uint8Array(31).fill(1))) + }) + + it('accepts the largest valid scalar (order - 1)', () => { + const largestValidBytes = Uint8Array.from( + Buffer.from('fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364140', 'hex'), + ) + assert.equal(toValidAccountKey(largestValidBytes), bytesToHex(largestValidBytes)) + }) + + it('returns a lowercase address for a private key', () => { + const address = addressOfPrivateKey(`0x${'11'.repeat(32)}`) + assert.match(address, /^0x[0-9a-f]{40}$/) + }) +}) diff --git a/packages/passkey-login-widget/src/crypto/prfDerivation.ts b/packages/passkey-login-widget/src/crypto/prfDerivation.ts new file mode 100644 index 00000000..1f12b682 --- /dev/null +++ b/packages/passkey-login-widget/src/crypto/prfDerivation.ts @@ -0,0 +1,85 @@ +import { privateKeyToAddress } from 'viem/accounts' +import { encodeBase64Url, bytesToHex, type Bytes } from './encoding.ts' +import type { HexString } from '../widgetRuntimeContract.ts' + +/** + * Fixed, versioned PRF input. Changing it changes every derived value, so a new + * version means a new account family and must be a deliberate format bump. + */ +export const PRF_SALT_TEXT = 'goodwidget:passkey-login:prf:v1' + +/** HKDF `info` labels. Each derivation has its own label so none reveals anything about the others. */ +export const ACCOUNT_KEY_LABEL = 'goodwidget/passkey-login/v1/account-key' +export const BACKUP_ENCRYPTION_KEY_LABEL = 'goodwidget/passkey-login/v1/backup-encryption-key' +export const BACKUP_FILE_TAG_LABEL = 'goodwidget/passkey-login/v1/backup-file-tag' + +const ACCOUNT_KEY_BYTE_LENGTH = 32 +const BACKUP_FILE_TAG_BYTE_LENGTH = 16 + +/** Order of the secp256k1 group; a valid private key is an integer in [1, order - 1]. */ +const SECP256K1_CURVE_ORDER = 0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141n + +const textEncoder = new TextEncoder() + +/** + * HKDF-SHA-256 over the PRF output. + * @param prfOutput secret PRF bytes (kept in memory only) + * @param label domain-separation label (HKDF info) + * @param salt HKDF salt; empty when the derivation is not site-specific + * @param byteLength number of bytes to derive + */ +async function hkdfSha256( + prfOutput: Bytes, + label: string, + salt: Bytes, + byteLength: number, +): Promise { + const inputKey = await crypto.subtle.importKey('raw', prfOutput, 'HKDF', false, ['deriveBits']) + const derivedBits = await crypto.subtle.deriveBits( + { name: 'HKDF', hash: 'SHA-256', salt, info: textEncoder.encode(label) }, + inputKey, + byteLength * 8, + ) + return new Uint8Array(derivedBits) +} + +/** Returns the key as hex when it is a valid secp256k1 scalar; throws otherwise. */ +export function toValidAccountKey(keyBytes: Bytes): HexString { + const keyHex = bytesToHex(keyBytes) + const keyScalar = BigInt(keyHex) + if (keyBytes.length !== ACCOUNT_KEY_BYTE_LENGTH || keyScalar === 0n || keyScalar >= SECP256K1_CURVE_ORDER) { + throw new Error('Derived bytes are not a valid secp256k1 private key') + } + return keyHex +} + +/** 32-byte account private key for a NEW account, derived from the passkey PRF output. */ +export async function deriveAccountKey(prfOutput: Bytes): Promise { + const keyBytes = await hkdfSha256(prfOutput, ACCOUNT_KEY_LABEL, new Uint8Array(0), ACCOUNT_KEY_BYTE_LENGTH) + return toValidAccountKey(keyBytes) +} + +/** AES-256-GCM key that encrypts the account key in the backup file. Non-extractable. */ +export async function deriveBackupEncryptionKey(prfOutput: Bytes): Promise { + const keyBytes = await hkdfSha256(prfOutput, BACKUP_ENCRYPTION_KEY_LABEL, new Uint8Array(0), 32) + return crypto.subtle.importKey('raw', keyBytes, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']) +} + +/** + * `prfTag`: one-way 22-character base64url tag, unique per passkey AND per rpId + * (rpId is the HKDF salt). Used in backup file names to find the backup of this passkey. + */ +export async function deriveBackupFileTag(prfOutput: Bytes, rpId: string): Promise { + const tagBytes = await hkdfSha256( + prfOutput, + BACKUP_FILE_TAG_LABEL, + textEncoder.encode(rpId), + BACKUP_FILE_TAG_BYTE_LENGTH, + ) + return encodeBase64Url(tagBytes) +} + +/** Lowercase public address of a private key. */ +export function addressOfPrivateKey(privateKey: HexString): HexString { + return privateKeyToAddress(privateKey).toLowerCase() as HexString +} diff --git a/packages/passkey-login-widget/src/element.ts b/packages/passkey-login-widget/src/element.ts new file mode 100644 index 00000000..cf53f4ba --- /dev/null +++ b/packages/passkey-login-widget/src/element.ts @@ -0,0 +1,33 @@ +import { createMiniAppElement } from '@goodwidget/embed' +import type React from 'react' +import { PasskeyLoginWidget } from './PasskeyLoginWidget.tsx' + +/** + * Custom Element wrapping the PasskeyLoginWidget React component. + * + * import { createGoogleDriveStorage } from '@goodwidget/passkey-login-widget' + * const el = document.querySelector('gw-passkey-login') + * el.storage = [createGoogleDriveStorage({ clientId })] // JS property: holds functions + * el.rpId = 'example.org' + * el.appName = 'Example' + * el.privateKey = '0x…' // only for the backup flow; JS property, never an attribute + * el.addEventListener('login', (event) => event.detail) // { privateKey, address, isNewAccount } + * + * Events: login, backup-complete, skip, error. + */ +export const PasskeyLoginWidgetElement = createMiniAppElement( + // Required props (storage, rpId, appName) are set by the host as properties, so the loose element prop type is intended + PasskeyLoginWidget as unknown as React.ComponentType>, + { + shadow: true, + defaultTheme: 'dark', + // The private key and the storage adapters are objects/secrets: JS properties only, never HTML attributes + props: { + privateKey: 'property', + storage: 'property', + themeOverrides: 'property', + config: 'property', + }, + events: ['login', 'backup-complete', 'skip', 'error'], + }, +) diff --git a/packages/passkey-login-widget/src/flow/passkeyFlowMachine.test.ts b/packages/passkey-login-widget/src/flow/passkeyFlowMachine.test.ts new file mode 100644 index 00000000..ae4d8852 --- /dev/null +++ b/packages/passkey-login-widget/src/flow/passkeyFlowMachine.test.ts @@ -0,0 +1,189 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' +import { + createInitialFlowState, + passkeyFlowReducer, + type PasskeyFlowEvent, + type PasskeyFlowState, +} from './passkeyFlowMachine.ts' + +/** Applies events in order and returns the screens visited after each one. */ +function runEvents(initialState: PasskeyFlowState, events: PasskeyFlowEvent[]) { + const states = [initialState] + for (const event of events) { + states.push(passkeyFlowReducer(states[states.length - 1], event)) + } + return { finalState: states[states.length - 1], screens: states.map((state) => state.screen) } +} + +const newUserState = createInitialFlowState({ isExistingUser: false, hasMultipleProviders: true }) +const existingUserState = createInitialFlowState({ isExistingUser: true, hasMultipleProviders: true }) + +describe('passkey flow machine', () => { + it('starts at A1 for a new visitor and at B1 for an existing user', () => { + assert.equal(newUserState.screen, 'A1_ENTRY') + assert.equal(existingUserState.screen, 'B1_BACKUP_PROMPT') + }) + + it('logs in: A1 -> A2 -> C1 -> C2 -> C3 when no provider is known', () => { + const { screens } = runEvents(newUserState, [ + { type: 'LOGIN_STARTED' }, + { type: 'PASSKEY_VERIFIED', providerId: null }, + { type: 'PROVIDER_CHOSEN', providerId: 'icloud' }, + { type: 'RESTORE_SUCCEEDED' }, + ]) + assert.deepEqual(screens, ['A1_ENTRY', 'A2_AUTHENTICATING', 'C1_RESTORE_PROVIDER', 'C2_RESTORE_PROGRESS', 'C3_RESTORE_SUCCESS']) + }) + + it('skips C1 when the provider is remembered or the only one', () => { + const { screens, finalState } = runEvents(newUserState, [ + { type: 'LOGIN_STARTED' }, + { type: 'PASSKEY_VERIFIED', providerId: 'google-drive' }, + ]) + assert.deepEqual(screens, ['A1_ENTRY', 'A2_AUTHENTICATING', 'C2_RESTORE_PROGRESS']) + assert.equal(finalState.providerId, 'google-drive') + }) + + it('starts the restore progress at "Backup found" and advances to "Decrypting"', () => { + const { finalState } = runEvents(newUserState, [ + { type: 'LOGIN_STARTED' }, + { type: 'PASSKEY_VERIFIED', providerId: 'google-drive' }, + ]) + assert.equal(finalState.progressStepIndex, 1) + assert.equal(passkeyFlowReducer(finalState, { type: 'PROGRESS_ADVANCED' }).progressStepIndex, 2) + }) + + it('creates a new account: A1 -> A2 -> D2 -> A3 -> A4 -> B2 -> B3 -> B4', () => { + const { screens, finalState } = runEvents(newUserState, [ + { type: 'LOGIN_STARTED' }, + { type: 'FAILED', errorCode: 'PASSKEY_CANCELLED' }, + { type: 'NEW_ACCOUNT_REQUESTED' }, + { type: 'NEW_ACCOUNT_CONFIRMED' }, + { type: 'PASSKEY_CREATED', providerId: null }, + { type: 'PROVIDER_CHOSEN', providerId: 'google-drive' }, + { type: 'BACKUP_SUCCEEDED' }, + ]) + assert.deepEqual(screens, [ + 'A1_ENTRY', + 'A2_AUTHENTICATING', + 'D2_PASSKEY_CANCELLED', + 'A3_NEW_ACCOUNT_WARNING', + 'A4_CREATE_PASSKEY', + 'B2_BACKUP_PROVIDER', + 'B3_BACKUP_PROGRESS', + 'B4_BACKUP_SUCCESS', + ]) + assert.equal(finalState.flowKind, 'new-account') + }) + + it('skips B2 only when a single provider is offered', () => { + const { screens } = runEvents(newUserState, [ + { type: 'NEW_ACCOUNT_CONFIRMED' }, + { type: 'PASSKEY_CREATED', providerId: 'google-drive' }, + ]) + assert.deepEqual(screens, ['A1_ENTRY', 'A4_CREATE_PASSKEY', 'B3_BACKUP_PROGRESS']) + }) + + it('backs up an existing user with a fresh passkey: B1 -> A4 -> B2 -> B3 -> B4', () => { + const { screens, finalState } = runEvents(existingUserState, [ + { type: 'BACKUP_REQUESTED' }, + { type: 'PASSKEY_CREATED', providerId: null }, + { type: 'PROVIDER_CHOSEN', providerId: 'icloud' }, + { type: 'BACKUP_SUCCEEDED' }, + ]) + assert.deepEqual(screens, ['B1_BACKUP_PROMPT', 'A4_CREATE_PASSKEY', 'B2_BACKUP_PROVIDER', 'B3_BACKUP_PROGRESS', 'B4_BACKUP_SUCCESS']) + assert.equal(finalState.flowKind, 'backup') + }) + + it('starts the backup progress at "Uploading"', () => { + const { finalState } = runEvents(existingUserState, [ + { type: 'BACKUP_REQUESTED' }, + { type: 'PASSKEY_CREATED', providerId: 'icloud' }, + ]) + assert.equal(finalState.progressStepIndex, 2) + }) + + it('maps every error code to its screen, AMBIGUOUS_BACKUP sharing D6', () => { + const expectedScreens = { + UNSUPPORTED_BROWSER: 'D1_UNSUPPORTED_BROWSER', + PASSKEY_CANCELLED: 'D2_PASSKEY_CANCELLED', + BACKUP_NOT_FOUND: 'D3_BACKUP_NOT_FOUND', + PROVIDER_AUTH_FAILED: 'D4_PROVIDER_AUTH_FAILED', + BACKUP_FAILED: 'D5_BACKUP_FAILED', + DECRYPT_FAILED: 'D6_DECRYPT_FAILED', + AMBIGUOUS_BACKUP: 'D6_DECRYPT_FAILED', + } as const + for (const [errorCode, expectedScreen] of Object.entries(expectedScreens)) { + const failedState = passkeyFlowReducer(newUserState, { + type: 'FAILED', + errorCode: errorCode as keyof typeof expectedScreens, + }) + assert.equal(failedState.screen, expectedScreen) + assert.equal(failedState.errorCode, errorCode) + } + }) + + it('D2 Retry starts over at A1 (B1 for existing users)', () => { + const cancelledNewUser = runEvents(newUserState, [{ type: 'LOGIN_STARTED' }, { type: 'FAILED', errorCode: 'PASSKEY_CANCELLED' }, { type: 'RETRY_REQUESTED' }]) + assert.equal(cancelledNewUser.finalState.screen, 'A1_ENTRY') + const cancelledExistingUser = runEvents(existingUserState, [{ type: 'BACKUP_REQUESTED' }, { type: 'FAILED', errorCode: 'PASSKEY_CANCELLED' }, { type: 'RETRY_REQUESTED' }]) + assert.equal(cancelledExistingUser.finalState.screen, 'B1_BACKUP_PROMPT') + }) + + it('D4 / D5 / D6 Retry re-runs the screen that failed, keeping the provider', () => { + const restoreFailed = runEvents(newUserState, [ + { type: 'LOGIN_STARTED' }, + { type: 'PASSKEY_VERIFIED', providerId: 'icloud' }, + { type: 'FAILED', errorCode: 'DECRYPT_FAILED' }, + { type: 'RETRY_REQUESTED' }, + ]) + assert.equal(restoreFailed.finalState.screen, 'C2_RESTORE_PROGRESS') + assert.equal(restoreFailed.finalState.providerId, 'icloud') + + const backupFailed = runEvents(newUserState, [ + { type: 'NEW_ACCOUNT_CONFIRMED' }, + { type: 'PASSKEY_CREATED', providerId: 'google-drive' }, + { type: 'FAILED', errorCode: 'BACKUP_FAILED' }, + { type: 'RETRY_REQUESTED' }, + ]) + assert.equal(backupFailed.finalState.screen, 'B3_BACKUP_PROGRESS') + assert.equal(backupFailed.finalState.errorCode, null) + }) + + it('counts every retry as a new attempt so the screen work runs again', () => { + const failedState = runEvents(newUserState, [ + { type: 'LOGIN_STARTED' }, + { type: 'PASSKEY_VERIFIED', providerId: 'icloud' }, + { type: 'FAILED', errorCode: 'PROVIDER_AUTH_FAILED' }, + ]).finalState + const retriedState = passkeyFlowReducer(failedState, { type: 'RETRY_REQUESTED' }) + assert.ok(retriedState.attempt > failedState.attempt) + assert.equal(passkeyFlowReducer(retriedState, { type: 'PROGRESS_ADVANCED' }).attempt, retriedState.attempt) + }) + + it('"other provider" leads to C1 when restoring and to B2 when backing up', () => { + const restoring = runEvents(newUserState, [ + { type: 'LOGIN_STARTED' }, + { type: 'PASSKEY_VERIFIED', providerId: 'google-drive' }, + { type: 'FAILED', errorCode: 'BACKUP_NOT_FOUND' }, + { type: 'OTHER_PROVIDER_REQUESTED' }, + ]) + assert.equal(restoring.finalState.screen, 'C1_RESTORE_PROVIDER') + assert.equal(restoring.finalState.providerId, null) + + const backingUp = runEvents(existingUserState, [ + { type: 'BACKUP_REQUESTED' }, + { type: 'PASSKEY_CREATED', providerId: 'google-drive' }, + { type: 'FAILED', errorCode: 'PROVIDER_AUTH_FAILED' }, + { type: 'OTHER_PROVIDER_REQUESTED' }, + ]) + assert.equal(backingUp.finalState.screen, 'B2_BACKUP_PROVIDER') + }) + + it('"Back" returns to A1 for new visitors and B1 for existing users', () => { + const restoreFailed = runEvents(newUserState, [{ type: 'FAILED', errorCode: 'DECRYPT_FAILED' }, { type: 'BACK_REQUESTED' }]) + assert.equal(restoreFailed.finalState.screen, 'A1_ENTRY') + const existingFailed = runEvents(existingUserState, [{ type: 'FAILED', errorCode: 'DECRYPT_FAILED' }, { type: 'BACK_REQUESTED' }]) + assert.equal(existingFailed.finalState.screen, 'B1_BACKUP_PROMPT') + }) +}) diff --git a/packages/passkey-login-widget/src/flow/passkeyFlowMachine.ts b/packages/passkey-login-widget/src/flow/passkeyFlowMachine.ts new file mode 100644 index 00000000..dc155ed8 --- /dev/null +++ b/packages/passkey-login-widget/src/flow/passkeyFlowMachine.ts @@ -0,0 +1,183 @@ +import type { PasskeyErrorCode, PasskeyProviderId, PasskeyScreenId } from '../widgetRuntimeContract.ts' + +/** + * Pure state machine for the screens of the passkey flow (A1-D6). + * It holds no secrets: the PRF output and private keys live in the adapter, outside React state. + */ + +/** Which journey is running; decides where the recovery buttons of the D screens lead. */ +export type PasskeyFlowKind = + | 'login' // returning user restoring an account with an existing passkey + | 'new-account' // new user: fresh passkey, fresh account, first backup + | 'backup' // existing (already logged in) user backing up their account + +export interface PasskeyFlowState { + screen: PasskeyScreenId + flowKind: PasskeyFlowKind + /** The host passed a `privateKey`: the widget starts in the backup flow and D5 may be skipped. */ + isExistingUser: boolean + /** More than one backup provider is offered, so provider choice screens (B2/C1) and "other provider" buttons exist. */ + hasMultipleProviders: boolean + providerId: PasskeyProviderId | null + /** Index of the active step on the A4/B3/C2 progress screens. */ + progressStepIndex: number + /** + * Counts screen entries. The adapter starts the work of a screen once per attempt, so retrying + * a screen re-runs its work and a re-render (or React strict-mode effect replay) does not. + */ + attempt: number + /** Set on D screens: the failure that led there. */ + errorCode: PasskeyErrorCode | null + /** Screen that was running when the failure happened; D4/D5/D6 "retry" returns to it. */ + failedScreen: PasskeyScreenId | null +} + +export type PasskeyFlowEvent = + | { type: 'LOGIN_STARTED' } // A1 "Continue with passkey" -> A2 + | { type: 'PASSKEY_VERIFIED'; providerId: PasskeyProviderId | null } // A2 -> C2 (provider known) or C1 + | { type: 'NEW_ACCOUNT_REQUESTED' } // D2 -> A3 + | { type: 'NEW_ACCOUNT_CONFIRMED' } // A3 -> A4 + | { type: 'BACKUP_REQUESTED' } // B1 -> A4 (fresh passkey) + | { type: 'PASSKEY_CREATED'; providerId: PasskeyProviderId | null } // A4 -> B3 (provider known) or B2 + | { type: 'PROVIDER_CHOSEN'; providerId: PasskeyProviderId } // C1 -> C2, B2 -> B3 + | { type: 'PROGRESS_ADVANCED' } // next step on A4/B3/C2 + | { type: 'RESTORE_SUCCEEDED' } // C2 -> C3 + | { type: 'BACKUP_SUCCEEDED' } // B3 -> B4 + | { type: 'FAILED'; errorCode: PasskeyErrorCode } // any working screen -> D1..D6 + | { type: 'RETRY_REQUESTED' } // D2 "Retry", D4 "Try again", D5 "Retry", D6 "Try again" + | { type: 'OTHER_PROVIDER_REQUESTED' } // D3/D4 "other provider" -> C1 or B2 + | { type: 'BACK_REQUESTED' } // D3/D6 "Back" -> A1 (or B1 for existing users) + +export function createInitialFlowState(options: { + isExistingUser: boolean + hasMultipleProviders: boolean +}): PasskeyFlowState { + return { + screen: options.isExistingUser ? 'B1_BACKUP_PROMPT' : 'A1_ENTRY', + flowKind: options.isExistingUser ? 'backup' : 'login', + isExistingUser: options.isExistingUser, + hasMultipleProviders: options.hasMultipleProviders, + providerId: null, + progressStepIndex: 0, + attempt: 0, + errorCode: null, + failedScreen: null, + } +} + +/** Screen that shows which error. AMBIGUOUS_BACKUP shares the D6 layout. */ +const ERROR_SCREEN_BY_CODE: Record = { + UNSUPPORTED_BROWSER: 'D1_UNSUPPORTED_BROWSER', + PASSKEY_CANCELLED: 'D2_PASSKEY_CANCELLED', + BACKUP_NOT_FOUND: 'D3_BACKUP_NOT_FOUND', + PROVIDER_AUTH_FAILED: 'D4_PROVIDER_AUTH_FAILED', + BACKUP_FAILED: 'D5_BACKUP_FAILED', + DECRYPT_FAILED: 'D6_DECRYPT_FAILED', + AMBIGUOUS_BACKUP: 'D6_DECRYPT_FAILED', +} + +/** First screen of the journey, used by D2 "Retry" and the D3/D6 "Back" buttons. */ +function entryScreenOf(state: PasskeyFlowState): PasskeyScreenId { + return state.isExistingUser ? 'B1_BACKUP_PROMPT' : 'A1_ENTRY' +} + +/** Provider choice screen for the current journey: C1 when restoring, B2 when backing up. */ +function providerChoiceScreenOf(state: PasskeyFlowState): PasskeyScreenId { + return state.flowKind === 'login' ? 'C1_RESTORE_PROVIDER' : 'B2_BACKUP_PROVIDER' +} + +/** + * Step that is active when a progress screen is entered. B3 starts at "Uploading" and C2 at + * "Backup found" (searching), because the earlier steps already happened on the previous screens. + */ +const INITIAL_PROGRESS_STEP_BY_SCREEN: Partial> = { + B3_BACKUP_PROGRESS: 2, + C2_RESTORE_PROGRESS: 1, +} + +/** Moves to a screen and resets the per-screen progress and error fields. */ +function goToScreen( + state: PasskeyFlowState, + screen: PasskeyScreenId, + changes: Partial = {}, +): PasskeyFlowState { + return { + ...state, + screen, + progressStepIndex: INITIAL_PROGRESS_STEP_BY_SCREEN[screen] ?? 0, + attempt: state.attempt + 1, + errorCode: null, + failedScreen: null, + ...changes, + } +} + +export function passkeyFlowReducer(state: PasskeyFlowState, event: PasskeyFlowEvent): PasskeyFlowState { + switch (event.type) { + case 'LOGIN_STARTED': + return goToScreen(state, 'A2_AUTHENTICATING', { flowKind: 'login' }) + + case 'PASSKEY_VERIFIED': + // The provider is known when it was remembered or only one is offered: skip C1 + return event.providerId + ? goToScreen(state, 'C2_RESTORE_PROGRESS', { providerId: event.providerId }) + : goToScreen(state, 'C1_RESTORE_PROVIDER') + + case 'NEW_ACCOUNT_REQUESTED': + return goToScreen(state, 'A3_NEW_ACCOUNT_WARNING', { flowKind: 'new-account' }) + + case 'NEW_ACCOUNT_CONFIRMED': + return goToScreen(state, 'A4_CREATE_PASSKEY', { flowKind: 'new-account' }) + + case 'BACKUP_REQUESTED': + return goToScreen(state, 'A4_CREATE_PASSKEY', { flowKind: 'backup' }) + + case 'PASSKEY_CREATED': + // B2 is skipped only when a single provider is offered; with several the remembered one is just preselected + return event.providerId + ? goToScreen(state, 'B3_BACKUP_PROGRESS', { providerId: event.providerId }) + : goToScreen(state, 'B2_BACKUP_PROVIDER') + + case 'PROVIDER_CHOSEN': + return goToScreen( + state, + state.flowKind === 'login' ? 'C2_RESTORE_PROGRESS' : 'B3_BACKUP_PROGRESS', + { providerId: event.providerId }, + ) + + case 'PROGRESS_ADVANCED': + return { ...state, progressStepIndex: state.progressStepIndex + 1 } + + case 'RESTORE_SUCCEEDED': + return goToScreen(state, 'C3_RESTORE_SUCCESS') + + case 'BACKUP_SUCCEEDED': + return goToScreen(state, 'B4_BACKUP_SUCCESS') + + case 'FAILED': + return { + ...state, + screen: ERROR_SCREEN_BY_CODE[event.errorCode], + errorCode: event.errorCode, + failedScreen: state.screen, + attempt: state.attempt + 1, + progressStepIndex: 0, + } + + case 'RETRY_REQUESTED': + // D2 starts over; D4/D5/D6 re-run the step that failed + if (state.screen === 'D2_PASSKEY_CANCELLED') { + return goToScreen(state, entryScreenOf(state), { flowKind: state.isExistingUser ? 'backup' : 'login' }) + } + return goToScreen(state, state.failedScreen ?? entryScreenOf(state)) + + case 'OTHER_PROVIDER_REQUESTED': + return goToScreen(state, providerChoiceScreenOf(state), { providerId: null }) + + case 'BACK_REQUESTED': + return goToScreen(state, entryScreenOf(state), { flowKind: state.isExistingUser ? 'backup' : 'login' }) + + default: + return state + } +} diff --git a/packages/passkey-login-widget/src/flow/passkeyOperations.test.ts b/packages/passkey-login-widget/src/flow/passkeyOperations.test.ts new file mode 100644 index 00000000..ed078235 --- /dev/null +++ b/packages/passkey-login-widget/src/flow/passkeyOperations.test.ts @@ -0,0 +1,159 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' +import { buildBackupFileName } from '../crypto/backupFileName.ts' +import { BackupDecryptError, BackupFormatError } from '../crypto/backupErrors.ts' +import { addressOfPrivateKey, deriveBackupFileTag } from '../crypto/prfDerivation.ts' +import { ProviderAuthError } from '../storage/storageAdapter.ts' +import { PasskeyFlowError } from '../webauthn/passkeyErrors.ts' +import { createFakePrfOutput, createInMemoryStorage } from '../testFixtures.ts' +import { prepareAccountBackup, restoreAccountFromProvider, uploadPreparedBackup } from './passkeyOperations.ts' + +const RP_ID = 'example.org' +const PRIVATE_KEY = `0x${'44'.repeat(32)}` as const + +/** Backs up PRIVATE_KEY under the given fake passkey and returns what was stored. */ +async function backUpWithPasskey(storage: ReturnType, passkeySeed: number) { + const backup = await prepareAccountBackup({ privateKey: PRIVATE_KEY, prfOutput: createFakePrfOutput(passkeySeed), rpId: RP_ID }) + await uploadPreparedBackup(storage, backup) + return backup +} + +describe('backing up', () => { + it('names the file after the passkey tag and the account address', async () => { + const storage = createInMemoryStorage() + const backup = await backUpWithPasskey(storage, 1) + const prfTag = await deriveBackupFileTag(createFakePrfOutput(1), RP_ID) + assert.equal(backup.fileName, buildBackupFileName(prfTag, addressOfPrivateKey(PRIVATE_KEY))) + assert.deepEqual([...storage.files.keys()], [backup.fileName]) + }) + + it('replaces the file when the same account is backed up again with the same passkey', async () => { + const storage = createInMemoryStorage() + await backUpWithPasskey(storage, 1) + await backUpWithPasskey(storage, 1) + assert.equal(storage.files.size, 1) + }) + + it('creates a separate file for every new passkey (one passkey = one account)', async () => { + const storage = createInMemoryStorage() + await backUpWithPasskey(storage, 1) + await backUpWithPasskey(storage, 2) + assert.equal(storage.files.size, 2) + }) + + it('never uploads the plaintext key', async () => { + const storage = createInMemoryStorage() + await backUpWithPasskey(storage, 1) + for (const [fileName, backupJson] of storage.files) { + assert.ok(!fileName.includes(PRIVATE_KEY.slice(2))) + assert.ok(!backupJson.includes(PRIVATE_KEY.slice(2))) + } + }) + + it('surfaces provider sign-in failures', async () => { + const storage = createInMemoryStorage() + storage.failAuthorization = true + await assert.rejects(backUpWithPasskey(storage, 1), ProviderAuthError) + }) +}) + +describe('restoring', () => { + it('restores the account with the same passkey and reports progress once the file is found', async () => { + const storage = createInMemoryStorage() + await backUpWithPasskey(storage, 1) + let backupFoundCalls = 0 + const restored = await restoreAccountFromProvider({ + provider: storage, + prfOutput: createFakePrfOutput(1), + rpId: RP_ID, + onBackupFound: () => { + backupFoundCalls += 1 + }, + }) + assert.deepEqual(restored, { privateKey: PRIVATE_KEY, address: addressOfPrivateKey(PRIVATE_KEY) }) + assert.equal(backupFoundCalls, 1) + }) + + it('reports BACKUP_NOT_FOUND when there is no file for this passkey', async () => { + const storage = createInMemoryStorage() + await backUpWithPasskey(storage, 1) + await assert.rejects( + restoreAccountFromProvider({ provider: storage, prfOutput: createFakePrfOutput(2), rpId: RP_ID }), + (error: PasskeyFlowError) => error.code === 'BACKUP_NOT_FOUND', + ) + }) + + it('reports BACKUP_NOT_FOUND when the listed file disappears before it is read', async () => { + const storage = createInMemoryStorage() + await backUpWithPasskey(storage, 1) + storage.readBackup = async () => null + await assert.rejects( + restoreAccountFromProvider({ provider: storage, prfOutput: createFakePrfOutput(1), rpId: RP_ID }), + (error: PasskeyFlowError) => error.code === 'BACKUP_NOT_FOUND', + ) + }) + + it('reports AMBIGUOUS_BACKUP when several files share one passkey tag', async () => { + const storage = createInMemoryStorage() + const backup = await backUpWithPasskey(storage, 1) + const prfTag = await deriveBackupFileTag(createFakePrfOutput(1), RP_ID) + storage.files.set(buildBackupFileName(prfTag, addressOfPrivateKey(`0x${'55'.repeat(32)}`)), backup.backupJson) + await assert.rejects( + restoreAccountFromProvider({ provider: storage, prfOutput: createFakePrfOutput(1), rpId: RP_ID }), + (error: PasskeyFlowError) => error.code === 'AMBIGUOUS_BACKUP', + ) + }) + + it('ignores files of other passkeys and foreign names', async () => { + const storage = createInMemoryStorage() + await backUpWithPasskey(storage, 2) + storage.files.set('gwpk-bogus.json', '{}') + await assert.rejects( + restoreAccountFromProvider({ provider: storage, prfOutput: createFakePrfOutput(1), rpId: RP_ID }), + (error: PasskeyFlowError) => error.code === 'BACKUP_NOT_FOUND', + ) + }) + + it('fails to decrypt a file that was renamed to another passkey tag', async () => { + const storage = createInMemoryStorage() + const backup = await backUpWithPasskey(storage, 2) + const prfTagOfPasskeyOne = await deriveBackupFileTag(createFakePrfOutput(1), RP_ID) + storage.files.clear() + storage.files.set(buildBackupFileName(prfTagOfPasskeyOne, addressOfPrivateKey(PRIVATE_KEY)), backup.backupJson) + await assert.rejects( + restoreAccountFromProvider({ provider: storage, prfOutput: createFakePrfOutput(1), rpId: RP_ID }), + BackupDecryptError, + ) + }) + + it('fails when the address in the file name differs from the decrypted account', async () => { + const storage = createInMemoryStorage() + const backup = await backUpWithPasskey(storage, 1) + const prfTag = await deriveBackupFileTag(createFakePrfOutput(1), RP_ID) + storage.files.clear() + storage.files.set(buildBackupFileName(prfTag, addressOfPrivateKey(`0x${'66'.repeat(32)}`)), backup.backupJson) + await assert.rejects( + restoreAccountFromProvider({ provider: storage, prfOutput: createFakePrfOutput(1), rpId: RP_ID }), + BackupDecryptError, + ) + }) + + it('fails with a format error for an unknown backup version', async () => { + const storage = createInMemoryStorage() + const backup = await backUpWithPasskey(storage, 1) + storage.files.set(backup.fileName, JSON.stringify({ ...JSON.parse(backup.backupJson), version: 2 })) + await assert.rejects( + restoreAccountFromProvider({ provider: storage, prfOutput: createFakePrfOutput(1), rpId: RP_ID }), + BackupFormatError, + ) + }) + + it('surfaces provider sign-in failures', async () => { + const storage = createInMemoryStorage() + storage.failAuthorization = true + await assert.rejects( + restoreAccountFromProvider({ provider: storage, prfOutput: createFakePrfOutput(1), rpId: RP_ID }), + ProviderAuthError, + ) + }) +}) diff --git a/packages/passkey-login-widget/src/flow/passkeyOperations.ts b/packages/passkey-login-widget/src/flow/passkeyOperations.ts new file mode 100644 index 00000000..7a0d9b1f --- /dev/null +++ b/packages/passkey-login-widget/src/flow/passkeyOperations.ts @@ -0,0 +1,85 @@ +import { filterBackupNamesByTag, buildBackupFileName, parseBackupFileName } from '../crypto/backupFileName.ts' +import { BackupDecryptError } from '../crypto/backupErrors.ts' +import { createBackupJson, restorePrivateKeyFromBackupJson } from '../crypto/backupFormat.ts' +import type { Bytes } from '../crypto/encoding.ts' +import { addressOfPrivateKey, deriveBackupEncryptionKey, deriveBackupFileTag } from '../crypto/prfDerivation.ts' +import { PASSKEY_ERROR_MESSAGES, PasskeyFlowError } from '../webauthn/passkeyErrors.ts' +import type { HexString, PasskeyBackupStorage } from '../widgetRuntimeContract.ts' + +/** + * UI-free building blocks of the flow: prepare an encrypted backup, upload it, restore it. + * They only ever hand ciphertext to a storage adapter. + */ + +export interface PreparedBackup { + address: HexString + fileName: string + /** Versioned backup JSON; ciphertext only. */ + backupJson: string +} + +export interface RestoredAccount { + privateKey: HexString + address: HexString +} + +/** Encrypts `privateKey` for this passkey and names the file `gwpk--
    .json`. */ +export async function prepareAccountBackup(options: { + privateKey: HexString + prfOutput: Bytes + rpId: string +}): Promise { + const { privateKey, prfOutput, rpId } = options + const address = addressOfPrivateKey(privateKey) + const prfTag = await deriveBackupFileTag(prfOutput, rpId) + const encryptionKey = await deriveBackupEncryptionKey(prfOutput) + return { + address, + fileName: buildBackupFileName(prfTag, address), + backupJson: await createBackupJson(privateKey, encryptionKey), + } +} + +/** Signs in to the provider and writes the prepared backup (replacing a file with the same name). */ +export async function uploadPreparedBackup(provider: PasskeyBackupStorage, backup: PreparedBackup): Promise { + await provider.authorize() + await provider.writeBackup(backup.fileName, backup.backupJson) +} + +/** + * Finds this passkey's backup at the provider and decrypts it. + * 0 matches -> BACKUP_NOT_FOUND, more than 1 -> AMBIGUOUS_BACKUP. The decrypted key must reproduce + * both the address inside the file and the address in the file name, otherwise decrypt fails. + * @param onBackupFound called once the backup file has been located (drives the progress screen) + */ +export async function restoreAccountFromProvider(options: { + provider: PasskeyBackupStorage + prfOutput: Bytes + rpId: string + onBackupFound?: () => void +}): Promise { + const { provider, prfOutput, rpId, onBackupFound } = options + await provider.authorize() + + const prfTag = await deriveBackupFileTag(prfOutput, rpId) + const matchingFileNames = filterBackupNamesByTag(await provider.listBackupNames(prfTag), prfTag) + if (matchingFileNames.length === 0) { + throw new PasskeyFlowError('BACKUP_NOT_FOUND', PASSKEY_ERROR_MESSAGES.BACKUP_NOT_FOUND) + } + if (matchingFileNames.length > 1) { + throw new PasskeyFlowError('AMBIGUOUS_BACKUP', PASSKEY_ERROR_MESSAGES.AMBIGUOUS_BACKUP) + } + const [backupFileName] = matchingFileNames + onBackupFound?.() + + const backupJson = await provider.readBackup(backupFileName) + if (backupJson === null) { + throw new PasskeyFlowError('BACKUP_NOT_FOUND', PASSKEY_ERROR_MESSAGES.BACKUP_NOT_FOUND) + } + const restoredAccount = await restorePrivateKeyFromBackupJson(backupJson, await deriveBackupEncryptionKey(prfOutput)) + // The file name is only a hint; a forged name must not log anyone into a different account + if (parseBackupFileName(backupFileName)?.address !== restoredAccount.address) { + throw new BackupDecryptError('Backup file name does not match the decrypted account') + } + return restoredAccount +} diff --git a/packages/passkey-login-widget/src/index.ts b/packages/passkey-login-widget/src/index.ts new file mode 100644 index 00000000..1d59788f --- /dev/null +++ b/packages/passkey-login-widget/src/index.ts @@ -0,0 +1,25 @@ +export { PasskeyLoginWidget, PasskeyLoginWidgetPreview } from './PasskeyLoginWidget.tsx' +export type { PasskeyLoginWidgetPreviewProps } from './PasskeyLoginWidget.tsx' +export { PasskeyLoginWidgetView } from './components/PasskeyLoginWidgetView.tsx' +export { usePasskeyLoginAdapter } from './adapter.ts' +export type { PasskeyLoginAdapterActions, PasskeyLoginAdapterResult, PasskeyProviderOption } from './adapter.ts' +export { createGoogleDriveStorage } from './storage/googleDriveStorage.ts' +export type { GoogleDriveStorageOptions } from './storage/googleDriveStorage.ts' +export { createICloudStorage } from './storage/iCloudStorage.ts' +export type { ICloudStorageOptions } from './storage/iCloudStorage.ts' +export { ProviderAuthError } from './storage/storageAdapter.ts' +export { createInitialFlowState } from './flow/passkeyFlowMachine.ts' +export type { PasskeyFlowKind, PasskeyFlowState } from './flow/passkeyFlowMachine.ts' +export { passkeyLoginWidgetConfig } from './config.ts' +export { PASSKEY_ERROR_CODES, PASSKEY_SCREEN_IDS } from './widgetRuntimeContract.ts' +export type { + HexString, + PasskeyBackupResult, + PasskeyBackupStorage, + PasskeyErrorCode, + PasskeyLoginErrorDetail, + PasskeyLoginResult, + PasskeyLoginWidgetProps, + PasskeyProviderId, + PasskeyScreenId, +} from './widgetRuntimeContract.ts' diff --git a/packages/passkey-login-widget/src/register.ts b/packages/passkey-login-widget/src/register.ts new file mode 100644 index 00000000..68ed8257 --- /dev/null +++ b/packages/passkey-login-widget/src/register.ts @@ -0,0 +1,20 @@ +import { PasskeyLoginWidgetElement } from './element.ts' + +const DEFAULT_TAG_NAME = 'gw-passkey-login' + +/** + * Register the custom element. + * + * import '@goodwidget/passkey-login-widget/register' + * + * Or register under a custom tag: + * const tag = register('my-passkey-login') + */ +export function register(tagName: string = DEFAULT_TAG_NAME): string { + if (!customElements.get(tagName)) { + customElements.define(tagName, PasskeyLoginWidgetElement) + } + return tagName +} + +register() diff --git a/packages/passkey-login-widget/src/storage/googleDriveStorage.test.ts b/packages/passkey-login-widget/src/storage/googleDriveStorage.test.ts new file mode 100644 index 00000000..3c8f896f --- /dev/null +++ b/packages/passkey-login-widget/src/storage/googleDriveStorage.test.ts @@ -0,0 +1,134 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' +import { createGoogleDriveStorage, GOOGLE_DRIVE_APPDATA_SCOPE } from './googleDriveStorage.ts' +import { ProviderAuthError } from './storageAdapter.ts' + +const OWN_FILE = 'gwpk-abcdefghijklmnopqrstuv-0x1111111111111111111111111111111111111111.json' + +interface RecordedRequest { + url: string + method: string + authorization: string | undefined + body: unknown +} + +/** Fake Drive: answers list requests from `pages` and records every request. */ +function createFakeDrive(pages: { files: { id: string; name: string }[]; nextPageToken?: string }[], fileContents = 'backup-json') { + const requests: RecordedRequest[] = [] + const fetchImplementation: typeof fetch = async (input, init) => { + const url = String(input) + requests.push({ + url, + method: init?.method ?? 'GET', + authorization: (init?.headers as Record | undefined)?.Authorization, + body: init?.body, + }) + if (url.includes('alt=media')) { + return new Response(fileContents) + } + if (url.startsWith('https://www.googleapis.com/drive/v3/files?')) { + const pageToken = new URL(url).searchParams.get('pageToken') + const pageIndex = pageToken ? Number(pageToken) : 0 + return Response.json(pages[pageIndex]) + } + return Response.json({}) + } + return { requests, fetchImplementation } +} + +function createAuthorizedStorage(fetchImplementation: typeof fetch) { + return createGoogleDriveStorage({ + clientId: 'test-client', + requestAccessToken: async () => ({ accessToken: 'token-1', expiresInSeconds: 3600 }), + fetchImplementation, + }) +} + +describe('Google Drive storage', () => { + it('only requests the drive.appdata scope', () => { + assert.equal(GOOGLE_DRIVE_APPDATA_SCOPE, 'https://www.googleapis.com/auth/drive.appdata') + }) + + it('lists only this passkey\'s files from the app data folder, across pages', async () => { + const { requests, fetchImplementation } = createFakeDrive([ + { files: [{ id: '1', name: 'unrelated.txt' }], nextPageToken: '1' }, + { files: [{ id: '2', name: OWN_FILE }, { id: '3', name: 'gwpk-zzzzzzzzzzzzzzzzzzzzzz-0x1111111111111111111111111111111111111111.json' }] }, + ]) + const storage = createAuthorizedStorage(fetchImplementation) + await storage.authorize() + assert.deepEqual(await storage.listBackupNames('abcdefghijklmnopqrstuv'), [OWN_FILE]) + assert.equal(requests.length, 2) + assert.ok(requests.every((request) => new URL(request.url).searchParams.get('spaces') === 'appDataFolder')) + assert.ok(requests.every((request) => request.authorization === 'Bearer token-1')) + }) + + it('reads a file by name and returns null when it does not exist', async () => { + const { requests, fetchImplementation } = createFakeDrive([{ files: [{ id: 'file-7', name: OWN_FILE }] }], 'the-json') + const storage = createAuthorizedStorage(fetchImplementation) + await storage.authorize() + assert.equal(await storage.readBackup(OWN_FILE), 'the-json') + assert.ok(requests.some((request) => request.url.includes('/files/file-7?alt=media'))) + assert.equal(await storage.readBackup('missing.json'), null) + }) + + it('creates the file in appDataFolder when it does not exist', async () => { + const { requests, fetchImplementation } = createFakeDrive([{ files: [] }]) + const storage = createAuthorizedStorage(fetchImplementation) + await storage.authorize() + await storage.writeBackup(OWN_FILE, '{"version":1}') + const createRequest = requests.find((request) => request.method === 'POST') + assert.ok(createRequest?.url.includes('uploadType=multipart')) + assert.ok(String(createRequest?.body).includes(`"parents":["appDataFolder"]`)) + assert.ok(String(createRequest?.body).includes(OWN_FILE)) + }) + + it('updates the existing file instead of creating a duplicate', async () => { + const { requests, fetchImplementation } = createFakeDrive([{ files: [{ id: 'file-7', name: OWN_FILE }] }]) + const storage = createAuthorizedStorage(fetchImplementation) + await storage.authorize() + await storage.writeBackup(OWN_FILE, '{"version":1}') + assert.ok(!requests.some((request) => request.method === 'POST')) + const updateRequest = requests.find((request) => request.method === 'PATCH') + assert.ok(updateRequest?.url.includes('/files/file-7?uploadType=media')) + assert.equal(updateRequest?.body, '{"version":1}') + }) + + it('maps 401 and 403 to ProviderAuthError and other failures to a plain error', async () => { + for (const status of [401, 403]) { + const storage = createAuthorizedStorage(async () => new Response('', { status })) + await storage.authorize() + await assert.rejects(storage.listBackupNames('abcdefghijklmnopqrstuv'), ProviderAuthError) + } + const failingStorage = createAuthorizedStorage(async () => new Response('', { status: 500 })) + await failingStorage.authorize() + await assert.rejects(failingStorage.listBackupNames('abcdefghijklmnopqrstuv'), (error: Error) => !(error instanceof ProviderAuthError)) + }) + + it('requires authorize before any request', async () => { + const storage = createAuthorizedStorage(async () => Response.json({ files: [] })) + await assert.rejects(storage.listBackupNames('abcdefghijklmnopqrstuv'), ProviderAuthError) + }) + + it('surfaces a failed sign-in as ProviderAuthError and reuses a valid token', async () => { + let tokenRequests = 0 + const storage = createGoogleDriveStorage({ + clientId: 'test-client', + requestAccessToken: async () => { + tokenRequests += 1 + return { accessToken: 'token', expiresInSeconds: 3600 } + }, + fetchImplementation: async () => Response.json({ files: [] }), + }) + await storage.authorize() + await storage.authorize() + assert.equal(tokenRequests, 1) + + const refusedStorage = createGoogleDriveStorage({ + clientId: 'test-client', + requestAccessToken: async () => { + throw new ProviderAuthError('closed') + }, + }) + await assert.rejects(refusedStorage.authorize(), ProviderAuthError) + }) +}) diff --git a/packages/passkey-login-widget/src/storage/googleDriveStorage.ts b/packages/passkey-login-widget/src/storage/googleDriveStorage.ts new file mode 100644 index 00000000..bd602e4a --- /dev/null +++ b/packages/passkey-login-widget/src/storage/googleDriveStorage.ts @@ -0,0 +1,200 @@ +import { filterBackupNamesByTag } from '../crypto/backupFileName.ts' +import { loadExternalScript } from './loadExternalScript.ts' +import { ProviderAuthError } from './storageAdapter.ts' +import type { PasskeyBackupStorage } from '../widgetRuntimeContract.ts' + +/** The only OAuth scope requested: files this OAuth client creates in its private app data folder. */ +export const GOOGLE_DRIVE_APPDATA_SCOPE = 'https://www.googleapis.com/auth/drive.appdata' + +const GOOGLE_IDENTITY_SERVICES_URL = 'https://accounts.google.com/gsi/client' +const DRIVE_FILES_URL = 'https://www.googleapis.com/drive/v3/files' +const DRIVE_UPLOAD_URL = 'https://www.googleapis.com/upload/drive/v3/files' +const APP_DATA_FOLDER = 'appDataFolder' +const LIST_PAGE_SIZE = 100 +/** Refresh slightly before the token really expires so a request never races the expiry. */ +const TOKEN_EXPIRY_MARGIN_MS = 60_000 + +export interface GoogleAccessToken { + accessToken: string + expiresInSeconds: number +} + +export interface GoogleDriveStorageOptions { + /** Google OAuth client ID (Web application). Must stay stable: backups live in this client's appDataFolder. */ + clientId: string + /** Test seam: replaces the Google Identity Services popup. */ + requestAccessToken?: () => Promise + /** Test seam: replaces `fetch`. */ + fetchImplementation?: typeof fetch +} + +interface DriveFile { + id: string + name: string +} + +interface GoogleTokenResponse { + access_token?: string + expires_in?: number | string + error?: string +} + +interface GoogleTokenClient { + requestAccessToken: (overrideConfig?: { prompt?: string }) => void +} + +interface GoogleIdentityServices { + accounts: { + oauth2: { + initTokenClient: (config: { + client_id: string + scope: string + callback: (response: GoogleTokenResponse) => void + error_callback: (error: { type?: string }) => void + }) => GoogleTokenClient + } + } +} + +/** Opens the Google consent / account popup and resolves with an access token for `drive.appdata`. */ +async function requestAccessTokenWithGoogleIdentityServices(clientId: string): Promise { + await loadExternalScript(GOOGLE_IDENTITY_SERVICES_URL) + const googleIdentityServices = (window as unknown as { google?: GoogleIdentityServices }).google + if (!googleIdentityServices) { + throw new ProviderAuthError('Google Identity Services is not available') + } + return new Promise((resolve, reject) => { + const tokenClient = googleIdentityServices.accounts.oauth2.initTokenClient({ + client_id: clientId, + scope: GOOGLE_DRIVE_APPDATA_SCOPE, + callback: (response) => { + if (response.error || !response.access_token) { + reject(new ProviderAuthError('Google sign-in was not completed')) + return + } + resolve({ accessToken: response.access_token, expiresInSeconds: Number(response.expires_in ?? 3600) }) + }, + // Popup closed or blocked + error_callback: () => reject(new ProviderAuthError('Google sign-in was cancelled')), + }) + tokenClient.requestAccessToken() + }) +} + +/** + * Google Drive backup storage. Uses only the `drive.appdata` scope and the hidden + * `appDataFolder`; every request goes straight from the browser to Google. + */ +export function createGoogleDriveStorage({ + clientId, + requestAccessToken = () => requestAccessTokenWithGoogleIdentityServices(clientId), + fetchImplementation = (...fetchArguments) => fetch(...fetchArguments), +}: GoogleDriveStorageOptions): PasskeyBackupStorage { + let cachedAccessToken: string | null = null + let cachedAccessTokenExpiresAt = 0 + + /** Drive request with the bearer token; 401/403 become `ProviderAuthError`, other failures a plain Error. */ + async function callDrive(url: string, init: RequestInit = {}): Promise { + if (!cachedAccessToken) { + throw new ProviderAuthError('Not signed in to Google') + } + const response = await fetchImplementation(url, { + ...init, + headers: { ...init.headers, Authorization: `Bearer ${cachedAccessToken}` }, + }) + if (response.status === 401 || response.status === 403) { + cachedAccessToken = null + throw new ProviderAuthError('Google rejected the request') + } + if (!response.ok) { + throw new Error(`Google Drive request failed with status ${response.status}`) + } + return response + } + + /** Lists every file in the app folder, following pagination. */ + async function listAllFiles(): Promise { + const files: DriveFile[] = [] + let pageToken: string | undefined + do { + const query = new URLSearchParams({ + spaces: APP_DATA_FOLDER, + pageSize: String(LIST_PAGE_SIZE), + fields: 'nextPageToken,files(id,name)', + }) + if (pageToken) { + query.set('pageToken', pageToken) + } + const page = (await (await callDrive(`${DRIVE_FILES_URL}?${query}`)).json()) as { + files?: DriveFile[] + nextPageToken?: string + } + files.push(...(page.files ?? [])) + pageToken = page.nextPageToken + } while (pageToken) + return files + } + + return { + id: 'google-drive', + label: 'Google Drive', + + async authorize() { + const tokenStillValid = cachedAccessToken !== null && Date.now() < cachedAccessTokenExpiresAt + if (tokenStillValid) { + return + } + const token = await requestAccessToken() + cachedAccessToken = token.accessToken + cachedAccessTokenExpiresAt = Date.now() + token.expiresInSeconds * 1000 - TOKEN_EXPIRY_MARGIN_MS + }, + + // Drive's `name contains` is token based, so the prefix filter runs client-side + async listBackupNames(prfTag) { + const files = await listAllFiles() + return filterBackupNamesByTag( + files.map((file) => file.name), + prfTag, + ) + }, + + async readBackup(fileName) { + const existingFile = (await listAllFiles()).find((file) => file.name === fileName) + if (!existingFile) { + return null + } + const response = await callDrive(`${DRIVE_FILES_URL}/${encodeURIComponent(existingFile.id)}?alt=media`) + return response.text() + }, + + // Drive allows duplicate names, so look the name up first: update when it exists, create otherwise + async writeBackup(fileName, backupJson) { + const existingFile = (await listAllFiles()).find((file) => file.name === fileName) + if (existingFile) { + await callDrive(`${DRIVE_UPLOAD_URL}/${encodeURIComponent(existingFile.id)}?uploadType=media`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: backupJson, + }) + return + } + const multipartBoundary = `gwpk-${crypto.randomUUID()}` + const multipartBody = [ + `--${multipartBoundary}`, + 'Content-Type: application/json; charset=UTF-8', + '', + JSON.stringify({ name: fileName, parents: [APP_DATA_FOLDER] }), + `--${multipartBoundary}`, + 'Content-Type: application/json', + '', + backupJson, + `--${multipartBoundary}--`, + ].join('\r\n') + await callDrive(`${DRIVE_UPLOAD_URL}?uploadType=multipart`, { + method: 'POST', + headers: { 'Content-Type': `multipart/related; boundary=${multipartBoundary}` }, + body: multipartBody, + }) + }, + } +} diff --git a/packages/passkey-login-widget/src/storage/iCloudStorage.test.ts b/packages/passkey-login-widget/src/storage/iCloudStorage.test.ts new file mode 100644 index 00000000..02db1d14 --- /dev/null +++ b/packages/passkey-login-widget/src/storage/iCloudStorage.test.ts @@ -0,0 +1,169 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' +import { CLOUDKIT_RECORD_TYPE, createICloudStorage, type CloudKitNamespace } from './iCloudStorage.ts' +import { ProviderAuthError } from './storageAdapter.ts' + +const PRF_TAG = 'abcdefghijklmnopqrstuv' +const ADDRESS = '0x1111111111111111111111111111111111111111' +const RECORD_NAME = `gwpk-${PRF_TAG}-${ADDRESS}` + +interface FakeRecord { + recordName: string + recordChangeTag?: string + fields: Record +} + +/** Fake CloudKit JS namespace with a private database held in a Map. */ +function createFakeCloudKit(options: { signedIn?: boolean; queryPageSize?: number } = {}) { + const { signedIn = true, queryPageSize = 100 } = options + const records = new Map() + const savedRecords: unknown[] = [] + const queries: unknown[] = [] + let configuredContainer: unknown = null + const cloudKit: CloudKitNamespace = { + configure(config) { + configuredContainer = config.containers[0] + }, + getDefaultContainer: () => ({ + setUpAuth: async () => (signedIn ? { userRecordName: 'user' } : null), + whenUserSignsIn: async () => ({ userRecordName: 'user' }), + privateCloudDatabase: { + async performQuery(query, queryOptions) { + queries.push(query) + const wantedTag = query.filterBy[0].fieldValue.value + const matching = [...records.values()].filter((record) => record.fields.prfTag.value === wantedTag) + const offset = Number(queryOptions?.continuationMarker ?? 0) + const page = matching.slice(offset, offset + queryPageSize) + const hasMore = offset + queryPageSize < matching.length + return { records: page, continuationMarker: hasMore ? String(offset + queryPageSize) : undefined } + }, + async fetchRecords(recordNames) { + const record = records.get(recordNames[0]) + return record + ? { records: [record] } + : { hasErrors: true, errors: [{ serverErrorCode: 'NOT_FOUND', recordName: recordNames[0] }] } + }, + async saveRecords(record) { + savedRecords.push(record) + const saved = record as FakeRecord + records.set(saved.recordName, { ...saved, recordChangeTag: 'tag-1' }) + return { records: [saved] } + }, + }, + }), + } + return { cloudKit, records, savedRecords, queries, getConfiguredContainer: () => configuredContainer } +} + +describe('iCloud storage', () => { + it('configures the container with the API token and the chosen environment', async () => { + const fake = createFakeCloudKit() + const storage = createICloudStorage({ + containerId: 'iCloud.test', + apiToken: 'api-token', + environment: 'production', + loadCloudKit: async () => fake.cloudKit, + }) + await storage.authorize() + assert.deepEqual(fake.getConfiguredContainer(), { + containerIdentifier: 'iCloud.test', + apiTokenAuth: { apiToken: 'api-token', persist: true }, + environment: 'production', + }) + }) + + it('requires authorize before any request', async () => { + const fake = createFakeCloudKit() + const storage = createICloudStorage({ containerId: 'c', apiToken: 't', loadCloudKit: async () => fake.cloudKit }) + await assert.rejects(storage.listBackupNames(PRF_TAG), ProviderAuthError) + }) + + it('writes a PasskeyBackup record named after the file and reads it back', async () => { + const fake = createFakeCloudKit() + const storage = createICloudStorage({ containerId: 'c', apiToken: 't', loadCloudKit: async () => fake.cloudKit }) + await storage.authorize() + await storage.writeBackup(`${RECORD_NAME}.json`, '{"version":1}') + assert.deepEqual(fake.savedRecords[0], { + recordType: CLOUDKIT_RECORD_TYPE, + recordName: RECORD_NAME, + fields: { prfTag: { value: PRF_TAG }, address: { value: ADDRESS }, payload: { value: '{"version":1}' } }, + }) + assert.equal(await storage.readBackup(`${RECORD_NAME}.json`), '{"version":1}') + assert.equal(await storage.readBackup(`gwpk-${PRF_TAG}-0x2222222222222222222222222222222222222222.json`), null) + }) + + it('replaces an existing record using its change tag', async () => { + const fake = createFakeCloudKit() + const storage = createICloudStorage({ containerId: 'c', apiToken: 't', loadCloudKit: async () => fake.cloudKit }) + await storage.authorize() + await storage.writeBackup(`${RECORD_NAME}.json`, 'first') + await storage.writeBackup(`${RECORD_NAME}.json`, 'second') + assert.equal(fake.records.size, 1) + assert.equal((fake.savedRecords[1] as FakeRecord).recordChangeTag, 'tag-1') + assert.equal(await storage.readBackup(`${RECORD_NAME}.json`), 'second') + }) + + it('lists by prfTag with a server-side query and follows pagination', async () => { + const fake = createFakeCloudKit({ queryPageSize: 1 }) + const storage = createICloudStorage({ containerId: 'c', apiToken: 't', loadCloudKit: async () => fake.cloudKit }) + await storage.authorize() + const secondAddress = '0x2222222222222222222222222222222222222222' + await storage.writeBackup(`${RECORD_NAME}.json`, 'a') + await storage.writeBackup(`gwpk-${PRF_TAG}-${secondAddress}.json`, 'b') + await storage.writeBackup(`gwpk-zzzzzzzzzzzzzzzzzzzzzz-${ADDRESS}.json`, 'c') + assert.deepEqual((await storage.listBackupNames(PRF_TAG)).sort(), [`${RECORD_NAME}.json`, `gwpk-${PRF_TAG}-${secondAddress}.json`]) + assert.equal(fake.queries.length, 2) + }) + + it('signs in through the Apple button when there is no session', async () => { + const fake = createFakeCloudKit({ signedIn: false }) + const appendedElements: unknown[] = [] + const originalDocument = (globalThis as { document?: unknown }).document + ;(globalThis as { document?: unknown }).document = { + createElement: () => ({ style: {}, appendChild: (child: unknown) => appendedElements.push(child), remove: () => appendedElements.splice(0) }), + body: { appendChild: (element: unknown) => appendedElements.push(element) }, + } + try { + const storage = createICloudStorage({ containerId: 'c', apiToken: 't', loadCloudKit: async () => fake.cloudKit }) + await storage.authorize() + await storage.writeBackup(`${RECORD_NAME}.json`, 'a') + assert.equal(appendedElements.length, 0, 'the sign-in overlay is removed again') + } finally { + ;(globalThis as { document?: unknown }).document = originalDocument + } + }) + + it('maps authentication errors to ProviderAuthError and other errors to a plain error', async () => { + const fake = createFakeCloudKit() + const container = fake.cloudKit.getDefaultContainer() + const failingDatabase = container.privateCloudDatabase + const authFailingCloudKit: CloudKitNamespace = { + configure: () => undefined, + getDefaultContainer: () => ({ + ...container, + privateCloudDatabase: { ...failingDatabase, performQuery: async () => ({ hasErrors: true, errors: [{ serverErrorCode: 'AUTHENTICATION_REQUIRED' }] }) }, + }), + } + const storage = createICloudStorage({ containerId: 'c', apiToken: 't', loadCloudKit: async () => authFailingCloudKit }) + await storage.authorize() + await assert.rejects(storage.listBackupNames(PRF_TAG), ProviderAuthError) + + const otherFailingCloudKit: CloudKitNamespace = { + configure: () => undefined, + getDefaultContainer: () => ({ + ...container, + privateCloudDatabase: { ...failingDatabase, performQuery: async () => ({ hasErrors: true, errors: [{ serverErrorCode: 'BAD_REQUEST' }] }) }, + }), + } + const otherStorage = createICloudStorage({ containerId: 'c', apiToken: 't', loadCloudKit: async () => otherFailingCloudKit }) + await otherStorage.authorize() + await assert.rejects(otherStorage.listBackupNames(PRF_TAG), (error: Error) => !(error instanceof ProviderAuthError)) + }) + + it('rejects names that are not backup file names', async () => { + const fake = createFakeCloudKit() + const storage = createICloudStorage({ containerId: 'c', apiToken: 't', loadCloudKit: async () => fake.cloudKit }) + await storage.authorize() + await assert.rejects(storage.writeBackup('other.json', 'x')) + }) +}) diff --git a/packages/passkey-login-widget/src/storage/iCloudStorage.ts b/packages/passkey-login-widget/src/storage/iCloudStorage.ts new file mode 100644 index 00000000..c772269b --- /dev/null +++ b/packages/passkey-login-widget/src/storage/iCloudStorage.ts @@ -0,0 +1,209 @@ +import { filterBackupNamesByTag, parseBackupFileName } from '../crypto/backupFileName.ts' +import { loadExternalScript } from './loadExternalScript.ts' +import { ProviderAuthError } from './storageAdapter.ts' +import type { PasskeyBackupStorage } from '../widgetRuntimeContract.ts' + +const CLOUDKIT_JS_URL = 'https://cdn.apple-cloudkit.com/ck/2/cloudkit.js' +/** CloudKit record type; it and a queryable index on `prfTag` must exist in the container (see QA.md). */ +export const CLOUDKIT_RECORD_TYPE = 'PasskeyBackup' +const APPLE_SIGN_IN_BUTTON_ELEMENT_ID = 'apple-sign-in-button' +const BACKUP_FILE_EXTENSION = '.json' + +export type ICloudEnvironment = 'development' | 'production' + +interface CloudKitRecord { + recordName: string + recordType?: string + recordChangeTag?: string + fields?: Record +} + +interface CloudKitServerError { + serverErrorCode?: string + recordName?: string +} + +interface CloudKitResponse { + hasErrors?: boolean + errors?: CloudKitServerError[] + records?: CloudKitRecord[] + continuationMarker?: string +} + +interface CloudKitDatabase { + performQuery( + query: { + recordType: string + filterBy: { fieldName: string; comparator: string; fieldValue: { value: string } }[] + }, + options?: { continuationMarker?: string }, + ): Promise + fetchRecords(recordNames: string[]): Promise + saveRecords(records: CloudKitRecord | CloudKitRecord[]): Promise +} + +export interface CloudKitContainer { + setUpAuth(): Promise + whenUserSignsIn(): Promise + privateCloudDatabase: CloudKitDatabase +} + +export interface CloudKitNamespace { + configure(config: { + containers: { + containerIdentifier: string + apiTokenAuth: { apiToken: string; persist: boolean } + environment: ICloudEnvironment + }[] + }): void + getDefaultContainer(): CloudKitContainer +} + +export interface ICloudStorageOptions { + /** CloudKit container identifier, e.g. `iCloud.org.gooddollar.passkey`. */ + containerId: string + /** CloudKit API token (public by design; restricted to allowed origins in the CloudKit Dashboard). */ + apiToken: string + environment?: ICloudEnvironment + /** Test seam: replaces loading CloudKit JS from Apple's CDN. */ + loadCloudKit?: () => Promise +} + +async function loadCloudKitFromAppleCdn(): Promise { + await loadExternalScript(CLOUDKIT_JS_URL) + const cloudKit = (window as unknown as { CloudKit?: CloudKitNamespace }).CloudKit + if (!cloudKit) { + throw new ProviderAuthError('CloudKit JS is not available') + } + return cloudKit +} + +/** + * CloudKit renders its own Apple sign-in button, and sign-in must start from a click on it. + * Shows that button in a small overlay and resolves once the user has signed in. + */ +async function signInWithAppleButton(container: CloudKitContainer): Promise { + const overlayElement = document.createElement('div') + overlayElement.style.cssText = + 'position:fixed;inset:0;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.5);z-index:2147483647' + const buttonElement = document.createElement('div') + buttonElement.id = APPLE_SIGN_IN_BUTTON_ELEMENT_ID + overlayElement.appendChild(buttonElement) + document.body.appendChild(overlayElement) + try { + await container.setUpAuth() + await container.whenUserSignsIn() + } finally { + overlayElement.remove() + } +} + +/** Throws `ProviderAuthError` for authentication problems and a plain Error for any other CloudKit failure. */ +function assertCloudKitResponseOk(response: CloudKitResponse): void { + if (!response.hasErrors) { + return + } + const errorCodes = (response.errors ?? []).map((error) => error.serverErrorCode) + if (errorCodes.some((code) => code === 'AUTHENTICATION_REQUIRED' || code === 'AUTHENTICATION_FAILED')) { + throw new ProviderAuthError('iCloud sign-in is required') + } + throw new Error(`CloudKit request failed: ${errorCodes.join(', ')}`) +} + +/** + * iCloud backup storage on CloudKit JS (private database). The backup of a passkey is + * one `PasskeyBackup` record whose `recordName` is the backup file name without `.json`. + */ +export function createICloudStorage({ + containerId, + apiToken, + environment = 'development', + loadCloudKit = loadCloudKitFromAppleCdn, +}: ICloudStorageOptions): PasskeyBackupStorage { + let signedInContainer: CloudKitContainer | null = null + + function requireDatabase(): CloudKitDatabase { + if (!signedInContainer) { + throw new ProviderAuthError('Not signed in to iCloud') + } + return signedInContainer.privateCloudDatabase + } + + /** Fetches one record; null when it does not exist. */ + async function fetchRecordOrNull(recordName: string): Promise { + const response = await requireDatabase().fetchRecords([recordName]) + const notFound = (response.errors ?? []).some((error) => error.serverErrorCode === 'NOT_FOUND') + if (response.hasErrors && !notFound) { + assertCloudKitResponseOk(response) + } + return response.records?.find((record) => record.recordName === recordName && record.fields) ?? null + } + + return { + id: 'icloud', + label: 'iCloud', + + async authorize() { + if (signedInContainer) { + return + } + const cloudKit = await loadCloudKit() + cloudKit.configure({ + containers: [{ containerIdentifier: containerId, apiTokenAuth: { apiToken, persist: true }, environment }], + }) + const container = cloudKit.getDefaultContainer() + const userIdentity = await container.setUpAuth() + if (!userIdentity) { + await signInWithAppleButton(container) + } + signedInContainer = container + }, + + // The `prfTag` field has a queryable index, so the lookup is a server-side query + async listBackupNames(prfTag) { + const database = requireDatabase() + const fileNames: string[] = [] + let continuationMarker: string | undefined + do { + const response = await database.performQuery( + { + recordType: CLOUDKIT_RECORD_TYPE, + filterBy: [{ fieldName: 'prfTag', comparator: 'EQUALS', fieldValue: { value: prfTag } }], + }, + continuationMarker ? { continuationMarker } : undefined, + ) + assertCloudKitResponseOk(response) + fileNames.push(...(response.records ?? []).map((record) => `${record.recordName}${BACKUP_FILE_EXTENSION}`)) + continuationMarker = response.continuationMarker + } while (continuationMarker) + return filterBackupNamesByTag(fileNames, prfTag) + }, + + async readBackup(fileName) { + const record = await fetchRecordOrNull(fileName.slice(0, -BACKUP_FILE_EXTENSION.length)) + const payload = record?.fields?.payload?.value + return typeof payload === 'string' ? payload : null + }, + + // Saving by recordName replaces the backup; an existing record must be saved with its change tag + async writeBackup(fileName, backupJson) { + const parsedName = parseBackupFileName(fileName) + if (!parsedName) { + throw new Error('Invalid backup file name') + } + const recordName = fileName.slice(0, -BACKUP_FILE_EXTENSION.length) + const existingRecord = await fetchRecordOrNull(recordName) + const response = await requireDatabase().saveRecords({ + recordType: CLOUDKIT_RECORD_TYPE, + recordName, + ...(existingRecord?.recordChangeTag ? { recordChangeTag: existingRecord.recordChangeTag } : {}), + fields: { + prfTag: { value: parsedName.prfTag }, + address: { value: parsedName.address }, + payload: { value: backupJson }, + }, + }) + assertCloudKitResponseOk(response) + }, + } +} diff --git a/packages/passkey-login-widget/src/storage/loadExternalScript.ts b/packages/passkey-login-widget/src/storage/loadExternalScript.ts new file mode 100644 index 00000000..c2dcc4ae --- /dev/null +++ b/packages/passkey-login-widget/src/storage/loadExternalScript.ts @@ -0,0 +1,23 @@ +const pendingScriptLoads = new Map>() + +/** Loads a third-party browser SDK once per page and resolves when it is ready. */ +export function loadExternalScript(scriptUrl: string): Promise { + const pendingLoad = pendingScriptLoads.get(scriptUrl) + if (pendingLoad) { + return pendingLoad + } + const scriptLoad = new Promise((resolve, reject) => { + const scriptElement = document.createElement('script') + scriptElement.src = scriptUrl + scriptElement.async = true + scriptElement.onload = () => resolve() + scriptElement.onerror = () => { + // Allow a later attempt to retry after a network failure + pendingScriptLoads.delete(scriptUrl) + reject(new Error(`Could not load ${scriptUrl}`)) + } + document.head.appendChild(scriptElement) + }) + pendingScriptLoads.set(scriptUrl, scriptLoad) + return scriptLoad +} diff --git a/packages/passkey-login-widget/src/storage/rememberedProvider.test.ts b/packages/passkey-login-widget/src/storage/rememberedProvider.test.ts new file mode 100644 index 00000000..c5d15745 --- /dev/null +++ b/packages/passkey-login-widget/src/storage/rememberedProvider.test.ts @@ -0,0 +1,42 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' +import { createInMemoryStorage } from '../testFixtures.ts' +import { REMEMBERED_PROVIDER_STORAGE_KEY, readRememberedProviderId, rememberProviderId } from './rememberedProvider.ts' + +function createFakeBrowserStorage() { + const values = new Map() + return { + values, + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => { + values.set(key, value) + }, + } +} + +describe('remembered provider', () => { + const offeredProviders = [createInMemoryStorage('google-drive'), createInMemoryStorage('icloud')] + + it('stores only the provider id', () => { + const browserStorage = createFakeBrowserStorage() + rememberProviderId('icloud', browserStorage) + assert.deepEqual([...browserStorage.values], [[REMEMBERED_PROVIDER_STORAGE_KEY, 'icloud']]) + }) + + it('returns the remembered id only while that provider is still offered', () => { + const browserStorage = createFakeBrowserStorage() + rememberProviderId('icloud', browserStorage) + assert.equal(readRememberedProviderId(offeredProviders, browserStorage), 'icloud') + assert.equal(readRememberedProviderId([offeredProviders[0]], browserStorage), null) + }) + + it('returns null when nothing is remembered or storage is blocked', () => { + assert.equal(readRememberedProviderId(offeredProviders, createFakeBrowserStorage()), null) + const blockedStorage = { + getItem: () => { + throw new Error('blocked') + }, + } + assert.equal(readRememberedProviderId(offeredProviders, blockedStorage), null) + }) +}) diff --git a/packages/passkey-login-widget/src/storage/rememberedProvider.ts b/packages/passkey-login-widget/src/storage/rememberedProvider.ts new file mode 100644 index 00000000..cce8a7b8 --- /dev/null +++ b/packages/passkey-login-widget/src/storage/rememberedProvider.ts @@ -0,0 +1,32 @@ +import type { PasskeyBackupStorage, PasskeyProviderId } from '../widgetRuntimeContract.ts' + +/** localStorage key holding the id (and only the id) of the last provider used successfully. */ +export const REMEMBERED_PROVIDER_STORAGE_KEY = 'goodwidget.passkey-login.provider' + +/** + * Returns the remembered provider id when it is still one of the offered providers. + * Browsers that block localStorage simply behave as "nothing remembered". + */ +export function readRememberedProviderId( + offeredProviders: PasskeyBackupStorage[], + browserStorage: Pick | undefined = globalThis.localStorage, +): PasskeyProviderId | null { + try { + const rememberedId = browserStorage?.getItem(REMEMBERED_PROVIDER_STORAGE_KEY) + return offeredProviders.find((provider) => provider.id === rememberedId)?.id ?? null + } catch { + return null + } +} + +/** Remembers the provider id so the next restore can skip the provider choice. Failures are ignored. */ +export function rememberProviderId( + providerId: PasskeyProviderId, + browserStorage: Pick | undefined = globalThis.localStorage, +): void { + try { + browserStorage?.setItem(REMEMBERED_PROVIDER_STORAGE_KEY, providerId) + } catch { + // Remembering the provider is a convenience; the flow works without it. + } +} diff --git a/packages/passkey-login-widget/src/storage/storageAdapter.ts b/packages/passkey-login-widget/src/storage/storageAdapter.ts new file mode 100644 index 00000000..933afb55 --- /dev/null +++ b/packages/passkey-login-widget/src/storage/storageAdapter.ts @@ -0,0 +1,15 @@ +export type { + PasskeyBackupStorage, + PasskeyProviderId, +} from '../widgetRuntimeContract.ts' + +/** + * Thrown by storage adapters when provider sign-in, consent or a request authorization fails. + * Maps to the D4 screen / `PROVIDER_AUTH_FAILED`. + */ +export class ProviderAuthError extends Error { + constructor(message: string) { + super(message) + this.name = 'ProviderAuthError' + } +} diff --git a/packages/passkey-login-widget/src/testFixtures.ts b/packages/passkey-login-widget/src/testFixtures.ts new file mode 100644 index 00000000..edf196ca --- /dev/null +++ b/packages/passkey-login-widget/src/testFixtures.ts @@ -0,0 +1,48 @@ +import type { Bytes } from './crypto/encoding.ts' +import { ProviderAuthError } from './storage/storageAdapter.ts' +import type { PasskeyBackupStorage, PasskeyProviderId } from './widgetRuntimeContract.ts' + +/** Deterministic stand-in for a passkey PRF output; `seed` makes a different "passkey". */ +export function createFakePrfOutput(seed: number): Bytes { + return Uint8Array.from({ length: 32 }, (_, index) => (index * 7 + seed) % 256) +} + +export interface InMemoryStorageControls { + /** File name -> backup JSON currently stored. */ + files: Map + /** Make `authorize` fail with a provider auth error. */ + failAuthorization: boolean + /** Make `writeBackup` fail with a plain error. */ + failWrites: boolean +} + +/** In-memory `PasskeyBackupStorage` used by unit tests; behaves like a provider with an exact name lookup. */ +export function createInMemoryStorage( + id: PasskeyProviderId = 'google-drive', +): PasskeyBackupStorage & InMemoryStorageControls { + const storage: PasskeyBackupStorage & InMemoryStorageControls = { + id, + label: id === 'google-drive' ? 'Google Drive' : 'iCloud', + files: new Map(), + failAuthorization: false, + failWrites: false, + async authorize() { + if (storage.failAuthorization) { + throw new ProviderAuthError('sign-in failed') + } + }, + async listBackupNames(prfTag) { + return [...storage.files.keys()].filter((name) => name.startsWith(`gwpk-${prfTag}-`)) + }, + async readBackup(fileName) { + return storage.files.get(fileName) ?? null + }, + async writeBackup(fileName, backupJson) { + if (storage.failWrites) { + throw new Error('write failed') + } + storage.files.set(fileName, backupJson) + }, + } + return storage +} diff --git a/packages/passkey-login-widget/src/webauthn/passkeyClient.ts b/packages/passkey-login-widget/src/webauthn/passkeyClient.ts new file mode 100644 index 00000000..e488f20b --- /dev/null +++ b/packages/passkey-login-widget/src/webauthn/passkeyClient.ts @@ -0,0 +1,111 @@ +import type { Bytes } from '../crypto/encoding.ts' +import { PRF_SALT_TEXT } from '../crypto/prfDerivation.ts' +import { PASSKEY_ERROR_MESSAGES, PasskeyFlowError } from './passkeyErrors.ts' + +const PRF_SALT = new TextEncoder().encode(PRF_SALT_TEXT) + +/** ES256 and RS256: what every platform authenticator supports. */ +const SUPPORTED_SIGNATURE_ALGORITHMS: PublicKeyCredentialParameters[] = [ + { type: 'public-key', alg: -7 }, + { type: 'public-key', alg: -257 }, +] + +export interface CreatePasskeyOptions { + rpId: string + appName: string +} + +export interface PasskeyPrfResult { + /** Secret PRF output. Keep in memory only; never log, store or return to the host. */ + prfOutput: Bytes +} + +/** + * Whether this browser can run the flow. WebAuthn + (when the browser can tell) the PRF extension. + * Browsers without `getClientCapabilities` are confirmed at registration instead. + */ +export async function isPasskeyPrfSupported(): Promise { + if (typeof PublicKeyCredential === 'undefined' || !navigator.credentials) { + return false + } + const getClientCapabilities = ( + PublicKeyCredential as unknown as { getClientCapabilities?: () => Promise> } + ).getClientCapabilities + if (typeof getClientCapabilities !== 'function') { + return true + } + const capabilities = await getClientCapabilities.call(PublicKeyCredential) + return capabilities['extension:prf'] !== false +} + +/** Reads `prf.results.first` from a credential's extension results; null when the authenticator gave none. */ +function readPrfOutput(credential: PublicKeyCredential): Bytes | null { + const prfResults = (credential.getClientExtensionResults() as { prf?: { results?: { first?: BufferSource } } }).prf + ?.results + if (!prfResults?.first) { + return null + } + const first = prfResults.first + // Copy out of the view so offsets of a larger underlying buffer are not leaked into the result + return ArrayBuffer.isView(first) + ? new Uint8Array(first.buffer.slice(first.byteOffset, first.byteOffset + first.byteLength)) + : new Uint8Array(first) +} + +/** + * Asks the user for an existing discoverable passkey of this rpId and returns its PRF output. + * @param rpId relying party id + * @param credentialId limit the request to this credential (used right after creating a passkey) + */ +export async function authenticateWithPasskey(rpId: string, credentialId?: ArrayBuffer): Promise { + const assertion = (await navigator.credentials.get({ + publicKey: { + rpId, + // No server: the challenge only has to be fresh. The assertion itself is never verified. + challenge: crypto.getRandomValues(new Uint8Array(32)), + userVerification: 'required', + allowCredentials: credentialId ? [{ type: 'public-key', id: credentialId }] : [], + extensions: { prf: { eval: { first: PRF_SALT } } } as AuthenticationExtensionsClientInputs, + }, + })) as PublicKeyCredential | null + if (!assertion) { + throw new PasskeyFlowError('PASSKEY_CANCELLED', PASSKEY_ERROR_MESSAGES.PASSKEY_CANCELLED) + } + const prfOutput = readPrfOutput(assertion) + if (!prfOutput) { + throw new PasskeyFlowError('UNSUPPORTED_BROWSER', PASSKEY_ERROR_MESSAGES.UNSUPPORTED_BROWSER) + } + return { prfOutput } +} + +/** + * Creates a NEW discoverable passkey and returns its PRF output. + * When the authenticator only reports `prf.enabled` at registration, a follow-up + * assertion fetches the output. When PRF is unsupported nothing is kept and D1 is raised. + */ +export async function createPasskey({ rpId, appName }: CreatePasskeyOptions): Promise { + const accountLabel = `${appName} ${new Date().toISOString().slice(0, 10)}` + const credential = (await navigator.credentials.create({ + publicKey: { + rp: { id: rpId, name: appName }, + user: { id: crypto.getRandomValues(new Uint8Array(16)), name: accountLabel, displayName: accountLabel }, + // No server: the challenge only has to be fresh. The attestation is never verified. + challenge: crypto.getRandomValues(new Uint8Array(32)), + pubKeyCredParams: SUPPORTED_SIGNATURE_ALGORITHMS, + authenticatorSelection: { residentKey: 'required', userVerification: 'required' }, + extensions: { prf: { eval: { first: PRF_SALT } } } as AuthenticationExtensionsClientInputs, + }, + })) as PublicKeyCredential | null + if (!credential) { + throw new PasskeyFlowError('PASSKEY_CANCELLED', PASSKEY_ERROR_MESSAGES.PASSKEY_CANCELLED) + } + const prfOutputAtRegistration = readPrfOutput(credential) + if (prfOutputAtRegistration) { + return { prfOutput: prfOutputAtRegistration } + } + const prfExtension = (credential.getClientExtensionResults() as { prf?: { enabled?: boolean } }).prf + if (!prfExtension?.enabled) { + throw new PasskeyFlowError('UNSUPPORTED_BROWSER', PASSKEY_ERROR_MESSAGES.UNSUPPORTED_BROWSER) + } + return authenticateWithPasskey(rpId, credential.rawId) +} diff --git a/packages/passkey-login-widget/src/webauthn/passkeyErrors.test.ts b/packages/passkey-login-widget/src/webauthn/passkeyErrors.test.ts new file mode 100644 index 00000000..28b7467a --- /dev/null +++ b/packages/passkey-login-widget/src/webauthn/passkeyErrors.test.ts @@ -0,0 +1,40 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' +import { BackupDecryptError, BackupFormatError } from '../crypto/backupErrors.ts' +import { ProviderAuthError } from '../storage/storageAdapter.ts' +import { PASSKEY_ERROR_MESSAGES, PasskeyFlowError, toPasskeyFlowError } from './passkeyErrors.ts' +import { PASSKEY_ERROR_CODES } from '../widgetRuntimeContract.ts' + +describe('toPasskeyFlowError', () => { + it('keeps errors that already carry a code', () => { + const original = new PasskeyFlowError('BACKUP_NOT_FOUND', 'x') + assert.equal(toPasskeyFlowError(original, 'BACKUP_FAILED'), original) + }) + + it('maps provider sign-in failures to D4', () => { + assert.equal(toPasskeyFlowError(new ProviderAuthError('no'), 'BACKUP_FAILED').code, 'PROVIDER_AUTH_FAILED') + }) + + it('maps decrypt and format failures to D6', () => { + assert.equal(toPasskeyFlowError(new BackupDecryptError('x'), 'BACKUP_FAILED').code, 'DECRYPT_FAILED') + assert.equal(toPasskeyFlowError(new BackupFormatError('x'), 'BACKUP_FAILED').code, 'DECRYPT_FAILED') + }) + + it('maps WebAuthn dialog dismissal to D2 and missing support to D1', () => { + assert.equal(toPasskeyFlowError(new DOMException('x', 'NotAllowedError'), 'BACKUP_FAILED').code, 'PASSKEY_CANCELLED') + assert.equal(toPasskeyFlowError(new DOMException('x', 'AbortError'), 'BACKUP_FAILED').code, 'PASSKEY_CANCELLED') + assert.equal(toPasskeyFlowError(new DOMException('x', 'NotSupportedError'), 'BACKUP_FAILED').code, 'UNSUPPORTED_BROWSER') + }) + + it('uses the fallback code for unknown errors and never leaks their message', () => { + const flowError = toPasskeyFlowError(new Error(`secret ${'0x11'.repeat(16)}`), 'BACKUP_FAILED') + assert.equal(flowError.code, 'BACKUP_FAILED') + assert.equal(flowError.message, PASSKEY_ERROR_MESSAGES.BACKUP_FAILED) + }) + + it('has a message for every error code', () => { + for (const code of PASSKEY_ERROR_CODES) { + assert.ok(PASSKEY_ERROR_MESSAGES[code].length > 0) + } + }) +}) diff --git a/packages/passkey-login-widget/src/webauthn/passkeyErrors.ts b/packages/passkey-login-widget/src/webauthn/passkeyErrors.ts new file mode 100644 index 00000000..2891afe3 --- /dev/null +++ b/packages/passkey-login-widget/src/webauthn/passkeyErrors.ts @@ -0,0 +1,52 @@ +import { BackupDecryptError, BackupFormatError } from '../crypto/backupErrors.ts' +import { ProviderAuthError } from '../storage/storageAdapter.ts' +import type { PasskeyErrorCode } from '../widgetRuntimeContract.ts' + +/** An error that already knows which D screen / `onError` code it belongs to. */ +export class PasskeyFlowError extends Error { + code: PasskeyErrorCode + + constructor(code: PasskeyErrorCode, message: string) { + super(message) + this.name = 'PasskeyFlowError' + this.code = code + } +} + +/** User-facing messages per code. They never include key material. */ +export const PASSKEY_ERROR_MESSAGES: Record = { + UNSUPPORTED_BROWSER: "This browser doesn't support passkey login.", + PASSKEY_CANCELLED: 'The passkey request was cancelled or no passkey was found.', + BACKUP_NOT_FOUND: 'No backup was found for this passkey.', + PROVIDER_AUTH_FAILED: "Couldn't connect to the backup provider.", + BACKUP_FAILED: 'The backup could not be written.', + DECRYPT_FAILED: "The backup couldn't be unlocked with this passkey.", + AMBIGUOUS_BACKUP: 'More than one backup was found for this passkey.', +} + +/** + * Maps anything thrown while running the flow to a `PasskeyFlowError`. + * WebAuthn cannot tell "no passkey" from "cancelled", so both are `PASSKEY_CANCELLED`. + * @param error the thrown value + * @param fallbackCode code to use when the error is not one of the known kinds + */ +export function toPasskeyFlowError(error: unknown, fallbackCode: PasskeyErrorCode): PasskeyFlowError { + if (error instanceof PasskeyFlowError) { + return error + } + if (error instanceof ProviderAuthError) { + return new PasskeyFlowError('PROVIDER_AUTH_FAILED', PASSKEY_ERROR_MESSAGES.PROVIDER_AUTH_FAILED) + } + if (error instanceof BackupDecryptError || error instanceof BackupFormatError) { + return new PasskeyFlowError('DECRYPT_FAILED', PASSKEY_ERROR_MESSAGES.DECRYPT_FAILED) + } + if (error instanceof DOMException) { + if (error.name === 'NotSupportedError') { + return new PasskeyFlowError('UNSUPPORTED_BROWSER', PASSKEY_ERROR_MESSAGES.UNSUPPORTED_BROWSER) + } + if (error.name === 'NotAllowedError' || error.name === 'AbortError') { + return new PasskeyFlowError('PASSKEY_CANCELLED', PASSKEY_ERROR_MESSAGES.PASSKEY_CANCELLED) + } + } + return new PasskeyFlowError(fallbackCode, PASSKEY_ERROR_MESSAGES[fallbackCode]) +} diff --git a/packages/passkey-login-widget/src/widgetRuntimeContract.ts b/packages/passkey-login-widget/src/widgetRuntimeContract.ts new file mode 100644 index 00000000..5a47fc88 --- /dev/null +++ b/packages/passkey-login-widget/src/widgetRuntimeContract.ts @@ -0,0 +1,106 @@ +import type { GoodWidgetConfig, GoodWidgetThemeOverrides } from '@goodwidget/core' + +/** 0x-prefixed hex string (private key or public address). */ +export type HexString = `0x${string}` + +/** Backup providers the widget knows how to talk to. */ +export type PasskeyProviderId = 'google-drive' | 'icloud' + +/** + * Error codes reported through `onError`. Each maps to one of the D screens. + * `AMBIGUOUS_BACKUP` reuses the D6 layout with its own copy. + */ +export const PASSKEY_ERROR_CODES = [ + 'UNSUPPORTED_BROWSER', // D1 + 'PASSKEY_CANCELLED', // D2 + 'BACKUP_NOT_FOUND', // D3 + 'PROVIDER_AUTH_FAILED', // D4 + 'BACKUP_FAILED', // D5 + 'DECRYPT_FAILED', // D6 + 'AMBIGUOUS_BACKUP', // D6 layout, different copy +] as const +export type PasskeyErrorCode = (typeof PASSKEY_ERROR_CODES)[number] + +/** Error payload for `onError`. Never contains key material. */ +export interface PasskeyLoginErrorDetail { + code: PasskeyErrorCode + message: string +} + +/** Payload for `onLogin`. This is the only callback that carries the private key. */ +export interface PasskeyLoginResult { + privateKey: HexString + address: HexString + isNewAccount: boolean +} + +/** Payload for `onBackupComplete`. Never contains the private key. */ +export interface PasskeyBackupResult { + address: HexString + provider: PasskeyProviderId +} + +/** + * Backup store (Google Drive, iCloud, or a test double). Adapters move an opaque + * JSON string and know nothing about keys. + */ +export interface PasskeyBackupStorage { + id: PasskeyProviderId + /** Human-readable provider name shown on the provider tiles. */ + label: string + /** Provider sign-in / consent. Throws `ProviderAuthError` when it fails. */ + authorize(): Promise + /** Names of backup files starting with `gwpk--`; empty when there are none. */ + listBackupNames(prfTag: string): Promise + /** Raw backup JSON, or null when the file is gone. */ + readBackup(fileName: string): Promise + /** Creates the file, or replaces the file that already has this name. */ + writeBackup(fileName: string, backupJson: string): Promise +} + +/** Screens of the flow. The ids follow the A1-D6 designs. */ +export const PASSKEY_SCREEN_IDS = [ + 'A1_ENTRY', + 'A2_AUTHENTICATING', + 'A3_NEW_ACCOUNT_WARNING', + 'A4_CREATE_PASSKEY', + 'B1_BACKUP_PROMPT', + 'B2_BACKUP_PROVIDER', + 'B3_BACKUP_PROGRESS', + 'B4_BACKUP_SUCCESS', + 'C1_RESTORE_PROVIDER', + 'C2_RESTORE_PROGRESS', + 'C3_RESTORE_SUCCESS', + 'D1_UNSUPPORTED_BROWSER', + 'D2_PASSKEY_CANCELLED', + 'D3_BACKUP_NOT_FOUND', + 'D4_PROVIDER_AUTH_FAILED', + 'D5_BACKUP_FAILED', + 'D6_DECRYPT_FAILED', +] as const +export type PasskeyScreenId = (typeof PASSKEY_SCREEN_IDS)[number] + +export interface PasskeyLoginWidgetProps { + /** + * Existing user: key of the account that is already logged in. Enables the backup + * flow (B). Omitted: login / create flow (A, C). A secret: on the custom element it + * is a JS property only, never an HTML attribute. + */ + privateKey?: HexString + /** Fired when the user presses Continue after a successful login or account creation. */ + onLogin?: (result: PasskeyLoginResult) => void + onBackupComplete?: (result: PasskeyBackupResult) => void + /** "Not now" (B1), "Skip for now" (D5, existing users) and "Use another login method" (A1, A3, D1). */ + onSkip?: () => void + onError?: (error: PasskeyLoginErrorDetail) => void + /** Backup providers offered to the user, in display order. */ + storage: PasskeyBackupStorage[] + /** WebAuthn relying party id, normally the host's registrable domain. */ + rpId: string + /** App name shown in the passkey dialog. */ + appName: string + themeOverrides?: GoodWidgetThemeOverrides + config?: GoodWidgetConfig + defaultTheme?: 'light' | 'dark' + 'data-testid'?: string +} diff --git a/packages/passkey-login-widget/tsconfig.build.json b/packages/passkey-login-widget/tsconfig.build.json new file mode 100644 index 00000000..074095b1 --- /dev/null +++ b/packages/passkey-login-widget/tsconfig.build.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": ".", + "allowImportingTsExtensions": true, + "paths": { + "react-native": ["./node_modules/react-native-web"] + } + }, + "include": ["src", "package.json"], + "exclude": ["src/**/*.test.ts", "src/testFixtures.ts"] +} diff --git a/packages/passkey-login-widget/tsconfig.json b/packages/passkey-login-widget/tsconfig.json new file mode 100644 index 00000000..bb144c29 --- /dev/null +++ b/packages/passkey-login-widget/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + // Editor/typecheck config only; emitting is tsup's job via tsconfig.build.json. + "noEmit": true, + // Logic modules import each other with explicit .ts so `node --test --experimental-strip-types` can run them. + "allowImportingTsExtensions": true, + "types": ["node"], + "paths": { + "@goodwidget/core": ["../core/src/index.ts"], + "@goodwidget/core/*": ["../core/src/*"], + "@goodwidget/ui": ["../ui/src/index.ts"], + "react-native": ["./node_modules/react-native-web"] + } + }, + "include": ["src", "package.json"] +} diff --git a/packages/passkey-login-widget/tsup.config.ts b/packages/passkey-login-widget/tsup.config.ts new file mode 100644 index 00000000..95c2de0d --- /dev/null +++ b/packages/passkey-login-widget/tsup.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'tsup' + +export default defineConfig({ + entry: { + index: 'src/index.ts', + element: 'src/element.ts', + register: 'src/register.ts', + }, + format: ['esm', 'cjs'], + dts: true, + sourcemap: true, + clean: true, + tsconfig: 'tsconfig.build.json', + external: ['react', 'react-dom', 'react-native', 'react-native-web'], +}) diff --git a/packages/ui/src/components/Button.tsx b/packages/ui/src/components/Button.tsx index eac8fa18..3cbcf57f 100644 --- a/packages/ui/src/components/Button.tsx +++ b/packages/ui/src/components/Button.tsx @@ -170,8 +170,15 @@ export const ButtonFrame = createComponent(Stack, { }, }) +/** + * Button label. Intentionally has no component `name`: Tamagui resolves a named + * component's theme by falling back to every ancestor component theme, which made + * the label pick up light_Button / dark_Button (white text) even inside the + * `` that Button applies to non-primary variants. Unnamed, the label + * simply inherits the nearest theme: the Button theme for primary, the surrounding + * theme for every other variant. + */ export const ButtonText = styled(TamaguiText, { - name: 'ButtonText', fontFamily: '$body', fontSize: '$3', fontWeight: '600', diff --git a/packages/ui/src/components/Icon.tsx b/packages/ui/src/components/Icon.tsx index 998d4e26..07f97f75 100644 --- a/packages/ui/src/components/Icon.tsx +++ b/packages/ui/src/components/Icon.tsx @@ -83,6 +83,14 @@ const SVG_PATHS: Record = { 'M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10zM16.24 7.76l-2.12 6.36-6.36 2.12 2.12-6.36 6.36-2.12z', user: 'M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2M12 11a4 4 0 100-8 4 4 0 000 8z', 'log-out': ['M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4', 'M16 17l5-5-5-5', 'M21 12H9'], + // Passkey: person (head + shoulders) with a key to its right, as in the passkey login designs + passkey: [ + 'M9 11a4 4 0 100-8 4 4 0 000 8z', + 'M2 21v-1a5 5 0 015-5h3', + 'M18 20a3 3 0 100-6 3 3 0 000 6z', + 'M18 20v3', + 'M18 22h2', + ], } // --------------------------------------------------------------------------- diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 391b3d5e..fe02a022 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -320,6 +320,9 @@ importers: '@goodwidget/connect-a-wallet-widget': specifier: workspace:* version: link:../../packages/connect-a-wallet-widget + '@goodwidget/passkey-login-widget': + specifier: workspace:* + version: link:../../packages/passkey-login-widget '@goodwidget/claim-widget-theme-demo': specifier: workspace:* version: link:../../packages/claim-widget-theme-demo @@ -733,6 +736,43 @@ importers: specifier: ^5.7.0 version: 5.9.3 + packages/passkey-login-widget: + dependencies: + '@goodwidget/core': + specifier: workspace:* + version: link:../core + '@goodwidget/embed': + specifier: workspace:* + version: link:../embed + '@goodwidget/ui': + specifier: workspace:* + version: link:../ui + viem: + specifier: ^2.0.0 + version: 2.48.4(typescript@5.9.3)(zod@4.1.11) + devDependencies: + '@types/node': + specifier: ^22.10.0 + version: 22.20.1 + '@types/react': + specifier: ^18.3.0 + version: 18.3.28 + '@types/react-dom': + specifier: ^18.3.0 + version: 18.3.7(@types/react@18.3.28) + react: + specifier: ^18.3.0 + version: 18.3.1 + react-dom: + specifier: ^18.3.0 + version: 18.3.1(react@18.3.1) + tsup: + specifier: ^8.4.0 + version: 8.5.1(@swc/core@1.15.30)(postcss@8.5.8)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.9.0) + typescript: + specifier: ^5.7.0 + version: 5.9.3 + packages/staking-migration-widget: dependencies: '@goodwidget/core': diff --git a/tests/design-system/smoke.spec.ts b/tests/design-system/smoke.spec.ts index 455e7403..f69de531 100644 --- a/tests/design-system/smoke.spec.ts +++ b/tests/design-system/smoke.spec.ts @@ -534,3 +534,60 @@ test('DataTable/Controllable story exposes a working range control for the neste await expect(addressColumnCell).toHaveJSProperty('offsetWidth', 350) await screenshotStory(page, 'tests/design-system/test-results/story-datatable-controllable-width.png') }) + +/** WCAG 2.x minimum contrast ratio for normal-size text. */ +const MINIMUM_TEXT_CONTRAST_RATIO = 4.5 + +/** Button variants drawn on a transparent background, whose label must follow the surrounding theme. */ +const TRANSPARENT_BACKGROUND_BUTTON_VARIANTS = ['secondary', 'outline', 'ghost', 'text'] as const + +for (const themeMode of ['light', 'dark'] as const) { + test(`Button/${themeMode}: non-primary labels are readable on the ${themeMode} background`, async ({ page }) => { + // Regression guard: ButtonText used to inherit the white light_Button/dark_Button text color + // even inside non-primary variants, so secondary/text labels were invisible on light surfaces. + await gotoStory(page, `design-system-primitives-button--variants-${themeMode}`) + const frame = getStoryFrame(page) + await expect(frame.getByTestId('Button-variants')).toBeVisible() + + for (const variant of TRANSPARENT_BACKGROUND_BUTTON_VARIANTS) { + const contrastRatio = await frame.getByTestId(`ButtonText-${variant}`).evaluate((labelElement) => { + /** Parse a computed `rgb()/rgba()` string into channels and alpha. */ + const parseColor = (cssColor: string) => { + const [red, green, blue, alpha = 1] = cssColor.match(/[\d.]+/g)!.map(Number) + return { red, green, blue, alpha } + } + /** WCAG relative luminance of an sRGB color. */ + const relativeLuminance = ({ red, green, blue }: { red: number; green: number; blue: number }) => { + const [linearRed, linearGreen, linearBlue] = [red, green, blue].map((channel) => { + const unit = channel / 255 + return unit <= 0.03928 ? unit / 12.92 : ((unit + 0.055) / 1.055) ** 2.4 + }) + return 0.2126 * linearRed + 0.7152 * linearGreen + 0.0722 * linearBlue + } + + // Walk up to the first ancestor that paints an opaque background (the story surface). + let surfaceElement: Element | null = labelElement.parentElement + let surfaceColor = parseColor(getComputedStyle(document.body).backgroundColor) + while (surfaceElement) { + const candidateColor = parseColor(getComputedStyle(surfaceElement).backgroundColor) + if (candidateColor.alpha === 1) { + surfaceColor = candidateColor + break + } + surfaceElement = surfaceElement.parentElement + } + + const labelLuminance = relativeLuminance(parseColor(getComputedStyle(labelElement).color)) + const surfaceLuminance = relativeLuminance(surfaceColor) + const lighter = Math.max(labelLuminance, surfaceLuminance) + const darker = Math.min(labelLuminance, surfaceLuminance) + return (lighter + 0.05) / (darker + 0.05) + }) + expect(contrastRatio, `${variant} label contrast in ${themeMode} theme`).toBeGreaterThanOrEqual( + MINIMUM_TEXT_CONTRAST_RATIO, + ) + } + + await screenshotStory(page, `tests/design-system/test-results/story-button-${themeMode}.png`) + }) +} diff --git a/tests/design-system/test-results/story-button-dark.png b/tests/design-system/test-results/story-button-dark.png new file mode 100644 index 00000000..9d42a959 Binary files /dev/null and b/tests/design-system/test-results/story-button-dark.png differ diff --git a/tests/design-system/test-results/story-button-light.png b/tests/design-system/test-results/story-button-light.png new file mode 100644 index 00000000..6ea661b6 Binary files /dev/null and b/tests/design-system/test-results/story-button-light.png differ diff --git a/tests/widgets/passkey-login-widget/QA.md b/tests/widgets/passkey-login-widget/QA.md new file mode 100644 index 00000000..e2020909 --- /dev/null +++ b/tests/widgets/passkey-login-widget/QA.md @@ -0,0 +1,46 @@ +# Passkey login widget QA + +Covers `packages/passkey-login-widget` (issue #307, feature #305). + +## What the automated specs prove + +Run against a prestarted Storybook (`pnpm --filter storybook dev`, port 6006) with `pnpm test:demo tests/widgets/passkey-login-widget`. + +| Spec | Evidence | +| --- | --- | +| `states.spec.ts` | Every screen A1-D6 (incl. D3 single provider, D5 new/existing user, D6 ambiguous) in light and dark; screenshots in `test-results/{light,dark}/`. | +| `new-account.spec.ts`, `restore-existing-passkey.spec.ts`, `existing-user-backup.spec.ts`, `provider-choice.spec.ts` | Create, back up, restart and restore round trips; provider choice and remembered provider; exact key restore for an account whose key was not derived from the passkey. | +| `errors-and-recovery.spec.ts` | D1-D6, each with its recovery buttons and the `onError` code. | +| `key-safety.spec.ts` | The account key and PRF output never reach the DOM, web storage, console or the uploaded file; the file holds only ciphertext. | +| `version.spec.ts` | Version label in light/dark, desktop/mobile. | + +**Limits.** These specs use a Chromium virtual authenticator (real WebAuthn + PRF + WebCrypto) and an in-memory storage double. They do not prove that Google Drive, CloudKit, a platform authenticator or a real password manager work. Those checks are manual and listed below. + +## Manual live check (needs GoodDollar-owned values) + +Open `QA / PasskeyLoginWidget / Live (real passkey + real providers)`. Use a throwaway Google / Apple account: the story writes real backup files. + +| Variable (or story control) | Meaning | +| --- | --- | +| `VITE_PASSKEY_GOOGLE_CLIENT_ID` | Google OAuth "Web application" client ID. Add the Storybook origin as an authorized JavaScript origin. Backups live in this client's `appDataFolder`, so the ID must stay stable once users have backups. | +| `VITE_PASSKEY_ICLOUD_CONTAINER_ID` | CloudKit container identifier, e.g. `iCloud.org.gooddollar.passkey`. | +| `VITE_PASSKEY_ICLOUD_API_TOKEN` | CloudKit API token restricted to the Storybook origin. | +| `VITE_PASSKEY_ICLOUD_ENVIRONMENT` | `development` (default) or `production`. | + +Put them in `examples/storybook/.env.local` (git-ignored). Never commit them. + +Checklist: new account + backup, restart + restore, existing user backup (`backUpThrowawayAccount`), provider sign-in cancelled (D4), and a second backup file under the same passkey (ambiguous copy). The Live story must show "Google Drive: configured" before testing. + +## CloudKit handover (one-time container setup) + +In the CloudKit Dashboard, container schema: + +- Record type `PasskeyBackup` in the **private** database with fields `prfTag` (String), `address` (String) and `payload` (String). +- A **Queryable** index on `prfTag` (the lookup is a server-side query on it). +- An API token for the web origins, "Sign In Callback: none" (CloudKit JS renders its own Apple sign-in button, shown in a small overlay). +- Deploy the development schema to production before a production release. + +## Known gaps + +- Google Drive and iCloud adapters have unit tests with mocked transports only; the CloudKit sign-in overlay is unverified in a real browser. +- Wrong-passkey (D6) cannot be simulated with one virtual authenticator, so D6 is covered by tampered ciphertext, mismatched address, unknown version and ambiguous-backup files. diff --git a/tests/widgets/passkey-login-widget/errors-and-recovery.spec.ts b/tests/widgets/passkey-login-widget/errors-and-recovery.spec.ts new file mode 100644 index 00000000..8a7c625b --- /dev/null +++ b/tests/widgets/passkey-login-widget/errors-and-recovery.spec.ts @@ -0,0 +1,215 @@ +import { expect, test, type Page } from '@playwright/test' +import { addVirtualAuthenticator, stubWebAuthnFailure } from './webauthnFixture' +import { + createAccountAndBackUp, + expectScreen, + openInteractionStory, + pressButton, + readHostEvents, + readStoredBackup, + readStoredBackupNames, + replaceStoredBackups, + setStorageFailure, +} from './interactionHelpers' + +/** `onError` codes reported to the host so far. */ +async function readErrorCodes(page: Page): Promise<(string | undefined)[]> { + return (await readHostEvents(page)).filter((event) => event.name === 'error').map((event) => event.detail?.code) +} + +/** Creates and backs up an account, then restarts the widget so the next step is a login. */ +async function createBackedUpAccountAndRestart(page: Page, story: 'one-provider' | 'two-providers'): Promise { + await openInteractionStory(page, story) + await createAccountAndBackUp(page, story === 'two-providers' ? 'google-drive' : undefined) + await page.getByRole('button', { name: 'Restart widget' }).click() + await expectScreen(page, 'A1_ENTRY') +} + +test.describe('D1 unsupported browser', () => { + test('a browser without WebAuthn goes straight to D1', async ({ page }) => { + await stubWebAuthnFailure(page, 'no-webauthn') + await openInteractionStory(page, 'one-provider') + + await expectScreen(page, 'D1_UNSUPPORTED_BROWSER') + expect(await readErrorCodes(page)).toEqual(['UNSUPPORTED_BROWSER']) + await pressButton(page, 'Back to login methods') + expect((await readHostEvents(page)).at(-1)?.name).toBe('skip') + }) + + test('an authenticator without PRF ends on D1', async ({ page }) => { + await stubWebAuthnFailure(page, 'no-prf') + await openInteractionStory(page, 'one-provider') + + await pressButton(page, 'Continue with passkey') + await expectScreen(page, 'D1_UNSUPPORTED_BROWSER') + expect(await readErrorCodes(page)).toEqual(['UNSUPPORTED_BROWSER']) + }) +}) + +test.describe('D2 passkey cancelled', () => { + test('Retry returns to A1 and "Create new account" goes to A3', async ({ page }) => { + await stubWebAuthnFailure(page, 'user-cancelled') + await openInteractionStory(page, 'one-provider') + + await pressButton(page, 'Continue with passkey') + await expectScreen(page, 'D2_PASSKEY_CANCELLED') + expect(await readErrorCodes(page)).toEqual(['PASSKEY_CANCELLED']) + + // Retry starts over at A1; a second attempt is cancelled again + await pressButton(page, 'Retry') + await expectScreen(page, 'A1_ENTRY') + await pressButton(page, 'Continue with passkey') + await expectScreen(page, 'D2_PASSKEY_CANCELLED') + expect(await readErrorCodes(page)).toEqual(['PASSKEY_CANCELLED', 'PASSKEY_CANCELLED']) + + await pressButton(page, 'Create new account') + await expectScreen(page, 'A3_NEW_ACCOUNT_WARNING') + }) +}) + +test.describe('D3 backup not found', () => { + test('a passkey without any backup shows D3 and "Back" returns to A1', async ({ page }) => { + await addVirtualAuthenticator(page) + await createBackedUpAccountAndRestart(page, 'one-provider') + await replaceStoredBackups(page, 'google-drive', {}) + + await pressButton(page, 'Continue with passkey') + await expectScreen(page, 'D3_BACKUP_NOT_FOUND') + expect(await readErrorCodes(page)).toContain('BACKUP_NOT_FOUND') + // One provider only: no "other provider" button + await expect(page.getByRole('button', { name: 'Try the other provider' })).toHaveCount(0) + + await pressButton(page, 'Back') + await expectScreen(page, 'A1_ENTRY') + }) +}) + +test.describe('D4 provider sign-in failed', () => { + test('Try again works once the provider sign-in succeeds, and "Choose another provider" offers C1', async ({ page }) => { + await addVirtualAuthenticator(page) + await createBackedUpAccountAndRestart(page, 'two-providers') + await setStorageFailure(page, 'google-drive', 'failAuthorization', true) + + await pressButton(page, 'Continue with passkey') + await expectScreen(page, 'D4_PROVIDER_AUTH_FAILED') + expect(await readErrorCodes(page)).toContain('PROVIDER_AUTH_FAILED') + + await pressButton(page, 'Choose another provider') + await expectScreen(page, 'C1_RESTORE_PROVIDER') + await page.getByTestId('provider-tile-google-drive').click() + await expectScreen(page, 'D4_PROVIDER_AUTH_FAILED') + + await setStorageFailure(page, 'google-drive', 'failAuthorization', false) + await pressButton(page, 'Try again') + await expectScreen(page, 'C3_RESTORE_SUCCESS') + }) +}) + +test.describe('D5 backup failed', () => { + test('a new user stays logged out, can retry, and has no "Skip for now"', async ({ page }) => { + await addVirtualAuthenticator(page) + await openInteractionStory(page, 'one-provider') + await setStorageFailure(page, 'google-drive', 'failWrites', true) + + await pressButton(page, 'Continue with passkey') + await pressButton(page, 'Create new account') + await pressButton(page, 'Create new account') + await expectScreen(page, 'D5_BACKUP_FAILED') + expect(await readErrorCodes(page)).toContain('BACKUP_FAILED') + await expect(page.getByRole('button', { name: 'Skip for now' })).toHaveCount(0) + // Not logged in: the key stays inside the widget until the backup exists + expect((await readHostEvents(page)).some((event) => event.name === 'login')).toBe(false) + + await setStorageFailure(page, 'google-drive', 'failWrites', false) + await pressButton(page, 'Retry') + await expectScreen(page, 'B4_BACKUP_SUCCESS') + await pressButton(page, 'Continue') + const loginEvent = (await readHostEvents(page)).find((event) => event.name === 'login') + expect(loginEvent?.detail?.isNewAccount).toBe(true) + }) + + test('an existing user can retry or skip', async ({ page }) => { + const authenticator = await addVirtualAuthenticator(page) + await openInteractionStory(page, 'existing-user-one-provider') + await setStorageFailure(page, 'google-drive', 'failWrites', true) + + await pressButton(page, 'Set up passkey backup') + await expectScreen(page, 'D5_BACKUP_FAILED') + await pressButton(page, 'Retry') + // Retry re-uploads the prepared backup without a second passkey prompt + await expectScreen(page, 'D5_BACKUP_FAILED') + expect(await authenticator.countCredentials()).toBe(1) + + await pressButton(page, 'Skip for now') + expect((await readHostEvents(page)).at(-1)?.name).toBe('skip') + }) +}) + +test.describe('D6 backup cannot be decrypted', () => { + /** Stored backup of the only account, for the tampering cases. */ + async function readOnlyBackup(page: Page): Promise<{ fileName: string; backup: Record }> { + const [fileName] = await readStoredBackupNames(page, 'google-drive') + const backupJson = await readStoredBackup(page, 'google-drive', fileName) + return { fileName, backup: JSON.parse(backupJson ?? '{}') as Record } + } + + async function expectDecryptFailureAfterTampering( + page: Page, + tamper: (stored: { fileName: string; backup: Record }) => Record, + expectedErrorCode: 'DECRYPT_FAILED' | 'AMBIGUOUS_BACKUP' = 'DECRYPT_FAILED', + ): Promise { + await addVirtualAuthenticator(page) + await createBackedUpAccountAndRestart(page, 'one-provider') + await replaceStoredBackups(page, 'google-drive', tamper(await readOnlyBackup(page))) + + await pressButton(page, 'Continue with passkey') + await expectScreen(page, 'D6_DECRYPT_FAILED') + expect(await readErrorCodes(page)).toContain(expectedErrorCode) + } + + test('a corrupted ciphertext shows D6', async ({ page }) => { + await expectDecryptFailureAfterTampering(page, ({ fileName, backup }) => ({ + [fileName]: JSON.stringify({ ...backup, ciphertext: `A${String(backup.ciphertext).slice(1)}` }), + })) + }) + + test('an address in the file that differs from the file name shows D6', async ({ page }) => { + await expectDecryptFailureAfterTampering(page, ({ fileName, backup }) => ({ + [fileName]: JSON.stringify({ ...backup, address: '0x0000000000000000000000000000000000000001' }), + })) + }) + + test('an unknown backup version shows D6', async ({ page }) => { + await expectDecryptFailureAfterTampering(page, ({ fileName, backup }) => ({ + [fileName]: JSON.stringify({ ...backup, version: 2 }), + })) + }) + + test('a file name whose address differs from the encrypted address shows D6', async ({ page }) => { + await expectDecryptFailureAfterTampering(page, ({ fileName, backup }) => ({ + [fileName.replace(/0x[0-9a-f]{40}/, '0x0000000000000000000000000000000000000001')]: JSON.stringify(backup), + })) + }) + + test('Try again retries and "Back" returns to A1', async ({ page }) => { + await expectDecryptFailureAfterTampering(page, ({ fileName, backup }) => ({ + [fileName]: JSON.stringify({ ...backup, version: 2 }), + })) + await pressButton(page, 'Try again') + await expectScreen(page, 'D6_DECRYPT_FAILED') + await pressButton(page, 'Back') + await expectScreen(page, 'A1_ENTRY') + }) + + test('several backups under one passkey show the ambiguous-backup copy on the D6 layout', async ({ page }) => { + await expectDecryptFailureAfterTampering( + page, + ({ fileName, backup }) => ({ + [fileName]: JSON.stringify(backup), + [fileName.replace(/0x[0-9a-f]{40}/, '0x0000000000000000000000000000000000000002')]: JSON.stringify(backup), + }), + 'AMBIGUOUS_BACKUP', + ) + await expect(page.getByTestId('screen-D6')).toContainText('More than one backup matches this passkey') + }) +}) diff --git a/tests/widgets/passkey-login-widget/existing-user-backup.spec.ts b/tests/widgets/passkey-login-widget/existing-user-backup.spec.ts new file mode 100644 index 00000000..fe337522 --- /dev/null +++ b/tests/widgets/passkey-login-widget/existing-user-backup.spec.ts @@ -0,0 +1,49 @@ +import { expect, test } from '@playwright/test' +import { addVirtualAuthenticator } from './webauthnFixture' +import { expectScreen, openInteractionStory, pressButton, readHostEvents, readStoredBackupNames } from './interactionHelpers' + +test('an existing user backs up their account with a fresh passkey and is not logged in again', async ({ page }) => { + const authenticator = await addVirtualAuthenticator(page) + await openInteractionStory(page, 'existing-user-one-provider') + + await expectScreen(page, 'B1_BACKUP_PROMPT') + await pressButton(page, 'Set up passkey backup') + await expectScreen(page, 'B4_BACKUP_SUCCESS') + expect(await authenticator.countCredentials()).toBe(1) + expect(await readStoredBackupNames(page, 'google-drive')).toHaveLength(1) + + await pressButton(page, 'Continue') + const hostEvents = await readHostEvents(page) + // Only the backup result: the user is already logged in, so there is no onLogin + expect(hostEvents.map((event) => event.name)).toEqual(['backup-complete']) + expect(hostEvents[0].detail?.provider).toBe('google-drive') + expect(JSON.stringify(hostEvents[0].detail)).not.toContain('privateKey') +}) + +test('every backup creates a new passkey and a new backup file', async ({ page }) => { + const authenticator = await addVirtualAuthenticator(page) + await openInteractionStory(page, 'existing-user-one-provider') + + for (let backupRun = 0; backupRun < 2; backupRun += 1) { + await expectScreen(page, 'B1_BACKUP_PROMPT') + await pressButton(page, 'Set up passkey backup') + await expectScreen(page, 'B4_BACKUP_SUCCESS') + await pressButton(page, 'Continue') + await page.getByRole('button', { name: 'Restart widget' }).click() + } + + expect(await authenticator.countCredentials()).toBe(2) + expect(await readStoredBackupNames(page, 'google-drive')).toHaveLength(2) +}) + +test('"Not now" skips the backup without touching passkeys or storage', async ({ page }) => { + const authenticator = await addVirtualAuthenticator(page) + await openInteractionStory(page, 'existing-user-one-provider') + + await expectScreen(page, 'B1_BACKUP_PROMPT') + await pressButton(page, 'Not now') + + expect((await readHostEvents(page)).map((event) => event.name)).toEqual(['skip']) + expect(await authenticator.countCredentials()).toBe(0) + expect(await readStoredBackupNames(page, 'google-drive')).toHaveLength(0) +}) diff --git a/tests/widgets/passkey-login-widget/interactionHelpers.ts b/tests/widgets/passkey-login-widget/interactionHelpers.ts new file mode 100644 index 00000000..bceca359 --- /dev/null +++ b/tests/widgets/passkey-login-widget/interactionHelpers.ts @@ -0,0 +1,115 @@ +import { expect, type Page } from '@playwright/test' + +const INTERACTION_STORY_PREFIX = '/iframe.html?id=qa-passkeyloginwidget-interactions--' + +/** Key of the remembered provider in localStorage (an id only, never key material). */ +export const REMEMBERED_PROVIDER_STORAGE_KEY = 'goodwidget.passkey-login.provider' + +/** Interaction stories: the real widget on in-memory storage. */ +export type InteractionStory = + | 'one-provider' + | 'two-providers' + | 'existing-user-one-provider' + | 'existing-user-two-providers' + +/** Host callback recorded by the interaction story harness (mock-only payloads). */ +export interface RecordedHostEvent { + name: string + detail?: { address?: string; privateKey?: string; isNewAccount?: boolean; provider?: string; code?: string } +} + +/** Shape of the `window.__passkeyStory` hook the interaction stories create. */ +interface StoryWindow { + __passkeyStory?: { + events: RecordedHostEvent[] + storages: Record; failAuthorization: boolean; failWrites: boolean } }> + } +} + +export async function openInteractionStory(page: Page, story: InteractionStory): Promise { + await page.setViewportSize({ width: 440, height: 900 }) + await page.goto(`${INTERACTION_STORY_PREFIX}${story}&viewMode=story`) + await expect(page.getByTestId('passkey-login-widget')).toBeVisible({ timeout: 20_000 }) +} + +/** Waits until the widget shows the given screen (`data-screen` of the widget root). */ +export async function expectScreen(page: Page, screen: string): Promise { + await expect(page.getByTestId('passkey-login-widget')).toHaveAttribute('data-screen', screen, { timeout: 15_000 }) +} + +export async function pressButton(page: Page, buttonName: string): Promise { + await page.getByTestId('passkey-login-widget').getByRole('button', { name: buttonName, exact: true }).click() +} + +export async function readHostEvents(page: Page): Promise { + return page.evaluate(() => (window as unknown as StoryWindow).__passkeyStory?.events ?? []) +} + +/** Backup file names currently stored in the mock storage of one provider. */ +export async function readStoredBackupNames(page: Page, providerId: string): Promise { + return page.evaluate((id) => [...((window as unknown as StoryWindow).__passkeyStory?.storages[id]?.controls.files.keys() ?? [])], providerId) +} + +/** Raw backup JSON of one stored file. */ +export async function readStoredBackup(page: Page, providerId: string, fileName: string): Promise { + return page.evaluate( + ([id, name]) => (window as unknown as StoryWindow).__passkeyStory?.storages[id]?.controls.files.get(name), + [providerId, fileName], + ) +} + +/** + * Runs the "new account" journey from A1 up to and including the Continue press on B4. + * @param chosenProviderId provider to pick on B2; omit when only one provider is offered (B2 is skipped) + */ +export async function createAccountAndBackUp(page: Page, chosenProviderId?: 'google-drive' | 'icloud'): Promise { + await expectScreen(page, 'A1_ENTRY') + await pressButton(page, 'Continue with passkey') + // A fresh authenticator has no passkey yet, so the request ends on D2, which offers a new account + await expectScreen(page, 'D2_PASSKEY_CANCELLED') + await pressButton(page, 'Create new account') + await expectScreen(page, 'A3_NEW_ACCOUNT_WARNING') + await pressButton(page, 'Create new account') + if (chosenProviderId) { + await expectScreen(page, 'B2_BACKUP_PROVIDER') + await page.getByTestId(`provider-tile-${chosenProviderId}`).click() + await pressButton(page, 'Continue') + } + await expectScreen(page, 'B4_BACKUP_SUCCESS') + await pressButton(page, 'Continue') +} + +/** Flips a failure switch of one mock provider storage. */ +export async function setStorageFailure( + page: Page, + providerId: string, + failure: 'failAuthorization' | 'failWrites', + isFailing: boolean, +): Promise { + await page.evaluate( + ([id, failureName, value]) => { + const storage = (window as unknown as StoryWindow).__passkeyStory?.storages[id as string] + if (storage) { + storage.controls[failureName as 'failAuthorization' | 'failWrites'] = value as boolean + } + }, + [providerId, failure, isFailing], + ) +} + +/** Replaces everything stored by one mock provider with the given file name -> JSON entries. */ +export async function replaceStoredBackups( + page: Page, + providerId: string, + backups: Record, +): Promise { + await page.evaluate( + ([id, entries]) => { + const storage = (window as unknown as StoryWindow).__passkeyStory?.storages[id as string] + if (storage) { + storage.controls.files = new Map(Object.entries(entries as Record)) + } + }, + [providerId, backups], + ) +} diff --git a/tests/widgets/passkey-login-widget/key-safety.spec.ts b/tests/widgets/passkey-login-widget/key-safety.spec.ts new file mode 100644 index 00000000..109673be --- /dev/null +++ b/tests/widgets/passkey-login-widget/key-safety.spec.ts @@ -0,0 +1,135 @@ +import { expect, test, type Page } from '@playwright/test' +import { addVirtualAuthenticator } from './webauthnFixture' +import { + REMEMBERED_PROVIDER_STORAGE_KEY, + createAccountAndBackUp, + expectScreen, + openInteractionStory, + pressButton, + readHostEvents, + readStoredBackup, + readStoredBackupNames, +} from './interactionHelpers' + +/** Window hook that records the PRF output of every WebAuthn call, so the spec knows what must never leak. */ +interface PrfSpyWindow { + __prfOutputsBase64?: string[] +} + +/** Wraps `navigator.credentials.create/get` to record PRF outputs (test-only spy; the widget code is untouched). */ +async function recordPrfOutputs(page: Page): Promise { + await page.addInitScript(() => { + const spyWindow = window as unknown as PrfSpyWindow + spyWindow.__prfOutputsBase64 = [] + const credentials = navigator.credentials + for (const methodName of ['create', 'get'] as const) { + const originalMethod = credentials[methodName].bind(credentials) as (options?: unknown) => Promise + ;(credentials as unknown as Record)[methodName] = async (options?: unknown) => { + const credential = (await originalMethod(options)) as PublicKeyCredential | null + const prfResults = (credential?.getClientExtensionResults() as { prf?: { results?: { first?: ArrayBuffer } } }) + ?.prf?.results + if (prfResults?.first) { + spyWindow.__prfOutputsBase64?.push(btoa(String.fromCharCode(...new Uint8Array(prfResults.first)))) + } + return credential + } + } + }) +} + +/** Console output of the page, collected for the whole test. */ +function collectConsoleText(page: Page): string[] { + const consoleLines: string[] = [] + page.on('console', (message) => consoleLines.push(message.text())) + page.on('pageerror', (error) => consoleLines.push(String(error))) + return consoleLines +} + +/** Every representation of a secret a leak could take: hex with/without 0x, base64 and base64url. */ +function secretRepresentations(secretBytesHex: string, secretBase64: string): string[] { + const hexWithoutPrefix = secretBytesHex.replace(/^0x/, '') + const base64Url = secretBase64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') + return [hexWithoutPrefix, `0x${hexWithoutPrefix}`, secretBase64, base64Url] +} + +function base64ToHex(valueBase64: string): string { + return Buffer.from(valueBase64, 'base64').toString('hex') +} + +/** Text the page exposes to anything but the host callback: DOM, both web storages and the console. */ +async function readEverythingVisibleToThePage(page: Page, consoleLines: string[]): Promise { + const domAndStorage = await page.evaluate(() => + JSON.stringify({ + html: document.documentElement.outerHTML, + localStorage: { ...localStorage }, + sessionStorage: { ...sessionStorage }, + }), + ) + return `${domAndStorage}\n${consoleLines.join('\n')}` +} + +test('neither the account key nor the PRF output leaks outside the login callback and the encrypted backup', async ({ page }) => { + const consoleLines = collectConsoleText(page) + await recordPrfOutputs(page) + await addVirtualAuthenticator(page) + await openInteractionStory(page, 'one-provider') + await createAccountAndBackUp(page) + + // Second session: restore through the passkey, so both the create and the restore paths are covered + await page.getByRole('button', { name: 'Restart widget' }).click() + await expectScreen(page, 'A1_ENTRY') + await pressButton(page, 'Continue with passkey') + await expectScreen(page, 'C3_RESTORE_SUCCESS') + await pressButton(page, 'Continue') + + const hostEvents = await readHostEvents(page) + const loginEvents = hostEvents.filter((event) => event.name === 'login') + const accountPrivateKey = loginEvents[0].detail?.privateKey as string + expect(accountPrivateKey).toMatch(/^0x[0-9a-f]{64}$/) + + const prfOutputsBase64 = await page.evaluate(() => (window as unknown as PrfSpyWindow).__prfOutputsBase64 ?? []) + expect(prfOutputsBase64.length).toBeGreaterThan(0) + + const forbiddenValues = [ + ...secretRepresentations(accountPrivateKey, Buffer.from(accountPrivateKey.slice(2), 'hex').toString('base64')), + ...prfOutputsBase64.flatMap((prfOutputBase64) => secretRepresentations(base64ToHex(prfOutputBase64), prfOutputBase64)), + ] + + // 1. DOM, web storage and console never contain the key or the PRF output + const pageVisibleText = await readEverythingVisibleToThePage(page, consoleLines) + for (const forbiddenValue of forbiddenValues) { + expect(pageVisibleText).not.toContain(forbiddenValue) + } + + // 2. Only the remembered provider id is persisted by the widget + const storedKeys = await page.evaluate(() => [...Object.keys(localStorage), ...Object.keys(sessionStorage)]) + expect(storedKeys.filter((storedKey) => storedKey.startsWith('goodwidget.'))).toEqual([REMEMBERED_PROVIDER_STORAGE_KEY]) + + // 3. The uploaded file is versioned JSON of ciphertext only, with a name made of tag and address + const backupNames = await readStoredBackupNames(page, 'google-drive') + expect(backupNames).toHaveLength(1) + expect(backupNames[0]).toMatch(/^gwpk-[A-Za-z0-9_-]{22}-0x[0-9a-f]{40}\.json$/) + const backupJson = (await readStoredBackup(page, 'google-drive', backupNames[0])) as string + expect(Object.keys(JSON.parse(backupJson)).sort()).toEqual(['address', 'ciphertext', 'iv', 'version']) + for (const forbiddenValue of forbiddenValues) { + expect(backupJson).not.toContain(forbiddenValue) + expect(backupNames[0]).not.toContain(forbiddenValue) + } + + // 4. Only `onLogin` carries the key; both logins (create and restore) hand over the same key + expect(loginEvents[1].detail?.privateKey).toBe(accountPrivateKey) + const eventsWithoutLogin = hostEvents.filter((event) => event.name !== 'login') + for (const forbiddenValue of forbiddenValues) { + expect(JSON.stringify(eventsWithoutLogin)).not.toContain(forbiddenValue) + } +}) + +test('the event log of the story masks the key that onLogin delivers', async ({ page }) => { + await addVirtualAuthenticator(page) + await openInteractionStory(page, 'one-provider') + await createAccountAndBackUp(page) + + await expect(page.getByTestId('passkey-event-log')).toContainText('privateKey ••••••••') + const accountPrivateKey = (await readHostEvents(page)).find((event) => event.name === 'login')?.detail?.privateKey as string + await expect(page.getByTestId('passkey-event-log')).not.toContainText(accountPrivateKey.slice(2)) +}) diff --git a/tests/widgets/passkey-login-widget/new-account.spec.ts b/tests/widgets/passkey-login-widget/new-account.spec.ts new file mode 100644 index 00000000..a3b20fd4 --- /dev/null +++ b/tests/widgets/passkey-login-widget/new-account.spec.ts @@ -0,0 +1,91 @@ +import { expect, test } from '@playwright/test' +import { addVirtualAuthenticator, stubPrfEnabledOnlyAtRegistration } from './webauthnFixture' +import { + createAccountAndBackUp, + expectScreen, + openInteractionStory, + pressButton, + readHostEvents, + readStoredBackup, + readStoredBackupNames, +} from './interactionHelpers' + +const HEX_PRIVATE_KEY = /^0x[0-9a-f]{64}$/ +const BACKUP_FILE_NAME = /^gwpk-[A-Za-z0-9_-]+-0x[0-9a-f]{40}\.json$/ + +test('a new account is created, backed up and handed to the host', async ({ page }) => { + const authenticator = await addVirtualAuthenticator(page) + await openInteractionStory(page, 'one-provider') + + await expectScreen(page, 'A1_ENTRY') + await pressButton(page, 'Continue with passkey') + await expectScreen(page, 'D2_PASSKEY_CANCELLED') + await pressButton(page, 'Create new account') + await expectScreen(page, 'A3_NEW_ACCOUNT_WARNING') + await pressButton(page, 'Create new account') + + // One provider: B2 is skipped. The backup is written before the host learns about the account. + await expectScreen(page, 'B4_BACKUP_SUCCESS') + await expect(page.getByTestId('screen-B4')).toContainText('Your account is backed up to Google Drive.') + expect(await authenticator.countCredentials()).toBe(1) + const storedNames = await readStoredBackupNames(page, 'google-drive') + expect(storedNames).toHaveLength(1) + expect(storedNames[0]).toMatch(BACKUP_FILE_NAME) + expect((await readHostEvents(page)).map((event) => event.name)).toEqual(['error']) + + await pressButton(page, 'Continue') + const hostEvents = await readHostEvents(page) + const loginEvent = hostEvents.find((event) => event.name === 'login') + const backupEvent = hostEvents.find((event) => event.name === 'backup-complete') + expect(loginEvent?.detail?.isNewAccount).toBe(true) + expect(loginEvent?.detail?.privateKey).toMatch(HEX_PRIVATE_KEY) + expect(backupEvent?.detail?.provider).toBe('google-drive') + expect(backupEvent?.detail?.address).toBe(loginEvent?.detail?.address) + // The backup file is named after the address the host received + expect(storedNames[0]).toContain(String(loginEvent?.detail?.address).toLowerCase()) + // The backup-complete payload never carries the key + expect(JSON.stringify(backupEvent?.detail)).not.toContain('privateKey') +}) + +test('the stored backup is an encrypted v1 envelope without the key', async ({ page }) => { + await addVirtualAuthenticator(page) + await openInteractionStory(page, 'one-provider') + await createAccountAndBackUp(page) + + const [fileName] = await readStoredBackupNames(page, 'google-drive') + const backupJson = await readStoredBackup(page, 'google-drive', fileName) + const backup = JSON.parse(backupJson ?? '{}') as Record + const loginEvent = (await readHostEvents(page)).find((event) => event.name === 'login') + + expect(Object.keys(backup).sort()).toEqual(['address', 'ciphertext', 'iv', 'version']) + expect(backup.version).toBe(1) + expect(String(backup.address).toLowerCase()).toBe(String(loginEvent?.detail?.address).toLowerCase()) + expect(backupJson).not.toContain(String(loginEvent?.detail?.privateKey).slice(2)) +}) + +test('the new-account warning says a NEW account is created and points to the backup route and "Back to login methods" skips', async ({ page }) => { + await addVirtualAuthenticator(page) + await openInteractionStory(page, 'one-provider') + await pressButton(page, 'Continue with passkey') + await expectScreen(page, 'D2_PASSKEY_CANCELLED') + await pressButton(page, 'Create new account') + await expectScreen(page, 'A3_NEW_ACCOUNT_WARNING') + await expect(page.getByTestId('screen-A3')).toContainText('Continuing will create a NEW account.') + await expect(page.getByTestId('screen-A3')).toContainText('Already have an account with Google, Facebook or SMS?') + + await pressButton(page, 'Back to login methods') + expect((await readHostEvents(page)).map((event) => event.name)).toEqual(['error', 'skip']) +}) + +test('a passkey that reports PRF only at registration gets a follow-up assertion', async ({ page }) => { + const authenticator = await addVirtualAuthenticator(page) + await stubPrfEnabledOnlyAtRegistration(page) + await openInteractionStory(page, 'one-provider') + + await createAccountAndBackUp(page) + + const loginEvent = (await readHostEvents(page)).find((event) => event.name === 'login') + expect(loginEvent?.detail?.privateKey).toMatch(HEX_PRIVATE_KEY) + expect(await authenticator.countCredentials()).toBe(1) + expect(await readStoredBackupNames(page, 'google-drive')).toHaveLength(1) +}) diff --git a/tests/widgets/passkey-login-widget/provider-choice.spec.ts b/tests/widgets/passkey-login-widget/provider-choice.spec.ts new file mode 100644 index 00000000..50224360 --- /dev/null +++ b/tests/widgets/passkey-login-widget/provider-choice.spec.ts @@ -0,0 +1,107 @@ +import { expect, test, type Page } from '@playwright/test' +import { addVirtualAuthenticator } from './webauthnFixture' +import { + createAccountAndBackUp, + expectScreen, + openInteractionStory, + pressButton, + readHostEvents, + readStoredBackupNames, + REMEMBERED_PROVIDER_STORAGE_KEY, +} from './interactionHelpers' + +/** Walks A1 -> D2 -> A3 -> A4 so the widget stands on B2 with a freshly created passkey. */ +async function reachBackupProviderChoice(page: Page): Promise { + await pressButton(page, 'Continue with passkey') + await expectScreen(page, 'D2_PASSKEY_CANCELLED') + await pressButton(page, 'Create new account') + await pressButton(page, 'Create new account') + await expectScreen(page, 'B2_BACKUP_PROVIDER') +} + +test('B2 offers radio tiles, preselects the first provider and backs up to the chosen one', async ({ page }) => { + await addVirtualAuthenticator(page) + await openInteractionStory(page, 'two-providers') + await reachBackupProviderChoice(page) + + const driveTile = page.getByTestId('provider-tile-google-drive') + const iCloudTile = page.getByTestId('provider-tile-icloud') + await expect(page.getByRole('radiogroup', { name: 'Backup provider' })).toBeVisible() + await expect(driveTile).toHaveAttribute('role', 'radio') + await expect(driveTile).toHaveAttribute('aria-checked', 'true') + await expect(iCloudTile).toHaveAttribute('aria-checked', 'false') + + // Keyboard: focus the iCloud tile and activate it + await iCloudTile.focus() + await page.keyboard.press('Enter') + await expect(iCloudTile).toHaveAttribute('aria-checked', 'true') + await expect(driveTile).toHaveAttribute('aria-checked', 'false') + + await pressButton(page, 'Continue') + await expectScreen(page, 'B4_BACKUP_SUCCESS') + await expect(page.getByTestId('screen-B4')).toContainText('Your account is backed up to iCloud.') + expect(await readStoredBackupNames(page, 'icloud')).toHaveLength(1) + expect(await readStoredBackupNames(page, 'google-drive')).toHaveLength(0) +}) + +test('the chosen provider is remembered (id only) and skips the provider choice on the next login', async ({ page }) => { + await addVirtualAuthenticator(page) + await openInteractionStory(page, 'two-providers') + await createAccountAndBackUp(page, 'icloud') + + const rememberedValue = await page.evaluate((key) => window.localStorage.getItem(key), REMEMBERED_PROVIDER_STORAGE_KEY) + expect(rememberedValue).toBe('icloud') + + await page.getByRole('button', { name: 'Restart widget' }).click() + await pressButton(page, 'Continue with passkey') + // Remembered: no C1, the restore goes straight to the remembered provider + await expectScreen(page, 'C3_RESTORE_SUCCESS') + await pressButton(page, 'Continue') + expect((await readHostEvents(page)).filter((event) => event.name === 'login')[1].detail?.isNewAccount).toBe(false) +}) + +test('C1 asks for the provider when none is remembered', async ({ page }) => { + await addVirtualAuthenticator(page) + await openInteractionStory(page, 'two-providers') + await createAccountAndBackUp(page, 'google-drive') + + // Forget the provider: the next login must ask where the backup is + await page.evaluate((key) => window.localStorage.removeItem(key), REMEMBERED_PROVIDER_STORAGE_KEY) + await page.getByRole('button', { name: 'Restart widget' }).click() + await pressButton(page, 'Continue with passkey') + await expectScreen(page, 'C1_RESTORE_PROVIDER') + await expect(page.getByTestId('provider-tile-google-drive')).toBeVisible() + await expect(page.getByTestId('provider-tile-icloud')).toBeVisible() + + await page.getByTestId('provider-tile-google-drive').click() + await expectScreen(page, 'C3_RESTORE_SUCCESS') +}) + +test('D3 "Try the other provider" leads back to C1 and the backup is found there', async ({ page }) => { + await addVirtualAuthenticator(page) + await openInteractionStory(page, 'two-providers') + await createAccountAndBackUp(page, 'icloud') + + // The backup lives in iCloud, but the user remembers Google Drive + await page.evaluate((key) => window.localStorage.setItem(key, 'google-drive'), REMEMBERED_PROVIDER_STORAGE_KEY) + await page.getByRole('button', { name: 'Restart widget' }).click() + await pressButton(page, 'Continue with passkey') + await expectScreen(page, 'D3_BACKUP_NOT_FOUND') + await expect(page.getByTestId('screen-D3')).toContainText('No backup found in Google Drive') + + await pressButton(page, 'Try the other provider') + await expectScreen(page, 'C1_RESTORE_PROVIDER') + await page.getByTestId('provider-tile-icloud').click() + await expectScreen(page, 'C3_RESTORE_SUCCESS') +}) + +test('a single configured provider never shows a provider choice, even when nothing is remembered', async ({ page }) => { + await addVirtualAuthenticator(page) + await openInteractionStory(page, 'one-provider') + await createAccountAndBackUp(page) + + await page.evaluate((key) => window.localStorage.removeItem(key), REMEMBERED_PROVIDER_STORAGE_KEY) + await page.getByRole('button', { name: 'Restart widget' }).click() + await pressButton(page, 'Continue with passkey') + await expectScreen(page, 'C3_RESTORE_SUCCESS') +}) diff --git a/tests/widgets/passkey-login-widget/restore-existing-passkey.spec.ts b/tests/widgets/passkey-login-widget/restore-existing-passkey.spec.ts new file mode 100644 index 00000000..75c74f2d --- /dev/null +++ b/tests/widgets/passkey-login-widget/restore-existing-passkey.spec.ts @@ -0,0 +1,59 @@ +import { expect, test } from '@playwright/test' +import { addVirtualAuthenticator } from './webauthnFixture' +import { createAccountAndBackUp, expectScreen, openInteractionStory, pressButton, readHostEvents } from './interactionHelpers' + +/** Key of the logged-in user in the interaction stories (a throwaway demo key). */ +const DEMO_ACCOUNT_PRIVATE_KEY = '0x4c0883a69102937d6231471b5dbb6204fe5129617082792ae468d01a3f362318' + +test('a backed-up account is restored with the same passkey after the widget restarts', async ({ page }) => { + const authenticator = await addVirtualAuthenticator(page) + await openInteractionStory(page, 'one-provider') + await createAccountAndBackUp(page) + const createdLogin = (await readHostEvents(page)).find((event) => event.name === 'login') + + // New widget session, same passkey and same stored backup + await page.getByRole('button', { name: 'Restart widget' }).click() + await expectScreen(page, 'A1_ENTRY') + await pressButton(page, 'Continue with passkey') + await expectScreen(page, 'C3_RESTORE_SUCCESS') + await expect(page.getByTestId('screen-C3')).toContainText('Your account has been restored.') + expect(await authenticator.countCredentials()).toBe(1) + + await pressButton(page, 'Continue') + const restoredLogin = (await readHostEvents(page)).filter((event) => event.name === 'login')[1] + expect(restoredLogin.detail?.isNewAccount).toBe(false) + expect(restoredLogin.detail?.address).toBe(createdLogin?.detail?.address) + expect(restoredLogin.detail?.privateKey).toBe(createdLogin?.detail?.privateKey) +}) + +test('"Use another login method" hands control back to the host', async ({ page }) => { + await addVirtualAuthenticator(page) + await openInteractionStory(page, 'one-provider') + await expectScreen(page, 'A1_ENTRY') + + await pressButton(page, 'Use another login method') + expect((await readHostEvents(page)).map((event) => event.name)).toEqual(['skip']) +}) + +test('an account whose key was not derived from the passkey is restored with its exact address', async ({ page }) => { + await addVirtualAuthenticator(page) + await openInteractionStory(page, 'existing-user-one-provider') + + // The logged-in user's own key (not derived from any passkey) is backed up with a new passkey + await pressButton(page, 'Set up passkey backup') + await expectScreen(page, 'B4_BACKUP_SUCCESS') + await pressButton(page, 'Continue') + const backedUpAddress = (await readHostEvents(page)).find((event) => event.name === 'backup-complete')?.detail?.address + + // Logged out again: the passkey alone brings the same account back, found by prfTag listing + await page.getByRole('button', { name: 'Restart as logged-out user' }).click() + await expectScreen(page, 'A1_ENTRY') + await pressButton(page, 'Continue with passkey') + await expectScreen(page, 'C3_RESTORE_SUCCESS') + await pressButton(page, 'Continue') + + const restoredLogin = (await readHostEvents(page)).find((event) => event.name === 'login') + expect(restoredLogin?.detail?.isNewAccount).toBe(false) + expect(restoredLogin?.detail?.address).toBe(backedUpAddress) + expect(restoredLogin?.detail?.privateKey).toBe(DEMO_ACCOUNT_PRIVATE_KEY) +}) diff --git a/tests/widgets/passkey-login-widget/states.spec.ts b/tests/widgets/passkey-login-widget/states.spec.ts new file mode 100644 index 00000000..0ceed490 --- /dev/null +++ b/tests/widgets/passkey-login-widget/states.spec.ts @@ -0,0 +1,70 @@ +import { expect, test } from '@playwright/test' + +const FIXTURE_STORY_PREFIX = '/iframe.html?id=qa-passkeyloginwidget-screen-fixtures--' + +// Every screen is checked in both themes so light/dark parity stays covered. +const THEMES = ['light', 'dark'] as const + +interface ScreenFixture { + /** Storybook story id suffix of the fixture. */ + storyId: string + /** `data-screen` value the widget root must report. */ + screen: string + /** `data-testid` of the screen card. */ + cardTestId: string + /** Texts the screen must show. */ + texts: string[] + /** Names of the buttons the screen must offer. */ + buttons: string[] + /** Screenshot file name, without theme folder or extension. */ + screenshotName: string +} + +const SCREEN_FIXTURES: ScreenFixture[] = [ + { storyId: 'a-1-entry', screen: 'A1_ENTRY', cardTestId: 'screen-A1', texts: ['Sign in with passkey'], buttons: ['Continue with passkey', 'Use another login method'], screenshotName: 'pl-a1-entry' }, + { storyId: 'a-2-authenticating', screen: 'A2_AUTHENTICATING', cardTestId: 'screen-A2', texts: ['Sign in with passkey'], buttons: [], screenshotName: 'pl-a2-authenticating' }, + { storyId: 'a-3-new-account-warning', screen: 'A3_NEW_ACCOUNT_WARNING', cardTestId: 'screen-A3', texts: ['Create new account'], buttons: ['Create new account', 'Back to login methods'], screenshotName: 'pl-a3-new-account-warning' }, + { storyId: 'a-4-create-passkey', screen: 'A4_CREATE_PASSKEY', cardTestId: 'screen-A4', texts: ['Creating your passkey'], buttons: [], screenshotName: 'pl-a4-create-passkey' }, + { storyId: 'b-1-backup-prompt', screen: 'B1_BACKUP_PROMPT', cardTestId: 'screen-B1', texts: ['Back up your account with a passkey'], buttons: ['Set up passkey backup', 'Not now'], screenshotName: 'pl-b1-backup-prompt' }, + { storyId: 'b-2-backup-provider', screen: 'B2_BACKUP_PROVIDER', cardTestId: 'screen-B2', texts: ['Choose where to back up', 'Google Drive', 'iCloud'], buttons: ['Continue'], screenshotName: 'pl-b2-backup-provider' }, + { storyId: 'b-3-backup-progress', screen: 'B3_BACKUP_PROGRESS', cardTestId: 'screen-B3', texts: ['Backing up your account'], buttons: [], screenshotName: 'pl-b3-backup-progress' }, + { storyId: 'b-4-backup-success', screen: 'B4_BACKUP_SUCCESS', cardTestId: 'screen-B4', texts: ['Backup complete', 'Your account is backed up to Google Drive.'], buttons: ['Continue'], screenshotName: 'pl-b4-backup-success' }, + { storyId: 'c-1-restore-provider', screen: 'C1_RESTORE_PROVIDER', cardTestId: 'screen-C1', texts: ['Where is your backup?', 'Google Drive', 'iCloud'], buttons: [], screenshotName: 'pl-c1-restore-provider' }, + { storyId: 'c-2-restore-progress', screen: 'C2_RESTORE_PROGRESS', cardTestId: 'screen-C2', texts: ['Restoring your account'], buttons: [], screenshotName: 'pl-c2-restore-progress' }, + { storyId: 'c-3-restore-success', screen: 'C3_RESTORE_SUCCESS', cardTestId: 'screen-C3', texts: ['Welcome back', 'Your account has been restored.'], buttons: ['Continue'], screenshotName: 'pl-c3-restore-success' }, + { storyId: 'd-1-unsupported-browser', screen: 'D1_UNSUPPORTED_BROWSER', cardTestId: 'screen-D1', texts: [], buttons: ['Back to login methods'], screenshotName: 'pl-d1-unsupported-browser' }, + { storyId: 'd-2-passkey-cancelled', screen: 'D2_PASSKEY_CANCELLED', cardTestId: 'screen-D2', texts: ['Passkey request cancelled'], buttons: ['Retry', 'Create new account'], screenshotName: 'pl-d2-passkey-cancelled' }, + { storyId: 'd-3-backup-not-found', screen: 'D3_BACKUP_NOT_FOUND', cardTestId: 'screen-D3', texts: ['No backup found in Google Drive'], buttons: ['Try the other provider', 'Back'], screenshotName: 'pl-d3-backup-not-found' }, + { storyId: 'd-3-backup-not-found-single-provider', screen: 'D3_BACKUP_NOT_FOUND', cardTestId: 'screen-D3', texts: ['No backup found in Google Drive'], buttons: ['Back'], screenshotName: 'pl-d3-backup-not-found-single-provider' }, + { storyId: 'd-4-provider-auth-failed', screen: 'D4_PROVIDER_AUTH_FAILED', cardTestId: 'screen-D4', texts: ["Couldn't connect to Google Drive"], buttons: ['Try again', 'Choose another provider'], screenshotName: 'pl-d4-provider-auth-failed' }, + { storyId: 'd-5-backup-failed-new-user', screen: 'D5_BACKUP_FAILED', cardTestId: 'screen-D5', texts: ['Backup failed. Your account is not backed up yet.'], buttons: ['Retry'], screenshotName: 'pl-d5-backup-failed-new-user' }, + { storyId: 'd-5-backup-failed-existing-user', screen: 'D5_BACKUP_FAILED', cardTestId: 'screen-D5', texts: ['Backup failed. Your account is not backed up yet.'], buttons: ['Retry', 'Skip for now'], screenshotName: 'pl-d5-backup-failed-existing-user' }, + { storyId: 'd-6-decrypt-failed', screen: 'D6_DECRYPT_FAILED', cardTestId: 'screen-D6', texts: [], buttons: ['Try again', 'Back'], screenshotName: 'pl-d6-decrypt-failed' }, + { storyId: 'd-6-ambiguous-backup', screen: 'D6_DECRYPT_FAILED', cardTestId: 'screen-D6', texts: ['More than one backup matches this passkey'], buttons: ['Try again', 'Back'], screenshotName: 'pl-d6-ambiguous-backup' }, +] + +for (const theme of THEMES) { + test.describe(`${theme} theme`, () => { + for (const fixture of SCREEN_FIXTURES) { + test(`${fixture.screenshotName} renders ${fixture.screen}`, async ({ page }) => { + await page.setViewportSize({ width: 440, height: 800 }) + await page.goto(`${FIXTURE_STORY_PREFIX}${fixture.storyId}&viewMode=story&args=themeMode:${theme}`) + + const widgetRoot = page.getByTestId('passkey-login-widget') + await expect(widgetRoot).toHaveAttribute('data-screen', fixture.screen, { timeout: 20_000 }) + await expect(page.getByTestId(fixture.cardTestId)).toBeVisible() + for (const text of fixture.texts) { + await expect(widgetRoot.getByText(text, { exact: false }).first()).toBeVisible() + } + for (const buttonName of fixture.buttons) { + await expect(widgetRoot.getByRole('button', { name: buttonName, exact: true })).toBeVisible() + } + + await page.screenshot({ + path: `tests/widgets/passkey-login-widget/test-results/${theme}/${fixture.screenshotName}.png`, + fullPage: true, + }) + }) + } + }) +} diff --git a/tests/widgets/passkey-login-widget/test-results/dark/pl-a1-entry.png b/tests/widgets/passkey-login-widget/test-results/dark/pl-a1-entry.png new file mode 100644 index 00000000..545a5a88 Binary files /dev/null and b/tests/widgets/passkey-login-widget/test-results/dark/pl-a1-entry.png differ diff --git a/tests/widgets/passkey-login-widget/test-results/dark/pl-a2-authenticating.png b/tests/widgets/passkey-login-widget/test-results/dark/pl-a2-authenticating.png new file mode 100644 index 00000000..6d11edfa Binary files /dev/null and b/tests/widgets/passkey-login-widget/test-results/dark/pl-a2-authenticating.png differ diff --git a/tests/widgets/passkey-login-widget/test-results/dark/pl-a3-new-account-warning.png b/tests/widgets/passkey-login-widget/test-results/dark/pl-a3-new-account-warning.png new file mode 100644 index 00000000..23ee4720 Binary files /dev/null and b/tests/widgets/passkey-login-widget/test-results/dark/pl-a3-new-account-warning.png differ diff --git a/tests/widgets/passkey-login-widget/test-results/dark/pl-a4-create-passkey.png b/tests/widgets/passkey-login-widget/test-results/dark/pl-a4-create-passkey.png new file mode 100644 index 00000000..c3f8ae3f Binary files /dev/null and b/tests/widgets/passkey-login-widget/test-results/dark/pl-a4-create-passkey.png differ diff --git a/tests/widgets/passkey-login-widget/test-results/dark/pl-b1-backup-prompt.png b/tests/widgets/passkey-login-widget/test-results/dark/pl-b1-backup-prompt.png new file mode 100644 index 00000000..c2c55348 Binary files /dev/null and b/tests/widgets/passkey-login-widget/test-results/dark/pl-b1-backup-prompt.png differ diff --git a/tests/widgets/passkey-login-widget/test-results/dark/pl-b2-backup-provider.png b/tests/widgets/passkey-login-widget/test-results/dark/pl-b2-backup-provider.png new file mode 100644 index 00000000..c5fcc6b3 Binary files /dev/null and b/tests/widgets/passkey-login-widget/test-results/dark/pl-b2-backup-provider.png differ diff --git a/tests/widgets/passkey-login-widget/test-results/dark/pl-b3-backup-progress.png b/tests/widgets/passkey-login-widget/test-results/dark/pl-b3-backup-progress.png new file mode 100644 index 00000000..cb5f6dfc Binary files /dev/null and b/tests/widgets/passkey-login-widget/test-results/dark/pl-b3-backup-progress.png differ diff --git a/tests/widgets/passkey-login-widget/test-results/dark/pl-b4-backup-success.png b/tests/widgets/passkey-login-widget/test-results/dark/pl-b4-backup-success.png new file mode 100644 index 00000000..7570e816 Binary files /dev/null and b/tests/widgets/passkey-login-widget/test-results/dark/pl-b4-backup-success.png differ diff --git a/tests/widgets/passkey-login-widget/test-results/dark/pl-c1-restore-provider.png b/tests/widgets/passkey-login-widget/test-results/dark/pl-c1-restore-provider.png new file mode 100644 index 00000000..7b29805a Binary files /dev/null and b/tests/widgets/passkey-login-widget/test-results/dark/pl-c1-restore-provider.png differ diff --git a/tests/widgets/passkey-login-widget/test-results/dark/pl-c2-restore-progress.png b/tests/widgets/passkey-login-widget/test-results/dark/pl-c2-restore-progress.png new file mode 100644 index 00000000..0f723c84 Binary files /dev/null and b/tests/widgets/passkey-login-widget/test-results/dark/pl-c2-restore-progress.png differ diff --git a/tests/widgets/passkey-login-widget/test-results/dark/pl-c3-restore-success.png b/tests/widgets/passkey-login-widget/test-results/dark/pl-c3-restore-success.png new file mode 100644 index 00000000..af066a3b Binary files /dev/null and b/tests/widgets/passkey-login-widget/test-results/dark/pl-c3-restore-success.png differ diff --git a/tests/widgets/passkey-login-widget/test-results/dark/pl-d1-unsupported-browser.png b/tests/widgets/passkey-login-widget/test-results/dark/pl-d1-unsupported-browser.png new file mode 100644 index 00000000..5b1b7caf Binary files /dev/null and b/tests/widgets/passkey-login-widget/test-results/dark/pl-d1-unsupported-browser.png differ diff --git a/tests/widgets/passkey-login-widget/test-results/dark/pl-d2-passkey-cancelled.png b/tests/widgets/passkey-login-widget/test-results/dark/pl-d2-passkey-cancelled.png new file mode 100644 index 00000000..2786c9a9 Binary files /dev/null and b/tests/widgets/passkey-login-widget/test-results/dark/pl-d2-passkey-cancelled.png differ diff --git a/tests/widgets/passkey-login-widget/test-results/dark/pl-d3-backup-not-found-single-provider.png b/tests/widgets/passkey-login-widget/test-results/dark/pl-d3-backup-not-found-single-provider.png new file mode 100644 index 00000000..1a0f0a5c Binary files /dev/null and b/tests/widgets/passkey-login-widget/test-results/dark/pl-d3-backup-not-found-single-provider.png differ diff --git a/tests/widgets/passkey-login-widget/test-results/dark/pl-d3-backup-not-found.png b/tests/widgets/passkey-login-widget/test-results/dark/pl-d3-backup-not-found.png new file mode 100644 index 00000000..30a45e02 Binary files /dev/null and b/tests/widgets/passkey-login-widget/test-results/dark/pl-d3-backup-not-found.png differ diff --git a/tests/widgets/passkey-login-widget/test-results/dark/pl-d4-provider-auth-failed.png b/tests/widgets/passkey-login-widget/test-results/dark/pl-d4-provider-auth-failed.png new file mode 100644 index 00000000..8daab96f Binary files /dev/null and b/tests/widgets/passkey-login-widget/test-results/dark/pl-d4-provider-auth-failed.png differ diff --git a/tests/widgets/passkey-login-widget/test-results/dark/pl-d5-backup-failed-existing-user.png b/tests/widgets/passkey-login-widget/test-results/dark/pl-d5-backup-failed-existing-user.png new file mode 100644 index 00000000..a2b682dc Binary files /dev/null and b/tests/widgets/passkey-login-widget/test-results/dark/pl-d5-backup-failed-existing-user.png differ diff --git a/tests/widgets/passkey-login-widget/test-results/dark/pl-d5-backup-failed-new-user.png b/tests/widgets/passkey-login-widget/test-results/dark/pl-d5-backup-failed-new-user.png new file mode 100644 index 00000000..6fc5018b Binary files /dev/null and b/tests/widgets/passkey-login-widget/test-results/dark/pl-d5-backup-failed-new-user.png differ diff --git a/tests/widgets/passkey-login-widget/test-results/dark/pl-d6-ambiguous-backup.png b/tests/widgets/passkey-login-widget/test-results/dark/pl-d6-ambiguous-backup.png new file mode 100644 index 00000000..6aa24482 Binary files /dev/null and b/tests/widgets/passkey-login-widget/test-results/dark/pl-d6-ambiguous-backup.png differ diff --git a/tests/widgets/passkey-login-widget/test-results/dark/pl-d6-decrypt-failed.png b/tests/widgets/passkey-login-widget/test-results/dark/pl-d6-decrypt-failed.png new file mode 100644 index 00000000..d06e80f5 Binary files /dev/null and b/tests/widgets/passkey-login-widget/test-results/dark/pl-d6-decrypt-failed.png differ diff --git a/tests/widgets/passkey-login-widget/test-results/light/pl-a1-entry.png b/tests/widgets/passkey-login-widget/test-results/light/pl-a1-entry.png new file mode 100644 index 00000000..1d87a158 Binary files /dev/null and b/tests/widgets/passkey-login-widget/test-results/light/pl-a1-entry.png differ diff --git a/tests/widgets/passkey-login-widget/test-results/light/pl-a2-authenticating.png b/tests/widgets/passkey-login-widget/test-results/light/pl-a2-authenticating.png new file mode 100644 index 00000000..cbd6753d Binary files /dev/null and b/tests/widgets/passkey-login-widget/test-results/light/pl-a2-authenticating.png differ diff --git a/tests/widgets/passkey-login-widget/test-results/light/pl-a3-new-account-warning.png b/tests/widgets/passkey-login-widget/test-results/light/pl-a3-new-account-warning.png new file mode 100644 index 00000000..ae5e6509 Binary files /dev/null and b/tests/widgets/passkey-login-widget/test-results/light/pl-a3-new-account-warning.png differ diff --git a/tests/widgets/passkey-login-widget/test-results/light/pl-a4-create-passkey.png b/tests/widgets/passkey-login-widget/test-results/light/pl-a4-create-passkey.png new file mode 100644 index 00000000..a3b41121 Binary files /dev/null and b/tests/widgets/passkey-login-widget/test-results/light/pl-a4-create-passkey.png differ diff --git a/tests/widgets/passkey-login-widget/test-results/light/pl-b1-backup-prompt.png b/tests/widgets/passkey-login-widget/test-results/light/pl-b1-backup-prompt.png new file mode 100644 index 00000000..acffc14e Binary files /dev/null and b/tests/widgets/passkey-login-widget/test-results/light/pl-b1-backup-prompt.png differ diff --git a/tests/widgets/passkey-login-widget/test-results/light/pl-b2-backup-provider.png b/tests/widgets/passkey-login-widget/test-results/light/pl-b2-backup-provider.png new file mode 100644 index 00000000..4c52e5b8 Binary files /dev/null and b/tests/widgets/passkey-login-widget/test-results/light/pl-b2-backup-provider.png differ diff --git a/tests/widgets/passkey-login-widget/test-results/light/pl-b3-backup-progress.png b/tests/widgets/passkey-login-widget/test-results/light/pl-b3-backup-progress.png new file mode 100644 index 00000000..de248431 Binary files /dev/null and b/tests/widgets/passkey-login-widget/test-results/light/pl-b3-backup-progress.png differ diff --git a/tests/widgets/passkey-login-widget/test-results/light/pl-b4-backup-success.png b/tests/widgets/passkey-login-widget/test-results/light/pl-b4-backup-success.png new file mode 100644 index 00000000..f6c90ee1 Binary files /dev/null and b/tests/widgets/passkey-login-widget/test-results/light/pl-b4-backup-success.png differ diff --git a/tests/widgets/passkey-login-widget/test-results/light/pl-c1-restore-provider.png b/tests/widgets/passkey-login-widget/test-results/light/pl-c1-restore-provider.png new file mode 100644 index 00000000..d95cef89 Binary files /dev/null and b/tests/widgets/passkey-login-widget/test-results/light/pl-c1-restore-provider.png differ diff --git a/tests/widgets/passkey-login-widget/test-results/light/pl-c2-restore-progress.png b/tests/widgets/passkey-login-widget/test-results/light/pl-c2-restore-progress.png new file mode 100644 index 00000000..c958029a Binary files /dev/null and b/tests/widgets/passkey-login-widget/test-results/light/pl-c2-restore-progress.png differ diff --git a/tests/widgets/passkey-login-widget/test-results/light/pl-c3-restore-success.png b/tests/widgets/passkey-login-widget/test-results/light/pl-c3-restore-success.png new file mode 100644 index 00000000..acb8a5c7 Binary files /dev/null and b/tests/widgets/passkey-login-widget/test-results/light/pl-c3-restore-success.png differ diff --git a/tests/widgets/passkey-login-widget/test-results/light/pl-d1-unsupported-browser.png b/tests/widgets/passkey-login-widget/test-results/light/pl-d1-unsupported-browser.png new file mode 100644 index 00000000..03c77bb4 Binary files /dev/null and b/tests/widgets/passkey-login-widget/test-results/light/pl-d1-unsupported-browser.png differ diff --git a/tests/widgets/passkey-login-widget/test-results/light/pl-d2-passkey-cancelled.png b/tests/widgets/passkey-login-widget/test-results/light/pl-d2-passkey-cancelled.png new file mode 100644 index 00000000..fa0b69be Binary files /dev/null and b/tests/widgets/passkey-login-widget/test-results/light/pl-d2-passkey-cancelled.png differ diff --git a/tests/widgets/passkey-login-widget/test-results/light/pl-d3-backup-not-found-single-provider.png b/tests/widgets/passkey-login-widget/test-results/light/pl-d3-backup-not-found-single-provider.png new file mode 100644 index 00000000..78cc7542 Binary files /dev/null and b/tests/widgets/passkey-login-widget/test-results/light/pl-d3-backup-not-found-single-provider.png differ diff --git a/tests/widgets/passkey-login-widget/test-results/light/pl-d3-backup-not-found.png b/tests/widgets/passkey-login-widget/test-results/light/pl-d3-backup-not-found.png new file mode 100644 index 00000000..f49b183a Binary files /dev/null and b/tests/widgets/passkey-login-widget/test-results/light/pl-d3-backup-not-found.png differ diff --git a/tests/widgets/passkey-login-widget/test-results/light/pl-d4-provider-auth-failed.png b/tests/widgets/passkey-login-widget/test-results/light/pl-d4-provider-auth-failed.png new file mode 100644 index 00000000..a3368cf0 Binary files /dev/null and b/tests/widgets/passkey-login-widget/test-results/light/pl-d4-provider-auth-failed.png differ diff --git a/tests/widgets/passkey-login-widget/test-results/light/pl-d5-backup-failed-existing-user.png b/tests/widgets/passkey-login-widget/test-results/light/pl-d5-backup-failed-existing-user.png new file mode 100644 index 00000000..1b519206 Binary files /dev/null and b/tests/widgets/passkey-login-widget/test-results/light/pl-d5-backup-failed-existing-user.png differ diff --git a/tests/widgets/passkey-login-widget/test-results/light/pl-d5-backup-failed-new-user.png b/tests/widgets/passkey-login-widget/test-results/light/pl-d5-backup-failed-new-user.png new file mode 100644 index 00000000..c2a4b986 Binary files /dev/null and b/tests/widgets/passkey-login-widget/test-results/light/pl-d5-backup-failed-new-user.png differ diff --git a/tests/widgets/passkey-login-widget/test-results/light/pl-d6-ambiguous-backup.png b/tests/widgets/passkey-login-widget/test-results/light/pl-d6-ambiguous-backup.png new file mode 100644 index 00000000..e4983ac9 Binary files /dev/null and b/tests/widgets/passkey-login-widget/test-results/light/pl-d6-ambiguous-backup.png differ diff --git a/tests/widgets/passkey-login-widget/test-results/light/pl-d6-decrypt-failed.png b/tests/widgets/passkey-login-widget/test-results/light/pl-d6-decrypt-failed.png new file mode 100644 index 00000000..874d53ee Binary files /dev/null and b/tests/widgets/passkey-login-widget/test-results/light/pl-d6-decrypt-failed.png differ diff --git a/tests/widgets/passkey-login-widget/test-results/version-a1-desktop-dark.png b/tests/widgets/passkey-login-widget/test-results/version-a1-desktop-dark.png new file mode 100644 index 00000000..56c0cd1a Binary files /dev/null and b/tests/widgets/passkey-login-widget/test-results/version-a1-desktop-dark.png differ diff --git a/tests/widgets/passkey-login-widget/test-results/version-a1-desktop-light.png b/tests/widgets/passkey-login-widget/test-results/version-a1-desktop-light.png new file mode 100644 index 00000000..8195c35b Binary files /dev/null and b/tests/widgets/passkey-login-widget/test-results/version-a1-desktop-light.png differ diff --git a/tests/widgets/passkey-login-widget/test-results/version-a1-mobile-dark.png b/tests/widgets/passkey-login-widget/test-results/version-a1-mobile-dark.png new file mode 100644 index 00000000..387c15b5 Binary files /dev/null and b/tests/widgets/passkey-login-widget/test-results/version-a1-mobile-dark.png differ diff --git a/tests/widgets/passkey-login-widget/test-results/version-a1-mobile-light.png b/tests/widgets/passkey-login-widget/test-results/version-a1-mobile-light.png new file mode 100644 index 00000000..9b756478 Binary files /dev/null and b/tests/widgets/passkey-login-widget/test-results/version-a1-mobile-light.png differ diff --git a/tests/widgets/passkey-login-widget/version.spec.ts b/tests/widgets/passkey-login-widget/version.spec.ts new file mode 100644 index 00000000..d09b6642 --- /dev/null +++ b/tests/widgets/passkey-login-widget/version.spec.ts @@ -0,0 +1,24 @@ +import { readFileSync } from 'node:fs' +import { test } from '@playwright/test' +import { captureVersionScreenshot } from '../version-screenshot' + +const { version } = JSON.parse(readFileSync('packages/passkey-login-widget/package.json', 'utf-8')) as { + version: string +} + +// The entry screen is the first thing a user sees, so it carries the version label checked here. +test('PasskeyLoginWidget displays its package version', async ({ page }) => { + for (const themeName of ['dark', 'light']) { + for (const viewport of [ + { name: 'desktop', width: 1280, height: 900 }, + { name: 'mobile', width: 430, height: 932 }, + ]) { + await captureVersionScreenshot(page, [`Passkey Login v${version}`], { + url: `/iframe.html?id=qa-passkeyloginwidget-screen-fixtures--a-1-entry&viewMode=story&args=themeMode:${themeName}`, + path: `tests/widgets/passkey-login-widget/test-results/version-a1-${viewport.name}-${themeName}.png`, + width: viewport.width, + height: viewport.height, + }) + } + } +}) diff --git a/tests/widgets/passkey-login-widget/webauthnFixture.ts b/tests/widgets/passkey-login-widget/webauthnFixture.ts new file mode 100644 index 00000000..1caa05b8 --- /dev/null +++ b/tests/widgets/passkey-login-widget/webauthnFixture.ts @@ -0,0 +1,91 @@ +import type { CDPSession, Page } from '@playwright/test' + +/** Handle on a Chromium virtual authenticator that stores passkeys and answers the PRF extension. */ +export interface VirtualAuthenticator { + cdpSession: CDPSession + authenticatorId: string + /** Passkeys currently stored on the authenticator. */ + countCredentials(): Promise +} + +/** + * Attaches a virtual platform authenticator (resident keys, user verification, PRF / hmac-secret) + * through the Chrome DevTools Protocol. The widget's real WebAuthn calls run against it unchanged, + * so the same passkey derives the same PRF output across widget restarts. + */ +export async function addVirtualAuthenticator(page: Page): Promise { + const cdpSession = await page.context().newCDPSession(page) + await cdpSession.send('WebAuthn.enable') + const { authenticatorId } = await cdpSession.send('WebAuthn.addVirtualAuthenticator', { + options: { + protocol: 'ctap2', + ctap2Version: 'ctap2_1', + transport: 'internal', + hasResidentKey: true, + hasUserVerification: true, + isUserVerified: true, + automaticPresenceSimulation: true, + hasPrf: true, + }, + }) + return { + cdpSession, + authenticatorId, + async countCredentials() { + const { credentials } = await cdpSession.send('WebAuthn.getCredentials', { authenticatorId }) + return credentials.length + }, + } +} + +/** Ways the browser's WebAuthn can misbehave, for the error screens. */ +export type WebAuthnFailureMode = + /** The user dismisses the passkey dialog (`navigator.credentials.*` rejects with NotAllowedError). */ + | 'user-cancelled' + /** The browser has no WebAuthn at all (`PublicKeyCredential` is undefined). */ + | 'no-webauthn' + /** The authenticator answers without PRF results. */ + | 'no-prf' + +/** Replaces WebAuthn before the page loads, so the widget meets the chosen failure. */ +export async function stubWebAuthnFailure(page: Page, failureMode: WebAuthnFailureMode): Promise { + await page.addInitScript((mode) => { + if (mode === 'no-webauthn') { + Object.defineProperty(window, 'PublicKeyCredential', { value: undefined, configurable: true }) + return + } + const rejectAsCancelled = () => Promise.reject(new DOMException('The operation was cancelled.', 'NotAllowedError')) + if (mode === 'user-cancelled') { + navigator.credentials.create = rejectAsCancelled + navigator.credentials.get = rejectAsCancelled + return + } + // no-prf: a credential whose extension results carry no PRF output + const credentialWithoutPrf = { + id: 'stub', + type: 'public-key', + rawId: new Uint8Array([1, 2, 3, 4]).buffer, + getClientExtensionResults: () => ({}), + } + navigator.credentials.create = () => Promise.resolve(credentialWithoutPrf as unknown as Credential) + navigator.credentials.get = () => Promise.resolve(credentialWithoutPrf as unknown as Credential) + }, failureMode) +} + +/** + * Makes passkey creation report only `prf: { enabled: true }` without PRF results, like authenticators + * that evaluate PRF at assertion time only. The widget must then run a follow-up `get()`. + * Call after `addVirtualAuthenticator` so the wrapped `create` still reaches the virtual authenticator. + */ +export async function stubPrfEnabledOnlyAtRegistration(page: Page): Promise { + await page.addInitScript(() => { + const createWithAuthenticator = navigator.credentials.create.bind(navigator.credentials) + navigator.credentials.create = async (options) => { + const credential = (await createWithAuthenticator(options)) as PublicKeyCredential + Object.defineProperty(credential, 'getClientExtensionResults', { + value: () => ({ prf: { enabled: true } }), + }) + return credential + } + }) +}