faceless-cli 1.1.7 → 1.1.9
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/package.json +1 -1
- package/src/client.mjs +96 -103
- package/src/config.mjs +29 -29
- package/src/generated/operations.json +1 -1
- package/src/index.mjs +521 -633
- package/src/mcp/stdio.mjs +76 -85
- package/src/oauth.mjs +306 -0
- package/src/output.mjs +83 -86
package/src/mcp/stdio.mjs
CHANGED
|
@@ -2,112 +2,103 @@ import fs from "node:fs";
|
|
|
2
2
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
3
3
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
4
|
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
5
|
-
import { resolveApiKey, resolveBaseUrl } from "../config.mjs";
|
|
5
|
+
import { loadConfig, resolveApiKey, resolveBaseUrl } from "../config.mjs";
|
|
6
6
|
import { CliError, request } from "../client.mjs";
|
|
7
7
|
|
|
8
8
|
const pkg = JSON.parse(fs.readFileSync(new URL("../../package.json", import.meta.url), "utf8"));
|
|
9
9
|
let spec;
|
|
10
10
|
try {
|
|
11
|
-
|
|
12
|
-
fs.readFileSync(new URL("../generated/operations.json", import.meta.url), "utf8")
|
|
13
|
-
);
|
|
11
|
+
spec = JSON.parse(fs.readFileSync(new URL("../generated/operations.json", import.meta.url), "utf8"));
|
|
14
12
|
} catch {
|
|
15
|
-
|
|
16
|
-
'cli/src/generated/operations.json is missing. Run "npm run generate:agents" in the repo root to generate it.'
|
|
17
|
-
);
|
|
13
|
+
throw new Error('cli/src/generated/operations.json is missing. Run "npm run generate:agents" in the repo root to generate it.');
|
|
18
14
|
}
|
|
19
15
|
const operations = spec.operations.filter((op) => op.mcp && op.mcp.enabled);
|
|
20
16
|
|
|
21
17
|
function toolInputSchema(op) {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
]),
|
|
31
|
-
];
|
|
32
|
-
const schema = { type: "object", properties, additionalProperties: false };
|
|
33
|
-
if (required.length) schema.required = required;
|
|
34
|
-
return schema;
|
|
18
|
+
const properties = {
|
|
19
|
+
...((op.querySchema && op.querySchema.properties) || {}),
|
|
20
|
+
...((op.requestSchema && op.requestSchema.properties) || {}),
|
|
21
|
+
};
|
|
22
|
+
const required = [...new Set([...((op.querySchema && op.querySchema.required) || []), ...((op.requestSchema && op.requestSchema.required) || [])])];
|
|
23
|
+
const schema = { type: "object", properties, additionalProperties: false };
|
|
24
|
+
if (required.length) schema.required = required;
|
|
25
|
+
return schema;
|
|
35
26
|
}
|
|
36
27
|
|
|
37
28
|
function errorResult(type, message) {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
29
|
+
return {
|
|
30
|
+
isError: true,
|
|
31
|
+
content: [
|
|
32
|
+
{
|
|
33
|
+
type: "text",
|
|
34
|
+
text: JSON.stringify({ success: false, error: { type, message } }),
|
|
35
|
+
},
|
|
36
|
+
],
|
|
37
|
+
};
|
|
47
38
|
}
|
|
48
39
|
|
|
49
40
|
export async function runMcpServer() {
|
|
50
|
-
|
|
51
|
-
{ name: "faceless", version: pkg.version },
|
|
52
|
-
{ capabilities: { tools: {} } }
|
|
53
|
-
);
|
|
41
|
+
const server = new Server({ name: "faceless", version: pkg.version }, { capabilities: { tools: {} } });
|
|
54
42
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
43
|
+
const tools = operations.map((op) => ({
|
|
44
|
+
name: op.mcp.name,
|
|
45
|
+
description: `${op.summary}. ${op.description}`,
|
|
46
|
+
inputSchema: toolInputSchema(op),
|
|
47
|
+
}));
|
|
60
48
|
|
|
61
|
-
|
|
49
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools }));
|
|
62
50
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
51
|
+
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
52
|
+
const name = req.params.name;
|
|
53
|
+
const args = { ...(req.params.arguments || {}) };
|
|
54
|
+
const op = operations.find((o) => o.mcp.name === name);
|
|
55
|
+
if (!op) return errorResult("invalid_input", `Unknown tool: ${name}`);
|
|
68
56
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
57
|
+
// A null key is fine when `faceless login` stored an OAuth session: request() resolves and
|
|
58
|
+
// refreshes it. Only fail fast when NEITHER credential exists, and say the browser flow first -
|
|
59
|
+
// it is the path an agent can actually complete without a human pasting a key.
|
|
60
|
+
const apiKey = resolveApiKey({});
|
|
61
|
+
if (!apiKey && !loadConfig().oauth?.refreshToken) {
|
|
62
|
+
return errorResult(
|
|
63
|
+
"unauthorized",
|
|
64
|
+
'Not authenticated. Run "faceless login" (opens a browser for OAuth - no key to paste), or set the FACELESS_API_KEY environment variable with a key from https://faceless.so/team.'
|
|
65
|
+
);
|
|
66
|
+
}
|
|
76
67
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
68
|
+
let path = op.path;
|
|
69
|
+
for (const match of op.path.matchAll(/\{(\w+)\}/g)) {
|
|
70
|
+
const param = match[1];
|
|
71
|
+
if (args[param] === undefined || args[param] === null) {
|
|
72
|
+
return errorResult("invalid_input", `Missing required parameter: ${param}`);
|
|
73
|
+
}
|
|
74
|
+
path = path.replace(`{${param}}`, encodeURIComponent(String(args[param])));
|
|
75
|
+
delete args[param];
|
|
76
|
+
}
|
|
86
77
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
78
|
+
try {
|
|
79
|
+
const method = op.method.toLowerCase();
|
|
80
|
+
const hasBody = method !== "get" && method !== "delete";
|
|
81
|
+
// Transport-level replay key (same semantics as the HTTP Idempotency-Key
|
|
82
|
+
// header and the remote MCP's idempotencyKey argument), not operation input.
|
|
83
|
+
const { idempotencyKey, ...opArgs } = args;
|
|
84
|
+
const result = await request({
|
|
85
|
+
method: op.method,
|
|
86
|
+
path,
|
|
87
|
+
query: hasBody ? undefined : opArgs,
|
|
88
|
+
body: hasBody ? opArgs : undefined,
|
|
89
|
+
apiKey,
|
|
90
|
+
baseUrl: resolveBaseUrl({}),
|
|
91
|
+
idempotencyKey,
|
|
92
|
+
});
|
|
93
|
+
return {
|
|
94
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
95
|
+
};
|
|
96
|
+
} catch (err) {
|
|
97
|
+
if (err instanceof CliError) return errorResult(err.type, err.message);
|
|
98
|
+
return errorResult("internal_error", err.message || "Unknown error");
|
|
99
|
+
}
|
|
100
|
+
});
|
|
110
101
|
|
|
111
|
-
|
|
112
|
-
|
|
102
|
+
const transport = new StdioServerTransport();
|
|
103
|
+
await server.connect(transport);
|
|
113
104
|
}
|
package/src/oauth.mjs
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import http from "node:http";
|
|
3
|
+
import { execFile } from "node:child_process";
|
|
4
|
+
import { loadConfig, saveConfig } from "./config.mjs";
|
|
5
|
+
import { CliError } from "./client.mjs";
|
|
6
|
+
|
|
7
|
+
// OAuth 2.0 login for the CLI and the MCP stdio server.
|
|
8
|
+
//
|
|
9
|
+
// This is what makes `faceless login` work for an AGENT, not just a human with a key to paste:
|
|
10
|
+
// dynamic client registration (RFC 7591), the authorization-code + PKCE flow with the redirect
|
|
11
|
+
// caught on a loopback listener (RFC 8252), and refresh-token rotation handled transparently on
|
|
12
|
+
// every subsequent request. The only human moment is approving the consent screen.
|
|
13
|
+
//
|
|
14
|
+
// The server side of all of this lives in the main app (src/pages/api/oauth2/*); this file is a
|
|
15
|
+
// client of the same documents any third-party agent would read.
|
|
16
|
+
|
|
17
|
+
const LOOPBACK_PORT = 8976;
|
|
18
|
+
const REDIRECT_URI = `http://127.0.0.1:${LOOPBACK_PORT}/callback`;
|
|
19
|
+
/** Refresh this many ms before expiry, so a token cannot die mid-request. */
|
|
20
|
+
const EXPIRY_SKEW_MS = 60 * 1000;
|
|
21
|
+
|
|
22
|
+
/** https://faceless.so/api/v1 -> https://faceless.so (where the oauth2 + well-known routes live). */
|
|
23
|
+
export const siteBaseFrom = (apiBaseUrl) =>
|
|
24
|
+
String(apiBaseUrl || "")
|
|
25
|
+
.replace(/\/+$/, "")
|
|
26
|
+
.replace(/\/api\/v1$/, "");
|
|
27
|
+
|
|
28
|
+
export function generatePkce() {
|
|
29
|
+
const verifier = crypto.randomBytes(32).toString("base64url");
|
|
30
|
+
const challenge = crypto.createHash("sha256").update(verifier, "ascii").digest("base64url");
|
|
31
|
+
return { verifier, challenge };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function postJson(url, body) {
|
|
35
|
+
const response = await fetch(url, {
|
|
36
|
+
method: "POST",
|
|
37
|
+
headers: { "Content-Type": "application/json" },
|
|
38
|
+
body: JSON.stringify(body),
|
|
39
|
+
});
|
|
40
|
+
let payload = null;
|
|
41
|
+
try {
|
|
42
|
+
payload = await response.json();
|
|
43
|
+
} catch {
|
|
44
|
+
payload = null;
|
|
45
|
+
}
|
|
46
|
+
return { status: response.status, payload };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Register (or reuse) this machine's public client. One client per config file is plenty. */
|
|
50
|
+
async function ensureClient(site) {
|
|
51
|
+
const config = loadConfig();
|
|
52
|
+
if (config.oauthClientId) return config.oauthClientId;
|
|
53
|
+
const { status, payload } = await postJson(`${site}/api/oauth2/register`, {
|
|
54
|
+
client_name: `faceless-cli (${process.env.USER || process.env.USERNAME || "user"})`,
|
|
55
|
+
redirect_uris: [REDIRECT_URI],
|
|
56
|
+
});
|
|
57
|
+
if (status !== 201 || !payload?.client_id) {
|
|
58
|
+
throw new CliError("server_error", `OAuth client registration failed (${status}): ${payload?.error_description || "unknown error"}`);
|
|
59
|
+
}
|
|
60
|
+
saveConfig({ oauthClientId: payload.client_id });
|
|
61
|
+
return payload.client_id;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Every scope the server publishes; the consent screen is where the user narrows it. */
|
|
65
|
+
async function allScopes(site) {
|
|
66
|
+
try {
|
|
67
|
+
const response = await fetch(`${site}/.well-known/oauth-protected-resource`);
|
|
68
|
+
const metadata = await response.json();
|
|
69
|
+
if (Array.isArray(metadata?.scopes_supported) && metadata.scopes_supported.length) return metadata.scopes_supported.join(" ");
|
|
70
|
+
} catch {
|
|
71
|
+
/* fall through */
|
|
72
|
+
}
|
|
73
|
+
return ""; // absent scope -> server's read-only default
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The page the loopback listener serves after the consent redirect.
|
|
78
|
+
*
|
|
79
|
+
* Three rules, each learned from the first version being wrong:
|
|
80
|
+
* - It must not claim success before it is true. "Logged in" was sent before the error/state
|
|
81
|
+
* params were even read, so DENYING consent still showed "Logged in", and even a real approval
|
|
82
|
+
* has only produced a code at this point - the exchange can still fail in the terminal.
|
|
83
|
+
* - It must scrub itself from history. The redirect lands with ?code=... in the URL, and the
|
|
84
|
+
* browser stores that in history/autocomplete; history.replaceState drops the query so a
|
|
85
|
+
* credential-bearing URL is never retained. The code is never echoed into the HTML either.
|
|
86
|
+
* - It should look like ours. A bare unstyled heading at a raw IP reads as broken or phishy.
|
|
87
|
+
*/
|
|
88
|
+
export const callbackHtml = ({ ok, detail = "", site = "https://faceless.so" }) => `<!doctype html>
|
|
89
|
+
<html lang="en">
|
|
90
|
+
<head>
|
|
91
|
+
<meta charset="utf-8" />
|
|
92
|
+
<title>Faceless CLI</title>
|
|
93
|
+
<meta name="robots" content="noindex" />
|
|
94
|
+
<script>history.replaceState(null, "", "/callback");</script>
|
|
95
|
+
<style>
|
|
96
|
+
/* The consent page's visual shell (src/pages/oauth2/authorize.jsx, which itself mirrors /login),
|
|
97
|
+
replicated with the app's real tokens because this page is served from the CLI's loopback
|
|
98
|
+
listener where Tailwind and React do not exist: #060607 canvas, orbiting-star canvas at half
|
|
99
|
+
opacity, two blurred glow orbs (primary-600 / info-500), the .fa-grain noise overlay, the
|
|
100
|
+
top-left logo, and the #0A0A0D hairline card. This page renders one second after the consent
|
|
101
|
+
screen; any style break between them reads as leaving the product. */
|
|
102
|
+
* { box-sizing: border-box; }
|
|
103
|
+
body { margin: 0; min-height: 100vh; background: #060607; color: #fff;
|
|
104
|
+
font: 16px/1.6 Inter, -apple-system, "Segoe UI", Roboto, sans-serif; }
|
|
105
|
+
.ambient { position: fixed; inset: 0; pointer-events: none; overflow: hidden; }
|
|
106
|
+
.stars { position: absolute; inset: 0; opacity: 0.5; }
|
|
107
|
+
.orb-a { position: absolute; top: -160px; right: 8%; width: 480px; height: 480px;
|
|
108
|
+
border-radius: 50%; background: rgba(127, 20, 255, 0.08); filter: blur(160px); }
|
|
109
|
+
.orb-b { position: absolute; bottom: -10%; left: -6%; width: 420px; height: 420px;
|
|
110
|
+
border-radius: 50%; background: rgba(48, 180, 255, 0.04); filter: blur(130px); }
|
|
111
|
+
.grain { position: absolute; inset: 0; opacity: 0.04;
|
|
112
|
+
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='140' height='140'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='140' height='140' filter='url(%23n)'/%3E%3C/svg%3E"); }
|
|
113
|
+
.logo { position: absolute; left: 2.5rem; top: 2rem; z-index: 10; }
|
|
114
|
+
.logo img { display: block; width: 120px; height: auto; }
|
|
115
|
+
.shell { position: relative; z-index: 10; min-height: 100vh; display: flex;
|
|
116
|
+
align-items: center; justify-content: center; padding: 2.5rem 1rem; }
|
|
117
|
+
.card { position: relative; overflow: hidden; width: 100%; max-width: 420px; padding: 1.5rem 1.25rem;
|
|
118
|
+
background: #0A0A0D; border: 1px solid rgba(255, 255, 255, 0.08); border-radius: 1.5rem; }
|
|
119
|
+
.hairline { pointer-events: none; position: absolute; left: 2.5rem; right: 2.5rem; top: 0; height: 1px;
|
|
120
|
+
background: linear-gradient(to right, transparent, rgba(255, 255, 255, 0.2), transparent); }
|
|
121
|
+
h1 { margin: 0 0 0.35rem; font-size: 22px; font-weight: 600; letter-spacing: -0.01em; color: #fff; }
|
|
122
|
+
.fail h1 { color: #f87171; }
|
|
123
|
+
p { margin: 0; font-size: 13px; line-height: 1.55; color: #AEAEB2; }
|
|
124
|
+
.hint { margin-top: 1rem; font-size: 12px; color: #8E8E93; }
|
|
125
|
+
</style>
|
|
126
|
+
</head>
|
|
127
|
+
<body>
|
|
128
|
+
<div class="ambient" aria-hidden="true">
|
|
129
|
+
<canvas class="stars" id="stars"></canvas>
|
|
130
|
+
<div class="orb-a"></div>
|
|
131
|
+
<div class="orb-b"></div>
|
|
132
|
+
<div class="grain"></div>
|
|
133
|
+
</div>
|
|
134
|
+
<div class="logo"><img src="${site}/faceless-logo.png" alt="Faceless" /></div>
|
|
135
|
+
<div class="shell">
|
|
136
|
+
<main class="card ${ok ? "" : "fail"}">
|
|
137
|
+
<div class="hairline"></div>
|
|
138
|
+
<h1>${ok ? "Authorization received" : "Authorization failed"}</h1>
|
|
139
|
+
<p>${ok ? "You can close this tab. The login finishes in your terminal." : `${detail} See your terminal for details.`}</p>
|
|
140
|
+
<p class="hint">${ok ? "This window holds no credentials and is safe to close." : "You can retry with `faceless login`."}</p>
|
|
141
|
+
</main>
|
|
142
|
+
</div>
|
|
143
|
+
<script>
|
|
144
|
+
// Compact port of the consent page's StarryBackground: stars slowly orbiting a centre point.
|
|
145
|
+
(function () {
|
|
146
|
+
var canvas = document.getElementById("stars");
|
|
147
|
+
var ctx = canvas.getContext("2d");
|
|
148
|
+
canvas.width = window.innerWidth;
|
|
149
|
+
canvas.height = window.innerHeight;
|
|
150
|
+
var cx = canvas.width / 2, cy = canvas.height / 2;
|
|
151
|
+
var stars = [];
|
|
152
|
+
for (var i = 0; i < 140; i++) {
|
|
153
|
+
stars.push({
|
|
154
|
+
radius: Math.random() * 1.5,
|
|
155
|
+
angle: Math.random() * Math.PI * 2,
|
|
156
|
+
orbit: Math.random() * (canvas.width / 2) + 100,
|
|
157
|
+
speed: Math.random() * 0.001 + 0.0005,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
(function animate() {
|
|
161
|
+
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
162
|
+
ctx.fillStyle = "#fff";
|
|
163
|
+
for (var j = 0; j < stars.length; j++) {
|
|
164
|
+
var s = stars[j];
|
|
165
|
+
s.angle += s.speed;
|
|
166
|
+
var x = cx + Math.cos(s.angle) * s.orbit;
|
|
167
|
+
var y = cy + Math.sin(s.angle) * s.orbit;
|
|
168
|
+
ctx.globalAlpha = 0.9 - Math.min(0.7, s.orbit / canvas.width);
|
|
169
|
+
ctx.beginPath();
|
|
170
|
+
ctx.arc(x, y, s.radius, 0, Math.PI * 2);
|
|
171
|
+
ctx.fill();
|
|
172
|
+
}
|
|
173
|
+
ctx.globalAlpha = 1;
|
|
174
|
+
requestAnimationFrame(animate);
|
|
175
|
+
})();
|
|
176
|
+
})();
|
|
177
|
+
</script>
|
|
178
|
+
</body>
|
|
179
|
+
</html>`;
|
|
180
|
+
|
|
181
|
+
const openBrowser = (url) => {
|
|
182
|
+
const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
183
|
+
execFile(command, [url], () => {});
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* The interactive login: registers a client, opens the consent screen, catches the redirect on a
|
|
188
|
+
* loopback listener, exchanges the code, and returns the token set. Writes nothing itself beyond
|
|
189
|
+
* the client id - the caller decides how to store the tokens.
|
|
190
|
+
*/
|
|
191
|
+
export async function loginWithOAuth({ baseUrl, log = () => {} }) {
|
|
192
|
+
const site = siteBaseFrom(baseUrl);
|
|
193
|
+
const clientId = await ensureClient(site);
|
|
194
|
+
const { verifier, challenge } = generatePkce();
|
|
195
|
+
const state = crypto.randomBytes(8).toString("base64url");
|
|
196
|
+
const scope = await allScopes(site);
|
|
197
|
+
|
|
198
|
+
const authorizeUrl =
|
|
199
|
+
`${site}/oauth2/authorize?response_type=code&client_id=${clientId}` +
|
|
200
|
+
`&redirect_uri=${encodeURIComponent(REDIRECT_URI)}${scope ? `&scope=${encodeURIComponent(scope)}` : ""}` +
|
|
201
|
+
`&state=${state}&code_challenge=${challenge}&code_challenge_method=S256`;
|
|
202
|
+
|
|
203
|
+
log(`Opening your browser to authorize this machine...\n ${authorizeUrl}\n`);
|
|
204
|
+
openBrowser(authorizeUrl);
|
|
205
|
+
|
|
206
|
+
const code = await new Promise((resolve, reject) => {
|
|
207
|
+
const timer = setTimeout(() => {
|
|
208
|
+
server.close();
|
|
209
|
+
reject(new CliError("timeout", "Timed out after 5 minutes waiting for browser approval."));
|
|
210
|
+
}, 300000);
|
|
211
|
+
const server = http.createServer((req, res) => {
|
|
212
|
+
const url = new URL(req.url, `http://127.0.0.1:${LOOPBACK_PORT}`);
|
|
213
|
+
if (url.pathname !== "/callback") {
|
|
214
|
+
res.writeHead(404).end();
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
// Validate BEFORE responding, so the page tells the truth: the first version sent
|
|
218
|
+
// "Logged in" unconditionally, including when the user had just clicked Deny.
|
|
219
|
+
const errorParam = url.searchParams.get("error");
|
|
220
|
+
const stateOk = url.searchParams.get("state") === state;
|
|
221
|
+
const code = url.searchParams.get("code");
|
|
222
|
+
const ok = stateOk && !errorParam && Boolean(code);
|
|
223
|
+
const detail = !stateOk
|
|
224
|
+
? "The redirect did not match this login attempt."
|
|
225
|
+
: errorParam === "access_denied"
|
|
226
|
+
? "You denied the request."
|
|
227
|
+
: errorParam
|
|
228
|
+
? `The server rejected the request (${errorParam}).`
|
|
229
|
+
: "No authorization code arrived.";
|
|
230
|
+
res.writeHead(ok ? 200 : 400, {
|
|
231
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
232
|
+
// The redirect URL carried a credential (the code); nothing about it may be cached.
|
|
233
|
+
"Cache-Control": "no-store",
|
|
234
|
+
"Referrer-Policy": "no-referrer",
|
|
235
|
+
});
|
|
236
|
+
res.end(callbackHtml({ ok, detail, site }));
|
|
237
|
+
clearTimeout(timer);
|
|
238
|
+
server.close();
|
|
239
|
+
if (!stateOk) return reject(new CliError("unauthorized", "State mismatch in OAuth redirect."));
|
|
240
|
+
if (errorParam)
|
|
241
|
+
return reject(new CliError("unauthorized", `Authorization was ${errorParam === "access_denied" ? "denied" : `rejected: ${errorParam}`}.`));
|
|
242
|
+
if (!code) return reject(new CliError("unauthorized", "Redirect arrived without an authorization code."));
|
|
243
|
+
resolve(code);
|
|
244
|
+
});
|
|
245
|
+
server.on("error", (err) => {
|
|
246
|
+
clearTimeout(timer);
|
|
247
|
+
reject(
|
|
248
|
+
err?.code === "EADDRINUSE"
|
|
249
|
+
? new CliError("server_error", `Port ${LOOPBACK_PORT} is in use; free it and retry.`)
|
|
250
|
+
: new CliError("server_error", `Loopback listener failed: ${err.message}`)
|
|
251
|
+
);
|
|
252
|
+
});
|
|
253
|
+
server.listen(LOOPBACK_PORT, "127.0.0.1");
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
const { status, payload } = await postJson(`${site}/api/oauth2/token`, {
|
|
257
|
+
grant_type: "authorization_code",
|
|
258
|
+
code,
|
|
259
|
+
redirect_uri: REDIRECT_URI,
|
|
260
|
+
client_id: clientId,
|
|
261
|
+
code_verifier: verifier,
|
|
262
|
+
});
|
|
263
|
+
if (status !== 200 || !payload?.access_token) {
|
|
264
|
+
throw new CliError("server_error", `Token exchange failed (${status}): ${payload?.error_description || payload?.error || "unknown"}`);
|
|
265
|
+
}
|
|
266
|
+
return tokenSetFrom(payload, site, clientId);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const tokenSetFrom = (payload, site, clientId) => ({
|
|
270
|
+
site,
|
|
271
|
+
clientId,
|
|
272
|
+
accessToken: payload.access_token,
|
|
273
|
+
refreshToken: payload.refresh_token,
|
|
274
|
+
scope: payload.scope,
|
|
275
|
+
expiresAt: Date.now() + (Number(payload.expires_in) || 3600) * 1000,
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* A valid access token from the stored OAuth session, refreshing (and persisting the rotated pair)
|
|
280
|
+
* when it is near expiry. Returns null when there is no OAuth session at all; throws when the
|
|
281
|
+
* session existed but is dead (family revoked, refresh expired) so the caller can say "log in
|
|
282
|
+
* again" instead of a bare 401.
|
|
283
|
+
*/
|
|
284
|
+
export async function ensureOauthAccessToken() {
|
|
285
|
+
const config = loadConfig();
|
|
286
|
+
const session = config.oauth;
|
|
287
|
+
if (!session?.refreshToken) return null;
|
|
288
|
+
|
|
289
|
+
if (session.accessToken && Date.now() < Number(session.expiresAt || 0) - EXPIRY_SKEW_MS) {
|
|
290
|
+
return session.accessToken;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const { status, payload } = await postJson(`${session.site}/api/oauth2/token`, {
|
|
294
|
+
grant_type: "refresh_token",
|
|
295
|
+
refresh_token: session.refreshToken,
|
|
296
|
+
client_id: session.clientId,
|
|
297
|
+
});
|
|
298
|
+
if (status !== 200 || !payload?.access_token) {
|
|
299
|
+
// Rotation means a dead refresh token is unrecoverable; clear it so the next error is honest.
|
|
300
|
+
saveConfig({ oauth: undefined });
|
|
301
|
+
throw new CliError("unauthorized", 'Your OAuth session has expired or was revoked. Run "faceless login" again.');
|
|
302
|
+
}
|
|
303
|
+
const rotated = tokenSetFrom(payload, session.site, session.clientId);
|
|
304
|
+
saveConfig({ oauth: rotated });
|
|
305
|
+
return rotated.accessToken;
|
|
306
|
+
}
|