diff --git a/src/core/server/app/handlers/auth/local.ts b/src/core/server/app/handlers/auth/local.ts index 25688d25a..505444c7c 100644 --- a/src/core/server/app/handlers/auth/local.ts +++ b/src/core/server/app/handlers/auth/local.ts @@ -7,10 +7,10 @@ import { handleLogout, handleSuccessfulLogin, } from "talk-server/app/middleware/passport"; -import { JWTSigningConfig } from "talk-server/app/middleware/passport/jwt"; import { validate } from "talk-server/app/request/body"; import { GQLUSER_ROLE } from "talk-server/graph/tenant/schema/__generated__/types"; import { LocalProfile } from "talk-server/models/user"; +import { JWTSigningConfig } from "talk-server/services/jwt"; import { upsert } from "talk-server/services/users"; import { Request } from "talk-server/types/express"; diff --git a/src/core/server/app/index.ts b/src/core/server/app/index.ts index 9c418db21..034f6bbd6 100644 --- a/src/core/server/app/index.ts +++ b/src/core/server/app/index.ts @@ -7,16 +7,16 @@ import nunjucks from "nunjucks"; import path from "path"; import { Config } from "talk-common/config"; +import { cacheHeadersMiddleware } from "talk-server/app/middleware/cacheHeaders"; +import { errorHandler } from "talk-server/app/middleware/error"; 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 { handleSubscriptions } from "talk-server/graph/common/subscriptions/middleware"; import { Schemas } from "talk-server/graph/schemas"; +import { JWTSigningConfig } from "talk-server/services/jwt"; import { TaskQueue } from "talk-server/services/queue"; import TenantCache from "talk-server/services/tenant/cache"; -import { cacheHeadersMiddleware } from "talk-server/app/middleware/cacheHeaders"; -import { errorHandler } from "talk-server/app/middleware/error"; import { accessLogger, errorLogger } from "./middleware/logging"; import serveStatic from "./middleware/serveStatic"; import { createRouter } from "./router"; diff --git a/src/core/server/app/middleware/passport/index.ts b/src/core/server/app/middleware/passport/index.ts index b5f06ba03..865e4d61b 100644 --- a/src/core/server/app/middleware/passport/index.ts +++ b/src/core/server/app/middleware/passport/index.ts @@ -6,19 +6,18 @@ import { Db } from "mongodb"; import passport, { Authenticator } from "passport"; import { Config } from "talk-common/config"; +import { JWTStrategy } from "talk-server/app/middleware/passport/strategies/jwt"; +import { createLocalStrategy } from "talk-server/app/middleware/passport/strategies/local"; +import OIDCStrategy from "talk-server/app/middleware/passport/strategies/oidc"; +import { validate } from "talk-server/app/request/body"; +import { User } from "talk-server/models/user"; import { blacklistJWT, - createJWTStrategy, extractJWTFromRequest, JWTSigningConfig, SigningTokenOptions, signTokenString, -} from "talk-server/app/middleware/passport/jwt"; -import { createLocalStrategy } from "talk-server/app/middleware/passport/local"; -import { createOIDCStrategy } from "talk-server/app/middleware/passport/oidc"; -import { createSSOStrategy } from "talk-server/app/middleware/passport/sso"; -import { validate } from "talk-server/app/request/body"; -import { User } from "talk-server/models/user"; +} from "talk-server/services/jwt"; import TenantCache from "talk-server/services/tenant/cache"; import { Request } from "talk-server/types/express"; @@ -42,17 +41,14 @@ export function createPassport( // Create the authenticator. const auth = new Authenticator(); - // Use the OIDC Strategy. - auth.use(createOIDCStrategy(options)); - // Use the LocalStrategy. auth.use(createLocalStrategy(options)); - // Use the SSOStrategy. - auth.use(createSSOStrategy(options)); + // Use the OIDC Strategy. + auth.use(new OIDCStrategy(options)); - // Use the JWTStrategy. - auth.use(createJWTStrategy(options)); + // Use the SSOStrategy. + auth.use(new JWTStrategy(options)); return auth; } @@ -114,9 +110,6 @@ export async function handleSuccessfulLogin( 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. diff --git a/src/core/server/app/middleware/passport/sso.ts b/src/core/server/app/middleware/passport/sso.ts deleted file mode 100644 index 066f2d139..000000000 --- a/src/core/server/app/middleware/passport/sso.ts +++ /dev/null @@ -1,224 +0,0 @@ -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 { - mongo: Db; -} - -export interface SSOUserProfile { - id: string; - email: string; - username: string; - avatar?: string; - displayName?: string; -} - -export interface SSOToken { - user: SSOUserProfile; -} - -export const SSOUserProfileSchema = Joi.object() - .keys({ - id: Joi.string(), - email: Joi.string(), - username: Joi.string(), - avatar: Joi.string().default(undefined), - }) - .optionalKeys(["avatar"]); - -export const SSODisplayNameUserProfileSchema = SSOUserProfileSchema.keys({ - displayName: Joi.string().default(undefined), -}).optionalKeys(["displayName"]); - -export async function findOrCreateSSOUser( - db: Db, - tenant: Tenant, - token: SSOToken -) { - if (!token.user) { - // TODO: (wyattjoh) replace with better error. - throw new Error("token is malformed, missing user claim"); - } - - // Unpack/validate the token content. - const { id, email, username, displayName, avatar }: SSOUserProfile = validate( - tenant.auth.integrations.sso!.displayNameEnable - ? SSODisplayNameUserProfileSchema - : SSOUserProfileSchema, - token.user - ); - - const profile: SSOProfile = { - type: "sso", - id, - }; - - // Try to lookup user given their id provided in the `sub` claim. - let user = await retrieveUserWithProfile(db, tenant.id, profile); - if (!user) { - // FIXME: (wyattjoh) implement rules! Not all users should be able to create an account via this method. - - // Create the new user, as one didn't exist before! - user = await upsert(db, tenant, { - username, - // When the displayName is disabled on the tenant, the displayName will - // never be set (or even stored in the database). - displayName, - role: GQLUSER_ROLE.COMMENTER, - email, - avatar, - profiles: [profile], - }); - } - - // TODO: (wyattjoh) possibly update the user profile if the remaining details mismatch? - - return user; -} - -/** - * isSSOUserProfile will check if the given profile is a SSOUserProfile. - * - * @param profile the profile to check for the type - */ -export function isSSOUserProfile( - profile: SSOUserProfile | object -): profile is SSOUserProfile { - return ( - typeof (profile as SSOUserProfile).id !== "undefined" && - typeof (profile as SSOUserProfile).email !== "undefined" && - typeof (profile as SSOUserProfile).username !== "undefined" - ); -} - -export function isSSOToken(token: SSOToken | object): token is SSOToken { - return ( - typeof (token as SSOToken).user === "object" && - isSSOUserProfile((token as SSOToken).user) - ); -} - -export default class SSOStrategy extends Strategy { - public name = "sso"; - - private mongo: Db; - - constructor({ mongo }: SSOStrategyOptions) { - super(); - - this.mongo = mongo; - } - - /** - * 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.mongo, 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.mongo, tenant, token); - } - - public authenticate(req: Request) { - const { tenant } = req; - if (!tenant) { - // TODO: (wyattjoh) return a better error. - return this.error(new Error("tenant not found")); - } - - // Lookup the token. - const token = extractJWTFromRequest(req); - if (!token) { - // TODO: (wyattjoh) return a better error. - return this.fail(new Error("no token on request"), 400); - } - - // Perform the JWT validation. - jwt.verify( - token, - this.getSigningSecretGetter(tenant), - { - // Force the use of the HS256 algorithm. We can explore switching this - // out in the future.. - algorithms: ["HS256"], // TODO: (wyattjoh) investigate replacing algorithm. - }, - async (err: Error | undefined, decoded: OIDCIDToken | SSOToken) => { - if (err) { - // TODO: (wyattjoh) wrap error? - return this.error(err); - } - - try { - // Find or create the user based on the decoded token. - const user = await this.findOrCreateUser(tenant, decoded); - - // The user was found or created! - return this.success(user, null); - } catch (err) { - return this.error(err); - } - } - ); - } -} - -export function createSSOStrategy(options: SSOStrategyOptions) { - return new SSOStrategy(options); -} diff --git a/src/core/server/app/middleware/passport/strategies/jwt.ts b/src/core/server/app/middleware/passport/strategies/jwt.ts new file mode 100644 index 000000000..a979c378c --- /dev/null +++ b/src/core/server/app/middleware/passport/strategies/jwt.ts @@ -0,0 +1,121 @@ +import { Redis } from "ioredis"; +import jwt from "jsonwebtoken"; +import { Db } from "mongodb"; +import { Strategy } from "passport-strategy"; + +import { + JWTToken, + JWTVerifier, +} from "talk-server/app/middleware/passport/strategies/verifiers/jwt"; +import { + SSOToken, + SSOVerifier, +} from "talk-server/app/middleware/passport/strategies/verifiers/sso"; +import { Tenant } from "talk-server/models/tenant"; +import { User } from "talk-server/models/user"; +import { + extractJWTFromRequest, + JWTSigningConfig, +} from "talk-server/services/jwt"; +import { Request } from "talk-server/types/express"; + +export interface JWTStrategyOptions { + signingConfig: JWTSigningConfig; + mongo: Db; + redis: Redis; +} + +/** + * Token is the various forms of the Token that can be verified. + */ +type Token = SSOToken | JWTToken | object | string | null; + +/** + * Verifier allows different implementations to offer ways to verify a given + * Token. + */ +interface Verifier { + /** + * verify will perform the verification and return a User. + */ + verify: ( + tokenString: string, + token: T, + tenant: Tenant + ) => Promise | null>; + + /** + * supports will perform type checking and ensure that the given Tenant + * supports the requested verification type. + */ + supports: (token: T | object, tenant: Tenant) => token is T; +} + +export class JWTStrategy extends Strategy { + public name = "jwt"; + + private verifiers: { + sso: Verifier; + jwt: Verifier; + }; + + constructor(options: JWTStrategyOptions) { + super(); + + this.verifiers = { + sso: new SSOVerifier(options), + jwt: new JWTVerifier(options), + }; + } + + private async verify(tokenString: string, tenant: Tenant) { + const token: Token = jwt.decode(tokenString); + if (!token || typeof token === "string") { + // TODO: (wyattjoh) return a better error. + throw new Error("token could not be decoded"); + } + + // Handle SSO integrations. + if (this.verifiers.sso.supports(token, tenant)) { + return this.verifiers.sso.verify(tokenString, token, tenant); + } + + // Handle the raw JWT token. + if (this.verifiers.jwt.supports(token, tenant)) { + // Verify the token with the JWT verification strategy. + return this.verifiers.jwt.verify(tokenString, token, tenant); + } + + // No verifier could be found. + // TODO: (wyattjoh) return a better error. + throw new Error("no suitable jwt verifier could be found"); + } + + public async authenticate(req: Request) { + // Get the token from the request. + const token = extractJWTFromRequest(req); + if (!token) { + // There was no token on the request, so don't bother actually checking + // anything further. + return this.pass(); + } + + const { tenant } = req; + if (!tenant) { + // TODO: (wyattjoh) log this error, and return a better one? + return this.error(new Error("tenant not found")); + } + + try { + const user = await this.verify(token, tenant); + if (!user) { + return this.pass(); + } + + return this.success(user, null); + } catch (err) { + // TODO: (wyattjoh) log this error + return this.fail(err); + } + } +} diff --git a/src/core/server/app/middleware/passport/local.ts b/src/core/server/app/middleware/passport/strategies/local.ts similarity index 100% rename from src/core/server/app/middleware/passport/local.ts rename to src/core/server/app/middleware/passport/strategies/local.ts diff --git a/src/core/server/app/middleware/passport/oidc.spec.ts b/src/core/server/app/middleware/passport/strategies/oidc.spec.ts similarity index 96% rename from src/core/server/app/middleware/passport/oidc.spec.ts rename to src/core/server/app/middleware/passport/strategies/oidc.spec.ts index 6f10b1140..141b145ba 100644 --- a/src/core/server/app/middleware/passport/oidc.spec.ts +++ b/src/core/server/app/middleware/passport/strategies/oidc.spec.ts @@ -1,7 +1,7 @@ import { OIDCDisplayNameIDTokenSchema, OIDCIDTokenSchema, -} from "talk-server/app/middleware/passport/oidc"; +} from "talk-server/app/middleware/passport/strategies/oidc"; import { validate } from "talk-server/app/request/body"; describe("OIDCIDTokenSchema", () => { diff --git a/src/core/server/app/middleware/passport/oidc.ts b/src/core/server/app/middleware/passport/strategies/oidc.ts similarity index 99% rename from src/core/server/app/middleware/passport/oidc.ts rename to src/core/server/app/middleware/passport/strategies/oidc.ts index fffc0b6ff..bb0885a54 100644 --- a/src/core/server/app/middleware/passport/oidc.ts +++ b/src/core/server/app/middleware/passport/strategies/oidc.ts @@ -391,7 +391,3 @@ export default class OIDCStrategy extends Strategy { } } } - -export function createOIDCStrategy(options: OIDCStrategyOptions) { - return new OIDCStrategy(options); -} diff --git a/src/core/server/app/middleware/passport/strategies/verifiers/jwt.ts b/src/core/server/app/middleware/passport/strategies/verifiers/jwt.ts new file mode 100644 index 000000000..94c040a1c --- /dev/null +++ b/src/core/server/app/middleware/passport/strategies/verifiers/jwt.ts @@ -0,0 +1,65 @@ +import { Redis } from "ioredis"; +import jwt from "jsonwebtoken"; +import { Db } from "mongodb"; + +import { Tenant } from "talk-server/models/tenant"; +import { retrieveUser } from "talk-server/models/user"; +import { checkBlacklistJWT, JWTSigningConfig } from "talk-server/services/jwt"; + +export interface JWTToken { + // aud: string; + jti: string; + sub: string; + exp: number; + iss: string; +} + +export function isJWTToken(token: JWTToken | object): token is JWTToken { + return ( + // typeof (token as JWTToken).aud === "string" && + typeof (token as JWTToken).jti === "string" && + typeof (token as JWTToken).sub === "string" && + typeof (token as JWTToken).exp === "number" && + typeof (token as JWTToken).iss === "string" + ); +} + +export interface JWTVerifierOptions { + signingConfig: JWTSigningConfig; + mongo: Db; + redis: Redis; +} + +export class JWTVerifier { + private signingConfig: JWTSigningConfig; + private mongo: Db; + private redis: Redis; + + constructor({ signingConfig, mongo, redis }: JWTVerifierOptions) { + this.signingConfig = signingConfig; + this.mongo = mongo; + this.redis = redis; + } + + public supports(token: JWTToken | object, tenant: Tenant): token is JWTToken { + return isJWTToken(token) && token.iss === tenant.id; + } + + public async verify(tokenString: string, token: JWTToken, tenant: Tenant) { + // Verify that the token is valid. This will throw an error if it isn't. + jwt.verify(tokenString, this.signingConfig.secret, { + issuer: tenant.id, + algorithms: [this.signingConfig.algorithm], + }); + + // Check to see if the token has been blacklisted, as these tokens can be + // revoked. + await checkBlacklistJWT(this.redis, token.jti); + + // Find the user. + const user = await retrieveUser(this.mongo, tenant.id, token.sub); + + // Return the user now that we have found them!. + return user; + } +} diff --git a/src/core/server/app/middleware/passport/sso.spec.ts b/src/core/server/app/middleware/passport/strategies/verifiers/sso.spec.ts similarity index 96% rename from src/core/server/app/middleware/passport/sso.spec.ts rename to src/core/server/app/middleware/passport/strategies/verifiers/sso.spec.ts index f973f45b7..7f9816541 100644 --- a/src/core/server/app/middleware/passport/sso.spec.ts +++ b/src/core/server/app/middleware/passport/strategies/verifiers/sso.spec.ts @@ -2,7 +2,7 @@ import { isSSOToken, SSODisplayNameUserProfileSchema, SSOUserProfileSchema, -} from "talk-server/app/middleware/passport/sso"; +} from "talk-server/app/middleware/passport/strategies/verifiers/sso"; import { validate } from "talk-server/app/request/body"; describe("isSSOToken", () => { diff --git a/src/core/server/app/middleware/passport/strategies/verifiers/sso.ts b/src/core/server/app/middleware/passport/strategies/verifiers/sso.ts new file mode 100644 index 000000000..1ff9ff44f --- /dev/null +++ b/src/core/server/app/middleware/passport/strategies/verifiers/sso.ts @@ -0,0 +1,143 @@ +import Joi from "joi"; +import jwt from "jsonwebtoken"; +import { Db } from "mongodb"; + +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"; + +export interface SSOStrategyOptions { + mongo: Db; +} + +export interface SSOUserProfile { + id: string; + email: string; + username: string; + avatar?: string; + displayName?: string; +} + +export interface SSOToken { + user: SSOUserProfile; +} + +export const SSOUserProfileSchema = Joi.object() + .keys({ + id: Joi.string(), + email: Joi.string(), + username: Joi.string(), + avatar: Joi.string().default(undefined), + }) + .optionalKeys(["avatar"]); + +export const SSODisplayNameUserProfileSchema = SSOUserProfileSchema.keys({ + displayName: Joi.string().default(undefined), +}).optionalKeys(["displayName"]); + +export async function findOrCreateSSOUser( + db: Db, + tenant: Tenant, + token: SSOToken +) { + if (!token.user) { + // TODO: (wyattjoh) replace with better error. + throw new Error("token is malformed, missing user claim"); + } + + // Unpack/validate the token content. + const { id, email, username, displayName, avatar }: SSOUserProfile = validate( + tenant.auth.integrations.sso!.displayNameEnable + ? SSODisplayNameUserProfileSchema + : SSOUserProfileSchema, + token.user + ); + + const profile: SSOProfile = { + type: "sso", + id, + }; + + // Try to lookup user given their id provided in the `sub` claim. + let user = await retrieveUserWithProfile(db, tenant.id, profile); + if (!user) { + // FIXME: (wyattjoh) implement rules! Not all users should be able to create an account via this method. + + // Create the new user, as one didn't exist before! + user = await upsert(db, tenant, { + username, + // When the displayName is disabled on the tenant, the displayName will + // never be set (or even stored in the database). + displayName, + role: GQLUSER_ROLE.COMMENTER, + email, + avatar, + profiles: [profile], + }); + } + + // TODO: (wyattjoh) possibly update the user profile if the remaining details mismatch? + + return user; +} + +/** + * isSSOUserProfile will check if the given profile is a SSOUserProfile. + * + * @param profile the profile to check for the type + */ +export function isSSOUserProfile( + profile: SSOUserProfile | object +): profile is SSOUserProfile { + return ( + typeof (profile as SSOUserProfile).id !== "undefined" && + typeof (profile as SSOUserProfile).email !== "undefined" && + typeof (profile as SSOUserProfile).username !== "undefined" + ); +} + +export function isSSOToken(token: SSOToken | object): token is SSOToken { + return ( + typeof (token as SSOToken).user === "object" && + isSSOUserProfile((token as SSOToken).user) + ); +} + +export interface SSOVerifierOptions { + mongo: Db; +} + +export class SSOVerifier { + private mongo: Db; + + constructor({ mongo }: SSOVerifierOptions) { + this.mongo = mongo; + } + + public supports(token: SSOToken | object, tenant: Tenant): token is SSOToken { + return tenant.auth.integrations.sso.enabled && isSSOToken(token); + } + + public async verify(tokenString: string, token: SSOToken, tenant: Tenant) { + const integration = tenant.auth.integrations.sso; + if (!integration.enabled) { + // TODO: (wyattjoh) return a better error. + throw new Error("integration not enabled"); + } + + if (!integration.key) { + throw new Error("integration key does not exist"); + } + + // Verify that the token is valid. This will throw an error if it isn't. + jwt.verify(tokenString, integration.key, { + // Force the use of the HS256 algorithm. We can explore switching this + // out in the future.. + algorithms: ["HS256"], // TODO: (wyattjoh) investigate replacing algorithm. + }); + + return findOrCreateSSOUser(this.mongo, tenant, token); + } +} diff --git a/src/core/server/app/router.ts b/src/core/server/app/router.ts index 62237690a..d68bcc2b8 100644 --- a/src/core/server/app/router.ts +++ b/src/core/server/app/router.ts @@ -93,7 +93,6 @@ function createNewAuthRouter(app: AppOptions, options: RouterOptions) { signupHandler({ db: app.mongo, signingConfig: app.signingConfig }) ); - router.post("/sso", wrapAuthn(options.passport, app.signingConfig, "sso")); router.get("/oidc", wrapAuthn(options.passport, app.signingConfig, "oidc")); router.get( "/oidc/callback", diff --git a/src/core/server/index.ts b/src/core/server/index.ts index 5b02fbb65..5fbda5731 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -2,13 +2,13 @@ import express, { Express } from "express"; import http from "http"; import config, { Config } from "talk-common/config"; -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 { createQueue } from "talk-server/services/queue"; import TenantCache from "talk-server/services/tenant/cache"; +import { createJWTSigningConfig } from "talk-server/services/jwt"; import { attachSubscriptionHandlers, createApp, listenAndServe } from "./app"; import logger from "./logger"; import { createMongoDB } from "./services/mongodb"; diff --git a/src/core/server/app/middleware/passport/__snapshots__/jwt.spec.ts.snap b/src/core/server/services/jwt/__snapshots__/index.spec.ts.snap similarity index 100% rename from src/core/server/app/middleware/passport/__snapshots__/jwt.spec.ts.snap rename to src/core/server/services/jwt/__snapshots__/index.spec.ts.snap diff --git a/src/core/server/services/jwt/__snapshots__/jwt.spec.ts.snap b/src/core/server/services/jwt/__snapshots__/jwt.spec.ts.snap new file mode 100644 index 000000000..ada6ed29d --- /dev/null +++ b/src/core/server/services/jwt/__snapshots__/jwt.spec.ts.snap @@ -0,0 +1,31 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`createJWTSigningConfig parses a RSA certificate 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/services/jwt/index.spec.ts b/src/core/server/services/jwt/index.spec.ts new file mode 100644 index 000000000..e2179221f --- /dev/null +++ b/src/core/server/services/jwt/index.spec.ts @@ -0,0 +1,53 @@ +import sinon from "sinon"; + +import { Config } from "talk-common/config"; +import { + createJWTSigningConfig, + extractJWTFromRequest, +} from "talk-server/services/jwt"; +import { Request } from "talk-server/types/express"; + +describe("extractJWTFromRequest", () => { + it("extracts the token from header", () => { + const req = { + headers: { + authorization: "Bearer token", + }, + url: "", + }; + + expect(extractJWTFromRequest((req as any) as Request)).toEqual("token"); + + delete req.headers.authorization; + + expect(extractJWTFromRequest((req as any) as Request)).toEqual(null); + }); + + it("extracts the token from query string", () => { + const req = { + url: "", + }; + expect(extractJWTFromRequest((req as any) as Request)).toEqual(null); + + req.url = "https://talk.coralproject.net/api?access_token=token"; + + expect(extractJWTFromRequest((req as any) as Request)).toEqual("token"); + }); +}); + +describe("createJWTSigningConfig", () => { + it("parses a RSA certificate", () => { + 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/services/jwt/index.ts similarity index 57% rename from src/core/server/app/middleware/passport/jwt.ts rename to src/core/server/services/jwt/index.ts index 0359ef450..597e164b4 100644 --- a/src/core/server/app/middleware/passport/jwt.ts +++ b/src/core/server/services/jwt/index.ts @@ -1,48 +1,12 @@ import { Redis } from "ioredis"; import jwt, { SignOptions } from "jsonwebtoken"; -import { Db } from "mongodb"; -import { Strategy } from "passport-strategy"; import { Bearer } from "permit"; -import uuid from "uuid"; +import uuid from "uuid/v4"; import { Config } from "talk-common/config"; -import { retrieveUser, User } from "talk-server/models/user"; +import { User } from "talk-server/models/user"; import { Request } from "talk-server/types/express"; -export function extractJWTFromRequest(req: Request) { - const permit = new Bearer({ - basic: "password", - query: "access_token", - }); - - return permit.check(req) || null; -} - -function generateJTIBlacklistKey(jti: string) { - // jtib: JTI Blacklist namespace. - return `jtib:${jti}`; -} - -export async function blacklistJWT( - redis: Redis, - jti: string, - validFor: number -) { - await redis.setex( - generateJTIBlacklistKey(jti), - Math.ceil(validFor), - Date.now() - ); -} - -export async function checkBlacklistJWT(redis: Redis, jti: string) { - const expiredAtString = await redis.get(generateJTIBlacklistKey(jti)); - if (expiredAtString) { - // TODO: (wyattjoh) return a better error. - throw new Error("JWT exists in blacklist"); - } -} - export enum AsymmetricSigningAlgorithm { RS256 = "RS256", RS384 = "RS384", @@ -127,93 +91,42 @@ export const signTokenString = async ( ) => jwt.sign({}, secret, { ...options, - jwtid: uuid.v4(), + jwtid: uuid(), algorithm, expiresIn: "1 day", // TODO: (wyattjoh) evaluate allowing configuration? subject: user.id, }); -export interface JWTToken { - jti: string; - sub: string; - exp: number; - iss?: string; +export function extractJWTFromRequest(req: Request) { + const permit = new Bearer({ + basic: "password", + query: "access_token", + }); + + return permit.check(req) || null; } -export interface JWTStrategyOptions { - signingConfig: JWTSigningConfig; - mongo: Db; - redis: Redis; +function generateJTIBlacklistKey(jti: string) { + // jtib: JTI Blacklist namespace. + return `jtib:${jti}`; } -export class JWTStrategy extends Strategy { - public name = "jwt"; +export async function blacklistJWT( + redis: Redis, + jti: string, + validFor: number +) { + await redis.setex( + generateJTIBlacklistKey(jti), + Math.ceil(validFor), + Date.now() + ); +} - private signingConfig: JWTSigningConfig; - private mongo: Db; - private redis: Redis; - - constructor({ signingConfig, mongo, redis }: JWTStrategyOptions) { - super(); - - this.signingConfig = signingConfig; - this.mongo = mongo; - this.redis = redis; - } - - 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 successful. - return this.success(null, null); - } - - const { tenant } = req; - if (!tenant) { - // TODO: (wyattjoh) return a better error. - return this.error(new Error("tenant not found")); - } - - jwt.verify( - token, - // Use the secret specified in the configuration. - this.signingConfig.secret, - { - // We need to verify that the token is for the specified tenant. - issuer: tenant.id, - // Use the algorithm specified in the configuration. - algorithms: [this.signingConfig.algorithm], - }, - async (err: Error | undefined, decoded: JWTToken) => { - if (err) { - return this.fail(err, 401); - } - - if (!decoded) { - // There was no token on the request, so there was no user, so let's - // mark that the strategy was successful. - return this.success(null, null); - } - - try { - // Find the user. - const user = await retrieveUser(this.mongo, tenant.id, decoded.sub); - - // Check to see if the token has been blacklisted. - await checkBlacklistJWT(this.redis, decoded.jti); - - // Return them! The user may be null, but that's ok here. - this.success(user, null); - } catch (err) { - return this.error(err); - } - } - ); +export async function checkBlacklistJWT(redis: Redis, jti: string) { + const expiredAtString = await redis.get(generateJTIBlacklistKey(jti)); + if (expiredAtString) { + // TODO: (wyattjoh) return a better error. + throw new Error("JWT exists in blacklist"); } } - -export function createJWTStrategy(options: JWTStrategyOptions) { - return new JWTStrategy(options); -} diff --git a/src/core/server/app/middleware/passport/jwt.spec.ts b/src/core/server/services/jwt/jwt.spec.ts similarity index 96% rename from src/core/server/app/middleware/passport/jwt.spec.ts rename to src/core/server/services/jwt/jwt.spec.ts index 6078b35f3..31c65dbcb 100644 --- a/src/core/server/app/middleware/passport/jwt.spec.ts +++ b/src/core/server/services/jwt/jwt.spec.ts @@ -1,11 +1,8 @@ import sinon from "sinon"; import { Config } from "talk-common/config"; -import { - createJWTSigningConfig, - extractJWTFromRequest, -} from "talk-server/app/middleware/passport/jwt"; import { Request } from "talk-server/types/express"; +import { createJWTSigningConfig, extractJWTFromRequest } from "."; describe("extractJWTFromRequest", () => { it("extracts the token from header", () => { diff --git a/src/types/jsonwebtoken.d.ts b/src/types/jsonwebtoken.d.ts index de8c9f82f..805cd92a4 100644 --- a/src/types/jsonwebtoken.d.ts +++ b/src/types/jsonwebtoken.d.ts @@ -15,5 +15,5 @@ declare module "jsonwebtoken" { secretOrPublicKey: string | Buffer | KeyFunction, options?: VerifyOptions, callback?: VerifyCallback - ): void; + ): object | string; }