Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 43 additions & 1 deletion packages/functional-tests/lib/passkeyPolyfill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,29 @@ import {
VirtualAuthenticator,
type VirtualCredential,
} from '../../../libs/accounts/passkey/src/lib/virtual-authenticator';
// Pull the canonical WebAuthn error name list from fxa-settings so the
// polyfill can never drift from the categoriser. The module is a pure
// constants file (no imports, no decorators) — Babel-safe.
import { ERROR_MAP } from '../../fxa-settings/src/lib/passkeys/webauthn-errors';
import type {
RegistrationResponseJSON,
AuthenticationResponseJSON,
} from '@simplewebauthn/server';

/**
* DOMException names the polyfill passes through unchanged. Derived from the
* canonical {@link ERROR_MAP}, with two intentional exclusions:
* - `TypeError`: Playwright surfaces serialisation hiccups as TypeError; we
* deliberately collapse those to NotAllowedError so they categorise as a
* user-cancel rather than landing in the "unexpected" bucket.
* - `AuthenticatorAlreadyRegistered`: synthetic key the categoriser
* fabricates during registration with excludeCredentials. The browser
* never throws this name as a DOMException.
*/
const WEBAUTHN_DOM_EXCEPTION_NAMES = Object.keys(ERROR_MAP).filter(
(name) => name !== 'TypeError' && name !== 'AuthenticatorAlreadyRegistered'
);

type Mode = 'pending' | 'success' | 'cancel';
type Trigger = () => Promise<void>;
type PostCheck = () => Promise<void>;
Expand Down Expand Up @@ -111,6 +129,23 @@ export class PasskeyPolyfill {
}
}

/**
* Simulate a successful assertion ceremony (sign-in). Unlike {@link success},
* does not wait for a new credential to be added — assertions reuse existing
* credentials minted by prior `success()` calls. The trigger is responsible
* for awaiting whatever post-assertion side effect indicates completion
* (typically a navigation).
*/
async assertion(trigger: Trigger) {
const previous = this.mode;
this.mode = 'success';
try {
await trigger();
} finally {
this.mode = previous;
}
}

/**
* Simulate a user-cancelled browser prompt: switch to `cancel` mode so the
* polyfill rejects the ceremony with a `NotAllowedError` DOMException, then
Expand Down Expand Up @@ -283,12 +318,19 @@ const BROWSER_POLYFILL = `(() => {
'NotAllowedError'
);
}
// Templated from WEBAUTHN_DOM_EXCEPTION_NAMES at module load —
// see that constant for the source-of-truth and exclusions.
const WEBAUTHN_ERROR_NAMES = ${JSON.stringify(WEBAUTHN_DOM_EXCEPTION_NAMES)};
try {
return await fn(options, window.location.origin);
} catch (err) {
// exposeFunction serialises Errors; rebuild a DOMException so the
// fxa-settings webauthn-errors handler categorises correctly.
const errName = (err && err.name) || 'NotAllowedError';
const incoming = err && err.name;
const errName =
incoming && WEBAUTHN_ERROR_NAMES.indexOf(incoming) !== -1
? incoming
: 'NotAllowedError';
const msg = (err && err.message) || 'WebAuthn operation failed';
throw new DOMException(msg, errName);
}
Expand Down
6 changes: 5 additions & 1 deletion packages/functional-tests/pages/signin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,11 @@ export class SigninPage extends PasskeyPage {
}

get signInButton() {
return this.page.getByRole('button', { name: 'Sign in' });
return this.page.getByRole('button', { name: 'Sign in', exact: true });
}

get passkeySigninButton() {
return this.page.getByRole('button', { name: 'Sign in with passkey' });
}

get signinBouncedHeading() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,19 @@
import { expect, test } from '../../lib/fixtures/standard';

/**
* These tests use a demo site at https://passkeys.eu which is a simple
* webauthn/passkeys registration and authentication demo.
* Reference-only suite. The in-house passkey functional tests live at
* `tests/settings/passkey.spec.ts` — those are what runs in CI against our
* own surfaces.
*
* The site allows any origin to register and authenticate passkeys, making it
* suitable for testing purposes and making sure the `PasskeyVirtualAuthenticator`
* wrapper works as expected. Much of the logic and complexity here can be moved
* into the signIn/signUp pages, but for now this keeps things simple and readable
* This file exercises the `PasskeyVirtualAuthenticator` wrapper against the
* https://passkeys.eu demo site (any-origin registration/authentication).
* It cannot run in CI because the third-party site blocks us, so the suite
* is skipped wholesale. Kept as a worked example of the CDP /
* virtual-authenticator setup pattern.
*/

