diff --git a/config/paths.js b/config/paths.js index 7d98ac998..73cc43370 100644 --- a/config/paths.js +++ b/config/paths.js @@ -46,7 +46,7 @@ module.exports = { appPostCssConfig: resolveApp("config/postcss.config.js"), appJestConfig: resolveApp("config/jest.config.js"), appLoaders: resolveApp("loaders"), - appDist: resolveApp("dist"), + appDist: resolveApp("dist/static"), appPublic: resolveApp("public"), appPackageJson: resolveApp("package.json"), appSrc: resolveApp("src"), diff --git a/config/watcher.ts b/config/watcher.ts index 5e4938a90..c91874c9b 100644 --- a/config/watcher.ts +++ b/config/watcher.ts @@ -10,6 +10,12 @@ import "./env"; const config: Config = { rootDir: path.resolve(__dirname, "../src"), watchers: { + compileSchema: { + paths: ["core/server/**/*.graphql"], + executor: new CommandExecutor("npm run compile:schema", { + runOnInit: true, + }), + }, compileRelayStream: { paths: [ "core/client/stream/**/*.ts", @@ -49,7 +55,7 @@ const config: Config = { }, defaultSet: "client", sets: { - server: ["runServer"], + server: ["compileSchema", "runServer"], client: [ "runServer", "runWebpackDevServer", @@ -57,7 +63,7 @@ const config: Config = { "compileRelayStream", ], docz: ["runDocz", "compileCSSTypes"], - compile: ["compileCSSTypes", "compileRelayStream"], + compile: ["compileSchema", "compileCSSTypes", "compileRelayStream"], }, }; diff --git a/config/webpack.config.prod.js b/config/webpack.config.prod.js index 4980d73be..d1ad40d3b 100644 --- a/config/webpack.config.prod.js +++ b/config/webpack.config.prod.js @@ -39,7 +39,7 @@ if (env.stringified["process.env"].NODE_ENV !== '"production"') { // because of this bug https://github.com/webpack-contrib/extract-text-webpack-plugin/issues/763. // TODO: Repalce with mini-css-extract-plugin once it supports HMR. // https://github.com/webpack-contrib/mini-css-extract-plugin -const cssFilename = "static/css/[name].[md5:contenthash:hex:20].css"; +const cssFilename = "assets/css/[name].[md5:contenthash:hex:20].css"; // ExtractTextPlugin expects the build output to be flat. // (See https://github.com/webpack-contrib/extract-text-webpack-plugin/issues/27) @@ -72,8 +72,8 @@ module.exports = { // Generated JS file names (with nested folders). // There will be one main bundle, and one file per asynchronous chunk. // We don't currently advertise code splitting but Webpack supports it. - filename: "static/js/[name].[chunkhash:8].js", - chunkFilename: "static/js/[name].[chunkhash:8].chunk.js", + filename: "assets/js/[name].[chunkhash:8].js", + chunkFilename: "assets/js/[name].[chunkhash:8].chunk.js", // We inferred the "public path" (such as / or /my-project) from homepage. publicPath: publicPath, // Point sourcemap entries to original disk location (format as URL on Windows) @@ -210,7 +210,7 @@ module.exports = { loader: require.resolve("url-loader"), options: { limit: 10000, - name: "static/media/[name].[hash:8].[ext]", + name: "assets/media/[name].[hash:8].[ext]", }, }, // Process JS with Babel. @@ -299,7 +299,7 @@ module.exports = { exclude: [/\.(js|jsx|mjs|ts|tsx)$/, /\.html$/, /\.json$/], loader: require.resolve("file-loader"), options: { - name: "static/media/[name].[hash:8].[ext]", + name: "assets/media/[name].[hash:8].[ext]", }, }, ], diff --git a/package-lock.json b/package-lock.json index d65ee55d8..5dfcb6af0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1537,6 +1537,12 @@ "integrity": "sha512-D7VxhADdZbDJ0HjUTMnSQ5xIGb4H2yWpg8k9Sf1T08zfFiQYlaxM8LZydpR4FQ2E6LZJX8IlabNZ5io4vdChwg==", "dev": true }, + "@types/bcryptjs": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.1.tgz", + "integrity": "sha512-CVJ8ExtzUQJzLJbEk/lWrHD3MTvstTodjWidcH23gCii5WSD0z1TPSLqSdtbn5eCDw+DxfKgoUALi+loe8ftXA==", + "dev": true + }, "@types/bluebird": { "version": "3.5.21", "resolved": "https://registry.npmjs.org/@types/bluebird/-/bluebird-3.5.21.tgz", @@ -1547,7 +1553,6 @@ "version": "1.17.0", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.17.0.tgz", "integrity": "sha512-a2+YeUjPkztKJu5aIF2yArYFQQp8d51wZ7DavSHjFuY1mqVgidGyzEQ41JIVNy82fXj8yPgy2vJmfIywgESW6w==", - "dev": true, "requires": { "@types/connect": "*", "@types/node": "*" @@ -1607,7 +1612,6 @@ "version": "3.4.32", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.32.tgz", "integrity": "sha512-4r8qa0quOvh7lGD0pre62CAb1oni1OO6ecJLGCezTmhQ8Fz50Arx9RUszryR8KlgK6avuSXvviL6yWyViQABOg==", - "dev": true, "requires": { "@types/node": "*" } @@ -1658,31 +1662,45 @@ "@types/events": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@types/events/-/events-1.2.0.tgz", - "integrity": "sha512-KEIlhXnIutzKwRbQkGWb/I4HFqBuUykAdHgDED6xqwXJfONCjF5VoE0cXEiurh3XauygxzeDzgtXUqvLkxFzzA==", - "dev": true + "integrity": "sha512-KEIlhXnIutzKwRbQkGWb/I4HFqBuUykAdHgDED6xqwXJfONCjF5VoE0cXEiurh3XauygxzeDzgtXUqvLkxFzzA==" }, "@types/express": { "version": "4.16.0", "resolved": "https://registry.npmjs.org/@types/express/-/express-4.16.0.tgz", "integrity": "sha512-TtPEYumsmSTtTetAPXlJVf3kEqb6wZK0bZojpJQrnD/djV4q1oB6QQ8aKvKqwNPACoe02GNiy5zDzcYivR5Z2w==", - "dev": true, "requires": { "@types/body-parser": "*", "@types/express-serve-static-core": "*", "@types/serve-static": "*" } }, + "@types/express-jwt": { + "version": "0.0.34", + "resolved": "https://registry.npmjs.org/@types/express-jwt/-/express-jwt-0.0.34.tgz", + "integrity": "sha1-/b7kxq9cCiRu8qkz9VGZc8dxfwI=", + "requires": { + "@types/express": "*", + "@types/express-unless": "*" + } + }, "@types/express-serve-static-core": { "version": "4.16.0", "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.16.0.tgz", "integrity": "sha512-lTeoCu5NxJU4OD9moCgm0ESZzweAx0YqsAcab6OB0EB3+As1OaHtKnaGJvcngQxYsi9UNv0abn4/DRavrRxt4w==", - "dev": true, "requires": { "@types/events": "*", "@types/node": "*", "@types/range-parser": "*" } }, + "@types/express-unless": { + "version": "0.0.32", + "resolved": "https://registry.npmjs.org/@types/express-unless/-/express-unless-0.0.32.tgz", + "integrity": "sha512-6YpJyFNlDDnPnRjMOvJCoDYlSDDmG/OEEUsPk7yhNkL4G9hUYtgab6vi1CcWsGSSSM0CsvNlWTG+ywAGnvF03g==", + "requires": { + "@types/express": "*" + } + }, "@types/graphql": { "version": "0.13.3", "resolved": "https://registry.npmjs.org/@types/graphql/-/graphql-0.13.3.tgz", @@ -1723,6 +1741,15 @@ "parse5": "^4.0.0" } }, + "@types/jsonwebtoken": { + "version": "7.2.8", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-7.2.8.tgz", + "integrity": "sha512-XENN3YzEB8D6TiUww0O8SRznzy1v+77lH7UmuN54xq/IHIsyWjWOzZuFFTtoiRuaE782uAoRwBe/wwow+vQXZw==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/lodash": { "version": "4.14.111", "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.111.tgz", @@ -1738,8 +1765,7 @@ "@types/mime": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-2.0.0.tgz", - "integrity": "sha512-A2TAGbTFdBw9azHbpVd+/FkdW2T6msN1uct1O9bH3vTerEHKZhTXJUQXy+hNq1B0RagfU8U+KBdqiZpxjhOUQA==", - "dev": true + "integrity": "sha512-A2TAGbTFdBw9azHbpVd+/FkdW2T6msN1uct1O9bH3vTerEHKZhTXJUQXy+hNq1B0RagfU8U+KBdqiZpxjhOUQA==" }, "@types/mongodb": { "version": "3.1.1", @@ -1755,18 +1781,58 @@ "@types/node": { "version": "10.5.2", "resolved": "https://registry.npmjs.org/@types/node/-/node-10.5.2.tgz", - "integrity": "sha512-m9zXmifkZsMHZBOyxZWilMwmTlpC8x5Ty360JKTiXvlXZfBWYpsg9ZZvP/Ye+iZUh+Q+MxDLjItVTWIsfwz+8Q==", - "dev": true + "integrity": "sha512-m9zXmifkZsMHZBOyxZWilMwmTlpC8x5Ty360JKTiXvlXZfBWYpsg9ZZvP/Ye+iZUh+Q+MxDLjItVTWIsfwz+8Q==" + }, + "@types/oauth": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@types/oauth/-/oauth-0.9.0.tgz", + "integrity": "sha512-1oouefxKPGiDkb5m6lNxDkFry3PItCOJ+tlNtEn/gRvWShb2Rb3y0pccOIGwN/AwHUpwsuwlRwSpg7aoCN3bQQ==", + "dev": true, + "requires": { + "@types/node": "*" + } }, "@types/passport": { - "version": "0.4.5", - "resolved": "https://registry.npmjs.org/@types/passport/-/passport-0.4.5.tgz", - "integrity": "sha512-Ow5akVXwEZlOPCWGbEGy0GX4ocdwKz7JJH1K+BMd/BSOxmJTo2obH2AKbsgcncQvw5z7AGopdIu1Ap/j9sMRnQ==", + "version": "0.4.6", + "resolved": "https://registry.npmjs.org/@types/passport/-/passport-0.4.6.tgz", + "integrity": "sha512-P7TxrdpAze3nvHghYPeLlHkYcFDiIkRBbp7xYz2ehX9zmi1yr/qWQMTpXsMxN5w3ESJpMzn917inK4giASaDcQ==", "dev": true, "requires": { "@types/express": "*" } }, + "@types/passport-local": { + "version": "1.0.33", + "resolved": "https://registry.npmjs.org/@types/passport-local/-/passport-local-1.0.33.tgz", + "integrity": "sha512-+rn6ZIxje0jZ2+DAiWFI8vGG7ZFKB0hXx2cUdMmudSWsigSq6ES7Emso46r4HJk0qCgrZVfI8sJiM7HIYf4SbA==", + "dev": true, + "requires": { + "@types/express": "*", + "@types/passport": "*", + "@types/passport-strategy": "*" + } + }, + "@types/passport-oauth2": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@types/passport-oauth2/-/passport-oauth2-1.4.5.tgz", + "integrity": "sha512-q/pT4RKkiHU1W20P2qUAtVmua3bVF1b8Tulag/niDISS+8CGrRthmQxvPhkIVtJb68ssCxn8ck+eagInGuKHDQ==", + "dev": true, + "requires": { + "@types/express": "*", + "@types/oauth": "*", + "@types/passport": "*" + } + }, + "@types/passport-strategy": { + "version": "0.2.33", + "resolved": "https://registry.npmjs.org/@types/passport-strategy/-/passport-strategy-0.2.33.tgz", + "integrity": "sha512-tmj//XbNqCWmD+PJ/KnxAouircAmMGLN9IHBO3utH5DXuHHHYN4ZG53DRrQBjlZMiS/1b5IP38U2ay1GfbcQrQ==", + "dev": true, + "requires": { + "@types/express": "*", + "@types/passport": "*" + } + }, "@types/query-string": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/@types/query-string/-/query-string-6.1.0.tgz", @@ -1776,8 +1842,7 @@ "@types/range-parser": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.2.tgz", - "integrity": "sha512-HtKGu+qG1NPvYe1z7ezLsyIaXYyi8SoAVqWDZgDQ8dLrsZvSzUNCwZyfX33uhWxL/SU0ZDQZ3nwZ0nimt507Kw==", - "dev": true + "integrity": "sha512-HtKGu+qG1NPvYe1z7ezLsyIaXYyi8SoAVqWDZgDQ8dLrsZvSzUNCwZyfX33uhWxL/SU0ZDQZ3nwZ0nimt507Kw==" }, "@types/react": { "version": "16.4.2", @@ -1848,7 +1913,6 @@ "version": "1.13.2", "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.13.2.tgz", "integrity": "sha512-/BZ4QRLpH/bNYgZgwhKEh+5AsboDBcUdlBYgzoLX0fpj3Y2gp6EApyOlM3bK53wQS/OE1SrdSYBAbux2D1528Q==", - "dev": true, "requires": { "@types/express-serve-static-core": "*", "@types/mime": "*" @@ -2618,8 +2682,7 @@ "asn1": { "version": "0.2.3", "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.3.tgz", - "integrity": "sha1-2sh4dxPJlmhJ/IGAd36+nB3fO4Y=", - "dev": true + "integrity": "sha1-2sh4dxPJlmhJ/IGAd36+nB3fO4Y=" }, "asn1.js": { "version": "4.10.1", @@ -2661,8 +2724,7 @@ "assert-plus": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", - "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=", - "dev": true + "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=" }, "assign-symbols": { "version": "1.0.0", @@ -2705,8 +2767,7 @@ "asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=", - "dev": true + "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=" }, "atob": { "version": "2.1.1", @@ -2739,14 +2800,12 @@ "aws-sign2": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", - "integrity": "sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg=", - "dev": true + "integrity": "sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg=" }, "aws4": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.7.0.tgz", - "integrity": "sha512-32NDda82rhwD9/JBCCkB+MRYDp0oSvlo2IL6rQWA10PQi7tDUM3eqMSltXmY+Oyl/7N3P3qNtAlv7X0d9bI28w==", - "dev": true + "integrity": "sha512-32NDda82rhwD9/JBCCkB+MRYDp0oSvlo2IL6rQWA10PQi7tDUM3eqMSltXmY+Oyl/7N3P3qNtAlv7X0d9bI28w==" }, "b3b": { "version": "0.0.1", @@ -4585,12 +4644,16 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", "integrity": "sha1-pDAdOJtqQ/m2f/PKEaP2Y342Dp4=", - "dev": true, "optional": true, "requires": { "tweetnacl": "^0.14.3" } }, + "bcryptjs": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", + "integrity": "sha1-mrVie5PmBiH/fNrF2pczAn3x0Ms=" + }, "big.js": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/big.js/-/big.js-3.2.0.tgz", @@ -4892,6 +4955,11 @@ "isarray": "^1.0.0" } }, + "buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha1-+OcRMvf/5uAaXJaXpMbz5I1cyBk=" + }, "buffer-from": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.0.tgz", @@ -5114,8 +5182,7 @@ "caseless": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", - "integrity": "sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw=", - "dev": true + "integrity": "sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw=" }, "ccount": { "version": "1.0.3", @@ -5523,8 +5590,7 @@ "co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", - "integrity": "sha1-bqa989hTrlTMuOR7+gvz+QMfsYQ=", - "dev": true + "integrity": "sha1-bqa989hTrlTMuOR7+gvz+QMfsYQ=" }, "coa": { "version": "1.0.4", @@ -5613,7 +5679,6 @@ "version": "1.0.6", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.6.tgz", "integrity": "sha1-cj599ugBrFYTETp+RFqbactjKBg=", - "dev": true, "requires": { "delayed-stream": "~1.0.0" } @@ -5856,8 +5921,7 @@ "core-util-is": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", - "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=", - "dev": true + "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=" }, "cosmiconfig": { "version": "3.1.0", @@ -6628,7 +6692,6 @@ "version": "1.14.1", "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", "integrity": "sha1-hTz6D3y+L+1d4gMmuN1YEDX24vA=", - "dev": true, "requires": { "assert-plus": "^1.0.0" } @@ -6837,8 +6900,7 @@ "delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=", - "dev": true + "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=" }, "delegate": { "version": "3.2.0", @@ -7917,12 +7979,19 @@ "version": "0.1.1", "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.1.tgz", "integrity": "sha1-D8c6ntXw1Tw4GTOYUj735UN3dQU=", - "dev": true, "optional": true, "requires": { "jsbn": "~0.1.0" } }, + "ecdsa-sig-formatter": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.10.tgz", + "integrity": "sha1-HFlQAPBKiJffuFAAiSoPTDOvhsM=", + "requires": { + "safe-buffer": "^5.0.1" + } + }, "ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -8532,8 +8601,7 @@ "extend": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.1.tgz", - "integrity": "sha1-p1Xqe8Gt/MWjHOfnYtuq3F5jZEQ=", - "dev": true + "integrity": "sha1-p1Xqe8Gt/MWjHOfnYtuq3F5jZEQ=" }, "extend-shallow": { "version": "3.0.2", @@ -8647,8 +8715,7 @@ "extsprintf": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", - "integrity": "sha1-lpGEQOMEGnpBT4xS48V06zw+HgU=", - "dev": true + "integrity": "sha1-lpGEQOMEGnpBT4xS48V06zw+HgU=" }, "facepaint": { "version": "1.2.1", @@ -9012,14 +9079,12 @@ "forever-agent": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", - "integrity": "sha1-+8cfDEGt6zf5bFd60e1C2P2sypE=", - "dev": true + "integrity": "sha1-+8cfDEGt6zf5bFd60e1C2P2sypE=" }, "form-data": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.2.tgz", "integrity": "sha1-SXBJi+YEwgwAXU9cI67NIda0kJk=", - "dev": true, "requires": { "asynckit": "^0.4.0", "combined-stream": "1.0.6", @@ -9707,7 +9772,6 @@ "version": "0.1.7", "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", "integrity": "sha1-Xv+OPmhNVprkyysSgmBOi6YhSfo=", - "dev": true, "requires": { "assert-plus": "^1.0.0" } @@ -10049,6 +10113,15 @@ "cross-fetch": "2.0.0" } }, + "graphql-schema-typescript": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/graphql-schema-typescript/-/graphql-schema-typescript-1.2.1.tgz", + "integrity": "sha512-ipZh3Epm/Kqcy6MF5FM6uxwCMFok07q+6qyxFOa7ViRufcjzH9Y3nECmECH5WgqRGl2wR6TmskbZd5qJJrGpoA==", + "dev": true, + "requires": { + "yargs": "^11.0.0" + } + }, "graphql-subscriptions": { "version": "0.5.8", "resolved": "https://registry.npmjs.org/graphql-subscriptions/-/graphql-subscriptions-0.5.8.tgz", @@ -10208,14 +10281,12 @@ "har-schema": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz", - "integrity": "sha1-qUwiJOvKwEeCoNkDVSHyRzW37JI=", - "dev": true + "integrity": "sha1-qUwiJOvKwEeCoNkDVSHyRzW37JI=" }, "har-validator": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/har-validator/-/har-validator-5.0.3.tgz", "integrity": "sha1-ukAsJmGU8VlW7xXg/PJCmT9qff0=", - "dev": true, "requires": { "ajv": "^5.1.0", "har-schema": "^2.0.0" @@ -10225,7 +10296,6 @@ "version": "5.5.2", "resolved": "https://registry.npmjs.org/ajv/-/ajv-5.5.2.tgz", "integrity": "sha1-c7Xuyj+rZT49P5Qis0GtQiBdyWU=", - "dev": true, "requires": { "co": "^4.6.0", "fast-deep-equal": "^1.0.0", @@ -10236,14 +10306,12 @@ "fast-deep-equal": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-1.1.0.tgz", - "integrity": "sha1-wFNHeBfIa1HaqFPIHgWbcz0CNhQ=", - "dev": true + "integrity": "sha1-wFNHeBfIa1HaqFPIHgWbcz0CNhQ=" }, "json-schema-traverse": { "version": "0.3.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.3.1.tgz", - "integrity": "sha1-NJptRMU6Ud6JtAgFxdXlm0F9M0A=", - "dev": true + "integrity": "sha1-NJptRMU6Ud6JtAgFxdXlm0F9M0A=" } } }, @@ -10622,7 +10690,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz", "integrity": "sha1-muzZJRFHcvPZW2WmCruPfBj7rOE=", - "dev": true, "requires": { "assert-plus": "^1.0.0", "jsprim": "^1.2.2", @@ -11499,8 +11566,7 @@ "is-typedarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", - "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=", - "dev": true + "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=" }, "is-utf8": { "version": "0.2.1", @@ -11595,8 +11661,7 @@ "isstream": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", - "integrity": "sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo=", - "dev": true + "integrity": "sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo=" }, "istanbul-api": { "version": "1.3.1", @@ -13489,7 +13554,6 @@ "version": "0.1.1", "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", "integrity": "sha1-peZUwuWi3rXyAdls77yoDA7y9RM=", - "dev": true, "optional": true }, "jsdom": { @@ -13553,8 +13617,7 @@ "json-schema": { "version": "0.2.3", "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.2.3.tgz", - "integrity": "sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM=", - "dev": true + "integrity": "sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM=" }, "json-schema-traverse": { "version": "0.4.1", @@ -13565,8 +13628,7 @@ "json-stringify-safe": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", - "integrity": "sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=", - "dev": true + "integrity": "sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=" }, "json3": { "version": "3.3.2", @@ -13596,11 +13658,33 @@ "integrity": "sha1-LHS27kHZPKUbe1qu6PUDYx0lKnM=", "dev": true }, + "jsonwebtoken": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-8.3.0.tgz", + "integrity": "sha512-oge/hvlmeJCH+iIz1DwcO7vKPkNGJHhgkspk8OH3VKlw+mbi42WtD4ig1+VXRln765vxptAv+xT26Fd3cteqag==", + "requires": { + "jws": "^3.1.5", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1" + }, + "dependencies": { + "ms": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.1.tgz", + "integrity": "sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==" + } + } + }, "jsprim": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.1.tgz", "integrity": "sha1-MT5mvB5cwG5Di8G3SZwuXFastqI=", - "dev": true, "requires": { "assert-plus": "1.0.0", "extsprintf": "1.3.0", @@ -13614,6 +13698,38 @@ "integrity": "sha512-mJVp13Ix6gFo3SBAy9U/kL+oeZqzlYYYLQBwXVBlVzIsZwBqGREnOro24oC/8s8aox+rJhtZ2DiQof++IrkA+g==", "dev": true }, + "jwa": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.1.6.tgz", + "integrity": "sha512-tBO/cf++BUsJkYql/kBbJroKOgHWEigTKBAjjBEmrMGYd1QMBC74Hr4Wo2zCZw6ZrVhlJPvoMrkcOnlWR/DJfw==", + "requires": { + "buffer-equal-constant-time": "1.0.1", + "ecdsa-sig-formatter": "1.0.10", + "safe-buffer": "^5.0.1" + } + }, + "jwks-rsa": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/jwks-rsa/-/jwks-rsa-1.3.0.tgz", + "integrity": "sha512-9q+d5VffK/FvFAjuXoddrq7zQybFSINV4mcwJJExGKXGyjWWpTt3vsn/aX33aB0heY02LK0qSyicdtRK0gVTig==", + "requires": { + "@types/express-jwt": "0.0.34", + "debug": "^2.2.0", + "limiter": "^1.1.0", + "lru-memoizer": "^1.6.0", + "ms": "^2.0.0", + "request": "^2.73.0" + } + }, + "jws": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.1.5.tgz", + "integrity": "sha512-GsCSexFADNQUr8T5HPJvayTjvPIfoyJPtLQBwn5a4WZQchcrPMPMAWcC1AzJVRDKyD6ZPROPAxgv6rfHViO4uQ==", + "requires": { + "jwa": "^1.1.5", + "safe-buffer": "^5.0.1" + } + }, "keygrip": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/keygrip/-/keygrip-1.0.2.tgz", @@ -13855,6 +13971,11 @@ "type-check": "~0.3.2" } }, + "limiter": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/limiter/-/limiter-1.1.3.tgz", + "integrity": "sha512-zrycnIMsLw/3ZxTbW7HCez56rcFGecWTx5OZNplzcXUUmJLmoYArC6qdJzmAN5BWiNXGcpjhF9RQ1HSv5zebEw==" + }, "load-cfg": { "version": "0.5.6", "resolved": "https://registry.npmjs.org/load-cfg/-/load-cfg-0.5.6.tgz", @@ -13980,6 +14101,11 @@ "path-exists": "^3.0.0" } }, + "lock": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/lock/-/lock-0.1.4.tgz", + "integrity": "sha1-/sfervF+fDoKVeHaBCgD4l2RdF0=" + }, "lodash": { "version": "4.17.10", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.10.tgz", @@ -14061,11 +14187,41 @@ "integrity": "sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk=", "dev": true }, + "lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha1-YLuYqHy5I8aMoeUTJUgzFISfVT8=" + }, + "lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha1-bC4XHbKiV82WgC/UOwGyDV9YcPY=" + }, "lodash.isempty": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/lodash.isempty/-/lodash.isempty-4.4.0.tgz", "integrity": "sha1-b4bL7di+TsmHvpqvM8loTbGzHn4=" }, + "lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha1-YZwK89A/iwTDH1iChAt3sRzWg0M=" + }, + "lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha1-POdoEMWSjQM1IwGsKHMX8RwLH/w=" + }, + "lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha1-fFJqUtibRcRcxpC4gWO+BJf1UMs=" + }, + "lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha1-1SfftUVuynzJu5XV2ur4i6VKVFE=" + }, "lodash.iteratee": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/lodash.iteratee/-/lodash.iteratee-4.7.0.tgz", @@ -14088,6 +14244,11 @@ "resolved": "https://registry.npmjs.org/lodash.noop/-/lodash.noop-3.0.1.tgz", "integrity": "sha1-OBiPTWUKOkdCWEObluxFsyYXEzw=" }, + "lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha1-DdOXEhPHxW34gJd9UEyI+0cal6w=" + }, "lodash.partial": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/lodash.partial/-/lodash.partial-4.2.1.tgz", @@ -14245,6 +14406,28 @@ "yallist": "^2.1.2" } }, + "lru-memoizer": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/lru-memoizer/-/lru-memoizer-1.12.0.tgz", + "integrity": "sha1-7+ZXBsyKnMZT+A8NWm6jitlQ41I=", + "requires": { + "lock": "~0.1.2", + "lodash": "^4.17.4", + "lru-cache": "~4.0.0", + "very-fast-args": "^1.1.0" + }, + "dependencies": { + "lru-cache": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-4.0.2.tgz", + "integrity": "sha1-HRdnnAac2l0ECZGgnbwsDbN35V4=", + "requires": { + "pseudomap": "^1.0.1", + "yallist": "^2.0.0" + } + } + } + }, "luxon": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/luxon/-/luxon-1.3.1.tgz", @@ -15176,11 +15359,15 @@ "integrity": "sha512-Zt6HRR6RcJkuj5/N9zeE7FN6YitRW//hK2wTOwX274IBphbY3Zf5+yn5mZ9v/SzAOTMjQNxZf9KkmPLWn0cV4g==", "dev": true }, + "oauth": { + "version": "0.9.15", + "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.9.15.tgz", + "integrity": "sha1-vR/vr2hslrdUda7VGWQS/2DPucE=" + }, "oauth-sign": { "version": "0.8.2", "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.8.2.tgz", - "integrity": "sha1-Rqarfwrq2N6unsBWV4C31O/rnUM=", - "dev": true + "integrity": "sha1-Rqarfwrq2N6unsBWV4C31O/rnUM=" }, "object-assign": { "version": "4.1.1", @@ -15638,6 +15825,25 @@ "pause": "0.0.1" } }, + "passport-local": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/passport-local/-/passport-local-1.0.0.tgz", + "integrity": "sha1-H+YyaMkudWBmJkN+O5BmYsFbpu4=", + "requires": { + "passport-strategy": "1.x.x" + } + }, + "passport-oauth2": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/passport-oauth2/-/passport-oauth2-1.4.0.tgz", + "integrity": "sha1-9i+BWDy+EmCb585vFguTlaJ7hq0=", + "requires": { + "oauth": "0.9.x", + "passport-strategy": "1.x.x", + "uid2": "0.0.x", + "utils-merge": "1.x.x" + } + }, "passport-strategy": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", @@ -18178,8 +18384,7 @@ "pseudomap": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz", - "integrity": "sha1-8FKijacOYYkX7wqKw0wa5aaChrM=", - "dev": true + "integrity": "sha1-8FKijacOYYkX7wqKw0wa5aaChrM=" }, "psl": { "version": "1.1.28", @@ -19664,7 +19869,6 @@ "version": "2.87.0", "resolved": "https://registry.npmjs.org/request/-/request-2.87.0.tgz", "integrity": "sha512-fcogkm7Az5bsS6Sl0sibkbhcKsnyon/jV1kF3ajGmF0c8HrttdKTPRT9hieOaQHA5HEq6r8OyWOo/o781C1tNw==", - "dev": true, "requires": { "aws-sign2": "~0.7.0", "aws4": "^1.6.0", @@ -19691,14 +19895,12 @@ "punycode": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", - "integrity": "sha1-wNWmOycYgArY4esPpSachN1BhF4=", - "dev": true + "integrity": "sha1-wNWmOycYgArY4esPpSachN1BhF4=" }, "tough-cookie": { "version": "2.3.4", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.3.4.tgz", "integrity": "sha512-TZ6TTfI5NtZnuyy/Kecv+CnoROnyXn2DN97LontgQpCwsX2XyLYCC0ENhYkehSOwAp8rTQKc/NUIF7BkQ5rKLA==", - "dev": true, "requires": { "punycode": "^1.4.1" } @@ -19954,8 +20156,7 @@ "safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, "samsam": { "version": "1.3.0", @@ -20571,7 +20772,6 @@ "version": "1.14.2", "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.14.2.tgz", "integrity": "sha1-xvxhZIo9nE52T9P8306hBeSSupg=", - "dev": true, "requires": { "asn1": "~0.2.3", "assert-plus": "^1.0.0", @@ -21586,7 +21786,6 @@ "version": "0.6.0", "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", "integrity": "sha1-J6XeoGs2sEoKmWZ3SykIaPD8QP0=", - "dev": true, "requires": { "safe-buffer": "^5.0.1" } @@ -21595,7 +21794,6 @@ "version": "0.14.5", "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", "integrity": "sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=", - "dev": true, "optional": true }, "type-check": { @@ -21904,6 +22102,11 @@ } } }, + "uid2": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/uid2/-/uid2-0.0.3.tgz", + "integrity": "sha1-SDEm4Rd03y9xuLY53NeZw3YWK4I=" + }, "ulid": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/ulid/-/ulid-2.3.0.tgz", @@ -22397,13 +22600,17 @@ "version": "1.10.0", "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", "integrity": "sha1-OhBcoXBTr1XW4nDB+CiGguGNpAA=", - "dev": true, "requires": { "assert-plus": "^1.0.0", "core-util-is": "1.0.2", "extsprintf": "^1.2.0" } }, + "very-fast-args": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/very-fast-args/-/very-fast-args-1.1.0.tgz", + "integrity": "sha1-4W0dH6+KbllqJGQh/ZCneWPQs5Y=" + }, "vfile": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/vfile/-/vfile-2.3.0.tgz", @@ -23229,8 +23436,7 @@ "yallist": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/yallist/-/yallist-2.1.2.tgz", - "integrity": "sha1-HBH5IY8HYImkfdUS+TxmmaaoHVI=", - "dev": true + "integrity": "sha1-HBH5IY8HYImkfdUS+TxmmaaoHVI=" }, "yargs": { "version": "11.0.0", diff --git a/package.json b/package.json index 46201f070..58a713a79 100644 --- a/package.json +++ b/package.json @@ -3,46 +3,53 @@ "version": "5.0.0", "description": "A better commenting experience from Mozilla, The Washington Post, and The New York Times.", "scripts": { - "start": "node dist/index.js", - "test": "node scripts/test.js --env=jsdom", "build": "npm-run-all compile --parallel build:*", "build:client": "node ./scripts/build.js", "build:server": "tsc -p ./src/tsconfig.json", - "watch": "cross-env NODE_ENV=development ts-node ./scripts/watcher/bin/watcher.ts --config ./config/watcher.ts", "compile": "npm-run-all --parallel compile:*", "compile:css-types": "tcm src/core/client/", "compile:relay-stream": "ts-node ./scripts/compileRelay --src ./src/core/client/stream --schema tenant", - "start:development": "cross-env NODE_ENV=development ts-node --project ./src/tsconfig.json -r tsconfig-paths/register ./src/index.ts", - "start:webpackDevServer": "node ./scripts/start.js", + "compile:schema": "node ./scripts/generateSchemaTypes.js", + "docz": "docz", "lint-fix": "npm run lint:server -- --fix && npm run lint:client -- --fix && npm run lint:scripts -- --fix", - "lint": "npm-run-all --parallel lint:*", - "lint:server": "tslint --project ./src/tsconfig.json", "lint:client": "tslint --project ./src/core/client/tsconfig.json", "lint:scripts": "tslint --project ./tsconfig.json", - "docz": "docz" + "lint:server": "tslint --project ./src/tsconfig.json", + "lint": "npm-run-all --parallel lint:*", + "start:development": "cross-env NODE_ENV=development ts-node --project ./src/tsconfig.json -r tsconfig-paths/register ./src/index.ts", + "start:webpackDevServer": "node ./scripts/start.js", + "start": "node dist/index.js", + "test": "node scripts/test.js --env=jsdom", + "watch": "cross-env NODE_ENV=development ts-node ./scripts/watcher/bin/watcher.ts --config ./config/watcher.ts" }, "author": "", "license": "Apache-2.0", "dependencies": { "apollo-server-express": "^1.3.6", + "bcryptjs": "^2.4.3", "bunyan": "^1.8.12", "convict": "^4.3.1", "dataloader": "^1.4.0", - "dotenv": "^6.0.0", "dotenv-expand": "^4.2.0", + "dotenv": "^6.0.0", "dotize": "^0.2.0", - "express": "^4.16.3", "express-static-gzip": "^0.3.2", + "express": "^4.16.3", "fs-extra": "^6.0.1", - "graphql": "^0.13.2", "graphql-config": "^2.0.1", "graphql-redis-subscriptions": "^1.5.0", "graphql-tools": "^3.0.5", + "graphql": "^0.13.2", "ioredis": "^3.2.2", "joi": "^13.4.0", + "jsonwebtoken": "^8.3.0", + "jwks-rsa": "^1.3.0", "lodash": "^4.17.10", "luxon": "^1.3.1", "mongodb": "^3.1.1", + "passport-local": "^1.0.0", + "passport-oauth2": "^1.4.0", + "passport-strategy": "^1.0.0", "passport": "^0.4.0", "performance-now": "^2.1.0", "subscriptions-transport-ws": "^0.9.12", @@ -55,6 +62,7 @@ "@babel/polyfill": "7.0.0-beta.49", "@babel/preset-env": "7.0.0-beta.49", "@babel/preset-react": "7.0.0-beta.49", + "@types/bcryptjs": "^2.4.1", "@types/bunyan": "^1.8.4", "@types/chokidar": "^1.7.5", "@types/classnames": "^2.2.4", @@ -70,11 +78,15 @@ "@types/jest": "^23.1.5", "@types/joi": "^13.0.8", "@types/jsdom": "^11.0.6", + "@types/jsonwebtoken": "^7.2.7", "@types/lodash": "^4.14.111", "@types/luxon": "^0.5.3", "@types/mongodb": "^3.1.1", "@types/node": "^10.5.2", - "@types/passport": "^0.4.5", + "@types/passport": "^0.4.6", + "@types/passport-local": "^1.0.33", + "@types/passport-oauth2": "^1.4.5", + "@types/passport-strategy": "^0.2.33", "@types/query-string": "^6.1.0", "@types/react-dom": "^16.0.6", "@types/react-relay": "github:coralproject/patched#types/react-relay", @@ -112,6 +124,7 @@ "fluent-langneg": "^0.1.0", "fluent-react": "^0.7.0", "graphql-playground-middleware-express": "^1.7.2", + "graphql-schema-typescript": "^1.2.1", "html-webpack-plugin": "^3.2.0", "jest": "^23.4.1", "jest-junit": "^5.1.0", diff --git a/scripts/generateSchemaTypes.js b/scripts/generateSchemaTypes.js new file mode 100644 index 000000000..3344eb33f --- /dev/null +++ b/scripts/generateSchemaTypes.js @@ -0,0 +1,90 @@ +const { Linter, Configuration } = require("tslint"); +const { generateTSTypesAsString } = require("graphql-schema-typescript"); +const { getGraphQLConfig } = require("graphql-config"); +const path = require("path"); +const fs = require("fs"); + +function lintAndWrite(files) { + const linter = new Linter({ fix: true }); + + for (const { fileName, types } of files) { + const configuration = Configuration.findConfiguration(null, fileName) + .results; + linter.lint(fileName, types, configuration); + } +} + +function getFileName(name) { + return path.join( + __dirname, + "../src/core/server/graph", + name, + "schema/__generated__/types.ts" + ); +} + +async function main() { + const config = getGraphQLConfig(__dirname); + const projects = config.getProjects(); + + const files = [ + { + name: "tenant", + fileName: getFileName("tenant"), + config: { + contextType: "TenantContext", + importStatements: [ + 'import { Cursor } from "talk-server/models/connection";', + 'import TenantContext from "talk-server/graph/tenant/context";', + ], + customScalarType: { Cursor: "Cursor", Time: "string" }, + }, + }, + { + name: "management", + fileName: getFileName("management"), + config: { + contextType: "ManagementContext", + importStatements: [ + 'import ManagementContext from "talk-server/graph/management/context";', + ], + }, + }, + ]; + + for (const file of files) { + // Load the graph schema. + const schema = projects[file.name].getSchema(); + + // Create the generated directory. + const dir = path.dirname(file.fileName); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir); + } + + // Create the types for this file. + file.types = await generateTSTypesAsString(schema, { + tabSpaces: 2, + typePrefix: "GQL", + strictNulls: true, + ...file.config, + }); + } + + // Send the files off to the linter to be linted and written. + lintAndWrite(files); + + return files; +} + +main() + .then(files => { + for (const { fileName } of files) { + // tslint:disable-next-line:no-console + console.log(`Generated ${fileName}`); + } + }) + .catch(err => { + // tslint:disable-next-line:no-console + console.error(err); + }); diff --git a/src/core/client/stream/components/Comment/Comment.tsx b/src/core/client/stream/components/Comment/Comment.tsx index f923e4149..03a947a5a 100644 --- a/src/core/client/stream/components/Comment/Comment.tsx +++ b/src/core/client/stream/components/Comment/Comment.tsx @@ -9,7 +9,7 @@ import Username from "./Username"; export interface CommentProps { author: { - username: string; + username: string | null; } | null; body: string | null; createdAt: string; @@ -19,7 +19,8 @@ const Comment: StatelessComponent = props => { return (
- {props.author && {props.author.username}} + {props.author && + props.author.username && {props.author.username}} {props.createdAt} {props.body} diff --git a/src/core/client/stream/containers/CommentContainer.spec.tsx b/src/core/client/stream/containers/CommentContainer.spec.tsx index 98612be38..4836991b8 100644 --- a/src/core/client/stream/containers/CommentContainer.spec.tsx +++ b/src/core/client/stream/containers/CommentContainer.spec.tsx @@ -19,3 +19,18 @@ it("renders username and body", () => { const wrapper = shallow(); expect(wrapper).toMatchSnapshot(); }); + +it("renders body only", () => { + const props: PropTypesOf = { + data: { + author: { + username: null, + }, + body: "Woof", + createdAt: "1995-12-17T03:24:00.000Z", + }, + }; + + const wrapper = shallow(); + expect(wrapper).toMatchSnapshot(); +}); diff --git a/src/core/client/stream/containers/__snapshots__/CommentContainer.spec.tsx.snap b/src/core/client/stream/containers/__snapshots__/CommentContainer.spec.tsx.snap index 9e2476f37..f6ec9754b 100644 --- a/src/core/client/stream/containers/__snapshots__/CommentContainer.spec.tsx.snap +++ b/src/core/client/stream/containers/__snapshots__/CommentContainer.spec.tsx.snap @@ -1,5 +1,17 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`renders body only 1`] = ` + +`; + exports[`renders username and body 1`] = ` async ( + req: Request, + res, + next +) => { + try { + // TODO: rate limit based on the IP address and user agent. + + // Tenant is guaranteed at this point. + const tenant = req.tenant!; + + // Check to ensure that the local integration has been enabled. + if (!tenant.auth.integrations.local.enabled) { + // TODO: replace with better error. + return next(new Error("integration is disabled")); + } + + // Get the fields from the body. Validate will throw an error if the body + // does not conform to the specification. + const { username, password, email }: SignupBody = validate( + SignupBodySchema, + req.body + ); + + // Configure with profile. + const profile: LocalProfile = { + id: email, + type: "local", + }; + + // Create the new user. + const user = await upsert(options.db, tenant, { + email, + username, + password, + profiles: [profile], + // New users signing up via local auth will have the commenter role to + // start with. + role: GQLUSER_ROLE.COMMENTER, + }); + + // Send off to the passport handler. + return handleSuccessfulLogin(user, options.signingConfig, req, res, next); + } catch (err) { + return next(err); + } +}; diff --git a/src/core/server/app/index.ts b/src/core/server/app/index.ts index 3711234e6..4594c9e81 100644 --- a/src/core/server/app/index.ts +++ b/src/core/server/app/index.ts @@ -3,14 +3,14 @@ import http from "http"; import { Redis } from "ioredis"; import { Db } from "mongodb"; +import { notFoundMiddleware } from "talk-server/app/middleware/notFound"; +import { createPassport } from "talk-server/app/middleware/passport"; +import { JWTSigningConfig } from "talk-server/app/middleware/passport/jwt"; import { Config } from "talk-server/config"; import { handleSubscriptions } from "talk-server/graph/common/subscriptions/middleware"; import { Schemas } from "talk-server/graph/schemas"; -import { - access as accessLogger, - error as errorLogger, -} from "./middleware/logging"; +import { accessLogger, errorLogger } from "./middleware/logging"; import serveStatic from "./middleware/serveStatic"; import { createRouter } from "./router"; @@ -20,6 +20,7 @@ export interface AppOptions { mongo: Db; redis: Redis; schemas: Schemas; + signingConfig: JWTSigningConfig; } /** @@ -32,13 +33,24 @@ export async function createApp(options: AppOptions): Promise { // Logging parent.use(accessLogger); + // Create some services for the router. + const passport = createPassport({ + db: options.mongo, + signingConfig: options.signingConfig, + }); + + // Mount the router. + parent.use( + await createRouter(options, { + passport, + }) + ); + // Static Files parent.use(serveStatic); - // Mount the router. - parent.use(await createRouter(options)); - // Error Handling + parent.use(notFoundMiddleware); parent.use(errorLogger); return parent; diff --git a/src/core/server/app/middleware/error.ts b/src/core/server/app/middleware/error.ts new file mode 100644 index 000000000..2f666260b --- /dev/null +++ b/src/core/server/app/middleware/error.ts @@ -0,0 +1,6 @@ +import { ErrorRequestHandler } from "express"; + +export const apiErrorHandler: ErrorRequestHandler = (err, req, res, next) => { + // TODO: handle better when we improve errors. + res.status(500).json({ error: err.message }); +}; diff --git a/src/core/server/app/middleware/logging.ts b/src/core/server/app/middleware/logging.ts index 9db48fa31..134c19df1 100644 --- a/src/core/server/app/middleware/logging.ts +++ b/src/core/server/app/middleware/logging.ts @@ -1,8 +1,9 @@ import { ErrorRequestHandler, RequestHandler } from "express"; import now from "performance-now"; -import logger from "../../logger"; -export const access: RequestHandler = (req, res, next) => { +import logger from "talk-server/logger"; + +export const accessLogger: RequestHandler = (req, res, next) => { const startTime = now(); const end = res.end; res.end = (chunk: any, encodingOrCb?: any, cb?: any) => { @@ -37,7 +38,7 @@ export const access: RequestHandler = (req, res, next) => { next(); }; -export const error: ErrorRequestHandler = (err, req, res, next) => { - logger.error({ err }, "http error"); +export const errorLogger: ErrorRequestHandler = (err, req, res, next) => { + logger.error(err, "http error"); next(err); }; diff --git a/src/core/server/app/middleware/notFound.ts b/src/core/server/app/middleware/notFound.ts new file mode 100644 index 000000000..1d5dbde5c --- /dev/null +++ b/src/core/server/app/middleware/notFound.ts @@ -0,0 +1,5 @@ +import { RequestHandler } from "express"; + +export const notFoundMiddleware: RequestHandler = (req, res, next) => { + next(new Error("not found")); +}; diff --git a/src/core/server/app/middleware/passport/__snapshots__/jwt.spec.ts.snap b/src/core/server/app/middleware/passport/__snapshots__/jwt.spec.ts.snap new file mode 100644 index 000000000..1e61492d7 --- /dev/null +++ b/src/core/server/app/middleware/passport/__snapshots__/jwt.spec.ts.snap @@ -0,0 +1,31 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`createJWTSigningConfig parses a RSA certiciate 1`] = ` +"-----BEGIN RSA PRIVATE KEY----- +MIIEpQIBAAKCAQEAyxR2DVlvkQRquggUQTpHN+PxDs2iOiItGgn6u4+faUCdgGEV +EnmG69//3lAZHnEQN9rkZS3/20zc41mTJnO7dslJbB316vWUSIwYcVY/VC9DTbk+ +MHWZd94p5hOB8PoY2vEGA53KiyWLqQC5FWE3u7cz7eYTr9/eRPDTc15IzohLXd5U +C9EbO5ebho2CvWrBfrLozM5Kidp8r3Jp+A0o3kfJ/kRDDn/BmG6pM0TohWZFYMs2 +nQaGg+of9tcafgAs7hZAgBrrcc/jke6+MKxpC8algik79nMk7s7prxF1Z9EbAeQV +1ssL2VgsjvGAHIV+Arckl6QJbVDvQXNAM0PqbQIDAQABAoIBAQCoG6D5vf5P8nMS +2ltB/6cyyfsjgO/45Y+mTXqERwj0DOwUeMkDyRv6KCxb8LxKade+FPIaG7D/7amw +fdcE7qrRUyD3YfnPbUk5oNcfAwFbg+BX969WWBMZmgvfDGj1fWKT4w9ScQ1YkFUD +KrkLzLVhK+/N0Dad0VjiguTXTMZCSDFOY9fO8HRF6EA3aewEPeEY62J6rSjGXvWB +GdW+FNvf/uRr36xGHNqiOP837pdVUppjgDyVsORnMfFtYMyWyxS2XD5r8gRwcRg7 +0nz6bLM53DjKweO+Yl+pIVPFAyXL0pwzQDlnjShsCzyzjA9lJftkQwbcMWopeegJ +kPLmiq4VAoGBAOqDmySNx8vmWWMOaXKFuH6Gqu/Nd7gBHxZ73wvsEmvV52xwa0oi +55h+v6P1YEaNZQWXDFsvILoOUHr2kwZY+Du/MC7tgqpj+Fu3h7UHslulJRE3A+sN +oLbHjZuwm3wwsatpHdyEYOGg0HIGWXi+9pDT/1gy8g3L2Gf0X6rfkBBXAoGBAN2v +lbii0+HvZ2y0D0P6NfUJ6cQDrSyuTe7UW6OVYjBjrVAk8+bhnQ4eKd9edCnUDqu6 +9C8ZSrqR6VBeItbt8y+5ZCRcrigxd2VdH8rL9g6idD9RPnSbHx7Al8DxSUv25xMK +8Z/ZOAvuCmwDfdleycNDoTawKqLtWBzUEntLs5DbAoGAPlTKiJWylAxel8h92HWY +SvDqQCChgGOz6prz9sxBPS42e4kJy0OpwMt3jlGqzDXKswipvRayoSEq3PPqshY1 +rFOtr9trDnTRzzbhuAkaq+ciCghQX0pY/BvgFJCFUyXyIzgmOrVotq+yl4v+fexr +xqTCSqQH2AjlNQQr5VPUi7MCgYEAsNbbMXE6YlXug+lS8CANoM3qm4FvSGA3LNhb +za9hp0YsP+1qXvgEp/lp35RiR+ewWE+HcHbVhOTWYFTnp9ojDyPtfZAtIUTsgIB7 +1vNC8kOnRccSckQ32/k4VSJlHOL1S9yECMZnjiSyTZ2va5HQkyJE3PJE4LlCe6S0 +pYQq1tcCgYEAoJDeSeAPqi5NIu+MWNUWzw4vo5raKyHrJi+cTvKyM/2zJFHvBc5f +RaxkcIAOmIDoVdFgy6APY/0DnDnpqT1kMagUaxZjG9PLFIDds5DRaL99m+S7l8mt +ySX/MbmhQHYWpVf2nL6pmfPuP4Ih6tbKIUUGA3wZXYYZ5r+pZFG1IrA= +-----END RSA PRIVATE KEY-----" +`; diff --git a/src/core/server/app/middleware/passport/index.ts b/src/core/server/app/middleware/passport/index.ts index a22e11144..d9d6768f3 100644 --- a/src/core/server/app/middleware/passport/index.ts +++ b/src/core/server/app/middleware/passport/index.ts @@ -1,13 +1,115 @@ +import { NextFunction, RequestHandler, Response } from "express"; import { Db } from "mongodb"; import passport, { Authenticator } from "passport"; +import { + createJWTStrategy, + JWTSigningConfig, + SigningTokenOptions, + signTokenString, +} from "talk-server/app/middleware/passport/jwt"; +import { createLocalStrategy } from "talk-server/app/middleware/passport/local"; +import { createOIDCStrategy } from "talk-server/app/middleware/passport/oidc"; +import { createSSOStrategy } from "talk-server/app/middleware/passport/sso"; +import { User } from "talk-server/models/user"; +import { Request } from "talk-server/types/express"; + +export type VerifyCallback = ( + err?: Error | null, + user?: User | null, + info?: { message: string } +) => void; + export interface PassportOptions { db: Db; + signingConfig: JWTSigningConfig; } -export function createPassport(opts: PassportOptions): passport.Authenticator { +export function createPassport({ + db, + signingConfig, +}: PassportOptions): passport.Authenticator { // Create the authenticator. const auth = new Authenticator(); + // Use the OIDC Strategy. + auth.use(createOIDCStrategy({ db })); + + // Use the LocalStrategy. + auth.use(createLocalStrategy({ db })); + + // Use the SSOStrategy. + auth.use(createSSOStrategy({ db })); + + // Use the JWTStrategy. + auth.use(createJWTStrategy({ db, signingConfig })); + return auth; } + +export async function handleSuccessfulLogin( + user: User, + signingConfig: JWTSigningConfig, + req: Request, + res: Response, + next: NextFunction +) { + try { + // Grab the tenant from the request. + const { tenant } = req; + + const options: SigningTokenOptions = {}; + + if (tenant) { + // Attach the tenant's id to the issued token as a `iss` claim. + options.issuer = tenant.id; + + // TODO: (wyattjoh) evaluate the possibility when we have multiple + // integrations per type to use the integration id as the audience. + } + + // Grab the token. + const token = await signTokenString(signingConfig, user, options); + + // Set the cache control headers. + res.header("Cache-Control", "private, no-cache, no-store, must-revalidate"); + res.header("Expires", "-1"); + res.header("Pragma", "no-cache"); + + // Send back the details! + res.json({ token }); + } catch (err) { + return next(err); + } +} + +/** + * wrapAuthn will wrap a authenticators authenticate method with one that + * will return a valid login token for a valid login by a compatible strategy. + * + * @param authenticator the base authenticator instance + * @param signingConfig used to sign the tokens that are issued. + * @param name the name of the authenticator to use + * @param options any options to be passed to the authenticate call + */ +export const wrapAuthn = ( + authenticator: passport.Authenticator, + signingConfig: JWTSigningConfig, + name: string, + options?: any +): RequestHandler => (req: Request, res, next) => + authenticator.authenticate( + name, + { ...options, session: false }, + (err: Error | null, user: User | null) => { + if (err) { + return next(err); + } + if (!user) { + // TODO: (wyattjoh) replace with better error. + return next(new Error("no user on request")); + } + + handleSuccessfulLogin(user, signingConfig, req, res, next); + } + )(req, res, next); diff --git a/src/core/server/app/middleware/passport/jwt.spec.ts b/src/core/server/app/middleware/passport/jwt.spec.ts new file mode 100644 index 000000000..fa7b87d9d --- /dev/null +++ b/src/core/server/app/middleware/passport/jwt.spec.ts @@ -0,0 +1,84 @@ +import sinon from "sinon"; + +import { + createJWTSigningConfig, + extractJWTFromRequest, + parseAuthHeader, +} from "talk-server/app/middleware/passport/jwt"; +import { Config } from "talk-server/config"; +import { Request } from "talk-server/types/express"; + +describe("parseAuthHeader", () => { + it("parses valid headers", () => { + const parsed = { + scheme: "bearer", + value: "token", + }; + + expect(parseAuthHeader("Bearer token")).toEqual(parsed); + + expect(parseAuthHeader("bearer token")).toEqual(parsed); + + expect(parseAuthHeader("bearer token")).toEqual(parsed); + }); + + it("parses invalid headers", () => { + expect(parseAuthHeader("this-is-a-wrong-header")).toEqual(null); + expect(parseAuthHeader("bearerthis-is-a-wrong-header")).toEqual(null); + }); +}); + +describe("extractJWTFromRequest", () => { + it("extracts the token from header", () => { + const req = { + get: sinon + .stub() + .withArgs("authorization") + .returns("Bearer token"), + }; + + expect(extractJWTFromRequest((req as any) as Request)).toEqual("token"); + expect(req.get.calledOnce).toBeTruthy(); + + req.get.reset(); + req.get.returns(null); + expect(extractJWTFromRequest((req as any) as Request)).toEqual(null); + expect(req.get.calledOnce).toBeTruthy(); + }); + + it("extracts the token from query string", () => { + const req = { + get: sinon + .stub() + .withArgs("authorization") + .returns(null), + query: { access_token: "token" }, + }; + + expect(extractJWTFromRequest((req as any) as Request)).toEqual("token"); + expect(req.get.calledOnce).toBeTruthy(); + + delete req.query.access_token; + + req.get.reset(); + expect(extractJWTFromRequest((req as any) as Request)).toEqual(null); + expect(req.get.calledOnce).toBeTruthy(); + }); +}); + +describe("createJWTSigningConfig", () => { + it("parses a RSA certiciate", () => { + const input = `-----BEGIN RSA PRIVATE KEY-----\\nMIIEpQIBAAKCAQEAyxR2DVlvkQRquggUQTpHN+PxDs2iOiItGgn6u4+faUCdgGEV\\nEnmG69//3lAZHnEQN9rkZS3/20zc41mTJnO7dslJbB316vWUSIwYcVY/VC9DTbk+\\nMHWZd94p5hOB8PoY2vEGA53KiyWLqQC5FWE3u7cz7eYTr9/eRPDTc15IzohLXd5U\\nC9EbO5ebho2CvWrBfrLozM5Kidp8r3Jp+A0o3kfJ/kRDDn/BmG6pM0TohWZFYMs2\\nnQaGg+of9tcafgAs7hZAgBrrcc/jke6+MKxpC8algik79nMk7s7prxF1Z9EbAeQV\\n1ssL2VgsjvGAHIV+Arckl6QJbVDvQXNAM0PqbQIDAQABAoIBAQCoG6D5vf5P8nMS\\n2ltB/6cyyfsjgO/45Y+mTXqERwj0DOwUeMkDyRv6KCxb8LxKade+FPIaG7D/7amw\\nfdcE7qrRUyD3YfnPbUk5oNcfAwFbg+BX969WWBMZmgvfDGj1fWKT4w9ScQ1YkFUD\\nKrkLzLVhK+/N0Dad0VjiguTXTMZCSDFOY9fO8HRF6EA3aewEPeEY62J6rSjGXvWB\\nGdW+FNvf/uRr36xGHNqiOP837pdVUppjgDyVsORnMfFtYMyWyxS2XD5r8gRwcRg7\\n0nz6bLM53DjKweO+Yl+pIVPFAyXL0pwzQDlnjShsCzyzjA9lJftkQwbcMWopeegJ\\nkPLmiq4VAoGBAOqDmySNx8vmWWMOaXKFuH6Gqu/Nd7gBHxZ73wvsEmvV52xwa0oi\\n55h+v6P1YEaNZQWXDFsvILoOUHr2kwZY+Du/MC7tgqpj+Fu3h7UHslulJRE3A+sN\\noLbHjZuwm3wwsatpHdyEYOGg0HIGWXi+9pDT/1gy8g3L2Gf0X6rfkBBXAoGBAN2v\\nlbii0+HvZ2y0D0P6NfUJ6cQDrSyuTe7UW6OVYjBjrVAk8+bhnQ4eKd9edCnUDqu6\\n9C8ZSrqR6VBeItbt8y+5ZCRcrigxd2VdH8rL9g6idD9RPnSbHx7Al8DxSUv25xMK\\n8Z/ZOAvuCmwDfdleycNDoTawKqLtWBzUEntLs5DbAoGAPlTKiJWylAxel8h92HWY\\nSvDqQCChgGOz6prz9sxBPS42e4kJy0OpwMt3jlGqzDXKswipvRayoSEq3PPqshY1\\nrFOtr9trDnTRzzbhuAkaq+ciCghQX0pY/BvgFJCFUyXyIzgmOrVotq+yl4v+fexr\\nxqTCSqQH2AjlNQQr5VPUi7MCgYEAsNbbMXE6YlXug+lS8CANoM3qm4FvSGA3LNhb\\nza9hp0YsP+1qXvgEp/lp35RiR+ewWE+HcHbVhOTWYFTnp9ojDyPtfZAtIUTsgIB7\\n1vNC8kOnRccSckQ32/k4VSJlHOL1S9yECMZnjiSyTZ2va5HQkyJE3PJE4LlCe6S0\\npYQq1tcCgYEAoJDeSeAPqi5NIu+MWNUWzw4vo5raKyHrJi+cTvKyM/2zJFHvBc5f\\nRaxkcIAOmIDoVdFgy6APY/0DnDnpqT1kMagUaxZjG9PLFIDds5DRaL99m+S7l8mt\\nySX/MbmhQHYWpVf2nL6pmfPuP4Ih6tbKIUUGA3wZXYYZ5r+pZFG1IrA=\\n-----END RSA PRIVATE KEY-----`; + const config = { + get: sinon.stub(), + }; + + config.get.withArgs("signing_secret").returns(input); + config.get.withArgs("signing_algorithm").returns("RS256"); + + const signingConfig = createJWTSigningConfig((config as any) as Config); + + expect(signingConfig.algorithm).toEqual("RS256"); + expect(signingConfig.secret.toString()).toMatchSnapshot(); + }); +}); diff --git a/src/core/server/app/middleware/passport/jwt.ts b/src/core/server/app/middleware/passport/jwt.ts new file mode 100644 index 000000000..b47bd06dd --- /dev/null +++ b/src/core/server/app/middleware/passport/jwt.ts @@ -0,0 +1,204 @@ +import jwt, { SignOptions } from "jsonwebtoken"; +import uuid from "uuid"; + +import { Db } from "mongodb"; +import { Strategy } from "passport-strategy"; +import { Config } from "talk-server/config"; +import { retrieveUser, User } from "talk-server/models/user"; +import { Request } from "talk-server/types/express"; + +const authHeaderRegex = /(\S+)\s+(\S+)/; + +export function parseAuthHeader(header: string) { + const matches = header.match(authHeaderRegex); + if (!matches || matches.length < 3) { + return null; + } + + return { + scheme: matches[1].toLowerCase(), + value: matches[2], + }; +} + +export function extractJWTFromRequest(req: Request) { + const header = req.get("authorization"); + if (header) { + const parts = parseAuthHeader(header); + if (parts && parts.scheme === "bearer") { + return parts.value; + } + } + + const token: string | undefined | false = req.query && req.query.access_token; + if (token) { + return token; + } + + return null; +} + +export enum AsymmetricSigningAlgorithm { + RS256 = "RS256", + RS384 = "RS384", + RS512 = "RS512", + ES256 = "ES256", + ES384 = "ES384", + ES512 = "ES512", +} + +export enum SymmetricSigningAlgorithm { + HS256 = "HS256", + HS384 = "HS384", + HS512 = "HS512", +} + +export type JWTSigningAlgorithm = + | AsymmetricSigningAlgorithm + | SymmetricSigningAlgorithm; + +export interface JWTSigningConfig { + secret: Buffer; + algorithm: JWTSigningAlgorithm; +} + +export function createAsymmetricSigningConfig( + algorithm: AsymmetricSigningAlgorithm, + secret: string +): JWTSigningConfig { + return { + // Secrets have their newlines encoded with newline litterals. + secret: Buffer.from(secret.replace(/\\n/g, "\n")), + algorithm, + }; +} + +export function createSymmetricSigningConfig( + algorithm: SymmetricSigningAlgorithm, + secret: string +): JWTSigningConfig { + return { + secret: new Buffer(secret), + algorithm, + }; +} + +function isSymmetricSigningAlgorithm( + algorithm: string | SymmetricSigningAlgorithm +): algorithm is SymmetricSigningAlgorithm { + return algorithm in SymmetricSigningAlgorithm; +} + +function isAsymmetricSigningAlgorithm( + algorithm: string | AsymmetricSigningAlgorithm +): algorithm is AsymmetricSigningAlgorithm { + return algorithm in AsymmetricSigningAlgorithm; +} + +/** + * Parses the config and provides the signing config. + * + * @param config the server configuration + */ +export function createJWTSigningConfig(config: Config): JWTSigningConfig { + const secret = config.get("signing_secret"); + const algorithm = config.get("signing_algorithm"); + if (isSymmetricSigningAlgorithm(algorithm)) { + return createSymmetricSigningConfig(algorithm, secret); + } else if (isAsymmetricSigningAlgorithm(algorithm)) { + return createAsymmetricSigningConfig(algorithm, secret); + } + + // TODO: (wyattjoh) return better error. + throw new Error("invalid algorithm specified"); +} + +export type SigningTokenOptions = Pick; + +export async function signTokenString( + { algorithm, secret }: JWTSigningConfig, + user: User, + options: SigningTokenOptions +) { + return jwt.sign({}, secret, { + ...options, + jwtid: uuid.v4(), + algorithm, + expiresIn: "1 day", // TODO: (wyattjoh) evaluate allowing configuration? + subject: user.id, + }); +} + +export interface JWTToken { + jti: string; + sub: string; + exp: number; + iss?: string; +} + +export interface JWTStrategyOptions { + signingConfig: JWTSigningConfig; + db: Db; +} + +export class JWTStrategy extends Strategy { + private signingConfig: JWTSigningConfig; + private db: Db; + + public name: string; + + constructor({ signingConfig, db }: JWTStrategyOptions) { + super(); + + this.name = "jwt"; + this.signingConfig = signingConfig; + this.db = db; + } + + public authenticate(req: Request) { + // Lookup the token. + const token = extractJWTFromRequest(req); + if (!token) { + // There was no token on the request, so there was no user, so let's mark + // that the strategy was succesfull. + return this.success(null, null); + } + + const { tenant } = req; + if (!tenant) { + // TODO: (wyattjoh) return a better error. + return this.error(new Error("tenant not found")); + } + + jwt.verify( + token, + // Use the secret specified in the configuration. + this.signingConfig.secret, + { + // We need to verify that the token is for the specified tenant. + issuer: tenant.id, + // Use the algorithm specified in the configuration. + algorithms: [this.signingConfig.algorithm], + }, + async (err: Error | undefined, { sub }: JWTToken) => { + if (err) { + return this.fail(err, 401); + } + + try { + // Find the user. + const user = await retrieveUser(this.db, tenant.id, sub); + + // Return them! The user may be null, but that's ok here. + this.success(user, null); + } catch (err) { + return this.error(err); + } + } + ); + } +} + +export function createJWTStrategy(options: JWTStrategyOptions) { + return new JWTStrategy(options); +} diff --git a/src/core/server/app/middleware/passport/local.ts b/src/core/server/app/middleware/passport/local.ts new file mode 100644 index 000000000..b57c4402e --- /dev/null +++ b/src/core/server/app/middleware/passport/local.ts @@ -0,0 +1,60 @@ +import { Db } from "mongodb"; +import { Strategy as LocalStrategy } from "passport-local"; + +import { VerifyCallback } from "talk-server/app/middleware/passport"; +import { + retrieveUserWithProfile, + verifyUserPassword, +} from "talk-server/models/user"; +import { Request } from "talk-server/types/express"; + +const verifyFactory = (db: Db) => async ( + req: Request, + email: string, + password: string, + done: VerifyCallback +) => { + try { + // TODO: rate limit based on the IP address and user agent. + + // The tenant is guaranteed at this point. + const tenant = req.tenant!; + + // Get the user from the database. + const user = await retrieveUserWithProfile(db, tenant.id, { + id: email, + type: "local", + }); + if (!user) { + // The user didn't exist. + return done(null, null); + } + + // Verify the password. + const passwordVerified = await verifyUserPassword(user, password); + if (!passwordVerified) { + // TODO: return better error + return done(new Error("invalid password")); + } + + return done(null, user); + } catch (err) { + return done(err); + } +}; + +export interface LocalStrategyOptions { + db: Db; +} + +export function createLocalStrategy({ db }: LocalStrategyOptions) { + return new LocalStrategy( + { + usernameField: "email", + passwordField: "password", + session: false, + passReqToCallback: true, + }, + verifyFactory(db) + ); +} diff --git a/src/core/server/app/middleware/passport/oidc.spec.ts b/src/core/server/app/middleware/passport/oidc.spec.ts new file mode 100644 index 000000000..6f10b1140 --- /dev/null +++ b/src/core/server/app/middleware/passport/oidc.spec.ts @@ -0,0 +1,87 @@ +import { + OIDCDisplayNameIDTokenSchema, + OIDCIDTokenSchema, +} from "talk-server/app/middleware/passport/oidc"; +import { validate } from "talk-server/app/request/body"; + +describe("OIDCIDTokenSchema", () => { + it("allows a valid payload", () => { + const token = { + sub: "sub", + iss: "iss", + aud: "aud", + email: "email", + email_verified: true, + }; + + expect(validate(OIDCIDTokenSchema, token)).toEqual(token); + }); + + it("allows an empty email_verified", () => { + const token = { + sub: "sub", + iss: "iss", + aud: "aud", + email: "email", + }; + + expect(validate(OIDCIDTokenSchema, token)).toEqual({ + ...token, + email_verified: false, + }); + }); + + it("allows an empty picture", () => { + const token = { + sub: "sub", + iss: "iss", + aud: "aud", + email: "email", + email_verified: true, + }; + + expect(validate(OIDCIDTokenSchema, token)).toEqual(token); + }); +}); + +describe("OIDCDisplayNameIDTokenSchema", () => { + it("allows a valid payload", () => { + const token = { + sub: "sub", + iss: "iss", + aud: "aud", + email: "email", + email_verified: true, + name: "name", + nickname: "nickname", + }; + + expect(validate(OIDCDisplayNameIDTokenSchema, token)).toEqual(token); + }); + + it("allows an empty name", () => { + const token = { + sub: "sub", + iss: "iss", + aud: "aud", + email: "email", + email_verified: false, + nickname: "nickname", + }; + + expect(validate(OIDCDisplayNameIDTokenSchema, token)).toEqual(token); + }); + + it("allows an empty nickname", () => { + const token = { + sub: "sub", + iss: "iss", + aud: "aud", + email: "email", + email_verified: false, + name: "name", + }; + + expect(validate(OIDCDisplayNameIDTokenSchema, token)).toEqual(token); + }); +}); diff --git a/src/core/server/app/middleware/passport/oidc.ts b/src/core/server/app/middleware/passport/oidc.ts new file mode 100644 index 000000000..2bbe16a30 --- /dev/null +++ b/src/core/server/app/middleware/passport/oidc.ts @@ -0,0 +1,375 @@ +import Joi from "joi"; +import jwt from "jsonwebtoken"; +import jwks, { JwksClient } from "jwks-rsa"; +import { Db } from "mongodb"; +import { Strategy as OAuth2Strategy, VerifyCallback } from "passport-oauth2"; +import { Strategy } from "passport-strategy"; + +import { validate } from "talk-server/app/request/body"; +import { reconstructURL } from "talk-server/app/url"; +import { GQLUSER_ROLE } from "talk-server/graph/tenant/schema/__generated__/types"; +import { OIDCAuthIntegration, Tenant } from "talk-server/models/tenant"; +import { OIDCProfile, retrieveUserWithProfile } from "talk-server/models/user"; +import { upsert } from "talk-server/services/users"; +import { Request } from "talk-server/types/express"; + +export interface Params { + id_token?: string; +} + +/** + * OIDCIDToken describes the set of claims that are present in a ID Token. This + * interface confirms with the ID Token specification as defined: + * https://openid.net/specs/openid-connect-core-1_0.html#IDToken + */ +export interface OIDCIDToken { + aud: string; + iss: string; + sub: string; + exp: number; // TODO: use this as the source for how long an OIDC user can be logged in for + email?: string; + email_verified?: boolean; + picture?: string; + name?: string; + nickname?: string; +} + +export interface StrategyItem { + strategy: OAuth2Strategy; + jwksClient?: JwksClient; +} + +export interface OIDCStrategyOptions { + db: Db; +} + +export function isOIDCToken(token: OIDCIDToken | object): token is OIDCIDToken { + if ( + (token as OIDCIDToken).iss && + (token as OIDCIDToken).sub && + (token as OIDCIDToken).email + ) { + return true; + } + + return false; +} + +/** + * keyFunc will provide the secret based on the given jwkw client. + * + * @param client the jwks client for the specific request being made + */ +const signingKeyFactory = (client: jwks.JwksClient): jwt.KeyFunction => ( + { kid }, + callback +) => { + if (!kid) { + // TODO: return better error. + return callback(new Error("no kid in id_token")); + } + + // Get the signing key from the jwks provider. + client.getSigningKey(kid, (err, key) => { + if (err) { + // TODO: wrap error? + return callback(err); + } + + // Grab the signingKey out of the provided key. + const signingKey = key.publicKey || key.rsaPublicKey; + + callback(null, signingKey); + }); +}; + +function getEnabledIntegration(tenant: Tenant) { + const integration = tenant.auth.integrations.oidc; + if (!integration) { + // TODO: return a better error. + throw new Error("integration not found"); + } + + // Handle when the integration is enabled/disabled. + if (!integration.enabled) { + // TODO: return a better error. + throw new Error("integration not enabled"); + } + + return integration; +} + +export const OIDCIDTokenSchema = Joi.object() + .keys({ + sub: Joi.string(), + iss: Joi.string(), + aud: Joi.string(), + email: Joi.string(), + email_verified: Joi.boolean().default(false), + picture: Joi.string().default(undefined), + }) + .optionalKeys(["picture", "email_verified"]); + +export const OIDCDisplayNameIDTokenSchema = OIDCIDTokenSchema.keys({ + name: Joi.string().default(undefined), + nickname: Joi.string().default(undefined), +}).optionalKeys(["name", "nickname"]); + +export async function findOrCreateOIDCUser( + db: Db, + tenant: Tenant, + token: OIDCIDToken +) { + // Unpack/validate the token content. + const { + sub, + iss, + aud, + email, + email_verified, + picture, + name, + nickname, + }: OIDCIDToken = validate( + tenant.auth.integrations.oidc!.displayNameEnable + ? OIDCDisplayNameIDTokenSchema + : OIDCIDTokenSchema, + token + ); + + // Construct the profile that will be used to query for the user. + const profile: OIDCProfile = { + type: "oidc", + id: sub, + issuer: iss, + audience: aud, + }; + + // Try to lookup user given their id provided in the `sub` claim. + let user = await retrieveUserWithProfile(db, tenant.id, profile); + if (!user) { + // FIXME: implement rules. + + // Default the displayName. When it is disabled, Joi will strip the + // displayName fields from the token, so it will fallback to undefined. + const displayName = nickname || name || undefined; + + // Create the new user, as one didn't exist before! + user = await upsert(db, tenant, { + username: null, + displayName, + role: GQLUSER_ROLE.COMMENTER, + email, + email_verified, + avatar: picture, + profiles: [profile], + }); + } + + // TODO: (wyattjoh) possibly update the user profile if the remaining details mismatch? + + return user; +} + +/** + * OIDC_SCOPE is the set of scopes requested for users signing up via OIDC. + */ +const OIDC_SCOPE = "openid email profile"; + +// FIXME: attach strategy to cache updates of the tenants + +export default class OIDCStrategy extends Strategy { + public name: string; + + private db: Db; + private cache: Map; + + constructor({ db }: OIDCStrategyOptions) { + super(); + + this.name = "oidc"; + this.cache = new Map(); + this.db = db; + } + + private lookupJWKSClient( + req: Request, + tenantID: string, + oidc: OIDCAuthIntegration + ) { + let entry = this.cache.get(tenantID); + if (!entry) { + const strategy = this.createStrategy(req, oidc); + + // Create the entry. + entry = { + strategy, + }; + + // We don't reset the entry in the cache here because if we just created + // it, we'll be creating the jwksClient anyways, so we'll update it there. + } + + if (!entry.jwksClient) { + // Create the new JWKS client. + const jwksClient = jwks({ + jwksUri: oidc.jwksURI, + }); + + // Set the jwksClient on the entry. + entry.jwksClient = jwksClient; + + // Update the cached entry. + this.cache.set(tenantID, entry); + } + + return entry.jwksClient; + } + + private userAuthenticatedCallback = ( + req: Request, + accessToken: string, // ignore the access token, we don't use it. + refreshToken: string, // ignore the refresh token, we don't use it. + params: Params, + profile: any, // we don't look inside the profile (yet). + done: VerifyCallback + ) => { + // Try to lookup user given their id provided in the `sub` claim of the + // `id_token`. + const { id_token } = params; + if (!id_token) { + // TODO: return better error. + return done(new Error("no id_token in params")); + } + + // Grab the tenant out of the request, as we need some more details. + const { tenant } = req; + if (!tenant) { + // TODO: return a better error. + return done(new Error("tenant not found")); + } + + // Get the integration from the tenant. If needed, it will be used to create + // a new strategy. + let integration: OIDCAuthIntegration; + try { + integration = getEnabledIntegration(tenant); + } catch (err) { + // TODO: wrap error? + return done(err); + } + + // Grab the JWKSClient. + const client = this.lookupJWKSClient(req, tenant.id, integration); + + // Verify that the id_token is valid or not. + jwt.verify( + id_token, + signingKeyFactory(client), + { + issuer: integration.issuer, + }, + async (err, decoded) => { + if (err) { + // TODO: wrap error? + return done(err); + } + + try { + const user = await findOrCreateOIDCUser( + this.db, + tenant, + decoded as OIDCIDToken + ); + return done(null, user); + } catch (err) { + return done(err); + } + } + ); + }; + + private createStrategy( + req: Request, + integration: OIDCAuthIntegration + ): OAuth2Strategy { + const { clientID, clientSecret, authorizationURL, tokenURL } = integration; + + // Construct the callbackURL from the request. + const callbackURL = reconstructURL(req, "/api/tenant/auth/oidc/callback"); + + // Create a new OAuth2Strategy, where we pass the verify callback bound to + // this OIDCStrategy instance. + return new OAuth2Strategy( + { + passReqToCallback: true, + clientID, + clientSecret, + authorizationURL, + tokenURL, + callbackURL, + }, + this.userAuthenticatedCallback + ); + } + + private async lookupStrategy(req: Request) { + const { tenant } = req; + if (!tenant) { + // TODO: return a better error. + throw new Error("tenant not found"); + } + + // Get the integration from the tenant. If needed, it will be used to create + // a new strategy. + const integration = getEnabledIntegration(tenant); + + // Try to get the Tenant's cached integrations. + let entry = this.cache.get(tenant.id); + if (!entry) { + // Create the strategy. + const strategy = this.createStrategy(req, integration); + + // Reset the entry. + entry = { + strategy, + }; + + // Update the cached integrations value. + this.cache.set(tenant.id, entry); + } + + return entry.strategy; + } + + public async authenticate(req: Request) { + try { + // Lookup the strategy. + const strategy = await this.lookupStrategy(req); + if (!strategy) { + throw new Error("strategy not found"); + } + + // Augment the strategy with the request method bindings. + strategy.error = this.error.bind(this); + strategy.fail = this.fail.bind(this); + strategy.pass = this.pass.bind(this); + strategy.redirect = this.redirect.bind(this); + strategy.success = this.success.bind(this); + + // Authenticate with the strategy, binding the current context to the method + // to provide it with the augmented passport handlers. We also request the + // 'openid' scope so we can get an id_token back. + strategy.authenticate(req, { + scope: OIDC_SCOPE, + session: false, + }); + } catch (err) { + return this.error(err); + } + } +} + +export function createOIDCStrategy({ db }: OIDCStrategyOptions) { + return new OIDCStrategy({ db }); +} diff --git a/src/core/server/app/middleware/passport/sso.spec.ts b/src/core/server/app/middleware/passport/sso.spec.ts new file mode 100644 index 000000000..f973f45b7 --- /dev/null +++ b/src/core/server/app/middleware/passport/sso.spec.ts @@ -0,0 +1,83 @@ +import { + isSSOToken, + SSODisplayNameUserProfileSchema, + SSOUserProfileSchema, +} from "talk-server/app/middleware/passport/sso"; +import { validate } from "talk-server/app/request/body"; + +describe("isSSOToken", () => { + it("understands valid sso tokens", () => { + const token = { user: { id: "id", email: "email", username: "username" } }; + expect(isSSOToken(token)).toBeTruthy(); + }); + + it("understands invalid sso tokens", () => { + expect(isSSOToken({ user: { id: "id", email: "email" } })).toBeFalsy(); + expect( + isSSOToken({ user: { id: "id", username: "username" } }) + ).toBeFalsy(); + expect( + isSSOToken({ user: { email: "email", username: "username" } }) + ).toBeFalsy(); + expect(isSSOToken({})).toBeFalsy(); + }); +}); + +describe("SSOUserProfileSchema", () => { + it("allows a valid payload", () => { + const profile = { + id: "id", + email: "email", + username: "username", + avatar: "avatar", + }; + + expect(validate(SSOUserProfileSchema, profile)).toEqual(profile); + }); + + it("allows an empty avatar", () => { + const profile = { + id: "id", + email: "email", + username: "username", + }; + + expect(validate(SSOUserProfileSchema, profile)).toEqual(profile); + }); +}); + +describe("SSODisplayNameUserProfileSchema", () => { + it("allows a valid payload", () => { + const profile = { + id: "id", + email: "email", + username: "username", + avatar: "avatar", + displayName: "displayName", + }; + + expect(validate(SSODisplayNameUserProfileSchema, profile)).toEqual(profile); + }); + + it("allows an empty avatar", () => { + const profile = { + id: "id", + email: "email", + username: "username", + displayName: "displayName", + }; + + expect(validate(SSODisplayNameUserProfileSchema, profile)).toEqual(profile); + }); + + it("allows an empty displayName", () => { + const profile = { + id: "id", + email: "email", + username: "username", + avatar: "avatar", + }; + + expect(validate(SSODisplayNameUserProfileSchema, profile)).toEqual(profile); + }); +}); diff --git a/src/core/server/app/middleware/passport/sso.ts b/src/core/server/app/middleware/passport/sso.ts new file mode 100644 index 000000000..4e6903139 --- /dev/null +++ b/src/core/server/app/middleware/passport/sso.ts @@ -0,0 +1,225 @@ +import Joi from "joi"; +import jwt, { KeyFunctionCallback } from "jsonwebtoken"; +import { Db } from "mongodb"; +import { Strategy } from "passport-strategy"; + +import { extractJWTFromRequest } from "talk-server/app/middleware/passport/jwt"; +import { + findOrCreateOIDCUser, + isOIDCToken, + OIDCIDToken, +} from "talk-server/app/middleware/passport/oidc"; +import { validate } from "talk-server/app/request/body"; +import { GQLUSER_ROLE } from "talk-server/graph/tenant/schema/__generated__/types"; +import { Tenant } from "talk-server/models/tenant"; +import { retrieveUserWithProfile, SSOProfile } from "talk-server/models/user"; +import { upsert } from "talk-server/services/users"; +import { Request } from "talk-server/types/express"; + +export interface SSOStrategyOptions { + db: Db; +} + +export interface SSOUserProfile { + id: string; + email: string; + username: string; + avatar?: string; + displayName?: string; +} + +export interface SSOToken { + user: SSOUserProfile; +} + +export const SSOUserProfileSchema = Joi.object() + .keys({ + id: Joi.string(), + email: Joi.string(), + username: Joi.string(), + avatar: Joi.string().default(undefined), + }) + .optionalKeys(["avatar"]); + +export const SSODisplayNameUserProfileSchema = SSOUserProfileSchema.keys({ + displayName: Joi.string().default(undefined), +}).optionalKeys(["displayName"]); + +export async function findOrCreateSSOUser( + db: Db, + tenant: Tenant, + token: SSOToken +) { + if (!token.user) { + // TODO: (wyattjoh) replace with better error. + throw new Error("token is malformed, missing user claim"); + } + + // Unpack/validate the token content. + const { id, email, username, displayName, avatar }: SSOUserProfile = validate( + tenant.auth.integrations.sso!.displayNameEnable + ? SSODisplayNameUserProfileSchema + : SSOUserProfileSchema, + token.user + ); + + const profile: SSOProfile = { + type: "sso", + id, + }; + + // Try to lookup user given their id provided in the `sub` claim. + let user = await retrieveUserWithProfile(db, tenant.id, profile); + if (!user) { + // FIXME: (wyattjoh) implement rules! Not all users should be able to create an account via this method. + + // Create the new user, as one didn't exist before! + user = await upsert(db, tenant, { + username, + // When the displayName is disabled on the tenant, the displayName will + // never be set (or even stored in the database). + displayName, + role: GQLUSER_ROLE.COMMENTER, + email, + avatar, + profiles: [profile], + }); + } + + // TODO: (wyattjoh) possibly update the user profile if the remaining details mismatch? + + return user; +} + +/** + * isSSOUserProfile will check if the given profile is a SSOUserProfile. + * + * @param profile the profile to check for the type + */ +export function isSSOUserProfile( + profile: SSOUserProfile | object +): profile is SSOUserProfile { + return ( + typeof (profile as SSOUserProfile).id !== "undefined" && + typeof (profile as SSOUserProfile).email !== "undefined" && + typeof (profile as SSOUserProfile).username !== "undefined" + ); +} + +export function isSSOToken(token: SSOToken | object): token is SSOToken { + return ( + typeof (token as SSOToken).user === "object" && + isSSOUserProfile((token as SSOToken).user) + ); +} + +export default class SSOStrategy extends Strategy { + public name: string; + + private db: Db; + + constructor({ db }: SSOStrategyOptions) { + super(); + + this.name = "sso"; + this.db = db; + } + + /** + * retrieves the integration's secret to be used to verify the token. + */ + private getSigningSecretGetter = (tenant: Tenant) => async ( + headers: { kid?: string }, + done: KeyFunctionCallback + ) => { + const integration = tenant.auth.integrations.sso; + if (!integration) { + // TODO: (wyattjoh) return a better error. + return done(new Error("integration not found")); + } + + if (!integration.enabled) { + // TODO: (wyattjoh) return a better error. + return done(new Error("integration not enabled")); + } + + // TODO: (wyattjoh) do something with the kid... Lookup the secret or verify it matches what we have? + + return done(null, integration.key); + }; + + /** + * findOrCreateUser will interpret the token and use the correct strategy for + * retrieving/creating the user. + * + * @param tenant the tenant for the new/returning user + * @param token the token that was unpacked and validated from the sso strategy + */ + private async findOrCreateUser( + tenant: Tenant, + token: OIDCIDToken | SSOToken + ) { + if (isOIDCToken(token)) { + // The token provided for SSO contains an issuer claim. We're assuming + // that this request is associated with an OpenID Connect provider. + return findOrCreateOIDCUser(this.db, tenant, token); + } + + // Check to see if this token is a SSO Token or not, if it isn't error out. + if (!isSSOToken(token)) { + // TODO: (wyattjoh) return a better error. + throw new Error("token is invalid"); + } + + // The token provided does not confirm to the OpenID Connect provider + // spec, but id does conform to a SSOToken so we should expect the token to + // contain the user profile. + return findOrCreateSSOUser(this.db, tenant, token); + } + + public authenticate(req: Request) { + const { tenant } = req; + if (!tenant) { + // TODO: (wyattjoh) return a better error. + return this.error(new Error("tenant not found")); + } + + // Lookup the token. + const token = extractJWTFromRequest(req); + if (!token) { + // TODO: (wyattjoh) return a better error. + return this.fail(new Error("no token on request"), 400); + } + + // Perform the JWT validation. + jwt.verify( + token, + this.getSigningSecretGetter(tenant), + { + // Force the use of the HS256 algorithm. We can explore switching this + // out in the future.. + algorithms: ["HS256"], // TODO: (wyattjoh) investigate replacing algorithm. + }, + async (err: Error | undefined, decoded: OIDCIDToken | SSOToken) => { + if (err) { + // TODO: (wyattjoh) wrap error? + return this.error(err); + } + + try { + // Find or create the user based on the decoded token. + const user = await this.findOrCreateUser(tenant, decoded); + + // The user was found or created! + return this.success(user, null); + } catch (err) { + return this.error(err); + } + } + ); + } +} + +export function createSSOStrategy(options: SSOStrategyOptions) { + return new SSOStrategy(options); +} diff --git a/src/core/server/app/request/__snapshots__/body.spec.ts.snap b/src/core/server/app/request/__snapshots__/body.spec.ts.snap new file mode 100644 index 000000000..823d9cb68 --- /dev/null +++ b/src/core/server/app/request/__snapshots__/body.spec.ts.snap @@ -0,0 +1,3 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`throws an error for missing fields 1`] = `"child \\"d\\" fails because [\\"d\\" is required]"`; diff --git a/src/core/server/app/request/body.spec.ts b/src/core/server/app/request/body.spec.ts new file mode 100644 index 000000000..98fd1376d --- /dev/null +++ b/src/core/server/app/request/body.spec.ts @@ -0,0 +1,32 @@ +import Joi from "joi"; + +import { validate } from "talk-server/app/request/body"; + +it("strips out unknown fields", () => { + const payload = { a: 1, b: 2, c: 3 }; + const schema = Joi.object().keys({}); + + expect(validate(schema, payload)).toEqual({}); +}); + +it("allows valid fields", () => { + const payload = { a: 1, b: 2, c: 3 }; + const schema = Joi.object().keys({ a: Joi.number() }); + + expect(validate(schema, payload)).toEqual({ a: 1 }); +}); + +it("allows valid fields from extended schema", () => { + const payload = { a: 1, b: 2, c: 3 }; + const schema = Joi.object().keys({ a: Joi.number() }); + const extendedSchema = schema.keys({ b: Joi.number() }); + + expect(validate(extendedSchema, payload)).toEqual({ a: 1, b: 2 }); +}); + +it("throws an error for missing fields", () => { + const payload = { a: 1, b: 2, c: 3 }; + const schema = Joi.object().keys({ d: Joi.number() }); + + expect(() => validate(schema, payload)).toThrowErrorMatchingSnapshot(); +}); diff --git a/src/core/server/app/request/body.ts b/src/core/server/app/request/body.ts new file mode 100644 index 000000000..a8e34412c --- /dev/null +++ b/src/core/server/app/request/body.ts @@ -0,0 +1,24 @@ +import Joi from "joi"; + +/** + * validate will strip unknown fields and perform validation against it. It will + * throw any error encountered. + * + * @param schema the Joi schema to validate against + * @param body the body to parse and strip of unknown fields + */ +export const validate = (schema: Joi.SchemaLike, body: any) => { + // Extract the schema from the request. + const { value, error: err } = Joi.validate(body, schema, { + stripUnknown: true, + presence: "required", + abortEarly: false, + }); + + if (err) { + // TODO: wrap error? + throw err; + } + + return value; +}; diff --git a/src/core/server/app/router.ts b/src/core/server/app/router.ts index fd481ee5c..085b9e4f9 100644 --- a/src/core/server/app/router.ts +++ b/src/core/server/app/router.ts @@ -1,5 +1,10 @@ import express from "express"; +import passport from "passport"; +import { signupHandler } from "talk-server/app/handlers/auth/local"; +import { apiErrorHandler } from "talk-server/app/middleware/error"; +import { errorLogger } from "talk-server/app/middleware/logging"; +import { wrapAuthn } from "talk-server/app/middleware/passport"; import tenantMiddleware from "talk-server/app/middleware/tenant"; import managementGraphMiddleware from "talk-server/graph/management/middleware"; import tenantGraphMiddleware from "talk-server/graph/tenant/middleware"; @@ -7,7 +12,7 @@ import tenantGraphMiddleware from "talk-server/graph/tenant/middleware"; import { AppOptions } from "./index"; import playground from "./middleware/playground"; -async function createManagementRouter(opts: AppOptions) { +async function createManagementRouter(app: AppOptions, options: RouterOptions) { const router = express.Router(); // Management API @@ -15,51 +20,96 @@ async function createManagementRouter(opts: AppOptions) { "/graphql", express.json(), await managementGraphMiddleware( - opts.schemas.management, - opts.config, - opts.mongo + app.schemas.management, + app.config, + app.mongo ) ); return router; } -async function createTenantRouter(opts: AppOptions) { +async function createTenantRouter(app: AppOptions, options: RouterOptions) { const router = express.Router(); // Tenant identification middleware. - router.use(tenantMiddleware({ db: opts.mongo })); + router.use(tenantMiddleware({ db: app.mongo })); + + // Setup Passport middleware. + router.use(options.passport.initialize()); + + // Setup auth routes. + router.use("/auth", createNewAuthRouter(app, options)); // Tenant API router.use( "/graphql", express.json(), - await tenantGraphMiddleware(opts.schemas.tenant, opts.config, opts.mongo) + // Any users may submit their GraphQL requests with authentication, this + // middleware will unpack their user into the request. + options.passport.authenticate("jwt", { session: false }), + await tenantGraphMiddleware(app.schemas.tenant, app.config, app.mongo) ); return router; } -async function createAPIRouter(opts: AppOptions) { - // Create a router. +function createNewAuthRouter(app: AppOptions, options: RouterOptions) { const router = express.Router(); - // Configure the tenant routes. - router.use("/tenant", await createTenantRouter(opts)); - - // Configure the management routes. - router.use("/management", await createManagementRouter(opts)); + // Mount the passport routes. + router.post( + "/local", + express.json(), + wrapAuthn(options.passport, app.signingConfig, "local") + ); + router.post( + "/local/signup", + express.json(), + signupHandler({ db: app.mongo, signingConfig: app.signingConfig }) + ); + router.post("/sso", wrapAuthn(options.passport, app.signingConfig, "sso")); + router.get("/oidc", wrapAuthn(options.passport, app.signingConfig, "oidc")); + router.get( + "/oidc/callback", + wrapAuthn(options.passport, app.signingConfig, "oidc") + ); return router; } -export async function createRouter(opts: AppOptions) { +async function createAPIRouter(app: AppOptions, options: RouterOptions) { // Create a router. const router = express.Router(); - router.use("/api", await createAPIRouter(opts)); + // Configure the tenant routes. + router.use("/tenant", await createTenantRouter(app, options)); - if (opts.config.get("env") === "development") { + // Configure the management routes. + router.use("/management", await createManagementRouter(app, options)); + + // General API error handler. + router.use(errorLogger); + router.use(apiErrorHandler); + + return router; +} + +export interface RouterOptions { + /** + * passport is the instance of the Authenticator that can be used to create + * and mount new authentication middleware. + */ + passport: passport.Authenticator; +} + +export async function createRouter(app: AppOptions, options: RouterOptions) { + // Create a router. + const router = express.Router(); + + router.use("/api", await createAPIRouter(app, options)); + + if (app.config.get("env") === "development") { // Tenant GraphiQL router.get( "/tenant/graphiql", diff --git a/src/core/server/app/url.ts b/src/core/server/app/url.ts new file mode 100644 index 000000000..b656e4421 --- /dev/null +++ b/src/core/server/app/url.ts @@ -0,0 +1,12 @@ +import { Request } from "talk-server/types/express"; +import { URL } from "url"; + +export function reconstructURL(req: Request, path: string = "/"): string { + const scheme = req.secure ? "https" : "http"; + const host = req.get("host"); + const base = `${scheme}://${host}`; + + const url = new URL(path, base); + + return url.href; +} diff --git a/src/core/server/config.ts b/src/core/server/config.ts index f82bb46b8..d4b5ca3fc 100644 --- a/src/core/server/config.ts +++ b/src/core/server/config.ts @@ -1,11 +1,6 @@ import convict from "convict"; -import dotenv from "dotenv"; import Joi from "joi"; -// Apply all the configuration provided in the .env file if it isn't already in -// the environment. -dotenv.config(); - // Add custom format for the mongo uri scheme. convict.addFormat({ name: "mongo-uri", @@ -60,12 +55,29 @@ const config = convict({ env: "REDIS", arg: "redis", }, - secret: { - doc: "The secret used to sign and verify JWTs", + signing_secret: { + doc: "", format: "*", - default: null, - env: "SECRET", - arg: "secret", + default: "keyboard cat", // TODO: (wyattjoh) evaluate best solution + env: "SIGNING_SECRET", + arg: "signingSecret", + }, + signing_algorithm: { + doc: "", + format: [ + "HS256", + "HS384", + "HS512", + "RS256", + "RS384", + "RS512", + "ES256", + "ES384", + "ES512", + ], + default: "HS256", + env: "SIGNING_ALGORITHM", + arg: "signingAlgorithm", }, logging_level: { doc: "The logging level to print to the console", diff --git a/src/core/server/graph/common/context.ts b/src/core/server/graph/common/context.ts new file mode 100644 index 000000000..d939d7b64 --- /dev/null +++ b/src/core/server/graph/common/context.ts @@ -0,0 +1,13 @@ +import { User } from "talk-server/models/user"; + +export interface CommonContextOptions { + user?: User; +} + +export default class CommonContext { + public user?: User; + + constructor({ user }: CommonContextOptions) { + this.user = user; + } +} diff --git a/src/core/server/graph/common/directives/auth.ts b/src/core/server/graph/common/directives/auth.ts new file mode 100644 index 000000000..2b21a6366 --- /dev/null +++ b/src/core/server/graph/common/directives/auth.ts @@ -0,0 +1,39 @@ +import { DirectiveResolverFn } from "graphql-tools"; + +import CommonContext from "talk-server/graph/common/context"; +import { GQLUSER_ROLE } from "talk-server/graph/tenant/schema/__generated__/types"; + +export interface AuthDirectiveArgs { + roles?: GQLUSER_ROLE[]; + userIDField?: string; +} + +const auth: DirectiveResolverFn< + Record, + CommonContext +> = (next, src, { roles, userIDField }: AuthDirectiveArgs, { user }) => { + // If there is a user on the request. + if (user) { + // If the role and user owner checks are disabled, then allow them based on + // their authenticated status. + if (!roles && !userIDField) { + return next(); + } + + // And the user has the expected role. + if (roles && roles.includes(user.role)) { + // Let the request continue. + return next(); + } + + // Or the item is owned by the specific user. + if (userIDField && src[userIDField] && src[userIDField] === user.id) { + return next(); + } + } + + // TODO: return better error. + throw new Error("not authorized"); +}; + +export default auth; diff --git a/src/core/server/graph/common/scalars/cursor.spec.ts b/src/core/server/graph/common/scalars/cursor.spec.ts new file mode 100644 index 000000000..6d3b807f2 --- /dev/null +++ b/src/core/server/graph/common/scalars/cursor.spec.ts @@ -0,0 +1,136 @@ +import { Kind } from "graphql"; +import { DateTime } from "luxon"; + +import Cursor from "./cursor"; + +describe("parseLiteral", () => { + it("parses a date from a string", () => { + expect( + Cursor.parseLiteral({ + kind: Kind.STRING, + value: "2018-07-16T18:34:26.744Z", + }) + ).toBeInstanceOf(Date); + + expect( + Cursor.parseLiteral({ + kind: Kind.STRING, + value: "this-should-fail", + }) + ).toEqual(null); + + expect( + Cursor.parseLiteral({ + kind: Kind.STRING, + value: "", + }) + ).toEqual(null); + }); + + it("parses a number from a string", () => { + expect( + Cursor.parseLiteral({ + kind: Kind.STRING, + value: "20", + }) + ).toEqual(20); + + expect( + Cursor.parseLiteral({ + kind: Kind.STRING, + value: "0", + }) + ).toEqual(0); + + expect( + Cursor.parseLiteral({ + kind: Kind.STRING, + value: "null", + }) + ).toEqual(null); + + expect( + Cursor.parseLiteral({ + kind: Kind.STRING, + value: "0", + }) + ).toEqual(0); + }); + + it("parses a number from a number", () => { + expect( + Cursor.parseLiteral({ + kind: Kind.INT, + value: "20", + }) + ).toEqual(20); + + expect( + Cursor.parseLiteral({ + kind: Kind.INT, + value: "0", + }) + ).toEqual(0); + + expect( + Cursor.parseLiteral({ + kind: Kind.INT, + value: "", + }) + ).toEqual(null); + }); + + it("does not parse unknown kinds", () => { + expect( + Cursor.parseLiteral({ + kind: Kind.FLOAT, + value: "0.0", + }) + ).toEqual(null); + }); +}); + +describe("serialize", () => { + it("renders native dates correctly", () => { + const date = new Date(); + const expected = date.toISOString(); + expect(Cursor.serialize(date)).toEqual(expected); + + expect(Cursor.serialize({})).toEqual(null); + }); + + it("renders luxon dates correctly", () => { + const date = DateTime.fromJSDate(new Date()); + const expected = date.toISO(); + expect(Cursor.serialize(date)).toEqual(expected); + }); + + it("renders numbers correctly", () => { + let value = 50; + let expected = "50"; + expect(Cursor.serialize(value)).toEqual(expected); + + value = 0; + expected = "0"; + expect(Cursor.serialize(value)).toEqual(expected); + + expect(Cursor.serialize(null)).toEqual(null); + }); +}); + +describe("parseValue", () => { + it("parses the string value of a Date", () => { + const date = new Date(); + const expected = date.toISOString(); + expect(Cursor.parseValue(expected)).toBeInstanceOf(Date); + }); + + it("parses the string value of a number", () => { + expect(Cursor.parseValue("0")).toEqual(0); + }); + + it("handles invalid properties", () => { + expect(Cursor.parseValue(null)).toEqual(null); + expect(Cursor.parseValue(2)).toEqual(null); + }); +}); diff --git a/src/core/server/graph/common/scalars/cursor.ts b/src/core/server/graph/common/scalars/cursor.ts index 23649e10b..e4aeaab74 100644 --- a/src/core/server/graph/common/scalars/cursor.ts +++ b/src/core/server/graph/common/scalars/cursor.ts @@ -1,11 +1,15 @@ import { GraphQLScalarType } from "graphql"; import { Kind } from "graphql/language"; import { DateTime } from "luxon"; + import { Cursor } from "talk-server/models/connection"; function parseIntegerCursor(value: string): number | null { try { const cursor = parseInt(value, 10); + if (isNaN(cursor)) { + return null; + } return cursor; } catch (err) { diff --git a/src/core/server/graph/management/context.ts b/src/core/server/graph/management/context.ts index dda52cb5b..c77f1a207 100644 --- a/src/core/server/graph/management/context.ts +++ b/src/core/server/graph/management/context.ts @@ -1,13 +1,16 @@ import { Db } from "mongodb"; +import CommonContext from "talk-server/graph/common/context"; export interface ManagementContextOptions { db: Db; } -export default class ManagementContext { +export default class ManagementContext extends CommonContext { public db: Db; constructor({ db }: ManagementContextOptions) { + super({}); + this.db = db; } } diff --git a/src/core/server/graph/management/resolvers/index.ts b/src/core/server/graph/management/resolvers/index.ts index 42b27b820..b1bcbcef8 100644 --- a/src/core/server/graph/management/resolvers/index.ts +++ b/src/core/server/graph/management/resolvers/index.ts @@ -1,5 +1,5 @@ -import Cursor from "../../common/scalars/cursor"; +import { GQLResolver } from "talk-server/graph/management/schema/__generated__/types"; -export default { - Cursor, -}; +const Resolvers: GQLResolver = {}; + +export default Resolvers; diff --git a/src/core/server/graph/management/schema/index.ts b/src/core/server/graph/management/schema/index.ts index 6dccc1c9c..41c6fe4ef 100644 --- a/src/core/server/graph/management/schema/index.ts +++ b/src/core/server/graph/management/schema/index.ts @@ -1,6 +1,8 @@ +import { IResolvers } from "graphql-tools"; + import { loadSchema } from "talk-common/graphql"; import resolvers from "talk-server/graph/management/resolvers"; export default function getManagementSchema() { - return loadSchema("management", resolvers); + return loadSchema("management", resolvers as IResolvers); } diff --git a/src/core/server/graph/management/schema/schema.graphql b/src/core/server/graph/management/schema/schema.graphql index 67655516d..e07ccbdd9 100644 --- a/src/core/server/graph/management/schema/schema.graphql +++ b/src/core/server/graph/management/schema/schema.graphql @@ -7,27 +7,22 @@ Time represented as an ISO8601 string. """ scalar Time -""" -Cursor represents a paginating cursor. -""" -scalar Cursor - ################################################################################ ## Tenant ################################################################################ type Tenant { - id: ID! + id: ID! - """ - organizationName is the name of the organization. - """ - organizationName: String + """ + organizationName is the name of the organization. + """ + organizationName: String - """ - organizationContactEmail is the email of the organization. - """ - organizationContactEmail: String + """ + organizationContactEmail is the email of the organization. + """ + organizationContactEmail: String } ################################################################################ @@ -35,5 +30,5 @@ type Tenant { ################################################################################ type Query { - tenant(id: ID!): Tenant + tenant(id: ID!): Tenant } diff --git a/src/core/server/graph/tenant/context.ts b/src/core/server/graph/tenant/context.ts index c49b3d49a..0f51eb529 100644 --- a/src/core/server/graph/tenant/context.ts +++ b/src/core/server/graph/tenant/context.ts @@ -1,4 +1,5 @@ import { Db } from "mongodb"; +import CommonContext from "talk-server/graph/common/context"; import { Tenant } from "talk-server/models/tenant"; import { User } from "talk-server/models/user"; import loaders from "./loaders"; @@ -10,14 +11,16 @@ export interface TenantContextOptions { user?: User; } -export default class TenantContext { +export default class TenantContext extends CommonContext { public loaders: ReturnType; public mutators: ReturnType; public db: Db; - public tenant: Tenant; public user?: User; + public tenant: Tenant; constructor({ user, tenant, db }: TenantContextOptions) { + super({ user }); + this.tenant = tenant; this.user = user; this.loaders = loaders(this); diff --git a/src/core/server/graph/tenant/loaders/assets.ts b/src/core/server/graph/tenant/loaders/assets.ts index 55c50a0dc..b747a1be2 100644 --- a/src/core/server/graph/tenant/loaders/assets.ts +++ b/src/core/server/graph/tenant/loaders/assets.ts @@ -1,8 +1,16 @@ import DataLoader from "dataloader"; + import TenantContext from "talk-server/graph/tenant/context"; -import { Asset, retrieveManyAssets } from "talk-server/models/asset"; +import { + Asset, + FindOrCreateAssetInput, + retrieveManyAssets, +} from "talk-server/models/asset"; +import { findOrCreate } from "talk-server/services/assets"; export default (ctx: TenantContext) => ({ + findOrCreate: (input: FindOrCreateAssetInput) => + findOrCreate(ctx.db, ctx.tenant, input), asset: new DataLoader(ids => retrieveManyAssets(ctx.db, ctx.tenant.id, ids) ), diff --git a/src/core/server/graph/tenant/loaders/comments.ts b/src/core/server/graph/tenant/loaders/comments.ts index 6649a50ab..719046a83 100644 --- a/src/core/server/graph/tenant/loaders/comments.ts +++ b/src/core/server/graph/tenant/loaders/comments.ts @@ -1,18 +1,48 @@ import DataLoader from "dataloader"; + import Context from "talk-server/graph/tenant/context"; import { - ConnectionInput, - retrieveAssetConnection, - retrieveMany, - retrieveRepliesConnection, + AssetToCommentsArgs, + CommentToRepliesArgs, + GQLCOMMENT_SORT, +} from "talk-server/graph/tenant/schema/__generated__/types"; +import { + retrieveCommentAssetConnection, + retrieveCommentRepliesConnection, + retrieveManyComments, } from "talk-server/models/comment"; export default (ctx: Context) => ({ comment: new DataLoader((ids: string[]) => - retrieveMany(ctx.db, ctx.tenant.id, ids) + retrieveManyComments(ctx.db, ctx.tenant.id, ids) ), - forAsset: (assetID: string, input: ConnectionInput) => - retrieveAssetConnection(ctx.db, ctx.tenant.id, assetID, input), - forParent: (assetID: string, parentID: string, input: ConnectionInput) => - retrieveRepliesConnection(ctx.db, ctx.tenant.id, assetID, parentID, input), + forAsset: ( + assetID: string, + // Apply the graph schema defaults at the loader. + { + first = 10, + orderBy = GQLCOMMENT_SORT.CREATED_AT_DESC, + after, + }: AssetToCommentsArgs + ) => + retrieveCommentAssetConnection(ctx.db, ctx.tenant.id, assetID, { + first, + orderBy, + after, + }), + forParent: ( + assetID: string, + parentID: string, + // Apply the graph schema defaults at the loader. + { + first = 10, + orderBy = GQLCOMMENT_SORT.CREATED_AT_DESC, + after, + }: CommentToRepliesArgs + ) => + retrieveCommentRepliesConnection(ctx.db, ctx.tenant.id, assetID, parentID, { + first, + orderBy, + after, + }), }); diff --git a/src/core/server/graph/tenant/loaders/users.ts b/src/core/server/graph/tenant/loaders/users.ts index d875bc6b4..34bbebc10 100644 --- a/src/core/server/graph/tenant/loaders/users.ts +++ b/src/core/server/graph/tenant/loaders/users.ts @@ -1,9 +1,9 @@ import DataLoader from "dataloader"; import Context from "talk-server/graph/tenant/context"; -import { retrieveMany, User } from "talk-server/models/user"; +import { retrieveManyUsers, User } from "talk-server/models/user"; export default (ctx: Context) => ({ user: new DataLoader(ids => - retrieveMany(ctx.db, ctx.tenant.id, ids) + retrieveManyUsers(ctx.db, ctx.tenant.id, ids) ), }); diff --git a/src/core/server/graph/tenant/mutators/comment.ts b/src/core/server/graph/tenant/mutators/comment.ts index 1046930b9..1ad380a78 100644 --- a/src/core/server/graph/tenant/mutators/comment.ts +++ b/src/core/server/graph/tenant/mutators/comment.ts @@ -1,12 +1,12 @@ import TenantContext from "talk-server/graph/tenant/context"; -import { CreateCommentInput } from "talk-server/graph/tenant/resolvers/mutation"; +import { GQLCreateCommentInput } from "talk-server/graph/tenant/schema/__generated__/types"; import { Comment } from "talk-server/models/comment"; import { create } from "talk-server/services/comments"; export default (ctx: TenantContext) => ({ - create: (input: CreateCommentInput): Promise => { + create: (input: GQLCreateCommentInput): Promise => { // FIXME: remove tenant + user ! - return create(ctx.db, ctx.tenant!.id, { + return create(ctx.db, ctx.tenant, { author_id: ctx.user!.id, asset_id: input.assetID, body: input.body, diff --git a/src/core/server/graph/tenant/resolvers/asset.ts b/src/core/server/graph/tenant/resolvers/asset.ts index 7f17e8861..e5487ba08 100644 --- a/src/core/server/graph/tenant/resolvers/asset.ts +++ b/src/core/server/graph/tenant/resolvers/asset.ts @@ -1,10 +1,11 @@ -import Context from "talk-server/graph/tenant/context"; +import { GQLAssetTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types"; import { Asset } from "talk-server/models/asset"; -import { ConnectionInput } from "talk-server/models/comment"; -export default { - comments: async (asset: Asset, input: ConnectionInput, ctx: Context) => +const Asset: GQLAssetTypeResolver = { + comments: (asset, input, ctx) => ctx.loaders.Comments.forAsset(asset.id, input), // TODO: implement this. isClosed: () => false, }; + +export default Asset; diff --git a/src/core/server/graph/tenant/resolvers/auth_integrations.ts b/src/core/server/graph/tenant/resolvers/auth_integrations.ts new file mode 100644 index 000000000..6842b891b --- /dev/null +++ b/src/core/server/graph/tenant/resolvers/auth_integrations.ts @@ -0,0 +1,14 @@ +import { GQLAuthIntegrationsTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types"; +import { AuthIntegration, AuthIntegrations } from "talk-server/models/tenant"; + +const disabled: AuthIntegration = { enabled: false }; + +const AuthIntegrations: GQLAuthIntegrationsTypeResolver = { + local: auth => auth.local || disabled, + sso: auth => auth.sso || disabled, + oidc: auth => auth.oidc || disabled, + google: auth => auth.google || disabled, + facebook: auth => auth.facebook || disabled, +}; + +export default AuthIntegrations; diff --git a/src/core/server/graph/tenant/resolvers/auth_settings.ts b/src/core/server/graph/tenant/resolvers/auth_settings.ts new file mode 100644 index 000000000..ed8ec8659 --- /dev/null +++ b/src/core/server/graph/tenant/resolvers/auth_settings.ts @@ -0,0 +1,8 @@ +import { GQLAuthSettingsTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types"; +import { Auth } from "talk-server/models/tenant"; + +const AuthSettings: GQLAuthSettingsTypeResolver = { + integrations: auth => auth.integrations, +}; + +export default AuthSettings; diff --git a/src/core/server/graph/tenant/resolvers/comment.ts b/src/core/server/graph/tenant/resolvers/comment.ts index d4033e0cb..3ecf35f41 100644 --- a/src/core/server/graph/tenant/resolvers/comment.ts +++ b/src/core/server/graph/tenant/resolvers/comment.ts @@ -1,11 +1,12 @@ -import Context from "talk-server/graph/tenant/context"; -import { Comment, ConnectionInput } from "talk-server/models/comment"; +import { GQLCommentTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types"; +import { Comment } from "talk-server/models/comment"; -export default { - createdAt: async (comment: Comment, _: any, ctx: Context) => - comment.created_at, - author: async (comment: Comment, _: any, ctx: Context) => +const Comment: GQLCommentTypeResolver = { + createdAt: comment => comment.created_at, + author: (comment, input, ctx) => ctx.loaders.Users.user.load(comment.author_id), - replies: async (comment: Comment, input: ConnectionInput, ctx: Context) => + replies: (comment, input, ctx) => ctx.loaders.Comments.forParent(comment.asset_id, comment.id, input), }; + +export default Comment; diff --git a/src/core/server/graph/tenant/resolvers/facebook_auth_integration.ts b/src/core/server/graph/tenant/resolvers/facebook_auth_integration.ts new file mode 100644 index 000000000..c0a127d82 --- /dev/null +++ b/src/core/server/graph/tenant/resolvers/facebook_auth_integration.ts @@ -0,0 +1,10 @@ +import { GQLFacebookAuthIntegrationTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types"; +import { FacebookAuthIntegration } from "talk-server/models/tenant"; + +const FacebookAuthIntegration: GQLFacebookAuthIntegrationTypeResolver< + FacebookAuthIntegration +> = { + config: auth => auth, +}; + +export default FacebookAuthIntegration; diff --git a/src/core/server/graph/tenant/resolvers/google_auth_integration.ts b/src/core/server/graph/tenant/resolvers/google_auth_integration.ts new file mode 100644 index 000000000..8edbabcf6 --- /dev/null +++ b/src/core/server/graph/tenant/resolvers/google_auth_integration.ts @@ -0,0 +1,10 @@ +import { GQLGoogleAuthIntegrationTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types"; +import { GoogleAuthIntegration } from "talk-server/models/tenant"; + +const GoogleAuthIntegration: GQLGoogleAuthIntegrationTypeResolver< + GoogleAuthIntegration +> = { + config: auth => auth, +}; + +export default GoogleAuthIntegration; diff --git a/src/core/server/graph/tenant/resolvers/index.ts b/src/core/server/graph/tenant/resolvers/index.ts index d1143f1ff..dd0a09082 100644 --- a/src/core/server/graph/tenant/resolvers/index.ts +++ b/src/core/server/graph/tenant/resolvers/index.ts @@ -1,13 +1,33 @@ -import Cursor from "../../common/scalars/cursor"; -import Asset from "./asset"; -import Comment from "./comment"; -import Mutation from "./mutation"; -import Query from "./query"; +import Cursor from "talk-server/graph/common/scalars/cursor"; +import { GQLResolver } from "talk-server/graph/tenant/schema/__generated__/types"; -export default { +import Asset from "./asset"; +import AuthIntegrations from "./auth_integrations"; +import AuthSettings from "./auth_settings"; +import Comment from "./comment"; +import FacebookAuthIntegration from "./facebook_auth_integration"; +import GoogleAuthIntegration from "./google_auth_integration"; +import LocalAuthIntegration from "./local_auth_integration"; +import Mutation from "./mutation"; +import OIDCAuthIntegration from "./oidc_auth_integration"; +import Profile from "./profile"; +import Query from "./query"; +import SSOAuthIntegration from "./sso_auth_integration"; + +const Resolvers: GQLResolver = { Asset, + AuthIntegrations, + AuthSettings, Comment, + FacebookAuthIntegration, + GoogleAuthIntegration, + LocalAuthIntegration, + OIDCAuthIntegration, + SSOAuthIntegration, Cursor, - Query, Mutation, + Profile, + Query, }; + +export default Resolvers; diff --git a/src/core/server/graph/tenant/resolvers/local_auth_integration.ts b/src/core/server/graph/tenant/resolvers/local_auth_integration.ts new file mode 100644 index 000000000..fe14c7b92 --- /dev/null +++ b/src/core/server/graph/tenant/resolvers/local_auth_integration.ts @@ -0,0 +1,8 @@ +import { GQLLocalAuthIntegrationTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types"; +import { LocalAuthIntegration } from "talk-server/models/tenant"; + +const LocalAuthIntegration: GQLLocalAuthIntegrationTypeResolver< + LocalAuthIntegration +> = {}; + +export default LocalAuthIntegration; diff --git a/src/core/server/graph/tenant/resolvers/mutation.ts b/src/core/server/graph/tenant/resolvers/mutation.ts index 4d3b82a76..f51f0fc1b 100644 --- a/src/core/server/graph/tenant/resolvers/mutation.ts +++ b/src/core/server/graph/tenant/resolvers/mutation.ts @@ -1,23 +1,7 @@ -import { ClientMutationProps } from "talk-server/graph/common/resolvers/mutation"; -import TenantContext from "talk-server/graph/tenant/context"; -import { Comment } from "talk-server/models/comment"; +import { GQLMutationTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types"; -export interface CreateCommentInput extends ClientMutationProps { - assetID: string; - parentID?: string; - body: string; -} - -export interface CreateCommentPayload extends ClientMutationProps { - comment: Comment; -} - -const Mutation = { - createComment: async ( - source: void, - input: CreateCommentInput, - ctx: TenantContext - ): Promise => ({ +const Mutation: GQLMutationTypeResolver = { + createComment: async (source, { input }, ctx) => ({ comment: await ctx.mutators.Comment.create(input), clientMutationId: input.clientMutationId, }), diff --git a/src/core/server/graph/tenant/resolvers/oidc_auth_integration.ts b/src/core/server/graph/tenant/resolvers/oidc_auth_integration.ts new file mode 100644 index 000000000..966001ab9 --- /dev/null +++ b/src/core/server/graph/tenant/resolvers/oidc_auth_integration.ts @@ -0,0 +1,10 @@ +import { GQLOIDCAuthIntegrationTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types"; +import { OIDCAuthIntegration } from "talk-server/models/tenant"; + +const OIDCAuthIntegration: GQLOIDCAuthIntegrationTypeResolver< + OIDCAuthIntegration +> = { + config: auth => auth, +}; + +export default OIDCAuthIntegration; diff --git a/src/core/server/graph/tenant/resolvers/profile.ts b/src/core/server/graph/tenant/resolvers/profile.ts new file mode 100644 index 000000000..c1c6ad13b --- /dev/null +++ b/src/core/server/graph/tenant/resolvers/profile.ts @@ -0,0 +1,21 @@ +import { GQLProfileTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types"; + +import { Profile } from "talk-server/models/user"; + +const resolveType: GQLProfileTypeResolver = profile => { + switch (profile.type) { + case "local": + return "LocalProfile"; + case "oidc": + return "OIDCProfile"; + case "sso": + return "SSOProfile"; + default: + // TODO: replace with better error. + throw new Error("invalid profile type"); + } +}; + +export default { + __resolveType: resolveType, +}; diff --git a/src/core/server/graph/tenant/resolvers/query.ts b/src/core/server/graph/tenant/resolvers/query.ts index ae76679a9..5dbd4bccc 100644 --- a/src/core/server/graph/tenant/resolvers/query.ts +++ b/src/core/server/graph/tenant/resolvers/query.ts @@ -1,10 +1,9 @@ -import TenantContext from "talk-server/graph/tenant/context"; +import { GQLQueryTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types"; -export default { - asset: async ( - source: void, - { id }: { id: string; url: string }, - ctx: TenantContext - ) => ctx.loaders.Assets.asset.load(id), - settings: async (parent: any, args: any, ctx: TenantContext) => ctx.tenant, +const Query: GQLQueryTypeResolver = { + asset: (source, args, ctx) => ctx.loaders.Assets.findOrCreate(args), + settings: (source, args, ctx) => ctx.tenant, + me: (source, args, ctx) => ctx.user, }; + +export default Query; diff --git a/src/core/server/graph/tenant/resolvers/sso_auth_integration.ts b/src/core/server/graph/tenant/resolvers/sso_auth_integration.ts new file mode 100644 index 000000000..562cad894 --- /dev/null +++ b/src/core/server/graph/tenant/resolvers/sso_auth_integration.ts @@ -0,0 +1,10 @@ +import { GQLSSOAuthIntegrationTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types"; +import { SSOAuthIntegration } from "talk-server/models/tenant"; + +const SSOAuthIntegration: GQLSSOAuthIntegrationTypeResolver< + SSOAuthIntegration +> = { + config: auth => auth, +}; + +export default SSOAuthIntegration; diff --git a/src/core/server/graph/tenant/schema/index.ts b/src/core/server/graph/tenant/schema/index.ts index 844e9a4b4..251298ee0 100644 --- a/src/core/server/graph/tenant/schema/index.ts +++ b/src/core/server/graph/tenant/schema/index.ts @@ -1,6 +1,14 @@ +import { attachDirectiveResolvers, IResolvers } from "graphql-tools"; + import { loadSchema } from "talk-common/graphql"; +import auth from "talk-server/graph/common/directives/auth"; import resolvers from "talk-server/graph/tenant/resolvers"; export default function getTenantSchema() { - return loadSchema("tenant", resolvers); + const schema = loadSchema("tenant", resolvers as IResolvers); + + // Attach the directive resolvers. + attachDirectiveResolvers(schema, { auth }); + + return schema; } diff --git a/src/core/server/graph/tenant/schema/schema.graphql b/src/core/server/graph/tenant/schema/schema.graphql index 5270f26fd..d8f082ff7 100644 --- a/src/core/server/graph/tenant/schema/schema.graphql +++ b/src/core/server/graph/tenant/schema/schema.graphql @@ -1,3 +1,16 @@ +################################################################################ +## Custom Directives +################################################################################ + +""" +auth is a directive that will enforce authorization rules on the schema +definition. It will restrict the viewer of the field based on roles or if the +`userIDField` is specified, it will see if the current users ID equals the field +specified. This allows users that own a specific resource (like a comment, or a +flag) see their own content, but restrict it to everyone else. +""" +directive @auth(roles: [USER_ROLE!], userIDField: String) on FIELD_DEFINITION + ################################################################################ ## Custom Scalar Types ################################################################################ @@ -31,9 +44,9 @@ enum MODERATION_MODE { } """ -Wordlist describes all the available wordlists. +WordlistSettings describes all the available wordlists. """ -type Wordlist { +type WordlistSettings { """ banned words will by default reject the comment if it is found. """ @@ -45,12 +58,129 @@ type Wordlist { suspect: [String!]! } -# Settings stores the global settings for a given installation. +################################################################################ +## AuthSettings +################################################################################ + +########################## +## LocalAuthIntegration +########################## + +type LocalAuthIntegration { + enabled: Boolean! +} + +########################## +## SSOAuthIntegration +########################## + +type SSOAuthIntegrationConfig { + key: String! + + """ + displayNameEnable when enabled, will allow Users to set and view their + displayName's. + """ + displayNameEnable: Boolean! +} + +type SSOAuthIntegration { + enabled: Boolean! + config: SSOAuthIntegrationConfig @auth(roles: [ADMIN]) +} + +########################## +## OIDCAuthIntegration +########################## + +type OIDCAuthIntegrationConfig { + clientID: String! + clientSecret: String! + authorizationURL: String! + tokenURL: String! + + """ + displayNameEnable when enabled, will allow Users to set and view their + displayName's. + """ + displayNameEnable: Boolean! +} + +type OIDCAuthIntegrationOptions { + name: String! +} + +type OIDCAuthIntegration { + enabled: Boolean! + options: OIDCAuthIntegrationOptions + config: SSOAuthIntegrationConfig @auth(roles: [ADMIN]) +} + +########################## +## GoogleAuthIntegration +########################## + +type GoogleAuthIntegrationConfig { + clientID: String! + clientSecret: String! +} + +type GoogleAuthIntegration { + enabled: Boolean! + config: GoogleAuthIntegrationConfig @auth(roles: [ADMIN]) +} + +########################## +## FacebookAuthIntegration +########################## + +type FacebookAuthIntegrationConfig { + clientID: String! + clientSecret: String! +} + +type FacebookAuthIntegration { + enabled: Boolean! + config: FacebookAuthIntegrationConfig @auth(roles: [ADMIN]) +} + +type AuthIntegrations { + local: LocalAuthIntegration! + sso: SSOAuthIntegration! + oidc: OIDCAuthIntegration! + google: GoogleAuthIntegration! + facebook: FacebookAuthIntegration! +} + +""" +AuthSettings contains all the settings related to authentication and +authorization. +""" +type AuthSettings { + """ + integrations are the set of configurations for the variations of + authentication solutions. + """ + integrations: AuthIntegrations! +} + +################################################################################ +## Settings +################################################################################ + +""" +Settings stores the global settings for a given Tenant. +""" type Settings { + """ + domain is the domain that is associated with this Tenant. + """ + domain: String @auth(roles: [ADMIN]) + """ moderation is the moderation mode for all Asset's on the site. """ - moderation: MODERATION_MODE! + moderation: MODERATION_MODE @auth(roles: [ADMIN]) """ Enables a requirement for email confirmation before a user can login. @@ -87,13 +217,13 @@ type Settings { """ premodLinksEnable will put all comments that contain links into premod. """ - premodLinksEnable: Boolean! + premodLinksEnable: Boolean @auth(roles: [ADMIN]) """ autoCloseStream when true will auto close the stream when the `closeTimeout` amount of seconds have been reached. """ - autoCloseStream: Boolean! + autoCloseStream: Boolean! @auth(roles: [ADMIN]) """ customCssUrl is the URL of the custom CSS used to display on the frontend. @@ -152,18 +282,79 @@ type Settings { """ wordlist will return a given list of words. """ - wordlist: Wordlist! + wordlist: WordlistSettings @auth(roles: [ADMIN, MODERATOR]) """ domains will return a given list of whitelisted domains. """ - domains: [String!]! + domains: [String!] @auth(roles: [ADMIN]) @auth(roles: [ADMIN]) + + """ + auth contains all the settings related to authentication and authorization. + """ + auth: AuthSettings! } ################################################################################ ## User ################################################################################ +enum USER_ROLE { + COMMENTER + MODERATOR + ADMIN +} + +enum USER_USERNAME_STATUS { + """ + UNSET is used when the username can be changed, and does not necessarily + require moderator action to become active. This can be used when the user + signs up with a social login and has the option of setting their own + username. + """ + UNSET + + """ + SET is used when the username has been set for the first time, but cannot + change without the username being rejected by a moderator and that moderator + agreeing that the username should be allowed to change. + """ + SET + + """ + APPROVED is used when the username was changed, and subsequently approved by + said moderator. + """ + APPROVED + + """ + REJECTED is used when the username was changed, and subsequently rejected by + said moderator. + """ + REJECTED + + """ + CHANGED is used after a user has changed their username after it was + rejected. + """ + CHANGED +} + +type LocalProfile { + id: String! +} + +type OIDCProfile { + id: String! + provider: String! +} + +type SSOProfile { + id: String! +} + +union Profile = LocalProfile | OIDCProfile | SSOProfile + """ User is someone that leaves Comments, and logs in. """ @@ -176,7 +367,22 @@ type User { """ username is the name of the User visible to other Users. """ - username: String! + username: String + + """ + displayName is provided optionally when enabled and available. + """ + displayName: String + + """ + profiles is the array of profiles assigned to the user. + """ + profiles: [Profile!] @auth(roles: [ADMIN, MODERATOR], userIDField: "id") + + """ + role is the current role of the User. + """ + role: USER_ROLE! @auth(roles: [ADMIN, MODERATOR], userIDField: "id") } ################################################################################ @@ -391,9 +597,9 @@ type Query { assets(cursor: Cursor, limit: Int = 10): AssetsConnection """ - asset is the Asset specified by its ID. + asset is the Asset specified by its ID/URL. """ - asset(id: ID!): Asset + asset(id: ID, url: String): Asset """ me is the current logged in User. @@ -463,7 +669,7 @@ type Mutation { """ createComment will create a Comment as the current logged in User. """ - createComment(input: CreateCommentInput!): CreateCommentPayload + createComment(input: CreateCommentInput!): CreateCommentPayload @auth } ################################################################################ diff --git a/src/core/server/index.ts b/src/core/server/index.ts index fdf4f3aea..56e92f7f7 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -1,9 +1,11 @@ import express, { Express } from "express"; import http from "http"; +import { createJWTSigningConfig } from "talk-server/app/middleware/passport/jwt"; import getManagementSchema from "talk-server/graph/management/schema"; import { Schemas } from "talk-server/graph/schemas"; import getTenantSchema from "talk-server/graph/tenant/schema"; + import { attachSubscriptionHandlers, createApp, listenAndServe } from "./app"; import config, { Config } from "./config"; import logger from "./logger"; @@ -63,6 +65,9 @@ class Server { // Setup Redis. const redis = await createRedisClient(config); + // Create the signing config. + const signingConfig = createJWTSigningConfig(this.config); + // Create the Talk App, branching off from the parent app. const app: Express = await createApp({ parent, @@ -70,6 +75,7 @@ class Server { redis, config: this.config, schemas: this.schemas, + signingConfig, }); // Start the application and store the resulting http.Server. diff --git a/src/core/server/logger.ts b/src/core/server/logger.ts index c3b50c355..7a9ee886e 100644 --- a/src/core/server/logger.ts +++ b/src/core/server/logger.ts @@ -1,5 +1,8 @@ import bunyan from "bunyan"; -const logger = bunyan.createLogger({ name: "talk" }); +const logger = bunyan.createLogger({ + name: "talk", + serializers: bunyan.stdSerializers, +}); export default logger; diff --git a/src/core/server/models/asset.ts b/src/core/server/models/asset.ts index e00dd2a56..b09583d9c 100644 --- a/src/core/server/models/asset.ts +++ b/src/core/server/models/asset.ts @@ -1,10 +1,10 @@ import dotize from "dotize"; import { defaults } from "lodash"; import { Db } from "mongodb"; +import uuid from "uuid"; + import { Omit } from "talk-common/types"; import { TenantResource } from "talk-server/models/tenant"; -import uuid from "uuid"; -import Query from "./query"; function collection(db: Db) { return db.collection>("assets"); @@ -27,48 +27,103 @@ export interface Asset extends TenantResource { created_at: Date; } -export type CreateAssetInput = Pick; +export interface UpsertAssetInput { + id?: string; + url: string; +} -export async function createAsset( +export async function upsertAsset( db: Db, tenantID: string, - input: CreateAssetInput + { id, url }: UpsertAssetInput ) { const now = new Date(); - // Construct the filter. - const query = new Query(collection(db)).where({ - tenant_id: tenantID, - }); - if (input.id) { - query.where({ id: input.id }); - } else { - query.where({ url: input.url }); - } + // TODO: verify that the url for the given Asset is whitelisted by the tenant. - // Craft the update object. + // Create the asset, optionally sourcing the id from the input, additionally + // porting in the tenant_id. const update: { $setOnInsert: Asset } = { - $setOnInsert: defaults(input, { - id: uuid.v4(), - tenant_id: tenantID, - created_at: now, - }), + $setOnInsert: defaults( + { + url, + tenant_id: tenantID, + created_at: now, + }, + { id }, + { + id: uuid.v4(), + } + ), }; - // Perform the upsert operation. - const result = await collection(db).findOneAndUpdate(query.filter, update, { - // Create the object if it doesn't already exist. - upsert: true, - // False to return the updated document instead of the original - // document. - returnOriginal: false, - }); + // Perform the find and update operation to try and find and or create the + // asset. + const { value: asset } = await collection(db).findOneAndUpdate( + { url }, + update, + { + // Create the object if it doesn't already exist. + upsert: true, - return result.value || null; + // False to return the updated document instead of the original + // document. + returnOriginal: false, + } + ); + if (!asset) { + return null; + } + + if (!asset.scraped) { + // TODO: create scrape job to collect asset metadata + } + + return asset; +} + +export interface FindOrCreateAssetInput { + id?: string; + url?: string; +} + +export async function findOrCreateAsset( + db: Db, + tenantID: string, + { id, url }: FindOrCreateAssetInput +) { + if (id) { + if (url) { + // The URL was specified, this is an upsert operation. + return upsertAsset(db, tenantID, { + id, + url, + }); + } + + // The URL was not specified, this is a lookup operation. + return retrieveAsset(db, tenantID, id); + } + + // The ID was not specified, this is an upsert operation. Check to see that + // the URL exists. + if (!url) { + throw new Error("cannot upsert an asset without the url"); + } + + return upsertAsset(db, tenantID, { url }); +} + +export async function retrieveAssetByURL( + db: Db, + tenantID: string, + url: string +) { + return collection(db).findOne({ url, tenant_id: tenantID }); } export async function retrieveAsset(db: Db, tenantID: string, id: string) { - return await collection(db).findOne({ id, tenant_id: tenantID }); + return collection(db).findOne({ id, tenant_id: tenantID }); } export async function retrieveManyAssets( @@ -86,6 +141,21 @@ export async function retrieveManyAssets( return ids.map(id => assets.find(asset => asset.id === id) || null); } +export async function retrieveManyAssetsByURL( + db: Db, + tenantID: string, + urls: string[] +) { + const cursor = await collection(db).find({ + url: { $in: urls }, + tenant_id: tenantID, + }); + + const assets = await cursor.toArray(); + + return urls.map(url => assets.find(asset => asset.url === url) || null); +} + export type UpdateAssetInput = Omit< Partial, "id" | "tenant_id" | "url" | "created_at" diff --git a/src/core/server/models/comment.ts b/src/core/server/models/comment.ts index 43c588509..875317b8d 100644 --- a/src/core/server/models/comment.ts +++ b/src/core/server/models/comment.ts @@ -1,6 +1,12 @@ import { merge } from "lodash"; import { Db } from "mongodb"; +import uuid from "uuid"; + import { Omit, Sub } from "talk-common/types"; +import { + GQLCOMMENT_SORT, + GQLCOMMENT_STATUS, +} from "talk-server/graph/tenant/schema/__generated__/types"; import { ActionCounts } from "talk-server/models/actions"; import { Connection, @@ -11,7 +17,6 @@ import { } from "talk-server/models/connection"; import Query from "talk-server/models/query"; import { TenantResource } from "talk-server/models/tenant"; -import uuid from "uuid"; function collection(db: Db) { return db.collection>("comments"); @@ -23,35 +28,25 @@ export interface BodyHistoryItem { } export interface StatusHistoryItem { - status: CommentStatus; // TODO: migrate field + status: GQLCOMMENT_STATUS; // TODO: migrate field assigned_by?: string; created_at: Date; } -export enum CommentStatus { - ACCEPTED = "ACCEPTED", - REJECTED = "REJECTED", - PREMOD = "PREMOD", - SYSTEM_WITHHELD = "SYSTEM_WITHHELD", - NONE = "NONE", -} - export interface Comment extends TenantResource { readonly id: string; - parent_id?: string; + parent_id: string | null; author_id: string; asset_id: string; body: string; body_history: BodyHistoryItem[]; - status: CommentStatus; + status: GQLCOMMENT_STATUS; status_history: StatusHistoryItem[]; action_counts: ActionCounts; reply_count: number; created_at: Date; deleted_at?: Date; - metadata?: { - [_: string]: any; - }; + metadata?: Record; } export type CreateCommentInput = Omit< @@ -64,7 +59,7 @@ export type CreateCommentInput = Omit< | "status_history" >; -export async function create( +export async function createComment( db: Db, tenantID: string, input: CreateCommentInput @@ -110,11 +105,15 @@ export async function create( return comment; } -export async function retrieve(db: Db, tenantID: string, id: string) { +export async function retrieveComment(db: Db, tenantID: string, id: string) { return collection(db).findOne({ id, tenant_id: tenantID }); } -export async function retrieveMany(db: Db, tenantID: string, ids: string[]) { +export async function retrieveManyComments( + db: Db, + tenantID: string, + ids: string[] +) { const cursor = await collection(db).find({ id: { $in: ids, @@ -127,16 +126,9 @@ export async function retrieveMany(db: Db, tenantID: string, ids: string[]) { return ids.map(id => comments.find(comment => comment.id === id) || null); } -export enum CommentSort { - CREATED_AT_DESC = "CREATED_AT_DESC", - CREATED_AT_ASC = "CREATED_AT_ASC", - REPLIES_DESC = "REPLIES_DESC", - RESPECT_DESC = "RESPECT_DESC", -} - export interface ConnectionInput { first: number; - orderBy: CommentSort; + orderBy: GQLCOMMENT_SORT; after?: Cursor; } @@ -144,11 +136,11 @@ function cursorGetterFactory( input: ConnectionInput ): NodeToCursorTransformer { switch (input.orderBy) { - case CommentSort.CREATED_AT_DESC: - case CommentSort.CREATED_AT_ASC: + case GQLCOMMENT_SORT.CREATED_AT_DESC: + case GQLCOMMENT_SORT.CREATED_AT_ASC: return comment => comment.created_at; - case CommentSort.REPLIES_DESC: - case CommentSort.RESPECT_DESC: + case GQLCOMMENT_SORT.REPLIES_DESC: + case GQLCOMMENT_SORT.RESPECT_DESC: return (_, index) => (input.after ? (input.after as number) : 0) + index + 1; } @@ -162,7 +154,7 @@ function cursorGetterFactory( * @param parentID the parent id for the comment to retrieve * @param input connection configuration */ -export async function retrieveRepliesConnection( +export async function retrieveCommentRepliesConnection( db: Db, tenantID: string, assetID: string, @@ -188,7 +180,7 @@ export async function retrieveRepliesConnection( * @param assetID the Asset id for the comment to retrieve * @param input connection configuration */ -export async function retrieveAssetConnection( +export async function retrieveCommentAssetConnection( db: Db, tenantID: string, assetID: string, @@ -254,25 +246,25 @@ async function retrieveConnection( function applyInputToQuery(input: ConnectionInput, query: Query) { switch (input.orderBy) { - case CommentSort.CREATED_AT_DESC: + case GQLCOMMENT_SORT.CREATED_AT_DESC: query.orderBy({ created_at: -1 }); if (input.after) { query.where({ created_at: { $lt: input.after as Date } }); } break; - case CommentSort.CREATED_AT_ASC: + case GQLCOMMENT_SORT.CREATED_AT_ASC: query.orderBy({ created_at: 1 }); if (input.after) { query.where({ created_at: { $gt: input.after as Date } }); } break; - case CommentSort.REPLIES_DESC: + case GQLCOMMENT_SORT.REPLIES_DESC: query.orderBy({ reply_count: -1, created_at: -1 }); if (input.after) { query.after(input.after as number); } break; - case CommentSort.RESPECT_DESC: + case GQLCOMMENT_SORT.RESPECT_DESC: query.orderBy({ "action_counts.respect": -1, created_at: -1 }); if (input.after) { query.after(input.after as number); diff --git a/src/core/server/models/tenant.ts b/src/core/server/models/tenant.ts index 8e41a1bf2..bc19c6553 100644 --- a/src/core/server/models/tenant.ts +++ b/src/core/server/models/tenant.ts @@ -1,9 +1,14 @@ import dotize from "dotize"; import { merge } from "lodash"; import { Db } from "mongodb"; -import { Sub } from "talk-common/types"; import uuid from "uuid"; +import { Sub } from "talk-common/types"; +import { + GQLMODERATION_MODE, + GQLUSER_ROLE, +} from "talk-server/graph/tenant/schema/__generated__/types"; + function collection(db: Db) { return db.collection>("tenants"); } @@ -17,11 +22,104 @@ export interface Wordlist { suspect: string[]; } -export enum Moderation { - PRE = "PRE", - POST = "POST", +// AuthIntegrations. + +export interface EmailDomainRuleCondition { + // emailDomain is the domain name component of the email addresses that should + // match for this condition. + emailDomain: string; + + // emailVerifiedRequired stipulates that this rule only applies when the user + // account has been marked as having their email address already verified. + emailVerifiedRequired: boolean; } +// RoleRule describes the role assignment for when a user logs into Talk, how +// they can have their account automatically upgraded to a specific role when +// the domain for their email matches the one provided. +export interface RoleRule extends Partial { + // role is the specific GQLUSER_ROLE that should be assigned to the newly created + // user depending on their email address. + role: GQLUSER_ROLE; +} + +export interface AuthRules { + // roles allow the configuration of automatic role assignment based on the + // user's email address. + roles?: RoleRule[]; + + // restrictTo when populated, will restrict which users can login using this + // integration. If a user successfully logs in using the OIDCStrategy, but + // does not match the following rules, the user will not be created. + restrictTo?: EmailDomainRuleCondition[]; +} + +export interface AuthIntegration { + enabled: boolean; +} + +export interface DisplayNameAuthIntegration { + displayNameEnable: boolean; +} + +// SSOAuthIntegration is an AuthIntegration that provides a secret to the admins +// of a tenant, where they can sign a SSO payload with it to provide to the +// embed to allow single sign on. +export interface SSOAuthIntegration + extends AuthIntegration, + DisplayNameAuthIntegration { + key: string; +} + +// OIDCAuthIntegration provides a way to store Open ID Connect credentials. This +// will be used in the admin to provide staff logins for users. +export interface OIDCAuthIntegration + extends AuthIntegration, + DisplayNameAuthIntegration { + clientID: string; + clientSecret: string; + issuer: string; + authorizationURL: string; + jwksURI: string; + tokenURL: string; +} + +export interface FacebookAuthIntegration extends AuthIntegration { + clientID: string; + clientSecret: string; +} + +export interface GoogleAuthIntegration extends AuthIntegration { + clientID: string; + clientSecret: string; +} + +export type LocalAuthIntegration = AuthIntegration; + +// AuthIntegrations describes all of the possible auth integration configurations. +export interface AuthIntegrations { + // local is the auth integration for the local auth. + local: LocalAuthIntegration; + + // sso is the external auth integration for the single sign on auth. + sso?: SSOAuthIntegration; + + // sso is the external auth integration for the OpenID Connect auth. + oidc?: OIDCAuthIntegration; + + // sso is the external auth integration for the Google auth. + google?: GoogleAuthIntegration; + + // sso is the external auth integration for the Facebook auth. + facebook?: FacebookAuthIntegration; +} + +export interface Auth { + integrations: AuthIntegrations; +} + +// Tenant definition. + export interface Tenant { readonly id: string; @@ -29,7 +127,7 @@ export interface Tenant { // specific tenant that the API request pertains to. domain: string; - moderation: Moderation; + moderation: GQLMODERATION_MODE; requireEmailConfirmation: boolean; infoBoxEnable: boolean; infoBoxContent?: string; @@ -57,6 +155,9 @@ export interface Tenant { // domains is the set of whitelisted domains. domains: string[]; + + // Set of configured authentication integrations. + auth: Auth; } /** @@ -81,7 +182,7 @@ export async function createTenant(db: Db, input: CreateTenantInput) { id: uuid.v4(), // Default to post moderation. - moderation: Moderation.POST, + moderation: GQLMODERATION_MODE.POST, // Email confirmation is default off. requireEmailConfirmation: false, @@ -99,6 +200,13 @@ export async function createTenant(db: Db, input: CreateTenantInput) { suspect: [], banned: [], }, + auth: { + integrations: { + local: { + enabled: true, + }, + }, + }, }; // Create the new Tenant by merging it together with the defaults. @@ -114,7 +222,7 @@ export async function retrieveTenantByDomain(db: Db, domain: string) { return collection(db).findOne({ domain }); } -export async function retrieve(db: Db, id: string) { +export async function retrieveTenant(db: Db, id: string) { return collection(db).findOne({ id }); } diff --git a/src/core/server/models/user.ts b/src/core/server/models/user.ts index 6812fefce..5adc4bc53 100644 --- a/src/core/server/models/user.ts +++ b/src/core/server/models/user.ts @@ -1,112 +1,107 @@ +import bcrypt from "bcryptjs"; import { merge } from "lodash"; import { Db } from "mongodb"; -import { Omit, Sub } from "talk-common/types"; -import { ActionCounts } from "talk-server/models/actions"; -import { TenantResource } from "talk-server/models/tenant"; import uuid from "uuid"; +import { Omit, Sub } from "talk-common/types"; +import { + GQLUSER_ROLE, + GQLUSER_USERNAME_STATUS, +} from "talk-server/graph/tenant/schema/__generated__/types"; +import { ActionCounts } from "talk-server/models/actions"; +import { FilterQuery } from "talk-server/models/query"; +import { TenantResource } from "talk-server/models/tenant"; + function collection(db: Db) { return db.collection>("users"); } -export interface Profile { - readonly id: string; - provider: string; +export interface LocalProfile { + type: "local"; + id: string; } +export interface OIDCProfile { + type: "oidc"; + id: string; + issuer: string; + audience: string; +} + +export interface SSOProfile { + type: "sso"; + id: string; +} + +export type Profile = LocalProfile | OIDCProfile | SSOProfile; + export interface Token { readonly id: string; name: string; active: boolean; } -export enum UserUsernameStatus { - // UNSET is used when the username can be changed, and does not necessarily - // require moderator action to become active. This can be used when the user - // signs up with a social login and has the option of setting their own - // username. - UNSET = "UNSET", - - // SET is used when the username has been set for the first time, but cannot - // change without the username being rejected by a moderator and that moderator - // agreeing that the username should be allowed to change. - SET = "SET", - - // APPROVED is used when the username was changed, and subsequently approved by - // said moderator. - APPROVED = "APPROVED", - - // REJECTED is used when the username was changed, and subsequently rejected by - // said moderator. - REJECTED = "REJECTED", - - // CHANGED is used after a user has changed their username after it was - // rejected. - CHANGED = "CHANGED", -} - -export enum UserRole { - ADMIN = "ADMIN", - MODERATOR = "MODERATOR", - STAFF = "STAFF", - COMMENTER = "COMMENTER", -} - export interface UserStatusHistory { - status: T; // TODO: migrate field + status: T; assigned_by?: string; - reason?: string; // TODO: migrate field + reason?: string; created_at: Date; } export interface UserStatusItem { - status: T; // TODO: migrate field + status: T; history: Array>; } export interface UserStatus { - username: UserStatusItem; + username: UserStatusItem; banned: UserStatusItem; suspension: UserStatusItem; } export interface User extends TenantResource { readonly id: string; - username: string; + username: string | null; + displayName?: string; password?: string; + avatar?: string; + email?: string; + email_verified?: boolean; profiles: Profile[]; tokens: Token[]; - role: UserRole; + role: GQLUSER_ROLE; status: UserStatus; action_counts: ActionCounts; - ignored_users: string[]; // TODO: migrate field + ignored_users: string[]; created_at: Date; } -export type CreateUserInput = Omit< +export type UpsertUserInput = Omit< User, | "id" | "tenant_id" | "tokens" | "status" - | "role" | "action_counts" | "ignored_users" | "created_at" >; -export async function create(db: Db, tenantID: string, input: CreateUserInput) { +export async function upsertUser( + db: Db, + tenantID: string, + input: UpsertUserInput +) { const now = new Date(); - // // Pull out some useful properties from the input. - // const { body, status } = input; + // Create a new ID for the user. + const id = uuid.v4(); // default are the properties set by the application when a new user is // created. - const defaults: Sub = { - id: uuid.v4(), + const defaults: Sub = { + id, tenant_id: tenantID, - role: UserRole.COMMENTER, tokens: [], action_counts: {}, ignored_users: [], @@ -120,27 +115,84 @@ export async function create(db: Db, tenantID: string, input: CreateUserInput) { history: [], }, username: { - status: UserUsernameStatus.SET, + status: input.username + ? GQLUSER_USERNAME_STATUS.SET + : GQLUSER_USERNAME_STATUS.UNSET, history: [], }, }, created_at: now, }; + let hashedPassword; + if (input.password) { + // Hash the user's password with bcrypt. + hashedPassword = await bcrypt.hash(input.password, 10); + } + // Merge the defaults and the input together. - const user: Readonly = merge({}, defaults, input); + const user: Readonly = merge({}, defaults, input, { + // Specified last in the merge call, it will override any existing password + // entry if it is defined. + password: hashedPassword, + }); - // Insert it into the database. - await collection(db).insertOne(user); + // Create a query that will utilize a findOneAndUpdate to facilitate an upsert + // operation to ensure no user has the same profile and/or email address. If + // any user is found to have the same profile as any of the profiles specified + // in the new user object, then we should error here. + const filter = createUpsertUserFilter(user); - return user; + // Create the upsert/update operation. + const update: { $setOnInsert: Readonly } = { + $setOnInsert: user, + }; + + // Insert it into the database. This may throw an error. + const result = await collection(db).findOneAndUpdate(filter, update, { + // We are using this to create a user, so we need to upsert it. + upsert: true, + + // False to return the updated document instead of the original document. + // This lets us detect if the document was updated or not. + returnOriginal: false, + }); + + // Check to see if this was a new user that was upserted, or one was found + // that matched existing records. We are sure here that the record exists + // because we're returning the updated document and performing an upsert + // operation. + if (result.value!.id !== id) { + // TODO: return better error. + throw new Error("user already found"); + } + + return result.value!; } -export async function retrieve(db: Db, tenantID: string, id: string) { +const createUpsertUserFilter = (user: Readonly) => { + const query: FilterQuery = { + // Query by the profiles if the user is being created with one. + $or: user.profiles.map(profile => ({ profiles: { $elemMatch: profile } })), + }; + + if (user.email) { + // Query by the email address if the user is being created with one. + query.$or.push({ email: user.email }); + } + + return query; +}; + +export async function retrieveUser(db: Db, tenantID: string, id: string) { return collection(db).findOne({ id, tenant_id: tenantID }); } -export async function retrieveMany(db: Db, tenantID: string, ids: string[]) { +export async function retrieveManyUsers( + db: Db, + tenantID: string, + ids: string[] +) { const cursor = await collection(db).find({ id: { $in: ids, @@ -153,11 +205,24 @@ export async function retrieveMany(db: Db, tenantID: string, ids: string[]) { return ids.map(id => users.find(comment => comment.id === id) || null); } -export async function updateRole( +export async function retrieveUserWithProfile( + db: Db, + tenantID: string, + profile: Profile +) { + return collection(db).findOne({ + tenant_id: tenantID, + profiles: { + $elemMatch: profile, + }, + }); +} + +export async function updateUserRole( db: Db, tenantID: string, id: string, - role: UserRole + role: GQLUSER_ROLE ) { const result = await collection(db).findOneAndUpdate( { id, tenant_id: tenantID }, @@ -167,3 +232,11 @@ export async function updateRole( return result.value || null; } + +export async function verifyUserPassword(user: User, password: string) { + if (user.password) { + return bcrypt.compare(password, user.password); + } + + return false; +} diff --git a/src/core/server/services/assets/index.ts b/src/core/server/services/assets/index.ts new file mode 100644 index 000000000..8c43269d9 --- /dev/null +++ b/src/core/server/services/assets/index.ts @@ -0,0 +1,21 @@ +import { Db } from "mongodb"; + +import { + findOrCreateAsset, + FindOrCreateAssetInput, +} from "talk-server/models/asset"; +import { Tenant } from "talk-server/models/tenant"; + +export type FindOrCreateAsset = FindOrCreateAssetInput; + +export async function findOrCreate( + db: Db, + tenant: Tenant, + input: FindOrCreateAsset +) { + // TODO: check to see if the tenant has enabled lazy asset creation. + + const asset = await findOrCreateAsset(db, tenant.id, input); + + return asset; +} diff --git a/src/core/server/services/comments/index.ts b/src/core/server/services/comments/index.ts index 95a9001cb..dfba035d4 100644 --- a/src/core/server/services/comments/index.ts +++ b/src/core/server/services/comments/index.ts @@ -1,26 +1,19 @@ import { Db } from "mongodb"; import { Omit } from "talk-common/types"; -import { - Comment, - CommentStatus, - create as createComment, - CreateCommentInput, -} from "talk-server/models/comment"; +import { GQLCOMMENT_STATUS } from "talk-server/graph/tenant/schema/__generated__/types"; +import { createComment, CreateCommentInput } from "talk-server/models/comment"; +import { Tenant } from "talk-server/models/tenant"; export type CreateComment = Omit< CreateCommentInput, "status" | "action_counts" >; -export async function create( - db: Db, - tenantID: string, - input: CreateComment -): Promise { +export async function create(db: Db, tenant: Tenant, input: CreateComment) { // TODO: run the comment through the moderation phases. - const comment = await createComment(db, tenantID, { - status: CommentStatus.ACCEPTED, + const comment = await createComment(db, tenant.id, { + status: GQLCOMMENT_STATUS.ACCEPTED, action_counts: {}, ...input, }); diff --git a/src/core/server/services/users/index.ts b/src/core/server/services/users/index.ts new file mode 100644 index 000000000..1a0a62b4f --- /dev/null +++ b/src/core/server/services/users/index.ts @@ -0,0 +1,12 @@ +import { Db } from "mongodb"; + +import { Tenant } from "talk-server/models/tenant"; +import { upsertUser, UpsertUserInput } from "talk-server/models/user"; + +export type UpsertUser = UpsertUserInput; + +export async function upsert(db: Db, tenant: Tenant, input: UpsertUser) { + const user = await upsertUser(db, tenant.id, input); + + return user; +} diff --git a/src/index.ts b/src/index.ts index ec3479156..985b3476e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,9 @@ +import dotenv from "dotenv"; + +// Apply all the configuration provided in the .env file if it isn't already in +// the environment. +dotenv.config(); + import express from "express"; import logger from "talk-server/logger"; diff --git a/src/tsconfig.json b/src/tsconfig.json index 857892f4d..ac216dbaa 100644 --- a/src/tsconfig.json +++ b/src/tsconfig.json @@ -9,7 +9,7 @@ "outDir": "../dist", // See https://github.com/prismagraphql/graphql-request/issues/26 for why we // have to include "dom" here. - "lib": ["es6", "esnext.asynciterable", "dom"], + "lib": ["es2017", "es6", "esnext.asynciterable", "dom"], "baseUrl": "./", "paths": { "talk-server/*": ["./core/server/*"], diff --git a/src/types/jsonwebtoken.d.ts b/src/types/jsonwebtoken.d.ts new file mode 100644 index 000000000..de8c9f82f --- /dev/null +++ b/src/types/jsonwebtoken.d.ts @@ -0,0 +1,19 @@ +import { VerifyOptions, VerifyCallback } from "jsonwebtoken"; + +declare module "jsonwebtoken" { + export type KeyFunctionCallback = ( + err: Error | null, + secretOrPublicKey?: string | Buffer + ) => void; + export type KeyFunction = ( + headers: { kid?: string }, + callback: KeyFunctionCallback + ) => void; + + export function verify( + token: string, + secretOrPublicKey: string | Buffer | KeyFunction, + options?: VerifyOptions, + callback?: VerifyCallback + ): void; +} diff --git a/src/types/webfinger.d.ts b/src/types/webfinger.d.ts new file mode 100644 index 000000000..a13bfb998 --- /dev/null +++ b/src/types/webfinger.d.ts @@ -0,0 +1,16 @@ +declare module "webfinger" { + export interface WebfingerOptions { + webfingerOnly?: boolean; + } + + export interface WebfingerCallback { + (err: Error, jrd: { [key: string]: any }): void; + } + + export function webfinger( + resource: string, + res: string, + options: WebfingerOptions, + callback: WebfingerCallback + ): void; +} diff --git a/tsconfig.json b/tsconfig.json index dbb50ebf7..bee79d2d0 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -15,12 +15,6 @@ "noErrorTruncation": true, "lib": ["es6", "esnext.asynciterable"] }, - "include": [ - "./src/**/.*.js", - "./scripts/**/*", - "./config/**/*", - "./test/**/*", - "*.js" - ], + "include": ["./src/**/.*.js", "./scripts/**/*", "./config/**/*", "*.js"], "exclude": ["node_modules"] }