repoburg 1.3.217 → 1.3.218

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -108,17 +108,22 @@ function createPaneDelegate(pane) {
108
108
  pane.routing(req, res);
109
109
  };
110
110
  }
111
- /**
112
- * Single upgrade router for the daemon's HTTP server.
113
- *
114
- * `/ws` goes to the embedded agent-pane app (when present); everything else —
115
- * e.g. the platform frontend's auth WebSocket — goes to the daemon wss.
116
- */
117
- function createUpgradeRouter(pane, daemonWss) {
111
+ function createUpgradeRouter(pane, daemonWss, guard) {
118
112
  return (req, socket, head) => {
119
113
  const url = req.url || '';
120
- if (pane && url.startsWith('/ws')) {
114
+ const guardedUpgrade = () => {
121
115
  pane.handleWsUpgrade(req, socket, head);
116
+ };
117
+ const reject = () => {
118
+ socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
119
+ socket.destroy();
120
+ };
121
+ if (pane && url.startsWith('/ws')) {
122
+ if (!guard) {
123
+ guardedUpgrade();
124
+ return;
125
+ }
126
+ void guard(req).then((allowed) => (allowed ? guardedUpgrade() : reject()));
122
127
  return;
123
128
  }
124
129
  daemonWss.handleUpgrade(req, socket, head, (ws) => {
@@ -131,6 +131,15 @@ class AuthManager {
131
131
  throw new Error('Could not save access token.');
132
132
  }
133
133
  }
134
+ /** Raw device JWT for outbound calls to the account server. */
135
+ async readToken() {
136
+ try {
137
+ return await fs_extra_1.default.readFile(TOKEN_PATH, 'utf-8');
138
+ }
139
+ catch {
140
+ return null;
141
+ }
142
+ }
134
143
  async logout() {
135
144
  try {
136
145
  await fs_extra_1.default.remove(TOKEN_PATH);
@@ -9,11 +9,15 @@ const http_1 = __importDefault(require("http"));
9
9
  const ws_1 = require("ws");
10
10
  const cors_1 = __importDefault(require("cors"));
11
11
  const serviceManager_1 = require("./serviceManager");
12
+ const corsOrigin_1 = require("./middleware/corsOrigin");
12
13
  const services_1 = __importDefault(require("./api/services"));
13
14
  const auth_1 = __importDefault(require("./api/auth"));
14
15
  const registry_1 = __importDefault(require("./api/registry"));
15
16
  const mcp_gateway_1 = require("./mcp-gateway");
16
17
  const agentPane_1 = require("./agentPane");
18
+ const routes_1 = require("./remote/routes");
19
+ const checkRemoteAuth_1 = require("./middleware/checkRemoteAuth");
20
+ const staticServing_1 = require("./staticServing");
17
21
  const PORT = 9998;
18
22
  async function main() {
19
23
  await serviceManager_1.serviceManager.connect();
@@ -27,15 +31,24 @@ async function main() {
27
31
  const server = http_1.default.createServer(app);
28
32
  // Daemon wss in noServer mode — the shared upgrade router dispatches below.
29
33
  const wss = new ws_1.WebSocketServer({ noServer: true });
34
+ // Spec: allowlist only — local tooling (vite, daemon-desktop) and
35
+ // *.repoburg.com tunnel hostnames. The previous origin:'*' with
36
+ // credentials:true was an open API surface.
30
37
  app.use((0, cors_1.default)({
31
- origin: '*', // Allow all
38
+ origin: corsOrigin_1.corsOrigin,
32
39
  methods: 'GET,HEAD,PUT,PATCH,POST,DELETE',
33
40
  credentials: true,
34
41
  }));
42
+ // CF Access guard for the embedded pane: when the machine has a tunnel and
43
+ // Access is configured, /api/* traffic must carry a valid Access JWT.
44
+ app.use('/api', checkRemoteAuth_1.checkRemoteAuth);
35
45
  // Embedded agent-pane API. MUST precede express.json() — Fastify parses its
36
46
  // own request bodies; the stream can only be consumed once.
37
47
  app.all('/api/*', (0, agentPane_1.createPaneDelegate)(pane));
38
48
  app.use(express_1.default.json());
49
+ // Tunnel lifecycle API — local-only; the CF ingress edge blocks /remote/*
50
+ // for tunnel traffic (see account-server CloudflareService.setIngress).
51
+ app.use('/remote', (0, routes_1.createRemoteRouter)());
39
52
  // API Routers
40
53
  app.use('/services', services_1.default);
41
54
  app.use('/auth', (0, auth_1.default)(wss));
@@ -57,15 +70,29 @@ async function main() {
57
70
  app.get('/health', (req, res) => {
58
71
  res.status(200).json({ status: 'ok' });
59
72
  });
73
+ // Pane frontend SPA — mounted LAST so API routes (including /health) are
74
+ // never shadowed. A machine's own origin serves the pane and the API on
75
+ // one origin.
76
+ const paneFrontendRouter = (0, staticServing_1.createPaneFrontendRouter)((0, staticServing_1.resolvePaneFrontendDist)(__dirname));
77
+ if (paneFrontendRouter) {
78
+ app.use(paneFrontendRouter);
79
+ console.log(`[pane] serving frontend from ${(0, staticServing_1.resolvePaneFrontendDist)(__dirname)}`);
80
+ }
81
+ else {
82
+ console.warn(`[pane] frontend dist not found at ${(0, staticServing_1.resolvePaneFrontendDist)(__dirname)} — SPA not served`);
83
+ }
60
84
  wss.on('connection', (ws) => {
61
85
  console.log('Client connected to daemon WebSocket');
62
86
  ws.on('close', () => console.log('Client disconnected from daemon WebSocket'));
63
87
  ws.on('error', (error) => console.error('WebSocket error:', error));
64
88
  });
65
- // Single upgrade router: '/ws' -> embedded agent-pane, everything else -> daemon wss.
66
- server.on('upgrade', (0, agentPane_1.createUpgradeRouter)(pane, wss));
67
- server.listen(PORT, () => {
68
- console.log(`Repoburg Platform Daemon listening on http://localhost:${PORT}`);
89
+ // Single upgrade router: '/ws' -> embedded agent-pane (guarded by CF Access
90
+ // when configured), everything else -> daemon wss.
91
+ server.on('upgrade', (0, agentPane_1.createUpgradeRouter)(pane, wss, checkRemoteAuth_1.checkRemoteWsAuth));
92
+ // Spec: loopback binding only — remote access is exclusively via the
93
+ // cloudflared tunnel; the daemon must never listen on public interfaces.
94
+ server.listen(PORT, '127.0.0.1', () => {
95
+ console.log(`Repoburg Platform Daemon listening on http://127.0.0.1:${PORT}`);
69
96
  console.log(`MCP Gateway available at http://localhost:${PORT}/mcp`);
70
97
  });
71
98
  // Clean up MCP gateway sessions and the embedded agent-pane on shutdown.
@@ -0,0 +1,72 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.checkRemoteAuth = checkRemoteAuth;
4
+ exports.checkRemoteWsAuth = checkRemoteWsAuth;
5
+ const tunnel_state_1 = require("../remote/tunnel-state");
6
+ const access_jwt_1 = require("../remote/access-jwt");
7
+ /**
8
+ * Guard for daemon endpoints that must not be reachable by anonymous tunnel
9
+ * traffic once CF Access is configured.
10
+ *
11
+ * Only requests that ARRIVED THROUGH THE TUNNEL (Host = the machine's
12
+ * <mahlas>.repoburg.com hostname) need the Access JWT. Requests with a
13
+ * local Host header — the local pane at localhost:9998 and the public
14
+ * pane host agent.repoburg.com calling into the visitor's own daemon —
15
+ * pass through: the loopback binding is the boundary there, and remote
16
+ * clients cannot forge the Host through the CF edge (routing is by
17
+ * hostname/SNI).
18
+ */
19
+ function isLocalHostHeader(host) {
20
+ if (!host)
21
+ return true; // no Host header = non-HTTP client tooling
22
+ return host.startsWith('localhost') || host.startsWith('127.0.0.1') || host.startsWith('0.0.0.0');
23
+ }
24
+ async function checkRemoteAuth(req, res, next) {
25
+ const state = (0, tunnel_state_1.loadTunnelState)();
26
+ const config = state?.accessTeamDomain && state.accessAud
27
+ ? { teamDomain: state.accessTeamDomain, aud: state.accessAud }
28
+ : null;
29
+ if (!config || !state || isLocalHostHeader(req.headers.host)) {
30
+ // No Access configured, or local usage — loopback binding is the boundary.
31
+ next();
32
+ return;
33
+ }
34
+ // Fail closed: Access is configured but the owner email was never pinned
35
+ // (state from before email pinging). Re-provision the machine.
36
+ if (!state.ownerEmail) {
37
+ res.status(401).json({ error: 'Tunnel owner email is missing — re-provision this machine.' });
38
+ return;
39
+ }
40
+ const assertion = req.headers['cf-access-jwt-assertion'];
41
+ if (typeof assertion !== 'string') {
42
+ res.status(401).json({ error: 'Cloudflare Access authentication required.' });
43
+ return;
44
+ }
45
+ try {
46
+ await (0, access_jwt_1.verifyAccessJwt)(assertion, config, { ownerEmail: state.ownerEmail });
47
+ next();
48
+ }
49
+ catch {
50
+ res.status(401).json({ error: 'Invalid or expired Cloudflare Access token.' });
51
+ }
52
+ }
53
+ /** Same guard for WebSocket upgrades: false = reject and destroy the socket. */
54
+ async function checkRemoteWsAuth(req) {
55
+ const state = (0, tunnel_state_1.loadTunnelState)();
56
+ if (!state?.accessTeamDomain || !state.accessAud)
57
+ return true; // no Access configured
58
+ if (isLocalHostHeader(req.headers.host))
59
+ return true; // local usage
60
+ if (!state.ownerEmail)
61
+ return false; // fail closed: no pinned owner email
62
+ const assertion = req.headers['cf-access-jwt-assertion'];
63
+ if (typeof assertion !== 'string')
64
+ return false;
65
+ try {
66
+ await (0, access_jwt_1.verifyAccessJwt)(assertion, { teamDomain: state.accessTeamDomain, aud: state.accessAud }, { ownerEmail: state.ownerEmail });
67
+ return true;
68
+ }
69
+ catch {
70
+ return false;
71
+ }
72
+ }
@@ -0,0 +1,117 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const checkRemoteAuth_1 = require("./checkRemoteAuth");
4
+ const tunnel_state_1 = require("../remote/tunnel-state");
5
+ const access_jwt_1 = require("../remote/access-jwt");
6
+ jest.mock('../remote/tunnel-state');
7
+ jest.mock('../remote/access-jwt');
8
+ const mockedLoad = tunnel_state_1.loadTunnelState;
9
+ const mockedVerify = access_jwt_1.verifyAccessJwt;
10
+ /** Requests THROUGH THE TUNNEL carry the machine hostname as Host. */
11
+ const TUNNEL_HOST = 'zztest.repoburg.com:443';
12
+ /** Fully provisioned state (Access + pinned owner email). */
13
+ const PINNED_STATE = {
14
+ accessTeamDomain: 'team.cloudflareaccess.com',
15
+ accessAud: 'aud-tag',
16
+ ownerEmail: 'owner@repoburg.com',
17
+ };
18
+ const makeRes = () => {
19
+ const res = { status: jest.fn(), json: jest.fn() };
20
+ res.status.mockReturnValue(res);
21
+ return res;
22
+ };
23
+ const makeReq = (headers) => ({ headers });
24
+ beforeEach(() => {
25
+ mockedLoad.mockReset().mockReturnValue(null);
26
+ mockedVerify.mockReset().mockResolvedValue(undefined);
27
+ });
28
+ describe('checkRemoteAuth (HTTP)', () => {
29
+ it('passes through when no tunnel state exists (local usage)', async () => {
30
+ const next = jest.fn();
31
+ await (0, checkRemoteAuth_1.checkRemoteAuth)(makeReq({ host: 'localhost:9998' }), makeRes(), next);
32
+ expect(next).toHaveBeenCalled();
33
+ });
34
+ it('passes through when Access is not configured yet', async () => {
35
+ mockedLoad.mockReturnValue({ accessTeamDomain: undefined, accessAud: undefined });
36
+ const next = jest.fn();
37
+ await (0, checkRemoteAuth_1.checkRemoteAuth)(makeReq({ host: TUNNEL_HOST }), makeRes(), next);
38
+ expect(next).toHaveBeenCalled();
39
+ });
40
+ it('passes local requests even when Access is configured (local pane + agent.repoburg.com FE)', async () => {
41
+ mockedLoad.mockReturnValue(PINNED_STATE);
42
+ for (const host of ['localhost:9998', '127.0.0.1:9998', '0.0.0.0:9998']) {
43
+ const next = jest.fn();
44
+ await (0, checkRemoteAuth_1.checkRemoteAuth)(makeReq({ host }), makeRes(), next);
45
+ expect(next).toHaveBeenCalledWith();
46
+ }
47
+ expect(mockedVerify).not.toHaveBeenCalled();
48
+ });
49
+ it('requires an Access assertion for tunnel-host requests when configured', async () => {
50
+ mockedLoad.mockReturnValue(PINNED_STATE);
51
+ const res = makeRes();
52
+ const next = jest.fn();
53
+ await (0, checkRemoteAuth_1.checkRemoteAuth)(makeReq({ host: TUNNEL_HOST }), res, next);
54
+ expect(res.status).toHaveBeenCalledWith(401);
55
+ expect(next).not.toHaveBeenCalled();
56
+ });
57
+ it('FAILS CLOSED when Access is configured but no owner email is pinned (pre-pinning state)', async () => {
58
+ mockedLoad.mockReturnValue({ accessTeamDomain: 'team.cloudflareaccess.com', accessAud: 'aud-tag' });
59
+ const res = makeRes();
60
+ const next = jest.fn();
61
+ await (0, checkRemoteAuth_1.checkRemoteAuth)(makeReq({ host: TUNNEL_HOST, 'cf-access-jwt-assertion': 'jwt' }), res, next);
62
+ expect(res.status).toHaveBeenCalledWith(401);
63
+ expect(mockedVerify).not.toHaveBeenCalled();
64
+ expect(next).not.toHaveBeenCalled();
65
+ });
66
+ it('accepts a valid Access JWT and pins the owner email', async () => {
67
+ mockedLoad.mockReturnValue(PINNED_STATE);
68
+ const next = jest.fn();
69
+ await (0, checkRemoteAuth_1.checkRemoteAuth)(makeReq({ host: TUNNEL_HOST, 'cf-access-jwt-assertion': 'jwt' }), makeRes(), next);
70
+ expect(mockedVerify).toHaveBeenCalledWith('jwt', { teamDomain: 'team.cloudflareaccess.com', aud: 'aud-tag' }, { ownerEmail: 'owner@repoburg.com' });
71
+ expect(next).toHaveBeenCalled();
72
+ });
73
+ it('rejects a valid-signature JWT for the WRONG email with 401 (email pinning)', async () => {
74
+ mockedLoad.mockReturnValue(PINNED_STATE);
75
+ // Simulates a policy edited by a compromised account-server: CF signed
76
+ // the token, but common_name is the attacker's email.
77
+ mockedVerify.mockRejectedValue(new Error('Access token email does not match the machine owner'));
78
+ const res = makeRes();
79
+ const next = jest.fn();
80
+ await (0, checkRemoteAuth_1.checkRemoteAuth)(makeReq({ host: TUNNEL_HOST, 'cf-access-jwt-assertion': 'jwt' }), res, next);
81
+ expect(res.status).toHaveBeenCalledWith(401);
82
+ expect(next).not.toHaveBeenCalled();
83
+ });
84
+ it('rejects an invalid Access JWT with 401', async () => {
85
+ mockedLoad.mockReturnValue(PINNED_STATE);
86
+ mockedVerify.mockRejectedValue(new Error('bad token'));
87
+ const res = makeRes();
88
+ const next = jest.fn();
89
+ await (0, checkRemoteAuth_1.checkRemoteAuth)(makeReq({ host: TUNNEL_HOST, 'cf-access-jwt-assertion': 'jwt' }), res, next);
90
+ expect(res.status).toHaveBeenCalledWith(401);
91
+ expect(next).not.toHaveBeenCalled();
92
+ });
93
+ });
94
+ describe('checkRemoteWsAuth (WebSocket upgrade)', () => {
95
+ it('allows everything when Access is not configured', async () => {
96
+ mockedLoad.mockReturnValue(null);
97
+ await expect((0, checkRemoteAuth_1.checkRemoteWsAuth)(makeReq({ host: 'localhost:9998' }))).resolves.toBe(true);
98
+ });
99
+ it('allows local upgrades even when configured', async () => {
100
+ mockedLoad.mockReturnValue(PINNED_STATE);
101
+ await expect((0, checkRemoteAuth_1.checkRemoteWsAuth)(makeReq({ host: 'localhost:9998' }))).resolves.toBe(true);
102
+ expect(mockedVerify).not.toHaveBeenCalled();
103
+ });
104
+ it('denies tunnel-host upgrades without an assertion when configured', async () => {
105
+ mockedLoad.mockReturnValue(PINNED_STATE);
106
+ await expect((0, checkRemoteAuth_1.checkRemoteWsAuth)(makeReq({ host: TUNNEL_HOST }))).resolves.toBe(false);
107
+ });
108
+ it('fails closed when configured without a pinned owner email', async () => {
109
+ mockedLoad.mockReturnValue({ accessTeamDomain: 'team.cloudflareaccess.com', accessAud: 'aud' });
110
+ await expect((0, checkRemoteAuth_1.checkRemoteWsAuth)(makeReq({ host: TUNNEL_HOST }))).resolves.toBe(false);
111
+ });
112
+ it('allows tunnel-host upgrades with a valid assertion and pins the owner email', async () => {
113
+ mockedLoad.mockReturnValue(PINNED_STATE);
114
+ await expect((0, checkRemoteAuth_1.checkRemoteWsAuth)(makeReq({ host: TUNNEL_HOST, 'cf-access-jwt-assertion': 'jwt' }))).resolves.toBe(true);
115
+ expect(mockedVerify).toHaveBeenCalledWith('jwt', { teamDomain: 'team.cloudflareaccess.com', aud: 'aud-tag' }, { ownerEmail: 'owner@repoburg.com' });
116
+ });
117
+ });
@@ -0,0 +1,53 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.corsOrigin = corsOrigin;
4
+ exports.setExtraOriginsForTest = setExtraOriginsForTest;
5
+ /**
6
+ * CORS origin allowlist for the daemon.
7
+ *
8
+ * Spec: the daemon serves the embedded agent-pane to local tooling
9
+ * (vite dev servers, daemon-desktop) and, once a tunnel is provisioned, to
10
+ * `*.repoburg.com` machine hostnames. Everything else is rejected — the
11
+ * previous `origin: '*'` with `credentials: true` is an open API surface.
12
+ *
13
+ * Allowed:
14
+ * - no Origin header (same-origin / non-browser clients such as curl)
15
+ * - http://localhost:<any> and http://127.0.0.1:<any>
16
+ * - https://<anything>.repoburg.com (tunnel machine hostnames)
17
+ * - extra origins from REPOBURG_ALLOWED_ORIGINS (comma-separated)
18
+ */
19
+ const REPORG_HOSTNAME_SUFFIX = '.repoburg.com';
20
+ const extraOrigins = (process.env.REPOBURG_ALLOWED_ORIGINS ?? '')
21
+ .split(',')
22
+ .map((value) => value.trim())
23
+ .filter(Boolean);
24
+ function isLocalOrigin(origin) {
25
+ return (origin.protocol === 'http:' &&
26
+ (origin.hostname === 'localhost' || origin.hostname === '127.0.0.1'));
27
+ }
28
+ function isTunnelOrigin(origin) {
29
+ return origin.protocol === 'https:' && origin.hostname.endsWith(REPORG_HOSTNAME_SUFFIX);
30
+ }
31
+ /** CORS origin callback. `false` = do not emit CORS headers for this origin. */
32
+ function corsOrigin(origin, callback) {
33
+ // Same-origin requests and non-browser clients send no Origin header.
34
+ if (origin === undefined) {
35
+ callback(null, true);
36
+ return;
37
+ }
38
+ let parsed;
39
+ try {
40
+ parsed = new URL(origin);
41
+ }
42
+ catch {
43
+ callback(null, false);
44
+ return;
45
+ }
46
+ const allowed = isLocalOrigin(parsed) || isTunnelOrigin(parsed) || extraOrigins.includes(origin);
47
+ callback(null, allowed);
48
+ }
49
+ /** Exposed for tests. */
50
+ function setExtraOriginsForTest(origins) {
51
+ extraOrigins.length = 0;
52
+ extraOrigins.push(...origins);
53
+ }
@@ -0,0 +1,46 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const corsOrigin_1 = require("./corsOrigin");
4
+ function evaluate(origin) {
5
+ let result = false;
6
+ (0, corsOrigin_1.corsOrigin)(origin, (err, allow) => {
7
+ if (err)
8
+ throw err;
9
+ result = !!allow;
10
+ });
11
+ return result;
12
+ }
13
+ describe('corsOrigin', () => {
14
+ afterEach(() => (0, corsOrigin_1.setExtraOriginsForTest)([]));
15
+ it('allows requests without an Origin header (same-origin / curl)', () => {
16
+ expect(evaluate(undefined)).toBe(true);
17
+ });
18
+ it('allows localhost and 127.0.0.1 on any port', () => {
19
+ expect(evaluate('http://localhost:3000')).toBe(true);
20
+ expect(evaluate('http://localhost:9998')).toBe(true);
21
+ expect(evaluate('http://127.0.0.1:5173')).toBe(true);
22
+ });
23
+ it('allows tunnel machine hostnames under repoburg.com (https only)', () => {
24
+ expect(evaluate('https://workstation.repoburg.com')).toBe(true);
25
+ expect(evaluate('https://a-b.repoburg.com')).toBe(true);
26
+ });
27
+ it('rejects unknown origins', () => {
28
+ expect(evaluate('https://evil.example.com')).toBe(false);
29
+ expect(evaluate('http://evil.com')).toBe(false);
30
+ });
31
+ it('rejects repoburg.com hostnames over http (tunnels are https)', () => {
32
+ expect(evaluate('http://workstation.repoburg.com')).toBe(false);
33
+ });
34
+ it('rejects lookalike suffixes', () => {
35
+ expect(evaluate('https://workstation.repoburg.com.evil.com')).toBe(false);
36
+ expect(evaluate('https://evilrepoburg.com')).toBe(false);
37
+ });
38
+ it('rejects malformed origins', () => {
39
+ expect(evaluate('not a url')).toBe(false);
40
+ });
41
+ it('allows extra origins configured via env list', () => {
42
+ (0, corsOrigin_1.setExtraOriginsForTest)(['https://staging.example.com']);
43
+ expect(evaluate('https://staging.example.com')).toBe(true);
44
+ expect(evaluate('https://other.example.com')).toBe(false);
45
+ });
46
+ });
@@ -0,0 +1,67 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.teamJwks = teamJwks;
37
+ exports.verifyAccessJwt = verifyAccessJwt;
38
+ /**
39
+ * CF Access JWT verification for requests arriving through the tunnel.
40
+ *
41
+ * Spec: when the machine has a tunnel AND Cloudflare Access is configured
42
+ * (team domain + application AUD stored in remote.json), every /api/* and
43
+ * /ws request must carry a valid `Cf-Access-Jwt-Assertion` header signed by
44
+ * the team's Cloudflare Zero Trust org. Without Access configured, the
45
+ * loopback binding remains the security boundary (tunnel traffic is
46
+ * Edge-protected by the user's Access policy once set up).
47
+ */
48
+ const jose = __importStar(require("jose"));
49
+ /** Build (and cache) the JWKS for a team's public Access signing keys. */
50
+ function teamJwks(teamDomain) {
51
+ const url = new URL(`https://${teamDomain}/cdn-cgi/access/certs`);
52
+ return jose.createRemoteJWKSet(url);
53
+ }
54
+ /** Verify a Cloudflare Access JWT against the team's keys. */
55
+ async function verifyAccessJwt(assertion, config, options = {}) {
56
+ const jwks = options.jwks ?? teamJwks(config.teamDomain);
57
+ const { payload } = await jose.jwtVerify(assertion, jwks, {
58
+ issuer: `https://${config.teamDomain}`,
59
+ audience: config.aud,
60
+ });
61
+ // CF Access JWTs carry the identity email in `email`; older docs/flows used
62
+ // `common_name`. Accept either so pinning matches real edge-issued tokens.
63
+ const identity = payload.common_name ?? payload.email;
64
+ if (options.ownerEmail && identity !== options.ownerEmail) {
65
+ throw new Error(`Access token email does not match the machine owner (${identity} !== ${options.ownerEmail}).`);
66
+ }
67
+ }
@@ -0,0 +1,94 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ const access_jwt_1 = require("./access-jwt");
37
+ const jose = __importStar(require("jose"));
38
+ /**
39
+ * Real-crypto tests: sign actual Access-style JWTs with an injected HMAC key
40
+ * and verify the email-pinning logic on top of signature/iss/aud checks.
41
+ */
42
+ const SECRET = new TextEncoder().encode('test-secret-for-specs-only');
43
+ const CONFIG = { teamDomain: 'team.cloudflareaccess.com', aud: 'aud-tag' };
44
+ const jwks = (async () => SECRET);
45
+ async function makeAssertion(claims) {
46
+ return new jose.SignJWT(claims)
47
+ .setProtectedHeader({ alg: 'HS256' })
48
+ .setIssuer('https://team.cloudflareaccess.com')
49
+ .setAudience('aud-tag')
50
+ .setExpirationTime('2h')
51
+ .sign(SECRET);
52
+ }
53
+ describe('verifyAccessJwt', () => {
54
+ it('accepts a valid token signed by the team', async () => {
55
+ const assertion = await makeAssertion({ common_name: 'owner@repoburg.com' });
56
+ await expect((0, access_jwt_1.verifyAccessJwt)(assertion, CONFIG, { jwks })).resolves.toBeUndefined();
57
+ });
58
+ it('accepts any identity when no owner email is pinned (optional check)', async () => {
59
+ const assertion = await makeAssertion({ common_name: 'someone-else@evil.com' });
60
+ await expect((0, access_jwt_1.verifyAccessJwt)(assertion, CONFIG, { jwks })).resolves.toBeUndefined();
61
+ });
62
+ it('rejects a wrong-email token when the owner email is pinned', async () => {
63
+ const assertion = await makeAssertion({ common_name: 'hacker@evil.com' });
64
+ await expect((0, access_jwt_1.verifyAccessJwt)(assertion, CONFIG, { jwks, ownerEmail: 'owner@repoburg.com' })).rejects.toThrow(/does not match the machine owner/);
65
+ });
66
+ it('accepts the pinned owner email', async () => {
67
+ const assertion = await makeAssertion({ common_name: 'owner@repoburg.com' });
68
+ await expect((0, access_jwt_1.verifyAccessJwt)(assertion, CONFIG, { jwks, ownerEmail: 'owner@repoburg.com' })).resolves.toBeUndefined();
69
+ });
70
+ it('matches the pinned owner email via the email claim (real CF Access format)', async () => {
71
+ // Live-verified 2026-09: CF Access JWTs carry `email`, NOT `common_name`.
72
+ const assertion = await makeAssertion({ email: 'owner@repoburg.com' });
73
+ await expect((0, access_jwt_1.verifyAccessJwt)(assertion, CONFIG, { jwks, ownerEmail: 'owner@repoburg.com' })).resolves.toBeUndefined();
74
+ });
75
+ it('rejects a wrong email claim when the owner email is pinned', async () => {
76
+ const assertion = await makeAssertion({ email: 'hacker@evil.com' });
77
+ await expect((0, access_jwt_1.verifyAccessJwt)(assertion, CONFIG, { jwks, ownerEmail: 'owner@repoburg.com' })).rejects.toThrow(/does not match the machine owner/);
78
+ });
79
+ it('rejects a token with neither email nor common_name when pinned', async () => {
80
+ const assertion = await makeAssertion({ sub: '99dd1943-5978-589e-9dd0-884f0c79e926' });
81
+ await expect((0, access_jwt_1.verifyAccessJwt)(assertion, CONFIG, { jwks, ownerEmail: 'owner@repoburg.com' })).rejects.toThrow(/does not match the machine owner/);
82
+ });
83
+ it('rejects tokens with a wrong issuer or audience', async () => {
84
+ const wrongIss = await new jose.SignJWT({ common_name: 'owner@repoburg.com' })
85
+ .setProtectedHeader({ alg: 'HS256' })
86
+ .setIssuer('https://other-team.cloudflareaccess.com')
87
+ .setAudience('aud-tag')
88
+ .setExpirationTime('2h')
89
+ .sign(SECRET);
90
+ await expect((0, access_jwt_1.verifyAccessJwt)(wrongIss, CONFIG, { jwks })).rejects.toThrow();
91
+ const wrongAud = await makeAssertion({ common_name: 'owner@repoburg.com' });
92
+ await expect((0, access_jwt_1.verifyAccessJwt)(wrongAud, { teamDomain: 'team.cloudflareaccess.com', aud: 'other-aud' }, { jwks })).rejects.toThrow();
93
+ });
94
+ });