Skip to content
Open
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
90 changes: 90 additions & 0 deletions backend/src/api-key.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { createHash, randomBytes } from "node:crypto";
import type { FastifyReply, FastifyRequest } from "fastify";

import { env } from "./env.js";
import { convex, internal } from "./convex.js";
import { LOCAL_USER_ID } from "./local-credentials.js";

const KEY_PREFIX_LEN = 8;

function hashKey(key: string): string {
return createHash("sha256").update(key).digest("hex");
}

export function generateApiKey(): { key: string; keyHash: string; keyPrefix: string } {
const random = randomBytes(32).toString("base64url");
const key = `bsk_${random}`;
const keyHash = hashKey(key);
const keyPrefix = key.slice(0, KEY_PREFIX_LEN);
return { key, keyHash, keyPrefix };
}

export function apiKeyHash(key: string): string {
return hashKey(key);
}

export async function requireApiKey(
req: FastifyRequest,
reply: FastifyReply,
): Promise<boolean> {
const header = req.headers["x-api-key"];
const authHeader = req.headers.authorization;
let rawKey: string | undefined;
if (typeof header === "string" && header.startsWith("bsk_")) {
rawKey = header;
} else if (typeof authHeader === "string" && authHeader.startsWith("Bearer bsk_")) {
rawKey = authHeader.slice("Bearer ".length);
}
if (!rawKey) return false;

const keyHash = hashKey(rawKey);
const record = await convex.query(internal.apiKeys.lookupByHash, { keyHash });
if (!record) {
await reply.code(401).send({ error: "Invalid API key" });
return true;
}

req.auth = { userId: record.ownerId };

void convex
.mutation(internal.apiKeys.touchLastUsed, {
id: record._id,
lastUsedAt: Date.now(),
})
.catch(() => {});

return true;
}

/**
* Try API-key auth. Returns:
* - `true` if the request had an X-API-Key / Bearer bsk_… header
* (either the key was accepted and req.auth is set, or the response
* was already sent with 401). Caller must NOT continue.
* - `false` if no API key was presented. Caller should fall through
* to Clerk (or whatever its next auth mechanism is).
*/
export async function tryApiKeyAuth(
req: FastifyRequest,
reply: FastifyReply,
): Promise<boolean> {
const header = req.headers["x-api-key"];
const authHeader = req.headers.authorization;
const hasKey =
(typeof header === "string" && header.startsWith("bsk_")) ||
(typeof authHeader === "string" && authHeader.startsWith("Bearer bsk_"));
if (!hasKey) return false;
await requireApiKey(req, reply);
return true;
}

export async function requireApiKeyOrCli(
req: FastifyRequest,
reply: FastifyReply,
): Promise<boolean> {
if (env.IS_LOCAL_MODE) {
req.auth = { userId: LOCAL_USER_ID };
return true;
}
return requireApiKey(req, reply);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
33 changes: 24 additions & 9 deletions backend/src/clerk-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { createClerkClient, type ClerkClient } from "@clerk/backend";

import { env } from "./env.js";
import { LOCAL_USER_ID } from "./local-credentials.js";
import { tryApiKeyAuth } from "./api-key.js";

/**
* Clerk JWT verification for the Fastify backend.
Expand Down Expand Up @@ -79,13 +80,10 @@ export async function getUserEmail(
export default fp(clerkPlugin, { name: "clerk-auth" });

/**
* Fastify preHandler that requires a valid Clerk session token.
*
* Reads `Authorization: Bearer <token>`, verifies it via Clerk's
* `authenticateRequest`, and attaches `req.auth = { userId }` on success.
* Returns 401 otherwise.
* Fastify preHandler that requires a valid Clerk session token — no API key fallback.
* Use for routes that must only be accessible via Clerk (e.g. API key management).
*/
export async function requireAuth(
export async function requireClerkAuth(
req: FastifyRequest,
reply: FastifyReply,
): Promise<void> {
Expand All @@ -100,8 +98,6 @@ export async function requireAuth(
return;
}

// Wrap the Fastify request just enough for Clerk's authenticateRequest API.
// Clerk accepts a Web Request; build one from the headers we care about.
const headers = new Headers();
for (const [k, v] of Object.entries(req.headers)) {
if (typeof v === "string") headers.set(k, v);
Expand All @@ -116,7 +112,6 @@ export async function requireAuth(
const requestState = await req.server.clerk.authenticateRequest(
clerkRequest,
{
// Anyone consuming our backend is our own frontend; lock to its origin.
authorizedParties: [env.CLIENT_ORIGIN],
},
);
Expand All @@ -134,3 +129,23 @@ export async function requireAuth(

req.auth = { userId: auth.userId };
}

/**
* Fastify preHandler that accepts either a Clerk session token or a valid API key.
* Use for routes that should be accessible from both browser (Clerk) and
* programmatic clients (API key).
*/
export async function requireAuth(
req: FastifyRequest,
reply: FastifyReply,
): Promise<void> {
if (env.IS_LOCAL_MODE) {
req.auth = { userId: LOCAL_USER_ID };
return;
}

const apiKeyHandled = await tryApiKeyAuth(req, reply);
if (apiKeyHandled) return;

await requireClerkAuth(req, reply);
}
Loading