@kidd-cli/core 0.1.1 → 0.2.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/dist/{config-BvGapuFJ.js → config-Db_sjFU-.js} +60 -65
- package/dist/config-Db_sjFU-.js.map +1 -0
- package/dist/create-http-client-tZJWlWp1.js +165 -0
- package/dist/create-http-client-tZJWlWp1.js.map +1 -0
- package/dist/{create-store-BQUX0tAn.js → create-store-D-fQpCql.js} +32 -4
- package/dist/create-store-D-fQpCql.js.map +1 -0
- package/dist/index.d.ts +21 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +18 -17
- package/dist/index.js.map +1 -1
- package/dist/lib/config.js +2 -2
- package/dist/lib/project.d.ts +1 -1
- package/dist/lib/project.d.ts.map +1 -1
- package/dist/lib/project.js +1 -1
- package/dist/lib/store.d.ts +2 -1
- package/dist/lib/store.d.ts.map +1 -1
- package/dist/lib/store.js +2 -2
- package/dist/middleware/auth.d.ts +223 -14
- package/dist/middleware/auth.d.ts.map +1 -1
- package/dist/middleware/auth.js +973 -408
- package/dist/middleware/auth.js.map +1 -1
- package/dist/middleware/http.d.ts +10 -16
- package/dist/middleware/http.d.ts.map +1 -1
- package/dist/middleware/http.js +21 -221
- package/dist/middleware/http.js.map +1 -1
- package/dist/{middleware-D3psyhYo.js → middleware-BFBKNSPQ.js} +13 -2
- package/dist/{middleware-D3psyhYo.js.map → middleware-BFBKNSPQ.js.map} +1 -1
- package/dist/{project-NPtYX2ZX.js → project-DuXgjaa_.js} +19 -16
- package/dist/project-DuXgjaa_.js.map +1 -0
- package/dist/{types-kjpRau0U.d.ts → types-BaZ5WqVM.d.ts} +78 -13
- package/dist/types-BaZ5WqVM.d.ts.map +1 -0
- package/dist/{types-Cz9h927W.d.ts → types-C0CYivzY.d.ts} +1 -1
- package/dist/{types-Cz9h927W.d.ts.map → types-C0CYivzY.d.ts.map} +1 -1
- package/package.json +5 -12
- package/dist/config-BvGapuFJ.js.map +0 -1
- package/dist/create-store-BQUX0tAn.js.map +0 -1
- package/dist/lib/output.d.ts +0 -62
- package/dist/lib/output.d.ts.map +0 -1
- package/dist/lib/output.js +0 -276
- package/dist/lib/output.js.map +0 -1
- package/dist/lib/prompts.d.ts +0 -24
- package/dist/lib/prompts.d.ts.map +0 -1
- package/dist/lib/prompts.js +0 -3
- package/dist/project-NPtYX2ZX.js.map +0 -1
- package/dist/prompts-lLfUSgd6.js +0 -63
- package/dist/prompts-lLfUSgd6.js.map +0 -1
- package/dist/types-CqKJhsYk.d.ts +0 -135
- package/dist/types-CqKJhsYk.d.ts.map +0 -1
- package/dist/types-DFtYg5uZ.d.ts +0 -26
- package/dist/types-DFtYg5uZ.d.ts.map +0 -1
- package/dist/types-kjpRau0U.d.ts.map +0 -1
package/dist/middleware/auth.js
CHANGED
|
@@ -1,18 +1,40 @@
|
|
|
1
|
-
import { n as decorateContext, t as middleware } from "../middleware-
|
|
2
|
-
import "../project-
|
|
3
|
-
import { t as createStore } from "../create-store-
|
|
1
|
+
import { n as decorateContext, t as middleware } from "../middleware-BFBKNSPQ.js";
|
|
2
|
+
import "../project-DuXgjaa_.js";
|
|
3
|
+
import { t as createStore } from "../create-store-D-fQpCql.js";
|
|
4
|
+
import { t as createHttpClient } from "../create-http-client-tZJWlWp1.js";
|
|
4
5
|
import { join } from "node:path";
|
|
5
6
|
import { ok } from "@kidd-cli/utils/fp";
|
|
6
7
|
import { match as match$1 } from "ts-pattern";
|
|
7
8
|
import { platform } from "node:os";
|
|
8
9
|
import { readFileSync } from "node:fs";
|
|
10
|
+
import { Buffer } from "node:buffer";
|
|
11
|
+
import { execFile } from "node:child_process";
|
|
12
|
+
import { createServer } from "node:http";
|
|
9
13
|
import { parse } from "dotenv";
|
|
10
14
|
import { attempt as attempt$1 } from "es-toolkit";
|
|
11
15
|
import { z } from "zod";
|
|
12
|
-
import {
|
|
13
|
-
|
|
14
|
-
|
|
16
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
17
|
+
|
|
18
|
+
//#region src/middleware/http/build-auth-headers.ts
|
|
19
|
+
/**
|
|
20
|
+
* Convert auth credentials into HTTP headers.
|
|
21
|
+
*
|
|
22
|
+
* Uses exhaustive pattern matching to map each credential variant to
|
|
23
|
+
* the appropriate header format.
|
|
24
|
+
*
|
|
25
|
+
* @module
|
|
26
|
+
*/
|
|
27
|
+
/**
|
|
28
|
+
* Convert an auth credential into HTTP headers.
|
|
29
|
+
*
|
|
30
|
+
* @param credential - The credential to convert.
|
|
31
|
+
* @returns A record of header name to header value.
|
|
32
|
+
*/
|
|
33
|
+
function buildAuthHeaders(credential) {
|
|
34
|
+
return match$1(credential).with({ type: "bearer" }, (c) => ({ Authorization: `Bearer ${c.token}` })).with({ type: "basic" }, (c) => ({ Authorization: `Basic ${Buffer.from(`${c.username}:${c.password}`).toString("base64")}` })).with({ type: "api-key" }, (c) => ({ [c.headerName]: c.key })).with({ type: "custom" }, (c) => ({ ...c.headers })).exhaustive();
|
|
35
|
+
}
|
|
15
36
|
|
|
37
|
+
//#endregion
|
|
16
38
|
//#region src/middleware/auth/constants.ts
|
|
17
39
|
/**
|
|
18
40
|
* Default filename for file-based credential storage.
|
|
@@ -23,6 +45,26 @@ const DEFAULT_AUTH_FILENAME = "auth.json";
|
|
|
23
45
|
*/
|
|
24
46
|
const TOKEN_VAR_SUFFIX = "_TOKEN";
|
|
25
47
|
/**
|
|
48
|
+
* Default port for the local OAuth callback server (`0` = ephemeral).
|
|
49
|
+
*/
|
|
50
|
+
const DEFAULT_OAUTH_PORT = 0;
|
|
51
|
+
/**
|
|
52
|
+
* Default callback path for the local OAuth server.
|
|
53
|
+
*/
|
|
54
|
+
const DEFAULT_OAUTH_CALLBACK_PATH = "/callback";
|
|
55
|
+
/**
|
|
56
|
+
* Default timeout for the OAuth PKCE flow in milliseconds (2 minutes).
|
|
57
|
+
*/
|
|
58
|
+
const DEFAULT_OAUTH_TIMEOUT = 12e4;
|
|
59
|
+
/**
|
|
60
|
+
* Default poll interval for the device code flow in milliseconds (5 seconds).
|
|
61
|
+
*/
|
|
62
|
+
const DEFAULT_DEVICE_CODE_POLL_INTERVAL = 5e3;
|
|
63
|
+
/**
|
|
64
|
+
* Default timeout for the device code flow in milliseconds (5 minutes).
|
|
65
|
+
*/
|
|
66
|
+
const DEFAULT_DEVICE_CODE_TIMEOUT = 3e5;
|
|
67
|
+
/**
|
|
26
68
|
* Derive the default environment variable name from a CLI name.
|
|
27
69
|
*
|
|
28
70
|
* Converts kebab-case to SCREAMING_SNAKE_CASE and appends `_TOKEN`.
|
|
@@ -36,7 +78,503 @@ function deriveTokenVar(cliName) {
|
|
|
36
78
|
}
|
|
37
79
|
|
|
38
80
|
//#endregion
|
|
39
|
-
//#region src/middleware/auth/
|
|
81
|
+
//#region src/middleware/auth/credential.ts
|
|
82
|
+
/**
|
|
83
|
+
* Check whether a token string is a non-empty, non-whitespace value.
|
|
84
|
+
*
|
|
85
|
+
* Acts as a type guard: when it returns true, TypeScript narrows the
|
|
86
|
+
* token to `string`. Consolidates the repeated `!token || token.trim() === ''`
|
|
87
|
+
* guard found across strategy resolvers.
|
|
88
|
+
*
|
|
89
|
+
* @param token - The token string to check.
|
|
90
|
+
* @returns True when the token is a non-empty string.
|
|
91
|
+
*/
|
|
92
|
+
function isValidToken(token) {
|
|
93
|
+
if (!token) return false;
|
|
94
|
+
if (token.trim() === "") return false;
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Construct a bearer credential from a raw token string.
|
|
99
|
+
*
|
|
100
|
+
* @param token - The access token value.
|
|
101
|
+
* @returns A BearerCredential with `type: 'bearer'`.
|
|
102
|
+
*/
|
|
103
|
+
function createBearerCredential(token) {
|
|
104
|
+
return {
|
|
105
|
+
token,
|
|
106
|
+
type: "bearer"
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* POST form-encoded parameters to a URL.
|
|
111
|
+
*
|
|
112
|
+
* Wraps the duplicated `fetch` call with `Content-Type: application/x-www-form-urlencoded`
|
|
113
|
+
* found in the OAuth and device code strategies. Returns null on network or
|
|
114
|
+
* request failure instead of throwing.
|
|
115
|
+
*
|
|
116
|
+
* @param url - The endpoint URL.
|
|
117
|
+
* @param params - The URL-encoded form parameters.
|
|
118
|
+
* @param signal - Optional AbortSignal for timeout/cancellation.
|
|
119
|
+
* @returns The fetch Response on success, null on failure.
|
|
120
|
+
*/
|
|
121
|
+
async function postFormEncoded(url, params, signal) {
|
|
122
|
+
try {
|
|
123
|
+
return await fetch(url, {
|
|
124
|
+
body: params.toString(),
|
|
125
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
126
|
+
method: "POST",
|
|
127
|
+
signal
|
|
128
|
+
});
|
|
129
|
+
} catch {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
//#endregion
|
|
135
|
+
//#region src/middleware/auth/oauth-server.ts
|
|
136
|
+
/**
|
|
137
|
+
* Shared utilities for OAuth-based auth resolvers.
|
|
138
|
+
*
|
|
139
|
+
* Extracted from the local HTTP server, browser-launch, and
|
|
140
|
+
* lifecycle patterns shared by the PKCE and device-code flows.
|
|
141
|
+
*
|
|
142
|
+
* @module
|
|
143
|
+
*/
|
|
144
|
+
const CLOSE_PAGE_HTML = [
|
|
145
|
+
"<!DOCTYPE html>",
|
|
146
|
+
"<html>",
|
|
147
|
+
"<body><p>Authentication complete. You can close this tab.</p></body>",
|
|
148
|
+
"</html>"
|
|
149
|
+
].join("\n");
|
|
150
|
+
/**
|
|
151
|
+
* Create a deferred promise with externally accessible resolve.
|
|
152
|
+
*
|
|
153
|
+
* Uses a mutable state container to capture the promise resolver --
|
|
154
|
+
* this is an intentional exception to immutability rules because the
|
|
155
|
+
* Promise constructor API requires synchronous resolver capture.
|
|
156
|
+
*
|
|
157
|
+
* @returns A deferred object with promise and resolve.
|
|
158
|
+
*/
|
|
159
|
+
function createDeferred() {
|
|
160
|
+
const state = { resolve: null };
|
|
161
|
+
return {
|
|
162
|
+
promise: new Promise((resolve) => {
|
|
163
|
+
state.resolve = resolve;
|
|
164
|
+
}),
|
|
165
|
+
resolve: (value) => {
|
|
166
|
+
if (state.resolve) state.resolve(value);
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Create a clearable timeout.
|
|
172
|
+
*
|
|
173
|
+
* Returns a promise that resolves after `ms` milliseconds and a `clear`
|
|
174
|
+
* function that cancels the timer so it does not hold the event loop open.
|
|
175
|
+
*
|
|
176
|
+
* Uses a mutable state container to capture the timer id -- this is an
|
|
177
|
+
* intentional exception to immutability rules because `setTimeout`
|
|
178
|
+
* returns an opaque handle that must be stored for later cancellation.
|
|
179
|
+
*
|
|
180
|
+
* @param ms - Duration in milliseconds.
|
|
181
|
+
* @returns A Timeout with `promise` and `clear`.
|
|
182
|
+
*/
|
|
183
|
+
function createTimeout(ms) {
|
|
184
|
+
const state = { id: null };
|
|
185
|
+
return {
|
|
186
|
+
clear: () => {
|
|
187
|
+
if (state.id !== null) {
|
|
188
|
+
clearTimeout(state.id);
|
|
189
|
+
state.id = null;
|
|
190
|
+
}
|
|
191
|
+
},
|
|
192
|
+
promise: new Promise((resolve) => {
|
|
193
|
+
state.id = setTimeout(resolve, ms);
|
|
194
|
+
})
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Track socket connections on a server so they can be destroyed on close.
|
|
199
|
+
*
|
|
200
|
+
* Mutates the provided socket set -- this is an intentional exception to
|
|
201
|
+
* immutability rules because the HTTP server API is inherently stateful.
|
|
202
|
+
*
|
|
203
|
+
* @param server - The HTTP server.
|
|
204
|
+
* @param sockets - The set to track sockets in.
|
|
205
|
+
*/
|
|
206
|
+
function trackConnections(server, sockets) {
|
|
207
|
+
server.on("connection", (socket) => {
|
|
208
|
+
sockets.add(socket);
|
|
209
|
+
socket.on("close", () => {
|
|
210
|
+
sockets.delete(socket);
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Close a server and destroy all active connections immediately.
|
|
216
|
+
*
|
|
217
|
+
* `server.close()` only stops accepting new connections -- existing
|
|
218
|
+
* keep-alive connections hold the event loop open. This helper
|
|
219
|
+
* destroys every tracked socket so the process can exit cleanly.
|
|
220
|
+
*
|
|
221
|
+
* @param server - The HTTP server to close.
|
|
222
|
+
* @param sockets - The set of tracked sockets.
|
|
223
|
+
*/
|
|
224
|
+
function destroyServer(server, sockets) {
|
|
225
|
+
server.close();
|
|
226
|
+
Array.from(sockets, (socket) => socket.destroy());
|
|
227
|
+
sockets.clear();
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Send an HTML success page and end the response.
|
|
231
|
+
*
|
|
232
|
+
* @param res - The server response object.
|
|
233
|
+
*/
|
|
234
|
+
function sendSuccessPage(res) {
|
|
235
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
236
|
+
res.end(CLOSE_PAGE_HTML);
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Check whether a URL is safe for use as an OAuth endpoint.
|
|
240
|
+
*
|
|
241
|
+
* Requires HTTPS for all URLs except loopback addresses, where
|
|
242
|
+
* HTTP is permitted per RFC 8252 §8.3 (native app redirect URIs).
|
|
243
|
+
*
|
|
244
|
+
* @param url - The URL string to validate.
|
|
245
|
+
* @returns True when the URL uses HTTPS or HTTP on a loopback address.
|
|
246
|
+
*/
|
|
247
|
+
function isSecureAuthUrl(url) {
|
|
248
|
+
try {
|
|
249
|
+
const parsed = new URL(url);
|
|
250
|
+
if (parsed.protocol === "https:") return true;
|
|
251
|
+
if (parsed.protocol !== "http:") return false;
|
|
252
|
+
return isLoopbackHost(parsed.hostname);
|
|
253
|
+
} catch {
|
|
254
|
+
return false;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Open a URL in the user's default browser using a platform-specific command.
|
|
259
|
+
*
|
|
260
|
+
* Validates that the URL uses the HTTP or HTTPS protocol before opening
|
|
261
|
+
* to prevent dangerous schemes like `javascript:` or `data:`. Silently
|
|
262
|
+
* returns if the URL is invalid.
|
|
263
|
+
*
|
|
264
|
+
* On Windows, `start` is a `cmd.exe` built-in -- not a standalone executable --
|
|
265
|
+
* so it must be invoked via `cmd /c start "" <url>`. The empty string argument
|
|
266
|
+
* prevents `cmd` from interpreting the URL as a window title.
|
|
267
|
+
*
|
|
268
|
+
* @param url - The URL to open (must use http: or https: protocol).
|
|
269
|
+
*/
|
|
270
|
+
function openBrowser(url) {
|
|
271
|
+
if (!isHttpUrl(url)) return;
|
|
272
|
+
const { command, args } = match$1(platform()).with("darwin", () => ({
|
|
273
|
+
args: [url],
|
|
274
|
+
command: "open"
|
|
275
|
+
})).with("win32", () => ({
|
|
276
|
+
args: [
|
|
277
|
+
"/c",
|
|
278
|
+
"start",
|
|
279
|
+
"",
|
|
280
|
+
escapeCmdMeta(url)
|
|
281
|
+
],
|
|
282
|
+
command: "cmd"
|
|
283
|
+
})).otherwise(() => ({
|
|
284
|
+
args: [url],
|
|
285
|
+
command: "xdg-open"
|
|
286
|
+
}));
|
|
287
|
+
execFile(command, args).on("error", () => void 0);
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* Start a local HTTP server on `127.0.0.1` with socket tracking.
|
|
291
|
+
*
|
|
292
|
+
* Returns a handle containing the server, tracked sockets, and a port
|
|
293
|
+
* promise that resolves once the server is listening.
|
|
294
|
+
*
|
|
295
|
+
* @param options - Server configuration.
|
|
296
|
+
* @returns A LocalServerHandle with port, server, and sockets.
|
|
297
|
+
*/
|
|
298
|
+
function startLocalServer(options) {
|
|
299
|
+
const portDeferred = createDeferred();
|
|
300
|
+
const sockets = /* @__PURE__ */ new Set();
|
|
301
|
+
const server = createServer(options.onRequest);
|
|
302
|
+
trackConnections(server, sockets);
|
|
303
|
+
server.on("error", () => {
|
|
304
|
+
destroyServer(server, sockets);
|
|
305
|
+
portDeferred.resolve(null);
|
|
306
|
+
});
|
|
307
|
+
server.listen(options.port, "127.0.0.1", () => {
|
|
308
|
+
const addr = server.address();
|
|
309
|
+
if (addr === null || typeof addr === "string") {
|
|
310
|
+
destroyServer(server, sockets);
|
|
311
|
+
portDeferred.resolve(null);
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
portDeferred.resolve(addr.port);
|
|
315
|
+
});
|
|
316
|
+
return {
|
|
317
|
+
port: portDeferred.promise,
|
|
318
|
+
server,
|
|
319
|
+
sockets
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* Check whether a URL uses the HTTP or HTTPS protocol.
|
|
324
|
+
*
|
|
325
|
+
* Rejects dangerous schemes like `javascript:`, `data:`, and `file:`
|
|
326
|
+
* to prevent browser-based attacks when opening untrusted URLs.
|
|
327
|
+
*
|
|
328
|
+
* @private
|
|
329
|
+
* @param url - The URL string to validate.
|
|
330
|
+
* @returns True when the URL uses http: or https: protocol.
|
|
331
|
+
*/
|
|
332
|
+
function isHttpUrl(url) {
|
|
333
|
+
try {
|
|
334
|
+
const parsed = new URL(url);
|
|
335
|
+
return parsed.protocol === "https:" || parsed.protocol === "http:";
|
|
336
|
+
} catch {
|
|
337
|
+
return false;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* Check whether a hostname is a loopback address.
|
|
342
|
+
*
|
|
343
|
+
* RFC 8252 §8.3 permits HTTP for loopback interfaces during
|
|
344
|
+
* native app authorization flows.
|
|
345
|
+
*
|
|
346
|
+
* @private
|
|
347
|
+
* @param hostname - The hostname to check.
|
|
348
|
+
* @returns True when the hostname is a loopback address.
|
|
349
|
+
*/
|
|
350
|
+
function isLoopbackHost(hostname) {
|
|
351
|
+
return hostname === "127.0.0.1" || hostname === "[::1]" || hostname === "localhost";
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* Escape `cmd.exe` metacharacters in a URL string.
|
|
355
|
+
*
|
|
356
|
+
* Characters like `&`, `|`, `<`, `>`, and `^` are interpreted as
|
|
357
|
+
* command separators or redirectors by `cmd.exe`. Prefixing each
|
|
358
|
+
* with `^` neutralises the special meaning.
|
|
359
|
+
*
|
|
360
|
+
* @private
|
|
361
|
+
* @param url - The URL to escape.
|
|
362
|
+
* @returns The escaped URL string.
|
|
363
|
+
*/
|
|
364
|
+
function escapeCmdMeta(url) {
|
|
365
|
+
return url.replaceAll(/[&|<>^]/g, "^$&");
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
//#endregion
|
|
369
|
+
//#region src/middleware/auth/strategies/device-code.ts
|
|
370
|
+
/**
|
|
371
|
+
* OAuth 2.0 Device Authorization Grant resolver (RFC 8628).
|
|
372
|
+
*
|
|
373
|
+
* Requests a device code, displays the verification URL and user code,
|
|
374
|
+
* and polls the token endpoint until the user completes authorization
|
|
375
|
+
* or the flow times out.
|
|
376
|
+
*
|
|
377
|
+
* @module
|
|
378
|
+
*/
|
|
379
|
+
/**
|
|
380
|
+
* RFC 8628 slow_down backoff increment in milliseconds.
|
|
381
|
+
*/
|
|
382
|
+
const SLOW_DOWN_INCREMENT = 5e3;
|
|
383
|
+
/**
|
|
384
|
+
* RFC 8628 device code grant type URN.
|
|
385
|
+
*/
|
|
386
|
+
const DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code";
|
|
387
|
+
/**
|
|
388
|
+
* Resolve a bearer credential via OAuth 2.0 Device Authorization Grant.
|
|
389
|
+
*
|
|
390
|
+
* 1. POSTs to the device authorization endpoint to obtain a device code
|
|
391
|
+
* 2. Displays the verification URL and user code via prompts
|
|
392
|
+
* 3. Optionally opens the verification URL in the browser
|
|
393
|
+
* 4. Polls the token endpoint until authorization completes or times out
|
|
394
|
+
*
|
|
395
|
+
* @param options - Device code flow configuration.
|
|
396
|
+
* @returns A bearer credential on success, null on failure or timeout.
|
|
397
|
+
*/
|
|
398
|
+
async function resolveFromDeviceCode(options) {
|
|
399
|
+
if (!isSecureAuthUrl(options.deviceAuthUrl)) return null;
|
|
400
|
+
if (!isSecureAuthUrl(options.tokenUrl)) return null;
|
|
401
|
+
const deadline = Date.now() + options.timeout;
|
|
402
|
+
const signal = AbortSignal.timeout(options.timeout);
|
|
403
|
+
const authResponse = await requestDeviceAuth({
|
|
404
|
+
clientId: options.clientId,
|
|
405
|
+
deviceAuthUrl: options.deviceAuthUrl,
|
|
406
|
+
scopes: options.scopes,
|
|
407
|
+
signal
|
|
408
|
+
});
|
|
409
|
+
if (!authResponse) return null;
|
|
410
|
+
await displayUserCode(options.prompts, authResponse.verificationUri, authResponse.userCode);
|
|
411
|
+
if (options.openBrowserOnStart !== false) openBrowser(authResponse.verificationUri);
|
|
412
|
+
const interval = resolveInterval(authResponse.interval, options.pollInterval);
|
|
413
|
+
return pollForToken({
|
|
414
|
+
clientId: options.clientId,
|
|
415
|
+
deadline,
|
|
416
|
+
deviceCode: authResponse.deviceCode,
|
|
417
|
+
interval,
|
|
418
|
+
signal,
|
|
419
|
+
tokenUrl: options.tokenUrl
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
/**
|
|
423
|
+
* Request a device code from the authorization server.
|
|
424
|
+
*
|
|
425
|
+
* @private
|
|
426
|
+
* @param options - Device auth request parameters.
|
|
427
|
+
* @returns The parsed device auth response, or null on failure.
|
|
428
|
+
*/
|
|
429
|
+
async function requestDeviceAuth(options) {
|
|
430
|
+
const body = new URLSearchParams({ client_id: options.clientId });
|
|
431
|
+
if (options.scopes.length > 0) body.set("scope", options.scopes.join(" "));
|
|
432
|
+
const response = await postFormEncoded(options.deviceAuthUrl, body, options.signal);
|
|
433
|
+
if (!response) return null;
|
|
434
|
+
if (!response.ok) return null;
|
|
435
|
+
try {
|
|
436
|
+
return parseDeviceAuthResponse(await response.json());
|
|
437
|
+
} catch {
|
|
438
|
+
return null;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
/**
|
|
442
|
+
* Parse a device authorization response body.
|
|
443
|
+
*
|
|
444
|
+
* @private
|
|
445
|
+
* @param data - The raw response data.
|
|
446
|
+
* @returns The parsed response, or null if required fields are missing.
|
|
447
|
+
*/
|
|
448
|
+
function parseDeviceAuthResponse(data) {
|
|
449
|
+
if (typeof data !== "object" || data === null) return null;
|
|
450
|
+
const record = data;
|
|
451
|
+
if (typeof record.device_code !== "string" || record.device_code === "") return null;
|
|
452
|
+
if (typeof record.user_code !== "string" || record.user_code === "") return null;
|
|
453
|
+
if (typeof record.verification_uri !== "string" || record.verification_uri === "") return null;
|
|
454
|
+
const interval = resolveServerInterval(record.interval);
|
|
455
|
+
return {
|
|
456
|
+
deviceCode: record.device_code,
|
|
457
|
+
interval,
|
|
458
|
+
userCode: record.user_code,
|
|
459
|
+
verificationUri: record.verification_uri
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
/**
|
|
463
|
+
* Display the verification URL and user code to the user.
|
|
464
|
+
*
|
|
465
|
+
* Uses `prompts.text()` to show the information and wait for
|
|
466
|
+
* the user to press Enter to acknowledge.
|
|
467
|
+
*
|
|
468
|
+
* @private
|
|
469
|
+
* @param prompts - The prompts instance.
|
|
470
|
+
* @param verificationUri - The URL the user should visit.
|
|
471
|
+
* @param userCode - The code the user should enter.
|
|
472
|
+
*/
|
|
473
|
+
async function displayUserCode(prompts, verificationUri, userCode) {
|
|
474
|
+
try {
|
|
475
|
+
await prompts.text({
|
|
476
|
+
defaultValue: "",
|
|
477
|
+
message: `Open ${verificationUri} and enter code: ${userCode} (press Enter to continue)`
|
|
478
|
+
});
|
|
479
|
+
} catch {}
|
|
480
|
+
}
|
|
481
|
+
/**
|
|
482
|
+
* Resolve the poll interval, preferring server-provided value.
|
|
483
|
+
*
|
|
484
|
+
* @private
|
|
485
|
+
* @param serverInterval - The interval from the server response (in ms), or null.
|
|
486
|
+
* @param configInterval - The configured default interval.
|
|
487
|
+
* @returns The resolved interval in milliseconds.
|
|
488
|
+
*/
|
|
489
|
+
function resolveInterval(serverInterval, configInterval) {
|
|
490
|
+
if (serverInterval !== null) return serverInterval;
|
|
491
|
+
return configInterval;
|
|
492
|
+
}
|
|
493
|
+
/**
|
|
494
|
+
* Poll the token endpoint for an access token using recursive tail-call style.
|
|
495
|
+
*
|
|
496
|
+
* Handles RFC 8628 error codes:
|
|
497
|
+
* - `authorization_pending` -- continue polling
|
|
498
|
+
* - `slow_down` -- increase interval by 5 seconds, continue
|
|
499
|
+
* - `expired_token` -- return null
|
|
500
|
+
* - `access_denied` -- return null
|
|
501
|
+
*
|
|
502
|
+
* @private
|
|
503
|
+
* @param options - Polling parameters.
|
|
504
|
+
* @returns A bearer credential on success, null on failure or timeout.
|
|
505
|
+
*/
|
|
506
|
+
async function pollForToken(options) {
|
|
507
|
+
if (Date.now() >= options.deadline) return null;
|
|
508
|
+
await sleep(options.interval);
|
|
509
|
+
if (Date.now() >= options.deadline) return null;
|
|
510
|
+
return match$1(await requestToken({
|
|
511
|
+
clientId: options.clientId,
|
|
512
|
+
deviceCode: options.deviceCode,
|
|
513
|
+
signal: options.signal,
|
|
514
|
+
tokenUrl: options.tokenUrl
|
|
515
|
+
})).with({ status: "success" }, (r) => r.credential).with({ status: "pending" }, () => pollForToken(options)).with({ status: "slow_down" }, () => pollForToken({
|
|
516
|
+
...options,
|
|
517
|
+
interval: options.interval + SLOW_DOWN_INCREMENT
|
|
518
|
+
})).with({ status: "denied" }, () => null).with({ status: "expired" }, () => null).with({ status: "error" }, () => null).exhaustive();
|
|
519
|
+
}
|
|
520
|
+
/**
|
|
521
|
+
* Convert a server-provided interval value to milliseconds.
|
|
522
|
+
*
|
|
523
|
+
* @private
|
|
524
|
+
* @param value - The raw interval value from the server response.
|
|
525
|
+
* @returns The interval in milliseconds, or null if not a number.
|
|
526
|
+
*/
|
|
527
|
+
function resolveServerInterval(value) {
|
|
528
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return null;
|
|
529
|
+
return Math.max(1e3, Math.min(value * 1e3, 6e4));
|
|
530
|
+
}
|
|
531
|
+
/**
|
|
532
|
+
* Sleep for a given duration.
|
|
533
|
+
*
|
|
534
|
+
* @private
|
|
535
|
+
* @param ms - Duration in milliseconds.
|
|
536
|
+
* @returns A promise that resolves after the delay.
|
|
537
|
+
*/
|
|
538
|
+
function sleep(ms) {
|
|
539
|
+
return new Promise((resolve) => {
|
|
540
|
+
setTimeout(resolve, ms);
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
/**
|
|
544
|
+
* Request an access token from the token endpoint.
|
|
545
|
+
*
|
|
546
|
+
* @private
|
|
547
|
+
* @param options - Token request parameters.
|
|
548
|
+
* @returns A discriminated result indicating the outcome.
|
|
549
|
+
*/
|
|
550
|
+
async function requestToken(options) {
|
|
551
|
+
const body = new URLSearchParams({
|
|
552
|
+
client_id: options.clientId,
|
|
553
|
+
device_code: options.deviceCode,
|
|
554
|
+
grant_type: DEVICE_CODE_GRANT_TYPE
|
|
555
|
+
});
|
|
556
|
+
const response = await postFormEncoded(options.tokenUrl, body, options.signal);
|
|
557
|
+
if (!response) return { status: "error" };
|
|
558
|
+
try {
|
|
559
|
+
const data = await response.json();
|
|
560
|
+
if (typeof data !== "object" || data === null) return { status: "error" };
|
|
561
|
+
const record = data;
|
|
562
|
+
if (response.ok && typeof record.access_token === "string" && record.access_token !== "") {
|
|
563
|
+
if (typeof record.token_type === "string" && record.token_type.toLowerCase() !== "bearer") return { status: "error" };
|
|
564
|
+
return {
|
|
565
|
+
credential: createBearerCredential(record.access_token),
|
|
566
|
+
status: "success"
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
if (typeof record.error !== "string") return { status: "error" };
|
|
570
|
+
return match$1(record.error).with("authorization_pending", () => ({ status: "pending" })).with("slow_down", () => ({ status: "slow_down" })).with("expired_token", () => ({ status: "expired" })).with("access_denied", () => ({ status: "denied" })).otherwise(() => ({ status: "error" }));
|
|
571
|
+
} catch {
|
|
572
|
+
return { status: "error" };
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
//#endregion
|
|
577
|
+
//#region src/middleware/auth/strategies/dotenv.ts
|
|
40
578
|
/**
|
|
41
579
|
* Resolve a bearer credential from a `.env` file without mutating `process.env`.
|
|
42
580
|
*
|
|
@@ -53,15 +591,12 @@ function resolveFromDotenv(options) {
|
|
|
53
591
|
const [readError, content] = attempt$1(() => readFileSync(options.path, "utf8"));
|
|
54
592
|
if (readError || content === null) return null;
|
|
55
593
|
const token = parse(content)[options.tokenVar];
|
|
56
|
-
if (!token) return null;
|
|
57
|
-
return
|
|
58
|
-
token,
|
|
59
|
-
type: "bearer"
|
|
60
|
-
};
|
|
594
|
+
if (!isValidToken(token)) return null;
|
|
595
|
+
return createBearerCredential(token);
|
|
61
596
|
}
|
|
62
597
|
|
|
63
598
|
//#endregion
|
|
64
|
-
//#region src/middleware/auth/
|
|
599
|
+
//#region src/middleware/auth/strategies/env.ts
|
|
65
600
|
/**
|
|
66
601
|
* Resolve a bearer credential from a process environment variable.
|
|
67
602
|
*
|
|
@@ -70,11 +605,8 @@ function resolveFromDotenv(options) {
|
|
|
70
605
|
*/
|
|
71
606
|
function resolveFromEnv(options) {
|
|
72
607
|
const token = process.env[options.tokenVar];
|
|
73
|
-
if (!token) return null;
|
|
74
|
-
return
|
|
75
|
-
token,
|
|
76
|
-
type: "bearer"
|
|
77
|
-
};
|
|
608
|
+
if (!isValidToken(token)) return null;
|
|
609
|
+
return createBearerCredential(token);
|
|
78
610
|
}
|
|
79
611
|
|
|
80
612
|
//#endregion
|
|
@@ -121,7 +653,7 @@ const authCredentialSchema = z.discriminatedUnion("type", [
|
|
|
121
653
|
]);
|
|
122
654
|
|
|
123
655
|
//#endregion
|
|
124
|
-
//#region src/middleware/auth/
|
|
656
|
+
//#region src/middleware/auth/strategies/file.ts
|
|
125
657
|
/**
|
|
126
658
|
* Resolve credentials from a JSON file on disk.
|
|
127
659
|
*
|
|
@@ -131,337 +663,224 @@ const authCredentialSchema = z.discriminatedUnion("type", [
|
|
|
131
663
|
*
|
|
132
664
|
* @param options - Options with the filename and directory name.
|
|
133
665
|
* @returns A validated auth credential, or null if not found or invalid.
|
|
134
|
-
*/
|
|
135
|
-
function resolveFromFile(options) {
|
|
136
|
-
const data = createStore({ dirName: options.dirName }).load(options.filename);
|
|
137
|
-
if (data === null) return null;
|
|
138
|
-
const result = authCredentialSchema.safeParse(data);
|
|
139
|
-
if (!result.success) return null;
|
|
140
|
-
return result.data;
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
//#endregion
|
|
144
|
-
//#region src/middleware/auth/resolve-oauth.ts
|
|
145
|
-
/**
|
|
146
|
-
* Maximum request body size in bytes (16 KB).
|
|
147
|
-
*
|
|
148
|
-
* Limits memory consumption from the local OAuth callback server
|
|
149
|
-
* to prevent resource exhaustion from oversized payloads.
|
|
150
|
-
*
|
|
151
|
-
* @private
|
|
152
|
-
*/
|
|
153
|
-
const MAX_BODY_BYTES = 16384;
|
|
154
|
-
const CLOSE_PAGE_HTML = [
|
|
155
|
-
"<!DOCTYPE html>",
|
|
156
|
-
"<html>",
|
|
157
|
-
"<body><p>Authentication complete. You can close this tab.</p></body>",
|
|
158
|
-
"</html>"
|
|
159
|
-
].join("\n");
|
|
160
|
-
/**
|
|
161
|
-
* Resolve a bearer credential via an OAuth browser flow.
|
|
162
|
-
*
|
|
163
|
-
* Starts a minimal HTTP server on a local port, opens the user's browser
|
|
164
|
-
* to the auth URL with a callback parameter, and waits for the token
|
|
165
|
-
* to arrive via POST body.
|
|
166
|
-
*
|
|
167
|
-
* Only POST requests with a JSON body containing a `token` field are
|
|
168
|
-
* accepted. Query-string tokens are rejected to avoid leaking credentials
|
|
169
|
-
* in server logs, browser history, and referrer headers.
|
|
170
|
-
*
|
|
171
|
-
* @param options - OAuth flow configuration.
|
|
172
|
-
* @returns A bearer credential on success, null on timeout.
|
|
173
|
-
*/
|
|
174
|
-
async function resolveFromOAuth(options) {
|
|
175
|
-
const controller = new AbortController();
|
|
176
|
-
const state = randomBytes(32).toString("hex");
|
|
177
|
-
const timeout = createTimeout(options.timeout);
|
|
178
|
-
const tokenPromise = listenForToken({
|
|
179
|
-
callbackPath: options.callbackPath,
|
|
180
|
-
port: options.port,
|
|
181
|
-
signal: controller.signal,
|
|
182
|
-
state
|
|
183
|
-
});
|
|
184
|
-
const timeoutPromise = timeout.promise.then(() => {
|
|
185
|
-
controller.abort();
|
|
186
|
-
return null;
|
|
187
|
-
});
|
|
188
|
-
const serverPort = await getServerPort(tokenPromise);
|
|
189
|
-
if (serverPort === null) {
|
|
190
|
-
controller.abort();
|
|
191
|
-
timeout.clear();
|
|
192
|
-
return null;
|
|
193
|
-
}
|
|
194
|
-
const callbackUrl = `http://127.0.0.1:${String(serverPort)}${options.callbackPath}`;
|
|
195
|
-
openBrowser(`${options.authUrl}?callback_url=${encodeURIComponent(callbackUrl)}&state=${encodeURIComponent(state)}`);
|
|
196
|
-
const result = await Promise.race([tokenPromise.result, timeoutPromise]);
|
|
197
|
-
timeout.clear();
|
|
198
|
-
controller.abort();
|
|
199
|
-
return result;
|
|
200
|
-
}
|
|
201
|
-
/**
|
|
202
|
-
* Start an HTTP server that listens for an OAuth callback token.
|
|
203
|
-
*
|
|
204
|
-
* The server accepts POST requests with a JSON body `{ "token": "..." }`
|
|
205
|
-
* on the configured callback path. All other requests receive a 400.
|
|
206
|
-
*
|
|
207
|
-
* @private
|
|
208
|
-
* @param options - Listener configuration.
|
|
209
|
-
* @returns A TokenListener with port and result promises.
|
|
210
|
-
*/
|
|
211
|
-
function listenForToken(options) {
|
|
212
|
-
const portResolvers = createDeferred();
|
|
213
|
-
const resultResolvers = createDeferred();
|
|
214
|
-
const sockets = /* @__PURE__ */ new Set();
|
|
215
|
-
const server = createServer((req, res) => {
|
|
216
|
-
extractTokenFromBody(req, options.callbackPath, options.state, (token) => {
|
|
217
|
-
if (!token) {
|
|
218
|
-
res.writeHead(400);
|
|
219
|
-
res.end();
|
|
220
|
-
return;
|
|
221
|
-
}
|
|
222
|
-
sendSuccessPage(res);
|
|
223
|
-
destroyServer(server, sockets);
|
|
224
|
-
resultResolvers.resolve({
|
|
225
|
-
token,
|
|
226
|
-
type: "bearer"
|
|
227
|
-
});
|
|
228
|
-
});
|
|
229
|
-
});
|
|
230
|
-
trackConnections(server, sockets);
|
|
231
|
-
server.on("error", () => {
|
|
232
|
-
destroyServer(server, sockets);
|
|
233
|
-
portResolvers.resolve(null);
|
|
234
|
-
resultResolvers.resolve(null);
|
|
235
|
-
});
|
|
236
|
-
options.signal.addEventListener("abort", () => {
|
|
237
|
-
destroyServer(server, sockets);
|
|
238
|
-
resultResolvers.resolve(null);
|
|
239
|
-
});
|
|
240
|
-
server.listen(options.port, "127.0.0.1", () => {
|
|
241
|
-
const addr = server.address();
|
|
242
|
-
if (addr === null || typeof addr === "string") {
|
|
243
|
-
destroyServer(server, sockets);
|
|
244
|
-
portResolvers.resolve(null);
|
|
245
|
-
resultResolvers.resolve(null);
|
|
246
|
-
return;
|
|
247
|
-
}
|
|
248
|
-
portResolvers.resolve(addr.port);
|
|
249
|
-
});
|
|
250
|
-
return {
|
|
251
|
-
port: portResolvers.promise,
|
|
252
|
-
result: resultResolvers.promise
|
|
253
|
-
};
|
|
666
|
+
*/
|
|
667
|
+
function resolveFromFile(options) {
|
|
668
|
+
const data = createStore({ dirName: options.dirName }).load(options.filename);
|
|
669
|
+
if (data === null) return null;
|
|
670
|
+
const result = authCredentialSchema.safeParse(data);
|
|
671
|
+
if (!result.success) return null;
|
|
672
|
+
return result.data;
|
|
254
673
|
}
|
|
674
|
+
|
|
675
|
+
//#endregion
|
|
676
|
+
//#region src/middleware/auth/strategies/oauth.ts
|
|
255
677
|
/**
|
|
256
|
-
*
|
|
678
|
+
* OAuth 2.0 Authorization Code + PKCE resolver (RFC 7636 + RFC 8252).
|
|
257
679
|
*
|
|
258
|
-
*
|
|
259
|
-
*
|
|
680
|
+
* Opens the user's browser to the authorization URL with a PKCE challenge,
|
|
681
|
+
* listens for a GET redirect with an authorization code on a local server,
|
|
682
|
+
* and exchanges the code at the token endpoint with the code verifier.
|
|
260
683
|
*
|
|
261
|
-
* @
|
|
262
|
-
* @param server - The HTTP server.
|
|
263
|
-
* @param sockets - The set to track sockets in.
|
|
684
|
+
* @module
|
|
264
685
|
*/
|
|
265
|
-
function trackConnections(server, sockets) {
|
|
266
|
-
server.on("connection", (socket) => {
|
|
267
|
-
sockets.add(socket);
|
|
268
|
-
socket.on("close", () => {
|
|
269
|
-
sockets.delete(socket);
|
|
270
|
-
});
|
|
271
|
-
});
|
|
272
|
-
}
|
|
273
686
|
/**
|
|
274
|
-
*
|
|
687
|
+
* Resolve a bearer credential via OAuth 2.0 Authorization Code + PKCE.
|
|
275
688
|
*
|
|
276
|
-
*
|
|
277
|
-
*
|
|
278
|
-
*
|
|
689
|
+
* 1. Generates a `code_verifier` and derives the `code_challenge`
|
|
690
|
+
* 2. Starts a local HTTP server on `127.0.0.1`
|
|
691
|
+
* 3. Opens the browser to the authorization URL with PKCE params
|
|
692
|
+
* 4. Receives the authorization code via GET redirect
|
|
693
|
+
* 5. Exchanges the code at the token endpoint with the verifier
|
|
694
|
+
* 6. Returns the access token as a bearer credential
|
|
279
695
|
*
|
|
280
|
-
* @
|
|
281
|
-
* @
|
|
282
|
-
* @param sockets - The set of tracked sockets.
|
|
696
|
+
* @param options - PKCE flow configuration.
|
|
697
|
+
* @returns A bearer credential on success, null on failure or timeout.
|
|
283
698
|
*/
|
|
284
|
-
function
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
699
|
+
async function resolveFromOAuth(options) {
|
|
700
|
+
if (!isSecureAuthUrl(options.authUrl)) return null;
|
|
701
|
+
if (!isSecureAuthUrl(options.tokenUrl)) return null;
|
|
702
|
+
const codeVerifier = generateCodeVerifier();
|
|
703
|
+
const codeChallenge = deriveCodeChallenge(codeVerifier);
|
|
704
|
+
const state = randomBytes(32).toString("hex");
|
|
705
|
+
const timeout = createTimeout(options.timeout);
|
|
706
|
+
const codeDeferred = createDeferred();
|
|
707
|
+
const handle = startLocalServer({
|
|
708
|
+
onRequest: (req, res) => {
|
|
709
|
+
handleCallback(req, res, options.callbackPath, state, codeDeferred.resolve);
|
|
710
|
+
},
|
|
711
|
+
port: options.port
|
|
712
|
+
});
|
|
713
|
+
const serverPort = await handle.port;
|
|
714
|
+
if (serverPort === null) {
|
|
715
|
+
timeout.clear();
|
|
716
|
+
return null;
|
|
717
|
+
}
|
|
718
|
+
const redirectUri = `http://127.0.0.1:${String(serverPort)}${options.callbackPath}`;
|
|
719
|
+
openBrowser(buildAuthUrl({
|
|
720
|
+
authUrl: options.authUrl,
|
|
721
|
+
clientId: options.clientId,
|
|
722
|
+
codeChallenge,
|
|
723
|
+
redirectUri,
|
|
724
|
+
scopes: options.scopes,
|
|
725
|
+
state
|
|
726
|
+
}));
|
|
727
|
+
const timeoutPromise = timeout.promise.then(() => {
|
|
728
|
+
codeDeferred.resolve(null);
|
|
729
|
+
destroyServer(handle.server, handle.sockets);
|
|
730
|
+
return null;
|
|
731
|
+
});
|
|
732
|
+
const code = await Promise.race([codeDeferred.promise, timeoutPromise]);
|
|
733
|
+
timeout.clear();
|
|
734
|
+
if (!code) {
|
|
735
|
+
destroyServer(handle.server, handle.sockets);
|
|
736
|
+
return null;
|
|
737
|
+
}
|
|
738
|
+
destroyServer(handle.server, handle.sockets);
|
|
739
|
+
return await exchangeCodeForToken({
|
|
740
|
+
clientId: options.clientId,
|
|
741
|
+
code,
|
|
742
|
+
codeVerifier,
|
|
743
|
+
redirectUri,
|
|
744
|
+
tokenUrl: options.tokenUrl
|
|
745
|
+
});
|
|
288
746
|
}
|
|
289
747
|
/**
|
|
290
|
-
*
|
|
291
|
-
*
|
|
292
|
-
* Uses a mutable state container to capture the promise resolver —
|
|
293
|
-
* this is an intentional exception to immutability rules because the
|
|
294
|
-
* Promise constructor API requires synchronous resolver capture.
|
|
748
|
+
* Generate a cryptographically random code verifier for PKCE.
|
|
295
749
|
*
|
|
296
750
|
* @private
|
|
297
|
-
* @returns A
|
|
751
|
+
* @returns A base64url-encoded random string.
|
|
298
752
|
*/
|
|
299
|
-
function
|
|
300
|
-
|
|
301
|
-
return {
|
|
302
|
-
promise: new Promise((resolve) => {
|
|
303
|
-
state.resolve = resolve;
|
|
304
|
-
}),
|
|
305
|
-
resolve: (value) => {
|
|
306
|
-
if (state.resolve) state.resolve(value);
|
|
307
|
-
}
|
|
308
|
-
};
|
|
753
|
+
function generateCodeVerifier() {
|
|
754
|
+
return randomBytes(32).toString("base64url");
|
|
309
755
|
}
|
|
310
756
|
/**
|
|
311
|
-
*
|
|
312
|
-
*
|
|
313
|
-
* Returns a promise that resolves after `ms` milliseconds and a `clear`
|
|
314
|
-
* function that cancels the timer so it does not hold the event loop open.
|
|
315
|
-
*
|
|
316
|
-
* Uses a mutable state container to capture the timer id — this is an
|
|
317
|
-
* intentional exception to immutability rules because `setTimeout`
|
|
318
|
-
* returns an opaque handle that must be stored for later cancellation.
|
|
757
|
+
* Derive a S256 code challenge from a code verifier.
|
|
319
758
|
*
|
|
320
759
|
* @private
|
|
321
|
-
* @param
|
|
322
|
-
* @returns
|
|
760
|
+
* @param verifier - The code verifier string.
|
|
761
|
+
* @returns The base64url-encoded SHA-256 hash.
|
|
323
762
|
*/
|
|
324
|
-
function
|
|
325
|
-
|
|
326
|
-
return {
|
|
327
|
-
clear: () => {
|
|
328
|
-
if (state.id !== null) {
|
|
329
|
-
clearTimeout(state.id);
|
|
330
|
-
state.id = null;
|
|
331
|
-
}
|
|
332
|
-
},
|
|
333
|
-
promise: new Promise((resolve) => {
|
|
334
|
-
state.id = setTimeout(resolve, ms);
|
|
335
|
-
})
|
|
336
|
-
};
|
|
763
|
+
function deriveCodeChallenge(verifier) {
|
|
764
|
+
return createHash("sha256").update(verifier).digest("base64url");
|
|
337
765
|
}
|
|
338
766
|
/**
|
|
339
|
-
*
|
|
767
|
+
* Build the full authorization URL with PKCE query parameters.
|
|
340
768
|
*
|
|
341
769
|
* @private
|
|
342
|
-
* @param
|
|
343
|
-
* @returns The
|
|
770
|
+
* @param options - Authorization URL components.
|
|
771
|
+
* @returns The complete authorization URL string.
|
|
344
772
|
*/
|
|
345
|
-
|
|
346
|
-
|
|
773
|
+
function buildAuthUrl(options) {
|
|
774
|
+
const url = new URL(options.authUrl);
|
|
775
|
+
url.searchParams.set("response_type", "code");
|
|
776
|
+
url.searchParams.set("client_id", options.clientId);
|
|
777
|
+
url.searchParams.set("redirect_uri", options.redirectUri);
|
|
778
|
+
url.searchParams.set("code_challenge", options.codeChallenge);
|
|
779
|
+
url.searchParams.set("code_challenge_method", "S256");
|
|
780
|
+
url.searchParams.set("state", options.state);
|
|
781
|
+
if (options.scopes.length > 0) url.searchParams.set("scope", options.scopes.join(" "));
|
|
782
|
+
return url.toString();
|
|
347
783
|
}
|
|
348
784
|
/**
|
|
349
|
-
*
|
|
785
|
+
* Handle an incoming HTTP request on the callback server.
|
|
350
786
|
*
|
|
351
|
-
*
|
|
352
|
-
*
|
|
353
|
-
*
|
|
354
|
-
* to prevent credential leakage through browser history, server logs,
|
|
355
|
-
* and referrer headers.
|
|
356
|
-
*
|
|
357
|
-
* The `Content-Type` check prevents CORS-safelisted simple requests
|
|
358
|
-
* (which skip preflight) from delivering forged payloads — `text/plain`
|
|
359
|
-
* is safelisted, but `application/json` is not (Fetch Standard §2.2.2).
|
|
360
|
-
*
|
|
361
|
-
* Body size is capped at {@link MAX_BODY_BYTES} to prevent resource
|
|
362
|
-
* exhaustion from oversized payloads.
|
|
787
|
+
* Accepts GET requests to the callback path with `code` and `state`
|
|
788
|
+
* query parameters. Validates the state nonce and resolves the
|
|
789
|
+
* authorization code.
|
|
363
790
|
*
|
|
364
791
|
* @private
|
|
365
|
-
* @param req - The incoming request.
|
|
792
|
+
* @param req - The incoming HTTP request.
|
|
793
|
+
* @param res - The server response.
|
|
366
794
|
* @param callbackPath - The expected callback path.
|
|
367
|
-
* @param expectedState - The state nonce to validate
|
|
368
|
-
* @param
|
|
795
|
+
* @param expectedState - The state nonce to validate.
|
|
796
|
+
* @param resolve - Callback to deliver the authorization code.
|
|
369
797
|
*/
|
|
370
|
-
function
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
callback(null);
|
|
798
|
+
function handleCallback(req, res, callbackPath, expectedState, resolve) {
|
|
799
|
+
const result = extractCodeFromUrl(req.url, callbackPath, expectedState);
|
|
800
|
+
if (!result.ok) {
|
|
801
|
+
res.writeHead(400);
|
|
802
|
+
res.end();
|
|
803
|
+
if (result.isOAuthError) resolve(null);
|
|
377
804
|
return;
|
|
378
805
|
}
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
return;
|
|
382
|
-
}
|
|
383
|
-
const chunks = [];
|
|
384
|
-
const received = { bytes: 0 };
|
|
385
|
-
req.on("data", (chunk) => {
|
|
386
|
-
received.bytes += chunk.length;
|
|
387
|
-
if (received.bytes > MAX_BODY_BYTES) {
|
|
388
|
-
req.destroy();
|
|
389
|
-
callback(null);
|
|
390
|
-
return;
|
|
391
|
-
}
|
|
392
|
-
chunks.push(chunk);
|
|
393
|
-
});
|
|
394
|
-
req.on("end", () => {
|
|
395
|
-
callback(parseTokenFromJson(Buffer.concat(chunks).toString("utf8"), expectedState));
|
|
396
|
-
});
|
|
397
|
-
req.on("error", () => {
|
|
398
|
-
callback(null);
|
|
399
|
-
});
|
|
806
|
+
sendSuccessPage(res);
|
|
807
|
+
resolve(result.code);
|
|
400
808
|
}
|
|
401
809
|
/**
|
|
402
|
-
*
|
|
403
|
-
*
|
|
404
|
-
* Expects `{ "token": "<value>", "state": "<value>" }`. Returns null
|
|
405
|
-
* for invalid JSON, missing/empty token fields, or mismatched state.
|
|
810
|
+
* Extract an authorization code from a request URL.
|
|
406
811
|
*
|
|
407
|
-
*
|
|
408
|
-
*
|
|
409
|
-
*
|
|
410
|
-
*
|
|
411
|
-
|
|
412
|
-
function parseTokenFromJson(body, expectedState) {
|
|
413
|
-
try {
|
|
414
|
-
const parsed = JSON.parse(body);
|
|
415
|
-
if (typeof parsed !== "object" || parsed === null) return null;
|
|
416
|
-
const record = parsed;
|
|
417
|
-
if (typeof record.token !== "string" || record.token === "") return null;
|
|
418
|
-
if (record.state !== expectedState) return null;
|
|
419
|
-
return record.token;
|
|
420
|
-
} catch {
|
|
421
|
-
return null;
|
|
422
|
-
}
|
|
423
|
-
}
|
|
424
|
-
/**
|
|
425
|
-
* Send an HTML success page and end the response.
|
|
812
|
+
* Validates that the request path matches the callback path,
|
|
813
|
+
* the `state` parameter matches the expected nonce, and a
|
|
814
|
+
* `code` parameter is present. Detects OAuth error responses
|
|
815
|
+
* (e.g. `?error=access_denied`) and flags them so the caller
|
|
816
|
+
* can resolve immediately instead of waiting for the timeout.
|
|
426
817
|
*
|
|
427
818
|
* @private
|
|
428
|
-
* @param
|
|
819
|
+
* @param reqUrl - The raw request URL string.
|
|
820
|
+
* @param callbackPath - The expected callback path.
|
|
821
|
+
* @param expectedState - The state nonce to validate.
|
|
822
|
+
* @returns An extraction result with the code or error flag.
|
|
429
823
|
*/
|
|
430
|
-
function
|
|
431
|
-
|
|
432
|
-
|
|
824
|
+
function extractCodeFromUrl(reqUrl, callbackPath, expectedState) {
|
|
825
|
+
const url = new URL(reqUrl ?? "/", "http://localhost");
|
|
826
|
+
if (url.pathname !== callbackPath) return {
|
|
827
|
+
isOAuthError: false,
|
|
828
|
+
ok: false
|
|
829
|
+
};
|
|
830
|
+
if (url.searchParams.get("state") !== expectedState) return {
|
|
831
|
+
isOAuthError: false,
|
|
832
|
+
ok: false
|
|
833
|
+
};
|
|
834
|
+
if (url.searchParams.get("error")) return {
|
|
835
|
+
isOAuthError: true,
|
|
836
|
+
ok: false
|
|
837
|
+
};
|
|
838
|
+
const code = url.searchParams.get("code");
|
|
839
|
+
if (!code) return {
|
|
840
|
+
isOAuthError: false,
|
|
841
|
+
ok: false
|
|
842
|
+
};
|
|
843
|
+
return {
|
|
844
|
+
code,
|
|
845
|
+
ok: true
|
|
846
|
+
};
|
|
433
847
|
}
|
|
434
848
|
/**
|
|
435
|
-
*
|
|
849
|
+
* Exchange an authorization code for an access token at the token endpoint.
|
|
436
850
|
*
|
|
437
|
-
*
|
|
438
|
-
*
|
|
439
|
-
*
|
|
851
|
+
* Sends a POST request with `application/x-www-form-urlencoded` body
|
|
852
|
+
* containing the authorization code, redirect URI, client ID, and
|
|
853
|
+
* PKCE code verifier.
|
|
440
854
|
*
|
|
441
855
|
* @private
|
|
442
|
-
* @param
|
|
856
|
+
* @param options - Token exchange parameters.
|
|
857
|
+
* @returns A bearer credential on success, null on failure.
|
|
443
858
|
*/
|
|
444
|
-
function
|
|
445
|
-
const
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
859
|
+
async function exchangeCodeForToken(options) {
|
|
860
|
+
const body = new URLSearchParams({
|
|
861
|
+
client_id: options.clientId,
|
|
862
|
+
code: options.code,
|
|
863
|
+
code_verifier: options.codeVerifier,
|
|
864
|
+
grant_type: "authorization_code",
|
|
865
|
+
redirect_uri: options.redirectUri
|
|
866
|
+
});
|
|
867
|
+
const response = await postFormEncoded(options.tokenUrl, body);
|
|
868
|
+
if (!response) return null;
|
|
869
|
+
if (!response.ok) return null;
|
|
870
|
+
try {
|
|
871
|
+
const data = await response.json();
|
|
872
|
+
if (typeof data !== "object" || data === null) return null;
|
|
873
|
+
const record = data;
|
|
874
|
+
if (typeof record.access_token !== "string" || record.access_token === "") return null;
|
|
875
|
+
if (typeof record.token_type === "string" && record.token_type.toLowerCase() !== "bearer") return null;
|
|
876
|
+
return createBearerCredential(record.access_token);
|
|
877
|
+
} catch {
|
|
878
|
+
return null;
|
|
879
|
+
}
|
|
461
880
|
}
|
|
462
881
|
|
|
463
882
|
//#endregion
|
|
464
|
-
//#region src/middleware/auth/
|
|
883
|
+
//#region src/middleware/auth/strategies/token.ts
|
|
465
884
|
/**
|
|
466
885
|
* Resolve a bearer credential by interactively prompting the user.
|
|
467
886
|
*
|
|
@@ -473,24 +892,18 @@ function openBrowser(url) {
|
|
|
473
892
|
* @param options - Options with the prompt message and prompts instance.
|
|
474
893
|
* @returns A bearer credential on input, null on cancellation.
|
|
475
894
|
*/
|
|
476
|
-
async function
|
|
895
|
+
async function resolveFromToken(options) {
|
|
477
896
|
try {
|
|
478
897
|
const token = await options.prompts.password({ message: options.message });
|
|
479
|
-
if (!token) return null;
|
|
480
|
-
return
|
|
481
|
-
token,
|
|
482
|
-
type: "bearer"
|
|
483
|
-
};
|
|
898
|
+
if (!isValidToken(token)) return null;
|
|
899
|
+
return createBearerCredential(token);
|
|
484
900
|
} catch {
|
|
485
901
|
return null;
|
|
486
902
|
}
|
|
487
903
|
}
|
|
488
904
|
|
|
489
905
|
//#endregion
|
|
490
|
-
//#region src/middleware/auth/
|
|
491
|
-
const DEFAULT_OAUTH_PORT = 0;
|
|
492
|
-
const DEFAULT_OAUTH_CALLBACK_PATH = "/callback";
|
|
493
|
-
const DEFAULT_OAUTH_TIMEOUT = 12e4;
|
|
906
|
+
//#region src/middleware/auth/chain.ts
|
|
494
907
|
const DEFAULT_PROMPT_MESSAGE = "Enter your API key";
|
|
495
908
|
/**
|
|
496
909
|
* Chain credential resolvers, returning the first non-null result.
|
|
@@ -502,11 +915,22 @@ const DEFAULT_PROMPT_MESSAGE = "Enter your API key";
|
|
|
502
915
|
* @param options - Options with resolvers, CLI name, and prompts instance.
|
|
503
916
|
* @returns The first resolved credential, or null if all resolvers fail.
|
|
504
917
|
*/
|
|
505
|
-
async function
|
|
918
|
+
async function runStrategyChain(options) {
|
|
506
919
|
const defaultTokenVar = deriveTokenVar(options.cliName);
|
|
507
920
|
return tryResolvers(options.resolvers, 0, defaultTokenVar, options);
|
|
508
921
|
}
|
|
509
922
|
/**
|
|
923
|
+
* Return the given value when defined, otherwise the fallback.
|
|
924
|
+
*
|
|
925
|
+
* @param value - The optional value.
|
|
926
|
+
* @param fallback - The default value.
|
|
927
|
+
* @returns The resolved value.
|
|
928
|
+
*/
|
|
929
|
+
function withDefault(value, fallback) {
|
|
930
|
+
if (value !== void 0) return value;
|
|
931
|
+
return fallback;
|
|
932
|
+
}
|
|
933
|
+
/**
|
|
510
934
|
* Recursively try resolvers until one returns a credential or the list is exhausted.
|
|
511
935
|
*
|
|
512
936
|
* @private
|
|
@@ -534,56 +958,44 @@ async function tryResolvers(configs, index, defaultTokenVar, context) {
|
|
|
534
958
|
* @returns The resolved credential, or null.
|
|
535
959
|
*/
|
|
536
960
|
async function dispatchResolver(config, defaultTokenVar, context) {
|
|
537
|
-
return match$1(config).with({ source: "env" }, (c) => resolveFromEnv({ tokenVar:
|
|
538
|
-
path:
|
|
539
|
-
tokenVar:
|
|
961
|
+
return match$1(config).with({ source: "env" }, (c) => resolveFromEnv({ tokenVar: withDefault(c.tokenVar, defaultTokenVar) })).with({ source: "dotenv" }, (c) => resolveFromDotenv({
|
|
962
|
+
path: withDefault(c.path, join(process.cwd(), ".env")),
|
|
963
|
+
tokenVar: withDefault(c.tokenVar, defaultTokenVar)
|
|
540
964
|
})).with({ source: "file" }, (c) => resolveFromFile({
|
|
541
|
-
dirName:
|
|
542
|
-
filename:
|
|
965
|
+
dirName: withDefault(c.dirName, `.${context.cliName}`),
|
|
966
|
+
filename: withDefault(c.filename, DEFAULT_AUTH_FILENAME)
|
|
543
967
|
})).with({ source: "oauth" }, (c) => resolveFromOAuth({
|
|
544
968
|
authUrl: c.authUrl,
|
|
545
|
-
callbackPath:
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
969
|
+
callbackPath: withDefault(c.callbackPath, DEFAULT_OAUTH_CALLBACK_PATH),
|
|
970
|
+
clientId: c.clientId,
|
|
971
|
+
port: withDefault(c.port, DEFAULT_OAUTH_PORT),
|
|
972
|
+
scopes: withDefault(c.scopes, []),
|
|
973
|
+
timeout: withDefault(c.timeout, DEFAULT_OAUTH_TIMEOUT),
|
|
974
|
+
tokenUrl: c.tokenUrl
|
|
975
|
+
})).with({ source: "device-code" }, (c) => resolveFromDeviceCode({
|
|
976
|
+
clientId: c.clientId,
|
|
977
|
+
deviceAuthUrl: c.deviceAuthUrl,
|
|
978
|
+
openBrowserOnStart: withDefault(c.openBrowser, true),
|
|
979
|
+
pollInterval: withDefault(c.pollInterval, DEFAULT_DEVICE_CODE_POLL_INTERVAL),
|
|
980
|
+
prompts: context.prompts,
|
|
981
|
+
scopes: withDefault(c.scopes, []),
|
|
982
|
+
timeout: withDefault(c.timeout, DEFAULT_DEVICE_CODE_TIMEOUT),
|
|
983
|
+
tokenUrl: c.tokenUrl
|
|
984
|
+
})).with({ source: "token" }, (c) => resolveFromToken({
|
|
985
|
+
message: withDefault(c.message, DEFAULT_PROMPT_MESSAGE),
|
|
550
986
|
prompts: context.prompts
|
|
551
987
|
})).with({ source: "custom" }, (c) => c.resolver()).exhaustive();
|
|
552
988
|
}
|
|
553
|
-
/**
|
|
554
|
-
* Resolve an optional string value, falling back to a default.
|
|
555
|
-
*
|
|
556
|
-
* @private
|
|
557
|
-
* @param value - The optional value.
|
|
558
|
-
* @param fallback - The default value.
|
|
559
|
-
* @returns The resolved string.
|
|
560
|
-
*/
|
|
561
|
-
function resolveOptionalString(value, fallback) {
|
|
562
|
-
if (value !== void 0) return value;
|
|
563
|
-
return fallback;
|
|
564
|
-
}
|
|
565
|
-
/**
|
|
566
|
-
* Resolve an optional number value, falling back to a default.
|
|
567
|
-
*
|
|
568
|
-
* @private
|
|
569
|
-
* @param value - The optional value.
|
|
570
|
-
* @param fallback - The default value.
|
|
571
|
-
* @returns The resolved number.
|
|
572
|
-
*/
|
|
573
|
-
function resolveOptionalNumber(value, fallback) {
|
|
574
|
-
if (value !== void 0) return value;
|
|
575
|
-
return fallback;
|
|
576
|
-
}
|
|
577
989
|
|
|
578
990
|
//#endregion
|
|
579
|
-
//#region src/middleware/auth/
|
|
991
|
+
//#region src/middleware/auth/context.ts
|
|
580
992
|
/**
|
|
581
993
|
* Create an {@link AuthContext} value for `ctx.auth`.
|
|
582
994
|
*
|
|
583
995
|
* No credential data is stored on the returned object. `credential()`
|
|
584
996
|
* resolves passively on every call, `authenticated()` checks existence,
|
|
585
|
-
*
|
|
586
|
-
*
|
|
997
|
+
* `login()` runs the configured interactive resolvers, saves the
|
|
998
|
+
* credential to the global file store, and `logout()` removes it.
|
|
587
999
|
*
|
|
588
1000
|
* @param options - Factory options.
|
|
589
1001
|
* @returns An AuthContext instance.
|
|
@@ -612,49 +1024,65 @@ function createAuthContext(options) {
|
|
|
612
1024
|
* Run configured resolvers interactively and persist the credential.
|
|
613
1025
|
*
|
|
614
1026
|
* @private
|
|
615
|
-
* @returns A Result with the credential on success or
|
|
1027
|
+
* @returns A Result with the credential on success or an AuthError on failure.
|
|
616
1028
|
*/
|
|
617
|
-
async function
|
|
618
|
-
const resolved = await
|
|
1029
|
+
async function login() {
|
|
1030
|
+
const resolved = await runStrategyChain({
|
|
619
1031
|
cliName,
|
|
620
1032
|
prompts,
|
|
621
1033
|
resolvers
|
|
622
1034
|
});
|
|
623
|
-
if (resolved === null) return
|
|
1035
|
+
if (resolved === null) return authError({
|
|
624
1036
|
message: "No credential resolved from any source",
|
|
625
1037
|
type: "no_credential"
|
|
626
1038
|
});
|
|
627
1039
|
const [saveError] = createStore({ dirName: `.${cliName}` }).save(DEFAULT_AUTH_FILENAME, resolved);
|
|
628
|
-
if (saveError) return
|
|
1040
|
+
if (saveError) return authError({
|
|
629
1041
|
message: `Failed to save credential: ${saveError.message}`,
|
|
630
1042
|
type: "save_failed"
|
|
631
1043
|
});
|
|
632
1044
|
return ok(resolved);
|
|
633
1045
|
}
|
|
1046
|
+
/**
|
|
1047
|
+
* Remove the stored credential from disk.
|
|
1048
|
+
*
|
|
1049
|
+
* @private
|
|
1050
|
+
* @returns A Result with the removed file path on success or an AuthError on failure.
|
|
1051
|
+
*/
|
|
1052
|
+
async function logout() {
|
|
1053
|
+
const [removeError, filePath] = createStore({ dirName: `.${cliName}` }).remove(DEFAULT_AUTH_FILENAME);
|
|
1054
|
+
if (removeError) return authError({
|
|
1055
|
+
message: `Failed to remove credential: ${removeError.message}`,
|
|
1056
|
+
type: "remove_failed"
|
|
1057
|
+
});
|
|
1058
|
+
return ok(filePath);
|
|
1059
|
+
}
|
|
634
1060
|
return {
|
|
635
|
-
authenticate,
|
|
636
1061
|
authenticated,
|
|
637
|
-
credential
|
|
1062
|
+
credential,
|
|
1063
|
+
login,
|
|
1064
|
+
logout
|
|
638
1065
|
};
|
|
639
1066
|
}
|
|
640
1067
|
/**
|
|
641
|
-
* Construct a failure Result tuple with
|
|
1068
|
+
* Construct a failure Result tuple with an {@link AuthError}.
|
|
642
1069
|
*
|
|
643
1070
|
* @private
|
|
644
|
-
* @param error - The
|
|
645
|
-
* @returns A Result tuple `[
|
|
1071
|
+
* @param error - The auth error.
|
|
1072
|
+
* @returns A Result tuple `[AuthError, null]`.
|
|
646
1073
|
*/
|
|
647
|
-
function
|
|
1074
|
+
function authError(error) {
|
|
648
1075
|
return [error, null];
|
|
649
1076
|
}
|
|
650
1077
|
|
|
651
1078
|
//#endregion
|
|
652
1079
|
//#region src/middleware/auth/auth.ts
|
|
653
1080
|
/**
|
|
654
|
-
* Auth middleware factory.
|
|
1081
|
+
* Auth middleware factory with resolver builder functions.
|
|
655
1082
|
*
|
|
656
1083
|
* Decorates `ctx.auth` with functions to resolve credentials on demand
|
|
657
|
-
* and run interactive authentication.
|
|
1084
|
+
* and run interactive authentication. Also supports creating authenticated
|
|
1085
|
+
* HTTP clients via the `http` option.
|
|
658
1086
|
*
|
|
659
1087
|
* @module
|
|
660
1088
|
*/
|
|
@@ -662,50 +1090,207 @@ function loginError(error) {
|
|
|
662
1090
|
* Create an auth middleware that decorates `ctx.auth`.
|
|
663
1091
|
*
|
|
664
1092
|
* No credential data is stored on the context. `ctx.auth.credential()`
|
|
665
|
-
* resolves passively from
|
|
1093
|
+
* resolves passively from three sources on every call:
|
|
666
1094
|
* 1. File — `~/.cli-name/auth.json`
|
|
667
|
-
* 2.
|
|
1095
|
+
* 2. Dotenv — `.env` file (when configured)
|
|
1096
|
+
* 3. Env — `CLI_NAME_TOKEN`
|
|
668
1097
|
*
|
|
669
1098
|
* Interactive resolvers (OAuth, prompt, custom) only run when the
|
|
670
|
-
* command handler explicitly calls `ctx.auth.
|
|
1099
|
+
* command handler explicitly calls `ctx.auth.login()`.
|
|
1100
|
+
*
|
|
1101
|
+
* When `options.http` is provided, the middleware also creates HTTP
|
|
1102
|
+
* client(s) with automatic credential header injection and decorates
|
|
1103
|
+
* them onto `ctx[namespace]`.
|
|
671
1104
|
*
|
|
672
1105
|
* @param options - Auth middleware configuration.
|
|
673
|
-
* @returns A Middleware that decorates ctx.auth.
|
|
1106
|
+
* @returns A Middleware that decorates ctx.auth (and optionally HTTP clients).
|
|
674
1107
|
*/
|
|
675
|
-
function
|
|
1108
|
+
function createAuth(options) {
|
|
676
1109
|
const { resolvers } = options;
|
|
677
1110
|
return middleware((ctx, next) => {
|
|
678
1111
|
const cliName = ctx.meta.name;
|
|
679
|
-
|
|
1112
|
+
const authContext = createAuthContext({
|
|
680
1113
|
cliName,
|
|
681
1114
|
prompts: ctx.prompts,
|
|
682
|
-
resolveCredential: () =>
|
|
1115
|
+
resolveCredential: () => resolveStoredCredential(cliName, resolvers),
|
|
683
1116
|
resolvers
|
|
684
|
-
})
|
|
1117
|
+
});
|
|
1118
|
+
decorateContext(ctx, "auth", authContext);
|
|
1119
|
+
if (options.http !== void 0) normalizeHttpOptions(options.http).reduce((context, httpConfig) => {
|
|
1120
|
+
const client = createHttpClient({
|
|
1121
|
+
baseUrl: httpConfig.baseUrl,
|
|
1122
|
+
defaultHeaders: httpConfig.headers,
|
|
1123
|
+
resolveHeaders: () => credentialToHeaders(authContext.credential())
|
|
1124
|
+
});
|
|
1125
|
+
return decorateContext(context, httpConfig.namespace, client);
|
|
1126
|
+
}, ctx);
|
|
685
1127
|
return next();
|
|
686
1128
|
});
|
|
687
1129
|
}
|
|
688
1130
|
/**
|
|
689
|
-
*
|
|
1131
|
+
* Auth middleware factory with resolver builder methods.
|
|
1132
|
+
*
|
|
1133
|
+
* Use as `auth({ resolvers: [...] })` to create middleware, or use
|
|
1134
|
+
* the builder methods (`auth.env()`, `auth.oauth()`, etc.) to construct
|
|
1135
|
+
* resolver configs with a cleaner API.
|
|
1136
|
+
*/
|
|
1137
|
+
const auth = Object.assign(createAuth, {
|
|
1138
|
+
apiKey: buildToken,
|
|
1139
|
+
custom: buildCustom,
|
|
1140
|
+
deviceCode: buildDeviceCode,
|
|
1141
|
+
dotenv: buildDotenv,
|
|
1142
|
+
env: buildEnv,
|
|
1143
|
+
file: buildFile,
|
|
1144
|
+
oauth: buildOAuth,
|
|
1145
|
+
token: buildToken
|
|
1146
|
+
});
|
|
1147
|
+
/**
|
|
1148
|
+
* Build an env resolver config.
|
|
1149
|
+
*
|
|
1150
|
+
* @private
|
|
1151
|
+
* @param options - Optional env resolver options.
|
|
1152
|
+
* @returns An EnvSourceConfig with `source: 'env'`.
|
|
1153
|
+
*/
|
|
1154
|
+
function buildEnv(options) {
|
|
1155
|
+
return {
|
|
1156
|
+
source: "env",
|
|
1157
|
+
...options
|
|
1158
|
+
};
|
|
1159
|
+
}
|
|
1160
|
+
/**
|
|
1161
|
+
* Build a dotenv resolver config.
|
|
690
1162
|
*
|
|
691
|
-
*
|
|
692
|
-
*
|
|
693
|
-
*
|
|
1163
|
+
* @private
|
|
1164
|
+
* @param options - Optional dotenv resolver options.
|
|
1165
|
+
* @returns A DotenvSourceConfig with `source: 'dotenv'`.
|
|
1166
|
+
*/
|
|
1167
|
+
function buildDotenv(options) {
|
|
1168
|
+
return {
|
|
1169
|
+
source: "dotenv",
|
|
1170
|
+
...options
|
|
1171
|
+
};
|
|
1172
|
+
}
|
|
1173
|
+
/**
|
|
1174
|
+
* Build a file resolver config.
|
|
1175
|
+
*
|
|
1176
|
+
* @private
|
|
1177
|
+
* @param options - Optional file resolver options.
|
|
1178
|
+
* @returns A FileSourceConfig with `source: 'file'`.
|
|
1179
|
+
*/
|
|
1180
|
+
function buildFile(options) {
|
|
1181
|
+
return {
|
|
1182
|
+
source: "file",
|
|
1183
|
+
...options
|
|
1184
|
+
};
|
|
1185
|
+
}
|
|
1186
|
+
/**
|
|
1187
|
+
* Build an OAuth resolver config.
|
|
1188
|
+
*
|
|
1189
|
+
* @private
|
|
1190
|
+
* @param options - OAuth resolver options (clientId, authUrl, tokenUrl required).
|
|
1191
|
+
* @returns An OAuthSourceConfig with `source: 'oauth'`.
|
|
1192
|
+
*/
|
|
1193
|
+
function buildOAuth(options) {
|
|
1194
|
+
return {
|
|
1195
|
+
source: "oauth",
|
|
1196
|
+
...options
|
|
1197
|
+
};
|
|
1198
|
+
}
|
|
1199
|
+
/**
|
|
1200
|
+
* Build a device code resolver config.
|
|
1201
|
+
*
|
|
1202
|
+
* @private
|
|
1203
|
+
* @param options - Device code resolver options (clientId, deviceAuthUrl, tokenUrl required).
|
|
1204
|
+
* @returns A DeviceCodeSourceConfig with `source: 'device-code'`.
|
|
1205
|
+
*/
|
|
1206
|
+
function buildDeviceCode(options) {
|
|
1207
|
+
return {
|
|
1208
|
+
source: "device-code",
|
|
1209
|
+
...options
|
|
1210
|
+
};
|
|
1211
|
+
}
|
|
1212
|
+
/**
|
|
1213
|
+
* Build a token resolver config.
|
|
1214
|
+
*
|
|
1215
|
+
* Prompts the user for a token interactively. Aliased as `auth.apiKey()`.
|
|
1216
|
+
*
|
|
1217
|
+
* @private
|
|
1218
|
+
* @param options - Optional token resolver options.
|
|
1219
|
+
* @returns A TokenSourceConfig with `source: 'token'`.
|
|
1220
|
+
*/
|
|
1221
|
+
function buildToken(options) {
|
|
1222
|
+
return {
|
|
1223
|
+
source: "token",
|
|
1224
|
+
...options
|
|
1225
|
+
};
|
|
1226
|
+
}
|
|
1227
|
+
/**
|
|
1228
|
+
* Build a custom resolver config from a resolver function.
|
|
1229
|
+
*
|
|
1230
|
+
* @private
|
|
1231
|
+
* @param resolver - The custom resolver function.
|
|
1232
|
+
* @returns A CustomSourceConfig with `source: 'custom'`.
|
|
1233
|
+
*/
|
|
1234
|
+
function buildCustom(resolver) {
|
|
1235
|
+
return {
|
|
1236
|
+
resolver,
|
|
1237
|
+
source: "custom"
|
|
1238
|
+
};
|
|
1239
|
+
}
|
|
1240
|
+
/**
|
|
1241
|
+
* Normalize the `http` option into an array of configs.
|
|
1242
|
+
*
|
|
1243
|
+
* @private
|
|
1244
|
+
* @param http - A single config or array of configs.
|
|
1245
|
+
* @returns An array of AuthHttpOptions.
|
|
1246
|
+
*/
|
|
1247
|
+
function normalizeHttpOptions(http) {
|
|
1248
|
+
if ("baseUrl" in http) return [http];
|
|
1249
|
+
return http;
|
|
1250
|
+
}
|
|
1251
|
+
/**
|
|
1252
|
+
* Convert a credential into auth headers, returning an empty record
|
|
1253
|
+
* when no credential is available.
|
|
1254
|
+
*
|
|
1255
|
+
* @private
|
|
1256
|
+
* @param credential - The credential or null.
|
|
1257
|
+
* @returns A record of auth headers.
|
|
1258
|
+
*/
|
|
1259
|
+
function credentialToHeaders(credential) {
|
|
1260
|
+
if (credential === null) return {};
|
|
1261
|
+
return buildAuthHeaders(credential);
|
|
1262
|
+
}
|
|
1263
|
+
/**
|
|
1264
|
+
* Attempt to resolve a credential from stored (non-interactive) sources.
|
|
1265
|
+
*
|
|
1266
|
+
* Checks the file store first, then dotenv, then falls back to the
|
|
1267
|
+
* environment variable. Scans the resolver list for `file`, `dotenv`,
|
|
1268
|
+
* and `env` source configs to respect user-configured overrides
|
|
1269
|
+
* (e.g. a custom `tokenVar`, `dirName`, or dotenv `path`).
|
|
694
1270
|
*
|
|
695
1271
|
* @private
|
|
696
1272
|
* @param cliName - The CLI name, used to derive paths and env var names.
|
|
697
1273
|
* @param resolvers - The configured resolver list for extracting overrides.
|
|
698
1274
|
* @returns The resolved credential, or null.
|
|
699
1275
|
*/
|
|
700
|
-
function
|
|
1276
|
+
function resolveStoredCredential(cliName, resolvers) {
|
|
701
1277
|
const fileConfig = findResolverBySource(resolvers, "file");
|
|
1278
|
+
const dotenvConfig = findResolverBySource(resolvers, "dotenv");
|
|
702
1279
|
const envConfig = findResolverBySource(resolvers, "env");
|
|
1280
|
+
const defaultTokenVar = deriveTokenVar(cliName);
|
|
703
1281
|
const fromFile = resolveFromFile({
|
|
704
|
-
dirName:
|
|
705
|
-
filename:
|
|
1282
|
+
dirName: withDefault(extractProp(fileConfig, "dirName"), `.${cliName}`),
|
|
1283
|
+
filename: withDefault(extractProp(fileConfig, "filename"), DEFAULT_AUTH_FILENAME)
|
|
706
1284
|
});
|
|
707
1285
|
if (fromFile) return fromFile;
|
|
708
|
-
|
|
1286
|
+
if (dotenvConfig !== void 0) {
|
|
1287
|
+
const fromDotenv = resolveFromDotenv({
|
|
1288
|
+
path: withDefault(extractProp(dotenvConfig, "path"), join(process.cwd(), ".env")),
|
|
1289
|
+
tokenVar: withDefault(extractProp(dotenvConfig, "tokenVar"), defaultTokenVar)
|
|
1290
|
+
});
|
|
1291
|
+
if (fromDotenv) return fromDotenv;
|
|
1292
|
+
}
|
|
1293
|
+
return resolveFromEnv({ tokenVar: withDefault(extractProp(envConfig, "tokenVar"), defaultTokenVar) });
|
|
709
1294
|
}
|
|
710
1295
|
/**
|
|
711
1296
|
* Find the first resolver config matching a given source type.
|
|
@@ -719,39 +1304,19 @@ function findResolverBySource(resolvers, source) {
|
|
|
719
1304
|
return resolvers.find((r) => r.source === source);
|
|
720
1305
|
}
|
|
721
1306
|
/**
|
|
722
|
-
*
|
|
723
|
-
*
|
|
724
|
-
* @private
|
|
725
|
-
* @param config - The file resolver config, or undefined.
|
|
726
|
-
* @param cliName - The CLI name for deriving the default.
|
|
727
|
-
* @returns The directory name.
|
|
728
|
-
*/
|
|
729
|
-
function resolveFileDir(config, cliName) {
|
|
730
|
-
if (config !== void 0 && config.dirName !== void 0) return config.dirName;
|
|
731
|
-
return `.${cliName}`;
|
|
732
|
-
}
|
|
733
|
-
/**
|
|
734
|
-
* Resolve the file store filename from a file resolver config.
|
|
1307
|
+
* Safely extract a property from an optional config object.
|
|
735
1308
|
*
|
|
736
|
-
*
|
|
737
|
-
*
|
|
738
|
-
* @returns The filename.
|
|
739
|
-
*/
|
|
740
|
-
function resolveFileFilename(config) {
|
|
741
|
-
if (config !== void 0 && config.filename !== void 0) return config.filename;
|
|
742
|
-
return DEFAULT_AUTH_FILENAME;
|
|
743
|
-
}
|
|
744
|
-
/**
|
|
745
|
-
* Resolve the environment variable name from an env resolver config.
|
|
1309
|
+
* Returns the property value when the config is defined, or undefined
|
|
1310
|
+
* when the config itself is undefined.
|
|
746
1311
|
*
|
|
747
1312
|
* @private
|
|
748
|
-
* @param config - The
|
|
749
|
-
* @param
|
|
750
|
-
* @returns The
|
|
1313
|
+
* @param config - The config object, or undefined.
|
|
1314
|
+
* @param key - The property key to extract.
|
|
1315
|
+
* @returns The property value, or undefined.
|
|
751
1316
|
*/
|
|
752
|
-
function
|
|
753
|
-
if (config
|
|
754
|
-
return
|
|
1317
|
+
function extractProp(config, key) {
|
|
1318
|
+
if (config === void 0) return;
|
|
1319
|
+
return config[key];
|
|
755
1320
|
}
|
|
756
1321
|
|
|
757
1322
|
//#endregion
|