add revoke and signOut

master
Peter Babič 5 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 { gql } from "apollo-server-express"
import { GraphQLClient, rawRequest } from "graphql-request" import { GraphQLClient, rawRequest } from "graphql-request"
import fetch from "node-fetch" import fetch from "node-fetch"
@ -5,25 +7,54 @@ import { createConnection } from "typeorm"
import { createServer } from "./server" import { createServer } from "./server"
import { gqlToStr } from "./server/schema" import { gqlToStr } from "./server/schema"
import { testingConnectionOptions } from "./server/testing" 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 { User } from "./server/userResolver/User"
import cookie = require("cookie")
let user: User
describe("server should", () => { 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 halfADay = (60 * 60 * 24) / 2
const fifteenDays = 60 * 60 * 24 * 15 const fifteenDays = 60 * 60 * 24 * 15
const createUserResponse = await rawRequest(gqlUri, gqlToStr(createUserMutation)) const accessTokenReponse = await rawRequest(gqlUri, gqlToStr(accessTokenMutation))
const userId = createUserResponse.data.createUser.id
const accessTokenReponse = await rawRequest(gqlUri, gqlToStr(accessTokenQuery))
const accessToken: string = accessTokenReponse.data.accessToken const accessToken: string = accessTokenReponse.data.accessToken
const headers: Headers = accessTokenReponse.headers const headers: Headers = accessTokenReponse.headers
const cookieHeader = headers.get("set-cookie") as string
const varyHeader = headers.get("vary") as string const varyHeader = headers.get("vary") as string
const acacHeader = headers.get("access-control-allow-credentials") as string const acacHeader = headers.get("access-control-allow-credentials") as string
const acaoHeader = headers.get("access-control-allow-origin") 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 parsedCookie = cookie.parse(cookieHeader)
const refreshCookieExpires = dateInKiloSeconds(parsedCookie.Expires) const refreshCookieExpires = dateInKiloSeconds(parsedCookie.Expires)
const refreshTokenPayload = verifiedRefreshTokenPayload(parsedCookie.rt) const refreshTokenPayload = verifiedRefreshTokenPayload(parsedCookie.rt)
@ -35,7 +66,7 @@ describe("server should", () => {
Authorization: "Bearer " + accessToken, Authorization: "Bearer " + accessToken,
}, },
}) })
const meResponse = await client.rawRequest(gqlToStr(meQuery)) const meResponse = await client.rawRequest(gqlToStr(meMutation))
const refreshTokenResponse = await fetch(refreshTokenUri, { const refreshTokenResponse = await fetch(refreshTokenUri, {
method: "POST", method: "POST",
@ -46,63 +77,68 @@ describe("server should", () => {
expect(varyHeader).toBe("Origin") expect(varyHeader).toBe("Origin")
expect(acacHeader).toBe("true") expect(acacHeader).toBe("true")
expect(acaoHeader).toMatch(/http:/) expect(acaoHeader).toMatch(/http:/)
expect(cookieHeader).toMatch(/HttpOnly/) expect(cookieHeader).toMatch(/HttpOnly/)
expect(parsedCookie.Path).toBe("/refresh_token") 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(refreshCookieExpires).toBeCloseTo(refLifetime)
expect(jwtLifetime).toBeGreaterThanOrEqual(halfADay) expect(jwtLifetime).toBeGreaterThanOrEqual(halfADay)
expect(jwtLifetime).not.toBeGreaterThan(fifteenDays) expect(jwtLifetime).not.toBeGreaterThan(fifteenDays)
expect(meResponse.data.me.email).toBe("auth@server.com") expect(meResponse.data.me.email).toBe("auth@server.com")
expect(jsonResponse.data).toBeDefined() expect(jsonResponse.data).toBeDefined()
expect(jsonResponse.errors).toBeUndefined() expect(jsonResponse.errors).toBeUndefined()
}) })
it("it doesnt perform refresh tokens without valid cookie", async () => { it("provide an empty rt cookie on signOut mutation", async () => {
const response = await fetch(refreshTokenUri, { const signOutReponse = await rawRequest(gqlUri, gqlToStr(signOutMutation))
method: "POST", const headers: Headers = signOutReponse.headers
headers: { cookie: "INVALID-COOKIE" }, const cookieHeader = headers.get("set-cookie") as string
}) const parsedCookie = cookie.parse(cookieHeader)
const jsonResponse = await response.json()
expect(jsonResponse.data).toBeNull() expect(cookieHeader).toMatch(/HttpOnly/)
expect(jsonResponse.errors).not.toBeUndefined() expect(parsedCookie.Path).toBe("/refresh_token")
expect(parsedCookie.rt).toBe("")
}) })
}) })
beforeAll(async () => { beforeAll(async () => {
await createConnection(testingConnectionOptions()) await createConnection(testingConnectionOptions())
await createServer(port) await createServer(port)
await User.delete({ email: "auth@server.com" })
user = await User.create({ email: "auth@server.com", password: "password" }).save()
}) })
afterAll(async () => { afterAll(async () => {
User.delete({ email: "auth@server.com" }) await User.delete({ email: "auth@server.com" })
}) })
const port = 4001 const port = 4001
const gqlUri = `http://localhost:${port}/graphql` const gqlUri = `http://localhost:${port}/graphql`
const refreshTokenUri = `http://localhost:${port}/refresh_token` const refreshTokenUri = `http://localhost:${port}/refresh_token`
const createUserMutation = gql` const accessTokenMutation = gql`
mutation { mutation {
createUser(email: "auth@server.com", password: "password") {
email
id
}
}
`
const accessTokenQuery = gql`
query {
accessToken(email: "auth@server.com", password: "password") accessToken(email: "auth@server.com", password: "password")
} }
` `
const meQuery = gql` const meMutation = gql`
query { mutation {
me { me {
email email
} }
} }
` `
const signOutMutation = gql`
mutation {
signOut
}
`
const dateInKiloSeconds = (date: string | number) => new Date(date).getTime() / 1000000 const dateInKiloSeconds = (date: string | number) => new Date(date).getTime() / 1000000

