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