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
5 changes: 5 additions & 0 deletions packages/functional-tests/lib/passkeyPolyfill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,11 @@ export class PasskeyPolyfill {
}));
}

/** Returns raw credentials (with private keys) for signing later assertions. */
getCredentialObjects(): VirtualCredential[] {
return this.credentials.slice();
}

/** Clear all minted credentials and reset to `pending` mode. */
async cleanup() {
this.credentials = [];
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
/* 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/. */

// Browser-driven Sync passkey signin + password-fallback flow: enroll a
// passkey, simulate a new-device passkey ceremony via auth-client, seed the
// resulting session, then drive /signin_passkey_fallback from a fresh browser.

import { Page } from '@playwright/test';
import { FirefoxCommand } from '../../lib/channels';
import { expect, test } from '../../lib/fixtures/standard';
import { syncDesktopOAuthQueryParams } from '../../lib/query-params';
import {
VirtualAuthenticator,
VirtualCredential,
} from '../../../../libs/accounts/passkey/src/lib/virtual-authenticator';
import { BaseTarget } from '../../lib/targets/base';

async function seedAccountInLocalStorage(
page: Page,
args: { uid: string; sessionToken: string; email: string }
) {
await page.evaluate(({ uid, sessionToken, email }) => {
const account = { uid, sessionToken, email, verified: true };
window.localStorage.setItem(
'__fxa_storage.accounts',
JSON.stringify({ [uid]: account })
);
window.localStorage.setItem(
'__fxa_storage.currentAccountUid',
JSON.stringify(uid)
);
}, args);
}

async function simulateNewDevicePasskeyAuth(
target: BaseTarget,
credential: VirtualCredential
) {
const origin = target.contentServerUrl;
const rpId = new URL(target.contentServerUrl).hostname;
const authStart = await target.authClient.beginPasskeyAuthentication();
const assertion = VirtualAuthenticator.createAssertionResponse(credential, {
challenge: authStart.challenge,
origin,
rpId,
});
return target.authClient.completePasskeyAuthentication(
assertion as any,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Could drop as any or use the actual type PublicKeyCredentialJSON

authStart.challenge,
{ service: 'sync' }
);
}

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

test('full flow: enable passkey → fallback page submit signs into Sync', async ({
target,
syncOAuthBrowserPages: {
page,
signin,
signinTokenCode,
settings,
settingsPasskeyAdd,
connectAnotherDevice,
},
testAccountTracker,
}) => {
const { email, password } = await testAccountTracker.signUpSync();

await signin.goto('/authorization', syncDesktopOAuthQueryParams);
await signin.fillOutEmailFirstForm(email);
await signin.fillOutPasswordForm(password);
await page.waitForURL(/signin_token_code/);
const tokenCode = await target.emailClient.getVerifyLoginCode(email);
await signinTokenCode.fillOutCodeForm(tokenCode);
await expect(connectAnotherDevice.fxaConnected).toBeVisible();

await settings.goto();
await expect(settings.settingsHeading).toBeVisible();
await expect(settings.passkey.status).toHaveText('Not set');

await settingsPasskeyAdd.initPasskeys(page);
await settingsPasskeyAdd.passkeyAuth.success(async () => {
await settings.passkey.createButton.click();
await settings.confirmMfaGuard(email);
});
await expect(settings.passkey.status).toHaveText('Enabled');

const [credential] =
settingsPasskeyAdd.passkeyAuth.getCredentialObjects();
const authFinish = await simulateNewDevicePasskeyAuth(target, credential);
expect(authFinish.requiresPasswordForSync).toBe(true);

// Simulate a fresh device so the fallback page hydrates from the seeded
// passkey session, not leftover state from the initial Sync OAuth flow.
await page.context().clearCookies();
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will need follow-up, but figured a net positive to have something exercising the flow end to end. The test simulates a verified passkey login, seeds the resulting session into localStorage, then signin_passkey_fallback from the browser.

await page.evaluate(() => {
localStorage.clear();
sessionStorage.clear();
});
await seedAccountInLocalStorage(page, {
uid: authFinish.uid,
sessionToken: authFinish.sessionToken,
email,
});

await page.goto(
`${target.contentServerUrl}/signin_passkey_fallback?${syncDesktopOAuthQueryParams}`
);

await expect(page.getByTestId('passkey-fallback-email')).toHaveText(
email
);

await page.getByTestId('password-input-field').fill(password);
await page.getByTestId('continue-button').click();

await signin.checkWebChannelMessageScopes(
FirefoxCommand.OAuthLogin,
'https://identity.mozilla.com/apps/oldsync'
);
});

test('shows an error banner when the password is wrong', async ({
target,
syncOAuthBrowserPages: {
page,
signin,
signinTokenCode,
settings,
settingsPasskeyAdd,
connectAnotherDevice,
},
testAccountTracker,
}) => {
const { email, password } = await testAccountTracker.signUpSync();

await signin.goto('/authorization', syncDesktopOAuthQueryParams);
await signin.fillOutEmailFirstForm(email);
await signin.fillOutPasswordForm(password);
await page.waitForURL(/signin_token_code/);
const tokenCode = await target.emailClient.getVerifyLoginCode(email);
await signinTokenCode.fillOutCodeForm(tokenCode);
await expect(connectAnotherDevice.fxaConnected).toBeVisible();

await settings.goto();
await settingsPasskeyAdd.initPasskeys(page);
await settingsPasskeyAdd.passkeyAuth.success(async () => {
await settings.passkey.createButton.click();
await settings.confirmMfaGuard(email);
});
await expect(settings.passkey.status).toHaveText('Enabled');

const [credential] =
settingsPasskeyAdd.passkeyAuth.getCredentialObjects();
const authFinish = await simulateNewDevicePasskeyAuth(target, credential);

await page.context().clearCookies();
await page.evaluate(() => {
localStorage.clear();
sessionStorage.clear();
});
await seedAccountInLocalStorage(page, {
uid: authFinish.uid,
sessionToken: authFinish.sessionToken,
email,
});

await page.goto(
`${target.contentServerUrl}/signin_passkey_fallback?${syncDesktopOAuthQueryParams}`
);
await page
.getByTestId('password-input-field')
.fill('definitely-the-wrong-password');
await page.getByTestId('continue-button').click();

await expect(page.getByText(/password/i).first()).toBeVisible();
});
});
});
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,
Comment thread
vpomerleau marked this conversation as resolved.
requiresPasswordForSync,
hasPassword,
Expand Down
81 changes: 81 additions & 0 deletions packages/fxa-auth-server/test/remote/passkeys.in.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -332,3 +332,84 @@ describe('#integration - remote passkey authentication', () => {
}).rejects.toBeDefined();
});
});

