feat: connection candidates refresh + relay identity hardening
Candidates refresh (server + mobile + desktop clients):
- GET /api/client-auth/connection/candidates returns the server's current
LAN URLs, relay candidate, and serverId for already-paired devices
- /health and /api/version expose serverId so clients can verify a learned
address belongs to the expected server before sending their bearer token
- mobile: refresh saved candidates over the live transport after every
connect/wake, hot-switch relay->LAN when a fresh address is reachable;
serverId gate on direct probes; token no longer sent to /health
- desktop: refresh stored host apiUrl after a relay connect and hot-switch
back to direct; electron probe verifies serverId before authenticated fetch
Fixes found while debugging a dead pairing:
- settings: strict reader that throws on corrupt/unreadable file instead of
returning {}; relay signing/encryption key generation is now gated on it,
so a swallowed read failure can no longer mint a new server identity and
orphan every paired device (loud log when a keypair IS generated)
- SessionAuthGate: bounded auto-retry for transient session-check failures
(initial request racing the relay tunnel's first WS attempt, startup 5xx)
This commit is contained in:
@@ -571,6 +571,56 @@ describe('client auth routes', () => {
|
||||
expect(listedAfterPurge.body.clients).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('reports current connection candidates with server identity for paired devices', async () => {
|
||||
const app = express();
|
||||
const relayCandidate = {
|
||||
type: 'relay',
|
||||
relayUrl: 'wss://relay.example/ws',
|
||||
serverId: 'server-abc',
|
||||
hostEncPubJwk: { kty: 'EC', crv: 'P-256', x: 'x', y: 'y' },
|
||||
priority: 30,
|
||||
};
|
||||
const dependencies = {
|
||||
...createDependencies({ resolveAuthContext: async () => ({ type: 'client', clientId: 'client-1' }) }),
|
||||
getDirectCandidateUrls: () => ['http://192.168.1.20:3000', 'http://10.0.0.5:3000', 'not-a-url'],
|
||||
getRelayPairingCandidate: async () => relayCandidate,
|
||||
getServerId: async () => 'server-abc',
|
||||
getServerLabel: () => 'my-host',
|
||||
};
|
||||
registerAuthAndAccessRoutes(app, dependencies);
|
||||
|
||||
const response = await request(app).get('/api/client-auth/connection/candidates');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers['cache-control']).toBe('no-store');
|
||||
expect(response.body.serverId).toBe('server-abc');
|
||||
expect(response.body.label).toBe('my-host');
|
||||
expect(response.body.candidates).toEqual([
|
||||
{ type: 'lan', url: 'http://192.168.1.20:3000', priority: 10 },
|
||||
{ type: 'lan', url: 'http://10.0.0.5:3000', priority: 10 },
|
||||
relayCandidate,
|
||||
]);
|
||||
});
|
||||
|
||||
it('omits serverId and relay candidate when unavailable and survives failures', async () => {
|
||||
const app = express();
|
||||
const dependencies = {
|
||||
...createDependencies(),
|
||||
getDirectCandidateUrls: () => {
|
||||
throw new Error('scan failed');
|
||||
},
|
||||
getRelayPairingCandidate: async () => {
|
||||
throw new Error('relay status failed');
|
||||
},
|
||||
getServerId: async () => null,
|
||||
};
|
||||
registerAuthAndAccessRoutes(app, dependencies);
|
||||
|
||||
const response = await request(app).get('/api/client-auth/connection/candidates');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).not.toHaveProperty('serverId');
|
||||
expect(response.body.candidates).toEqual([]);
|
||||
});
|
||||
|
||||
it('scopes non-desktop client credentials to list and revoke only themselves', async () => {
|
||||
const app = express();
|
||||
let authContext = { type: 'session' };
|
||||
|
||||
Reference in New Issue
Block a user