@ -6,6 +6,7 @@ import {
contextFunction, contextFunction,
verifiedRefreshTokenPayload, verifiedRefreshTokenPayload,
} from "./server/userResolver/auth" } from "./server/userResolver/auth"
import { User } from "./server/userResolver/User"
import cookie = require("cookie") import cookie = require("cookie")
import cors = require("cors") 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 { try {
const parsedCookie = cookie.parse(req.headers.cookie!) const parsedCookie = cookie.parse(req.headers.cookie!)
const refreshPayload = verifiedRefreshTokenPayload(parsedCookie.rt) const rtPayload = verifiedRefreshTokenPayload(parsedCookie.rt)
const accessToken = accessTokenWithRefreshCookie(refreshPayload.userId, res)
await User.findOneOrFail({
where: { id: rtPayload.uid, tokenVersion: rtPayload.ver },
})
const accessToken = accessTokenWithRefreshCookie(
rtPayload.uid,
rtPayload.ver!,
res
)
res.json({ data: accessToken }) res.json({ data: accessToken })
} catch (error) { } catch (error) {

@ -3,143 +3,175 @@ import { Request, Response } from "express"
import { createConnection, getConnection } from "typeorm" import { createConnection, getConnection } from "typeorm"
import { callSchema } from "./schema" import { callSchema } from "./schema"
import { import {
initializeRollbackTransactions, initializeRollbackTransactions,
runInRollbackTransaction, runInRollbackTransaction,
testingConnectionOptions, testingConnectionOptions,
} from "./testing" } from "./testing"
import { Context, signAccessToken, verifiedAccessTokenPayload } from "./userResolver/auth" import { Context, signAccessToken, verifiedAccessTokenPayload } from "./userResolver/auth"
import { User } from "./userResolver/User" import { User } from "./userResolver/User"
describe("resolver of user", () => { describe("resolver of user", () => {
describe("createUser mutation should", () => { describe("createUser mutation should", () => {
it( it(
"return email as it creates user with mutation", "return email and default token version as it creates user with mutation",
runInRollbackTransaction(async () => { runInRollbackTransaction(async () => {
const response = await callSchema(createUserMutation) const response = await callSchema(createUserMutation)
expect(response.errors).toBeUndefined() expect(response.errors).toBeUndefined()
expect(response.data).toMatchObject({ expect(response.data).toMatchObject({
createUser: { email: "user-mutation@user-resolver.com" }, createUser: {
}) email: "user-mutation@user-resolver.com",
}) },
) })
}) })
)
describe("accessToken query should", () => { })
it(
"return error for bad password or not-existent user", describe("accessToken mutation should", () => {
runInRollbackTransaction(async () => { it(
await User.create({ "return error for bad password or not-existent user",
email: "access-token@user-resolver.com", runInRollbackTransaction(async () => {
password: "BAD-password", const nonExistenUserResponse = await callSchema(
}).save() accessTokenMutation,
contextWithCookie()
const response = await callSchema(accessTokenQuery, contextWithCookie()) )
await User.create({
expect(response.errors).not.toBeUndefined() email: "access-token@user-resolver.com",
expect(response.data).toBeNull() password: "BAD-password",
}) }).save()
)
const badPasswordResponse = await callSchema(
it( accessTokenMutation,
"return a valid access token with expiry providing good credentials", contextWithCookie()
runInRollbackTransaction(async () => { )
const oneMinute = 60
const sixteenMinutes = 60 * 16 expect(nonExistenUserResponse.errors).not.toBeUndefined()
expect(nonExistenUserResponse.data).toBeNull()
const user = await User.create({ expect(badPasswordResponse.errors).not.toBeUndefined()
email: "access-token@user-resolver.com", expect(badPasswordResponse.data).toBeNull()
password: "password", })
}).save() )
const response = await callSchema(accessTokenQuery, contextWithCookie()) it(
const accessToken: string = response.data!.accessToken "return a valid access token with expiry providing good credentials",
const jwtPayload = verifiedAccessTokenPayload(accessToken) runInRollbackTransaction(async () => {
const jwtLifetime = jwtPayload.exp! - jwtPayload.iat! const oneMinute = 60
const sixteenMinutes = 60 * 16
expect(jwtLifetime).toBeGreaterThanOrEqual(oneMinute)
expect(jwtLifetime).not.toBeGreaterThan(sixteenMinutes) const user = await User.create({
expect(jwtPayload.userId).toBe(user.id) email: "access-token@user-resolver.com",
expect(jwtPayload.ms).toBeLessThan(1000) password: "password",
expect(response.errors).toBeUndefined() }).save()
})
) const response = await callSchema(
}) accessTokenMutation,
contextWithCookie()
describe("me query should", () => { )
it( const accessToken: string = response.data!.accessToken
"return an error without a valid access token", const jwtPayload = verifiedAccessTokenPayload(accessToken)
runInRollbackTransaction(async () => { const jwtLifetime = jwtPayload.exp! - jwtPayload.iat!
const contextWithInvalidToken = contextWithAuthHeader(
"Bearer INVALID-TOKEN" expect(jwtLifetime).toBeGreaterThanOrEqual(oneMinute)
) expect(jwtLifetime).not.toBeGreaterThan(sixteenMinutes)
const response = await callSchema(meQuery, contextWithInvalidToken) expect(jwtPayload.uid).toBe(user.id)
expect(jwtPayload.msc).toBeLessThan(1000)
expect(response.errors).not.toBeUndefined() expect(response.errors).toBeUndefined()
expect(response.data).toBeNull() })
}) )
) })
it( describe("sign out mutation should", () => {
"return an user with a valid access token", it(
runInRollbackTransaction(async () => { "return true",
const user = await User.create({ runInRollbackTransaction(async () => {
email: "me-query@user-resolver.com", const response = await callSchema(signOutMutation, contextWithCookie())
}).save()
expect(response.errors).toBeUndefined()
const contextWithValidToken = contextWithAuthHeader( expect(response.data!.signOut).toBeTruthy()
"Bearer " + signAccessToken({ userId: user.id }) })
) )
const response = await callSchema(meQuery, contextWithValidToken) })
expect(response.errors).toBeUndefined() describe("me mutation should", () => {
expect(response.data).toMatchObject({ it(
me: { email: "me-query@user-resolver.com" }, "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 () => { beforeAll(async () => {
initializeRollbackTransactions() initializeRollbackTransactions()
await createConnection(testingConnectionOptions()) await createConnection(testingConnectionOptions())
}) })
afterAll(async () => { afterAll(async () => {
await getConnection().close() await getConnection().close()
}) })
const createUserMutation = gql` const createUserMutation = gql`
mutation { mutation {
createUser(email: "user-mutation@user-resolver.com", password: "password") { createUser(email: "user-mutation@user-resolver.com", password: "password") {
email email
} }
} }
` `
const accessTokenQuery = gql` const accessTokenMutation = gql`
query { mutation {
accessToken(email: "access-token@user-resolver.com", password: "password") accessToken(email: "access-token@user-resolver.com", password: "password")
} }
` `
const meQuery = gql` const meMutation = gql`
query { mutation {
me { me {
email email
} }
} }
`
const signOutMutation = gql`
mutation {
signOut
}
` `
const contextWithAuthHeader = (header: string): Context => ({ const contextWithAuthHeader = (header: string): Context => ({
req: { req: {
headers: { headers: {
authorization: header, authorization: header,
}, },
} as Request, } as Request,
res: {} as Response, res: {} as Response,
}) })
const contextWithCookie = (): Context => ({ const contextWithCookie = (): Context => ({
req: {} as Request, req: {} as Request,
res: ({ cookie: () => undefined } as unknown) as Response, res: ({ cookie: () => undefined } as unknown) as Response,
}) })

@ -1,43 +1,55 @@
import "reflect-metadata" import "reflect-metadata"
import { Arg, Authorized, Ctx, Mutation, Query } from "type-graphql" import { Arg, Authorized, Ctx, Mutation, Query } from "type-graphql"
import { import {
accessTokenWithRefreshCookie, accessTokenWithRefreshCookie,
comparePasswords, comparePasswords,
Context, Context,
createRtCookie,
} from "./userResolver/auth" } from "./userResolver/auth"
import { User } from "./userResolver/User" import { User } from "./userResolver/User"
export class UserResolver { export class UserResolver {
@Query(() => String) @Query(() => String)
async accessToken( async query() {
@Arg("email") email: string, return ""
@Arg("password") password: string, }
@Ctx() { res }: Context
) {
try {
const user = await User.findOne({ where: { email } })
await comparePasswords(user!.password, password)
return accessTokenWithRefreshCookie(user!.id, res) @Mutation(() => User)
} catch (error) { async createUser(@Arg("email") email: string, @Arg("password") password: string) {
throw new Error("Login credentials are invalid: " + error) return await User.create({
} email,
} password,
}).save()
}
@Query(() => User) @Mutation(() => String)
@Authorized() async accessToken(
async me(@Ctx() { payload }: Context) { @Arg("email") email: string,
const id = payload!.userId @Arg("password") password: string,
const user = await User.findOne({ where: { id } }) @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) @Mutation(() => User)
async createUser(@Arg("email") email: string, @Arg("password") password: string) { @Authorized()
return await User.create({ async me(@Ctx() { payload }: Context) {
email, return await User.findOne({
password, where: { id: payload!.uid },
}).save() })
} }
@Mutation(() => Boolean)
async signOut(@Ctx() { res }: Context) {
createRtCookie(res, "")
return true
}
} }

@ -6,19 +6,27 @@ import { hashPassword } from "./auth"
@ObjectType() @ObjectType()
@Entity() @Entity()
export class User extends BaseEntity { export class User extends BaseEntity {
@Field() @Field()
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn()
id!: number id!: number
@Field() @Field()
@Column() @Column()
email: string = "" email: string = ""
@Column() @Column()
password: string = "" password: string = ""
@BeforeInsert() @Column()
async hashPassword() { tokenVersion: number = 0
this.password = await hashPassword(this.password)
} @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) => { export const signAccessToken = (payload: ContextPayload) => {
const accessTokenSecret = process.env.ACCESS_SECRET as string 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), expiresIn: parseInt(process.env.ACCESS_EXPIRY as string),
}) })
} }
export const signRefreshToken = (payload: ContextPayload) => { 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), expiresIn: parseInt(process.env.REFRESH_EXPIRY as string),
}) })
} }
@ -34,26 +35,32 @@ export const signRefreshToken = (payload: ContextPayload) => {
export const verifiedAccessTokenPayload = (token: string) => { export const verifiedAccessTokenPayload = (token: string) => {
const accessTokenSecret = process.env.ACCESS_SECRET as 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) => { export const verifiedRefreshTokenPayload = (token: string) => {
const refreshTokenSecret = process.env.REFRESH_SECRET as 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) => { export const createRtCookie = (res: Response, token: string) =>
const accessToken = signAccessToken({ userId }) res.cookie("rt", token, rtCookieOptions())
export const rtCookieOptions = () => {
const refreshExpiryMs = parseInt(process.env.REFRESH_EXPIRY as string) * 1000 const refreshExpiryMs = parseInt(process.env.REFRESH_EXPIRY as string) * 1000
res.cookie("rt", signRefreshToken({ userId }), {
return {
httpOnly: true, httpOnly: true,
path: "/refresh_token", path: "/refresh_token",
expires: new Date(new Date().getTime() + refreshExpiryMs), expires: new Date(new Date().getTime() + refreshExpiryMs),
}) }
return accessToken
} }
export const customAuthChecker: AuthChecker<Context> = ({ context }) => { export const customAuthChecker: AuthChecker<Context> = ({ context }) => {
@ -61,6 +68,7 @@ export const customAuthChecker: AuthChecker<Context> = ({ context }) => {
const authHeader = context.req.headers["authorization"] const authHeader = context.req.headers["authorization"]
const accessToken = authHeader!.split(" ")[1] const accessToken = authHeader!.split(" ")[1]
const accessTokenPayload = verifiedAccessTokenPayload(accessToken) const accessTokenPayload = verifiedAccessTokenPayload(accessToken)
context.payload = accessTokenPayload as ContextPayload context.payload = accessTokenPayload as ContextPayload
return true return true
@ -78,12 +86,12 @@ export type Context = {
} }
type ContextPayload = { type ContextPayload = {
userId: number uid: number
ver?: number
} }
type JWTPayload = { type TokenPayload = ContextPayload & {
userId: number msc: number
iat: number iat?: number
exp?: number exp?: number
ms?: number
} }

Loading…
Cancel
Save