mirror of
https://github.com/wassname/talk.git
synced 2026-08-18 12:30:39 +08:00
[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:
@@ -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.
|
||||
|
||||
@@ -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")) {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user