mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-08-04 22:40:11 +00:00
fix: align WebAuthn connectors with Bitwarden clients
Add official-compatible mobile and desktop connector flows, preserve exact .html asset paths, and cover the protocol and framing behavior with regression tests. Fixes #326
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import test from 'node:test';
|
||||
|
||||
import type { Env } from '../src/types';
|
||||
import { getConfiguredWebAuthnAllowedOrigins } from '../src/utils/origins';
|
||||
import { applyCors, handleCors } from '../src/utils/response';
|
||||
|
||||
const env = {} as Env;
|
||||
|
||||
test('only the iframe connector drops anti-framing headers', () => {
|
||||
const connectorRequest = new Request('https://vault.example.test/webauthn-connector.html');
|
||||
const connector = applyCors(connectorRequest, new Response('<!doctype html>'), env);
|
||||
assert.equal(connector.headers.get('X-Frame-Options'), null);
|
||||
assert.doesNotMatch(connector.headers.get('Content-Security-Policy') || '', /frame-ancestors/);
|
||||
assert.match(connector.headers.get('Content-Security-Policy') || '', /script-src 'self'/);
|
||||
|
||||
for (const path of ['/', '/webauthn-fallback-connector.html', '/webauthn-mobile-connector.html']) {
|
||||
const request = new Request(`https://vault.example.test${path}`);
|
||||
const response = applyCors(request, new Response('<!doctype html>'), env);
|
||||
assert.equal(response.headers.get('X-Frame-Options'), 'DENY');
|
||||
assert.match(response.headers.get('Content-Security-Policy') || '', /frame-ancestors 'none'/);
|
||||
}
|
||||
});
|
||||
|
||||
test('official Bitwarden desktop origin receives credentialed CORS', () => {
|
||||
assert.ok(getConfiguredWebAuthnAllowedOrigins(env).includes('bw-desktop-file://bundle'));
|
||||
const preflight = handleCors(new Request('https://vault.example.test/api/sync', {
|
||||
method: 'OPTIONS',
|
||||
headers: {
|
||||
Origin: 'bw-desktop-file://bundle',
|
||||
'Access-Control-Request-Headers': 'authorization, content-type',
|
||||
},
|
||||
}), env);
|
||||
assert.equal(preflight.headers.get('Access-Control-Allow-Origin'), 'bw-desktop-file://bundle');
|
||||
assert.equal(preflight.headers.get('Access-Control-Allow-Credentials'), 'true');
|
||||
});
|
||||
|
||||
test('Worker assets preserve exact official connector .html paths', async () => {
|
||||
for (const configUrl of [
|
||||
new URL('../wrangler.toml', import.meta.url),
|
||||
new URL('../wrangler.kv.toml', import.meta.url),
|
||||
]) {
|
||||
const config = await readFile(configUrl, 'utf8');
|
||||
const assetsSection = config.match(/\[assets\]([\s\S]*?)(?=\n\[|$)/)?.[1] || '';
|
||||
assert.match(assetsSection, /^\s*html_handling\s*=\s*"none"\s*$/m);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
buildCredentialData,
|
||||
normalizePublicKeyOptions,
|
||||
parseConnectorRequest,
|
||||
resolveParentChannel,
|
||||
} from '../webapp/public/webauthn-connector.js';
|
||||
|
||||
function encodeBase64Utf8(value) {
|
||||
return Buffer.from(value, 'utf8').toString('base64');
|
||||
}
|
||||
|
||||
const publicKeyOptions = {
|
||||
challenge: 'AQID',
|
||||
allowCredentials: [{ id: 'BAUG', type: 'public-key', transports: ['usb'] }],
|
||||
timeout: 60000,
|
||||
rpId: 'vault.example.test',
|
||||
};
|
||||
|
||||
test('parses the official desktop/browser V1 connector request', () => {
|
||||
const params = new URLSearchParams({
|
||||
data: encodeBase64Utf8(JSON.stringify(publicKeyOptions)),
|
||||
parent: encodeURIComponent('file:///C:/Program Files/Bitwarden/resources/app/index.html'),
|
||||
btnText: encodeURIComponent('Read security key'),
|
||||
btnAwaitingInteractionText: encodeURIComponent('Awaiting security key interaction...'),
|
||||
v: '1',
|
||||
});
|
||||
const request = parseConnectorRequest(params);
|
||||
assert.equal(request.parentUrl, 'file:///C:/Program Files/Bitwarden/resources/app/index.html');
|
||||
assert.equal(request.parentProtocol, 'file:');
|
||||
assert.deepEqual(JSON.parse(request.webauthnJson), publicKeyOptions);
|
||||
assert.equal(request.buttonText, 'Read security key');
|
||||
assert.equal(request.awaitingText, 'Awaiting security key interaction...');
|
||||
});
|
||||
|
||||
test('keeps V2 parsing compatible with the shared official connector protocol', () => {
|
||||
const params = new URLSearchParams({
|
||||
data: encodeBase64Utf8(JSON.stringify({ data: JSON.stringify(publicKeyOptions) })),
|
||||
parent: encodeURIComponent('chrome-extension://nngceckbapebfimnlniiiahkandclblb/popup/index.html'),
|
||||
v: '2',
|
||||
});
|
||||
assert.deepEqual(JSON.parse(parseConnectorRequest(params).webauthnJson), publicKeyOptions);
|
||||
});
|
||||
|
||||
test('normalizes WebAuthn challenge and allowed credential IDs', () => {
|
||||
const normalized = normalizePublicKeyOptions(JSON.stringify(publicKeyOptions));
|
||||
assert.deepEqual(Array.from(normalized.challenge), [1, 2, 3]);
|
||||
assert.deepEqual(Array.from(normalized.allowCredentials[0].id), [4, 5, 6]);
|
||||
});
|
||||
|
||||
test('emits the exact assertion shape consumed by official Bitwarden clients', () => {
|
||||
const output = JSON.parse(buildCredentialData({
|
||||
id: 'credential-id',
|
||||
rawId: Uint8Array.from([1, 2, 3]).buffer,
|
||||
type: 'public-key',
|
||||
getClientExtensionResults: () => ({ appid: false }),
|
||||
response: {
|
||||
authenticatorData: Uint8Array.from([4, 5]).buffer,
|
||||
clientDataJSON: Uint8Array.from([6, 7]).buffer,
|
||||
signature: Uint8Array.from([8, 9]).buffer,
|
||||
},
|
||||
}));
|
||||
assert.deepEqual(output, {
|
||||
id: 'credential-id',
|
||||
rawId: 'AQID',
|
||||
type: 'public-key',
|
||||
extensions: { appid: false },
|
||||
response: {
|
||||
authenticatorData: 'BAU',
|
||||
clientDataJson: 'Bgc',
|
||||
signature: 'CAk',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('accepts legacy file and current official desktop parent origins', () => {
|
||||
assert.deepEqual(resolveParentChannel({
|
||||
parentProtocol: 'file:',
|
||||
parentUrl: 'file:///C:/Bitwarden/index.html',
|
||||
}, 'https://vault.example.test'), {
|
||||
eventOrigin: 'null',
|
||||
targetOrigin: 'file:///C:/Bitwarden/index.html',
|
||||
});
|
||||
assert.deepEqual(resolveParentChannel({
|
||||
parentProtocol: 'bw-desktop-file:',
|
||||
parentUrl: 'bw-desktop-file://bundle/index.html',
|
||||
}, 'https://vault.example.test'), {
|
||||
eventOrigin: 'bw-desktop-file://bundle',
|
||||
targetOrigin: 'bw-desktop-file://bundle/index.html',
|
||||
});
|
||||
});
|
||||
|
||||
test('accepts configured official extension origins and rejects arbitrary parents', () => {
|
||||
const extension = 'chrome-extension://nngceckbapebfimnlniiiahkandclblb';
|
||||
assert.deepEqual(resolveParentChannel({
|
||||
parentProtocol: 'chrome-extension:',
|
||||
parentUrl: `${extension}/popup/index.html`,
|
||||
}, 'https://vault.example.test', [extension]), {
|
||||
eventOrigin: extension,
|
||||
targetOrigin: extension,
|
||||
});
|
||||
assert.throws(() => resolveParentChannel({
|
||||
parentProtocol: 'https:',
|
||||
parentUrl: 'https://attacker.example/frame',
|
||||
}, 'https://vault.example.test', []), /Untrusted parent/);
|
||||
});
|
||||
|
||||
test('uses the official postMessage message contract and iframe-sized fallback styling', async () => {
|
||||
const [html, source, viteConfig] = await Promise.all([
|
||||
readFile(new URL('../webapp/public/webauthn-connector.html', import.meta.url), 'utf8'),
|
||||
readFile(new URL('../webapp/public/webauthn-connector.js', import.meta.url), 'utf8'),
|
||||
readFile(new URL('../webapp/vite.config.ts', import.meta.url), 'utf8'),
|
||||
]);
|
||||
assert.match(html, /id="webauthn-button"/);
|
||||
assert.match(html, /min-height:\s*40px/);
|
||||
assert.match(html, /background:\s*#2563eb/);
|
||||
assert.match(source, /post\('info\|ready'\)/);
|
||||
assert.match(source, /post\(`success\|\$\{buildCredentialData\(credential\)\}`\)/);
|
||||
assert.match(source, /post\(`error\|\$\{browserErrorMessage\(error\)\}`\)/);
|
||||
assert.match(source, /event\.data === 'stop'/);
|
||||
assert.match(source, /event\.data === 'start'/);
|
||||
assert.match(viteConfig, /endsWith\('-connector\.html'\)/);
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
base64UrlFromBuffer,
|
||||
buildCallbackUrl,
|
||||
buildCredentialData,
|
||||
decodeBase64Utf8,
|
||||
normalizePublicKeyOptions,
|
||||
parseConnectorRequest,
|
||||
resolveMobileCallbackUri,
|
||||
} from '../webapp/public/webauthn-mobile-connector.js';
|
||||
|
||||
function encodeBase64Utf8(value) {
|
||||
return Buffer.from(value, 'utf8').toString('base64');
|
||||
}
|
||||
|
||||
function v2Search(payload, extra = '') {
|
||||
return `?data=${encodeURIComponent(encodeBase64Utf8(JSON.stringify(payload)))}&parent=bitwarden%3A__webauthn-callback&v=2${extra}`;
|
||||
}
|
||||
|
||||
const assertionOptions = {
|
||||
challenge: 'AQID-v8',
|
||||
rpId: 'vault.example.com',
|
||||
timeout: 60000,
|
||||
userVerification: 'preferred',
|
||||
allowCredentials: [{ id: 'BAUGBwg', type: 'public-key', transports: ['internal'] }],
|
||||
};
|
||||
|
||||
test('parses the current Bitwarden Android V2 connector payload', () => {
|
||||
const request = parseConnectorRequest(v2Search({
|
||||
btnReturnText: 'Return to app', btnText: 'Authenticate', data: JSON.stringify(assertionOptions),
|
||||
headerText: 'Verify your identity', mobile: true,
|
||||
}, '&client=mobile&deeplinkScheme=bitwarden'), 'vault.example.com');
|
||||
assert.equal(request.callbackUri, 'bitwarden://webauthn-callback');
|
||||
assert.equal(request.headerText, 'Verify your identity');
|
||||
assert.equal(request.buttonText, 'Authenticate');
|
||||
assert.equal(request.returnButtonText, 'Return to app');
|
||||
assert.deepEqual(JSON.parse(request.webauthnJson), assertionOptions);
|
||||
});
|
||||
|
||||
test('uses callbackUri only as a signal and never as the redirect target', () => {
|
||||
const trustedLooking = parseConnectorRequest(v2Search({
|
||||
callbackUri: 'https://bitwarden.eu/webauthn-callback', data: assertionOptions,
|
||||
}).replace('&parent=bitwarden%3A__webauthn-callback', ''));
|
||||
const attacker = parseConnectorRequest(v2Search({
|
||||
callbackUri: 'https://attacker.example/capture', data: assertionOptions,
|
||||
}).replace('&parent=bitwarden%3A__webauthn-callback', ''));
|
||||
assert.equal(trustedLooking.callbackUri, 'bitwarden://webauthn-callback');
|
||||
assert.equal(attacker.callbackUri, 'bitwarden://webauthn-callback');
|
||||
});
|
||||
|
||||
test('treats any non-HTTPS deeplinkScheme as the fixed Bitwarden custom scheme', () => {
|
||||
const request = parseConnectorRequest(v2Search({ mobile: true, data: assertionOptions }, '&deeplinkScheme=untrusted'));
|
||||
assert.equal(request.callbackUri, 'bitwarden://webauthn-callback');
|
||||
});
|
||||
|
||||
test('supports Android custom-scheme and official HTTPS App Link callbacks', () => {
|
||||
const payload = { mobile: true, data: assertionOptions };
|
||||
const custom = parseConnectorRequest(v2Search(payload, '&client=mobile&deeplinkScheme=bitwarden'));
|
||||
const eu = parseConnectorRequest(v2Search(payload, '&client=mobile&deeplinkScheme=https'), 'vault.bitwarden.eu');
|
||||
const selfHosted = parseConnectorRequest(v2Search(payload, '&client=mobile&deeplinkScheme=https'), 'vault.example.com');
|
||||
assert.equal(custom.callbackUri, 'bitwarden://webauthn-callback');
|
||||
assert.equal(eu.callbackUri, 'https://bitwarden.eu/webauthn-callback');
|
||||
assert.equal(selfHosted.callbackUri, 'https://bitwarden.com/webauthn-callback');
|
||||
});
|
||||
|
||||
test('supports V1 mobile requests and requires a recognized mobile signal', () => {
|
||||
const encoded = encodeURIComponent(encodeBase64Utf8(JSON.stringify(assertionOptions)));
|
||||
assert.equal(parseConnectorRequest(`?data=${encoded}&v=1&client=mobile`).callbackUri, 'bitwarden://webauthn-callback');
|
||||
assert.equal(resolveMobileCallbackUri({ payload: {}, hostname: 'vault.example.com' }), null);
|
||||
assert.throws(() => parseConnectorRequest(`?data=${encoded}&v=1`), /return target/i);
|
||||
});
|
||||
|
||||
test('decodes UTF-8 and normalizes WebAuthn binary fields without mutation', () => {
|
||||
assert.equal(decodeBase64Utf8(encodeBase64Utf8('验证身份')), '验证身份');
|
||||
const original = structuredClone(assertionOptions);
|
||||
const normalized = normalizePublicKeyOptions(original);
|
||||
assert.deepEqual(Array.from(normalized.challenge), [1, 2, 3, 250, 255]);
|
||||
assert.deepEqual(Array.from(normalized.allowCredentials[0].id), [4, 5, 6, 7, 8]);
|
||||
assert.deepEqual(original, assertionOptions);
|
||||
});
|
||||
|
||||
test('serializes the exact assertion shape emitted by Bitwarden common-webauthn', () => {
|
||||
const serialized = JSON.parse(buildCredentialData({
|
||||
id: 'credential-id', rawId: Uint8Array.from([1, 2, 255]).buffer, type: 'public-key',
|
||||
getClientExtensionResults: () => ({ appid: false }),
|
||||
response: {
|
||||
authenticatorData: Uint8Array.from([3, 4]).buffer,
|
||||
clientDataJSON: Uint8Array.from([5, 6]).buffer,
|
||||
signature: Uint8Array.from([7, 8]).buffer,
|
||||
userHandle: Uint8Array.from([9, 10]).buffer,
|
||||
},
|
||||
}));
|
||||
assert.deepEqual(serialized, {
|
||||
id: 'credential-id',
|
||||
rawId: 'AQL_',
|
||||
type: 'public-key',
|
||||
extensions: { appid: false },
|
||||
response: { authenticatorData: 'AwQ', clientDataJson: 'BQY', signature: 'Bwg' },
|
||||
});
|
||||
assert.equal(base64UrlFromBuffer(Uint8Array.from([251, 255])), '-_8');
|
||||
});
|
||||
|
||||
test('encodes success and error callbacks safely', () => {
|
||||
assert.equal(buildCallbackUrl('bitwarden://webauthn-callback', 'data', '{"id":"a+b"}'), 'bitwarden://webauthn-callback?data=%7B%22id%22%3A%22a%2Bb%22%7D');
|
||||
assert.equal(buildCallbackUrl('bitwarden://webauthn-callback?source=nodewarden', 'error', 'Not allowed'), 'bitwarden://webauthn-callback?source=nodewarden&error=Not%20allowed');
|
||||
});
|
||||
|
||||
test('HTML matches the fallback connector visual structure', async () => {
|
||||
const html = await readFile(new URL('../webapp/public/webauthn-mobile-connector.html', import.meta.url), 'utf8');
|
||||
assert.match(html, /id="webauthn-header"/);
|
||||
assert.match(html, /id="webauthn-button"/);
|
||||
assert.match(html, /class="connector-card"/);
|
||||
assert.match(html, /class="brand"/);
|
||||
assert.match(html, /class="form"/);
|
||||
assert.match(html, /class="msg"/);
|
||||
assert.match(html, /src="\/nodewarden-logo\.svg"/);
|
||||
assert.match(html, /src="\/webauthn-mobile-connector\.js"/);
|
||||
assert.match(html, /default-src 'none'/);
|
||||
});
|
||||
|
||||
test('runtime uses Bitwarden-compatible replacement navigation', async () => {
|
||||
const source = await readFile(new URL('../webapp/public/webauthn-mobile-connector.js', import.meta.url), 'utf8');
|
||||
assert.match(source, /window\.location\.replace\(uri\)/);
|
||||
assert.doesNotMatch(source, /location\.assign/);
|
||||
assert.doesNotMatch(source, /safeCallbackFromPayload/);
|
||||
});
|
||||
|
||||
test('Service Worker keeps connector navigations out of the SPA shell', async () => {
|
||||
const config = await readFile(new URL('../webapp/vite.config.ts', import.meta.url), 'utf8');
|
||||
assert.match(config, /url\.pathname\.endsWith\('-connector\.html'\)/);
|
||||
assert.match(config, /connectorNavigation\(request\)/);
|
||||
assert.match(config, /WebAuthn connector is unavailable while offline/);
|
||||
});
|
||||
Reference in New Issue
Block a user