Spaces:
Runtime error
Runtime error
File size: 5,906 Bytes
cd8bd0a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 | 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;
}
|