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

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