Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions relay/src/middleware/admin-or-api-key-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,12 @@
*/

import { Request, Response, NextFunction } from "express";
import { secureCompare, hashToken, validateAdminToken, isAdminPasswordConfigured } from "../utils/security";
import {
secureCompare,
hashToken,
validateAdminToken,
isAdminPasswordConfigured,
} from "../utils/security";
import { authConfig } from "../config";
import { validateApiKey } from "../utils/api-keys-store";
import { loggers } from "../utils/logger";
Expand Down Expand Up @@ -59,7 +64,7 @@ export async function adminOrApiKeyAuthMiddleware(
// If admin token fails, try API key authentication
if (token.startsWith("shogun-api-")) {
try {
const keyData = validateApiKey(token);
const keyData = await validateApiKey(token);
if (keyData) {
log.debug(
{
Expand Down
14 changes: 7 additions & 7 deletions relay/src/routes/api-keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@ const router: Router = Router();
/**
* List all API keys
*/
router.get("/", (req: Request, res: Response) => {
router.get("/", async (req: Request, res: Response) => {
try {
const keys = listApiKeys();
const keys = await listApiKeys();
res.json({ success: true, keys });
} catch (error: any) {
loggers.server.error({ error }, "Error listing API keys");
Expand All @@ -20,14 +20,14 @@ router.get("/", (req: Request, res: Response) => {
/**
* Generate a new API key
*/
router.post("/generate", (req: Request, res: Response) => {
router.post("/generate", async (req: Request, res: Response) => {
try {
const { name } = req.body;
if (!name) {
return res.status(400).json({ success: false, error: "Name is required for the API key" });
}

const { token, data } = generateApiKey(name);
const { token, data } = await generateApiKey(name);
res.json({ success: true, token, data });
} catch (error: any) {
loggers.server.error({ error }, "Error generating API key");
Expand All @@ -38,11 +38,11 @@ router.post("/generate", (req: Request, res: Response) => {
/**
* Revoke an API key
*/
router.delete("/:keyId", (req: Request, res: Response) => {
router.delete("/:keyId", async (req: Request, res: Response) => {
try {
const { keyId } = req.params;
const success = revokeApiKey(keyId as string);
const success = await revokeApiKey(keyId as string);

if (success) {
res.json({ success: true, message: "API key revoked successfully" });
} else {
Expand Down
126 changes: 83 additions & 43 deletions relay/src/utils/api-keys-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,79 +25,116 @@ interface ApiKeyStore {

// In-memory cache
let storeCache: ApiKeyStore | null = null;
let loadPromise: Promise<ApiKeyStore> | null = null;
let savePromise: Promise<void> | null = null;
let savePending = false;

/**
* Ensure data directory exists
*/
function ensureDataDir() {
if (!fs.existsSync(DATA_DIR)) {
fs.mkdirSync(DATA_DIR, { recursive: true });
async function ensureDataDir() {
try {
await fs.promises.access(DATA_DIR);
} catch {
await fs.promises.mkdir(DATA_DIR, { recursive: true });
}
}

/**
* Load store from disk
*/
function loadStore(): ApiKeyStore {
if (storeCache) return storeCache;
async function doLoadStore(): Promise<ApiKeyStore> {
await ensureDataDir();

ensureDataDir();

if (fs.existsSync(STORE_PATH)) {
try {
const data = fs.readFileSync(STORE_PATH, "utf-8");
storeCache = JSON.parse(data);
return storeCache!;
} catch (error) {
try {
const data = await fs.promises.readFile(STORE_PATH, "utf-8");
storeCache = JSON.parse(data);
return storeCache!;
} catch (error: any) {
if (error.code !== "ENOENT") {
loggers.server.error({ error }, "Failed to read api-keys.json, initializing empty store");
}
}

// Init empty store
storeCache = { keys: {} };
saveStore(storeCache);
await saveStore(storeCache);
return storeCache;
}

/**
* Save store to disk
* Load store from disk
*/
function saveStore(store: ApiKeyStore) {
async function loadStore(): Promise<ApiKeyStore> {
if (storeCache) return storeCache;
if (loadPromise) return loadPromise;

loadPromise = doLoadStore().finally(() => {
loadPromise = null;
});

return loadPromise;
}

async function doSaveStore(store: ApiKeyStore) {
try {
ensureDataDir();
fs.writeFileSync(STORE_PATH, JSON.stringify(store, null, 2), "utf-8");
await ensureDataDir();
const tempPath = STORE_PATH + ".tmp";
await fs.promises.writeFile(tempPath, JSON.stringify(store, null, 2), "utf-8");
await fs.promises.rename(tempPath, STORE_PATH);
storeCache = store;
} catch (error) {
loggers.server.error({ error }, "Failed to write api-keys.json");
}
}

/**
* Save store to disk
*/
async function saveStore(store: ApiKeyStore) {
savePending = true;

if (savePromise) {
return savePromise;
}

const startSaveLoop = async () => {
while (savePending) {
savePending = false;
await doSaveStore(storeCache || store);
}
};

savePromise = startSaveLoop().finally(() => {
savePromise = null;
});

return savePromise;
}

/**
* Generate a new API key
* Returns the full token (only once) and the stored data
*/
export function generateApiKey(name: string): { token: string; data: ApiKeyData } {
const store = loadStore();
export async function generateApiKey(name: string): Promise<{ token: string; data: ApiKeyData }> {
const store = await loadStore();

// Generate a random token
const rawSecret = randomBytes(32).toString("base64url");
const token = `shogun-api-${rawSecret}`;

const keyId = `key_${randomBytes(8).toString("hex")}`;
const keyPrefix = token.substring(0, 16) + "...";

const data: ApiKeyData = {
keyId,
name,
keyPrefix,
createdAt: Date.now(),
};

// We use the token itself as the key in the store for fast lookup.
// In a highly secure environment, we would hash the token before storing,
// We use the token itself as the key in the store for fast lookup.
// In a highly secure environment, we would hash the token before storing,
// but since adminPassword is also in cleartext in .env, this is acceptable for the node operator.
store.keys[token] = data;
saveStore(store);
await saveStore(store);

loggers.server.info({ keyId, name }, "Generated new API key");

Expand All @@ -107,49 +144,52 @@ export function generateApiKey(name: string): { token: string; data: ApiKeyData
/**
* List all API keys (without the full token)
*/
export function listApiKeys(): ApiKeyData[] {
const store = loadStore();
return Object.values(store.keys).map(k => ({ ...k }));
export async function listApiKeys(): Promise<ApiKeyData[]> {
const store = await loadStore();
return Object.values(store.keys).map((k) => ({ ...k }));
}

/**
* Validate an API key token
* Returns the key data if valid, null otherwise
*/
export function validateApiKey(token: string): ApiKeyData | null {
const store = loadStore();
export async function validateApiKey(token: string): Promise<ApiKeyData | null> {
const store = await loadStore();
const data = store.keys[token];

if (data) {
// Update last used
data.lastUsed = Date.now();
saveStore(store);
// Fire-and-forget save so we don't penalize API latency
saveStore(store).catch((err) => {
loggers.server.error({ error: err }, "Failed background save of api-keys.json");
});
return { ...data };
}

return null;
}

/**
* Revoke an API key by ID
*/
export function revokeApiKey(keyId: string): boolean {
const store = loadStore();
export async function revokeApiKey(keyId: string): Promise<boolean> {
const store = await loadStore();
let tokenToRemove: string | null = null;

for (const [token, data] of Object.entries(store.keys)) {
if (data.keyId === keyId) {
tokenToRemove = token;
break;
}
}

if (tokenToRemove) {
delete store.keys[tokenToRemove];
saveStore(store);
await saveStore(store);
loggers.server.info({ keyId }, "Revoked API key");
return true;
}

return false;
}
Loading