From b2a95e3bb842cdd29a1c38d3ee4f6fdef06ac540 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Peter=20Babi=C4=8D?= Date: Mon, 21 Oct 2019 11:17:15 +0200 Subject: [PATCH] add refresh tokens feature --- package-lock.json | 356 ++---------------- package.json | 5 +- src/server.spec.ts | 161 ++++---- src/server.ts | 20 +- src/server/UserResolver.spec.ts | 93 ++--- src/server/UserResolver.ts | 29 +- src/server/schema.ts | 9 +- .../{LoginTokens.ts => AccessToken.ts} | 7 +- src/server/userResolver/ContextInterface.ts | 8 - src/server/userResolver/User.ts | 1 + src/server/userResolver/auth.ts | 54 ++- 11 files changed, 264 insertions(+), 479 deletions(-) rename src/server/userResolver/{LoginTokens.ts => AccessToken.ts} (53%) delete mode 100644 src/server/userResolver/ContextInterface.ts diff --git a/package-lock.json b/package-lock.json index 4f17068..78fab08 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 490c6de..14bebd7 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/server.spec.ts b/src/server.spec.ts index 15afc53..fd908e7 100644 --- a/src/server.spec.ts +++ b/src/server.spec.ts @@ -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 diff --git a/src/server.ts b/src/server.ts index 2dcf6c9..191249a 100644 --- a/src/server.ts +++ b/src/server.ts @@ -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 }) diff --git a/src/server/UserResolver.spec.ts b/src/server/UserResolver.spec.ts index cf0a319..ae3321a 100644 --- a/src/server/UserResolver.spec.ts +++ b/src/server/UserResolver.spec.ts @@ -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, +}) diff --git a/src/server/UserResolver.ts b/src/server/UserResolver.ts index aa13a7a..c07d86b 100644 --- a/src/server/UserResolver.ts +++ b/src/server/UserResolver.ts @@ -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 { + @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 { + async createUser(@Arg("email") email: string, @Arg("password") password: string) { return await User.create({ email, password, diff --git a/src/server/schema.ts b/src/server/schema.ts index 74bb84e..b58ed6e 100644 --- a/src/server/schema.ts +++ b/src/server/schema.ts @@ -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 diff --git a/src/server/userResolver/LoginTokens.ts b/src/server/userResolver/AccessToken.ts similarity index 53% rename from src/server/userResolver/LoginTokens.ts rename to src/server/userResolver/AccessToken.ts index 46df569..6a0b04d 100644 --- a/src/server/userResolver/LoginTokens.ts +++ b/src/server/userResolver/AccessToken.ts @@ -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 } diff --git a/src/server/userResolver/ContextInterface.ts b/src/server/userResolver/ContextInterface.ts deleted file mode 100644 index d8c823b..0000000 --- a/src/server/userResolver/ContextInterface.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Request, Response } from "express" -import { ContextPayload } from "./auth" - -export interface ContextInterface { - req: Request - res: Response - payload?: ContextPayload -} diff --git a/src/server/userResolver/User.ts b/src/server/userResolver/User.ts index acf7697..0036806 100644 --- a/src/server/userResolver/User.ts +++ b/src/server/userResolver/User.ts @@ -6,6 +6,7 @@ import { hashPassword } from "./auth" @ObjectType() @Entity() export class User extends BaseEntity { + @Field() @PrimaryGeneratedColumn() id!: number diff --git a/src/server/userResolver/auth.ts b/src/server/userResolver/auth.ts index 39c7755..2255966 100644 --- a/src/server/userResolver/auth.ts +++ b/src/server/userResolver/auth.ts @@ -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 = ({ context }) => { +export const customAuthChecker: AuthChecker = ({ 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 = ({ context }) => } } -export const contextFunction = ({ req, res }: ContextInterface) => ({ req, res }) +export const contextFunction = ({ req, res }: Context) => ({ req, res })