llm-cli-gateway 2.4.0 → 2.6.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/CHANGELOG.md +45 -0
- package/README.md +18 -18
- package/dist/async-job-manager.d.ts +2 -0
- package/dist/async-job-manager.js +43 -3
- package/dist/auth.d.ts +44 -1
- package/dist/auth.js +60 -13
- package/dist/cli-updater.js +22 -13
- package/dist/config.d.ts +2 -0
- package/dist/config.js +151 -0
- package/dist/doctor.d.ts +15 -0
- package/dist/doctor.js +22 -11
- package/dist/executor.d.ts +1 -0
- package/dist/executor.js +7 -0
- package/dist/http-transport.js +74 -12
- package/dist/index.d.ts +16 -1
- package/dist/index.js +643 -306
- package/dist/oauth.d.ts +38 -0
- package/dist/oauth.js +441 -0
- package/dist/provider-codegen.d.ts +27 -0
- package/dist/provider-codegen.js +335 -0
- package/dist/provider-login-guidance.js +9 -9
- package/dist/provider-status.js +5 -5
- package/dist/request-context.d.ts +7 -0
- package/dist/request-context.js +8 -0
- package/dist/request-helpers.js +2 -2
- package/dist/upstream-contracts.js +95 -116
- package/dist/workspace-registry.d.ts +63 -0
- package/dist/workspace-registry.js +417 -0
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/setup/status.schema.json +42 -1
package/dist/oauth.d.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
import type { Logger } from "./logger.js";
|
|
3
|
+
import { type RemoteOAuthConfig } from "./auth.js";
|
|
4
|
+
export interface OAuthServerOptions {
|
|
5
|
+
protectedPath: string;
|
|
6
|
+
config: RemoteOAuthConfig;
|
|
7
|
+
logger?: Logger;
|
|
8
|
+
}
|
|
9
|
+
export interface OAuthRequestContext {
|
|
10
|
+
req: IncomingMessage;
|
|
11
|
+
res: ServerResponse;
|
|
12
|
+
url: URL;
|
|
13
|
+
baseUrl: string;
|
|
14
|
+
}
|
|
15
|
+
export declare const OAUTH_CODE_TTL_MS: number;
|
|
16
|
+
export declare function generateSecret(bytes?: number): string;
|
|
17
|
+
export declare function hashSecret(secret: string): string;
|
|
18
|
+
export declare function isSecretHash(value: string): boolean;
|
|
19
|
+
export declare function verifySecret(secret: string, encodedHash: string): boolean;
|
|
20
|
+
export declare function redactSecret(value: string | null | undefined): string | null;
|
|
21
|
+
export declare function isLocalHost(host: string): boolean;
|
|
22
|
+
export declare function oauthBaseUrlFromRequest(req: IncomingMessage, config: RemoteOAuthConfig): string | null;
|
|
23
|
+
export declare class OAuthServer {
|
|
24
|
+
private readonly opts;
|
|
25
|
+
private readonly codes;
|
|
26
|
+
private readonly clients;
|
|
27
|
+
constructor(opts: OAuthServerOptions);
|
|
28
|
+
resourceMetadataUrl(baseUrl: string): string;
|
|
29
|
+
isOAuthPath(pathname: string): boolean;
|
|
30
|
+
handle(ctx: OAuthRequestContext): Promise<boolean>;
|
|
31
|
+
private protectedResourceMetadata;
|
|
32
|
+
private authorizationServerMetadata;
|
|
33
|
+
private registrationAllowedByPolicy;
|
|
34
|
+
private handleRegister;
|
|
35
|
+
private handleAuthorize;
|
|
36
|
+
private handleToken;
|
|
37
|
+
private pruneExpiredCodes;
|
|
38
|
+
}
|
package/dist/oauth.js
ADDED
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
import { createHash, randomBytes, randomUUID, scryptSync, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { URLSearchParams } from "node:url";
|
|
3
|
+
import { issueOAuthAccessToken, timingSafeStringEqual, } from "./auth.js";
|
|
4
|
+
export const OAUTH_CODE_TTL_MS = 5 * 60 * 1000;
|
|
5
|
+
const GENERATED_SECRET_BYTES = 32;
|
|
6
|
+
const SCRYPT_N = 32768;
|
|
7
|
+
const SCRYPT_R = 8;
|
|
8
|
+
const SCRYPT_P = 1;
|
|
9
|
+
const SCRYPT_KEYLEN = 32;
|
|
10
|
+
const SCRYPT_MAXMEM = 64 * 1024 * 1024;
|
|
11
|
+
export function generateSecret(bytes = GENERATED_SECRET_BYTES) {
|
|
12
|
+
return randomBytes(bytes).toString("base64url");
|
|
13
|
+
}
|
|
14
|
+
export function hashSecret(secret) {
|
|
15
|
+
const salt = randomBytes(16);
|
|
16
|
+
const hash = scryptSync(secret, salt, SCRYPT_KEYLEN, {
|
|
17
|
+
N: SCRYPT_N,
|
|
18
|
+
r: SCRYPT_R,
|
|
19
|
+
p: SCRYPT_P,
|
|
20
|
+
maxmem: SCRYPT_MAXMEM,
|
|
21
|
+
});
|
|
22
|
+
return `scrypt:N=${SCRYPT_N},r=${SCRYPT_R},p=${SCRYPT_P}:${salt.toString("base64url")}:${hash.toString("base64url")}`;
|
|
23
|
+
}
|
|
24
|
+
export function isSecretHash(value) {
|
|
25
|
+
return /^scrypt:N=\d+,r=\d+,p=\d+:[A-Za-z0-9_-]+:[A-Za-z0-9_-]+$/.test(value);
|
|
26
|
+
}
|
|
27
|
+
export function verifySecret(secret, encodedHash) {
|
|
28
|
+
const parts = encodedHash.split(":");
|
|
29
|
+
if (parts.length !== 4 || parts[0] !== "scrypt")
|
|
30
|
+
return false;
|
|
31
|
+
const params = Object.fromEntries(parts[1].split(",").map(entry => {
|
|
32
|
+
const [key, value] = entry.split("=");
|
|
33
|
+
return [key, Number(value)];
|
|
34
|
+
}));
|
|
35
|
+
if (!params.N || !params.r || !params.p)
|
|
36
|
+
return false;
|
|
37
|
+
const salt = Buffer.from(parts[2], "base64url");
|
|
38
|
+
const expected = Buffer.from(parts[3], "base64url");
|
|
39
|
+
const actual = scryptSync(secret, salt, expected.length, {
|
|
40
|
+
N: params.N,
|
|
41
|
+
r: params.r,
|
|
42
|
+
p: params.p,
|
|
43
|
+
maxmem: SCRYPT_MAXMEM,
|
|
44
|
+
});
|
|
45
|
+
if (actual.length !== expected.length)
|
|
46
|
+
return false;
|
|
47
|
+
return timingSafeEqual(actual, expected);
|
|
48
|
+
}
|
|
49
|
+
export function redactSecret(value) {
|
|
50
|
+
return value ? "<redacted>" : null;
|
|
51
|
+
}
|
|
52
|
+
function firstHeader(value) {
|
|
53
|
+
return Array.isArray(value) ? value[0] : value;
|
|
54
|
+
}
|
|
55
|
+
function methodNotAllowed(res) {
|
|
56
|
+
res.writeHead(405, { allow: "GET, POST", "content-type": "application/json" });
|
|
57
|
+
res.end(JSON.stringify({ error: "Method not allowed" }));
|
|
58
|
+
}
|
|
59
|
+
function jsonResponse(res, status, body) {
|
|
60
|
+
res.writeHead(status, { "content-type": "application/json" });
|
|
61
|
+
res.end(JSON.stringify(body));
|
|
62
|
+
}
|
|
63
|
+
function isHttpsOrLoopbackUrl(value) {
|
|
64
|
+
try {
|
|
65
|
+
const url = new URL(value);
|
|
66
|
+
if (url.protocol === "https:")
|
|
67
|
+
return true;
|
|
68
|
+
if (url.protocol !== "http:")
|
|
69
|
+
return false;
|
|
70
|
+
return ["localhost", "127.0.0.1", "::1", "[::1]"].includes(url.hostname);
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
export function isLocalHost(host) {
|
|
77
|
+
const hostname = host.split(":")[0]?.toLowerCase() ?? "";
|
|
78
|
+
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1";
|
|
79
|
+
}
|
|
80
|
+
export function oauthBaseUrlFromRequest(req, config) {
|
|
81
|
+
if (config.issuer && config.issuer !== "auto") {
|
|
82
|
+
try {
|
|
83
|
+
return new URL(config.issuer).origin;
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
const configured = process.env.LLM_GATEWAY_PUBLIC_URL;
|
|
90
|
+
if (configured) {
|
|
91
|
+
try {
|
|
92
|
+
return new URL(configured).origin;
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
const host = firstHeader(req.headers.host) ?? "127.0.0.1:3333";
|
|
99
|
+
if (!isLocalHost(host))
|
|
100
|
+
return null;
|
|
101
|
+
return `http://${host}`;
|
|
102
|
+
}
|
|
103
|
+
function extractStringArray(value, params, key) {
|
|
104
|
+
const values = Array.isArray(value) ? value : params.getAll(key);
|
|
105
|
+
return values.filter((item) => typeof item === "string" && item.length > 0);
|
|
106
|
+
}
|
|
107
|
+
async function readRawBody(req) {
|
|
108
|
+
return new Promise((resolve, reject) => {
|
|
109
|
+
const chunks = [];
|
|
110
|
+
req.on("data", chunk => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)));
|
|
111
|
+
req.on("error", reject);
|
|
112
|
+
req.on("end", () => resolve(chunks.length ? Buffer.concat(chunks).toString("utf8") : ""));
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
async function readOAuthBody(req) {
|
|
116
|
+
const raw = await readRawBody(req);
|
|
117
|
+
const contentType = firstHeader(req.headers["content-type"]) ?? "";
|
|
118
|
+
if (contentType.includes("application/json")) {
|
|
119
|
+
const parsed = JSON.parse(raw || "{}");
|
|
120
|
+
const params = new URLSearchParams();
|
|
121
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
122
|
+
if (typeof value === "string")
|
|
123
|
+
params.set(key, value);
|
|
124
|
+
else if (Array.isArray(value)) {
|
|
125
|
+
for (const item of value) {
|
|
126
|
+
if (typeof item === "string")
|
|
127
|
+
params.append(key, item);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return { params, json: parsed };
|
|
132
|
+
}
|
|
133
|
+
return { params: new URLSearchParams(raw), json: {} };
|
|
134
|
+
}
|
|
135
|
+
function basicClientCredentials(req) {
|
|
136
|
+
const authorization = firstHeader(req.headers.authorization);
|
|
137
|
+
if (!authorization?.startsWith("Basic "))
|
|
138
|
+
return null;
|
|
139
|
+
const raw = Buffer.from(authorization.slice("Basic ".length), "base64").toString("utf8");
|
|
140
|
+
const separator = raw.indexOf(":");
|
|
141
|
+
if (separator < 0)
|
|
142
|
+
return null;
|
|
143
|
+
return {
|
|
144
|
+
clientId: decodeURIComponent(raw.slice(0, separator)),
|
|
145
|
+
clientSecret: decodeURIComponent(raw.slice(separator + 1)),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
function oauthClientSecret(req, params) {
|
|
149
|
+
return params.get("client_secret") ?? basicClientCredentials(req)?.clientSecret ?? null;
|
|
150
|
+
}
|
|
151
|
+
function oauthClientId(req, params) {
|
|
152
|
+
return params.get("client_id") ?? basicClientCredentials(req)?.clientId ?? null;
|
|
153
|
+
}
|
|
154
|
+
function validPkceVerifier(verifier, challenge, method) {
|
|
155
|
+
if (!challenge)
|
|
156
|
+
return true;
|
|
157
|
+
if (!verifier)
|
|
158
|
+
return false;
|
|
159
|
+
if (method === "S256") {
|
|
160
|
+
const digest = createHash("sha256").update(verifier).digest("base64url");
|
|
161
|
+
return timingSafeStringEqual(digest, challenge);
|
|
162
|
+
}
|
|
163
|
+
if (!method || method === "plain") {
|
|
164
|
+
return timingSafeStringEqual(verifier, challenge);
|
|
165
|
+
}
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
function oauthErrorRedirect(redirectUri, error, state) {
|
|
169
|
+
const target = new URL(redirectUri);
|
|
170
|
+
target.searchParams.set("error", error);
|
|
171
|
+
if (state)
|
|
172
|
+
target.searchParams.set("state", state);
|
|
173
|
+
return target.toString();
|
|
174
|
+
}
|
|
175
|
+
function normalizeScopes(scope) {
|
|
176
|
+
const scopes = (scope ?? "mcp")
|
|
177
|
+
.split(/\s+/)
|
|
178
|
+
.map(item => item.trim())
|
|
179
|
+
.filter(Boolean);
|
|
180
|
+
return [...new Set(scopes.length ? scopes : ["mcp"])];
|
|
181
|
+
}
|
|
182
|
+
function scopesAllowed(requested, client) {
|
|
183
|
+
return requested.every(scope => client.scopes.has(scope));
|
|
184
|
+
}
|
|
185
|
+
function toRuntimeClient(client, allowPublicClients) {
|
|
186
|
+
return {
|
|
187
|
+
clientId: client.clientId,
|
|
188
|
+
clientSecretHash: client.clientSecretHash ?? null,
|
|
189
|
+
redirectUris: new Set(client.allowedRedirectUris),
|
|
190
|
+
scopes: new Set(client.scopes.length ? client.scopes : ["mcp"]),
|
|
191
|
+
issuedAt: Math.floor(Date.now() / 1000),
|
|
192
|
+
publicClient: allowPublicClients && !client.clientSecretHash,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
export class OAuthServer {
|
|
196
|
+
opts;
|
|
197
|
+
codes = new Map();
|
|
198
|
+
clients = new Map();
|
|
199
|
+
constructor(opts) {
|
|
200
|
+
this.opts = opts;
|
|
201
|
+
for (const client of opts.config.clients) {
|
|
202
|
+
this.clients.set(client.clientId, toRuntimeClient(client, opts.config.allowPublicClients));
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
resourceMetadataUrl(baseUrl) {
|
|
206
|
+
return `${baseUrl}/.well-known/oauth-protected-resource`;
|
|
207
|
+
}
|
|
208
|
+
isOAuthPath(pathname) {
|
|
209
|
+
return (pathname.startsWith("/.well-known/oauth-protected-resource") ||
|
|
210
|
+
pathname.startsWith("/.well-known/oauth-authorization-server") ||
|
|
211
|
+
pathname === "/.well-known/openid-configuration" ||
|
|
212
|
+
pathname.startsWith("/oauth/"));
|
|
213
|
+
}
|
|
214
|
+
async handle(ctx) {
|
|
215
|
+
const { req, res, url, baseUrl } = ctx;
|
|
216
|
+
if (url.pathname.startsWith("/.well-known/oauth-protected-resource")) {
|
|
217
|
+
if (req.method !== "GET") {
|
|
218
|
+
methodNotAllowed(res);
|
|
219
|
+
return true;
|
|
220
|
+
}
|
|
221
|
+
jsonResponse(res, 200, this.protectedResourceMetadata(baseUrl));
|
|
222
|
+
return true;
|
|
223
|
+
}
|
|
224
|
+
if (url.pathname.startsWith("/.well-known/oauth-authorization-server") ||
|
|
225
|
+
url.pathname === "/.well-known/openid-configuration") {
|
|
226
|
+
if (req.method !== "GET") {
|
|
227
|
+
methodNotAllowed(res);
|
|
228
|
+
return true;
|
|
229
|
+
}
|
|
230
|
+
jsonResponse(res, 200, this.authorizationServerMetadata(baseUrl));
|
|
231
|
+
return true;
|
|
232
|
+
}
|
|
233
|
+
if (url.pathname === "/oauth/register") {
|
|
234
|
+
await this.handleRegister(req, res);
|
|
235
|
+
return true;
|
|
236
|
+
}
|
|
237
|
+
if (url.pathname === "/oauth/authorize") {
|
|
238
|
+
await this.handleAuthorize(req, res);
|
|
239
|
+
return true;
|
|
240
|
+
}
|
|
241
|
+
if (url.pathname === "/oauth/token") {
|
|
242
|
+
await this.handleToken(req, res);
|
|
243
|
+
return true;
|
|
244
|
+
}
|
|
245
|
+
return false;
|
|
246
|
+
}
|
|
247
|
+
protectedResourceMetadata(baseUrl) {
|
|
248
|
+
return {
|
|
249
|
+
resource: `${baseUrl}${this.opts.protectedPath}`,
|
|
250
|
+
authorization_servers: [baseUrl],
|
|
251
|
+
scopes_supported: ["mcp", "workspace:admin"],
|
|
252
|
+
bearer_methods_supported: ["header"],
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
authorizationServerMetadata(baseUrl) {
|
|
256
|
+
return {
|
|
257
|
+
issuer: baseUrl,
|
|
258
|
+
authorization_endpoint: `${baseUrl}/oauth/authorize`,
|
|
259
|
+
token_endpoint: `${baseUrl}/oauth/token`,
|
|
260
|
+
registration_endpoint: `${baseUrl}/oauth/register`,
|
|
261
|
+
response_types_supported: ["code"],
|
|
262
|
+
grant_types_supported: ["authorization_code"],
|
|
263
|
+
token_endpoint_auth_methods_supported: this.opts.config.allowPublicClients
|
|
264
|
+
? ["client_secret_post", "client_secret_basic", "none"]
|
|
265
|
+
: ["client_secret_post", "client_secret_basic"],
|
|
266
|
+
code_challenge_methods_supported: this.opts.config.allowPlainPkce
|
|
267
|
+
? ["S256", "plain"]
|
|
268
|
+
: ["S256"],
|
|
269
|
+
scopes_supported: ["mcp", "workspace:admin"],
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
registrationAllowedByPolicy(req, params) {
|
|
273
|
+
const policy = this.opts.config.registrationPolicy;
|
|
274
|
+
if (policy === "open_dev") {
|
|
275
|
+
const host = firstHeader(req.headers.host) ?? "";
|
|
276
|
+
return isLocalHost(host) || process.env.LLM_GATEWAY_OAUTH_OPEN_DEV === "1";
|
|
277
|
+
}
|
|
278
|
+
if (policy === "static_clients")
|
|
279
|
+
return false;
|
|
280
|
+
const supplied = params.get("shared_secret") ?? params.get("registration_secret");
|
|
281
|
+
if (!supplied || supplied.includes("?"))
|
|
282
|
+
return false;
|
|
283
|
+
const hash = this.opts.config.sharedSecret?.enabled
|
|
284
|
+
? this.opts.config.sharedSecret.secretHash
|
|
285
|
+
: null;
|
|
286
|
+
return Boolean(hash && verifySecret(supplied, hash));
|
|
287
|
+
}
|
|
288
|
+
async handleRegister(req, res) {
|
|
289
|
+
if (req.method !== "POST") {
|
|
290
|
+
methodNotAllowed(res);
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
const { params, json } = await readOAuthBody(req);
|
|
294
|
+
if (new URL(req.url ?? "/", "http://localhost").searchParams.has("shared_secret")) {
|
|
295
|
+
jsonResponse(res, 400, { error: "invalid_request" });
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
if (!this.registrationAllowedByPolicy(req, params)) {
|
|
299
|
+
jsonResponse(res, 403, { error: "invalid_client" });
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
const redirectUris = extractStringArray(json.redirect_uris, params, "redirect_uris");
|
|
303
|
+
if (redirectUris.length === 0 || redirectUris.some(uri => !isHttpsOrLoopbackUrl(uri))) {
|
|
304
|
+
jsonResponse(res, 400, { error: "invalid_redirect_uri" });
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
const clientId = `llm-cli-gateway-${randomUUID()}`;
|
|
308
|
+
const clientSecret = this.opts.config.allowPublicClients ? null : generateSecret();
|
|
309
|
+
const issuedAt = Math.floor(Date.now() / 1000);
|
|
310
|
+
this.clients.set(clientId, {
|
|
311
|
+
clientId,
|
|
312
|
+
clientSecretHash: clientSecret ? hashSecret(clientSecret) : null,
|
|
313
|
+
redirectUris: new Set(redirectUris),
|
|
314
|
+
scopes: new Set(["mcp"]),
|
|
315
|
+
issuedAt,
|
|
316
|
+
publicClient: !clientSecret,
|
|
317
|
+
});
|
|
318
|
+
jsonResponse(res, 201, {
|
|
319
|
+
client_id: clientId,
|
|
320
|
+
...(clientSecret ? { client_secret: clientSecret } : {}),
|
|
321
|
+
client_id_issued_at: issuedAt,
|
|
322
|
+
grant_types: ["authorization_code"],
|
|
323
|
+
response_types: ["code"],
|
|
324
|
+
redirect_uris: redirectUris,
|
|
325
|
+
token_endpoint_auth_method: clientSecret ? "client_secret_post" : "none",
|
|
326
|
+
scope: "mcp",
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
async handleAuthorize(req, res) {
|
|
330
|
+
if (req.method !== "GET" && req.method !== "POST") {
|
|
331
|
+
methodNotAllowed(res);
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
const params = req.method === "POST"
|
|
335
|
+
? (await readOAuthBody(req)).params
|
|
336
|
+
: new URL(req.url ?? "/", "http://localhost").searchParams;
|
|
337
|
+
if (params.has("shared_secret")) {
|
|
338
|
+
jsonResponse(res, 400, { error: "invalid_request" });
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
const responseType = params.get("response_type");
|
|
342
|
+
const clientId = params.get("client_id") ?? "";
|
|
343
|
+
const redirectUri = params.get("redirect_uri");
|
|
344
|
+
const state = params.get("state");
|
|
345
|
+
if (!redirectUri) {
|
|
346
|
+
jsonResponse(res, 400, { error: "invalid_request" });
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
const client = this.clients.get(clientId);
|
|
350
|
+
if (!client || !client.redirectUris.has(redirectUri)) {
|
|
351
|
+
jsonResponse(res, 400, { error: "invalid_request" });
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
const method = params.get("code_challenge_method");
|
|
355
|
+
const codeChallenge = params.get("code_challenge");
|
|
356
|
+
if (responseType !== "code" ||
|
|
357
|
+
(this.opts.config.requirePkce && !codeChallenge) ||
|
|
358
|
+
(codeChallenge &&
|
|
359
|
+
method !== "S256" &&
|
|
360
|
+
!(this.opts.config.allowPlainPkce && method === "plain"))) {
|
|
361
|
+
res.writeHead(302, {
|
|
362
|
+
location: oauthErrorRedirect(redirectUri, "invalid_request", state),
|
|
363
|
+
});
|
|
364
|
+
res.end();
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
const requestedScopes = normalizeScopes(params.get("scope"));
|
|
368
|
+
if (!scopesAllowed(requestedScopes, client)) {
|
|
369
|
+
res.writeHead(302, {
|
|
370
|
+
location: oauthErrorRedirect(redirectUri, "invalid_scope", state),
|
|
371
|
+
});
|
|
372
|
+
res.end();
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
this.pruneExpiredCodes();
|
|
376
|
+
const code = randomUUID();
|
|
377
|
+
this.codes.set(code, {
|
|
378
|
+
clientId,
|
|
379
|
+
redirectUri,
|
|
380
|
+
scope: requestedScopes.join(" "),
|
|
381
|
+
codeChallenge,
|
|
382
|
+
codeChallengeMethod: method,
|
|
383
|
+
expiresAt: Date.now() + OAUTH_CODE_TTL_MS,
|
|
384
|
+
});
|
|
385
|
+
const target = new URL(redirectUri);
|
|
386
|
+
target.searchParams.set("code", code);
|
|
387
|
+
if (state)
|
|
388
|
+
target.searchParams.set("state", state);
|
|
389
|
+
res.writeHead(302, { location: target.toString() });
|
|
390
|
+
res.end();
|
|
391
|
+
}
|
|
392
|
+
async handleToken(req, res) {
|
|
393
|
+
if (req.method !== "POST") {
|
|
394
|
+
methodNotAllowed(res);
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
if (new URL(req.url ?? "/", "http://localhost").searchParams.has("client_secret")) {
|
|
398
|
+
jsonResponse(res, 400, { error: "invalid_request" });
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
const { params } = await readOAuthBody(req);
|
|
402
|
+
const code = params.get("code") ?? "";
|
|
403
|
+
const entry = this.codes.get(code);
|
|
404
|
+
const clientId = oauthClientId(req, params);
|
|
405
|
+
const client = clientId ? this.clients.get(clientId) : undefined;
|
|
406
|
+
const clientSecret = oauthClientSecret(req, params);
|
|
407
|
+
const secretOk = client?.publicClient ||
|
|
408
|
+
Boolean(client?.clientSecretHash &&
|
|
409
|
+
clientSecret &&
|
|
410
|
+
verifySecret(clientSecret, client.clientSecretHash));
|
|
411
|
+
if (params.get("grant_type") !== "authorization_code" ||
|
|
412
|
+
!entry ||
|
|
413
|
+
entry.expiresAt < Date.now() ||
|
|
414
|
+
!client ||
|
|
415
|
+
client.clientId !== entry.clientId ||
|
|
416
|
+
!secretOk ||
|
|
417
|
+
params.get("redirect_uri") !== entry.redirectUri ||
|
|
418
|
+
!validPkceVerifier(params.get("code_verifier"), entry.codeChallenge, entry.codeChallengeMethod)) {
|
|
419
|
+
jsonResponse(res, 400, { error: "invalid_grant" });
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
this.codes.delete(code);
|
|
423
|
+
const token = issueOAuthAccessToken({
|
|
424
|
+
clientId: client.clientId,
|
|
425
|
+
scopes: normalizeScopes(entry.scope),
|
|
426
|
+
ttlSeconds: this.opts.config.tokenTtlSeconds,
|
|
427
|
+
});
|
|
428
|
+
jsonResponse(res, 200, {
|
|
429
|
+
access_token: token.accessToken,
|
|
430
|
+
token_type: "Bearer",
|
|
431
|
+
expires_in: token.expiresIn,
|
|
432
|
+
scope: token.scope,
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
pruneExpiredCodes(now = Date.now()) {
|
|
436
|
+
for (const [code, entry] of this.codes) {
|
|
437
|
+
if (entry.expiresAt < now)
|
|
438
|
+
this.codes.delete(code);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { z } from "zod/v3";
|
|
2
|
+
import type { CliContract } from "./upstream-contracts.js";
|
|
3
|
+
export type FlagEmitRule = "value_if_present" | "value_if_defined" | "flag_if_true" | "csv_if_nonempty" | "repeat_if_nonempty";
|
|
4
|
+
export interface FlagGenerationMeta {
|
|
5
|
+
flag: string;
|
|
6
|
+
requestParameter: string;
|
|
7
|
+
emit: FlagEmitRule;
|
|
8
|
+
inputType: "string" | "number" | "boolean" | "string[]";
|
|
9
|
+
describe?: string;
|
|
10
|
+
minLength?: number;
|
|
11
|
+
numeric?: {
|
|
12
|
+
int?: boolean;
|
|
13
|
+
positive?: boolean;
|
|
14
|
+
safe?: boolean;
|
|
15
|
+
max?: number;
|
|
16
|
+
min?: number;
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
export declare function buildArgvFromGeneration(contract: CliContract, generation: readonly FlagGenerationMeta[], params: Record<string, unknown>): string[];
|
|
20
|
+
export declare function deriveZodShapeFromGeneration(contract: CliContract, generation: readonly FlagGenerationMeta[]): Record<string, z.ZodTypeAny>;
|
|
21
|
+
export declare const GROK_GEN_OUTPUT_FORMAT: readonly FlagGenerationMeta[];
|
|
22
|
+
export declare const GROK_GEN_MAIN: readonly FlagGenerationMeta[];
|
|
23
|
+
export declare const GROK_GEN_PROMPT_FILE: readonly FlagGenerationMeta[];
|
|
24
|
+
export declare const GROK_GEN_SINGLE: readonly FlagGenerationMeta[];
|
|
25
|
+
export declare const GROK_GEN_TAIL: readonly FlagGenerationMeta[];
|
|
26
|
+
export declare const GROK_FLAG_GENERATION: readonly FlagGenerationMeta[];
|
|
27
|
+
export declare const UNGENERATED_GROK_FLAGS: readonly string[];
|