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