mirror of
https://github.com/wassname/talk.git
synced 2026-06-27 19:17:09 +08:00
Merge pull request #1743 from coralproject/next-passport
[next] Passport Integration
This commit is contained in:
+1
-1
@@ -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"),
|
||||
|
||||
+8
-2
@@ -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"],
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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]",
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
Generated
+287
-81
@@ -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",
|
||||
|
||||
+25
-12
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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<CommentProps> = props => {
|
||||
return (
|
||||
<div role="article">
|
||||
<TopBar>
|
||||
{props.author && <Username>{props.author.username}</Username>}
|
||||
{props.author &&
|
||||
props.author.username && <Username>{props.author.username}</Username>}
|
||||
<Timestamp>{props.createdAt}</Timestamp>
|
||||
</TopBar>
|
||||
<Typography>{props.body}</Typography>
|
||||
|
||||
@@ -19,3 +19,18 @@ it("renders username and body", () => {
|
||||
const wrapper = shallow(<CommentContainer {...props} />);
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders body only", () => {
|
||||
const props: PropTypesOf<typeof CommentContainer> = {
|
||||
data: {
|
||||
author: {
|
||||
username: null,
|
||||
},
|
||||
body: "Woof",
|
||||
createdAt: "1995-12-17T03:24:00.000Z",
|
||||
},
|
||||
};
|
||||
|
||||
const wrapper = shallow(<CommentContainer {...props} />);
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`renders body only 1`] = `
|
||||
<Comment
|
||||
author={
|
||||
Object {
|
||||
"username": null,
|
||||
}
|
||||
}
|
||||
body="Woof"
|
||||
createdAt="1995-12-17T03:24:00.000Z"
|
||||
/>
|
||||
`;
|
||||
|
||||
exports[`renders username and body 1`] = `
|
||||
<Comment
|
||||
author={
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { RequestHandler } from "express";
|
||||
import Joi from "joi";
|
||||
import { Db } from "mongodb";
|
||||
|
||||
import { handleSuccessfulLogin } from "talk-server/app/middleware/passport";
|
||||
import { JWTSigningConfig } from "talk-server/app/middleware/passport/jwt";
|
||||
import { validate } from "talk-server/app/request/body";
|
||||
import { GQLUSER_ROLE } from "talk-server/graph/tenant/schema/__generated__/types";
|
||||
import { LocalProfile } from "talk-server/models/user";
|
||||
import { upsert } from "talk-server/services/users";
|
||||
import { Request } from "talk-server/types/express";
|
||||
|
||||
export interface SignupBody {
|
||||
username: string;
|
||||
password: string;
|
||||
email: string;
|
||||
displayName?: string;
|
||||
}
|
||||
|
||||
const SignupBodySchema = Joi.object().keys({
|
||||
username: Joi.string().trim(),
|
||||
password: Joi.string().trim(),
|
||||
email: Joi.string().trim(),
|
||||
});
|
||||
|
||||
export interface SignupOptions {
|
||||
db: Db;
|
||||
signingConfig: JWTSigningConfig;
|
||||
}
|
||||
|
||||
export const signupHandler = (options: SignupOptions): RequestHandler => 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);
|
||||
}
|
||||
};
|
||||
@@ -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<Express> {
|
||||
// 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;
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { RequestHandler } from "express";
|
||||
|
||||
export const notFoundMiddleware: RequestHandler = (req, res, next) => {
|
||||
next(new Error("not found"));
|
||||
};
|
||||
@@ -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-----"
|
||||
`;
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<SignOptions, "audience" | "issuer">;
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<string, StrategyItem>;
|
||||
|
||||
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 });
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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]"`;
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
+22
-10
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<string, string | undefined>,
|
||||
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;
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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<typeof loaders>;
|
||||
public mutators: ReturnType<typeof mutators>;
|
||||
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);
|
||||
|
||||
@@ -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<string, Asset | null>(ids =>
|
||||
retrieveManyAssets(ctx.db, ctx.tenant.id, ids)
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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<string, User | null>(ids =>
|
||||
retrieveMany(ctx.db, ctx.tenant.id, ids)
|
||||
retrieveManyUsers(ctx.db, ctx.tenant.id, ids)
|
||||
),
|
||||
});
|
||||
|
||||
@@ -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<Comment> => {
|
||||
create: (input: GQLCreateCommentInput): Promise<Comment> => {
|
||||
// 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,
|
||||
|
||||
@@ -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<Asset> = {
|
||||
comments: (asset, input, ctx) =>
|
||||
ctx.loaders.Comments.forAsset(asset.id, input),
|
||||
// TODO: implement this.
|
||||
isClosed: () => false,
|
||||
};
|
||||
|
||||
export default Asset;
|
||||
|
||||
@@ -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<AuthIntegrations> = {
|
||||
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;
|
||||
@@ -0,0 +1,8 @@
|
||||
import { GQLAuthSettingsTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types";
|
||||
import { Auth } from "talk-server/models/tenant";
|
||||
|
||||
const AuthSettings: GQLAuthSettingsTypeResolver<Auth> = {
|
||||
integrations: auth => auth.integrations,
|
||||
};
|
||||
|
||||
export default AuthSettings;
|
||||
@@ -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<Comment> = {
|
||||
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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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<CreateCommentPayload> => ({
|
||||
const Mutation: GQLMutationTypeResolver<void> = {
|
||||
createComment: async (source, { input }, ctx) => ({
|
||||
comment: await ctx.mutators.Comment.create(input),
|
||||
clientMutationId: input.clientMutationId,
|
||||
}),
|
||||
|
||||
@@ -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;
|
||||
@@ -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> = 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,
|
||||
};
|
||||
@@ -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<void> = {
|
||||
asset: (source, args, ctx) => ctx.loaders.Assets.findOrCreate(args),
|
||||
settings: (source, args, ctx) => ctx.tenant,
|
||||
me: (source, args, ctx) => ctx.user,
|
||||
};
|
||||
|
||||
export default Query;
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
################################################################################
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
+100
-30
@@ -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<Readonly<Asset>>("assets");
|
||||
@@ -27,48 +27,103 @@ export interface Asset extends TenantResource {
|
||||
created_at: Date;
|
||||
}
|
||||
|
||||
export type CreateAssetInput = Pick<Asset, "id" | "url">;
|
||||
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<Asset>(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<Asset>,
|
||||
"id" | "tenant_id" | "url" | "created_at"
|
||||
|
||||
@@ -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<Readonly<Comment>>("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<string, any>;
|
||||
}
|
||||
|
||||
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<Comment> {
|
||||
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<Comment>) {
|
||||
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);
|
||||
|
||||
@@ -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<Readonly<Tenant>>("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<EmailDomainRuleCondition> {
|
||||
// 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 });
|
||||
}
|
||||
|
||||
|
||||
+135
-62
@@ -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<Readonly<User>>("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<T> {
|
||||
status: T; // TODO: migrate field
|
||||
status: T;
|
||||
assigned_by?: string;
|
||||
reason?: string; // TODO: migrate field
|
||||
reason?: string;
|
||||
created_at: Date;
|
||||
}
|
||||
|
||||
export interface UserStatusItem<T> {
|
||||
status: T; // TODO: migrate field
|
||||
status: T;
|
||||
history: Array<UserStatusHistory<T>>;
|
||||
}
|
||||
|
||||
export interface UserStatus {
|
||||
username: UserStatusItem<UserUsernameStatus>;
|
||||
username: UserStatusItem<GQLUSER_USERNAME_STATUS>;
|
||||
banned: UserStatusItem<boolean>;
|
||||
suspension: UserStatusItem<Date | null>;
|
||||
}
|
||||
|
||||
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<User, CreateUserInput> = {
|
||||
id: uuid.v4(),
|
||||
const defaults: Sub<User, UpsertUserInput> = {
|
||||
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<User> = merge({}, defaults, input);
|
||||
const user: Readonly<User> = 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<User> } = {
|
||||
$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<User>) => {
|
||||
const query: FilterQuery<User> = {
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<Comment> {
|
||||
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,
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
+1
-1
@@ -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/*"],
|
||||
|
||||
Vendored
+19
@@ -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;
|
||||
}
|
||||
Vendored
+16
@@ -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;
|
||||
}
|
||||
+1
-7
@@ -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"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user