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",