Add native self-hosted instance connection to fluxer_desktop
Trimmed monorepo checkout (fluxer_desktop + packages/voice_engine_v2 + tools/ci) with a "Connect to a Different Server" menu item and popout that lets the desktop app switch to any self-hosted Fluxer instance, plus fixes for well-known discovery on single-domain self-hosted deployments and a false-positive ERR_ABORTED on same-origin client redirects during the switch. Defaults to chat.fluxr.chat and uses an isolated userData directory from the official build.
This commit is contained in:
@@ -0,0 +1,322 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
const {existsSync} = require('node:fs');
|
||||
const {createHash} = require('node:crypto');
|
||||
const {join, sep} = require('node:path');
|
||||
const {createNativeLoadError, loadNativeBinding} = require('./loader-diagnostics.cjs');
|
||||
const MODULE_NAME = '@fluxer/webauthn';
|
||||
const SKIP_NATIVE_PROBE_ENV = 'FLUXER_WEBAUTHN_SKIP_NATIVE_PROBE';
|
||||
const WEBAUTHN_AUTHENTICATOR_ATTACHMENT_ANY = 0;
|
||||
const WEBAUTHN_AUTHENTICATOR_ATTACHMENT_PLATFORM = 1;
|
||||
const WEBAUTHN_AUTHENTICATOR_ATTACHMENT_CROSS_PLATFORM = 2;
|
||||
const WEBAUTHN_USER_VERIFICATION_REQUIREMENT_REQUIRED = 1;
|
||||
const WEBAUTHN_USER_VERIFICATION_REQUIREMENT_PREFERRED = 2;
|
||||
const WEBAUTHN_USER_VERIFICATION_REQUIREMENT_DISCOURAGED = 3;
|
||||
const WEBAUTHN_ATTESTATION_CONVEYANCE_PREFERENCE_NONE = 1;
|
||||
const WEBAUTHN_ATTESTATION_CONVEYANCE_PREFERENCE_INDIRECT = 2;
|
||||
const WEBAUTHN_ATTESTATION_CONVEYANCE_PREFERENCE_DIRECT = 3;
|
||||
const WEBAUTHN_ENTERPRISE_ATTESTATION_NONE = 0;
|
||||
const WEBAUTHN_ENTERPRISE_ATTESTATION_VENDOR_FACILITATED = 1;
|
||||
const WEBAUTHN_CTAP_TRANSPORT_USB = 0x00000001;
|
||||
const WEBAUTHN_CTAP_TRANSPORT_NFC = 0x00000002;
|
||||
const WEBAUTHN_CTAP_TRANSPORT_BLE = 0x00000004;
|
||||
const WEBAUTHN_CTAP_TRANSPORT_INTERNAL = 0x00000010;
|
||||
const WEBAUTHN_CTAP_TRANSPORT_HYBRID = 0x00000020;
|
||||
const WEBAUTHN_CTAP_TRANSPORT_SMART_CARD = 0x00000040;
|
||||
|
||||
function resolveNativeRoot() {
|
||||
const asarSegment = `${sep}app.asar${sep}`;
|
||||
if (!__dirname.includes(asarSegment)) return __dirname;
|
||||
const unpackedDir = __dirname.replace(asarSegment, `${sep}app.asar.unpacked${sep}`);
|
||||
return existsSync(unpackedDir) ? unpackedDir : __dirname;
|
||||
}
|
||||
|
||||
function nativeFileName(platform = process.platform, arch = process.arch) {
|
||||
if (platform === 'darwin' && (arch === 'x64' || arch === 'arm64')) return `webauthn.darwin-${arch}.node`;
|
||||
if (platform === 'win32' && (arch === 'x64' || arch === 'arm64')) return `webauthn.win32-${arch}-msvc.node`;
|
||||
if (platform === 'linux' && (arch === 'x64' || arch === 'arm64')) return `webauthn.linux-${arch}-gnu.node`;
|
||||
return null;
|
||||
}
|
||||
|
||||
let binding = null;
|
||||
let loadError = null;
|
||||
|
||||
const fileName = nativeFileName();
|
||||
|
||||
if (fileName) {
|
||||
try {
|
||||
const nativeRoot = resolveNativeRoot();
|
||||
const nativePath = join(nativeRoot, fileName);
|
||||
const loaded = loadNativeBinding({
|
||||
moduleName: MODULE_NAME,
|
||||
nativePath,
|
||||
nativeRoot,
|
||||
packageDir: __dirname,
|
||||
skipNativeProbeEnv: SKIP_NATIVE_PROBE_ENV,
|
||||
});
|
||||
binding = loaded.binding;
|
||||
loadError = loaded.loadError;
|
||||
if (loadError) throw loadError;
|
||||
} catch (error) {
|
||||
loadError = createNativeLoadError({
|
||||
moduleName: MODULE_NAME,
|
||||
nativeRoot: resolveNativeRoot(),
|
||||
packageDir: __dirname,
|
||||
reason: 'native loader threw before binding load completed',
|
||||
cause: error,
|
||||
skipNativeProbeEnv: SKIP_NATIVE_PROBE_ENV,
|
||||
});
|
||||
throw loadError;
|
||||
}
|
||||
} else {
|
||||
loadError = createNativeLoadError({
|
||||
moduleName: MODULE_NAME,
|
||||
nativeRoot: resolveNativeRoot(),
|
||||
packageDir: __dirname,
|
||||
reason: `no native binary mapping for ${process.platform}/${process.arch}`,
|
||||
skipNativeProbeEnv: SKIP_NATIVE_PROBE_ENV,
|
||||
});
|
||||
throw loadError;
|
||||
}
|
||||
|
||||
function base64Url(buffer) {
|
||||
return Buffer.from(buffer).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
}
|
||||
|
||||
function normalizedOrigin(origin) {
|
||||
if (typeof origin !== 'string' || origin.length === 0) {
|
||||
throw new TypeError('@fluxer/webauthn requires an origin string');
|
||||
}
|
||||
return new URL(origin).origin;
|
||||
}
|
||||
|
||||
function relyingPartyId(options, origin) {
|
||||
if (typeof options.rpId === 'string' && options.rpId.length > 0) return options.rpId;
|
||||
if (options.rp && typeof options.rp.id === 'string' && options.rp.id.length > 0) return options.rp.id;
|
||||
return new URL(origin).hostname;
|
||||
}
|
||||
|
||||
function clientDataJSON(type, challenge, origin) {
|
||||
return Buffer.from(
|
||||
JSON.stringify({
|
||||
type,
|
||||
challenge: base64Url(challenge),
|
||||
origin,
|
||||
crossOrigin: false,
|
||||
}),
|
||||
'utf8',
|
||||
);
|
||||
}
|
||||
|
||||
function clientDataHash(data) {
|
||||
return createHash('sha256').update(data).digest();
|
||||
}
|
||||
|
||||
function authenticatorAttachment(value) {
|
||||
if (value === 'platform') return WEBAUTHN_AUTHENTICATOR_ATTACHMENT_PLATFORM;
|
||||
if (value === 'cross-platform') return WEBAUTHN_AUTHENTICATOR_ATTACHMENT_CROSS_PLATFORM;
|
||||
return WEBAUTHN_AUTHENTICATOR_ATTACHMENT_ANY;
|
||||
}
|
||||
|
||||
function userVerification(value) {
|
||||
if (value === 'required') return WEBAUTHN_USER_VERIFICATION_REQUIREMENT_REQUIRED;
|
||||
if (value === 'discouraged') return WEBAUTHN_USER_VERIFICATION_REQUIREMENT_DISCOURAGED;
|
||||
return WEBAUTHN_USER_VERIFICATION_REQUIREMENT_PREFERRED;
|
||||
}
|
||||
|
||||
function attestation(value) {
|
||||
if (value === 'direct') return WEBAUTHN_ATTESTATION_CONVEYANCE_PREFERENCE_DIRECT;
|
||||
if (value === 'indirect') return WEBAUTHN_ATTESTATION_CONVEYANCE_PREFERENCE_INDIRECT;
|
||||
return WEBAUTHN_ATTESTATION_CONVEYANCE_PREFERENCE_NONE;
|
||||
}
|
||||
|
||||
function enterpriseAttestation(value) {
|
||||
return value === 'enterprise'
|
||||
? WEBAUTHN_ENTERPRISE_ATTESTATION_VENDOR_FACILITATED
|
||||
: WEBAUTHN_ENTERPRISE_ATTESTATION_NONE;
|
||||
}
|
||||
|
||||
function transportBits(transports) {
|
||||
if (!Array.isArray(transports)) return 0;
|
||||
let bits = 0;
|
||||
for (const transport of transports) {
|
||||
if (transport === 'usb') bits |= WEBAUTHN_CTAP_TRANSPORT_USB;
|
||||
else if (transport === 'nfc') bits |= WEBAUTHN_CTAP_TRANSPORT_NFC;
|
||||
else if (transport === 'ble') bits |= WEBAUTHN_CTAP_TRANSPORT_BLE;
|
||||
else if (transport === 'internal') bits |= WEBAUTHN_CTAP_TRANSPORT_INTERNAL;
|
||||
else if (transport === 'hybrid') bits |= WEBAUTHN_CTAP_TRANSPORT_HYBRID;
|
||||
else if (transport === 'smart-card') bits |= WEBAUTHN_CTAP_TRANSPORT_SMART_CARD;
|
||||
}
|
||||
return bits;
|
||||
}
|
||||
|
||||
function credentialDescriptors(descriptors) {
|
||||
if (!Array.isArray(descriptors)) return [];
|
||||
return descriptors.map((descriptor) => ({
|
||||
id: Buffer.from(descriptor.id),
|
||||
transports: transportBits(descriptor.transports),
|
||||
}));
|
||||
}
|
||||
|
||||
function windowHandleBuffer(value) {
|
||||
if (Buffer.isBuffer(value)) return value;
|
||||
if (value instanceof Uint8Array) return Buffer.from(value);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function pinString(value) {
|
||||
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
function residentKeyFlags(selection) {
|
||||
const residentKey = selection?.residentKey;
|
||||
const requireResidentKey = Boolean(selection?.requireResidentKey) || residentKey === 'required';
|
||||
return {
|
||||
requireResidentKey,
|
||||
preferResidentKey: requireResidentKey || residentKey === 'preferred',
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeCreateOptions(options) {
|
||||
if (!options || typeof options !== 'object') throw new TypeError('registration options must be an object');
|
||||
const origin = normalizedOrigin(options.origin);
|
||||
const selection = options.authenticatorSelection || {};
|
||||
const residentKeys = residentKeyFlags(selection);
|
||||
return {
|
||||
rpId: relyingPartyId(options, origin),
|
||||
rpName: options.rp && typeof options.rp.name === 'string' ? options.rp.name : relyingPartyId(options, origin),
|
||||
challenge: Buffer.from(options.challenge),
|
||||
userId: Buffer.from(options.user.id),
|
||||
userName: options.user.name,
|
||||
userDisplayName: options.user.displayName,
|
||||
clientDataJSON: clientDataJSON('webauthn.create', options.challenge, origin),
|
||||
pubKeyCredParams: options.pubKeyCredParams,
|
||||
excludeCredentials: credentialDescriptors(options.excludeCredentials),
|
||||
timeout: Number.isFinite(options.timeout) ? Math.max(0, Math.trunc(options.timeout)) : 0,
|
||||
authenticatorAttachment: authenticatorAttachment(selection.authenticatorAttachment),
|
||||
userVerification: userVerification(selection.userVerification),
|
||||
attestation: attestation(options.attestation),
|
||||
enterpriseAttestation: enterpriseAttestation(options.attestation),
|
||||
windowHandle: windowHandleBuffer(options.windowHandle),
|
||||
pin: pinString(options.pin),
|
||||
...residentKeys,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeGetOptions(options) {
|
||||
if (!options || typeof options !== 'object') throw new TypeError('assertion options must be an object');
|
||||
const origin = normalizedOrigin(options.origin);
|
||||
return {
|
||||
rpId: relyingPartyId(options, origin),
|
||||
challenge: Buffer.from(options.challenge),
|
||||
clientDataJSON: clientDataJSON('webauthn.get', options.challenge, origin),
|
||||
allowCredentials: credentialDescriptors(options.allowCredentials),
|
||||
timeout: Number.isFinite(options.timeout) ? Math.max(0, Math.trunc(options.timeout)) : 0,
|
||||
authenticatorAttachment: WEBAUTHN_AUTHENTICATOR_ATTACHMENT_ANY,
|
||||
userVerification: userVerification(options.userVerification),
|
||||
windowHandle: windowHandleBuffer(options.windowHandle),
|
||||
pin: pinString(options.pin),
|
||||
};
|
||||
}
|
||||
|
||||
function unavailableError() {
|
||||
return new Error(
|
||||
`@fluxer/webauthn native backend unavailable on ${process.platform}/${process.arch}: ${
|
||||
loadError instanceof Error ? loadError.message : 'unknown load error'
|
||||
}`,
|
||||
);
|
||||
}
|
||||
|
||||
function requireBinding() {
|
||||
if (!binding) throw unavailableError();
|
||||
return binding;
|
||||
}
|
||||
|
||||
async function isSupported() {
|
||||
if (!binding || typeof binding.isSupported !== 'function') return false;
|
||||
return Boolean(await binding.isSupported());
|
||||
}
|
||||
|
||||
function rawIdCredential(rawId, response, authenticatorAttachment) {
|
||||
const id = base64Url(rawId);
|
||||
return {
|
||||
id,
|
||||
rawId,
|
||||
response: Buffer.from(JSON.stringify(response), 'utf8'),
|
||||
authenticatorAttachment,
|
||||
type: 'public-key',
|
||||
};
|
||||
}
|
||||
|
||||
async function create(options) {
|
||||
const native = requireBinding();
|
||||
if (typeof native.create !== 'function') {
|
||||
throw new Error(
|
||||
`@fluxer/webauthn native backend did not export registration on ${process.platform}/${process.arch}`,
|
||||
);
|
||||
}
|
||||
const normalized = normalizeCreateOptions(options);
|
||||
normalized.clientDataHash = clientDataHash(normalized.clientDataJSON);
|
||||
const result = await native.create(normalized);
|
||||
return rawIdCredential(
|
||||
result.rawId,
|
||||
{
|
||||
clientDataJSON: base64Url(result.clientDataJSON),
|
||||
attestationObject: base64Url(result.attestationObject),
|
||||
},
|
||||
result.authenticatorAttachment,
|
||||
);
|
||||
}
|
||||
|
||||
async function get(options) {
|
||||
const native = requireBinding();
|
||||
if (typeof native.get !== 'function') {
|
||||
throw new Error(
|
||||
`@fluxer/webauthn native backend did not export authentication on ${process.platform}/${process.arch}`,
|
||||
);
|
||||
}
|
||||
const normalized = normalizeGetOptions(options);
|
||||
normalized.clientDataHash = clientDataHash(normalized.clientDataJSON);
|
||||
const result = await native.get(normalized);
|
||||
const response = {
|
||||
clientDataJSON: base64Url(result.clientDataJSON),
|
||||
authenticatorData: base64Url(result.authenticatorData),
|
||||
signature: base64Url(result.signature),
|
||||
};
|
||||
if (result.userHandle) response.userHandle = base64Url(result.userHandle);
|
||||
return rawIdCredential(result.rawId, response, result.authenticatorAttachment);
|
||||
}
|
||||
|
||||
function getBackendInfo() {
|
||||
if (!binding || typeof binding.getBackendInfo !== 'function') {
|
||||
return {
|
||||
target: `${process.platform}/${process.arch}`,
|
||||
backend: 'unavailable',
|
||||
nativeLoaded: false,
|
||||
ceremoniesImplemented: false,
|
||||
platformBrokerAvailable: false,
|
||||
platformAuthenticatorAvailable: false,
|
||||
supported: false,
|
||||
apiVersion: 0,
|
||||
reason: loadError instanceof Error ? loadError.message : 'native backend did not load',
|
||||
};
|
||||
}
|
||||
return binding.getBackendInfo();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
create,
|
||||
get,
|
||||
getBackendInfo,
|
||||
isSupported,
|
||||
loadError,
|
||||
_private: {
|
||||
base64Url,
|
||||
clientDataJSON,
|
||||
nativeFileName,
|
||||
normalizeCreateOptions,
|
||||
normalizeGetOptions,
|
||||
resolveNativeRoot,
|
||||
transportBits,
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user