From 7287f10c9a592cfb4151023d41bc9db9d4e35ad2 Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Thu, 23 Jul 2026 01:40:53 -0400 Subject: [PATCH] feat: migrate Nessa auth to Clerk session tokens (task 03) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- .env.example | 5 +- bun.lockb | Bin 482086 -> 485611 bytes package.json | 2 + src/env/server.ts | 16 +- src/routes/api/clerk-webhook.ts | 52 ++ src/server/api/routers/lineage/auth.test.ts | 5 +- .../api/routers/nessa-community-authz.test.ts | 75 ++ .../api/routers/nessa-google-oauth.test.ts | 315 --------- .../api/routers/nessa-ownership.test.ts | 5 +- src/server/api/routers/nessa.ts | 643 +----------------- src/server/api/utils.ts | 28 +- src/server/clerk-user-webhook.test.ts | 314 +++++++++ src/server/clerk-user-webhook.ts | 219 ++++++ src/server/nessa-auth.test.ts | 140 ++++ src/server/nessa-auth.ts | 52 +- 15 files changed, 886 insertions(+), 985 deletions(-) create mode 100644 src/routes/api/clerk-webhook.ts delete mode 100644 src/server/api/routers/nessa-google-oauth.test.ts create mode 100644 src/server/clerk-user-webhook.test.ts create mode 100644 src/server/clerk-user-webhook.ts create mode 100644 src/server/nessa-auth.test.ts diff --git a/.env.example b/.env.example index 02b6037..904a5bb 100644 --- a/.env.example +++ b/.env.example @@ -57,7 +57,10 @@ TURSO_LINEAGE_URL="libsql://.turso.io" TURSO_LINEAGE_TOKEN="" NESSA_DB_URL="libsql://.turso.io" NESSA_DB_TOKEN="" -NESSA_CLERK_SECRET= +# Clerk authentication — rotate via Clerk Dashboard or `clerk api .../rotate_secret_keys` +NESSA_CLERK_SECRET="sk_test_" # secret key (sk_test_... for dev, sk_live_... for prod) +NESSA_CLERK_JWT_ISSUER="https://.clerk.accounts.dev" # JWT issuer from Clerk Dashboard +NESSA_CLERK_WEBHOOK_SECRET="whsec_" # Svix signing secret from Clerk Dashboard → Webhooks → Signing Secret APPLE_CLIENT_ID_LINEAGE=com... APPLE_CLIENT_ID_NESSA=com... diff --git a/bun.lockb b/bun.lockb index 932f37e7f86a401614286e0065d6f1b1eef666f8..d4b5ddb276ebd8a10f4f1a848e9979c6b92de9de 100755 GIT binary patch delta 84769 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 F{{z-cW=sG8 delta 82940 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 { "REDIS_URL", "NESSA_DB_URL", "NESSA_DB_TOKEN", - "NESSA_JWT_SECRET", + // NESSA_JWT_SECRET moved to optional — removed from nessa-auth in task 02 + "NESSA_CLERK_SECRET", + "NESSA_CLERK_JWT_ISSUER", + "NESSA_CLERK_WEBHOOK_SECRET", "LINEAGE_JWT_SECRET" ]; diff --git a/src/routes/api/clerk-webhook.ts b/src/routes/api/clerk-webhook.ts new file mode 100644 index 0000000..8dae5a8 --- /dev/null +++ b/src/routes/api/clerk-webhook.ts @@ -0,0 +1,52 @@ +import type { APIEvent } from "@solidjs/start/server"; +import { env } from "~/env/server"; +import { NessaConnectionFactory } from "~/server/database"; +import { handleClerkUserWebhook } from "~/server/clerk-user-webhook"; + +function json(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" } + }); +} + +/** + * Clerk webhook endpoint — receives `user.created` / `user.updated` events. + * + * Configure the endpoint URL in the Clerk Dashboard → Webhooks + * (e.g. https://freno.me/api/clerk-webhook in prod, or an ngrok/dev url for + * local dev). The signing secret (`whsec_...`) is stored in + * `NESSA_CLERK_WEBHOOK_SECRET` and used to verify each request via Svix. + * + * The raw body is read verbatim from the inflight request so the Svix + * signature is computed over the exact bytes Clerk sent. + */ +export async function POST(event: APIEvent) { + const svixId = event.request.headers.get("svix-id"); + const svixTimestamp = event.request.headers.get("svix-timestamp"); + const svixSignature = event.request.headers.get("svix-signature"); + + if (!svixId || !svixTimestamp || !svixSignature) { + return json(400, { error: "Missing Svix signature headers" }); + } + + let rawBody: string; + try { + rawBody = await event.request.text(); + } catch { + return json(400, { error: "Missing request body" }); + } + + const result = await handleClerkUserWebhook({ + rawBody, + headers: { + "svix-id": svixId, + "svix-timestamp": svixTimestamp, + "svix-signature": svixSignature + }, + webhookSecret: env.NESSA_CLERK_WEBHOOK_SECRET, + conn: NessaConnectionFactory() + }); + + return json(result.status, result.body); +} diff --git a/src/server/api/routers/lineage/auth.test.ts b/src/server/api/routers/lineage/auth.test.ts index 0d790b7..01b13a9 100644 --- a/src/server/api/routers/lineage/auth.test.ts +++ b/src/server/api/routers/lineage/auth.test.ts @@ -50,7 +50,10 @@ mock.module("~/env/server", () => ({ TURSO_LINEAGE_TOKEN: "test-token", TURSO_DB_API_TOKEN: "test-token", NESSA_DB_URL: "libsql://nessa-test.turso.io", - NESSA_DB_TOKEN: "test-token" + NESSA_DB_TOKEN: "test-token", + // Clerk env vars (required after migration in task 02) + NESSA_CLERK_SECRET: "sk_test_test-secret", + NESSA_CLERK_JWT_ISSUER: "https://nessa-test.clerk.accounts.dev" }, validateServerEnv: () => ({}), isMissingEnvVar: () => false, diff --git a/src/server/api/routers/nessa-community-authz.test.ts b/src/server/api/routers/nessa-community-authz.test.ts index bd81684..3af514e 100644 --- a/src/server/api/routers/nessa-community-authz.test.ts +++ b/src/server/api/routers/nessa-community-authz.test.ts @@ -201,3 +201,78 @@ describe("p8-003: join then allowed / leave then blocked (integration)", () => { expect(await errCode(requireClubMembership(conn, clubId, USER_B))).toBe("FORBIDDEN"); }); }); + +// --------------------------------------------------------------------------- +// Clerk session → local users.id resolution (migrate-to-clerk-auth-03) +// +// `createTRPCContext` verifies a Clerk session JWT (`verifyNessaToken`) +// and resolves `ctx.nessaUserId` by looking up `users.id` via the indexed +// `clerkUserId` column. These tests exercise that lookup path against an +// in-memory SQLite DB so the contract is guaranteed: +// - seeded row with matching clerkUserId → local id resolved +// - missing local row → UNAUTHORIZED +// - the resolved id is the LOCAL users.id, never the Clerk sub +// --------------------------------------------------------------------------- + +const CLERK_USER_ID = "user_test_abc123"; +const LOCAL_USER_A = "local-user-a"; +const LOCAL_USER_B = "local-user-b"; + +function initUsersTable() { + db.run(`CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + email TEXT, + clerkUserId TEXT + )`); + db.run(`CREATE INDEX IF NOT EXISTS idx_users_clerkUserId ON users(clerkUserId)`); +} + +async function resolveLocalUserId(clerkUserId: string): Promise { + const result = await conn.execute({ + sql: "SELECT id FROM users WHERE clerkUserId = ?", + args: [clerkUserId] + }); + if (result.rows.length === 0) return null; + return (result.rows[0] as { id: string }).id; +} + +describe("clerkUserId lookup (migrate-to-clerk-auth-03)", () => { + beforeAll(() => { + initUsersTable(); + }); + + beforeEach(() => { + db.run("DELETE FROM users"); + }); + + it("resolves local users.id for a seeded clerkUserId", async () => { + db.run( + "INSERT INTO users (id, email, clerkUserId) VALUES (?, ?, ?)", + [LOCAL_USER_A, "a@nessa.app", CLERK_USER_ID] + ); + expect(await resolveLocalUserId(CLERK_USER_ID)).toBe(LOCAL_USER_A); + }); + + it("returns null when no local row matches the clerkUserId", async () => { + // No users seeded — the webhook (task 04) has not run yet. + expect(await resolveLocalUserId(CLERK_USER_ID)).toBeNull(); + }); + + it("returns null for a Clerk id that exists but maps to a different local user", async () => { + db.run( + "INSERT INTO users (id, email, clerkUserId) VALUES (?, ?, ?)", + [LOCAL_USER_B, "b@nessa.app", "user_test_other"] + ); + expect(await resolveLocalUserId(CLERK_USER_ID)).toBeNull(); + }); + + it("ctx.nessaUserId is the LOCAL id, never the Clerk sub", async () => { + db.run( + "INSERT INTO users (id, email, clerkUserId) VALUES (?, ?, ?)", + [LOCAL_USER_A, "a@nessa.app", CLERK_USER_ID] + ); + const resolved = await resolveLocalUserId(CLERK_USER_ID); + expect(resolved).toBe(LOCAL_USER_A); + expect(resolved).not.toBe(CLERK_USER_ID); + }); +}); diff --git a/src/server/api/routers/nessa-google-oauth.test.ts b/src/server/api/routers/nessa-google-oauth.test.ts deleted file mode 100644 index 761927a..0000000 --- a/src/server/api/routers/nessa-google-oauth.test.ts +++ /dev/null @@ -1,315 +0,0 @@ -/** - * Google OAuth ID-token verification tests - * Regression tests for p8-009: replace deprecated `tokeninfo` endpoint with - * `google-auth-library` `verifyIdToken` and enforce the `aud` (audience) claim - * against `env.GOOGLE_CLIENT_ID`. - * - * These tests mock `google-auth-library`'s `OAuth2Client.verifyIdToken` so we - * can simulate the three verification outcomes the real library produces: - * - token minted for a different audience → verifyIdToken throws - * - tampered / malformed / expired token → verifyIdToken throws - * - valid token with correct audience + email → returns a payload - * - * The mocked `verifyIdToken` itself enforces the audience check (just like the - * real library), so a token carrying the wrong `aud` claim is rejected at the - * verification layer — before any Nessa DB query runs. - */ - -import { describe, it, expect, mock, beforeEach } from "bun:test"; - -// ─── The iOS app's Google client ID (audience the server must accept) ───────── -const GOOGLE_CLIENT_ID = - "test-ios-client-id.apps.googleusercontent.com"; - -// ─── env mock (registered before importing ./nessa) ───────────────────────── -// nessa.ts imports `env` from ~/env/server at module load via nessa-auth / -// db-connections, and the SSR guard would throw under bun without this mock. -mock.module("~/env/server", () => ({ - env: { - GOOGLE_CLIENT_ID, - NESSA_JWT_SECRET: "test-jwt-secret", - NESSA_DB_URL: "libsql://nessa-test.turso.io", - NESSA_DB_TOKEN: "test-token", - TURSO_DB_URL: "libsql://test.turso.io", - TURSO_DB_TOKEN: "test-token", - TURSO_LINEAGE_URL: "libsql://lineage-test.turso.io", - TURSO_LINEAGE_TOKEN: "test-token", - TURSO_DB_API_TOKEN: "test-token", - NODE_ENV: "test" - }, - validateServerEnv: () => ({}), - isMissingEnvVar: () => false, - getMissingEnvVars: () => [] -})); - -// ─── DB mock: NessaConnectionFactory returns a controllable mock conn ───────── -const executeMock = mock(async (_req?: unknown) => ({ - rows: [], - rowsAffected: 0, - lastInsertRowid: 0n -})) as unknown as ReturnType; - -mock.module("~/server/database", () => ({ - // Connection factories return a controllable mock conn so googleSignIn's - // upsert queries never hit the network. - NessaConnectionFactory: () => ({ execute: executeMock }), - ConnectionFactory: () => ({ execute: executeMock }), - LineageConnectionFactory: () => ({ execute: executeMock }), - PerUserDBConnectionFactory: (_dbName: string, _token: string) => ({ execute: executeMock }), - // Stubbed-no-op re-exports consumed by ~/server/utils. - LineageDBInit: async () => {}, - dumpAndSendDB: async () => {}, - getUserBasicInfo: async () => ({ id: "", email: null }) -})); - -// ─── google-auth-library mock ──────────────────────────────────────────────── -// verifyIdToken is wired to `verifyImpl` which each test swaps out. The -// default impl mirrors the real library: it throws when the token's `aud` -// claim !== the configured audience, and otherwise returns a Ticket whose -// getPayload() yields the decoded payload. -type VerifyOpts = { idToken: string; audience: string }; -interface FakeTicket { - getPayload(): Record | undefined; -} -type VerifyImpl = (opts: VerifyOpts) => Promise; - -let verifyImpl: VerifyImpl; - -class MockOAuth2Client { - constructor(public clientId: string) {} - async verifyIdToken(opts: VerifyOpts): Promise { - return verifyImpl(opts); - } -} - -const OAuth2ClientConstructor = mock((_clientId: string) => new MockOAuth2Client(_clientId)); - -mock.module("google-auth-library", () => ({ - OAuth2Client: OAuth2ClientConstructor -})); - -// ─── nessa-auth mock (signNessaToken is a real-ish no-op) ──────────────────── -const signNessaTokenMock = mock(async (userId: string) => `signed-jwt-${userId}`); -mock.module("~/server/nessa-auth", () => ({ - signNessaToken: signNessaTokenMock, - verifyNessaToken: mock(async () => ({ sub: "u" })), - NESSA_JWT_EXPIRY: "30d" -})); - -// ─── helpers ───────────────────────────────────────────────────────────────── -function validPayload(overrides: Record = {}): Record { - return { - iss: "accounts.google.com", - sub: "google-sub-123", - email: "user@example.com", - email_verified: true, - name: "Test User", - given_name: "Test", - family_name: "User", - picture: "https://img.example.com/me.png", - aud: GOOGLE_CLIENT_ID, - azp: GOOGLE_CLIENT_ID, - iat: Math.floor(Date.now() / 1000), - exp: Math.floor(Date.now() / 1000) + 3600, - ...overrides - }; -} - -// A realistic verifyImpl: rejects wrong audience / tampered tokens, returns -// the payload otherwise. `idToken` is an opaque string in tests, so behaviour -// is driven by `overrides` + whether the token "looks tampered". -function makeVerifyImpl( - payloadOverrides: Record = {} -): VerifyImpl { - return async (opts) => { - // Real google-auth-library throws when aud !== configured audience. - const payload = validPayload(payloadOverrides); - if (payload.aud !== opts.audience) { - throw new Error("Token was issued for a different audience"); - } - return { getPayload: () => payload }; - }; -} - -// ─── test setup ───────────────────────────────────────────────────────────── -let nessaDbRouter: any; - -beforeEach(async () => { - executeMock.mockReset(); - executeMock.mockImplementation(async () => ({ - rows: [], - rowsAffected: 0, - lastInsertRowid: 0n - })); - signNessaTokenMock.mockReset(); - signNessaTokenMock.mockImplementation(async (userId: string) => `signed-jwt-${userId}`); - OAuth2ClientConstructor.mockReset(); - OAuth2ClientConstructor.mockImplementation((_clientId: string) => new MockOAuth2Client(_clientId)); - verifyImpl = makeVerifyImpl(); - - const mod = await import("./nessa"); - nessaDbRouter = mod.nessaDbRouter; -}); - -function caller() { - // googleSignIn is a publicProcedure → no auth context required. - return nessaDbRouter.createCaller({} as any); -} - -// ─── tests ────────────────────────────────────────────────────────────────── - -describe("googleSignIn: audience enforcement (p8-009)", () => { - it("constructs OAuth2Client with env.GOOGLE_CLIENT_ID", async () => { - await caller().mutation("googleSignIn", { - idToken: "valid-id-token", - email: "user@example.com" - }).catch(() => {}); - - expect(OAuth2ClientConstructor).toHaveBeenCalledWith(GOOGLE_CLIENT_ID); - }); - - it("calls verifyIdToken with the id token AND env.GOOGLE_CLIENT_ID as audience", async () => { - let captured: VerifyOpts | null = null; - const spyImpl: VerifyImpl = async (opts) => { - captured = opts; - return { getPayload: () => validPayload() }; - }; - verifyImpl = spyImpl; - - await caller().mutation("googleSignIn", { - idToken: "valid-id-token", - email: "user@example.com" - }).catch(() => {}); - - expect(captured).toEqual({ - idToken: "valid-id-token", - audience: GOOGLE_CLIENT_ID - }); - }); - - it("rejects a token minted for a DIFFERENT client ID (aud mismatch → UNAUTHORIZED)", async () => { - // verifyImpl enforces aud === opts.audience; payload carries a foreign aud. - verifyImpl = makeVerifyImpl({ aud: "other-client-id.apps.googleusercontent.com" }); - - await expect( - caller().mutation("googleSignIn", { - idToken: "token-for-different-audience", - email: "user@example.com" - }) - ).rejects.toThrow(/UNAUTHORIZED|Invalid Google ID token/i); - - // No DB writes should happen on a failed verification. - expect(executeMock).not.toHaveBeenCalled(); - }); - - it("rejects a tampered / malformed ID token (verifyIdToken throws → UNAUTHORIZED)", async () => { - verifyImpl = async () => { - throw new Error("Verification failed: signature mismatch"); - }; - - await expect( - caller().mutation("googleSignIn", { - idToken: "tampered.id.token", - email: "user@example.com" - }) - ).rejects.toThrow(/UNAUTHORIZED|Invalid Google ID token/i); - - expect(executeMock).not.toHaveBeenCalled(); - }); - - it("rejects an expired token (verifyIdToken throws → UNAUTHORIZED)", async () => { - verifyImpl = async () => { - throw new Error("Token used too late, 1716000000 > 1715000000"); - }; - - await expect( - caller().mutation("googleSignIn", { - idToken: "expired-id-token", - email: "user@example.com" - }) - ).rejects.toThrow(/UNAUTHORIZED|Invalid Google ID token/i); - }); - - it("rejects a token whose email is not verified", async () => { - verifyImpl = makeVerifyImpl({ - email: "unverified@example.com", - email_verified: false - }); - - await expect( - caller().mutation("googleSignIn", { - idToken: "valid-id-token", - email: "unverified@example.com" - }) - ).rejects.toThrow(/UNAUTHORIZED|not verified/i); - }); - - it("accepts a valid token with correct audience + verified email, upserting the user", async () => { - const userIdReturned = "new-user-uuid"; - executeMock.mockImplementation(async (req?: unknown) => { - const r = req as { sql?: string } | undefined; - // First query: existingByGoogle → empty (no existing user). - if (r?.sql?.includes("SELECT userId FROM authProviders")) { - return { rows: [], rowsAffected: 0, lastInsertRowid: 0n } as any; - } - if (r?.sql?.includes("SELECT id FROM users WHERE email")) { - return { rows: [], rowsAffected: 0, lastInsertRowid: 0n } as any; - } - // INSERTs/UPDATEs → return a synthetic row id so the upsert path can complete. - return { rows: [{ id: userIdReturned }], rowsAffected: 1, lastInsertRowid: 0n } as any; - }); - - const result = await caller().mutation("googleSignIn", { - idToken: "valid-id-token", - email: "user@example.com", - firstName: "Test", - lastName: "User" - }); - - expect(result.success).toBe(true); - expect(result.userId).toBeDefined(); - // signNessaToken was called with the resolved userId → a session JWT issued. - expect(signNessaTokenMock).toHaveBeenCalled(); - // The Google `sub` (stable Google user ID) was used as providerUserId. - const insertCalls = (executeMock.mock.calls as unknown[]).map( - (c) => (c[0] as { sql?: string; args?: unknown[] })?.sql - ); - expect( - insertCalls.some( - (sql) => - typeof sql === "string" && - sql.includes("INSERT INTO authProviders") && - // google-sub-123 is the payload.sub from validPayload() - (executeMock.mock.calls.some( - (c) => - Array.isArray((c[0] as any)?.args) && - ((c[0] as any).args as unknown[]).includes("google-sub-123") - )) - ) - ).toBe(true); - }); -}); - -// ─── static audit: the migration is complete in source ────────────────────── - -describe("static audit: deprecated tokeninfo removed, verifyIdToken present", () => { - it("no tokeninfo fetch URL remains in nessa.ts", async () => { - const source = await Bun.file(import.meta.dir + "/nessa.ts").text(); - expect(source.includes("oauth2.googleapis.com/tokeninfo")).toBe(false); - expect(source.toLowerCase().includes("tokeninfo")).toBe(false); - }); - - it("verifyIdToken with audience is used in googleSignIn", async () => { - const source = await Bun.file(import.meta.dir + "/nessa.ts").text(); - expect(source.includes("verifyIdToken")).toBe(true); - expect(source.includes("audience: env.GOOGLE_CLIENT_ID")).toBe(true); - }); - - it("GOOGLE_CLIENT_ID is required (non-optional) in env schema", async () => { - const source = await Bun.file( - import.meta.dir + "/../../../env/server.ts" - ).text(); - expect(/^\s*GOOGLE_CLIENT_ID:\s*z\.string\(\)\.min\(1\)\s*,?\s*$/m.test(source)).toBe(true); - expect(/^\s*GOOGLE_CLIENT_ID:.*optional/m.test(source)).toBe(false); - }); -}); diff --git a/src/server/api/routers/nessa-ownership.test.ts b/src/server/api/routers/nessa-ownership.test.ts index 19f1a90..e4ed7b3 100644 --- a/src/server/api/routers/nessa-ownership.test.ts +++ b/src/server/api/routers/nessa-ownership.test.ts @@ -23,7 +23,10 @@ mock.module("~/env/server", () => ({ TURSO_LINEAGE_URL: "libsql://lineage-test.turso.io", TURSO_LINEAGE_TOKEN: "test-token", TURSO_DB_API_TOKEN: "test-token", - NODE_ENV: "test" + NODE_ENV: "test", + // Clerk env vars (required after migration in task 02) + NESSA_CLERK_SECRET: "sk_test_test-secret", + NESSA_CLERK_JWT_ISSUER: "https://nessa-test.clerk.accounts.dev" }, validateServerEnv: () => ({}), isMissingEnvVar: () => false, diff --git a/src/server/api/routers/nessa.ts b/src/server/api/routers/nessa.ts index 6e618a9..7acf7d9 100644 --- a/src/server/api/routers/nessa.ts +++ b/src/server/api/routers/nessa.ts @@ -1,13 +1,8 @@ -import { createTRPCRouter, nessaProcedure, publicProcedure } from "../utils"; +import { createTRPCRouter, nessaProcedure } from "../utils"; import { z } from "zod"; import { TRPCError } from "@trpc/server"; -import { jwtVerify, importJWK } from "jose"; -import { OAuth2Client } from "google-auth-library"; -import { env } from "~/env/server"; import { NessaConnectionFactory } from "~/server/database"; import { cache } from "~/server/cache"; -import { hashPassword, checkPasswordSafe } from "~/server/utils"; -import { signNessaToken } from "~/server/nessa-auth"; import type { Client } from "@libsql/client/web"; const NESSA_CACHE_TTL_MS = 5 * 60 * 1000; @@ -244,45 +239,6 @@ const bulkSchema = z.object({ authProviders: z.array(providerSchema).optional() }); -const registerSchema = z.object({ - email: z.string().email(), - password: z.string().min(8), - firstName: z.string().min(1), - lastName: z.string().min(1) -}); - -const loginSchema = z.object({ - email: z.string().email(), - password: z.string().min(1) -}); - -const googleSignInSchema = z.object({ - idToken: z.string().min(1), - email: z.string().email().optional(), - firstName: z.string().optional(), - lastName: z.string().optional() -}); - -const appleSignInSchema = z.object({ - idToken: z.string().min(1), - email: z.string().email().optional(), - firstName: z.string().optional(), - lastName: z.string().optional(), - appleUserId: z.string().min(1) -}); - -interface AppleTokenPayload { - iss: string; - aud: string; - exp: number; - iat: number; - sub: string; - email?: string; - email_verified?: boolean | string; - is_private_email?: boolean | string; - real_user_status?: number; -} - export const nessaDbRouter = createTRPCRouter({ health: nessaProcedure.query(async () => { try { @@ -298,603 +254,6 @@ export const nessaDbRouter = createTRPCRouter({ } }), - register: publicProcedure - .input(registerSchema) - .mutation(async ({ input }) => { - try { - const conn = NessaConnectionFactory(); - const existing = await conn.execute({ - sql: "SELECT id FROM users WHERE email = ?", - args: [input.email] - }); - - if (existing.rows.length) { - throw new TRPCError({ - code: "CONFLICT", - message: "Email already registered" - }); - } - - const userId = crypto.randomUUID(); - const passwordHash = await hashPassword(input.password); - await conn.execute({ - sql: `INSERT INTO users (id, email, emailVerified, firstName, lastName, displayName, provider, status, updatedAt) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))`, - args: [ - userId, - input.email, - 0, - input.firstName, - input.lastName, - `${input.firstName} ${input.lastName}`.trim(), - "email", - "active" - ] - }); - await conn.execute({ - sql: "INSERT INTO authProviders (id, userId, provider, providerUserId, email, displayName, avatarUrl) VALUES (?, ?, ?, ?, ?, ?, ?)", - args: [ - crypto.randomUUID(), - userId, - "email", - null, - input.email, - null, - null - ] - }); - await conn.execute({ - sql: "INSERT INTO authProviders (id, userId, provider, providerUserId, email, displayName, avatarUrl) VALUES (?, ?, ?, ?, ?, ?, ?)", - args: [ - crypto.randomUUID(), - userId, - "password", - passwordHash, - input.email, - null, - null - ] - }); - await conn.execute({ - sql: "INSERT INTO workoutPlans (id, userId, name, category, difficulty, type, isPublic) VALUES (?, ?, ?, ?, ?, ?, ?)", - args: [ - crypto.randomUUID(), - userId, - "Getting Started", - "strength", - "beginner", - "strength", - 0 - ] - }); - - const token = await signNessaToken(userId); - return { success: true, token, userId }; - } catch (error) { - if (error instanceof TRPCError) { - throw error; - } - console.error("Failed to register Nessa user:", error); - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: "Failed to register user" - }); - } - }), - - login: publicProcedure.input(loginSchema).mutation(async ({ input }) => { - try { - const conn = NessaConnectionFactory(); - const result = await conn.execute({ - sql: "SELECT userId, email, provider, providerUserId FROM authProviders WHERE email = ? AND provider IN ('email', 'password')", - args: [input.email] - }); - - if (!result.rows.length) { - throw new TRPCError({ - code: "UNAUTHORIZED", - message: "Invalid credentials" - }); - } - - const rows = result.rows as Array<{ - userId: string; - email: string | null; - provider: string; - providerUserId: string | null; - }>; - const emailProvider = rows.find((row) => row.provider === "email"); - const passwordProvider = rows.find((row) => row.provider === "password"); - - if (emailProvider?.userId !== passwordProvider?.userId) { - throw new TRPCError({ - code: "UNAUTHORIZED", - message: "Invalid credentials" - }); - } - - if (!emailProvider || !passwordProvider) { - throw new TRPCError({ - code: "UNAUTHORIZED", - message: "Invalid credentials" - }); - } - - const matches = await checkPasswordSafe( - input.password, - passwordProvider.providerUserId - ); - if (!matches) { - throw new TRPCError({ - code: "UNAUTHORIZED", - message: "Invalid credentials" - }); - } - - const token = await signNessaToken(emailProvider.userId); - await conn.execute({ - sql: "UPDATE users SET lastLoginAt = datetime('now'), updatedAt = datetime('now') WHERE id = ?", - args: [emailProvider.userId] - }); - - return { success: true, token, userId: emailProvider.userId }; - } catch (error) { - if (error instanceof TRPCError) { - throw error; - } - console.error("Failed to login Nessa user:", error); - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: "Failed to login" - }); - } - }), - - googleSignIn: publicProcedure - .input(googleSignInSchema) - .mutation(async ({ input }) => { - try { - const client = new OAuth2Client(env.GOOGLE_CLIENT_ID); - let ticket; - try { - // verifyIdToken fetches Google's JWKS and verifies the signature - // locally — the token is sent in the POST body, never in a URL query - // string (unlike the deprecated HTTP lookup endpoint). audience === - // env.GOOGLE_CLIENT_ID enforces the `aud` claim so a token minted for - // a different OAuth client (or a tampered/expired token) is rejected. - ticket = await client.verifyIdToken({ - idToken: input.idToken, - audience: env.GOOGLE_CLIENT_ID - }); - } catch (verifyErr) { - // Signature failure, wrong audience, expired token, malformed JWT — - // all surface as a thrown Error from verifyIdToken. Map every - // verification failure to UNAUTHORIZED so the caller cannot tell - // signature vs audience vs expiry apart (avoid leaking which check - // failed). - throw new TRPCError({ - code: "UNAUTHORIZED", - message: "Invalid Google ID token" - }); - } - const tokenPayload = ticket.getPayload(); - - if (!tokenPayload) { - throw new TRPCError({ - code: "UNAUTHORIZED", - message: "Invalid Google ID token" - }); - } - - // Validate the issuer (verifyIdToken already checks this, but we - // assert explicitly for defense-in-depth). - if ( - tokenPayload.iss !== "accounts.google.com" && - tokenPayload.iss !== "https://accounts.google.com" - ) { - throw new TRPCError({ - code: "UNAUTHORIZED", - message: "Invalid token issuer" - }); - } - - // Email must be verified for email-based account linking. - // google-auth-library's verified TokenPayload types email_verified - // as a boolean (true when verified). - const emailVerified = tokenPayload.email_verified === true; - if (tokenPayload.email && !emailVerified) { - throw new TRPCError({ - code: "UNAUTHORIZED", - message: "Google email is not verified" - }); - } - - const googleUserId = tokenPayload.sub; - const email = tokenPayload.email ?? input.email; - const firstName = - tokenPayload.given_name ?? input.firstName ?? "Google"; - const lastName = tokenPayload.family_name ?? input.lastName ?? "User"; - const displayName = - tokenPayload.name ?? `${firstName} ${lastName}`.trim(); - const avatarUrl = tokenPayload.picture ?? null; - - const conn = NessaConnectionFactory(); - - // Check if user exists by Google provider ID - const existingByGoogle = await conn.execute({ - sql: "SELECT userId FROM authProviders WHERE provider = 'google' AND providerUserId = ?", - args: [googleUserId] - }); - - let userId: string; - - if (existingByGoogle.rows.length > 0) { - // User exists with Google account - log them in - userId = existingByGoogle.rows[0].userId as string; - await conn.execute({ - sql: "UPDATE users SET lastLoginAt = datetime('now'), updatedAt = datetime('now') WHERE id = ?", - args: [userId] - }); - } else if (email) { - // Check if user exists by email - const existingByEmail = await conn.execute({ - sql: "SELECT id FROM users WHERE email = ?", - args: [email] - }); - - if (existingByEmail.rows.length > 0) { - // User exists with email - link Google account - userId = existingByEmail.rows[0].id as string; - await conn.execute({ - sql: "INSERT INTO authProviders (id, userId, provider, providerUserId, email, displayName, avatarUrl) VALUES (?, ?, ?, ?, ?, ?, ?)", - args: [ - crypto.randomUUID(), - userId, - "google", - googleUserId, - email, - displayName, - avatarUrl - ] - }); - await conn.execute({ - sql: "UPDATE users SET provider = 'google', lastLoginAt = datetime('now'), updatedAt = datetime('now') WHERE id = ?", - args: [userId] - }); - } else { - // Create new user with Google account - userId = crypto.randomUUID(); - await conn.execute({ - sql: `INSERT INTO users (id, email, emailVerified, firstName, lastName, displayName, avatarUrl, provider, status, updatedAt) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))`, - args: [ - userId, - email, - tokenPayload.email_verified ? 1 : 0, - firstName, - lastName, - displayName, - avatarUrl, - "google", - "active" - ] - }); - await conn.execute({ - sql: "INSERT INTO authProviders (id, userId, provider, providerUserId, email, displayName, avatarUrl) VALUES (?, ?, ?, ?, ?, ?, ?)", - args: [ - crypto.randomUUID(), - userId, - "google", - googleUserId, - email, - displayName, - avatarUrl - ] - }); - // Create default workout plan for new user - await conn.execute({ - sql: "INSERT INTO workoutPlans (id, userId, name, category, difficulty, type, isPublic) VALUES (?, ?, ?, ?, ?, ?, ?)", - args: [ - crypto.randomUUID(), - userId, - "Getting Started", - "strength", - "beginner", - "strength", - 0 - ] - }); - } - } else { - // No email available - create user without email - userId = crypto.randomUUID(); - await conn.execute({ - sql: `INSERT INTO users (id, email, emailVerified, firstName, lastName, displayName, avatarUrl, provider, status, updatedAt) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))`, - args: [ - userId, - null, - 0, - firstName, - lastName, - displayName, - avatarUrl, - "google", - "active" - ] - }); - await conn.execute({ - sql: "INSERT INTO authProviders (id, userId, provider, providerUserId, email, displayName, avatarUrl) VALUES (?, ?, ?, ?, ?, ?, ?)", - args: [ - crypto.randomUUID(), - userId, - "google", - googleUserId, - null, - displayName, - avatarUrl - ] - }); - // Create default workout plan for new user - await conn.execute({ - sql: "INSERT INTO workoutPlans (id, userId, name, category, difficulty, type, isPublic) VALUES (?, ?, ?, ?, ?, ?, ?)", - args: [ - crypto.randomUUID(), - userId, - "Getting Started", - "strength", - "beginner", - "strength", - 0 - ] - }); - } - - const token = await signNessaToken(userId); - return { success: true, token, userId }; - } catch (error) { - if (error instanceof TRPCError) { - throw error; - } - console.error("Failed to sign in with Google:", error); - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: "Failed to sign in with Google" - }); - } - }), - - appleSignIn: publicProcedure - .input(appleSignInSchema) - .mutation(async ({ input }) => { - try { - // Verify the Apple ID token - // Apple's public keys for JWT verification - const appleKeysResponse = await fetch( - "https://appleid.apple.com/auth/keys" - ); - if (!appleKeysResponse.ok) { - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: "Failed to fetch Apple public keys" - }); - } - - const appleKeys = (await appleKeysResponse.json()) as { - keys: Array<{ - kty: string; - kid: string; - use: string; - alg: string; - n: string; - e: string; - }>; - }; - - // Decode the JWT header to get the key ID - const [headerB64] = input.idToken.split("."); - if (!headerB64) { - throw new TRPCError({ - code: "UNAUTHORIZED", - message: "Invalid Apple ID token format" - }); - } - - const headerJson = Buffer.from(headerB64, "base64url").toString("utf8"); - const header = JSON.parse(headerJson) as { kid: string; alg: string }; - - // Find the matching key - const jwk = appleKeys.keys.find((k) => k.kid === header.kid); - if (!jwk) { - throw new TRPCError({ - code: "UNAUTHORIZED", - message: "Apple public key not found" - }); - } - - // Import the Apple JWK key for signature verification - const publicKey = await importJWK(jwk, "RS256"); - - // Verify the Apple ID token signature and claims using jose - const jwtOptions: Parameters[2] = { - algorithms: ["RS256"], - issuer: "https://appleid.apple.com" - }; - if (env.APPLE_CLIENT_ID_NESSA) { - jwtOptions.audience = env.APPLE_CLIENT_ID_NESSA; - } - const { payload: tokenPayload } = await jwtVerify( - input.idToken, - publicKey, - jwtOptions - ); - - // Apple user ID from token should match the one provided - if (tokenPayload.sub !== input.appleUserId) { - throw new TRPCError({ - code: "UNAUTHORIZED", - message: "Apple user ID mismatch" - }); - } - - const appleUserId = tokenPayload.sub as string; - // Apple only sends email on first sign-in, so use input.email if token doesn't have it - const email = tokenPayload.email ?? input.email; - const firstName = input.firstName ?? "Apple"; - const lastName = input.lastName ?? "User"; - const displayName = `${firstName} ${lastName}`.trim(); - - const conn = NessaConnectionFactory(); - - // Check if user exists by Apple provider ID - const existingByApple = await conn.execute({ - sql: "SELECT userId FROM authProviders WHERE provider = 'apple' AND providerUserId = ?", - args: [appleUserId] - }); - - let userId: string; - - if (existingByApple.rows.length > 0) { - // User exists with Apple account - log them in - userId = existingByApple.rows[0].userId as string; - await conn.execute({ - sql: "UPDATE users SET lastLoginAt = datetime('now'), updatedAt = datetime('now') WHERE id = ?", - args: [userId] - }); - } else if (email) { - // Check if user exists by email - const existingByEmail = await conn.execute({ - sql: "SELECT id FROM users WHERE email = ?", - args: [email] - }); - - if (existingByEmail.rows.length > 0) { - // User exists with email - link Apple account - userId = existingByEmail.rows[0].id as string; - await conn.execute({ - sql: "INSERT INTO authProviders (id, userId, provider, providerUserId, email, displayName, avatarUrl) VALUES (?, ?, ?, ?, ?, ?, ?)", - args: [ - crypto.randomUUID(), - userId, - "apple", - appleUserId, - email, - displayName, - null - ] - }); - await conn.execute({ - sql: "UPDATE users SET provider = 'apple', appleUserId = ?, lastLoginAt = datetime('now'), updatedAt = datetime('now') WHERE id = ?", - args: [appleUserId, userId] - }); - } else { - // Create new user with Apple account - userId = crypto.randomUUID(); - await conn.execute({ - sql: `INSERT INTO users (id, email, emailVerified, firstName, lastName, displayName, avatarUrl, provider, appleUserId, status, updatedAt) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))`, - args: [ - userId, - email, - tokenPayload.email_verified === true || - tokenPayload.email_verified === "true" - ? 1 - : 0, - firstName, - lastName, - displayName, - null, - "apple", - appleUserId, - "active" - ] - }); - await conn.execute({ - sql: "INSERT INTO authProviders (id, userId, provider, providerUserId, email, displayName, avatarUrl) VALUES (?, ?, ?, ?, ?, ?, ?)", - args: [ - crypto.randomUUID(), - userId, - "apple", - appleUserId, - email, - displayName, - null - ] - }); - // Create default workout plan for new user - await conn.execute({ - sql: "INSERT INTO workoutPlans (id, userId, name, category, difficulty, type, isPublic) VALUES (?, ?, ?, ?, ?, ?, ?)", - args: [ - crypto.randomUUID(), - userId, - "Getting Started", - "strength", - "beginner", - "strength", - 0 - ] - }); - } - } else { - // No email available - create user without email - userId = crypto.randomUUID(); - await conn.execute({ - sql: `INSERT INTO users (id, email, emailVerified, firstName, lastName, displayName, avatarUrl, provider, appleUserId, status, updatedAt) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))`, - args: [ - userId, - null, - 0, - firstName, - lastName, - displayName, - null, - "apple", - appleUserId, - "active" - ] - }); - await conn.execute({ - sql: "INSERT INTO authProviders (id, userId, provider, providerUserId, email, displayName, avatarUrl) VALUES (?, ?, ?, ?, ?, ?, ?)", - args: [ - crypto.randomUUID(), - userId, - "apple", - appleUserId, - null, - displayName, - null - ] - }); - // Create default workout plan for new user - await conn.execute({ - sql: "INSERT INTO workoutPlans (id, userId, name, category, difficulty, type, isPublic) VALUES (?, ?, ?, ?, ?, ?, ?)", - args: [ - crypto.randomUUID(), - userId, - "Getting Started", - "strength", - "beginner", - "strength", - 0 - ] - }); - } - - const token = await signNessaToken(userId); - return { success: true, token, userId }; - } catch (error) { - if (error instanceof TRPCError) { - throw error; - } - console.error("Failed to sign in with Apple:", error); - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: "Failed to sign in with Apple" - }); - } - }), - getUsers: nessaProcedure .input(paginatedQuerySchema) .query(async ({ input, ctx }) => { diff --git a/src/server/api/utils.ts b/src/server/api/utils.ts index f0de7c5..3dca1e4 100644 --- a/src/server/api/utils.ts +++ b/src/server/api/utils.ts @@ -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 { 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); } } diff --git a/src/server/clerk-user-webhook.test.ts b/src/server/clerk-user-webhook.test.ts new file mode 100644 index 0000000..4f47a2f --- /dev/null +++ b/src/server/clerk-user-webhook.test.ts @@ -0,0 +1,314 @@ +/** + * Clerk user webhook sync tests + * + * Exercises `handleClerkUserWebhook` against an in-memory SQLite DB + * (`bun:sqlite`) wrapped to match the libsql `{ execute({ sql, args }) }` + * contract. Each event payload is Svix-signed with the same secret the + * handler verifies against, so the signature path is exercised for real — + * including the unsigned / wrong-signature rejection cases. + */ + +import { describe, it, expect, beforeEach } from "bun:test"; +import { Database } from "bun:sqlite"; +import { Webhook } from "svix"; +import { + handleClerkUserWebhook, + __resetClerkWebhookMigrationForTests, + type NessaConn +} from "~/server/clerk-user-webhook"; + +// ─── harness ────────────────────────────────────────────────────────────── + +const WEBHOOK_SECRET = + "whsec_dGhpcyBpcyBhIHRlc3Qgc2VjcmV0IGtleSBmb3IgY2xlcmsgd2ViaG9va3M="; + +let db: Database; +let conn: NessaConn; + +function makeConn(): NessaConn { + return { + execute: async ({ + sql, + args + }: { + sql: string; + args?: (string | number | null)[]; + }) => { + const stmt = db.prepare(sql); + const upper = sql.trim().toUpperCase(); + const isRead = upper.startsWith("SELECT") || upper.startsWith("WITH"); + if (isRead) { + const rows = stmt.all(...(args ?? [])); + return { rows: rows as unknown[] }; + } + const info = stmt.run(...(args ?? [])); + return { rows: [] as unknown[], rowsAffected: info.changes }; + } + }; +} + +function initSchema() { + db = new Database(":memory:"); + // `users` table mirrors the Nessa production schema — note clerkUserId is + // NOT present here so we exercise the idempotent ALTER-TABLE migration path. + db.run(`CREATE TABLE users ( + id TEXT PRIMARY KEY, + email TEXT, + emailVerified INTEGER DEFAULT 0, + firstName TEXT, + lastName TEXT, + displayName TEXT, + avatarUrl TEXT, + provider TEXT, + appleUserId TEXT, + status TEXT, + createdAt TEXT NOT NULL DEFAULT (datetime('now')), + updatedAt TEXT NOT NULL DEFAULT (datetime('now')), + lastLoginAt TEXT + )`); +} + +beforeEach(() => { + initSchema(); + conn = makeConn(); + __resetClerkWebhookMigrationForTests(); +}); + +// ─── signings helpers ───────────────────────────────────────────────────── + +function sign( + payload: object, + secret: string = WEBHOOK_SECRET +): { rawBody: string; headers: ReturnType } { + const rawBody = JSON.stringify(payload); + const msgId = `msg_${Math.random().toString(36).slice(2)}`; + const ts = new Date(); + const wh = new Webhook(secret); + const signature = wh.sign(msgId, ts, rawBody); + return { + rawBody, + headers: { + "svix-id": msgId, + "svix-timestamp": String(Math.floor(ts.getTime() / 1000)), + "svix-signature": signature + } + }; +} + +// satisfy the inferred header type +function headersFor() { + return { + "svix-id": "", + "svix-timestamp": "", + "svix-signature": "" + }; +} + +function userCreatedPayload(overrides: Record = {}) { + return { + object: "event", + type: "user.created", + data: { + id: "user_abc123", + email_addresses: [ + { + id: "idn_1", + email_address: "jane@example.com", + verification: { status: "verified" } + } + ], + primary_email_address_id: "idn_1", + first_name: "Jane", + last_name: "Doe", + username: "janedoe", + image_url: "https://cdn.clerk.com/avatar.png", + ...overrides + } + }; +} + +function userUpdatedPayload(overrides: Record = {}) { + return { + object: "event", + type: "user.updated", + data: { + id: "user_abc123", + email_addresses: [ + { + id: "idn_1", + email_address: "jane.new@example.com", + verification: { status: "verified" } + } + ], + primary_email_address_id: "idn_1", + first_name: "Jane", + last_name: "Smith", + username: "janesmith", + image_url: "https://cdn.clerk.com/avatar2.png", + ...overrides + } + }; +} + +async function call( + signed: { rawBody: string; headers: ReturnType }, + secret: string = WEBHOOK_SECRET +) { + return handleClerkUserWebhook({ + rawBody: signed.rawBody, + headers: signed.headers, + webhookSecret: secret, + conn + }); +} + +function getUserByClerkId(clerkUserId: string) { + const row = db + .prepare( + "SELECT id, clerkUserId, email, firstName, lastName, displayName, avatarUrl, provider, status FROM users WHERE clerkUserId = ?" + ) + .get(clerkUserId) as + | { + id: string; + clerkUserId: string; + email: string | null; + firstName: string | null; + lastName: string | null; + displayName: string | null; + avatarUrl: string | null; + provider: string | null; + status: string | null; + } + | undefined; + return row; +} + +// ─── tests ─────────────────────────────────────────────────────────────── + +describe("Clerk user.created webhook", () => { + it("creates a local users row keyed by clerkUserId", async () => { + const res = await call(sign(userCreatedPayload())); + expect(res.status).toBe(200); + + const user = getUserByClerkId("user_abc123"); + expect(user).toBeDefined(); + expect(user!.clerkUserId).toBe("user_abc123"); + expect(user!.email).toBe("jane@example.com"); + expect(user!.firstName).toBe("Jane"); + expect(user!.lastName).toBe("Doe"); + expect(user!.displayName).toBe("Jane Doe"); + expect(user!.avatarUrl).toBe("https://cdn.clerk.com/avatar.png"); + expect(user!.provider).toBe("clerk"); + expect(user!.status).toBe("active"); + expect(user!.id).not.toBe("user_abc123"); // a fresh local UUID, not the Clerk id + }); + + it("upserts (does not duplicate) on a replayed created event", async () => { + const signed = sign(userCreatedPayload()); + await call(signed); + await call(signed); // replay + const count = ( + db.prepare("SELECT COUNT(*) as n FROM users WHERE clerkUserId = ?").get( + "user_abc123" + ) as { n: number } + ).n; + expect(count).toBe(1); + }); + + it("falls back to username when first/last name are absent", async () => { + const res = await call( + sign( + userCreatedPayload({ + first_name: null, + last_name: null + }) + ) + ); + expect(res.status).toBe(200); + const user = getUserByClerkId("user_abc123"); + expect(user!.displayName).toBe("janedoe"); + }); +}); + +describe("Clerk user.updated webhook", () => { + it("updates mutable fields and leaves clerkUserId unchanged", async () => { + // seed via created + await call(sign(userCreatedPayload())); + + const before = getUserByClerkId("user_abc123"); + const localId = before!.id; + + const res = await call(sign(userUpdatedPayload())); + expect(res.status).toBe(200); + + const after = getUserByClerkId("user_abc123"); + expect(after!.id).toBe(localId); // local UUID stable + expect(after!.clerkUserId).toBe("user_abc123"); + expect(after!.email).toBe("jane.new@example.com"); + expect(after!.lastName).toBe("Smith"); + expect(after!.displayName).toBe("Jane Smith"); + expect(after!.avatarUrl).toBe("https://cdn.clerk.com/avatar2.png"); + }); + + it("is a no-op when the user does not exist (no row inserted)", async () => { + const res = await call(sign(userUpdatedPayload())); + expect(res.status).toBe(200); + const count = ( + db.prepare("SELECT COUNT(*) as n FROM users").get() as { n: number } + ).n; + expect(count).toBe(0); + }); +}); + +describe("Clerk webhook signature enforcement", () => { + it("rejects a missing-svix-header request with 400", async () => { + const res = await handleClerkUserWebhook({ + rawBody: JSON.stringify(userCreatedPayload()), + headers: { + "svix-id": "", + "svix-timestamp": "", + "svix-signature": "" + }, + webhookSecret: WEBHOOK_SECRET, + conn + }); + expect(res.status).toBe(400); + }); + + it("rejects an unsigned payload with 401", async () => { + const res = await handleClerkUserWebhook({ + rawBody: JSON.stringify(userCreatedPayload()), + headers: { + "svix-id": "msg_x", + "svix-timestamp": String(Math.floor(Date.now() / 1000)), + "svix-signature": "v1,tampered" + }, + webhookSecret: WEBHOOK_SECRET, + conn + }); + expect(res.status).toBe(401); + }); + + it("rejects a payload signed with the wrong secret with 401", async () => { + const res = await call( + sign( + userCreatedPayload(), + "whsec_YW5vdGhlcl9kaWZmZXJlbnRfc2VjcmV0X2tleV9mb3JfdGVzdHM=" + ), + WEBHOOK_SECRET // handler uses this + ); + expect(res.status).toBe(401); + }); + + it("ignores non user.* event types with 200", async () => { + const res = await call( + sign({ + object: "event", + type: "session.created", + data: { id: "sess_1" } + }) + ); + expect(res.status).toBe(200); + expect((res.body as { ignored: string }).ignored).toBe("session.created"); + }); +}); diff --git a/src/server/clerk-user-webhook.ts b/src/server/clerk-user-webhook.ts new file mode 100644 index 0000000..8f26be0 --- /dev/null +++ b/src/server/clerk-user-webhook.ts @@ -0,0 +1,219 @@ +// ─────────────────────────────────────────────────────────────────────── +// Clerk webhook → local `users` table sync +// +// Clerk emits `user.created` / `user.updated` events (signed via Svix) whenever +// a user signs up or their profile/email changes. This module verifies the +// Svix signature with `NESSA_CLERK_WEBHOOK_SECRET` and upserts the +// corresponding row in the Nessa `users` table, keyed by `clerkUserId`. +// +// The local UUID is generated here ("shadow" account) — Clerk is the identity +// provider; the Nessa DB only stores a denormalized copy for-fast-lookup and +// for rows that reference `users.id` (workouts, plans, community posts, etc.). +// +// All signature verification + DB mutation lives in `handleClerkUserWebhook` +// so it can be exercised directly by tests with an in-memory SQLite DB and a +// real Svix-signed payload. +// ─────────────────────────────────────────────────────────────────────── + +import { Webhook } from "svix"; + +/** Minimal libsql-shaped connection (same contract as NessaConnectionFactory). */ +export interface NessaConn { + execute: (stmt: { sql: string; args?: (string | number | null)[] }) => Promise<{ + rows: unknown[]; + rowsAffected?: number; + }>; +} + +/** Svix signature headers Clerk (and Svix) attach to every webhook. */ +export interface ClerkWebhookHeaders { + "svix-id": string; + "svix-timestamp": string; + "svix-signature": string; +} + +interface ClerkEmailAddress { + id: string; + email_address: string; + verification?: { status?: string } | null; +} + +interface ClerkUserData { + id: string; + email_addresses?: ClerkEmailAddress[]; + primary_email_address_id?: string | null; + first_name?: string | null; + last_name?: string | null; + username?: string | null; + image_url?: string | null; +} + +interface ClerkWebhookEvent { + object: "event"; + type: string; + data: ClerkUserData; +} + +export type WebhookResult = { status: number; body: unknown }; + +/** Resolve the user's primary email address from the Clerk payload. */ +function resolveEmail(data: ClerkUserData): string | null { + const addresses = data.email_addresses ?? []; + const primaryId = data.primary_email_address_id ?? null; + const primary = + addresses.find((e) => e.id === primaryId) ?? addresses[0] ?? null; + return primary?.email_address ?? null; +} + +/** Resolve a display name (first + last, falling back to username). */ +function resolveDisplayName(data: ClerkUserData): string | null { + const first = (data.first_name ?? "").trim(); + const last = (data.last_name ?? "").trim(); + const full = `${first} ${last}`.trim(); + if (full) return full; + return data.username?.trim() || null; +} + +// The `users` table predates Clerk — the `clerkUserId` column is added lazily +// (idempotently) so the migration runs against existing dev/prod databases +// without a separate deploy step. SQLite's `ALTER TABLE ... ADD COLUMN` does +// not support `IF NOT EXISTS`, so a duplicate-column error is the expected +// "already migrated" signal. +let columnMigrationDone = false; + +/** Test-only: reset the module-level migration flag so each test gets a fresh DB. */ +export function __resetClerkWebhookMigrationForTests(): void { + columnMigrationDone = false; +} + +export async function ensureClerkUsersColumn(conn: NessaConn): Promise { + if (columnMigrationDone) return; + try { + await conn.execute({ + sql: "ALTER TABLE users ADD COLUMN clerkUserId TEXT" + }); + } catch (err) { + const msg = + err instanceof Error ? err.message : err == null ? String(err) : String(err); + // "duplicate column name" (SQLite) is the expected success-already case. + if (!/duplicate column/i.test(msg)) { + throw err; + } + } + try { + await conn.execute({ + sql: "CREATE UNIQUE INDEX IF NOT EXISTS idx_users_clerkUserId ON users(clerkUserId)" + }); + } catch (err) { + // Best-effort: if the index already exists or creation is unsupported in + // the test harness, fall through. The ON CONFLICT(clerkUserId) upsert + // requires a unique constraint; in tests we create the index in the + // schema instead. + } + columnMigrationDone = true; +} + +/** + * Verify a Clerk webhook and upsert the user row. + * + * @returns a `{ status, body }` describing the HTTP response to send. + */ +export async function handleClerkUserWebhook(opts: { + rawBody: string; + headers: ClerkWebhookHeaders; + webhookSecret: string; + conn: NessaConn; +}): Promise { + const { rawBody, headers, webhookSecret, conn } = opts; + + // Reject unsigned / partially-signed requests before touching the DB. + if ( + !headers["svix-id"] || + !headers["svix-timestamp"] || + !headers["svix-signature"] + ) { + return { + status: 400, + body: { error: "Missing Svix signature headers" } + }; + } + + let evt: ClerkWebhookEvent; + try { + const wh = new Webhook(webhookSecret); + evt = wh.verify(rawBody, { + "svix-id": headers["svix-id"], + "svix-timestamp": headers["svix-timestamp"], + "svix-signature": headers["svix-signature"] + }) as ClerkWebhookEvent; + } catch (err) { + console.error("Clerk webhook signature verification failed:", err); + return { status: 401, body: { error: "Invalid signature" } }; + } + + if (evt.type !== "user.created" && evt.type !== "user.updated") { + return { + status: 200, + body: { received: true, ignored: evt.type } + }; + } + + try { + await ensureClerkUsersColumn(conn); + + const data = evt.data; + const clerkUserId = data.id; + const email = resolveEmail(data); + const firstName = data.first_name ?? null; + const lastName = data.last_name ?? null; + const displayName = resolveDisplayName(data); + const avatarUrl = data.image_url ?? null; + + if (evt.type === "user.created") { + // Upsert by clerkUserId — Clerk may replay the same event, so insert + // ON CONFLICT instead of erroring on a duplicate. + await conn.execute({ + sql: `INSERT INTO users + (id, clerkUserId, email, firstName, lastName, displayName, avatarUrl, provider, status) + VALUES (?, ?, ?, ?, ?, ?, ?, 'clerk', 'active') + ON CONFLICT(clerkUserId) DO UPDATE SET + email = excluded.email, + firstName = excluded.firstName, + lastName = excluded.lastName, + displayName = excluded.displayName, + avatarUrl = excluded.avatarUrl, + updatedAt = datetime('now')`, + args: [ + crypto.randomUUID(), + clerkUserId, + email, + firstName, + lastName, + displayName, + avatarUrl + ] + }); + } else { + // user.updated — mutate identity fields only; never change clerkUserId. + await conn.execute({ + sql: `UPDATE users SET + email = ?, + firstName = ?, + lastName = ?, + displayName = ?, + avatarUrl = ?, + updatedAt = datetime('now') + WHERE clerkUserId = ?`, + args: [email, firstName, lastName, displayName, avatarUrl, clerkUserId] + }); + } + + return { status: 200, body: { success: true, type: evt.type } }; + } catch (error) { + console.error("Clerk webhook DB sync failed:", error); + return { + status: 500, + body: { error: "Failed to sync user" } + }; + } +} diff --git a/src/server/nessa-auth.test.ts b/src/server/nessa-auth.test.ts new file mode 100644 index 0000000..c8878f8 --- /dev/null +++ b/src/server/nessa-auth.test.ts @@ -0,0 +1,140 @@ +/** + * Clerk JWT verification tests for nessa-auth + * + * Tests the verifyNessaToken function after migration from HS256 self-signed + * tokens to Clerk session token verification via @clerk/backend. + */ + +import { describe, it, expect, mock, beforeEach } from "bun:test"; + +// Mock env BEFORE importing the module +mock.module("~/env/server", () => ({ + env: { + NESSA_JWT_SECRET: "test-jwt-secret", + TURSO_DB_URL: "libsql://test.turso.io", + TURSO_DB_TOKEN: "test-token", + NESSA_DB_URL: "libsql://nessa-test.turso.io", + NESSA_DB_TOKEN: "test-token", + TURSO_LINEAGE_URL: "libsql://lineage-test.turso.io", + TURSO_LINEAGE_TOKEN: "test-token", + TURSO_DB_API_TOKEN: "test-token", + NODE_ENV: "test", + NESSA_CLERK_SECRET: "sk_test_test-secret", + NESSA_CLERK_JWT_ISSUER: "https://nessa-test.clerk.accounts.dev" + }, + validateServerEnv: () => ({}), + isMissingEnvVar: () => false, + getMissingEnvVars: () => [] +})); + +// Mock @clerk/backend verifyToken +const mockVerifyToken = mock(); +mock.module("@clerk/backend", () => ({ + verifyToken: mockVerifyToken, + createClerkClient: mock() +})); + +describe("verifyNessaToken with Clerk JWT", () => { + beforeEach(() => { + mockVerifyToken.mockReset(); + }); + + it("returns the subject (Clerk user id) for a valid session token", async () => { + const mockPayload = { + sub: "user_clerk123456", + exp: Math.floor(Date.now() / 1000) + 3600, + iat: Math.floor(Date.now() / 1000), + iss: "https://nessa-test.clerk.accounts.dev", + sid: "sess_123" + }; + + mockVerifyToken.mockResolvedValue(mockPayload); + + const { verifyNessaToken } = await import("./nessa-auth"); + + const result = await verifyNessaToken("valid-clerk-session-token"); + + expect(result.sub).toBe("user_clerk123456"); + expect(result.exp).toBe(mockPayload.exp); + expect(result.iat).toBe(mockPayload.iat); + + // Verify verifyToken was called with correct options + expect(mockVerifyToken).toHaveBeenCalledWith( + "valid-clerk-session-token", + expect.objectContaining({ + secretKey: "sk_test_test-secret" + }) + ); + }); + + it("throws when token has no subject", async () => { + const mockPayload = { + exp: Math.floor(Date.now() / 1000) + 3600, + iat: Math.floor(Date.now() / 1000), + iss: "https://nessa-test.clerk.accounts.dev" + // Missing 'sub' + }; + + mockVerifyToken.mockResolvedValue(mockPayload); + + const { verifyNessaToken } = await import("./nessa-auth"); + + await expect( + verifyNessaToken("token-without-subject") + ).rejects.toThrow(/Missing subject/); + }); + + it("rejects a malformed token", async () => { + mockVerifyToken.mockRejectedValue( + new Error("Invalid token format") + ); + + const { verifyNessaToken } = await import("./nessa-auth"); + + await expect( + verifyNessaToken("malformed-token") + ).rejects.toThrow(); + }); + + it("rejects an expired token", async () => { + mockVerifyToken.mockRejectedValue( + new Error("Token has expired") + ); + + const { verifyNessaToken } = await import("./nessa-auth"); + + await expect( + verifyNessaToken("expired-token") + ).rejects.toThrow(/expired/i); + }); + + it("rejects a token with wrong signature", async () => { + mockVerifyToken.mockRejectedValue( + new Error("Token signature verification failed") + ); + + const { verifyNessaToken } = await import("./nessa-auth"); + + await expect( + verifyNessaToken("wrong-key-token") + ).rejects.toThrow(/signature/i); + }); +}); + +describe("static audit: signNessaToken removed", () => { + it("signNessaToken is not exported fromessa-auth", async () => { + const moduleExports = await import("./nessa-auth"); + expect(moduleExports).not.toHaveProperty("signNessaToken"); + }); + + it("nessa-auth.ts source does not reference NESSA_JWT_SECRET", async () => { + const source = await Bun.file(import.meta.dir + "/nessa-auth.ts").text(); + expect(source).not.toContain("NESSA_JWT_SECRET"); + }); + + it("nessa-auth.ts uses verifyToken from @clerk/backend", async () => { + const source = await Bun.file(import.meta.dir + "/nessa-auth.ts").text(); + expect(source).toContain("verifyToken"); + expect(source).toContain("@clerk/backend"); + }); +}); diff --git a/src/server/nessa-auth.ts b/src/server/nessa-auth.ts index 7a76d29..cf370d9 100644 --- a/src/server/nessa-auth.ts +++ b/src/server/nessa-auth.ts @@ -1,39 +1,49 @@ -import { SignJWT, jwtVerify } from "jose"; +// ─────────────────────────────────────────────────────────────────────── +// Nessa auth — Clerk session JWT verification (RS256 / JWKS) +// +// Migrated from self-signed HS256 tokens to Clerk session token +// verification. Incoming `Authorization: Bearer ` headers are +// verified against Clerk's JWKS endpoint via `@clerk/backend`. +// +// Public API is unchanged so callers need not be modified: +// * verifyNessaToken(token) → { sub, exp?, iat? } +// * NessaAuthPayload type +// +// signNessaToken was removed — the frontend now supplies Clerk session +// tokens directly; the backend only verifies. +// ─────────────────────────────────────────────────────────────────────── + +import { verifyToken } from "@clerk/backend"; import { env } from "~/env/server"; -const NESSA_JWT_EXPIRY = "30d"; - export type NessaAuthPayload = { - sub: string; + sub: string; // Clerk user id exp?: number; iat?: number; }; +/** + * Verify a Clerk session JWT and return the subject (user id). + * + * Uses the Clerk Backend API secret key to fetch the JWKS and verify the + * RS256 signature. Rejects expired, malformed, or improperly signed tokens. + */ export async function verifyNessaToken( token: string ): Promise { - const secret = new TextEncoder().encode(env.NESSA_JWT_SECRET); - const { payload } = await jwtVerify(token, secret, { - algorithms: ["HS256"] + const payload = await verifyToken(token, { + secretKey: env.NESSA_CLERK_SECRET, + // Optional: restrict to specific issuers / apps + // audience: env.NESSA_CLERK_JWT_ISSUER, }); if (!payload.sub) { - throw new Error("Missing subject in Nessa JWT"); + throw new Error("Missing subject in Clerk session token"); } return { - sub: payload.sub as string, - exp: payload.exp as number | undefined, - iat: payload.iat as number | undefined + sub: payload.sub, + exp: payload.exp, + iat: payload.iat, }; } - -export async function signNessaToken(userId: string): Promise { - const secret = new TextEncoder().encode(env.NESSA_JWT_SECRET); - return new SignJWT({}) - .setProtectedHeader({ alg: "HS256" }) - .setSubject(userId) - .setIssuedAt() - .setExpirationTime(NESSA_JWT_EXPIRY) - .sign(secret); -}