test.describe('severity-1 #smoke', () => {
// Skip all tests in this suite because they cannot run in CI (third party site blocks us).
// But, leaving here for reference until we implement Passkey support and tests in our own app.
test.skip(true, 'Tests use a demo site and are not part of smoke suite');
test.skip(true, 'Reference-only — see tests/settings/passkey.spec.ts');
/**
* Passkeys have a potential to collide with other tests due to the use of
* CDP sessions and virtual authenticators. To avoid this, we run all passkey
Expand Down
197 changes: 197 additions & 0 deletions packages/functional-tests/tests/passkeyAuth/passkey-signin.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */

/*
* Passkey sign-in flow. Requires the same flags as the registration suite
* (FEATURE_FLAGS_PASSKEYS_ENABLED, FEATURE_FLAGS_PASSKEY_REGISTRATION_ENABLED)
* plus FEATURE_FLAGS_PASSKEY_AUTHENTICATION_ENABLED on the content server, and
* passkeys.enabled=true on the auth-server. The suite is skipped at runtime
* if any of those report as disabled.
*/

import { Page, expect, test } from '../../lib/fixtures/standard';
import { BaseTarget, Credentials } from '../../lib/targets/base';
import { TestAccountTracker } from '../../lib/testAccountTracker';
import { SettingsPage } from '../../pages/settings';
import { SettingsPasskeyAddPage } from '../../pages/settings/passkey';
import { SigninPage } from '../../pages/signin';

test.describe('severity-1 #smoke', () => {
test.describe('passkey sign-in', () => {
test.beforeEach(async ({ pages: { configPage } }) => {
const config = await configPage.getConfig();
test.skip(
!config.featureFlags?.passkeysEnabled ||
!config.featureFlags?.passkeyRegistrationEnabled ||
!config.featureFlags?.passkeyAuthenticationEnabled,
'Passkey feature flags are not enabled'
);
});

test('signs in with a registered passkey from email-first', async ({
target,
pages: { page, settings, settingsPasskeyAdd, signin },
testAccountTracker,
}) => {
await setUpAccountWithPasskey({
target,
page,
settings,
settingsPasskeyAdd,
signin,
testAccountTracker,
});
await clearSession(page);
await page.goto(target.contentServerUrl);

await settingsPasskeyAdd.passkeyAuth.assertion(async () => {
await signin.passkeySigninButton.click();
await page.waitForURL(/settings/);
});

await expect(settings.settingsHeading).toBeVisible();
});

test('signs in with a registered passkey from /signin after submitting an email', async ({
target,
pages: { page, settings, settingsPasskeyAdd, signin },
testAccountTracker,
}) => {
const { email } = await setUpAccountWithPasskey({
target,
page,
settings,
settingsPasskeyAdd,
signin,
testAccountTracker,
});
await clearSession(page);
await page.goto(target.contentServerUrl);

// Submit the email on email-first to land on /signin's password form,
// then choose passkey from there instead of typing the password.
await signin.fillOutEmailFirstForm(email);
await expect(signin.passwordFormHeading).toBeVisible();

await settingsPasskeyAdd.passkeyAuth.assertion(async () => {
await signin.passkeySigninButton.click();
await page.waitForURL(/settings/);
});

await expect(settings.settingsHeading).toBeVisible();
});

test('shows an error banner when the user cancels the WebAuthn prompt', async ({
target,
pages: { page, settings, settingsPasskeyAdd, signin },
testAccountTracker,
}) => {
await setUpAccountWithPasskey({
target,
page,
settings,
settingsPasskeyAdd,
signin,
testAccountTracker,
});
await clearSession(page);
await page.goto(target.contentServerUrl);

const signinUrl = page.url();

await settingsPasskeyAdd.passkeyAuth.fail(
async () => {
await signin.passkeySigninButton.click();
},
async () => {
// Banner renders with role="alert" for error type. Match by role
// and assert it contains *some* mention of the failed sign-in to
// avoid coupling to the exact localized string.
await expect(page.getByRole('alert')).toContainText(
/Sign-in with passkey failed/i
);
}
);

// The hook keeps the user on the email-first page after a cancelled
// ceremony so they can retry or choose another method.
expect(page.url()).toBe(signinUrl);
});

test('shows the "passkey not recognized" banner when the server no longer knows the credential', async ({
target,
pages: { page, settings, settingsPasskeyAdd, signin },
testAccountTracker,
}) => {
const { email } = await setUpAccountWithPasskey({
target,
page,
settings,
settingsPasskeyAdd,
signin,
testAccountTracker,
});

// Delete the passkey server-side; the polyfill keeps the credential in
// memory so a sign-in attempt still produces an assertion for the now
// unknown credentialId — exactly the divergence PASSKEY_NOT_FOUND covers
// (e.g. user deleted from another device, authenticator retained it).
await settings.deletePasskey(email);
await expect(settings.passkey.status).toHaveText('Not set');

await clearSession(page);
await page.goto(target.contentServerUrl);

await settingsPasskeyAdd.passkeyAuth.assertion(async () => {
await signin.passkeySigninButton.click();
await expect(page.getByRole('alert')).toContainText(
/Passkey not recognized/i
);
});
});
});
});

/**
* Signs up a fresh account, signs in with password, then registers a passkey
* via Settings. Leaves the user on the Settings page with a polyfill-minted
* credential available to subsequent sign-in attempts.
*/
async function setUpAccountWithPasskey({
target,
page,
settings,
settingsPasskeyAdd,
signin,
testAccountTracker,
}: {
target: BaseTarget;
page: Page;
settings: SettingsPage;
settingsPasskeyAdd: SettingsPasskeyAddPage;
signin: SigninPage;
testAccountTracker: TestAccountTracker;
}): Promise<Credentials> {
const credentials = await testAccountTracker.signUp();
await page.goto(target.contentServerUrl);
await signin.fillOutEmailFirstForm(credentials.email);
await signin.fillOutPasswordForm(credentials.password);
await page.waitForURL(/settings/);
await expect(settings.settingsHeading).toBeVisible();

await settingsPasskeyAdd.registerNewPasskey(settings, credentials.email);

return credentials;
}

/**
* Drops cookies and localStorage so the next visit to the content server is
* treated as fully signed-out. The page-level passkey polyfill (installed via
* `page.addInitScript`) survives the clear, so previously minted credentials
* remain discoverable in the next ceremony.
*/
async function clearSession(page: Page) {
await page.context().clearCookies();
await page.evaluate(() => localStorage.clear());
}
14 changes: 9 additions & 5 deletions packages/fxa-auth-server/lib/routes/passkeys.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,9 +132,10 @@ describe('passkeys routes', () => {
emailVerified: true,
verifierSetAt: 1234567890,
}),
createPasskeyVerifiedSessionToken: jest
.fn()
.mockResolvedValue({ id: 'new-session-token-id' }),
createPasskeyVerifiedSessionToken: jest.fn().mockResolvedValue({
id: 'new-session-token-id',
data: 'new-session-token-data',
}),
securityEvent: jest.fn().mockResolvedValue(undefined),
};

Expand Down Expand Up @@ -1044,7 +1045,7 @@ describe('passkeys routes', () => {
);
expect(result).toEqual({
uid: UID,
sessionToken: 'new-session-token-id',
sessionToken: 'new-session-token-data',
verified: true,
requiresPasswordForSync: false,
hasPassword: true,
Expand Down Expand Up @@ -1238,7 +1239,10 @@ describe('passkeys routes', () => {
mockRequest as any
);

expect(result).toEqual({ id: 'new-session-token-id' });
expect(result).toEqual({
id: 'new-session-token-id',
data: 'new-session-token-data',
});
expect(statsd.increment).toHaveBeenCalledWith(
'passkeys.createSessionToken.success'
);
Expand Down
4 changes: 2 additions & 2 deletions packages/fxa-auth-server/lib/routes/passkeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ interface DB {
uaOSVersion?: string;
uaDeviceType?: string;
uaFormFactor?: string;
}): Promise<{ id: string }>;
}): Promise<{ id: string; data: string }>;
/** Records a security event in the audit log. */
securityEvent: (arg: any) => Promise<void>;
}
Expand Down Expand Up @@ -429,7 +429,7 @@ export class PasskeyHandler {

return {
uid,
sessionToken: sessionToken.id,
sessionToken: sessionToken.data,
verified: true,
requiresPasswordForSync,
hasPassword,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@
"paymentsNextSubscriptionManagement": true,
"passkeysEnabled": true,
"passkeyRegistrationEnabled": true,
"passkeyAuthenticationEnabled": false,
"passkeyAuthenticationEnabled": true,
"passwordlessEnabled": true
},
"darkMode": {
Expand Down
4 changes: 4 additions & 0 deletions packages/fxa-settings/src/components/App/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -572,6 +572,10 @@ const AuthAndAccountSetupRoutes = ({
path="/post_verify/third_party_auth/set_password/*"
{...{ flowQueryParams, integration, useFxAStatusResult }}
/>
<SetPasswordContainer
path="/post_verify/passkey/set_password/*"
{...{ flowQueryParams, integration, useFxAStatusResult }}
/>
<ServiceWelcome
path="/post_verify/service_welcome/*"
{...{ integration }}
Expand Down
Loading
Loading