Source commit: f6c01dca61eefca1a7431c48878a04dbb7a3f710 Public tree identity: sha256:3df7a93eb61c8c5c7841ac9ad6eedec89b167fd77fdd32e9fc31b57c10207387
222 lines
8.2 KiB
JavaScript
222 lines
8.2 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
const assert = require("assert");
|
|
const cp = require("child_process");
|
|
const fs = require("fs");
|
|
const net = require("net");
|
|
const path = require("path");
|
|
|
|
const repo = path.resolve(__dirname, "..");
|
|
const tmp = path.join(repo, "target", "acceptance", "tmp", "cli-browser-login-flow");
|
|
const project = path.join(tmp, "project");
|
|
fs.rmSync(tmp, { recursive: true, force: true });
|
|
fs.mkdirSync(project, { recursive: true });
|
|
|
|
function writeOpener() {
|
|
const opener = path.join(tmp, "browser-opener.js");
|
|
const trace = path.join(tmp, "browser-opener.log");
|
|
fs.writeFileSync(
|
|
opener,
|
|
`#!/usr/bin/env node
|
|
const fs = require("fs");
|
|
const trace = ${JSON.stringify(trace)};
|
|
const loginUrl = new URL(process.argv[2]);
|
|
const state = loginUrl.searchParams.get("state");
|
|
const nonce = loginUrl.searchParams.get("nonce");
|
|
const challenge = loginUrl.searchParams.get("code_challenge");
|
|
if (loginUrl.protocol !== "https:" || !state || !nonce || !challenge) {
|
|
fs.appendFileSync(trace, "missing server-owned OIDC parameters\\n");
|
|
process.exit(1);
|
|
}
|
|
fs.appendFileSync(trace, "server-owned browser transaction\\n");
|
|
// Model a real browser/opener that remains alive after the CLI transaction.
|
|
// Its descriptors must not keep the invoking CLI process open.
|
|
setTimeout(() => process.exit(0), 5000);
|
|
`
|
|
);
|
|
fs.chmodSync(opener, 0o755);
|
|
return opener;
|
|
}
|
|
|
|
function startCoordinator() {
|
|
return new Promise((resolve, reject) => {
|
|
const requests = [];
|
|
const server = net.createServer((socket) => {
|
|
let buffered = "";
|
|
socket.on("data", (chunk) => {
|
|
buffered += chunk.toString("utf8");
|
|
while (buffered.includes("\n")) {
|
|
const newline = buffered.indexOf("\n");
|
|
const line = buffered.slice(0, newline);
|
|
buffered = buffered.slice(newline + 1);
|
|
const envelope = JSON.parse(line);
|
|
requests.push(envelope);
|
|
assert.strictEqual(envelope.type, "coordinator_request");
|
|
assert.strictEqual(envelope.protocol_version, 1);
|
|
assert.strictEqual(envelope.authentication.kind, "none");
|
|
const request = envelope.payload;
|
|
|
|
if (requests.length === 1) {
|
|
assert.strictEqual(envelope.request_id, "cli-1");
|
|
assert.strictEqual(envelope.operation, "begin_oidc_browser_login");
|
|
assert.deepStrictEqual(request, {
|
|
type: "begin_oidc_browser_login",
|
|
});
|
|
socket.write(
|
|
`${JSON.stringify({
|
|
type: "oidc_browser_login_started",
|
|
transaction_id: "login-transaction",
|
|
polling_secret: "opaque-polling-secret",
|
|
authorization_url:
|
|
"https://auth.michelpaulissen.com/application/o/authorize/?state=server-state&nonce=server-nonce&code_challenge=server-pkce&code_challenge_method=S256&redirect_uri=https%3A%2F%2Fclusterflux.michelpaulissen.com%2Fauth%2Fcallback",
|
|
expires_at_epoch_seconds: 1800000000,
|
|
})}\n`
|
|
);
|
|
continue;
|
|
}
|
|
|
|
assert.strictEqual(envelope.request_id, "cli-2");
|
|
assert.strictEqual(envelope.operation, "poll_oidc_browser_login");
|
|
assert.deepStrictEqual(request, {
|
|
type: "poll_oidc_browser_login",
|
|
transaction_id: "login-transaction",
|
|
polling_secret: "opaque-polling-secret",
|
|
});
|
|
socket.end(
|
|
`${JSON.stringify({
|
|
type: "oidc_browser_session",
|
|
session: {
|
|
tenant: "tenant-smoke",
|
|
project: "project-smoke",
|
|
user: "user-smoke",
|
|
cli_session_credential_kind: "CliDeviceSession",
|
|
cli_session_secret: "scoped-cli-session-secret",
|
|
expires_at_epoch_seconds: 1800000000,
|
|
provider_tokens_sent_to_nodes: false,
|
|
},
|
|
})}\n`
|
|
);
|
|
server.close();
|
|
}
|
|
});
|
|
});
|
|
server.once("error", reject);
|
|
server.listen(0, "127.0.0.1", () => {
|
|
const address = server.address();
|
|
resolve({
|
|
url: `${address.address}:${address.port}`,
|
|
requests,
|
|
close: () =>
|
|
new Promise((closeResolve) => {
|
|
if (!server.listening) closeResolve();
|
|
else server.close(() => closeResolve());
|
|
}),
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
function runClusterflux(args, env) {
|
|
return new Promise((resolve, reject) => {
|
|
const child = cp.spawn(
|
|
"cargo",
|
|
[
|
|
"run",
|
|
"-q",
|
|
"--manifest-path",
|
|
path.join(repo, "Cargo.toml"),
|
|
"-p",
|
|
"clusterflux-cli",
|
|
"--bin",
|
|
"clusterflux",
|
|
"--",
|
|
...args,
|
|
],
|
|
{ cwd: project, env, stdio: ["ignore", "pipe", "pipe"] }
|
|
);
|
|
let stdout = "";
|
|
let stderr = "";
|
|
child.stdout.on("data", (chunk) => (stdout += chunk.toString("utf8")));
|
|
child.stderr.on("data", (chunk) => (stderr += chunk.toString("utf8")));
|
|
child.once("error", reject);
|
|
child.once("close", (code) => {
|
|
if (code === 0) resolve(stdout);
|
|
else reject(new Error(`clusterflux exited ${code}\n${stderr}\n${stdout}`));
|
|
});
|
|
});
|
|
}
|
|
|
|
(async () => {
|
|
const opener = writeOpener();
|
|
const coordinator = await startCoordinator();
|
|
try {
|
|
const loginStarted = Date.now();
|
|
const report = JSON.parse(
|
|
await runClusterflux(
|
|
[
|
|
"login",
|
|
"--browser",
|
|
"--json",
|
|
"--coordinator",
|
|
coordinator.url,
|
|
"--project-id",
|
|
"project-smoke",
|
|
],
|
|
{
|
|
...process.env,
|
|
CLUSTERFLUX_BROWSER_OPEN_COMMAND: opener,
|
|
CLUSTERFLUX_BROWSER_LOGIN_TIMEOUT_SECONDS: "5",
|
|
}
|
|
)
|
|
);
|
|
assert(
|
|
Date.now() - loginStarted < 3000,
|
|
"a long-lived browser opener must not keep CLI output pipes or login completion open"
|
|
);
|
|
assert.strictEqual(report.plan.coordinator, coordinator.url);
|
|
assert.strictEqual(report.boundary.cli_contacted_coordinator, true);
|
|
assert.strictEqual(report.boundary.scoped_cli_session_received, true);
|
|
assert.strictEqual(report.boundary.local_cli_session_file_written, true);
|
|
assert.strictEqual(report.boundary.provider_tokens_persisted_locally, false);
|
|
assert.strictEqual(report.boundary.provider_tokens_exposed_to_cli, false);
|
|
assert.strictEqual(report.boundary.provider_tokens_sent_to_nodes, false);
|
|
assert.strictEqual(report.boundary.coordinator_session_requests, 2);
|
|
assert.strictEqual(coordinator.requests.length, 2);
|
|
|
|
const sessionFile = path.join(project, ".clusterflux", "session.json");
|
|
const sessionText = fs.readFileSync(sessionFile, "utf8");
|
|
const session = JSON.parse(sessionText);
|
|
assert.strictEqual(session.kind, "human");
|
|
assert.strictEqual(session.coordinator, coordinator.url);
|
|
assert.strictEqual(session.tenant, "tenant-smoke");
|
|
assert.strictEqual(session.project, "project-smoke");
|
|
assert.strictEqual(session.user, "user-smoke");
|
|
assert.strictEqual(session.cli_session_credential_kind, "CliDeviceSession");
|
|
assert.strictEqual(session.token_expiry_posture, "expires_at");
|
|
assert.strictEqual(session.expires_at, "1800000000");
|
|
assert.strictEqual(session.provider_tokens_exposed_to_cli, false);
|
|
assert.strictEqual(session.provider_tokens_sent_to_nodes, false);
|
|
assert.doesNotMatch(
|
|
sessionText,
|
|
/access_token|refresh_token|id_token|provider-secret|authorization_code|Bearer/
|
|
);
|
|
|
|
const authStatus = JSON.parse(
|
|
await runClusterflux(["auth", "status", "--json"], process.env)
|
|
);
|
|
assert.strictEqual(authStatus.active_coordinator, coordinator.url);
|
|
assert.strictEqual(authStatus.principal, "user-smoke");
|
|
assert.strictEqual(authStatus.tenant, "tenant-smoke");
|
|
assert.strictEqual(authStatus.project, "project-smoke");
|
|
assert.strictEqual(authStatus.session.kind, "human");
|
|
assert.strictEqual(authStatus.session.source, "session_file");
|
|
assert.strictEqual(authStatus.session.provider_tokens_exposed_to_cli, false);
|
|
assert.strictEqual(authStatus.session.provider_tokens_exposed_to_nodes, false);
|
|
} finally {
|
|
await coordinator.close();
|
|
}
|
|
console.log("CLI browser login flow smoke passed");
|
|
})().catch((error) => {
|
|
console.error(error.stack || error.message);
|
|
process.exit(1);
|
|
});
|