add revoke and signOut

master
Peter Babič 4 years ago
parent 28666f00f9
commit 20569fc63d
Signed by: peter.babic
GPG Key ID: 4BB075BC1884BA40
  1. 98
      src/server.spec.ts
  2. 16
      src/server.ts
  3. 266
      src/server/UserResolver.spec.ts
  4. 74
      src/server/UserResolver.ts
  5. 32
      src/server/userResolver/User.ts
  6. 42
      src/server/userResolver/auth.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

@ -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) {

@ -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,
})

@ -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
}
}

@ -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()
}
}

@ -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> = ({ context }) => {
@ -61,6 +68,7 @@ export const customAuthChecker: AuthChecker<Context> = ({ 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
}

Loading…
Cancel
Save