feat: pairing v2 — one-tap trusted devices over LAN and private relay (#2103)
Reworks how devices connect to an OpenChamber server, end to end. Pairing v2: - One-time pairing links/QR codes (openchamber://connect?v=2) carrying a set of transport candidates (LAN/tunnel/relay) and a single-use secret redeemed server-side; no tokens embedded in links - Add-a-device dialog written for first-time users: intent-based transport choice (Anywhere / Home network only / This computer only) with plain-language descriptions, transparent fallback checkboxes, server-authoritative LAN detection, high-res QR dialog - Private relay folded into pairing as a transport candidate with a demand-driven lifecycle (enables when a relay device is paired, disables when none remain) Multi-transport devices: - A saved device holds all its transports and one token; mobile re-probes on connect, resume, and network change and hot-switches LAN<->relay seamlessly (no re-pairing, no remount, session preserved) - Desktop can import relay pairing links, switch to relay hosts through the E2EE tunnel, and restore a relay default host after relaunch Device management: - Device list (web + desktop) shows live per-device connectivity with the active transport (Connected - Local network / Relay) and platform badges (iOS/Android/macOS/Windows/Linux) - One physical device = one record: stable per-install dedupe keys across pairing and password re-login; typed pairing label names the device, paired devices name the connection by the issuing server hostname - Trusted desktop-local client manages all devices (list, revoke, clear revoked); relay host reaps dead client sockets after 3 missed keepalives Android: - LAN transport unblocked (cleartext + mixed content, mirroring iOS ATS exceptions); resume re-probe retries through network flux and silently auto-reconnects from a disconnected state
This commit is contained in:
@@ -36,7 +36,9 @@ Command modules implement user-facing commands and preserve output contracts acr
|
||||
- `commands-connect-url.js`
|
||||
- Implements `openchamber connect-url`.
|
||||
- Finds or starts a local instance and prints the browser/connect URL according to the selected output mode.
|
||||
- `--relay` builds an end-to-end-encrypted relay pairing link instead: it mints a client token and an offer from the instance's local relay identity (no server URL, no auto-start). The relay endpoint follows `OPENCHAMBER_RELAY_URL` / the stored setting / the default, matching the running host; clients read it from the offer.
|
||||
- Emits a **pairing v2** link (`openchamber://connect?v=2&p=<base64url>`): it creates a one-time pairing session in the shared store (`client-pairing-sessions.json`) and encodes the pairing id + secret + transport candidates. The client redeems the secret over whichever candidate connects first (`/api/client-auth/pairing/redeem`). No standalone token is embedded — the QR itself is the single-use credential.
|
||||
- The default form advertises the resolved server URL as a direct (lan/tunnel) candidate and folds in a relay candidate when the host relay is enabled, so one link works on-LAN and off-network.
|
||||
- `--relay` builds a relay-only pairing link (the sole candidate is the relay transport), for sharing with a device that is not on the host's network — no server URL, no auto-start. The relay endpoint follows `OPENCHAMBER_RELAY_URL` / the stored setting / the default, matching the running host; the host must be running with the relay enabled to serve the redeem over the tunnel.
|
||||
|
||||
- `commands-update.js`
|
||||
- Implements `openchamber update`.
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import { discoverRunningInstances } from './cli-lifecycle.js';
|
||||
import { getInstanceFilePath, readInstanceOptions } from './cli-process.js';
|
||||
import { createRemoteClientAuthRuntime } from '../../server/lib/client-auth/remote-clients.js';
|
||||
import { createClientPairingRuntime } from '../../server/lib/client-auth/pairing.js';
|
||||
import { createRelayIdentityRuntime } from '../../server/lib/relay/identity.js';
|
||||
import { DEFAULT_RELAY_URL } from '../../server/lib/relay/service.js';
|
||||
import { bytesToBase64Url } from '../../server/lib/relay/e2ee.js';
|
||||
@@ -28,6 +29,7 @@ import {
|
||||
|
||||
const REMOTE_CLIENTS_FILE_NAME = 'remote-clients.json';
|
||||
const SETTINGS_FILE_NAME = 'settings.json';
|
||||
const PAIRING_SESSIONS_FILE_NAME = 'client-pairing-sessions.json';
|
||||
|
||||
function isValidRelayUrl(value) {
|
||||
if (typeof value !== 'string') return false;
|
||||
@@ -69,42 +71,94 @@ function createSettingsAccessors() {
|
||||
return { readSettingsFromDiskMigrated, writeSettingsToDisk };
|
||||
}
|
||||
|
||||
// Builds an end-to-end-encrypted relay pairing link. Reuses the instance's relay
|
||||
// identity (serverId + encryption public key), generating it if the relay was
|
||||
// never enabled. The client reads the relay URL from the offer, so no client-side
|
||||
// configuration is needed.
|
||||
async function buildRelayConnectionPayload({ token, label }) {
|
||||
// Resolves the instance's relay identity (serverId + encryption public key,
|
||||
// generating it if the relay was never enabled) into a pairing-v2 relay
|
||||
// candidate. Relay is a transport, not a separate link format: the candidate
|
||||
// carries no token — the client redeems the one-time pairing secret over the
|
||||
// E2EE tunnel like any other candidate. `enabled` reports whether the host relay
|
||||
// is actually on (a relay candidate only connects when the host is relaying).
|
||||
async function buildRelayPairingCandidate() {
|
||||
const accessors = createSettingsAccessors();
|
||||
const settings = await accessors.readSettingsFromDiskMigrated();
|
||||
const relayUrl = resolveRelayUrl(settings);
|
||||
const identityRuntime = createRelayIdentityRuntime({ crypto, ...accessors });
|
||||
const identity = await identityRuntime.getRelayIdentity();
|
||||
const offer = {
|
||||
v: 1,
|
||||
mode: 'relay',
|
||||
return {
|
||||
enabled: settings?.privateRelay?.enabled === true,
|
||||
relayUrl,
|
||||
serverId: identity.serverId,
|
||||
hostEncPubJwk: identity.hostEncPubJwk,
|
||||
label,
|
||||
token,
|
||||
candidate: {
|
||||
type: 'relay',
|
||||
relayUrl,
|
||||
serverId: identity.serverId,
|
||||
hostEncPubJwk: identity.hostEncPubJwk,
|
||||
priority: 30,
|
||||
},
|
||||
};
|
||||
const encoded = bytesToBase64Url(new TextEncoder().encode(JSON.stringify(offer)));
|
||||
return { connectUrl: `openchamber://connect?v=1&mode=relay#offer=${encoded}`, relayUrl, serverId: identity.serverId };
|
||||
}
|
||||
|
||||
async function generateRelayConnectUrl(options) {
|
||||
const label = options.name || os.hostname();
|
||||
const runtime = createRemoteClientAuthRuntime({
|
||||
// Pairing runtime backed by the same on-disk store the running host reads, so a
|
||||
// session created here is redeemable by the live server. createPairingSession
|
||||
// only writes the store (no server needed to mint); redeem is served by the host.
|
||||
function createCliPairingRuntime() {
|
||||
const dataDir = getOpenChamberDataDir();
|
||||
const remoteClientAuthRuntime = createRemoteClientAuthRuntime({
|
||||
fsPromises: fs.promises,
|
||||
path,
|
||||
crypto,
|
||||
storePath: path.join(getOpenChamberDataDir(), REMOTE_CLIENTS_FILE_NAME),
|
||||
storePath: path.join(dataDir, REMOTE_CLIENTS_FILE_NAME),
|
||||
});
|
||||
const result = await runtime.createClient({ label, clientKind: 'relay' });
|
||||
const { connectUrl, relayUrl, serverId } = await buildRelayConnectionPayload({ token: result.token, label });
|
||||
return createClientPairingRuntime({
|
||||
fsPromises: fs.promises,
|
||||
path,
|
||||
crypto,
|
||||
storePath: path.join(dataDir, PAIRING_SESSIONS_FILE_NAME),
|
||||
remoteClientAuthRuntime,
|
||||
});
|
||||
}
|
||||
|
||||
// Mirror of encodePairingConnectionPayload in @openchamber/ui (the bin cannot
|
||||
// import the UI package). Keep in sync: v2 payload → base64url(JSON) in the URL
|
||||
// query, so the one-time secret rides the link, never the network.
|
||||
function encodePairingConnectUrl(payload) {
|
||||
const encoded = bytesToBase64Url(new TextEncoder().encode(JSON.stringify(payload)));
|
||||
return `openchamber://connect?v=2&p=${encoded}`;
|
||||
}
|
||||
|
||||
function buildPairingPayload({ pairing, label, candidates }) {
|
||||
return {
|
||||
v: 2,
|
||||
pairingId: pairing.id,
|
||||
secret: pairing.secret,
|
||||
...(label ? { label } : {}),
|
||||
...(pairing.fingerprint ? { fingerprint: pairing.fingerprint } : {}),
|
||||
...(pairing.expiresAt ? { expiresAt: pairing.expiresAt } : {}),
|
||||
candidates,
|
||||
};
|
||||
}
|
||||
|
||||
// Relay-only pairing link: the sole candidate is the relay transport, for
|
||||
// sharing with a device that is not on the host's network. Needs no reachable
|
||||
// server URL, but the host must be running with the relay enabled to serve the
|
||||
// redeem over the tunnel.
|
||||
async function generateRelayConnectUrl(options) {
|
||||
const label = options.name || os.hostname();
|
||||
const relay = await buildRelayPairingCandidate();
|
||||
const pairingRuntime = createCliPairingRuntime();
|
||||
const { pairing } = await pairingRuntime.createPairingSession({ label });
|
||||
const connectUrl = encodePairingConnectUrl(buildPairingPayload({ pairing, label, candidates: [relay.candidate] }));
|
||||
|
||||
if (isJsonMode(options)) {
|
||||
printJson({ mode: 'relay', relayUrl, serverId, connectUrl, token: result.token, client: result.client });
|
||||
printJson({
|
||||
mode: 'relay',
|
||||
relayUrl: relay.relayUrl,
|
||||
serverId: relay.serverId,
|
||||
relayEnabled: relay.enabled,
|
||||
pairingId: pairing.id,
|
||||
fingerprint: pairing.fingerprint,
|
||||
expiresAt: pairing.expiresAt,
|
||||
connectUrl,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -113,15 +167,18 @@ async function generateRelayConnectUrl(options) {
|
||||
return;
|
||||
}
|
||||
|
||||
clackIntro('OpenChamber relay connect URL');
|
||||
clackIntro('OpenChamber relay pairing link');
|
||||
logStatus('success', connectUrl);
|
||||
clackLog.info(`Relay: ${relayUrl}`);
|
||||
logStatus('info', '[RELAY_ENABLE]', 'Enable the relay on this instance so this link can connect (Settings -> Remote Instances).');
|
||||
clackLog.info('Copy this link into another OpenChamber client. The token is shown only once.');
|
||||
clackLog.info(`Relay: ${relay.relayUrl}`);
|
||||
if (pairing.fingerprint) clackLog.info(`Fingerprint: ${pairing.fingerprint}`);
|
||||
if (!relay.enabled) {
|
||||
logStatus('info', '[RELAY_ENABLE]', 'Enable the relay on this instance so this link can connect (Settings -> Remote Instances).');
|
||||
}
|
||||
clackLog.info('Scan or paste this link into another OpenChamber client. It is single-use and expires.');
|
||||
if (options.qr === true) {
|
||||
await displayTunnelQrCode(connectUrl);
|
||||
}
|
||||
clackOutro('relay connect URL generated');
|
||||
clackOutro('relay pairing link generated');
|
||||
}
|
||||
|
||||
async function resolveConnectUrlServerUrl(options) {
|
||||
@@ -190,15 +247,6 @@ function getOpenChamberDataDir() {
|
||||
: path.join(os.homedir(), '.config', 'openchamber');
|
||||
}
|
||||
|
||||
function buildClientConnectionPayload({ serverUrl, token, label }) {
|
||||
const params = new URLSearchParams();
|
||||
params.set('v', '1');
|
||||
params.set('server', serverUrl.trim().replace(/\/+$/, ''));
|
||||
params.set('token', token.trim());
|
||||
if (label?.trim()) params.set('label', label.trim());
|
||||
return `openchamber://connect?${params.toString()}`;
|
||||
}
|
||||
|
||||
async function displayTunnelQrCode(url) {
|
||||
try {
|
||||
const qrcode = await import('qrcode-terminal');
|
||||
@@ -247,18 +295,29 @@ function createConnectUrlCommand({ serveCommand }) {
|
||||
? { serverUrl: explicitServerUrl, source: 'explicit' }
|
||||
: await resolveConnectUrlServerUrl(options);
|
||||
const serverUrl = resolvedServerUrl.serverUrl;
|
||||
const label = options.name || `OpenChamber ${serverUrl}`;
|
||||
const runtime = createRemoteClientAuthRuntime({
|
||||
fsPromises: fs.promises,
|
||||
path,
|
||||
crypto,
|
||||
storePath: path.join(getOpenChamberDataDir(), REMOTE_CLIENTS_FILE_NAME),
|
||||
});
|
||||
const result = await runtime.createClient({ label });
|
||||
const connectUrl = buildClientConnectionPayload({ serverUrl, token: result.token, label });
|
||||
const label = options.name || os.hostname();
|
||||
|
||||
// Direct candidate for the reachable server URL, plus the relay transport as
|
||||
// a fallback candidate when the host relay is enabled — one link that works
|
||||
// both on the LAN and off-network.
|
||||
const candidates = [{ type: serverUrl.startsWith('https://') ? 'tunnel' : 'lan', url: serverUrl, priority: 10 }];
|
||||
const relay = await buildRelayPairingCandidate();
|
||||
if (relay.enabled) candidates.push(relay.candidate);
|
||||
|
||||
const pairingRuntime = createCliPairingRuntime();
|
||||
const { pairing } = await pairingRuntime.createPairingSession({ label });
|
||||
const connectUrl = encodePairingConnectUrl(buildPairingPayload({ pairing, label, candidates }));
|
||||
|
||||
if (isJsonMode(options)) {
|
||||
printJson({ serverUrl, connectUrl, token: result.token, client: result.client, autoStarted: serverState.autoStarted });
|
||||
printJson({
|
||||
serverUrl,
|
||||
connectUrl,
|
||||
pairingId: pairing.id,
|
||||
fingerprint: pairing.fingerprint,
|
||||
expiresAt: pairing.expiresAt,
|
||||
candidates,
|
||||
autoStarted: serverState.autoStarted,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -267,22 +326,28 @@ function createConnectUrlCommand({ serveCommand }) {
|
||||
return;
|
||||
}
|
||||
|
||||
clackIntro('OpenChamber connect URL');
|
||||
clackIntro('OpenChamber pairing link');
|
||||
if (serverState.autoStarted) {
|
||||
logStatus('success', `started OpenChamber on port ${options.port}`);
|
||||
}
|
||||
logStatus('success', connectUrl);
|
||||
clackLog.info(`Server URL: ${serverUrl}`);
|
||||
if (relay.enabled) {
|
||||
clackLog.info(`Relay fallback: ${relay.relayUrl}`);
|
||||
}
|
||||
if (pairing.fingerprint) {
|
||||
clackLog.info(`Fingerprint: ${pairing.fingerprint}`);
|
||||
}
|
||||
if (resolvedServerUrl.source === 'lan-detected') {
|
||||
clackLog.info('Detected a LAN address because OpenChamber is bound to all interfaces. Use --server to override it.');
|
||||
} else if (resolvedServerUrl.source === 'loopback-fallback') {
|
||||
clackLog.warn('OpenChamber is bound to all interfaces, but no LAN address was detected. Use --server to provide a reachable URL.');
|
||||
}
|
||||
clackLog.info('Copy this connection link into another OpenChamber client. The token is shown only once.');
|
||||
clackLog.info('Scan or paste this link into another OpenChamber client. It is single-use and expires.');
|
||||
if (options.qr === true) {
|
||||
await displayTunnelQrCode(connectUrl);
|
||||
}
|
||||
clackOutro('connect URL generated');
|
||||
clackOutro('pairing link generated');
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -87,6 +87,7 @@ import { createNotificationTemplateRuntime } from './lib/notifications/template-
|
||||
import { createGracefulShutdownRuntime } from './lib/opencode/shutdown-runtime.js';
|
||||
import { createProjectConfigRuntime } from './lib/projects/project-config.js';
|
||||
import { createRemoteClientAuthRuntime } from './lib/client-auth/remote-clients.js';
|
||||
import { createClientPairingRuntime } from './lib/client-auth/pairing.js';
|
||||
import { createPreviewProxyRuntime } from './lib/preview/proxy-runtime.js';
|
||||
import { attachRealtimeProxy } from './lib/realtime-proxy.js';
|
||||
import { createRelayService } from './lib/relay/service.js';
|
||||
@@ -282,6 +283,7 @@ const SETTINGS_FILE_PATH = path.join(OPENCHAMBER_DATA_DIR, 'settings.json');
|
||||
const PUSH_SUBSCRIPTIONS_FILE_PATH = path.join(OPENCHAMBER_DATA_DIR, 'push-subscriptions.json');
|
||||
const APNS_TOKENS_FILE_PATH = path.join(OPENCHAMBER_DATA_DIR, 'apns-tokens.json');
|
||||
const REMOTE_CLIENTS_FILE_PATH = path.join(OPENCHAMBER_DATA_DIR, 'remote-clients.json');
|
||||
const CLIENT_PAIRING_SESSIONS_FILE_PATH = path.join(OPENCHAMBER_DATA_DIR, 'client-pairing-sessions.json');
|
||||
const CLOUDFLARE_MANAGED_REMOTE_TUNNELS_FILE_PATH = path.join(OPENCHAMBER_DATA_DIR, 'cloudflare-managed-remote-tunnels.json');
|
||||
const CLOUDFLARE_LEGACY_NAMED_TUNNELS_FILE_PATH = path.join(OPENCHAMBER_DATA_DIR, 'cloudflare-named-tunnels.json');
|
||||
const CLOUDFLARE_MANAGED_REMOTE_TUNNELS_VERSION = 1;
|
||||
@@ -873,6 +875,13 @@ const remoteClientAuthRuntime = createRemoteClientAuthRuntime({
|
||||
crypto,
|
||||
storePath: REMOTE_CLIENTS_FILE_PATH,
|
||||
});
|
||||
const clientPairingRuntime = createClientPairingRuntime({
|
||||
fsPromises,
|
||||
path,
|
||||
crypto,
|
||||
storePath: CLIENT_PAIRING_SESSIONS_FILE_PATH,
|
||||
remoteClientAuthRuntime,
|
||||
});
|
||||
const featureRoutesRuntime = createFeatureRoutesRuntime({
|
||||
clientReloadDelayMs: CLIENT_RELOAD_DELAY_MS,
|
||||
});
|
||||
@@ -1102,6 +1111,34 @@ async function main(options = {}) {
|
||||
|| (typeof process.env.OPENCHAMBER_HOST === 'string' && process.env.OPENCHAMBER_HOST.trim().length > 0
|
||||
? process.env.OPENCHAMBER_HOST.trim()
|
||||
: '127.0.0.1');
|
||||
|
||||
// Pairing transports advertised to the create-device dialog. LAN reachability is
|
||||
// derived from the SERVER's actual bind (a wildcard bind → the machine's LAN IP;
|
||||
// a specific non-loopback host → that host), NOT from how the UI was opened — so
|
||||
// "Local network" works even when the UI is opened on localhost, and is absent
|
||||
// when the server is only bound to loopback (a LAN link would not connect).
|
||||
const resolvePairingTransports = () => {
|
||||
const activePort = tunnelRuntimeContext.getActivePort() || port;
|
||||
const local = `http://127.0.0.1:${activePort}`;
|
||||
let lanHost = null;
|
||||
if (isNetworkExposedBindHost(effectiveBindHost)) {
|
||||
try {
|
||||
for (const list of Object.values(os.networkInterfaces())) {
|
||||
for (const entry of (list || [])) {
|
||||
if (entry.family === 'IPv4' && !entry.internal) { lanHost = entry.address; break; }
|
||||
}
|
||||
if (lanHost) break;
|
||||
}
|
||||
} catch {
|
||||
lanHost = null;
|
||||
}
|
||||
} else {
|
||||
const h = String(effectiveBindHost || '').toLowerCase();
|
||||
if (h && h !== '127.0.0.1' && h !== 'localhost' && h !== '::1') lanHost = effectiveBindHost;
|
||||
}
|
||||
const lan = lanHost ? `http://${lanHost.includes(':') ? `[${lanHost}]` : lanHost}:${activePort}` : null;
|
||||
return { local, lan, relayAvailable: true };
|
||||
};
|
||||
const uiPassword = typeof options.uiPassword === 'string'
|
||||
? options.uiPassword
|
||||
: (typeof process.env.OPENCHAMBER_UI_PASSWORD === 'string' ? process.env.OPENCHAMBER_UI_PASSWORD : null);
|
||||
@@ -1204,6 +1241,11 @@ async function main(options = {}) {
|
||||
server = http.createServer(app);
|
||||
let realtimeProxyRuntime = { stop: () => {} };
|
||||
|
||||
// The relay service is constructed further below (it depends on the tunnel
|
||||
// runtime's active port). The pairing routes registered here only read the
|
||||
// relay candidate lazily at request time, so a late-bound holder is enough.
|
||||
let relayServiceInstance = null;
|
||||
|
||||
const bootstrapResult = bootstrapRuntime.setupBaseRoutes(app, {
|
||||
process,
|
||||
openchamberVersion: OPENCHAMBER_VERSION,
|
||||
@@ -1244,6 +1286,30 @@ async function main(options = {}) {
|
||||
uiPassword,
|
||||
tunnelAuthController,
|
||||
remoteClientAuthRuntime,
|
||||
clientPairingRuntime,
|
||||
getRelayPairingCandidate: (options) => {
|
||||
if (!relayServiceInstance) return null;
|
||||
// A relay pairing link enables the relay on demand; a plain link only
|
||||
// advertises relay when it is already on.
|
||||
return options?.ensureEnabled
|
||||
? relayServiceInstance.ensureEnabledForPairing()
|
||||
: relayServiceInstance.getPairingCandidate();
|
||||
},
|
||||
// Re-evaluate the relay lifecycle after pairing/device changes (a revoked or
|
||||
// redeemed device can flip relay demand on or off).
|
||||
reconcileRelay: () => (relayServiceInstance ? relayServiceInstance.reconcile() : Promise.resolve()),
|
||||
getPairingTransports: resolvePairingTransports,
|
||||
// The display name a paired device shows for THIS server. Devices name the
|
||||
// connection by the issuing machine's hostname, not the per-device pairing
|
||||
// label typed by the operator.
|
||||
getServerLabel: () => {
|
||||
try {
|
||||
const name = os.hostname();
|
||||
return typeof name === 'string' && name.trim().length > 0 ? name.trim() : 'OpenChamber';
|
||||
} catch {
|
||||
return 'OpenChamber';
|
||||
}
|
||||
},
|
||||
readSettingsFromDiskMigrated,
|
||||
normalizeTunnelSessionTtlMs,
|
||||
sayTTSCapability,
|
||||
@@ -1297,7 +1363,17 @@ async function main(options = {}) {
|
||||
writeSettingsToDisk,
|
||||
remoteClientAuthRuntime,
|
||||
getLocalPort: () => tunnelRuntimeContext.getActivePort(),
|
||||
// Relay demand = any paired device or pending pairing session that uses the
|
||||
// relay transport. Drives the auto on/off lifecycle.
|
||||
hasRelayDemand: async () => {
|
||||
const [pendingRelay, deviceRelay] = await Promise.all([
|
||||
clientPairingRuntime.hasActiveRelaySession().catch(() => false),
|
||||
remoteClientAuthRuntime.hasActiveRelayClients().catch(() => false),
|
||||
]);
|
||||
return pendingRelay || deviceRelay;
|
||||
},
|
||||
});
|
||||
relayServiceInstance = relayService;
|
||||
relayService.registerRoutes(app);
|
||||
|
||||
await featureRoutesRuntime.registerRoutes(app, {
|
||||
@@ -1410,7 +1486,9 @@ async function main(options = {}) {
|
||||
}
|
||||
|
||||
// Only opens a relay control socket when the user opted in (config enabled).
|
||||
void relayService.startIfEnabled();
|
||||
// Reconcile the relay lifecycle from demand on startup: run it if any relay
|
||||
// device/session exists, stop it (and clear a stale enabled flag) otherwise.
|
||||
void relayService.reconcile();
|
||||
|
||||
return {
|
||||
expressApp: app,
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
const STORE_VERSION = 1;
|
||||
const PAIRING_ID_PREFIX = 'pair_';
|
||||
const SECRET_BYTES = 32;
|
||||
const FINGERPRINT_BYTES = 4;
|
||||
const DEFAULT_TTL_MS = 10 * 60 * 1000;
|
||||
const MAX_LABEL_LENGTH = 80;
|
||||
const VALID_CLIENT_KINDS = new Set(['mobile', 'desktop']);
|
||||
const GENERIC_REDEEM_ERROR = 'Invalid or expired pairing session';
|
||||
|
||||
const normalizeOptionalString = (value) => {
|
||||
if (typeof value !== 'string') return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
};
|
||||
|
||||
// Placeholder shown in the pending-devices list when the operator did not type a
|
||||
// name. It is a DISPLAY default only — the stored label stays null so redeem can
|
||||
// fall back to the device's own reported name instead of this placeholder.
|
||||
const PAIRING_LABEL_PLACEHOLDER = 'Pair new device';
|
||||
|
||||
// The operator's typed device label, capped. Returns null when unset so callers
|
||||
// can distinguish "no name given" from a real name.
|
||||
const normalizeStoredLabel = (value) => {
|
||||
const normalized = normalizeOptionalString(value);
|
||||
if (!normalized) return null;
|
||||
return normalized.length > MAX_LABEL_LENGTH ? normalized.slice(0, MAX_LABEL_LENGTH) : normalized;
|
||||
};
|
||||
|
||||
const normalizeTimestamp = (value) => {
|
||||
const normalized = normalizeOptionalString(value);
|
||||
if (!normalized) return null;
|
||||
const time = Date.parse(normalized);
|
||||
return Number.isFinite(time) ? new Date(time).toISOString() : null;
|
||||
};
|
||||
|
||||
const normalizeClientKind = (value) => {
|
||||
const normalized = normalizeOptionalString(value);
|
||||
return normalized && VALID_CLIENT_KINDS.has(normalized) ? normalized : null;
|
||||
};
|
||||
|
||||
const normalizeAllowedClientKinds = (value) => {
|
||||
if (!Array.isArray(value)) return ['mobile', 'desktop'];
|
||||
const kinds = value.map(normalizeClientKind).filter(Boolean);
|
||||
return kinds.length > 0 ? Array.from(new Set(kinds)) : ['mobile', 'desktop'];
|
||||
};
|
||||
|
||||
const safeJsonParse = (raw) => {
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const constantTimeEqual = (left, right, crypto) => {
|
||||
if (typeof left !== 'string' || typeof right !== 'string') return false;
|
||||
const leftBuffer = Buffer.from(left, 'hex');
|
||||
const rightBuffer = Buffer.from(right, 'hex');
|
||||
if (leftBuffer.length !== rightBuffer.length) return false;
|
||||
return crypto.timingSafeEqual(leftBuffer, rightBuffer);
|
||||
};
|
||||
|
||||
const publicSession = (session) => ({
|
||||
id: session.id,
|
||||
createdAt: session.createdAt,
|
||||
expiresAt: session.expiresAt,
|
||||
usedAt: session.usedAt,
|
||||
cancelledAt: session.cancelledAt,
|
||||
clientId: session.clientId,
|
||||
label: session.label || PAIRING_LABEL_PLACEHOLDER,
|
||||
fingerprint: session.fingerprint,
|
||||
allowedClientKinds: session.allowedClientKinds,
|
||||
createdByClientId: session.createdByClientId,
|
||||
usesRelay: session.usesRelay === true,
|
||||
});
|
||||
|
||||
// A pending session is one that can still be redeemed: not used, not cancelled,
|
||||
// not expired.
|
||||
const isPendingSession = (session) => !session.usedAt
|
||||
&& !session.cancelledAt
|
||||
&& Number.isFinite(Date.parse(session.expiresAt))
|
||||
&& Date.parse(session.expiresAt) > Date.now();
|
||||
|
||||
const redeemError = () => {
|
||||
const error = new Error(GENERIC_REDEEM_ERROR);
|
||||
error.statusCode = 400;
|
||||
return error;
|
||||
};
|
||||
|
||||
export const createClientPairingRuntime = ({
|
||||
fsPromises,
|
||||
path,
|
||||
crypto,
|
||||
storePath,
|
||||
remoteClientAuthRuntime,
|
||||
ttlMs = DEFAULT_TTL_MS,
|
||||
} = {}) => {
|
||||
if (!fsPromises || !path || !crypto || !storePath || !remoteClientAuthRuntime) {
|
||||
throw new Error('createClientPairingRuntime requires fsPromises, path, crypto, storePath, and remoteClientAuthRuntime');
|
||||
}
|
||||
|
||||
const nowIso = () => new Date().toISOString();
|
||||
const hashSecret = (secret) => crypto.createHash('sha256').update(secret).digest('hex');
|
||||
const generateId = () => `${PAIRING_ID_PREFIX}${crypto.randomBytes(12).toString('hex')}`;
|
||||
const generateSecret = () => crypto.randomBytes(SECRET_BYTES).toString('base64url');
|
||||
const generateFingerprint = () => crypto.randomBytes(FINGERPRINT_BYTES).toString('hex').toUpperCase().replace(/^(.{4})(.{4})$/, '$1-$2');
|
||||
let storeMutationQueue = Promise.resolve();
|
||||
|
||||
const withStoreMutation = async (fn) => {
|
||||
const previous = storeMutationQueue;
|
||||
let release;
|
||||
storeMutationQueue = new Promise((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
await previous;
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeStore = (payload) => ({
|
||||
version: STORE_VERSION,
|
||||
sessions: Array.isArray(payload?.sessions)
|
||||
? payload.sessions
|
||||
.filter((session) => session && typeof session === 'object')
|
||||
.map((session) => ({
|
||||
id: typeof session.id === 'string' ? session.id : generateId(),
|
||||
secretHash: typeof session.secretHash === 'string' ? session.secretHash : '',
|
||||
createdAt: typeof session.createdAt === 'string' ? session.createdAt : nowIso(),
|
||||
expiresAt: normalizeTimestamp(session.expiresAt) || new Date(Date.now() + ttlMs).toISOString(),
|
||||
usedAt: normalizeTimestamp(session.usedAt),
|
||||
cancelledAt: normalizeTimestamp(session.cancelledAt),
|
||||
clientId: normalizeOptionalString(session.clientId),
|
||||
label: normalizeStoredLabel(session.label),
|
||||
fingerprint: normalizeOptionalString(session.fingerprint) || generateFingerprint(),
|
||||
allowedClientKinds: normalizeAllowedClientKinds(session.allowedClientKinds),
|
||||
createdByClientId: normalizeOptionalString(session.createdByClientId),
|
||||
usesRelay: session.usesRelay === true,
|
||||
}))
|
||||
.filter((session) => session.secretHash.length > 0)
|
||||
: [],
|
||||
});
|
||||
|
||||
const readStore = async () => {
|
||||
try {
|
||||
const raw = await fsPromises.readFile(storePath, 'utf8');
|
||||
return normalizeStore(safeJsonParse(raw));
|
||||
} catch (error) {
|
||||
if (error?.code === 'ENOENT') return normalizeStore(null);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const writeStore = async (store) => {
|
||||
await fsPromises.mkdir(path.dirname(storePath), { recursive: true, mode: 0o700 });
|
||||
await fsPromises.writeFile(storePath, JSON.stringify(normalizeStore(store), null, 2), { mode: 0o600 });
|
||||
if (typeof fsPromises.chmod === 'function') {
|
||||
await fsPromises.chmod(storePath, 0o600).catch(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
const sweepExpiredSessionsFromStore = (store) => {
|
||||
const now = Date.now();
|
||||
const cutoff = now - ttlMs;
|
||||
store.sessions = store.sessions.filter((session) => {
|
||||
const usedAt = Date.parse(session.usedAt || '');
|
||||
const cancelledAt = Date.parse(session.cancelledAt || '');
|
||||
const inactiveAt = Number.isFinite(usedAt) ? usedAt : cancelledAt;
|
||||
if (Number.isFinite(inactiveAt)) return inactiveAt >= cutoff;
|
||||
// Never used or cancelled: drop once the session itself has expired —
|
||||
// it can no longer be redeemed and would otherwise sit in the store forever.
|
||||
const expiresAt = Date.parse(session.expiresAt || '');
|
||||
return !Number.isFinite(expiresAt) || expiresAt > now;
|
||||
});
|
||||
};
|
||||
|
||||
const createPairingSession = async ({ label, allowedClientKinds, createdByClientId, usesRelay } = {}) => {
|
||||
return withStoreMutation(async () => {
|
||||
const store = await readStore();
|
||||
sweepExpiredSessionsFromStore(store);
|
||||
const secret = generateSecret();
|
||||
const session = {
|
||||
id: generateId(),
|
||||
secretHash: hashSecret(secret),
|
||||
createdAt: nowIso(),
|
||||
expiresAt: new Date(Date.now() + ttlMs).toISOString(),
|
||||
usedAt: null,
|
||||
cancelledAt: null,
|
||||
clientId: null,
|
||||
label: normalizeStoredLabel(label),
|
||||
fingerprint: generateFingerprint(),
|
||||
allowedClientKinds: normalizeAllowedClientKinds(allowedClientKinds),
|
||||
createdByClientId: normalizeOptionalString(createdByClientId),
|
||||
usesRelay: usesRelay === true,
|
||||
};
|
||||
store.sessions.push(session);
|
||||
await writeStore(store);
|
||||
return { pairing: { ...publicSession(session), secret } };
|
||||
});
|
||||
};
|
||||
|
||||
// Sessions that can still be redeemed (link created, device not yet connected).
|
||||
const listPendingSessions = async () => withStoreMutation(async () => {
|
||||
const store = await readStore();
|
||||
return store.sessions.filter(isPendingSession).map(publicSession);
|
||||
});
|
||||
|
||||
// Relay-transport demand from pairing: any still-redeemable relay session.
|
||||
const hasActiveRelaySession = async () => withStoreMutation(async () => {
|
||||
const store = await readStore();
|
||||
return store.sessions.some((session) => session.usesRelay === true && isPendingSession(session));
|
||||
});
|
||||
|
||||
const getPairingSession = async (id) => {
|
||||
const normalizedId = normalizeOptionalString(id);
|
||||
if (!normalizedId) return null;
|
||||
return withStoreMutation(async () => {
|
||||
const store = await readStore();
|
||||
const session = store.sessions.find((entry) => entry.id === normalizedId);
|
||||
return session ? publicSession(session) : null;
|
||||
});
|
||||
};
|
||||
|
||||
const cancelPairingSession = async (id) => {
|
||||
const normalizedId = normalizeOptionalString(id);
|
||||
if (!normalizedId) return { cancelled: false };
|
||||
return withStoreMutation(async () => {
|
||||
const store = await readStore();
|
||||
const session = store.sessions.find((entry) => entry.id === normalizedId);
|
||||
if (!session) return { cancelled: false };
|
||||
if (!session.cancelledAt) session.cancelledAt = nowIso();
|
||||
await writeStore(store);
|
||||
return { cancelled: true, pairing: publicSession(session) };
|
||||
});
|
||||
};
|
||||
|
||||
const redeemPairingSession = async ({
|
||||
pairingId,
|
||||
secret,
|
||||
clientLabel,
|
||||
clientKind,
|
||||
deviceName,
|
||||
devicePlatform,
|
||||
deviceModel,
|
||||
appVersion,
|
||||
dedupeKey,
|
||||
} = {}) => {
|
||||
const normalizedId = normalizeOptionalString(pairingId);
|
||||
const normalizedSecret = normalizeOptionalString(secret);
|
||||
const normalizedKind = normalizeClientKind(clientKind) || 'mobile';
|
||||
if (!normalizedId || !normalizedSecret) throw redeemError();
|
||||
|
||||
return withStoreMutation(async () => {
|
||||
const store = await readStore();
|
||||
const session = store.sessions.find((entry) => entry.id === normalizedId);
|
||||
if (!session) throw redeemError();
|
||||
if (session.cancelledAt || session.usedAt) throw redeemError();
|
||||
if (Date.parse(session.expiresAt) <= Date.now()) throw redeemError();
|
||||
if (!session.allowedClientKinds.includes(normalizedKind)) throw redeemError();
|
||||
if (!constantTimeEqual(session.secretHash, hashSecret(normalizedSecret), crypto)) throw redeemError();
|
||||
|
||||
// The operator's typed pairing label is THIS server's name for the device
|
||||
// (shown in the device list). It wins over the device's self-reported
|
||||
// label; fall back to that only when no pairing label was set.
|
||||
const label = normalizeOptionalString(session.label)
|
||||
|| normalizeOptionalString(clientLabel)
|
||||
|| normalizeOptionalString(deviceName)
|
||||
|| 'Remote client';
|
||||
const result = await remoteClientAuthRuntime.createClient({
|
||||
label,
|
||||
clientKind: normalizedKind,
|
||||
dedupeKey: normalizeOptionalString(dedupeKey) || `pairing:${session.id}`,
|
||||
authMethod: 'pairing',
|
||||
pairingId: session.id,
|
||||
deviceName,
|
||||
devicePlatform,
|
||||
deviceModel,
|
||||
appVersion,
|
||||
usesRelay: session.usesRelay === true,
|
||||
});
|
||||
session.usedAt = nowIso();
|
||||
session.clientId = result.client?.id || null;
|
||||
await writeStore(store);
|
||||
return { pairing: publicSession(session), client: result.client, token: result.token };
|
||||
});
|
||||
};
|
||||
|
||||
const sweepExpiredSessions = async () => withStoreMutation(async () => {
|
||||
const store = await readStore();
|
||||
const before = store.sessions.length;
|
||||
sweepExpiredSessionsFromStore(store);
|
||||
const purged = before - store.sessions.length;
|
||||
if (purged > 0) await writeStore(store);
|
||||
return { purged };
|
||||
});
|
||||
|
||||
return {
|
||||
createPairingSession,
|
||||
getPairingSession,
|
||||
listPendingSessions,
|
||||
hasActiveRelaySession,
|
||||
cancelPairingSession,
|
||||
redeemPairingSession,
|
||||
sweepExpiredSessions,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,142 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
import { createClientPairingRuntime } from './pairing.js';
|
||||
|
||||
const makeRuntime = async (options = {}) => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'openchamber-pairing-test-'));
|
||||
const createdClients = [];
|
||||
const remoteClientAuthRuntime = options.remoteClientAuthRuntime || {
|
||||
createClient: vi.fn(async (input) => {
|
||||
const client = {
|
||||
id: `client-${createdClients.length + 1}`,
|
||||
label: input.label,
|
||||
clientKind: input.clientKind,
|
||||
authMethod: input.authMethod,
|
||||
pairingId: input.pairingId,
|
||||
deviceName: input.deviceName ?? null,
|
||||
};
|
||||
createdClients.push(client);
|
||||
return { client, token: `token-${createdClients.length}` };
|
||||
}),
|
||||
};
|
||||
const runtime = createClientPairingRuntime({
|
||||
fsPromises: fs,
|
||||
path,
|
||||
crypto,
|
||||
storePath: path.join(dir, 'pairing.json'),
|
||||
remoteClientAuthRuntime,
|
||||
ttlMs: options.ttlMs ?? 10 * 60 * 1000,
|
||||
});
|
||||
return { dir, runtime, remoteClientAuthRuntime, createdClients };
|
||||
};
|
||||
|
||||
describe('client auth pairing runtime', () => {
|
||||
it('redeems a pairing session once and propagates client metadata', async () => {
|
||||
const { runtime, remoteClientAuthRuntime } = await makeRuntime();
|
||||
const created = await runtime.createPairingSession({ allowedClientKinds: ['mobile'] });
|
||||
|
||||
const result = await runtime.redeemPairingSession({
|
||||
pairingId: created.pairing.id,
|
||||
secret: created.pairing.secret,
|
||||
clientLabel: 'Iryna iPhone',
|
||||
clientKind: 'mobile',
|
||||
deviceName: 'Iryna iPhone',
|
||||
dedupeKey: 'device-key',
|
||||
});
|
||||
|
||||
expect(result.token).toBe('token-1');
|
||||
expect(result.client).toMatchObject({
|
||||
label: 'Iryna iPhone',
|
||||
clientKind: 'mobile',
|
||||
authMethod: 'pairing',
|
||||
pairingId: created.pairing.id,
|
||||
deviceName: 'Iryna iPhone',
|
||||
});
|
||||
expect(remoteClientAuthRuntime.createClient).toHaveBeenCalledWith(expect.objectContaining({
|
||||
authMethod: 'pairing',
|
||||
pairingId: created.pairing.id,
|
||||
clientKind: 'mobile',
|
||||
dedupeKey: 'device-key',
|
||||
}));
|
||||
|
||||
await expect(runtime.redeemPairingSession({
|
||||
pairingId: created.pairing.id,
|
||||
secret: created.pairing.secret,
|
||||
clientKind: 'mobile',
|
||||
})).rejects.toThrow('Invalid or expired pairing session');
|
||||
});
|
||||
|
||||
it('rejects expired, cancelled, wrong-secret, and disallowed-kind redemption', async () => {
|
||||
const { runtime: expiredRuntime } = await makeRuntime({ ttlMs: -1000 });
|
||||
const expired = await expiredRuntime.createPairingSession();
|
||||
await expect(expiredRuntime.redeemPairingSession({
|
||||
pairingId: expired.pairing.id,
|
||||
secret: expired.pairing.secret,
|
||||
clientKind: 'mobile',
|
||||
})).rejects.toThrow('Invalid or expired pairing session');
|
||||
|
||||
const { runtime } = await makeRuntime();
|
||||
const cancelled = await runtime.createPairingSession();
|
||||
await runtime.cancelPairingSession(cancelled.pairing.id);
|
||||
await expect(runtime.redeemPairingSession({
|
||||
pairingId: cancelled.pairing.id,
|
||||
secret: cancelled.pairing.secret,
|
||||
clientKind: 'mobile',
|
||||
})).rejects.toThrow('Invalid or expired pairing session');
|
||||
|
||||
const wrongSecret = await runtime.createPairingSession();
|
||||
await expect(runtime.redeemPairingSession({
|
||||
pairingId: wrongSecret.pairing.id,
|
||||
secret: 'wrong',
|
||||
clientKind: 'mobile',
|
||||
})).rejects.toThrow('Invalid or expired pairing session');
|
||||
|
||||
const desktopOnly = await runtime.createPairingSession({ allowedClientKinds: ['desktop'] });
|
||||
await expect(runtime.redeemPairingSession({
|
||||
pairingId: desktopOnly.pairing.id,
|
||||
secret: desktopOnly.pairing.secret,
|
||||
clientKind: 'mobile',
|
||||
})).rejects.toThrow('Invalid or expired pairing session');
|
||||
});
|
||||
|
||||
it('does not consume the pairing session if client issuance fails', async () => {
|
||||
const createClient = vi.fn()
|
||||
.mockRejectedValueOnce(new Error('disk failed'))
|
||||
.mockResolvedValueOnce({ client: { id: 'client-1' }, token: 'token-1' });
|
||||
const { runtime } = await makeRuntime({ remoteClientAuthRuntime: { createClient } });
|
||||
const created = await runtime.createPairingSession();
|
||||
|
||||
await expect(runtime.redeemPairingSession({
|
||||
pairingId: created.pairing.id,
|
||||
secret: created.pairing.secret,
|
||||
clientKind: 'mobile',
|
||||
})).rejects.toThrow('disk failed');
|
||||
|
||||
await expect(runtime.redeemPairingSession({
|
||||
pairingId: created.pairing.id,
|
||||
secret: created.pairing.secret,
|
||||
clientKind: 'mobile',
|
||||
})).resolves.toMatchObject({ token: 'token-1' });
|
||||
expect(createClient).toHaveBeenLastCalledWith(expect.objectContaining({
|
||||
dedupeKey: `pairing:${created.pairing.id}`,
|
||||
}));
|
||||
});
|
||||
|
||||
it('sweeps expired never-used sessions from the store on the next create', async () => {
|
||||
const { dir, runtime } = await makeRuntime({ ttlMs: -1000 });
|
||||
// Immediately expired (negative TTL), never used or cancelled.
|
||||
const expired = await runtime.createPairingSession({ label: 'stale' });
|
||||
|
||||
// The next create sweeps the store; only the fresh session should remain.
|
||||
const storePath = path.join(dir, 'pairing.json');
|
||||
await runtime.createPairingSession({ label: 'fresh' });
|
||||
const store = JSON.parse(await fs.readFile(storePath, 'utf8'));
|
||||
const ids = store.sessions.map((session) => session.id);
|
||||
expect(ids).not.toContain(expired.pairing.id);
|
||||
expect(ids).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -25,6 +25,15 @@ const normalizeOptionalString = (value) => {
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
};
|
||||
|
||||
const normalizeMetadata = (client) => ({
|
||||
authMethod: normalizeOptionalString(client.authMethod),
|
||||
pairingId: normalizeOptionalString(client.pairingId),
|
||||
deviceName: normalizeOptionalString(client.deviceName),
|
||||
devicePlatform: normalizeOptionalString(client.devicePlatform),
|
||||
deviceModel: normalizeOptionalString(client.deviceModel),
|
||||
appVersion: normalizeOptionalString(client.appVersion),
|
||||
});
|
||||
|
||||
const safeJsonParse = (raw) => {
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
@@ -77,6 +86,9 @@ export const createRemoteClientAuthRuntime = ({ fsPromises, path, crypto, storeP
|
||||
expiresAt: normalizeTimestamp(client.expiresAt),
|
||||
clientKind: normalizeOptionalString(client.clientKind),
|
||||
dedupeKey: normalizeOptionalString(client.dedupeKey),
|
||||
usesRelay: client.usesRelay === true,
|
||||
lastTransport: client.lastTransport === 'relay' || client.lastTransport === 'direct' ? client.lastTransport : null,
|
||||
...normalizeMetadata(client),
|
||||
}))
|
||||
.filter((client) => client.tokenHash.length > 0)
|
||||
: [],
|
||||
@@ -108,6 +120,14 @@ export const createRemoteClientAuthRuntime = ({ fsPromises, path, crypto, storeP
|
||||
revokedAt: client.revokedAt,
|
||||
expiresAt: client.expiresAt,
|
||||
clientKind: client.clientKind,
|
||||
authMethod: client.authMethod,
|
||||
pairingId: client.pairingId,
|
||||
deviceName: client.deviceName,
|
||||
devicePlatform: client.devicePlatform,
|
||||
deviceModel: client.deviceModel,
|
||||
appVersion: client.appVersion,
|
||||
usesRelay: client.usesRelay === true,
|
||||
lastTransport: client.lastTransport ?? null,
|
||||
});
|
||||
|
||||
const listClients = async () => {
|
||||
@@ -117,7 +137,34 @@ export const createRemoteClientAuthRuntime = ({ fsPromises, path, crypto, storeP
|
||||
});
|
||||
};
|
||||
|
||||
const createClient = async ({ label, expiresAt, clientKind, dedupeKey } = {}) => {
|
||||
// Relay-transport demand from paired devices: any non-revoked, non-expired
|
||||
// client that was paired over the relay.
|
||||
const hasActiveRelayClients = async () => {
|
||||
return withStoreMutation(async () => {
|
||||
const store = await readStore();
|
||||
const now = Date.now();
|
||||
return store.clients.some((client) => {
|
||||
if (client.usesRelay !== true) return false;
|
||||
if (client.revokedAt) return false;
|
||||
const expires = Date.parse(client.expiresAt || '');
|
||||
return !Number.isFinite(expires) || expires > now;
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const createClient = async ({
|
||||
label,
|
||||
expiresAt,
|
||||
clientKind,
|
||||
dedupeKey,
|
||||
authMethod,
|
||||
pairingId,
|
||||
deviceName,
|
||||
devicePlatform,
|
||||
deviceModel,
|
||||
appVersion,
|
||||
usesRelay,
|
||||
} = {}) => {
|
||||
return withStoreMutation(async () => {
|
||||
const store = await readStore();
|
||||
const normalizedDedupeKey = normalizeOptionalString(dedupeKey);
|
||||
@@ -132,6 +179,13 @@ export const createRemoteClientAuthRuntime = ({ fsPromises, path, crypto, storeP
|
||||
expiresAt: normalizeTimestamp(expiresAt),
|
||||
clientKind: normalizeOptionalString(clientKind),
|
||||
dedupeKey: normalizedDedupeKey,
|
||||
authMethod: normalizeOptionalString(authMethod),
|
||||
pairingId: normalizeOptionalString(pairingId),
|
||||
deviceName: normalizeOptionalString(deviceName),
|
||||
devicePlatform: normalizeOptionalString(devicePlatform),
|
||||
deviceModel: normalizeOptionalString(deviceModel),
|
||||
appVersion: normalizeOptionalString(appVersion),
|
||||
usesRelay: usesRelay === true,
|
||||
};
|
||||
if (normalizedDedupeKey) {
|
||||
store.clients = store.clients.filter((entry) => entry.dedupeKey !== normalizedDedupeKey);
|
||||
@@ -177,10 +231,14 @@ export const createRemoteClientAuthRuntime = ({ fsPromises, path, crypto, storeP
|
||||
});
|
||||
};
|
||||
|
||||
const authenticateBearerToken = async (token) => {
|
||||
const authenticateBearerToken = async (token, req) => {
|
||||
if (typeof token !== 'string' || !token.startsWith(TOKEN_PREFIX)) {
|
||||
return null;
|
||||
}
|
||||
// Which transport carried this request: the relay tunnel proxy stamps every
|
||||
// forwarded request with x-openchamber-relay-connection; anything else is a
|
||||
// direct (local/LAN/tunnel-URL) request. Display-only device metadata.
|
||||
const transport = req?.headers?.['x-openchamber-relay-connection'] ? 'relay' : 'direct';
|
||||
return withStoreMutation(async () => {
|
||||
const tokenHash = hashToken(token);
|
||||
const store = await readStore();
|
||||
@@ -189,8 +247,11 @@ export const createRemoteClientAuthRuntime = ({ fsPromises, path, crypto, storeP
|
||||
if (client.expiresAt && Date.parse(client.expiresAt) <= Date.now()) return null;
|
||||
const now = Date.now();
|
||||
const lastUsedAt = Date.parse(client.lastUsedAt || '');
|
||||
if (!Number.isFinite(lastUsedAt) || now - lastUsedAt >= LAST_USED_WRITE_INTERVAL_MS) {
|
||||
// Write on the throttle interval — or immediately when the transport
|
||||
// changed, so a LAN⇄relay switch is visible right away, not a minute late.
|
||||
if (!Number.isFinite(lastUsedAt) || now - lastUsedAt >= LAST_USED_WRITE_INTERVAL_MS || client.lastTransport !== transport) {
|
||||
client.lastUsedAt = new Date(now).toISOString();
|
||||
client.lastTransport = transport;
|
||||
await writeStore(store);
|
||||
}
|
||||
return { ok: true, clientId: client.id, sessionToken: client.id, client: publicClient(client) };
|
||||
@@ -201,6 +262,7 @@ export const createRemoteClientAuthRuntime = ({ fsPromises, path, crypto, storeP
|
||||
authenticateBearerToken,
|
||||
createClient,
|
||||
listClients,
|
||||
hasActiveRelayClients,
|
||||
purgeRevokedClients,
|
||||
revokeClient,
|
||||
};
|
||||
|
||||
@@ -22,6 +22,11 @@ export const createBootstrapRuntime = (dependencies) => {
|
||||
uiPassword,
|
||||
tunnelAuthController,
|
||||
remoteClientAuthRuntime,
|
||||
clientPairingRuntime,
|
||||
getRelayPairingCandidate,
|
||||
reconcileRelay,
|
||||
getPairingTransports,
|
||||
getServerLabel,
|
||||
readSettingsFromDiskMigrated,
|
||||
normalizeTunnelSessionTtlMs,
|
||||
sayTTSCapability,
|
||||
@@ -82,6 +87,11 @@ export const createBootstrapRuntime = (dependencies) => {
|
||||
tunnelAuthController,
|
||||
uiAuthController,
|
||||
remoteClientAuthRuntime,
|
||||
clientPairingRuntime,
|
||||
getRelayPairingCandidate,
|
||||
reconcileRelay,
|
||||
getPairingTransports,
|
||||
getServerLabel,
|
||||
readSettingsFromDiskMigrated,
|
||||
normalizeTunnelSessionTtlMs,
|
||||
});
|
||||
|
||||
@@ -358,9 +358,26 @@ export const registerAuthAndAccessRoutes = (app, dependencies) => {
|
||||
tunnelAuthController,
|
||||
uiAuthController,
|
||||
remoteClientAuthRuntime,
|
||||
clientPairingRuntime,
|
||||
readSettingsFromDiskMigrated,
|
||||
normalizeTunnelSessionTtlMs,
|
||||
// Returns the relay pairing candidate ({ type:'relay', relayUrl, serverId,
|
||||
// hostEncPubJwk, priority }) when the host relay is enabled, else null.
|
||||
// Injected lazily because the relay service is constructed after these routes.
|
||||
getRelayPairingCandidate = async () => null,
|
||||
// Re-evaluate the relay lifecycle after pairing/device changes.
|
||||
reconcileRelay = async () => {},
|
||||
// Returns { local, lan, relayAvailable } — the direct transport URLs the
|
||||
// server can actually be reached on (LAN derived from the server bind, not
|
||||
// the UI origin), for the create-device dialog.
|
||||
getPairingTransports = () => ({ local: null, lan: null, relayAvailable: true }),
|
||||
// Display name a paired device shows for THIS server (issuing machine's
|
||||
// hostname), distinct from the per-device pairing label typed by the operator.
|
||||
getServerLabel = () => 'OpenChamber',
|
||||
} = dependencies;
|
||||
const PAIRING_REDEEM_RATE_LIMIT_WINDOW_MS = 5 * 60 * 1000;
|
||||
const PAIRING_REDEEM_RATE_LIMIT_MAX_ATTEMPTS = 10;
|
||||
const pairingRedeemAttempts = new Map();
|
||||
|
||||
const runWithUiAuth = async (req, res, next, handler, options = {}) => {
|
||||
try {
|
||||
@@ -440,6 +457,112 @@ export const registerAuthAndAccessRoutes = (app, dependencies) => {
|
||||
return clients.find((client) => client.id === clientId) || null;
|
||||
};
|
||||
|
||||
const requestOrigin = (req) => {
|
||||
const forwardedProto = typeof req.headers?.['x-forwarded-proto'] === 'string'
|
||||
? req.headers['x-forwarded-proto'].split(',')[0].trim()
|
||||
: '';
|
||||
const protocol = forwardedProto || (req.socket?.encrypted ? 'https' : 'http');
|
||||
const host = typeof req.headers?.host === 'string' ? req.headers.host.trim() : '';
|
||||
if (!host) return null;
|
||||
return `${protocol}://${host}`;
|
||||
};
|
||||
|
||||
const requestIp = (req) => {
|
||||
// Do not use req.ip here: Express rewrites it from X-Forwarded-For when
|
||||
// trust proxy is enabled, and redeem is unauthenticated before this limit.
|
||||
return req.socket?.remoteAddress || req.connection?.remoteAddress || 'unknown';
|
||||
};
|
||||
|
||||
const pairingIdFromRequest = (req) => {
|
||||
const raw = typeof req.body?.pairingId === 'string' ? req.body.pairingId.trim() : '';
|
||||
return raw || 'missing';
|
||||
};
|
||||
|
||||
const checkPairingRedeemRateLimit = (req) => {
|
||||
const now = Date.now();
|
||||
const key = `${requestIp(req)}:${pairingIdFromRequest(req)}`;
|
||||
for (const [entryKey, entry] of pairingRedeemAttempts.entries()) {
|
||||
if (!entry || now - entry.firstAttemptAt >= PAIRING_REDEEM_RATE_LIMIT_WINDOW_MS) {
|
||||
pairingRedeemAttempts.delete(entryKey);
|
||||
}
|
||||
}
|
||||
const entry = pairingRedeemAttempts.get(key);
|
||||
if (!entry) {
|
||||
pairingRedeemAttempts.set(key, { count: 1, firstAttemptAt: now });
|
||||
return { allowed: true, remaining: PAIRING_REDEEM_RATE_LIMIT_MAX_ATTEMPTS - 1, reset: Math.ceil((now + PAIRING_REDEEM_RATE_LIMIT_WINDOW_MS) / 1000) };
|
||||
}
|
||||
const reset = Math.ceil((entry.firstAttemptAt + PAIRING_REDEEM_RATE_LIMIT_WINDOW_MS) / 1000);
|
||||
if (entry.count >= PAIRING_REDEEM_RATE_LIMIT_MAX_ATTEMPTS) {
|
||||
return {
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
reset,
|
||||
retryAfter: Math.max(1, Math.ceil((entry.firstAttemptAt + PAIRING_REDEEM_RATE_LIMIT_WINDOW_MS - now) / 1000)),
|
||||
};
|
||||
}
|
||||
entry.count += 1;
|
||||
return { allowed: true, remaining: PAIRING_REDEEM_RATE_LIMIT_MAX_ATTEMPTS - entry.count, reset };
|
||||
};
|
||||
|
||||
const clearPairingRedeemRateLimit = (req) => {
|
||||
pairingRedeemAttempts.delete(`${requestIp(req)}:${pairingIdFromRequest(req)}`);
|
||||
};
|
||||
|
||||
const normalizeCandidateUrl = (value) => {
|
||||
if (typeof value !== 'string' || !value.trim()) return null;
|
||||
try {
|
||||
const parsed = new URL(value.trim());
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null;
|
||||
parsed.hash = '';
|
||||
parsed.search = '';
|
||||
return parsed.toString().replace(/\/+$/, '');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// `preferredServerUrl` is the caller-supplied externally reachable URL (the
|
||||
// desktop UI reaches its own server over loopback, so the request origin is not
|
||||
// scannable — it passes the LAN URL instead). Falls back to the request origin
|
||||
// for remote callers where the Host header IS the reachable address.
|
||||
//
|
||||
// `includeRelay` is the per-link transport choice from the create-link dialog:
|
||||
// true → add the relay candidate, enabling the relay host on demand;
|
||||
// false → direct only, never relay;
|
||||
// undefined → legacy: advertise relay only if it is already enabled.
|
||||
// `includeDirect === false` produces a relay-only link (no direct candidate).
|
||||
const pairingServerCandidates = async (req, { preferredServerUrl, includeRelay, includeDirect = true } = {}) => {
|
||||
const candidates = [];
|
||||
if (includeDirect) {
|
||||
const direct = normalizeCandidateUrl(preferredServerUrl) || requestOrigin(req);
|
||||
if (direct) {
|
||||
let type = 'lan';
|
||||
try {
|
||||
const parsed = new URL(direct);
|
||||
type = parsed.protocol === 'https:' ? 'tunnel' : 'lan';
|
||||
} catch {
|
||||
}
|
||||
candidates.push({ type, url: direct, priority: 10 });
|
||||
}
|
||||
}
|
||||
// The client races candidates and falls back to relay only if the direct URL
|
||||
// is unreachable (relay carries a higher priority number).
|
||||
if (includeRelay !== false) {
|
||||
try {
|
||||
const relayCandidate = await getRelayPairingCandidate({ ensureEnabled: includeRelay === true });
|
||||
if (relayCandidate) candidates.push(relayCandidate);
|
||||
} catch {
|
||||
// A relay enable/status failure must not break direct pairing.
|
||||
}
|
||||
}
|
||||
return candidates;
|
||||
};
|
||||
|
||||
const sendPairingRedeemError = (res, error) => {
|
||||
const statusCode = typeof error?.statusCode === 'number' ? error.statusCode : 400;
|
||||
res.status(statusCode).json({ error: 'Invalid or expired pairing session' });
|
||||
};
|
||||
|
||||
const requireApiAuth = async (req, res, next) => {
|
||||
// Preview proxy requests carry a target-scoped capability token that the
|
||||
// preview proxy validates against the registered target id/TTL. Let those
|
||||
@@ -588,7 +711,12 @@ export const registerAuthAndAccessRoutes = (app, dependencies) => {
|
||||
await runWithClientManagementAuth(req, res, next, async (authContext) => {
|
||||
if (authContext.type === 'client') {
|
||||
const client = await clientRecordFromAuthContext(authContext);
|
||||
return res.json({ clients: client ? [client] : [] });
|
||||
// The desktop shell's local client is the trusted operator of this
|
||||
// server; it manages devices just like a browser UI session. Every
|
||||
// other client token is scoped to its own record.
|
||||
if (client?.clientKind !== 'desktop-local') {
|
||||
return res.json({ clients: client ? [client] : [] });
|
||||
}
|
||||
}
|
||||
const clients = await remoteClientAuthRuntime.listClients();
|
||||
res.json({ clients });
|
||||
@@ -610,24 +738,136 @@ export const registerAuthAndAccessRoutes = (app, dependencies) => {
|
||||
app.delete('/api/client-auth/clients/:id', async (req, res, next) => {
|
||||
await runWithClientManagementAuth(req, res, next, async (authContext) => {
|
||||
if (authContext.type === 'client') {
|
||||
const clientId = clientIdFromAuthContext(authContext);
|
||||
if (!clientId || clientId !== req.params?.id) {
|
||||
return res.status(403).json({ revoked: false, error: 'Client tokens can only revoke themselves' });
|
||||
const actingClient = await clientRecordFromAuthContext(authContext);
|
||||
// The desktop shell's local client manages every device; other client
|
||||
// tokens may only revoke themselves.
|
||||
if (actingClient?.clientKind !== 'desktop-local') {
|
||||
const clientId = clientIdFromAuthContext(authContext);
|
||||
if (!clientId || clientId !== req.params?.id) {
|
||||
return res.status(403).json({ revoked: false, error: 'Client tokens can only revoke themselves' });
|
||||
}
|
||||
}
|
||||
}
|
||||
const result = await remoteClientAuthRuntime.revokeClient(req.params?.id);
|
||||
if (!result.revoked) {
|
||||
return res.status(404).json({ revoked: false, error: 'Client not found' });
|
||||
}
|
||||
void reconcileRelay();
|
||||
res.json(result);
|
||||
});
|
||||
});
|
||||
|
||||
app.delete('/api/client-auth/clients', async (req, res, next) => {
|
||||
await runWithUiAuth(req, res, next, async () => {
|
||||
await runWithClientManagementAuth(req, res, next, async (authContext) => {
|
||||
if (authContext.type === 'client') {
|
||||
const actingClient = await clientRecordFromAuthContext(authContext);
|
||||
// Purging revoked devices is a whole-server management action; only the
|
||||
// trusted desktop shell client (or a UI session) may do it.
|
||||
if (actingClient?.clientKind !== 'desktop-local') {
|
||||
return res.status(403).json({ purged: 0, error: 'Client tokens cannot purge revoked devices' });
|
||||
}
|
||||
}
|
||||
const result = await remoteClientAuthRuntime.purgeRevokedClients();
|
||||
void reconcileRelay();
|
||||
res.json(result);
|
||||
}, { sessionOnly: true });
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/api/client-auth/pairing/sessions', express.json({ limit: '64kb' }), async (req, res, next) => {
|
||||
await runWithClientCreateAuth(req, res, next, async (authContext) => {
|
||||
const candidates = await pairingServerCandidates(req, {
|
||||
preferredServerUrl: req.body?.serverUrl,
|
||||
includeRelay: typeof req.body?.includeRelay === 'boolean' ? req.body.includeRelay : undefined,
|
||||
includeDirect: req.body?.includeDirect !== false,
|
||||
});
|
||||
const usesRelay = candidates.some((candidate) => candidate.type === 'relay');
|
||||
const result = await clientPairingRuntime.createPairingSession({
|
||||
label: req.body?.label,
|
||||
allowedClientKinds: req.body?.allowedClientKinds,
|
||||
createdByClientId: clientIdFromAuthContext(authContext),
|
||||
usesRelay,
|
||||
});
|
||||
void reconcileRelay();
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
res.status(201).json({
|
||||
...result,
|
||||
server: { label: getServerLabel(), candidates },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Direct transports the server can be reached on (for the create-device dialog).
|
||||
app.get('/api/client-auth/pairing/transports', async (req, res, next) => {
|
||||
await runWithClientCreateAuth(req, res, next, async () => {
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
res.json(getPairingTransports());
|
||||
});
|
||||
});
|
||||
|
||||
// Pending pairing sessions (link created, device not yet connected) for the
|
||||
// "pending devices" list. Secrets are never included.
|
||||
app.get('/api/client-auth/pairing/sessions', async (req, res, next) => {
|
||||
await runWithClientCreateAuth(req, res, next, async () => {
|
||||
const pending = await clientPairingRuntime.listPendingSessions();
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
res.json({ pending });
|
||||
});
|
||||
});
|
||||
|
||||
app.delete('/api/client-auth/pairing/sessions/:id', async (req, res, next) => {
|
||||
await runWithClientCreateAuth(req, res, next, async () => {
|
||||
const result = await clientPairingRuntime.cancelPairingSession(req.params?.id);
|
||||
if (!result.cancelled) {
|
||||
return res.status(404).json({ cancelled: false, error: 'Pairing session not found' });
|
||||
}
|
||||
void reconcileRelay();
|
||||
res.json(result);
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/api/client-auth/pairing/redeem', express.json({ limit: '64kb' }), async (req, res, next) => {
|
||||
try {
|
||||
const rateLimit = checkPairingRedeemRateLimit(req);
|
||||
res.setHeader('X-RateLimit-Limit', PAIRING_REDEEM_RATE_LIMIT_MAX_ATTEMPTS);
|
||||
res.setHeader('X-RateLimit-Remaining', rateLimit.remaining);
|
||||
res.setHeader('X-RateLimit-Reset', rateLimit.reset);
|
||||
if (!rateLimit.allowed) {
|
||||
res.setHeader('Retry-After', rateLimit.retryAfter);
|
||||
return res.status(429).json({ error: 'Invalid or expired pairing session' });
|
||||
}
|
||||
const result = await clientPairingRuntime.redeemPairingSession({
|
||||
pairingId: req.body?.pairingId,
|
||||
secret: req.body?.secret,
|
||||
clientLabel: req.body?.clientLabel,
|
||||
clientKind: req.body?.clientKind,
|
||||
deviceName: req.body?.deviceName,
|
||||
devicePlatform: req.body?.devicePlatform,
|
||||
deviceModel: req.body?.deviceModel,
|
||||
appVersion: req.body?.appVersion,
|
||||
dedupeKey: req.body?.dedupeKey,
|
||||
});
|
||||
clearPairingRedeemRateLimit(req);
|
||||
// The session became a device: relay demand may have moved from the pending
|
||||
// session to the paired device (or a non-relay redeem may drop it).
|
||||
void reconcileRelay();
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
res.json({
|
||||
ok: true,
|
||||
server: {
|
||||
label: getServerLabel(),
|
||||
url: requestOrigin(req),
|
||||
fingerprint: result.pairing?.fingerprint || null,
|
||||
},
|
||||
client: result.client,
|
||||
clientToken: result.token,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error?.message === 'Invalid or expired pairing session') {
|
||||
sendPairingRedeemError(res, error);
|
||||
return;
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/connect', async (req, res) => {
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import express from 'express';
|
||||
import request from 'supertest';
|
||||
import { registerAuthAndAccessRoutes, registerCommonRequestMiddleware, registerServerStatusRoutes } from './core-routes.js';
|
||||
|
||||
describe('core-routes', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should call gracefulShutdown with exitProcess: true on /api/system/shutdown', async () => {
|
||||
const app = express();
|
||||
let shutdownOpts = null;
|
||||
@@ -225,6 +229,206 @@ describe('core-routes', () => {
|
||||
}
|
||||
});
|
||||
|
||||
const createPairingRouteApp = (overrides = {}) => {
|
||||
const app = express();
|
||||
const dependencies = {
|
||||
express,
|
||||
tunnelAuthController: {
|
||||
classifyRequestScope: () => 'local',
|
||||
requireTunnelSession: vi.fn(),
|
||||
getTunnelSessionFromRequest: vi.fn(),
|
||||
clearTunnelSessionCookie: vi.fn(),
|
||||
exchangeBootstrapToken: vi.fn(),
|
||||
},
|
||||
uiAuthController: {
|
||||
resolveAuthContext: vi.fn(async () => ({ type: 'session', token: 'session-token' })),
|
||||
requireAuth: vi.fn((_req, _res, next) => next()),
|
||||
requireSessionAuth: vi.fn((_req, _res, next) => next()),
|
||||
handleSessionStatus: vi.fn(),
|
||||
handleSessionCreate: vi.fn(),
|
||||
handleUrlAuthToken: vi.fn(),
|
||||
handlePasskeyStatus: vi.fn(),
|
||||
handlePasskeyAuthenticationOptions: vi.fn(),
|
||||
handlePasskeyAuthenticationVerify: vi.fn(),
|
||||
handlePasskeyRegistrationOptions: vi.fn(),
|
||||
handlePasskeyRegistrationVerify: vi.fn(),
|
||||
handlePasskeyList: vi.fn(),
|
||||
handlePasskeyRevoke: vi.fn(),
|
||||
handleResetAuth: vi.fn(),
|
||||
},
|
||||
remoteClientAuthRuntime: {
|
||||
listClients: vi.fn(async () => []),
|
||||
createClient: vi.fn(),
|
||||
revokeClient: vi.fn(),
|
||||
purgeRevokedClients: vi.fn(),
|
||||
},
|
||||
clientPairingRuntime: {
|
||||
createPairingSession: vi.fn(async () => ({ pairing: { id: 'pair_1', secret: 'secret', expiresAt: '2099-01-01T00:00:00.000Z', fingerprint: 'ABCD-1234' } })),
|
||||
cancelPairingSession: vi.fn(async () => ({ cancelled: true })),
|
||||
redeemPairingSession: vi.fn(async () => ({
|
||||
pairing: { fingerprint: 'ABCD-1234' },
|
||||
client: { id: 'client-1', label: 'Phone', authMethod: 'pairing' },
|
||||
token: 'oc_client_token',
|
||||
})),
|
||||
},
|
||||
readSettingsFromDiskMigrated: vi.fn(async () => ({})),
|
||||
normalizeTunnelSessionTtlMs: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
registerAuthAndAccessRoutes(app, dependencies);
|
||||
return { app, dependencies };
|
||||
};
|
||||
|
||||
it('creates pairing sessions behind owner auth and returns no-store payload data', async () => {
|
||||
const { app, dependencies } = createPairingRouteApp();
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/client-auth/pairing/sessions')
|
||||
.set('Host', 'runtime.example')
|
||||
.send({ label: 'Pair phone', allowedClientKinds: ['mobile'] })
|
||||
.expect(201);
|
||||
|
||||
expect(response.headers['cache-control']).toBe('no-store');
|
||||
expect(response.body.pairing).toMatchObject({ id: 'pair_1', secret: 'secret' });
|
||||
expect(response.body.server.candidates).toEqual([{ type: 'lan', url: 'http://runtime.example', priority: 10 }]);
|
||||
expect(dependencies.clientPairingRuntime.createPairingSession).toHaveBeenCalledWith({
|
||||
label: 'Pair phone',
|
||||
allowedClientKinds: ['mobile'],
|
||||
createdByClientId: null,
|
||||
usesRelay: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('advertises the caller-supplied serverUrl as the direct candidate over the request origin', async () => {
|
||||
const { app } = createPairingRouteApp();
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/client-auth/pairing/sessions')
|
||||
.set('Host', 'runtime.example')
|
||||
.send({ label: 'Pair phone', serverUrl: 'http://192.168.1.20:2606' })
|
||||
.expect(201);
|
||||
|
||||
expect(response.body.server.candidates).toEqual([
|
||||
{ type: 'lan', url: 'http://192.168.1.20:2606', priority: 10 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('folds in a relay candidate when the host relay is enabled', async () => {
|
||||
const relayCandidate = {
|
||||
type: 'relay',
|
||||
relayUrl: 'wss://relay.example/ws',
|
||||
serverId: 'srv_1',
|
||||
hostEncPubJwk: { kty: 'EC', crv: 'P-256', x: 'aaa', y: 'bbb' },
|
||||
priority: 30,
|
||||
};
|
||||
const { app } = createPairingRouteApp({ getRelayPairingCandidate: vi.fn(async () => relayCandidate) });
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/client-auth/pairing/sessions')
|
||||
.set('Host', 'runtime.example')
|
||||
.send({ label: 'Pair phone' })
|
||||
.expect(201);
|
||||
|
||||
expect(response.body.server.candidates).toEqual([
|
||||
{ type: 'lan', url: 'http://runtime.example', priority: 10 },
|
||||
relayCandidate,
|
||||
]);
|
||||
});
|
||||
|
||||
it('still returns the direct candidate when the relay candidate lookup throws', async () => {
|
||||
const { app } = createPairingRouteApp({
|
||||
getRelayPairingCandidate: vi.fn(async () => { throw new Error('relay status read failed'); }),
|
||||
});
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/client-auth/pairing/sessions')
|
||||
.set('Host', 'runtime.example')
|
||||
.send({ label: 'Pair phone' })
|
||||
.expect(201);
|
||||
|
||||
expect(response.body.server.candidates).toEqual([{ type: 'lan', url: 'http://runtime.example', priority: 10 }]);
|
||||
});
|
||||
|
||||
it('requires owner auth before creating or cancelling pairing sessions', async () => {
|
||||
const { app, dependencies } = createPairingRouteApp({
|
||||
uiAuthController: {
|
||||
resolveAuthContext: vi.fn(async () => null),
|
||||
requireAuth: vi.fn((_req, res) => res.status(401).json({ error: 'Unauthorized' })),
|
||||
requireSessionAuth: vi.fn((_req, res) => res.status(401).json({ error: 'Unauthorized' })),
|
||||
},
|
||||
});
|
||||
|
||||
await request(app).post('/api/client-auth/pairing/sessions').send({}).expect(401);
|
||||
await request(app).delete('/api/client-auth/pairing/sessions/pair_1').expect(401);
|
||||
expect(dependencies.clientPairingRuntime.createPairingSession).not.toHaveBeenCalled();
|
||||
expect(dependencies.clientPairingRuntime.cancelPairingSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('redeems pairing sessions with no-store response and generic errors', async () => {
|
||||
const { app, dependencies } = createPairingRouteApp();
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/client-auth/pairing/redeem')
|
||||
.set('Host', 'runtime.example')
|
||||
.send({ pairingId: 'pair_1', secret: 'secret', clientKind: 'mobile', deviceName: 'Phone' })
|
||||
.expect(200);
|
||||
|
||||
expect(response.headers['cache-control']).toBe('no-store');
|
||||
expect(response.body).toMatchObject({
|
||||
ok: true,
|
||||
server: { label: 'OpenChamber', url: 'http://runtime.example', fingerprint: 'ABCD-1234' },
|
||||
client: { id: 'client-1', authMethod: 'pairing' },
|
||||
clientToken: 'oc_client_token',
|
||||
});
|
||||
expect(dependencies.clientPairingRuntime.redeemPairingSession).toHaveBeenCalledWith(expect.objectContaining({
|
||||
pairingId: 'pair_1',
|
||||
secret: 'secret',
|
||||
clientKind: 'mobile',
|
||||
deviceName: 'Phone',
|
||||
}));
|
||||
|
||||
dependencies.clientPairingRuntime.redeemPairingSession.mockRejectedValueOnce(new Error('Invalid or expired pairing session'));
|
||||
await request(app)
|
||||
.post('/api/client-auth/pairing/redeem')
|
||||
.send({ pairingId: 'pair_2', secret: 'wrong' })
|
||||
.expect(400, { error: 'Invalid or expired pairing session' });
|
||||
});
|
||||
|
||||
it('rate limits pairing redeem attempts by socket address and pairingId, then resets after the window', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'));
|
||||
const { app, dependencies } = createPairingRouteApp();
|
||||
app.set('trust proxy', true);
|
||||
dependencies.clientPairingRuntime.redeemPairingSession.mockRejectedValue(new Error('Invalid or expired pairing session'));
|
||||
|
||||
// The X-Forwarded-For headers below are deliberate spoof attempts: the rate
|
||||
// limiter buckets by socket address (not forwarded headers), so rotating the
|
||||
// header must NOT reset the counter or evade the lockout.
|
||||
for (let index = 0; index < 10; index += 1) {
|
||||
await request(app)
|
||||
.post('/api/client-auth/pairing/redeem')
|
||||
.set('X-Forwarded-For', `203.0.113.${index}`)
|
||||
.send({ pairingId: 'pair_rate', secret: `wrong-${index}` })
|
||||
.expect(400, { error: 'Invalid or expired pairing session' });
|
||||
}
|
||||
|
||||
const locked = await request(app)
|
||||
.post('/api/client-auth/pairing/redeem')
|
||||
.set('X-Forwarded-For', '203.0.113.10')
|
||||
.send({ pairingId: 'pair_rate', secret: 'wrong-locked' })
|
||||
.expect(429, { error: 'Invalid or expired pairing session' });
|
||||
expect(locked.headers['retry-after']).toBe('300');
|
||||
expect(dependencies.clientPairingRuntime.redeemPairingSession).toHaveBeenCalledTimes(10);
|
||||
|
||||
vi.setSystemTime(new Date('2026-01-01T00:05:01Z'));
|
||||
await request(app)
|
||||
.post('/api/client-auth/pairing/redeem')
|
||||
.set('X-Forwarded-For', '203.0.113.10')
|
||||
.send({ pairingId: 'pair_rate', secret: 'wrong-after-reset' })
|
||||
.expect(400, { error: 'Invalid or expired pairing session' });
|
||||
expect(dependencies.clientPairingRuntime.redeemPairingSession).toHaveBeenCalledTimes(11);
|
||||
});
|
||||
|
||||
it('should let preview proxy credentials reach preview proxy validation', async () => {
|
||||
const app = express();
|
||||
const requireAuth = vi.fn((_req, res) => res.status(401).type('text/plain').send('Authentication required'));
|
||||
@@ -364,11 +568,9 @@ describe('client auth routes', () => {
|
||||
|
||||
const listedAfterPurge = await request(app).get('/api/client-auth/clients');
|
||||
expect(listedAfterPurge.body.clients).toHaveLength(0);
|
||||
expect(dependencies.testHooks.requireSessionAuth).toHaveBeenCalled();
|
||||
expect(dependencies.testHooks.requireAuth).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows client credentials to list and revoke only the authenticated client', async () => {
|
||||
it('scopes non-desktop client credentials to list and revoke only themselves', async () => {
|
||||
const app = express();
|
||||
let authContext = { type: 'session' };
|
||||
const dependencies = createDependencies({
|
||||
@@ -383,20 +585,57 @@ describe('client auth routes', () => {
|
||||
.post('/api/client-auth/clients')
|
||||
.send({ label: 'Other device' });
|
||||
|
||||
authContext = { type: 'client', clientId: current.body.client.id, client: current.body.client };
|
||||
// A regular (non-desktop-local) client token only sees and manages itself.
|
||||
authContext = { type: 'client', clientId: other.body.client.id, client: other.body.client };
|
||||
|
||||
const listed = await request(app).get('/api/client-auth/clients');
|
||||
expect(listed.status).toBe(200);
|
||||
expect(listed.body.clients).toEqual([current.body.client]);
|
||||
expect(listed.body.clients).toEqual([other.body.client]);
|
||||
|
||||
const denied = await request(app).delete(`/api/client-auth/clients/${other.body.client.id}`);
|
||||
const denied = await request(app).delete(`/api/client-auth/clients/${current.body.client.id}`);
|
||||
expect(denied.status).toBe(403);
|
||||
expect(denied.body.revoked).toBe(false);
|
||||
|
||||
const revoked = await request(app).delete(`/api/client-auth/clients/${current.body.client.id}`);
|
||||
const deniedPurge = await request(app).delete('/api/client-auth/clients');
|
||||
expect(deniedPurge.status).toBe(403);
|
||||
|
||||
const revoked = await request(app).delete(`/api/client-auth/clients/${other.body.client.id}`);
|
||||
expect(revoked.status).toBe(200);
|
||||
expect(revoked.body.revoked).toBe(true);
|
||||
expect(revoked.body.client.id).toBe(current.body.client.id);
|
||||
expect(revoked.body.client.id).toBe(other.body.client.id);
|
||||
});
|
||||
|
||||
it('lets the local desktop client list and revoke every device', async () => {
|
||||
const app = express();
|
||||
let authContext = { type: 'session' };
|
||||
const dependencies = createDependencies({
|
||||
resolveAuthContext: async () => authContext,
|
||||
});
|
||||
registerAuthAndAccessRoutes(app, dependencies);
|
||||
|
||||
const desktop = await request(app)
|
||||
.post('/api/client-auth/clients')
|
||||
.send({ label: 'OpenChamber Desktop', clientKind: 'desktop-local' });
|
||||
const other = await request(app)
|
||||
.post('/api/client-auth/clients')
|
||||
.send({ label: 'Other device' });
|
||||
|
||||
// The trusted desktop shell client manages all devices like a UI session.
|
||||
authContext = { type: 'client', clientId: desktop.body.client.id, client: desktop.body.client };
|
||||
|
||||
const listed = await request(app).get('/api/client-auth/clients');
|
||||
expect(listed.status).toBe(200);
|
||||
const listedIds = listed.body.clients.map((client) => client.id).sort();
|
||||
expect(listedIds).toEqual([desktop.body.client.id, other.body.client.id].sort());
|
||||
|
||||
const revoked = await request(app).delete(`/api/client-auth/clients/${other.body.client.id}`);
|
||||
expect(revoked.status).toBe(200);
|
||||
expect(revoked.body.revoked).toBe(true);
|
||||
expect(revoked.body.client.id).toBe(other.body.client.id);
|
||||
|
||||
const purged = await request(app).delete('/api/client-auth/clients');
|
||||
expect(purged.status).toBe(200);
|
||||
expect(purged.body.purged).toBe(1);
|
||||
});
|
||||
|
||||
it('allows only the local desktop client token to create remote client tokens', async () => {
|
||||
|
||||
@@ -19,7 +19,7 @@ Traffic is modeled as three stacked layers. The relay understands only Layer 1;
|
||||
## Entrypoints and structure
|
||||
|
||||
Host side (`packages/web/server/lib/relay/`):
|
||||
- `service.js` — thin entrypoint: relay config (enabled flag + relay URL), the management routes (`GET/POST /api/openchamber/relay/{status,enable,disable,offer}`), and lifecycle wiring. Started from `packages/web/server/index.js` only when the user has explicitly enabled the relay. The relay endpoint defaults to the OpenChamber-hosted relay but can be pinned to a self-hosted relay via the `OPENCHAMBER_RELAY_URL` env var (must be `ws://`/`wss://`); when set it overrides the stored setting for the host connection, the pairing offer, and status, so paired clients inherit the endpoint automatically from the offer.
|
||||
- `service.js` — thin entrypoint: relay config (enabled flag + relay URL), the management routes (`GET/POST /api/openchamber/relay/{status,enable,disable}`), a `getPairingCandidate()` accessor (the relay transport candidate folded into pairing-v2 links when enabled, consumed by the pairing-session route in `core-routes.js`), and lifecycle wiring. Started from `packages/web/server/index.js` only when the user has explicitly enabled the relay. The relay endpoint defaults to the OpenChamber-hosted relay but can be pinned to a self-hosted relay via the `OPENCHAMBER_RELAY_URL` env var (must be `ws://`/`wss://`); when set it overrides the stored setting for the host connection, the pairing candidate, and status, so paired clients inherit the endpoint automatically.
|
||||
- `identity.js` — the host's stable identity: the long-lived signing keypair (shared with the push relay, defines the routing id) plus a long-lived encryption keypair (the E2EE trust anchor). Reused across restarts; never rotated implicitly.
|
||||
- `signing-key.js` — storage/derivation of the signing keypair and the routing id, shared with the notifications runtime.
|
||||
- `host-client.js` — the long-lived connection manager: one outbound control connection to the relay, a per-client data connection for each connected device, reconnect/backoff, and the E2EE responder handshake per connection.
|
||||
@@ -32,7 +32,8 @@ Client side (`packages/ui/src/lib/relay/`):
|
||||
- `tunnel-codec.ts` — Layer 3 frame codec, fragmentation, and outbound frame batching.
|
||||
- `tunnel-client.ts` — the client tunnel: exposes a `fetch()`-compatible and a WebSocket-compatible surface backed by the encrypted tunnel.
|
||||
- `tunnel-payloads.ts`, `runtime-tunnel.ts`, `runtime-socket.ts` — payload helpers, the active-tunnel singleton, and the shared "open a runtime WebSocket the right way" helper.
|
||||
- `offer.ts` — the pairing payload builder/parser (secrets travel in URL fragments only).
|
||||
|
||||
Relay is not a separate link format: it is one transport candidate inside the unified **pairing v2** payload (`packages/ui/src/lib/connectionPayload.ts`). A relay candidate is `{ type: 'relay', relayUrl, serverId, hostEncPubJwk }` — no embedded token; the client redeems the one-time pairing secret over the tunnel like any other candidate.
|
||||
|
||||
## What travels the tunnel
|
||||
|
||||
@@ -52,7 +53,7 @@ The host dispatcher restricts tunneled traffic to explicit path allowlists (one
|
||||
|
||||
## End-to-end flow (overview)
|
||||
|
||||
1. **Pairing.** The host builds an offer describing the relay endpoint, its routing id, and its encryption public key, rendered as a QR code / deep link. Secrets are carried in the URL fragment so they never reach any server. The client imports it and stores the connection.
|
||||
1. **Pairing.** The host issues a pairing-v2 link (QR / deep link) carrying a one-time secret and a list of transport candidates. When the relay is enabled, one candidate is the relay transport (its endpoint, routing id, and encryption public key — the E2EE trust anchor). The client redeems the secret over the first reachable candidate; over the relay candidate it opens the E2EE tunnel first, then redeems through it, and stores the connection.
|
||||
2. **Presence.** When the relay is enabled, the host opens one outbound control connection and waits.
|
||||
3. **Connect.** The client connects for a given routing id; the relay notifies the host over the control connection; the host opens a matching per-client data connection.
|
||||
4. **Handshake.** Over that connection pair, client and host run the E2EE handshake and derive a shared encrypted channel the relay cannot read.
|
||||
|
||||
@@ -12,6 +12,14 @@ import { createTunnelHost } from './tunnel-host.js';
|
||||
const BACKOFF_BASE_MS = 1000;
|
||||
const BACKOFF_CAP_MS = 30000;
|
||||
const DATA_SOCKET_OPEN_TIMEOUT_MS = 15000;
|
||||
// Clients send a tunnel Ping at least every ~30s when idle, so a data socket
|
||||
// with no inbound traffic for 3 ping intervals belongs to a client that died
|
||||
// without a WebSocket close (network loss, battery kill). The relay worker may
|
||||
// not notice the dead client leg for a long time, so the host must reap these
|
||||
// itself — both to free resources and to keep the "N devices connected" status
|
||||
// honest instead of counting ghosts.
|
||||
const DATA_SOCKET_IDLE_TIMEOUT_MS = 90_000;
|
||||
const DATA_SOCKET_IDLE_SWEEP_INTERVAL_MS = 30_000;
|
||||
const DEFAULT_BATCH_WINDOW_MS = 150;
|
||||
|
||||
// Resolve the frame-batching flush window: explicit option wins, then env, then
|
||||
@@ -103,7 +111,7 @@ export const startRelayHost = ({ relayUrl, identity, localPort, getLocalPort, on
|
||||
return;
|
||||
}
|
||||
|
||||
const entry = { socket, tunnel: null, openTimer: null, batcher: null };
|
||||
const entry = { socket, tunnel: null, openTimer: null, batcher: null, lastActivityAt: Date.now() };
|
||||
dataSockets.set(connectionId, entry);
|
||||
entry.openTimer = setTimeout(() => {
|
||||
logger.warn('[Relay] host-data socket open timeout');
|
||||
@@ -141,6 +149,9 @@ export const startRelayHost = ({ relayUrl, identity, localPort, getLocalPort, on
|
||||
const handleMessage = async (data, isBinary) => {
|
||||
const current = dataSockets.get(connectionId);
|
||||
if (current !== entry) return;
|
||||
// Any inbound message (including the client's keepalive Ping) proves the
|
||||
// client is alive; the idle sweeper reaps sockets this stops updating.
|
||||
entry.lastActivityAt = Date.now();
|
||||
|
||||
if (!isBinary) {
|
||||
const action = await handshake.handleText(data.toString('utf8'));
|
||||
@@ -298,9 +309,22 @@ export const startRelayHost = ({ relayUrl, identity, localPort, getLocalPort, on
|
||||
});
|
||||
};
|
||||
|
||||
// Reap data sockets whose client went silent (no frames, no keepalive pings)
|
||||
// — a dead phone leg the relay worker hasn't noticed yet.
|
||||
const idleSweepTimer = setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [connectionId, entry] of [...dataSockets.entries()]) {
|
||||
if (now - entry.lastActivityAt <= DATA_SOCKET_IDLE_TIMEOUT_MS) continue;
|
||||
logger.info(`[Relay] reaping idle data socket connectionId=${connectionId}`);
|
||||
teardownDataSocket(connectionId, 1001, 'client idle timeout');
|
||||
}
|
||||
}, DATA_SOCKET_IDLE_SWEEP_INTERVAL_MS);
|
||||
if (typeof idleSweepTimer.unref === 'function') idleSweepTimer.unref();
|
||||
|
||||
const stop = () => {
|
||||
if (stopped) return;
|
||||
stopped = true;
|
||||
clearInterval(idleSweepTimer);
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
|
||||
@@ -15,7 +15,6 @@ import express from 'express';
|
||||
|
||||
import { createRelayIdentityRuntime } from './identity.js';
|
||||
import { startRelayHost } from './host-client.js';
|
||||
import { bytesToBase64Url } from './e2ee.js';
|
||||
|
||||
export const DEFAULT_RELAY_URL = 'wss://relay.openchamber.dev/ws';
|
||||
|
||||
@@ -49,21 +48,20 @@ const envRelayUrlOverride = () => {
|
||||
/**
|
||||
* @param {{
|
||||
* crypto: typeof import('node:crypto'),
|
||||
* os: typeof import('node:os'),
|
||||
* readSettingsFromDiskMigrated: () => Promise<object>,
|
||||
* writeSettingsToDisk: (settings: object) => Promise<void>,
|
||||
* remoteClientAuthRuntime: { createClient: (options: object) => Promise<{ client: object, token: string }> },
|
||||
* getLocalPort: () => number,
|
||||
* logger?: Pick<Console, 'warn'>,
|
||||
* }} deps
|
||||
*/
|
||||
export const createRelayService = ({
|
||||
crypto,
|
||||
os,
|
||||
readSettingsFromDiskMigrated,
|
||||
writeSettingsToDisk,
|
||||
remoteClientAuthRuntime,
|
||||
getLocalPort,
|
||||
// Returns true when any paired device or pending pairing session uses the
|
||||
// relay transport. The relay lifecycle is driven purely by this demand.
|
||||
hasRelayDemand = async () => false,
|
||||
logger = console,
|
||||
}) => {
|
||||
const identityRuntime = createRelayIdentityRuntime({ crypto, readSettingsFromDiskMigrated, writeSettingsToDisk });
|
||||
@@ -125,6 +123,28 @@ export const createRelayService = ({
|
||||
}
|
||||
};
|
||||
|
||||
// Drive the relay lifecycle from demand: run it when a device or pending
|
||||
// session uses the relay, stop it when none remain. Called on startup and after
|
||||
// pairing/device changes, so the operator never toggles it manually.
|
||||
const reconcile = async () => {
|
||||
try {
|
||||
const demand = await hasRelayDemand();
|
||||
const config = await readConfig();
|
||||
if (demand) {
|
||||
if (!config.enabled) await writeConfig({ enabled: true, relayUrl: config.relayUrl });
|
||||
if (!hostClient) {
|
||||
const next = await readConfig();
|
||||
await start(next.relayUrl);
|
||||
}
|
||||
} else {
|
||||
if (config.enabled) await writeConfig({ enabled: false, relayUrl: config.relayUrl });
|
||||
stop();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(`[Relay] reconcile failed: ${error?.message ?? error}`);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatus = async () => {
|
||||
const config = await readConfig();
|
||||
const identity = await identityRuntime.getRelayIdentity();
|
||||
@@ -140,29 +160,44 @@ export const createRelayService = ({
|
||||
};
|
||||
};
|
||||
|
||||
const buildOffer = async ({ includeToken = false, clientLabel } = {}) => {
|
||||
// Pairing candidate for the unified connection payload (pairing v2). Relay is
|
||||
// just another transport: it carries the relay route + E2EE trust anchor, no
|
||||
// embedded token — the client redeems the one-time pairing secret over the
|
||||
// tunnel like any other candidate. Returns null when the host relay is off, so
|
||||
// callers only advertise relay when it is actually reachable. Priority is high
|
||||
// (tried after LAN/tunnel) since the relay path is the last-resort transport.
|
||||
const buildPairingCandidate = async () => {
|
||||
const config = await readConfig();
|
||||
const identity = await identityRuntime.getRelayIdentity();
|
||||
const offer = {
|
||||
v: 1,
|
||||
mode: 'relay',
|
||||
return {
|
||||
type: 'relay',
|
||||
relayUrl: config.relayUrl,
|
||||
serverId: identity.serverId,
|
||||
hostEncPubJwk: identity.hostEncPubJwk,
|
||||
label: os.hostname(),
|
||||
priority: 30,
|
||||
};
|
||||
if (includeToken) {
|
||||
const label = typeof clientLabel === 'string' && clientLabel.trim().length > 0
|
||||
? clientLabel.trim()
|
||||
: 'Relay client';
|
||||
const { token } = await remoteClientAuthRuntime.createClient({ label, clientKind: 'relay' });
|
||||
offer.token = token;
|
||||
};
|
||||
|
||||
const getPairingCandidate = async () => {
|
||||
const config = await readConfig();
|
||||
if (!config.enabled) return null;
|
||||
return buildPairingCandidate();
|
||||
};
|
||||
|
||||
// Enable the relay host on demand and return its pairing candidate. Creating a
|
||||
// relay pairing link IS the demand signal, so the relay turns itself on here
|
||||
// rather than requiring a separate manual toggle. Idempotent: a no-op when the
|
||||
// relay is already enabled and running.
|
||||
const ensureEnabledForPairing = async () => {
|
||||
const config = await readConfig();
|
||||
if (!config.enabled) {
|
||||
await writeConfig({ enabled: true, relayUrl: config.relayUrl });
|
||||
}
|
||||
const encoded = bytesToBase64Url(new TextEncoder().encode(JSON.stringify(offer)));
|
||||
return {
|
||||
offer,
|
||||
url: `openchamber://connect?v=1&mode=relay#offer=${encoded}`,
|
||||
};
|
||||
if (!hostClient) {
|
||||
const next = await readConfig();
|
||||
await start(next.relayUrl);
|
||||
}
|
||||
return buildPairingCandidate();
|
||||
};
|
||||
|
||||
const registerRoutes = (app) => {
|
||||
@@ -198,24 +233,15 @@ export const createRelayService = ({
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/openchamber/relay/offer', express.json({ limit: '16kb' }), async (req, res) => {
|
||||
try {
|
||||
const result = await buildOffer({
|
||||
includeToken: req.body?.includeToken === true,
|
||||
clientLabel: req.body?.clientLabel,
|
||||
});
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error?.message ?? 'Failed to build relay offer' });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
registerRoutes,
|
||||
startIfEnabled,
|
||||
reconcile,
|
||||
stop,
|
||||
getStatus,
|
||||
buildOffer,
|
||||
getPairingCandidate,
|
||||
ensureEnabledForPairing,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -3,9 +3,15 @@
|
||||
## Purpose
|
||||
This module owns OpenChamber UI authentication for browser access, including password session auth, WebAuthn passkeys, and trusted-device session handling.
|
||||
|
||||
Trusted-device access has one durable credential model: a remote client bearer token stored by `packages/web/server/lib/client-auth/remote-clients.js`. Password, passkey, and Pairing v2 are issuance methods for that credential, not separate credential systems. Issued client tokens are returned once, stored server-side only as hashes, and are later authenticated via `Authorization: Bearer oc_client_...`.
|
||||
|
||||
Pairing v2 is implemented by `packages/web/server/lib/client-auth/pairing.js`. It stores short-lived one-time pairing sessions with hashed secrets, exposes create/cancel/redeem routes under `/api/client-auth/pairing/*`, and redeems a valid pairing secret into the same remote client token used by password/passkey trusted-device flows.
|
||||
|
||||
## Entrypoints and structure
|
||||
- `packages/web/server/lib/ui-auth/ui-auth.js`: UI auth controller runtime, cookie/session issuance, rate limiting, and auth route handlers.
|
||||
- `packages/web/server/lib/ui-auth/ui-passkeys.js`: passkey store and WebAuthn registration/authentication verification helpers.
|
||||
- `packages/web/server/lib/client-auth/remote-clients.js`: trusted-device client token storage, bearer authentication, last-used tracking, and revocation.
|
||||
- `packages/web/server/lib/client-auth/pairing.js`: short-lived Pairing v2 sessions and one-time secret redemption into trusted-device client tokens.
|
||||
|
||||
## Public exports (ui-auth.js)
|
||||
- `createUiAuth({ password, cookieName, sessionTtlMs, readSettingsFromDiskMigrated })`: creates UI auth controller with methods:
|
||||
|
||||
@@ -829,6 +829,11 @@ export const createUiAuth = ({
|
||||
expiresAt: new Date(Date.now() + ttlMs).toISOString(),
|
||||
clientKind: req.body?.clientKind,
|
||||
dedupeKey: req.body?.dedupeKey,
|
||||
authMethod: 'password',
|
||||
deviceName: req.body?.deviceName,
|
||||
devicePlatform: req.body?.devicePlatform,
|
||||
deviceModel: req.body?.deviceModel,
|
||||
appVersion: req.body?.appVersion,
|
||||
});
|
||||
}
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
@@ -892,6 +897,11 @@ export const createUiAuth = ({
|
||||
expiresAt: new Date(Date.now() + ttlMs).toISOString(),
|
||||
clientKind: req.body?.clientKind,
|
||||
dedupeKey: req.body?.dedupeKey,
|
||||
authMethod: 'passkey',
|
||||
deviceName: req.body?.deviceName,
|
||||
devicePlatform: req.body?.devicePlatform,
|
||||
deviceModel: req.body?.deviceModel,
|
||||
appVersion: req.body?.appVersion,
|
||||
});
|
||||
}
|
||||
res.json({
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type {
|
||||
ClientAuthAPI,
|
||||
PairingSessionCreateResult,
|
||||
PendingPairingRecord,
|
||||
RemoteClientCreateResult,
|
||||
RemoteClientPurgeRevokedResult,
|
||||
RemoteClientRecord,
|
||||
@@ -37,6 +39,61 @@ export const createWebClientAuthAPI = (): ClientAuthAPI => ({
|
||||
return payload;
|
||||
},
|
||||
|
||||
async createPairingSession(input = {}): Promise<PairingSessionCreateResult> {
|
||||
const response = await runtimeFetch('/api/client-auth/pairing/sessions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify({
|
||||
label: input.label ?? '',
|
||||
...(input.allowedClientKinds ? { allowedClientKinds: input.allowedClientKinds } : {}),
|
||||
...(input.serverUrl ? { serverUrl: input.serverUrl } : {}),
|
||||
...(typeof input.includeRelay === 'boolean' ? { includeRelay: input.includeRelay } : {}),
|
||||
...(typeof input.includeDirect === 'boolean' ? { includeDirect: input.includeDirect } : {}),
|
||||
}),
|
||||
});
|
||||
const payload = await jsonOrNull<PairingSessionCreateResult & { error?: string }>(response);
|
||||
if (!response.ok || typeof payload?.pairing?.secret !== 'string' || !payload?.server) {
|
||||
throw new Error(payload?.error || response.statusText || 'Failed to create pairing session');
|
||||
}
|
||||
return payload;
|
||||
},
|
||||
|
||||
async listPendingPairings(): Promise<PendingPairingRecord[]> {
|
||||
const response = await runtimeFetch('/api/client-auth/pairing/sessions', {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
const payload = await jsonOrNull<{ pending?: PendingPairingRecord[]; error?: string }>(response);
|
||||
if (!response.ok || !payload) {
|
||||
throw new Error(payload?.error || response.statusText || 'Failed to load pending pairings');
|
||||
}
|
||||
return Array.isArray(payload.pending) ? payload.pending : [];
|
||||
},
|
||||
|
||||
async getPairingTransports(): Promise<{ local: string | null; lan: string | null; relayAvailable: boolean }> {
|
||||
const response = await runtimeFetch('/api/client-auth/pairing/transports', {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
const payload = await jsonOrNull<{ local?: string | null; lan?: string | null; relayAvailable?: boolean; error?: string }>(response);
|
||||
if (!response.ok || !payload) {
|
||||
throw new Error(payload?.error || response.statusText || 'Failed to load pairing transports');
|
||||
}
|
||||
return { local: payload.local ?? null, lan: payload.lan ?? null, relayAvailable: payload.relayAvailable !== false };
|
||||
},
|
||||
|
||||
async cancelPairing(id: string): Promise<{ cancelled: boolean }> {
|
||||
const response = await runtimeFetch(`/api/client-auth/pairing/sessions/${encodeURIComponent(id)}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
const payload = await jsonOrNull<{ cancelled?: boolean; error?: string }>(response);
|
||||
if (!response.ok || !payload) {
|
||||
throw new Error(payload?.error || response.statusText || 'Failed to cancel pairing');
|
||||
}
|
||||
return { cancelled: payload.cancelled === true };
|
||||
},
|
||||
|
||||
async revokeClient(id: string): Promise<RemoteClientRevokeResult> {
|
||||
const response = await runtimeFetch(`/api/client-auth/clients/${encodeURIComponent(id)}`, {
|
||||
method: 'DELETE',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { getRuntimeExtraHeadersSync, refreshLocalRuntimeUrlAuthToken, refreshRuntimeUrlAuthToken, setRuntimeBearerToken, setRuntimeExtraHeaders } from '@openchamber/ui/lib/runtime-auth';
|
||||
import { installRuntimeFetchBridge } from '@openchamber/ui/lib/runtime-fetch';
|
||||
import { initializeRuntimeEndpoint } from '@openchamber/ui/lib/runtime-switch';
|
||||
import { restoreDesktopRelayRuntime } from '@openchamber/ui/lib/desktopRelayRestore';
|
||||
import { configureRuntimeUrlResolver } from '@openchamber/ui/lib/runtime-url';
|
||||
import { createWebAPIs } from './api';
|
||||
|
||||
@@ -48,5 +49,8 @@ export const createConfiguredWebAPIs = () => {
|
||||
void refreshLocalRuntimeUrlAuthToken(localOrigin).catch(() => {});
|
||||
}
|
||||
installRuntimeFetchBridge();
|
||||
// Desktop only: if the default host is a relay host, re-open its tunnel now
|
||||
// that the fetch bridge is installed. No-op elsewhere.
|
||||
void restoreDesktopRelayRuntime().catch(() => {});
|
||||
return createWebAPIs({ urls });
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user