@throng/cli 0.1.0
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 +120 -0
- package/dist/commands/api.js +80 -0
- package/dist/commands/auth.js +96 -0
- package/dist/commands/org.js +52 -0
- package/dist/commands/token.js +14 -0
- package/dist/config.js +37 -0
- package/dist/credentials.js +211 -0
- package/dist/errors.js +6 -0
- package/dist/graphql.js +129 -0
- package/dist/index.js +111 -0
- package/dist/oauth/login.js +135 -0
- package/dist/oauth/pkce.js +14 -0
- package/dist/oauth/refresh.js +24 -0
- package/dist/oauth/revoke.js +33 -0
- package/dist/oauth/session.js +117 -0
- package/dist/oauth/token-request.js +87 -0
- package/dist/output.js +46 -0
- package/package.json +39 -0
package/dist/graphql.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { AuthRequiredError, SIGN_IN_COMMAND } from "./errors.js";
|
|
2
|
+
import { getAccessToken } from "./oauth/session.js";
|
|
3
|
+
import { printable } from "./oauth/token-request.js";
|
|
4
|
+
/** A data query can legitimately run long, so this is well above the 15 s a token exchange gets. */
|
|
5
|
+
export const GRAPHQL_TIMEOUT_MS = 60 * 1000;
|
|
6
|
+
/** The server answered, but not with data: a transport failure or a refusal on the merits. */
|
|
7
|
+
export class ApiError extends Error {
|
|
8
|
+
code;
|
|
9
|
+
name = "ApiError";
|
|
10
|
+
constructor(message, code) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.code = code;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
/** `x-throng-organisation` was rejected as malformed (`ORGANISATION_INVALID`). */
|
|
16
|
+
export class OrgInvalidError extends ApiError {
|
|
17
|
+
name = "OrgInvalidError";
|
|
18
|
+
}
|
|
19
|
+
/** The signed-in user is not a member of the selected organisation (`ORGANISATION_ACCESS_DENIED`). */
|
|
20
|
+
export class OrgAccessDeniedError extends ApiError {
|
|
21
|
+
name = "OrgAccessDeniedError";
|
|
22
|
+
}
|
|
23
|
+
const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
24
|
+
/** The error entries of a GraphQL-shaped `errors` array; anything else yields none. */
|
|
25
|
+
function errorEntries(value) {
|
|
26
|
+
return Array.isArray(value) ? value.filter(isObject) : [];
|
|
27
|
+
}
|
|
28
|
+
function codeOf(entries) {
|
|
29
|
+
for (const entry of entries) {
|
|
30
|
+
const code = isObject(entry.extensions) ? entry.extensions.code : undefined;
|
|
31
|
+
if (typeof code === "string")
|
|
32
|
+
return code;
|
|
33
|
+
}
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Mutations answer refusals on the merits as `{ result, errors }` payloads with
|
|
38
|
+
* HTTP 200. Gather the entries of every payload among the root fields of `data`.
|
|
39
|
+
*/
|
|
40
|
+
function payloadErrors(data) {
|
|
41
|
+
if (!isObject(data))
|
|
42
|
+
return [];
|
|
43
|
+
return Object.values(data).flatMap((field) => (isObject(field) ? errorEntries(field.errors) : []));
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* POST `document` to `${host}/api/graphql` as the signed-in user and return its `data`.
|
|
47
|
+
*
|
|
48
|
+
* Failures map as follows, and the access token never reaches a message:
|
|
49
|
+
* - HTTP 401, by status alone (the body is not GraphQL-shaped): `AuthRequiredError`.
|
|
50
|
+
* - `extensions.code` of the first coded entry of `errors[]` is `ApiError.code`;
|
|
51
|
+
* `ORGANISATION_INVALID` and `ORGANISATION_ACCESS_DENIED` select their own classes.
|
|
52
|
+
* - Any other `errors[]`, top-level (a validation error arrives with HTTP 200 and no
|
|
53
|
+
* `data`) or in a mutation payload, becomes one `ApiError` listing every message.
|
|
54
|
+
* - A non-2xx without GraphQL errors, or a body that is not JSON, becomes an `ApiError`
|
|
55
|
+
* naming the status.
|
|
56
|
+
*/
|
|
57
|
+
export async function graphql(host, document, opts = {}) {
|
|
58
|
+
const token = await getAccessToken(host);
|
|
59
|
+
const headers = {
|
|
60
|
+
authorization: `Bearer ${token}`,
|
|
61
|
+
"content-type": "application/json",
|
|
62
|
+
};
|
|
63
|
+
if (opts.orgId !== undefined)
|
|
64
|
+
headers["x-throng-organisation"] = opts.orgId;
|
|
65
|
+
const payload = { query: document };
|
|
66
|
+
if (opts.variables !== undefined)
|
|
67
|
+
payload.variables = opts.variables;
|
|
68
|
+
const signal = AbortSignal.timeout(GRAPHQL_TIMEOUT_MS);
|
|
69
|
+
let status;
|
|
70
|
+
let ok;
|
|
71
|
+
let text;
|
|
72
|
+
try {
|
|
73
|
+
const response = await fetch(`${host}/api/graphql`, {
|
|
74
|
+
method: "POST",
|
|
75
|
+
headers,
|
|
76
|
+
body: JSON.stringify(payload),
|
|
77
|
+
signal,
|
|
78
|
+
});
|
|
79
|
+
({ status, ok } = response);
|
|
80
|
+
// A stall after the headers must be reported as a timeout, never as a malformed answer.
|
|
81
|
+
text = await response.text();
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
if (signal.aborted) {
|
|
85
|
+
throw new ApiError(`Request to ${host} timed out after ${GRAPHQL_TIMEOUT_MS / 1000} seconds`);
|
|
86
|
+
}
|
|
87
|
+
throw error;
|
|
88
|
+
}
|
|
89
|
+
if (status === 401) {
|
|
90
|
+
throw new AuthRequiredError(`${host} rejected your session. Run \`${SIGN_IN_COMMAND}\` to sign in again.`);
|
|
91
|
+
}
|
|
92
|
+
// Server text lands in a terminal: drop the token should it be echoed, then sanitise.
|
|
93
|
+
const clean = (value) => printable(token === "" ? value : value.replaceAll(token, "[redacted]"));
|
|
94
|
+
const messagesOf = (entries) => entries.map((entry) => clean(typeof entry.message === "string" ? entry.message : "unknown error")).join("; ");
|
|
95
|
+
let body;
|
|
96
|
+
try {
|
|
97
|
+
body = JSON.parse(text);
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
throw new ApiError(`GraphQL request to ${host} returned HTTP ${status} and a body that is not JSON`);
|
|
101
|
+
}
|
|
102
|
+
if (!isObject(body)) {
|
|
103
|
+
throw new ApiError(`GraphQL request to ${host} returned HTTP ${status} and an unexpected body`);
|
|
104
|
+
}
|
|
105
|
+
const topLevel = errorEntries(body.errors);
|
|
106
|
+
const code = codeOf(topLevel);
|
|
107
|
+
const safeCode = code === undefined ? undefined : clean(code);
|
|
108
|
+
if (code === "ORGANISATION_INVALID") {
|
|
109
|
+
const named = opts.orgId === undefined ? "" : ` (${clean(opts.orgId)})`;
|
|
110
|
+
throw new OrgInvalidError(`Invalid organisation${named}: ${messagesOf(topLevel)}`, safeCode);
|
|
111
|
+
}
|
|
112
|
+
if (code === "ORGANISATION_ACCESS_DENIED") {
|
|
113
|
+
throw new OrgAccessDeniedError(messagesOf(topLevel), safeCode);
|
|
114
|
+
}
|
|
115
|
+
if (topLevel.length > 0) {
|
|
116
|
+
const prefix = ok ? "" : `GraphQL request failed (HTTP ${status}): `;
|
|
117
|
+
throw new ApiError(`${prefix}${messagesOf(topLevel)}`, safeCode);
|
|
118
|
+
}
|
|
119
|
+
if (!ok) {
|
|
120
|
+
throw new ApiError(`GraphQL request to ${host} failed (HTTP ${status})`);
|
|
121
|
+
}
|
|
122
|
+
const refusals = payloadErrors(body.data);
|
|
123
|
+
if (refusals.length > 0)
|
|
124
|
+
throw new ApiError(messagesOf(refusals));
|
|
125
|
+
if (body.data === undefined || body.data === null) {
|
|
126
|
+
throw new ApiError(`GraphQL response from ${host} carried neither data nor errors`);
|
|
127
|
+
}
|
|
128
|
+
return body.data;
|
|
129
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { realpathSync } from "node:fs";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
import { resolve } from "node:path";
|
|
5
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
6
|
+
import { Command, CommanderError } from "commander";
|
|
7
|
+
import { registerApi } from "./commands/api.js";
|
|
8
|
+
import { registerAuth } from "./commands/auth.js";
|
|
9
|
+
import { registerOrg } from "./commands/org.js";
|
|
10
|
+
import { registerToken } from "./commands/token.js";
|
|
11
|
+
import { resolveHost } from "./config.js";
|
|
12
|
+
import { AuthRequiredError } from "./errors.js";
|
|
13
|
+
import { describeError, info } from "./output.js";
|
|
14
|
+
const require = createRequire(import.meta.url);
|
|
15
|
+
const { version } = require("../package.json");
|
|
16
|
+
/** Exit codes: 0 success, 1 API error or refused mutation, 2 usage error, 3 authentication required. */
|
|
17
|
+
export function exitCodeFor(error) {
|
|
18
|
+
if (error instanceof AuthRequiredError)
|
|
19
|
+
return 3;
|
|
20
|
+
// Commander reports --help and --version through the same channel as its usage errors.
|
|
21
|
+
if (error instanceof CommanderError)
|
|
22
|
+
return error.exitCode === 0 ? 0 : 2;
|
|
23
|
+
// ApiError and every other failure (a raw network TypeError, a lock timeout, ...).
|
|
24
|
+
return 1;
|
|
25
|
+
}
|
|
26
|
+
export function buildProgram(deps = {}) {
|
|
27
|
+
const program = new Command();
|
|
28
|
+
program
|
|
29
|
+
.name("throng")
|
|
30
|
+
.version(version)
|
|
31
|
+
.option("--host <url>", "Throng host (default: $THRONG_HOST, then https://app.throng.dev)")
|
|
32
|
+
.option("--org <id>", "organisation id to act in, overriding the one chosen with `org use`")
|
|
33
|
+
.option("--json", "print machine-readable JSON where a command has a table form")
|
|
34
|
+
// Throw instead of exiting, so one handler owns the exit code. Set before the
|
|
35
|
+
// subcommands exist: they copy this setting when created.
|
|
36
|
+
.exitOverride();
|
|
37
|
+
// Resolved on first use, after the command line has been parsed, and only once.
|
|
38
|
+
let resolved;
|
|
39
|
+
const ctx = () => {
|
|
40
|
+
if (resolved)
|
|
41
|
+
return resolved;
|
|
42
|
+
const opts = program.opts();
|
|
43
|
+
let host;
|
|
44
|
+
try {
|
|
45
|
+
host = resolveHost(opts.host, process.env);
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
return program.error(`error: ${error.message}`, { exitCode: 2 });
|
|
49
|
+
}
|
|
50
|
+
resolved = {
|
|
51
|
+
host,
|
|
52
|
+
org: opts.org,
|
|
53
|
+
json: opts.json === true,
|
|
54
|
+
stdin: deps.stdin ?? process.stdin,
|
|
55
|
+
openBrowser: deps.openBrowser,
|
|
56
|
+
exitCode: 0,
|
|
57
|
+
};
|
|
58
|
+
return resolved;
|
|
59
|
+
};
|
|
60
|
+
registerAuth(program, ctx);
|
|
61
|
+
registerToken(program, ctx);
|
|
62
|
+
registerOrg(program, ctx);
|
|
63
|
+
registerApi(program, ctx);
|
|
64
|
+
return { program, exitCode: () => resolved?.exitCode ?? 0 };
|
|
65
|
+
}
|
|
66
|
+
/** Run the CLI and return its exit code. Never throws and never exits the process. */
|
|
67
|
+
export async function run(args, deps = {}) {
|
|
68
|
+
const { program, exitCode } = buildProgram(deps);
|
|
69
|
+
try {
|
|
70
|
+
await program.parseAsync(args, { from: "user" });
|
|
71
|
+
return exitCode();
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
// Commander has already written its own usage errors.
|
|
75
|
+
if (!(error instanceof CommanderError))
|
|
76
|
+
info(describeError(error));
|
|
77
|
+
return exitCodeFor(error);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
// Only the real entry point exits the process. Resolve symlinks: `npm` installs `throng` as one.
|
|
81
|
+
if (isEntryPoint(process.argv[1])) {
|
|
82
|
+
quietOnBrokenPipe();
|
|
83
|
+
process.exitCode = await run(process.argv.slice(2));
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* `throng api | head`, `| jq ... | head` and `| less` then `q` all close the pipe while we
|
|
87
|
+
* are still writing. Node reports that as an `error` event on stdout, and unhandled it
|
|
88
|
+
* prints a stack trace. The reader has gone, so there is nobody to tell: stop quietly,
|
|
89
|
+
* with 141 (128 + SIGPIPE), the status a shell reports for a process killed that way.
|
|
90
|
+
* Any other stdout error is a real failure and is rethrown.
|
|
91
|
+
*/
|
|
92
|
+
function quietOnBrokenPipe() {
|
|
93
|
+
process.stdout.on("error", (error) => {
|
|
94
|
+
if (error.code === "EPIPE")
|
|
95
|
+
process.exit(141);
|
|
96
|
+
throw error;
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
function isEntryPoint(invokedAs) {
|
|
100
|
+
if (!invokedAs)
|
|
101
|
+
return false;
|
|
102
|
+
try {
|
|
103
|
+
return import.meta.url === pathToFileURL(realpathSync(invokedAs)).href;
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
// `node dist/index` names a path that does not exist as written: Node appended the
|
|
107
|
+
// extension itself. Not being the entry point would make the CLI silently do nothing,
|
|
108
|
+
// so accept "this module is that path plus an extension".
|
|
109
|
+
return fileURLToPath(import.meta.url).startsWith(`${resolve(invokedAs)}.`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { createServer } from "node:http";
|
|
3
|
+
import open from "open";
|
|
4
|
+
import { generatePkce } from "./pkce.js";
|
|
5
|
+
import { postToken, printable } from "./token-request.js";
|
|
6
|
+
export const CLIENT_ID = "01993b1c-8f42-7c00-9a1e-4d5f6a7b8c90";
|
|
7
|
+
const SCOPE = "account";
|
|
8
|
+
const CALLBACK_PATH = "/auth/callback";
|
|
9
|
+
const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;
|
|
10
|
+
// The token exchange has not run when this is served, so it must not claim success.
|
|
11
|
+
const SUCCESS_PAGE = page("Authorization received", "You can close this tab and return to your terminal.");
|
|
12
|
+
const FAILURE_PAGE = page("Sign-in failed", "Authorization was not completed. Return to your terminal for details.");
|
|
13
|
+
// Fixed strings only: nothing from the callback query is ever written into a page.
|
|
14
|
+
function page(title, message) {
|
|
15
|
+
return `<!doctype html><html><head><meta charset="utf-8"><title>${title}</title></head><body><h1>${title}</h1><p>${message}</p></body></html>`;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Sign in with the OAuth 2.1 authorization code flow and PKCE: open the browser at
|
|
19
|
+
* the host's authorize endpoint, receive the redirect on a loopback server bound to
|
|
20
|
+
* an ephemeral port, then exchange the code for tokens. Every failing callback is
|
|
21
|
+
* rejected before the code is exchanged.
|
|
22
|
+
*/
|
|
23
|
+
export async function login(host, opts = {}) {
|
|
24
|
+
const openBrowser = opts.openBrowser ??
|
|
25
|
+
(async (url) => {
|
|
26
|
+
await open(url);
|
|
27
|
+
});
|
|
28
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
29
|
+
const { verifier, challenge } = generatePkce();
|
|
30
|
+
const state = randomBytes(16).toString("base64url");
|
|
31
|
+
const server = createServer();
|
|
32
|
+
let timer;
|
|
33
|
+
const callback = new Promise((resolve, reject) => {
|
|
34
|
+
timer = setTimeout(() => reject(new Error(`Login timed out after ${Math.round(timeoutMs / 1000)}s waiting for the browser`)), timeoutMs);
|
|
35
|
+
server.on("request", (req, res) => {
|
|
36
|
+
const reply = (status, text, extra = {}) => {
|
|
37
|
+
res.writeHead(status, { "content-type": "text/plain; charset=utf-8", connection: "close", ...extra });
|
|
38
|
+
res.end(text);
|
|
39
|
+
};
|
|
40
|
+
// A malformed request target makes `new URL` throw; that must not escape the handler.
|
|
41
|
+
let url;
|
|
42
|
+
try {
|
|
43
|
+
url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
reply(400, "Bad request");
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
if (url.pathname !== CALLBACK_PATH) {
|
|
50
|
+
reply(404, "Not found");
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (req.method !== "GET") {
|
|
54
|
+
reply(405, "Method not allowed", { allow: "GET" });
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
const params = url.searchParams;
|
|
58
|
+
const failure = checkCallback(params, state, host);
|
|
59
|
+
res.writeHead(failure ? 400 : 200, {
|
|
60
|
+
"content-type": "text/html; charset=utf-8",
|
|
61
|
+
"x-content-type-options": "nosniff",
|
|
62
|
+
connection: "close",
|
|
63
|
+
});
|
|
64
|
+
res.end(failure ? FAILURE_PAGE : SUCCESS_PAGE);
|
|
65
|
+
if (failure)
|
|
66
|
+
reject(failure);
|
|
67
|
+
else
|
|
68
|
+
resolve(params.get("code"));
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
// The callback can settle while we are still awaiting `openBrowser`; the real
|
|
72
|
+
// handler is attached below, so keep that window from raising an unhandled rejection.
|
|
73
|
+
callback.catch(() => { });
|
|
74
|
+
try {
|
|
75
|
+
await new Promise((resolve, reject) => {
|
|
76
|
+
server.once("error", reject);
|
|
77
|
+
server.listen(0, "127.0.0.1", resolve);
|
|
78
|
+
});
|
|
79
|
+
const { port } = server.address();
|
|
80
|
+
const redirectUri = `http://127.0.0.1:${port}${CALLBACK_PATH}`;
|
|
81
|
+
const authorizeUrl = new URL(`${host}/oauth/authorize`);
|
|
82
|
+
authorizeUrl.search = new URLSearchParams({
|
|
83
|
+
response_type: "code",
|
|
84
|
+
client_id: CLIENT_ID,
|
|
85
|
+
redirect_uri: redirectUri,
|
|
86
|
+
scope: SCOPE,
|
|
87
|
+
state,
|
|
88
|
+
code_challenge: challenge,
|
|
89
|
+
code_challenge_method: "S256",
|
|
90
|
+
}).toString();
|
|
91
|
+
await openBrowser(authorizeUrl.toString());
|
|
92
|
+
const code = await callback;
|
|
93
|
+
return await exchangeCode(host, { code, verifier, redirectUri });
|
|
94
|
+
}
|
|
95
|
+
finally {
|
|
96
|
+
clearTimeout(timer);
|
|
97
|
+
server.close();
|
|
98
|
+
server.closeAllConnections();
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/** Returns the reason the callback must be rejected, or null if it is acceptable. */
|
|
102
|
+
function checkCallback(params, state, host) {
|
|
103
|
+
const error = params.get("error");
|
|
104
|
+
if (error !== null) {
|
|
105
|
+
const reason = printable(error);
|
|
106
|
+
return new Error(error === "access_denied"
|
|
107
|
+
? "Login was declined (access_denied)"
|
|
108
|
+
: `Authorization failed: ${reason}`);
|
|
109
|
+
}
|
|
110
|
+
if (params.get("state") !== state) {
|
|
111
|
+
return new Error("Callback state did not match the request; aborting (possible CSRF)");
|
|
112
|
+
}
|
|
113
|
+
// RFC 9207: an absent iss is tolerated, a present-and-wrong one is not. The server's
|
|
114
|
+
// issuer is a bare origin, but the host may carry a subpath, so accept either form.
|
|
115
|
+
const iss = params.get("iss");
|
|
116
|
+
if (iss !== null && !acceptedIssuers(host).includes(iss.replace(/\/+$/, ""))) {
|
|
117
|
+
return new Error(`Callback issuer ${JSON.stringify(printable(iss))} did not match ${host}; aborting`);
|
|
118
|
+
}
|
|
119
|
+
if (!params.get("code")) {
|
|
120
|
+
return new Error("Callback did not include an authorization code");
|
|
121
|
+
}
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
function acceptedIssuers(host) {
|
|
125
|
+
return [host, new URL(host).origin];
|
|
126
|
+
}
|
|
127
|
+
function exchangeCode(host, { code, verifier, redirectUri }) {
|
|
128
|
+
return postToken(host, {
|
|
129
|
+
grant_type: "authorization_code",
|
|
130
|
+
code,
|
|
131
|
+
code_verifier: verifier,
|
|
132
|
+
client_id: CLIENT_ID,
|
|
133
|
+
redirect_uri: redirectUri,
|
|
134
|
+
}, [code, verifier], "Token exchange");
|
|
135
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
/**
|
|
3
|
+
* Generate a PKCE (Proof Key for Code Exchange) pair for OAuth 2.1 authorization
|
|
4
|
+
* code flow with S256 challenge method.
|
|
5
|
+
*
|
|
6
|
+
* The verifier is a base64url-encoded random 32 bytes, which produces a 43-character
|
|
7
|
+
* string that falls within RFC 7636's 43–128 character range and unreserved-character
|
|
8
|
+
* set. The challenge is the base64url-encoded SHA-256 hash of the verifier.
|
|
9
|
+
*/
|
|
10
|
+
export function generatePkce() {
|
|
11
|
+
const verifier = randomBytes(32).toString("base64url");
|
|
12
|
+
const challenge = createHash("sha256").update(verifier).digest("base64url");
|
|
13
|
+
return { verifier, challenge };
|
|
14
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { AuthRequiredError, SIGN_IN_COMMAND } from "../errors.js";
|
|
2
|
+
import { CLIENT_ID } from "./login.js";
|
|
3
|
+
import { TokenEndpointError, postToken } from "./token-request.js";
|
|
4
|
+
/**
|
|
5
|
+
* Exchange a refresh token for a new token pair.
|
|
6
|
+
*
|
|
7
|
+
* Refresh tokens rotate: the one passed in is spent by this call, and the
|
|
8
|
+
* caller must persist the returned pair before doing anything else. A 400 or
|
|
9
|
+
* 401 means the server rejected the token, which is never retryable, so it is
|
|
10
|
+
* an `AuthRequiredError`. Any other failure (5xx, network, timeout) is a plain
|
|
11
|
+
* `Error` because signing in again would not help. No error message carries
|
|
12
|
+
* either token.
|
|
13
|
+
*/
|
|
14
|
+
export async function refresh(host, refreshToken) {
|
|
15
|
+
try {
|
|
16
|
+
return await postToken(host, { grant_type: "refresh_token", refresh_token: refreshToken, client_id: CLIENT_ID }, [refreshToken], "Token refresh");
|
|
17
|
+
}
|
|
18
|
+
catch (error) {
|
|
19
|
+
if (error instanceof TokenEndpointError && (error.status === 400 || error.status === 401)) {
|
|
20
|
+
throw new AuthRequiredError(`The server rejected your session for ${host}. Run \`${SIGN_IN_COMMAND}\` to sign in again.`);
|
|
21
|
+
}
|
|
22
|
+
throw error;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { CLIENT_ID } from "./login.js";
|
|
2
|
+
import { TOKEN_REQUEST_TIMEOUT_MS } from "./token-request.js";
|
|
3
|
+
/**
|
|
4
|
+
* Ask the server to revoke a refresh token (RFC 7009) by POSTing it to
|
|
5
|
+
* `${host}/oauth/revoke`, which ends the whole grant.
|
|
6
|
+
*
|
|
7
|
+
* RFC 7009 answers 200 whether or not the token was valid, so the body carries
|
|
8
|
+
* nothing and is not read for meaning. Anything but a 200, a network failure or
|
|
9
|
+
* the timeout rejects with a message that names no token and no server text.
|
|
10
|
+
* Callers treat that as non-fatal: logging out must work offline.
|
|
11
|
+
*/
|
|
12
|
+
export async function revoke(host, refreshToken) {
|
|
13
|
+
const signal = AbortSignal.timeout(TOKEN_REQUEST_TIMEOUT_MS);
|
|
14
|
+
let status;
|
|
15
|
+
try {
|
|
16
|
+
const response = await fetch(`${host}/oauth/revoke`, {
|
|
17
|
+
method: "POST",
|
|
18
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
19
|
+
body: new URLSearchParams({ token: refreshToken, client_id: CLIENT_ID }),
|
|
20
|
+
signal,
|
|
21
|
+
});
|
|
22
|
+
({ status } = response);
|
|
23
|
+
// Drain the body inside the timeout so the connection is released; its content is irrelevant.
|
|
24
|
+
await response.text();
|
|
25
|
+
}
|
|
26
|
+
catch (error) {
|
|
27
|
+
if (signal.aborted)
|
|
28
|
+
throw new Error("timed out waiting for the server");
|
|
29
|
+
throw error;
|
|
30
|
+
}
|
|
31
|
+
if (status !== 200)
|
|
32
|
+
throw new Error(`the server answered HTTP ${status}`);
|
|
33
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { deleteCredentials, readCredentials, withLock, writeCredentials, } from "../credentials.js";
|
|
2
|
+
import { AuthRequiredError, SIGN_IN_COMMAND } from "../errors.js";
|
|
3
|
+
import { describeError } from "../output.js";
|
|
4
|
+
import { refresh } from "./refresh.js";
|
|
5
|
+
import { revoke } from "./revoke.js";
|
|
6
|
+
export const REFRESH_THRESHOLD_SECONDS = 60;
|
|
7
|
+
function notSignedIn(host) {
|
|
8
|
+
return new AuthRequiredError(`Not signed in to ${host}. Run \`${SIGN_IN_COMMAND}\` first.`);
|
|
9
|
+
}
|
|
10
|
+
function isFresh(creds) {
|
|
11
|
+
return creds.expires_at - Math.floor(Date.now() / 1000) > REFRESH_THRESHOLD_SECONDS;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* A usable access token for the host, refreshing it first if fewer than
|
|
15
|
+
* `REFRESH_THRESHOLD_SECONDS` remain.
|
|
16
|
+
*
|
|
17
|
+
* Refresh tokens rotate on every use and the server treats a replayed one as
|
|
18
|
+
* theft, revoking the whole chain. So the refresh runs under the credentials
|
|
19
|
+
* lock, re-reads inside it (another process may have just renewed them), and
|
|
20
|
+
* persists the rotated pair before the new token is returned. If another
|
|
21
|
+
* process holds the lock for the whole wait, `LockTimeoutError` propagates; its
|
|
22
|
+
* message already says another command is probably refreshing.
|
|
23
|
+
*/
|
|
24
|
+
export async function getAccessToken(host) {
|
|
25
|
+
const stored = await readCredentials(host);
|
|
26
|
+
if (!stored)
|
|
27
|
+
throw notSignedIn(host);
|
|
28
|
+
if (isFresh(stored))
|
|
29
|
+
return stored.access_token;
|
|
30
|
+
return withLock(async () => {
|
|
31
|
+
// Re-read under the lock: whoever held it before us may already have rotated the token.
|
|
32
|
+
const current = await readCredentials(host);
|
|
33
|
+
if (!current)
|
|
34
|
+
throw notSignedIn(host);
|
|
35
|
+
if (isFresh(current))
|
|
36
|
+
return current.access_token;
|
|
37
|
+
// Dead credentials are deliberately kept when this throws AuthRequiredError;
|
|
38
|
+
// signing in again overwrites them.
|
|
39
|
+
const pair = await refresh(host, current.refresh_token);
|
|
40
|
+
try {
|
|
41
|
+
await writeCredentials(host, { ...current, ...pair });
|
|
42
|
+
}
|
|
43
|
+
catch (cause) {
|
|
44
|
+
// The server has already rotated the token, so the one still on disk is dead.
|
|
45
|
+
// Say so, naming the errno only: nothing from the tokens may reach the message.
|
|
46
|
+
const code = cause.code ?? "unknown error";
|
|
47
|
+
throw new Error(`Your session was refreshed but could not be saved (${code}), so it is no longer valid. Run \`${SIGN_IN_COMMAND}\` to sign in again.`, { cause });
|
|
48
|
+
}
|
|
49
|
+
return pair.access_token;
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
export async function getOrgId(host) {
|
|
53
|
+
return (await readCredentials(host))?.org_id;
|
|
54
|
+
}
|
|
55
|
+
/** When the stored access token expires, in epoch seconds. Reads only: it never refreshes. */
|
|
56
|
+
export async function getExpiresAt(host) {
|
|
57
|
+
return (await readCredentials(host))?.expires_at;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Read-modify-write of the whole record, so it takes the lock: an unlocked
|
|
61
|
+
* write racing a refresh could put back a stale refresh token over a rotated one.
|
|
62
|
+
*/
|
|
63
|
+
export async function setOrgId(host, orgId) {
|
|
64
|
+
await withLock(async () => {
|
|
65
|
+
const current = await readCredentials(host);
|
|
66
|
+
if (!current)
|
|
67
|
+
throw notSignedIn(host);
|
|
68
|
+
await writeCredentials(host, { ...current, org_id: orgId });
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
export async function clearOrgId(host) {
|
|
72
|
+
await withLock(async () => {
|
|
73
|
+
const current = await readCredentials(host);
|
|
74
|
+
if (!current)
|
|
75
|
+
return;
|
|
76
|
+
const { org_id: _dropped, ...rest } = current;
|
|
77
|
+
await writeCredentials(host, rest);
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Persist the tokens a sign-in just produced, replacing the whole record.
|
|
82
|
+
*
|
|
83
|
+
* Any previously stored `org_id` is dropped on purpose: a fresh sign-in may be a
|
|
84
|
+
* different user, and carrying over an organisation they are not a member of
|
|
85
|
+
* would make their very first command fail with `ORGANISATION_ACCESS_DENIED`, a
|
|
86
|
+
* confusing failure manufactured by our own bookkeeping. Signing in again as the
|
|
87
|
+
* same person costs one `throng org use`.
|
|
88
|
+
*/
|
|
89
|
+
export async function saveLogin(host, pair) {
|
|
90
|
+
await withLock(() => writeCredentials(host, { ...pair }));
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Revoke the stored grant on the server, then forget it locally. A no-op when
|
|
94
|
+
* there are no stored credentials.
|
|
95
|
+
*
|
|
96
|
+
* Revocation is best-effort: forgetting the file is not enough, since a refresh
|
|
97
|
+
* token already copied elsewhere would stay valid for its full lifetime, but a
|
|
98
|
+
* user who cannot reach the server still needs to be signed out here. A failure
|
|
99
|
+
* is reported in the result, never thrown. It runs under the lock so the token
|
|
100
|
+
* revoked is the current one, not one a concurrent refresh has just rotated.
|
|
101
|
+
*/
|
|
102
|
+
export async function logout(host) {
|
|
103
|
+
return withLock(async () => {
|
|
104
|
+
const stored = await readCredentials(host);
|
|
105
|
+
let revokeFailure;
|
|
106
|
+
if (stored) {
|
|
107
|
+
try {
|
|
108
|
+
await revoke(host, stored.refresh_token);
|
|
109
|
+
}
|
|
110
|
+
catch (error) {
|
|
111
|
+
revokeFailure = describeError(error);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
await deleteCredentials(host);
|
|
115
|
+
return revokeFailure === undefined ? {} : { revokeFailure };
|
|
116
|
+
});
|
|
117
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
export const TOKEN_REQUEST_TIMEOUT_MS = 15 * 1000;
|
|
2
|
+
/** The token endpoint answered with a non-2xx status. The message never carries the secrets sent. */
|
|
3
|
+
export class TokenEndpointError extends Error {
|
|
4
|
+
status;
|
|
5
|
+
name = "TokenEndpointError";
|
|
6
|
+
constructor(message, status) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.status = status;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
// Server and callback text ends up in terminal output, so strip control
|
|
12
|
+
// characters (escape sequences) and bound the length.
|
|
13
|
+
export function printable(value) {
|
|
14
|
+
return value.replace(/[\u0000-\u001f\u007f-\u009f]/g, "?").slice(0, 200);
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Every spelling in which a server might echo `secret` back: as-is,
|
|
18
|
+
* percent-encoded, and as `URLSearchParams` form-encodes it (which differs
|
|
19
|
+
* from `encodeURIComponent` for `~!'()*` and space).
|
|
20
|
+
*/
|
|
21
|
+
function spellings(secret) {
|
|
22
|
+
return [
|
|
23
|
+
secret,
|
|
24
|
+
encodeURIComponent(secret),
|
|
25
|
+
new URLSearchParams({ x: secret }).toString().slice(2),
|
|
26
|
+
];
|
|
27
|
+
}
|
|
28
|
+
function redact(text, secrets) {
|
|
29
|
+
// Longest first, so a secret that contains another is not left half-redacted.
|
|
30
|
+
const variants = secrets
|
|
31
|
+
.filter((secret) => secret !== "")
|
|
32
|
+
.flatMap(spellings)
|
|
33
|
+
.sort((a, b) => b.length - a.length);
|
|
34
|
+
for (const variant of variants)
|
|
35
|
+
text = text.replaceAll(variant, "[redacted]");
|
|
36
|
+
return text;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* POST a form to `${host}/oauth/token` and return the token pair it issues.
|
|
40
|
+
*
|
|
41
|
+
* The whole exchange, including reading the body, is bounded by one timeout.
|
|
42
|
+
* Non-2xx answers throw `TokenEndpointError` with the body redacted of every
|
|
43
|
+
* entry in `secrets` and sanitised. `label` names the operation in messages.
|
|
44
|
+
*/
|
|
45
|
+
export async function postToken(host, params, secrets, label) {
|
|
46
|
+
const signal = AbortSignal.timeout(TOKEN_REQUEST_TIMEOUT_MS);
|
|
47
|
+
let status;
|
|
48
|
+
let ok;
|
|
49
|
+
let text;
|
|
50
|
+
try {
|
|
51
|
+
const response = await fetch(`${host}/oauth/token`, {
|
|
52
|
+
method: "POST",
|
|
53
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
54
|
+
body: new URLSearchParams(params),
|
|
55
|
+
signal,
|
|
56
|
+
});
|
|
57
|
+
({ status, ok } = response);
|
|
58
|
+
// A stall after the headers must be reported as a timeout, never as a malformed answer.
|
|
59
|
+
text = await response.text();
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
if (signal.aborted)
|
|
63
|
+
throw new Error(`${label} timed out waiting for the server`);
|
|
64
|
+
throw error;
|
|
65
|
+
}
|
|
66
|
+
if (!ok) {
|
|
67
|
+
throw new TokenEndpointError(`${label} failed (HTTP ${status}): ${printable(redact(text, secrets))}`, status);
|
|
68
|
+
}
|
|
69
|
+
let body;
|
|
70
|
+
try {
|
|
71
|
+
body = JSON.parse(text);
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
body = null;
|
|
75
|
+
}
|
|
76
|
+
const { access_token, refresh_token, expires_in } = body ?? {};
|
|
77
|
+
if (typeof access_token !== "string" ||
|
|
78
|
+
typeof refresh_token !== "string" ||
|
|
79
|
+
typeof expires_in !== "number") {
|
|
80
|
+
throw new Error(`${label} returned a malformed response (missing tokens or expiry)`);
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
refresh_token,
|
|
84
|
+
access_token,
|
|
85
|
+
expires_at: Math.floor(Date.now() / 1000) + expires_in,
|
|
86
|
+
};
|
|
87
|
+
}
|