mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-08-04 22:40:11 +00:00
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
269 lines
8.9 KiB
JavaScript
269 lines
8.9 KiB
JavaScript
const OFFICIAL_DESKTOP_ORIGIN = 'bw-desktop-file://bundle';
|
|
|
|
function safeDecodeURIComponent(value) {
|
|
let decoded = String(value || '');
|
|
for (let index = 0; index < 2 && /%[0-9a-f]{2}/i.test(decoded); index += 1) {
|
|
try {
|
|
const next = decodeURIComponent(decoded);
|
|
if (next === decoded) break;
|
|
decoded = next;
|
|
} catch (_error) {
|
|
break;
|
|
}
|
|
}
|
|
return decoded;
|
|
}
|
|
|
|
export function decodeBase64Utf8(value) {
|
|
let normalized = String(value || '').replace(/ /g, '+').replace(/-/g, '+').replace(/_/g, '/');
|
|
normalized += '='.repeat((4 - (normalized.length % 4 || 4)) % 4);
|
|
let binary;
|
|
try {
|
|
binary = atob(normalized);
|
|
} catch (_error) {
|
|
throw new Error('Cannot parse WebAuthn data.');
|
|
}
|
|
const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0));
|
|
if (typeof TextDecoder !== 'undefined') return new TextDecoder().decode(bytes);
|
|
return decodeURIComponent(Array.from(bytes, (byte) => `%${byte.toString(16).padStart(2, '0')}`).join(''));
|
|
}
|
|
|
|
export function bytesFromBase64Url(value) {
|
|
let normalized = String(value || '').replace(/-/g, '+').replace(/_/g, '/');
|
|
normalized += '='.repeat((4 - (normalized.length % 4 || 4)) % 4);
|
|
try {
|
|
return Uint8Array.from(atob(normalized), (character) => character.charCodeAt(0));
|
|
} catch (_error) {
|
|
throw new Error('Cannot parse WebAuthn data.');
|
|
}
|
|
}
|
|
|
|
export function base64UrlFromBuffer(value) {
|
|
const bytes = value instanceof Uint8Array ? value : new Uint8Array(value);
|
|
let binary = '';
|
|
for (let index = 0; index < bytes.length; index += 1) binary += String.fromCharCode(bytes[index]);
|
|
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
|
|
}
|
|
|
|
export function parseConnectorRequest(search) {
|
|
const params = search instanceof URLSearchParams
|
|
? search
|
|
: new URLSearchParams(String(search || '').replace(/^\?/, ''));
|
|
const parentUrl = safeDecodeURIComponent(params.get('parent'));
|
|
const encodedData = params.get('data');
|
|
if (!parentUrl) throw new Error('No parent.');
|
|
if (!encodedData) throw new Error('No data.');
|
|
|
|
let parsedParent;
|
|
try {
|
|
parsedParent = new URL(parentUrl);
|
|
} catch (_error) {
|
|
throw new Error('Invalid parent.');
|
|
}
|
|
|
|
let webauthnJson;
|
|
if (params.get('v') === '1') {
|
|
webauthnJson = decodeBase64Utf8(encodedData);
|
|
} else {
|
|
let payload;
|
|
try {
|
|
payload = JSON.parse(decodeBase64Utf8(encodedData));
|
|
} catch (_error) {
|
|
throw new Error('Cannot parse data.');
|
|
}
|
|
if (!payload || (typeof payload.data !== 'string' && typeof payload.data !== 'object')) {
|
|
throw new Error('Cannot parse data.');
|
|
}
|
|
webauthnJson = typeof payload.data === 'string' ? payload.data : JSON.stringify(payload.data);
|
|
}
|
|
|
|
return {
|
|
parentUrl,
|
|
parentProtocol: parsedParent.protocol.toLowerCase(),
|
|
parentOrigin: parsedParent.origin,
|
|
webauthnJson,
|
|
buttonText: safeDecodeURIComponent(params.get('btnText')),
|
|
awaitingText: safeDecodeURIComponent(params.get('btnAwaitingInteractionText')),
|
|
};
|
|
}
|
|
|
|
export function normalizePublicKeyOptions(webauthnJson) {
|
|
const source = typeof webauthnJson === 'string' ? JSON.parse(webauthnJson) : webauthnJson;
|
|
if (!source || typeof source !== 'object' || !source.challenge) throw new Error('Cannot parse WebAuthn data.');
|
|
const publicKey = { ...source, challenge: bytesFromBase64Url(source.challenge) };
|
|
if (Array.isArray(source.allowCredentials)) {
|
|
publicKey.allowCredentials = source.allowCredentials.map((credential) => ({
|
|
...credential,
|
|
id: bytesFromBase64Url(credential?.id),
|
|
}));
|
|
}
|
|
return publicKey;
|
|
}
|
|
|
|
export function buildCredentialData(assertedCredential) {
|
|
const response = assertedCredential?.response;
|
|
if (!assertedCredential || !response?.authenticatorData || !response?.clientDataJSON || !response?.signature) {
|
|
throw new Error('The authenticator returned an incomplete response.');
|
|
}
|
|
return JSON.stringify({
|
|
id: assertedCredential.id,
|
|
rawId: base64UrlFromBuffer(assertedCredential.rawId),
|
|
type: assertedCredential.type,
|
|
extensions: typeof assertedCredential.getClientExtensionResults === 'function'
|
|
? assertedCredential.getClientExtensionResults()
|
|
: {},
|
|
response: {
|
|
authenticatorData: base64UrlFromBuffer(response.authenticatorData),
|
|
clientDataJson: base64UrlFromBuffer(response.clientDataJSON),
|
|
signature: base64UrlFromBuffer(response.signature),
|
|
},
|
|
});
|
|
}
|
|
|
|
function normalizeAllowedOrigin(value) {
|
|
try {
|
|
const url = new URL(String(value || ''));
|
|
return url.protocol && url.host ? `${url.protocol}//${url.host}` : '';
|
|
} catch (_error) {
|
|
return '';
|
|
}
|
|
}
|
|
|
|
function isExtensionOrigin(origin) {
|
|
return origin.startsWith('chrome-extension://')
|
|
|| origin.startsWith('moz-extension://')
|
|
|| origin.startsWith('safari-web-extension://');
|
|
}
|
|
|
|
export function resolveParentChannel(request, connectorOrigin, allowedOrigins = []) {
|
|
if (request.parentProtocol === 'file:') {
|
|
return { eventOrigin: 'null', targetOrigin: request.parentUrl };
|
|
}
|
|
|
|
const parentOrigin = normalizeAllowedOrigin(request.parentUrl);
|
|
if (!parentOrigin) throw new Error('Invalid parent.');
|
|
if (parentOrigin === connectorOrigin) {
|
|
return { eventOrigin: parentOrigin, targetOrigin: parentOrigin };
|
|
}
|
|
if (parentOrigin === OFFICIAL_DESKTOP_ORIGIN) {
|
|
return { eventOrigin: parentOrigin, targetOrigin: request.parentUrl };
|
|
}
|
|
const trustedOrigins = allowedOrigins.map(normalizeAllowedOrigin).filter(Boolean);
|
|
if (isExtensionOrigin(parentOrigin) && trustedOrigins.includes(parentOrigin)) {
|
|
return { eventOrigin: parentOrigin, targetOrigin: parentOrigin };
|
|
}
|
|
throw new Error('Untrusted parent.');
|
|
}
|
|
|
|
async function loadAllowedParentOrigins() {
|
|
try {
|
|
const response = await fetch('/api/web-bootstrap', {
|
|
headers: { Accept: 'application/json' },
|
|
credentials: 'omit',
|
|
});
|
|
if (!response.ok) return [];
|
|
const body = await response.json();
|
|
return Array.isArray(body?.webAuthnAllowedOrigins) ? body.webAuthnAllowedOrigins : [];
|
|
} catch (_error) {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function browserErrorMessage(error) {
|
|
return error?.message || String(error || 'WebAuthn failed.');
|
|
}
|
|
|
|
async function initializePage() {
|
|
const button = document.getElementById('webauthn-button');
|
|
if (!button) return;
|
|
|
|
let request;
|
|
let publicKey;
|
|
let channel;
|
|
let stopWebAuthn = false;
|
|
let sentSuccess = false;
|
|
let running = false;
|
|
|
|
const defaultText = 'Read security key';
|
|
const awaitingDefaultText = 'Awaiting security key interaction...';
|
|
|
|
function setButton(awaiting = false) {
|
|
button.textContent = awaiting
|
|
? request?.awaitingText || awaitingDefaultText
|
|
: request?.buttonText || defaultText;
|
|
button.setAttribute('aria-disabled', awaiting ? 'true' : 'false');
|
|
button.setAttribute('aria-busy', awaiting ? 'true' : 'false');
|
|
button.onclick = awaiting ? null : executeWebAuthn;
|
|
}
|
|
|
|
function post(message) {
|
|
window.parent.postMessage(message, channel.targetOrigin);
|
|
}
|
|
|
|
function reportError(error) {
|
|
if (channel) post(`error|${browserErrorMessage(error)}`);
|
|
setButton(false);
|
|
}
|
|
|
|
async function executeWebAuthn() {
|
|
if (running || sentSuccess) return;
|
|
if (stopWebAuthn) {
|
|
stopWebAuthn = false;
|
|
setButton(false);
|
|
return;
|
|
}
|
|
running = true;
|
|
setButton(true);
|
|
try {
|
|
const credential = await navigator.credentials.get({ publicKey });
|
|
if (!credential) throw new Error('No security key was selected.');
|
|
if (sentSuccess) return;
|
|
post(`success|${buildCredentialData(credential)}`);
|
|
sentSuccess = true;
|
|
} catch (error) {
|
|
reportError(error);
|
|
} finally {
|
|
running = false;
|
|
}
|
|
}
|
|
|
|
try {
|
|
request = parseConnectorRequest(window.location.search);
|
|
publicKey = normalizePublicKeyOptions(request.webauthnJson);
|
|
channel = resolveParentChannel(request, window.location.origin, await loadAllowedParentOrigins());
|
|
setButton(false);
|
|
} catch (error) {
|
|
button.textContent = browserErrorMessage(error);
|
|
button.setAttribute('aria-disabled', 'true');
|
|
return;
|
|
}
|
|
|
|
if (!navigator.credentials || typeof navigator.credentials.get !== 'function' || !window.PublicKeyCredential) {
|
|
reportError(new Error('WebAuthn is not supported in this browser.'));
|
|
return;
|
|
}
|
|
|
|
window.addEventListener('message', (event) => {
|
|
if (event.source !== window.parent || event.origin !== channel.eventOrigin) return;
|
|
if (event.data === 'stop') {
|
|
stopWebAuthn = true;
|
|
setButton(false);
|
|
} else if (event.data === 'start' && stopWebAuthn) {
|
|
stopWebAuthn = false;
|
|
void executeWebAuthn();
|
|
}
|
|
});
|
|
|
|
post('info|ready');
|
|
const isSafari = navigator.userAgent.includes(' Safari/') && !navigator.userAgent.includes('Chrome');
|
|
if (!isSafari) void executeWebAuthn();
|
|
}
|
|
|
|
if (typeof window !== 'undefined' && typeof document !== 'undefined') {
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', () => void initializePage(), { once: true });
|
|
} else {
|
|
void initializePage();
|
|
}
|
|
}
|