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,74 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="referrer" content="no-referrer" />
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'none'; script-src 'self'; style-src 'unsafe-inline'; connect-src 'self'; base-uri 'none'; form-action 'none'"
|
||||
/>
|
||||
<title>NodeWarden WebAuthn Connector</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light;
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
button {
|
||||
width: 100%;
|
||||
min-height: 40px;
|
||||
padding: 8px 14px;
|
||||
border: 1px solid #2563eb;
|
||||
border-radius: 10px;
|
||||
background: #2563eb;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-weight: 800;
|
||||
line-height: 1.2;
|
||||
transition: background-color 160ms ease, border-color 160ms ease;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
border-color: #1d4ed8;
|
||||
background: #1d4ed8;
|
||||
}
|
||||
|
||||
button:focus-visible {
|
||||
outline: 2px solid #2563eb;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
button[aria-disabled="true"] {
|
||||
border-color: #d0d5dd;
|
||||
background: #d0d5dd;
|
||||
color: #667085;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<button id="webauthn-button" type="button" aria-live="polite">Read security key</button>
|
||||
<script type="module" src="/webauthn-connector.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,268 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#f6f8fb" />
|
||||
<meta name="color-scheme" content="light" />
|
||||
<meta name="referrer" content="no-referrer" />
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'none'; script-src 'self'; style-src 'unsafe-inline'; img-src 'self'; connect-src 'none'; base-uri 'none'; form-action 'none'"
|
||||
/>
|
||||
<title>NodeWarden WebAuthn Connector</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--primary: #2563eb;
|
||||
--primary-strong: #1d4ed8;
|
||||
--text: #101828;
|
||||
--muted: #667085;
|
||||
--line: #d8e0ec;
|
||||
--panel: #ffffff;
|
||||
--surface: #f6f8fb;
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
main {
|
||||
display: grid;
|
||||
min-height: 100vh;
|
||||
min-height: 100svh;
|
||||
place-items: center;
|
||||
padding: max(28px, env(safe-area-inset-top)) max(18px, env(safe-area-inset-right)) max(28px, env(safe-area-inset-bottom)) max(18px, env(safe-area-inset-left));
|
||||
}
|
||||
|
||||
.connector-card {
|
||||
width: min(100%, 430px);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 18px;
|
||||
background: var(--panel);
|
||||
box-shadow: 0 18px 44px rgba(16, 24, 40, 0.10);
|
||||
padding: 28px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.brand img {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.brand strong {
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 26px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.form {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
button {
|
||||
min-height: 48px;
|
||||
width: 100%;
|
||||
border: 1px solid var(--primary);
|
||||
border-radius: 10px;
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-weight: 800;
|
||||
transition: background-color 160ms ease, border-color 160ms ease, transform 120ms ease;
|
||||
}
|
||||
|
||||
button:hover:not(:disabled)[aria-disabled="false"] {
|
||||
background: var(--primary-strong);
|
||||
border-color: var(--primary-strong);
|
||||
}
|
||||
|
||||
button:active:not(:disabled)[aria-disabled="false"] {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
button:disabled,
|
||||
button[aria-disabled="true"] {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.62;
|
||||
}
|
||||
|
||||
button[data-state="return"] {
|
||||
cursor: pointer;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.msg {
|
||||
display: none;
|
||||
border-radius: 10px;
|
||||
padding: 11px 12px;
|
||||
font-size: 14px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.msg.show {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.msg.info {
|
||||
border: 1px solid #bfdbfe;
|
||||
background: #eff6ff;
|
||||
color: #1e40af;
|
||||
}
|
||||
|
||||
.msg.error {
|
||||
border: 1px solid #fecaca;
|
||||
background: #fef2f2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.msg.success {
|
||||
border: 1px solid #bbf7d0;
|
||||
background: #f0fdf4;
|
||||
color: #166534;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<section class="connector-card" aria-labelledby="webauthn-header">
|
||||
<div class="brand">
|
||||
<img src="/nodewarden-logo.svg" alt="NodeWarden" />
|
||||
<strong>NodeWarden</strong>
|
||||
</div>
|
||||
<h1 id="webauthn-header">Verify your identity</h1>
|
||||
<p id="webauthn-copy">Use your security key to finish two-step verification.</p>
|
||||
<div class="form">
|
||||
<div id="webauthn-status" class="msg" role="status" aria-live="polite" hidden></div>
|
||||
<button id="webauthn-button" type="button" data-state="loading" aria-busy="true" aria-disabled="true">
|
||||
Preparing passkey…
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<script type="module" src="/webauthn-mobile-connector.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,272 @@
|
||||
const CUSTOM_SCHEME_CALLBACK = 'bitwarden://webauthn-callback';
|
||||
const APP_LINK_HOSTS = ['bitwarden.com', 'bitwarden.eu', 'bitwarden.pw', 'bitwarden-gov.com'];
|
||||
|
||||
function safeDecodeURIComponent(value) {
|
||||
let decoded = String(value || '');
|
||||
for (let index = 0; index < 2 && /%[0-9a-f]{2}/i.test(decoded); index += 1) {
|
||||
try {
|
||||
decoded = decodeURIComponent(decoded);
|
||||
} 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('The WebAuthn challenge is not valid Base64.');
|
||||
}
|
||||
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('The WebAuthn challenge contains invalid binary data.');
|
||||
}
|
||||
}
|
||||
|
||||
export function base64UrlFromBuffer(value) {
|
||||
if (value == null) return undefined;
|
||||
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, '');
|
||||
}
|
||||
|
||||
function officialAppLinkHost(hostname) {
|
||||
const normalized = String(hostname || '').toLowerCase();
|
||||
return APP_LINK_HOSTS.find((host) => normalized === host || normalized.endsWith(`.${host}`)) || 'bitwarden.com';
|
||||
}
|
||||
|
||||
export function resolveMobileCallbackUri({ deeplinkScheme, payload, hostname, legacyMobile = false }) {
|
||||
// Match Bitwarden's connector protocol: the scheme parameter governs the
|
||||
// callback shape. Any non-HTTPS scheme resolves to Bitwarden's fixed custom
|
||||
// scheme; a client-provided callbackUri is only a mobile-flow signal.
|
||||
if (deeplinkScheme) {
|
||||
return String(deeplinkScheme).toLowerCase() === 'https'
|
||||
? `https://${officialAppLinkHost(hostname)}/webauthn-callback`
|
||||
: CUSTOM_SCHEME_CALLBACK;
|
||||
}
|
||||
return payload?.mobile === true || payload?.callbackUri != null || legacyMobile
|
||||
? CUSTOM_SCHEME_CALLBACK
|
||||
: null;
|
||||
}
|
||||
|
||||
export function parseConnectorRequest(search, hostname = '') {
|
||||
const params = search instanceof URLSearchParams
|
||||
? search
|
||||
: new URLSearchParams(String(search || '').replace(/^\?/, ''));
|
||||
const encodedData = params.get('data');
|
||||
if (!encodedData) throw new Error('No WebAuthn challenge was provided.');
|
||||
|
||||
const version = params.get('v');
|
||||
let payload = null;
|
||||
let webauthnJson;
|
||||
let headerText;
|
||||
let buttonText;
|
||||
let returnButtonText;
|
||||
let awaitingText;
|
||||
|
||||
if (version === '1') {
|
||||
webauthnJson = decodeBase64Utf8(encodedData);
|
||||
headerText = params.get('headerText');
|
||||
buttonText = params.get('btnText');
|
||||
returnButtonText = params.get('btnReturnText');
|
||||
awaitingText = params.get('btnAwaitingInteractionText');
|
||||
} else {
|
||||
try {
|
||||
payload = JSON.parse(decodeBase64Utf8(encodedData));
|
||||
} catch (_error) {
|
||||
throw new Error('The WebAuthn challenge could not be decoded.');
|
||||
}
|
||||
if (!payload || (typeof payload.data !== 'string' && typeof payload.data !== 'object')) {
|
||||
throw new Error('The WebAuthn challenge is incomplete.');
|
||||
}
|
||||
webauthnJson = typeof payload.data === 'string' ? payload.data : JSON.stringify(payload.data);
|
||||
headerText = payload.headerText;
|
||||
buttonText = payload.btnText;
|
||||
returnButtonText = payload.btnReturnText;
|
||||
awaitingText = payload.btnAwaitingInteractionText;
|
||||
}
|
||||
|
||||
const callbackUri = resolveMobileCallbackUri({
|
||||
deeplinkScheme: params.get('deeplinkScheme'),
|
||||
payload,
|
||||
hostname,
|
||||
legacyMobile: params.get('client') === 'mobile',
|
||||
});
|
||||
if (!callbackUri) throw new Error('No supported mobile return target was provided.');
|
||||
|
||||
return {
|
||||
callbackUri,
|
||||
webauthnJson,
|
||||
headerText: safeDecodeURIComponent(headerText),
|
||||
buttonText: safeDecodeURIComponent(buttonText),
|
||||
returnButtonText: safeDecodeURIComponent(returnButtonText),
|
||||
awaitingText: safeDecodeURIComponent(awaitingText),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizePublicKeyOptions(webauthnJson) {
|
||||
const source = typeof webauthnJson === 'string' ? JSON.parse(webauthnJson) : webauthnJson;
|
||||
if (!source || typeof source !== 'object' || !source.challenge) {
|
||||
throw new Error('The WebAuthn challenge is invalid.');
|
||||
}
|
||||
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.');
|
||||
}
|
||||
const extensions = typeof assertedCredential.getClientExtensionResults === 'function'
|
||||
? assertedCredential.getClientExtensionResults()
|
||||
: {};
|
||||
const clientData = base64UrlFromBuffer(response.clientDataJSON);
|
||||
return JSON.stringify({
|
||||
id: assertedCredential.id,
|
||||
rawId: base64UrlFromBuffer(assertedCredential.rawId),
|
||||
type: assertedCredential.type,
|
||||
extensions,
|
||||
response: {
|
||||
authenticatorData: base64UrlFromBuffer(response.authenticatorData),
|
||||
clientDataJson: clientData,
|
||||
signature: base64UrlFromBuffer(response.signature),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function buildCallbackUrl(callbackUri, key, value) {
|
||||
const separator = String(callbackUri).includes('?') ? '&' : '?';
|
||||
return `${callbackUri}${separator}${encodeURIComponent(key)}=${encodeURIComponent(String(value || ''))}`;
|
||||
}
|
||||
|
||||
function translations(locale) {
|
||||
const normalized = String(locale || 'en').toLowerCase();
|
||||
if (normalized.startsWith('zh-tw') || normalized.startsWith('zh-hk')) {
|
||||
return {
|
||||
title: '兩步驟驗證', copy: '使用通行密鑰或安全金鑰完成登入。', button: '使用通行密鑰驗證',
|
||||
awaiting: '請依照系統提示完成驗證…', returning: '正在返回 Bitwarden…', returnButton: '返回 Bitwarden',
|
||||
unsupported: '此瀏覽器不支援通行密鑰。', cancelled: '驗證已取消,請重試。',
|
||||
};
|
||||
}
|
||||
if (normalized.startsWith('zh')) {
|
||||
return {
|
||||
title: '两步验证', copy: '使用通行密钥或安全密钥完成登录。', button: '使用通行密钥验证',
|
||||
awaiting: '请按照系统提示完成验证…', returning: '正在返回 Bitwarden…', returnButton: '返回 Bitwarden',
|
||||
unsupported: '此浏览器不支持通行密钥。', cancelled: '验证已取消,请重试。',
|
||||
};
|
||||
}
|
||||
return {
|
||||
title: 'Two-step verification', copy: 'Use your passkey or security key to finish signing in.',
|
||||
button: 'Authenticate with passkey', awaiting: 'Follow the system prompt to continue…',
|
||||
returning: 'Returning to Bitwarden…', returnButton: 'Return to Bitwarden',
|
||||
unsupported: 'This browser does not support passkeys.', cancelled: 'Verification was cancelled. Please try again.',
|
||||
};
|
||||
}
|
||||
|
||||
function browserErrorMessage(error, text) {
|
||||
if (error?.name === 'NotAllowedError' || error?.name === 'AbortError') return text.cancelled;
|
||||
return error?.message || String(error || 'WebAuthn failed.');
|
||||
}
|
||||
|
||||
function initializePage() {
|
||||
const button = document.getElementById('webauthn-button');
|
||||
const header = document.getElementById('webauthn-header');
|
||||
const copy = document.getElementById('webauthn-copy');
|
||||
const status = document.getElementById('webauthn-status');
|
||||
if (!button || !header || !copy || !status) return;
|
||||
|
||||
const text = translations(navigator.languages?.[0] || navigator.language);
|
||||
document.documentElement.lang = navigator.languages?.[0] || navigator.language || 'en';
|
||||
copy.textContent = text.copy;
|
||||
let request;
|
||||
let publicKey;
|
||||
let completed = false;
|
||||
let returnUri = '';
|
||||
|
||||
function setButton(label, state, handler) {
|
||||
button.textContent = label;
|
||||
button.dataset.state = state;
|
||||
button.disabled = state === 'unavailable';
|
||||
button.setAttribute('aria-disabled', handler ? 'false' : 'true');
|
||||
button.setAttribute('aria-busy', state === 'waiting' ? 'true' : 'false');
|
||||
button.onclick = handler;
|
||||
}
|
||||
|
||||
function setStatus(kind, message) {
|
||||
status.hidden = !message;
|
||||
status.dataset.kind = kind;
|
||||
status.textContent = message || '';
|
||||
status.className = message ? `msg show ${kind}` : 'msg';
|
||||
}
|
||||
|
||||
function navigate(uri) {
|
||||
returnUri = uri;
|
||||
window.location.replace(uri);
|
||||
setButton(request?.returnButtonText || text.returnButton, 'return', () => window.location.replace(returnUri));
|
||||
}
|
||||
|
||||
function handoffError(message) {
|
||||
setStatus('error', message);
|
||||
if (request?.callbackUri) navigate(buildCallbackUrl(request.callbackUri, 'error', message));
|
||||
}
|
||||
|
||||
async function executeWebAuthn() {
|
||||
if (completed || button.dataset.state === 'waiting') return;
|
||||
setStatus('info', request.awaitingText || text.awaiting);
|
||||
setButton(request.awaitingText || text.awaiting, 'waiting', null);
|
||||
try {
|
||||
const credential = await navigator.credentials.get({ publicKey });
|
||||
if (!credential) throw new Error('No passkey was selected.');
|
||||
const data = buildCredentialData(credential);
|
||||
completed = true;
|
||||
setStatus('success', text.returning);
|
||||
navigate(buildCallbackUrl(request.callbackUri, 'data', data));
|
||||
} catch (error) {
|
||||
setButton(request.buttonText || text.button, 'ready', executeWebAuthn);
|
||||
handoffError(browserErrorMessage(error, text));
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
request = parseConnectorRequest(window.location.search, window.location.hostname);
|
||||
publicKey = normalizePublicKeyOptions(request.webauthnJson);
|
||||
header.textContent = request.headerText || text.title;
|
||||
setButton(request.buttonText || text.button, 'ready', executeWebAuthn);
|
||||
} catch (error) {
|
||||
header.textContent = text.title;
|
||||
setStatus('error', browserErrorMessage(error, text));
|
||||
setButton(text.button, 'unavailable', null);
|
||||
}
|
||||
|
||||
if (!navigator.credentials || typeof navigator.credentials.get !== 'function' || !window.PublicKeyCredential) {
|
||||
handoffError(text.unsupported);
|
||||
if (!request?.callbackUri) setButton(text.button, 'unavailable', null);
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined' && typeof document !== 'undefined') {
|
||||
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', initializePage, { once: true });
|
||||
else initializePage();
|
||||
}
|
||||
Reference in New Issue
Block a user