Files
growqr-backend/src/auth/clerk.ts
2026-06-05 00:40:28 +05:30

123 lines
3.6 KiB
TypeScript

import { createClerkClient, verifyToken } from "@clerk/backend";
import { createMiddleware } from "hono/factory";
import { HTTPException } from "hono/http-exception";
import { config } from "../config.js";
import { db } from "../db/client.js";
import { users } from "../db/schema.js";
import { eq } from "drizzle-orm";
import { log } from "../log.js";
export type AuthContext = {
Variables: {
userId: string;
userEmail: string;
};
};
export const clerk = config.clerkSecretKey
? createClerkClient({ secretKey: config.clerkSecretKey })
: null;
// Verifies a Clerk session JWT from the Authorization header.
// Falls back to allowing the configured SERVICE_TOKEN for actor → backend calls.
//
// Bootstraps a row in `users` on first sight so downstream code can FK to it.
export const requireUser = createMiddleware<AuthContext>(async (c, next) => {
const auth = c.req.header("authorization") ?? "";
const token = auth.replace(/^Bearer\s+/i, "").trim();
// Service-to-service path (Grow Agent actor calling backend).
// Header `x-growqr-user` is REQUIRED so we can scope the call.
const trustedServiceTokens = new Set(
[
config.serviceToken,
config.nodeEnv !== "production" ? config.a2aAllowedKey : "",
].filter(Boolean),
);
if (
token &&
trustedServiceTokens.has(token) &&
c.req.header("x-growqr-user")
) {
const userId = c.req.header("x-growqr-user")!;
let row = await db.query.users.findFirst({ where: eq(users.id, userId) });
if (!row) {
const inserted = await db
.insert(users)
.values({
id: userId,
email: `${userId}@service.local`,
displayName: userId,
})
.onConflictDoNothing()
.returning();
row = inserted[0];
}
if (!row) {
throw new HTTPException(401, { message: "service token references unknown user" });
}
c.set("userId", row.id);
c.set("userEmail", row.email);
return next();
}
if (!token) {
throw new HTTPException(401, { message: "missing bearer token" });
}
if (!clerk) {
throw new HTTPException(500, { message: "Clerk not configured" });
}
let payload: Awaited<ReturnType<typeof verifyToken>>;
try {
payload = await verifyToken(token, {
secretKey: config.clerkSecretKey,
});
} catch {
throw new HTTPException(401, { message: "invalid clerk token" });
}
const userId = payload.sub;
if (!userId) {
throw new HTTPException(401, { message: "clerk token missing subject" });
}
// Lazy-mirror Clerk user → users table.
let row = await db.query.users.findFirst({ where: eq(users.id, userId) });
if (!row) {
let email = `${userId}@unknown.local`;
let displayName: string | null = null;
try {
const clerkUser = await clerk.users.getUser(userId);
email =
clerkUser.primaryEmailAddress?.emailAddress ??
clerkUser.emailAddresses[0]?.emailAddress ??
email;
displayName =
[clerkUser.firstName, clerkUser.lastName].filter(Boolean).join(" ") ||
clerkUser.username ||
null;
} catch (err) {
log.warn({ err, userId }, "failed to hydrate Clerk user details; creating minimal backend mirror row");
}
const inserted = await db
.insert(users)
.values({ id: userId, email, displayName })
.onConflictDoUpdate({
target: users.id,
set: { email, displayName, updatedAt: new Date() },
})
.returning();
row = inserted[0];
if (!row) {
throw new HTTPException(500, { message: "failed to upsert user" });
}
}
c.set("userId", row.id);
c.set("userEmail", row.email);
return next();
});