Harden auth and mobile session handling

This commit is contained in:
2026-06-29 15:21:31 -04:00
parent d0c916d659
commit ef826dffa9
20 changed files with 403 additions and 106 deletions
+5 -22
View File
@@ -7,22 +7,11 @@ import {
getApiKeyDisplayPrefix,
hashApiKey,
} from "~/server/api/api-keys";
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
import { createTRPCRouter, sessionProcedure } from "~/server/api/trpc";
import { apiKeys } from "~/server/db/schema";
function requireSessionAuth(ctx: { authSource: "session" | "api-key" | "none" }) {
if (ctx.authSource !== "session") {
throw new TRPCError({
code: "FORBIDDEN",
message: "API keys can only be managed from an authenticated session",
});
}
}
export const apiKeysRouter = createTRPCRouter({
list: protectedProcedure.query(async ({ ctx }) => {
requireSessionAuth(ctx);
list: sessionProcedure.query(async ({ ctx }) => {
return ctx.db.query.apiKeys.findMany({
where: eq(apiKeys.userId, ctx.session.user.id),
columns: {
@@ -39,7 +28,7 @@ export const apiKeysRouter = createTRPCRouter({
});
}),
create: protectedProcedure
create: sessionProcedure
.input(
z.object({
name: z.string().trim().min(1).max(100),
@@ -47,8 +36,6 @@ export const apiKeysRouter = createTRPCRouter({
}),
)
.mutation(async ({ ctx, input }) => {
requireSessionAuth(ctx);
if (input.expiresAt && input.expiresAt <= new Date()) {
throw new TRPCError({
code: "BAD_REQUEST",
@@ -84,11 +71,9 @@ export const apiKeysRouter = createTRPCRouter({
return { ...apiKey, key };
}),
revoke: protectedProcedure
revoke: sessionProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
requireSessionAuth(ctx);
const now = new Date();
const [apiKey] = await ctx.db
.update(apiKeys)
@@ -108,9 +93,7 @@ export const apiKeysRouter = createTRPCRouter({
return { success: true };
}),
revokeAll: protectedProcedure.mutation(async ({ ctx }) => {
requireSessionAuth(ctx);
revokeAll: sessionProcedure.mutation(async ({ ctx }) => {
const now = new Date();
await ctx.db
.update(apiKeys)
+2 -2
View File
@@ -1,6 +1,6 @@
import { z } from "zod";
import { Resend } from "resend";
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
import { createTRPCRouter, sessionProcedure } from "~/server/api/trpc";
import { invoices, platformSettings } from "~/server/db/schema";
import { eq } from "drizzle-orm";
import { env } from "~/env";
@@ -36,7 +36,7 @@ function normalizeEmailNoteHtml(value: string) {
}
export const emailRouter = createTRPCRouter({
sendInvoice: protectedProcedure
sendInvoice: sessionProcedure
.input(
z.object({
invoiceId: z.string(),
+10 -5
View File
@@ -1,6 +1,11 @@
import { z } from "zod";
import { and, desc, eq, inArray } from "drizzle-orm";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
import {
createTRPCRouter,
protectedProcedure,
publicProcedure,
sessionProcedure,
} from "../trpc";
import {
invoices,
invoiceItems,
@@ -754,7 +759,7 @@ export const invoicesRouter = createTRPCRouter({
return { success: true, deleted: ownedIds.length };
}),
bulkImport: protectedProcedure
bulkImport: sessionProcedure
.input(bulkImportSchema)
.mutation(async ({ ctx, input }) => {
const userId = ctx.session.user.id;
@@ -998,7 +1003,7 @@ export const invoicesRouter = createTRPCRouter({
// ── Public token (shareable link) ──────────────────────────────────────────
generatePublicToken: protectedProcedure
generatePublicToken: sessionProcedure
.input(z.object({ id: z.string(), ttlHours: z.number().positive().optional() }))
.mutation(async ({ ctx, input }) => {
const invoice = await ctx.db.query.invoices.findFirst({
@@ -1018,7 +1023,7 @@ export const invoicesRouter = createTRPCRouter({
return { token, expiresAt };
}),
revokePublicToken: protectedProcedure
revokePublicToken: sessionProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
const invoice = await ctx.db.query.invoices.findFirst({
@@ -1060,7 +1065,7 @@ export const invoicesRouter = createTRPCRouter({
// ── Send reminder ──────────────────────────────────────────────────────────
sendReminder: protectedProcedure
sendReminder: sessionProcedure
.input(z.object({ id: z.string(), customMessage: z.string().optional() }))
.mutation(async ({ ctx, input }) => {
const invoice = await ctx.db.query.invoices.findFirst({
+8 -4
View File
@@ -7,6 +7,7 @@ import {
createTRPCRouter,
protectedProcedure,
publicProcedure,
sessionProcedure,
} from "~/server/api/trpc";
import { requireAdmin } from "~/server/api/require-admin";
import {
@@ -32,6 +33,7 @@ import {
pdfTemplateSchema,
type ColorMode,
} from "~/lib/branding";
import { revokeUserSessions } from "~/lib/session-security";
function resolveBusinessId(
refs: { businessName?: string; businessNickname?: string },
@@ -512,7 +514,7 @@ export const settingsRouter = createTRPCRouter({
}),
// Change user password
changePassword: protectedProcedure
changePassword: sessionProcedure
.input(
z
.object({
@@ -595,11 +597,13 @@ export const settingsRouter = createTRPCRouter({
}
});
await revokeUserSessions(userId, ctx.session.session?.token);
return { success: true };
}),
// Export user data (backup)
exportData: protectedProcedure.query(async ({ ctx }) => {
exportData: sessionProcedure.query(async ({ ctx }) => {
const userId = ctx.session.user.id;
const user = await ctx.db.query.users.findFirst({
@@ -855,7 +859,7 @@ export const settingsRouter = createTRPCRouter({
}),
// Import user data (restore)
importData: protectedProcedure
importData: sessionProcedure
.input(BackupDataSchema)
.mutation(async ({ ctx, input }) => {
const userId = ctx.session.user.id;
@@ -1168,7 +1172,7 @@ export const settingsRouter = createTRPCRouter({
}),
// Delete all user data (for account deletion)
deleteAllData: protectedProcedure
deleteAllData: sessionProcedure
.input(
z.object({
confirmText: z.string().refine((val) => val === "DELETE ALL DATA", {
+45 -5
View File
@@ -12,7 +12,11 @@ import superjson from "superjson";
import { ZodError } from "zod";
import { auth } from "~/lib/auth";
import { hasSessionCookie } from "~/lib/auth-server";
import {
hasSessionCookie,
headersWithAuthCookieFallback,
} from "~/lib/auth-server";
import { checkRateLimit } from "~/lib/rate-limit";
import { db } from "~/server/db";
import { getBearerToken, getUserForApiKey } from "~/server/api/api-keys";
@@ -29,7 +33,8 @@ import { getBearerToken, getUserForApiKey } from "~/server/api/api-keys";
* @see https://trpc.io/docs/server/context
*/
export const createTRPCContext = async (opts: { headers: Headers }) => {
const bearerToken = getBearerToken(opts.headers);
const headers = headersWithAuthCookieFallback(opts.headers);
const bearerToken = getBearerToken(headers);
if (bearerToken) {
const apiKeyAuth = await getUserForApiKey(db, bearerToken);
@@ -44,23 +49,25 @@ export const createTRPCContext = async (opts: { headers: Headers }) => {
authSource: "api-key" as const,
apiKeyId: apiKeyAuth.apiKeyId,
...opts,
headers,
};
}
}
if (!hasSessionCookie(opts.headers)) {
if (!hasSessionCookie(headers)) {
return {
db,
session: null,
authSource: "none" as const,
apiKeyId: null,
...opts,
headers,
};
}
try {
const session = await auth.api.getSession({
headers: opts.headers,
headers,
});
return {
@@ -69,6 +76,7 @@ export const createTRPCContext = async (opts: { headers: Headers }) => {
authSource: session?.user ? ("session" as const) : ("none" as const),
apiKeyId: null,
...opts,
headers,
};
} catch (error) {
console.error("[tRPC] Failed to resolve session:", error);
@@ -79,6 +87,7 @@ export const createTRPCContext = async (opts: { headers: Headers }) => {
authSource: "none" as const,
apiKeyId: null,
...opts,
headers,
};
}
};
@@ -143,6 +152,24 @@ const timingMiddleware = t.middleware(async ({ next, path }) => {
return result;
});
const apiKeyRateLimitMiddleware = t.middleware(({ ctx, next }) => {
if (ctx.authSource === "api-key" && ctx.apiKeyId) {
const result = checkRateLimit(`trpc:api-key:${ctx.apiKeyId}`, {
windowMs: 60 * 1000,
max: 120,
});
if (!result.allowed) {
throw new TRPCError({
code: "TOO_MANY_REQUESTS",
message: "API key rate limit exceeded. Please try again later.",
});
}
}
return next();
});
/**
* Public (unauthenticated) procedure
*
@@ -150,7 +177,9 @@ const timingMiddleware = t.middleware(async ({ next, path }) => {
* guarantee that a user querying is authorized, but you can still access user session data if they
* are logged in.
*/
export const publicProcedure = t.procedure.use(timingMiddleware);
export const publicProcedure = t.procedure
.use(timingMiddleware)
.use(apiKeyRateLimitMiddleware);
/**
* Protected (authenticated) procedure
@@ -173,3 +202,14 @@ export const protectedProcedure = t.procedure
},
});
});
export const sessionProcedure = protectedProcedure.use(({ ctx, next }) => {
if (ctx.authSource !== "session") {
throw new TRPCError({
code: "FORBIDDEN",
message: "This action requires an authenticated browser or app session",
});
}
return next();
});