Files
sencho/backend/src/__tests__/pilot-agent-loopback-auth.test.ts
T
Anso a6d3e5d052 fix(pilot): inject loopback auth on agent-side HTTP/WS forwarding (#990)
After the central proxy forwards a request through the pilot tunnel, the
agent receives an http_req or ws_open frame and opens a local loopback
request to its own Sencho server. The forwarder previously copied the
inbound headers verbatim with no Authorization header, so the local
authMiddleware rejected every proxied call with 401 Authentication
required. The central cannot sign the missing token because each Sencho
instance has its own auth_jwt_secret generated at first-run setup.

The agent process runs in the same Node process as the local Sencho
server and shares the local DatabaseService, so it can mint a fresh
pilot_tunnel-scoped JWT signed with the local secret. Token TTL is 5
minutes with a refresh-on-use 4-minute cache to avoid jwt.sign on every
request. The header is built once per request via buildLoopbackHeaders
and applied to both onHttpReq and onWsOpen so the two forwarder paths
stay in lockstep. The local authMiddleware accepts the scope through
its existing pilot_tunnel branch with no special-case bypass.

Together with the proxy-side change in the previous commit, this
completes the central, bridge, agent, local-Sencho HTTP path for
pilot-agent-mode nodes.
2026-05-08 10:00:15 -04:00

60 lines
2.3 KiB
TypeScript

/**
* Regression guard for the agent-side loopback auth injection.
*
* The central proxy strips browser cookies and the (empty) pilot-agent api_token
* before forwarding through the tunnel. Without an inline auth header on the
* agent's loopback request, the local Sencho's `authMiddleware` would 401 every
* proxied call. The agent injects a `pilot_tunnel`-scoped JWT signed by the
* LOCAL `auth_jwt_secret`, which the loopback `authMiddleware` accepts via the
* existing scope branch with no special-case bypass.
*
* This test verifies:
* 1. `getLoopbackAuthHeader` returns a Bearer header backed by a JWT that
* the local secret verifies, with the expected scope.
* 2. Subsequent calls within the refresh window return the cached header
* (no re-mint per request).
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import jwt from 'jsonwebtoken';
import { setupTestDb, cleanupTestDb, TEST_JWT_SECRET } from './helpers/setupTestDb';
import { PilotAgent } from '../pilot/agent';
describe('PilotAgent loopback auth injection', () => {
let tmpDir: string;
let agent: PilotAgent;
let mintHeader: () => string | null;
beforeAll(async () => {
tmpDir = await setupTestDb();
// Importing the index loads DatabaseService against the seeded baseline so
// getGlobalSettings().auth_jwt_secret resolves to TEST_JWT_SECRET.
await import('../index');
agent = new PilotAgent({
primaryUrl: 'http://primary.invalid',
loopbackPort: 1,
initialToken: 'irrelevant-for-this-test',
enrolling: false,
});
mintHeader = (agent as unknown as { getLoopbackAuthHeader: () => string | null }).getLoopbackAuthHeader.bind(agent);
});
afterAll(() => {
cleanupTestDb(tmpDir);
});
it('mints a Bearer header with a pilot_tunnel-scoped JWT signed by the local secret', () => {
const header = mintHeader();
expect(header).toMatch(/^Bearer /);
const token = header!.slice('Bearer '.length);
const decoded = jwt.verify(token, TEST_JWT_SECRET) as { scope?: string; nodeId?: number; exp?: number };
expect(decoded.scope).toBe('pilot_tunnel');
expect(typeof decoded.exp).toBe('number');
});
it('returns the cached header on a quick second call', () => {
const a = mintHeader();
const b = mintHeader();
expect(a).toBe(b);
});
});