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.