diff --git a/src/server.spec.ts b/src/server.spec.ts index 9e3c160..a8782be 100644 --- a/src/server.spec.ts +++ b/src/server.spec.ts @@ -1,3 +1,5 @@ +// TODO: convert to import +import cookie = require("cookie") import { gql } from "apollo-server-express" import { GraphQLClient, rawRequest } from "graphql-request" import fetch from "node-fetch" @@ -5,25 +7,54 @@ import { createConnection } from "typeorm" import { createServer } from "./server" import { gqlToStr } from "./server/schema" import { testingConnectionOptions } from "./server/testing" -import { verifiedRefreshTokenPayload } from "./server/userResolver/auth" +import { + rtCookieOptions, + signRefreshToken, + verifiedRefreshTokenPayload, +} from "./server/userResolver/auth" import { User } from "./server/userResolver/User" -import cookie = require("cookie") + +let user: User describe("server should", () => { - it("perform the refresh tokens operation flawlessly", async () => { + it("reject refresh token without valid cookie", async () => { + const response = await fetch(refreshTokenUri, { + method: "POST", + headers: { cookie: "INVALID-COOKIE" }, + }) + const jsonResponse = await response.json() + + expect(jsonResponse.data).toBeNull() + expect(jsonResponse.errors).not.toBeUndefined() + }) + + it("reject refresh token with tokenVersion mismatch", async () => { + const oldRefreshToken = signRefreshToken({ uid: user.id, ver: 0 }) + const cookieHeader = cookie.serialize("rt", oldRefreshToken, rtCookieOptions()) + + await user.invalidateTokens() + const response = await fetch(refreshTokenUri, { + method: "POST", + headers: { cookie: cookieHeader }, + }) + const jsonResponse = await response.json() + + expect(jsonResponse.data).toBeNull() + expect(jsonResponse.errors).not.toBeUndefined() + }) + + it("provide access token given good crendentials and grant refresh token with it", async () => { const halfADay = (60 * 60 * 24) / 2 const fifteenDays = 60 * 60 * 24 * 15 - const createUserResponse = await rawRequest(gqlUri, gqlToStr(createUserMutation)) - const userId = createUserResponse.data.createUser.id - - const accessTokenReponse = await rawRequest(gqlUri, gqlToStr(accessTokenQuery)) + const accessTokenReponse = await rawRequest(gqlUri, gqlToStr(accessTokenMutation)) const accessToken: string = accessTokenReponse.data.accessToken const headers: Headers = accessTokenReponse.headers - const cookieHeader = headers.get("set-cookie") as string const varyHeader = headers.get("vary") as string const acacHeader = headers.get("access-control-allow-credentials") as string const acaoHeader = headers.get("access-control-allow-origin") as string + + const cookieHeader = headers.get("set-cookie") as string const parsedCookie = cookie.parse(cookieHeader) const refreshCookieExpires = dateInKiloSeconds(parsedCookie.Expires) const refreshTokenPayload = verifiedRefreshTokenPayload(parsedCookie.rt) @@ -35,7 +66,7 @@ describe("server should", () => { Authorization: "Bearer " + accessToken, }, }) - const meResponse = await client.rawRequest(gqlToStr(meQuery)) + const meResponse = await client.rawRequest(gqlToStr(meMutation)) const refreshTokenResponse = await fetch(refreshTokenUri, { method: "POST", @@ -46,63 +77,68 @@ describe("server should", () => { expect(varyHeader).toBe("Origin") expect(acacHeader).toBe("true") expect(acaoHeader).toMatch(/http:/) + expect(cookieHeader).toMatch(/HttpOnly/) expect(parsedCookie.Path).toBe("/refresh_token") - expect(refreshTokenPayload.userId).toBe(userId) + expect(refreshTokenPayload.uid).toBe(user.id) + expect(refreshTokenPayload.ver).toBe(user.tokenVersion) + expect(refreshTokenPayload.msc).toBeLessThan(1000) expect(refreshCookieExpires).toBeCloseTo(refLifetime) expect(jwtLifetime).toBeGreaterThanOrEqual(halfADay) expect(jwtLifetime).not.toBeGreaterThan(fifteenDays) + expect(meResponse.data.me.email).toBe("auth@server.com") + expect(jsonResponse.data).toBeDefined() expect(jsonResponse.errors).toBeUndefined() }) - it("it doesnt perform refresh tokens without valid cookie", async () => { - const response = await fetch(refreshTokenUri, { - method: "POST", - headers: { cookie: "INVALID-COOKIE" }, - }) - const jsonResponse = await response.json() + it("provide an empty rt cookie on signOut mutation", async () => { + const signOutReponse = await rawRequest(gqlUri, gqlToStr(signOutMutation)) + const headers: Headers = signOutReponse.headers + const cookieHeader = headers.get("set-cookie") as string + const parsedCookie = cookie.parse(cookieHeader) - expect(jsonResponse.data).toBeNull() - expect(jsonResponse.errors).not.toBeUndefined() + expect(cookieHeader).toMatch(/HttpOnly/) + expect(parsedCookie.Path).toBe("/refresh_token") + expect(parsedCookie.rt).toBe("") }) }) beforeAll(async () => { await createConnection(testingConnectionOptions()) await createServer(port) + + await User.delete({ email: "auth@server.com" }) + user = await User.create({ email: "auth@server.com", password: "password" }).save() }) afterAll(async () => { - User.delete({ email: "auth@server.com" }) + await User.delete({ email: "auth@server.com" }) }) const port = 4001 const gqlUri = `http://localhost:${port}/graphql` const refreshTokenUri = `http://localhost:${port}/refresh_token` -const createUserMutation = gql` +const accessTokenMutation = gql` mutation { - createUser(email: "auth@server.com", password: "password") { - email - id - } - } -` - -const accessTokenQuery = gql` - query { accessToken(email: "auth@server.com", password: "password") } ` -const meQuery = gql` - query { +const meMutation = gql` + mutation { me { email } } ` +const signOutMutation = gql` + mutation { + signOut + } +` + const dateInKiloSeconds = (date: string | number) => new Date(date).getTime() / 1000000 diff --git a/src/server.ts b/src/server.ts index 83c1c36..4eb6547 100644 --- a/src/server.ts +++ b/src/server.ts @@ -6,6 +6,7 @@ import { contextFunction, verifiedRefreshTokenPayload, } from "./server/userResolver/auth" +import { User } from "./server/userResolver/User" import cookie = require("cookie") import cors = require("cors") @@ -26,11 +27,20 @@ export const createServer = async (port: number) => { }) ) - app.post("/refresh_token", (req, res) => { + app.post("/refresh_token", async (req, res) => { try { const parsedCookie = cookie.parse(req.headers.cookie!) - const refreshPayload = verifiedRefreshTokenPayload(parsedCookie.rt) - const accessToken = accessTokenWithRefreshCookie(refreshPayload.userId, res) + const rtPayload = verifiedRefreshTokenPayload(parsedCookie.rt) + + await User.findOneOrFail({ + where: { id: rtPayload.uid, tokenVersion: rtPayload.ver }, + }) + + const accessToken = accessTokenWithRefreshCookie( + rtPayload.uid, + rtPayload.ver!, + res + ) res.json({ data: accessToken }) } catch (error) { diff --git a/src/server/UserResolver.spec.ts b/src/server/UserResolver.spec.ts index 332640f..54d0e1a 100644 --- a/src/server/UserResolver.spec.ts +++ b/src/server/UserResolver.spec.ts @@ -3,143 +3,175 @@ import { Request, Response } from "express" import { createConnection, getConnection } from "typeorm" import { callSchema } from "./schema" import { - initializeRollbackTransactions, - runInRollbackTransaction, - testingConnectionOptions, + initializeRollbackTransactions, + runInRollbackTransaction, + testingConnectionOptions, } from "./testing" import { Context, signAccessToken, verifiedAccessTokenPayload } from "./userResolver/auth" import { User } from "./userResolver/User" describe("resolver of user", () => { - describe("createUser mutation should", () => { - it( - "return email as it creates user with mutation", - runInRollbackTransaction(async () => { - const response = await callSchema(createUserMutation) - - expect(response.errors).toBeUndefined() - expect(response.data).toMatchObject({ - createUser: { email: "user-mutation@user-resolver.com" }, - }) - }) - ) - }) - - describe("accessToken query should", () => { - it( - "return error for bad password or not-existent user", - runInRollbackTransaction(async () => { - await User.create({ - email: "access-token@user-resolver.com", - password: "BAD-password", - }).save() - - const response = await callSchema(accessTokenQuery, contextWithCookie()) - - expect(response.errors).not.toBeUndefined() - expect(response.data).toBeNull() - }) - ) - - it( - "return a valid access token with expiry providing good credentials", - runInRollbackTransaction(async () => { - const oneMinute = 60 - const sixteenMinutes = 60 * 16 - - const user = await User.create({ - email: "access-token@user-resolver.com", - password: "password", - }).save() - - const response = await callSchema(accessTokenQuery, contextWithCookie()) - const accessToken: string = response.data!.accessToken - const jwtPayload = verifiedAccessTokenPayload(accessToken) - const jwtLifetime = jwtPayload.exp! - jwtPayload.iat! - - expect(jwtLifetime).toBeGreaterThanOrEqual(oneMinute) - expect(jwtLifetime).not.toBeGreaterThan(sixteenMinutes) - expect(jwtPayload.userId).toBe(user.id) - expect(jwtPayload.ms).toBeLessThan(1000) - expect(response.errors).toBeUndefined() - }) - ) - }) - - describe("me query should", () => { - it( - "return an error without a valid access token", - runInRollbackTransaction(async () => { - const contextWithInvalidToken = contextWithAuthHeader( - "Bearer INVALID-TOKEN" - ) - const response = await callSchema(meQuery, contextWithInvalidToken) - - expect(response.errors).not.toBeUndefined() - expect(response.data).toBeNull() - }) - ) - - it( - "return an user with a valid access token", - runInRollbackTransaction(async () => { - const user = await User.create({ - email: "me-query@user-resolver.com", - }).save() - - const contextWithValidToken = contextWithAuthHeader( - "Bearer " + signAccessToken({ userId: user.id }) - ) - const response = await callSchema(meQuery, contextWithValidToken) - - expect(response.errors).toBeUndefined() - expect(response.data).toMatchObject({ - me: { email: "me-query@user-resolver.com" }, - }) - }) - ) - }) + describe("createUser mutation should", () => { + it( + "return email and default token version as it creates user with mutation", + runInRollbackTransaction(async () => { + const response = await callSchema(createUserMutation) + + expect(response.errors).toBeUndefined() + expect(response.data).toMatchObject({ + createUser: { + email: "user-mutation@user-resolver.com", + }, + }) + }) + ) + }) + + describe("accessToken mutation should", () => { + it( + "return error for bad password or not-existent user", + runInRollbackTransaction(async () => { + const nonExistenUserResponse = await callSchema( + accessTokenMutation, + contextWithCookie() + ) + await User.create({ + email: "access-token@user-resolver.com", + password: "BAD-password", + }).save() + + const badPasswordResponse = await callSchema( + accessTokenMutation, + contextWithCookie() + ) + + expect(nonExistenUserResponse.errors).not.toBeUndefined() + expect(nonExistenUserResponse.data).toBeNull() + expect(badPasswordResponse.errors).not.toBeUndefined() + expect(badPasswordResponse.data).toBeNull() + }) + ) + + it( + "return a valid access token with expiry providing good credentials", + runInRollbackTransaction(async () => { + const oneMinute = 60 + const sixteenMinutes = 60 * 16 + + const user = await User.create({ + email: "access-token@user-resolver.com", + password: "password", + }).save() + + const response = await callSchema( + accessTokenMutation, + contextWithCookie() + ) + const accessToken: string = response.data!.accessToken + const jwtPayload = verifiedAccessTokenPayload(accessToken) + const jwtLifetime = jwtPayload.exp! - jwtPayload.iat! + + expect(jwtLifetime).toBeGreaterThanOrEqual(oneMinute) + expect(jwtLifetime).not.toBeGreaterThan(sixteenMinutes) + expect(jwtPayload.uid).toBe(user.id) + expect(jwtPayload.msc).toBeLessThan(1000) + expect(response.errors).toBeUndefined() + }) + ) + }) + + describe("sign out mutation should", () => { + it( + "return true", + runInRollbackTransaction(async () => { + const response = await callSchema(signOutMutation, contextWithCookie()) + + expect(response.errors).toBeUndefined() + expect(response.data!.signOut).toBeTruthy() + }) + ) + }) + + describe("me mutation should", () => { + it( + "return an error without a valid access token", + runInRollbackTransaction(async () => { + const contextWithInvalidToken = contextWithAuthHeader( + "Bearer INVALID-TOKEN" + ) + const response = await callSchema(meMutation, contextWithInvalidToken) + + expect(response.errors).not.toBeUndefined() + expect(response.data).toBeNull() + }) + ) + + it( + "return an user with a valid access token", + runInRollbackTransaction(async () => { + const user = await User.create({ + email: "me-mutation@user-resolver.com", + }).save() + + const contextWithValidToken = contextWithAuthHeader( + "Bearer " + signAccessToken({ uid: user.id, ver: user.tokenVersion }) + ) + const response = await callSchema(meMutation, contextWithValidToken) + + expect(response.errors).toBeUndefined() + expect(response.data).toMatchObject({ + me: { email: "me-mutation@user-resolver.com" }, + }) + }) + ) + }) }) beforeAll(async () => { - initializeRollbackTransactions() - await createConnection(testingConnectionOptions()) + initializeRollbackTransactions() + await createConnection(testingConnectionOptions()) }) afterAll(async () => { - await getConnection().close() + await getConnection().close() }) const createUserMutation = gql` - mutation { - createUser(email: "user-mutation@user-resolver.com", password: "password") { - email - } - } + mutation { + createUser(email: "user-mutation@user-resolver.com", password: "password") { + email + } + } ` -const accessTokenQuery = gql` - query { - accessToken(email: "access-token@user-resolver.com", password: "password") - } +const accessTokenMutation = gql` + mutation { + accessToken(email: "access-token@user-resolver.com", password: "password") + } ` -const meQuery = gql` - query { - me { - email - } - } +const meMutation = gql` + mutation { + me { + email + } + } +` + +const signOutMutation = gql` + mutation { + signOut + } ` const contextWithAuthHeader = (header: string): Context => ({ - req: { - headers: { - authorization: header, - }, - } as Request, - res: {} as Response, + req: { + headers: { + authorization: header, + }, + } as Request, + res: {} as Response, }) const contextWithCookie = (): Context => ({ - req: {} as Request, - res: ({ cookie: () => undefined } as unknown) as Response, + req: {} as Request, + res: ({ cookie: () => undefined } as unknown) as Response, }) diff --git a/src/server/UserResolver.ts b/src/server/UserResolver.ts index 6b858d2..8ca43ab 100644 --- a/src/server/UserResolver.ts +++ b/src/server/UserResolver.ts @@ -1,43 +1,55 @@ import "reflect-metadata" import { Arg, Authorized, Ctx, Mutation, Query } from "type-graphql" import { - accessTokenWithRefreshCookie, - comparePasswords, - Context, + accessTokenWithRefreshCookie, + comparePasswords, + Context, + createRtCookie, } from "./userResolver/auth" import { User } from "./userResolver/User" export class UserResolver { - @Query(() => String) - async accessToken( - @Arg("email") email: string, - @Arg("password") password: string, - @Ctx() { res }: Context - ) { - try { - const user = await User.findOne({ where: { email } }) - await comparePasswords(user!.password, password) + @Query(() => String) + async query() { + return "" + } - return accessTokenWithRefreshCookie(user!.id, res) - } catch (error) { - throw new Error("Login credentials are invalid: " + error) - } - } + @Mutation(() => User) + async createUser(@Arg("email") email: string, @Arg("password") password: string) { + return await User.create({ + email, + password, + }).save() + } - @Query(() => User) - @Authorized() - async me(@Ctx() { payload }: Context) { - const id = payload!.userId - const user = await User.findOne({ where: { id } }) + @Mutation(() => String) + async accessToken( + @Arg("email") email: string, + @Arg("password") password: string, + @Ctx() { res }: Context + ) { + try { + const user = await User.findOne({ where: { email } }) + await comparePasswords(user!.password, password) - return user - } + return accessTokenWithRefreshCookie(user!.id, user!.tokenVersion, res) + } catch (error) { + throw new Error("Login credentials are invalid: " + error) + } + } - @Mutation(() => User) - async createUser(@Arg("email") email: string, @Arg("password") password: string) { - return await User.create({ - email, - password, - }).save() - } + @Mutation(() => User) + @Authorized() + async me(@Ctx() { payload }: Context) { + return await User.findOne({ + where: { id: payload!.uid }, + }) + } + + @Mutation(() => Boolean) + async signOut(@Ctx() { res }: Context) { + createRtCookie(res, "") + + return true + } } diff --git a/src/server/userResolver/User.ts b/src/server/userResolver/User.ts index 0036806..2220884 100644 --- a/src/server/userResolver/User.ts +++ b/src/server/userResolver/User.ts @@ -6,19 +6,27 @@ import { hashPassword } from "./auth" @ObjectType() @Entity() export class User extends BaseEntity { - @Field() - @PrimaryGeneratedColumn() - id!: number + @Field() + @PrimaryGeneratedColumn() + id!: number - @Field() - @Column() - email: string = "" + @Field() + @Column() + email: string = "" - @Column() - password: string = "" + @Column() + password: string = "" - @BeforeInsert() - async hashPassword() { - this.password = await hashPassword(this.password) - } + @Column() + tokenVersion: number = 0 + + @BeforeInsert() + async hashPassword() { + this.password = await hashPassword(this.password) + } + + async invalidateTokens() { + this.tokenVersion += 1 + await this.save() + } } diff --git a/src/server/userResolver/auth.ts b/src/server/userResolver/auth.ts index b76517c..5c8e3ee 100644 --- a/src/server/userResolver/auth.ts +++ b/src/server/userResolver/auth.ts @@ -16,17 +16,18 @@ export const comparePasswords = async (hash: string, plain: string) => { export const signAccessToken = (payload: ContextPayload) => { const accessTokenSecret = process.env.ACCESS_SECRET as string - const payloadWithMs = { ...payload, ms: Date.now() % 1000 } + const tokenPayload: TokenPayload = { ...payload, msc: Date.now() % 1000 } - return jwtSign(payloadWithMs, accessTokenSecret, { + return jwtSign(tokenPayload, accessTokenSecret, { expiresIn: parseInt(process.env.ACCESS_EXPIRY as string), }) } export const signRefreshToken = (payload: ContextPayload) => { - const accessTokenSecret = process.env.REFRESH_SECRET as string + const refreshTokenSecret = process.env.REFRESH_SECRET as string + const tokenPayload: TokenPayload = { ...payload, msc: Date.now() % 1000 } - return jwtSign(payload, accessTokenSecret, { + return jwtSign(tokenPayload, refreshTokenSecret, { expiresIn: parseInt(process.env.REFRESH_EXPIRY as string), }) } @@ -34,26 +35,32 @@ export const signRefreshToken = (payload: ContextPayload) => { export const verifiedAccessTokenPayload = (token: string) => { const accessTokenSecret = process.env.ACCESS_SECRET as string - return jwtVerify(token, accessTokenSecret) as JWTPayload + return jwtVerify(token, accessTokenSecret) as TokenPayload } export const verifiedRefreshTokenPayload = (token: string) => { const refreshTokenSecret = process.env.REFRESH_SECRET as string + return jwtVerify(token, refreshTokenSecret) as TokenPayload +} - return jwtVerify(token, refreshTokenSecret) as JWTPayload +export const accessTokenWithRefreshCookie = (uid: number, ver: number, res: Response) => { + const refreshToken = signRefreshToken({ uid, ver }) + createRtCookie(res, refreshToken) + + return signAccessToken({ uid }) } -export const accessTokenWithRefreshCookie = (userId: number, res: Response) => { - const accessToken = signAccessToken({ userId }) +export const createRtCookie = (res: Response, token: string) => + res.cookie("rt", token, rtCookieOptions()) +export const rtCookieOptions = () => { const refreshExpiryMs = parseInt(process.env.REFRESH_EXPIRY as string) * 1000 - res.cookie("rt", signRefreshToken({ userId }), { + + return { httpOnly: true, path: "/refresh_token", expires: new Date(new Date().getTime() + refreshExpiryMs), - }) - - return accessToken + } } export const customAuthChecker: AuthChecker = ({ context }) => { @@ -61,6 +68,7 @@ export const customAuthChecker: AuthChecker = ({ context }) => { const authHeader = context.req.headers["authorization"] const accessToken = authHeader!.split(" ")[1] const accessTokenPayload = verifiedAccessTokenPayload(accessToken) + context.payload = accessTokenPayload as ContextPayload return true @@ -78,12 +86,12 @@ export type Context = { } type ContextPayload = { - userId: number + uid: number + ver?: number } -type JWTPayload = { - userId: number - iat: number +type TokenPayload = ContextPayload & { + msc: number + iat?: number exp?: number - ms?: number }