add refresh tokens feature

master
Peter Babič 5 years ago
parent 5e56e072c1
commit b2a95e3bb8
Signed by: peter.babic
GPG Key ID: 4BB075BC1884BA40
  1. 356
      package-lock.json
  2. 5
      package.json
  3. 161
      src/server.spec.ts
  4. 20
      src/server.ts
  5. 93
      src/server/UserResolver.spec.ts
  6. 29
      src/server/UserResolver.ts
  7. 9
      src/server/schema.ts
  8. 7
      src/server/userResolver/AccessToken.ts
  9. 8
      src/server/userResolver/ContextInterface.ts
  10. 1
      src/server/userResolver/User.ts
  11. 54
      src/server/userResolver/auth.ts

356
package-lock.json generated

@ -526,6 +526,12 @@
"@types/node": "*"
}
},
"@types/cookie": {
"version": "0.3.3",
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.3.3.tgz",
"integrity": "sha512-LKVP3cgXBT9RYj+t+9FDKwS5tdI+rPBXaNSkma7hvqy35lc7mAokC2zsqWJH0LaqIt3B962nuYI77hsJoT1gow==",
"dev": true
},
"@types/cookies": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/@types/cookies/-/cookies-0.7.2.tgz",
@ -781,22 +787,6 @@
"integrity": "sha512-gCubfBUZ6KxzoibJ+SCUc/57Ms1jz5NjHe4+dI2krNmU5zCPAphyLJYyTOg06ueIyfj+SaCUqmzun7ImlxDcKg==",
"dev": true
},
"@types/zen-observable": {
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/@types/zen-observable/-/zen-observable-0.8.0.tgz",
"integrity": "sha512-te5lMAWii1uEJ4FwLjzdlbw3+n0FZNOvFXHxQDKeT0dilh7HOzdMzV2TrJVUzq8ep7J4Na8OUYPRLSQkJHAlrg==",
"dev": true
},
"@wry/context": {
"version": "0.4.4",
"resolved": "https://registry.npmjs.org/@wry/context/-/context-0.4.4.tgz",
"integrity": "sha512-LrKVLove/zw6h2Md/KZyWxIkFM6AoyKp71OqpH9Hiip1csjPVoD3tPxlbQUNxEnHENks3UGgNpSBCAfq9KWuag==",
"dev": true,
"requires": {
"@types/node": ">=6",
"tslib": "^1.9.3"
}
},
"@wry/equality": {
"version": "0.1.9",
"resolved": "https://registry.npmjs.org/@wry/equality/-/equality-0.1.9.tgz",
@ -896,33 +886,6 @@
"normalize-path": "^2.1.1"
}
},
"apollo-boost": {
"version": "0.4.4",
"resolved": "https://registry.npmjs.org/apollo-boost/-/apollo-boost-0.4.4.tgz",
"integrity": "sha512-ASngBvazmp9xNxXfJ2InAzfDwz65o4lswlEPrWoN35scXmCz8Nz4k3CboUXbrcN/G0IExkRf/W7o9Rg0cjEBqg==",
"dev": true,
"requires": {
"apollo-cache": "^1.3.2",
"apollo-cache-inmemory": "^1.6.3",
"apollo-client": "^2.6.4",
"apollo-link": "^1.0.6",
"apollo-link-error": "^1.0.3",
"apollo-link-http": "^1.3.1",
"graphql-tag": "^2.4.2",
"ts-invariant": "^0.4.0",
"tslib": "^1.9.3"
}
},
"apollo-cache": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/apollo-cache/-/apollo-cache-1.3.2.tgz",
"integrity": "sha512-+KA685AV5ETEJfjZuviRTEImGA11uNBp/MJGnaCvkgr+BYRrGLruVKBv6WvyFod27WEB2sp7SsG8cNBKANhGLg==",
"dev": true,
"requires": {
"apollo-utilities": "^1.3.2",
"tslib": "^1.9.3"
}
},
"apollo-cache-control": {
"version": "0.8.4",
"resolved": "https://registry.npmjs.org/apollo-cache-control/-/apollo-cache-control-0.8.4.tgz",
@ -932,35 +895,6 @@
"graphql-extensions": "^0.10.3"
}
},
"apollo-cache-inmemory": {
"version": "1.6.3",
"resolved": "https://registry.npmjs.org/apollo-cache-inmemory/-/apollo-cache-inmemory-1.6.3.tgz",
"integrity": "sha512-S4B/zQNSuYc0M/1Wq8dJDTIO9yRgU0ZwDGnmlqxGGmFombOZb9mLjylewSfQKmjNpciZ7iUIBbJ0mHlPJTzdXg==",
"dev": true,
"requires": {
"apollo-cache": "^1.3.2",
"apollo-utilities": "^1.3.2",
"optimism": "^0.10.0",
"ts-invariant": "^0.4.0",
"tslib": "^1.9.3"
}
},
"apollo-client": {
"version": "2.6.4",
"resolved": "https://registry.npmjs.org/apollo-client/-/apollo-client-2.6.4.tgz",
"integrity": "sha512-oWOwEOxQ9neHHVZrQhHDbI6bIibp9SHgxaLRVPoGvOFy7OH5XUykZE7hBQAVxq99tQjBzgytaZffQkeWo1B4VQ==",
"dev": true,
"requires": {
"@types/zen-observable": "^0.8.0",
"apollo-cache": "1.3.2",
"apollo-link": "^1.0.0",
"apollo-utilities": "1.3.2",
"symbol-observable": "^1.0.2",
"ts-invariant": "^0.4.0",
"tslib": "^1.9.3",
"zen-observable": "^0.8.0"
}
},
"apollo-datasource": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/apollo-datasource/-/apollo-datasource-0.6.3.tgz",
@ -1022,51 +956,6 @@
"zen-observable-ts": "^0.8.20"
}
},
"apollo-link-error": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/apollo-link-error/-/apollo-link-error-1.1.12.tgz",
"integrity": "sha512-psNmHyuy3valGikt/XHJfe0pKJnRX19tLLs6P6EHRxg+6q6JMXNVLYPaQBkL0FkwdTCB0cbFJAGRYCBviG8TDA==",
"dev": true,
"requires": {
"apollo-link": "^1.2.13",
"apollo-link-http-common": "^0.2.15",
"tslib": "^1.9.3"
}
},
"apollo-link-http": {
"version": "1.5.16",
"resolved": "https://registry.npmjs.org/apollo-link-http/-/apollo-link-http-1.5.16.tgz",
"integrity": "sha512-IA3xA/OcrOzINRZEECI6IdhRp/Twom5X5L9jMehfzEo2AXdeRwAMlH5LuvTZHgKD8V1MBnXdM6YXawXkTDSmJw==",
"dev": true,
"requires": {
"apollo-link": "^1.2.13",
"apollo-link-http-common": "^0.2.15",
"tslib": "^1.9.3"
}
},
"apollo-link-http-common": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/apollo-link-http-common/-/apollo-link-http-common-0.2.15.tgz",
"integrity": "sha512-+Heey4S2IPsPyTf8Ag3PugUupASJMW894iVps6hXbvwtg1aHSNMXUYO5VG7iRHkPzqpuzT4HMBanCTXPjtGzxg==",
"dev": true,
"requires": {
"apollo-link": "^1.2.13",
"ts-invariant": "^0.4.0",
"tslib": "^1.9.3"
}
},
"apollo-server": {
"version": "2.9.3",
"resolved": "https://registry.npmjs.org/apollo-server/-/apollo-server-2.9.3.tgz",
"integrity": "sha512-JQoeseSo3yOBu3WJzju0NTreoqYckNILybgXNUOhdurE55VFpZ8dsBEO6nMfdO2y1A70W14mnnVWCBEm+1rE8w==",
"requires": {
"apollo-server-core": "^2.9.3",
"apollo-server-express": "^2.9.3",
"express": "^4.0.0",
"graphql-subscriptions": "^1.0.0",
"graphql-tools": "^4.0.0"
}
},
"apollo-server-caching": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/apollo-server-caching/-/apollo-server-caching-0.5.0.tgz",
@ -1075,34 +964,6 @@
"lru-cache": "^5.0.0"
}
},
"apollo-server-core": {
"version": "2.9.3",
"resolved": "https://registry.npmjs.org/apollo-server-core/-/apollo-server-core-2.9.3.tgz",
"integrity": "sha512-KQpOM3nAXdMqKVE0HHcOkH/EVhyDqFEKLNFlsyGHGOn9ujpI6RsltX+YpXRyAdbfQHpTk11v/IAo6XksWN+g1Q==",
"requires": {
"@apollographql/apollo-tools": "^0.4.0",
"@apollographql/graphql-playground-html": "1.6.24",
"@types/graphql-upload": "^8.0.0",
"@types/ws": "^6.0.0",
"apollo-cache-control": "^0.8.4",
"apollo-datasource": "^0.6.3",
"apollo-engine-reporting": "^1.4.6",
"apollo-server-caching": "^0.5.0",
"apollo-server-env": "^2.4.3",
"apollo-server-errors": "^2.3.3",
"apollo-server-plugin-base": "^0.6.4",
"apollo-server-types": "^0.2.4",
"apollo-tracing": "^0.8.4",
"fast-json-stable-stringify": "^2.0.0",
"graphql-extensions": "^0.10.3",
"graphql-tag": "^2.9.2",
"graphql-tools": "^4.0.0",
"graphql-upload": "^8.0.2",
"sha.js": "^2.4.11",
"subscriptions-transport-ws": "^0.9.11",
"ws": "^6.0.0"
}
},
"apollo-server-env": {
"version": "2.4.3",
"resolved": "https://registry.npmjs.org/apollo-server-env/-/apollo-server-env-2.4.3.tgz",
@ -1178,131 +1039,6 @@
"apollo-server-types": "^0.2.4"
}
},
"apollo-server-testing": {
"version": "2.9.6",
"resolved": "https://registry.npmjs.org/apollo-server-testing/-/apollo-server-testing-2.9.6.tgz",
"integrity": "sha512-pbURQD5VjNFk4GMVVxyCds9rY4/NIqjvjE4tyf1k89RHwMdk+zuVggt/DGudteorZtqAqtsOIHWojMBU4s2klA==",
"dev": true,
"requires": {
"apollo-server-core": "^2.9.6"
},
"dependencies": {
"apollo-cache-control": {
"version": "0.8.5",
"resolved": "https://registry.npmjs.org/apollo-cache-control/-/apollo-cache-control-0.8.5.tgz",
"integrity": "sha512-2yQ1vKgJQ54SGkoQS/ZLZrDX3La6cluAYYdruFYJMJtL4zQrSdeOCy11CQliCMYEd6eKNyE70Rpln51QswW2Og==",
"dev": true,
"requires": {
"apollo-server-env": "^2.4.3",
"graphql-extensions": "^0.10.4"
}
},
"apollo-engine-reporting": {
"version": "1.4.7",
"resolved": "https://registry.npmjs.org/apollo-engine-reporting/-/apollo-engine-reporting-1.4.7.tgz",
"integrity": "sha512-qsKDz9VkoctFhojM3Nj3nvRBO98t8TS2uTgtiIjUGs3Hln2poKMP6fIQ37Nm2Q2B3JJst76HQtpPwXmRJd1ZUg==",
"dev": true,
"requires": {
"apollo-engine-reporting-protobuf": "^0.4.1",
"apollo-graphql": "^0.3.4",
"apollo-server-caching": "^0.5.0",
"apollo-server-env": "^2.4.3",
"apollo-server-types": "^0.2.5",
"async-retry": "^1.2.1",
"graphql-extensions": "^0.10.4"
}
},
"apollo-engine-reporting-protobuf": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/apollo-engine-reporting-protobuf/-/apollo-engine-reporting-protobuf-0.4.1.tgz",
"integrity": "sha512-d7vFFZ2oUrvGaN0Hpet8joe2ZG0X0lIGilN+SwgVP38dJnOuadjsaYMyrD9JudGQJg0bJA5wVQfYzcCVy0slrw==",
"dev": true,
"requires": {
"protobufjs": "^6.8.6"
}
},
"apollo-graphql": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/apollo-graphql/-/apollo-graphql-0.3.4.tgz",
"integrity": "sha512-w+Az1qxePH4oQ8jvbhQBl5iEVvqcqynmU++x/M7MM5xqN1C7m1kyIzpN17gybXlTJXY4Oxej2WNURC2/hwpfYw==",
"dev": true,
"requires": {
"apollo-env": "^0.5.1",
"lodash.sortby": "^4.7.0"
}
},
"apollo-server-core": {
"version": "2.9.6",
"resolved": "https://registry.npmjs.org/apollo-server-core/-/apollo-server-core-2.9.6.tgz",
"integrity": "sha512-2tHAWQxP7HrETI/BZvg2fem6YlahF9HUp4Y6SSL95WP3uNMOJBlN12yM1y+O2u5K5e4jwdPNaLjoL2A/26XrLw==",
"dev": true,
"requires": {
"@apollographql/apollo-tools": "^0.4.0",
"@apollographql/graphql-playground-html": "1.6.24",
"@types/graphql-upload": "^8.0.0",
"@types/ws": "^6.0.0",
"apollo-cache-control": "^0.8.5",
"apollo-datasource": "^0.6.3",
"apollo-engine-reporting": "^1.4.7",
"apollo-server-caching": "^0.5.0",
"apollo-server-env": "^2.4.3",
"apollo-server-errors": "^2.3.3",
"apollo-server-plugin-base": "^0.6.5",
"apollo-server-types": "^0.2.5",
"apollo-tracing": "^0.8.5",
"fast-json-stable-stringify": "^2.0.0",
"graphql-extensions": "^0.10.4",
"graphql-tag": "^2.9.2",
"graphql-tools": "^4.0.0",
"graphql-upload": "^8.0.2",
"sha.js": "^2.4.11",
"subscriptions-transport-ws": "^0.9.11",
"ws": "^6.0.0"
}
},
"apollo-server-plugin-base": {
"version": "0.6.5",
"resolved": "https://registry.npmjs.org/apollo-server-plugin-base/-/apollo-server-plugin-base-0.6.5.tgz",
"integrity": "sha512-z2ve7HEPWmZI3EzL0iiY9qyt1i0hitT+afN5PzssCw594LB6DfUQWsI14UW+W+gcw8hvl8VQUpXByfUntAx5vw==",
"dev": true,
"requires": {
"apollo-server-types": "^0.2.5"
}
},
"apollo-server-types": {
"version": "0.2.5",
"resolved": "https://registry.npmjs.org/apollo-server-types/-/apollo-server-types-0.2.5.tgz",
"integrity": "sha512-6iJQsPh59FWu4K7ABrVmpnQVgeK8Ockx8BcawBh+saFYWTlVczwcLyGSZPeV1tPSKwFwKZutyEslrYSafcarXQ==",
"dev": true,
"requires": {
"apollo-engine-reporting-protobuf": "^0.4.1",
"apollo-server-caching": "^0.5.0",
"apollo-server-env": "^2.4.3"
}
},
"apollo-tracing": {
"version": "0.8.5",
"resolved": "https://registry.npmjs.org/apollo-tracing/-/apollo-tracing-0.8.5.tgz",
"integrity": "sha512-lZn10/GRBZUlMxVYLghLMFsGcLN0jTYDd98qZfBtxw+wEWUx+PKkZdljDT+XNoOm/kDvEutFGmi5tSLhArIzWQ==",
"dev": true,
"requires": {
"apollo-server-env": "^2.4.3",
"graphql-extensions": "^0.10.4"
}
},
"graphql-extensions": {
"version": "0.10.4",
"resolved": "https://registry.npmjs.org/graphql-extensions/-/graphql-extensions-0.10.4.tgz",
"integrity": "sha512-lE6MroluEYocbR/ICwccv39w+Pz4cBPadJ11z1rJkbZv5wstISEganbDOwl9qN21rcZGiWzh7QUNxUiFUXXEDw==",
"dev": true,
"requires": {
"@apollographql/apollo-tools": "^0.4.0",
"apollo-server-env": "^2.4.3",
"apollo-server-types": "^0.2.5"
}
}
}
},
"apollo-server-types": {
"version": "0.2.4",
"resolved": "https://registry.npmjs.org/apollo-server-types/-/apollo-server-types-0.2.4.tgz",
@ -1476,24 +1212,6 @@
"integrity": "sha512-ReZxvNHIOv88FlT7rxcXIIC0fPt4KZqZbOlivyWtXLt8ESx84zd3kMC6iK5jVeS2qt+g7ftS7ye4fi06X5rtRQ==",
"dev": true
},
"axios": {
"version": "0.19.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-0.19.0.tgz",
"integrity": "sha512-1uvKqKQta3KBxIz14F2v06AEHZ/dIoeKfbTRkK1E5oqjDnuEerLmYTgJB5AiQZHJcljpg1TuRzdjDR06qNk0DQ==",
"dev": true,
"requires": {
"follow-redirects": "1.5.10",
"is-buffer": "^2.0.2"
},
"dependencies": {
"is-buffer": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.4.tgz",
"integrity": "sha512-Kq1rokWXOPXWuaMAqZiJW4XxsmD9zGx9q4aePabbn3qCRGedtH7Cm+zV8WETitMfu1wdh+Rvd6w5egwSngUX2A==",
"dev": true
}
}
},
"babel-jest": {
"version": "24.9.0",
"resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-24.9.0.tgz",
@ -2118,6 +1836,24 @@
"vary": "^1"
}
},
"cross-fetch": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-2.2.2.tgz",
"integrity": "sha1-pH/09/xxLauo9qaVoRyUhEDUVyM=",
"dev": true,
"requires": {
"node-fetch": "2.1.2",
"whatwg-fetch": "2.0.4"
},
"dependencies": {
"node-fetch": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.1.2.tgz",
"integrity": "sha1-q4hOjn5X44qUR1POxwb3iNF2i7U=",
"dev": true
}
}
},
"cross-spawn": {
"version": "6.0.5",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz",
@ -2794,26 +2530,6 @@
"locate-path": "^3.0.0"
}
},
"follow-redirects": {
"version": "1.5.10",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.5.10.tgz",
"integrity": "sha512-0V5l4Cizzvqt5D44aTXbFZz+FtyXV1vrDN6qrelxtfYQKW0KO0W2T/hkE8xvGa/540LkZlkaUjO4ailYTFtHVQ==",
"dev": true,
"requires": {
"debug": "=3.1.0"
},
"dependencies": {
"debug": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz",
"integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==",
"dev": true,
"requires": {
"ms": "2.0.0"
}
}
}
},
"for-in": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/for-in/-/for-in-1.0.2.tgz",
@ -3510,6 +3226,15 @@
"lodash.get": "^4.4.2"
}
},
"graphql-request": {
"version": "1.8.2",
"resolved": "https://registry.npmjs.org/graphql-request/-/graphql-request-1.8.2.tgz",
"integrity": "sha512-dDX2M+VMsxXFCmUX0Vo0TopIZIX4ggzOtiCsThgtrKR4niiaagsGTDIHj3fsOMFETpa064vzovI+4YV4QnMbcg==",
"dev": true,
"requires": {
"cross-fetch": "2.2.2"
}
},
"graphql-subscriptions": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/graphql-subscriptions/-/graphql-subscriptions-1.1.0.tgz",
@ -5356,15 +5081,6 @@
"wrappy": "1"
}
},
"optimism": {
"version": "0.10.3",
"resolved": "https://registry.npmjs.org/optimism/-/optimism-0.10.3.tgz",
"integrity": "sha512-9A5pqGoQk49H6Vhjb9kPgAeeECfUDF6aIICbMDL23kDLStBn1MWk3YvcZ4xWF9CsSf6XEgvRLkXy4xof/56vVw==",
"dev": true,
"requires": {
"@wry/context": "^0.4.0"
}
},
"optimist": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/optimist/-/optimist-0.6.1.tgz",
@ -7182,6 +6898,12 @@
"iconv-lite": "0.4.24"
}
},
"whatwg-fetch": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-2.0.4.tgz",
"integrity": "sha512-dcQ1GWpOD/eEQ97k66aiEVpNnapVj90/+R+SXTPYGHpYBBypfKJEQjLrvMZ7YXbKm21gXd4NcuxUTjiv1YtLng==",
"dev": true
},
"whatwg-mimetype": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-2.3.0.tgz",

