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:
@@ -50,7 +50,10 @@ mock.module("~/env/server", () => ({
|
||||
TURSO_LINEAGE_TOKEN: "test-token",
|
||||
TURSO_DB_API_TOKEN: "test-token",
|
||||
NESSA_DB_URL: "libsql://nessa-test.turso.io",
|
||||
NESSA_DB_TOKEN: "test-token"
|
||||
NESSA_DB_TOKEN: "test-token",
|
||||
// Clerk env vars (required after migration in task 02)
|
||||
NESSA_CLERK_SECRET: "sk_test_test-secret",
|
||||
NESSA_CLERK_JWT_ISSUER: "https://nessa-test.clerk.accounts.dev"
|
||||
},
|
||||
validateServerEnv: () => ({}),
|
||||
isMissingEnvVar: () => false,
|
||||
|
||||
@@ -201,3 +201,78 @@ describe("p8-003: join then allowed / leave then blocked (integration)", () => {
|
||||
expect(await errCode(requireClubMembership(conn, clubId, USER_B))).toBe("FORBIDDEN");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Clerk session → local users.id resolution (migrate-to-clerk-auth-03)
|
||||
//
|
||||
// `createTRPCContext` verifies a Clerk session JWT (`verifyNessaToken`)
|
||||
// and resolves `ctx.nessaUserId` by looking up `users.id` via the indexed
|
||||
// `clerkUserId` column. These tests exercise that lookup path against an
|
||||
// in-memory SQLite DB so the contract is guaranteed:
|
||||
// - seeded row with matching clerkUserId → local id resolved
|
||||
// - missing local row → UNAUTHORIZED
|
||||
// - the resolved id is the LOCAL users.id, never the Clerk sub
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const CLERK_USER_ID = "user_test_abc123";
|
||||
const LOCAL_USER_A = "local-user-a";
|
||||
const LOCAL_USER_B = "local-user-b";
|
||||
|
||||
function initUsersTable() {
|
||||
db.run(`CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
email TEXT,
|
||||
clerkUserId TEXT
|
||||
)`);
|
||||
db.run(`CREATE INDEX IF NOT EXISTS idx_users_clerkUserId ON users(clerkUserId)`);
|
||||
}
|
||||
|
||||
async function resolveLocalUserId(clerkUserId: string): Promise<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_TOKEN: "test-token",
|
||||
TURSO_DB_API_TOKEN: "test-token",
|
||||
NODE_ENV: "test"
|
||||
NODE_ENV: "test",
|
||||
// Clerk env vars (required after migration in task 02)
|
||||
NESSA_CLERK_SECRET: "sk_test_test-secret",
|
||||
NESSA_CLERK_JWT_ISSUER: "https://nessa-test.clerk.accounts.dev"
|
||||
},
|
||||
validateServerEnv: () => ({}),
|
||||
isMissingEnvVar: () => false,
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
import { createTRPCRouter, nessaProcedure, publicProcedure } from "../utils";
|
||||
import { createTRPCRouter, nessaProcedure } from "../utils";
|
||||
import { z } from "zod";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { jwtVerify, importJWK } from "jose";
|
||||
import { OAuth2Client } from "google-auth-library";
|
||||
import { env } from "~/env/server";
|
||||
import { NessaConnectionFactory } from "~/server/database";
|
||||
import { cache } from "~/server/cache";
|
||||
import { hashPassword, checkPasswordSafe } from "~/server/utils";
|
||||
import { signNessaToken } from "~/server/nessa-auth";
|
||||
import type { Client } from "@libsql/client/web";
|
||||
|
||||
const NESSA_CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
@@ -244,45 +239,6 @@ const bulkSchema = z.object({
|
||||
authProviders: z.array(providerSchema).optional()
|
||||
});
|
||||
|
||||
const registerSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(8),
|
||||
firstName: z.string().min(1),
|
||||
lastName: z.string().min(1)
|
||||
});
|
||||
|
||||
const loginSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(1)
|
||||
});
|
||||
|
||||
const googleSignInSchema = z.object({
|
||||
idToken: z.string().min(1),
|
||||
email: z.string().email().optional(),
|
||||
firstName: z.string().optional(),
|
||||
lastName: z.string().optional()
|
||||
});
|
||||
|
||||
const appleSignInSchema = z.object({
|
||||
idToken: z.string().min(1),
|
||||
email: z.string().email().optional(),
|
||||
firstName: z.string().optional(),
|
||||
lastName: z.string().optional(),
|
||||
appleUserId: z.string().min(1)
|
||||
});
|
||||
|
||||
interface AppleTokenPayload {
|
||||
iss: string;
|
||||
aud: string;
|
||||
exp: number;
|
||||
iat: number;
|
||||
sub: string;
|
||||
email?: string;
|
||||
email_verified?: boolean | string;
|
||||
is_private_email?: boolean | string;
|
||||
real_user_status?: number;
|
||||
}
|
||||
|
||||
export const nessaDbRouter = createTRPCRouter({
|
||||
health: nessaProcedure.query(async () => {
|
||||
try {
|
||||
@@ -298,603 +254,6 @@ export const nessaDbRouter = createTRPCRouter({
|
||||
}
|
||||
}),
|
||||
|
||||
register: publicProcedure
|
||||
.input(registerSchema)
|
||||
.mutation(async ({ input }) => {
|
||||
try {
|
||||
const conn = NessaConnectionFactory();
|
||||
const existing = await conn.execute({
|
||||
sql: "SELECT id FROM users WHERE email = ?",
|
||||
args: [input.email]
|
||||
});
|
||||
|
||||
if (existing.rows.length) {
|
||||
throw new TRPCError({
|
||||
code: "CONFLICT",
|
||||
message: "Email already registered"
|
||||
});
|
||||
}
|
||||
|
||||
const userId = crypto.randomUUID();
|
||||
const passwordHash = await hashPassword(input.password);
|
||||
await conn.execute({
|
||||
sql: `INSERT INTO users (id, email, emailVerified, firstName, lastName, displayName, provider, status, updatedAt)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))`,
|
||||
args: [
|
||||
userId,
|
||||
input.email,
|
||||
0,
|
||||
input.firstName,
|
||||
input.lastName,
|
||||
`${input.firstName} ${input.lastName}`.trim(),
|
||||
"email",
|
||||
"active"
|
||||
]
|
||||
});
|
||||
await conn.execute({
|
||||
sql: "INSERT INTO authProviders (id, userId, provider, providerUserId, email, displayName, avatarUrl) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
args: [
|
||||
crypto.randomUUID(),
|
||||
userId,
|
||||
"email",
|
||||
null,
|
||||
input.email,
|
||||
null,
|
||||
null
|
||||
]
|
||||
});
|
||||
await conn.execute({
|
||||
sql: "INSERT INTO authProviders (id, userId, provider, providerUserId, email, displayName, avatarUrl) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
args: [
|
||||
crypto.randomUUID(),
|
||||
userId,
|
||||
"password",
|
||||
passwordHash,
|
||||
input.email,
|
||||
null,
|
||||
null
|
||||
]
|
||||
});
|
||||
await conn.execute({
|
||||
sql: "INSERT INTO workoutPlans (id, userId, name, category, difficulty, type, isPublic) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
args: [
|
||||
crypto.randomUUID(),
|
||||
userId,
|
||||
"Getting Started",
|
||||
"strength",
|
||||
"beginner",
|
||||
"strength",
|
||||
0
|
||||
]
|
||||
});
|
||||
|
||||
const token = await signNessaToken(userId);
|
||||
return { success: true, token, userId };
|
||||
} catch (error) {
|
||||
if (error instanceof TRPCError) {
|
||||
throw error;
|
||||
}
|
||||
console.error("Failed to register Nessa user:", error);
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to register user"
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
login: publicProcedure.input(loginSchema).mutation(async ({ input }) => {
|
||||
try {
|
||||
const conn = NessaConnectionFactory();
|
||||
const result = await conn.execute({
|
||||
sql: "SELECT userId, email, provider, providerUserId FROM authProviders WHERE email = ? AND provider IN ('email', 'password')",
|
||||
args: [input.email]
|
||||
});
|
||||
|
||||
if (!result.rows.length) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "Invalid credentials"
|
||||
});
|
||||
}
|
||||
|
||||
const rows = result.rows as Array<{
|
||||
userId: string;
|
||||
email: string | null;
|
||||
provider: string;
|
||||
providerUserId: string | null;
|
||||
}>;
|
||||
const emailProvider = rows.find((row) => row.provider === "email");
|
||||
const passwordProvider = rows.find((row) => row.provider === "password");
|
||||
|
||||
if (emailProvider?.userId !== passwordProvider?.userId) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "Invalid credentials"
|
||||
});
|
||||
}
|
||||
|
||||
if (!emailProvider || !passwordProvider) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "Invalid credentials"
|
||||
});
|
||||
}
|
||||
|
||||
const matches = await checkPasswordSafe(
|
||||
input.password,
|
||||
passwordProvider.providerUserId
|
||||
);
|
||||
if (!matches) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "Invalid credentials"
|
||||
});
|
||||
}
|
||||
|
||||
const token = await signNessaToken(emailProvider.userId);
|
||||
await conn.execute({
|
||||
sql: "UPDATE users SET lastLoginAt = datetime('now'), updatedAt = datetime('now') WHERE id = ?",
|
||||
args: [emailProvider.userId]
|
||||
});
|
||||
|
||||
return { success: true, token, userId: emailProvider.userId };
|
||||
} catch (error) {
|
||||
if (error instanceof TRPCError) {
|
||||
throw error;
|
||||
}
|
||||
console.error("Failed to login Nessa user:", error);
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to login"
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
googleSignIn: publicProcedure
|
||||
.input(googleSignInSchema)
|
||||
.mutation(async ({ input }) => {
|
||||
try {
|
||||
const client = new OAuth2Client(env.GOOGLE_CLIENT_ID);
|
||||
let ticket;
|
||||
try {
|
||||
// verifyIdToken fetches Google's JWKS and verifies the signature
|
||||
// locally — the token is sent in the POST body, never in a URL query
|
||||
// string (unlike the deprecated HTTP lookup endpoint). audience ===
|
||||
// env.GOOGLE_CLIENT_ID enforces the `aud` claim so a token minted for
|
||||
// a different OAuth client (or a tampered/expired token) is rejected.
|
||||
ticket = await client.verifyIdToken({
|
||||
idToken: input.idToken,
|
||||
audience: env.GOOGLE_CLIENT_ID
|
||||
});
|
||||
} catch (verifyErr) {
|
||||
// Signature failure, wrong audience, expired token, malformed JWT —
|
||||
// all surface as a thrown Error from verifyIdToken. Map every
|
||||
// verification failure to UNAUTHORIZED so the caller cannot tell
|
||||
// signature vs audience vs expiry apart (avoid leaking which check
|
||||
// failed).
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "Invalid Google ID token"
|
||||
});
|
||||
}
|
||||
const tokenPayload = ticket.getPayload();
|
||||
|
||||
if (!tokenPayload) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "Invalid Google ID token"
|
||||
});
|
||||
}
|
||||
|
||||
// Validate the issuer (verifyIdToken already checks this, but we
|
||||
// assert explicitly for defense-in-depth).
|
||||
if (
|
||||
tokenPayload.iss !== "accounts.google.com" &&
|
||||
tokenPayload.iss !== "https://accounts.google.com"
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "Invalid token issuer"
|
||||
});
|
||||
}
|
||||
|
||||
// Email must be verified for email-based account linking.
|
||||
// google-auth-library's verified TokenPayload types email_verified
|
||||
// as a boolean (true when verified).
|
||||
const emailVerified = tokenPayload.email_verified === true;
|
||||
if (tokenPayload.email && !emailVerified) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "Google email is not verified"
|
||||
});
|
||||
}
|
||||
|
||||
const googleUserId = tokenPayload.sub;
|
||||
const email = tokenPayload.email ?? input.email;
|
||||
const firstName =
|
||||
tokenPayload.given_name ?? input.firstName ?? "Google";
|
||||
const lastName = tokenPayload.family_name ?? input.lastName ?? "User";
|
||||
const displayName =
|
||||
tokenPayload.name ?? `${firstName} ${lastName}`.trim();
|
||||
const avatarUrl = tokenPayload.picture ?? null;
|
||||
|
||||
const conn = NessaConnectionFactory();
|
||||
|
||||
// Check if user exists by Google provider ID
|
||||
const existingByGoogle = await conn.execute({
|
||||
sql: "SELECT userId FROM authProviders WHERE provider = 'google' AND providerUserId = ?",
|
||||
args: [googleUserId]
|
||||
});
|
||||
|
||||
let userId: string;
|
||||
|
||||
if (existingByGoogle.rows.length > 0) {
|
||||
// User exists with Google account - log them in
|
||||
userId = existingByGoogle.rows[0].userId as string;
|
||||
await conn.execute({
|
||||
sql: "UPDATE users SET lastLoginAt = datetime('now'), updatedAt = datetime('now') WHERE id = ?",
|
||||
args: [userId]
|
||||
});
|
||||
} else if (email) {
|
||||
// Check if user exists by email
|
||||
const existingByEmail = await conn.execute({
|
||||
sql: "SELECT id FROM users WHERE email = ?",
|
||||
args: [email]
|
||||
});
|
||||
|
||||
if (existingByEmail.rows.length > 0) {
|
||||
// User exists with email - link Google account
|
||||
userId = existingByEmail.rows[0].id as string;
|
||||
await conn.execute({
|
||||
sql: "INSERT INTO authProviders (id, userId, provider, providerUserId, email, displayName, avatarUrl) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
args: [
|
||||
crypto.randomUUID(),
|
||||
userId,
|
||||
"google",
|
||||
googleUserId,
|
||||
email,
|
||||
displayName,
|
||||
avatarUrl
|
||||
]
|
||||
});
|
||||
await conn.execute({
|
||||
sql: "UPDATE users SET provider = 'google', lastLoginAt = datetime('now'), updatedAt = datetime('now') WHERE id = ?",
|
||||
args: [userId]
|
||||
});
|
||||
} else {
|
||||
// Create new user with Google account
|
||||
userId = crypto.randomUUID();
|
||||
await conn.execute({
|
||||
sql: `INSERT INTO users (id, email, emailVerified, firstName, lastName, displayName, avatarUrl, provider, status, updatedAt)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))`,
|
||||
args: [
|
||||
userId,
|
||||
email,
|
||||
tokenPayload.email_verified ? 1 : 0,
|
||||
firstName,
|
||||
lastName,
|
||||
displayName,
|
||||
avatarUrl,
|
||||
"google",
|
||||
"active"
|
||||
]
|
||||
});
|
||||
await conn.execute({
|
||||
sql: "INSERT INTO authProviders (id, userId, provider, providerUserId, email, displayName, avatarUrl) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
args: [
|
||||
crypto.randomUUID(),
|
||||
userId,
|
||||
"google",
|
||||
googleUserId,
|
||||
email,
|
||||
displayName,
|
||||
avatarUrl
|
||||
]
|
||||
});
|
||||
// Create default workout plan for new user
|
||||
await conn.execute({
|
||||
sql: "INSERT INTO workoutPlans (id, userId, name, category, difficulty, type, isPublic) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
args: [
|
||||
crypto.randomUUID(),
|
||||
userId,
|
||||
"Getting Started",
|
||||
"strength",
|
||||
"beginner",
|
||||
"strength",
|
||||
0
|
||||
]
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// No email available - create user without email
|
||||
userId = crypto.randomUUID();
|
||||
await conn.execute({
|
||||
sql: `INSERT INTO users (id, email, emailVerified, firstName, lastName, displayName, avatarUrl, provider, status, updatedAt)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))`,
|
||||
args: [
|
||||
userId,
|
||||
null,
|
||||
0,
|
||||
firstName,
|
||||
lastName,
|
||||
displayName,
|
||||
avatarUrl,
|
||||
"google",
|
||||
"active"
|
||||
]
|
||||
});
|
||||
await conn.execute({
|
||||
sql: "INSERT INTO authProviders (id, userId, provider, providerUserId, email, displayName, avatarUrl) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
args: [
|
||||
crypto.randomUUID(),
|
||||
userId,
|
||||
"google",
|
||||
googleUserId,
|
||||
null,
|
||||
displayName,
|
||||
avatarUrl
|
||||
]
|
||||
});
|
||||
// Create default workout plan for new user
|
||||
await conn.execute({
|
||||
sql: "INSERT INTO workoutPlans (id, userId, name, category, difficulty, type, isPublic) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
args: [
|
||||
crypto.randomUUID(),
|
||||
userId,
|
||||
"Getting Started",
|
||||
"strength",
|
||||
"beginner",
|
||||
"strength",
|
||||
0
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
const token = await signNessaToken(userId);
|
||||
return { success: true, token, userId };
|
||||
} catch (error) {
|
||||
if (error instanceof TRPCError) {
|
||||
throw error;
|
||||
}
|
||||
console.error("Failed to sign in with Google:", error);
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to sign in with Google"
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
appleSignIn: publicProcedure
|
||||
.input(appleSignInSchema)
|
||||
.mutation(async ({ input }) => {
|
||||
try {
|
||||
// Verify the Apple ID token
|
||||
// Apple's public keys for JWT verification
|
||||
const appleKeysResponse = await fetch(
|
||||
"https://appleid.apple.com/auth/keys"
|
||||
);
|
||||
if (!appleKeysResponse.ok) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to fetch Apple public keys"
|
||||
});
|
||||
}
|
||||
|
||||
const appleKeys = (await appleKeysResponse.json()) as {
|
||||
keys: Array<{
|
||||
kty: string;
|
||||
kid: string;
|
||||
use: string;
|
||||
alg: string;
|
||||
n: string;
|
||||
e: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
// Decode the JWT header to get the key ID
|
||||
const [headerB64] = input.idToken.split(".");
|
||||
if (!headerB64) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "Invalid Apple ID token format"
|
||||
});
|
||||
}
|
||||
|
||||
const headerJson = Buffer.from(headerB64, "base64url").toString("utf8");
|
||||
const header = JSON.parse(headerJson) as { kid: string; alg: string };
|
||||
|
||||
// Find the matching key
|
||||
const jwk = appleKeys.keys.find((k) => k.kid === header.kid);
|
||||
if (!jwk) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "Apple public key not found"
|
||||
});
|
||||
}
|
||||
|
||||
// Import the Apple JWK key for signature verification
|
||||
const publicKey = await importJWK(jwk, "RS256");
|
||||
|
||||
// Verify the Apple ID token signature and claims using jose
|
||||
const jwtOptions: Parameters<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
|
||||
.input(paginatedQuerySchema)
|
||||
.query(async ({ input, ctx }) => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { logVisit, enrichAnalyticsEntry } from "~/server/analytics";
|
||||
import { getRequestIP } from "vinxi/http";
|
||||
import { verifyNessaToken } from "~/server/nessa-auth";
|
||||
import { getAuthPayloadFromEvent } from "~/server/auth";
|
||||
import { NessaConnectionFactory } from "~/server/database";
|
||||
|
||||
export type Context = {
|
||||
event: APIEvent;
|
||||
@@ -63,9 +64,32 @@ async function createContextInner(event: APIEvent): Promise<Context> {
|
||||
if (authHeader && authHeader.startsWith("Bearer ")) {
|
||||
const token = authHeader.replace("Bearer ", "").trim();
|
||||
try {
|
||||
const payload = await verifyNessaToken(token);
|
||||
nessaUserId = payload.sub;
|
||||
// Verify the Clerk session JWT — `sub` is the Clerk user id.
|
||||
const clerkPayload = await verifyNessaToken(token);
|
||||
|
||||
// Resolve the Clerk user id to the local users.id via the indexed
|
||||
// clerkUserId column. One indexed query per request is acceptable;
|
||||
// no premature caching (the row is created by the Clerk webhook).
|
||||
const conn = NessaConnectionFactory();
|
||||
const result = await conn.execute({
|
||||
sql: "SELECT id FROM users WHERE clerkUserId = ?",
|
||||
args: [clerkPayload.sub]
|
||||
});
|
||||
if (result.rows.length === 0) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "Nessa user not found — Clerk account not linked"
|
||||
});
|
||||
}
|
||||
// `nessaUserId` is the LOCAL users.id — router bodies reference it
|
||||
// exactly as before (club ownership, membership, row scoping).
|
||||
nessaUserId = (result.rows[0] as { id: string }).id;
|
||||
} catch (error) {
|
||||
// Re-throw typed TRPCError (lookup miss) so the caller gets UNAUTHORIZED;
|
||||
// swallow Clerk verification failures (expired/invalid token) the same
|
||||
// way the legacy path did — the enforceNessaUser middleware rejects
|
||||
// null nessaUserId with UNAUTHORIZED.
|
||||
if (error instanceof TRPCError) throw error;
|
||||
console.error("Nessa JWT verification failed:", error);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user