Adding users to new teams with Lucia auth and Prisma
Last tended October 22, 2023.
Recently I’ve been building Doc Duck, a user feedback platform. I wanted to add support for teams so that a customer can invite their coworkers to also participate. Multiple people from a team/company wanting access to the software is a really common scenario in B2B. For Doc Duck, I anticipate that an analyst or PM will want to view data in the dashboard and share it with their team. Plus they will want an engineer to do the integration work. In this post, I’m going to explain how I added support for teams and wired up that information in the auth library I’m using, Lucia-auth so that when accessing a user’s session I also get the information about teams they are on.
Updating the database with tables for auth and teams
First, I needed to add the relevant tables and fields to my database to support auth & teams. I’m using prisma
, so the following is what I’ve added to my schema.prisma
file to support the fields needed for Lucia and to add support for teams.
// This describes a user's role in a team.
enum Role {
admin
user
@@map("role")
}
model User {
// These 3 fields are the User model defined by Lucia
id String @id @unique
auth_session Session[]
key Key[]
// These are additional fields I want
email String @unique
created_date DateTime
// This defines a relation to the TeamMember table where a user can be a member of multiple teams
teams TeamMember[]
}
// The Session model defined by Lucia
model Session {
id String @id @unique
user_id String
active_expires BigInt
idle_expires BigInt
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
@@index([user_id])
}
// The Key model defined by Lucia
model Key {
id String @id @unique
hashed_password String?
user_id String
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
}
// This model represents teams in the database
model Team {
id String @id @unique
name String
created_date DateTime
users TeamMember[]
}
// This model defines team membership - each row represents one user's membership in one team
model TeamMember {
team_id String
user_id String
role Role
joined DateTime @default(now())
team Team @relation(fields: [team_id], references: [id], onDelete: Cascade)
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
@@id([team_id, user_id])
}
For simplicity I’m only using a Role
enum to manage user permissions within a team. For more complex permission setups like Discord’s role management system, I’d recommend implementing a more advanced permission system like Role-based access control. RBAC is out of scope for this article, but if you’d like one from me in the future, let me know on Twitter. If it gets enough interest, I might do it.
Now that the model is defined, we can:
npx prisma generate
to generate the type definitions we will be usingnpx prisma db push
to push these changes to the database
Implementing team support in the database model
I care a lot about providing a great user experience - both to myself as a developer, and to users of the software I develop. I don’t want new users to start their experience using Doc Duck by signing up and then creating a team. I want to minimize the time it takes to get from 0 to the “aha” moment, so when they sign up I’m going to create a team for them.
Requirements for user and team creation
Creating a team and a user in the database simultaneously poses an additional challenge over doing them as separate operations. If creating one of them fails, both should fail. They need to be completed in a single ACID transaction.
Since Lucia doesn’t support this, I’m going to have to define my own function for creating a user that meets my requirements. Those requirements are:
- When a signing up a user, an account and team must be created
- Both need to succeed or fail completely - partial success is a full failure
- The results must still work with Lucia.
- On success, it should return the created
User
andTeam
- On failure, it should return the error information
- I want to use TypeScript to easily and safely determine whether it succeeded or failed and get the corresponding data
For now, I’m only supporting email & password combos, so that’s what the function will take in as a parameter. Since database requests happen over a network, the most efficient way to write them is using async functions. Here’s the function signature for creating a user:
export const createUser = async (userData: {
email: string;
password: string;
}) => {
// What goes here?
}
Next I’m building the Prisma query for creating the user, team, and the relationship between them. To do this I used the prisma client and a couple utilities from Lucia for password hashing and creating a key.
import { prisma } from '$lib/server/prisma';
import { generateLuciaPasswordHash } from 'lucia/utils';
import { createKeyId } from 'lucia';
I create a single Prisma client to use across my backend in '$lib/server/prisma'
, which is that first import. I’m using the slick server-only module feature of SvelteKit. By putting my Prisma module in $lib/server
, SvelteKit will not let me import it into client code. Nifty!
Including the right data
Now let’s look at the Prisma query itself! I am making a query to create a User
and inside it I’m nesting the insertion of a Key
, TeamMember
, Team
. Prisma performs nested inserts as a transaction, which means the inserts will all succeed or all fail.
I split it out into the constituent parts to make it a bit easier to discuss here. There are 3 parts:
- The
data
object contains the data I want to insert to the database - the
include
argument tells Prisma what to include in the response, in addition to the insertedUser
- Perform the query
This is based on the excellent Lucia documentation about falling back to database queries in Lucia.
// @noErrors
const data = {
// to-do
} satisfies Prisma.UserCreateArgs['data'];
const include = {
// to-do
} satisfies Prisma.UserCreateArgs['include'];
// this performs the actual query
const result = await prisma.user.create({
include,
data
// If you're curious about the shape of the query result, this is how to get it from prisma
// see here: V
}) satisfies Prisma.UserGetPayload<{include: typeof include}>;
I’ve used satisfies Prisma.<something>
in all of these to get better type safety and tooling support. Note that for include
and data
it’s important to use satisfies
rather than a type annotation. This is because a type annotation will widen the value to the type of the annotation. In contrast, satisfies
enforces the constraints of the specified type, but won’t widen the value into that type. This behavior is necessary for Prisma.UserGetPayload<>
to provide the correct result type. You can learn more about the satisfies
operator in the Typescript 4.9 release announcement.
Now that I have the structure of the query and result, lets look at the meat of the request, starting with include:
// @noErrors
const include = {
teams: {
include: {
team: true
}
}
} satisfies Prisma.UserCreateArgs['include'];
This include object might look a little convoluted. Think back to the data model; to support users being in multiple teams, I have 3 tables:
- User - the table with user properties
- Team - the table with team properties
- TeamMember - the table that defines relationships between users and teams
So the include statement above is telling Prisma what fields to give us in response. In addition to the basic fields of the User
model, I’ve told it to include an array of TeamMember
s and corresponding team
s that the user is in. Because I’m inserting a new user and team here, I know that teams
will be an array of exactly 1 team.
User
type that includes teams
Shape of a The result will be of the following shape, which I’ve written out to provide annotations:
type UserIncludingTeam = {
// The basic fields from the `User` model
id: string;
email: string;
created_date: Date;
// also include the `TeamMember` relation, called `teams`
teams: {
// The basic fields from the `TeamMember` model
team_id: string;
user_id: string;
role: $Enums.Role;
joined: Date;
// also include the `Team` relation, called `team`:
team: {
id: string;
name: string;
created_date: Date;
}
}[]
}
Outside of an article, I prefer to use the Prisma utilities for advanced type safety. They are generated based on the model definition, so are going to stay up-to-date when the model changes. An equivalent type can be produced by Prisma with a single line: Prisma.UserGetPayload<{include: typeof include}>
. Much easier to write!
Create the new user and their team
Up next is the data
object. This is how I specify what data should be used to create:
- The user
- The
TeamMember
and correspondingTeam
that the user will be in - A
Key
entry so the user can sign in with username and password
const data = {
// The basic user data
created_date: new Date(Date.now()),
email,
id: crypto.randomUUID(), // available on most browsers and NodeJS
teams: {
// This tells Prisma to create a TeamMember with a relation to the new user
create: {
role: 'admin',
team: {
// This creates the new Team
create: {
name: 'My Team',
created_date: new Date(Date.now()),
id: crypto.randomUUID()
}
}
}
},
key: {
// I need to store a key so in the future the user can sign in
create: {
// These functions are provided by Lucia-auth
id: createKeyId('email', email.toLowerCase()),
hashed_password: await generateLuciaPasswordHash(password)
}
}
//V This also provides great intellisense support!
} satisfies Prisma.UserCreateArgs['data'];
Finally I bring it all together and create the user and their new team. Whew!
const queryResult = await prisma.user.create({
data,
include
});
Now that the bulk of my createUser()
logic is done, I need to return the results!
Structuring and returning the result of creating a user
To get my User
and Team
back from the sign-up transaction, I’ll use the types defined by prisma to build my result types. I’ve opted to use separate types to represent success and failure, and combine them into a union type called CreateUserResult
:
import type { Team, User } from '@prisma/client';
type CreateUserSuccess = {
success: true;
user: User;
team: Team;
};
type CreateUserFailure = {
success: false;
error: string;
code: string | undefined;
};
type CreateUserResult = CreateUserSuccess | CreateUserFailure;
export const createUser = async (userData: {
email: string;
password: string;
}): Promise<CreateUserResult> => {
// ...
}
This uses a TypeScript technique called a discriminated union. Based on the value of .success
in the CreateUserResult
returned by createUser()
, TypeScript can safely determine whether the object is a success or failure and expose the correct fields to me.
The success case is fairly straightforward; grab the user and team from the queryResult
and return a CreateUserSuccess
:
return {
success: true, // client code can use this to discriminate the result is `CreateUserSuccess`
// This will narrow the `user` down to the basic fields of email, created_date, and id.
user: queryResult as Omit<typeof queryResult, 'teams'>,
// Since it's a newly inserted user, I know they are in exactly 1 team
team: user.teams[0].team
} satisfies CreateUserSuccess; // this provides better intellisense support
The failure case occurs when something goes wrong with the Prisma query. When that happens, it will throw an error. We can catch it by wrapping the query in a try/catch block:
const data = {...};
const include = {...};
try {
const queryResult = await prisma.user.create({
data,
include
});
return {...} satisfies CreateUserSuccess;
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError &&
e.code === 'P2002'){
return {
success: false,
error: e.message,
code: e.code
}
}
return {
success: false,
error: 'Unknown error occurred.',
code: undefined
}
}
I’ve kept this error handling example brief. When building for production, I recommend handling the full list of Prisma errors.
Extending Lucia types to include teams
Now that teams are supported in the database and get created alongside new users, I want to start using them in my SvelteKit endpoints and load functions. That’s how I will validate the user’s session and make sure they are authorized for whatever they are trying to do. This is important because I don’t want to let any user view or interact with data for teams they aren’t part of. Plus, some actions like adding or removing members from the team will require the user to be in the team and have elevated privileges. The Lucia guide to SvelteKit setup adds the Lucia auth
object to locals
, so it’s readily available for this.
Unfortunately, I’ve got a problem! Lucia doesn’t know about teams yet, so the Session
object it gives me doesn’t contain team data:
import type { PageServerLoad } from './$types'; // generated by SvelteKit
import { redirect } from '@sveltejs/kit';
export const load: PageServerLoad = async ({ locals, params }) => {
const session = await locals.auth.validate(); // this
if(null === session) throw redirect(302, '/login');
const user = session.user;
// Now I want to check the user's teams, but it's not included in the session info!
const teams = user.teams; // error!
}
Now that I’ve used Lucia to authenticate the user, I want to check their team data to make sure they are authorized to view this page. The most straightforward way to do this would be to use Prisma from here. I could use the value of session.user.id
to get the teams the user is in from the database. The problem with that approach is it requires a second round-trip request to the database, which has several downsides:
- Higher serverless function execution time, which increases costs
- More database queries also increases costs
- Longer backend execution times means it takes longer for the page to load, degrading the user experience
- The increase in database load would reduce how many concurrent users can be supported
Overall not a great solution! Instead, I want Lucia to grab the team with the user and session info in a single query, rather than making 2 (or even 3!) queries out to the database. I also need to tell Lucia what my desired user type is so it can provide type safety. Fortunately, this is all supported out of the box; no hacks necessarily!
User
type to include team information
Modifying the auth I found the easiest way to go about updating Lucia’s auth User
type to include teams is to re-run the Lucia starter guide for my framework (SvelteKit). It looks like these changes are really similar for other frameworks too.
First, I need to update the app.d.ts
file to extend Lucia’s DatabaseUserAttributes
to include the team data I want. I used Prisma to generate the typing for me:
// app.d.ts
import type { Prisma, User } from '@prisma/client';
declare global {
namespace Lucia {
type Auth = import('$lib/server/lucia').Auth;
// Lucia wants you to exclude the 'id' attribute, because it's required
type DatabaseUserAttributes = Omit<User, 'id'> & {
teams: Prisma.TeamMemberGetPayload<{include: {team: true}}>[];
};
type DatabaseSessionAttributes = {};
}
}
// THIS IS IMPORTANT (according to the Lucia docs)
export {}
Two things of note here:
- I used
Omit<>
to remove theid
attribute from theUser
type Prisma generated from my data model because Lucia already knows that users have anid
. - The type of
Lucia.DatabaseUserAttributes.teams
is the result of getting aTeamMember
entry (which defines the relation between a user and team) and including theTeam
with it. I made this an array by appending[]
because a user can be in multiple teams.
Plumbing the team data into Lucia
By default, Lucia’s Prisma adapter works great. Unfortunately, it doesn’t know how to perform the slightly more complex join query to get the team data I want with my users. Fortunately, there’s a great (experimental as of this writing) solution to this problem: joinAdapters()
.
The default adapter works for everything I need except getting users. To fix that I use joinAdapters()
to partially extend the default Prisma adapter. The change takes place in the Lucia config that I defined in $lib/server/lucia.ts
. As an optimization, Lucia
will try to use the provided adapter’s getSessionAndUser()
to get both in a single query. If it’s not present, it will fall back to getUser()
. To get the best performance in all cases, I override both.
getUser()
and getSessionAndUser()
functions
Writing custom I started with getUser()
because it’s simpler. I imported Lucia’s Adapter
type to defines the function signature for getUser()
and my Prisma client instance to perform the query within. Here’s how that all comes together:
// $lib/server/lucia.ts
import type { Prisma } from '@prisma/client';
import type { Adapter } from 'lucia';
import { prisma } from '$lib/server/prisma';
// override the default getUser method on the prisma adapter to include the user's teams
const getUser = (async (userId) => {
const user = await prisma.user.findUnique({
select: {
teams: { include: { team: true } },
id: true,
email: true,
created_date: true
},
// The specific user to grab
where: { id: userId }
});
return user;
// This gives me type checking and better intellisense
}) satisfies Adapter['getUser'];
Note that the value being assigned to getUser
is wrapped in parentheses. This is so I can use the satisfies
operator to provide a type constraint.
Continuing on in the file I made getSessionAndUser()
. This one is a little more complicated because the result needs to be a 2 element array: [session, user]
. I ran into a couple gotchas here:
- If the session doesn’t exist,
getSessionAndUser()
should return[null, null]
- Lucia’s models the Session’s
active_expires
andidle_expires
asBigInt
s. I had to convert them tonumber
s
const getSessionAndUser = (async (sessionId) => {
const session = await prisma.session.findUnique({
where: { id: sessionId },
include: { user: { include: { teams: { include: { team: true } } } } }
});
if (null === session) return [null, null];
return [
{
...session,
active_expires: Number(session.active_expires),
idle_expires: Number(session.idle_expires)
},
session.user
];
// Again, this is for better type checking & intellisense support
}) satisfies Adapter['getSessionAndUser'];
Tying it all together in the Lucia config
With the two overrides written, all that remains is to bring it all together in the Lucia config!
To do this, I used the (currently experimental) joinAdapters()
utility that Lucia provides. It’s pretty straightforward:
- Pass it the first adapter to use
- Pass an object with the Adapter methods to override
Tying it all together, I have the following Lucia config:
import { lucia, type Adapter } from 'lucia';
import { sveltekit } from 'lucia/middleware';
import { dev } from '$app/environment';
import { prisma as PrismaAdapter } from '@lucia-auth/adapter-prisma';
import { prisma } from '$lib/server/prisma';
import { __experimental_joinAdapters as joinAdapters } from 'lucia/utils';
// truncated for brevity
const getUser = (...) satisfies Adapter['getUser'];
const getSessionAndUser = (...) satisfies Adapter['getSessionAndUser'];
// The Lucia config
export const auth = lucia({
env: dev ? 'DEV' : 'PROD',
middleware: sveltekit(),
// joinAdapters makes it possible to override parts of the default `PrismaAdapter`
adapter: joinAdapters(
PrismaAdapter(prisma, {
user: 'user',
key: 'key',
session: 'session'
}),
// This "adapter" object has the overrides to get teams with the user
{ getUser, getSessionAndUser }
),
getUserAttributes: (user /*: UserSchema*/) => {
// `UserSchema` pulls in the `teams` field I added in app.d.ts
return user;
}
});
export type Auth = typeof auth;
Accessing the user’s team data provided by Lucia
Now that Lucia knows how to get the team data, I can access it easily via (await locals.auth.validate()).user.teams
in my SvelteKit page load functions and server endpoints. Going back to the code example from the start of this section on extending Lucia types to include teams, it now works the way I wanted! Check it out:
// routes/[teamId]/+page.server.ts
import type { PageServerLoad } from './$types'; // generated by SvelteKit
import { redirect } from '@sveltejs/kit';
export const load: PageServerLoad = async ({ locals, params }) => {
const session = await locals.auth.validate(); // this
if(null === session) throw redirect(302, '/login');
const user = session.user;
// Now the user's teams are included in the session info!
const teams = user.teams; // works now!
if (teams.find(team => team.id === params.teamId) === undefined){
// unauthorized
}
// The user is supposed to be here!
}
Now when I use Lucia to authenticate user sessions, I also get the data I need to make sure the user is authorized. Awesome!
Conclusion
I hope this is helpful to you in adding support for teams (or anything else!) to your SaaS!
This article was a lot of fun to write, and I ended up learning and improving my code a lot in the process. I have really enjoyed building with Lucia, SvelteKit, and Prisma. With any luck, this article has helped you get excited to go build something with them too!