Hey, Swayne here.
Nearly every webapp today has authentication. While it's becoming more popular to verify a user with a phone code, it's still rare to see. You will be pressed to find a popular website without classic email and password login, which is what I will be implementing today.
We will be using
- GraphQL
- TypeScript
- TypeORM
- Type-GraphQL
- Redis for storing cookies
- Express-sessions for the cookie-sessions
This will be backend only.
As usual, I start from Ben Awad's command npx create-graphql-api graphql-auth-example
or clone this starter GitHub repo.
But this will naturally still work if you have your own project already initialized, we just avoid a lot of boilerplate code using the command.
To begin, create your User entity using TypeORM definitions, possibly in a User.ts in your entity folder:
@ObjectType()
@Entity()
export class User extends BaseEntity {
@Field()
@PrimaryGeneratedColumn()
id!: number;
@Field()
@Column({type: "text", unique: true})
username!: string;
@Field(() => String)
@Column()
password!: string;
@Field(() => String)
@CreateDateColumn()
createdAt: Date;
@Field(() => String)
@CreateDateColumn()
updatedAt: Date;
}
This will guide us in making our mutations. Next, run migrations with the TypeORM CLI:
npx typeorm migration:create -n migrationfirst
Migrations look at your entities, and create corresponding SQL to create the tables. It will still work without running this command, I just believe it's a good practice😁
Next, create a new UserResolver
. In our UserResolver
we will handle our registration()
and login()
methods:
@InputType()
class UsernamePasswordInput {
@Field()
username: string;
@Field()
password: string;
}
@Mutation(() => User)
async register(
@Arg("options") options: UsernamePasswordInput
) {
const hashedPassword = await argon2.hash(options.password);
const user = User.create({
username: options.username,
password: hashedPassword,
}).save();
return user;
The mutation returns a User
and takes in an InputType()
. InputTypes()
are a way to simplify our code, so the arguments don't become too cluttered. As you can see, you just make a class with the fields and the corresponding types, which you can then pass into the mutation as an argument. In our example it's a username and password.
We make sure to hash the password using argon2 before storing it🔐
We can now call User.create()
from TypeORM to store a user into our database. We make sure to pass in the hashed password, not the user-inputted one!
Let's try it out in the GraphQL playground. Start the server, go to http://localhost:4000/playground and in the window run the following mutation:
mutation {
register(options: { username: "swayne1", password: "swayne1" }) {
errors {
field
message
}
user {
id
username
createdAt
updatedAt
}
}
}
Congrats, you just created a user in your database! 🎉
Before we continue, I would like to define some more InputType()
-classes.
@ObjectType()
class FieldError {
@Field()
field: string;
@Field()
message: string;
}
@ObjectType()
class UserResponse {
@Field(() => [FieldError], { nullable: true })
errors?: FieldError[];
@Field(() => User, { nullable: true })
user?: User;
}
The Fielderror
class simply shows a field
and message
. This is useful to show the correct error message for the correct field.
We also have a UserResponse
InputType
, which is a function return type. We either show a list of errors, using the FieldError
class we defined earlier, or returns the User
.
We are now ready to implement the login()
-mutation:
@Mutation(() => UserResponse)
async login(
@Arg("options") options: UsernamePasswordInput
): Promise<UserResponse> {
//1
const user = await User.findOne({ username: options.username });
//2
if (!user) {
return {
errors: [{ field: "username", message: "username doesn't exist" }],
};
}
const valid = await argon2.verify(user.password, options.password);
//3
if (!valid) {
return {
errors: [
{
field: "password",
message: "that password doesn't exist",
},
],
};
}
//4
return {user};
}
- We look for a user with the corresponding username. Note: that this implementation isn't case-sensitive. If you want case-sensitive username validation, where eg. "Swayne" and "swayne" are two different usernames, just call
.toLowerCase
onoptions.username
. - If we can't find a user with the username inputted, the user doesn't exist so we return error.
-
Next we verify the password using argon2.
verify()
takes the hashed password and compares it to the user-inputted one, and returns true if they match🗝If not, we return an error.
Lastly we just return the
User
-object if it passes all of the previous check. Note that we are only returning either Errors or a User, just like we definedUserResponse
-inputtype.
Test it out on http://localhost:4000/graphql by
mutation {
login(options: { username: "swayne", password: "swayne" }) {
errors {
field
message
}
user {
id
username
}
}
}
Cookies are sometimes a little inconsistent. Don't hesitate to leave a comment if you are having issues.
Let's implement similar valididation for registration()
@Resolver()
export class UserResolver {
@Mutation(() => UserResponse)
async register(
@Arg("options") options: UsernamePasswordInput,
@Ctx() {req} : any
): Promise<UserResponse> {
//username validation
if (options.username.length <= 2) {
return {
errors: [
{
field: "username",
message: "length must be greater than 2",
},
],
};
}
//password validation
if (options.password.length <= 2) {
return {
errors: [
{
field: "password",
message: "password must be greater than 2",
},
],
};
}
const hashedPassword = await argon2.hash(options.password);
let user: User | undefined = undefined;
try {
user = await User.create({
username: options.username,
password: hashedPassword,
}).save();
} catch(err) {
if (err.errno === 19) {
return {
errors: [
{
field: "username",
message: "username already taken",
},
],
};
}
}
return { user };
}
A few extra lines, but they are pretty similar, so don't feel overwhelmed!
First we check if the user-inputted passwords and usernames are of a length greater than 2
. Note, you may want a longer password in non-testing purposes.
After hashing the password, we use a try
catch
block. We try to create and save a user to the database. If it fails, we return an error. In SQL-lite, the err.errno=19
is the "username already exists"-error, so we return a fitting error message. You can find the error code by console.log(err)
and then triggering an error in the GraphQL playground. Look in the console logs for the error code.
To make sure the user doesn't need to login every time, we will store a cookie with the user's information on it. We will use a Redis-server for this purpose, which you can install on the website.
Sessions and cookies🍪
First I would like to go through the setup and explain how cookies and sessions work at the end.
We will have to add Redis and express-session plus their types.
yarn add redis connect-redis express-session
yarn add -D @types/redis
yarn add -D @types/express-session @types/connect-redis
We will just use the sample redis setup from the Docs, and configure a few cookie-related things.
const RedisStore = connectRedis(session)
const redisClient = redis.createClient()
app.use(
session({
name: 'qid',
store: new RedisStore({
client: redisClient,
disableTouch: true,
}),
cookie: {
maxAge: 10000000000, //long time
httpOnly: true,
secure: false, //cookie only works in https (we are developing)
sameSite: 'lax'
},
saveUninitialized: false,
secret: 'qiwroasdjlasddde', //you would want to hide this in production
resave: false
})
)
we set the name
to 'qid', the maxAge is how long before the cookie expires in ms. localhost isn't "https", so you should set secure
to false, but definitely set it to true during production.
sameSite
is a setting which controls who can set a cookie. We could also set it to 'strict' which means that a cookie can only be set if a user is directly on the site. If someone was following an email to your site, the cookie can't be set. The cookie only works in a first-party context. 'lax' allows users not directly on your site set a cookie, which is to be preferred right now.
saveUnitialized:false
prevents a lot of empty session objects being saved at the store. When 'true', we save a session object at the end of the request, but in some cases we won't have anything useful to store.
The secret
is a string, which redis uses to decrypt the cookie. More on that at the end.
resave:false
tells redis that a particiluar session is still active, but we won't need this as we save the cookie with a (near) infinite age😅
Let's change our resolvers to use sessions!
async login(
@Arg("options") options: UsernamePasswordInput,
@Ctx() {req}: MyContext
): Promise<UserResponse> {
const user = await User.findOne({ username: options.username });
if (!user) {
return {
errors: [{ field: "username", message: "username doesn't exist" }],
};
}
const valid = await argon2.verify(user.password, options.password);
if (!valid) {
return {
errors: [
{
field: "password",
message: "that password doesn't exist",
},
],
};
}
req.session.userId = user.id;
return { user };
}
I make sure to get the request, req
, from the Ctx()
-decorator. The type, MyContext
is an imported file:
export type MyContext = {
req: Request & { session: Session & Partial<SessionData> & { userId?: number } }
res: Response
redis: Redis
}
This it to avoid code-duplication and simplifies our code a lot.
Lastly, we store the current users [user.id](http://user.id)
in the session, req.session.userId
. In the real world, this means that auto-login is enabled.
To test it out, remember to set "request.credentials": "include", in graphql playground settings. You access the settings by clicking the top-right gear icon. After running this query:
mutation {
login(options: { username: "swayne", password: "swayne" }) {
errors {
field
message
}
user {
id
username
}
}
}
You can check that a cookie was set by opening chrome developer tools, opening the application tab and checking under the "cookies" tab:
The code is exactly the same in register:
@Ctx() {req}: MyContext
//...
//...
//store user id session, auto-logs in after registration🤩
req.session.userId = user?.id
return { user };
I prefer when other sites auto log me in after registration, so I implement the same in my projects⚡️
How do cookies and sessions work?🤔
First, we need to know what Redis is. Redis is a big key,value map, and if you know your data-structures, you know that given a key, we can get the corresponding value. If the user log-in is successful, we store their user.id
into the session with the line:
req.session.userId = user.id;
Which then sends the data to Redis, eg. {userId: 1}
In Redis, we save that to a key (remember that Redis is a K,V-map):
qwoaowkdaw
: {userId:1}
Express-session will then set a cookie, for example wiqeoi4390i490
on the browser for the given userId
just saved in the Key,Value-pair! If the user then makes a request, we send the cookie wiqeoi4390i4901)
to the server. Our secret (the one set in index.ts
) then decrypts the cookie and turns it into the key saved in redis, wiqeoi4390i4901
→ qwoaowkdaw
Finally, the server makes a request to Redis with the key that we just "decrypted" in the prior step, and gets the value {userId: 1}
, and then stores it on req.session
for the user to use 😄
Conclusion
We have just added authentication with cookies!
Check out the code on GitHub on the auth
branch:
Top comments (3)
I am using apollo-server-express ^3.1.2. It seems that when I go to localhost:4000/graphql, it redirects me to studio.apollographql.com and then I can run queries and mutations. When I run the login mutation it returns a successful response but the cookie is never set. Also, I can't change the 'request.credential' option to 'include' because the online playground doesn't have a config json that we can edit.
So, how do I set and get cookies from the online playground?
Hello!
I have not tried the new apollo studio, but I looked through the docs and found this section:
apollographql.com/docs/studio/expl...
Have you tried these settings? I also think some of the CORS-settings (the URL part specifically) has to be changed to reflect the updated URL.
I hope this helps and please let me know how it goes!
Hey i have used apollo studio before and remember to set Include Cookies in the settings to true. 😉