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