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:
@@ -57,7 +57,10 @@ TURSO_LINEAGE_URL="libsql://<lineage-db>.turso.io"
|
|||||||
TURSO_LINEAGE_TOKEN="<rotate-in-turso-dashboard>"
|
TURSO_LINEAGE_TOKEN="<rotate-in-turso-dashboard>"
|
||||||
NESSA_DB_URL="libsql://<nessa-db>.turso.io"
|
NESSA_DB_URL="libsql://<nessa-db>.turso.io"
|
||||||
NESSA_DB_TOKEN="<rotate-in-turso-dashboard>"
|
NESSA_DB_TOKEN="<rotate-in-turso-dashboard>"
|
||||||
NESSA_CLERK_SECRET=<rotate-in-clerk-dashboard>
|
# Clerk authentication — rotate via Clerk Dashboard or `clerk api .../rotate_secret_keys`
|
||||||
|
NESSA_CLERK_SECRET="sk_test_<rotate-in-clerk-dashboard>" # secret key (sk_test_... for dev, sk_live_... for prod)
|
||||||
|
NESSA_CLERK_JWT_ISSUER="https://<your-app>.clerk.accounts.dev" # JWT issuer from Clerk Dashboard
|
||||||
|
NESSA_CLERK_WEBHOOK_SECRET="whsec_<rotate-in-clerk-dashboard>" # Svix signing secret from Clerk Dashboard → Webhooks → Signing Secret
|
||||||
|
|
||||||
APPLE_CLIENT_ID_LINEAGE=com...
|
APPLE_CLIENT_ID_LINEAGE=com...
|
||||||
APPLE_CLIENT_ID_NESSA=com...
|
APPLE_CLIENT_ID_NESSA=com...
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.953.0",
|
"@aws-sdk/client-s3": "^3.953.0",
|
||||||
"@aws-sdk/s3-request-presigner": "^3.953.0",
|
"@aws-sdk/s3-request-presigner": "^3.953.0",
|
||||||
|
"@clerk/backend": "^3.12.0",
|
||||||
"@libsql/client": "^0.15.15",
|
"@libsql/client": "^0.15.15",
|
||||||
"@motionone/solid": "^10.16.4",
|
"@motionone/solid": "^10.16.4",
|
||||||
"@solidjs/meta": "^0.29.4",
|
"@solidjs/meta": "^0.29.4",
|
||||||
@@ -57,6 +58,7 @@
|
|||||||
"motion": "^12.23.26",
|
"motion": "^12.23.26",
|
||||||
"solid-js": "^1.9.5",
|
"solid-js": "^1.9.5",
|
||||||
"solid-tiptap": "^0.8.0",
|
"solid-tiptap": "^0.8.0",
|
||||||
|
"svix": "^1.98.0",
|
||||||
"ua-parser-js": "^2.0.7",
|
"ua-parser-js": "^2.0.7",
|
||||||
"uuid": "^13.0.0",
|
"uuid": "^13.0.0",
|
||||||
"vinxi": "^0.5.7",
|
"vinxi": "^0.5.7",
|
||||||
|
|||||||
16
src/env/server.ts
vendored
16
src/env/server.ts
vendored
@@ -56,7 +56,16 @@ const serverEnvSchema = z.object({
|
|||||||
REDIS_URL: z.string().min(1),
|
REDIS_URL: z.string().min(1),
|
||||||
NESSA_DB_URL: z.string().min(1),
|
NESSA_DB_URL: z.string().min(1),
|
||||||
NESSA_DB_TOKEN: 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),
|
LINEAGE_JWT_SECRET: z.string().min(32),
|
||||||
APPLE_CLIENT_ID_NESSA: z.string().min(1).optional(),
|
APPLE_CLIENT_ID_NESSA: z.string().min(1).optional(),
|
||||||
APPLE_CLIENT_ID_LINEAGE: z.string().min(1).optional(),
|
APPLE_CLIENT_ID_LINEAGE: z.string().min(1).optional(),
|
||||||
@@ -167,7 +176,10 @@ export const getMissingEnvVars = (): string[] => {
|
|||||||
"REDIS_URL",
|
"REDIS_URL",
|
||||||
"NESSA_DB_URL",
|
"NESSA_DB_URL",
|
||||||
"NESSA_DB_TOKEN",
|
"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"
|
"LINEAGE_JWT_SECRET"
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
52
src/routes/api/clerk-webhook.ts
Normal file
52
src/routes/api/clerk-webhook.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
@@ -50,7 +50,10 @@ mock.module("~/env/server", () => ({
|
|||||||
TURSO_LINEAGE_TOKEN: "test-token",
|
TURSO_LINEAGE_TOKEN: "test-token",
|
||||||
TURSO_DB_API_TOKEN: "test-token",
|
TURSO_DB_API_TOKEN: "test-token",
|
||||||
NESSA_DB_URL: "libsql://nessa-test.turso.io",
|
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: () => ({}),
|
validateServerEnv: () => ({}),
|
||||||
isMissingEnvVar: () => false,
|
isMissingEnvVar: () => false,
|
||||||
|
|||||||
@@ -201,3 +201,78 @@ describe("p8-003: join then allowed / leave then blocked (integration)", () => {
|
|||||||
expect(await errCode(requireClubMembership(conn, clubId, USER_B))).toBe("FORBIDDEN");
|
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<string | null> {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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<typeof mock>;
|
|
||||||
|
|
||||||
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<string, unknown> | undefined;
|
|
||||||
}
|
|
||||||
type VerifyImpl = (opts: VerifyOpts) => Promise<FakeTicket>;
|
|
||||||
|
|
||||||
let verifyImpl: VerifyImpl;
|
|
||||||
|
|
||||||
class MockOAuth2Client {
|
|
||||||
constructor(public clientId: string) {}
|
|
||||||
async verifyIdToken(opts: VerifyOpts): Promise<FakeTicket> {
|
|
||||||
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<string, unknown> = {}): Record<string, unknown> {
|
|
||||||
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<string, unknown> = {}
|
|
||||||
): 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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -23,7 +23,10 @@ mock.module("~/env/server", () => ({
|
|||||||
TURSO_LINEAGE_URL: "libsql://lineage-test.turso.io",
|
TURSO_LINEAGE_URL: "libsql://lineage-test.turso.io",
|
||||||
TURSO_LINEAGE_TOKEN: "test-token",
|
TURSO_LINEAGE_TOKEN: "test-token",
|
||||||
TURSO_DB_API_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: () => ({}),
|
validateServerEnv: () => ({}),
|
||||||
isMissingEnvVar: () => false,
|
isMissingEnvVar: () => false,
|
||||||
|
|||||||
@@ -1,13 +1,8 @@
|
|||||||
import { createTRPCRouter, nessaProcedure, publicProcedure } from "../utils";
|
import { createTRPCRouter, nessaProcedure } from "../utils";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { TRPCError } from "@trpc/server";
|
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 { NessaConnectionFactory } from "~/server/database";
|
||||||
import { cache } from "~/server/cache";
|
import { cache } from "~/server/cache";
|
||||||
import { hashPassword, checkPasswordSafe } from "~/server/utils";
|
|
||||||
import { signNessaToken } from "~/server/nessa-auth";
|
|
||||||
import type { Client } from "@libsql/client/web";
|
import type { Client } from "@libsql/client/web";
|
||||||
|
|
||||||
const NESSA_CACHE_TTL_MS = 5 * 60 * 1000;
|
const NESSA_CACHE_TTL_MS = 5 * 60 * 1000;
|
||||||
@@ -244,45 +239,6 @@ const bulkSchema = z.object({
|
|||||||
authProviders: z.array(providerSchema).optional()
|
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({
|
export const nessaDbRouter = createTRPCRouter({
|
||||||
health: nessaProcedure.query(async () => {
|
health: nessaProcedure.query(async () => {
|
||||||
try {
|
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<typeof jwtVerify>[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
|
getUsers: nessaProcedure
|
||||||
.input(paginatedQuerySchema)
|
.input(paginatedQuerySchema)
|
||||||
.query(async ({ input, ctx }) => {
|
.query(async ({ input, ctx }) => {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { logVisit, enrichAnalyticsEntry } from "~/server/analytics";
|
|||||||
import { getRequestIP } from "vinxi/http";
|
import { getRequestIP } from "vinxi/http";
|
||||||
import { verifyNessaToken } from "~/server/nessa-auth";
|
import { verifyNessaToken } from "~/server/nessa-auth";
|
||||||
import { getAuthPayloadFromEvent } from "~/server/auth";
|
import { getAuthPayloadFromEvent } from "~/server/auth";
|
||||||
|
import { NessaConnectionFactory } from "~/server/database";
|
||||||
|
|
||||||
export type Context = {
|
export type Context = {
|
||||||
event: APIEvent;
|
event: APIEvent;
|
||||||
@@ -63,9 +64,32 @@ async function createContextInner(event: APIEvent): Promise<Context> {
|
|||||||
if (authHeader && authHeader.startsWith("Bearer ")) {
|
if (authHeader && authHeader.startsWith("Bearer ")) {
|
||||||
const token = authHeader.replace("Bearer ", "").trim();
|
const token = authHeader.replace("Bearer ", "").trim();
|
||||||
try {
|
try {
|
||||||
const payload = await verifyNessaToken(token);
|
// Verify the Clerk session JWT — `sub` is the Clerk user id.
|
||||||
nessaUserId = payload.sub;
|
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) {
|
} 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);
|
console.error("Nessa JWT verification failed:", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
219
src/server/clerk-user-webhook.ts
Normal file
219
src/server/clerk-user-webhook.ts
Normal file
@@ -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<void> {
|
||||||
|
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<WebhookResult> {
|
||||||
|
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" }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
140
src/server/nessa-auth.test.ts
Normal file
140
src/server/nessa-auth.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 <token>` 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";
|
import { env } from "~/env/server";
|
||||||
|
|
||||||
const NESSA_JWT_EXPIRY = "30d";
|
|
||||||
|
|
||||||
export type NessaAuthPayload = {
|
export type NessaAuthPayload = {
|
||||||
sub: string;
|
sub: string; // Clerk user id
|
||||||
exp?: number;
|
exp?: number;
|
||||||
iat?: 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(
|
export async function verifyNessaToken(
|
||||||
token: string
|
token: string
|
||||||
): Promise<NessaAuthPayload> {
|
): Promise<NessaAuthPayload> {
|
||||||
const secret = new TextEncoder().encode(env.NESSA_JWT_SECRET);
|
const payload = await verifyToken(token, {
|
||||||
const { payload } = await jwtVerify(token, secret, {
|
secretKey: env.NESSA_CLERK_SECRET,
|
||||||
algorithms: ["HS256"]
|
// Optional: restrict to specific issuers / apps
|
||||||
|
// audience: env.NESSA_CLERK_JWT_ISSUER,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!payload.sub) {
|
if (!payload.sub) {
|
||||||
throw new Error("Missing subject in Nessa JWT");
|
throw new Error("Missing subject in Clerk session token");
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
sub: payload.sub as string,
|
sub: payload.sub,
|
||||||
exp: payload.exp as number | undefined,
|
exp: payload.exp,
|
||||||
iat: payload.iat as number | undefined
|
iat: payload.iat,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function signNessaToken(userId: string): Promise<string> {
|
|
||||||
const secret = new TextEncoder().encode(env.NESSA_JWT_SECRET);
|
|
||||||
return new SignJWT({})
|
|
||||||
.setProtectedHeader({ alg: "HS256" })
|
|
||||||
.setSubject(userId)
|
|
||||||
.setIssuedAt()
|
|
||||||
.setExpirationTime(NESSA_JWT_EXPIRY)
|
|
||||||
.sign(secret);
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user