@greatwait/cli 0.1.0-alpha.3
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/LICENSE +21 -0
- package/README.md +82 -0
- package/dist/args.d.ts +46 -0
- package/dist/args.js +118 -0
- package/dist/cli.d.ts +14 -0
- package/dist/cli.js +191 -0
- package/dist/commands.d.ts +14 -0
- package/dist/commands.js +836 -0
- package/dist/config.d.ts +74 -0
- package/dist/config.js +242 -0
- package/dist/exit.d.ts +58 -0
- package/dist/exit.js +73 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +9 -0
- package/dist/journey.d.ts +49 -0
- package/dist/journey.js +76 -0
- package/dist/main.d.ts +2 -0
- package/dist/main.js +24 -0
- package/dist/node-runtime.d.ts +35 -0
- package/dist/node-runtime.js +210 -0
- package/dist/oauth.d.ts +96 -0
- package/dist/oauth.js +205 -0
- package/dist/output.d.ts +66 -0
- package/dist/output.js +76 -0
- package/dist/runtime.d.ts +63 -0
- package/dist/runtime.js +32 -0
- package/dist/session.d.ts +66 -0
- package/dist/session.js +157 -0
- package/package.json +51 -0
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { createServer } from "node:http";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { createInterface } from "node:readline";
|
|
5
|
+
import { InterruptedError, } from "./runtime.js";
|
|
6
|
+
/**
|
|
7
|
+
* The one file that knows there is a process.
|
|
8
|
+
*
|
|
9
|
+
* Everything above this takes its surroundings as a parameter, so the shape of
|
|
10
|
+
* this file is the list of things a test has to be able to fake: a browser, a
|
|
11
|
+
* listener, a terminal, a clock, a signal.
|
|
12
|
+
*/
|
|
13
|
+
/**
|
|
14
|
+
* A listener for exactly one authorization redirect.
|
|
15
|
+
*
|
|
16
|
+
* **Only the redirect satisfies it.** A browser sent to a loopback address will
|
|
17
|
+
* happily make other requests to it first — a speculative preconnect, a
|
|
18
|
+
* `/favicon.ico` once the page renders — and a listener that resolved on the
|
|
19
|
+
* first request would resolve with one of those, carrying no `state`, killing
|
|
20
|
+
* the sign-in with a message about a state mismatch while the real redirect
|
|
21
|
+
* lands a moment later on a closed socket. Anything that is not the redirect is
|
|
22
|
+
* answered and the wait continues.
|
|
23
|
+
*
|
|
24
|
+
* Port zero: the operating system picks, and the port is known before the
|
|
25
|
+
* client is registered for it, which RFC 8252's exact-match rule requires.
|
|
26
|
+
*/
|
|
27
|
+
export async function openLoopback() {
|
|
28
|
+
let deliver;
|
|
29
|
+
let fail;
|
|
30
|
+
const received = new Promise((resolve, reject) => {
|
|
31
|
+
deliver = resolve;
|
|
32
|
+
fail = reject;
|
|
33
|
+
});
|
|
34
|
+
// **Nobody is necessarily waiting on this when `close` runs.** A sign-in
|
|
35
|
+
// abandoned before the wait begins — dynamic registration refused, the
|
|
36
|
+
// authorization address unbuildable — reaches the `finally` that closes the
|
|
37
|
+
// listener while `received` has never been awaited, and an unhandled
|
|
38
|
+
// rejection ends the process in Node with a stack trace instead of the exit
|
|
39
|
+
// class the refusal earned. A handler attached here is never the *only* one:
|
|
40
|
+
// a caller that does await it still sees the rejection.
|
|
41
|
+
received.catch(() => undefined);
|
|
42
|
+
const server = createServer((request, response) => {
|
|
43
|
+
const url = new URL(request.url ?? "/", "http://127.0.0.1");
|
|
44
|
+
const isRedirect = url.pathname === "/callback" &&
|
|
45
|
+
(url.searchParams.has("code") || url.searchParams.has("error"));
|
|
46
|
+
if (!isRedirect) {
|
|
47
|
+
response.writeHead(204);
|
|
48
|
+
response.end();
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
response.writeHead(200, { "content-type": "text/plain; charset=utf-8" });
|
|
52
|
+
response.end("Greatwait received the answer. You can close this tab.\n");
|
|
53
|
+
deliver?.(url.searchParams);
|
|
54
|
+
});
|
|
55
|
+
await new Promise((resolve, reject) => {
|
|
56
|
+
server.once("error", reject);
|
|
57
|
+
// `127.0.0.1` rather than every interface: this socket receives an
|
|
58
|
+
// authorization code, and nothing outside this machine has any business
|
|
59
|
+
// reaching it.
|
|
60
|
+
server.listen(0, "127.0.0.1", () => {
|
|
61
|
+
resolve();
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
const address = server.address();
|
|
65
|
+
if (address === null || typeof address === "string") {
|
|
66
|
+
server.close();
|
|
67
|
+
throw new Error("the loopback listener did not bind a port");
|
|
68
|
+
}
|
|
69
|
+
return {
|
|
70
|
+
close: () => {
|
|
71
|
+
fail?.(new Error("the sign-in was abandoned"));
|
|
72
|
+
server.close();
|
|
73
|
+
},
|
|
74
|
+
received,
|
|
75
|
+
redirectUri: `http://127.0.0.1:${String(address.port)}/callback`,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
/** The platform's "open this" command, or a rejection where there is none. */
|
|
79
|
+
async function openBrowser(url) {
|
|
80
|
+
const command = process.platform === "darwin"
|
|
81
|
+
? { args: [url], name: "open" }
|
|
82
|
+
: process.platform === "win32"
|
|
83
|
+
? { args: ["/c", "start", "", url], name: "cmd" }
|
|
84
|
+
: { args: [url], name: "xdg-open" };
|
|
85
|
+
await new Promise((resolve, reject) => {
|
|
86
|
+
const child = spawn(command.name, command.args, {
|
|
87
|
+
// The browser outlives this process and must not hold its streams.
|
|
88
|
+
detached: true,
|
|
89
|
+
stdio: "ignore",
|
|
90
|
+
});
|
|
91
|
+
child.once("error", reject);
|
|
92
|
+
child.once("spawn", () => {
|
|
93
|
+
child.unref();
|
|
94
|
+
resolve();
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* One line, not echoed.
|
|
100
|
+
*
|
|
101
|
+
* `readline` with the terminal's own echo turned off rather than a library:
|
|
102
|
+
* what has to be true is that the characters never reach the screen or the
|
|
103
|
+
* scrollback, and that is a property of the terminal rather than of the prompt.
|
|
104
|
+
*/
|
|
105
|
+
export async function promptSecret(question, streams = { input: process.stdin, output: process.stderr }) {
|
|
106
|
+
const input = streams.input;
|
|
107
|
+
const wasRaw = input.isRaw === true;
|
|
108
|
+
const readline = createInterface({ input, output: streams.output });
|
|
109
|
+
streams.output.write(question);
|
|
110
|
+
if (input.isTTY)
|
|
111
|
+
input.setRawMode(true);
|
|
112
|
+
try {
|
|
113
|
+
return await new Promise((resolve, reject) => {
|
|
114
|
+
let typed = "";
|
|
115
|
+
const onData = (chunk) => {
|
|
116
|
+
for (const byte of chunk) {
|
|
117
|
+
if (byte === 0x0d || byte === 0x0a) {
|
|
118
|
+
input.off("data", onData);
|
|
119
|
+
streams.output.write("\n");
|
|
120
|
+
resolve(typed);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (byte === 0x7f || byte === 0x08) {
|
|
124
|
+
typed = typed.slice(0, -1);
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
if (byte === 0x03) {
|
|
128
|
+
input.off("data", onData);
|
|
129
|
+
streams.output.write("\n");
|
|
130
|
+
// **Ctrl-C at the prompt is an interruption, not an empty
|
|
131
|
+
// answer.** Resolving empty made it a `usage` refusal about a
|
|
132
|
+
// credential nobody entered, which is the wrong sentence and the
|
|
133
|
+
// wrong exit number: a shell reports `130` for a process the
|
|
134
|
+
// person stopped, and every other wait in this command already
|
|
135
|
+
// does.
|
|
136
|
+
reject(new InterruptedError());
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
typed += String.fromCharCode(byte);
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
input.on("data", onData);
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
finally {
|
|
146
|
+
if (input.isTTY)
|
|
147
|
+
input.setRawMode(wasRaw);
|
|
148
|
+
readline.close();
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
async function readStdin() {
|
|
152
|
+
if (process.stdin.isTTY)
|
|
153
|
+
return "";
|
|
154
|
+
const chunks = [];
|
|
155
|
+
for await (const chunk of process.stdin) {
|
|
156
|
+
chunks.push(chunk);
|
|
157
|
+
}
|
|
158
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* A sleep that ends when the signal fires.
|
|
162
|
+
*
|
|
163
|
+
* The reason Ctrl-C is a criterion of its own: the published poll interval is
|
|
164
|
+
* five seconds, so a wait that only noticed its signal between sleeps would
|
|
165
|
+
* hold the terminal for up to five seconds after somebody asked it to stop.
|
|
166
|
+
*/
|
|
167
|
+
function sleep(milliseconds, signal) {
|
|
168
|
+
return new Promise((resolve) => {
|
|
169
|
+
const timer = setTimeout(() => {
|
|
170
|
+
signal.removeEventListener("abort", onAbort);
|
|
171
|
+
resolve();
|
|
172
|
+
}, milliseconds);
|
|
173
|
+
function onAbort() {
|
|
174
|
+
clearTimeout(timer);
|
|
175
|
+
resolve();
|
|
176
|
+
}
|
|
177
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
export function nodeRuntime(signal) {
|
|
181
|
+
return {
|
|
182
|
+
argv: process.argv.slice(2),
|
|
183
|
+
env: process.env,
|
|
184
|
+
fetch: ((url, init) => fetch(url, init)),
|
|
185
|
+
home: homedir(),
|
|
186
|
+
interactive: process.stdin.isTTY === true,
|
|
187
|
+
listen: openLoopback,
|
|
188
|
+
now: () => Date.now(),
|
|
189
|
+
openBrowser,
|
|
190
|
+
platform: process.platform,
|
|
191
|
+
promptSecret: (question) => promptSecret(question),
|
|
192
|
+
randomBytes: (length) => crypto.getRandomValues(new Uint8Array(length)),
|
|
193
|
+
randomUuid: () => crypto.randomUUID(),
|
|
194
|
+
readStdin,
|
|
195
|
+
signal,
|
|
196
|
+
sleep,
|
|
197
|
+
stderr: (text) => {
|
|
198
|
+
process.stderr.write(text);
|
|
199
|
+
},
|
|
200
|
+
stdout: (text) => {
|
|
201
|
+
process.stdout.write(text);
|
|
202
|
+
},
|
|
203
|
+
// **`undefined` on Windows**, where a file mode carries no access
|
|
204
|
+
// information at all: `stat` reports `0o666` for everything, so an
|
|
205
|
+
// owner-only check there would be asserting a constant.
|
|
206
|
+
uid: process.platform === "win32"
|
|
207
|
+
? undefined
|
|
208
|
+
: (process.getuid?.() ?? undefined),
|
|
209
|
+
};
|
|
210
|
+
}
|
package/dist/oauth.d.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { type CliRuntime } from "./runtime.js";
|
|
2
|
+
/**
|
|
3
|
+
* The delegated-authorization half of `login`, spoken as a public client.
|
|
4
|
+
*
|
|
5
|
+
* **Everything here is discovered, not configured.** The origin is the only
|
|
6
|
+
* thing this command is told: it reads RFC 9728 protected-resource metadata to
|
|
7
|
+
* learn which authorization server protects that origin, then RFC 8414 metadata
|
|
8
|
+
* to learn where that server's endpoints are, and uses exactly the URLs it
|
|
9
|
+
* found. A client that constructed `/oauth/token` from the origin would work
|
|
10
|
+
* against this service and against nothing else, and would keep working after
|
|
11
|
+
* the service moved the endpoint — right up until it did not.
|
|
12
|
+
*
|
|
13
|
+
* **No client secret, ever.** A command installed on somebody's laptop cannot
|
|
14
|
+
* hold one: whatever it shipped with, every copy has. That is what makes S256
|
|
15
|
+
* PKCE mandatory rather than advisable here — the code is bound to a verifier
|
|
16
|
+
* this process generated and never sent, so a code intercepted on its way back
|
|
17
|
+
* is worth nothing.
|
|
18
|
+
*
|
|
19
|
+
* **The loopback listener binds before registration.** RFC 8252 has the
|
|
20
|
+
* authorization server match a redirect URI exactly, port included, so the port
|
|
21
|
+
* has to be known before the client is registered. Registering first and
|
|
22
|
+
* binding afterwards is how a client ends up registered for a port something
|
|
23
|
+
* else has taken.
|
|
24
|
+
*/
|
|
25
|
+
export interface AuthorizationServer {
|
|
26
|
+
readonly authorizationEndpoint: string;
|
|
27
|
+
readonly registrationEndpoint: string | undefined;
|
|
28
|
+
readonly resource: string;
|
|
29
|
+
readonly revocationEndpoint: string | undefined;
|
|
30
|
+
readonly scopesSupported: readonly string[];
|
|
31
|
+
readonly tokenEndpoint: string;
|
|
32
|
+
}
|
|
33
|
+
export interface OAuthTokens {
|
|
34
|
+
readonly accessToken: string;
|
|
35
|
+
readonly expiresInSeconds: number;
|
|
36
|
+
readonly refreshToken: string;
|
|
37
|
+
readonly scope: string;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Discovery, in the order a client that had never heard of this service does it.
|
|
41
|
+
*
|
|
42
|
+
* The protected-resource document is asked first because it is the one a `401`
|
|
43
|
+
* points at, and it is what says *which* authorization server to trust for this
|
|
44
|
+
* origin. Going straight to the authorization-server document would be assuming
|
|
45
|
+
* the answer.
|
|
46
|
+
*/
|
|
47
|
+
export declare function discover(runtime: CliRuntime, origin: string): Promise<AuthorizationServer>;
|
|
48
|
+
export declare function registerClient(runtime: CliRuntime, server: AuthorizationServer, options: {
|
|
49
|
+
readonly redirectUri: string;
|
|
50
|
+
readonly scopes: readonly string[];
|
|
51
|
+
}): Promise<string>;
|
|
52
|
+
export interface AuthorizationRequest {
|
|
53
|
+
readonly authorizationUrl: string;
|
|
54
|
+
readonly state: string;
|
|
55
|
+
readonly verifier: string;
|
|
56
|
+
}
|
|
57
|
+
export declare function buildAuthorization(runtime: CliRuntime, server: AuthorizationServer, options: {
|
|
58
|
+
readonly clientId: string;
|
|
59
|
+
readonly redirectUri: string;
|
|
60
|
+
readonly scopes: readonly string[];
|
|
61
|
+
}): Promise<AuthorizationRequest>;
|
|
62
|
+
/**
|
|
63
|
+
* Turns the redirect into a code, refusing anything that is not this run's.
|
|
64
|
+
*
|
|
65
|
+
* `state` is compared before `code` is read. A redirect carrying somebody
|
|
66
|
+
* else's state is not a code to exchange — it is a request this process did not
|
|
67
|
+
* make — and reading the code first would exchange it anyway.
|
|
68
|
+
*/
|
|
69
|
+
export declare function codeFrom(returned: URLSearchParams, state: string): string;
|
|
70
|
+
export declare function exchangeCode(runtime: CliRuntime, server: AuthorizationServer, options: {
|
|
71
|
+
readonly clientId: string;
|
|
72
|
+
readonly code: string;
|
|
73
|
+
readonly redirectUri: string;
|
|
74
|
+
readonly verifier: string;
|
|
75
|
+
}): Promise<OAuthTokens>;
|
|
76
|
+
export declare function refresh(runtime: CliRuntime, server: AuthorizationServer, options: {
|
|
77
|
+
readonly clientId: string;
|
|
78
|
+
readonly refreshToken: string;
|
|
79
|
+
}): Promise<OAuthTokens>;
|
|
80
|
+
/**
|
|
81
|
+
* Revokes the refresh token, and says whether the server took it.
|
|
82
|
+
*
|
|
83
|
+
* **The refresh token rather than the access token**, because RFC 7009 lets a
|
|
84
|
+
* server end the whole grant when it is given one, and ending the grant is what
|
|
85
|
+
* `logout` means. Revoking only the access token would leave a refresh token
|
|
86
|
+
* this command has just deleted and the service still honours.
|
|
87
|
+
*
|
|
88
|
+
* A failure here is reported, not thrown: local material is removed either way.
|
|
89
|
+
* A `logout` that refused to forget a credential because the network was down
|
|
90
|
+
* would leave the credential on the disk, which is the opposite of what was
|
|
91
|
+
* asked for.
|
|
92
|
+
*/
|
|
93
|
+
export declare function revoke(runtime: CliRuntime, server: AuthorizationServer, options: {
|
|
94
|
+
readonly clientId: string;
|
|
95
|
+
readonly token: string;
|
|
96
|
+
}): Promise<boolean>;
|
package/dist/oauth.js
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { CliError } from "./runtime.js";
|
|
2
|
+
function base64Url(bytes) {
|
|
3
|
+
return Buffer.from(bytes).toString("base64url");
|
|
4
|
+
}
|
|
5
|
+
async function readJson(runtime, url, init) {
|
|
6
|
+
const response = await runtime.fetch(url, {
|
|
7
|
+
...init,
|
|
8
|
+
signal: runtime.signal,
|
|
9
|
+
});
|
|
10
|
+
const text = await response.text();
|
|
11
|
+
let body;
|
|
12
|
+
try {
|
|
13
|
+
body = JSON.parse(text);
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
body = undefined;
|
|
17
|
+
}
|
|
18
|
+
return { body, status: response.status };
|
|
19
|
+
}
|
|
20
|
+
function requireString(value, field, source) {
|
|
21
|
+
if (typeof value !== "string" || value === "") {
|
|
22
|
+
throw new CliError(`${source} does not name a ${field}`, "internal", "This origin publishes authorization metadata this command cannot use. Sign in with an API key instead: greatwait login --api-key");
|
|
23
|
+
}
|
|
24
|
+
return value;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Discovery, in the order a client that had never heard of this service does it.
|
|
28
|
+
*
|
|
29
|
+
* The protected-resource document is asked first because it is the one a `401`
|
|
30
|
+
* points at, and it is what says *which* authorization server to trust for this
|
|
31
|
+
* origin. Going straight to the authorization-server document would be assuming
|
|
32
|
+
* the answer.
|
|
33
|
+
*/
|
|
34
|
+
export async function discover(runtime, origin) {
|
|
35
|
+
const resourceUrl = `${origin}/.well-known/oauth-protected-resource`;
|
|
36
|
+
const resource = await readJson(runtime, resourceUrl);
|
|
37
|
+
if (resource.status !== 200) {
|
|
38
|
+
throw new CliError(`${origin} does not publish protected-resource metadata (${String(resource.status)})`, "validation", "Check the origin. A Greatwait API answers GET /__greatwait/version with no credential; try greatwait doctor --api-url <origin>.");
|
|
39
|
+
}
|
|
40
|
+
const resourceBody = resource.body;
|
|
41
|
+
const servers = Array.isArray(resourceBody.authorization_servers)
|
|
42
|
+
? resourceBody.authorization_servers
|
|
43
|
+
: [];
|
|
44
|
+
const issuer = requireString(servers[0], "authorization server", resourceUrl);
|
|
45
|
+
const metadataUrl = `${issuer}/.well-known/oauth-authorization-server`;
|
|
46
|
+
const metadata = await readJson(runtime, metadataUrl);
|
|
47
|
+
if (metadata.status !== 200) {
|
|
48
|
+
throw new CliError(`${issuer} does not publish authorization-server metadata (${String(metadata.status)})`, "validation");
|
|
49
|
+
}
|
|
50
|
+
const body = metadata.body;
|
|
51
|
+
const scopes = body["scopes_supported"];
|
|
52
|
+
return {
|
|
53
|
+
authorizationEndpoint: requireString(body["authorization_endpoint"], "authorization endpoint", metadataUrl),
|
|
54
|
+
registrationEndpoint: typeof body["registration_endpoint"] === "string"
|
|
55
|
+
? body["registration_endpoint"]
|
|
56
|
+
: undefined,
|
|
57
|
+
// The audience this token is for, taken from the document rather than from
|
|
58
|
+
// the origin the command was given. RFC 8707's whole point is that a token
|
|
59
|
+
// minted for one audience must not be accepted by another, and a client
|
|
60
|
+
// that names an audience it invented is asserting something it did not
|
|
61
|
+
// check.
|
|
62
|
+
resource: requireString(resourceBody.resource, "resource", resourceUrl),
|
|
63
|
+
revocationEndpoint: typeof body["revocation_endpoint"] === "string"
|
|
64
|
+
? body["revocation_endpoint"]
|
|
65
|
+
: undefined,
|
|
66
|
+
scopesSupported: Array.isArray(scopes)
|
|
67
|
+
? scopes.filter((scope) => typeof scope === "string")
|
|
68
|
+
: [],
|
|
69
|
+
tokenEndpoint: requireString(body["token_endpoint"], "token endpoint", metadataUrl),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
export async function registerClient(runtime, server, options) {
|
|
73
|
+
if (server.registrationEndpoint === undefined) {
|
|
74
|
+
throw new CliError("this authorization server does not offer dynamic client registration", "validation", "Sign in with an API key instead: greatwait login --api-key");
|
|
75
|
+
}
|
|
76
|
+
const registered = await readJson(runtime, server.registrationEndpoint, {
|
|
77
|
+
body: JSON.stringify({
|
|
78
|
+
client_name: "Greatwait CLI",
|
|
79
|
+
client_uri: "https://greatwait.dev",
|
|
80
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
81
|
+
redirect_uris: [options.redirectUri],
|
|
82
|
+
response_types: ["code"],
|
|
83
|
+
scope: options.scopes.join(" "),
|
|
84
|
+
// A public client: no secret to present, which is what the standard
|
|
85
|
+
// calls this and what PKCE compensates for.
|
|
86
|
+
token_endpoint_auth_method: "none",
|
|
87
|
+
}),
|
|
88
|
+
headers: { "content-type": "application/json" },
|
|
89
|
+
method: "POST",
|
|
90
|
+
});
|
|
91
|
+
if (registered.status !== 201 && registered.status !== 200) {
|
|
92
|
+
throw new CliError(`dynamic client registration was refused (${String(registered.status)}: ${registered.body?.error ??
|
|
93
|
+
"no reason given"})`, "validation");
|
|
94
|
+
}
|
|
95
|
+
return requireString(registered.body.client_id, "client_id", server.registrationEndpoint);
|
|
96
|
+
}
|
|
97
|
+
export async function buildAuthorization(runtime, server, options) {
|
|
98
|
+
const state = base64Url(runtime.randomBytes(24));
|
|
99
|
+
const verifier = base64Url(runtime.randomBytes(48));
|
|
100
|
+
const challenge = base64Url(new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))));
|
|
101
|
+
const url = new URL(server.authorizationEndpoint);
|
|
102
|
+
url.searchParams.set("client_id", options.clientId);
|
|
103
|
+
url.searchParams.set("code_challenge", challenge);
|
|
104
|
+
url.searchParams.set("code_challenge_method", "S256");
|
|
105
|
+
url.searchParams.set("redirect_uri", options.redirectUri);
|
|
106
|
+
url.searchParams.set("resource", server.resource);
|
|
107
|
+
url.searchParams.set("response_type", "code");
|
|
108
|
+
url.searchParams.set("scope", options.scopes.join(" "));
|
|
109
|
+
url.searchParams.set("state", state);
|
|
110
|
+
return { authorizationUrl: url.toString(), state, verifier };
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Turns the redirect into a code, refusing anything that is not this run's.
|
|
114
|
+
*
|
|
115
|
+
* `state` is compared before `code` is read. A redirect carrying somebody
|
|
116
|
+
* else's state is not a code to exchange — it is a request this process did not
|
|
117
|
+
* make — and reading the code first would exchange it anyway.
|
|
118
|
+
*/
|
|
119
|
+
export function codeFrom(returned, state) {
|
|
120
|
+
const error = returned.get("error");
|
|
121
|
+
if (error !== null) {
|
|
122
|
+
throw new CliError(`authorization was refused (${error})`, "authentication", error === "access_denied"
|
|
123
|
+
? "Nothing was granted. Run greatwait login again and approve the request, or use an API key."
|
|
124
|
+
: undefined);
|
|
125
|
+
}
|
|
126
|
+
const returnedState = returned.get("state");
|
|
127
|
+
if (returnedState !== state) {
|
|
128
|
+
throw new CliError(`the redirect carried ${returnedState === null ? "no state" : "a state this run did not issue"}`, "authentication", "A browser tab left open from an earlier sign-in is the usual cause. Close it and run greatwait login again.");
|
|
129
|
+
}
|
|
130
|
+
const code = returned.get("code");
|
|
131
|
+
if (code === null) {
|
|
132
|
+
throw new CliError("the redirect carried no code", "authentication");
|
|
133
|
+
}
|
|
134
|
+
return code;
|
|
135
|
+
}
|
|
136
|
+
async function exchange(runtime, server, form) {
|
|
137
|
+
const response = await readJson(runtime, server.tokenEndpoint, {
|
|
138
|
+
body: new URLSearchParams(form).toString(),
|
|
139
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
140
|
+
method: "POST",
|
|
141
|
+
});
|
|
142
|
+
if (response.status !== 200) {
|
|
143
|
+
const error = response.body?.error;
|
|
144
|
+
throw new CliError(`the authorization server refused the token request (${error ?? String(response.status)})`, "authentication", error === "invalid_grant"
|
|
145
|
+
? "The grant is no longer usable — it expired, was revoked, or was already spent. Run greatwait login again."
|
|
146
|
+
: undefined);
|
|
147
|
+
}
|
|
148
|
+
const body = response.body;
|
|
149
|
+
return {
|
|
150
|
+
accessToken: requireString(body["access_token"], "access_token", server.tokenEndpoint),
|
|
151
|
+
expiresInSeconds: typeof body["expires_in"] === "number" ? body["expires_in"] : 0,
|
|
152
|
+
refreshToken: requireString(body["refresh_token"], "refresh_token", server.tokenEndpoint),
|
|
153
|
+
scope: typeof body["scope"] === "string" ? body["scope"] : "",
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
export async function exchangeCode(runtime, server, options) {
|
|
157
|
+
return exchange(runtime, server, {
|
|
158
|
+
client_id: options.clientId,
|
|
159
|
+
code: options.code,
|
|
160
|
+
code_verifier: options.verifier,
|
|
161
|
+
grant_type: "authorization_code",
|
|
162
|
+
redirect_uri: options.redirectUri,
|
|
163
|
+
resource: server.resource,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
export async function refresh(runtime, server, options) {
|
|
167
|
+
return exchange(runtime, server, {
|
|
168
|
+
client_id: options.clientId,
|
|
169
|
+
grant_type: "refresh_token",
|
|
170
|
+
refresh_token: options.refreshToken,
|
|
171
|
+
resource: server.resource,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Revokes the refresh token, and says whether the server took it.
|
|
176
|
+
*
|
|
177
|
+
* **The refresh token rather than the access token**, because RFC 7009 lets a
|
|
178
|
+
* server end the whole grant when it is given one, and ending the grant is what
|
|
179
|
+
* `logout` means. Revoking only the access token would leave a refresh token
|
|
180
|
+
* this command has just deleted and the service still honours.
|
|
181
|
+
*
|
|
182
|
+
* A failure here is reported, not thrown: local material is removed either way.
|
|
183
|
+
* A `logout` that refused to forget a credential because the network was down
|
|
184
|
+
* would leave the credential on the disk, which is the opposite of what was
|
|
185
|
+
* asked for.
|
|
186
|
+
*/
|
|
187
|
+
export async function revoke(runtime, server, options) {
|
|
188
|
+
if (server.revocationEndpoint === undefined)
|
|
189
|
+
return false;
|
|
190
|
+
try {
|
|
191
|
+
const response = await readJson(runtime, server.revocationEndpoint, {
|
|
192
|
+
body: new URLSearchParams({
|
|
193
|
+
client_id: options.clientId,
|
|
194
|
+
token: options.token,
|
|
195
|
+
token_type_hint: "refresh_token",
|
|
196
|
+
}).toString(),
|
|
197
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
198
|
+
method: "POST",
|
|
199
|
+
});
|
|
200
|
+
return response.status === 200;
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
return false;
|
|
204
|
+
}
|
|
205
|
+
}
|
package/dist/output.d.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { Problem } from "@greatwait/contracts";
|
|
2
|
+
import { type ExitClass } from "./exit.js";
|
|
3
|
+
/**
|
|
4
|
+
* What a machine reads, and what a person reads, kept apart on purpose.
|
|
5
|
+
*
|
|
6
|
+
* **`--json` is not a serialization of the human text.** The project's own CLI
|
|
7
|
+
* invariants say so, and the reason is that the two have different obligations:
|
|
8
|
+
* the human line may be rewritten whenever it reads badly, and the machine
|
|
9
|
+
* shape may not change without a version. Deriving one from the other means
|
|
10
|
+
* every wording change is a contract change nobody notices until a script
|
|
11
|
+
* breaks.
|
|
12
|
+
*
|
|
13
|
+
* So a command returns *data*, and this file renders it twice.
|
|
14
|
+
*/
|
|
15
|
+
/**
|
|
16
|
+
* The version of the machine shape, in every `--json` document.
|
|
17
|
+
*
|
|
18
|
+
* One number for the envelope rather than one per command: a consumer's first
|
|
19
|
+
* question is "can I parse this at all", and a per-command version makes that a
|
|
20
|
+
* table they have to hold. The payload under `data` is the operation's own
|
|
21
|
+
* published response shape wherever there is one, which is versioned already by
|
|
22
|
+
* the contract it comes from.
|
|
23
|
+
*/
|
|
24
|
+
export declare const OUTPUT_VERSION = 1;
|
|
25
|
+
export interface CommandResult {
|
|
26
|
+
/** Rendered for a person, one line or a short block. Never parsed. */
|
|
27
|
+
readonly human: string;
|
|
28
|
+
/** The machine shape, under `data`. */
|
|
29
|
+
readonly data: unknown;
|
|
30
|
+
/**
|
|
31
|
+
* A non-zero class for a command that answered, and whose answer is bad news.
|
|
32
|
+
*
|
|
33
|
+
* **`doctor` is why this exists.** It has a real machine shape — a check per
|
|
34
|
+
* name with an outcome — and its refusal is that shape rather than a
|
|
35
|
+
* different one. Raising instead put the whole rendered table into the
|
|
36
|
+
* error's `message` string, so a script asking which check failed got a
|
|
37
|
+
* paragraph to parse. A command that *cannot* answer still raises; this is
|
|
38
|
+
* for one that answered "no".
|
|
39
|
+
*/
|
|
40
|
+
readonly exit?: ExitClass;
|
|
41
|
+
/**
|
|
42
|
+
* What to do next, in a person's words. Printed after `human` and never in
|
|
43
|
+
* the JSON: a machine acts on `data` and on the exit class, and a sentence
|
|
44
|
+
* rewritten because it read badly must not be a change to a versioned
|
|
45
|
+
* contract.
|
|
46
|
+
*/
|
|
47
|
+
readonly nextMove?: string;
|
|
48
|
+
}
|
|
49
|
+
export interface CommandFailure {
|
|
50
|
+
readonly exit: ExitClass;
|
|
51
|
+
readonly human: string;
|
|
52
|
+
readonly nextMove?: string;
|
|
53
|
+
/** The service's own problem document, where the service produced one. */
|
|
54
|
+
readonly problem?: Problem;
|
|
55
|
+
}
|
|
56
|
+
export declare function renderResult(command: string, result: CommandResult, json: boolean): string;
|
|
57
|
+
export declare function renderFailure(command: string, failure: CommandFailure, json: boolean): string;
|
|
58
|
+
/**
|
|
59
|
+
* A secret, as anything but the command that just minted it may print it.
|
|
60
|
+
*
|
|
61
|
+
* The prefix is kept because it is what a person matches against a listing, and
|
|
62
|
+
* it is the part the service itself publishes as `displayPrefix`. Everything
|
|
63
|
+
* after it becomes a fixed run of dots rather than a length-preserving mask: a
|
|
64
|
+
* mask that preserved length would publish how long the secret is.
|
|
65
|
+
*/
|
|
66
|
+
export declare function redactSecret(value: string): string;
|
package/dist/output.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { EXIT_CLASSES } from "./exit.js";
|
|
2
|
+
/**
|
|
3
|
+
* What a machine reads, and what a person reads, kept apart on purpose.
|
|
4
|
+
*
|
|
5
|
+
* **`--json` is not a serialization of the human text.** The project's own CLI
|
|
6
|
+
* invariants say so, and the reason is that the two have different obligations:
|
|
7
|
+
* the human line may be rewritten whenever it reads badly, and the machine
|
|
8
|
+
* shape may not change without a version. Deriving one from the other means
|
|
9
|
+
* every wording change is a contract change nobody notices until a script
|
|
10
|
+
* breaks.
|
|
11
|
+
*
|
|
12
|
+
* So a command returns *data*, and this file renders it twice.
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* The version of the machine shape, in every `--json` document.
|
|
16
|
+
*
|
|
17
|
+
* One number for the envelope rather than one per command: a consumer's first
|
|
18
|
+
* question is "can I parse this at all", and a per-command version makes that a
|
|
19
|
+
* table they have to hold. The payload under `data` is the operation's own
|
|
20
|
+
* published response shape wherever there is one, which is versioned already by
|
|
21
|
+
* the contract it comes from.
|
|
22
|
+
*/
|
|
23
|
+
export const OUTPUT_VERSION = 1;
|
|
24
|
+
export function renderResult(command, result, json) {
|
|
25
|
+
if (!json) {
|
|
26
|
+
return result.nextMove === undefined
|
|
27
|
+
? `${result.human}\n`
|
|
28
|
+
: `${result.human}\n\n${result.nextMove}\n`;
|
|
29
|
+
}
|
|
30
|
+
return `${JSON.stringify({
|
|
31
|
+
command,
|
|
32
|
+
data: result.data,
|
|
33
|
+
...(result.exit === undefined
|
|
34
|
+
? {}
|
|
35
|
+
: { exit: result.exit, exitCode: EXIT_CLASSES[result.exit] }),
|
|
36
|
+
outputVersion: OUTPUT_VERSION,
|
|
37
|
+
ok: result.exit === undefined,
|
|
38
|
+
}, null, 2)}\n`;
|
|
39
|
+
}
|
|
40
|
+
export function renderFailure(command, failure, json) {
|
|
41
|
+
if (!json) {
|
|
42
|
+
const detail = failure.problem?.detail === undefined
|
|
43
|
+
? ""
|
|
44
|
+
: `\n${failure.problem.detail}`;
|
|
45
|
+
return failure.nextMove === undefined
|
|
46
|
+
? `${failure.human}${detail}\n`
|
|
47
|
+
: `${failure.human}${detail}\n\n${failure.nextMove}\n`;
|
|
48
|
+
}
|
|
49
|
+
return `${JSON.stringify({
|
|
50
|
+
command,
|
|
51
|
+
error: {
|
|
52
|
+
// **The public problem code survives.** A script that already branches
|
|
53
|
+
// on `idempotency_key_reused` against the REST surface must not have to
|
|
54
|
+
// learn a second vocabulary to branch on it here, and the exit class is
|
|
55
|
+
// deliberately coarser than the code rather than a rename of it.
|
|
56
|
+
...(failure.problem === undefined ? {} : { problem: failure.problem }),
|
|
57
|
+
exit: failure.exit,
|
|
58
|
+
exitCode: EXIT_CLASSES[failure.exit],
|
|
59
|
+
message: failure.human,
|
|
60
|
+
},
|
|
61
|
+
ok: false,
|
|
62
|
+
outputVersion: OUTPUT_VERSION,
|
|
63
|
+
}, null, 2)}\n`;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* A secret, as anything but the command that just minted it may print it.
|
|
67
|
+
*
|
|
68
|
+
* The prefix is kept because it is what a person matches against a listing, and
|
|
69
|
+
* it is the part the service itself publishes as `displayPrefix`. Everything
|
|
70
|
+
* after it becomes a fixed run of dots rather than a length-preserving mask: a
|
|
71
|
+
* mask that preserved length would publish how long the secret is.
|
|
72
|
+
*/
|
|
73
|
+
export function redactSecret(value) {
|
|
74
|
+
const visible = value.slice(0, 8);
|
|
75
|
+
return `${visible}${"•".repeat(12)}`;
|
|
76
|
+
}
|