@ -25,14 +25,15 @@
"typeorm": "^0.2.18"
},
"devDependencies": {
"@types/cookie": "^0.3.3",
"@types/express": "^4.17.1",
"@types/graphql": "^14.5.0",
"@types/jest": "^24.0.18",
"@types/jsonwebtoken": "^8.3.3",
"@types/node": "^12.7.5",
"@types/node-fetch": "^2.5.2",
"apollo-boost": "^0.4.4",
"apollo-server-testing": "^2.9.6",
"cookie": "^0.4.0",
"graphql-request": "^1.8.2",
"jest": "^24.9.0",
"node-fetch": "^2.6.0",
"ts-jest": "^24.1.0",

@ -1,85 +1,108 @@
import ApolloClient, { gql } from "apollo-boost"
import { gql } from "apollo-server-express"
import { GraphQLClient, rawRequest } from "graphql-request"
import fetch from "node-fetch"
import { createConnection } from "typeorm"
import { createServer } from "./server"
import {
initializeRollbackTransactions,
runInRollbackTransaction,
testingConnectionOptions,
} from "./server/testing"
const port = 4001
beforeAll(async () => {
initializeRollbackTransactions()
await createConnection(testingConnectionOptions())
await createServer(port)
})
import { gqlToString } from "./server/schema"
import { testingConnectionOptions } from "./server/testing"
import { AccessToken } from "./server/userResolver/AccessToken"
import { verifiedRefreshTokenPayload } from "./server/userResolver/auth"
import { User } from "./server/userResolver/User"
import cookie = require("cookie")
describe("server should", () => {
it(
"handle auth user me request",
runInRollbackTransaction(async () => {
const uri = `http://localhost:${port}/graphql`
let client = new ApolloClient({ uri, fetch })
const createUserMutation = gql`
mutation {
createUser(email: "email@email.com", password: "password") {
email
}
}
`
await client.mutate({ mutation: createUserMutation })
it("perform the refresh tokens operation flawlessly", async () => {
const halfADay = (60 * 60 * 24) / 2
const fifteenDays = 60 * 60 * 24 * 15
const loginTokensQuery = gql`
query {
loginTokens(email: "email@email.com", password: "password") {
accessToken
}
}
`
const createUserResponse = await rawRequest(
gqlUri,
gqlToString(createUserMutation)
)
const userId = createUserResponse.data.createUser.id
const tokens = await client.query({ query: loginTokensQuery })
const accessToken = tokens.data.loginTokens.accessToken
const accessTokenReponse = await rawRequest(gqlUri, gqlToString(accessTokenQuery))
const accessToken: AccessToken = accessTokenReponse.data.accessToken
const headers: Headers = accessTokenReponse.headers
const cookieHeader = headers.get("set-cookie") as string
const parsedCookie = cookie.parse(cookieHeader)
const refreshCookieExpires = dateInKiloSeconds(parsedCookie.Expires)
const refreshTokenPayload = verifiedRefreshTokenPayload(parsedCookie.rt)
const jwtLifetime = refreshTokenPayload.exp! - refreshTokenPayload.iat!
const refLifetime = dateInKiloSeconds(new Date().getTime() + jwtLifetime * 1000)
client = new ApolloClient({
uri,
fetch,
request: operation => {
operation.setContext({
headers: {
authorization: "Bearer " + accessToken,
},
})
},
})
const client = new GraphQLClient(gqlUri, {
headers: {
Authorization: "Bearer " + accessToken.jwt,
},
})
const meResponse = await client.rawRequest(gqlToString(meQuery))
const meQuery = gql`
query {
me {
email
}
}
`
const response = await fetch(refreshTokenUri, {
method: "POST",
headers: { cookie: cookieHeader },
})
const jsonResponse = await response.json()
const meResponse = await client.query({ query: meQuery })
const meEmail = meResponse.data.me.email
expect(cookieHeader).toMatch(/HttpOnly/)
expect(parsedCookie.Path).toBe("/refresh_token")
expect(refreshTokenPayload.userId).toBe(userId)
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()
})
expect(meEmail).toBe("email@email.com")
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(
"receive no refresh token without auth header",
runInRollbackTransaction(async () => {
const uri = `http://localhost:${port}/refresh_token`
expect(jsonResponse.data).toBeNull()
expect(jsonResponse.errors).not.toBeUndefined()
})
})
const response = await fetch(uri, { method: "POST" })
const jsonResponse = await response.json()
beforeAll(async () => {
await createConnection(testingConnectionOptions())
await createServer(port)
})
expect(jsonResponse.data).toBeNull()
expect(jsonResponse.errors).not.toBeUndefined()
})
)
afterAll(async () => {
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`
mutation {
createUser(email: "auth@server.com", password: "password") {
email
id
}
}
`
const accessTokenQuery = gql`
query {
accessToken(email: "auth@server.com", password: "password") {
jwt
}
}
`
const meQuery = gql`
query {
me {
email
}
}
`
const dateInKiloSeconds = (date: string | number) => new Date(date).getTime() / 1000000

@ -1,7 +1,12 @@
import express = require("express")
import { ApolloServer } from "apollo-server-express"
import { createSchema } from "./server/schema"
import { contextFunction } from "./server/userResolver/auth"
import {
contextFunction,
refreshTokens,
verifiedRefreshTokenPayload,
} from "./server/userResolver/auth"
import cookie = require("cookie")
export const createServer = async (port: number) => {
const server = new ApolloServer({
@ -13,9 +18,16 @@ export const createServer = async (port: number) => {
})
const app = express()
app.post("/refresh_token", (_req, res) =>
res.send({ data: null, errors: "Invalid access token" })
)
app.post("/refresh_token", (req, res) => {
try {
const parsedCookie = cookie.parse(req.headers.cookie!)
const refreshTokenPayload = verifiedRefreshTokenPayload(parsedCookie.rt)
const accessToken = refreshTokens(refreshTokenPayload.userId, res)
res.json({ data: accessToken })
} catch (error) {
res.json({ data: null, errors: "Refresh failed: " + error })
}
})
server.applyMiddleware({ app })
app.listen({ port })

@ -7,9 +7,8 @@ import {
runInRollbackTransaction,
testingConnectionOptions,
} from "./testing"
import { signAccessToken, verifyAccessToken } from "./userResolver/auth"
import { ContextInterface } from "./userResolver/ContextInterface"
import { LoginTokens } from "./userResolver/LoginTokens"
import { AccessToken } from "./userResolver/AccessToken"
import { Context, signAccessToken, verifiedAccessTokenPayload } from "./userResolver/auth"
import { User } from "./userResolver/User"
beforeAll(async () => {
@ -28,7 +27,10 @@ describe("resolver of user", () => {
runInRollbackTransaction(async () => {
const createUserMutation = gql`
mutation {
createUser(email: "email@email.com", password: "password") {
createUser(
email: "user-mutation@user-resolver.com"
password: "password"
) {
email
}
}
@ -38,7 +40,7 @@ describe("resolver of user", () => {
expect(response.errors).toBeUndefined()
expect(response.data).toMatchObject({
createUser: { email: "email@email.com" },
createUser: { email: "user-mutation@user-resolver.com" },
})
})
)
@ -56,48 +58,42 @@ describe("resolver of user", () => {
}
`
const user = await User.create({
email: "email@email.com",
await User.create({
email: "users-query@user-resolver.com",
}).save()
const response = await callSchema(usersQuery)
expect(response.errors).toBeUndefined()
expect(response.data).toMatchObject({
users: [{ email: user.email }],
users: [{ email: "users-query@user-resolver.com" }],
})
})
)
})
describe("loginTokens query should", () => {
const loginTokensQuery = gql`
describe("accessToken query should", () => {
const accessTokenQuery = gql`
query {
loginTokens(email: "email@email.com", password: "good-password") {
accessToken
accessToken(
email: "access-token@user-resolver.com"
password: "password"
) {
jwt
jwtExpiry
}
}
`
it(
"return error for non-existent user",
runInRollbackTransaction(async () => {
const response = await callSchema(loginTokensQuery)
expect(response.errors).not.toBeUndefined()
expect(response.data).toBeNull()
})
)
it(
"return error for bad password",
"return error for bad password or not-existent user",
runInRollbackTransaction(async () => {
await User.create({
email: "email@email.com",
email: "access-token@user-resolver.com",
password: "BAD-password",
}).save()
const response = await callSchema(loginTokensQuery)
const response = await callSchema(accessTokenQuery, contextWithCookie())
expect(response.errors).not.toBeUndefined()
expect(response.data).toBeNull()
@ -105,26 +101,26 @@ describe("resolver of user", () => {
)
it(
"return a valid access token with good credentials",
"return a valid access token with expiry providing good credentials",
runInRollbackTransaction(async () => {
await User.create({
email: "email@email.com",
password: "good-password",
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(loginTokensQuery)
const accessToken = response.data!.loginTokens.accessToken
const accessTokenPayload = verifyAccessToken(accessToken)
const loginTokens = new LoginTokens()
loginTokens.accessToken = accessToken
const fifteenMinutes = 900
const accessTokenLifetime =
accessTokenPayload.exp! - accessTokenPayload.iat!
expect(accessTokenLifetime).toBe(fifteenMinutes)
expect(accessTokenPayload).toBeTruthy()
const response = await callSchema(accessTokenQuery, contextWithCookie())
const accessToken: AccessToken = response.data!.accessToken
const jwtPayload = verifiedAccessTokenPayload(accessToken.jwt)
const jwtLifetime = jwtPayload.exp! - jwtPayload.iat!
expect(jwtLifetime).toBeGreaterThanOrEqual(oneMinute)
expect(jwtLifetime).not.toBeGreaterThan(sixteenMinutes)
expect(jwtLifetime).toBe(accessToken.jwtExpiry)
expect(jwtPayload.userId).toBe(user.id)
expect(response.errors).toBeUndefined()
expect(response.data).toMatchObject({ loginTokens })
})
)
})
@ -139,7 +135,7 @@ describe("resolver of user", () => {
`
it(
"return an error without a valid jwt token",
"return an error without a valid access token",
runInRollbackTransaction(async () => {
const contextWithInvalidToken = contextWithAuthHeader(
"Bearer INVALID-TOKEN"
@ -152,10 +148,10 @@ describe("resolver of user", () => {
)
it(
"return an user with a valid jwt token",
"return an user with a valid access token",
runInRollbackTransaction(async () => {
const user = await User.create({
email: "email@email.com",
email: "me-query@user-resolver.com",
}).save()
const contextWithValidToken = contextWithAuthHeader(
@ -165,14 +161,14 @@ describe("resolver of user", () => {
expect(response.errors).toBeUndefined()
expect(response.data).toMatchObject({
me: { email: user.email },
me: { email: "me-query@user-resolver.com" },
})
})
)
})
})
const contextWithAuthHeader = (header: string): ContextInterface => ({
const contextWithAuthHeader = (header: string): Context => ({
req: {
headers: {
authorization: header,
@ -180,3 +176,8 @@ const contextWithAuthHeader = (header: string): ContextInterface => ({
} as Request,
res: {} as Response,
})
const contextWithCookie = (): Context => ({
req: {} as Request,
res: ({ cookie: () => undefined } as unknown) as Response,
})

@ -1,8 +1,7 @@
import "reflect-metadata"
import { Arg, Authorized, Ctx, Mutation, Query } from "type-graphql"
import { comparePassword, signAccessToken } from "./userResolver/auth"
import { ContextInterface } from "./userResolver/ContextInterface"
import { LoginTokens } from "./userResolver/LoginTokens"
import { AccessToken } from "./userResolver/AccessToken"
import { comparePassword, Context, refreshTokens } from "./userResolver/auth"
import { User } from "./userResolver/User"
export class UserResolver {
@ -11,11 +10,12 @@ export class UserResolver {
return await User.find()
}
@Query(() => LoginTokens)
async loginTokens(
@Query(() => AccessToken)
async accessToken(
@Arg("email") email: string,
@Arg("password") password: string
): Promise<LoginTokens> {
@Arg("password") password: string,
@Ctx() { res }: Context
) {
try {
const user = await User.findOne({ where: { email } })
@ -23,19 +23,15 @@ export class UserResolver {
throw new Error()
}
const accessToken = signAccessToken({ userId: user!.id })
return {
accessToken,
}
return refreshTokens(user!.id, res)
} catch (error) {
throw new Error("login credentials are invalid")
throw new Error("Login credentials are invalid: " + error)
}
}
@Query(() => User)
@Authorized()
async me(@Ctx() { payload }: ContextInterface) {
async me(@Ctx() { payload }: Context) {
const id = payload!.userId
const user = await User.findOne({ where: { id } })
@ -43,10 +39,7 @@ export class UserResolver {
}
@Mutation(() => User)
async createUser(
@Arg("email") email: string,
@Arg("password") password: string
): Promise<User> {
async createUser(@Arg("email") email: string, @Arg("password") password: string) {
return await User.create({
email,
password,

@ -2,19 +2,18 @@ require("dotenv").config()
import { DocumentNode, graphql, GraphQLSchema } from "graphql"
import { buildSchema } from "type-graphql"
import { UserResolver } from "./UserResolver"
import { customAuthChecker } from "./userResolver/auth"
import { ContextInterface } from "./userResolver/ContextInterface"
import { Context, customAuthChecker } from "./userResolver/auth"
let schema: GraphQLSchema
export const callSchema = async (document: DocumentNode, context?: ContextInterface) => {
export const callSchema = async (document: DocumentNode, context?: Context) => {
if (!schema) {
schema = await createSchema()
}
return graphql({
schema,
source: document.loc!.source.body,
source: gqlToString(document),
contextValue: context,
})
}
@ -24,3 +23,5 @@ export const createSchema = () =>
resolvers: [UserResolver],
authChecker: customAuthChecker,
})
export const gqlToString = (document: DocumentNode) => document.loc!.source.body as string

@ -2,7 +2,10 @@ import "reflect-metadata"
import { Field, ObjectType } from "type-graphql"
@ObjectType()
export class LoginTokens {
export class AccessToken {
@Field()
accessToken: string = ""
jwt: string = ""
@Field()
jwtExpiry: number = 0
}

@ -1,8 +0,0 @@
import { Request, Response } from "express"
import { ContextPayload } from "./auth"
export interface ContextInterface {
req: Request
res: Response
payload?: ContextPayload
}

@ -6,6 +6,7 @@ import { hashPassword } from "./auth"
@ObjectType()
@Entity()
export class User extends BaseEntity {
@Field()
@PrimaryGeneratedColumn()
id!: number

@ -1,13 +1,20 @@
import { argon2id, hash, verify as argonVerify } from "argon2"
import { Request, Response } from "express"
import { sign, verify as jwtVerify } from "jsonwebtoken"
import { AuthChecker } from "type-graphql"
import { ContextInterface } from "./ContextInterface"
import { AccessToken } from "./AccessToken"
export type ContextPayload = {
export type Context = {
req: Request
res: Response
payload?: ContextPayload
}
type ContextPayload = {
userId: number
}
type AccessTokenPayload = {
type JWTPayload = {
userId: number
iat: number
exp?: number
@ -23,21 +30,50 @@ export const signAccessToken = (payload: ContextPayload) => {
const accessTokenSecret = process.env.ACCESS_SECRET as string
return sign(payload, accessTokenSecret, {
expiresIn: process.env.ACCESS_EXP,
expiresIn: parseInt(process.env.ACCESS_EXPIRY as string),
})
}
export const signRefreshToken = (payload: ContextPayload) => {
const accessTokenSecret = process.env.REFRESH_SECRET as string
return sign(payload, accessTokenSecret, {
expiresIn: parseInt(process.env.REFRESH_EXPIRY as string),
})
}
export const verifyAccessToken = (token: string) => {
export const verifiedAccessTokenPayload = (token: string) => {
const accessTokenSecret = process.env.ACCESS_SECRET as string
return jwtVerify(token, accessTokenSecret) as AccessTokenPayload
return jwtVerify(token, accessTokenSecret) as JWTPayload
}
export const verifiedRefreshTokenPayload = (token: string) => {
const refreshTokenSecret = process.env.REFRESH_SECRET as string
return jwtVerify(token, refreshTokenSecret) as JWTPayload
}
export const refreshTokens = (userId: number, res: Response) => {
const accessToken = new AccessToken()
accessToken.jwt = signAccessToken({ userId })
accessToken.jwtExpiry = parseInt(process.env.ACCESS_EXPIRY as string)
const refreshExpiryMs = parseInt(process.env.REFRESH_EXPIRY as string) * 1000
res.cookie("rt", signRefreshToken({ userId }), {
httpOnly: true,
path: "/refresh_token",
expires: new Date(new Date().getTime() + refreshExpiryMs),
})
return accessToken
}
export const customAuthChecker: AuthChecker<ContextInterface> = ({ context }) => {
export const customAuthChecker: AuthChecker<Context> = ({ context }) => {
try {
const authHeader = context.req.headers["authorization"]
const accessToken = authHeader!.split(" ")[1]
const accessTokenPayload = verifyAccessToken(accessToken)
const accessTokenPayload = verifiedAccessTokenPayload(accessToken)
context.payload = accessTokenPayload as ContextPayload
return true
@ -46,4 +82,4 @@ export const customAuthChecker: AuthChecker<ContextInterface> = ({ context }) =>
}
}
export const contextFunction = ({ req, res }: ContextInterface) => ({ req, res })
export const contextFunction = ({ req, res }: Context) => ({ req, res })

Loading…
Cancel
Save