Files
freno-dev/src/server/api/utils.ts
Michael Freno 7287f10c9a feat: migrate Nessa auth to Clerk session tokens (task 03)
- src/server/nessa-auth.ts: replace jose HS256 sign/verify with Clerk
  session JWT verification via @clerk/backend verifyToken (RS256/JWKS).
  signNessaToken removed — frontend now supplies Clerk session tokens.
- src/server/api/utils.ts: createTRPCContext verifies Clerk JWT, resolves
  ctx.nessaUserId via SELECT id FROM users WHERE clerkUserId=? on the
  shared NessaConnectionFactory. Lookup miss throws typed UNAUTHORIZED
  (webhook has not run yet). Invalid/expired tokens are swallowed; the
  enforceNessaUser middleware rejects null nessaUserId.
- src/server/api/routers/nessa-community-authz.test.ts: add clerkUserId
  lookup tests (seeded match, missing row, mismatched id, local≠clerk).
- src/server/nessa-auth.test.ts: verifyNessaToken unit tests with mocked
  @clerk/backend (valid sub, missing sub, malformed/expired/wrong-signature
  rejection) plus static audit that signNessaToken is gone.
- src/server/clerk-user-webhook.ts + src/routes/api/clerk-webhook.ts:
  Clerk user.created/user.updated webhook handler (Svix signature
  verification, idempotent upsert by clerkUserId, lazy ALTER TABLE
  migration) with full test suite.
- src/server/api/routers/nessa.ts: remove legacy register/login/google/
  apple sign-in mutations (Clerk is now the sole identity provider).
- src/env/server.ts: add NESSA_CLERK_SECRET, NESSA_CLERK_JWT_ISSUER,
  NESSA_CLERK_WEBHOOK_SECRET; NESSA_JWT_SECRET moved to optional.
- package.json: add @clerk/backend, svix; lineage/auth.test.ts and
  nessa-ownership.test.ts: add Clerk env vars to env mocks.
- .env.example: document Clerk config vars and rotation.
- delete nessa-google-oauth.test.ts (Google auth removed).

ctx.nessaUserId remains the local users.id — router bodies are untouched.
2026-07-23 01:40:53 -04:00

185 lines
5.6 KiB
TypeScript

import { initTRPC, TRPCError } from "@trpc/server";
import type { APIEvent } from "@solidjs/start/server";
import { logVisit, enrichAnalyticsEntry } from "~/server/analytics";
import { getRequestIP } from "vinxi/http";
import { verifyNessaToken } from "~/server/nessa-auth";
import { getAuthPayloadFromEvent } from "~/server/auth";
import { NessaConnectionFactory } from "~/server/database";
export type Context = {
event: APIEvent;
userId: string | null;
isAdmin: boolean;
nessaUserId: string | null;
};
/** Safely get a header value from either Fetch API Headers or Node.js IncomingHttpHeaders */
function getHeader(
headers: Record<string, string | string[] | undefined> | Headers | undefined,
name: string
): string | undefined {
if (!headers) return undefined;
// Check if it's a Fetch API Headers object (has .get method)
if (typeof (headers as Headers).get === "function") {
return (headers as Headers).get(name) || undefined;
}
// Otherwise treat as Node.js IncomingHttpHeaders (plain object)
const value = (headers as Record<string, string | string[] | undefined>)[
name.toLowerCase()
];
if (Array.isArray(value)) return value[0];
return value;
}
async function createContextInner(event: APIEvent): Promise<Context> {
const payload = await getAuthPayloadFromEvent(event.nativeEvent);
let userId: string | null = null;
let isAdmin = false;
if (payload) {
userId = payload.sub;
isAdmin = payload.isAdmin;
}
const req = event.nativeEvent.node?.req || event.nativeEvent;
const path = req.url || event.request?.url || "unknown";
const method = req.method || event.request?.method || "GET";
const userAgent =
getHeader(req.headers, "user-agent") ||
getHeader(event.request?.headers, "user-agent");
const referrer =
getHeader(req.headers, "referer") ||
getHeader(req.headers, "referrer") ||
getHeader(event.request?.headers, "referer");
const ipAddress = getRequestIP(event.nativeEvent) || undefined;
const authHeader =
getHeader(req.headers, "authorization") ||
getHeader(event.request?.headers, "authorization") ||
null;
let nessaUserId: string | null = null;
if (authHeader && authHeader.startsWith("Bearer ")) {
const token = authHeader.replace("Bearer ", "").trim();
try {
// Verify the Clerk session JWT — `sub` is the Clerk user id.
const clerkPayload = await verifyNessaToken(token);
// Resolve the Clerk user id to the local users.id via the indexed
// clerkUserId column. One indexed query per request is acceptable;
// no premature caching (the row is created by the Clerk webhook).
const conn = NessaConnectionFactory();
const result = await conn.execute({
sql: "SELECT id FROM users WHERE clerkUserId = ?",
args: [clerkPayload.sub]
});
if (result.rows.length === 0) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Nessa user not found — Clerk account not linked"
});
}
// `nessaUserId` is the LOCAL users.id — router bodies reference it
// exactly as before (club ownership, membership, row scoping).
nessaUserId = (result.rows[0] as { id: string }).id;
} catch (error) {
// Re-throw typed TRPCError (lookup miss) so the caller gets UNAUTHORIZED;
// swallow Clerk verification failures (expired/invalid token) the same
// way the legacy path did — the enforceNessaUser middleware rejects
// null nessaUserId with UNAUTHORIZED.
if (error instanceof TRPCError) throw error;
console.error("Nessa JWT verification failed:", error);
}
}
// Don't log the performance logging endpoint itself to avoid circular tracking
if (!path.includes("analytics.logPerformance")) {
logVisit(
enrichAnalyticsEntry({
userId,
path,
method,
userAgent,
referrer,
ipAddress
})
);
}
return {
event,
userId,
isAdmin,
nessaUserId
};
}
export const createTRPCContext = (event: APIEvent) => {
return createContextInner(event);
};
export const t = initTRPC.context<Context>().create();
export const createTRPCRouter = t.router;
export const publicProcedure = t.procedure;
const enforceUserIsAuthed = t.middleware(({ ctx, next }) => {
if (!ctx.userId) {
throw new TRPCError({ code: "UNAUTHORIZED", message: "Not authenticated" });
}
return next({
ctx: {
...ctx,
userId: ctx.userId
}
});
});
const enforceUserIsAdmin = t.middleware(({ ctx, next }) => {
if (!ctx.isAdmin) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Admin access required"
});
}
return next({
ctx: {
...ctx,
userId: ctx.userId!
}
});
});
const enforceNessaUser = t.middleware(({ ctx, next }) => {
if (!ctx.nessaUserId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Nessa authentication required"
});
}
return next({
ctx: {
...ctx,
nessaUserId: ctx.nessaUserId
}
});
});
export const protectedProcedure = t.procedure.use(enforceUserIsAuthed);
export const adminProcedure = t.procedure.use(enforceUserIsAdmin);
export const nessaProcedure = t.procedure.use(enforceNessaUser);
// CSRF protection middleware - defined here to avoid circular dependency
const csrfProtection = t.middleware(async ({ ctx, next }) => {
// For now, pass through - full CSRF validation in security.ts
// This allows tests to run while maintaining the procedure interface
return next();
});
// CSRF-protected procedure
export const csrfProtectedProcedure = t.procedure.use(csrfProtection);
export { csrfProtection };