@throng/cli 0.1.1 → 0.1.2

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.
package/README.md CHANGED
@@ -15,6 +15,7 @@ throng org list List your organisations
15
15
  throng org use <slug-or-id>
16
16
  Choose the organisation later commands act in
17
17
  throng api [-f file] Run a GraphQL document and print the data as JSON
18
+ throng proxy Serve a local endpoint for a GraphQL GUI, credentials added
18
19
  ```
19
20
 
20
21
  `api` reads the document from `-f <file>`, or from stdin when `-f` is absent
@@ -29,6 +30,54 @@ Revocation is best-effort: if the server cannot be reached, logout warns on stde
29
30
  and still forgets the credentials locally. `auth status` asks the server who you
30
31
  are; if it cannot, it reports what is stored locally and says the user is unknown.
31
32
 
33
+ ### `throng proxy`
34
+
35
+ Point a GraphQL GUI at a local endpoint and it can query the API without ever
36
+ holding a token:
37
+
38
+ ```
39
+ throng proxy
40
+ ```
41
+
42
+ That serves `http://127.0.0.1:7979/api/graphql` — the same path as the real
43
+ endpoint, so only the origin differs — and forwards each request with
44
+ `authorization` and `x-throng-organisation` added. Set the one header it prints:
45
+
46
+ ```
47
+ x-throng-proxy-key: <printed on start>
48
+ ```
49
+
50
+ Responses come back untouched, status and body alike: a client needs the server's
51
+ own errors, not this proxy's opinion of them. The access token is refreshed as
52
+ needed, so a long session never stops working, and the stored organisation is
53
+ re-read per request — `throng org use` takes effect without a restart.
54
+
55
+ It runs in the foreground until interrupted. There is no daemon, so an
56
+ authenticated endpoint cannot outlive the terminal that opened it.
57
+
58
+ | Option | Meaning |
59
+ | --- | --- |
60
+ | `-p, --port <number>` | Port to listen on. Default `7979`. |
61
+ | `--new-key` | Replace the stored key before starting. |
62
+ | `--no-key` | Accept requests without the key. See below. |
63
+
64
+ The key lives at `~/.config/throng/proxy-key`, mode `0600`, and is stable so a
65
+ client needs it only once. It is a file of its own rather than a field in the
66
+ credentials: it is not a credential for any host, and keeping it separate keeps
67
+ it clear of the lock and rotation rules that protect refresh tokens.
68
+
69
+ **The key and CORS are coupled deliberately.** A custom request header forces a
70
+ cross-origin caller through a preflight, so with a key required the proxy can
71
+ safely echo the origin — which is what lets a browser-based client work where the
72
+ real endpoint cannot. With `--no-key` it sends no CORS headers at all, because
73
+ "reachable without a key" and "readable by any page you visit" must never both be
74
+ true; only non-browser clients can use that mode.
75
+
76
+ Requests are refused unless they arrive on loopback with a loopback `Host` (a
77
+ foreign `Host` is how DNS rebinding arrives), as a `POST` to `/api/graphql`.
78
+
79
+ Subscriptions are not proxied; this is HTTP only.
80
+
32
81
  ### Global options
33
82
 
34
83
  | Option | Meaning |
