Merge branch 'next' into admin

This commit is contained in:
Wyatt Johnson
2018-09-26 16:27:22 +00:00
committed by GitHub
19 changed files with 461 additions and 374 deletions
+1 -1
View File
@@ -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";
+3 -3
View File
@@ -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";
@@ -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.
@@ -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);
}
@@ -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<T> {
/**
* verify will perform the verification and return a User.
*/
verify: (
tokenString: string,
token: T,
tenant: Tenant
) => Promise<Readonly<User> | 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<SSOToken>;
jwt: Verifier<JWTToken>;
};
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);
}
}
}
@@ -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", () => {
@@ -391,7 +391,3 @@ export default class OIDCStrategy extends Strategy {
}
}
}
export function createOIDCStrategy(options: OIDCStrategyOptions) {
return new OIDCStrategy(options);
}
@@ -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;
}
}
@@ -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", () => {
@@ -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);
}
}
-1
View File
@@ -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",
+1 -1
View File
@@ -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";
@@ -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-----"
`;
@@ -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();
});
});
@@ -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);
}
@@ -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", () => {
+1 -1
View File
@@ -15,5 +15,5 @@ declare module "jsonwebtoken" {
secretOrPublicKey: string | Buffer | KeyFunction,
options?: VerifyOptions,
callback?: VerifyCallback
): void;
): object | string;
}