metergraph-cli 0.1.0 → 0.2.0-preview.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +500 -33
- package/assets/skill/SKILL.md +17 -1
- package/assets/skill/manifest.json +3 -3
- package/package.json +2 -2
- package/src/args.js +323 -17
- package/src/auth-binding.js +202 -0
- package/src/auth-browser.js +74 -0
- package/src/auth-callback.js +177 -0
- package/src/auth-login.js +410 -0
- package/src/auth-oauth.js +462 -0
- package/src/auth-session.js +174 -0
- package/src/auth-store.js +397 -0
- package/src/cli.js +78 -2
- package/src/constants.js +99 -4
- package/src/deployment-credential.js +207 -0
- package/src/deployment-route.js +449 -0
- package/src/doctor.js +10 -0
- package/src/http.js +1 -1
- package/src/output.js +454 -2
- package/src/read-contract.js +609 -0
- package/src/read-output.js +216 -0
- package/src/read.js +325 -0
- package/src/setup-deployment.js +178 -0
- package/src/setup-env-acl.js +99 -0
- package/src/setup-env-git.js +103 -0
- package/src/setup-env-parse.js +169 -0
- package/src/setup-env.js +685 -0
- package/src/setup-state.js +141 -0
- package/src/setup.js +326 -0
- package/src/skill-bundle.js +1 -1
- package/src/trace-contract.js +129 -0
- package/src/trace-open.js +36 -0
- package/src/transport.js +171 -0
- package/src/verify-output.js +36 -0
- package/src/verify.js +120 -0
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
import { Stop, isSlot } from "./auth-store.js";
|
|
6
|
+
import { SUPPORTED_PROFILES } from "./constants.js";
|
|
7
|
+
import { parseOrigin } from "./origin.js";
|
|
8
|
+
|
|
9
|
+
// The project binding, .metergraph/project.json, names which origin,
|
|
10
|
+
// workspace and profile a project is signed in to and which credential slot
|
|
11
|
+
// in the user's private store holds the grant. It holds no credential, user
|
|
12
|
+
// name or absolute path, so it is safe to commit. Other files in .metergraph,
|
|
13
|
+
// such as skill receipts, are never touched.
|
|
14
|
+
|
|
15
|
+
export const BINDING_PATH = ".metergraph/project.json";
|
|
16
|
+
const DIR = ".metergraph";
|
|
17
|
+
const FILE = "project.json";
|
|
18
|
+
const LOCK = "project.lock";
|
|
19
|
+
const MAX_BYTES = 16 * 1024;
|
|
20
|
+
const KEYS = ["credential_slot", "deployment_profile", "origin", "schema_version", "workspace_id"].join();
|
|
21
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
22
|
+
const NOFOLLOW = fs.constants.O_NOFOLLOW ?? 0;
|
|
23
|
+
|
|
24
|
+
export function resolveProject(project) {
|
|
25
|
+
try {
|
|
26
|
+
const real = fs.realpathSync(path.resolve(project ?? process.cwd()));
|
|
27
|
+
if (fs.statSync(real).isDirectory()) return real;
|
|
28
|
+
} catch {
|
|
29
|
+
// Fall through to the fixed error.
|
|
30
|
+
}
|
|
31
|
+
throw new Stop("invalid_input", "invalid_project");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Returns { binding, content } or null when the project is not bound.
|
|
35
|
+
export function readBinding(root) {
|
|
36
|
+
try {
|
|
37
|
+
const dir = lstatOrNull(path.join(root, DIR));
|
|
38
|
+
if (dir === null) return null;
|
|
39
|
+
if (!dir.isDirectory()) throw new Stop("conflict", "unsafe_path");
|
|
40
|
+
const content = readRegular(path.join(root, DIR, FILE));
|
|
41
|
+
if (content === null) return null;
|
|
42
|
+
const binding = parse(content);
|
|
43
|
+
if (binding === null) throw new Stop("conflict", "binding_invalid");
|
|
44
|
+
return { binding, content };
|
|
45
|
+
} catch (error) {
|
|
46
|
+
if (error instanceof Stop) throw error;
|
|
47
|
+
throw new Stop("filesystem_error", "read_failed");
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function bindingFor({ origin, workspaceId, profile, slot }) {
|
|
52
|
+
return {
|
|
53
|
+
schema_version: 1,
|
|
54
|
+
origin,
|
|
55
|
+
workspace_id: workspaceId,
|
|
56
|
+
deployment_profile: profile,
|
|
57
|
+
credential_slot: slot,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Writes binding in place of previous (the { content } readBinding saw, or
|
|
62
|
+
// null). Under the project lock, the file must still be exactly previous, so
|
|
63
|
+
// a concurrent change is never overwritten. A failed write leaves the
|
|
64
|
+
// previous file and removes a .metergraph directory this call created.
|
|
65
|
+
export function writeBinding(root, binding, previous) {
|
|
66
|
+
const content = Buffer.from(`${JSON.stringify(binding, null, 2)}\n`);
|
|
67
|
+
if (parse(content) === null) throw new Error("binding is not valid");
|
|
68
|
+
const dir = path.join(root, DIR);
|
|
69
|
+
let created = false;
|
|
70
|
+
try {
|
|
71
|
+
try {
|
|
72
|
+
fs.mkdirSync(dir);
|
|
73
|
+
created = true;
|
|
74
|
+
} catch (error) {
|
|
75
|
+
if (error.code !== "EEXIST") throw error;
|
|
76
|
+
if (!fs.lstatSync(dir).isDirectory()) throw new Stop("conflict", "unsafe_path");
|
|
77
|
+
}
|
|
78
|
+
withProjectLock(dir, () => {
|
|
79
|
+
expectUnchanged(dir, previous);
|
|
80
|
+
replace(dir, content);
|
|
81
|
+
});
|
|
82
|
+
} catch (error) {
|
|
83
|
+
if (created) {
|
|
84
|
+
try {
|
|
85
|
+
fs.rmdirSync(dir);
|
|
86
|
+
} catch {
|
|
87
|
+
// Not empty. An empty directory is harmless.
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (error instanceof Stop) throw error;
|
|
91
|
+
throw new Stop("filesystem_error", "binding_write_failed");
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Removes the binding if it is still exactly previous.
|
|
96
|
+
export function removeBinding(root, previous) {
|
|
97
|
+
const dir = path.join(root, DIR);
|
|
98
|
+
try {
|
|
99
|
+
withProjectLock(dir, () => {
|
|
100
|
+
expectUnchanged(dir, previous);
|
|
101
|
+
fs.unlinkSync(path.join(dir, FILE));
|
|
102
|
+
});
|
|
103
|
+
} catch (error) {
|
|
104
|
+
if (error instanceof Stop) throw error;
|
|
105
|
+
throw new Stop("filesystem_error", "binding_write_failed");
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function withProjectLock(dir, work) {
|
|
110
|
+
const lock = path.join(dir, LOCK);
|
|
111
|
+
try {
|
|
112
|
+
fs.closeSync(fs.openSync(lock, "wx"));
|
|
113
|
+
} catch (error) {
|
|
114
|
+
if (error.code === "EEXIST") throw new Stop("conflict", "binding_locked");
|
|
115
|
+
throw error;
|
|
116
|
+
}
|
|
117
|
+
try {
|
|
118
|
+
return work();
|
|
119
|
+
} finally {
|
|
120
|
+
try {
|
|
121
|
+
fs.unlinkSync(lock);
|
|
122
|
+
} catch {
|
|
123
|
+
// Reported by the next run as a held lock.
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function expectUnchanged(dir, previous) {
|
|
129
|
+
const now = readRegular(path.join(dir, FILE));
|
|
130
|
+
const same = previous === null ? now === null : now !== null && now.equals(previous.content);
|
|
131
|
+
if (!same) throw new Stop("conflict", "binding_changed");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function replace(dir, content) {
|
|
135
|
+
const dest = path.join(dir, FILE);
|
|
136
|
+
const temp = path.join(dir, `.${FILE}.${randomBytes(6).toString("hex")}.tmp`);
|
|
137
|
+
const fd = fs.openSync(temp, "wx", 0o666);
|
|
138
|
+
try {
|
|
139
|
+
try {
|
|
140
|
+
fs.writeFileSync(fd, content);
|
|
141
|
+
fs.fsyncSync(fd);
|
|
142
|
+
} finally {
|
|
143
|
+
fs.closeSync(fd);
|
|
144
|
+
}
|
|
145
|
+
fs.renameSync(temp, dest);
|
|
146
|
+
} catch (error) {
|
|
147
|
+
try {
|
|
148
|
+
fs.unlinkSync(temp);
|
|
149
|
+
} catch {
|
|
150
|
+
// Already renamed or never created.
|
|
151
|
+
}
|
|
152
|
+
throw error;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Accepts only the exact shape this CLI writes.
|
|
157
|
+
function parse(content) {
|
|
158
|
+
let value;
|
|
159
|
+
try {
|
|
160
|
+
value = JSON.parse(content.toString("utf8"));
|
|
161
|
+
} catch {
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return null;
|
|
165
|
+
if (Object.keys(value).sort().join() !== KEYS) return null;
|
|
166
|
+
if (value.schema_version !== 1) return null;
|
|
167
|
+
if (typeof value.origin !== "string" || parseOrigin(value.origin) !== value.origin) return null;
|
|
168
|
+
if (typeof value.workspace_id !== "string" || !UUID.test(value.workspace_id)) return null;
|
|
169
|
+
if (!SUPPORTED_PROFILES.includes(value.deployment_profile)) return null;
|
|
170
|
+
if (!isSlot(value.credential_slot)) return null;
|
|
171
|
+
return value;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function lstatOrNull(file) {
|
|
175
|
+
try {
|
|
176
|
+
return fs.lstatSync(file);
|
|
177
|
+
} catch (error) {
|
|
178
|
+
if (error.code === "ENOENT") return null;
|
|
179
|
+
throw error;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function readRegular(file) {
|
|
184
|
+
const stat = lstatOrNull(file);
|
|
185
|
+
if (stat === null) return null;
|
|
186
|
+
if (!stat.isFile()) throw new Stop("conflict", "unsafe_path");
|
|
187
|
+
let fd;
|
|
188
|
+
try {
|
|
189
|
+
fd = fs.openSync(file, fs.constants.O_RDONLY | NOFOLLOW);
|
|
190
|
+
} catch (error) {
|
|
191
|
+
if (error.code === "ELOOP") throw new Stop("conflict", "unsafe_path");
|
|
192
|
+
throw error;
|
|
193
|
+
}
|
|
194
|
+
try {
|
|
195
|
+
const opened = fs.fstatSync(fd);
|
|
196
|
+
if (!opened.isFile()) throw new Stop("conflict", "unsafe_path");
|
|
197
|
+
if (opened.size > MAX_BYTES) throw new Stop("conflict", "binding_invalid");
|
|
198
|
+
return fs.readFileSync(fd);
|
|
199
|
+
} finally {
|
|
200
|
+
fs.closeSync(fd);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import childProcess from "node:child_process";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
// Opens a URL with the operating system's own launcher: a fixed executable
|
|
5
|
+
// and an argument array, never a shell. The URL is the authorization request
|
|
6
|
+
// (public client ID, state and PKCE challenge); no token, code or verifier is
|
|
7
|
+
// ever on a command line. Resolves true when the launcher started, false
|
|
8
|
+
// when it could not be started. A started launcher does not prove that a
|
|
9
|
+
// browser opened, so callers still wait for the callback with a deadline.
|
|
10
|
+
export function openBrowser(url) {
|
|
11
|
+
const { command, args } = launcher();
|
|
12
|
+
return new Promise((resolve) => {
|
|
13
|
+
let child;
|
|
14
|
+
try {
|
|
15
|
+
child = childProcess.spawn(command, [...args, url], {
|
|
16
|
+
shell: false,
|
|
17
|
+
detached: true,
|
|
18
|
+
stdio: "ignore",
|
|
19
|
+
windowsHide: true,
|
|
20
|
+
});
|
|
21
|
+
} catch {
|
|
22
|
+
resolve(false);
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
child.once("error", () => resolve(false));
|
|
26
|
+
child.once("spawn", () => {
|
|
27
|
+
child.unref();
|
|
28
|
+
resolve(true);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function launcher() {
|
|
34
|
+
if (process.platform === "darwin") return { command: "/usr/bin/open", args: [] };
|
|
35
|
+
if (process.platform === "win32") {
|
|
36
|
+
return {
|
|
37
|
+
command: path.win32.join(systemRoot(), "System32", "rundll32.exe"),
|
|
38
|
+
args: ["url.dll,FileProtocolHandler"],
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
return { command: "/usr/bin/xdg-open", args: [] };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// The Windows directory, from SystemRoot only when it is a plain absolute
|
|
45
|
+
// drive path, otherwise the standard location.
|
|
46
|
+
export function systemRoot() {
|
|
47
|
+
const value = process.env.SystemRoot;
|
|
48
|
+
if (typeof value === "string" && /^[A-Za-z]:\\[A-Za-z0-9 ._\\-]*$/.test(value) && !value.includes("..")) {
|
|
49
|
+
return value;
|
|
50
|
+
}
|
|
51
|
+
return "C:\\Windows";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Variables whose presence means the browser a person uses is not on this
|
|
55
|
+
// machine, or no person is present. Only presence is checked; values are
|
|
56
|
+
// never read into output.
|
|
57
|
+
const REMOTE_MARKERS = [
|
|
58
|
+
["ssh_session", ["SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY"]],
|
|
59
|
+
["cloud_workspace", ["CODESPACES", "GITPOD_WORKSPACE_ID", "CLOUD_SHELL"]],
|
|
60
|
+
["ci_environment", ["CI"]],
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
// Returns null for an ordinary local session, or a fixed reason token.
|
|
64
|
+
export function detectRemoteSession(env = process.env) {
|
|
65
|
+
for (const [reason, names] of REMOTE_MARKERS) {
|
|
66
|
+
for (const name of names) {
|
|
67
|
+
const value = env[name];
|
|
68
|
+
if (typeof value === "string" && value !== "" && value !== "0" && value.toLowerCase() !== "false") {
|
|
69
|
+
return reason;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { timingSafeEqual } from "node:crypto";
|
|
2
|
+
import http from "node:http";
|
|
3
|
+
|
|
4
|
+
// Loopback listener for the OAuth redirect (RFC 8252 section 7.3). It binds
|
|
5
|
+
// 127.0.0.1 on an ephemeral port and accepts exactly one callback: a GET on
|
|
6
|
+
// the fixed path, with the exact Host, one state that matches, and either one
|
|
7
|
+
// code or one error. Anything else is answered with a fixed page and the
|
|
8
|
+
// listener keeps waiting, so a stray or forged request cannot end the flow.
|
|
9
|
+
// Pages are static: no code, state or other request value is reflected, and
|
|
10
|
+
// nothing about a request is logged.
|
|
11
|
+
|
|
12
|
+
export const CALLBACK_PATH = "/callback";
|
|
13
|
+
const HOST = "127.0.0.1";
|
|
14
|
+
const MAX_URL_LENGTH = 4096;
|
|
15
|
+
const MAX_CODE_LENGTH = 2048;
|
|
16
|
+
const ALLOWED_PARAMS = new Set(["code", "state", "error", "error_description", "error_uri", "iss"]);
|
|
17
|
+
|
|
18
|
+
const PAGE_HEADERS = {
|
|
19
|
+
"content-type": "text/html; charset=utf-8",
|
|
20
|
+
"cache-control": "no-store",
|
|
21
|
+
"content-security-policy": "default-src 'none'",
|
|
22
|
+
"referrer-policy": "no-referrer",
|
|
23
|
+
"x-content-type-options": "nosniff",
|
|
24
|
+
"x-frame-options": "DENY",
|
|
25
|
+
connection: "close",
|
|
26
|
+
};
|
|
27
|
+
const page = (text) =>
|
|
28
|
+
`<!doctype html><meta charset="utf-8"><title>Metergraph CLI</title><p>${text}</p>\n`;
|
|
29
|
+
const PAGES = {
|
|
30
|
+
done: page("Metergraph CLI: authorization received. You can close this tab and return to the terminal."),
|
|
31
|
+
failed: page("Metergraph CLI: authorization did not complete. Return to the terminal for details."),
|
|
32
|
+
invalid: page("Metergraph CLI: this request was not accepted."),
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
// Starts listening. Resolves with { redirectUri, wait, close }.
|
|
36
|
+
// wait({ state, issuer, requireIss, timeoutMs, cancel }) arms the listener
|
|
37
|
+
// synchronously: from the moment it returns, a matching callback is
|
|
38
|
+
// accepted and the timeout is running. Call it before the authorization
|
|
39
|
+
// URL is opened or printed, so a fast browser can never reach an unarmed
|
|
40
|
+
// listener. It may be called once. The promise resolves with
|
|
41
|
+
// { kind: "code", code }
|
|
42
|
+
// { kind: "denied" } the user or server declined
|
|
43
|
+
// { kind: "error", reason } authorization_error,
|
|
44
|
+
// callback_issuer_mismatch or
|
|
45
|
+
// callback_invalid
|
|
46
|
+
// { kind: "timeout" } or { kind: "cancelled" }
|
|
47
|
+
// close() stops the listener, destroys every socket and settles a pending
|
|
48
|
+
// wait as cancelled, clearing its timer. It is safe to call more than once
|
|
49
|
+
// and must be called on every outcome, including early returns.
|
|
50
|
+
export async function startCallbackListener() {
|
|
51
|
+
const sockets = new Set();
|
|
52
|
+
let handler = (request, response) => respond(response, 503, PAGES.invalid);
|
|
53
|
+
const server = http.createServer(
|
|
54
|
+
{ maxHeaderSize: 8192, requestTimeout: 10000, headersTimeout: 10000 },
|
|
55
|
+
(request, response) => handler(request, response),
|
|
56
|
+
);
|
|
57
|
+
server.on("connection", (socket) => {
|
|
58
|
+
sockets.add(socket);
|
|
59
|
+
socket.on("close", () => sockets.delete(socket));
|
|
60
|
+
});
|
|
61
|
+
server.on("clientError", (error, socket) => socket.destroy());
|
|
62
|
+
|
|
63
|
+
await new Promise((resolve, reject) => {
|
|
64
|
+
server.once("error", reject);
|
|
65
|
+
server.listen(0, HOST, () => {
|
|
66
|
+
server.removeListener("error", reject);
|
|
67
|
+
resolve();
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
const { port } = server.address();
|
|
71
|
+
const host = `${HOST}:${port}`;
|
|
72
|
+
const redirectUri = `http://${host}${CALLBACK_PATH}`;
|
|
73
|
+
|
|
74
|
+
let closed = false;
|
|
75
|
+
let armed = false;
|
|
76
|
+
// Settles the pending wait, if any. Replaced when wait() arms.
|
|
77
|
+
let settlePending = () => {};
|
|
78
|
+
|
|
79
|
+
const close = async () => {
|
|
80
|
+
settlePending({ kind: "cancelled" });
|
|
81
|
+
if (closed) return;
|
|
82
|
+
closed = true;
|
|
83
|
+
const done = new Promise((resolve) => server.close(() => resolve()));
|
|
84
|
+
for (const socket of sockets) socket.destroy();
|
|
85
|
+
await done;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const wait = ({ state, issuer, requireIss, timeoutMs, cancel }) => {
|
|
89
|
+
if (armed) throw new Error("callback listener is already armed");
|
|
90
|
+
armed = true;
|
|
91
|
+
// The executor runs synchronously, so the handler and timer are in
|
|
92
|
+
// place before wait() returns.
|
|
93
|
+
return new Promise((resolve) => {
|
|
94
|
+
let settled = false;
|
|
95
|
+
let timer = null;
|
|
96
|
+
const finish = (result) => {
|
|
97
|
+
if (settled) return;
|
|
98
|
+
settled = true;
|
|
99
|
+
if (timer !== null) clearTimeout(timer);
|
|
100
|
+
cancel?.removeEventListener("abort", onCancel);
|
|
101
|
+
resolve(result);
|
|
102
|
+
};
|
|
103
|
+
const onCancel = () => finish({ kind: "cancelled" });
|
|
104
|
+
settlePending = finish;
|
|
105
|
+
|
|
106
|
+
handler = (request, response) => {
|
|
107
|
+
if (settled) return respond(response, 410, PAGES.invalid);
|
|
108
|
+
const verdict = inspect(request, { host, state, issuer, requireIss });
|
|
109
|
+
if (verdict.result === undefined) return respond(response, verdict.status, PAGES.invalid);
|
|
110
|
+
const result = verdict.result;
|
|
111
|
+
respond(response, verdict.status, result.kind === "code" ? PAGES.done : PAGES.failed);
|
|
112
|
+
finish(result);
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
if (closed || cancel?.aborted) {
|
|
116
|
+
onCancel();
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
timer = setTimeout(() => finish({ kind: "timeout" }), timeoutMs);
|
|
120
|
+
cancel?.addEventListener("abort", onCancel, { once: true });
|
|
121
|
+
});
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
return { redirectUri, wait, close };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function respond(response, status, body) {
|
|
128
|
+
response.writeHead(status, { ...PAGE_HEADERS, "content-length": Buffer.byteLength(body) });
|
|
129
|
+
response.end(body);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Returns { status } for a request that is ignored, or { status, result }
|
|
133
|
+
// for the one request that ends the flow.
|
|
134
|
+
function inspect(request, { host, state, issuer, requireIss }) {
|
|
135
|
+
const url = request.url ?? "";
|
|
136
|
+
if (url.length > MAX_URL_LENGTH) return { status: 414 };
|
|
137
|
+
if (request.method !== "GET") return { status: 405 };
|
|
138
|
+
if (request.headers.host !== host) return { status: 400 };
|
|
139
|
+
const mark = url.indexOf("?");
|
|
140
|
+
const pathname = mark === -1 ? url : url.slice(0, mark);
|
|
141
|
+
if (pathname !== CALLBACK_PATH) return { status: 404 };
|
|
142
|
+
|
|
143
|
+
let params;
|
|
144
|
+
try {
|
|
145
|
+
params = new URLSearchParams(mark === -1 ? "" : url.slice(mark + 1));
|
|
146
|
+
} catch {
|
|
147
|
+
return { status: 400 };
|
|
148
|
+
}
|
|
149
|
+
const seen = new Set();
|
|
150
|
+
for (const key of params.keys()) {
|
|
151
|
+
if (!ALLOWED_PARAMS.has(key) || seen.has(key)) return { status: 400 };
|
|
152
|
+
seen.add(key);
|
|
153
|
+
}
|
|
154
|
+
// Only a request that carries this flow's exact state can end it.
|
|
155
|
+
if (!seen.has("state") || !sameText(params.get("state"), state)) return { status: 400 };
|
|
156
|
+
|
|
157
|
+
if (seen.has("iss") ? params.get("iss") !== issuer : requireIss) {
|
|
158
|
+
return { status: 400, result: { kind: "error", reason: "callback_issuer_mismatch" } };
|
|
159
|
+
}
|
|
160
|
+
if (seen.has("error")) {
|
|
161
|
+
if (seen.has("code")) return { status: 400, result: { kind: "error", reason: "callback_invalid" } };
|
|
162
|
+
return params.get("error") === "access_denied"
|
|
163
|
+
? { status: 200, result: { kind: "denied" } }
|
|
164
|
+
: { status: 200, result: { kind: "error", reason: "authorization_error" } };
|
|
165
|
+
}
|
|
166
|
+
const code = params.get("code");
|
|
167
|
+
if (code === null || code.length === 0 || code.length > MAX_CODE_LENGTH || /[^\x21-\x7e]/.test(code)) {
|
|
168
|
+
return { status: 400, result: { kind: "error", reason: "callback_invalid" } };
|
|
169
|
+
}
|
|
170
|
+
return { status: 200, result: { kind: "code", code } };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function sameText(actual, expected) {
|
|
174
|
+
const a = Buffer.from(actual);
|
|
175
|
+
const b = Buffer.from(expected);
|
|
176
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
177
|
+
}
|