@@ -0,0 +1,59 @@
1
+ import { getOrgId } from "../oauth/session.js";
2
+ import { DEFAULT_PROXY_PORT, PROXY_KEY_HEADER, readOrCreateProxyKey, rotateProxyKey, startProxy, waitForInterrupt, } from "../proxy.js";
3
+ import { info } from "../output.js";
4
+ /**
5
+ * `throng proxy`: serve a loopback GraphQL endpoint that adds the credentials.
6
+ *
7
+ * Point a GraphQL GUI at the printed URL and it can query the API without ever
8
+ * holding a token. Runs in the foreground until interrupted; there is no daemon,
9
+ * so an authenticated endpoint cannot outlive the terminal that opened it.
10
+ */
11
+ export function registerProxy(program, ctx) {
12
+ program
13
+ .command("proxy")
14
+ .description("Serve a local GraphQL endpoint that injects your credentials, for a GraphQL GUI")
15
+ .option("-p, --port <number>", `port to listen on (default: ${DEFAULT_PROXY_PORT})`)
16
+ .option("--no-key", "accept requests without the proxy key (see the warning it prints)")
17
+ .option("--new-key", "replace the stored proxy key before starting")
18
+ .action(async (opts, command) => {
19
+ const { host, org } = ctx();
20
+ let port = DEFAULT_PROXY_PORT;
21
+ if (opts.port !== undefined) {
22
+ port = Number(opts.port);
23
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
24
+ return command.error(`error: --port must be a whole number between 1 and 65535`, { exitCode: 2 });
25
+ }
26
+ }
27
+ const key = opts.key ? (opts.newKey ? await rotateProxyKey() : await readOrCreateProxyKey()) : undefined;
28
+ let proxy;
29
+ try {
30
+ proxy = await startProxy({ host, port, key, orgId: org });
31
+ }
32
+ catch (cause) {
33
+ const code = cause.code;
34
+ if (code === "EADDRINUSE") {
35
+ return command.error(`error: port ${port} is already in use. Pass --port to pick another.`, { exitCode: 1 });
36
+ }
37
+ throw cause;
38
+ }
39
+ const selectedOrg = org ?? (await getOrgId(host));
40
+ info(`Proxying ${proxy.url} -> ${host}/api/graphql`);
41
+ info(`Organisation: ${selectedOrg ?? "none selected — run `throng org use <slug-or-id>`"}`);
42
+ if (key) {
43
+ info("");
44
+ info("Set this header in your GraphQL client:");
45
+ info(` ${PROXY_KEY_HEADER}: ${key}`);
46
+ }
47
+ else {
48
+ info("");
49
+ info("WARNING: running without a key. Any program on this machine can issue");
50
+ info("authenticated requests through this proxy while it is running. Browsers are");
51
+ info("still blocked, because no CORS headers are sent in this mode.");
52
+ }
53
+ info("");
54
+ info("Press Ctrl-C to stop.");
55
+ await waitForInterrupt();
56
+ await proxy.close();
57
+ info("Proxy stopped.");
58
+ });
59
+ }
@@ -14,6 +14,17 @@ export const LOCK_TIMEOUT_MS = 30_000;
14
14
  export class LockTimeoutError extends Error {
15
15
  name = "LockTimeoutError";
16
16
  }
17
+ /**
18
+ * `${XDG_CONFIG_HOME:-~/.config}/throng`, where everything this CLI stores lives.
19
+ *
20
+ * A path helper, not credential access: other modules may use it to find a
21
+ * sibling file of their own. Reading or writing the credentials themselves
22
+ * still belongs to `oauth/session.ts` alone.
23
+ */
24
+ export function configDir(env = process.env) {
25
+ const configHome = env.XDG_CONFIG_HOME || join(env.HOME || homedir(), ".config");
26
+ return join(configHome, "throng");
27
+ }
17
28
  /**
18
29
  * `${XDG_CONFIG_HOME:-~/.config}/throng/credentials.json`.
19
30
  *
@@ -21,8 +32,7 @@ export class LockTimeoutError extends Error {
21
32
  * never at module load, so a changed environment is always honoured.
22
33
  */
23
34
  export function credentialsPath(env = process.env) {
24
- const configHome = env.XDG_CONFIG_HOME || join(env.HOME || homedir(), ".config");
25
- return join(configHome, "throng", "credentials.json");
35
+ return join(configDir(env), "credentials.json");
26
36
  }
