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