feat(cli): add HMAC-SHA256 machine token for localhost CLI auth

Generates a deterministic HMAC-SHA256(key=rawMachineId, msg=salt) token
in src/lib/machineToken.ts. The management authz policy now accepts this
token via x-omniroute-cli-token when Host is loopback, letting the local
CLI process call management APIs without requiring a user login session.
`omniroute config token` prints the token for manual use.
This commit is contained in:
diegosouzapw
2026-05-14 23:00:00 -03:00
parent 77a4429bf4
commit 1887483c0f
6 changed files with 142 additions and 0 deletions

View File

@@ -12,6 +12,7 @@ Usage:
omniroute config set <tool> [options] Write config for a tool
omniroute config validate <tool> Validate config format without writing
omniroute config tray <enable|disable> Enable/disable tray autostart on login
omniroute config token Show the machine-derived CLI auth token
Options:
--base-url <url> OmniRoute API base URL (default: http://localhost:20128/v1)
@@ -177,6 +178,24 @@ export async function runConfigCommand(argv) {
return 0;
}
if (subcommand === "token") {
const { getMachineTokenSync } = await import("../../../src/lib/machineToken.ts");
const token = getMachineTokenSync();
if (!token) {
printError("Could not derive machine token (machine-id unavailable).");
return 1;
}
if (hasFlag(flags, "json")) {
console.log(JSON.stringify({ token, header: "x-omniroute-cli-token" }));
} else {
printHeading("CLI Machine Token");
console.log(` Header: x-omniroute-cli-token`);
console.log(` Value: ${token}`);
console.log(`\n Use this token to authenticate management API calls from localhost.`);
}
return 0;
}
if (subcommand === "tray") {
const action = positionals[1];
if (action === "enable") {

26
src/lib/machineToken.ts Normal file
View File

@@ -0,0 +1,26 @@
import { createHmac } from "node:crypto";
import nodeMachineId from "node-machine-id";
const { machineIdSync } = nodeMachineId;
const DEFAULT_SALT = "omniroute-cli-auth";
function deriveToken(rawId: string, salt: string): string {
return createHmac("sha256", rawId).update(salt).digest("hex").slice(0, 32);
}
let cached: string | null = null;
export function getMachineTokenSync(salt = DEFAULT_SALT): string {
try {
// machineIdSync(true) returns the original unhashed hardware ID.
const rawId = machineIdSync(true);
if (salt === DEFAULT_SALT) {
cached ??= deriveToken(rawId, salt);
return cached;
}
return deriveToken(rawId, salt);
} catch {
return "";
}
}

View File

@@ -21,6 +21,9 @@ export const AUTHZ_HEADER_AUTH_ID = "x-omniroute-auth-id";
export const AUTHZ_HEADER_AUTH_LABEL = "x-omniroute-auth-label";
export const AUTHZ_HEADER_AUTH_SCOPES = "x-omniroute-auth-scopes";
/** CLI sends this header so the local process can call management APIs without login. */
export const CLI_TOKEN_HEADER = "x-omniroute-cli-token";
/**
* Headers the pipeline must NEVER trust on incoming requests. They are
* stripped before route classification to prevent header-spoofing attacks.

View File

@@ -1,9 +1,28 @@
import { isModelSyncInternalRequest } from "../../../shared/services/modelSyncScheduler";
import { isAuthRequired, isDashboardSessionAuthenticated } from "../../../shared/utils/apiAuth";
import { getMachineTokenSync } from "../../../lib/machineToken";
import type { AuthOutcome, PolicyContext, RoutePolicy } from "../context";
import { allow, reject } from "../context";
import { CLI_TOKEN_HEADER } from "../headers";
const MODEL_SYNC_MANAGEMENT_PATH = /^\/api\/providers\/[^/]+\/(sync-models|models)$/;
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]);
function isLoopbackRequest(headers: Headers): boolean {
const host = (headers.get("host") ?? "")
.split(":")[0]
.replace(/^\[|\]$/g, "")
.toLowerCase();
return LOOPBACK_HOSTS.has(host);
}
function hasValidCliToken(headers: Headers): boolean {
if (!isLoopbackRequest(headers)) return false;
const provided = headers.get(CLI_TOKEN_HEADER);
if (!provided) return false;
const expected = getMachineTokenSync();
return expected !== "" && provided === expected;
}
function hasBearerToken(headers: Headers): boolean {
const authHeader = headers.get("authorization") ?? headers.get("Authorization");
@@ -26,6 +45,10 @@ export const managementPolicy: RoutePolicy = {
return allow({ kind: "management_key", id: "model-sync", label: "internal-model-sync" });
}
if (hasValidCliToken(ctx.request.headers)) {
return allow({ kind: "management_key", id: "cli", label: "local-cli-token" });
}
if (await isDashboardSessionAuthenticated(ctx.request)) {
return allow({ kind: "dashboard_session", id: "dashboard" });
}

View File

@@ -0,0 +1,22 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { getMachineTokenSync } from "../../../src/lib/machineToken.ts";
test("getMachineTokenSync returns a 32-character hex string", () => {
const token = getMachineTokenSync();
assert.match(token, /^[0-9a-f]{32}$/, "token must be 32 lowercase hex chars");
});
test("getMachineTokenSync is deterministic", () => {
assert.equal(getMachineTokenSync(), getMachineTokenSync());
});
test("getMachineTokenSync produces different values for different salts", () => {
const t1 = getMachineTokenSync("salt-a");
const t2 = getMachineTokenSync("salt-b");
assert.notEqual(t1, t2);
});
test("getMachineTokenSync with empty string salt does not throw", () => {
assert.doesNotThrow(() => getMachineTokenSync(""));
});

View File

@@ -0,0 +1,49 @@
import { test, mock } from "node:test";
import assert from "node:assert/strict";
import { getMachineTokenSync } from "../../../src/lib/machineToken.ts";
import { managementPolicy } from "../../../src/server/authz/policies/management.ts";
import { CLI_TOKEN_HEADER } from "../../../src/server/authz/headers.ts";
function makeCtx(headers: Record<string, string>) {
return {
request: {
method: "GET",
headers: new Headers(headers),
cookies: { get: () => undefined },
nextUrl: { pathname: "/api/settings" },
url: "http://localhost:20128/api/settings",
},
classification: {
routeClass: "MANAGEMENT" as const,
normalizedPath: "/api/settings",
method: "GET",
},
requestId: "test-req",
};
}
test("management policy allows valid CLI token from localhost", async () => {
const token = getMachineTokenSync();
const ctx = makeCtx({ host: "localhost", [CLI_TOKEN_HEADER]: token });
const outcome = await managementPolicy.evaluate(ctx);
assert.equal(outcome.allow, true);
if (outcome.allow) {
assert.equal(outcome.subject.id, "cli");
}
});
test("management policy rejects valid token from non-localhost", async () => {
const token = getMachineTokenSync();
const ctx = makeCtx({ host: "192.168.1.100", [CLI_TOKEN_HEADER]: token });
const outcome = await managementPolicy.evaluate(ctx);
assert.equal(outcome.allow, false);
});
test("management policy rejects wrong CLI token from localhost", async () => {
const ctx = makeCtx({
host: "localhost",
[CLI_TOKEN_HEADER]: "deadbeefdeadbeefdeadbeefdeadbeef",
});
const outcome = await managementPolicy.evaluate(ctx);
assert.equal(outcome.allow, false);
});