Skip to content

Commit 975edae

Browse files
committed
test(e2e): add WebAuthn passkey Playwright coverage
Adds a same-origin proxy (example/vite.config.ts) so the example app can actually exercise passkeys locally: authorizer's WebAuthn RP config derives its permitted origin from the *backend's own* request host (internal/authenticators/webauthn/webauthn.go's newRP), matching web/app's real deployment where the frontend is bundled into the same origin as the API. The example app's separate :5173/:8080 origins don't satisfy that by default - confirmed by a real "failed to verify passkey registration" error from the backend's origin check before this fix. Proxying /graphql and /v1 through Vite's dev server (default Host-header passthrough) makes the browser's actual origin match what the backend expects, with zero backend changes. Every other MFA method already worked fine across origins; only WebAuthn needs this. Also adds a minimal settings page (previously nothing in the example app exercised the settings-mode usage of AuthorizerMFASetup / AuthorizerPasskeyRegister at all - passkey enrollment isn't reachable from the login-time offer screen by design, since registration needs a bearer token that doesn't exist in the withheld-token state). New tests (CDP virtual authenticator, no real hardware needed): - register a passkey in settings, then log in with it as the primary factor - exercises the full registration + primary-login ceremony end to end. - passkey login button is hidden when MFA is enforced - regression guard for the is_mfa_enforced fix from earlier this session. Full suite: 5/5 passing against live servers.
1 parent 9910e37 commit 975edae

7 files changed

Lines changed: 162 additions & 1 deletion

File tree

example/e2e/mfa-passkey.spec.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { test, expect } from '@playwright/test';
2+
import { addVirtualAuthenticator } from './webauthn';
3+
4+
test('register a passkey in settings, then log in with it as the primary factor', async ({
5+
page,
6+
}) => {
7+
await addVirtualAuthenticator(page);
8+
9+
const email = `pw-passkey-${Date.now()}@example.com`;
10+
const password = 'TestPass123!';
11+
12+
await page.goto('/');
13+
await page.getByText('Sign Up', { exact: true }).click();
14+
await page.locator('#authorizer-sign-up-given_name').fill('Playwright');
15+
await page.locator('#authorizer-sign-up-family_name').fill('Test');
16+
await page.locator('#authorizer-sign-up-email_or_phone_number').fill(email);
17+
await page.locator('#authorizer-sign-up-password').fill(password);
18+
await page.locator('#authorizer-sign-up-confirmPassword').fill(password);
19+
await page.getByRole('button', { name: 'Sign Up' }).click();
20+
21+
// Passkey is never offered on the login-time offer screen (it needs a
22+
// bearer token webauthn_registration_options/_verify require, which
23+
// doesn't exist in the withheld-token state) - skip to reach the
24+
// dashboard, then register the passkey from settings instead.
25+
await page.getByRole('button', { name: 'Skip for now' }).click();
26+
await expect(page.getByText('Hey 👋,')).toBeVisible({ timeout: 10_000 });
27+
28+
await page.getByRole('link', { name: 'Manage sign-in methods' }).click();
29+
await page.getByRole('button', { name: 'Set up' }).click();
30+
await page.getByRole('button', { name: 'Add a passkey' }).click();
31+
// AuthorizerMFASetup wires onSuccess={backToList}, so a successful
32+
// registration immediately returns to the method list - there's no
33+
// stable success message to assert on, only the absence of an error and
34+
// the navigation back.
35+
await expect(
36+
page.getByRole('heading', { name: 'Manage sign-in methods' }),
37+
).toBeVisible({ timeout: 10_000 });
38+
await expect(page.getByText(/failed to verify passkey/i)).toHaveCount(0);
39+
40+
await page.getByRole('link', { name: 'Back to dashboard' }).click();
41+
await page.getByText('Logout', { exact: true }).click();
42+
43+
await page.getByRole('button', { name: 'Sign in with a passkey' }).click();
44+
// The just-registered credential satisfies the account's MFA requirement
45+
// (webauthn credentials count as a verified factor) and setup was
46+
// skipped earlier, so this must log straight in - no MFA screen.
47+
await expect(page.getByText('Hey 👋,')).toBeVisible({ timeout: 10_000 });
48+
});
49+
50+
test('passkey login button is hidden when MFA is enforced', async ({
51+
page,
52+
}) => {
53+
const patchMeta = (json: any) => {
54+
const meta = json?.data?.meta ?? json;
55+
if (meta && typeof meta === 'object' && 'is_mfa_enforced' in meta) {
56+
meta.is_mfa_enforced = true;
57+
}
58+
return json;
59+
};
60+
61+
await page.route('**/v1/meta', async (route) => {
62+
const response = await route.fetch();
63+
const json = patchMeta(await response.json());
64+
await route.fulfill({ response, json });
65+
});
66+
await page.route('**/graphql', async (route) => {
67+
const postData = route.request().postDataJSON?.();
68+
if (postData?.operationName !== 'meta') {
69+
await route.continue();
70+
return;
71+
}
72+
const response = await route.fetch();
73+
const json = patchMeta(await response.json());
74+
await route.fulfill({ response, json });
75+
});
76+
77+
await page.goto('/');
78+
await page
79+
.locator('#authorizer-login-email-or-phone-number')
80+
.waitFor({ state: 'visible', timeout: 10_000 });
81+
await expect(
82+
page.getByRole('button', { name: 'Sign in with a passkey' }),
83+
).toHaveCount(0);
84+
});

example/e2e/webauthn.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import type { Page } from '@playwright/test';
2+
3+
// Chrome DevTools Protocol's WebAuthn domain: a software authenticator the
4+
// browser treats as a real platform authenticator (Touch ID/Windows Hello
5+
// equivalent), so registration/assertion ceremonies complete without any
6+
// real biometric hardware or user prompt - the standard way to test WebAuthn
7+
// flows in CI. isUserVerified: true auto-approves the "verify it's you" step
8+
// a real authenticator would otherwise block on.
9+
export async function addVirtualAuthenticator(page: Page) {
10+
const client = await page.context().newCDPSession(page);
11+
await client.send('WebAuthn.enable');
12+
const { authenticatorId } = await client.send(
13+
'WebAuthn.addVirtualAuthenticator',
14+
{
15+
options: {
16+
protocol: 'ctap2',
17+
transport: 'internal',
18+
hasResidentKey: true,
19+
hasUserVerification: true,
20+
isUserVerified: true,
21+
},
22+
},
23+
);
24+
return { client, authenticatorId };
25+
}

example/src/App.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { useAuthorizer } from 'authorizer-react';
33
import Dashboard from './pages/dashboard';
44
import Login from './pages/login';
55
import ResetPassword from './pages/resetPassword';
6+
import Settings from './pages/settings';
67

78
function App() {
89
const { token, loading } = useAuthorizer();
@@ -15,6 +16,7 @@ function App() {
1516
return (
1617
<Routes>
1718
<Route path="/" element={<Dashboard />} />
19+
<Route path="/settings" element={<Settings />} />
1820
</Routes>
1921
);
2022
}

example/src/index.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,11 @@ ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
2222
<BrowserRouter>
2323
<AuthorizerProvider
2424
config={{
25-
authorizerURL: 'http://localhost:8080',
25+
// Proxied to the real backend on :8080 by vite.config.ts -
26+
// same-origin is required for WebAuthn/passkey testing (see
27+
// that file's comment); every other flow works the same way
28+
// regardless.
29+
authorizerURL: window.location.origin,
2630
redirectURL: window.location.origin,
2731
}}
2832
>

example/src/pages/dashboard.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import * as React from 'react';
2+
import { Link } from 'react-router-dom';
23
import { useAuthorizer } from 'authorizer-react';
34

45
const Dashboard: React.FC = () => {
@@ -15,6 +16,10 @@ const Dashboard: React.FC = () => {
1516
</a>
1617
</p>
1718

19+
<p>
20+
<Link to="/settings">Manage sign-in methods</Link>
21+
</p>
22+
1823
<br />
1924
{loading ? (
2025
<h3>Processing....</h3>

example/src/pages/settings.tsx

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import * as React from 'react';
2+
import { Link } from 'react-router-dom';
3+
import { AuthorizerMFASetup } from 'authorizer-react';
4+
5+
const Settings: React.FC = () => {
6+
return (
7+
<div>
8+
<h1>Manage sign-in methods</h1>
9+
<AuthorizerMFASetup
10+
availableMfaMethods={{
11+
totp: false,
12+
passkey: true,
13+
emailOtp: false,
14+
smsOtp: false,
15+
}}
16+
heading="Add a passkey"
17+
/>
18+
<br />
19+
<Link to="/">Back to dashboard</Link>
20+
</div>
21+
);
22+
};
23+
24+
export default Settings;

example/vite.config.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,23 @@ import path from 'node:path';
55
// https://vitejs.dev/config/
66
export default defineConfig({
77
plugins: [react()],
8+
server: {
9+
// WebAuthn's RP origin check requires the frontend and backend to share
10+
// an origin (see authorizer's internal/authenticators/webauthn/webauthn.go
11+
// newRP: RPOrigins is derived from the *backend's* own request host,
12+
// matching web/app's real deployment - bundled into the same origin as
13+
// the API). Proxying keeps the browser on a single origin (:5173) for
14+
// local dev while still hitting the real backend on :8080 - Vite's proxy
15+
// preserves the original Host header by default, so the backend derives
16+
// HostURL as localhost:5173 too, matching what the browser actually used
17+
// for the WebAuthn ceremony. Combine with authorizerURL pointed at the
18+
// same origin (see src/index.tsx) - both sides of this only matter for
19+
// testing passkeys locally; every other MFA method works fine without it.
20+
proxy: {
21+
'/graphql': 'http://localhost:8080',
22+
'/v1': 'http://localhost:8080',
23+
},
24+
},
825
resolve: {
926
alias: [
1027
{

0 commit comments

Comments
 (0)