TRPC Security/ Authorization Concerns

Hey guys, I'm a bit worried the default protected procedure isn't enough to secure data in my app. As I understand it, if someone just logs in my app, he'll have a valid session, and could potentially reverse engineer the trpc queries and access other users data, is there something I'm missing here ? I'm also incredibly confused by TRPC context, should I be querying my db in the context to check if user has authorization for the data he's requesting ? Every example I find is only passing session and db connection, haven't seen any type of authorization method here.
11 Replies
lanc3
lanc3ā€¢10mo ago
T3 stacks provides authentication functionality for TRPC, i.e. protectedProcedure but does not handle authorization as that is a complex problem to generically solve. The simplest thing you could do is check if the user owns that particular resource by using DB queries. There are also some libraries like https://github.com/stalniy/casl which provide a much more thorough layer of authorization
GitHub
GitHub - stalniy/casl: CASL is an isomorphic authorization JavaScri...
CASL is an isomorphic authorization JavaScript library which restricts what resources a given user is allowed to access - GitHub - stalniy/casl: CASL is an isomorphic authorization JavaScript libra...
OtterSwims996
OtterSwims996ā€¢10mo ago
I think you'll have to write your own code to give users permissions in your db. You can probably write some more code in your middleware file to check requests against the roles you've given the users in your db in order to allow or disallow them. You can probably insert the role they have into the context you pass along in your middleware and then return the proper TRPC error code on your router functions
migs
migsā€¢10mo ago
Thanks for the suggestion, unfortunately I'm using drizzle and casl doesn't seem to have a package for drizzle, same with zenstack. Is there a good alternative for drizzle ? But also, my authorization rules will not be too complex, I was thinking of creating a table for authorizations and querying my db in the context, but not sure if this is a good thing to do
lanc3
lanc3ā€¢10mo ago
This is fine to do depending on how you do it. I've found the easiest primitive way is to just have an ownerId column on DB resources and check if the session user id is equal to the ownerId
andersgee
andersgeeā€¢10mo ago
If someone has a session yes they will have access to protected procedures, but if the procedure does something like getUserById({userId: session.userId}) then they will only get their own info back. Another example is something like deletePostById, you dont just do deletePostById({id: input.id}) you do deletePostById({id:input.id, creatorId: session.userId}) and hence they will only be able to delete their own posts These are just examples, but you are correct, protectedProcedure is just "Authentication". you still have to think about who has access to what aka "Authorization" yourself A common thing is managing authorization with user roles but you could choose to make it as specific as your usecase needs to be
migs
migsā€¢10mo ago
That was a very good explanation. Made me realize that what I need to do, is add more data to my session object, and use that value on the calls, instead of just receiving as an input
migs
migsā€¢10mo ago
Cody is in a very similar situation to mine in this video https://youtu.be/I5UXsAW9dUE?si=mGjih6SBAtJT3FNG I don't really like the way he solves this since we could always forget to add that assertion. Given this example from Cody, would it make sense to have the classroomId in the session, and change it based on the classroom you're currently managing so we could then access the classroomId in the trpc procedures ?
Web Dev Cody
YouTube
How I implement role based authorization to my T3 Stack application
šŸ’¬ Discord https://discord.gg/4kGbBaa šŸ¤‘ Patreon https://www.patreon.com/webdevjunkie šŸ”” Newsletter http://eepurl.com/hnderP šŸ“. GitHub https://github.com/codyseibert/youtube My VSCode Extensions: - theme: material community high contrast - fonts: Menlo, Monaco, 'Courier New', monospace - errors: Error Lens - extra git help: Git Lens - tailwind ...
andersgee
andersgeeā€¢10mo ago
I mean in programming there are always many way to do things, I feel like you are asking for "best practise" or something but as I said before, you just have to pick a way to do it that fits your use case. That being said, no I personally would not put classroomId in session. If your regular protectedProcedure looks like this
export const protectedProcedure = t.procedure.use(middleware(({ ctx, next }) => {
if (!ctx.user) throw new TRPCError({ code: "UNAUTHORIZED" });

return next({ ctx: { ...ctx, user: ctx.user } });
}));
export const protectedProcedure = t.procedure.use(middleware(({ ctx, next }) => {
if (!ctx.user) throw new TRPCError({ code: "UNAUTHORIZED" });

return next({ ctx: { ...ctx, user: ctx.user } });
}));
you could add another
export const teacherProcedure = t.procedure.use(middleware(async ({ ctx, next }) => {
if (!ctx.user) throw new TRPCError({ code: "UNAUTHORIZED" });

const role = await getUserRole(ctx.user) // fetch this from db or perhaps you already have this on "ctx.user"?
if (role !== "TEACHER") throw new TRPCError({ code: "UNAUTHORIZED" });

return next({ ctx: { ...ctx, user: ctx.user } });
}));
export const teacherProcedure = t.procedure.use(middleware(async ({ ctx, next }) => {
if (!ctx.user) throw new TRPCError({ code: "UNAUTHORIZED" });

const role = await getUserRole(ctx.user) // fetch this from db or perhaps you already have this on "ctx.user"?
if (role !== "TEACHER") throw new TRPCError({ code: "UNAUTHORIZED" });

return next({ ctx: { ...ctx, user: ctx.user } });
}));
now we can use teacherProcedure instead and again we are back to the situation where you do something like deleteClassroom({id:input.id, teacherId: session.userId})
migs
migsā€¢10mo ago
This was incredibly helpful, thank you so much @andersgee Inspired by this, I came up with the solution I think fits this best. Still based on cody's project so others can understand it better here's what I ended up with:
const userHasClassroomAccess = async ({
classroomId,
ctx,
}: {
classroomId: string;
ctx: {
session: Session | null;
db: PlanetScaleDatabase<typeof import("../db/schema")>;
};
}) => {
//this logic will depend a lot on your schema

const classroom = await ctx.db.query.classrooms.findFirst({
where: eq(classrooms.id, classroomId),
});

if (classroomId?.ownerId === ctx.session?.user.id) return true;

return false;
};
const userHasClassroomAccess = async ({
classroomId,
ctx,
}: {
classroomId: string;
ctx: {
session: Session | null;
db: PlanetScaleDatabase<typeof import("../db/schema")>;
};
}) => {
//this logic will depend a lot on your schema

const classroom = await ctx.db.query.classrooms.findFirst({
where: eq(classrooms.id, classroomId),
});

if (classroomId?.ownerId === ctx.session?.user.id) return true;

return false;
};
This part was what unlocked my brain mostly, I didn't know that I could easily get the input from the request here (finally read trpc documentation lol). This works perfectly for my case, since I was already sending the classroomId as input to all the procedures I needed, just like Cody is doing on the video. Note: Be careful with the input, rawInput means that it hasn't been validated with zod yet, so we validate here.
const enforceUserHasClassroomAccess = t.middleware(
async ({ ctx, next, rawInput }) => {
const { classroomId } = z.object({ classroomId: z.number() }).parse(rawInput);

if (!ctx.session?.user || !classroomId) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}

const hasAccess = await userHasClassroomAccess({
classroomId: classroomId,
ctx: ctx,
});

if (!hasAccess) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}

return next({
ctx: {
// infers the `session` as non-nullable
session: { ...ctx.session, user: ctx.session.user },
},
});
},
);
const enforceUserHasClassroomAccess = t.middleware(
async ({ ctx, next, rawInput }) => {
const { classroomId } = z.object({ classroomId: z.number() }).parse(rawInput);

if (!ctx.session?.user || !classroomId) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}

const hasAccess = await userHasClassroomAccess({
classroomId: classroomId,
ctx: ctx,
});

if (!hasAccess) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}

return next({
ctx: {
// infers the `session` as non-nullable
session: { ...ctx.session, user: ctx.session.user },
},
});
},
);
export const withClassroomAccessProcedure = t.procedure.use(
enforceUserHasClassroomAccess
);
export const withClassroomAccessProcedure = t.procedure.use(
enforceUserHasClassroomAccess
);
andersgee
andersgeeā€¢10mo ago
youre welcome šŸ™‚ btw about "careful with input", the typesafe way is also a 1 liner so might aswell use that
const { classroomId } = z.object({ classroomId: z.number() }).parse(input);
const { classroomId } = z.object({ classroomId: z.number() }).parse(input);
migs
migsā€¢10mo ago
I'll edit it above, thank you!
Want results from more Discord servers?
Add your server