Browse Source

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.
pull/7419/head
Luca Biemelt 1 week ago
parent
commit
c86ebd07fd
  1. 2
      playwright/docker-compose.yml
  2. 4
      playwright/test.env
  3. 72
      playwright/tests/setups/keyconnector.ts
  4. 61
      playwright/tests/sso_keyconnector.spec.ts

2
playwright/docker-compose.yml

@ -25,6 +25,8 @@ services:
- ADMIN_TOKEN - ADMIN_TOKEN
- DATABASE_URL - DATABASE_URL
- I_REALLY_WANT_VOLATILE_STORAGE - I_REALLY_WANT_VOLATILE_STORAGE
- KEY_CONNECTOR_ENABLED
- KEY_CONNECTOR_URL
- LOG_LEVEL - LOG_LEVEL
- LOGIN_RATELIMIT_MAX_BURST - LOGIN_RATELIMIT_MAX_BURST
- SMTP_HOST - SMTP_HOST

4
playwright/test.env

@ -67,6 +67,10 @@ SSO_CLIENT_SECRET=warden
SSO_AUTHORITY=http://${KC_HTTP_HOST}:${KC_HTTP_PORT}/realms/${TEST_REALM} SSO_AUTHORITY=http://${KC_HTTP_HOST}:${KC_HTTP_PORT}/realms/${TEST_REALM}
SSO_DEBUG_TOKENS=true 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 # Custom web-vault build
# PW_VW_REPO_URL=https://github.com/vaultwarden/vw_web_builds.git # PW_VW_REPO_URL=https://github.com/vaultwarden/vw_web_builds.git
# PW_VW_COMMIT_HASH=b5f5b2157b9b64b5813bc334a75a277d0377b5d3 # PW_VW_COMMIT_HASH=b5f5b2157b9b64b5813bc334a75a277d0377b5d3

72
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<string, string>();
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;
}
}

61
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();
});
Loading…
Cancel
Save