From c86ebd07fd32b250ae080ab3d7195cc4c514fe22 Mon Sep 17 00:00:00 2001 From: Luca Biemelt Date: Tue, 14 Jul 2026 13:28:00 +0200 Subject: [PATCH] Add Playwright tests for the Key Connector flow Drives the real web vault through the enrollment of a new SSO user (domain confirmation instead of master password creation) and a subsequent login that unlocks straight from the connector. The connector is a small in-memory mock started by the spec, it stores keys per token subject and leaves token validation to the service being mocked. --- playwright/docker-compose.yml | 2 + playwright/test.env | 4 ++ playwright/tests/setups/keyconnector.ts | 72 +++++++++++++++++++++++ playwright/tests/sso_keyconnector.spec.ts | 61 +++++++++++++++++++ 4 files changed, 139 insertions(+) create mode 100644 playwright/tests/setups/keyconnector.ts create mode 100644 playwright/tests/sso_keyconnector.spec.ts diff --git a/playwright/docker-compose.yml b/playwright/docker-compose.yml index f4402326..0cd6874c 100644 --- a/playwright/docker-compose.yml +++ b/playwright/docker-compose.yml @@ -25,6 +25,8 @@ services: - ADMIN_TOKEN - DATABASE_URL - I_REALLY_WANT_VOLATILE_STORAGE + - KEY_CONNECTOR_ENABLED + - KEY_CONNECTOR_URL - LOG_LEVEL - LOGIN_RATELIMIT_MAX_BURST - SMTP_HOST diff --git a/playwright/test.env b/playwright/test.env index df182ebe..c54c5705 100644 --- a/playwright/test.env +++ b/playwright/test.env @@ -67,6 +67,10 @@ SSO_CLIENT_SECRET=warden SSO_AUTHORITY=http://${KC_HTTP_HOST}:${KC_HTTP_PORT}/realms/${TEST_REALM} SSO_DEBUG_TOKENS=true +# Mock service started by sso_keyconnector.spec.ts +KEY_CONNECTOR_PORT=8004 +KEY_CONNECTOR_URL=http://127.0.0.1:${KEY_CONNECTOR_PORT} + # Custom web-vault build # PW_VW_REPO_URL=https://github.com/vaultwarden/vw_web_builds.git # PW_VW_COMMIT_HASH=b5f5b2157b9b64b5813bc334a75a277d0377b5d3 diff --git a/playwright/tests/setups/keyconnector.ts b/playwright/tests/setups/keyconnector.ts new file mode 100644 index 00000000..341d5779 --- /dev/null +++ b/playwright/tests/setups/keyconnector.ts @@ -0,0 +1,72 @@ +import { createServer, type Server } from 'node:http'; + +/** + * Barebone in-memory key connector, just enough for the clients to enroll and unlock. + * Keys are stored per token subject; token validation is deliberately out of scope, + * this only exists to test the Vaultwarden and web client side of the flow. + */ +export function startMockKeyConnector(port: number) { + const keys = new Map(); + + const server = createServer((req, res) => { + // The web client calls us cross-origin, with credentials + res.setHeader('Access-Control-Allow-Origin', req.headers['origin'] ?? '*'); + res.setHeader('Access-Control-Allow-Credentials', 'true'); + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', req.headers['access-control-request-headers'] ?? '*'); + + if (req.method === 'OPTIONS') { + res.writeHead(204).end(); + return; + } + + if (req.url === '/alive') { + res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({})); + return; + } + + const sub = tokenSubject(req.headers['authorization']); + if (!sub) { + res.writeHead(401).end(); + return; + } + + if (req.url !== '/user-keys') { + res.writeHead(404).end(); + return; + } + + if (req.method === 'GET') { + if (!keys.has(sub)) { + res.writeHead(404).end(); + return; + } + res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ key: keys.get(sub) })); + } else if (req.method === 'POST') { + let body = ''; + req.on('data', (chunk) => body += chunk); + req.on('end', () => { + keys.set(sub, JSON.parse(body).key); + res.writeHead(200).end(); + }); + } else { + res.writeHead(405).end(); + } + }); + + server.listen(port); + console.log(`Mock key connector running on port ${port}`); + + return { + keys, + stop: () => server.close(), + }; +} + +function tokenSubject(header?: string): string | null { + try { + return JSON.parse(Buffer.from(header.replace(/^Bearer /, '').split('.')[1], 'base64url').toString()).sub; + } catch { + return null; + } +} diff --git a/playwright/tests/sso_keyconnector.spec.ts b/playwright/tests/sso_keyconnector.spec.ts new file mode 100644 index 00000000..8eba8d60 --- /dev/null +++ b/playwright/tests/sso_keyconnector.spec.ts @@ -0,0 +1,61 @@ +import { test, expect, type Page, type TestInfo } from '@playwright/test'; + +import { startMockKeyConnector } from './setups/keyconnector'; +import * as utils from "../global-utils"; + +let users = utils.loadEnv(); +let keyConnector; + +test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => { + keyConnector = startMockKeyConnector(Number(process.env.KEY_CONNECTOR_PORT)); + await utils.startVault(browser, testInfo, { + SSO_ENABLED: true, + SSO_ONLY: false, + KEY_CONNECTOR_ENABLED: true, + KEY_CONNECTOR_URL: process.env.KEY_CONNECTOR_URL, + }); +}); + +test.afterAll('Teardown', async ({}) => { + utils.stopVault(); + keyConnector.stop(); +}); + +async function ssoLogin(page: Page, user: { email: string, name: string, password: string }) { + await page.context().clearCookies(); + await utils.cleanLanding(page); + + await page.locator("input[type=email].vw-email-sso").fill(user.email); + await page.getByRole('button', { name: /Use single sign-on/ }).click(); + + await expect(page.getByRole('heading', { name: 'Sign in to your account' })).toBeVisible(); + await page.getByLabel(/Username/).fill(user.name); + await page.getByLabel('Password', { exact: true }).fill(user.password); + await page.getByRole('button', { name: 'Sign In' }).click(); +} + +test('Account creation using SSO enrolls with the key connector', async ({ page }) => { + await ssoLogin(page, users.user1); + + // Instead of the master password creation we land on the domain confirmation + await expect(page.getByText(process.env.KEY_CONNECTOR_URL)).toBeVisible(); + // Button label differs between web-vault versions + await page.getByRole('button', { name: /^(Confirm|Continue with log in)$/ }).click(); + + await utils.ignoreExtension(page); + + await expect(page).toHaveTitle(/Vaultwarden Web/); + await expect(page.getByTitle('All vaults', { exact: true })).toBeVisible(); + + expect(keyConnector.keys.size).toBe(1); +}); + +test('SSO login unlocks with the key from the connector', async ({ page }) => { + await ssoLogin(page, users.user1); + + // No master password prompt, the vault opens directly + await utils.ignoreExtension(page); + + await expect(page).toHaveTitle(/Vaultwarden Web/); + await expect(page.getByTitle('All vaults', { exact: true })).toBeVisible(); +});