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:
2026-07-23 01:40:53 -04:00
parent 7ddbe752a5
commit 7287f10c9a
15 changed files with 886 additions and 985 deletions

View File

@@ -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");
});
});