describe('#integration - remote passkey-then-password fallback via /session/reauth', () => {
let authEmail: string;
let authClient: any;
let registeredCred: VirtualCredential;

async function authenticateWithPasskey(): Promise<string> {
const startResult = await authClient.api.doRequest(
'POST',
`${authClient.api.baseURL}/passkey/authentication/start`,
null,
{}
);
const assertionResponse = VirtualAuthenticator.createAssertionResponse(
registeredCred,
{
challenge: startResult.challenge,
origin: passkeyOrigin,
rpId: passkeyRpId,
}
);
const finishResult = await authClient.api.doRequest(
'POST',
`${authClient.api.baseURL}/passkey/authentication/finish`,
null,
{ response: assertionResponse, challenge: startResult.challenge }
);
return finishResult.sessionToken;
}

beforeEach(async () => {
authEmail = server.uniqueEmail();
authClient = await Client.createAndVerify(
server.publicUrl,
authEmail,
password,
server.mailbox,
{ version: 'V2' }
);

const accessToken = await getMfaAccessTokenForPasskey(authClient);
const options = await authClient.api.doRequestWithBearerToken(
'POST',
`${authClient.api.baseURL}/passkey/registration/start`,
accessToken,
{}
);
registeredCred = VirtualAuthenticator.createCredential();
const registrationResponse = VirtualAuthenticator.createAttestationResponse(
registeredCred,
{
challenge: options.challenge,
origin: passkeyOrigin,
rpId: passkeyRpId,
}
);
await authClient.api.doRequestWithBearerToken(
'POST',
`${authClient.api.baseURL}/passkey/registration/finish`,
accessToken,
{ response: registrationResponse, challenge: options.challenge }
);
});

it('passkey-verified session can call /session/reauth?keys=true to obtain a keyFetchToken', async () => {
const passkeySessionTokenHex = await authenticateWithPasskey();
const reauthClient = await Client.login(
server.publicUrl,
authEmail,
password,
{ version: 'V2' }
);
reauthClient.sessionToken = passkeySessionTokenHex;

await reauthClient.reauth({ keys: true });

const keyFetchToken =
reauthClient.keyFetchTokenVersion2 || reauthClient.keyFetchToken;
expect(keyFetchToken).toMatch(/^[0-9a-f]{64}$/);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,7 @@ const FRONTEND_ROUTES = [
'signin_passwordless_code',
'oauth/signin_passwordless_code',
'signin_confirmed',
// TODO: FXA-13100 - Uncomment when passkey fallback is fully implemented
// 'signin_passkey_fallback',
'signin_passkey_fallback',
'signin_permissions',
'signin_reported',
'signin_unblock',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ const getReactRouteGroups = (showReactApp, reactRoute) => {
'signin',
'oauth/signin',
'oauth/force_auth',
'signin_passkey_fallback',
'signin_token_code',
'signin_totp_code',
'signin_reported',
Expand Down
9 changes: 6 additions & 3 deletions packages/fxa-settings/src/components/App/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -111,8 +111,8 @@ const SigninBounced = lazy(() => import('../../pages/Signin/SigninBounced'));
const SigninConfirmed = lazy(
() => import('../../pages/Signin/SigninConfirmed')
);
const SigninPasskeyFallback = lazy(
() => import('../../pages/Signin/SigninPasskeyFallback')
const SigninPasskeyFallbackContainer = lazy(
() => import('../../pages/Signin/SigninPasskeyFallback/container')
);
const SigninRecoveryCodeContainer = lazy(
() => import('../../pages/Signin/SigninRecoveryCode/container')
Expand Down Expand Up @@ -698,7 +698,10 @@ const AuthAndAccountSetupRoutes = ({
path="/signin_confirmed/*"
{...{ isSignedIn, serviceName, integration }}
/>
<SigninPasskeyFallback path="/signin_passkey_fallback/*" />
<SigninPasskeyFallbackContainer
path="/signin_passkey_fallback/*"
{...{ integration }}
/>
<SigninRecoveryChoiceContainer
path="/signin_recovery_choice/*"
{...{ integration, setCurrentSplitLayout }}
Expand Down
Loading
Loading