feat: initial token issuing support

This commit is contained in:
Wyatt Johnson
2018-07-17 16:57:36 -06:00
parent 8487f22b8e
commit a2a0cebb06
5 changed files with 200 additions and 45 deletions
+7 -5
View File
@@ -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);
}
};
@@ -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);
+98 -2
View File
@@ -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<SignOptions, "audience" | "issuer">;
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,
});
}
+13 -9
View File
@@ -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;
}
+22 -5
View File
@@ -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",