27
37
  function lockPathFor(file) {
28
38
  return `${file}.lock`;
package/dist/index.js CHANGED
@@ -7,6 +7,7 @@ import { Command, CommanderError } from "commander";
7
7
  import { registerApi } from "./commands/api.js";
8
8
  import { registerAuth } from "./commands/auth.js";
9
9
  import { registerOrg } from "./commands/org.js";
10
+ import { registerProxy } from "./commands/proxy.js";
10
11
  import { registerToken } from "./commands/token.js";
11
12
  import { resolveHost } from "./config.js";
12
13
  import { AuthRequiredError } from "./errors.js";
@@ -61,6 +62,7 @@ export function buildProgram(deps = {}) {
61
62
  registerToken(program, ctx);
62
63
  registerOrg(program, ctx);
63
64
  registerApi(program, ctx);
65
+ registerProxy(program, ctx);
64
66
  return { program, exitCode: () => resolved?.exitCode ?? 0 };
65
67
  }
66
68
  /** Run the CLI and return its exit code. Never throws and never exits the process. */
package/dist/proxy.js ADDED
@@ -0,0 +1,235 @@
1
+ import { randomBytes, timingSafeEqual } from "node:crypto";
2
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import { createServer } from "node:http";
4
+ import { join } from "node:path";
5
+ import { configDir } from "./credentials.js";
6
+ import { AuthRequiredError } from "./errors.js";
7
+ import { getAccessToken, getOrgId } from "./oauth/session.js";
8
+ /** Mirrors the real endpoint's path, so the proxy URL differs only in origin. */
9
+ export const PROXY_PATH = "/api/graphql";
10
+ export const PROXY_KEY_HEADER = "x-throng-proxy-key";
11
+ export const DEFAULT_PROXY_PORT = 7979;
12
+ /** Matches the GraphQL client's own ceiling; a query may legitimately run long. */
13
+ const UPSTREAM_TIMEOUT_MS = 60_000;
14
+ /** `${XDG_CONFIG_HOME:-~/.config}/throng/proxy-key`. */
15
+ export function proxyKeyPath(env = process.env) {
16
+ return join(configDir(env), "proxy-key");
17
+ }
18
+ async function writeKey(env) {
19
+ const key = randomBytes(32).toString("base64url");
20
+ await mkdir(configDir(env), { recursive: true, mode: 0o700 });
21
+ await writeFile(proxyKeyPath(env), `${key}\n`, { mode: 0o600 });
22
+ return key;
23
+ }
24
+ /**
25
+ * The stored proxy key, created on first use.
26
+ *
27
+ * Deliberately a file of its own rather than a field in the credentials: it is
28
+ * not a credential for any host, and keeping it out of that file keeps it clear
29
+ * of the lock and the rotation rules that protect refresh tokens.
30
+ */
31
+ export async function readOrCreateProxyKey(env = process.env) {
32
+ try {
33
+ const stored = (await readFile(proxyKeyPath(env), "utf8")).trim();
34
+ if (stored.length > 0)
35
+ return stored;
36
+ }
37
+ catch (err) {
38
+ if (err.code !== "ENOENT")
39
+ throw err;
40
+ }
41
+ return writeKey(env);
42
+ }
43
+ /** Replace the stored key, invalidating any client still sending the old one. */
44
+ export async function rotateProxyKey(env = process.env) {
45
+ return writeKey(env);
46
+ }
47
+ function equalKeys(provided, expected) {
48
+ const a = Buffer.from(provided);
49
+ const b = Buffer.from(expected);
50
+ // timingSafeEqual throws on a length mismatch, which is itself observable;
51
+ // the length check first keeps the comparison total.
52
+ return a.length === b.length && timingSafeEqual(a, b);
53
+ }
54
+ /** A `Host` naming anything but loopback means the request was rebound to us. */
55
+ function hostIsLoopback(header, port) {
56
+ if (header === undefined)
57
+ return false;
58
+ const expected = [`127.0.0.1:${port}`, `localhost:${port}`, `[::1]:${port}`];
59
+ return expected.includes(header.toLowerCase());
60
+ }
61
+ async function readBody(req) {
62
+ const chunks = [];
63
+ for await (const chunk of req)
64
+ chunks.push(chunk);
65
+ return Buffer.concat(chunks);
66
+ }
67
+ /**
68
+ * A loopback GraphQL proxy that adds the credentials a client cannot.
69
+ *
70
+ * It exists so a GraphQL GUI can talk to the API without ever holding a token:
71
+ * the client sends an ordinary unauthenticated POST, and this injects
72
+ * `authorization` and `x-throng-organisation` on the way out. Responses pass
73
+ * back untouched — status, body and content type — because a client needs the
74
+ * server's own errors, not this proxy's opinion of them.
75
+ *
76
+ * **The key and CORS are coupled on purpose.** A custom request header forces a
77
+ * cross-origin caller through a preflight, so with a key required we can safely
78
+ * echo the origin and let a browser-based client work. With no key, echoing an
79
+ * origin would let any page the user visits read authenticated responses, so no
80
+ * CORS headers are sent at all and only non-browser clients can use it.
81
+ */
82
+ export async function startProxy(opts) {
83
+ const { host, port = DEFAULT_PROXY_PORT, key, orgId } = opts;
84
+ const log = opts.log ?? ((message) => void process.stderr.write(`${message}\n`));
85
+ const requireKey = key !== undefined && key.length > 0;
86
+ let boundPort = port;
87
+ const server = createServer((req, res) => {
88
+ void handle(req, res).catch(() => {
89
+ // A handler must never reject into the server's error event.
90
+ if (!res.headersSent)
91
+ res.writeHead(500, { "content-type": "text/plain; charset=utf-8" });
92
+ res.end("Proxy error");
93
+ });
94
+ });
95
+ const corsHeaders = (req) => {
96
+ if (!requireKey)
97
+ return {};
98
+ const origin = req.headers.origin;
99
+ return {
100
+ "access-control-allow-origin": typeof origin === "string" && origin.length > 0 ? origin : "*",
101
+ vary: "origin",
102
+ };
103
+ };
104
+ async function handle(req, res) {
105
+ const reply = (status, text, extra = {}) => {
106
+ res.writeHead(status, {
107
+ "content-type": "text/plain; charset=utf-8",
108
+ "x-content-type-options": "nosniff",
109
+ ...extra,
110
+ });
111
+ res.end(text);
112
+ };
113
+ // A malformed request target makes `new URL` throw; that must not escape.
114
+ let url;
115
+ try {
116
+ url = new URL(req.url ?? "/", "http://127.0.0.1");
117
+ }
118
+ catch {
119
+ reply(400, "Bad request");
120
+ return;
121
+ }
122
+ if (url.pathname !== PROXY_PATH) {
123
+ reply(404, "Not found");
124
+ return;
125
+ }
126
+ if (!hostIsLoopback(req.headers.host, boundPort)) {
127
+ reply(403, "Forbidden");
128
+ return;
129
+ }
130
+ if (req.method === "OPTIONS") {
131
+ if (!requireKey) {
132
+ reply(204, "");
133
+ return;
134
+ }
135
+ res.writeHead(204, {
136
+ ...corsHeaders(req),
137
+ "access-control-allow-methods": "POST, OPTIONS",
138
+ "access-control-allow-headers": `content-type, ${PROXY_KEY_HEADER}`,
139
+ "access-control-max-age": "600",
140
+ });
141
+ res.end();
142
+ return;
143
+ }
144
+ if (req.method !== "POST") {
145
+ reply(405, "Method not allowed", { allow: "POST, OPTIONS" });
146
+ return;
147
+ }
148
+ if (requireKey) {
149
+ const provided = req.headers[PROXY_KEY_HEADER];
150
+ if (typeof provided !== "string" || !equalKeys(provided, key)) {
151
+ // Never name the expected value, and never hint at its length.
152
+ reply(401, `Missing or invalid ${PROXY_KEY_HEADER}`, corsHeaders(req));
153
+ return;
154
+ }
155
+ }
156
+ const body = await readBody(req);
157
+ let token;
158
+ try {
159
+ token = await getAccessToken(host);
160
+ }
161
+ catch (err) {
162
+ const message = err instanceof AuthRequiredError ? err.message : "Could not obtain an access token";
163
+ log(`proxy: ${message}`);
164
+ reply(401, message, corsHeaders(req));
165
+ return;
166
+ }
167
+ const org = orgId ?? (await getOrgId(host));
168
+ const headers = {
169
+ authorization: `Bearer ${token}`,
170
+ "content-type": req.headers["content-type"] ?? "application/json",
171
+ accept: "application/json",
172
+ };
173
+ if (org !== undefined)
174
+ headers["x-throng-organisation"] = org;
175
+ let upstream;
176
+ try {
177
+ upstream = await fetch(`${host}${PROXY_PATH}`, {
178
+ method: "POST",
179
+ headers,
180
+ // Bytes, not a re-serialised string: whatever the client sent is what the
181
+ // API sees. The copy is deliberate — `BodyInit` wants a `Uint8Array` over
182
+ // a plain `ArrayBuffer`, and a `Buffer`'s is only `ArrayBufferLike`.
183
+ // GraphQL documents are small enough for that to cost nothing.
184
+ body: new Uint8Array(body),
185
+ signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS),
186
+ });
187
+ }
188
+ catch (err) {
189
+ const reason = err instanceof Error && err.name === "TimeoutError" ? "timed out" : "could not be reached";
190
+ log(`proxy: ${host} ${reason}`);
191
+ reply(502, `Upstream ${reason}`, corsHeaders(req));
192
+ return;
193
+ }
194
+ const text = await upstream.text();
195
+ if (upstream.status === 403 && text.includes("ORGANISATION_ACCESS_DENIED")) {
196
+ // Forwarded as-is: a background server silently rewriting the stored org
197
+ // would be worse than a visible error the user can act on.
198
+ log("proxy: the selected organisation was refused. Run `throng org use <slug-or-id>` to choose another.");
199
+ }
200
+ res.writeHead(upstream.status, {
201
+ "content-type": upstream.headers.get("content-type") ?? "application/json",
202
+ "x-content-type-options": "nosniff",
203
+ ...corsHeaders(req),
204
+ });
205
+ res.end(text);
206
+ }
207
+ await new Promise((resolve, reject) => {
208
+ server.once("error", reject);
209
+ server.listen(port, "127.0.0.1", () => {
210
+ server.removeListener("error", reject);
211
+ resolve();
212
+ });
213
+ });
214
+ const addr = server.address();
215
+ if (addr === null || typeof addr === "string")
216
+ throw new Error("proxy did not bind a TCP port");
217
+ boundPort = addr.port;
218
+ return {
219
+ port: boundPort,
220
+ address: addr.address,
221
+ url: `http://127.0.0.1:${boundPort}${PROXY_PATH}`,
222
+ close: () => new Promise((resolve) => {
223
+ server.closeAllConnections();
224
+ server.close(() => resolve());
225
+ }),
226
+ };
227
+ }
228
+ /** Keep a started proxy running until the process is interrupted. */
229
+ export function waitForInterrupt() {
230
+ return new Promise((resolve) => {
231
+ const stop = () => resolve();
232
+ process.once("SIGINT", stop);
233
+ process.once("SIGTERM", stop);
234
+ });
235
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@throng/cli",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Command-line client for Throng",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {