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 932f37e..d4b5ddb 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index bf87f6f..5eeb5a2 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "dependencies": { "@aws-sdk/client-s3": "^3.953.0", "@aws-sdk/s3-request-presigner": "^3.953.0", + "@clerk/backend": "^3.12.0", "@libsql/client": "^0.15.15", "@motionone/solid": "^10.16.4", "@solidjs/meta": "^0.29.4", @@ -57,6 +58,7 @@ "motion": "^12.23.26", "solid-js": "^1.9.5", "solid-tiptap": "^0.8.0", + "svix": "^1.98.0", "ua-parser-js": "^2.0.7", "uuid": "^13.0.0", "vinxi": "^0.5.7", diff --git a/src/env/server.ts b/src/env/server.ts index 879a5a0..a5ea05d 100644 --- a/src/env/server.ts +++ b/src/env/server.ts @@ -56,7 +56,16 @@ const serverEnvSchema = z.object({ REDIS_URL: z.string().min(1), NESSA_DB_URL: z.string().min(1), NESSA_DB_TOKEN: z.string().min(1), - NESSA_JWT_SECRET: z.string().min(1), + // NESSA_JWT_SECRET retained for backwards-compat; removed from nessa-auth in task 02. + // Delete in task 11 once all other references are gone. + NESSA_JWT_SECRET: z.string().min(1).optional(), + // Clerk authentication + NESSA_CLERK_SECRET: z.string().min(1), + NESSA_CLERK_JWT_ISSUER: z.string().min(1), + // Clerk webhook signing secret (Svix). Used to verify `user.created` / + // `user.updated` webhook payloads. Find it under Clerk Dashboard → + // Webhooks → your endpoint → Signing Secret (starts with `whsec_`). + NESSA_CLERK_WEBHOOK_SECRET: z.string().min(1), LINEAGE_JWT_SECRET: z.string().min(32), APPLE_CLIENT_ID_NESSA: z.string().min(1).optional(), APPLE_CLIENT_ID_LINEAGE: z.string().min(1).optional(), @@ -167,7 +176,10 @@ export const getMissingEnvVars = (): string[] => { "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); -}