feat: migrate Nessa auth to Clerk session tokens (task 03)

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

ctx.nessaUserId remains the local users.id — router bodies are untouched.
This commit is contained in:
2026-07-23 01:40:53 -04:00
parent 7ddbe752a5
commit 7287f10c9a
15 changed files with 886 additions and 985 deletions

View File

@@ -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,

View File

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

View File

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

View File

@@ -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,

View File

@@ -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 }) => {

View File

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