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.
This commit is contained in:
@@ -4,6 +4,7 @@ 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;
|
||||
@@ -63,9 +64,32 @@ async function createContextInner(event: APIEvent): Promise<Context> {
|
||||
if (authHeader && authHeader.startsWith("Bearer ")) {
|
||||
const token = authHeader.replace("Bearer ", "").trim();
|
||||
try {
|
||||
const payload = await verifyNessaToken(token);
|
||||
nessaUserId = payload.sub;
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user