Files
OmniRoute/src/lib/db/accessTokens.ts
Diego Rodrigues de Sa e Souza 3c9883bb73 Release v3.8.29 (#4126)
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.
2026-06-19 06:49:01 -03:00

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;
}