[next] Cookie Support (#2339)

* feat: added cookie support to coral

* feat: adapt client to use cookies

* fix: safari input styles

* fix: lint

* fix: linting

* fix: support clearing cookies properly, oauth

* feat: support cookies for websocket upgrade requests

* fix: lint

* fix: tests
This commit is contained in:
Wyatt Johnson
2019-06-25 21:48:58 +02:00
committed by Vinh
parent 3576b6a943
commit e72b15c505
79 changed files with 375 additions and 278 deletions
@@ -1,4 +1,4 @@
import { NextFunction, RequestHandler, Response } from "express";
import { CookieOptions, NextFunction, RequestHandler, Response } from "express";
import { Redis } from "ioredis";
import Joi from "joi";
import jwt from "jsonwebtoken";
@@ -14,12 +14,14 @@ import { validate } from "coral-server/app/request/body";
import { AuthenticationError } from "coral-server/errors";
import { User } from "coral-server/models/user";
import {
COOKIE_NAME,
extractTokenFromRequest,
JWTSigningConfig,
revokeJWT,
signTokenString,
} from "coral-server/services/jwt";
import { Request } from "coral-server/types/express";
import { DateTime } from "luxon";
export type VerifyCallback = (
err?: Error | null,
@@ -70,20 +72,18 @@ export async function handleLogout(redis: Redis, req: Request, res: Response) {
// Extract the token from the request.
const token = extractTokenFromRequest(req);
if (!token) {
// TODO: (wyattjoh) return a better error.
throw new Error("logout requires a token on the request, none was found");
// No token on the request, indicate that this was successful.
return res.sendStatus(204);
}
// Coral is guarenteed at this point.
// Coral is guaranteed at this point.
const { now } = req.coral!;
// Decode the token.
const decoded = jwt.decode(token, {});
if (!decoded) {
// TODO: (wyattjoh) return a better error.
throw new Error(
"logout requires a token on the request, token was invalid"
);
// Invalid token on request, indicate that this was successful.
return res.sendStatus(204);
}
// Grab the JTI from the decoded token.
@@ -94,9 +94,12 @@ export async function handleLogout(redis: Redis, req: Request, res: Response) {
if (validFor > 0) {
// Invalidate the token, the expiry is in the future and it needs to be
// revoked.
await revokeJWT(redis, jti, validFor);
await revokeJWT(redis, jti, validFor, now);
}
// Clear the cookie.
res.clearCookie(COOKIE_NAME, generateCookieOptions(req, new Date(0)));
return res.sendStatus(204);
}
@@ -108,16 +111,29 @@ export async function handleSuccessfulLogin(
next: NextFunction
) {
try {
// Coral is guaranteed at this point.
const coral = req.coral!;
// Tenant is guaranteed at this point.
const tenant = req.coral!.tenant!;
const tenant = coral.tenant!;
// Compute the expiry date.
const expiresIn = DateTime.fromJSDate(coral.now).plus({ days: 1 });
// Grab the token.
const token = await signTokenString(signingConfig, user, tenant);
const token = await signTokenString(signingConfig, user, tenant, {
expiresIn: Math.floor(expiresIn.toSeconds()),
});
// 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");
res.cookie(
COOKIE_NAME,
token,
generateCookieOptions(req, expiresIn.toJSDate())
);
// Send back the details!
res.json({ token });
@@ -126,6 +142,16 @@ export async function handleSuccessfulLogin(
}
}
const generateCookieOptions = (
req: Request,
expiresIn: Date
): CookieOptions => ({
path: "/api",
httpOnly: true,
secure: req.secure,
expires: expiresIn,
});
export async function handleOAuth2Callback(
err: Error | null,
user: User | null,
@@ -147,8 +173,18 @@ export async function handleOAuth2Callback(
// Tenant is guaranteed at this point.
const tenant = req.coral!.tenant!;
// Compute the expiry date.
const expiresIn = DateTime.fromJSDate(req.coral!.now).plus({ days: 1 });
// Grab the token.
const token = await signTokenString(signingConfig, user, tenant);
const token = await signTokenString(signingConfig, user, tenant, {
expiresIn: Math.floor(expiresIn.toSeconds()),
});
res.cookie(
COOKIE_NAME,
token,
generateCookieOptions(req, expiresIn.toJSDate())
);
// Send back the details!
res.redirect(path + `#accessToken=${token}`);
@@ -2,7 +2,11 @@ import jwt from "jsonwebtoken";
import { Strategy } from "passport-strategy";
import { AppOptions } from "coral-server/app";
import { TenantNotFoundError, TokenInvalidError } from "coral-server/errors";
import {
JWTRevokedError,
TenantNotFoundError,
TokenInvalidError,
} from "coral-server/errors";
import { Tenant } from "coral-server/models/tenant";
import { User } from "coral-server/models/user";
import { extractTokenFromRequest } from "coral-server/services/jwt";
@@ -54,7 +58,7 @@ export function createVerifiers(
];
}
export function verifyAndRetrieveUser(
export async function verifyAndRetrieveUser(
verifiers: Array<Verifier<Token>>,
tenant: Tenant,
tokenString: string,
@@ -65,11 +69,21 @@ export function verifyAndRetrieveUser(
throw new TokenInvalidError(tokenString, "token could not be decoded");
}
// Try to verify the token.
for (const verifier of verifiers) {
if (verifier.supports(token, tenant)) {
return verifier.verify(tokenString, token, tenant, now);
try {
// Try to verify the token.
for (const verifier of verifiers) {
if (verifier.supports(token, tenant)) {
return await verifier.verify(tokenString, token, tenant, now);
}
}
} catch (err) {
// When the JWT was revoked, just indicate that there is no user on the
// request rather than erroring out.
if (err instanceof JWTRevokedError) {
return null;
}
throw err;
}
// No verifier could be found.
@@ -6,7 +6,7 @@ import { Db } from "mongodb";
import { Tenant } from "coral-server/models/tenant";
import { retrieveUser } from "coral-server/models/user";
import {
checkJWTRevoked,
isJWTRevoked,
JWTSigningConfig,
StandardClaims,
verifyJWT,
@@ -74,7 +74,9 @@ export class JWTVerifier implements Verifier<JWTToken> {
if (!token.pat) {
// Check to see if the token has been revoked, as these tokens can be
// revoked.
await checkJWTRevoked(this.redis, token.jti);
if (await isJWTRevoked(this.redis, token.jti)) {
return null;
}
}
// Find the user.
+7 -1
View File
@@ -1,3 +1,4 @@
import cookies from "cookie-parser";
import express, { Router } from "express";
import { register } from "prom-client";
@@ -16,7 +17,12 @@ export function createRouter(app: AppOptions, options: RouterOptions) {
const router = express.Router();
// Attach the API router.
router.use("/api", noCacheMiddleware, createAPIRouter(app, options));
router.use(
"/api",
noCacheMiddleware,
cookies(),
createAPIRouter(app, options)
);
// Attach the GraphiQL if enabled.
if (app.config.get("enable_graphiql")) {
+9
View File
@@ -497,6 +497,15 @@ export class InvalidCredentialsError extends CoralError {
}
}
export class JWTRevokedError extends CoralError {
constructor(jti: string) {
super({
code: ERROR_CODES.AUTHENTICATION_ERROR,
context: { pvt: { jti } },
});
}
}
export class AuthenticationError extends CoralError {
constructor(reason: string) {
super({
+1
View File
@@ -45,4 +45,5 @@ export const ERROR_TRANSLATIONS: Record<ERROR_CODES, string> = {
PASSWORD_RESET_TOKEN_EXPIRED: "error-passwordResetTokenExpired",
EMAIL_CONFIRM_TOKEN_EXPIRED: "error-emailConfirmTokenExpired",
RATE_LIMIT_EXCEEDED: "error-rateLimitExceeded",
JWT_REVOKED: "error-jwtRevoked",
};
@@ -26,6 +26,7 @@ describe("extractJWTFromRequest", () => {
it("extracts the token from query string", () => {
const req = {
url: "",
headers: {},
};
expect(extractTokenFromRequest((req as any) as Request)).toEqual(null);
@@ -37,6 +38,7 @@ describe("extractJWTFromRequest", () => {
it("does not extract the token from query string when it's disabled", () => {
const req = {
url: "https://coral.coralproject.net/api?accessToken=token",
headers: {},
};
expect(extractTokenFromRequest((req as any) as Request, true)).toEqual(
+111 -7
View File
@@ -1,3 +1,5 @@
import cookie from "cookie";
import { IncomingMessage } from "http";
import { Redis } from "ioredis";
import Joi from "joi";
import jwt, { SignOptions, VerifyOptions } from "jsonwebtoken";
@@ -6,11 +8,14 @@ import uuid from "uuid/v4";
import { Omit } from "coral-common/types";
import { Config } from "coral-server/config";
import { AuthenticationError, TokenInvalidError } from "coral-server/errors";
import {
AuthenticationError,
JWTRevokedError,
TokenInvalidError,
} from "coral-server/errors";
import { Tenant } from "coral-server/models/tenant";
import { User } from "coral-server/models/user";
import { Request } from "coral-server/types/express";
import { IncomingMessage } from "http";
/**
* The following Claim Names are registered in the IANA "JSON Web Token
@@ -213,7 +218,6 @@ export const signTokenString = async (
) =>
jwt.sign({}, secret, {
jwtid: uuid(),
// TODO: (wyattjoh) evaluate allowing configuration?
expiresIn: "1 day",
...options,
issuer: tenant.id,
@@ -241,6 +245,8 @@ export async function signString<T extends {}>(
}
/**
* extractJWTFromRequest will extract the token from the request if it can find
* it. It first tries to get the token from the headers, then from the cookie.
*
* @param req the request to extract the JWT from
* @param excludeQuery when true, does not pull from the query params
@@ -248,6 +254,69 @@ export async function signString<T extends {}>(
export function extractTokenFromRequest(
req: Request | IncomingMessage,
excludeQuery: boolean = false
): string | null {
return (
extractJWTFromRequestHeaders(req, excludeQuery) ||
extractJWTFromRequestCookie(req)
);
}
/**
* COOKIE_NAME is the name of the authorization cookie used by Coral.
*/
export const COOKIE_NAME = "authorization";
/**
* isExpressRequest will check to see if this is a Request or an
* IncomingMessage.
*
* @param req a request to test if it is an Express Request or not.
*/
export function isExpressRequest(
req: Request | IncomingMessage
): req is Request {
// Only Express Request objects contain an `app` field.
if (typeof (req as Request).app === "undefined") {
return false;
}
return true;
}
/**
* extractJWTFromRequestCookie will parse the cookies off of the request if it
* can.
*
* @param req the incoming request possibly containing a cookie
*/
function extractJWTFromRequestCookie(
req: Request | IncomingMessage
): string | null {
if (!isExpressRequest(req)) {
// Grab the cookie header.
const header = req.headers.cookie;
if (typeof header !== "string" || header.length === 0) {
return null;
}
// Parse the cookies from that header.
const cookies = cookie.parse(header);
return cookies[COOKIE_NAME] || null;
}
return req.cookies && req.cookies[COOKIE_NAME]
? req.cookies[COOKIE_NAME]
: null;
}
/**
*
* @param req the request to extract the JWT from
* @param excludeQuery when true, does not pull from the query params
*/
function extractJWTFromRequestHeaders(
req: Request | IncomingMessage,
excludeQuery: boolean = false
) {
const options: BearerOptions = {
basic: "password",
@@ -267,18 +336,53 @@ function generateJTIRevokedKey(jti: string) {
return `jtir:${jti}`;
}
export async function revokeJWT(redis: Redis, jti: string, validFor: number) {
/**
* revokeJWT will place the token into a blacklist until it expires.
*
* @param redis the Redis instance to revoke the JWT with
* @param jti the JTI claim of the JWT token being revoked
* @param validFor number of seconds that the token was valid for
* @param now the current date
*/
export async function revokeJWT(
redis: Redis,
jti: string,
validFor: number,
now = new Date()
) {
await redis.setex(
generateJTIRevokedKey(jti),
Math.ceil(validFor),
Date.now()
now.valueOf()
);
}
export async function checkJWTRevoked(redis: Redis, jti: string) {
/**
* isJWTRevoked will check to see if the given token referenced by the JWT has
* been revoked or not.
*
* @param redis the Redis instance to check to see if the token was revoked
* @param jti the JTI claim of the JWT token being tested
*/
export async function isJWTRevoked(redis: Redis, jti: string) {
const expiredAtString = await redis.get(generateJTIRevokedKey(jti));
if (expiredAtString) {
throw new AuthenticationError("JWT was revoked");
return true;
}
return false;
}
/**
* checkJWTRevoked will test the JWT's JTI to see if it's revoked, if it is, it
* will throw an error.
*
* @param redis the Redis instance to check to see if the token was revoked
* @param jti the JTI claim of the JWT token being tested
*/
export async function checkJWTRevoked(redis: Redis, jti: string) {
if (await isJWTRevoked(redis, jti)) {
throw new JWTRevokedError(jti);
}
}