From 986c92598d761c78a8d5a8fb6c7bec4015b58478 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Thu, 28 Jun 2018 17:06:45 -0600 Subject: [PATCH 01/43] feature: added generated graph types --- package-lock.json | 9 ++ package.json | 4 +- scripts/types.js | 96 +++++++++++++++++++ .../graph/management/resolvers/index.ts | 8 +- .../server/graph/management/schema/index.ts | 4 +- .../graph/management/schema/schema.graphql | 25 ++--- .../server/graph/tenant/loaders/comments.ts | 10 +- .../server/graph/tenant/mutators/comment.ts | 6 +- .../server/graph/tenant/resolvers/asset.ts | 9 +- .../server/graph/tenant/resolvers/comment.ts | 12 ++- .../server/graph/tenant/resolvers/index.ts | 8 +- .../server/graph/tenant/resolvers/mutation.ts | 22 +---- .../server/graph/tenant/resolvers/query.ts | 14 ++- src/core/server/graph/tenant/schema/index.ts | 4 +- .../server/graph/tenant/schema/schema.graphql | 27 ++++-- src/core/server/logger.ts | 5 +- src/core/server/models/comment.ts | 29 +++--- 17 files changed, 201 insertions(+), 91 deletions(-) create mode 100644 scripts/types.js diff --git a/package-lock.json b/package-lock.json index c61ddd7fb..0d6ef44fd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9125,6 +9125,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", diff --git a/package.json b/package.json index 2ccf6c47c..86ecfe387 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,8 @@ "lint": "npm-run-all --parallel lint:*", "lint:server": "tslint --project ./tsconfig.json", "lint:client": "tslint --project ./src/core/client/tsconfig.json", - "docz:watch": "docz dev" + "docz:watch": "docz dev", + "postinstall": "node ./scripts/types.js" }, "author": "", "license": "Apache-2.0", @@ -95,6 +96,7 @@ "fluent-langneg": "^0.1.0", "fluent-react": "^0.7.0", "graphql-playground-middleware-express": "^1.7.0", + "graphql-schema-typescript": "^1.2.1", "html-webpack-plugin": "^3.2.0", "jest": "^23.2.0", "loader-utils": "^1.1.0", diff --git a/scripts/types.js b/scripts/types.js new file mode 100644 index 000000000..3825548e7 --- /dev/null +++ b/scripts/types.js @@ -0,0 +1,96 @@ +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 lint(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", + contextType: "any", + strictNulls: false, + minimizeInterfaceImplementation: true, + ...file.config, + }); + } + + // Send the files off to the linter to be linted and written. + lint(files); + + return files; +} + +main() + .then(files => { + for (const { fileName } of files) { + console.log(`Generated ${fileName}`); + } + }) + .catch(err => { + console.error(err); + }); diff --git a/src/core/server/graph/management/resolvers/index.ts b/src/core/server/graph/management/resolvers/index.ts index 42b27b820..b1bcbcef8 100644 --- a/src/core/server/graph/management/resolvers/index.ts +++ b/src/core/server/graph/management/resolvers/index.ts @@ -1,5 +1,5 @@ -import Cursor from "../../common/scalars/cursor"; +import { GQLResolver } from "talk-server/graph/management/schema/__generated__/types"; -export default { - Cursor, -}; +const Resolvers: GQLResolver = {}; + +export default Resolvers; diff --git a/src/core/server/graph/management/schema/index.ts b/src/core/server/graph/management/schema/index.ts index ab7cd2856..2f094a807 100644 --- a/src/core/server/graph/management/schema/index.ts +++ b/src/core/server/graph/management/schema/index.ts @@ -1,6 +1,8 @@ +import { IResolvers } from "graphql-tools"; + import loadSchema from "talk-server/graph/common/schema"; import resolvers from "talk-server/graph/management/resolvers"; export default function getManagementSchema() { - return loadSchema("management", resolvers); + return loadSchema("management", resolvers as IResolvers); } diff --git a/src/core/server/graph/management/schema/schema.graphql b/src/core/server/graph/management/schema/schema.graphql index 67655516d..e07ccbdd9 100644 --- a/src/core/server/graph/management/schema/schema.graphql +++ b/src/core/server/graph/management/schema/schema.graphql @@ -7,27 +7,22 @@ Time represented as an ISO8601 string. """ scalar Time -""" -Cursor represents a paginating cursor. -""" -scalar Cursor - ################################################################################ ## Tenant ################################################################################ type Tenant { - id: ID! + id: ID! - """ - organizationName is the name of the organization. - """ - organizationName: String + """ + organizationName is the name of the organization. + """ + organizationName: String - """ - organizationContactEmail is the email of the organization. - """ - organizationContactEmail: String + """ + organizationContactEmail is the email of the organization. + """ + organizationContactEmail: String } ################################################################################ @@ -35,5 +30,5 @@ type Tenant { ################################################################################ type Query { - tenant(id: ID!): Tenant + tenant(id: ID!): Tenant } diff --git a/src/core/server/graph/tenant/loaders/comments.ts b/src/core/server/graph/tenant/loaders/comments.ts index 6649a50ab..0e68876e2 100644 --- a/src/core/server/graph/tenant/loaders/comments.ts +++ b/src/core/server/graph/tenant/loaders/comments.ts @@ -1,7 +1,11 @@ import DataLoader from "dataloader"; + import Context from "talk-server/graph/tenant/context"; import { - ConnectionInput, + AssetToCommentsArgs, + CommentToRepliesArgs, +} from "talk-server/graph/tenant/schema/__generated__/types"; +import { retrieveAssetConnection, retrieveMany, retrieveRepliesConnection, @@ -11,8 +15,8 @@ export default (ctx: Context) => ({ comment: new DataLoader((ids: string[]) => retrieveMany(ctx.db, ctx.tenant.id, ids) ), - forAsset: (assetID: string, input: ConnectionInput) => + forAsset: (assetID: string, input: AssetToCommentsArgs) => retrieveAssetConnection(ctx.db, ctx.tenant.id, assetID, input), - forParent: (assetID: string, parentID: string, input: ConnectionInput) => + forParent: (assetID: string, parentID: string, input: CommentToRepliesArgs) => retrieveRepliesConnection(ctx.db, ctx.tenant.id, assetID, parentID, input), }); diff --git a/src/core/server/graph/tenant/mutators/comment.ts b/src/core/server/graph/tenant/mutators/comment.ts index 1046930b9..9025d3cef 100644 --- a/src/core/server/graph/tenant/mutators/comment.ts +++ b/src/core/server/graph/tenant/mutators/comment.ts @@ -1,12 +1,12 @@ import TenantContext from "talk-server/graph/tenant/context"; -import { CreateCommentInput } from "talk-server/graph/tenant/resolvers/mutation"; +import { GQLCreateCommentInput } from "talk-server/graph/tenant/schema/__generated__/types"; import { Comment } from "talk-server/models/comment"; import { create } from "talk-server/services/comments"; export default (ctx: TenantContext) => ({ - create: (input: CreateCommentInput): Promise => { + create: (input: GQLCreateCommentInput): Promise => { // FIXME: remove tenant + user ! - return create(ctx.db, ctx.tenant!.id, { + return create(ctx.db, ctx.tenant.id, { author_id: ctx.user!.id, asset_id: input.assetID, body: input.body, diff --git a/src/core/server/graph/tenant/resolvers/asset.ts b/src/core/server/graph/tenant/resolvers/asset.ts index 7f17e8861..e5487ba08 100644 --- a/src/core/server/graph/tenant/resolvers/asset.ts +++ b/src/core/server/graph/tenant/resolvers/asset.ts @@ -1,10 +1,11 @@ -import Context from "talk-server/graph/tenant/context"; +import { GQLAssetTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types"; import { Asset } from "talk-server/models/asset"; -import { ConnectionInput } from "talk-server/models/comment"; -export default { - comments: async (asset: Asset, input: ConnectionInput, ctx: Context) => +const Asset: GQLAssetTypeResolver = { + comments: (asset, input, ctx) => ctx.loaders.Comments.forAsset(asset.id, input), // TODO: implement this. isClosed: () => false, }; + +export default Asset; diff --git a/src/core/server/graph/tenant/resolvers/comment.ts b/src/core/server/graph/tenant/resolvers/comment.ts index 22f72b5e5..4b7f67023 100644 --- a/src/core/server/graph/tenant/resolvers/comment.ts +++ b/src/core/server/graph/tenant/resolvers/comment.ts @@ -1,9 +1,11 @@ -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 { - author: async (comment: Comment, _: any, ctx: Context) => +const Comment: GQLCommentTypeResolver = { + author: async (comment, args, ctx) => ctx.loaders.Users.user.load(comment.author_id), - replies: async (comment: Comment, input: ConnectionInput, ctx: Context) => + replies: async (comment, input, ctx) => ctx.loaders.Comments.forParent(comment.asset_id, comment.id, input), }; + +export default Comment; diff --git a/src/core/server/graph/tenant/resolvers/index.ts b/src/core/server/graph/tenant/resolvers/index.ts index d1143f1ff..e8d33540d 100644 --- a/src/core/server/graph/tenant/resolvers/index.ts +++ b/src/core/server/graph/tenant/resolvers/index.ts @@ -1,13 +1,17 @@ -import Cursor from "../../common/scalars/cursor"; +import Cursor from "talk-server/graph/common/scalars/cursor"; +import { GQLResolver } from "talk-server/graph/tenant/schema/__generated__/types"; + import Asset from "./asset"; import Comment from "./comment"; import Mutation from "./mutation"; import Query from "./query"; -export default { +const Resolvers: GQLResolver = { Asset, Comment, Cursor, Query, Mutation, }; + +export default Resolvers; diff --git a/src/core/server/graph/tenant/resolvers/mutation.ts b/src/core/server/graph/tenant/resolvers/mutation.ts index 4d3b82a76..f51f0fc1b 100644 --- a/src/core/server/graph/tenant/resolvers/mutation.ts +++ b/src/core/server/graph/tenant/resolvers/mutation.ts @@ -1,23 +1,7 @@ -import { ClientMutationProps } from "talk-server/graph/common/resolvers/mutation"; -import TenantContext from "talk-server/graph/tenant/context"; -import { Comment } from "talk-server/models/comment"; +import { GQLMutationTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types"; -export interface CreateCommentInput extends ClientMutationProps { - assetID: string; - parentID?: string; - body: string; -} - -export interface CreateCommentPayload extends ClientMutationProps { - comment: Comment; -} - -const Mutation = { - createComment: async ( - source: void, - input: CreateCommentInput, - ctx: TenantContext - ): Promise => ({ +const Mutation: GQLMutationTypeResolver = { + createComment: async (source, { input }, ctx) => ({ comment: await ctx.mutators.Comment.create(input), clientMutationId: input.clientMutationId, }), diff --git a/src/core/server/graph/tenant/resolvers/query.ts b/src/core/server/graph/tenant/resolvers/query.ts index ae76679a9..caf5e0a3b 100644 --- a/src/core/server/graph/tenant/resolvers/query.ts +++ b/src/core/server/graph/tenant/resolvers/query.ts @@ -1,10 +1,8 @@ -import TenantContext from "talk-server/graph/tenant/context"; +import { GQLQueryTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types"; -export default { - asset: async ( - source: void, - { id }: { id: string; url: string }, - ctx: TenantContext - ) => ctx.loaders.Assets.asset.load(id), - settings: async (parent: any, args: any, ctx: TenantContext) => ctx.tenant, +const Query: GQLQueryTypeResolver = { + asset: (source, args, ctx) => ctx.loaders.Assets.asset.load(args.id), + settings: (parent, args, ctx) => ctx.tenant, }; + +export default Query; diff --git a/src/core/server/graph/tenant/schema/index.ts b/src/core/server/graph/tenant/schema/index.ts index 93641ac66..1105c358e 100644 --- a/src/core/server/graph/tenant/schema/index.ts +++ b/src/core/server/graph/tenant/schema/index.ts @@ -1,6 +1,8 @@ +import { IResolvers } from "graphql-tools"; + import loadSchema from "talk-server/graph/common/schema"; import resolvers from "talk-server/graph/tenant/resolvers"; export default function getTenantSchema() { - return loadSchema("tenant", resolvers); + return loadSchema("tenant", resolvers as IResolvers); } diff --git a/src/core/server/graph/tenant/schema/schema.graphql b/src/core/server/graph/tenant/schema/schema.graphql index eb060b6a4..96657351d 100644 --- a/src/core/server/graph/tenant/schema/schema.graphql +++ b/src/core/server/graph/tenant/schema/schema.graphql @@ -31,9 +31,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. """ @@ -46,7 +46,20 @@ type Wordlist { } # Settings stores the global settings for a given installation. + +################################################################################ +## Settings +################################################################################ + +""" +Settings stores the global settings for a given Tenant. +""" type Settings { + """ + domain is the domain that is associated with this Tenant. + """ + domain: String! + """ moderation is the moderation mode for all Asset's on the site. """ @@ -152,7 +165,7 @@ type Settings { """ wordlist will return a given list of words. """ - wordlist: Wordlist! + wordlist: WordlistSettings! """ domains will return a given list of whitelisted domains. @@ -222,8 +235,8 @@ type Comment { replies will return the replies to this comment. """ replies( - first: Int = 10 - orderBy: COMMENT_SORT = CREATED_AT_DESC + first: Int! = 10 + orderBy: COMMENT_SORT! = CREATED_AT_DESC after: Cursor ): CommentsConnection } @@ -299,8 +312,8 @@ type Asset { comments are the comments on the Asset. """ comments( - first: Int = 10 - orderBy: COMMENT_SORT = CREATED_AT_DESC + first: Int! = 10 + orderBy: COMMENT_SORT! = CREATED_AT_DESC after: Cursor ): CommentsConnection diff --git a/src/core/server/logger.ts b/src/core/server/logger.ts index c3b50c355..7a9ee886e 100644 --- a/src/core/server/logger.ts +++ b/src/core/server/logger.ts @@ -1,5 +1,8 @@ import bunyan from "bunyan"; -const logger = bunyan.createLogger({ name: "talk" }); +const logger = bunyan.createLogger({ + name: "talk", + serializers: bunyan.stdSerializers, +}); export default logger; diff --git a/src/core/server/models/comment.ts b/src/core/server/models/comment.ts index 81d046159..5c0726669 100644 --- a/src/core/server/models/comment.ts +++ b/src/core/server/models/comment.ts @@ -1,11 +1,13 @@ import { merge } from "lodash"; import { Db } from "mongodb"; +import uuid from "uuid"; + import { Omit, Sub } from "talk-common/types"; +import { GQLCOMMENT_SORT } from "talk-server/graph/tenant/schema/__generated__/types"; import { ActionCounts } from "talk-server/models/actions"; import { Connection, Cursor } from "talk-server/models/connection"; import Query from "talk-server/models/query"; import { TenantResource } from "talk-server/models/tenant"; -import uuid from "uuid"; function collection(db: Db) { return db.collection>("comments"); @@ -121,16 +123,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,12 +139,12 @@ export interface ConnectionInput { function nodesToEdge(input: ConnectionInput, nodes: Comment[]) { let getCursor: (comment: Comment, index: number) => Cursor; 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: getCursor = comment => comment.created_at; break; - case CommentSort.REPLIES_DESC: - case CommentSort.RESPECT_DESC: + case GQLCOMMENT_SORT.REPLIES_DESC: + case GQLCOMMENT_SORT.RESPECT_DESC: getCursor = (_, index) => (input.after ? (input.after as number) : 0) + index + 1; break; @@ -226,25 +221,25 @@ async function retrieveConnection( ) { // Apply some sorting options. 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); From 1236946312b6f060c3e6919edc05ec75aa38be01 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Fri, 29 Jun 2018 16:11:32 -0600 Subject: [PATCH 02/43] initial oidc support --- config/nodemon/types.json | 7 + package-lock.json | 346 +++++++++++++----- package.json | 11 +- src/core/server/app/index.ts | 6 +- .../server/app/middleware/passport/index.ts | 78 +++- .../server/app/middleware/passport/oidc.ts | 223 +++++++++++ .../server/app/middleware/passport/sso.ts | 9 + src/core/server/app/router.ts | 40 +- src/core/server/app/url.ts | 13 + .../server/graph/common/directives/auth.ts | 12 + .../graph/tenant/resolvers/auth_settings.ts | 14 + .../resolvers/facebook_auth_integration.ts | 10 + .../resolvers/google_auth_integration.ts | 10 + .../resolvers/local_auth_integration.ts | 8 + .../tenant/resolvers/oidc_auth_integration.ts | 10 + .../tenant/resolvers/sso_auth_integration.ts | 10 + src/core/server/graph/tenant/schema/index.ts | 10 +- .../server/graph/tenant/schema/schema.graphql | 117 +++++- src/core/server/models/tenant.ts | 98 ++++- src/core/server/models/user.ts | 42 ++- src/types/jsonwebtoken.d.ts | 19 + src/types/webfinger.d.ts | 16 + 22 files changed, 968 insertions(+), 141 deletions(-) create mode 100644 config/nodemon/types.json create mode 100644 src/core/server/app/middleware/passport/oidc.ts create mode 100644 src/core/server/app/middleware/passport/sso.ts create mode 100644 src/core/server/app/url.ts create mode 100644 src/core/server/graph/common/directives/auth.ts create mode 100644 src/core/server/graph/tenant/resolvers/auth_settings.ts create mode 100644 src/core/server/graph/tenant/resolvers/facebook_auth_integration.ts create mode 100644 src/core/server/graph/tenant/resolvers/google_auth_integration.ts create mode 100644 src/core/server/graph/tenant/resolvers/local_auth_integration.ts create mode 100644 src/core/server/graph/tenant/resolvers/oidc_auth_integration.ts create mode 100644 src/core/server/graph/tenant/resolvers/sso_auth_integration.ts create mode 100644 src/types/jsonwebtoken.d.ts create mode 100644 src/types/webfinger.d.ts diff --git a/config/nodemon/types.json b/config/nodemon/types.json new file mode 100644 index 000000000..73203fd0a --- /dev/null +++ b/config/nodemon/types.json @@ -0,0 +1,7 @@ +{ + "exec": "npm run compile:server:types", + "ext": "graphql", + "watch": [ + "./src/core/server/graph" + ] +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index ff1c5e30f..f0f773f88 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1413,7 +1413,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": "*" @@ -1448,7 +1447,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": "*" } @@ -1471,31 +1469,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.1", "resolved": "https://registry.npmjs.org/@types/graphql/-/graphql-0.13.1.tgz", @@ -1524,6 +1536,15 @@ "integrity": "sha512-GXYdIVpwBP5ZBOlHitSYfQdH+vWXVahhkeQwalX0LkoX7Mx0D3L3tg4vXXhr6nYHkEpWlAzWuEjgWEBtcp5NZA==", "dev": true }, + "@types/jsonwebtoken": { + "version": "7.2.7", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-7.2.7.tgz", + "integrity": "sha512-lq9X76APpxGJDUe1VptL1P5GrogqhPCH+SDy94+gaBJw7Hhj6hwrVC6zuxAx2GrgktkBuwydESZBvPfrdBoOEg==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/lodash": { "version": "4.14.109", "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.109.tgz", @@ -1539,8 +1560,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.0.19", @@ -1556,8 +1576,16 @@ "@types/node": { "version": "10.3.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-10.3.1.tgz", - "integrity": "sha512-IsX9aDHDzJohkm3VCDB8tkzl5RQ34E/PFA29TQk6uDGb7Oc869ZBtmdKVDBzY3+h9GnXB8ssrRXEPVZrlIOPOw==", - "dev": true + "integrity": "sha512-IsX9aDHDzJohkm3VCDB8tkzl5RQ34E/PFA29TQk6uDGb7Oc869ZBtmdKVDBzY3+h9GnXB8ssrRXEPVZrlIOPOw==" + }, + "@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", @@ -1568,6 +1596,27 @@ "@types/express": "*" } }, + "@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", @@ -1577,8 +1626,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", @@ -1627,7 +1675,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": "*" @@ -2341,8 +2388,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", @@ -2384,8 +2430,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", @@ -2428,8 +2473,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", @@ -2454,14 +2498,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", @@ -4305,7 +4347,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.1.tgz", "integrity": "sha1-Y7xdy2EzG5K8Bf1SiVPDNGKgb40=", - "dev": true, "optional": true, "requires": { "tweetnacl": "^0.14.3" @@ -4649,6 +4690,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", @@ -4855,8 +4901,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", @@ -5210,8 +5255,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", @@ -5300,7 +5344,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" } @@ -5543,8 +5586,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", @@ -6231,7 +6273,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" } @@ -6440,8 +6481,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", @@ -7102,12 +7142,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", @@ -7639,8 +7686,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", @@ -7754,8 +7800,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=" }, "fast-deep-equal": { "version": "2.0.1", @@ -8105,14 +8150,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", @@ -8255,14 +8298,12 @@ "balanced-match": { "version": "1.0.0", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "brace-expansion": { "version": "1.1.11", "bundled": true, "dev": true, - "optional": true, "requires": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -8277,20 +8318,17 @@ "code-point-at": { "version": "1.1.0", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "concat-map": { "version": "0.0.1", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "console-control-strings": { "version": "1.1.0", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "core-util-is": { "version": "1.0.2", @@ -8407,8 +8445,7 @@ "inherits": { "version": "2.0.3", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "ini": { "version": "1.3.5", @@ -8420,7 +8457,6 @@ "version": "1.0.0", "bundled": true, "dev": true, - "optional": true, "requires": { "number-is-nan": "^1.0.0" } @@ -8435,7 +8471,6 @@ "version": "3.0.4", "bundled": true, "dev": true, - "optional": true, "requires": { "brace-expansion": "^1.1.7" } @@ -8443,14 +8478,12 @@ "minimist": { "version": "0.0.8", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "minipass": { "version": "2.2.4", "bundled": true, "dev": true, - "optional": true, "requires": { "safe-buffer": "^5.1.1", "yallist": "^3.0.0" @@ -8469,7 +8502,6 @@ "version": "0.5.1", "bundled": true, "dev": true, - "optional": true, "requires": { "minimist": "0.0.8" } @@ -8550,8 +8582,7 @@ "number-is-nan": { "version": "1.0.1", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "object-assign": { "version": "4.1.1", @@ -8563,7 +8594,6 @@ "version": "1.4.0", "bundled": true, "dev": true, - "optional": true, "requires": { "wrappy": "1" } @@ -8685,7 +8715,6 @@ "version": "1.0.2", "bundled": true, "dev": true, - "optional": true, "requires": { "code-point-at": "^1.0.0", "is-fullwidth-code-point": "^1.0.0", @@ -8797,7 +8826,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" } @@ -9301,14 +9329,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" @@ -9318,7 +9344,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", @@ -9329,14 +9354,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=" } } }, @@ -9707,7 +9730,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", @@ -10534,8 +10556,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", @@ -10630,8 +10651,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", @@ -11265,7 +11285,6 @@ "version": "0.1.1", "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", "integrity": "sha1-peZUwuWi3rXyAdls77yoDA7y9RM=", - "dev": true, "optional": true }, "jsdom": { @@ -11329,8 +11348,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", @@ -11341,8 +11359,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", @@ -11372,11 +11389,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", @@ -11384,6 +11423,38 @@ "verror": "1.10.0" } }, + "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", @@ -11643,6 +11714,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.2.8", "resolved": "https://registry.npmjs.org/load-cfg/-/load-cfg-0.2.8.tgz", @@ -11768,6 +11844,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", @@ -11843,16 +11924,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.isobject": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/lodash.isobject/-/lodash.isobject-3.0.2.tgz", "integrity": "sha1-PI+41bW/S/kK4G4U8qUwpO2TXh0=" }, + "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", @@ -11880,6 +11986,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", @@ -12031,6 +12142,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.2.1", "resolved": "https://registry.npmjs.org/luxon/-/luxon-1.2.1.tgz", @@ -12918,11 +13051,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", @@ -13316,6 +13453,17 @@ "pause": "0.0.1" } }, + "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", @@ -15874,8 +16022,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", @@ -17215,7 +17362,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", @@ -17242,14 +17388,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" } @@ -17495,8 +17639,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==" }, "sane": { "version": "2.5.2", @@ -18067,7 +18210,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", @@ -19325,7 +19467,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" } @@ -19334,7 +19475,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": { @@ -19619,6 +19759,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", @@ -20152,13 +20297,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", @@ -20961,8 +21110,7 @@ "yallist": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/yallist/-/yallist-2.1.2.tgz", - "integrity": "sha1-HBH5IY8HYImkfdUS+TxmmaaoHVI=", - "dev": true + "integrity": "sha1-HBH5IY8HYImkfdUS+TxmmaaoHVI=" }, "yargs": { "version": "11.0.0", diff --git a/package.json b/package.json index 64e1cfa9f..b4da13f68 100644 --- a/package.json +++ b/package.json @@ -13,8 +13,10 @@ "watch:css-types": "tcm src/core/client/ --watch", "watch:relay-stream": "nodemon --config ./config/nodemon/relay-stream.json", "watch:server": "nodemon --config ./config/nodemon/server.json", + "watch:types": "nodemon --config ./config/nodemon/types.json", "compile:css-types": "tcm src/core/client/", "compile:relay-stream": "relay-compiler --src ./src/core/client/stream --schema ./src/core/server/graph/tenant/schema/schema.graphql --language typescript --artifactDirectory ./src/core/client/stream/__generated__ --no-watchman", + "compile:server:types": "node ./scripts/types.js", "start:development": "NODE_ENV=development ts-node -r tsconfig-paths/register src/index.ts", "lint-fix": "npm run lint:server -- --fix && npm run lint:client -- --fix && npm run lint:scripts -- --fix", "lint": "npm-run-all --parallel lint:*", @@ -43,10 +45,14 @@ "graphql-tools": "^3.0.2", "ioredis": "^3.2.2", "joi": "^13.4.0", + "jsonwebtoken": "^8.3.0", + "jwks-rsa": "^1.3.0", "lodash": "^4.17.10", "luxon": "^1.2.1", "mongodb": "^3.0.10", "passport": "^0.4.0", + "passport-oauth2": "^1.4.0", + "passport-strategy": "^1.0.0", "performance-now": "^2.1.0", "subscriptions-transport-ws": "^0.9.11", "uuid": "^3.2.1" @@ -66,11 +72,14 @@ "@types/ioredis": "^3.2.8", "@types/jest": "^23.1.1", "@types/joi": "^13.0.8", + "@types/jsonwebtoken": "^7.2.7", "@types/lodash": "^4.14.109", "@types/luxon": "^0.5.3", "@types/mongodb": "^3.0.19", "@types/node": "^10.3.1", "@types/passport": "^0.4.5", + "@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": "^1.3.6", @@ -140,4 +149,4 @@ "webpack-hot-client": "^4.0.3", "webpack-manifest-plugin": "^2.0.3" } -} \ No newline at end of file +} diff --git a/src/core/server/app/index.ts b/src/core/server/app/index.ts index 3711234e6..ef65f8157 100644 --- a/src/core/server/app/index.ts +++ b/src/core/server/app/index.ts @@ -3,6 +3,7 @@ import http from "http"; import { Redis } from "ioredis"; import { Db } from "mongodb"; +import { createPassport } from "talk-server/app/middleware/passport"; import { Config } from "talk-server/config"; import { handleSubscriptions } from "talk-server/graph/common/subscriptions/middleware"; import { Schemas } from "talk-server/graph/schemas"; @@ -35,8 +36,11 @@ export async function createApp(options: AppOptions): Promise { // Static Files parent.use(serveStatic); + // Create some services for the router. + const passport = createPassport({ db: options.mongo }); + // Mount the router. - parent.use(await createRouter(options)); + parent.use(await createRouter(options, { passport })); // Error Handling parent.use(errorLogger); diff --git a/src/core/server/app/middleware/passport/index.ts b/src/core/server/app/middleware/passport/index.ts index a22e11144..bebc52631 100644 --- a/src/core/server/app/middleware/passport/index.ts +++ b/src/core/server/app/middleware/passport/index.ts @@ -1,13 +1,89 @@ import { Db } from "mongodb"; import passport, { Authenticator } from "passport"; +import { NextFunction, Response } from "express"; +import OIDCStrategy, { + Token, + VerifyCallback, +} from "talk-server/app/middleware/passport/oidc"; +import { GQLUSER_ROLE } from "talk-server/graph/tenant/schema/__generated__/types"; +import { Tenant } from "talk-server/models/tenant"; +import { create, retrieveWithProfile, User } from "talk-server/models/user"; +import { Request } from "talk-server/types/express"; + export interface PassportOptions { db: Db; } -export function createPassport(opts: PassportOptions): passport.Authenticator { +async function verifyOIDC( + db: Db, + tenant: Tenant, + { iss, sub, email, email_verified }: Token, + done: VerifyCallback +) { + try { + // Construct the profile that will be used to query for the user. + const profile = { + type: "oidc", + provider: iss, + id: sub, + }; + + // Try to lookup user given their id provided in the `sub` claim. + let user = await retrieveWithProfile(db, tenant.id, profile); + if (!user) { + // FIXME: implement rules. + + // Create the new user, as one didn't exist before! + user = await create(db, tenant.id, { + username: null, + role: GQLUSER_ROLE.COMMENTER, + email, + email_verified, + profiles: [profile], + }); + } + + return done(null, user); + } catch (err) { + return done(err); + } +} + +export function createPassport({ + db, +}: PassportOptions): passport.Authenticator { // Create the authenticator. const auth = new Authenticator(); + // Process the OIDC Strategy. + auth.use(new OIDCStrategy({ db }, verifyOIDC.bind(null, db))); + return auth; } + +export const authenticate = ( + authenticator: passport.Authenticator, + name: string +) => (req: Request, res: Response, next: NextFunction) => + authenticator.authenticate( + name, + { session: false }, + (err: Error | null, user: User | null) => { + if (err) { + // TODO: wrap error? + return next(err); + } + + // 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({ user }); + } + )(req, res, next); diff --git a/src/core/server/app/middleware/passport/oidc.ts b/src/core/server/app/middleware/passport/oidc.ts new file mode 100644 index 000000000..ceac0bc37 --- /dev/null +++ b/src/core/server/app/middleware/passport/oidc.ts @@ -0,0 +1,223 @@ +import jwt from "jsonwebtoken"; +import jwks, { JwksClient } from "jwks-rsa"; +import { Strategy as OAuth2Strategy } from "passport-oauth2"; +import { Strategy } from "passport-strategy"; + +import { reconstructURL } from "talk-server/app/url"; +import { OIDCAuthIntegration, Tenant } from "talk-server/models/tenant"; +import { User } from "talk-server/models/user"; +import { Request } from "talk-server/types/express"; + +export type OIDCStrategyOptions = any; + +export interface Params { + id_token?: string; +} + +export type VerifyCallback = ( + err?: Error | null, + user?: User | null, + info?: object +) => void; + +export interface Token { + iss: string; + sub: string; + email: string; + email_verified?: boolean; +} + +export type OIDCStrategyCallback = ( + tenant: Tenant, + token: Token, + done: VerifyCallback +) => void; + +export interface StrategyItem { + strategy: OAuth2Strategy; + jwksClient?: JwksClient; +} + +// FIXME: attach strategy to cache updates of the tenants + +export default class OIDCStrategy extends Strategy { + public name: string; + + private verify: OIDCStrategyCallback; + private cache: Map; + + constructor(options: OIDCStrategyOptions, verify: OIDCStrategyCallback) { + super(); + + this.name = "oidc"; + this.cache = new Map(); + this.verify = verify; + } + + 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 verifyCallback( + req: Request, + accessToken: string, + refreshToken: string, + params: Params, + profile: any, + 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; + + // Grab the JWKSClient. + const client = this.lookupJWKSClient(req, tenant!.id, tenant!.auth.oidc!); + + // Verify that the id_token is valid or not. + jwt.verify( + id_token, + ({ 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); + } + + const signingKey = key.publicKey || key.rsaPublicKey; + callback(null, signingKey); + }); + }, + { + issuer: tenant!.auth.oidc!.issuer, + }, + (err, decoded) => { + if (err) { + // TODO: wrap error? + return done(err); + } + + this.verify(tenant!, decoded as Token, done); + } + ); + } + + 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.verifyCallback.bind(this) + ); + } + + 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 = tenant.auth.oidc; + if (!integration) { + // TODO: return a better error. + throw new Error("integration not found"); + } + + // 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) { + // Lookup the strategy. + const strategy = await this.lookupStrategy(req); + if (!strategy) { + return; + } + + // 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: "openid email", session: false }); + } +} diff --git a/src/core/server/app/middleware/passport/sso.ts b/src/core/server/app/middleware/passport/sso.ts new file mode 100644 index 000000000..ecc883685 --- /dev/null +++ b/src/core/server/app/middleware/passport/sso.ts @@ -0,0 +1,9 @@ +import { Strategy } from "passport-strategy"; + +import { Request } from "talk-server/types/express"; + +export default class SSOStrategy extends Strategy { + public async authenticate(req: Request) { + return; + } +} diff --git a/src/core/server/app/router.ts b/src/core/server/app/router.ts index fd481ee5c..1ae9934b8 100644 --- a/src/core/server/app/router.ts +++ b/src/core/server/app/router.ts @@ -1,13 +1,15 @@ import express from "express"; +import passport from "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"; +import { authenticate } from "talk-server/app/middleware/passport"; 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 +17,63 @@ 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 })); + + router.use(options.passport.initialize()); + router.use("/auth/oidc", authenticate(options.passport, "oidc")); + router.use("/auth/oidc/callback", authenticate(options.passport, "oidc")); + // router.use("/auth/google", options.passport.authenticate("google")); + // router.use("/auth/google/callback", options.passport.authenticate("google")); + // router.use("/auth/facebook", options.passport.authenticate("facebook")); + // router.use("/auth/facebook/callback", options.passport.authenticate("facebook")); // Tenant API router.use( "/graphql", express.json(), - await tenantGraphMiddleware(opts.schemas.tenant, opts.config, opts.mongo) + await tenantGraphMiddleware(app.schemas.tenant, app.config, app.mongo) ); return router; } -async function createAPIRouter(opts: AppOptions) { +async function createAPIRouter(app: AppOptions, options: RouterOptions) { // Create a router. const router = express.Router(); // Configure the tenant routes. - router.use("/tenant", await createTenantRouter(opts)); + router.use("/tenant", await createTenantRouter(app, options)); // Configure the management routes. - router.use("/management", await createManagementRouter(opts)); + router.use("/management", await createManagementRouter(app, options)); return router; } -export async function createRouter(opts: AppOptions) { +export interface RouterOptions { + passport: passport.Authenticator; +} + +export async function createRouter(app: AppOptions, options: RouterOptions) { // Create a router. const router = express.Router(); - router.use("/api", await createAPIRouter(opts)); + router.use("/api", await createAPIRouter(app, options)); - if (opts.config.get("env") === "development") { + if (app.config.get("env") === "development") { // Tenant GraphiQL router.get( "/tenant/graphiql", diff --git a/src/core/server/app/url.ts b/src/core/server/app/url.ts new file mode 100644 index 000000000..b9f533f1a --- /dev/null +++ b/src/core/server/app/url.ts @@ -0,0 +1,13 @@ +import { Request } from "talk-server/types/express"; +import { URL } from "url"; + +export function reconstructURL(req: Request, input?: string): string { + const scheme = req.secure ? "https" : "http"; + const host = req.get("host"); + const base = `${scheme}://${host}`; + const path = input || req.originalUrl; + + const url = new URL(path, base); + + return url.href; +} diff --git a/src/core/server/graph/common/directives/auth.ts b/src/core/server/graph/common/directives/auth.ts new file mode 100644 index 000000000..600f5a2c1 --- /dev/null +++ b/src/core/server/graph/common/directives/auth.ts @@ -0,0 +1,12 @@ +import { DirectiveResolverFn } from "graphql-tools"; + +const auth: DirectiveResolverFn = (next, src, args, context) => { + return next().then(str => { + if (typeof str === "string") { + return str.toUpperCase(); + } + return str; + }); +}; + +export default auth; diff --git a/src/core/server/graph/tenant/resolvers/auth_settings.ts b/src/core/server/graph/tenant/resolvers/auth_settings.ts new file mode 100644 index 000000000..1519d24c0 --- /dev/null +++ b/src/core/server/graph/tenant/resolvers/auth_settings.ts @@ -0,0 +1,14 @@ +import { GQLAuthSettingsTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types"; +import { Auth, AuthIntegration } from "talk-server/models/tenant"; + +const disabled: AuthIntegration = { enabled: false }; + +const AuthSettings: GQLAuthSettingsTypeResolver = { + 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 AuthSettings; diff --git a/src/core/server/graph/tenant/resolvers/facebook_auth_integration.ts b/src/core/server/graph/tenant/resolvers/facebook_auth_integration.ts new file mode 100644 index 000000000..c0a127d82 --- /dev/null +++ b/src/core/server/graph/tenant/resolvers/facebook_auth_integration.ts @@ -0,0 +1,10 @@ +import { GQLFacebookAuthIntegrationTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types"; +import { FacebookAuthIntegration } from "talk-server/models/tenant"; + +const FacebookAuthIntegration: GQLFacebookAuthIntegrationTypeResolver< + FacebookAuthIntegration +> = { + config: auth => auth, +}; + +export default FacebookAuthIntegration; diff --git a/src/core/server/graph/tenant/resolvers/google_auth_integration.ts b/src/core/server/graph/tenant/resolvers/google_auth_integration.ts new file mode 100644 index 000000000..8edbabcf6 --- /dev/null +++ b/src/core/server/graph/tenant/resolvers/google_auth_integration.ts @@ -0,0 +1,10 @@ +import { GQLGoogleAuthIntegrationTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types"; +import { GoogleAuthIntegration } from "talk-server/models/tenant"; + +const GoogleAuthIntegration: GQLGoogleAuthIntegrationTypeResolver< + GoogleAuthIntegration +> = { + config: auth => auth, +}; + +export default GoogleAuthIntegration; diff --git a/src/core/server/graph/tenant/resolvers/local_auth_integration.ts b/src/core/server/graph/tenant/resolvers/local_auth_integration.ts new file mode 100644 index 000000000..fe14c7b92 --- /dev/null +++ b/src/core/server/graph/tenant/resolvers/local_auth_integration.ts @@ -0,0 +1,8 @@ +import { GQLLocalAuthIntegrationTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types"; +import { LocalAuthIntegration } from "talk-server/models/tenant"; + +const LocalAuthIntegration: GQLLocalAuthIntegrationTypeResolver< + LocalAuthIntegration +> = {}; + +export default LocalAuthIntegration; diff --git a/src/core/server/graph/tenant/resolvers/oidc_auth_integration.ts b/src/core/server/graph/tenant/resolvers/oidc_auth_integration.ts new file mode 100644 index 000000000..966001ab9 --- /dev/null +++ b/src/core/server/graph/tenant/resolvers/oidc_auth_integration.ts @@ -0,0 +1,10 @@ +import { GQLOIDCAuthIntegrationTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types"; +import { OIDCAuthIntegration } from "talk-server/models/tenant"; + +const OIDCAuthIntegration: GQLOIDCAuthIntegrationTypeResolver< + OIDCAuthIntegration +> = { + config: auth => auth, +}; + +export default OIDCAuthIntegration; diff --git a/src/core/server/graph/tenant/resolvers/sso_auth_integration.ts b/src/core/server/graph/tenant/resolvers/sso_auth_integration.ts new file mode 100644 index 000000000..562cad894 --- /dev/null +++ b/src/core/server/graph/tenant/resolvers/sso_auth_integration.ts @@ -0,0 +1,10 @@ +import { GQLSSOAuthIntegrationTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types"; +import { SSOAuthIntegration } from "talk-server/models/tenant"; + +const SSOAuthIntegration: GQLSSOAuthIntegrationTypeResolver< + SSOAuthIntegration +> = { + config: auth => auth, +}; + +export default SSOAuthIntegration; diff --git a/src/core/server/graph/tenant/schema/index.ts b/src/core/server/graph/tenant/schema/index.ts index 1105c358e..1a27fdb3c 100644 --- a/src/core/server/graph/tenant/schema/index.ts +++ b/src/core/server/graph/tenant/schema/index.ts @@ -1,8 +1,14 @@ -import { IResolvers } from "graphql-tools"; +import { attachDirectiveResolvers, IResolvers } from "graphql-tools"; +import auth from "talk-server/graph/common/directives/auth"; import loadSchema from "talk-server/graph/common/schema"; import resolvers from "talk-server/graph/tenant/resolvers"; export default function getTenantSchema() { - return loadSchema("tenant", resolvers as IResolvers); + const schema = loadSchema("tenant", resolvers as IResolvers); + + // Attach the directive resolvers. + attachDirectiveResolvers(schema, { auth }); + + return schema; } diff --git a/src/core/server/graph/tenant/schema/schema.graphql b/src/core/server/graph/tenant/schema/schema.graphql index 96657351d..9f5c0fdcd 100644 --- a/src/core/server/graph/tenant/schema/schema.graphql +++ b/src/core/server/graph/tenant/schema/schema.graphql @@ -1,3 +1,9 @@ +################################################################################ +## Custom Directives +################################################################################ + +directive @auth(roles: [USER_ROLE!]!) on FIELD_DEFINITION + ################################################################################ ## Custom Scalar Types ################################################################################ @@ -45,7 +51,90 @@ type WordlistSettings { suspect: [String!]! } -# Settings stores the global settings for a given installation. +################################################################################ +## AuthSettings +################################################################################ + +########################## +## LocalAuthIntegration +########################## + +type LocalAuthIntegration { + enabled: Boolean! +} + +########################## +## SSOAuthIntegration +########################## + +type SSOAuthIntegrationConfig { + key: String! +} + +type SSOAuthIntegration { + enabled: Boolean! + config: SSOAuthIntegrationConfig @auth(roles: [ADMIN]) +} + +########################## +## OIDCAuthIntegration +########################## + +type OIDCAuthIntegrationConfig { + clientID: String! + clientSecret: String! + authorizationURL: String! + tokenURL: String! +} + +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]) +} + +""" +AuthSettings contains all the settings related to authentication and authorization. +""" +type AuthSettings { + local: LocalAuthIntegration! + sso: SSOAuthIntegration! + oidc: OIDCAuthIntegration! + google: GoogleAuthIntegration! + facebook: FacebookAuthIntegration! +} ################################################################################ ## Settings @@ -58,12 +147,12 @@ type Settings { """ domain is the domain that is associated with this Tenant. """ - domain: String! + 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. @@ -100,7 +189,7 @@ 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` @@ -165,18 +254,29 @@ type Settings { """ wordlist will return a given list of words. """ - wordlist: WordlistSettings! + wordlist: WordlistSettings @auth(roles: [ADMIN]) """ domains will return a given list of whitelisted domains. """ - domains: [String!]! + domains: [String!] @auth(roles: [ADMIN]) + + """ + auth contains all the settings related to authentication and authorization. + """ + auth: AuthSettings! } ################################################################################ ## User ################################################################################ +enum USER_ROLE { + COMMENTER + MODERATOR + ADMIN +} + """ User is someone that leaves Comments, and logs in. """ @@ -190,6 +290,11 @@ type User { username is the name of the User visible to other Users. """ username: String! + + """ + role is the current role of the User. + """ + role: USER_ROLE! } ################################################################################ diff --git a/src/core/server/models/tenant.ts b/src/core/server/models/tenant.ts index 8e41a1bf2..123c6b261 100644 --- a/src/core/server/models/tenant.ts +++ b/src/core/server/models/tenant.ts @@ -1,9 +1,11 @@ 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 { GQLUSER_ROLE } from "talk-server/graph/tenant/schema/__generated__/types"; + function collection(db: Db) { return db.collection>("tenants"); } @@ -22,6 +24,92 @@ export enum Moderation { POST = "POST", } +// AuthIntegrations. + +export interface EmailDomainRuleCondition { + // emailDomain is the domain name component of the email addresses that should + // match for this condition. + emailDomain: string; + + // emailVerifiedRequired stipulates that this rule only applies when the user + // account has been marked as having their email address already verified. + emailVerifiedRequired: boolean; +} + +// RoleRule describes the role assignment for when a user logs into Talk, how +// they can have their account automatically upgraded to a specific role when +// the domain for their email matches the one provided. +export interface RoleRule extends Partial { + // role is the specific GQLUSER_ROLE that should be assigned to the newly created + // user depending on their email address. + role: GQLUSER_ROLE; +} + +export interface AuthRules { + // roles allow the configuration of automatic role assignment based on the + // user's email address. + roles?: RoleRule[]; + + // restrictTo when populated, will restrict which users can login using this + // integration. If a user successfully logs in using the OIDCStrategy, but + // does not match the following rules, the user will not be created. + restrictTo?: EmailDomainRuleCondition[]; +} + +export interface AuthIntegration { + enabled: boolean; +} + +// 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 { + 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 { + 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; + +// Auth describes all of the possible auth integration configurations. +export interface Auth { + // 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; +} + +// Tenant definition. + export interface Tenant { readonly id: string; @@ -57,6 +145,9 @@ export interface Tenant { // domains is the set of whitelisted domains. domains: string[]; + + // Set of configured authentication integrations. + auth: Auth; } /** @@ -99,6 +190,11 @@ export async function createTenant(db: Db, input: CreateTenantInput) { suspect: [], banned: [], }, + auth: { + local: { + enabled: true, + }, + }, }; // Create the new Tenant by merging it together with the defaults. diff --git a/src/core/server/models/user.ts b/src/core/server/models/user.ts index 6812fefce..487f7174f 100644 --- a/src/core/server/models/user.ts +++ b/src/core/server/models/user.ts @@ -1,9 +1,11 @@ import { merge } from "lodash"; import { Db } from "mongodb"; +import uuid from "uuid"; + import { Omit, Sub } from "talk-common/types"; +import { GQLUSER_ROLE } from "talk-server/graph/tenant/schema/__generated__/types"; import { ActionCounts } from "talk-server/models/actions"; import { TenantResource } from "talk-server/models/tenant"; -import uuid from "uuid"; function collection(db: Db) { return db.collection>("users"); @@ -11,6 +13,7 @@ function collection(db: Db) { export interface Profile { readonly id: string; + readonly type: string; provider: string; } @@ -45,13 +48,6 @@ export enum UserUsernameStatus { CHANGED = "CHANGED", } -export enum UserRole { - ADMIN = "ADMIN", - MODERATOR = "MODERATOR", - STAFF = "STAFF", - COMMENTER = "COMMENTER", -} - export interface UserStatusHistory { status: T; // TODO: migrate field assigned_by?: string; @@ -72,11 +68,13 @@ export interface UserStatus { export interface User extends TenantResource { readonly id: string; - username: string; + username: string | null; password?: 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 @@ -89,7 +87,6 @@ export type CreateUserInput = Omit< | "tenant_id" | "tokens" | "status" - | "role" | "action_counts" | "ignored_users" | "created_at" @@ -98,15 +95,11 @@ export type CreateUserInput = Omit< export async function create(db: Db, tenantID: string, input: CreateUserInput) { const now = new Date(); - // // Pull out some useful properties from the input. - // const { body, status } = input; - // default are the properties set by the application when a new user is // created. const defaults: Sub = { id: uuid.v4(), tenant_id: tenantID, - role: UserRole.COMMENTER, tokens: [], action_counts: {}, ignored_users: [], @@ -120,7 +113,9 @@ export async function create(db: Db, tenantID: string, input: CreateUserInput) { history: [], }, username: { - status: UserUsernameStatus.SET, + status: input.username + ? UserUsernameStatus.SET + : UserUsernameStatus.UNSET, history: [], }, }, @@ -153,11 +148,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 retrieveWithProfile( + db: Db, + tenantID: string, + profile: Profile +) { + return collection(db).findOne({ + tenant_id: tenantID, + profiles: { + $elemMatch: profile, + }, + }); +} + export async function updateRole( db: Db, tenantID: string, id: string, - role: UserRole + role: GQLUSER_ROLE ) { const result = await collection(db).findOneAndUpdate( { id, tenant_id: tenantID }, diff --git a/src/types/jsonwebtoken.d.ts b/src/types/jsonwebtoken.d.ts new file mode 100644 index 000000000..de8c9f82f --- /dev/null +++ b/src/types/jsonwebtoken.d.ts @@ -0,0 +1,19 @@ +import { VerifyOptions, VerifyCallback } from "jsonwebtoken"; + +declare module "jsonwebtoken" { + export type KeyFunctionCallback = ( + err: Error | null, + secretOrPublicKey?: string | Buffer + ) => void; + export type KeyFunction = ( + headers: { kid?: string }, + callback: KeyFunctionCallback + ) => void; + + export function verify( + token: string, + secretOrPublicKey: string | Buffer | KeyFunction, + options?: VerifyOptions, + callback?: VerifyCallback + ): void; +} diff --git a/src/types/webfinger.d.ts b/src/types/webfinger.d.ts new file mode 100644 index 000000000..a13bfb998 --- /dev/null +++ b/src/types/webfinger.d.ts @@ -0,0 +1,16 @@ +declare module "webfinger" { + export interface WebfingerOptions { + webfingerOnly?: boolean; + } + + export interface WebfingerCallback { + (err: Error, jrd: { [key: string]: any }): void; + } + + export function webfinger( + resource: string, + res: string, + options: WebfingerOptions, + callback: WebfingerCallback + ): void; +} From 679ad01a7dbb1fa0e3cbab2e5d463e0d5d17cb5b Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Wed, 4 Jul 2018 10:17:48 -0600 Subject: [PATCH 03/43] feat: supported watcher --- config/nodemon/relay-stream.json | 8 ------ config/nodemon/server.json | 10 -------- config/nodemon/types.json | 7 ------ config/watcher.ts | 6 +++++ package.json | 21 ++++++++-------- scripts/types.js | 6 +++-- src/core/client/tsconfig.json | 43 ++++++++------------------------ src/tsconfig.json | 25 +++++-------------- tsconfig.json | 18 +++---------- 9 files changed, 41 insertions(+), 103 deletions(-) delete mode 100644 config/nodemon/relay-stream.json delete mode 100644 config/nodemon/server.json delete mode 100644 config/nodemon/types.json diff --git a/config/nodemon/relay-stream.json b/config/nodemon/relay-stream.json deleted file mode 100644 index dc9556823..000000000 --- a/config/nodemon/relay-stream.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "exec": "npm-run-all compile:relay-stream", - "ext": "ts,tsx,graphql", - "watch": [ - "./src/core/client/stream", - "./src/core/**/*.graphql" - ] -} diff --git a/config/nodemon/server.json b/config/nodemon/server.json deleted file mode 100644 index 08472086e..000000000 --- a/config/nodemon/server.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "exec": "npm run start:development", - "ext": "ts,graphql", - "watch": [ - "./src" - ], - "ignore": [ - "./src/client" - ] -} diff --git a/config/nodemon/types.json b/config/nodemon/types.json deleted file mode 100644 index 73203fd0a..000000000 --- a/config/nodemon/types.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "exec": "npm run compile:server:types", - "ext": "graphql", - "watch": [ - "./src/core/server/graph" - ] -} \ No newline at end of file diff --git a/config/watcher.ts b/config/watcher.ts index 305193045..0831d698f 100644 --- a/config/watcher.ts +++ b/config/watcher.ts @@ -8,6 +8,12 @@ import { const config: Config = { rootDir: path.resolve(__dirname, "../src"), watchers: { + compileGraphQLTypes: { + paths: ["core/server/graph/**/*.graphql"], + executor: new CommandExecutor("npm run compile:server:types", { + runOnInit: true, + }), + }, compileRelayStream: { paths: [ "core/client/stream/**/*.ts", diff --git a/package.json b/package.json index 576662b45..97f04b404 100644 --- a/package.json +++ b/package.json @@ -3,23 +3,24 @@ "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 --parallel compile:* --parallel build:*", "build:client": "node ./scripts/build.js", "build:server": "tsc -p ./src/tsconfig.json", - "watch": "NODE_ENV=development ts-node ./scripts/watcher/bin/watcher.ts ./config/watcher.ts", + "build": "npm-run-all --parallel compile:* --parallel build:*", "compile:css-types": "tcm src/core/client/", - "watch:types": "nodemon --config ./config/nodemon/types.json", + "compile:graphql": "node ./scripts/types.js", "compile:relay-stream": "relay-compiler --src ./src/core/client/stream --schema $(ts-node ./scripts/schemaPath.ts tenant) --language typescript --artifactDirectory ./src/core/client/stream/__generated__ --no-watchman", - "start:development": "NODE_ENV=development ts-node --project ./src/tsconfig.json -r tsconfig-paths/register ./src/index.ts", - "start:webpackDevServer": "node ./scripts/start.js", + "docz:watch": "docz dev", "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:watch": "docz dev" + "lint:server": "tslint --project ./src/tsconfig.json", + "lint": "npm-run-all --parallel lint:*", + "start:development": "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:types": "nodemon --config ./config/nodemon/types.json", + "watch": "NODE_ENV=development ts-node ./scripts/watcher/bin/watcher.ts ./config/watcher.ts" }, "author": "", "license": "Apache-2.0", diff --git a/scripts/types.js b/scripts/types.js index 3825548e7..0bf026bd8 100644 --- a/scripts/types.js +++ b/scripts/types.js @@ -4,7 +4,7 @@ const { getGraphQLConfig } = require("graphql-config"); const path = require("path"); const fs = require("fs"); -function lint(files) { +function lintAndWrite(files) { const linter = new Linter({ fix: true }); for (const { fileName, types } of files) { @@ -80,7 +80,7 @@ async function main() { } // Send the files off to the linter to be linted and written. - lint(files); + lintAndWrite(files); return files; } @@ -88,9 +88,11 @@ async function main() { main() .then(files => { for (const { fileName } of files) { + // tslint:disable-next-line:no-console console.log(`Generated ${fileName}`); } }) .catch(err => { + // tslint:disable-next-line:no-console console.error(err); }); diff --git a/src/core/client/tsconfig.json b/src/core/client/tsconfig.json index ffb2142b6..e8fb8eeab 100644 --- a/src/core/client/tsconfig.json +++ b/src/core/client/tsconfig.json @@ -5,40 +5,17 @@ "module": "esnext", "jsx": "preserve", "allowJs": false, - "lib": [ - "dom", - "es7", - "scripthost", - "es2015", - "esnext.asynciterable" - ], + "lib": ["dom", "es7", "scripthost", "es2015", "esnext.asynciterable"], "baseUrl": "./", "paths": { - "talk-admin/*": [ - "./admin/*" - ], - "talk-stream/*": [ - "./stream/*" - ], - "talk-framework/*": [ - "./framework/*" - ], - "talk-ui/*": [ - "./ui/*" - ], - "talk-common/*": [ - "../common/*" - ], - "talk-locales/*": [ - "../../locales/*" - ] + "talk-admin/*": ["./admin/*"], + "talk-stream/*": ["./stream/*"], + "talk-framework/*": ["./framework/*"], + "talk-ui/*": ["./ui/*"], + "talk-common/*": ["../common/*"], + "talk-locales/*": ["../../locales/*"] } }, - "include": [ - "./**/*", - "../../types/**/*.d.ts" - ], - "exclude": [ - "node_modules" - ] -} \ No newline at end of file + "include": ["./**/*", "../../types/**/*.d.ts"], + "exclude": ["node_modules"] +} diff --git a/src/tsconfig.json b/src/tsconfig.json index eddf98d29..ac216dbaa 100644 --- a/src/tsconfig.json +++ b/src/tsconfig.json @@ -9,26 +9,13 @@ "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/*" - ], - "talk-common/*": [ - "./core/common/*" - ] + "talk-server/*": ["./core/server/*"], + "talk-common/*": ["./core/common/*"] } }, - "include": [ - "./**/*" - ], - "exclude": [ - "node_modules", - "./core/client" - ] -} \ No newline at end of file + "include": ["./**/*"], + "exclude": ["node_modules", "./core/client"] +} diff --git a/tsconfig.json b/tsconfig.json index 8e15c0223..bee79d2d0 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,18 +13,8 @@ "noImplicitAny": true, "strictNullChecks": true, "noErrorTruncation": true, - "lib": [ - "es6", - "esnext.asynciterable" - ] + "lib": ["es6", "esnext.asynciterable"] }, - "include": [ - "./src/**/.*.js", - "./scripts/**/*", - "./config/**/*", - "*.js" - ], - "exclude": [ - "node_modules" - ] -} \ No newline at end of file + "include": ["./src/**/.*.js", "./scripts/**/*", "./config/**/*", "*.js"], + "exclude": ["node_modules"] +} From ec6955715f2ccf103c24dad201c2470b298351fa Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Wed, 4 Jul 2018 10:21:29 -0600 Subject: [PATCH 04/43] fixed config --- config/watcher.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/watcher.ts b/config/watcher.ts index 0831d698f..a6b643e2c 100644 --- a/config/watcher.ts +++ b/config/watcher.ts @@ -10,7 +10,7 @@ const config: Config = { watchers: { compileGraphQLTypes: { paths: ["core/server/graph/**/*.graphql"], - executor: new CommandExecutor("npm run compile:server:types", { + executor: new CommandExecutor("npm run compile:graphql", { runOnInit: true, }), }, From eb7ddd7310b5f44ce2018d53495ee1c79101d96f Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Tue, 3 Jul 2018 15:49:35 -0300 Subject: [PATCH 05/43] Do not rerun relay compiler on its own changes --- config/watcher.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/watcher.ts b/config/watcher.ts index a6b643e2c..d407e7272 100644 --- a/config/watcher.ts +++ b/config/watcher.ts @@ -21,7 +21,7 @@ const config: Config = { "core/client/stream/**/*.graphql", "core/client/server/**/*.graphql", ], - ignore: ["core/**/*.d.ts"], + ignore: ["core/**/*.d.ts", "core/**/*.graphql.ts"], executor: new CommandExecutor("npm run compile:relay-stream", { runOnInit: true, }), From 6a185e587e69d99b8a785d90395adbacf9e51720 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Wed, 4 Jul 2018 10:50:10 -0600 Subject: [PATCH 06/43] feat: added support for --only flag --- scripts/watcher/bin/watcher.ts | 12 ++++++++++-- scripts/watcher/types.ts | 4 ++++ scripts/watcher/watch.ts | 17 +++++++++++++++-- 3 files changed, 29 insertions(+), 4 deletions(-) diff --git a/scripts/watcher/bin/watcher.ts b/scripts/watcher/bin/watcher.ts index 32209c064..a3a50e6c3 100644 --- a/scripts/watcher/bin/watcher.ts +++ b/scripts/watcher/bin/watcher.ts @@ -4,16 +4,24 @@ import program from "commander"; import path from "path"; import watch from "../"; +function list(val: string) { + return val.split(","); +} + program .version("0.1.0") .usage("") + .option("-o, --only ", "only run the specified watcher", list) .arguments("") .description("Run watchers defined in ") - .action(configFile => { + .action((configFile, cmd) => { + const { only = [] } = cmd; + let config: any = require(path.resolve(configFile)); if (config.__esModule) { config = config.default; } - watch(config); + + watch(config, { only }); }) .parse(process.argv); diff --git a/scripts/watcher/types.ts b/scripts/watcher/types.ts index f78daee24..15bd6090b 100644 --- a/scripts/watcher/types.ts +++ b/scripts/watcher/types.ts @@ -17,6 +17,10 @@ export interface Executor { execute(filePath: string): void; } +export interface Options { + only?: string[]; +} + export interface Config { rootDir?: string; backend?: Watcher; diff --git a/scripts/watcher/watch.ts b/scripts/watcher/watch.ts index 572f7107b..8a3feac75 100644 --- a/scripts/watcher/watch.ts +++ b/scripts/watcher/watch.ts @@ -2,7 +2,7 @@ import Joi from "joi"; import path from "path"; import ChokidarWatcher from "./ChokidarWatcher"; -import { Config, configSchema, WatchConfig, Watcher } from "./types"; +import { Config, configSchema, Options, WatchConfig, Watcher } from "./types"; // Polyfill the asyncIterator symbol. if (Symbol.asyncIterator === undefined) { @@ -43,9 +43,22 @@ function setupCleanup(config: Config) { ); } -export default async function watch(config: Config) { +function filterOnly(config: Config, only: string[]) { + for (const key of Object.keys(config.watchers)) { + if (only.indexOf(key) === -1) { + // tslint:disable-next-line:no-console + console.log(`Disabled watcher "${key}"`); + delete config.watchers[key]; + } + } +} + +export default async function watch(config: Config, options?: Options) { Joi.assert(config, configSchema); const watcher = config.backend || new ChokidarWatcher(); + if (options && options.only && options.only.length > 0) { + filterOnly(config, options.only); + } setupCleanup(config); for (const key of Object.keys(config.watchers)) { // tslint:disable-next-line:no-console From e450fa05e0835afffe44accfe51ec85440bfaeac Mon Sep 17 00:00:00 2001 From: Kiwi Date: Wed, 4 Jul 2018 14:01:08 -0300 Subject: [PATCH 07/43] Use JSDocs comments (#1727) --- scripts/watcher/CommandExecutor.ts | 6 +++--- scripts/watcher/LongRunningExecutor.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/scripts/watcher/CommandExecutor.ts b/scripts/watcher/CommandExecutor.ts index 60cf15e6a..fddba0b8b 100644 --- a/scripts/watcher/CommandExecutor.ts +++ b/scripts/watcher/CommandExecutor.ts @@ -5,13 +5,13 @@ import { Executor } from "./types"; interface CommandExecutorOptions { args?: ReadonlyArray; - // If true, allow spawning multiple processes. + /** If true, allow spawning multiple processes. */ spawnMutiple?: boolean; - // Specify the period in which the process is started at max once. + /** Specify the period in which the process is started at max once. */ debounce?: number | false; - // If true, will run command upon initialization. + /** If true, will run command upon initialization. */ runOnInit?: boolean; } diff --git a/scripts/watcher/LongRunningExecutor.ts b/scripts/watcher/LongRunningExecutor.ts index cffe6f7db..7019c1fb3 100644 --- a/scripts/watcher/LongRunningExecutor.ts +++ b/scripts/watcher/LongRunningExecutor.ts @@ -6,7 +6,7 @@ import { Executor } from "./types"; interface LongRunningExecutorOptions { args?: ReadonlyArray; - // Specify the period in which the process is restarted at max once. + /** Specify the period in which the process is restarted at max once. */ debounce?: number; } From 3e90877012406acb9c08e28e89b2aa342edc9b4b Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Wed, 4 Jul 2018 14:44:26 -0600 Subject: [PATCH 08/43] feat: added @auth directive --- package.json | 2 - src/core/server/graph/common/context.ts | 13 +++++++ .../server/graph/common/directives/auth.ts | 39 ++++++++++++++++--- src/core/server/graph/management/context.ts | 5 ++- src/core/server/graph/tenant/context.ts | 7 ++-- .../server/graph/tenant/schema/schema.graphql | 19 ++++++--- 6 files changed, 67 insertions(+), 18 deletions(-) create mode 100644 src/core/server/graph/common/context.ts diff --git a/package.json b/package.json index 97f04b404..cbe872d79 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,6 @@ "start:webpackDevServer": "node ./scripts/start.js", "start": "node dist/index.js", "test": "node scripts/test.js --env=jsdom", - "watch:types": "nodemon --config ./config/nodemon/types.json", "watch": "NODE_ENV=development ts-node ./scripts/watcher/bin/watcher.ts ./config/watcher.ts" }, "author": "", @@ -112,7 +111,6 @@ "html-webpack-plugin": "^3.2.0", "jest": "^23.2.0", "loader-utils": "^1.1.0", - "nodemon": "^1.17.5", "npm-run-all": "^4.1.3", "postcss-flexbugs-fixes": "^3.3.1", "postcss-font-magician": "^2.2.1", diff --git a/src/core/server/graph/common/context.ts b/src/core/server/graph/common/context.ts new file mode 100644 index 000000000..d939d7b64 --- /dev/null +++ b/src/core/server/graph/common/context.ts @@ -0,0 +1,13 @@ +import { User } from "talk-server/models/user"; + +export interface CommonContextOptions { + user?: User; +} + +export default class CommonContext { + public user?: User; + + constructor({ user }: CommonContextOptions) { + this.user = user; + } +} diff --git a/src/core/server/graph/common/directives/auth.ts b/src/core/server/graph/common/directives/auth.ts index 600f5a2c1..2b21a6366 100644 --- a/src/core/server/graph/common/directives/auth.ts +++ b/src/core/server/graph/common/directives/auth.ts @@ -1,12 +1,39 @@ import { DirectiveResolverFn } from "graphql-tools"; -const auth: DirectiveResolverFn = (next, src, args, context) => { - return next().then(str => { - if (typeof str === "string") { - return str.toUpperCase(); +import CommonContext from "talk-server/graph/common/context"; +import { GQLUSER_ROLE } from "talk-server/graph/tenant/schema/__generated__/types"; + +export interface AuthDirectiveArgs { + roles?: GQLUSER_ROLE[]; + userIDField?: string; +} + +const auth: DirectiveResolverFn< + Record, + CommonContext +> = (next, src, { roles, userIDField }: AuthDirectiveArgs, { user }) => { + // If there is a user on the request. + if (user) { + // If the role and user owner checks are disabled, then allow them based on + // their authenticated status. + if (!roles && !userIDField) { + return next(); } - return str; - }); + + // And the user has the expected role. + if (roles && roles.includes(user.role)) { + // Let the request continue. + return next(); + } + + // Or the item is owned by the specific user. + if (userIDField && src[userIDField] && src[userIDField] === user.id) { + return next(); + } + } + + // TODO: return better error. + throw new Error("not authorized"); }; export default auth; diff --git a/src/core/server/graph/management/context.ts b/src/core/server/graph/management/context.ts index dda52cb5b..c77f1a207 100644 --- a/src/core/server/graph/management/context.ts +++ b/src/core/server/graph/management/context.ts @@ -1,13 +1,16 @@ import { Db } from "mongodb"; +import CommonContext from "talk-server/graph/common/context"; export interface ManagementContextOptions { db: Db; } -export default class ManagementContext { +export default class ManagementContext extends CommonContext { public db: Db; constructor({ db }: ManagementContextOptions) { + super({}); + this.db = db; } } diff --git a/src/core/server/graph/tenant/context.ts b/src/core/server/graph/tenant/context.ts index c49b3d49a..21e2dc9b9 100644 --- a/src/core/server/graph/tenant/context.ts +++ b/src/core/server/graph/tenant/context.ts @@ -1,4 +1,5 @@ import { Db } from "mongodb"; +import CommonContext from "talk-server/graph/common/context"; import { Tenant } from "talk-server/models/tenant"; import { User } from "talk-server/models/user"; import loaders from "./loaders"; @@ -10,16 +11,16 @@ export interface TenantContextOptions { user?: User; } -export default class TenantContext { +export default class TenantContext extends CommonContext { public loaders: ReturnType; public mutators: ReturnType; public db: Db; public tenant: Tenant; - public user?: User; constructor({ user, tenant, db }: TenantContextOptions) { + super({ user }); + this.tenant = tenant; - this.user = user; this.loaders = loaders(this); this.mutators = mutators(this); this.db = db; diff --git a/src/core/server/graph/tenant/schema/schema.graphql b/src/core/server/graph/tenant/schema/schema.graphql index 9f5c0fdcd..bf6e5d6f8 100644 --- a/src/core/server/graph/tenant/schema/schema.graphql +++ b/src/core/server/graph/tenant/schema/schema.graphql @@ -2,7 +2,14 @@ ## Custom Directives ################################################################################ -directive @auth(roles: [USER_ROLE!]!) on FIELD_DEFINITION +""" +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 @@ -195,7 +202,7 @@ type Settings { 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. @@ -254,12 +261,12 @@ type Settings { """ wordlist will return a given list of words. """ - wordlist: WordlistSettings @auth(roles: [ADMIN]) + wordlist: WordlistSettings @auth(roles: [ADMIN, MODERATOR]) """ domains will return a given list of whitelisted domains. """ - domains: [String!] @auth(roles: [ADMIN]) + domains: [String!] @auth(roles: [ADMIN]) @auth(roles: [ADMIN]) """ auth contains all the settings related to authentication and authorization. @@ -294,7 +301,7 @@ type User { """ role is the current role of the User. """ - role: USER_ROLE! + role: USER_ROLE! @auth(roles: [ADMIN, MODERATOR], userIDField: "id") } ################################################################################ @@ -561,7 +568,7 @@ type Mutation { """ createComment will create a Comment as the current logged in User. """ - createComment(input: CreateCommentInput!): CreateCommentPayload + createComment(input: CreateCommentInput!): CreateCommentPayload @auth } ################################################################################ From 87ce65755c2840f6bb89b55a0dad31e141a412d1 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Thu, 5 Jul 2018 15:58:19 -0600 Subject: [PATCH 09/43] feat: initial local passport strategy --- package-lock.json | 107 ++++---------- package.json | 6 +- scripts/types.js | 4 +- .../server/app/middleware/passport/index.ts | 60 ++------ .../server/app/middleware/passport/local.ts | 61 ++++++++ .../server/app/middleware/passport/oidc.ts | 133 +++++++++++++----- src/core/server/app/router.ts | 9 ++ src/core/server/app/url.ts | 3 +- .../server/graph/tenant/loaders/comments.ts | 18 ++- src/core/server/graph/tenant/loaders/users.ts | 4 +- .../server/graph/tenant/schema/schema.graphql | 35 +++++ src/core/server/models/comment.ts | 20 +-- src/core/server/models/tenant.ts | 2 +- src/core/server/models/user.ts | 65 ++++----- src/core/server/services/comments/index.ts | 2 +- 15 files changed, 312 insertions(+), 217 deletions(-) create mode 100644 src/core/server/app/middleware/passport/local.ts diff --git a/package-lock.json b/package-lock.json index 4d7d90884..6b61ad34c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1403,6 +1403,12 @@ "lodash.deburr": "^4.1.0" } }, + "@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.20", "resolved": "https://registry.npmjs.org/@types/bluebird/-/bluebird-3.5.20.tgz", @@ -1624,6 +1630,17 @@ "@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", @@ -4380,6 +4397,11 @@ "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", @@ -9865,12 +9887,6 @@ "integrity": "sha512-Pgs951kaMm5GXP7MOvxERINe3gsaVjUWFm+UZPSq9xYriQAksyhg0csnS0KXSNRD5NmNdapXEpjxG49+AKh/ug==", "dev": true }, - "ignore-by-default": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", - "integrity": "sha1-SMptcvbGo68Aqa1K5odr44ieKwk=", - "dev": true - }, "immutable": { "version": "3.7.6", "resolved": "https://registry.npmjs.org/immutable/-/immutable-3.7.6.tgz", @@ -12946,50 +12962,6 @@ "which": "^1.3.0" } }, - "nodemon": { - "version": "1.17.5", - "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-1.17.5.tgz", - "integrity": "sha512-FG2mWJU1Y58a9ktgMJ/RZpsiPz3b7ge77t/okZHEa4NbrlXGKZ8s1A6Q+C7+JPXohAfcPALRwvxcAn8S874pmw==", - "dev": true, - "requires": { - "chokidar": "^2.0.2", - "debug": "^3.1.0", - "ignore-by-default": "^1.0.1", - "minimatch": "^3.0.4", - "pstree.remy": "^1.1.0", - "semver": "^5.5.0", - "supports-color": "^5.2.0", - "touch": "^3.1.0", - "undefsafe": "^2.0.2", - "update-notifier": "^2.3.0" - }, - "dependencies": { - "debug": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", - "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", - "dev": true, - "requires": { - "ms": "2.0.0" - } - }, - "has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=", - "dev": true - }, - "supports-color": { - "version": "5.4.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.4.0.tgz", - "integrity": "sha512-zjaXglF5nnWpsq470jSv6P9DwPvgLkuapYmfDm3JWOm0vkNTVF2tI4UrN2r6jH1qM/uc/WtxYY1hYoA2dOKj5w==", - "dev": true, - "requires": { - "has-flag": "^3.0.0" - } - } - } - }, "nopt": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/nopt/-/nopt-1.0.10.tgz", @@ -13529,6 +13501,14 @@ "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", @@ -16106,15 +16086,6 @@ "integrity": "sha512-+AqO1Ae+N/4r7Rvchrdm432afjT9hqJRyBN3DQv9At0tPz4hIFSGKbq64fN9dVoCow4oggIIax5/iONx0r9hZw==", "dev": true }, - "pstree.remy": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.0.tgz", - "integrity": "sha512-q5I5vLRMVtdWa8n/3UEzZX7Lfghzrg9eG2IKk2ENLSofKRCXVqMvMUHxCKgXNaqH/8ebhBxrqftHWnyTFweJ5Q==", - "dev": true, - "requires": { - "ps-tree": "^1.1.0" - } - }, "public-encrypt": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/public-encrypt/-/public-encrypt-4.0.2.tgz", @@ -18966,15 +18937,6 @@ "integrity": "sha1-LmhELZ9k7HILjMieZEOsbKqVACk=", "dev": true }, - "touch": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.0.tgz", - "integrity": "sha512-WBx8Uy5TLtOSRtIq+M03/sKDrXCLHxwDcquSP2c43Le03/9serjQBIztjRz6FkJez9D/hleyAXTBGLwwZUw9lA==", - "dev": true, - "requires": { - "nopt": "~1.0.10" - } - }, "tough-cookie": { "version": "2.4.3", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.4.3.tgz", @@ -19852,15 +19814,6 @@ "integrity": "sha512-keqHubrlpvT6G2wH0OEfSW4mquYRcbe/J8NMmveoQOjUqmo+hXtO+ORCpWhdbZ7k72UtY61BL7haGxW6enBnjw==", "dev": true }, - "undefsafe": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.2.tgz", - "integrity": "sha1-Il9rngM3Zj4Njnz9aG/Cg2zKznY=", - "dev": true, - "requires": { - "debug": "^2.2.0" - } - }, "unherit": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/unherit/-/unherit-1.1.1.tgz", diff --git a/package.json b/package.json index cbe872d79..76e1cb87f 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "license": "Apache-2.0", "dependencies": { "apollo-server-express": "^1.3.6", + "bcryptjs": "^2.4.3", "bunyan": "^1.8.12", "convict": "^4.3.0", "dataloader": "^1.4.0", @@ -46,6 +47,7 @@ "luxon": "^1.2.1", "mongodb": "^3.0.10", "passport": "^0.4.0", + "passport-local": "^1.0.0", "passport-oauth2": "^1.4.0", "passport-strategy": "^1.0.0", "performance-now": "^2.1.0", @@ -58,6 +60,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", @@ -76,6 +79,7 @@ "@types/mongodb": "^3.0.19", "@types/node": "^10.3.1", "@types/passport": "^0.4.5", + "@types/passport-local": "^1.0.33", "@types/passport-oauth2": "^1.4.5", "@types/passport-strategy": "^0.2.33", "@types/query-string": "^6.1.0", @@ -149,4 +153,4 @@ "webpack-hot-client": "^4.0.3", "webpack-manifest-plugin": "^2.0.3" } -} \ No newline at end of file +} diff --git a/scripts/types.js b/scripts/types.js index 0bf026bd8..be1449842 100644 --- a/scripts/types.js +++ b/scripts/types.js @@ -72,9 +72,7 @@ async function main() { file.types = await generateTSTypesAsString(schema, { tabSpaces: 2, typePrefix: "GQL", - contextType: "any", - strictNulls: false, - minimizeInterfaceImplementation: true, + strictNulls: true, ...file.config, }); } diff --git a/src/core/server/app/middleware/passport/index.ts b/src/core/server/app/middleware/passport/index.ts index bebc52631..1b6968564 100644 --- a/src/core/server/app/middleware/passport/index.ts +++ b/src/core/server/app/middleware/passport/index.ts @@ -1,63 +1,33 @@ +import { NextFunction, Response } from "express"; import { Db } from "mongodb"; import passport, { Authenticator } from "passport"; -import { NextFunction, Response } from "express"; -import OIDCStrategy, { - Token, - VerifyCallback, -} from "talk-server/app/middleware/passport/oidc"; -import { GQLUSER_ROLE } from "talk-server/graph/tenant/schema/__generated__/types"; -import { Tenant } from "talk-server/models/tenant"; -import { create, retrieveWithProfile, User } from "talk-server/models/user"; +import { createLocalStrategy } from "talk-server/app/middleware/passport/local"; +import { createOIDCStrategy } from "talk-server/app/middleware/passport/oidc"; +import { User } from "talk-server/models/user"; import { Request } from "talk-server/types/express"; +export type VerifyCallback = ( + err?: Error | null, + user?: User | null, + info?: object +) => void; + export interface PassportOptions { db: Db; } -async function verifyOIDC( - db: Db, - tenant: Tenant, - { iss, sub, email, email_verified }: Token, - done: VerifyCallback -) { - try { - // Construct the profile that will be used to query for the user. - const profile = { - type: "oidc", - provider: iss, - id: sub, - }; - - // Try to lookup user given their id provided in the `sub` claim. - let user = await retrieveWithProfile(db, tenant.id, profile); - if (!user) { - // FIXME: implement rules. - - // Create the new user, as one didn't exist before! - user = await create(db, tenant.id, { - username: null, - role: GQLUSER_ROLE.COMMENTER, - email, - email_verified, - profiles: [profile], - }); - } - - return done(null, user); - } catch (err) { - return done(err); - } -} - export function createPassport({ db, }: PassportOptions): passport.Authenticator { // Create the authenticator. const auth = new Authenticator(); - // Process the OIDC Strategy. - auth.use(new OIDCStrategy({ db }, verifyOIDC.bind(null, db))); + // Use the OIDC Strategy. + auth.use(createOIDCStrategy({ db })); + + // Use the LocalStrategy. + auth.use(createLocalStrategy({ db })); return auth; } diff --git a/src/core/server/app/middleware/passport/local.ts b/src/core/server/app/middleware/passport/local.ts new file mode 100644 index 000000000..4aa707153 --- /dev/null +++ b/src/core/server/app/middleware/passport/local.ts @@ -0,0 +1,61 @@ +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"; + +async function verify( + db: Db, + req: Request, + email: string, + password: string, + done: VerifyCallback +) { + try { + // The tenant is guaranteed at this point. + const tenant = req.tenant!; + + // TODO: rate limit the ip address + + // 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, + }, + verify.bind(null, db) + ); +} diff --git a/src/core/server/app/middleware/passport/oidc.ts b/src/core/server/app/middleware/passport/oidc.ts index ceac0bc37..f75fe43b3 100644 --- a/src/core/server/app/middleware/passport/oidc.ts +++ b/src/core/server/app/middleware/passport/oidc.ts @@ -1,57 +1,106 @@ import jwt from "jsonwebtoken"; import jwks, { JwksClient } from "jwks-rsa"; +import { Db } from "mongodb"; import { Strategy as OAuth2Strategy } from "passport-oauth2"; import { Strategy } from "passport-strategy"; 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 { User } from "talk-server/models/user"; +import { createUser, retrieveUserWithProfile } from "talk-server/models/user"; import { Request } from "talk-server/types/express"; -export type OIDCStrategyOptions = any; +import { VerifyCallback } from "./index"; export interface Params { id_token?: string; } -export type VerifyCallback = ( - err?: Error | null, - user?: User | null, - info?: object -) => void; - -export interface Token { +export interface OIDCIDToken { iss: string; sub: string; email: string; email_verified?: boolean; } -export type OIDCStrategyCallback = ( - tenant: Tenant, - token: Token, - done: VerifyCallback -) => void; - 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; +} + +export async function findOrCreateOIDCUser( + db: Db, + tenant: Tenant, + { iss, sub, email, email_verified }: OIDCIDToken +) { + // Construct the profile that will be used to query for the user. + const profile = { + type: "oidc", + provider: iss, + id: sub, + }; + + // 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. + + // Create the new user, as one didn't exist before! + user = await createUser(db, tenant.id, { + username: null, + role: GQLUSER_ROLE.COMMENTER, + email, + email_verified, + profiles: [profile], + }); + } + + return user; +} + // FIXME: attach strategy to cache updates of the tenants export default class OIDCStrategy extends Strategy { public name: string; - private verify: OIDCStrategyCallback; + private db: Db; private cache: Map; - constructor(options: OIDCStrategyOptions, verify: OIDCStrategyCallback) { + constructor({ db }: OIDCStrategyOptions) { super(); this.name = "oidc"; this.cache = new Map(); - this.verify = verify; + this.db = db; + } + + private async verify( + tenant: Tenant, + token: OIDCIDToken, + done: VerifyCallback + ) { + try { + const user = await findOrCreateOIDCUser(this.db, tenant, token); + return done(null, user); + } catch (err) { + return done(err); + } } private lookupJWKSClient( @@ -139,7 +188,7 @@ export default class OIDCStrategy extends Strategy { return done(err); } - this.verify(tenant!, decoded as Token, done); + this.verify(tenant!, decoded as OIDCIDToken, done); } ); } @@ -183,6 +232,12 @@ export default class OIDCStrategy extends Strategy { 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"); + } + // Try to get the Tenant's cached integrations. let entry = this.cache.get(tenant.id); if (!entry) { @@ -202,22 +257,30 @@ export default class OIDCStrategy extends Strategy { } public async authenticate(req: Request) { - // Lookup the strategy. - const strategy = await this.lookupStrategy(req); - if (!strategy) { - return; + 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: "openid email", session: false }); + } catch (err) { + return this.error(err); } - - // 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: "openid email", session: false }); } } + +export function createOIDCStrategy({ db }: OIDCStrategyOptions) { + return new OIDCStrategy({ db }); +} diff --git a/src/core/server/app/router.ts b/src/core/server/app/router.ts index 1ae9934b8..a1a668b51 100644 --- a/src/core/server/app/router.ts +++ b/src/core/server/app/router.ts @@ -33,6 +33,11 @@ async function createTenantRouter(app: AppOptions, options: RouterOptions) { router.use(tenantMiddleware({ db: app.mongo })); router.use(options.passport.initialize()); + router.use( + "/auth/local", + express.json(), + authenticate(options.passport, "local") + ); router.use("/auth/oidc", authenticate(options.passport, "oidc")); router.use("/auth/oidc/callback", authenticate(options.passport, "oidc")); // router.use("/auth/google", options.passport.authenticate("google")); @@ -64,6 +69,10 @@ async function createAPIRouter(app: AppOptions, options: RouterOptions) { } export interface RouterOptions { + /** + * passport is the instance of the Authenticator that can be used to create + * and mount new authentication middleware. + */ passport: passport.Authenticator; } diff --git a/src/core/server/app/url.ts b/src/core/server/app/url.ts index b9f533f1a..b656e4421 100644 --- a/src/core/server/app/url.ts +++ b/src/core/server/app/url.ts @@ -1,11 +1,10 @@ import { Request } from "talk-server/types/express"; import { URL } from "url"; -export function reconstructURL(req: Request, input?: string): string { +export function reconstructURL(req: Request, path: string = "/"): string { const scheme = req.secure ? "https" : "http"; const host = req.get("host"); const base = `${scheme}://${host}`; - const path = input || req.originalUrl; const url = new URL(path, base); diff --git a/src/core/server/graph/tenant/loaders/comments.ts b/src/core/server/graph/tenant/loaders/comments.ts index 0e68876e2..765ed6768 100644 --- a/src/core/server/graph/tenant/loaders/comments.ts +++ b/src/core/server/graph/tenant/loaders/comments.ts @@ -6,17 +6,23 @@ import { CommentToRepliesArgs, } from "talk-server/graph/tenant/schema/__generated__/types"; import { - retrieveAssetConnection, - retrieveMany, - retrieveRepliesConnection, + retrieveCommentAssetConnection, + retrieveManyComments, + retrieveCommentRepliesConnection, } 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: AssetToCommentsArgs) => - retrieveAssetConnection(ctx.db, ctx.tenant.id, assetID, input), + retrieveCommentAssetConnection(ctx.db, ctx.tenant.id, assetID, input), forParent: (assetID: string, parentID: string, input: CommentToRepliesArgs) => - retrieveRepliesConnection(ctx.db, ctx.tenant.id, assetID, parentID, input), + retrieveCommentRepliesConnection( + ctx.db, + ctx.tenant.id, + assetID, + parentID, + input + ), }); diff --git a/src/core/server/graph/tenant/loaders/users.ts b/src/core/server/graph/tenant/loaders/users.ts index d875bc6b4..34bbebc10 100644 --- a/src/core/server/graph/tenant/loaders/users.ts +++ b/src/core/server/graph/tenant/loaders/users.ts @@ -1,9 +1,9 @@ import DataLoader from "dataloader"; import Context from "talk-server/graph/tenant/context"; -import { retrieveMany, User } from "talk-server/models/user"; +import { retrieveManyUsers, User } from "talk-server/models/user"; export default (ctx: Context) => ({ user: new DataLoader(ids => - retrieveMany(ctx.db, ctx.tenant.id, ids) + retrieveManyUsers(ctx.db, ctx.tenant.id, ids) ), }); diff --git a/src/core/server/graph/tenant/schema/schema.graphql b/src/core/server/graph/tenant/schema/schema.graphql index bf6e5d6f8..2e3ae43af 100644 --- a/src/core/server/graph/tenant/schema/schema.graphql +++ b/src/core/server/graph/tenant/schema/schema.graphql @@ -284,6 +284,41 @@ enum USER_ROLE { 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 +} + """ User is someone that leaves Comments, and logs in. """ diff --git a/src/core/server/models/comment.ts b/src/core/server/models/comment.ts index 5c0726669..df5d1f877 100644 --- a/src/core/server/models/comment.ts +++ b/src/core/server/models/comment.ts @@ -34,7 +34,7 @@ export enum CommentStatus { export interface Comment extends TenantResource { readonly id: string; - parent_id?: string; + parent_id: string | null; author_id: string; asset_id: string; body: string; @@ -45,9 +45,7 @@ export interface Comment extends TenantResource { reply_count: number; created_at: Date; deleted_at?: Date; - metadata?: { - [_: string]: any; - }; + metadata?: Record; } export type CreateCommentInput = Omit< @@ -60,7 +58,7 @@ export type CreateCommentInput = Omit< | "status_history" >; -export async function create( +export async function createComment( db: Db, tenantID: string, input: CreateCommentInput @@ -106,11 +104,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, @@ -164,7 +166,7 @@ function nodesToEdge(input: ConnectionInput, nodes: Comment[]) { * @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, @@ -190,7 +192,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, diff --git a/src/core/server/models/tenant.ts b/src/core/server/models/tenant.ts index 123c6b261..e18ab7ec0 100644 --- a/src/core/server/models/tenant.ts +++ b/src/core/server/models/tenant.ts @@ -210,7 +210,7 @@ export async function retrieveTenantByDomain(db: Db, domain: string) { return collection(db).findOne({ domain }); } -export async function retrieve(db: Db, id: string) { +export async function retrieveTenant(db: Db, id: string) { return collection(db).findOne({ id }); } diff --git a/src/core/server/models/user.ts b/src/core/server/models/user.ts index 487f7174f..1fbcbb031 100644 --- a/src/core/server/models/user.ts +++ b/src/core/server/models/user.ts @@ -1,9 +1,13 @@ +import bcrypt from "bcryptjs"; import { merge } from "lodash"; import { Db } from "mongodb"; import uuid from "uuid"; import { Omit, Sub } from "talk-common/types"; -import { GQLUSER_ROLE } from "talk-server/graph/tenant/schema/__generated__/types"; +import { + GQLUSER_ROLE, + GQLUSER_USERNAME_STATUS, +} from "talk-server/graph/tenant/schema/__generated__/types"; import { ActionCounts } from "talk-server/models/actions"; import { TenantResource } from "talk-server/models/tenant"; @@ -14,7 +18,7 @@ function collection(db: Db) { export interface Profile { readonly id: string; readonly type: string; - provider: string; + provider?: string; } export interface Token { @@ -23,31 +27,6 @@ export interface Token { 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 interface UserStatusHistory { status: T; // TODO: migrate field assigned_by?: string; @@ -61,7 +40,7 @@ export interface UserStatusItem { } export interface UserStatus { - username: UserStatusItem; + username: UserStatusItem; banned: UserStatusItem; suspension: UserStatusItem; } @@ -92,7 +71,11 @@ export type CreateUserInput = Omit< | "created_at" >; -export async function create(db: Db, tenantID: string, input: CreateUserInput) { +export async function createUser( + db: Db, + tenantID: string, + input: CreateUserInput +) { const now = new Date(); // default are the properties set by the application when a new user is @@ -114,8 +97,8 @@ export async function create(db: Db, tenantID: string, input: CreateUserInput) { }, username: { status: input.username - ? UserUsernameStatus.SET - : UserUsernameStatus.UNSET, + ? GQLUSER_USERNAME_STATUS.SET + : GQLUSER_USERNAME_STATUS.UNSET, history: [], }, }, @@ -131,11 +114,15 @@ export async function create(db: Db, tenantID: string, input: CreateUserInput) { return user; } -export async function retrieve(db: Db, tenantID: string, id: string) { +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, @@ -148,7 +135,7 @@ export async function retrieveMany(db: Db, tenantID: string, ids: string[]) { return ids.map(id => users.find(comment => comment.id === id) || null); } -export async function retrieveWithProfile( +export async function retrieveUserWithProfile( db: Db, tenantID: string, profile: Profile @@ -161,7 +148,7 @@ export async function retrieveWithProfile( }); } -export async function updateRole( +export async function updateUserRole( db: Db, tenantID: string, id: string, @@ -175,3 +162,11 @@ export async function updateRole( return result.value || null; } + +export async function verifyUserPassword(user: User, password: string) { + if (user.password) { + return bcrypt.compare(user.password, password); + } + + return false; +} diff --git a/src/core/server/services/comments/index.ts b/src/core/server/services/comments/index.ts index 95a9001cb..f13852dfa 100644 --- a/src/core/server/services/comments/index.ts +++ b/src/core/server/services/comments/index.ts @@ -4,7 +4,7 @@ import { Omit } from "talk-common/types"; import { Comment, CommentStatus, - create as createComment, + createComment, CreateCommentInput, } from "talk-server/models/comment"; From 15478fe54f24b8533823b3c16b3b122021255150 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Thu, 5 Jul 2018 16:03:34 -0600 Subject: [PATCH 10/43] fix: linting --- src/core/server/graph/tenant/loaders/comments.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/server/graph/tenant/loaders/comments.ts b/src/core/server/graph/tenant/loaders/comments.ts index 765ed6768..df43c823c 100644 --- a/src/core/server/graph/tenant/loaders/comments.ts +++ b/src/core/server/graph/tenant/loaders/comments.ts @@ -7,8 +7,8 @@ import { } from "talk-server/graph/tenant/schema/__generated__/types"; import { retrieveCommentAssetConnection, - retrieveManyComments, retrieveCommentRepliesConnection, + retrieveManyComments, } from "talk-server/models/comment"; export default (ctx: Context) => ({ From 093e4fd736273392eb5fea2e8040724a0ceebad1 Mon Sep 17 00:00:00 2001 From: Kiwi Date: Fri, 6 Jul 2018 16:08:10 -0300 Subject: [PATCH 11/43] Adapt files.exclude (#1736) --- .prettierignore | 1 + .vscode/settings.json | 8 +++----- 2 files changed, 4 insertions(+), 5 deletions(-) create mode 100644 .prettierignore diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 000000000..3c3629e64 --- /dev/null +++ b/.prettierignore @@ -0,0 +1 @@ +node_modules diff --git a/.vscode/settings.json b/.vscode/settings.json index 195b73de5..4dd5d1b72 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -9,12 +9,10 @@ "**/.hg": true, "**/CVS": true, "**/.DS_Store": true, - "node_modules": true, - "dist": true, - ".vscode": true, - "package-lock.json": true + ".vs": true }, + "tslint.exclude": "**/node_modules/**", "tslint.autoFixOnSave": true, "tslint.jsEnable": true, "typescript.tsdk": "node_modules/typescript/lib" -} \ No newline at end of file +} From c0da4e97aa64b2b4e6da25c373e16e0409f2e23a Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 10 Jul 2018 13:11:32 -0600 Subject: [PATCH 12/43] feat: asset url query --- .../server/graph/tenant/loaders/assets.ts | 9 +- .../server/graph/tenant/loaders/comments.ts | 40 ++++-- .../server/graph/tenant/resolvers/query.ts | 2 +- .../server/graph/tenant/schema/schema.graphql | 10 +- src/core/server/models/asset.ts | 130 ++++++++++++++---- 5 files changed, 144 insertions(+), 47 deletions(-) diff --git a/src/core/server/graph/tenant/loaders/assets.ts b/src/core/server/graph/tenant/loaders/assets.ts index 55c50a0dc..cd7db4b57 100644 --- a/src/core/server/graph/tenant/loaders/assets.ts +++ b/src/core/server/graph/tenant/loaders/assets.ts @@ -1,8 +1,15 @@ import DataLoader from "dataloader"; import TenantContext from "talk-server/graph/tenant/context"; -import { Asset, retrieveManyAssets } from "talk-server/models/asset"; +import { + Asset, + findOrCreateAsset, + FindOrCreateAssetInput, + retrieveManyAssets, +} from "talk-server/models/asset"; export default (ctx: TenantContext) => ({ + findOrCreate: (input: FindOrCreateAssetInput) => + findOrCreateAsset(ctx.db, ctx.tenant.id, input), asset: new DataLoader(ids => retrieveManyAssets(ctx.db, ctx.tenant.id, ids) ), diff --git a/src/core/server/graph/tenant/loaders/comments.ts b/src/core/server/graph/tenant/loaders/comments.ts index df43c823c..719046a83 100644 --- a/src/core/server/graph/tenant/loaders/comments.ts +++ b/src/core/server/graph/tenant/loaders/comments.ts @@ -4,6 +4,7 @@ import Context from "talk-server/graph/tenant/context"; import { AssetToCommentsArgs, CommentToRepliesArgs, + GQLCOMMENT_SORT, } from "talk-server/graph/tenant/schema/__generated__/types"; import { retrieveCommentAssetConnection, @@ -15,14 +16,33 @@ export default (ctx: Context) => ({ comment: new DataLoader((ids: string[]) => retrieveManyComments(ctx.db, ctx.tenant.id, ids) ), - forAsset: (assetID: string, input: AssetToCommentsArgs) => - retrieveCommentAssetConnection(ctx.db, ctx.tenant.id, assetID, input), - forParent: (assetID: string, parentID: string, input: CommentToRepliesArgs) => - retrieveCommentRepliesConnection( - ctx.db, - ctx.tenant.id, - assetID, - parentID, - input - ), + forAsset: ( + assetID: string, + // Apply the graph schema defaults at the loader. + { + first = 10, + orderBy = GQLCOMMENT_SORT.CREATED_AT_DESC, + after, + }: AssetToCommentsArgs + ) => + retrieveCommentAssetConnection(ctx.db, ctx.tenant.id, assetID, { + first, + orderBy, + after, + }), + forParent: ( + assetID: string, + parentID: string, + // Apply the graph schema defaults at the loader. + { + first = 10, + orderBy = GQLCOMMENT_SORT.CREATED_AT_DESC, + after, + }: CommentToRepliesArgs + ) => + retrieveCommentRepliesConnection(ctx.db, ctx.tenant.id, assetID, parentID, { + first, + orderBy, + after, + }), }); diff --git a/src/core/server/graph/tenant/resolvers/query.ts b/src/core/server/graph/tenant/resolvers/query.ts index caf5e0a3b..56d99e1fe 100644 --- a/src/core/server/graph/tenant/resolvers/query.ts +++ b/src/core/server/graph/tenant/resolvers/query.ts @@ -1,7 +1,7 @@ import { GQLQueryTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types"; const Query: GQLQueryTypeResolver = { - asset: (source, args, ctx) => ctx.loaders.Assets.asset.load(args.id), + asset: (source, args, ctx) => ctx.loaders.Assets.findOrCreate(args), settings: (parent, args, ctx) => ctx.tenant, }; diff --git a/src/core/server/graph/tenant/schema/schema.graphql b/src/core/server/graph/tenant/schema/schema.graphql index 2e3ae43af..9e57502d5 100644 --- a/src/core/server/graph/tenant/schema/schema.graphql +++ b/src/core/server/graph/tenant/schema/schema.graphql @@ -382,8 +382,8 @@ type Comment { replies will return the replies to this comment. """ replies( - first: Int! = 10 - orderBy: COMMENT_SORT! = CREATED_AT_DESC + first: Int = 10 + orderBy: COMMENT_SORT = CREATED_AT_DESC after: Cursor ): CommentsConnection } @@ -459,8 +459,8 @@ type Asset { comments are the comments on the Asset. """ comments( - first: Int! = 10 - orderBy: COMMENT_SORT! = CREATED_AT_DESC + first: Int = 10 + orderBy: COMMENT_SORT = CREATED_AT_DESC after: Cursor ): CommentsConnection @@ -533,7 +533,7 @@ type Query { """ asset is the Asset specified by its ID. """ - asset(id: ID!): Asset + asset(id: ID, url: String!): Asset """ me is the current logged in User. diff --git a/src/core/server/models/asset.ts b/src/core/server/models/asset.ts index e00dd2a56..b09583d9c 100644 --- a/src/core/server/models/asset.ts +++ b/src/core/server/models/asset.ts @@ -1,10 +1,10 @@ import dotize from "dotize"; import { defaults } from "lodash"; import { Db } from "mongodb"; +import uuid from "uuid"; + import { Omit } from "talk-common/types"; import { TenantResource } from "talk-server/models/tenant"; -import uuid from "uuid"; -import Query from "./query"; function collection(db: Db) { return db.collection>("assets"); @@ -27,48 +27,103 @@ export interface Asset extends TenantResource { created_at: Date; } -export type CreateAssetInput = Pick; +export interface UpsertAssetInput { + id?: string; + url: string; +} -export async function createAsset( +export async function upsertAsset( db: Db, tenantID: string, - input: CreateAssetInput + { id, url }: UpsertAssetInput ) { const now = new Date(); - // Construct the filter. - const query = new Query(collection(db)).where({ - tenant_id: tenantID, - }); - if (input.id) { - query.where({ id: input.id }); - } else { - query.where({ url: input.url }); - } + // TODO: verify that the url for the given Asset is whitelisted by the tenant. - // Craft the update object. + // Create the asset, optionally sourcing the id from the input, additionally + // porting in the tenant_id. const update: { $setOnInsert: Asset } = { - $setOnInsert: defaults(input, { - id: uuid.v4(), - tenant_id: tenantID, - created_at: now, - }), + $setOnInsert: defaults( + { + url, + tenant_id: tenantID, + created_at: now, + }, + { id }, + { + id: uuid.v4(), + } + ), }; - // Perform the upsert operation. - const result = await collection(db).findOneAndUpdate(query.filter, update, { - // Create the object if it doesn't already exist. - upsert: true, - // False to return the updated document instead of the original - // document. - returnOriginal: false, - }); + // Perform the find and update operation to try and find and or create the + // asset. + const { value: asset } = await collection(db).findOneAndUpdate( + { url }, + update, + { + // Create the object if it doesn't already exist. + upsert: true, - return result.value || null; + // False to return the updated document instead of the original + // document. + returnOriginal: false, + } + ); + if (!asset) { + return null; + } + + if (!asset.scraped) { + // TODO: create scrape job to collect asset metadata + } + + return asset; +} + +export interface FindOrCreateAssetInput { + id?: string; + url?: string; +} + +export async function findOrCreateAsset( + db: Db, + tenantID: string, + { id, url }: FindOrCreateAssetInput +) { + if (id) { + if (url) { + // The URL was specified, this is an upsert operation. + return upsertAsset(db, tenantID, { + id, + url, + }); + } + + // The URL was not specified, this is a lookup operation. + return retrieveAsset(db, tenantID, id); + } + + // The ID was not specified, this is an upsert operation. Check to see that + // the URL exists. + if (!url) { + throw new Error("cannot upsert an asset without the url"); + } + + return upsertAsset(db, tenantID, { url }); +} + +export async function retrieveAssetByURL( + db: Db, + tenantID: string, + url: string +) { + return collection(db).findOne({ url, tenant_id: tenantID }); } export async function retrieveAsset(db: Db, tenantID: string, id: string) { - return await collection(db).findOne({ id, tenant_id: tenantID }); + return collection(db).findOne({ id, tenant_id: tenantID }); } export async function retrieveManyAssets( @@ -86,6 +141,21 @@ export async function retrieveManyAssets( return ids.map(id => assets.find(asset => asset.id === id) || null); } +export async function retrieveManyAssetsByURL( + db: Db, + tenantID: string, + urls: string[] +) { + const cursor = await collection(db).find({ + url: { $in: urls }, + tenant_id: tenantID, + }); + + const assets = await cursor.toArray(); + + return urls.map(url => assets.find(asset => asset.url === url) || null); +} + export type UpdateAssetInput = Omit< Partial, "id" | "tenant_id" | "url" | "created_at" From c5dbe4659b71a1a7b3c8ebd0347d435c8106848f Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 10 Jul 2018 13:56:20 -0600 Subject: [PATCH 13/43] feat: initial signup route --- src/core/server/app/handlers/auth/local.ts | 8 +++ .../server/app/middleware/passport/index.ts | 49 ++++++++++--------- .../server/app/middleware/passport/local.ts | 9 ++-- src/core/server/app/router.ts | 2 + 4 files changed, 41 insertions(+), 27 deletions(-) create mode 100644 src/core/server/app/handlers/auth/local.ts diff --git a/src/core/server/app/handlers/auth/local.ts b/src/core/server/app/handlers/auth/local.ts new file mode 100644 index 000000000..dbd47bb50 --- /dev/null +++ b/src/core/server/app/handlers/auth/local.ts @@ -0,0 +1,8 @@ +import { RequestHandler } from "express"; + +import { Request } from "talk-server/types/express"; + +export const signup: RequestHandler = async (req: Request, res, next) => { + // TODO: implement + res.send("ok"); +}; diff --git a/src/core/server/app/middleware/passport/index.ts b/src/core/server/app/middleware/passport/index.ts index 1b6968564..cd857a172 100644 --- a/src/core/server/app/middleware/passport/index.ts +++ b/src/core/server/app/middleware/passport/index.ts @@ -1,4 +1,4 @@ -import { NextFunction, Response } from "express"; +import { NextFunction, RequestHandler, Response } from "express"; import { Db } from "mongodb"; import passport, { Authenticator } from "passport"; @@ -10,7 +10,7 @@ import { Request } from "talk-server/types/express"; export type VerifyCallback = ( err?: Error | null, user?: User | null, - info?: object + info?: { message: string } ) => void; export interface PassportOptions { @@ -32,28 +32,33 @@ export function createPassport({ return auth; } +export const handle = ( + err: Error | null, + user: User | null +): RequestHandler => (req: Request, res, next) => { + if (err) { + // TODO: wrap error? + return next(err); + } + + // 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! + + // TODO: return the token instead of the user. + res.json({ user }); +}; + export const authenticate = ( authenticator: passport.Authenticator, - name: string -) => (req: Request, res: Response, next: NextFunction) => + name: string, + options?: any +): RequestHandler => (req: Request, res, next) => authenticator.authenticate( name, - { session: false }, - (err: Error | null, user: User | null) => { - if (err) { - // TODO: wrap error? - return next(err); - } - - // 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({ user }); - } + { ...options, session: false }, + (err: Error | null, user: User | null) => handle(err, user)(req, res, next) )(req, res, next); diff --git a/src/core/server/app/middleware/passport/local.ts b/src/core/server/app/middleware/passport/local.ts index 4aa707153..67dfe3b02 100644 --- a/src/core/server/app/middleware/passport/local.ts +++ b/src/core/server/app/middleware/passport/local.ts @@ -8,13 +8,12 @@ import { } from "talk-server/models/user"; import { Request } from "talk-server/types/express"; -async function verify( - db: Db, +const verifyFactory = (db: Db) => async ( req: Request, email: string, password: string, done: VerifyCallback -) { +) => { try { // The tenant is guaranteed at this point. const tenant = req.tenant!; @@ -42,7 +41,7 @@ async function verify( } catch (err) { return done(err); } -} +}; export interface LocalStrategyOptions { db: Db; @@ -56,6 +55,6 @@ export function createLocalStrategy({ db }: LocalStrategyOptions) { session: false, passReqToCallback: true, }, - verify.bind(null, db) + verifyFactory(db) ); } diff --git a/src/core/server/app/router.ts b/src/core/server/app/router.ts index a1a668b51..ba7c9e5da 100644 --- a/src/core/server/app/router.ts +++ b/src/core/server/app/router.ts @@ -5,6 +5,7 @@ import tenantMiddleware from "talk-server/app/middleware/tenant"; import managementGraphMiddleware from "talk-server/graph/management/middleware"; import tenantGraphMiddleware from "talk-server/graph/tenant/middleware"; +import { signup } from "talk-server/app/handlers/auth/local"; import { authenticate } from "talk-server/app/middleware/passport"; import { AppOptions } from "./index"; import playground from "./middleware/playground"; @@ -38,6 +39,7 @@ async function createTenantRouter(app: AppOptions, options: RouterOptions) { express.json(), authenticate(options.passport, "local") ); + router.use("/auth/local/signup", express.json(), signup); router.use("/auth/oidc", authenticate(options.passport, "oidc")); router.use("/auth/oidc/callback", authenticate(options.passport, "oidc")); // router.use("/auth/google", options.passport.authenticate("google")); From d46145f04c012da4f899f9e716df0264fc44e80f Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 10 Jul 2018 13:58:31 -0600 Subject: [PATCH 14/43] fix: linting --- src/core/server/app/middleware/passport/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/server/app/middleware/passport/index.ts b/src/core/server/app/middleware/passport/index.ts index cd857a172..5b5fee739 100644 --- a/src/core/server/app/middleware/passport/index.ts +++ b/src/core/server/app/middleware/passport/index.ts @@ -1,4 +1,4 @@ -import { NextFunction, RequestHandler, Response } from "express"; +import { RequestHandler } from "express"; import { Db } from "mongodb"; import passport, { Authenticator } from "passport"; From 6efe36ceaa2562debf99d542bcd3dd760728534b Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 10 Jul 2018 15:54:52 -0600 Subject: [PATCH 15/43] feat: signup enhancements; more extensions to schema --- src/core/server/app/handlers/auth/local.ts | 82 ++++++++++++++++++- .../server/app/middleware/passport/index.ts | 7 +- .../server/app/middleware/passport/local.ts | 4 +- .../server/app/middleware/passport/oidc.ts | 17 ++-- src/core/server/app/request/body.ts | 23 ++++++ src/core/server/app/router.ts | 2 +- .../tenant/resolvers/auth_integrations.ts | 14 ++++ .../graph/tenant/resolvers/auth_settings.ts | 10 +-- .../server/graph/tenant/resolvers/index.ts | 18 +++- .../server/graph/tenant/resolvers/profile.ts | 21 +++++ .../server/graph/tenant/schema/schema.graphql | 48 ++++++++++- src/core/server/models/comment.ts | 17 ++-- src/core/server/models/tenant.ts | 31 ++++--- src/core/server/models/user.ts | 29 +++++-- src/core/server/services/comments/index.ts | 7 +- src/core/server/services/users/index.ts | 11 +++ 16 files changed, 278 insertions(+), 63 deletions(-) create mode 100644 src/core/server/app/request/body.ts create mode 100644 src/core/server/graph/tenant/resolvers/auth_integrations.ts create mode 100644 src/core/server/graph/tenant/resolvers/profile.ts create mode 100644 src/core/server/services/users/index.ts diff --git a/src/core/server/app/handlers/auth/local.ts b/src/core/server/app/handlers/auth/local.ts index dbd47bb50..656b3cb57 100644 --- a/src/core/server/app/handlers/auth/local.ts +++ b/src/core/server/app/handlers/auth/local.ts @@ -1,8 +1,84 @@ import { RequestHandler } from "express"; +import Joi from "joi"; +import { Db } from "mongodb"; +import { handle } from "talk-server/app/middleware/passport"; +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 { create } from "talk-server/services/users"; import { Request } from "talk-server/types/express"; -export const signup: RequestHandler = async (req: Request, res, next) => { - // TODO: implement - res.send("ok"); +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(), +}); + +// Extends the default signup body schema with the displayName to allow it to be +// sent. +const SignupDisplayNameBodySchema = SignupBodySchema.keys({ + displayName: Joi.string().trim(), +}); + +export interface SignupOptions { + db: Db; +} + +export const signup = ({ db }: 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!; + + if (!tenant.auth.integrations.local.enabled) { + // TODO: replace with better error. + return next(new Error("integration is disabled")); + } + + // Get the fields from the body. We condition on the display name being + // enabled to allow the display name to be stripped in the event that the + // display name is not enabled, yielding a displayName being `undefined`, + // which will not be set in the resultant document. Validate will throw an + // error if the body does not conform to the specification. + const { username, password, email, displayName }: SignupBody = validate( + tenant.auth.displayNameEnable + ? SignupDisplayNameBodySchema + : SignupBodySchema, + req.body + ); + + // Configure with profile. + const profile: LocalProfile = { + id: email, + type: "local", + }; + + // Create the new user. + const user = await create(db, tenant.id, { + email, + username, + displayName, + password, + profiles: [profile], + role: GQLUSER_ROLE.COMMENTER, + }); + + // Send off to the passport handler. + return handle(null, user)(req, res, next); + } catch (err) { + return handle(err)(req, res, next); + } }; diff --git a/src/core/server/app/middleware/passport/index.ts b/src/core/server/app/middleware/passport/index.ts index 5b5fee739..0f24bdeea 100644 --- a/src/core/server/app/middleware/passport/index.ts +++ b/src/core/server/app/middleware/passport/index.ts @@ -34,13 +34,18 @@ export function createPassport({ export const handle = ( err: Error | null, - user: User | null + user?: User | null ): RequestHandler => (req: Request, res, next) => { if (err) { // TODO: wrap error? return next(err); } + if (!user) { + // TODO: replace with better error. + return next(new Error("no user on request")); + } + // Set the cache control headers. res.header("Cache-Control", "private, no-cache, no-store, must-revalidate"); res.header("Expires", "-1"); diff --git a/src/core/server/app/middleware/passport/local.ts b/src/core/server/app/middleware/passport/local.ts index 67dfe3b02..b57c4402e 100644 --- a/src/core/server/app/middleware/passport/local.ts +++ b/src/core/server/app/middleware/passport/local.ts @@ -15,11 +15,11 @@ const verifyFactory = (db: Db) => async ( 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!; - // TODO: rate limit the ip address - // Get the user from the database. const user = await retrieveUserWithProfile(db, tenant.id, { id: email, diff --git a/src/core/server/app/middleware/passport/oidc.ts b/src/core/server/app/middleware/passport/oidc.ts index f75fe43b3..c6be6d6c6 100644 --- a/src/core/server/app/middleware/passport/oidc.ts +++ b/src/core/server/app/middleware/passport/oidc.ts @@ -7,7 +7,8 @@ import { Strategy } from "passport-strategy"; 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 { createUser, retrieveUserWithProfile } from "talk-server/models/user"; +import { OIDCProfile, retrieveUserWithProfile } from "talk-server/models/user"; +import { create } from "talk-server/services/users"; import { Request } from "talk-server/types/express"; import { VerifyCallback } from "./index"; @@ -50,7 +51,7 @@ export async function findOrCreateOIDCUser( { iss, sub, email, email_verified }: OIDCIDToken ) { // Construct the profile that will be used to query for the user. - const profile = { + const profile: OIDCProfile = { type: "oidc", provider: iss, id: sub, @@ -62,7 +63,7 @@ export async function findOrCreateOIDCUser( // FIXME: implement rules. // Create the new user, as one didn't exist before! - user = await createUser(db, tenant.id, { + user = await create(db, tenant.id, { username: null, role: GQLUSER_ROLE.COMMENTER, email, @@ -157,7 +158,11 @@ export default class OIDCStrategy extends Strategy { const { tenant } = req; // Grab the JWKSClient. - const client = this.lookupJWKSClient(req, tenant!.id, tenant!.auth.oidc!); + const client = this.lookupJWKSClient( + req, + tenant!.id, + tenant!.auth.integrations.oidc! + ); // Verify that the id_token is valid or not. jwt.verify( @@ -180,7 +185,7 @@ export default class OIDCStrategy extends Strategy { }); }, { - issuer: tenant!.auth.oidc!.issuer, + issuer: tenant!.auth.integrations.oidc!.issuer, }, (err, decoded) => { if (err) { @@ -226,7 +231,7 @@ export default class OIDCStrategy extends Strategy { // Get the integration from the tenant. If needed, it will be used to create // a new strategy. - const integration = tenant.auth.oidc; + const integration = tenant.auth.integrations.oidc; if (!integration) { // TODO: return a better error. throw new Error("integration not found"); diff --git a/src/core/server/app/request/body.ts b/src/core/server/app/request/body.ts new file mode 100644 index 000000000..9b7cabb13 --- /dev/null +++ b/src/core/server/app/request/body.ts @@ -0,0 +1,23 @@ +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", + }); + + if (err) { + // TODO: return better error. + throw new Error("Validation Error"); + } + + return value; +}; diff --git a/src/core/server/app/router.ts b/src/core/server/app/router.ts index ba7c9e5da..7b2338e0f 100644 --- a/src/core/server/app/router.ts +++ b/src/core/server/app/router.ts @@ -39,7 +39,7 @@ async function createTenantRouter(app: AppOptions, options: RouterOptions) { express.json(), authenticate(options.passport, "local") ); - router.use("/auth/local/signup", express.json(), signup); + router.use("/auth/local/signup", express.json(), signup({ db: app.mongo })); router.use("/auth/oidc", authenticate(options.passport, "oidc")); router.use("/auth/oidc/callback", authenticate(options.passport, "oidc")); // router.use("/auth/google", options.passport.authenticate("google")); diff --git a/src/core/server/graph/tenant/resolvers/auth_integrations.ts b/src/core/server/graph/tenant/resolvers/auth_integrations.ts new file mode 100644 index 000000000..6842b891b --- /dev/null +++ b/src/core/server/graph/tenant/resolvers/auth_integrations.ts @@ -0,0 +1,14 @@ +import { GQLAuthIntegrationsTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types"; +import { AuthIntegration, AuthIntegrations } from "talk-server/models/tenant"; + +const disabled: AuthIntegration = { enabled: false }; + +const AuthIntegrations: GQLAuthIntegrationsTypeResolver = { + local: auth => auth.local || disabled, + sso: auth => auth.sso || disabled, + oidc: auth => auth.oidc || disabled, + google: auth => auth.google || disabled, + facebook: auth => auth.facebook || disabled, +}; + +export default AuthIntegrations; diff --git a/src/core/server/graph/tenant/resolvers/auth_settings.ts b/src/core/server/graph/tenant/resolvers/auth_settings.ts index 1519d24c0..ed8ec8659 100644 --- a/src/core/server/graph/tenant/resolvers/auth_settings.ts +++ b/src/core/server/graph/tenant/resolvers/auth_settings.ts @@ -1,14 +1,8 @@ import { GQLAuthSettingsTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types"; -import { Auth, AuthIntegration } from "talk-server/models/tenant"; - -const disabled: AuthIntegration = { enabled: false }; +import { Auth } from "talk-server/models/tenant"; const AuthSettings: GQLAuthSettingsTypeResolver = { - 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, + integrations: auth => auth.integrations, }; export default AuthSettings; diff --git a/src/core/server/graph/tenant/resolvers/index.ts b/src/core/server/graph/tenant/resolvers/index.ts index e8d33540d..dd0a09082 100644 --- a/src/core/server/graph/tenant/resolvers/index.ts +++ b/src/core/server/graph/tenant/resolvers/index.ts @@ -2,16 +2,32 @@ import Cursor from "talk-server/graph/common/scalars/cursor"; import { GQLResolver } from "talk-server/graph/tenant/schema/__generated__/types"; import Asset from "./asset"; +import AuthIntegrations from "./auth_integrations"; +import AuthSettings from "./auth_settings"; import Comment from "./comment"; +import FacebookAuthIntegration from "./facebook_auth_integration"; +import GoogleAuthIntegration from "./google_auth_integration"; +import LocalAuthIntegration from "./local_auth_integration"; import Mutation from "./mutation"; +import OIDCAuthIntegration from "./oidc_auth_integration"; +import Profile from "./profile"; import Query from "./query"; +import SSOAuthIntegration from "./sso_auth_integration"; const Resolvers: GQLResolver = { Asset, + AuthIntegrations, + AuthSettings, Comment, + FacebookAuthIntegration, + GoogleAuthIntegration, + LocalAuthIntegration, + OIDCAuthIntegration, + SSOAuthIntegration, Cursor, - Query, Mutation, + Profile, + Query, }; export default Resolvers; diff --git a/src/core/server/graph/tenant/resolvers/profile.ts b/src/core/server/graph/tenant/resolvers/profile.ts new file mode 100644 index 000000000..c1c6ad13b --- /dev/null +++ b/src/core/server/graph/tenant/resolvers/profile.ts @@ -0,0 +1,21 @@ +import { GQLProfileTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types"; + +import { Profile } from "talk-server/models/user"; + +const resolveType: GQLProfileTypeResolver = profile => { + switch (profile.type) { + case "local": + return "LocalProfile"; + case "oidc": + return "OIDCProfile"; + case "sso": + return "SSOProfile"; + default: + // TODO: replace with better error. + throw new Error("invalid profile type"); + } +}; + +export default { + __resolveType: resolveType, +}; diff --git a/src/core/server/graph/tenant/schema/schema.graphql b/src/core/server/graph/tenant/schema/schema.graphql index 9e57502d5..5ffd1e40b 100644 --- a/src/core/server/graph/tenant/schema/schema.graphql +++ b/src/core/server/graph/tenant/schema/schema.graphql @@ -132,10 +132,7 @@ type FacebookAuthIntegration { config: FacebookAuthIntegrationConfig @auth(roles: [ADMIN]) } -""" -AuthSettings contains all the settings related to authentication and authorization. -""" -type AuthSettings { +type AuthIntegrations { local: LocalAuthIntegration! sso: SSOAuthIntegration! oidc: OIDCAuthIntegration! @@ -143,6 +140,24 @@ type AuthSettings { facebook: FacebookAuthIntegration! } +""" +AuthSettings contains all the settings related to authentication and +authorization. +""" +type AuthSettings { + """ + displayNameEnable when enabled, will allow Users to set and view their + displayName's. + """ + displayNameEnable: Boolean! + + """ + integrations are the set of configurations for the variations of + authentication solutions. + """ + integrations: AuthIntegrations! +} + ################################################################################ ## Settings ################################################################################ @@ -319,6 +334,21 @@ enum USER_USERNAME_STATUS { 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. """ @@ -333,6 +363,16 @@ type User { """ 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. """ diff --git a/src/core/server/models/comment.ts b/src/core/server/models/comment.ts index df5d1f877..6207addb7 100644 --- a/src/core/server/models/comment.ts +++ b/src/core/server/models/comment.ts @@ -3,7 +3,10 @@ import { Db } from "mongodb"; import uuid from "uuid"; import { Omit, Sub } from "talk-common/types"; -import { GQLCOMMENT_SORT } from "talk-server/graph/tenant/schema/__generated__/types"; +import { + GQLCOMMENT_SORT, + GQLCOMMENT_STATUS, +} from "talk-server/graph/tenant/schema/__generated__/types"; import { ActionCounts } from "talk-server/models/actions"; import { Connection, Cursor } from "talk-server/models/connection"; import Query from "talk-server/models/query"; @@ -19,19 +22,11 @@ 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 | null; @@ -39,7 +34,7 @@ export interface Comment extends TenantResource { asset_id: string; body: string; body_history: BodyHistoryItem[]; - status: CommentStatus; + status: GQLCOMMENT_STATUS; status_history: StatusHistoryItem[]; action_counts: ActionCounts; reply_count: number; diff --git a/src/core/server/models/tenant.ts b/src/core/server/models/tenant.ts index e18ab7ec0..0b14a46c0 100644 --- a/src/core/server/models/tenant.ts +++ b/src/core/server/models/tenant.ts @@ -4,7 +4,10 @@ import { Db } from "mongodb"; import uuid from "uuid"; import { Sub } from "talk-common/types"; -import { GQLUSER_ROLE } from "talk-server/graph/tenant/schema/__generated__/types"; +import { + GQLMODERATION_MODE, + GQLUSER_ROLE, +} from "talk-server/graph/tenant/schema/__generated__/types"; function collection(db: Db) { return db.collection>("tenants"); @@ -19,11 +22,6 @@ export interface Wordlist { suspect: string[]; } -export enum Moderation { - PRE = "PRE", - POST = "POST", -} - // AuthIntegrations. export interface EmailDomainRuleCondition { @@ -90,8 +88,8 @@ export interface GoogleAuthIntegration extends AuthIntegration { export type LocalAuthIntegration = AuthIntegration; -// Auth describes all of the possible auth integration configurations. -export interface Auth { +// AuthIntegrations describes all of the possible auth integration configurations. +export interface AuthIntegrations { // local is the auth integration for the local auth. local: LocalAuthIntegration; @@ -108,6 +106,11 @@ export interface Auth { facebook?: FacebookAuthIntegration; } +export interface Auth { + integrations: AuthIntegrations; + displayNameEnable: boolean; +} + // Tenant definition. export interface Tenant { @@ -117,7 +120,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; @@ -172,7 +175,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, @@ -191,8 +194,12 @@ export async function createTenant(db: Db, input: CreateTenantInput) { banned: [], }, auth: { - local: { - enabled: true, + // Disable the displayName by default. + displayNameEnable: false, + integrations: { + local: { + enabled: true, + }, }, }, }; diff --git a/src/core/server/models/user.ts b/src/core/server/models/user.ts index 1fbcbb031..0631e9e85 100644 --- a/src/core/server/models/user.ts +++ b/src/core/server/models/user.ts @@ -15,12 +15,24 @@ function collection(db: Db) { return db.collection>("users"); } -export interface Profile { - readonly id: string; - readonly type: string; - provider?: string; +export interface LocalProfile { + type: "local"; + id: string; } +export interface OIDCProfile { + type: "oidc"; + id: string; + provider: string; +} + +export interface SSOProfile { + type: "sso"; + id: string; +} + +export type Profile = LocalProfile | OIDCProfile | SSOProfile; + export interface Token { readonly id: string; name: string; @@ -28,14 +40,14 @@ export interface Token { } export interface UserStatusHistory { - status: T; // TODO: migrate field + status: T; assigned_by?: string; - reason?: string; // TODO: migrate field + reason?: string; created_at: Date; } export interface UserStatusItem { - status: T; // TODO: migrate field + status: T; history: Array>; } @@ -48,6 +60,7 @@ export interface UserStatus { export interface User extends TenantResource { readonly id: string; username: string | null; + displayName?: string; password?: string; email?: string; email_verified?: boolean; @@ -56,7 +69,7 @@ export interface User extends TenantResource { role: GQLUSER_ROLE; status: UserStatus; action_counts: ActionCounts; - ignored_users: string[]; // TODO: migrate field + ignored_users: string[]; created_at: Date; } diff --git a/src/core/server/services/comments/index.ts b/src/core/server/services/comments/index.ts index f13852dfa..3d90149b8 100644 --- a/src/core/server/services/comments/index.ts +++ b/src/core/server/services/comments/index.ts @@ -2,7 +2,6 @@ import { Db } from "mongodb"; import { Omit } from "talk-common/types"; import { - Comment, CommentStatus, createComment, CreateCommentInput, @@ -13,11 +12,7 @@ export type CreateComment = Omit< "status" | "action_counts" >; -export async function create( - db: Db, - tenantID: string, - input: CreateComment -): Promise { +export async function create(db: Db, tenantID: string, input: CreateComment) { // TODO: run the comment through the moderation phases. const comment = await createComment(db, tenantID, { status: CommentStatus.ACCEPTED, diff --git a/src/core/server/services/users/index.ts b/src/core/server/services/users/index.ts new file mode 100644 index 000000000..08d18ab94 --- /dev/null +++ b/src/core/server/services/users/index.ts @@ -0,0 +1,11 @@ +import { Db } from "mongodb"; + +import { createUser, CreateUserInput } from "talk-server/models/user"; + +export type CreateUser = CreateUserInput; + +export async function create(db: Db, tenantID: string, input: CreateUser) { + const user = await createUser(db, tenantID, input); + + return user; +} From 35ba69c8ed33ade3e233fe0d407ff80f5a15e375 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Wed, 11 Jul 2018 16:30:57 -0600 Subject: [PATCH 16/43] fix: allow url to not be provided --- src/core/server/graph/tenant/schema/schema.graphql | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/core/server/graph/tenant/schema/schema.graphql b/src/core/server/graph/tenant/schema/schema.graphql index 086eb34ed..0d317b963 100644 --- a/src/core/server/graph/tenant/schema/schema.graphql +++ b/src/core/server/graph/tenant/schema/schema.graphql @@ -586,9 +586,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, url: String!): Asset + asset(id: ID, url: String): Asset """ me is the current logged in User. From 871206740ff58e8a7c99e2362ac8a898c4e22ee3 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Wed, 11 Jul 2018 16:35:20 -0600 Subject: [PATCH 17/43] fix: cleanups for asset --- .../server/graph/tenant/loaders/assets.ts | 5 +++-- .../server/graph/tenant/mutators/comment.ts | 2 +- src/core/server/services/assets/index.ts | 21 +++++++++++++++++++ src/core/server/services/comments/index.ts | 14 ++++++------- 4 files changed, 31 insertions(+), 11 deletions(-) create mode 100644 src/core/server/services/assets/index.ts diff --git a/src/core/server/graph/tenant/loaders/assets.ts b/src/core/server/graph/tenant/loaders/assets.ts index cd7db4b57..b747a1be2 100644 --- a/src/core/server/graph/tenant/loaders/assets.ts +++ b/src/core/server/graph/tenant/loaders/assets.ts @@ -1,15 +1,16 @@ import DataLoader from "dataloader"; + import TenantContext from "talk-server/graph/tenant/context"; import { Asset, - findOrCreateAsset, FindOrCreateAssetInput, retrieveManyAssets, } from "talk-server/models/asset"; +import { findOrCreate } from "talk-server/services/assets"; export default (ctx: TenantContext) => ({ findOrCreate: (input: FindOrCreateAssetInput) => - findOrCreateAsset(ctx.db, ctx.tenant.id, input), + findOrCreate(ctx.db, ctx.tenant, input), asset: new DataLoader(ids => retrieveManyAssets(ctx.db, ctx.tenant.id, ids) ), diff --git a/src/core/server/graph/tenant/mutators/comment.ts b/src/core/server/graph/tenant/mutators/comment.ts index 9025d3cef..1ad380a78 100644 --- a/src/core/server/graph/tenant/mutators/comment.ts +++ b/src/core/server/graph/tenant/mutators/comment.ts @@ -6,7 +6,7 @@ import { create } from "talk-server/services/comments"; export default (ctx: TenantContext) => ({ create: (input: GQLCreateCommentInput): Promise => { // FIXME: remove tenant + user ! - return create(ctx.db, ctx.tenant.id, { + return create(ctx.db, ctx.tenant, { author_id: ctx.user!.id, asset_id: input.assetID, body: input.body, diff --git a/src/core/server/services/assets/index.ts b/src/core/server/services/assets/index.ts new file mode 100644 index 000000000..8c43269d9 --- /dev/null +++ b/src/core/server/services/assets/index.ts @@ -0,0 +1,21 @@ +import { Db } from "mongodb"; + +import { + findOrCreateAsset, + FindOrCreateAssetInput, +} from "talk-server/models/asset"; +import { Tenant } from "talk-server/models/tenant"; + +export type FindOrCreateAsset = FindOrCreateAssetInput; + +export async function findOrCreate( + db: Db, + tenant: Tenant, + input: FindOrCreateAsset +) { + // TODO: check to see if the tenant has enabled lazy asset creation. + + const asset = await findOrCreateAsset(db, tenant.id, input); + + return asset; +} diff --git a/src/core/server/services/comments/index.ts b/src/core/server/services/comments/index.ts index 3d90149b8..dfba035d4 100644 --- a/src/core/server/services/comments/index.ts +++ b/src/core/server/services/comments/index.ts @@ -1,21 +1,19 @@ import { Db } from "mongodb"; import { Omit } from "talk-common/types"; -import { - CommentStatus, - 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) { +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, }); From 70ff49ec4f01fda771185d106b2e8ba7e750fa92 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Wed, 11 Jul 2018 16:49:54 -0600 Subject: [PATCH 18/43] fix: missed users service --- src/core/server/app/handlers/auth/local.ts | 2 +- src/core/server/app/middleware/passport/oidc.ts | 2 +- src/core/server/services/users/index.ts | 5 +++-- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/core/server/app/handlers/auth/local.ts b/src/core/server/app/handlers/auth/local.ts index 656b3cb57..3bd3d2eb7 100644 --- a/src/core/server/app/handlers/auth/local.ts +++ b/src/core/server/app/handlers/auth/local.ts @@ -67,7 +67,7 @@ export const signup = ({ db }: SignupOptions): RequestHandler => async ( }; // Create the new user. - const user = await create(db, tenant.id, { + const user = await create(db, tenant, { email, username, displayName, diff --git a/src/core/server/app/middleware/passport/oidc.ts b/src/core/server/app/middleware/passport/oidc.ts index c6be6d6c6..4892cd918 100644 --- a/src/core/server/app/middleware/passport/oidc.ts +++ b/src/core/server/app/middleware/passport/oidc.ts @@ -63,7 +63,7 @@ export async function findOrCreateOIDCUser( // FIXME: implement rules. // Create the new user, as one didn't exist before! - user = await create(db, tenant.id, { + user = await create(db, tenant, { username: null, role: GQLUSER_ROLE.COMMENTER, email, diff --git a/src/core/server/services/users/index.ts b/src/core/server/services/users/index.ts index 08d18ab94..6906a7d3b 100644 --- a/src/core/server/services/users/index.ts +++ b/src/core/server/services/users/index.ts @@ -1,11 +1,12 @@ import { Db } from "mongodb"; +import { Tenant } from "talk-server/models/tenant"; import { createUser, CreateUserInput } from "talk-server/models/user"; export type CreateUser = CreateUserInput; -export async function create(db: Db, tenantID: string, input: CreateUser) { - const user = await createUser(db, tenantID, input); +export async function create(db: Db, tenant: Tenant, input: CreateUser) { + const user = await createUser(db, tenant.id, input); return user; } From a2a49b8e45c4aed3d2e41d57ee79c32d527e92fa Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Fri, 13 Jul 2018 14:36:54 -0600 Subject: [PATCH 19/43] feat: improved user creation --- src/core/server/app/handlers/auth/local.ts | 6 +- src/core/server/app/index.ts | 5 +- src/core/server/app/middleware/error.ts | 6 + src/core/server/app/middleware/logging.ts | 6 +- .../server/app/middleware/passport/oidc.ts | 153 +++++++++++------- .../request/__snapshots__/body.spec.ts.snap | 3 + src/core/server/app/request/body.spec.ts | 32 ++++ src/core/server/app/request/body.ts | 5 +- src/core/server/app/router.ts | 41 +++-- src/core/server/models/user.ts | 72 +++++++-- src/core/server/services/users/index.ts | 8 +- 11 files changed, 246 insertions(+), 91 deletions(-) create mode 100644 src/core/server/app/middleware/error.ts create mode 100644 src/core/server/app/request/__snapshots__/body.spec.ts.snap create mode 100644 src/core/server/app/request/body.spec.ts diff --git a/src/core/server/app/handlers/auth/local.ts b/src/core/server/app/handlers/auth/local.ts index 3bd3d2eb7..777d64dc8 100644 --- a/src/core/server/app/handlers/auth/local.ts +++ b/src/core/server/app/handlers/auth/local.ts @@ -6,7 +6,7 @@ import { handle } from "talk-server/app/middleware/passport"; 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 { create } from "talk-server/services/users"; +import { upsert } from "talk-server/services/users"; import { Request } from "talk-server/types/express"; export interface SignupBody { @@ -67,12 +67,14 @@ export const signup = ({ db }: SignupOptions): RequestHandler => async ( }; // Create the new user. - const user = await create(db, tenant, { + const user = await upsert(db, tenant, { email, username, displayName, password, profiles: [profile], + // New users signing up via local auth will have the commenter role to + // start with. role: GQLUSER_ROLE.COMMENTER, }); diff --git a/src/core/server/app/index.ts b/src/core/server/app/index.ts index ef65f8157..0fefb561f 100644 --- a/src/core/server/app/index.ts +++ b/src/core/server/app/index.ts @@ -8,10 +8,7 @@ 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"; diff --git a/src/core/server/app/middleware/error.ts b/src/core/server/app/middleware/error.ts new file mode 100644 index 000000000..2f666260b --- /dev/null +++ b/src/core/server/app/middleware/error.ts @@ -0,0 +1,6 @@ +import { ErrorRequestHandler } from "express"; + +export const apiErrorHandler: ErrorRequestHandler = (err, req, res, next) => { + // TODO: handle better when we improve errors. + res.status(500).json({ error: err.message }); +}; diff --git a/src/core/server/app/middleware/logging.ts b/src/core/server/app/middleware/logging.ts index 9db48fa31..ebe1fe53e 100644 --- a/src/core/server/app/middleware/logging.ts +++ b/src/core/server/app/middleware/logging.ts @@ -2,7 +2,7 @@ import { ErrorRequestHandler, RequestHandler } from "express"; import now from "performance-now"; import logger from "../../logger"; -export const access: RequestHandler = (req, res, next) => { +export const accessLogger: RequestHandler = (req, res, next) => { const startTime = now(); const end = res.end; res.end = (chunk: any, encodingOrCb?: any, cb?: any) => { @@ -37,7 +37,7 @@ export const access: RequestHandler = (req, res, next) => { next(); }; -export const error: ErrorRequestHandler = (err, req, res, next) => { - logger.error({ err }, "http error"); +export const errorLogger: ErrorRequestHandler = (err, req, res, next) => { + logger.error(err, "http error"); next(err); }; diff --git a/src/core/server/app/middleware/passport/oidc.ts b/src/core/server/app/middleware/passport/oidc.ts index 4892cd918..a26ff32dd 100644 --- a/src/core/server/app/middleware/passport/oidc.ts +++ b/src/core/server/app/middleware/passport/oidc.ts @@ -1,27 +1,33 @@ import jwt from "jsonwebtoken"; import jwks, { JwksClient } from "jwks-rsa"; import { Db } from "mongodb"; -import { Strategy as OAuth2Strategy } from "passport-oauth2"; +import { Strategy as OAuth2Strategy, VerifyCallback } from "passport-oauth2"; import { Strategy } from "passport-strategy"; 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 { create } from "talk-server/services/users"; +import { upsert } from "talk-server/services/users"; import { Request } from "talk-server/types/express"; -import { VerifyCallback } from "./index"; - 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; - email: 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; } export interface StrategyItem { @@ -48,13 +54,14 @@ export function isOIDCToken(token: OIDCIDToken | object): token is OIDCIDToken { export async function findOrCreateOIDCUser( db: Db, tenant: Tenant, - { iss, sub, email, email_verified }: OIDCIDToken + token: OIDCIDToken ) { // Construct the profile that will be used to query for the user. const profile: OIDCProfile = { type: "oidc", - provider: iss, - id: sub, + id: token.sub, + issuer: token.iss, + audience: token.aud, }; // Try to lookup user given their id provided in the `sub` claim. @@ -63,11 +70,12 @@ export async function findOrCreateOIDCUser( // FIXME: implement rules. // Create the new user, as one didn't exist before! - user = await create(db, tenant, { + user = await upsert(db, tenant, { username: null, role: GQLUSER_ROLE.COMMENTER, - email, - email_verified, + email: token.email, + email_verified: token.email_verified, + avatar: token.picture, profiles: [profile], }); } @@ -75,6 +83,11 @@ export async function findOrCreateOIDCUser( 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 { @@ -138,14 +151,14 @@ export default class OIDCStrategy extends Strategy { return entry.jwksClient; } - private verifyCallback( + private verifyCallback = ( req: Request, - accessToken: string, - refreshToken: string, + 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, + 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; @@ -156,36 +169,30 @@ export default class OIDCStrategy extends Strategy { // 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, - tenant!.auth.integrations.oidc! - ); + const client = this.lookupJWKSClient(req, tenant.id, integration); // Verify that the id_token is valid or not. jwt.verify( id_token, - ({ 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); - } - - const signingKey = key.publicKey || key.rsaPublicKey; - callback(null, signingKey); - }); - }, + this.keyFunc(client), { - issuer: tenant!.auth.integrations.oidc!.issuer, + issuer: integration.issuer, }, (err, decoded) => { if (err) { @@ -193,10 +200,39 @@ export default class OIDCStrategy extends Strategy { return done(err); } - this.verify(tenant!, decoded as OIDCIDToken, done); + // Delegate the verify method off to the passed in verify method. + this.verify(tenant, decoded as OIDCIDToken, done); } ); - } + }; + + /** + * keyFunc will provide the secret based on the given jwkw client. + * + * @param client the jwks client for the specific request being made + */ + private keyFunc = (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); + }); + }; private createStrategy( req: Request, @@ -218,7 +254,7 @@ export default class OIDCStrategy extends Strategy { tokenURL, callbackURL, }, - this.verifyCallback.bind(this) + this.verifyCallback ); } @@ -231,17 +267,7 @@ export default class OIDCStrategy extends Strategy { // Get the integration from the tenant. If needed, it will be used to create // a new strategy. - 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"); - } + const integration = getEnabledIntegration(tenant); // Try to get the Tenant's cached integrations. let entry = this.cache.get(tenant.id); @@ -279,13 +305,32 @@ export default class OIDCStrategy extends Strategy { // 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: "openid email", session: false }); + strategy.authenticate(req, { + scope: OIDC_SCOPE, + session: false, + }); } catch (err) { return this.error(err); } } } +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 function createOIDCStrategy({ db }: OIDCStrategyOptions) { return new OIDCStrategy({ db }); } diff --git a/src/core/server/app/request/__snapshots__/body.spec.ts.snap b/src/core/server/app/request/__snapshots__/body.spec.ts.snap new file mode 100644 index 000000000..823d9cb68 --- /dev/null +++ b/src/core/server/app/request/__snapshots__/body.spec.ts.snap @@ -0,0 +1,3 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`throws an error for missing fields 1`] = `"child \\"d\\" fails because [\\"d\\" is required]"`; diff --git a/src/core/server/app/request/body.spec.ts b/src/core/server/app/request/body.spec.ts new file mode 100644 index 000000000..98fd1376d --- /dev/null +++ b/src/core/server/app/request/body.spec.ts @@ -0,0 +1,32 @@ +import Joi from "joi"; + +import { validate } from "talk-server/app/request/body"; + +it("strips out unknown fields", () => { + const payload = { a: 1, b: 2, c: 3 }; + const schema = Joi.object().keys({}); + + expect(validate(schema, payload)).toEqual({}); +}); + +it("allows valid fields", () => { + const payload = { a: 1, b: 2, c: 3 }; + const schema = Joi.object().keys({ a: Joi.number() }); + + expect(validate(schema, payload)).toEqual({ a: 1 }); +}); + +it("allows valid fields from extended schema", () => { + const payload = { a: 1, b: 2, c: 3 }; + const schema = Joi.object().keys({ a: Joi.number() }); + const extendedSchema = schema.keys({ b: Joi.number() }); + + expect(validate(extendedSchema, payload)).toEqual({ a: 1, b: 2 }); +}); + +it("throws an error for missing fields", () => { + const payload = { a: 1, b: 2, c: 3 }; + const schema = Joi.object().keys({ d: Joi.number() }); + + expect(() => validate(schema, payload)).toThrowErrorMatchingSnapshot(); +}); diff --git a/src/core/server/app/request/body.ts b/src/core/server/app/request/body.ts index 9b7cabb13..a8e34412c 100644 --- a/src/core/server/app/request/body.ts +++ b/src/core/server/app/request/body.ts @@ -12,11 +12,12 @@ export const validate = (schema: Joi.SchemaLike, body: any) => { const { value, error: err } = Joi.validate(body, schema, { stripUnknown: true, presence: "required", + abortEarly: false, }); if (err) { - // TODO: return better error. - throw new Error("Validation Error"); + // TODO: wrap error? + throw err; } return value; diff --git a/src/core/server/app/router.ts b/src/core/server/app/router.ts index 7b2338e0f..1011e532f 100644 --- a/src/core/server/app/router.ts +++ b/src/core/server/app/router.ts @@ -6,6 +6,8 @@ import managementGraphMiddleware from "talk-server/graph/management/middleware"; import tenantGraphMiddleware from "talk-server/graph/tenant/middleware"; import { signup } from "talk-server/app/handlers/auth/local"; +import { apiErrorHandler } from "talk-server/app/middleware/error"; +import { errorLogger } from "talk-server/app/middleware/logging"; import { authenticate } from "talk-server/app/middleware/passport"; import { AppOptions } from "./index"; import playground from "./middleware/playground"; @@ -33,19 +35,11 @@ async function createTenantRouter(app: AppOptions, options: RouterOptions) { // Tenant identification middleware. router.use(tenantMiddleware({ db: app.mongo })); + // Setup Passport middleware. router.use(options.passport.initialize()); - router.use( - "/auth/local", - express.json(), - authenticate(options.passport, "local") - ); - router.use("/auth/local/signup", express.json(), signup({ db: app.mongo })); - router.use("/auth/oidc", authenticate(options.passport, "oidc")); - router.use("/auth/oidc/callback", authenticate(options.passport, "oidc")); - // router.use("/auth/google", options.passport.authenticate("google")); - // router.use("/auth/google/callback", options.passport.authenticate("google")); - // router.use("/auth/facebook", options.passport.authenticate("facebook")); - // router.use("/auth/facebook/callback", options.passport.authenticate("facebook")); + + // Setup auth routes. + router.use("/auth", createNewAuthRouter(app, options)); // Tenant API router.use( @@ -57,6 +51,25 @@ async function createTenantRouter(app: AppOptions, options: RouterOptions) { return router; } +function createNewAuthRouter(app: AppOptions, options: RouterOptions) { + const router = express.Router(); + + router.post( + "/local", + express.json(), + authenticate(options.passport, "local") + ); + router.post("/local/signup", express.json(), signup({ db: app.mongo })); + router.get("/oidc", authenticate(options.passport, "oidc")); + router.get("/oidc/callback", authenticate(options.passport, "oidc")); + // router.get("/google", options.passport.authenticate("google")); + // router.get("/google/callback", options.passport.authenticate("google")); + // router.get("/facebook", options.passport.authenticate("facebook")); + // router.get("/facebook/callback", options.passport.authenticate("facebook")); + + return router; +} + async function createAPIRouter(app: AppOptions, options: RouterOptions) { // Create a router. const router = express.Router(); @@ -67,6 +80,10 @@ async function createAPIRouter(app: AppOptions, options: RouterOptions) { // Configure the management routes. router.use("/management", await createManagementRouter(app, options)); + // General API error handler. + router.use(errorLogger); + router.use(apiErrorHandler); + return router; } diff --git a/src/core/server/models/user.ts b/src/core/server/models/user.ts index 0631e9e85..04da3214d 100644 --- a/src/core/server/models/user.ts +++ b/src/core/server/models/user.ts @@ -9,6 +9,7 @@ import { 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) { @@ -23,7 +24,8 @@ export interface LocalProfile { export interface OIDCProfile { type: "oidc"; id: string; - provider: string; + issuer: string; + audience: string; } export interface SSOProfile { @@ -62,6 +64,7 @@ export interface User extends TenantResource { username: string | null; displayName?: string; password?: string; + avatar?: string; email?: string; email_verified?: boolean; profiles: Profile[]; @@ -73,7 +76,7 @@ export interface User extends TenantResource { created_at: Date; } -export type CreateUserInput = Omit< +export type UpsertUserInput = Omit< User, | "id" | "tenant_id" @@ -84,17 +87,20 @@ export type CreateUserInput = Omit< | "created_at" >; -export async function createUser( +export async function upsertUser( db: Db, tenantID: string, - input: CreateUserInput + input: UpsertUserInput ) { const now = new Date(); + // Create a new ID for the user. + const id = uuid.v4(); + // default are the properties set by the application when a new user is // created. - const defaults: Sub = { - id: uuid.v4(), + const defaults: Sub = { + id, tenant_id: tenantID, tokens: [], action_counts: {}, @@ -118,15 +124,61 @@ export async function createUser( created_at: now, }; + if (input.password) { + // Hash the user's password with bcrypt. + input.password = await bcrypt.hash(input.password, 10); + } + // Merge the defaults and the input together. const user: Readonly = merge({}, defaults, input); - // Insert it into the database. - await collection(db).insertOne(user); + // Create a query that will utilize a findOneAndUpdate to facilitate an upsert + // operation to ensure no user has the same profile and/or email address. If + // any user is found to have the same profile as any of the profiles specified + // in the new user object, then we should error here. + const filter = createUpsertUserFilter(user); - return user; + // Create the upsert/update operation. + const update: { $setOnInsert: Readonly } = { + $setOnInsert: user, + }; + + // Insert it into the database. This may throw an error. + const result = await collection(db).findOneAndUpdate(filter, update, { + // We are using this to create a user, so we need to upsert it. + upsert: true, + + // False to return the updated document instead of the original document. + // This lets us detect if the document was updated or not. + returnOriginal: false, + }); + + // Check to see if this was a new user that was upserted, or one was found + // that matched existing records. We are sure here that the record exists + // because we're returning the updated document and performing an upsert + // operation. + if (result.value!.id !== id) { + // TODO: return better error. + throw new Error("user already found"); + } + + return result.value!; } +const createUpsertUserFilter = (user: Readonly) => { + const query: FilterQuery = { + // Query by the profiles if the user is being created with one. + $or: user.profiles.map(profile => ({ profiles: { $elemMatch: profile } })), + }; + + if (user.email) { + // Query by the email address if the user is being created with one. + query.$or.push({ email: user.email }); + } + + return query; +}; + export async function retrieveUser(db: Db, tenantID: string, id: string) { return collection(db).findOne({ id, tenant_id: tenantID }); } @@ -178,7 +230,7 @@ export async function updateUserRole( export async function verifyUserPassword(user: User, password: string) { if (user.password) { - return bcrypt.compare(user.password, password); + return bcrypt.compare(password, user.password); } return false; diff --git a/src/core/server/services/users/index.ts b/src/core/server/services/users/index.ts index 6906a7d3b..1a0a62b4f 100644 --- a/src/core/server/services/users/index.ts +++ b/src/core/server/services/users/index.ts @@ -1,12 +1,12 @@ import { Db } from "mongodb"; import { Tenant } from "talk-server/models/tenant"; -import { createUser, CreateUserInput } from "talk-server/models/user"; +import { upsertUser, UpsertUserInput } from "talk-server/models/user"; -export type CreateUser = CreateUserInput; +export type UpsertUser = UpsertUserInput; -export async function create(db: Db, tenant: Tenant, input: CreateUser) { - const user = await createUser(db, tenant.id, input); +export async function upsert(db: Db, tenant: Tenant, input: UpsertUser) { + const user = await upsertUser(db, tenant.id, input); return user; } From 2a7fa08bd6a56c22fd320d596233b8a423edfe1f Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Fri, 13 Jul 2018 14:52:10 -0600 Subject: [PATCH 20/43] fix: renamed type file --- src/core/server/types/{express.ts => express.d.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/core/server/types/{express.ts => express.d.ts} (100%) diff --git a/src/core/server/types/express.ts b/src/core/server/types/express.d.ts similarity index 100% rename from src/core/server/types/express.ts rename to src/core/server/types/express.d.ts From f5ef551fb449bc824146ca8ada7ed4e14f435645 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Mon, 16 Jul 2018 10:49:16 -0600 Subject: [PATCH 21/43] review: input.password -> hashedPassword --- src/core/server/models/user.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/core/server/models/user.ts b/src/core/server/models/user.ts index 04da3214d..5adc4bc53 100644 --- a/src/core/server/models/user.ts +++ b/src/core/server/models/user.ts @@ -124,13 +124,18 @@ export async function upsertUser( created_at: now, }; + let hashedPassword; if (input.password) { // Hash the user's password with bcrypt. - input.password = await bcrypt.hash(input.password, 10); + hashedPassword = await bcrypt.hash(input.password, 10); } // Merge the defaults and the input together. - const user: Readonly = merge({}, defaults, input); + const user: Readonly = merge({}, defaults, input, { + // Specified last in the merge call, it will override any existing password + // entry if it is defined. + password: hashedPassword, + }); // 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 From 7e98580264d9c9baa6b025d0b65b7961c945b11e Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Mon, 16 Jul 2018 11:38:28 -0600 Subject: [PATCH 22/43] review: changes for review --- config/watcher.ts | 4 +- package.json | 4 +- scripts/{types.js => generateSchemaTypes.js} | 10 +- src/core/client/tsconfig.json | 3 +- src/core/server/app/handlers/auth/local.ts | 3 +- .../server/app/middleware/passport/oidc.ts | 121 +++++++++--------- 6 files changed, 66 insertions(+), 79 deletions(-) rename scripts/{types.js => generateSchemaTypes.js} (95%) diff --git a/config/watcher.ts b/config/watcher.ts index b4c1470dd..c441f09a2 100644 --- a/config/watcher.ts +++ b/config/watcher.ts @@ -9,8 +9,8 @@ const config: Config = { rootDir: path.resolve(__dirname, "../src"), watchers: { compileGraphQLTypes: { - paths: ["core/server/graph/**/*.graphql"], - executor: new CommandExecutor("npm run compile:graphql", { + paths: ["core/server/**/*.graphql"], + executor: new CommandExecutor("npm run compile:schema", { runOnInit: true, }), }, diff --git a/package.json b/package.json index aa5f888b1..33ae5e838 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "build:server": "tsc -p ./src/tsconfig.json", "build": "npm-run-all compile --parallel build:*", "compile:css-types": "tcm src/core/client/", - "compile:graphql": "node ./scripts/types.js", + "compile:schema": "node ./scripts/generateSchemaTypes.js", "compile:relay-stream": "relay-compiler --src ./src/core/client/stream --schema $(ts-node ./scripts/schemaPath.ts tenant) --language typescript --artifactDirectory ./src/core/client/stream/__generated__ --no-watchman", "compile": "npm-run-all --parallel compile:*", "docz:watch": "docz dev", @@ -175,4 +175,4 @@ "webpack-hot-client": "^4.0.3", "webpack-manifest-plugin": "^2.0.3" } -} +} \ No newline at end of file diff --git a/scripts/types.js b/scripts/generateSchemaTypes.js similarity index 95% rename from scripts/types.js rename to scripts/generateSchemaTypes.js index be1449842..3344eb33f 100644 --- a/scripts/types.js +++ b/scripts/generateSchemaTypes.js @@ -17,15 +17,9 @@ function lintAndWrite(files) { function getFileName(name) { return path.join( __dirname, - "..", - "src", - "core", - "server", - "graph", + "../src/core/server/graph", name, - "schema", - "__generated__", - "types.ts" + "schema/__generated__/types.ts" ); } diff --git a/src/core/client/tsconfig.json b/src/core/client/tsconfig.json index e8fb8eeab..a62cea6c5 100644 --- a/src/core/client/tsconfig.json +++ b/src/core/client/tsconfig.json @@ -12,8 +12,7 @@ "talk-stream/*": ["./stream/*"], "talk-framework/*": ["./framework/*"], "talk-ui/*": ["./ui/*"], - "talk-common/*": ["../common/*"], - "talk-locales/*": ["../../locales/*"] + "talk-common/*": ["../common/*"] } }, "include": ["./**/*", "../../types/**/*.d.ts"], diff --git a/src/core/server/app/handlers/auth/local.ts b/src/core/server/app/handlers/auth/local.ts index 777d64dc8..a5e483d08 100644 --- a/src/core/server/app/handlers/auth/local.ts +++ b/src/core/server/app/handlers/auth/local.ts @@ -22,8 +22,7 @@ const SignupBodySchema = Joi.object().keys({ email: Joi.string().trim(), }); -// Extends the default signup body schema with the displayName to allow it to be -// sent. +// Extends the default signup body schema to allow the displayName to be set. const SignupDisplayNameBodySchema = SignupBodySchema.keys({ displayName: Joi.string().trim(), }); diff --git a/src/core/server/app/middleware/passport/oidc.ts b/src/core/server/app/middleware/passport/oidc.ts index a26ff32dd..07dd55543 100644 --- a/src/core/server/app/middleware/passport/oidc.ts +++ b/src/core/server/app/middleware/passport/oidc.ts @@ -51,6 +51,50 @@ export function isOIDCToken(token: OIDCIDToken | object): token is OIDCIDToken { 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 async function findOrCreateOIDCUser( db: Db, tenant: Tenant, @@ -104,19 +148,6 @@ export default class OIDCStrategy extends Strategy { this.db = db; } - private async verify( - tenant: Tenant, - token: OIDCIDToken, - done: VerifyCallback - ) { - try { - const user = await findOrCreateOIDCUser(this.db, tenant, token); - return done(null, user); - } catch (err) { - return done(err); - } - } - private lookupJWKSClient( req: Request, tenantID: string, @@ -151,7 +182,7 @@ export default class OIDCStrategy extends Strategy { return entry.jwksClient; } - private verifyCallback = ( + 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. @@ -190,50 +221,30 @@ export default class OIDCStrategy extends Strategy { // Verify that the id_token is valid or not. jwt.verify( id_token, - this.keyFunc(client), + signingKeyFactory(client), { issuer: integration.issuer, }, - (err, decoded) => { + async (err, decoded) => { if (err) { // TODO: wrap error? return done(err); } - // Delegate the verify method off to the passed in verify method. - this.verify(tenant, decoded as OIDCIDToken, done); + try { + const user = await findOrCreateOIDCUser( + this.db, + tenant, + decoded as OIDCIDToken + ); + return done(null, user); + } catch (err) { + return done(err); + } } ); }; - /** - * keyFunc will provide the secret based on the given jwkw client. - * - * @param client the jwks client for the specific request being made - */ - private keyFunc = (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); - }); - }; - private createStrategy( req: Request, integration: OIDCAuthIntegration @@ -254,7 +265,7 @@ export default class OIDCStrategy extends Strategy { tokenURL, callbackURL, }, - this.verifyCallback + this.userAuthenticatedCallback ); } @@ -315,22 +326,6 @@ export default class OIDCStrategy extends Strategy { } } -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 function createOIDCStrategy({ db }: OIDCStrategyOptions) { return new OIDCStrategy({ db }); } From b07073b882479c16d8ba6661aa7ea6af063ac2be Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Mon, 16 Jul 2018 13:14:38 -0600 Subject: [PATCH 23/43] review: tests --- .../graph/common/scalars/cursor.spec.ts | 136 ++++++++++++++++++ .../server/graph/common/scalars/cursor.ts | 4 + .../server/types/{express.d.ts => express.ts} | 0 3 files changed, 140 insertions(+) create mode 100644 src/core/server/graph/common/scalars/cursor.spec.ts rename src/core/server/types/{express.d.ts => express.ts} (100%) diff --git a/src/core/server/graph/common/scalars/cursor.spec.ts b/src/core/server/graph/common/scalars/cursor.spec.ts new file mode 100644 index 000000000..6d3b807f2 --- /dev/null +++ b/src/core/server/graph/common/scalars/cursor.spec.ts @@ -0,0 +1,136 @@ +import { Kind } from "graphql"; +import { DateTime } from "luxon"; + +import Cursor from "./cursor"; + +describe("parseLiteral", () => { + it("parses a date from a string", () => { + expect( + Cursor.parseLiteral({ + kind: Kind.STRING, + value: "2018-07-16T18:34:26.744Z", + }) + ).toBeInstanceOf(Date); + + expect( + Cursor.parseLiteral({ + kind: Kind.STRING, + value: "this-should-fail", + }) + ).toEqual(null); + + expect( + Cursor.parseLiteral({ + kind: Kind.STRING, + value: "", + }) + ).toEqual(null); + }); + + it("parses a number from a string", () => { + expect( + Cursor.parseLiteral({ + kind: Kind.STRING, + value: "20", + }) + ).toEqual(20); + + expect( + Cursor.parseLiteral({ + kind: Kind.STRING, + value: "0", + }) + ).toEqual(0); + + expect( + Cursor.parseLiteral({ + kind: Kind.STRING, + value: "null", + }) + ).toEqual(null); + + expect( + Cursor.parseLiteral({ + kind: Kind.STRING, + value: "0", + }) + ).toEqual(0); + }); + + it("parses a number from a number", () => { + expect( + Cursor.parseLiteral({ + kind: Kind.INT, + value: "20", + }) + ).toEqual(20); + + expect( + Cursor.parseLiteral({ + kind: Kind.INT, + value: "0", + }) + ).toEqual(0); + + expect( + Cursor.parseLiteral({ + kind: Kind.INT, + value: "", + }) + ).toEqual(null); + }); + + it("does not parse unknown kinds", () => { + expect( + Cursor.parseLiteral({ + kind: Kind.FLOAT, + value: "0.0", + }) + ).toEqual(null); + }); +}); + +describe("serialize", () => { + it("renders native dates correctly", () => { + const date = new Date(); + const expected = date.toISOString(); + expect(Cursor.serialize(date)).toEqual(expected); + + expect(Cursor.serialize({})).toEqual(null); + }); + + it("renders luxon dates correctly", () => { + const date = DateTime.fromJSDate(new Date()); + const expected = date.toISO(); + expect(Cursor.serialize(date)).toEqual(expected); + }); + + it("renders numbers correctly", () => { + let value = 50; + let expected = "50"; + expect(Cursor.serialize(value)).toEqual(expected); + + value = 0; + expected = "0"; + expect(Cursor.serialize(value)).toEqual(expected); + + expect(Cursor.serialize(null)).toEqual(null); + }); +}); + +describe("parseValue", () => { + it("parses the string value of a Date", () => { + const date = new Date(); + const expected = date.toISOString(); + expect(Cursor.parseValue(expected)).toBeInstanceOf(Date); + }); + + it("parses the string value of a number", () => { + expect(Cursor.parseValue("0")).toEqual(0); + }); + + it("handles invalid properties", () => { + expect(Cursor.parseValue(null)).toEqual(null); + expect(Cursor.parseValue(2)).toEqual(null); + }); +}); diff --git a/src/core/server/graph/common/scalars/cursor.ts b/src/core/server/graph/common/scalars/cursor.ts index 23649e10b..e4aeaab74 100644 --- a/src/core/server/graph/common/scalars/cursor.ts +++ b/src/core/server/graph/common/scalars/cursor.ts @@ -1,11 +1,15 @@ import { GraphQLScalarType } from "graphql"; import { Kind } from "graphql/language"; import { DateTime } from "luxon"; + import { Cursor } from "talk-server/models/connection"; function parseIntegerCursor(value: string): number | null { try { const cursor = parseInt(value, 10); + if (isNaN(cursor)) { + return null; + } return cursor; } catch (err) { diff --git a/src/core/server/types/express.d.ts b/src/core/server/types/express.ts similarity index 100% rename from src/core/server/types/express.d.ts rename to src/core/server/types/express.ts From 34f42c29913a362c32259ee0d67595ce23f912f7 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Mon, 16 Jul 2018 13:24:02 -0600 Subject: [PATCH 24/43] feat: support graphql schema type compilation for watcher --- config/watcher.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/config/watcher.ts b/config/watcher.ts index b59928503..277ec34ec 100644 --- a/config/watcher.ts +++ b/config/watcher.ts @@ -8,7 +8,7 @@ import { const config: Config = { rootDir: path.resolve(__dirname, "../src"), watchers: { - compileGraphQLTypes: { + compileSchema: { paths: ["core/server/**/*.graphql"], executor: new CommandExecutor("npm run compile:schema", { runOnInit: true, @@ -53,7 +53,7 @@ const config: Config = { }, defaultSet: "client", sets: { - server: ["runServer"], + server: ["compileSchema", "runServer"], client: [ "runServer", "runWebpackDevServer", @@ -61,7 +61,7 @@ const config: Config = { "compileRelayStream", ], docz: ["runDocz", "compileCSSTypes"], - compile: ["compileCSSTypes", "compileRelayStream"], + compile: ["compileSchema", "compileCSSTypes", "compileRelayStream"], }, }; From 6f6bedb07acfb0ec402366eb9c64dbfb284da6b2 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Mon, 16 Jul 2018 13:26:41 -0600 Subject: [PATCH 25/43] feat: production updates --- config/paths.js | 2 +- config/webpack.config.prod.js | 10 +++++----- src/core/server/app/handlers/auth/local.ts | 3 ++- src/core/server/app/index.ts | 8 +++++--- src/core/server/app/middleware/notFound.ts | 5 +++++ src/core/server/app/router.ts | 14 +++++++++----- src/core/server/config.ts | 5 ----- src/index.ts | 6 ++++++ 8 files changed, 33 insertions(+), 20 deletions(-) create mode 100644 src/core/server/app/middleware/notFound.ts diff --git a/config/paths.js b/config/paths.js index 7d98ac998..73cc43370 100644 --- a/config/paths.js +++ b/config/paths.js @@ -46,7 +46,7 @@ module.exports = { appPostCssConfig: resolveApp("config/postcss.config.js"), appJestConfig: resolveApp("config/jest.config.js"), appLoaders: resolveApp("loaders"), - appDist: resolveApp("dist"), + appDist: resolveApp("dist/static"), appPublic: resolveApp("public"), appPackageJson: resolveApp("package.json"), appSrc: resolveApp("src"), diff --git a/config/webpack.config.prod.js b/config/webpack.config.prod.js index 4980d73be..d1ad40d3b 100644 --- a/config/webpack.config.prod.js +++ b/config/webpack.config.prod.js @@ -39,7 +39,7 @@ if (env.stringified["process.env"].NODE_ENV !== '"production"') { // because of this bug https://github.com/webpack-contrib/extract-text-webpack-plugin/issues/763. // TODO: Repalce with mini-css-extract-plugin once it supports HMR. // https://github.com/webpack-contrib/mini-css-extract-plugin -const cssFilename = "static/css/[name].[md5:contenthash:hex:20].css"; +const cssFilename = "assets/css/[name].[md5:contenthash:hex:20].css"; // ExtractTextPlugin expects the build output to be flat. // (See https://github.com/webpack-contrib/extract-text-webpack-plugin/issues/27) @@ -72,8 +72,8 @@ module.exports = { // Generated JS file names (with nested folders). // There will be one main bundle, and one file per asynchronous chunk. // We don't currently advertise code splitting but Webpack supports it. - filename: "static/js/[name].[chunkhash:8].js", - chunkFilename: "static/js/[name].[chunkhash:8].chunk.js", + filename: "assets/js/[name].[chunkhash:8].js", + chunkFilename: "assets/js/[name].[chunkhash:8].chunk.js", // We inferred the "public path" (such as / or /my-project) from homepage. publicPath: publicPath, // Point sourcemap entries to original disk location (format as URL on Windows) @@ -210,7 +210,7 @@ module.exports = { loader: require.resolve("url-loader"), options: { limit: 10000, - name: "static/media/[name].[hash:8].[ext]", + name: "assets/media/[name].[hash:8].[ext]", }, }, // Process JS with Babel. @@ -299,7 +299,7 @@ module.exports = { exclude: [/\.(js|jsx|mjs|ts|tsx)$/, /\.html$/, /\.json$/], loader: require.resolve("file-loader"), options: { - name: "static/media/[name].[hash:8].[ext]", + name: "assets/media/[name].[hash:8].[ext]", }, }, ], diff --git a/src/core/server/app/handlers/auth/local.ts b/src/core/server/app/handlers/auth/local.ts index a5e483d08..b1cfd11be 100644 --- a/src/core/server/app/handlers/auth/local.ts +++ b/src/core/server/app/handlers/auth/local.ts @@ -31,7 +31,7 @@ export interface SignupOptions { db: Db; } -export const signup = ({ db }: SignupOptions): RequestHandler => async ( +export const signupHandler = ({ db }: SignupOptions): RequestHandler => async ( req: Request, res, next @@ -42,6 +42,7 @@ export const signup = ({ db }: SignupOptions): RequestHandler => async ( // 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")); diff --git a/src/core/server/app/index.ts b/src/core/server/app/index.ts index 0fefb561f..c5dbd81a1 100644 --- a/src/core/server/app/index.ts +++ b/src/core/server/app/index.ts @@ -3,6 +3,7 @@ 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 { Config } from "talk-server/config"; import { handleSubscriptions } from "talk-server/graph/common/subscriptions/middleware"; @@ -30,16 +31,17 @@ export async function createApp(options: AppOptions): Promise { // Logging parent.use(accessLogger); - // Static Files - parent.use(serveStatic); - // Create some services for the router. const passport = createPassport({ db: options.mongo }); // Mount the router. parent.use(await createRouter(options, { passport })); + // Static Files + parent.use(serveStatic); + // Error Handling + parent.use(notFoundMiddleware); parent.use(errorLogger); return parent; diff --git a/src/core/server/app/middleware/notFound.ts b/src/core/server/app/middleware/notFound.ts new file mode 100644 index 000000000..1d5dbde5c --- /dev/null +++ b/src/core/server/app/middleware/notFound.ts @@ -0,0 +1,5 @@ +import { RequestHandler } from "express"; + +export const notFoundMiddleware: RequestHandler = (req, res, next) => { + next(new Error("not found")); +}; diff --git a/src/core/server/app/router.ts b/src/core/server/app/router.ts index 1011e532f..ad862d574 100644 --- a/src/core/server/app/router.ts +++ b/src/core/server/app/router.ts @@ -1,14 +1,14 @@ 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 { authenticate } 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"; -import { signup } from "talk-server/app/handlers/auth/local"; -import { apiErrorHandler } from "talk-server/app/middleware/error"; -import { errorLogger } from "talk-server/app/middleware/logging"; -import { authenticate } from "talk-server/app/middleware/passport"; import { AppOptions } from "./index"; import playground from "./middleware/playground"; @@ -59,7 +59,11 @@ function createNewAuthRouter(app: AppOptions, options: RouterOptions) { express.json(), authenticate(options.passport, "local") ); - router.post("/local/signup", express.json(), signup({ db: app.mongo })); + router.post( + "/local/signup", + express.json(), + signupHandler({ db: app.mongo }) + ); router.get("/oidc", authenticate(options.passport, "oidc")); router.get("/oidc/callback", authenticate(options.passport, "oidc")); // router.get("/google", options.passport.authenticate("google")); diff --git a/src/core/server/config.ts b/src/core/server/config.ts index f82bb46b8..1954de907 100644 --- a/src/core/server/config.ts +++ b/src/core/server/config.ts @@ -1,11 +1,6 @@ import convict from "convict"; -import dotenv from "dotenv"; import Joi from "joi"; -// Apply all the configuration provided in the .env file if it isn't already in -// the environment. -dotenv.config(); - // Add custom format for the mongo uri scheme. convict.addFormat({ name: "mongo-uri", diff --git a/src/index.ts b/src/index.ts index ec3479156..985b3476e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,9 @@ +import dotenv from "dotenv"; + +// Apply all the configuration provided in the .env file if it isn't already in +// the environment. +dotenv.config(); + import express from "express"; import logger from "talk-server/logger"; From caec45a0c68f8be53138885a1cb7480f03432742 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 17 Jul 2018 12:32:43 -0600 Subject: [PATCH 26/43] feat: passport impl --- .../app/middleware/passport/jwt.spec.ts | 65 +++++ .../server/app/middleware/passport/jwt.ts | 32 +++ .../app/middleware/passport/oidc.spec.ts | 87 +++++++ .../server/app/middleware/passport/oidc.ts | 56 ++++- .../app/middleware/passport/sso.spec.ts | 83 +++++++ .../server/app/middleware/passport/sso.ts | 223 +++++++++++++++++- src/core/server/app/router.ts | 1 + 7 files changed, 539 insertions(+), 8 deletions(-) create mode 100644 src/core/server/app/middleware/passport/jwt.spec.ts create mode 100644 src/core/server/app/middleware/passport/jwt.ts create mode 100644 src/core/server/app/middleware/passport/oidc.spec.ts create mode 100644 src/core/server/app/middleware/passport/sso.spec.ts diff --git a/src/core/server/app/middleware/passport/jwt.spec.ts b/src/core/server/app/middleware/passport/jwt.spec.ts new file mode 100644 index 000000000..22ea07405 --- /dev/null +++ b/src/core/server/app/middleware/passport/jwt.spec.ts @@ -0,0 +1,65 @@ +import sinon from "sinon"; + +import { + extractJWTFromRequest, + parseAuthHeader, +} from "talk-server/app/middleware/passport/jwt"; +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(); + }); +}); diff --git a/src/core/server/app/middleware/passport/jwt.ts b/src/core/server/app/middleware/passport/jwt.ts new file mode 100644 index 000000000..d0ec3d5ea --- /dev/null +++ b/src/core/server/app/middleware/passport/jwt.ts @@ -0,0 +1,32 @@ +import { Request } from "talk-server/types/express"; + +const re = /(\S+)\s+(\S+)/; + +export function parseAuthHeader(header: string) { + const matches = header.match(re); + 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; +} diff --git a/src/core/server/app/middleware/passport/oidc.spec.ts b/src/core/server/app/middleware/passport/oidc.spec.ts new file mode 100644 index 000000000..6f10b1140 --- /dev/null +++ b/src/core/server/app/middleware/passport/oidc.spec.ts @@ -0,0 +1,87 @@ +import { + OIDCDisplayNameIDTokenSchema, + OIDCIDTokenSchema, +} from "talk-server/app/middleware/passport/oidc"; +import { validate } from "talk-server/app/request/body"; + +describe("OIDCIDTokenSchema", () => { + it("allows a valid payload", () => { + const token = { + sub: "sub", + iss: "iss", + aud: "aud", + email: "email", + email_verified: true, + }; + + expect(validate(OIDCIDTokenSchema, token)).toEqual(token); + }); + + it("allows an empty email_verified", () => { + const token = { + sub: "sub", + iss: "iss", + aud: "aud", + email: "email", + }; + + expect(validate(OIDCIDTokenSchema, token)).toEqual({ + ...token, + email_verified: false, + }); + }); + + it("allows an empty picture", () => { + const token = { + sub: "sub", + iss: "iss", + aud: "aud", + email: "email", + email_verified: true, + }; + + expect(validate(OIDCIDTokenSchema, token)).toEqual(token); + }); +}); + +describe("OIDCDisplayNameIDTokenSchema", () => { + it("allows a valid payload", () => { + const token = { + sub: "sub", + iss: "iss", + aud: "aud", + email: "email", + email_verified: true, + name: "name", + nickname: "nickname", + }; + + expect(validate(OIDCDisplayNameIDTokenSchema, token)).toEqual(token); + }); + + it("allows an empty name", () => { + const token = { + sub: "sub", + iss: "iss", + aud: "aud", + email: "email", + email_verified: false, + nickname: "nickname", + }; + + expect(validate(OIDCDisplayNameIDTokenSchema, token)).toEqual(token); + }); + + it("allows an empty nickname", () => { + const token = { + sub: "sub", + iss: "iss", + aud: "aud", + email: "email", + email_verified: false, + name: "name", + }; + + expect(validate(OIDCDisplayNameIDTokenSchema, token)).toEqual(token); + }); +}); diff --git a/src/core/server/app/middleware/passport/oidc.ts b/src/core/server/app/middleware/passport/oidc.ts index 07dd55543..35982e0a1 100644 --- a/src/core/server/app/middleware/passport/oidc.ts +++ b/src/core/server/app/middleware/passport/oidc.ts @@ -1,9 +1,11 @@ +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"; @@ -28,6 +30,8 @@ export interface OIDCIDToken { email?: string; email_verified?: boolean; picture?: string; + name?: string; + nickname?: string; } export interface StrategyItem { @@ -95,17 +99,50 @@ function getEnabledIntegration(tenant: Tenant) { 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.displayNameEnable + ? OIDCDisplayNameIDTokenSchema + : OIDCIDTokenSchema, + token + ); + // Construct the profile that will be used to query for the user. const profile: OIDCProfile = { type: "oidc", - id: token.sub, - issuer: token.iss, - audience: token.aud, + id: sub, + issuer: iss, + audience: aud, }; // Try to lookup user given their id provided in the `sub` claim. @@ -113,17 +150,24 @@ export async function findOrCreateOIDCUser( 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: token.email, - email_verified: token.email_verified, - avatar: token.picture, + email, + email_verified, + avatar: picture, profiles: [profile], }); } + // TODO: (wyattjoh) possibly update the user profile if the remaining details mismatch? + return user; } diff --git a/src/core/server/app/middleware/passport/sso.spec.ts b/src/core/server/app/middleware/passport/sso.spec.ts new file mode 100644 index 000000000..f973f45b7 --- /dev/null +++ b/src/core/server/app/middleware/passport/sso.spec.ts @@ -0,0 +1,83 @@ +import { + isSSOToken, + SSODisplayNameUserProfileSchema, + SSOUserProfileSchema, +} from "talk-server/app/middleware/passport/sso"; +import { validate } from "talk-server/app/request/body"; + +describe("isSSOToken", () => { + it("understands valid sso tokens", () => { + const token = { user: { id: "id", email: "email", username: "username" } }; + expect(isSSOToken(token)).toBeTruthy(); + }); + + it("understands invalid sso tokens", () => { + expect(isSSOToken({ user: { id: "id", email: "email" } })).toBeFalsy(); + expect( + isSSOToken({ user: { id: "id", username: "username" } }) + ).toBeFalsy(); + expect( + isSSOToken({ user: { email: "email", username: "username" } }) + ).toBeFalsy(); + expect(isSSOToken({})).toBeFalsy(); + }); +}); + +describe("SSOUserProfileSchema", () => { + it("allows a valid payload", () => { + const profile = { + id: "id", + email: "email", + username: "username", + avatar: "avatar", + }; + + expect(validate(SSOUserProfileSchema, profile)).toEqual(profile); + }); + + it("allows an empty avatar", () => { + const profile = { + id: "id", + email: "email", + username: "username", + }; + + expect(validate(SSOUserProfileSchema, profile)).toEqual(profile); + }); +}); + +describe("SSODisplayNameUserProfileSchema", () => { + it("allows a valid payload", () => { + const profile = { + id: "id", + email: "email", + username: "username", + avatar: "avatar", + displayName: "displayName", + }; + + expect(validate(SSODisplayNameUserProfileSchema, profile)).toEqual(profile); + }); + + it("allows an empty avatar", () => { + const profile = { + id: "id", + email: "email", + username: "username", + displayName: "displayName", + }; + + expect(validate(SSODisplayNameUserProfileSchema, profile)).toEqual(profile); + }); + + it("allows an empty displayName", () => { + const profile = { + id: "id", + email: "email", + username: "username", + avatar: "avatar", + }; + + expect(validate(SSODisplayNameUserProfileSchema, profile)).toEqual(profile); + }); +}); diff --git a/src/core/server/app/middleware/passport/sso.ts b/src/core/server/app/middleware/passport/sso.ts index ecc883685..b5699114d 100644 --- a/src/core/server/app/middleware/passport/sso.ts +++ b/src/core/server/app/middleware/passport/sso.ts @@ -1,9 +1,228 @@ +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.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 async authenticate(req: Request) { - return; + 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); + } + + /** + * wrapNewTokenHandler wraps the token handling with a promise. + */ + private wrapNewTokenHandler = (tenant: Tenant) => async ( + err: Error | undefined, + decoded: OIDCIDToken | SSOToken + ) => { + if (err) { + return this.fail(err, 401); + } + + 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); + } + }; + + 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. + }, + this.wrapNewTokenHandler(tenant) + ); } } diff --git a/src/core/server/app/router.ts b/src/core/server/app/router.ts index ad862d574..b6af1564f 100644 --- a/src/core/server/app/router.ts +++ b/src/core/server/app/router.ts @@ -64,6 +64,7 @@ function createNewAuthRouter(app: AppOptions, options: RouterOptions) { express.json(), signupHandler({ db: app.mongo }) ); + router.post("/sso", authenticate(options.passport, "sso")); router.get("/oidc", authenticate(options.passport, "oidc")); router.get("/oidc/callback", authenticate(options.passport, "oidc")); // router.get("/google", options.passport.authenticate("google")); From 54e99dec128eac9190f55c1ed5716aea4e2145a1 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 17 Jul 2018 12:42:06 -0600 Subject: [PATCH 27/43] feat: added npm audit --- .circleci/config.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.circleci/config.yml b/.circleci/config.yml index bd7493f6b..0bf9891c0 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -24,6 +24,9 @@ jobs: - run: name: Install dependencies command: npm install + - run: + name: Audit dependencies + command: npm audit - save_cache: key: dependency-cache-{{ checksum "package-lock.json" }} paths: From 2cf7f1e2efb3e86cd17bd1c8007bcc43aa07b82d Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 17 Jul 2018 12:49:35 -0600 Subject: [PATCH 28/43] fix: update npm --- .circleci/config.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.circleci/config.yml b/.circleci/config.yml index 0bf9891c0..1680a0cca 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -21,6 +21,9 @@ jobs: at: ~/coralproject/talk - restore_cache: key: dependency-cache-{{ checksum "package-lock.json" }} + - run: + name: Update NPM + command: npm update -g npm - run: name: Install dependencies command: npm install From 7c139a37b43a5cdac1d89500404847a79e06b300 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 17 Jul 2018 12:50:21 -0600 Subject: [PATCH 29/43] fix: sudo with global npm update --- .circleci/config.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 1680a0cca..e65bc925a 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -23,7 +23,7 @@ jobs: key: dependency-cache-{{ checksum "package-lock.json" }} - run: name: Update NPM - command: npm update -g npm + command: sudo npm update -g npm - run: name: Install dependencies command: npm install From 189623a367d7e1ca03d1e965874af47bb5f51bcd Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 17 Jul 2018 12:51:24 -0600 Subject: [PATCH 30/43] fix: move audit up --- .circleci/config.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index e65bc925a..45d18b2db 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -24,12 +24,12 @@ jobs: - run: name: Update NPM command: sudo npm update -g npm - - run: - name: Install dependencies - command: npm install - run: name: Audit dependencies command: npm audit + - run: + name: Install dependencies + command: npm install - save_cache: key: dependency-cache-{{ checksum "package-lock.json" }} paths: From c7feaa344f521b28944f715e1e9257fdcf1ccf79 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 17 Jul 2018 13:51:59 -0600 Subject: [PATCH 31/43] fix: runInBand for CI --- .circleci/config.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 45d18b2db..92a18e11c 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1,6 +1,7 @@ # job_environment will setup the environment for any job being executed. job_environment: &job_environment NODE_ENV: test + CI: true # job_defaults applies all the defaults for each job. job_defaults: &job_defaults @@ -61,7 +62,9 @@ jobs: command: npm run compile - run: name: Perform testing - command: npm run test + # We're running these tests in band to avoid errors where the circleci + # test runner runs out of RAM trying to run them all in parallel. + command: npm run test -- --runInBand # build will build the static assets and server typescript files. build: From 8487f22b8ed19e17e9d004759bad4f2b290a22e4 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 17 Jul 2018 14:09:00 -0600 Subject: [PATCH 32/43] feat: added test results --- .circleci/config.yml | 11 +++++++++-- package-lock.json | 35 +++++++++++++++++++++++++++++++++++ package.json | 21 +++++++++++---------- scripts/test.js | 6 +++++- 4 files changed, 60 insertions(+), 13 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 92a18e11c..dd98d8c41 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1,7 +1,6 @@ # job_environment will setup the environment for any job being executed. job_environment: &job_environment NODE_ENV: test - CI: true # job_defaults applies all the defaults for each job. job_defaults: &job_defaults @@ -53,6 +52,10 @@ jobs: # unit_tests will run the unit tests. unit_tests: <<: *job_defaults + environment: + <<: *job_environment + CI: true + JEST_JUNIT_OUTPUT: "reports/junit/js-test-results.xml" steps: - checkout - attach_workspace: @@ -64,7 +67,11 @@ jobs: name: Perform testing # We're running these tests in band to avoid errors where the circleci # test runner runs out of RAM trying to run them all in parallel. - command: npm run test -- --runInBand + command: npm run test -- --ci --runInBand --testResultsProcessor="jest-junit" + - store_test_results: + path: reports/junit + - store_artifacts: + path: reports/junit # build will build the static assets and server typescript files. build: diff --git a/package-lock.json b/package-lock.json index db8d3ddbc..271d5e135 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12486,6 +12486,35 @@ "pretty-format": "^23.2.0" } }, + "jest-junit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/jest-junit/-/jest-junit-5.1.0.tgz", + "integrity": "sha512-3EVf1puv2ox5wybQDfLX3AEn3IKOgDV4E76y4pO2hBu46DEtAFZZAm//X1pzPQpqKji0zqgMIzqzF/K+uGAX9A==", + "dev": true, + "requires": { + "jest-validate": "^23.0.1", + "mkdirp": "^0.5.1", + "strip-ansi": "^4.0.0", + "xml": "^1.0.1" + }, + "dependencies": { + "ansi-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz", + "integrity": "sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=", + "dev": true + }, + "strip-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz", + "integrity": "sha1-qEeQIusaw2iocTibY1JixQXuNo8=", + "dev": true, + "requires": { + "ansi-regex": "^3.0.0" + } + } + } + }, "jest-leak-detector": { "version": "23.2.0", "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-23.2.0.tgz", @@ -23340,6 +23369,12 @@ "integrity": "sha1-SWsswQnsqNus/i3HK2A8F8WHCtQ=", "dev": true }, + "xml": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz", + "integrity": "sha1-eLpyAgApxbyHuKgaPPzXS0ovweU=", + "dev": true + }, "xml-name-validator": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-3.0.0.tgz", diff --git a/package.json b/package.json index b6a18e136..5f709a7e1 100644 --- a/package.json +++ b/package.json @@ -71,8 +71,8 @@ "@types/convict": "^4.2.0", "@types/cross-spawn": "^6.0.0", "@types/dotenv": "^4.0.3", - "@types/enzyme-adapter-react-16": "^1.0.2", "@types/enzyme": "^3.1.11", + "@types/enzyme-adapter-react-16": "^1.0.2", "@types/express": "^4.16.0", "@types/graphql": "^0.13.3", "@types/ioredis": "^3.2.12", @@ -84,10 +84,10 @@ "@types/luxon": "^0.5.3", "@types/mongodb": "^3.1.1", "@types/node": "^10.5.2", + "@types/passport": "^0.4.5", "@types/passport-local": "^1.0.33", "@types/passport-oauth2": "^1.4.5", "@types/passport-strategy": "^0.2.33", - "@types/passport": "^0.4.5", "@types/query-string": "^6.1.0", "@types/react-dom": "^16.0.6", "@types/react-relay": "github:coralproject/patched#types/react-relay", @@ -113,20 +113,21 @@ "cross-spawn": "^6.0.5", "css-loader": "^0.28.11", "docz": "^0.5.8", + "enzyme": "^3.3.0", "enzyme-adapter-react-16": "^1.1.1", "enzyme-to-json": "^3.3.4", - "enzyme": "^3.3.0", "extract-text-webpack-plugin": "^4.0.0-beta.0", "final-form": "^4.8.1", "flat": "^4.1.0", + "fluent": "^0.6.4", "fluent-intl-polyfill": "^0.1.0", "fluent-langneg": "^0.1.0", "fluent-react": "^0.7.0", - "fluent": "^0.6.4", "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", "jsdom": "^11.11.0", "loader-utils": "^1.1.0", "npm-run-all": "^4.1.3", @@ -143,6 +144,7 @@ "pstree.remy": "^1.1.0", "query-string": "^6.1.0", "raw-loader": "^0.5.1", + "react": "^16.4.0", "react-dev-utils": "6.0.0-next.3e165448", "react-dom": "^16.4.0", "react-final-form": "^3.6.4", @@ -150,10 +152,9 @@ "react-responsive": "^4.1.0", "react-test-renderer": "^16.4.1", "react-timeago": "^4.1.9", - "react": "^16.4.0", "recompose": "^0.27.1", - "relay-compiler-language-typescript": "github:coralproject/patched#relay-compiler-language-typescript", "relay-compiler": "github:coralproject/patched#relay-compiler", + "relay-compiler-language-typescript": "github:coralproject/patched#relay-compiler-language-typescript", "relay-local-schema": "^0.7.0", "relay-runtime": "github:coralproject/patched#relay-runtime", "relay-test-utils": "github:coralproject/patched#relay-test-utils", @@ -163,20 +164,20 @@ "ts-jest": "^23.0.0", "ts-loader": "^4.4.2", "ts-node": "^6.2.0", - "tsconfig-paths-webpack-plugin": "^3.1.4", "tsconfig-paths": "^3.4.2", + "tsconfig-paths-webpack-plugin": "^3.1.4", + "tslint": "^5.10.0", "tslint-config-prettier": "^1.13.0", "tslint-loader": "^3.6.0", "tslint-plugin-prettier": "^1.3.0", "tslint-react": "^3.6.0", - "tslint": "^5.10.0", "typed-css-modules": "^0.3.4", "typescript": "^2.9.2", "uglifyjs-webpack-plugin": "^1.2.5", + "webpack": "4.12.0", "webpack-cli": "^3.0.2", "webpack-dev-server": "^3.1.4", "webpack-hot-client": "^4.1.1", - "webpack-manifest-plugin": "^2.0.3", - "webpack": "4.12.0" + "webpack-manifest-plugin": "^2.0.3" } } diff --git a/scripts/test.js b/scripts/test.js index f08b18de6..59c8a93cc 100644 --- a/scripts/test.js +++ b/scripts/test.js @@ -22,7 +22,11 @@ let argv = process.argv.slice(2); argv.push("--config", paths.appJestConfig); // Watch unless on CI or in coverage mode -if (!process.env.CI && argv.indexOf("--coverage") < 0) { +if ( + !process.env.CI && // ensure that the ci env var is not set. + argv.indexOf("--ci") < 0 && // ensure that the ci flag is not passed + argv.indexOf("--coverage") < 0 // ensure that the coverage flag is not passed +) { argv.push("--watch"); } From a2a0cebb06b650120a3c55c3134f65364e0603d2 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 17 Jul 2018 16:57:36 -0600 Subject: [PATCH 33/43] feat: initial token issuing support --- src/core/server/app/handlers/auth/local.ts | 12 ++- .../server/app/middleware/passport/index.ts | 84 ++++++++++----- .../server/app/middleware/passport/jwt.ts | 100 +++++++++++++++++- src/core/server/app/router.ts | 22 ++-- src/core/server/config.ts | 27 ++++- 5 files changed, 200 insertions(+), 45 deletions(-) diff --git a/src/core/server/app/handlers/auth/local.ts b/src/core/server/app/handlers/auth/local.ts index b1cfd11be..baab6dcd6 100644 --- a/src/core/server/app/handlers/auth/local.ts +++ b/src/core/server/app/handlers/auth/local.ts @@ -2,7 +2,8 @@ import { RequestHandler } from "express"; import Joi from "joi"; import { Db } from "mongodb"; -import { handle } from "talk-server/app/middleware/passport"; +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"; @@ -29,9 +30,10 @@ const SignupDisplayNameBodySchema = SignupBodySchema.keys({ export interface SignupOptions { db: Db; + signingConfig: JWTSigningConfig; } -export const signupHandler = ({ db }: SignupOptions): RequestHandler => async ( +export const signupHandler = (options: SignupOptions): RequestHandler => async ( req: Request, res, next @@ -67,7 +69,7 @@ export const signupHandler = ({ db }: SignupOptions): RequestHandler => async ( }; // Create the new user. - const user = await upsert(db, tenant, { + const user = await upsert(options.db, tenant, { email, username, displayName, @@ -79,8 +81,8 @@ export const signupHandler = ({ db }: SignupOptions): RequestHandler => async ( }); // Send off to the passport handler. - return handle(null, user)(req, res, next); + return handleSuccessfulLogin(user, options.signingConfig, req, res, next); } catch (err) { - return handle(err)(req, res, next); + return next(err); } }; diff --git a/src/core/server/app/middleware/passport/index.ts b/src/core/server/app/middleware/passport/index.ts index 0f24bdeea..40c1bda39 100644 --- a/src/core/server/app/middleware/passport/index.ts +++ b/src/core/server/app/middleware/passport/index.ts @@ -1,7 +1,12 @@ -import { RequestHandler } from "express"; +import { NextFunction, RequestHandler, Response } from "express"; import { Db } from "mongodb"; import passport, { Authenticator } from "passport"; +import { + 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 { User } from "talk-server/models/user"; @@ -32,38 +37,69 @@ export function createPassport({ return auth; } -export const handle = ( - err: Error | null, - user?: User | null -): RequestHandler => (req: Request, res, next) => { - if (err) { - // TODO: wrap error? +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); } +} - if (!user) { - // TODO: replace with better error. - return next(new Error("no user on request")); - } - - // 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! - - // TODO: return the token instead of the user. - res.json({ user }); -}; - +/** + * authenticate 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 authenticate = ( 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) => handle(err, user)(req, res, next) + (err: Error | null, user: User | null) => { + if (err) { + return next(err); + } + if (!user) { + // TODO: (wyattjoh) replace with better error. + return next(new Error("no user on request")); + } + + handleSuccessfulLogin(user, signingConfig, req, res, next); + } )(req, res, next); diff --git a/src/core/server/app/middleware/passport/jwt.ts b/src/core/server/app/middleware/passport/jwt.ts index d0ec3d5ea..e8ec1ebfa 100644 --- a/src/core/server/app/middleware/passport/jwt.ts +++ b/src/core/server/app/middleware/passport/jwt.ts @@ -1,9 +1,14 @@ +import jwt, { SignOptions } from "jsonwebtoken"; +import uuid from "uuid"; + +import { Config } from "talk-server/config"; +import { User } from "talk-server/models/user"; import { Request } from "talk-server/types/express"; -const re = /(\S+)\s+(\S+)/; +const authHeaderRegex = /(\S+)\s+(\S+)/; export function parseAuthHeader(header: string) { - const matches = header.match(re); + const matches = header.match(authHeaderRegex); if (!matches || matches.length < 3) { return null; } @@ -30,3 +35,94 @@ export function extractJWTFromRequest(req: Request) { return null; } + +export enum AsymmetricSigningAlgorithm { + RS256 = "RS256", + RS384 = "RS384", + RS512 = "RS512", + ES256 = "ES256", + ES384 = "ES384", + ES512 = "ES512", +} + +export enum SymmetricSigningAlgorithm { + HS256 = "HS256", + HS384 = "HS384", + HS512 = "HS512", +} + +export type JWTSigningAlgorithm = + | AsymmetricSigningAlgorithm + | SymmetricSigningAlgorithm; + +export interface JWTSigningConfig { + secret: Buffer; + algorithm: JWTSigningAlgorithm; +} + +export function createAsymmetricSigningConfig( + algorithm: AsymmetricSigningAlgorithm, + secret: string +): JWTSigningConfig { + return { + // Secrets have their newlines encoded with newline litterals. + secret: Buffer.from(secret.replace(/\\n/g, "\n")), + algorithm, + }; +} + +export function createSymmetricSigningConfig( + algorithm: SymmetricSigningAlgorithm, + secret: string +): JWTSigningConfig { + return { + secret: new Buffer(secret), + algorithm, + }; +} + +function isSymmetricSigningAlgorithm( + algorithm: string | SymmetricSigningAlgorithm +): algorithm is SymmetricSigningAlgorithm { + return algorithm in SymmetricSigningAlgorithm; +} + +function isAsymmetricSigningAlgorithm( + algorithm: string | AsymmetricSigningAlgorithm +): algorithm is AsymmetricSigningAlgorithm { + return algorithm in AsymmetricSigningAlgorithm; +} + +/** + * Parses the config and provides the signing config. + * + * @param config the server configuration + */ +export function createJWTSigningConfig(config: Config): JWTSigningConfig { + const secret = config.get("signing_secret"); + const algorithm = config.get("signing_algorithm"); + if (isSymmetricSigningAlgorithm(algorithm)) { + return createSymmetricSigningConfig(algorithm, secret); + } else if (isAsymmetricSigningAlgorithm(algorithm)) { + return createAsymmetricSigningConfig(algorithm, secret); + } + + // TODO: (wyattjoh) return better error. + throw new Error("invalid algorithm specified"); +} + +export type SigningTokenOptions = Pick; + +export async function signTokenString( + { algorithm, secret }: JWTSigningConfig, + user: User, + options: SigningTokenOptions +) { + return jwt.sign({}, secret, { + ...options, + jwtid: uuid.v4(), + algorithm, + expiresIn: "1 day", // TODO: (wyattjoh) evalue allowing configuration? + subject: user.id, + }); +} diff --git a/src/core/server/app/router.ts b/src/core/server/app/router.ts index b6af1564f..b9914e5a5 100644 --- a/src/core/server/app/router.ts +++ b/src/core/server/app/router.ts @@ -9,6 +9,7 @@ import tenantMiddleware from "talk-server/app/middleware/tenant"; import managementGraphMiddleware from "talk-server/graph/management/middleware"; import tenantGraphMiddleware from "talk-server/graph/tenant/middleware"; +import { createJWTSigningConfig } from "talk-server/app/middleware/passport/jwt"; import { AppOptions } from "./index"; import playground from "./middleware/playground"; @@ -54,23 +55,26 @@ async function createTenantRouter(app: AppOptions, options: RouterOptions) { function createNewAuthRouter(app: AppOptions, options: RouterOptions) { const router = express.Router(); + // Create the signing config. + const signingConfig = createJWTSigningConfig(app.config); + + // Mount the passport routes. router.post( "/local", express.json(), - authenticate(options.passport, "local") + authenticate(options.passport, signingConfig, "local") ); router.post( "/local/signup", express.json(), - signupHandler({ db: app.mongo }) + signupHandler({ db: app.mongo, signingConfig }) + ); + router.post("/sso", authenticate(options.passport, signingConfig, "sso")); + router.get("/oidc", authenticate(options.passport, signingConfig, "oidc")); + router.get( + "/oidc/callback", + authenticate(options.passport, signingConfig, "oidc") ); - router.post("/sso", authenticate(options.passport, "sso")); - router.get("/oidc", authenticate(options.passport, "oidc")); - router.get("/oidc/callback", authenticate(options.passport, "oidc")); - // router.get("/google", options.passport.authenticate("google")); - // router.get("/google/callback", options.passport.authenticate("google")); - // router.get("/facebook", options.passport.authenticate("facebook")); - // router.get("/facebook/callback", options.passport.authenticate("facebook")); return router; } diff --git a/src/core/server/config.ts b/src/core/server/config.ts index 1954de907..d4b5ca3fc 100644 --- a/src/core/server/config.ts +++ b/src/core/server/config.ts @@ -55,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", From a6bfad0833371f1a3f7edb4ef191188d6c416a20 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Fri, 20 Jul 2018 09:58:40 -0600 Subject: [PATCH 34/43] feat: more JWT tests --- src/core/server/app/middleware/logging.ts | 3 +- .../passport/__snapshots__/jwt.spec.ts.snap | 31 +++++++++++++++++++ .../app/middleware/passport/jwt.spec.ts | 19 ++++++++++++ .../server/app/middleware/passport/jwt.ts | 2 +- 4 files changed, 53 insertions(+), 2 deletions(-) create mode 100644 src/core/server/app/middleware/passport/__snapshots__/jwt.spec.ts.snap diff --git a/src/core/server/app/middleware/logging.ts b/src/core/server/app/middleware/logging.ts index ebe1fe53e..134c19df1 100644 --- a/src/core/server/app/middleware/logging.ts +++ b/src/core/server/app/middleware/logging.ts @@ -1,6 +1,7 @@ import { ErrorRequestHandler, RequestHandler } from "express"; import now from "performance-now"; -import logger from "../../logger"; + +import logger from "talk-server/logger"; export const accessLogger: RequestHandler = (req, res, next) => { const startTime = now(); diff --git a/src/core/server/app/middleware/passport/__snapshots__/jwt.spec.ts.snap b/src/core/server/app/middleware/passport/__snapshots__/jwt.spec.ts.snap new file mode 100644 index 000000000..1e61492d7 --- /dev/null +++ b/src/core/server/app/middleware/passport/__snapshots__/jwt.spec.ts.snap @@ -0,0 +1,31 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`createJWTSigningConfig parses a RSA certiciate 1`] = ` +"-----BEGIN RSA PRIVATE KEY----- +MIIEpQIBAAKCAQEAyxR2DVlvkQRquggUQTpHN+PxDs2iOiItGgn6u4+faUCdgGEV +EnmG69//3lAZHnEQN9rkZS3/20zc41mTJnO7dslJbB316vWUSIwYcVY/VC9DTbk+ +MHWZd94p5hOB8PoY2vEGA53KiyWLqQC5FWE3u7cz7eYTr9/eRPDTc15IzohLXd5U +C9EbO5ebho2CvWrBfrLozM5Kidp8r3Jp+A0o3kfJ/kRDDn/BmG6pM0TohWZFYMs2 +nQaGg+of9tcafgAs7hZAgBrrcc/jke6+MKxpC8algik79nMk7s7prxF1Z9EbAeQV +1ssL2VgsjvGAHIV+Arckl6QJbVDvQXNAM0PqbQIDAQABAoIBAQCoG6D5vf5P8nMS +2ltB/6cyyfsjgO/45Y+mTXqERwj0DOwUeMkDyRv6KCxb8LxKade+FPIaG7D/7amw +fdcE7qrRUyD3YfnPbUk5oNcfAwFbg+BX969WWBMZmgvfDGj1fWKT4w9ScQ1YkFUD +KrkLzLVhK+/N0Dad0VjiguTXTMZCSDFOY9fO8HRF6EA3aewEPeEY62J6rSjGXvWB +GdW+FNvf/uRr36xGHNqiOP837pdVUppjgDyVsORnMfFtYMyWyxS2XD5r8gRwcRg7 +0nz6bLM53DjKweO+Yl+pIVPFAyXL0pwzQDlnjShsCzyzjA9lJftkQwbcMWopeegJ +kPLmiq4VAoGBAOqDmySNx8vmWWMOaXKFuH6Gqu/Nd7gBHxZ73wvsEmvV52xwa0oi +55h+v6P1YEaNZQWXDFsvILoOUHr2kwZY+Du/MC7tgqpj+Fu3h7UHslulJRE3A+sN +oLbHjZuwm3wwsatpHdyEYOGg0HIGWXi+9pDT/1gy8g3L2Gf0X6rfkBBXAoGBAN2v +lbii0+HvZ2y0D0P6NfUJ6cQDrSyuTe7UW6OVYjBjrVAk8+bhnQ4eKd9edCnUDqu6 +9C8ZSrqR6VBeItbt8y+5ZCRcrigxd2VdH8rL9g6idD9RPnSbHx7Al8DxSUv25xMK +8Z/ZOAvuCmwDfdleycNDoTawKqLtWBzUEntLs5DbAoGAPlTKiJWylAxel8h92HWY +SvDqQCChgGOz6prz9sxBPS42e4kJy0OpwMt3jlGqzDXKswipvRayoSEq3PPqshY1 +rFOtr9trDnTRzzbhuAkaq+ciCghQX0pY/BvgFJCFUyXyIzgmOrVotq+yl4v+fexr +xqTCSqQH2AjlNQQr5VPUi7MCgYEAsNbbMXE6YlXug+lS8CANoM3qm4FvSGA3LNhb +za9hp0YsP+1qXvgEp/lp35RiR+ewWE+HcHbVhOTWYFTnp9ojDyPtfZAtIUTsgIB7 +1vNC8kOnRccSckQ32/k4VSJlHOL1S9yECMZnjiSyTZ2va5HQkyJE3PJE4LlCe6S0 +pYQq1tcCgYEAoJDeSeAPqi5NIu+MWNUWzw4vo5raKyHrJi+cTvKyM/2zJFHvBc5f +RaxkcIAOmIDoVdFgy6APY/0DnDnpqT1kMagUaxZjG9PLFIDds5DRaL99m+S7l8mt +ySX/MbmhQHYWpVf2nL6pmfPuP4Ih6tbKIUUGA3wZXYYZ5r+pZFG1IrA= +-----END RSA PRIVATE KEY-----" +`; diff --git a/src/core/server/app/middleware/passport/jwt.spec.ts b/src/core/server/app/middleware/passport/jwt.spec.ts index 22ea07405..fa7b87d9d 100644 --- a/src/core/server/app/middleware/passport/jwt.spec.ts +++ b/src/core/server/app/middleware/passport/jwt.spec.ts @@ -1,9 +1,11 @@ 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", () => { @@ -63,3 +65,20 @@ describe("extractJWTFromRequest", () => { expect(req.get.calledOnce).toBeTruthy(); }); }); + +describe("createJWTSigningConfig", () => { + it("parses a RSA certiciate", () => { + const input = `-----BEGIN RSA PRIVATE KEY-----\\nMIIEpQIBAAKCAQEAyxR2DVlvkQRquggUQTpHN+PxDs2iOiItGgn6u4+faUCdgGEV\\nEnmG69//3lAZHnEQN9rkZS3/20zc41mTJnO7dslJbB316vWUSIwYcVY/VC9DTbk+\\nMHWZd94p5hOB8PoY2vEGA53KiyWLqQC5FWE3u7cz7eYTr9/eRPDTc15IzohLXd5U\\nC9EbO5ebho2CvWrBfrLozM5Kidp8r3Jp+A0o3kfJ/kRDDn/BmG6pM0TohWZFYMs2\\nnQaGg+of9tcafgAs7hZAgBrrcc/jke6+MKxpC8algik79nMk7s7prxF1Z9EbAeQV\\n1ssL2VgsjvGAHIV+Arckl6QJbVDvQXNAM0PqbQIDAQABAoIBAQCoG6D5vf5P8nMS\\n2ltB/6cyyfsjgO/45Y+mTXqERwj0DOwUeMkDyRv6KCxb8LxKade+FPIaG7D/7amw\\nfdcE7qrRUyD3YfnPbUk5oNcfAwFbg+BX969WWBMZmgvfDGj1fWKT4w9ScQ1YkFUD\\nKrkLzLVhK+/N0Dad0VjiguTXTMZCSDFOY9fO8HRF6EA3aewEPeEY62J6rSjGXvWB\\nGdW+FNvf/uRr36xGHNqiOP837pdVUppjgDyVsORnMfFtYMyWyxS2XD5r8gRwcRg7\\n0nz6bLM53DjKweO+Yl+pIVPFAyXL0pwzQDlnjShsCzyzjA9lJftkQwbcMWopeegJ\\nkPLmiq4VAoGBAOqDmySNx8vmWWMOaXKFuH6Gqu/Nd7gBHxZ73wvsEmvV52xwa0oi\\n55h+v6P1YEaNZQWXDFsvILoOUHr2kwZY+Du/MC7tgqpj+Fu3h7UHslulJRE3A+sN\\noLbHjZuwm3wwsatpHdyEYOGg0HIGWXi+9pDT/1gy8g3L2Gf0X6rfkBBXAoGBAN2v\\nlbii0+HvZ2y0D0P6NfUJ6cQDrSyuTe7UW6OVYjBjrVAk8+bhnQ4eKd9edCnUDqu6\\n9C8ZSrqR6VBeItbt8y+5ZCRcrigxd2VdH8rL9g6idD9RPnSbHx7Al8DxSUv25xMK\\n8Z/ZOAvuCmwDfdleycNDoTawKqLtWBzUEntLs5DbAoGAPlTKiJWylAxel8h92HWY\\nSvDqQCChgGOz6prz9sxBPS42e4kJy0OpwMt3jlGqzDXKswipvRayoSEq3PPqshY1\\nrFOtr9trDnTRzzbhuAkaq+ciCghQX0pY/BvgFJCFUyXyIzgmOrVotq+yl4v+fexr\\nxqTCSqQH2AjlNQQr5VPUi7MCgYEAsNbbMXE6YlXug+lS8CANoM3qm4FvSGA3LNhb\\nza9hp0YsP+1qXvgEp/lp35RiR+ewWE+HcHbVhOTWYFTnp9ojDyPtfZAtIUTsgIB7\\n1vNC8kOnRccSckQ32/k4VSJlHOL1S9yECMZnjiSyTZ2va5HQkyJE3PJE4LlCe6S0\\npYQq1tcCgYEAoJDeSeAPqi5NIu+MWNUWzw4vo5raKyHrJi+cTvKyM/2zJFHvBc5f\\nRaxkcIAOmIDoVdFgy6APY/0DnDnpqT1kMagUaxZjG9PLFIDds5DRaL99m+S7l8mt\\nySX/MbmhQHYWpVf2nL6pmfPuP4Ih6tbKIUUGA3wZXYYZ5r+pZFG1IrA=\\n-----END RSA PRIVATE KEY-----`; + const config = { + get: sinon.stub(), + }; + + config.get.withArgs("signing_secret").returns(input); + config.get.withArgs("signing_algorithm").returns("RS256"); + + const signingConfig = createJWTSigningConfig((config as any) as Config); + + expect(signingConfig.algorithm).toEqual("RS256"); + expect(signingConfig.secret.toString()).toMatchSnapshot(); + }); +}); diff --git a/src/core/server/app/middleware/passport/jwt.ts b/src/core/server/app/middleware/passport/jwt.ts index e8ec1ebfa..78d8d6c64 100644 --- a/src/core/server/app/middleware/passport/jwt.ts +++ b/src/core/server/app/middleware/passport/jwt.ts @@ -122,7 +122,7 @@ export async function signTokenString( ...options, jwtid: uuid.v4(), algorithm, - expiresIn: "1 day", // TODO: (wyattjoh) evalue allowing configuration? + expiresIn: "1 day", // TODO: (wyattjoh) evaluate allowing configuration? subject: user.id, }); } From bdd4bfc272e5594b75186b5d663021eac3e4d93d Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Fri, 20 Jul 2018 14:30:07 -0600 Subject: [PATCH 35/43] feat: initial passport user auth impl --- src/core/server/app/index.ts | 13 +++- .../server/app/middleware/passport/index.ts | 10 ++- .../server/app/middleware/passport/jwt.ts | 77 ++++++++++++++++++- .../server/app/middleware/passport/sso.ts | 4 +- src/core/server/app/router.ts | 19 +++-- src/core/server/graph/tenant/context.ts | 2 + .../server/graph/tenant/resolvers/query.ts | 3 +- src/core/server/index.ts | 6 ++ 8 files changed, 116 insertions(+), 18 deletions(-) diff --git a/src/core/server/app/index.ts b/src/core/server/app/index.ts index c5dbd81a1..4594c9e81 100644 --- a/src/core/server/app/index.ts +++ b/src/core/server/app/index.ts @@ -5,6 +5,7 @@ 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"; @@ -19,6 +20,7 @@ export interface AppOptions { mongo: Db; redis: Redis; schemas: Schemas; + signingConfig: JWTSigningConfig; } /** @@ -32,10 +34,17 @@ export async function createApp(options: AppOptions): Promise { parent.use(accessLogger); // Create some services for the router. - const passport = createPassport({ db: options.mongo }); + const passport = createPassport({ + db: options.mongo, + signingConfig: options.signingConfig, + }); // Mount the router. - parent.use(await createRouter(options, { passport })); + parent.use( + await createRouter(options, { + passport, + }) + ); // Static Files parent.use(serveStatic); diff --git a/src/core/server/app/middleware/passport/index.ts b/src/core/server/app/middleware/passport/index.ts index 40c1bda39..8ef4a7711 100644 --- a/src/core/server/app/middleware/passport/index.ts +++ b/src/core/server/app/middleware/passport/index.ts @@ -3,6 +3,7 @@ import { Db } from "mongodb"; import passport, { Authenticator } from "passport"; import { + createJWTStrategy, JWTSigningConfig, SigningTokenOptions, signTokenString, @@ -20,10 +21,12 @@ export type VerifyCallback = ( export interface PassportOptions { db: Db; + signingConfig: JWTSigningConfig; } export function createPassport({ db, + signingConfig, }: PassportOptions): passport.Authenticator { // Create the authenticator. const auth = new Authenticator(); @@ -34,6 +37,9 @@ export function createPassport({ // Use the LocalStrategy. auth.use(createLocalStrategy({ db })); + // Use the JWTStrategy. + auth.use(createJWTStrategy({ db, signingConfig })); + return auth; } @@ -74,7 +80,7 @@ export async function handleSuccessfulLogin( } /** - * authenticate will wrap a authenticators authenticate method with one that + * wrapAuthz 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 @@ -82,7 +88,7 @@ export async function handleSuccessfulLogin( * @param name the name of the authenticator to use * @param options any options to be passed to the authenticate call */ -export const authenticate = ( +export const wrapAuthz = ( authenticator: passport.Authenticator, signingConfig: JWTSigningConfig, name: string, diff --git a/src/core/server/app/middleware/passport/jwt.ts b/src/core/server/app/middleware/passport/jwt.ts index 78d8d6c64..be5c492e4 100644 --- a/src/core/server/app/middleware/passport/jwt.ts +++ b/src/core/server/app/middleware/passport/jwt.ts @@ -1,8 +1,10 @@ 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 { User } from "talk-server/models/user"; +import { retrieveUser, User } from "talk-server/models/user"; import { Request } from "talk-server/types/express"; const authHeaderRegex = /(\S+)\s+(\S+)/; @@ -126,3 +128,76 @@ export async function signTokenString( 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) { + 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"), 401); + } + + jwt.verify( + token, + // Use the secret specified in the configuration. + this.signingConfig.secret, + { + // We need to verify that the token is for the specified tenant. + issuer: tenant.id, + // Use the algorithm specified in the configuration. + algorithms: [this.signingConfig.algorithm], + }, + async (err: Error | undefined, { sub }: JWTToken) => { + if (err) { + return this.fail(err, 401); + } + + try { + // Find the user. + const user = await retrieveUser(this.db, tenant.id, sub); + + // Return them! The user may be null, but that's ok here. + this.success(user, null); + } catch (err) { + return this.error(err); + } + } + ); + } +} + +export function createJWTStrategy(options: JWTStrategyOptions) { + return new JWTStrategy(options); +} diff --git a/src/core/server/app/middleware/passport/sso.ts b/src/core/server/app/middleware/passport/sso.ts index b5699114d..e009cf747 100644 --- a/src/core/server/app/middleware/passport/sso.ts +++ b/src/core/server/app/middleware/passport/sso.ts @@ -182,7 +182,7 @@ export default class SSOStrategy extends Strategy { */ private wrapNewTokenHandler = (tenant: Tenant) => async ( err: Error | undefined, - decoded: OIDCIDToken | SSOToken + token: OIDCIDToken | SSOToken ) => { if (err) { return this.fail(err, 401); @@ -190,7 +190,7 @@ export default class SSOStrategy extends Strategy { try { // Find or create the user based on the decoded token. - const user = await this.findOrCreateUser(tenant, decoded); + const user = await this.findOrCreateUser(tenant, token); // The user was found or created! return this.success(user, null); diff --git a/src/core/server/app/router.ts b/src/core/server/app/router.ts index b9914e5a5..9467e4caf 100644 --- a/src/core/server/app/router.ts +++ b/src/core/server/app/router.ts @@ -4,12 +4,11 @@ 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 { authenticate } from "talk-server/app/middleware/passport"; +import { wrapAuthz } 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"; -import { createJWTSigningConfig } from "talk-server/app/middleware/passport/jwt"; import { AppOptions } from "./index"; import playground from "./middleware/playground"; @@ -46,6 +45,9 @@ async function createTenantRouter(app: AppOptions, options: RouterOptions) { router.use( "/graphql", express.json(), + // 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) ); @@ -55,25 +57,22 @@ async function createTenantRouter(app: AppOptions, options: RouterOptions) { function createNewAuthRouter(app: AppOptions, options: RouterOptions) { const router = express.Router(); - // Create the signing config. - const signingConfig = createJWTSigningConfig(app.config); - // Mount the passport routes. router.post( "/local", express.json(), - authenticate(options.passport, signingConfig, "local") + wrapAuthz(options.passport, app.signingConfig, "local") ); router.post( "/local/signup", express.json(), - signupHandler({ db: app.mongo, signingConfig }) + signupHandler({ db: app.mongo, signingConfig: app.signingConfig }) ); - router.post("/sso", authenticate(options.passport, signingConfig, "sso")); - router.get("/oidc", authenticate(options.passport, signingConfig, "oidc")); + router.post("/sso", wrapAuthz(options.passport, app.signingConfig, "sso")); + router.get("/oidc", wrapAuthz(options.passport, app.signingConfig, "oidc")); router.get( "/oidc/callback", - authenticate(options.passport, signingConfig, "oidc") + wrapAuthz(options.passport, app.signingConfig, "oidc") ); return router; diff --git a/src/core/server/graph/tenant/context.ts b/src/core/server/graph/tenant/context.ts index 21e2dc9b9..0f51eb529 100644 --- a/src/core/server/graph/tenant/context.ts +++ b/src/core/server/graph/tenant/context.ts @@ -15,12 +15,14 @@ export default class TenantContext extends CommonContext { public loaders: ReturnType; public mutators: ReturnType; public db: Db; + public user?: User; public tenant: Tenant; constructor({ user, tenant, db }: TenantContextOptions) { super({ user }); this.tenant = tenant; + this.user = user; this.loaders = loaders(this); this.mutators = mutators(this); this.db = db; diff --git a/src/core/server/graph/tenant/resolvers/query.ts b/src/core/server/graph/tenant/resolvers/query.ts index 56d99e1fe..5dbd4bccc 100644 --- a/src/core/server/graph/tenant/resolvers/query.ts +++ b/src/core/server/graph/tenant/resolvers/query.ts @@ -2,7 +2,8 @@ import { GQLQueryTypeResolver } from "talk-server/graph/tenant/schema/__generate const Query: GQLQueryTypeResolver = { asset: (source, args, ctx) => ctx.loaders.Assets.findOrCreate(args), - settings: (parent, args, ctx) => ctx.tenant, + settings: (source, args, ctx) => ctx.tenant, + me: (source, args, ctx) => ctx.user, }; export default Query; diff --git a/src/core/server/index.ts b/src/core/server/index.ts index fdf4f3aea..56e92f7f7 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -1,9 +1,11 @@ import express, { Express } from "express"; import http from "http"; +import { createJWTSigningConfig } from "talk-server/app/middleware/passport/jwt"; import getManagementSchema from "talk-server/graph/management/schema"; import { Schemas } from "talk-server/graph/schemas"; import getTenantSchema from "talk-server/graph/tenant/schema"; + import { attachSubscriptionHandlers, createApp, listenAndServe } from "./app"; import config, { Config } from "./config"; import logger from "./logger"; @@ -63,6 +65,9 @@ class Server { // Setup Redis. const redis = await createRedisClient(config); + // Create the signing config. + const signingConfig = createJWTSigningConfig(this.config); + // Create the Talk App, branching off from the parent app. const app: Express = await createApp({ parent, @@ -70,6 +75,7 @@ class Server { redis, config: this.config, schemas: this.schemas, + signingConfig, }); // Start the application and store the resulting http.Server. From 2c1111109a56140c84a2ffa6ee9fd5a8ef1e18dd Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Fri, 20 Jul 2018 16:19:33 -0600 Subject: [PATCH 36/43] fix: support not logged in users --- src/core/server/app/middleware/passport/jwt.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/core/server/app/middleware/passport/jwt.ts b/src/core/server/app/middleware/passport/jwt.ts index be5c492e4..b47bd06dd 100644 --- a/src/core/server/app/middleware/passport/jwt.ts +++ b/src/core/server/app/middleware/passport/jwt.ts @@ -156,19 +156,20 @@ export class JWTStrategy extends Strategy { } 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")); } - // Lookup the token. - const token = extractJWTFromRequest(req); - if (!token) { - // TODO: (wyattjoh) return a better error. - return this.fail(new Error("no token on request"), 401); - } - jwt.verify( token, // Use the secret specified in the configuration. From 0a0f7ce2165bb7c02ed741938bfba148046e790f Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Fri, 20 Jul 2018 16:26:35 -0600 Subject: [PATCH 37/43] fix: marked username as nullable --- src/core/server/graph/tenant/schema/schema.graphql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/server/graph/tenant/schema/schema.graphql b/src/core/server/graph/tenant/schema/schema.graphql index a3085b32f..f53f642eb 100644 --- a/src/core/server/graph/tenant/schema/schema.graphql +++ b/src/core/server/graph/tenant/schema/schema.graphql @@ -361,7 +361,7 @@ 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. From 558f81c9aa24ff25cff534d6434f99aa72a6e207 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Fri, 20 Jul 2018 16:38:03 -0600 Subject: [PATCH 38/43] fix: sso strategy --- .../server/app/middleware/passport/index.ts | 4 ++ .../server/app/middleware/passport/sso.ts | 43 +++++++++---------- 2 files changed, 24 insertions(+), 23 deletions(-) diff --git a/src/core/server/app/middleware/passport/index.ts b/src/core/server/app/middleware/passport/index.ts index 8ef4a7711..755b7f5ed 100644 --- a/src/core/server/app/middleware/passport/index.ts +++ b/src/core/server/app/middleware/passport/index.ts @@ -10,6 +10,7 @@ import { } 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"; @@ -37,6 +38,9 @@ export function createPassport({ // Use the LocalStrategy. auth.use(createLocalStrategy({ db })); + // Use the SSOStrategy. + auth.use(createSSOStrategy({ db })); + // Use the JWTStrategy. auth.use(createJWTStrategy({ db, signingConfig })); diff --git a/src/core/server/app/middleware/passport/sso.ts b/src/core/server/app/middleware/passport/sso.ts index e009cf747..cd50a67de 100644 --- a/src/core/server/app/middleware/passport/sso.ts +++ b/src/core/server/app/middleware/passport/sso.ts @@ -177,28 +177,6 @@ export default class SSOStrategy extends Strategy { return findOrCreateSSOUser(this.db, tenant, token); } - /** - * wrapNewTokenHandler wraps the token handling with a promise. - */ - private wrapNewTokenHandler = (tenant: Tenant) => async ( - err: Error | undefined, - token: OIDCIDToken | SSOToken - ) => { - if (err) { - return this.fail(err, 401); - } - - try { - // Find or create the user based on the decoded token. - const user = await this.findOrCreateUser(tenant, token); - - // The user was found or created! - return this.success(user, null); - } catch (err) { - return this.error(err); - } - }; - public authenticate(req: Request) { const { tenant } = req; if (!tenant) { @@ -222,7 +200,26 @@ export default class SSOStrategy extends Strategy { // out in the future.. algorithms: ["HS256"], // TODO: (wyattjoh) investigate replacing algorithm. }, - this.wrapNewTokenHandler(tenant) + 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); +} From 81e8f68e62db70ce24084db255c6e8f2148e4f84 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Mon, 23 Jul 2018 10:08:12 -0600 Subject: [PATCH 39/43] fix: upgrade @types/passport --- package-lock.json | 6 +++--- package.json | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4d50f11b4..417b255dd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1793,9 +1793,9 @@ } }, "@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": "*" diff --git a/package.json b/package.json index 2cc25ebd6..5b017e3e9 100644 --- a/package.json +++ b/package.json @@ -84,7 +84,7 @@ "@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", From 4b5c7c1c7b0887e2fb42bf3687e7b6cda9ccf709 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Mon, 23 Jul 2018 13:14:34 -0600 Subject: [PATCH 40/43] fix: move displayName into auth integrations --- src/core/server/app/handlers/auth/local.ts | 19 ++++--------------- .../server/app/middleware/passport/oidc.ts | 2 +- .../server/app/middleware/passport/sso.ts | 2 +- src/core/server/models/tenant.ts | 15 ++++++++++----- 4 files changed, 16 insertions(+), 22 deletions(-) diff --git a/src/core/server/app/handlers/auth/local.ts b/src/core/server/app/handlers/auth/local.ts index baab6dcd6..0c438ccf3 100644 --- a/src/core/server/app/handlers/auth/local.ts +++ b/src/core/server/app/handlers/auth/local.ts @@ -23,11 +23,6 @@ const SignupBodySchema = Joi.object().keys({ email: Joi.string().trim(), }); -// Extends the default signup body schema to allow the displayName to be set. -const SignupDisplayNameBodySchema = SignupBodySchema.keys({ - displayName: Joi.string().trim(), -}); - export interface SignupOptions { db: Db; signingConfig: JWTSigningConfig; @@ -50,15 +45,10 @@ export const signupHandler = (options: SignupOptions): RequestHandler => async ( return next(new Error("integration is disabled")); } - // Get the fields from the body. We condition on the display name being - // enabled to allow the display name to be stripped in the event that the - // display name is not enabled, yielding a displayName being `undefined`, - // which will not be set in the resultant document. Validate will throw an - // error if the body does not conform to the specification. - const { username, password, email, displayName }: SignupBody = validate( - tenant.auth.displayNameEnable - ? SignupDisplayNameBodySchema - : SignupBodySchema, + // 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 ); @@ -72,7 +62,6 @@ export const signupHandler = (options: SignupOptions): RequestHandler => async ( const user = await upsert(options.db, tenant, { email, username, - displayName, password, profiles: [profile], // New users signing up via local auth will have the commenter role to diff --git a/src/core/server/app/middleware/passport/oidc.ts b/src/core/server/app/middleware/passport/oidc.ts index 35982e0a1..2bbe16a30 100644 --- a/src/core/server/app/middleware/passport/oidc.ts +++ b/src/core/server/app/middleware/passport/oidc.ts @@ -131,7 +131,7 @@ export async function findOrCreateOIDCUser( name, nickname, }: OIDCIDToken = validate( - tenant.auth.displayNameEnable + tenant.auth.integrations.oidc!.displayNameEnable ? OIDCDisplayNameIDTokenSchema : OIDCIDTokenSchema, token diff --git a/src/core/server/app/middleware/passport/sso.ts b/src/core/server/app/middleware/passport/sso.ts index cd50a67de..4e6903139 100644 --- a/src/core/server/app/middleware/passport/sso.ts +++ b/src/core/server/app/middleware/passport/sso.ts @@ -57,7 +57,7 @@ export async function findOrCreateSSOUser( // Unpack/validate the token content. const { id, email, username, displayName, avatar }: SSOUserProfile = validate( - tenant.auth.displayNameEnable + tenant.auth.integrations.sso!.displayNameEnable ? SSODisplayNameUserProfileSchema : SSOUserProfileSchema, token.user diff --git a/src/core/server/models/tenant.ts b/src/core/server/models/tenant.ts index 0b14a46c0..bc19c6553 100644 --- a/src/core/server/models/tenant.ts +++ b/src/core/server/models/tenant.ts @@ -58,16 +58,24 @@ 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 { +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 { +export interface OIDCAuthIntegration + extends AuthIntegration, + DisplayNameAuthIntegration { clientID: string; clientSecret: string; issuer: string; @@ -108,7 +116,6 @@ export interface AuthIntegrations { export interface Auth { integrations: AuthIntegrations; - displayNameEnable: boolean; } // Tenant definition. @@ -194,8 +201,6 @@ export async function createTenant(db: Db, input: CreateTenantInput) { banned: [], }, auth: { - // Disable the displayName by default. - displayNameEnable: false, integrations: { local: { enabled: true, From 1ec1572efbb2e6b0df397a1f92d332ff0318e8af Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Mon, 23 Jul 2018 13:33:44 -0600 Subject: [PATCH 41/43] fix: updated client code to respect missing usernames --- .../client/stream/components/Comment/Comment.tsx | 5 +++-- .../stream/containers/CommentContainer.spec.tsx | 15 +++++++++++++++ .../__snapshots__/CommentContainer.spec.tsx.snap | 12 ++++++++++++ 3 files changed, 30 insertions(+), 2 deletions(-) diff --git a/src/core/client/stream/components/Comment/Comment.tsx b/src/core/client/stream/components/Comment/Comment.tsx index f923e4149..03a947a5a 100644 --- a/src/core/client/stream/components/Comment/Comment.tsx +++ b/src/core/client/stream/components/Comment/Comment.tsx @@ -9,7 +9,7 @@ import Username from "./Username"; export interface CommentProps { author: { - username: string; + username: string | null; } | null; body: string | null; createdAt: string; @@ -19,7 +19,8 @@ const Comment: StatelessComponent = props => { return (
- {props.author && {props.author.username}} + {props.author && + props.author.username && {props.author.username}} {props.createdAt} {props.body} diff --git a/src/core/client/stream/containers/CommentContainer.spec.tsx b/src/core/client/stream/containers/CommentContainer.spec.tsx index 98612be38..4836991b8 100644 --- a/src/core/client/stream/containers/CommentContainer.spec.tsx +++ b/src/core/client/stream/containers/CommentContainer.spec.tsx @@ -19,3 +19,18 @@ it("renders username and body", () => { const wrapper = shallow(); expect(wrapper).toMatchSnapshot(); }); + +it("renders body only", () => { + const props: PropTypesOf = { + data: { + author: { + username: null, + }, + body: "Woof", + createdAt: "1995-12-17T03:24:00.000Z", + }, + }; + + const wrapper = shallow(); + expect(wrapper).toMatchSnapshot(); +}); diff --git a/src/core/client/stream/containers/__snapshots__/CommentContainer.spec.tsx.snap b/src/core/client/stream/containers/__snapshots__/CommentContainer.spec.tsx.snap index 9e2476f37..f6ec9754b 100644 --- a/src/core/client/stream/containers/__snapshots__/CommentContainer.spec.tsx.snap +++ b/src/core/client/stream/containers/__snapshots__/CommentContainer.spec.tsx.snap @@ -1,5 +1,17 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`renders body only 1`] = ` + +`; + exports[`renders username and body 1`] = ` Date: Wed, 1 Aug 2018 15:52:56 -0600 Subject: [PATCH 42/43] fix: resolved schema error --- .../server/graph/tenant/schema/schema.graphql | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/core/server/graph/tenant/schema/schema.graphql b/src/core/server/graph/tenant/schema/schema.graphql index f53f642eb..d8f082ff7 100644 --- a/src/core/server/graph/tenant/schema/schema.graphql +++ b/src/core/server/graph/tenant/schema/schema.graphql @@ -76,6 +76,12 @@ type LocalAuthIntegration { type SSOAuthIntegrationConfig { key: String! + + """ + displayNameEnable when enabled, will allow Users to set and view their + displayName's. + """ + displayNameEnable: Boolean! } type SSOAuthIntegration { @@ -92,6 +98,12 @@ type OIDCAuthIntegrationConfig { clientSecret: String! authorizationURL: String! tokenURL: String! + + """ + displayNameEnable when enabled, will allow Users to set and view their + displayName's. + """ + displayNameEnable: Boolean! } type OIDCAuthIntegrationOptions { @@ -145,12 +157,6 @@ AuthSettings contains all the settings related to authentication and authorization. """ type AuthSettings { - """ - displayNameEnable when enabled, will allow Users to set and view their - displayName's. - """ - displayNameEnable: Boolean! - """ integrations are the set of configurations for the variations of authentication solutions. From 201e5fbcb1bb1529d0ea99339f1b893a4aaa8bcb Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Wed, 1 Aug 2018 15:56:04 -0600 Subject: [PATCH 43/43] fix: rename wrapAuthz with wrapAuthn --- src/core/server/app/middleware/passport/index.ts | 4 ++-- src/core/server/app/router.ts | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/core/server/app/middleware/passport/index.ts b/src/core/server/app/middleware/passport/index.ts index 755b7f5ed..d9d6768f3 100644 --- a/src/core/server/app/middleware/passport/index.ts +++ b/src/core/server/app/middleware/passport/index.ts @@ -84,7 +84,7 @@ export async function handleSuccessfulLogin( } /** - * wrapAuthz will wrap a authenticators authenticate method with one that + * 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 @@ -92,7 +92,7 @@ export async function handleSuccessfulLogin( * @param name the name of the authenticator to use * @param options any options to be passed to the authenticate call */ -export const wrapAuthz = ( +export const wrapAuthn = ( authenticator: passport.Authenticator, signingConfig: JWTSigningConfig, name: string, diff --git a/src/core/server/app/router.ts b/src/core/server/app/router.ts index 9467e4caf..085b9e4f9 100644 --- a/src/core/server/app/router.ts +++ b/src/core/server/app/router.ts @@ -4,7 +4,7 @@ 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 { wrapAuthz } from "talk-server/app/middleware/passport"; +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"; @@ -61,18 +61,18 @@ function createNewAuthRouter(app: AppOptions, options: RouterOptions) { router.post( "/local", express.json(), - wrapAuthz(options.passport, app.signingConfig, "local") + wrapAuthn(options.passport, app.signingConfig, "local") ); router.post( "/local/signup", express.json(), signupHandler({ db: app.mongo, signingConfig: app.signingConfig }) ); - router.post("/sso", wrapAuthz(options.passport, app.signingConfig, "sso")); - router.get("/oidc", wrapAuthz(options.passport, app.signingConfig, "oidc")); + router.post("/sso", wrapAuthn(options.passport, app.signingConfig, "sso")); + router.get("/oidc", wrapAuthn(options.passport, app.signingConfig, "oidc")); router.get( "/oidc/callback", - wrapAuthz(options.passport, app.signingConfig, "oidc") + wrapAuthn(options.passport, app.signingConfig, "oidc") ); return router;