Browse Source
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
4 changed files with 139 additions and 0 deletions
@ -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; |
|||
} |
|||
} |
|||
@ -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…
Reference in new issue