faceless-cli 1.1.8 → 1.1.10
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/generated/operations.json +1 -1
- package/src/oauth.mjs +130 -5
package/package.json
CHANGED
package/src/oauth.mjs
CHANGED
|
@@ -73,6 +73,111 @@ async function allScopes(site) {
|
|
|
73
73
|
return ""; // absent scope -> server's read-only default
|
|
74
74
|
}
|
|
75
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
|
+
|
|
76
181
|
const openBrowser = (url) => {
|
|
77
182
|
const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
78
183
|
execFile(command, [url], () => {});
|
|
@@ -109,13 +214,33 @@ export async function loginWithOAuth({ baseUrl, log = () => {} }) {
|
|
|
109
214
|
res.writeHead(404).end();
|
|
110
215
|
return;
|
|
111
216
|
}
|
|
112
|
-
|
|
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 }));
|
|
113
237
|
clearTimeout(timer);
|
|
114
238
|
server.close();
|
|
115
|
-
if (
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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);
|
|
119
244
|
});
|
|
120
245
|
server.on("error", (err) => {
|
|
121
246
|
clearTimeout(timer);
|