mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-02 13:22:11 +03:00
OmniRoute v3.8.29 — 115 commits since v3.8.28. Full CHANGELOG + 41 i18n mirrors. All content quality gates green (build, unit 8/8, vitest 188/188, PR test policy, quality gates extended, docs sync, quality ratchet). Remaining red CI checks are pre-existing release flakes (coverage-shard/integration/node-compat teardown), a new transitive undici advisory in electron devDeps, and a workflow-level CodeQL fail (0 open alerts). VPS-validated by the operator.
184 lines
5.8 KiB
TypeScript
184 lines
5.8 KiB
TypeScript
import { createHash, randomBytes, randomUUID } from "crypto";
|
|
import { getDbInstance } from "./core";
|
|
import { type AccessScope, normalizeScope } from "../accessTokens/scopes";
|
|
|
|
/**
|
|
* CLI access tokens — scoped credentials for remote-mode management commands.
|
|
* Distinct from `api_keys` (inference). Only the SHA-256 hash is persisted; the
|
|
* plaintext secret is returned exactly once, at creation.
|
|
*
|
|
* Token format: `oma_live_<base64url(32 bytes)>`. The first chars are stored as
|
|
* `token_prefix` so tokens can be listed/identified without revealing the secret.
|
|
*/
|
|
|
|
const TOKEN_RANDOM_BYTES = 32;
|
|
const TOKEN_SECRET_PREFIX = "oma_live_";
|
|
/** How many leading chars of the secret are kept for display (prefix). */
|
|
const DISPLAY_PREFIX_LEN = TOKEN_SECRET_PREFIX.length + 6;
|
|
|
|
export interface AccessTokenRecord {
|
|
id: string;
|
|
name: string;
|
|
scope: AccessScope;
|
|
tokenPrefix: string;
|
|
createdAt: string;
|
|
lastUsedAt: string | null;
|
|
expiresAt: string | null;
|
|
revokedAt: string | null;
|
|
}
|
|
|
|
/** Result of validating a presented token on a request. */
|
|
export interface VerifiedAccessToken {
|
|
id: string;
|
|
name: string;
|
|
scope: AccessScope;
|
|
}
|
|
|
|
interface AccessTokenRow {
|
|
id: string;
|
|
token_hash: string;
|
|
token_prefix: string;
|
|
name: string;
|
|
scope: string;
|
|
created_at: string;
|
|
last_used_at: string | null;
|
|
expires_at: string | null;
|
|
revoked_at: string | null;
|
|
}
|
|
|
|
function rowToRecord(row: AccessTokenRow): AccessTokenRecord {
|
|
return {
|
|
id: row.id,
|
|
name: row.name,
|
|
scope: normalizeScope(row.scope),
|
|
tokenPrefix: row.token_prefix,
|
|
createdAt: row.created_at,
|
|
lastUsedAt: row.last_used_at,
|
|
expiresAt: row.expires_at,
|
|
revokedAt: row.revoked_at,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Hash a token secret for storage/lookup.
|
|
*
|
|
* SHA-256 is intentional: these are high-entropy random secrets compared by exact
|
|
* hash match for per-request validation, NOT user passwords. bcrypt/scrypt would
|
|
* add ~100ms per request for no security gain. Mirrors `api_keys` hashing.
|
|
* lgtm[js/insufficient-password-hash]
|
|
*/
|
|
export function hashAccessToken(secret: string): string {
|
|
return createHash("sha256").update(secret).digest("hex"); // nosemgrep: insufficient-password-hash
|
|
}
|
|
|
|
/** True when an ISO timestamp is in the past (treats invalid dates as not-expired). */
|
|
function isExpired(expiresAt: string | null): boolean {
|
|
if (!expiresAt) return false;
|
|
const ts = new Date(expiresAt).getTime();
|
|
return Number.isFinite(ts) && ts <= Date.now();
|
|
}
|
|
|
|
/**
|
|
* Create a new access token. Returns the persisted record plus the plaintext
|
|
* secret — the ONLY time the secret is available. Caller must show it once and
|
|
* never store it server-side.
|
|
*/
|
|
export function createAccessToken(input: {
|
|
name: string;
|
|
scope?: AccessScope | string;
|
|
expiresAt?: string | null;
|
|
}): { record: AccessTokenRecord; secret: string } {
|
|
const name = (input.name ?? "").trim();
|
|
if (!name) throw new Error("Access token name is required");
|
|
|
|
const db = getDbInstance();
|
|
const scope = normalizeScope(input.scope, "read");
|
|
const secret = `${TOKEN_SECRET_PREFIX}${randomBytes(TOKEN_RANDOM_BYTES).toString("base64url")}`;
|
|
const id = `tok_${randomUUID()}`;
|
|
const tokenHash = hashAccessToken(secret);
|
|
const tokenPrefix = secret.slice(0, DISPLAY_PREFIX_LEN);
|
|
const createdAt = new Date().toISOString();
|
|
const expiresAt = input.expiresAt ?? null;
|
|
|
|
db.prepare(
|
|
`INSERT INTO cli_access_tokens
|
|
(id, token_hash, token_prefix, name, scope, created_at, expires_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)`
|
|
).run(id, tokenHash, tokenPrefix, name, scope, createdAt, expiresAt);
|
|
|
|
return {
|
|
secret,
|
|
record: {
|
|
id,
|
|
name,
|
|
scope,
|
|
tokenPrefix,
|
|
createdAt,
|
|
lastUsedAt: null,
|
|
expiresAt,
|
|
revokedAt: null,
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Validate a presented secret. Returns the token's identity + scope, or null when
|
|
* the secret is unknown, revoked, or expired. Touches `last_used_at` on success.
|
|
*/
|
|
export function verifyAccessToken(secret: string | null | undefined): VerifiedAccessToken | null {
|
|
if (!secret || typeof secret !== "string") return null;
|
|
const db = getDbInstance();
|
|
const row = db
|
|
.prepare("SELECT * FROM cli_access_tokens WHERE token_hash = ?")
|
|
.get(hashAccessToken(secret)) as AccessTokenRow | undefined;
|
|
if (!row) return null;
|
|
if (row.revoked_at) return null;
|
|
if (isExpired(row.expires_at)) return null;
|
|
|
|
// Best-effort usage stamp; never block validation on the write.
|
|
try {
|
|
db.prepare("UPDATE cli_access_tokens SET last_used_at = ? WHERE id = ?").run(
|
|
new Date().toISOString(),
|
|
row.id
|
|
);
|
|
} catch {
|
|
/* non-fatal */
|
|
}
|
|
|
|
return { id: row.id, name: row.name, scope: normalizeScope(row.scope) };
|
|
}
|
|
|
|
/** List all tokens (masked — never includes the secret or its hash). */
|
|
export function listAccessTokens(): AccessTokenRecord[] {
|
|
const db = getDbInstance();
|
|
const rows = db
|
|
.prepare("SELECT * FROM cli_access_tokens ORDER BY created_at DESC")
|
|
.all() as AccessTokenRow[];
|
|
return rows.map(rowToRecord);
|
|
}
|
|
|
|
/** Fetch one token's masked record by id, or null. */
|
|
export function getAccessToken(id: string): AccessTokenRecord | null {
|
|
const db = getDbInstance();
|
|
const row = db.prepare("SELECT * FROM cli_access_tokens WHERE id = ?").get(id) as
|
|
| AccessTokenRow
|
|
| undefined;
|
|
return row ? rowToRecord(row) : null;
|
|
}
|
|
|
|
/**
|
|
* Revoke a token by id or by its display prefix. Idempotent: revoking an
|
|
* already-revoked token is a no-op. Returns true when a row was newly revoked.
|
|
*/
|
|
export function revokeAccessToken(idOrPrefix: string): boolean {
|
|
if (!idOrPrefix) return false;
|
|
const db = getDbInstance();
|
|
const res = db
|
|
.prepare(
|
|
`UPDATE cli_access_tokens SET revoked_at = ?
|
|
WHERE (id = ? OR token_prefix = ?) AND revoked_at IS NULL`
|
|
)
|
|
.run(new Date().toISOString(), idOrPrefix, idOrPrefix);
|
|
return res.changes > 0;
|
|
}
|