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:
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user