Archived
Harden auth and mobile session handling
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
@@ -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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user